diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5e3352d..f096b9d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -27,8 +27,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - python -m pip install black pytest - if [ -f ./requirements.txt ]; then pip install -r ./requirements.txt; fi + python -m pip install --requirement ./requirements.txt + python -m pip install --requirement ./requirements.development.txt - name: Lint with Black run: | # Stop the build if there are any Python syntax or formatting errors diff --git a/CHANGELOG.md b/CHANGELOG.md index 2321948..a21b2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog All notable changes to this project will be documented in this file. +## [1.3.2] - 2026-05-19 +### Added +- Improved assembly and filtering of existing JSON-LD documents. + +## [1.3.1] - 2026-05-11 +### Added +- Improved item comparison. + +## [1.3.0] - 2026-05-08 +### Added +- Item comparison support. + ## [1.2.9] - 2026-04-08 ### Added - Improved documentation regarding model profiles and added an example profile. diff --git a/Dockerfile b/Dockerfile index 8e746f8..710d4a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,7 @@ elif [[ "${SERVICE}" == "flakes" ]]; then elif [[ "${SERVICE}" == "tests" ]]; then echo -e "pytest /tests ${ARGS[@]}"; pytest /tests ${ARGS[@]}; - pytest --verbose --codeblocks /README.md; + pytest --verbose --codeblocks --rootdir=/tests /tests/README.md ${ARGS[@]}; elif [[ "${SERVICE}" == "all" ]]; then if [[ "${ARGS[0]}" == "--reformat" ]]; then echo -e "black --verbose ${ARGS[@]:1} /source /tests"; diff --git a/README.md b/README.md index 1858147..a2691ac 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ Model.teardown() The SemanticPy library supports the concept of model profiles, which are used to define a metadata model, including its available entity type classes and properties. Profiles are stored as JSON documents, with sections to specify top-level properties, available model entity classes, as well as any class-level properties. Profiles support specifying the cardinality, domain and range of each of the properties, where this information is used to validate a model document as it is being assembled by ensuring that only values which are valid for a given property, can be set on that property. -#### Included Model Profiles +### Included Model Profiles The [Linked.Art](https://linked.art) profile is included with the SemanticPy library. It also acts as an example of how to specify a SemanticPy model profile file. Profiles can be developed for any valid JSON-LD metadata model. Additional profiles may be added to the library over time. @@ -486,7 +486,7 @@ path, including the `.json` file extension: Model.factory(profile="/absolute/path/to/model/profile.json") ``` -#### Model Profile Structure +### Model Profile Structure Each model profile is described within a JSON document; the document contains a dictionary with the following top-level keys: `properties` and `entities` – the `properties` key is @@ -517,7 +517,7 @@ Metadata model profiles accept the following keywords: | `scope_note` | Specifies a property's scope note for documentation | string | | `sorting` | Optionally specifies a property's serialised sorting | integer | -#### Top-Level Properties +### Top-Level Properties Properties, whether they are top-level or class-level, are referenced by name, and are defined through a key in a `properties` dictionary either at the top-level of the profile for top-level properties, or through a `properties` key on a model class entry under the @@ -526,7 +526,7 @@ top-level `entities` key. Each model property dictionary entry then defines the property's attributes, including its cardinality (can the property store one value or possibly multiple values?), its supported domain and range (what types of value does the property accept, including primitive types such as strings, integers, floating point numbers, dates, or class types defined in the model), and an optional sort ordering that is used to sort the properties used in a model document when it is serialised into JSON-LD. Sorting properties into a desired order is optional, but can help improve the readability of serialised JSON-LD, and navigation of the data within a document by outputting the JSON-LD dictionary keys in a consistent order. -#### Model Entity Classes and Class-Level Properties +### Model Entity Classes and Class-Level Properties Model entity classes are referenced by the name that is used for the class in code, and are defined through a key in the top-level `entities` dictionary of the profile. @@ -534,7 +534,7 @@ are defined through a key in the top-level `entities` dictionary of the profile. Each model entity class dictionary entry then defines the class' attributes, including its identifier, model type name, model names, and class code name, its superclass or superclasses, and any class-level properties (and the attributes of those properties). -#### Sample Model Profile +### Sample Model Profile The below sample model profile shows the required structure along with sample properties and classes to demonstrate defining a JSON-LD model for the SemanticPy library. @@ -611,14 +611,15 @@ In order to expand the generated JSON-LD to its graph representation, the contex The properties and classes that are referenced in the model profile should be present in the referenced JSON-LD context document in order for those classes and properties to be included when the graph expansion is performed. Additional properties can be specified in the model profile, or at runtime using the `Model.extend()` method documented above, but these additional properties will only be included in the JSON-LD representation of the data, which may be necessary or desirable for some use-cases. Graph expansion libraries like `pyld` or `rdflib` however need access to a definition of each class or property from a context document in order to understand how to translate each JSON-LD class and property to its graph representation. -#### Sample Model Document +### Sample Model Document -A sample model document using the sample model profile specified above can be generated using code similar to the following: +A sample model document using the sample model profile specified above can be generated +using code similar to the following: ```python import semanticpy -model = semanticpy.Model.factory(profile="sample", globals=globals()) +semanticpy.Model.factory(profile="sample", globals=globals()) entity = Entity( ident="https://data.example.org/entities/123", diff --git a/requirements.txt b/requirements.txt index d667178..8d25c56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ # SemanticPy Dependencies -requests>=2.32.5 -enumerific>=1.1.0 +requests>=2.32.5,<3.0 +enumerific>=1.1.0,<1.2 diff --git a/source/semanticpy/__init__.py b/source/semanticpy/__init__.py index 0c101b8..e69ad12 100644 --- a/source/semanticpy/__init__.py +++ b/source/semanticpy/__init__.py @@ -309,7 +309,7 @@ def teardown(cls, globals: dict = None): for key in removals: del cls._entities[key] - if isinstance(glo, dict): + if isinstance(glo, dict) and key in glo: del glo[key] # Reset the configuration to the defaults @@ -650,7 +650,7 @@ def prefix(cls, prefix: str, uri: str) -> None: @classmethod def entity(cls, name: str = None, property: str = None) -> Model | None: - """Helper method to return the referenced entity type from the model""" + """Helper method to return the referenced entity type from the model.""" if isinstance(name, str): if name in cls._entities: @@ -668,7 +668,7 @@ def entity(cls, name: str = None, property: str = None) -> Model | None: # TODO: Should 'create' be a "private" method? @classmethod def create(cls, data: dict, property: str = None) -> Model: - """Support creating a model entity from its data (dictionary) representation""" + """Support creating a model entity from its data (dictionary) representation.""" if not isinstance(data, dict): raise TypeError("The 'data' argument must have a dictionary value!") @@ -705,6 +705,8 @@ def create(cls, data: dict, property: str = None) -> Model: # TODO: Should 'load' be a "private" method? def load(self, data: dict, model: Model) -> None: + """Support loading data into the model entity from its dictionary representation.""" + if not isinstance(data, dict): raise ValueError("The 'data' argument must be provided as a dictionary!") @@ -904,8 +906,11 @@ def __setattr__(self, name: str, value: object) -> None: "The 'range' property can only contain valid type names or Model class types!" ) - if typed := self._find_type(range=range): - types += (typed,) + if not (typed := self._find_type(range=range)) is None: + if isinstance(typed, tuple): + types += tuple(typed) + else: + types += (typed,) else: raise ValueError( "The '%s' range for the '%s' property cannot be reconciled to a known range type!" diff --git a/source/semanticpy/types/attributed.py b/source/semanticpy/types/attributed.py index 2c85f2c..1cd111b 100644 --- a/source/semanticpy/types/attributed.py +++ b/source/semanticpy/types/attributed.py @@ -1,4 +1,5 @@ from semanticpy.logging import logger +from typing import Iterator class Attributed(object): @@ -44,7 +45,7 @@ def __delattr__(self, key: str) -> None: elif key in self._items: del self._items[key] - def __iter__(self): + def __iter__(self) -> Iterator[tuple[str, object]]: for key, value in self._items.items(): yield key, value @@ -63,3 +64,6 @@ def get(self, key: object, default: object = None) -> object | None: except AttributeError as exception: logger.debug(str(exception)) return default + + def clear(self) -> None: + self._items.clear() diff --git a/source/semanticpy/types/node.py b/source/semanticpy/types/node.py index b8bb3f2..2c750fe 100644 --- a/source/semanticpy/types/node.py +++ b/source/semanticpy/types/node.py @@ -11,6 +11,8 @@ class Node(object): """Node data type class supporting the creation of node tree structures""" + __iter__ = None # Mark the class as non-iterable + _type = None _name: str = None _data: dict[str, object] = None @@ -30,7 +32,10 @@ class Node(object): "_sorting", "_annotations", ] - + _aliases = { + "ident": "id", + "label": "_label", + } _overwrite_mode: OverwriteMode = None _appending_mode: AppendingMode = None @@ -74,7 +79,7 @@ def __init__(self, data: dict[str, object] = None, **kwargs): if data is None: self._data = {} elif isinstance(data, dict): - self._data = data + self._data = dict(data) else: raise TypeError("The `data` property must be provided as a dictionary!") @@ -96,6 +101,9 @@ def __init__(self, data: dict[str, object] = None, **kwargs): self._annotations = {} + for key, value in kwargs.items(): + self._data[key] = value + def __str__(self) -> str: return self.__repr__() @@ -114,6 +122,8 @@ def __getattr__(self, name: str) -> object | None: else: if name in self._data: value = self._data[name] + elif name in self._multiple: + self._data[name] = value = Nodes() # logger.debug("%s.__getattr__(name: %s) called => %s" % (self.__class__.__name__, name, value)) @@ -174,7 +184,7 @@ def __getitem__(self, name: str) -> object | None: def __setitem__(self, name: str, value: object): return self.__setattr__(name, value) - def equals(self, other: Node) -> bool: + def equals(self, other: Node, strict: bool = False) -> bool: """Support comparing Node instances for equality.""" if not isinstance(other, Node): @@ -182,25 +192,58 @@ def equals(self, other: Node) -> bool: equal: bool = False - properties = self.properties() + sproperties = self.properties() + oproperties = other.properties() - for name, value in other.properties(unpack=True): - if name in properties: - if properties[name] == value: + for name, value in oproperties.items(): + if name in sproperties: + if sproperties[name] == value: equal = True else: equal = False break - else: + elif name in self._aliases and self._aliases[name] in sproperties: + if sproperties[self._aliases[name]] == value: + equal = True + else: + equal = False + break + elif strict is True: equal = False break + if equal is True and strict is True: + for name, value in sproperties.items(): + if name in oproperties: + if oproperties[name] == value: + equal = True + else: + equal = False + break + elif name in self._aliases and self._aliases[name] in oproperties: + if oproperties[self._aliases[name]] == value: + equal = True + else: + equal = False + break + elif strict is True: + equal = False + break + return equal @property def type(self) -> str: + return self.__class__.__name__ + + @property + def typed(self) -> str: return self._type + @property + def context(self) -> str: + return self._context + @property def name(self) -> str: return self._name @@ -496,15 +539,136 @@ def _print( class Nodes(list): """The Nodes class holds a list of Node entities and supports filtering.""" - def __contains__(self, item: object) -> bool: + def __contains__(self, item: object, strict: bool = True) -> bool: """Determines if the list contains the specified item or not.""" if isinstance(item, Node): for node in self: if node is item: return True - elif node.equals(item): + elif node.equals(item, strict=strict): return True return False else: return super().__contains__(item) + + def unpack(self, property: str) -> Nodes[Node]: + """Unpack a nested property into a new Nodes instance.""" + + temp: Nodes[Node] = Nodes() + + if not isinstance(property, str): + raise TypeError("The 'property' argument must have a string value!") + + if len(self) == 0: + return temp + + for node in self: + if isinstance(subnode := getattr(node, property, None), list): + for node in subnode: + if isinstance(node, Node): + temp.append(node) + else: + logger.warning( + "All elements in the list must be Node instances!" + ) + elif isinstance(subnode, Node): + temp.append(subnode) + else: + logger.warning("All elements in the list must be Node instances!") + + return temp + + def filter(self, **filters: dict[str, object]) -> Nodes[Node]: + """Return a list of Node elements matching the provided filters.""" + + temp: Nodes[Node] = Nodes() + + if len(self) == 0: + return self + + if len(filters) == 0: + return self + + for node in self: + include: bool = False + + for name, value in filters.items(): + if isinstance(value, dict): + value = Node(data=value) + + if not (nodevalue := getattr(node, name, None)) is None: + if isinstance(valuelist := value, (list, Nodes)): + if isinstance(nodevalue, Nodes): + matches: bool = False + + for value in valuelist: + if nodevalue.__contains__(value, strict=False): + matches = True + else: + matches = False + break + + if matches is True: + include = True + else: + include = False + break + else: + logger.warning( + "The '%s' filter is a list, but the node's matching property is not!", + name, + ) + elif isinstance(nodevalue, Nodes): + if isinstance(value, (list, set, tuple)): + for val in value: + if nodevalue.__contains__(val, strict=False): + include = True + else: + include = False + break + if not include: + break + elif nodevalue.__contains__(value, strict=False): + include = True + else: + include = False + break + elif nodevalue == value: + include = True + else: + include = False + break + + if include: + temp.append(node) + + return temp + + def first(self, **filters: dict[str, object]) -> Node | None: + """Return the first matching Node, if one is found, or None otherwise.""" + + if len(self) == 0: + return None + + if len(filters) == 0: + return self[0] + + if len(matches := self.filter(**filters)) == 0: + return None + + return matches[0] + + def last(self, **filters: dict[str, object]) -> Node | None: + """Return the last matching Node, if one is found, or None otherwise.""" + + if len(self) == 0: + return None + + if len(filters) == 0: + return self[-1] + + if len(matches := self.filter(**filters)) == 0: + return None + + return matches[-1] diff --git a/source/semanticpy/version.txt b/source/semanticpy/version.txt index 3a3cd8c..1892b92 100644 --- a/source/semanticpy/version.txt +++ b/source/semanticpy/version.txt @@ -1 +1 @@ -1.3.1 +1.3.2 diff --git a/tests/conftest.py b/tests/conftest.py index 4c77f0b..a441412 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +import pytest_codeblocks import os import sys @@ -74,3 +75,23 @@ def fixture(profile: str = "linked-art", globals: dict = globals()): ) return fixture + + +def pytest_runtest_setup(item: pytest.Item) -> None: + """Set up test environment before each test runs. + + :param item: The test item that is about to run. + """ + + +def pytest_runtest_teardown(item: pytest.Item, nextitem: pytest.Item) -> None: + """Tear down test environment after each test ends. + + :param item: The test item that just finished running. + :param nextitem: The next test item that will run (or None if this is + """ + + # For test items run within documentation code blocks, tear down the model to ensure + # a clean and consistent runtime state after testing each block of code: + if isinstance(item, pytest_codeblocks.plugin.TestBlock): + semanticpy.Model.teardown() diff --git a/tests/data/examples/object.json b/tests/data/examples/object.json index feec0dc..e617535 100644 --- a/tests/data/examples/object.json +++ b/tests/data/examples/object.json @@ -31,7 +31,26 @@ { "type": "Identifier", "_label": "Accession Number for Artwork", + "classified_as": [ + { + "id": "http://vocab.getty.edu/aat/300312355", + "type": "Type", + "_label": "Accession Number" + } + ], "content": "1982.A.39" + }, + { + "type": "Identifier", + "_label": "Catalog Number for Artwork", + "classified_as": [ + { + "id": "http://vocab.getty.edu/aat/300417447", + "type": "Type", + "_label": "Catalog Number" + } + ], + "content": "X1290231.A72" } ], "produced_by": { diff --git a/tests/test_configuration_appending_mode.py b/tests/test_configuration_appending_mode.py index 1cce611..03af169 100644 --- a/tests/test_configuration_appending_mode.py +++ b/tests/test_configuration_appending_mode.py @@ -1,4 +1,4 @@ -from semanticpy import Model, AppendingMode +from semanticpy import Model, AppendingMode, Nodes def test_appending_mode_always(): @@ -18,7 +18,10 @@ def test_appending_mode_always(): object = model.HumanMadeObject() # Notice that the list of identifiers is initially unset (None) - assert object.identified_by is None + assert isinstance(object.identified_by, list) + assert isinstance(object.identified_by, Nodes) + assert len(object.identified_by) == 0 + assert object.identified_by == [] # Create an instance to demonstrate the various multiple-value property appending modes identifier = model.Identifier(content="123") @@ -60,8 +63,37 @@ def test_appending_mode_unique(): # Create an instance to demonstrate the various multiple-value property appending modes object = model.HumanMadeObject() - # Notice that the list of identifiers is initially unset (None) - assert object.identified_by is None + # Notice that the list of identifiers is initially empty + assert isinstance(object.classified_as, list) + assert isinstance(object.classified_as, Nodes) + assert len(object.classified_as) == 0 + assert object.classified_as == [] + + # Create and assign a Type + object.classified_as = type1 = model.Type( + ident="aat:300133025", + label="Works of Art", + ) + + assert len(object.classified_as) == 1 + + # Attempt to create and assign a Type with the same value, as + # appending mode is set to unique, this should be ignored + object.classified_as = type2 = model.Type( + ident="aat:300133025", + label="Works of Art", + ) + + # Check that the duplicate assignment was ignored + assert len(object.classified_as) == 1 + assert object.classified_as[0] is type1 + assert object.classified_as[0] is not type2 + + # Notice that the list of identifiers is initially empty + assert isinstance(object.identified_by, list) + assert isinstance(object.identified_by, Nodes) + assert len(object.identified_by) == 0 + assert object.identified_by == [] # Create an instance to demonstrate the various multiple-value property appending modes identifier1 = model.Identifier(content="123") diff --git a/tests/test_initialization.py b/tests/test_initialization.py index 26d8ff9..3ca6e16 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -43,13 +43,19 @@ def test_initialization_with_invalid_profile(): def test_teardown(): + """Test tearing down the model and removing the model classes from globals.""" + + # First create the models through the factory method, and add classes to globals semanticpy.Model.factory( profile="linked-art", globals=globals(), ) - # After running the factory method and creating the model, create a model instance - # This should succeed as the model class name has been added to globals() + # Check that one of the profile's model classes has been added to globals() + assert "HumanMadeObject" in globals() + + # After running the factory method and creating the model, create a model instance; + # this should succeed as the model class name has been added to globals() obj = HumanMadeObject() assert isinstance(obj, HumanMadeObject) @@ -57,6 +63,9 @@ def test_teardown(): # Now tear the model down, restoring globals() to its prior state semanticpy.Model.teardown(globals=globals()) + # Check that one of the profile's model classes has been removed from globals() + assert not "HumanMadeObject" in globals() + # Then try to create a model instance again, which should fail if the teardown was # successful, and if the restoration of globals() to its prior state succeeded try: diff --git a/tests/test_node.py b/tests/test_node.py index dff7937..299de9a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -78,12 +78,16 @@ def test_node_identity(): assert not id(node1) == id(node2) assert not hash(node1) == hash(node2) + assert node2 is not node1 + assert not id(node2) == id(node1) + assert not hash(node2) == hash(node1) + def test_node_equality(): """Test Node equality.""" # Create a new Node instance using the attribute properties and values of another - node1 = Node(data=dict(one=1, two="two")) + node1 = Node(data=dict(one=1, two="two", three="three")) node2 = Node(data=dict(one=1, two="two")) assert isinstance(node1, Node) @@ -91,6 +95,27 @@ def test_node_equality(): # Ensure the two nodes do not report having the same identity assert node1 is not node2 + assert node2 is not node1 # Ensure the two nodes, report being equal to each other as they have the same data assert node1.equals(node2) + assert node2.equals(node1) + + +def test_node_equality_strict(): + """Test Node equality in strict mode.""" + + # Create a new Node instance using the attribute properties and values of another + node1 = Node(data=dict(one=1, two="two", three="three")) + node2 = Node(data=dict(one=1, two="two")) + + assert isinstance(node1, Node) + assert isinstance(node2, Node) + + # Ensure the two nodes do not report having the same identity + assert node1 is not node2 + assert node2 is not node1 + + # Ensure the two nodes, report being equal to each other as they have the same data + assert not node1.equals(node2, strict=True) # not equal as node1.three cannot match + assert not node2.equals(node1, strict=True) # not equal as node1.three cannot match diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 4d426be..efcb9af 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -33,8 +33,45 @@ def test_nodes_length(nodes: Node): assert len(nodes) == 1 -def test_nodes_lists(node: Node, nodes: Nodes): - """Test adding Node entities to a list.""" +def test_nodes_append(node: Node, nodes: Nodes): + """Test appending Node entities to a Nodes list.""" + + assert isinstance(nodes, list) + assert isinstance(nodes, Nodes) + assert len(nodes) == 1 + + assert node in nodes + assert nodes[0] is node + assert nodes[0] == node + + new_node = Node(data=node.properties(), four=4.567) + + assert isinstance(new_node, Node) + assert new_node is not node + assert node is not new_node + assert new_node.equals(node, strict=True) is False + assert new_node.equals(node, strict=False) is True + assert node.equals(new_node, strict=True) is False + assert node.equals(new_node, strict=False) is True + + if not new_node in nodes: + nodes.append(new_node) + + assert len(nodes) == 2 + assert node in nodes + assert new_node in nodes + + assert nodes[0] is node + assert nodes[0].equals(node, strict=True) + assert nodes[0].equals(node, strict=False) + + assert nodes[1] is new_node + assert nodes[1].equals(new_node, strict=True) + assert nodes[1].equals(new_node, strict=False) + + +def test_nodes_append_duplicate_node(node: Node, nodes: Nodes): + """Test appending duplicate Node entities to a Nodes list.""" assert isinstance(nodes, list) assert isinstance(nodes, Nodes) @@ -49,15 +86,48 @@ def test_nodes_lists(node: Node, nodes: Nodes): assert isinstance(new_node, Node) assert new_node is not node assert new_node.equals(node) + assert new_node.equals(node, strict=True) + assert new_node.equals(node, strict=False) + # Check if an equivalent Node is already present or not in the Nodes list if not new_node in nodes: nodes.append(new_node) assert len(nodes) == 1 assert node in nodes + # The Node + # However, from an identity perspective, they are not the same + assert new_node in nodes assert nodes[0] is node assert nodes[0].equals(node) + assert nodes[0].equals(node, strict=True) + assert nodes[0].equals(node, strict=False) assert nodes[0] is not new_node assert nodes[0].equals(new_node) + assert nodes[0].equals(new_node, strict=True) + assert nodes[0].equals(new_node, strict=False) + + +def test_nodes_filter(): + nodes: Nodes = Nodes() + + nodes.append(Node(a=1, b=2, c=3)) + nodes.append(Node(a=1, b=2, c=4)) + nodes.append(Node(a=1, b=3, c=5)) + + filtered = nodes.filter(a=1, b=2) + + assert isinstance(filtered, list) + assert isinstance(filtered, Nodes) + assert len(filtered) == 2 + assert filtered[0] is nodes[0] + assert filtered[1] is nodes[1] + + filtered = nodes.filter(c=5) + + assert isinstance(filtered, list) + assert isinstance(filtered, Nodes) + assert len(filtered) == 1 + assert filtered[0] is nodes[2] diff --git a/tests/test_record_create.py b/tests/test_record_create.py index ca4534e..d71d02a 100644 --- a/tests/test_record_create.py +++ b/tests/test_record_create.py @@ -59,8 +59,25 @@ def test_record_create(factory: callable, data: callable): label="Accession Number for Artwork", ) + identifier.classified_as = Type( + ident="aat:300312355", + label="Accession Number", + ) + identifier.content = "1982.A.39" + # Include an Identifier node on the HMO to carry an identifier of the artwork + hmo.identified_by = identifier = Identifier( + label="Catalog Number for Artwork", + ) + + identifier.classified_as = Type( + ident="aat:300417447", + label="Catalog Number", + ) + + identifier.content = "X1290231.A72" + hmo.produced_by = production = Production() production.timespan = timespan = TimeSpan()