diff --git a/README.md b/README.md index de4833ea..df18a5dd 100644 --- a/README.md +++ b/README.md @@ -330,3 +330,39 @@ status using the following order of precedence (1 = the highest priority): [pytest]: https://www.pytest.org [pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/ [semver]: https://semver.org/ + +### Version-agnostic Protocol types (Python) + +By default, code written against one generated Python module can't accept +objects from another generated module, even if the two were generated from +compatible versions of the same model. + +Passing `--include-protocols yes` to the Python generator adds a +`protocols.py` module with a [`typing.Protocol`][typing-protocol] for every +class in the model. A Protocol accepts an object from _any_ generated module +whose version is compatible with the one the Protocol came from, so you can +write functions and classes that work across model versions instead of +being tied to one: + +```shell +shacl2code generate -i model.jsonld python --include-protocols yes -o out +``` + +```python +from out import protocols + +def describe(obj: protocols.MyClass) -> str: + return f"{obj.get_type()}: {obj.my_property}" +``` + +`describe()` accepts a `MyClass` instance from `out`, or from any other +generated module whose `MyClass` is compatible with `out`'s. + +A couple of things to keep in mind: + +- Object-reference properties are typed `Any` on a Protocol; read those + through the concrete module when you need a precise type. +- Protocols are for type annotations only -- construct objects using a + concrete generated module, not a Protocol. + +[typing-protocol]: https://docs.python.org/3/library/typing.html#typing.Protocol diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index cad9f41d..13b4a781 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -3,12 +3,17 @@ # SPDX-License-Identifier: MIT """Python language binding renderer""" +import hashlib import keyword import re from pathlib import Path +from typing import Iterable -from .common import JinjaTemplateRender +from jinja2 import TemplateRuntimeError + +from .common import JinjaTemplateRender, prop_is_list from .lang import TEMPLATE_DIR, language +from ..model import Class from ..util import convert_version_string DATATYPE_CLASSES = { @@ -75,6 +80,13 @@ def varname(*name): return name +def prop_shape(prop): + """Classify a property's container shape: (is_list, has_ref, is_enum).""" + is_enum = bool(prop.enum_values) + has_ref = bool(prop.class_id) and not is_enum + return prop_is_list(prop), has_ref, is_enum + + def prop_element_pytype(prop, classes): """Python type of a single element of prop, ignoring container shape. @@ -85,9 +97,60 @@ def prop_element_pytype(prop, classes): return "str" if prop.class_id: return "Union[str, '" + varname(*classes.get(prop.class_id).clsname) + "']" + if prop.datatype not in DATATYPE_PYTHON_TYPES: + # Same error as model.py.j2's abort() + raise TemplateRuntimeError("Unknown data type " + prop.datatype) return DATATYPE_PYTHON_TYPES[prop.datatype] +def protocols_use_datetime(classes: Iterable[Class]) -> bool: + """Whether any class has a datetime-typed scalar or list property.""" + for cls in classes: + for prop in cls.properties: + _, has_ref, is_enum = prop_shape(prop) + if has_ref or is_enum: + continue + if prop_element_pytype(prop, classes) == "datetime": + return True + return False + + +def protocol_discriminator_name(cls: Class) -> str: + """Stable, collision-resistant name for cls's Protocol discriminator method. + + Keyed by the class IRI (not the --context-compacted class name), so it + matches across generations with different --context flags. varname() + alone can sanitize two distinct IRIs to the same string (e.g. IRIs that + differ only in punctuation runs both collapsing to "_"), so a short hash + of the raw IRI is appended to disambiguate while staying stable across + regenerations of the same class. + """ + digest = hashlib.sha256(cls._id.encode("utf-8")).hexdigest()[:8] + return varname(cls._id, digest) + + +def protocols_extra_imports(classes: Iterable[Class]) -> str: + """Conditionally-needed stdlib imports for protocols.py.j2. + + Rendered as a single ``{{ }}`` expression (not a ``{% if %}`` block) so + black can parse the .j2 source as Python. The blank lines black then + requires around that expression separate these imports from the ones + above by more than flake8-import-order allows within one group, so each + line silences that deliberate exception. Always returns a non-blank + line (a comment when there's nothing to import) so the surrounding + black-mandated blank-line groups above and below never merge into one + run long enough to trip flake8's too-many-blank-lines check. + """ + lines = [] + if protocols_use_datetime(classes): + lines.append("from datetime import datetime # noqa: E402, I100, I202") + if any(cls.named_individuals for cls in classes): + lines.append("from typing import ClassVar, Dict # noqa: E402, I100, I202") + if not lines: + lines.append("# No extra imports needed for this model.") + return "\n".join(lines) + + @language("python") class PythonRender(JinjaTemplateRender): """Render Python Language Bindings.""" @@ -103,8 +166,9 @@ class PythonRender(JinjaTemplateRender): def __init__(self, args): super().__init__(args) self.__output = args.output - self.__use_slots = args.use_slots self.__include_main = args.include_main == "yes" + self.__include_protocols = args.include_protocols == "yes" + self.__use_slots = args.use_slots self.__version_str = args.version if args.version: self.__version = repr(convert_version_string(args.version)) @@ -126,6 +190,15 @@ def get_arguments(cls, parser): default="yes", help="Generate a main function for the module. Default is '%(default)s'", ) + parser.add_argument( + "--include-protocols", + choices=("yes", "no"), + default="no", + help=( + "Include a protocols.py module with version-agnostic Protocol " + "types for every class. Default is '%(default)s'" + ), + ) parser.add_argument( "--use-slots", choices=("auto", "yes", "no"), @@ -154,10 +227,16 @@ def get_file(name): yield get_file("cmd.py") yield get_file("__main__.py") + if self.__include_protocols: + yield get_file("protocols.py") + def get_extra_env(self): return { "varname": varname, "prop_element_pytype": prop_element_pytype, + "prop_shape": prop_shape, + "protocol_discriminator_name": protocol_discriminator_name, + "protocols_extra_imports": protocols_extra_imports, "DATATYPE_CLASSES": DATATYPE_CLASSES, "DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES, } @@ -170,8 +249,9 @@ def get_additional_render_args(self, model): else: use_slots = False return { - "use_slots": use_slots, "include_main": self.__include_main, - "version_str": self.__version_str, + "include_protocols": self.__include_protocols, + "use_slots": use_slots, "version": self.__version, + "version_str": self.__version_str, } diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 0af8e7e3..47379bae 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -4,12 +4,75 @@ # # SPDX-License-Identifier: {{ spdx_license }} -from .model import * # noqa: F401, F403 +from __future__ import annotations + +import importlib +import warnings +from types import ModuleType +from typing import Any, Callable, Dict, List, TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from .model import * # noqa: F401, F403 + +# True if any ontology behind this model is pre-release. +IS_PRERELEASE = {{ontologies | selectattr("is_prerelease") | list | length > 0}} + +if IS_PRERELEASE: + # Fires once on first import, regardless of import form. + warnings.warn( + f"{__name__!r} is a pre-release model version and may change without notice.", + FutureWarning, + ) # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +{%- if include_protocols %} +if TYPE_CHECKING: + from . import protocols # noqa: F401, I100, I202 +{%- endif %} + + +_LAZY_SUBMODULES: Dict[str, Callable[[], Any]] = { +{%- if include_protocols %} + "protocols": lambda: importlib.import_module(f"{__name__}.protocols"), +{%- endif %} {%- if include_main %} -from .cmd import main # noqa: F401, I100, I202 + "main": lambda: importlib.import_module(f"{__name__}.cmd").main, {%- endif %} +} + + +def __getattr__(name: str) -> Any: + # PEP 562 lazy access: each branch imports only what it needs. + if name == "__all__": + # Only "import *" needs this; it must load the model to compute it. + mod = importlib.import_module(f"{__name__}.model") + return sorted( + n + for n, o in vars(mod).items() + if not n.startswith("_") + and n != "TYPE_CHECKING" # imported flag, not model content + and not isinstance(o, (TypeVar, ModuleType)) + and ( + getattr(o, "__module__", None) == mod.__name__ + or getattr(o, "__module__", None) is None # plain constants + ) + ) + if name in _LAZY_SUBMODULES: + return _LAZY_SUBMODULES[name]() + mod = importlib.import_module(f"{__name__}.model") + try: + return getattr(mod, name) + except AttributeError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> List[str]: + # Opt-in: model loads only when dir() is actually called. + mod = importlib.import_module(f"{__name__}.model") + names = set(globals()) | set(dir(mod)) | set(_LAZY_SUBMODULES) + return sorted(names) + + {{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" -# fmt on +# fmt: on diff --git a/src/shacl2code/lang/templates/python/cmd.py.j2 b/src/shacl2code/lang/templates/python/cmd.py.j2 index 82b1dd23..eefd440d 100644 --- a/src/shacl2code/lang/templates/python/cmd.py.j2 +++ b/src/shacl2code/lang/templates/python/cmd.py.j2 @@ -4,23 +4,26 @@ # # SPDX-License-Identifier: {{ spdx_license }} +from __future__ import annotations + import argparse from pathlib import Path -from typing import Any, Iterable, List +from typing import Any, Iterable, List, TYPE_CHECKING + +if TYPE_CHECKING: + from .model import SHACLObject -from .model import ( - JSONLDDeserializer, - JSONLDSerializer, - ListProxy, - SHACLObject, - SHACLObjectSet, -) +# NOTE: .model is imported inside each function below, not here, because +# __init__.py can import this module just to fetch "main" (e.g. dir()), +# without calling it -- that must not force the model to load. def print_tree(objects: Iterable[SHACLObject], all_fields: bool = False) -> None: """ Print object tree """ + from .model import ListProxy, SHACLObject + seen = set() def callback(value: Any, path: List[str]) -> bool: @@ -52,6 +55,12 @@ def print_tree(objects: Iterable[SHACLObject], all_fields: bool = False) -> None def main() -> int: + from .model import ( + JSONLDDeserializer, + JSONLDSerializer, + SHACLObjectSet, + ) + parser = argparse.ArgumentParser(description="Python SHACL model test") parser.add_argument("infile", type=Path, help="Input file") parser.add_argument("--print", action="store_true", help="Print object tree") diff --git a/src/shacl2code/lang/templates/python/model.py.j2 b/src/shacl2code/lang/templates/python/model.py.j2 index 17cae1a3..043966d6 100644 --- a/src/shacl2code/lang/templates/python/model.py.j2 +++ b/src/shacl2code/lang/templates/python/model.py.j2 @@ -899,7 +899,7 @@ class SHACLObjectMeta(type): SHACLObject.CLASSES[key] = c -register_lock = threading.Lock() +_register_lock = threading.Lock() _ALL_NAMED_INDIVIDUAL_IDS: Set[str] = set() T_SHACLObject = TypeVar("T_SHACLObject", bound="SHACLObject") @@ -1002,7 +1002,7 @@ class SHACLObject(metaclass=SHACLObjectMeta): if self.ONTOLOGY: _warn_ontology(self.ONTOLOGY) - with register_lock: + with _register_lock: cls = self.__class__ if cls._NEEDS_REG: for p in cls._OBJ_PY_PROPS.values(): @@ -3010,7 +3010,7 @@ CONTEXT_URLS: List[str] = [ ] # ONTOLOGIES -{%- for o in ontologies %} +{% for o in ontologies %} {%- if o.comment %} {{ '"' }}{{ '"' }}{{ '"' }} {%- for l in o.comment.split("\n") %} @@ -3071,11 +3071,18 @@ class {{ varname(*class.clsname) }}( {%- endfor %} } {%- endif %} + {%- if include_protocols %} + + # Discriminator keyed by IRI, not the --context-compacted class name, so + # it matches across generations with different --context flags. + def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None: + pass + {%- endif %} {%- if class.properties %} PROPERTIES: ClassVar[List[ClassProp]] = [ {%- for prop in class.properties %} - {%- set is_list = prop_is_list(prop) %} + {%- set is_list, has_ref, is_enum = prop_shape(prop) %} {%- if prop.comment %} {%- for l in prop.comment.split("\n") %} #{{ (" " + l).rstrip() }} @@ -3085,13 +3092,13 @@ class {{ varname(*class.clsname) }}( "{{ varname(prop.varname) }}", lambda: {% if is_list -%}ListProp({% endif %} - {%- if prop.enum_values -%} + {%- if is_enum -%} EnumProp(( {%- for value in prop.enum_values %} ("{{ value }}", "{{ context.compact_vocab(value, prop.path) }}"), {%- endfor %} )) - {%- elif prop.class_id -%} + {%- elif has_ref -%} {%- set ctx = [] %} {%- for value in get_all_named_individuals(classes.get(prop.class_id)) %} {%- if context.compact_vocab(value, prop.path) != value %} diff --git a/src/shacl2code/lang/templates/python/model.pyi.j2 b/src/shacl2code/lang/templates/python/model.pyi.j2 index 365f41b5..cb6c3a75 100644 --- a/src/shacl2code/lang/templates/python/model.pyi.j2 +++ b/src/shacl2code/lang/templates/python/model.pyi.j2 @@ -464,6 +464,9 @@ class {{ varname(*class.clsname) }}( {%- endif %} **kwargs: Any ) -> None: ... + {%- if include_protocols %} + def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None: ... + {%- endif %} {%- if class.id_property %} {{ class.id_property }}: Optional[str] diff --git a/src/shacl2code/lang/templates/python/protocols.py.j2 b/src/shacl2code/lang/templates/python/protocols.py.j2 new file mode 100644 index 00000000..a3fc07d2 --- /dev/null +++ b/src/shacl2code/lang/templates/python/protocols.py.j2 @@ -0,0 +1,105 @@ +# {{ disclaimer }} +# {%- import "_macros.j2" as pymacros %} +# SPDX-License-Identifier: {{ spdx_license }} +"""Version-agnostic Protocol types, one per generated class. + +Satisfied by the corresponding concrete class of any model version +backward-compatible with the baseline these Protocols were generated from. +Use them to write functions and classes that work across model versions. + +Scalar and list-of-scalar properties are typed precisely. Object-reference +properties are typed ``Any``: structural typing can't resolve the circular +reference to the sibling protocol type they'd otherwise return. Read them +through the concrete version type when a typed value is needed. + +Construct objects with a concrete version module; use these Protocols only +for annotations. +""" + +from __future__ import annotations + +from typing import Any, Iterable, Iterator, Optional, Protocol, Set, Tuple + +{{protocols_extra_imports(classes)}} + + +class SHACLObjectProtocol(Protocol): + """Version-agnostic view of the SHACLObject machinery base.""" + + def get_id(self) -> Optional[str]: ... + def set_id(self, value: Optional[str]) -> None: ... + def get_type(self) -> str: ... + def get_compact_type(self) -> Optional[str]: ... + def property_keys(self) -> Iterator[Tuple[Optional[str], str, Optional[str]]]: ... + def __getitem__(self, iri: str) -> Any: ... + def __setitem__(self, iri: str, value: Any) -> None: ... + + +class SHACLObjectSetProtocol(Protocol): + """Version-agnostic view of the SHACLObjectSet collection. + + Raw index attributes (``objects``, ``obj_by_id``, etc.) and version-coupled + methods (``encode``/``decode``, ``merge``) are omitted; use a concrete + version type for those. + """ + + def foreach(self) -> Iterable[SHACLObjectProtocol]: ... + + def foreach_type( + self, typ: str, *, match_subclass: bool = True + ) -> Iterable[SHACLObjectProtocol]: ... + + def find_by_id( + self, _id: str, default: Any = None + ) -> Optional[SHACLObjectProtocol]: ... + + def link(self) -> Set[str]: ... + def add(self, obj: Any) -> Any: ... + def remove(self, obj: Any) -> None: ... + def update(self, *others: Iterable[Any]) -> None: ... + def __contains__(self, item: Any) -> bool: ... + + +# fmt: off +"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +# DOMAIN CLASSES +{% for class in classes %} + +class {{ varname(*class.clsname) }}( +{%- if class.parent_ids %} + {%- for id in class.parent_ids -%} + {{- varname(*classes.get(id).clsname) }}{%- if not loop.last -%}, {% endif -%} + {%- endfor -%} +{%- else -%} + SHACLObjectProtocol +{%- endif -%} +, Protocol): +{%- if class.comment %}{{ pymacros.class_docstring(class.comment) }}{% endif %} + + def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None: ... + {%- if class.id_property %} + {{ class.id_property }}: Optional[str] + {%- endif %} + {%- if class.named_individuals %} + NAMED_INDIVIDUALS: ClassVar[Dict[str, str]] + {%- for member in class.named_individuals %} + {{ varname(member.varname) }}: str + {%- endfor %} + {%- endif %} + {%- for prop in class.properties %} + {%- set is_list, has_ref, is_enum = prop_shape(prop) %} + {%- if not has_ref %} + {%- set ptype = prop_element_pytype(prop, classes) %} + {%- endif %} + {%- if has_ref %} + {{ varname(prop.varname) }}: Any + {%- elif is_list %} + {{ varname(prop.varname) }}: Iterable[{{ ptype }}] + {%- else %} + {{ varname(prop.varname) }}: Optional[{{ ptype }}] + {%- endif %} + {%- endfor %} +{% endfor %} + +{{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" +# fmt: on diff --git a/tests/data/model/test-v2.ttl b/tests/data/model/test-v2.ttl new file mode 100644 index 00000000..12f05a41 --- /dev/null +++ b/tests/data/model/test-v2.ttl @@ -0,0 +1,624 @@ +# Backward-compatible extension of test.ttl for cross-version Protocol tests. +# Adds: parent-class/v2-new-prop (new optional scalar) and test-another-class +# (same property set as test-class, for discriminator testing). +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . +@prefix sh-to-code: . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Derived class that sorts before the parent to test ordering" + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "The parent class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "The test class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path + ], + [ + sh:path + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:name "named_property" ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:positiveInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:nonNegativeInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:integer ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:anyURI ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:boolean ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:decimal ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:class ; + sh:path ; + sh:in ( + + + + + ) + ], + [ + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + sh:maxCount 1 + + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$" + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$" + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:datatype xsd:string + ], + [ + sh:path ; + sh:name "split" ; + sh:maxCount 1 + ] + . + + a owl:NamedIndividual, ; + rdfs:label "A named individual of the test class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 2 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "Another class" + . + + a sh:NodeShape, owl:DeprecatedClass ; + rdfs:subClassOf ; + rdfs:comment "A deprecated class" ; + sh:property [ + sh:datatype: xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a owl:DeprecatedProperty ; + rdfs:comment "A deprecated property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string list property" ; + . + + a rdf:Property ; + rdfs:comment "A string list property with no sh:datatype" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar string propery" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required scalar string property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required string list property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A named property"; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar datetime property"; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A datetime list property" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A scalar dateTimeStamp property"; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A positive integer" ; + rdfs:range xsd:positiveInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:nonNegativeInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:integer + . + + a rdf:Property ; + rdfs:comment "a URI" ; + rdfs:range xsd:anyURI + . + + a rdf:Property ; + rdfs:comment "a boolean property" ; + rdfs:range xsd:boolean + . + + a rdf:Property ; + rdfs:comment "a float property" ; + rdfs:range xsd:decimal + . + + a rdf:Property ; + rdfs:comment "A test-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A regex validated string" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex validated string list" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A split string property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that is a keyword" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that conflicts with an existing SHACLObject property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex dateTime" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A regex dateTimeStamp" ; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A class with no shape" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A string property in a derived class" ; + rdfs:range xsd:string + . + + a owl:Class ; + rdfs:comment "A class that is not a nodeshape" + . + + a owl:Class ; + rdfs:comment "An enumerated type" + . + + a owl:NamedIndividual, ; + rdfs:label "foo" ; + rdfs:comment "The foo value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:label "bar" ; + rdfs:comment "The bar value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:comment "This value has no label" + . + + a ; + rdfs:comment "This value is not a named individual and won't appear in the output" + . + +# Classes to test links + + a sh:NodeShape, owl:Class ; + rdfs:comment "A class to test links" ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ] + . + +# Note: link-derived-class and link-derived-2-class should both have no +# properties to test an edge case in the go bindings + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a rdf:Property ; + rdfs:comment "A link-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link to an extensible-class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "Tag used to identify object for testing" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A link to a derived class" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with an ID alias" ; + sh-to-code:idPropertyName "testid" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that inherits its idPropertyName from the parent" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be a blank node" ; + sh:nodeKind sh:BlankNode + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be an IRI" ; + sh:nodeKind sh:IRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that can be either a blank node or an IRI" ; + sh:nodeKind sh:BlankNodeOrIRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that derives its nodeKind from parent" ; + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh-to-code:isExtensible true ; + rdfs:comment "An extensible class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 0 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "An extensible property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required extensible property" ; + rdfs:range xsd:string + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "An Abstract class" ; + sh-to-code:isAbstract true + . + + a rdf:Class, sh:NodeShape, owl:Class, ; + rdfs:comment "An Abstract class using the SPDX type" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment: "An Abstract class using SHACL validation" ; + sh:property [ + sh:path rdf:type ; + sh:not [ sh:hasValue ] + ] . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with a mandatory abstract class" ; + sh:property [ + sh:class ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A required abstract class property" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + sh-to-code:isExtensible true ; + sh-to-code:isAbstract true ; + rdfs:comment "An extensible abstract class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class that uses an abstract extensible class" ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A property that references and abstract extensible class" ; + rdfs:range + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "Another class with the same own property set as test-class (for discriminator testing)" ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . diff --git a/tests/data/model/test-v3.ttl b/tests/data/model/test-v3.ttl new file mode 100644 index 00000000..31f3e0ad --- /dev/null +++ b/tests/data/model/test-v3.ttl @@ -0,0 +1,657 @@ +# Backward-compatible extension of test-v2.ttl for cross-version Protocol tests. +# Adds (on top of v2's parent-class/v2-new-prop and test-another-class): +# - test-class/v3-new-prop: new optional scalar on test-class. +# - test-derived-class-v3: new class, one level deeper than test-derived-class. +# - enumType/baz: new named individual (enum value growth). +# - test-class/split-string-prop marked owl:DeprecatedProperty (still present). +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . +@prefix sh-to-code: . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Derived class that sorts before the parent to test ordering" + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "The parent class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "The test class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path + ], + [ + sh:path + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:name "named_property" ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:positiveInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:nonNegativeInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:integer ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:anyURI ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:boolean ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:decimal ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:class ; + sh:path ; + sh:in ( + + + + + ) + ], + [ + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + sh:maxCount 1 + + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$" + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$" + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:datatype xsd:string + ], + [ + sh:path ; + sh:name "split" ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a owl:NamedIndividual, ; + rdfs:label "A named individual of the test class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 2 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-derived-class, added in v3" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "Another class" + . + + a sh:NodeShape, owl:DeprecatedClass ; + rdfs:subClassOf ; + rdfs:comment "A deprecated class" ; + sh:property [ + sh:datatype: xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a owl:DeprecatedProperty ; + rdfs:comment "A deprecated property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string list property" ; + . + + a rdf:Property ; + rdfs:comment "A string list property with no sh:datatype" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar string propery" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required scalar string property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required string list property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A named property"; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar datetime property"; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A datetime list property" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A scalar dateTimeStamp property"; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A positive integer" ; + rdfs:range xsd:positiveInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:nonNegativeInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:integer + . + + a rdf:Property ; + rdfs:comment "a URI" ; + rdfs:range xsd:anyURI + . + + a rdf:Property ; + rdfs:comment "a boolean property" ; + rdfs:range xsd:boolean + . + + a rdf:Property ; + rdfs:comment "a float property" ; + rdfs:range xsd:decimal + . + + a rdf:Property ; + rdfs:comment "A test-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A regex validated string" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex validated string list" ; + rdfs:range xsd:string + . + + a rdf:Property, owl:DeprecatedProperty ; + rdfs:comment "A split string property, deprecated in v3" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A new optional scalar property, added in v3" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that is a keyword" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that conflicts with an existing SHACLObject property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex dateTime" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A regex dateTimeStamp" ; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A class with no shape" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A string property in a derived class" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string property in test-derived-class-v3" ; + rdfs:range xsd:string + . + + a owl:Class ; + rdfs:comment "A class that is not a nodeshape" + . + + a owl:Class ; + rdfs:comment "An enumerated type" + . + + a owl:NamedIndividual, ; + rdfs:label "foo" ; + rdfs:comment "The foo value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:label "bar" ; + rdfs:comment "The bar value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:comment "This value has no label" + . + + a owl:NamedIndividual, ; + rdfs:label "baz" ; + rdfs:comment "The baz value of enumType, added in v3" + . + + a ; + rdfs:comment "This value is not a named individual and won't appear in the output" + . + +# Classes to test links + + a sh:NodeShape, owl:Class ; + rdfs:comment "A class to test links" ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ] + . + +# Note: link-derived-class and link-derived-2-class should both have no +# properties to test an edge case in the go bindings + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a rdf:Property ; + rdfs:comment "A link-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link to an extensible-class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "Tag used to identify object for testing" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A link to a derived class" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with an ID alias" ; + sh-to-code:idPropertyName "testid" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that inherits its idPropertyName from the parent" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be a blank node" ; + sh:nodeKind sh:BlankNode + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be an IRI" ; + sh:nodeKind sh:IRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that can be either a blank node or an IRI" ; + sh:nodeKind sh:BlankNodeOrIRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that derives its nodeKind from parent" ; + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh-to-code:isExtensible true ; + rdfs:comment "An extensible class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 0 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "An extensible property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required extensible property" ; + rdfs:range xsd:string + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "An Abstract class" ; + sh-to-code:isAbstract true + . + + a rdf:Class, sh:NodeShape, owl:Class, ; + rdfs:comment "An Abstract class using the SPDX type" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment: "An Abstract class using SHACL validation" ; + sh:property [ + sh:path rdf:type ; + sh:not [ sh:hasValue ] + ] . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with a mandatory abstract class" ; + sh:property [ + sh:class ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A required abstract class property" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + sh-to-code:isExtensible true ; + sh-to-code:isAbstract true ; + rdfs:comment "An extensible abstract class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class that uses an abstract extensible class" ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A property that references and abstract extensible class" ; + rdfs:range + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "Another class with the same own property set as test-class (for discriminator testing)" ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . diff --git a/tests/data/model/test-v4.ttl b/tests/data/model/test-v4.ttl new file mode 100644 index 00000000..42fde60f --- /dev/null +++ b/tests/data/model/test-v4.ttl @@ -0,0 +1,688 @@ +# Backward-compatible extension of test-v3.ttl for cross-version Protocol tests. +# Adds (on top of v3's test-class/v3-new-prop, test-derived-class-v3, +# enumType/baz, and the deprecated split-string-prop): +# - test-class/v4-new-prop: new optional scalar on test-class. +# - test-derived-class-v4: new class, one level deeper than test-derived-class-v3. +# - enumType/qux: new named individual (enum value growth). +# - test-another-class marked owl:DeprecatedClass (still present, still usable). +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . +@prefix sh-to-code: . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Derived class that sorts before the parent to test ordering" + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "The parent class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "The test class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path + ], + [ + sh:path + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:name "named_property" ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:positiveInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:nonNegativeInteger ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:integer ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:anyURI ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:boolean ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:decimal ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:class ; + sh:path ; + sh:in ( + + + + + ) + ], + [ + sh:path ; + sh:maxCount 1 ; + sh:in ( + + + + + ) + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + sh:maxCount 1 + + ], + [ + sh:datatype xsd:string ; + sh:pattern "^foo\\d" ; + sh:path ; + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:dateTime ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$" + ], + [ + sh:datatype xsd:dateTimeStamp ; + sh:path ; + sh:maxCount 1 ; + sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$" + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:datatype xsd:string + ], + [ + sh:path ; + sh:name "split" ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a owl:NamedIndividual, ; + rdfs:label "A named individual of the test class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 2 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-derived-class, added in v3" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from test-derived-class-v3, added in v4" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a sh:NodeShape, owl:Class ; + rdfs:comment "Another class" + . + + a sh:NodeShape, owl:DeprecatedClass ; + rdfs:subClassOf ; + rdfs:comment "A deprecated class" ; + sh:property [ + sh:datatype: xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . + + a owl:DeprecatedProperty ; + rdfs:comment "A deprecated property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string list property" ; + . + + a rdf:Property ; + rdfs:comment "A string list property with no sh:datatype" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar string propery" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required scalar string property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required string list property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A named property"; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A scalar datetime property"; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A datetime list property" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A scalar dateTimeStamp property"; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A positive integer" ; + rdfs:range xsd:positiveInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:nonNegativeInteger + . + + a rdf:Property ; + rdfs:comment "a non-negative integer" ; + rdfs:range xsd:integer + . + + a rdf:Property ; + rdfs:comment "a URI" ; + rdfs:range xsd:anyURI + . + + a rdf:Property ; + rdfs:comment "a boolean property" ; + rdfs:range xsd:boolean + . + + a rdf:Property ; + rdfs:comment "a float property" ; + rdfs:range xsd:decimal + . + + a rdf:Property ; + rdfs:comment "A test-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A test-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A enum property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A regex validated string" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex validated string list" ; + rdfs:range xsd:string + . + + a rdf:Property, owl:DeprecatedProperty ; + rdfs:comment "A split string property, deprecated in v3" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A new optional scalar property, added in v3" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A new optional scalar property, added in v4" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that is a keyword" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A property that conflicts with an existing SHACLObject property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A regex dateTime" ; + rdfs:range xsd:dateTime + . + + a rdf:Property ; + rdfs:comment "A regex dateTimeStamp" ; + rdfs:range xsd:dateTimeStamp + . + + a rdf:Property ; + rdfs:comment "A class with no shape" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A string property in a derived class" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string property in test-derived-class-v3" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A string property in test-derived-class-v4" ; + rdfs:range xsd:string + . + + a owl:Class ; + rdfs:comment "A class that is not a nodeshape" + . + + a owl:Class ; + rdfs:comment "An enumerated type" + . + + a owl:NamedIndividual, ; + rdfs:label "foo" ; + rdfs:comment "The foo value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:label "bar" ; + rdfs:comment "The bar value of enumType" + . + + a owl:NamedIndividual, ; + rdfs:comment "This value has no label" + . + + a owl:NamedIndividual, ; + rdfs:label "baz" ; + rdfs:comment "The baz value of enumType, added in v3" + . + + a owl:NamedIndividual, ; + rdfs:label "qux" ; + rdfs:comment "The qux value of enumType, added in v4" + . + + a ; + rdfs:comment "This value is not a named individual and won't appear in the output" + . + +# Classes to test links + + a sh:NodeShape, owl:Class ; + rdfs:comment "A class to test links" ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + ], + [ + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:class ; + sh:path ; + sh:maxCount 1 + ] + . + +# Note: link-derived-class and link-derived-2-class should both have no +# properties to test an edge case in the go bindings + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class derived from link-class" + . + + a rdf:Property ; + rdfs:comment "A link-class property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class property with no sh:class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link-class list property" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "A link to an extensible-class" ; + rdfs:range + . + + a rdf:Property ; + rdfs:comment "Tag used to identify object for testing" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A link to a derived class" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with an ID alias" ; + sh-to-code:idPropertyName "testid" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that inherits its idPropertyName from the parent" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be a blank node" ; + sh:nodeKind sh:BlankNode + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that must be an IRI" ; + sh:nodeKind sh:IRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that can be either a blank node or an IRI" ; + sh:nodeKind sh:BlankNodeOrIRI + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A class that derives its nodeKind from parent" ; + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + sh-to-code:isExtensible true ; + rdfs:comment "An extensible class" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 0 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 ; + sh:minCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "An extensible property" ; + rdfs:range xsd:string + . + + a rdf:Property ; + rdfs:comment "A required extensible property" ; + rdfs:range xsd:string + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "An Abstract class" ; + sh-to-code:isAbstract true + . + + a rdf:Class, sh:NodeShape, owl:Class, ; + rdfs:comment "An Abstract class using the SPDX type" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment: "An Abstract class using SHACL validation" ; + sh:property [ + sh:path rdf:type ; + sh:not [ sh:hasValue ] + ] . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A concrete class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with a mandatory abstract class" ; + sh:property [ + sh:class ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A required abstract class property" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + sh-to-code:isExtensible true ; + sh-to-code:isAbstract true ; + rdfs:comment "An extensible abstract class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class that uses an abstract extensible class" ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A property that references and abstract extensible class" ; + rdfs:range + . + + a sh:NodeShape, owl:Class, owl:DeprecatedClass ; + rdfs:comment "Another class with the same own property set as test-class (for discriminator testing). Marked deprecated in v4." ; + sh:property [ + sh:class ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . diff --git a/tests/data/no-datetime.ttl b/tests/data/no-datetime.ttl new file mode 100644 index 00000000..cd54746c --- /dev/null +++ b/tests/data/no-datetime.ttl @@ -0,0 +1,19 @@ +@base . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . + +# A minimal model with no datetime-typed property. +# Used to verify that protocols.py.j2 omits `from datetime import datetime` +# for models like this one, instead of emitting it unconditionally and +# leaving an unused import (flake8 F401). + + a rdfs:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with only a string property, no datetime types" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . diff --git a/tests/data/prerelease.ttl b/tests/data/prerelease.ttl new file mode 100644 index 00000000..0251f822 --- /dev/null +++ b/tests/data/prerelease.ttl @@ -0,0 +1,25 @@ +@base . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . +@prefix sh-to-code: . + +# A minimal model whose ontology is marked pre-release. +# Used to verify IS_PRERELEASE = True is generated for a version whose +# model TTL carries sh-to-code:isPreRelease true. + + a owl:Ontology ; + rdfs:comment "A pre-release test ontology" ; + rdfs:label "prerelease-test" ; + sh-to-code:isPreRelease true + . + + a rdfs:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class in a pre-release ontology" ; + sh:property [ + sh:datatype xsd:string ; + sh:path ; + sh:maxCount 1 + ] + . diff --git a/tests/test_python.py b/tests/test_python.py index 5a421db1..4285a21b 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -65,12 +65,7 @@ def shacl2code_generate(args, python_args, outfile): @pytest.fixture(scope="module") -def model_context_url(model_server): - yield model_server + "/test-context.json" - - -@pytest.fixture(scope="module") -def python_model(tmp_path_factory, model_context_url): +def python_model(tmp_path_factory, test_context_url): tmp_directory = tmp_path_factory.mktemp("pythontestcontext") module_name = "pymodel" output_dir = tmp_directory / module_name @@ -79,7 +74,7 @@ def python_model(tmp_path_factory, model_context_url): "--input", TEST_MODEL, "--context", - model_context_url, + test_context_url, "--jss-signature", "signatures", ], @@ -92,13 +87,19 @@ def python_model(tmp_path_factory, model_context_url): yield tmp_directory, module_name -@pytest.fixture -def python_model_env(python_model): - module_path, module_name = python_model +def _env_with_pythonpath(*paths: Path) -> "dict[str, str]": + """A copy of the current environment with `paths` appended to PYTHONPATH.""" env = os.environ.copy() env["PYTHONPATH"] = os.pathsep.join( - env.get("PYTHONPATH", "").split(os.pathsep) + [str(module_path)] + env.get("PYTHONPATH", "").split(os.pathsep) + [str(p) for p in paths] ) + return env + + +@pytest.fixture +def python_model_env(python_model): + module_path, module_name = python_model + env = _env_with_pythonpath(module_path) return env, module_name @@ -2121,7 +2122,7 @@ def test_varname_reserved_words(tmp_path): del sys.modules[mod] -def test_extensible_properties(model, model_context_url): +def test_extensible_properties(model, test_context_url): class Extension(model.extensible_class): TYPE = "http://example.org/shacl2code-test/extension" @@ -2136,7 +2137,7 @@ class Extension(model.extensible_class): DATA = { "@context": [ - model_context_url, + test_context_url, { "prefix": "http://example.org/shacl2code-test/", }, @@ -2169,7 +2170,7 @@ class Extension(model.extensible_class): assert s.serialize_data(objset, True) == DATA -def test_custom_objset_index(model, model_context_url): +def test_custom_objset_index(model, test_context_url): # Creates a derived objectset that indexes objects based on a property class ObjectSet(model.SHACLObjectSet): def create_index(self): @@ -2185,7 +2186,7 @@ def add_index(self, obj): d = model.JSONLDDeserializer() d.deserialize_data( { - "@context": model_context_url, + "@context": test_context_url, "@graph": [ { "@type": "test-class", @@ -2394,7 +2395,7 @@ def test_prerelease_warning(model): model.test_class() -def test_pre_release_cli_option(tmp_path_factory, model_context_url): +def test_pre_release_cli_option(tmp_path_factory, test_context_url): tmp_directory = tmp_path_factory.mktemp("prerelease_test") module_name = "pymodel_prerelease" output_dir = tmp_directory / module_name @@ -2403,7 +2404,7 @@ def test_pre_release_cli_option(tmp_path_factory, model_context_url): "--input", str(TEST_MODEL), "--context", - model_context_url, + test_context_url, "--pre-release", ], [ @@ -2419,9 +2420,12 @@ def test_pre_release_cli_option(tmp_path_factory, model_context_url): assert m.SHACL2CODE_TEST.is_prerelease is True finally: sys.path.remove(str(tmp_directory)) + for mod in list(sys.modules): + if mod == module_name or mod.startswith(module_name + "."): + del sys.modules[mod] -def test_no_pre_release_cli_option(tmp_path, model_context_url): +def test_no_pre_release_cli_option(tmp_path, test_context_url): ttl_content = """ @base . @prefix rdf: . @@ -2445,7 +2449,7 @@ def test_no_pre_release_cli_option(tmp_path, model_context_url): "--input", str(ttl_file), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2468,7 +2472,7 @@ def test_no_pre_release_cli_option(tmp_path, model_context_url): "--input", str(ttl_file), "--context", - model_context_url, + test_context_url, "--no-pre-release", ], [ @@ -2486,7 +2490,7 @@ def test_no_pre_release_cli_option(tmp_path, model_context_url): sys.path.remove(str(tmp_path)) -def test_pre_release_annotations_cases(tmp_path, model_context_url): +def test_pre_release_annotations_cases(tmp_path, test_context_url): # The number in the comment indicates the precedence of the annotation. # 1 is the highest precedence (force by command line option) cases = [ @@ -2559,7 +2563,7 @@ def test_pre_release_annotations_cases(tmp_path, model_context_url): "--input", str(ttl_file), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2578,7 +2582,7 @@ def test_pre_release_annotations_cases(tmp_path, model_context_url): sys.path.remove(str(tmp_path)) -def test_pre_release_precedence(tmp_path, model_context_url): +def test_pre_release_precedence(tmp_path, test_context_url): # Example 1: # 2) sh-to-code:isPreRelease false (False) # 3) adms:status EU SEMIC DEVELOP (True) @@ -2606,7 +2610,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): "--input", str(ttl_file_1), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2649,7 +2653,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): "--input", str(ttl_file_2), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2692,7 +2696,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): "--input", str(ttl_file_3), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2735,7 +2739,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): "--input", str(ttl_file_4), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2778,7 +2782,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): "--input", str(ttl_file_5), "--context", - model_context_url, + test_context_url, ], [ "--version", @@ -2795,7 +2799,7 @@ def test_pre_release_precedence(tmp_path, model_context_url): sys.path.remove(str(tmp_path)) -def test_pre_release_multi_valued_annotations(tmp_path, model_context_url): +def test_pre_release_multi_valued_annotations(tmp_path, test_context_url): # Each of these predicates can legally repeat. # A stable-looking value listed first must not hide # a pre-release-indicating value listed after it. @@ -2869,7 +2873,7 @@ def test_pre_release_multi_valued_annotations(tmp_path, model_context_url): "--input", str(ttl_file), "--context", - model_context_url, + test_context_url, ], [ "--version", diff --git a/tests/test_python_lazy_loading.py b/tests/test_python_lazy_loading.py new file mode 100644 index 00000000..30e3bfe8 --- /dev/null +++ b/tests/test_python_lazy_loading.py @@ -0,0 +1,125 @@ +# SPDX-FileContributor: Arthit Suriyawongkul +# SPDX-FileCopyrightText: 2026 Joshua Watt +# SPDX-FileType: SOURCE +# SPDX-License-Identifier: MIT + +import importlib +import subprocess +import sys +from pathlib import Path + +THIS_FILE = Path(__file__) +THIS_DIR = THIS_FILE.parent + +DATA_DIR = THIS_DIR / "data" + +TEST_MODEL = THIS_DIR / "data" / "model" / "test.ttl" + + +def shacl2code_generate(args, python_args, outfile): + p = subprocess.run( + [ + "shacl2code", + "generate", + ] + + args + + ["python"] + + python_args + + [ + "--output", + outfile, + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + # Add a py.typed file for type checking + (outfile / "py.typed").touch() + return p + + +class TestModelAll: + def test_wildcard_import_is_eager_and_matches_public_names( + self, tmp_path: Path + ) -> None: + """``from mypkg import *`` yields the model's public names + and requires loading the model. + + Generated without --context, so the domain class asserted below + keeps its recognizable "http_..." varname. + """ + module_name = "pymodel_star_check" + output_dir = tmp_path / module_name + shacl2code_generate( + ["--input", TEST_MODEL], + [], + output_dir, + ) + + sys.path.insert(0, str(tmp_path)) + try: + import sys as _sys + + before = set(_sys.modules) + ns: dict = {} + exec(f"from {module_name} import *", ns) + imported = {k for k in ns if not k.startswith("__")} + + # Domain classes, from the test fixture model. + assert "http_example_org_shacl2code_test_test_class" in imported + assert "http_example_org_shacl2code_test_parent_class" in imported + + # Generator infrastructure: constants, base/encoder/decoder classes. + assert "CONTEXT_URLS" in imported + assert "SHACLObject" in imported + assert "SHACLObjectSet" in imported + assert "JSONLDDecoder" in imported + assert "JSONLDEncoder" in imported + # rdflib is a test dependency, so the RDF* classes are defined and + # expected to be included. + assert "RDFSerializer" in imported + + # Must not leak model.py's imports or internal bookkeeping state. + assert not imported & { + "TYPE_CHECKING", + "Any", + "List", + "TypeVar", + "json", + "_ALL_NAMED_INDIVIDUAL_IDS", + "_register_lock", + } + + # The model was loaded as a side effect of the wildcard import. + assert f"{module_name}.model" in (set(_sys.modules) - before) + finally: + sys.path.remove(str(tmp_path)) + for m in list(sys.modules): + if m == module_name or m.startswith(module_name + "."): + del sys.modules[m] + + def test_protocols_submodule_import_stays_lazy( + self, tmp_path: Path, test_context_url: str + ) -> None: + """Importing the ``protocols`` submodule must not load ``model``.""" + module_name = "pymodel_lazy_check" + output_dir = tmp_path / module_name + shacl2code_generate( + ["--input", TEST_MODEL, "--context", test_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + + sys.path.insert(0, str(tmp_path)) + try: + import sys as _sys + + before = set(_sys.modules) + importlib.import_module(f"{module_name}.protocols") + assert f"{module_name}.model" not in (set(_sys.modules) - before) + finally: + sys.path.remove(str(tmp_path)) + for m in list(sys.modules): + if m == module_name or m.startswith(module_name + "."): + del sys.modules[m] diff --git a/tests/test_python_prerelease.py b/tests/test_python_prerelease.py new file mode 100644 index 00000000..6de2538c --- /dev/null +++ b/tests/test_python_prerelease.py @@ -0,0 +1,101 @@ +# SPDX-FileContributor: Arthit Suriyawongkul +# SPDX-FileCopyrightText: 2026 Joshua Watt +# SPDX-FileType: SOURCE +# SPDX-License-Identifier: MIT + +import importlib +import subprocess +import sys +import warnings +from pathlib import Path + +import pytest + +THIS_FILE = Path(__file__) +THIS_DIR = THIS_FILE.parent + +DATA_DIR = THIS_DIR / "data" + +TEST_MODEL = THIS_DIR / "data" / "model" / "test.ttl" + + +def shacl2code_generate(args, python_args, outfile): + p = subprocess.run( + [ + "shacl2code", + "generate", + ] + + args + + ["python"] + + python_args + + [ + "--output", + outfile, + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + # Add a py.typed file for type checking + (outfile / "py.typed").touch() + return p + + +PRERELEASE_MODEL = DATA_DIR / "prerelease.ttl" + + +@pytest.fixture(scope="module") +def prerelease_and_stable_modules(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate a pre-release and a stable module once, shared by both tests below.""" + tmp_directory = tmp_path_factory.mktemp("prerelease") + shacl2code_generate( + ["--input", PRERELEASE_MODEL], [], tmp_directory / "pymodel_prerelease" + ) + shacl2code_generate(["--input", TEST_MODEL], [], tmp_directory / "pymodel_stable") + return tmp_directory + + +def test_is_prerelease_constant(prerelease_and_stable_modules: Path) -> None: + """IS_PRERELEASE reflects sh-to-code:isPreRelease without loading model.py.""" + prerelease_dir = prerelease_and_stable_modules / "pymodel_prerelease" + stable_dir = prerelease_and_stable_modules / "pymodel_stable" + + assert "IS_PRERELEASE = True" in (prerelease_dir / "__init__.py").read_text() + assert "IS_PRERELEASE = False" in (stable_dir / "__init__.py").read_text() + + sys.path.insert(0, str(prerelease_and_stable_modules)) + try: + with pytest.warns(FutureWarning): + pkg = importlib.import_module("pymodel_prerelease") + assert pkg.IS_PRERELEASE is True + # Reading the constant must not have loaded model.py. + assert "pymodel_prerelease.model" not in sys.modules + finally: + sys.path.remove(str(prerelease_and_stable_modules)) + for m in list(sys.modules): + if m == "pymodel_prerelease" or m.startswith("pymodel_prerelease."): + del sys.modules[m] + + +def test_prerelease_import_warning(prerelease_and_stable_modules: Path) -> None: + """Pre-release package warns FutureWarning on first import, any form; stable doesn't.""" + sys.path.insert(0, str(prerelease_and_stable_modules)) + try: + with pytest.warns(FutureWarning): + import pymodel_prerelease # noqa: F401 + + # Second import of an already-loaded module must not re-warn. + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + importlib.import_module("pymodel_prerelease") + + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + import pymodel_stable # noqa: F401 + finally: + sys.path.remove(str(prerelease_and_stable_modules)) + for prefix in ("pymodel_prerelease", "pymodel_stable"): + for m in list(sys.modules): + if m == prefix or m.startswith(prefix + "."): + del sys.modules[m] diff --git a/tests/test_python_protocols.py b/tests/test_python_protocols.py new file mode 100644 index 00000000..83a4c50b --- /dev/null +++ b/tests/test_python_protocols.py @@ -0,0 +1,546 @@ +# SPDX-FileContributor: Arthit Suriyawongkul +# SPDX-FileCopyrightText: 2026 Joshua Watt +# SPDX-FileType: SOURCE +# SPDX-License-Identifier: MIT + +import importlib +import os +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Iterable, Tuple + +from jinja2 import TemplateRuntimeError + +import pytest + +import rdflib + +from shacl2code.lang.python import protocols_use_datetime +from shacl2code.model import Class, Model, Property +from shacl2code.urlcontext import UrlContext + +THIS_FILE = Path(__file__) +THIS_DIR = THIS_FILE.parent +TOP_DIR = THIS_DIR.parent + +DATA_DIR = THIS_DIR / "data" + +TEST_MODEL = THIS_DIR / "data" / "model" / "test.ttl" + +PRERELEASE_MODEL = DATA_DIR / "prerelease.ttl" + + +def shacl2code_generate(args, python_args, outfile): + p = subprocess.run( + [ + "shacl2code", + "generate", + ] + + args + + ["python"] + + python_args + + [ + "--output", + outfile, + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + # Add a py.typed file for type checking + (outfile / "py.typed").touch() + return p + + +def _env_with_pythonpath(*paths: Path) -> "dict[str, str]": + """A copy of the current environment with `paths` appended to PYTHONPATH.""" + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + env.get("PYTHONPATH", "").split(os.pathsep) + [str(p) for p in paths] + ) + return env + + +TEST_V2_MODEL = THIS_DIR / "data" / "model" / "test-v2.ttl" +TEST_V3_MODEL = THIS_DIR / "data" / "model" / "test-v3.ttl" +TEST_V4_MODEL = THIS_DIR / "data" / "model" / "test-v4.ttl" +NO_DATETIME_MODEL = DATA_DIR / "no-datetime.ttl" + + +def _load_classes(ttl_path: Path) -> Iterable[Class]: + """Parse a .ttl file in-process into Model.classes (no --context needed).""" + graph = rdflib.Graph() + graph.parse(ttl_path) + return Model(graph, UrlContext()).classes + + +class TestProtocolsUseDatetime: + """ + Direct, in-process unit tests for protocols_use_datetime(). Exercises both + branches without going through code generation, so coverage doesn't depend + on incidental property ordering in a generated model. + """ + + def test_true_when_datetime_property_present(self) -> None: + """ + TEST_MODEL has scalar datetime properties (and list/enum/ref properties + that sort before them), so this also exercises the "skip" continue + branch on the way to the True return. + """ + assert protocols_use_datetime(_load_classes(TEST_MODEL)) is True + + def test_false_when_no_datetime_property(self) -> None: + """NO_DATETIME_MODEL has only a plain string property.""" + assert protocols_use_datetime(_load_classes(NO_DATETIME_MODEL)) is False + + def test_raises_on_unknown_datatype(self) -> None: + """Unmapped datatype raises like model.py.j2's abort(), not KeyError.""" + bad_prop = Property( + path="http://example.org/bad", + varname="bad", + datatype="http://example.org/not-a-real-datatype", + max_count=1, + ) + bad_class = Class( + _id="http://example.org/BadClass", + clsname="BadClass", + parent_ids=[], + derived_ids=[], + properties=[bad_prop], + ) + with pytest.raises(TemplateRuntimeError, match="Unknown data type"): + protocols_use_datetime([bad_class]) + + +def _generate_protocols_fixture( + tmp_path_factory: pytest.TempPathFactory, + test_context_url: str, + model_path: Path, + version: str, +) -> Tuple[Path, str]: + """Generate a --include-protocols yes module for one version fixture.""" + tmp_directory = tmp_path_factory.mktemp(f"protocols_{version}") + module_name = f"pymodel_{version}" + output_dir = tmp_directory / module_name + shacl2code_generate( + ["--input", model_path, "--context", test_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + (output_dir / "py.typed").touch() + return tmp_directory, module_name + + +@pytest.fixture(scope="module") +def python_model_v1_protocols( + tmp_path_factory: pytest.TempPathFactory, test_context_url: str +) -> Tuple[Path, str]: + """v1 model generated with --include-protocols yes.""" + return _generate_protocols_fixture( + tmp_path_factory, test_context_url, TEST_MODEL, "v1" + ) + + +@pytest.fixture(scope="module") +def python_model_v2_protocols( + tmp_path_factory: pytest.TempPathFactory, test_context_url: str +) -> Tuple[Path, str]: + """v2 model (backward-compatible extension) generated with --include-protocols yes.""" + return _generate_protocols_fixture( + tmp_path_factory, test_context_url, TEST_V2_MODEL, "v2" + ) + + +@pytest.fixture(scope="module") +def python_model_v3_protocols( + tmp_path_factory: pytest.TempPathFactory, test_context_url: str +) -> Tuple[Path, str]: + """v3 model (backward-compatible extension of v2) with --include-protocols yes.""" + return _generate_protocols_fixture( + tmp_path_factory, test_context_url, TEST_V3_MODEL, "v3" + ) + + +@pytest.fixture(scope="module") +def python_model_v4_protocols( + tmp_path_factory: pytest.TempPathFactory, test_context_url: str +) -> Tuple[Path, str]: + """v4 model (backward-compatible extension of v3) with --include-protocols yes.""" + return _generate_protocols_fixture( + tmp_path_factory, test_context_url, TEST_V4_MODEL, "v4" + ) + + +@pytest.fixture(scope="module") +def python_model_no_datetime(tmp_path_factory: pytest.TempPathFactory) -> Path: + """--include-protocols yes generated from a model with no datetime property.""" + output_dir = tmp_path_factory.mktemp("no_datetime") / "pymodel" + shacl2code_generate( + ["--input", NO_DATETIME_MODEL], + ["--include-protocols", "yes"], + output_dir, + ) + return output_dir + + +class TestProtocolOutput: + """ + Tests for generated protocols.py - syntax, typing, and flake8. + """ + + def test_protocols_file_generated( + self, python_model_v1_protocols: Tuple[Path, str] + ) -> None: + output_path, module_name = python_model_v1_protocols + assert (output_path / module_name / "protocols.py").exists() + + def test_protocols_file_not_generated_by_default( + self, tmp_path: Path, test_context_url: str + ) -> None: + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", test_context_url], + [], + output_dir, + ) + assert not (output_dir / "protocols.py").exists() + + def test_dir_includes_lazy_names( + self, python_model_v1_protocols: Tuple[Path, str] + ) -> None: + """ + __dir__() must expose model classes, "protocols", and "main" for + dir()/tab-completion even though they are loaded lazily via + __getattr__ (PEP 562). + """ + output_path, module_name = python_model_v1_protocols + + sys.path.insert(0, str(output_path)) + try: + pkg = importlib.import_module(module_name) + names = dir(pkg) + + assert "test_class" in names + assert "parent_class" in names + assert "protocols" in names + assert "main" in names + + # protocols.py is not itself lazily loaded, so dir() on the + # lazy .protocols entry point must show its domain classes too -- + # confirms tab-completion works end to end through __getattr__. + proto_names = dir(pkg.protocols) + assert "SHACLObjectProtocol" in proto_names + assert "test_class" in proto_names + finally: + sys.path.remove(str(output_path)) + for m in list(sys.modules): + if m == module_name or m.startswith(module_name + "."): + del sys.modules[m] + + def test_mypy(self, python_model_v1_protocols: Tuple[Path, str]) -> None: + output_path, module_name = python_model_v1_protocols + subprocess.run( + ["mypy", output_path / module_name], encoding="utf-8", check=True + ) + + def test_flake8_all_files( + self, python_model_v1_protocols: Tuple[Path, str] + ) -> None: + """ + flake8 over the whole output directory with --include-protocols yes, + not just protocols.py -- catches issues in the conditional protocols + import inside __init__.py that a protocols.py-only check would miss. + """ + output_path, module_name = python_model_v1_protocols + output_dir = output_path / module_name + subprocess.run( + ["flake8", "--config", TOP_DIR / ".flake8"] + list(output_dir.iterdir()), + encoding="utf-8", + check=True, + ) + + def test_flake8_no_datetime_properties( + self, python_model_no_datetime: Path + ) -> None: + """ + protocols.py must not unconditionally import `datetime`. A model with + no datetime-typed property must not produce an unused import (F401). + """ + assert ( + "import datetime" + not in (python_model_no_datetime / "protocols.py").read_text() + ) + subprocess.run( + [ + "flake8", + "--config", + TOP_DIR / ".flake8", + python_model_no_datetime / "protocols.py", + ], + encoding="utf-8", + check=True, + ) + + def test_mypy_no_datetime_properties(self, python_model_no_datetime: Path) -> None: + """ + The generated package must still type-check when protocols.py omits + the `datetime` import. + """ + subprocess.run(["mypy", python_model_no_datetime], encoding="utf-8", check=True) + + +class TestProtocolConformance: + """ + Type-checked usage tests: concrete classes satisfy their protocols, + and the discriminator keeps structurally-identical classes distinct. + """ + + def test_conformance_mypy( + self, python_model_v1_protocols: Tuple[Path, str], tmp_path: Path + ) -> None: + """ + Every concrete class must satisfy its generated Protocol under mypy strict. + Verifies scalar read/write, object-ref typed read, Any-setter write. + """ + module_path, module_name = python_model_v1_protocols + env = _env_with_pythonpath(module_path) + + script = tmp_path / "conformance.py" + script.write_text(textwrap.dedent(f"""\ + from typing import Any, Optional + import {module_name} + from {module_name} import protocols + + # Protocol conformance: assignment forces the static check. + a: protocols.test_class = {module_name}.test_class() + b: protocols.parent_class = {module_name}.parent_class() + + # Scalar read + write through protocol. + def set_scalar(o: protocols.test_class, v: Optional[str]) -> None: + o.test_class_string_scalar_prop = v + + # Object-ref Any read + Any-setter write through protocol. + def get_ref(o: protocols.test_class) -> Any: + return o.test_class_class_prop + + def set_ref(o: protocols.test_class, v: {module_name}.test_class) -> None: + o.test_class_class_prop = v + + # Version-agnostic function accepts any conforming class. + def get_scalar(o: protocols.test_class) -> Optional[str]: + result: Optional[str] = o.test_class_string_scalar_prop + return result + + get_scalar(a) + """)) + + r = subprocess.run( + ["mypy", "--strict", str(script)], + encoding="utf-8", + env=env, + capture_output=True, + ) + assert r.returncode == 0, r.stdout + r.stderr + + def test_base_protocol_conformance_mypy( + self, python_model_v1_protocols: Tuple[Path, str], tmp_path: Path + ) -> None: + """ + The hand-written SHACLObjectProtocol/SHACLObjectSetProtocol must be + satisfied by the real generated SHACLObject/SHACLObjectSet, not just by + per-class domain protocols. Guards against model.py.j2 changes to + SHACLObject/SHACLObjectSet (e.g. renaming property_keys, retyping + find_by_id's default, altering __contains__) silently breaking the + base protocols with no test catching it. + """ + module_path, module_name = python_model_v1_protocols + env = _env_with_pythonpath(module_path) + + script = tmp_path / "base_conformance.py" + script.write_text(textwrap.dedent(f"""\ + from typing import Iterable + import {module_name} + from {module_name} import protocols + + # Protocol conformance: assignment forces the static check. + o: protocols.SHACLObjectProtocol = {module_name}.test_class() + s: protocols.SHACLObjectSetProtocol = {module_name}.SHACLObjectSet() + + # Version-agnostic function accepts any conforming object/set. + def get_id(obj: protocols.SHACLObjectProtocol) -> str: + return obj.get_type() + + def iter_objects( + objset: protocols.SHACLObjectSetProtocol, + ) -> Iterable[protocols.SHACLObjectProtocol]: + return objset.foreach() + + get_id(o) + iter_objects(s) + """)) + + r = subprocess.run( + ["mypy", "--strict", str(script)], + encoding="utf-8", + env=env, + capture_output=True, + ) + assert r.returncode == 0, r.stdout + r.stderr + + def test_discriminator_mypy( + self, python_model_v1_protocols: Tuple[Path, str], tmp_path: Path + ) -> None: + """ + The discriminator marker must prevent structurally-identical classes + (test_class and another_class share no own properties in v1) from + satisfying each other's protocol. + """ + module_path, module_name = python_model_v1_protocols + env = _env_with_pythonpath(module_path) + + # test_another_class has no own properties in v1, making it structurally + # identical to test_class. Without the discriminator both would satisfy + # each other's protocol. + script = tmp_path / "discriminator.py" + script.write_text(textwrap.dedent(f"""\ + import {module_name} + from {module_name} import protocols + + bad: protocols.test_class = {module_name}.test_another_class() + """)) + result = subprocess.run( + ["mypy", "--strict", str(script)], + encoding="utf-8", + env=env, + capture_output=True, + ) + assert result.returncode != 0, ( + "Expected mypy to reject test_another_class as protocols.test_class " + "(discriminator should prevent it)" + ) + + +class TestProtocolCrossVersion: + """ + Cross-version: newer concrete classes must satisfy older-generated + Protocols, and the discriminator still prevents wrong-type assignments + across versions. + + Pairs cover chain-to-baseline (vN vs v1) plus adjacent (vN vs vN-1), so + each new version is checked both against the original baseline and + against the version it was directly derived from. + """ + + @pytest.mark.parametrize( + "older_fixture,newer_fixture", + [ + ("python_model_v1_protocols", "python_model_v2_protocols"), + ("python_model_v1_protocols", "python_model_v3_protocols"), + ("python_model_v2_protocols", "python_model_v3_protocols"), + ("python_model_v1_protocols", "python_model_v4_protocols"), + ("python_model_v3_protocols", "python_model_v4_protocols"), + ], + ) + def test_cross_version_mypy( + self, + older_fixture: str, + newer_fixture: str, + request: pytest.FixtureRequest, + tmp_path: Path, + ) -> None: + """ + newer.test_class() satisfies older.protocols.test_class (backward-compat). + newer.another_class() does NOT satisfy older.protocols.test_class + (discriminator). + """ + older_path, older_name = request.getfixturevalue(older_fixture) + newer_path, newer_name = request.getfixturevalue(newer_fixture) + env = _env_with_pythonpath(older_path, newer_path) + + script = tmp_path / "cross_version.py" + script.write_text(textwrap.dedent(f"""\ + from typing import Any, Optional + import {older_name}, {newer_name} + from {older_name} import protocols as op + + # newer concrete satisfies older Protocol (additive-only versions). + a: op.test_class = {newer_name}.test_class() + b: op.parent_class = {newer_name}.parent_class() + + # Scalar read through older protocol on newer object. + def get_scalar(o: op.test_class) -> Optional[str]: + result: Optional[str] = o.test_class_string_scalar_prop + return result + + get_scalar(a) + + # Object-ref typed read through older protocol on newer object. + def get_ref(o: op.test_class) -> Any: + return o.test_class_class_prop + + # Any-setter write through older protocol on newer object. + def set_ref(o: op.test_class, v: {newer_name}.test_class) -> None: + o.test_class_class_prop = v + + # Discriminator: newer.another_class must NOT satisfy + # older.protocols.test_class. + bad: op.test_class = {newer_name}.test_another_class() # type: ignore[assignment] + """)) + + subprocess.run( + ["mypy", "--strict", str(script)], + encoding="utf-8", + env=env, + check=True, + ) + + # Confirm the discriminator actually works (without the ignore). + script2 = tmp_path / "cross_version_bad.py" + script2.write_text(textwrap.dedent(f"""\ + import {older_name}, {newer_name} + from {older_name} import protocols as op + + bad: op.test_class = {newer_name}.test_another_class() + """)) + result = subprocess.run( + ["mypy", "--strict", str(script2)], + encoding="utf-8", + env=env, + capture_output=True, + ) + assert result.returncode != 0, ( + "Expected mypy to reject newer.another_class as older.protocols.test_class " + "across versions" + ) + + +def test_prerelease_with_protocols(tmp_path: Path) -> None: + """ + --include-protocols and a pre-release model don't interact: the + import-time warning still fires, and protocols.py is still generated + and importable (protocols.py itself carries no pre-release awareness). + """ + output_dir = tmp_path / "pymodel_prerelease_protocols" + shacl2code_generate( + ["--input", PRERELEASE_MODEL], + ["--include-protocols", "yes"], + output_dir, + ) + assert "IS_PRERELEASE = True" in (output_dir / "__init__.py").read_text() + assert (output_dir / "protocols.py").exists() + + sys.path.insert(0, str(tmp_path)) + try: + with pytest.warns(FutureWarning): + pkg = importlib.import_module("pymodel_prerelease_protocols") + assert pkg.protocols is not None + finally: + sys.path.remove(str(tmp_path)) + for m in list(sys.modules): + if m == "pymodel_prerelease_protocols" or m.startswith( + "pymodel_prerelease_protocols." + ): + del sys.modules[m]