Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.

## [1.3.5] - 2026-06-22
### Added
- Improved document loading, namespaced property handling and additional unit testing.

## [1.3.4] - 2026-05-31
### Added
- Improved merge functionality and additional unit testing.
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,9 @@ The primary interface to the SemanticPy library is its `Model` class which offer

* `globals` (`dict`) – (optional) the `globals` argument can be used to specify an optional `globals()` scope into which a reference for the extended model subclass will be added.

* `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.
* `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, by setting the keyword argument to `False`.

* `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.
* `prefix(prefix: str, uri: str)` – the `prefix()` class method can be optionally 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:

Expand Down Expand Up @@ -272,7 +271,7 @@ The primary interface to the SemanticPy library is its `Model` class which offer

* `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:
* `open()` – the `open()` method can 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://`.

Expand Down Expand Up @@ -757,7 +756,7 @@ assert identifier.content == "1982.A.39"
<a name="saving"></a>
### Saving JSON-LD Model Document

To save model instance's data to a JSON-LD document, you can use code similar to the
To save a model instance's data to a JSON-LD document, you can use code similar to the
following:

```python
Expand All @@ -766,15 +765,18 @@ 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")
# Create the desired model entity document
document = HumanMadeObject("https://data.example.org/object/123")

# Map any desired properties and values
document.classified_as = Type(
ident="http://vocab.getty.edu/aat/300133025",
label="Works of Art",
)

document.save("./example.json", indent=2)
# Save the document to local storage, specifying the save path, any indentation, and if
# the save operation is allowed to overwrite a file of the same name that already exists
document.save("./example.json", indent=2, overwrite=True)
```

<a name="code-formatting"></a>
Expand Down
101 changes: 84 additions & 17 deletions source/semanticpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
)
from semanticpy.enumerations import OverwriteMode, AppendingMode

logger.debug("semanticpy library imported from: %s" % (__file__))
with open(os.path.join(os.path.dirname(__file__), "version.txt")) as file:
__version__ = file.read().strip()


logger.debug("%s library (%s) imported from: %s", __name__, __version__, __file__)


class Model(Node):
Expand All @@ -30,6 +34,7 @@ class Model(Node):
_hidden: list[str] = []
_globals: dict[str, object] = None
_prefixes: dict[str, str] = {}
_loading: bool = False

@classmethod
def factory(
Expand Down Expand Up @@ -467,6 +472,18 @@ def _validate_properties(cls, properties: dict, property: str) -> dict:
if not isinstance(properties["canonical"], str):
raise TypeError("The 'canonical' property must have a string value!")

if "namespace" in properties:
if not isinstance(properties["namespace"], str):
raise TypeError("The 'namespace' property must have a string value!")
elif not len(properties["namespace"].strip()) > 0:
raise ValueError(
"The 'namespace' property must have a non-empty string value!"
)
elif ":" in properties["namespace"]:
raise ValueError(
"The 'namespace' property value cannot contain the ':' character!"
)

return properties

@classmethod
Expand Down Expand Up @@ -543,6 +560,10 @@ def extend(
if isinstance(alias := props.get("alias"), str):
entity._properties[alias] = {**props, **{"alias": prop}}

# If the property is namespaced, add its reference here
if isinstance(namespace := props.get("namespace"), str):
entity._properties[namespace + ":" + prop] = {**props}

if isinstance(sorting := props.get("sorting"), int):
entity._sorting[prop] = sorting

Expand Down Expand Up @@ -737,6 +758,8 @@ def load(self, data: dict, model: Model, extensions: bool = False) -> None:
if not isinstance(extensions, bool):
raise TypeError("The 'extensions' argument must have a boolean value!")

self._loading = True

for property, value in data.items():
if isinstance(value, dict):
value = self.create(value, property=property, extensions=extensions)
Expand All @@ -755,6 +778,8 @@ def load(self, data: dict, model: Model, extensions: bool = False) -> None:
else:
setattr(model, property, value)

self._loading = False

def save(self, filepath: str, overwrite: bool = False, **kwargs) -> str:
"""Support saving the current Model entity to a JSON-LD file."""

Expand Down Expand Up @@ -796,10 +821,26 @@ def __new__(cls, *args, **kwargs):
"_reference",
"_referenced",
"_cloned",
"_loading",
]
if attr not in cls._special
]

if cls is Model:
if isinstance(data := kwargs.get("data"), dict):
if isinstance(type := data.get("type"), str):
if issubclass(entity := cls.entity(type), Model):
cls = entity
else:
raise SemanticPyError(
"Unable to determine model entity from the 'type' attribute value of %s!"
% (type)
)
else:
raise SemanticPyError(
"Unable to find the model 'type' attribute in the provided data!"
)

