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.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.
Expand Down
120 changes: 114 additions & 6 deletions README.md

Large diffs are not rendered by default.

120 changes: 92 additions & 28 deletions source/semanticpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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)):
Expand All @@ -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(
Expand Down Expand Up @@ -667,20 +666,39 @@ 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):
raise ValueError(
"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)
)
Expand All @@ -696,15 +714,15 @@ 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!"
)

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):
Expand All @@ -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
Expand All @@ -751,13 +807,17 @@ def __init__(
ident: str = None,
label: str = None,
data: dict[str, object] = None,
extensions: bool = False,
**kwargs,
):
super().__init__(
# TODO: Determine if setting data via the superclass' constructor is optimal
# 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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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!"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions source/semanticpy/types/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ def typed(self) -> str:
return self._type

@property
def context(self) -> str:
def context(self) -> object:
return self._context

@property
Expand Down Expand Up @@ -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())
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.2
1.3.3
2 changes: 1 addition & 1 deletion tests/data/examples/object.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
13 changes: 13 additions & 0 deletions tests/data/examples/saved.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
2 changes: 1 addition & 1 deletion tests/test_record_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
Loading
Loading