diff --git a/CHANGELOG.md b/CHANGELOG.md index a21b2cf..65f1438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [1.3.3] - 2026-05-28 +### Added +- Support for saving existing Model entities to JSON-LD documents. + ## [1.3.2] - 2026-05-19 ### Added - Improved assembly and filtering of existing JSON-LD documents. diff --git a/README.md b/README.md index a2691ac..c1aa2f3 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Model.factory(profile="linked-art", globals=globals()) Model.prefix("aat", "http://vocab.getty.edu/aat/") # Register one or more identifier prefixes that take the form ":" when used in -# an identifier and are replaced with the specified URIs during document serialization: +# an identifier and are replaced with the specified URIs during document serialisation: Model.prefix("tgn", "http://vocab.getty.edu/tgn/") # Create a HumanMadeObject (HMO) model instance @@ -58,7 +58,7 @@ hmo.classified_as = Type( label = "Works of Art", ) -# As this example HMO represents a painting, add a classification of "Paintings" as per +# As this example HMO represents a painting, add classification of "Paintings" per # the Linked.Art model to specify the type of artwork that this HMO represents: hmo.classified_as = typed = Type( ident = "aat:300033618", @@ -205,7 +205,7 @@ The primary interface to the SemanticPy library is its `Model` class which offer * `typed` (`bool`) – (optional) the `typed` argument can be used to specify if the model subclass should be serialised into JSON-LD with its `type` property or not; by default the `type` property is always included during serialisation; this option can be used to prevent this if required. - * `prefix(prefix: str, uri: str)` – the `prefix()` class method can be used to register one or more identifier prefixes with the library that will be replaced with the specified URI during document serialization. + * `prefix(prefix: str, uri: str)` – the `prefix()` class method can be used to register one or more identifier prefixes with the library that will be replaced with the specified URI during document serialisation. * `entity()` (`Model` | `None`) – the `entity()` method may be used to obtain the `type` reference for a named model entity, from which a new instance of that named model entity may be created; if no matching `Model` subclass can be found, the method returns `None`. The `entity()` method accepts the following arguments: @@ -226,6 +226,12 @@ The primary interface to the SemanticPy library is its `Model` class which offer * `reference()` (`Model`) – the `reference()` method may be used to create a reference to a model instance – useful for referencing a model entity from a property on another model instance without incorporating and nesting all of the properties of the referenced model instance. + * `walkthrough()` (`dict[str, object]`) – the `walkthrough()` method may be used to obtain a representation of the current model instance, containing all of its properties as dictionary keys and property values as dictionary values. The `walkthrough()` method accepts the following arguments: + + * `callback` (`callable`) – (optional) the `callback` argument may be used to specify a callback method that can be used to modify the value that is included in the returned container. The callback method must accept three arguments: the `key` (the name of the property as a `str` value), its `value` (an `object` value), and a reference to the current `container` (which will be either a `dict` or `list` reference). The callback must return the value to include in the container, either returning the `value` it was provided to leave the value as-is or to return a different value to change the value that will be included. + + * `attribute` (`str` | `int`) – (optional) the `attribute` argument may be used to control if the `callback` method should only be called for the named/indexed property/attribute, or to if the `callback` should be called for all properties. To limit calls to the `callback`, use the `attribute` argument to specify the name of the property or the index position that would need to match in order to call the `callback` method. + * `properties()` (`dict[str, object]`) – the `properties()` method may be used to obtain a dictionary representation of the current model instance, containing all of its properties as dictionary keys and property values as dictionary values. The `properties()` method accepts the following arguments: * `sorting` (`list[str]` | `dict[str, int]`) – (optional) the `sorting` argument may be used to specify a sort order that should be applied to the returned property data; sorting may be specified as a `list` of `str` values, where the list comprises the names of the properties in the order that they should be sorted into; alternatively, the `sorting` may be specified as a `dict` that comprise a list of property names associated with a sort order ranking specified as an integer where higher values integer sort later in the results. @@ -240,8 +246,7 @@ The primary interface to the SemanticPy library is its `Model` class which offer * `default` (`object`) – (optional) the `default` argument may be used to set an alternative return value for a call to the `property()` method if the method is unable to find and return the named property. - * `documents()` (`list[Model]`) – the `documents()` method may be used to obtain a list of model entity - documents from the current model instance; the `documents()` method accepts the following + * `documents()` (`list[Model]`) – the `documents()` method may be used to obtain a list of model entity documents from the current model instance; the `documents()` method accepts the following arguments, which control whether nodes of the following types will be including in the resulting list: * `blank` (`bool`) – (optional) to return any blank nodes within the current document, leave the @@ -255,10 +260,48 @@ The primary interface to the SemanticPy library is its `Model` class which offer * `filter` (`callable`) – (optional) to achieve finer-grained control over whether nodes are include in the resulting list, a callback method can be provided to the method via the `filter` argument; the callback method must take a reference to the current document, and its containing entity, and must return a `bool` value each time it is called; to include a node in the returned list via custom filtering, the method must return `True` and to omit the node, the method must return `False`. +* `json()` – the `json()` method may be used to generate a JSON-LD representation of the current model instance; the `json()` method accepts the following arguments, which control the formatting of the JSON output: + + * `compact` (`bool`) – (optional) controls if the JSON output should be emitted in its most compact form, without indentation or line breaks, when set to `True`, or allowing line breaks and indentation, when set to `False`. + + * `indent` (`int`) – (optional) controls the number of spaces used to indent each level of the JSON, which can be set if the `compact` argument is not set to `True`. + + * `sorting` (`list[str]` | `dict[str, int]`) – (optional) controls if and how the keys of the JSON are sorted; if a list of attribute names is provided, the matching attributes will be sorted to match the order defined in the list; if a dictionary of attribute names and numeric sort priorities are specified, the specified attribute names will be sorted according to the sort priorities specified against the attribute names; if additional attributes exist in the output that are not referenced in the list or dictionary of attribute names, their sort position is not guaranteed to be deterministic. + + * `callback` (`callable`) – (optional) the callback can be used to overwrite the value of specific attributes based on a runtime call to an optional callback function; the return value of the function will be used as the new value for the current attribute; the callback function will be provided with the attribute name, its initial value, and a reference to its parent container; the callback method must return the replacement value if there is one, and if not, must return the initial value. + + * `attribute` (`str`) – (optional) the attribute argument can be used to control for which model attributes the optional callback is called; if the `attribute` is not specified, the optional callback, if specified, will be called for every attribute. The attribute must be specified by its name. + +* `open()` – the `open()` method be used to open a pre-existing JSON-LD document mapped using the same JSON-LD context as the Model factory is instantiated with, such as the `linked-art` profile. The `open()` method accepts either a HTTP(S) URL or a file path that points to a valid JSON-LD document, and if the document can be opened and loaded, the method will return an instance of the `Model` subclass that represents the opened document. One can then access and filter properties of the document and extract data, or use the document as a starting point to build upon or modify and then re-save. See the [**Opening**](#opening) section for more information. The `open()` method accepts the following arguments: + + * `filepath` (`str`) – (required) the `filepath` argument must point to a valid and accessible JSON-LD document mapped using the same context as loaded via the `Model` class' `factory()` method. The `filepath` can either point to a document available via HTTP(S) or a local file system path. Files available via HTTP(S) must have URLs beginning with `http://` or `https://`. + + * `extensions` (`bool`) – (optional) the `extensions` argument controls whether the library will try to load and parse any extended data model classes and properties – those which go beyond those defined in the model context profile, which may have been added through calls to `Model.extend()`. To support the successful loading of any extended model classes or properties, the `Model.factory()` method needs to have been called followed by any necessary calls to `Model.extend()` before a record containing any extended classes or properties is loaded via the `open()` method. In such cases, the `extensions` argument can then be set to `True` allowing the extensions to load, otherwise, leaving the argument at its default value of `False`, loads all of the standard parts of the document and ignores any extended data model classes and properties. + +* `save()` – the `save()` method may be used to save a JSON-LD representation of the current model instance. See the [**Saving**](#saving) section for more information. The method accepts the following arguments: + + * `filepath` (`str`) – (required) the `filepath` argument is required and must point to a valid local or mounted file system path at which the document can be written. + + * `overwrite` (`bool`) – (optional) the `overwrite` argument controls whether the `save()` method will overwrite a document that already exists at the specified path or not. If a document already exists, and `overwrite` has its default value of `False`, an exception will be raised. To allow the method to overwrite an existing document, set the `overwrite` argument to `True`. + + * `compact` (`bool`) – (optional) controls if the JSON output should be emitted in its most compact form, without indentation or line breaks, when set to `True`, or allowing line breaks and indentation, when set to `False`. + + * `indent` (`int`) – (optional) controls the number of spaces used to indent each level of the JSON, which can be set if the `compact` argument is not set to `True`. + + * `sorting` (`list[str]` | `dict[str, int]`) – (optional) controls if and how the keys of the JSON are sorted; if a list of attribute names is provided, the matching attributes will be sorted to match the order defined in the list; if a dictionary of attribute names and sort priorities are specified, the specified attribute names will be sorted according to the sort priorities specified against the attribute names; if additional attributes exist in the output that are not referenced in the list or dictionary of attribute names, their sort position is not guaranteed to be deterministic. + + * `callback` (`callable`) – (optional) the callback can be used to overwrite the value of specific attributes based on a runtime call to an optional callback function; the return value of the function will be used as the new value for the current attribute; the callback function will be provided with the attribute name, its initial value, and a reference to its parent container; the callback method must return the replacement value if there is one, and if not, must return the initial value. + + * `attribute` (`str`) – (optional) the attribute argument can be used to control for which model attributes the optional callback is called; if the `attribute` is not specified, the optional callback, if specified, will be called for every attribute. The attribute must be specified by its name. + +* `print()` – the `print()` method may be used to print a representation of the current model instance. The method does not accept any arguments. + ### Properties The `Model` class offers the following named properties in addition to the methods defined above: + * `context` (`object`) – the `context` property provides access to the model instance's `@context` property value. + * `name` (`str`) – the `name` property provides access to the model instance's class name. * `label` (`str` | `None`) – the `label` property provides access to the model instance's assigned label, if any. @@ -363,7 +406,7 @@ multiple values; in the case of multi-value properties, all assignments result i assigned value being added to the list of values held by the property according to the default behaviour; any later value assignment simply appends the value to the list, rather than overwriting earlier values. To adjust the behaviour of appending values to -multi-value properties, see the [Appending Modes](#appending-modes) section below. +multi-value properties, see the [**Appending Modes**](#appending-modes) section below. ### Appending Modes @@ -669,6 +712,71 @@ This code will print the following JSON-LD output: } ``` + +### Opening JSON-LD Model Document + +To load a pre-existing JSON-LD model document for a supported model, you can use code +similar to the following: + +```python +import semanticpy + +# Load desired model profile to setup necessary classes for loading document +semanticpy.Model.factory(profile="linked-art", globals=globals()) + +# Open the desired JSON-LD document from its local path or remote http(s) URL +document = semanticpy.Model.open("/tests/data/examples/object.json") + +# Ensure that the opened JSON-LD record is of the type expected +assert isinstance(document, HumanMadeObject) + +# Ensure that the record @context property is as expected +assert document.context == "https://linked.art/ns/v1/linked-art.json" + +# Ensure that the record entity type is as expected +assert document.typed == "E22" # E22 (HumanMadeObject) + +# Ensure that the record type name is as expected +assert document.type == "HumanMadeObject" + +# Ensure that the record identifier (the `id` property value) is as expected +assert document.ident == "https://data.example.org/object/1" + +# Extract the desired identifier node by its matching classification +identifier = document.identified_by.first( + classified_as=Type(ident="http://vocab.getty.edu/aat/300312355") +) + +assert isinstance(identifier, Identifier) + +assert identifier.label == "Accession Number for Artwork" + +assert identifier.content == "1982.A.39" +``` + + +### Saving JSON-LD Model Document + +To save model instance's data to a JSON-LD document, you can use code similar to the +following: + +```python +import semanticpy + +# Load desired model profile to setup necessary classes for creating document +semanticpy.Model.factory(profile="linked-art", globals=globals()) + +# Open the desired JSON-LD document from its local path or remote http(s) URL +document = HumanMadeObject("https://www.example.org/object/123") + +document.classified_as = Type( + ident="http://vocab.getty.edu/aat/300133025", + label="Works of Art", +) + +document.save("./example.json", indent=2) +``` + ### Code Formatting diff --git a/source/semanticpy/__init__.py b/source/semanticpy/__init__.py index e69ad12..f7f95fe 100644 --- a/source/semanticpy/__init__.py +++ b/source/semanticpy/__init__.py @@ -317,7 +317,7 @@ def teardown(cls, globals: dict = None): cls._appending_mode = None @classmethod - def open(cls, filepath: str) -> Model: + def open(cls, filepath: str, extensions: bool = False) -> Model: """Support opening and loading model instances from stored JSON-LD files""" # cls.factory(profile=profile, context=context, globals=globals) @@ -336,11 +336,7 @@ def open(cls, filepath: str) -> Model: data: dict[str, object] = None - if ( - filepath.startswith("http://") - or filepath.startswith("https://") - or filepath.startswith("//") - ): + if filepath.startswith("http://") or filepath.startswith("https://"): try: if isinstance(response := requests.get(filepath), object): if response.status_code == 200: @@ -361,22 +357,22 @@ def open(cls, filepath: str) -> Model: "The specified file could not be loaded (%s) from its URL!" % (exception) ) - elif ( - filepath.startswith("/") - or filepath.startswith("./") - or filepath.startswith("../") - or filepath.startswith("~/") - ): + else: if filepath.startswith("~/"): filepath = os.path.expanduser(filepath) + else: + filepath = os.path.abspath(filepath) + + if not os.path.exists(filepath): + raise ValueError( + "The specified filepath (%s) does not exist!" % (filepath) + ) with open(filepath, "r") as handle: if not isinstance(data := json.load(handle), dict): raise ValueError( "The specified file does not contain valid JSON data!" ) - else: - raise ValueError("The specified filepath (%s) is unsupported!" % (filepath)) if isinstance(data, dict): if isinstance(context := data.get("@context"), (str, dict, list)): @@ -389,7 +385,10 @@ def open(cls, filepath: str) -> Model: if typed := data.get("type"): if entity := cls.entity(typed): - if instance := entity(data=readonlydict(data)): + if instance := entity( + data=readonlydict(data), + extensions=extensions, + ): return instance else: raise ValueError( @@ -667,12 +666,31 @@ 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: + def create( + cls, + data: dict, + property: str = None, + extensions: bool = False, + ) -> Model: """Support creating a model entity from its data (dictionary) representation.""" + model: Model = None + if not isinstance(data, dict): raise TypeError("The 'data' argument must have a dictionary value!") + if property is None: + pass + elif isinstance(property, str): + pass + else: + raise TypeError( + "The 'property' argument, if specified, must have a string value!" + ) + + if not isinstance(extensions, bool): + raise TypeError("The 'extensions' argument must have a boolean value!") + # Attempt to determine the entity type from the assigned 'type' string value if isinstance(typed := data.get("type"), str): if not isinstance(entity := cls.entity(name=typed), type): @@ -680,7 +698,7 @@ def create(cls, data: dict, property: str = None) -> Model: "The '%s' entity type cannot be mapped to a model entity!" % (typed) ) - if not isinstance(model := entity(data=data), Model): + if not isinstance(model := entity(data=data, extensions=extensions), Model): raise ValueError( "The '%s' entity type could not be instantiated!" % (typed) ) @@ -696,7 +714,7 @@ def create(cls, data: dict, property: str = None) -> Model: # If no entity type can be determined, raise an exception as the current data # node cannot be loaded into the data model; ensure the model has been defined # completely and in accordance with the provided data, including any extensions - else: + elif extensions is True: raise ValueError( "The entity type cannot be determined for the provided data dictionary; the dictionary must contain a valid 'type' property, or be an extended model entity assigned to an expected named property!" ) @@ -704,7 +722,7 @@ def create(cls, data: dict, property: str = None) -> Model: return model # TODO: Should 'load' be a "private" method? - def load(self, data: dict, model: Model) -> None: + def load(self, data: dict, model: Model, extensions: bool = False) -> None: """Support loading data into the model entity from its dictionary representation.""" if not isinstance(data, dict): @@ -716,19 +734,57 @@ def load(self, data: dict, model: Model) -> None: % (self.__class__.__name__) ) + if not isinstance(extensions, bool): + raise TypeError("The 'extensions' argument must have a boolean value!") + for property, value in data.items(): if isinstance(value, dict): - setattr(model, property, self.create(data=value, property=property)) + value = self.create(value, property=property, extensions=extensions) + + setattr(model, property, value) elif isinstance(value, list): for index, item in enumerate(value): - # if not isinstance(item, dict): - # raise TypeError( - # "The list item at index %d is not a dictionary, but rather %s!" % (index, type(item)) - # ) - setattr(model, property, self.create(data=item, property=property)) + if isinstance(item, dict): + item = self.create( + item, + property=property, + extensions=extensions, + ) + + setattr(model, property, item) else: setattr(model, property, value) + def save(self, filepath: str, overwrite: bool = False, **kwargs) -> str: + """Support saving the current Model entity to a JSON-LD file.""" + + if not isinstance(filepath, str): + raise TypeError("The 'filepath' argument must have a string value!") + elif not len(filepath := filepath.strip()) > 0: + raise ValueError( + "The 'filepath' argument must have a non-empty string value!" + ) + + if not isinstance(overwrite, bool): + raise TypeError("The 'overwrite' argument must have a boolean value!") + + filepath = os.path.abspath(filepath) + + if os.path.exists(filepath): + if not os.path.isfile(filepath): + raise ValueError( + f"The 'filepath' specifies a path, '{filepath}', for a file-system object that is not a file, such as a directory or socket; please update the path or remove the conflicting file-system object!" + ) + elif overwrite is False: + raise ValueError( + f"The 'filepath' specifies a path, '{filepath}', for a file that already exists; set 'overwrite' to 'True' to allow the file to be overwritten!" + ) + + with open(filepath, "w+", encoding="utf-8") as handle: + handle.write(self.json(**kwargs)) + + return handle.name + def __new__(cls, *args, **kwargs): # The '_special' list variable is defined in the base class and holds a list of # special class attribute names @@ -751,6 +807,7 @@ def __init__( ident: str = None, label: str = None, data: dict[str, object] = None, + extensions: bool = False, **kwargs, ): super().__init__( @@ -758,6 +815,9 @@ def __init__( # data=data, ) + if not isinstance(extensions, bool): + raise TypeError("The 'extensions' argument must have a boolean value!") + self._annotations: dict[str, object] = {} # Enable support for the essential model properties @@ -782,7 +842,7 @@ def __init__( pass elif not isinstance(ident, str): raise TypeError( - "The 'ident' argument, if specified, must have a string value!" + "The 'ident' (identity) argument, if specified, must have a string value!" ) self.id: str = ident or None @@ -791,7 +851,7 @@ def __init__( pass elif not isinstance(label, str): raise TypeError( - "The 'label' argument, if specified, must have a string value!" + "The 'label' (identity) argument, if specified, must have a string value!" ) self._label: str = label or None @@ -820,7 +880,7 @@ def __init__( if data is None: pass elif isinstance(data, dict): - self.load(data=data, model=self) + self.load(data=data, model=self, extensions=extensions) else: raise TypeError( "The 'data' argument, if specified, must have a dictionary value!" @@ -1053,6 +1113,10 @@ def merge(self, model: Model, properties: list[str] = None) -> Model: elif not prop in properties: continue + if attr := getattr(model.__class__, prop, None): + if isinstance(attr, property): + continue + if attr := getattr(model, prop): if callable(attr): continue diff --git a/source/semanticpy/types/node.py b/source/semanticpy/types/node.py index 2c750fe..f1f5d05 100644 --- a/source/semanticpy/types/node.py +++ b/source/semanticpy/types/node.py @@ -241,7 +241,7 @@ def typed(self) -> str: return self._type @property - def context(self) -> str: + def context(self) -> object: return self._context @property @@ -410,10 +410,10 @@ def walkthrough( self, callback: callable, attribute: str = None, - container: dict | list = None, - ): + container: dict[str, object] | list[dict[str, object]] = None, + ) -> dict[str, object]: """Perform a recursive walkthrough of a dictionary/list calling the callback - for any matched attribute""" + for any matched attribute, returning a dictionary representation of the Node.""" if container is None: container = dict(self.properties()) diff --git a/source/semanticpy/version.txt b/source/semanticpy/version.txt index 1892b92..31e5c84 100644 --- a/source/semanticpy/version.txt +++ b/source/semanticpy/version.txt @@ -1 +1 @@ -1.3.2 +1.3.3 diff --git a/tests/data/examples/object.json b/tests/data/examples/object.json index e617535..cbfad60 100644 --- a/tests/data/examples/object.json +++ b/tests/data/examples/object.json @@ -1,6 +1,6 @@ { "@context": "https://linked.art/ns/v1/linked-art.json", - "id": "https://example.org/object/1", + "id": "https://data.example.org/object/1", "type": "HumanMadeObject", "_label": "Example Object #1", "classified_as": [ diff --git a/tests/data/examples/saved.json b/tests/data/examples/saved.json new file mode 100644 index 0000000..fdbb229 --- /dev/null +++ b/tests/data/examples/saved.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linked.art/ns/v1/linked-art.json", + "id": "https://data.example.org/object/123", + "type": "HumanMadeObject", + "_label": "Example Object 123", + "classified_as": [ + { + "id": "http://vocab.getty.edu/aat/300133025", + "type": "Type", + "_label": "Works of Art" + } + ] +} \ No newline at end of file diff --git a/tests/test_record_create.py b/tests/test_record_create.py index d71d02a..8a7c119 100644 --- a/tests/test_record_create.py +++ b/tests/test_record_create.py @@ -22,7 +22,7 @@ def test_record_create(factory: callable, data: callable): # Create a HumanMadeObject (HMO) model instance hmo = HumanMadeObject( - ident="https://example.org/object/1", + ident="https://data.example.org/object/1", label="Example Object #1", ) diff --git a/tests/test_record_load.py b/tests/test_record_load.py index eea436a..dd40d4d 100644 --- a/tests/test_record_load.py +++ b/tests/test_record_load.py @@ -1,3 +1,140 @@ import logging +import semanticpy + +from semanticpy import Model, Node, Nodes + logger = logging.getLogger(__name__) + + +def test_record_load(factory: callable, data: callable, path: callable): + # Initialise the Model using a named profile; in this case specify the "linked-art" + # profile which is provided with the library so only needs specifying by its name. + # Other profiles may be used to create models of other types; see the *Profiles* + # section in the README for more information. The factory method dynamically creates + # the required model class types and adds them to the scope defined by the `globals` + # argument making the class types available for use just by referencing their names: + model = factory(profile="linked-art") + + # Open the record; this could be a JSON-LD record stored in a locally accessible + # filesystem or on a web server accessible via a HTTP(S) URL without authentication: + artefact = semanticpy.Model.open(path("examples/object.json")) + + # Ensure that the opened JSON-LD record is of the type expected + assert isinstance(artefact, model.HumanMadeObject) + assert isinstance(artefact, Model) + assert isinstance(artefact, Node) + + # Ensure that the record @context property is as expected + assert artefact.context == "https://linked.art/ns/v1/linked-art.json" + + # Ensure that the record entity type is as expected + assert artefact.typed == "E22" # E22 (HumanMadeObject) + + # Ensure that the record type name is as expected + assert artefact.type == "HumanMadeObject" + + # Ensure that the record type name is as expected + assert artefact.name == "HumanMadeObject" + + # Ensure that the record identifier (`id` property value) is as expected + assert artefact.ident == "https://data.example.org/object/1" + + assert isinstance(artefact.classified_as, list) + assert isinstance(artefact.classified_as, Nodes) + assert len(artefact.classified_as) == 2 + + assert artefact.classified_as[0].equals( + model.Type( + ident="http://vocab.getty.edu/aat/300133025", + label="Works of Art", + ) + ) + + assert model.Type( + ident="http://vocab.getty.edu/aat/300133025", + label="Works of Art", + ).equals(artefact.classified_as[0]) + + assert artefact.classified_as[1].equals( + model.Type( + ident="http://vocab.getty.edu/aat/300033618", + label="Paintings (Visual Works)", + ) + ) + + assert model.Type( + ident="http://vocab.getty.edu/aat/300033618", + label="Paintings (Visual Works)", + ).equals(artefact.classified_as[1]) + + assert isinstance(artefact.identified_by, list) + assert isinstance(artefact.identified_by, Nodes) + assert len(artefact.identified_by) == 3 + + assert artefact.identified_by[0].equals( + model.Name( + label="Name of Artwork", + content="A Painting", + ) + ) + + assert model.Name( + label="Name of Artwork", + content="A Painting", + ).equals(artefact.identified_by[0]) + + # Find the name by its type + name = artefact.identified_by.first(type="Name") + assert isinstance(name, model.Name) + assert isinstance(name, Model) + assert isinstance(name, Node) + assert name.label == "Name of Artwork" + assert name.content == "A Painting" + + artefact.identified_by[1].equals( + model.Identifier( + label="Accession Number for Artwork", + content="1982.A.39", + ) + ) + + assert model.Identifier( + label="Accession Number for Artwork", + content="1982.A.39", + ).equals(artefact.identified_by[1]) + + # Find the identifier by its type + identifier = artefact.identified_by.first(type="Identifier") + assert isinstance(identifier, model.Identifier) + assert isinstance(identifier, Model) + assert isinstance(identifier, Node) + assert identifier.label == "Accession Number for Artwork" + assert identifier.content == "1982.A.39" + + # Find the identifier by the desired classification + identifier = artefact.identified_by.first( + classified_as=model.Type(ident="http://vocab.getty.edu/aat/300312355") + ) + assert isinstance(identifier, model.Identifier) + assert identifier.label == "Accession Number for Artwork" + assert identifier.content == "1982.A.39" + + # Find the identifier by the desired classification + identifier = artefact.identified_by.first( + classified_as=dict( + ident="http://vocab.getty.edu/aat/300417447", + label="Catalog Number", + ) + ) + assert isinstance(identifier, model.Identifier) + assert isinstance(identifier, Model) + assert isinstance(identifier, Node) + assert identifier.label == "Catalog Number for Artwork" + assert identifier.content == "X1290231.A72" + + assert isinstance(artefact.produced_by, model.Production) + assert isinstance(artefact.produced_by.timespan, model.TimeSpan) + + assert artefact.produced_by.timespan.begin_of_the_begin == "2026-01-01T00:00:00" + assert artefact.produced_by.timespan.end_of_the_end == "2026-12-31T23:59:59" diff --git a/tests/test_record_save.py b/tests/test_record_save.py new file mode 100644 index 0000000..580e81c --- /dev/null +++ b/tests/test_record_save.py @@ -0,0 +1,46 @@ +import logging + +import semanticpy + +from semanticpy import Model, Node, Nodes + +logger = logging.getLogger(__name__) + + +def test_record_load(factory: callable, data: callable, path: callable): + # Initialise the Model using a named profile; in this case specify the "linked-art" + # profile which is provided with the library so only needs specifying by its name. + # Other profiles may be used to create models of other types; see the *Profiles* + # section in the README for more information. The factory method dynamically creates + # the required model class types and adds them to the object returned from the call: + model = factory(profile="linked-art") + + # Create a new Model instance + artefact = model.HumanMadeObject( + ident="https://data.example.org/object/123", + label="Example Object 123", + ) + + # Ensure that the created JSON-LD record is of the type expected + assert isinstance(artefact, model.HumanMadeObject) + assert isinstance(artefact, Model) + assert isinstance(artefact, Node) + + # Add an example property value to the Model instance + artefact.classified_as = model.Type( + ident="http://vocab.getty.edu/aat/300133025", + label="Works of Art", + ) + + # Save the Model instance object to storage + filepath: str = artefact.save("/tmp/object.json", indent=2) + + # Ensure that the call to the .save() method returned the expected file path + assert isinstance(filepath, str) + + # Load the contents of the saved file + contents: str = data(filepath) + assert isinstance(contents, str) + + # Ensure that the contents of the saved file match the pre-saved example file + assert contents == data("examples/saved.json")