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
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.

## [1.2.4] - 2026-02-11
### Added
- Added support for controlling overwrite behaviour for single-value properties.
- Added support for Python 3.14.

## [1.2.3] - 2026-02-03
### Added
- Added support for namespaced identifiers, where one or more prefixes can be registered
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ if [[ "${SERVICE}" == "black" ]]; then
black --check ${ARGS[@]:1} /source /tests;
fi
elif [[ "${SERVICE}" == "flakes" ]]; then
echo -e "pyflakes ${ARGS[@]:1}";
pyflakes ${ARGS[@]:1}
echo -e "pyflakes /source /tests ${ARGS[@]:1}";
pyflakes /source /tests ${ARGS[@]:1}
elif [[ "${SERVICE}" == "tests" ]]; then
echo -e "pytest /tests ${ARGS[@]}";
pytest /tests ${ARGS[@]};
Expand Down
242 changes: 217 additions & 25 deletions README.md

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ services:
context: ./
environment:
- SERVICE=black
command: black --check /source /tests
volumes:
*volumes

Expand All @@ -22,7 +21,6 @@ services:
context: ./
environment:
- SERVICE=flakes
command: pyflakes /source /tests
volumes:
*volumes

Expand All @@ -32,6 +30,5 @@ services:
context: ./
environment:
- SERVICE=tests
command: pytest /tests
volumes:
*volumes
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.10"
dynamic = [
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# SemanticPy Dependencies
requests
requests>=2.32.5
enumerific>=1.1.0
18 changes: 17 additions & 1 deletion source/semanticpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Namespace,
readonlydict,
)
from semanticpy.enumerations import OverwriteMode

logger.debug("semanticpy library imported from: %s" % (__file__))

Expand Down Expand Up @@ -307,6 +308,9 @@ def teardown(cls, globals: dict = None):
if isinstance(glo, dict):
del glo[key]

# Reset the overwrite mode to the default
cls._overwrite = OverwriteMode.Allow

