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]