diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 29521e23..632d5e57 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -6,9 +6,11 @@ import keyword import re from pathlib import Path +from typing import Iterable from .common import JinjaTemplateRender from .lang import TEMPLATE_DIR, language +from ..model import Class from ..util import convert_version_string DATATYPE_CLASSES = { @@ -75,6 +77,18 @@ def varname(*name): return name +def protocols_use_datetime(classes: Iterable[Class]) -> bool: + """Whether any class has a plain datetime-typed scalar property.""" + for cls in classes: + for prop in cls.properties: + is_list = prop.max_count is None or prop.max_count != 1 + has_ref = bool(prop.class_id) and not prop.enum_values + is_scalar = not (is_list or has_ref or prop.enum_values) + if is_scalar and DATATYPE_PYTHON_TYPES[prop.datatype] == "datetime": + return True + return False + + @language("python") class PythonRender(JinjaTemplateRender): """Render Python Language Bindings.""" @@ -90,8 +104,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)) @@ -113,6 +128,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"), @@ -141,9 +165,13 @@ 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, + "protocols_use_datetime": protocols_use_datetime, "DATATYPE_CLASSES": DATATYPE_CLASSES, "DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES, } @@ -156,8 +184,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..a870ebc5 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -4,12 +4,77 @@ # # 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, 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 %} + + +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 include_protocols %} + if name == "protocols": + return importlib.import_module(f"{__name__}.protocols") +{%- endif %} {%- if include_main %} -from .cmd import main # noqa: F401, I100, I202 + if name == "main": + return importlib.import_module(f"{__name__}.cmd").main {%- endif %} + 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)) +{%- if include_protocols %} + names.add("protocols") +{%- endif %} +{%- if include_main %} + names.add("main") +{%- endif %} + 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 aae056a9..0a6635cd 100644 --- a/src/shacl2code/lang/templates/python/model.py.j2 +++ b/src/shacl2code/lang/templates/python/model.py.j2 @@ -3049,6 +3049,9 @@ class {{ varname(*class.clsname) }}( } {%- endif %} + def _protocol_{{ varname(*class.clsname) }}(self) -> None: + pass + {%- if class.properties %} PROPERTIES: ClassVar[List[ClassProp]] = [ {%- for prop in class.properties %} diff --git a/src/shacl2code/lang/templates/python/model.pyi.j2 b/src/shacl2code/lang/templates/python/model.pyi.j2 index d257007f..dc584ea9 100644 --- a/src/shacl2code/lang/templates/python/model.pyi.j2 +++ b/src/shacl2code/lang/templates/python/model.pyi.j2 @@ -475,6 +475,7 @@ class {{ varname(*class.clsname) }}( {%- endif %} **kwargs: Any ) -> None: ... + def _protocol_{{ varname(*class.clsname) }}(self) -> None: ... {%- 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..b32decea --- /dev/null +++ b/src/shacl2code/lang/templates/python/protocols.py.j2 @@ -0,0 +1,110 @@ +# {{ disclaimer }} +# +# SPDX-License-Identifier: {{ spdx_license }} +"""Version-agnostic Protocol types, one per generated class. + +Each Protocol is satisfied by the corresponding concrete class of any model +version that is backward-compatible with the baseline from which these Protocols +were generated. Use these types to write functions and classes that work across +multiple model versions and still pass strict static type checking. + +Scalar properties are typed precisely and are plain read-write attributes. +Object-reference and list properties are typed ``Any`` for both read and write: +cross-module structural typing cannot resolve the circular reference between a +protocol class and the sibling protocol type its property would otherwise +return, so these properties fall back to ``Any``. 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 datetime import datetime" if protocols_use_datetime(classes) else ""}} +from typing import Any, Iterable, Iterator, Optional, Protocol, Set, Tuple + + +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 %} + {{ '"' }}{{ '"' }}{{ '"' }} + {%- for l in class.comment.split("\n") %} + {{ l.rstrip() }} + {%- endfor %} + {{ '"' }}{{ '"' }}{{ '"' }} +{%- endif %} + + def _protocol_{{ varname(*class.clsname) }}(self) -> None: ... + {%- if class.id_property %} + {{ class.id_property }}: Optional[str] + {%- endif %} + {%- for prop in class.properties %} + {%- set is_list = prop.max_count is none or prop.max_count != 1 %} + {%- set has_ref = prop.class_id and not prop.enum_values %} + {%- if prop.enum_values %} + {%- set ptype = "str" %} + {%- elif prop.class_id %} + {%- set ptype = "Any" %} + {%- else %} + {%- set ptype = DATATYPE_PYTHON_TYPES[prop.datatype] %} + {%- endif %} + {%- if is_list or has_ref %} + {{ varname(prop.varname) }}: Any + {%- 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/no-datetime.ttl b/tests/data/no-datetime.ttl new file mode 100644 index 00000000..83a6cae8 --- /dev/null +++ b/tests/data/no-datetime.ttl @@ -0,0 +1,18 @@ +@base . +@prefix rdfs: . +@prefix sh: . +@prefix owl: . +@prefix xsd: . + +# A minimal model with no datetime-typed property. Used to guard against +# protocols.py.j2 emitting an unconditional `from datetime import datetime` +# that would be an unused import (flake8 F401) for models like this one. + + 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 18aed753..a7c7bd2b 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -11,8 +11,10 @@ import subprocess import sys import textwrap +import warnings from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import Iterable, Tuple import jsonschema @@ -22,6 +24,10 @@ import rdflib +from shacl2code.lang.python import protocols_use_datetime +from shacl2code.model import Class, Model +from shacl2code.urlcontext import UrlContext + from testfixtures import jsonvalidation, timetests THIS_FILE = Path(__file__) @@ -2294,6 +2300,11 @@ def test_version(model): assert model.VERSION == convert_version_string(MODEL_VERSION) +# --------------------------------------------------------------------------- +# Ontology metadata tests +# --------------------------------------------------------------------------- + + def test_ontology(model): classes = list(model.SHACLObject.CLASSES.values()) assert classes @@ -2306,3 +2317,564 @@ def test_prerelease_warning(model): with pytest.warns(FutureWarning): model.test_class() + + +PRERELEASE_MODEL = DATA_DIR / "prerelease.ttl" + + +def test_is_prerelease_constant(tmp_path: Path) -> None: + """IS_PRERELEASE reflects sh-to-code:isPreRelease without loading model.py.""" + prerelease_dir = tmp_path / "pymodel_prerelease" + shacl2code_generate(["--input", PRERELEASE_MODEL], [], prerelease_dir) + + stable_dir = tmp_path / "pymodel_stable" + shacl2code_generate(["--input", TEST_MODEL], [], stable_dir) + + 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(tmp_path)) + 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(tmp_path)) + for m in list(sys.modules): + if m == "pymodel_prerelease" or m.startswith("pymodel_prerelease."): + del sys.modules[m] + + +def test_prerelease_import_warning(tmp_path: Path) -> None: + """Pre-release package warns FutureWarning on first import, any form; stable doesn't.""" + prerelease_dir = tmp_path / "pymodel_prerelease_import" + shacl2code_generate(["--input", PRERELEASE_MODEL], [], prerelease_dir) + + stable_dir = tmp_path / "pymodel_stable_import" + shacl2code_generate(["--input", TEST_MODEL], [], stable_dir) + + sys.path.insert(0, str(tmp_path)) + try: + with pytest.warns(FutureWarning): + import pymodel_prerelease_import # 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_import") + + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + import pymodel_stable_import # noqa: F401 + finally: + sys.path.remove(str(tmp_path)) + for prefix in ("pymodel_prerelease_import", "pymodel_stable_import"): + for m in list(sys.modules): + if m == prefix or m.startswith(prefix + "."): + del sys.modules[m] + + +# --------------------------------------------------------------------------- +# Protocol generation tests +# --------------------------------------------------------------------------- + +TEST_V2_MODEL = THIS_DIR / "data" / "model" / "test-v2.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 + + +@pytest.fixture(scope="module") +def python_model_v1_protocols( + tmp_path_factory: pytest.TempPathFactory, model_context_url: str +) -> Tuple[Path, str]: + """v1 model generated with --include-protocols yes.""" + tmp_directory = tmp_path_factory.mktemp("protocols_v1") + module_name = "pymodel_v1" + output_dir = tmp_directory / module_name + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + (output_dir / "py.typed").touch() + return tmp_directory, module_name + + +@pytest.fixture(scope="module") +def python_model_v2_protocols( + tmp_path_factory: pytest.TempPathFactory, model_context_url: str +) -> Tuple[Path, str]: + """v2 model (backward-compatible extension) generated with --include-protocols yes.""" + tmp_directory = tmp_path_factory.mktemp("protocols_v2") + module_name = "pymodel_v2" + output_dir = tmp_directory / module_name + shacl2code_generate( + ["--input", TEST_V2_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + (output_dir / "py.typed").touch() + return tmp_directory, module_name + + +class TestProtocolOutput: + """ + Tests for generated protocols.py - syntax, typing, and flake8. + """ + + def test_protocols_file_generated( + self, tmp_path: Path, model_context_url: str + ) -> None: + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + assert (output_dir / "protocols.py").exists() + + def test_protocols_file_not_generated_by_default( + self, tmp_path: Path, model_context_url: str + ) -> None: + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + [], + output_dir, + ) + assert not (output_dir / "protocols.py").exists() + + def test_dir_includes_lazy_names( + self, tmp_path: Path, model_context_url: str + ) -> None: + """ + __dir__() must expose model classes, "protocols", and "main" for + dir()/tab-completion even though they are loaded lazily via + __getattr__ (PEP 562). + """ + module_name = "pymodel_dir_check" + output_dir = tmp_path / module_name + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + + sys.path.insert(0, str(tmp_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(tmp_path)) + for m in list(sys.modules): + if m == module_name or m.startswith(module_name + "."): + del sys.modules[m] + + def test_mypy(self, tmp_path: Path, model_context_url: str) -> None: + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + (output_dir / "py.typed").touch() + subprocess.run(["mypy", output_dir], encoding="utf-8", check=True) + + def test_flake8(self, tmp_path: Path, model_context_url: str) -> None: + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + subprocess.run( + ["flake8", "--config", TOP_DIR / ".flake8", output_dir / "protocols.py"], + encoding="utf-8", + check=True, + ) + + def test_flake8_all_files(self, tmp_path: Path, model_context_url: 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_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--include-protocols", "yes"], + output_dir, + ) + subprocess.run( + ["flake8", "--config", TOP_DIR / ".flake8"] + list(output_dir.iterdir()), + encoding="utf-8", + check=True, + ) + + def test_flake8_no_datetime_properties(self, tmp_path: Path) -> None: + """ + protocols.py must not unconditionally import `datetime`. A model with + no datetime-typed property must not produce an unused import (F401). + """ + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", NO_DATETIME_MODEL], + ["--include-protocols", "yes"], + output_dir, + ) + assert "import datetime" not in (output_dir / "protocols.py").read_text() + subprocess.run( + ["flake8", "--config", TOP_DIR / ".flake8", output_dir / "protocols.py"], + encoding="utf-8", + check=True, + ) + + def test_mypy_no_datetime_properties(self, tmp_path: Path) -> None: + """ + The generated package must still type-check when protocols.py omits + the `datetime` import. + """ + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", NO_DATETIME_MODEL], + ["--include-protocols", "yes"], + output_dir, + ) + (output_dir / "py.typed").touch() + subprocess.run(["mypy", output_dir], 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 = os.environ.copy() + env["PYTHONPATH"] = str(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 = os.environ.copy() + env["PYTHONPATH"] = str(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 = os.environ.copy() + env["PYTHONPATH"] = str(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: v2 concrete classes must satisfy v1-generated Protocols, + and the discriminator still prevents wrong-type assignments across versions. + """ + + def test_cross_version_mypy( + self, + python_model_v1_protocols: Tuple[Path, str], + python_model_v2_protocols: Tuple[Path, str], + tmp_path: Path, + ) -> None: + """ + v2.test_class() satisfies v1.protocols.test_class (backward-compat). + v2.another_class() does NOT satisfy v1.protocols.test_class (discriminator). + """ + v1_path, v1_name = python_model_v1_protocols + v2_path, v2_name = python_model_v2_protocols + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join([str(v1_path), str(v2_path)]) + + script = tmp_path / "cross_version.py" + script.write_text(textwrap.dedent(f"""\ + from typing import Any, Optional + import {v1_name}, {v2_name} + from {v1_name} import protocols as v1p + + # v2 concrete satisfies v1 Protocol (additive-only minor version). + a: v1p.test_class = {v2_name}.test_class() + b: v1p.parent_class = {v2_name}.parent_class() + + # Scalar read through v1 protocol on v2 object. + def get_scalar(o: v1p.test_class) -> Optional[str]: + result: Optional[str] = o.test_class_string_scalar_prop + return result + + get_scalar(a) + + # Object-ref typed read through v1 protocol on v2 object. + def get_ref(o: v1p.test_class) -> Any: + return o.test_class_class_prop + + # Any-setter write through v1 protocol on v2 object. + def set_ref(o: v1p.test_class, v: {v2_name}.test_class) -> None: + o.test_class_class_prop = v + + # Discriminator: v2.another_class must NOT satisfy v1.protocols.test_class. + bad: v1p.test_class = {v2_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 {v1_name}, {v2_name} + from {v1_name} import protocols as v1p + + bad: v1p.test_class = {v2_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 v2.another_class as v1.protocols.test_class " + "across versions" + ) + + +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 "NAMED_INDIVIDUALS" 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 imports/type-checking machinery from model.py. + assert not imported & { + "TYPE_CHECKING", + "Any", + "List", + "TypeVar", + "json", + } + + # 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, model_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", model_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]