return super().__new__(cls)

def __init__(
Expand Down Expand Up @@ -911,7 +952,13 @@ def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)

def __setattr__(self, name: str, value: object) -> None:
# logger.debug("%s.%s(name: %s, value: %s) called" % (self.__class__.__name__, self.__setattr__.__name__, name, value))
logger.debug(
"%s.%s(name: %s, value: %s) called",
self.__class__.__name__,
self.__setattr__.__name__,
name,
value,
)

prop: dict[str, object] = self._properties.get(name) or {}

Expand All @@ -925,22 +972,42 @@ def __setattr__(self, name: str, value: object) -> None:
or name in self._special
or prop.get("accepted") is True
):
raise AttributeError(
"Cannot set property '%s' on %s as it is not in the list of accepted properties: '%s'!"
% (
name,
self.__class__.__name__,
"', '".join(
sorted(
[
name
for name, prop in self._properties.items()
if prop.get("accepted") is True
]
)
if self._loading is True:
logger.warning(
"Cannot set property '%s' on %s as it is not in the list of accepted properties: '%s'!"
% (
name,
self.__class__.__name__,
"', '".join(
sorted(
[
name
for name, prop in self._properties.items()
if prop.get("accepted") is True
]
)
),
),
),
)
)

return
else:
raise AttributeError(
"Cannot set property '%s' on %s as it is not in the list of accepted properties: '%s'!"
% (
name,
self.__class__.__name__,
"', '".join(
sorted(
[
name
for name, prop in self._properties.items()
if prop.get("accepted") is True
]
)
),
),
)

if value is None:
return super().__delattr__(name)
Expand Down
46 changes: 38 additions & 8 deletions source/semanticpy/types/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ def data(self) -> dict[str, object]:
@data.setter
def data(self, data: dict[str, object]):
if not isinstance(data, dict):
raise RuntimeError("The data must be defined as a dictionary!")
raise TypeError("The data must be defined as a dictionary!")

self._data = data

Expand All @@ -266,18 +266,43 @@ def settings(self) -> dict[str, object]:
@settings.setter
def settings(self, settings: dict[str, object]):
if not isinstance(settings, dict):
raise RuntimeError("The settings must be defined as a dictionary!")
raise TypeError("The settings must be defined as a dictionary!")

self._settings = settings

def annotate(self, name: str, value: object):
def annotate(self, name: str = None, value: object = None, /, **kwargs) -> Node:
"""Support adding arbitrary named 'annotations' to a node for later retrieval"""

self._annotations[name] = value
annotations: dict[str, object] = {}

if name is None:
pass
elif not isinstance(name, str):
raise TypeError(
"The 'name' argument, if specified, must have a string value!"
)
elif not len(name := name.strip()) > 0:
raise ValueError(
"The 'name' argument, if specified, must have a non-empty string value!"
)
else:
annotations[name] = value

for name, value in kwargs.items():
annotations[name] = value

self._annotations.update(annotations)

return self

def annotation(self, name: str, default: object = None):
"""Support retrieving a named annotation if available or returning the default"""

if not isinstance(name, str):
raise TypeError("The 'name' argument must have a string value!")
elif not len(name := name.strip()) > 0:
raise ValueError("The 'name' argument must have a non-empty string value!")

if name in self._annotations:
return self._annotations[name]

Expand All @@ -291,6 +316,11 @@ def annotations(self) -> dict[str, object]:
def _canonicalize(self, name: str) -> str:
"""Given a property name, return the canonical version of the property name."""

if not isinstance(name, str):
raise TypeError("The 'name' argument must have a string value!")
elif not len(name := name.strip()) > 0:
raise ValueError("The 'name' argument must have a non-empty string value!")

if name in self._canonical:
if name in self._namespace:
return self._namespace[name] + ":" + self._canonical[name]
Expand Down Expand Up @@ -367,8 +397,8 @@ def _sort(

def properties(
self,
prepend: dict = None,
append: dict = None,
prepend: dict[str, object] = None,
append: dict[str, object] = None,
sorting: list[str] | dict[str, int] = None,
callback: callable = None,
attribute: str = None,
Expand Down Expand Up @@ -419,12 +449,12 @@ def walkthrough(
container = dict(self.properties())

if not isinstance(container, (dict, list)):
raise RuntimeError("The 'container' argument must be a dictionary or list!")
raise TypeError("The 'container' argument must be a dictionary or list!")

if not (
attribute is None or (isinstance(attribute, str) and len(attribute) > 0)
):
raise RuntimeError(
raise ValueError(
"If provided, the 'attribute' parameter must be a non-empty string!"
)

Expand Down
2 changes: 1 addition & 1 deletion source/semanticpy/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.4
1.3.5
Loading
Loading