@classmethod
def open(cls, filepath: str) -> Model:
"""Support opening and loading model instances from stored JSON-LD files"""
Expand Down Expand Up @@ -465,7 +469,7 @@ def _validate_properties(cls, properties: dict, property: str) -> dict:
def extend(
cls,
subclass: Model,
properties: dict = None,
properties: dict[str, dict[str, object]] = None,
context: str = None,
globals: dict = None,
typed: bool = True,
Expand Down Expand Up @@ -1203,3 +1207,15 @@ def _nodes(
nodes = temp

return nodes


__all__ = [
# Classes
"Node",
"Namespace",
"Model",
# Enumerations
"OverwriteMode",
# Exceptions
"SemanticPyError",
]
23 changes: 23 additions & 0 deletions source/semanticpy/enumerations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from enumerific import Enumeration, auto


class OverwriteMode(Enumeration):
"""This enumeration defines the overwrite mode behaviours for properties that accept
single values, in terms of how the library should handle value reassignment."""

Allow = auto(
description="Allow single-value properties to have their values be overwritten",
default=True,
)

Warning = auto(
description="Emit a warning when single-value properties are overwritten"
)

Prevent = auto(
description="Prevent single-value properties from being overwritten and emit a warning"
)

Error = auto(
description="Raise an exception when single-value properties are overwritten"
)
4 changes: 0 additions & 4 deletions source/semanticpy/logging.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import logging

logger = logging.getLogger("semanticpy")

logger.addHandler(logging.StreamHandler())

logger.propagate = False
28 changes: 27 additions & 1 deletion source/semanticpy/types/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import json

from semanticpy.logging import logger
from semanticpy.enumerations import OverwriteMode
from semanticpy.errors import SemanticPyError


class Node(object):
Expand All @@ -26,6 +28,15 @@ class Node(object):
"_sorting",
"_annotations",
]
_overwrite: OverwriteMode = OverwriteMode.Allow

@classmethod
def overwrite(cls, mode: OverwriteMode | str):
if isinstance(mode, str):
mode = OverwriteMode.reconcile(name=mode, caseless=True)

if isinstance(mode, OverwriteMode):
cls._overwrite = mode

def __init__(self, data: dict[str, object] = None, **kwargs):
# logger.debug("%s.__init__(data: %s)" % (self.__class__.__name__, data))
Expand Down Expand Up @@ -88,7 +99,22 @@ def __setattr__(self, name: str, value: object):
if name in self._multiple:
self._data[name].append(value)
else:
self._data[name] = value
if self.__class__._overwrite is OverwriteMode.Warning:
logger.warning(
f"The '{self.__class__.__name__}' entity's '{name}' property has already been assigned to '{self._data[name]}', and will be overwritten with the newly provided value: '{value}'!"
)

self._data[name] = value
elif self.__class__._overwrite is OverwriteMode.Prevent:
logger.warning(
f"The '{self.__class__.__name__}' entity's '{name}' property has already been assigned to '{self._data[name]}', and current library configuration, prevents it from being overwritten!"
)
elif self.__class__._overwrite is OverwriteMode.Error:
raise SemanticPyError(
f"The '{self.__class__.__name__}' entity's '{name}' property has already been assigned to '{self._data[name]}', and current library configuration does not allow singular property values to be overwritten!"
)
else:
self._data[name] = value
else:
if name in self._multiple:
self._data[name] = [value]
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.2.3
1.2.4
149 changes: 149 additions & 0 deletions tests/test_overwrite_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from semanticpy import Model, OverwriteMode, SemanticPyError

import logging
import pytest


def test_overwrite_mode_allow():
"""Test the library's singular property value overwrite allowed mode."""

# Set up the model using the Linked.Art profile
Model.factory(profile="linked-art", globals=globals())

# By default the library is in overwrite allowed mode, but demonstrate setting here
Model.overwrite(mode=OverwriteMode.Allow)

# Create a new model instance to test with
identifier = Identifier()

assert isinstance(identifier, Identifier)

# Assign its initial value
identifier.content = "123"

# Ensure that the initial value was set
assert identifier.content == "123"

# Overwrite its initial value; in warning mode this should generate a warning
identifier.content = "456"

# Ensure that the overwritten value was set
assert identifier.content == "456"

# Tear down the model, removing it from the current scope and reset overwrite mode
Model.teardown(globals=globals())


def test_overwrite_mode_warning(caplog):
"""Test the library's singular property value overwrite warning mode."""

caplog.set_level(logging.WARNING, logger="semanticpy")

# Set up the model using the Linked.Art profile
Model.factory(profile="linked-art", globals=globals())

# Set the singular property value overwrite mode
Model.overwrite(mode=OverwriteMode.Warning)

# Create a new model instance to test with
identifier = Identifier()

assert isinstance(identifier, Identifier)

# Assign its initial value
identifier.content = "123"

# Ensure that the initial value was set
assert identifier.content == "123"

# Overwrite its initial value; in warning mode this should generate a warning
identifier.content = "456"

# Ensure that the expected warning message was captured
assert (
"The 'Identifier' entity's 'content' property has already been assigned to '123', and will be overwritten with the newly provided value: '456'!"
in caplog.text.strip()
)

# Ensure that the overwritten value was set
assert identifier.content == "456"

# Tear down the model, removing it from the current scope and reset overwrite mode
Model.teardown(globals=globals())


def test_overwrite_mode_prevent(caplog):
"""Test the library's singular property value overwrite prevention mode."""

caplog.set_level(logging.WARNING, logger="semanticpy")

# Set up the model using the Linked.Art profile
Model.factory(profile="linked-art", globals=globals())

# Set the singular property value overwrite mode
Model.overwrite(mode=OverwriteMode.Prevent)

# Create a new model instance to test with
identifier = Identifier()

assert isinstance(identifier, Identifier)

# Assign its initial value
identifier.content = "123"

# Ensure that the initial value was set
assert identifier.content == "123"

# Attempt to overwrite the initial value
identifier.content = "456"

# Ensure that the expected warning message was captured
assert (
"The 'Identifier' entity's 'content' property has already been assigned to '123', and current library configuration, prevents it from being overwritten!"
in caplog.text.strip()
)

# As we captured the exception above, and as prevent mode
assert identifier.content == "123"

# Tear down the model, removing it from the current scope and reset overwrite mode
Model.teardown(globals=globals())


def test_overwrite_mode_error():
"""Test the library's singular property value overwrite prevention mode."""

# Set up the model using the Linked.Art profile
Model.factory(profile="linked-art", globals=globals())

# Set the singular property value overwrite mode
Model.overwrite(mode=OverwriteMode.Error)

# Create a new model instance to test with
identifier = Identifier()

assert isinstance(identifier, Identifier)

# Assign its initial value
identifier.content = "123"

# Ensure that the initial value was set
assert identifier.content == "123"

# Setup PyTest to capture the expected SemanticPyError exception
with pytest.raises(SemanticPyError) as exception:
# Attempt to overwrite the initial value; per configuration this should error
identifier.content = "456"

# Ensure that the expected exception message was captured
assert (
str(exception)
== "The 'Identifier' entity's 'content' property has already been assigned to '123', and current library configuration, prevents it from being overwritten!"
)

# As we captured the exception above, and as the property was not overwritten, the
# property value should be the same as it was before the overwrite attempt was made
assert identifier.content == "123"

# Tear down the model, removing it from the current scope and reset overwrite mode
Model.teardown(globals=globals())