From 268c9a69b21d510141567dcf0d8dafee702c5bbd Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 27 Jun 2026 22:36:35 +0100 Subject: [PATCH 01/16] Add cross-version Protocols Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 20 + .../lang/templates/python/__init__.py.j2 | 3 + .../lang/templates/python/model.py.j2 | 3 + .../lang/templates/python/model.pyi.j2 | 1 + .../lang/templates/python/protocols.py.j2 | 107 +++ tests/data/model/test-v2.ttl | 624 ++++++++++++++++++ tests/test_python.py | 258 ++++++++ 7 files changed, 1016 insertions(+) create mode 100644 src/shacl2code/lang/templates/python/protocols.py.j2 create mode 100644 tests/data/model/test-v2.ttl diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 29521e23..ab230066 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -60,6 +60,11 @@ } +def protocol_name(cls): + """Protocol name for a class — same as the class name (identity mapping).""" + return varname(*cls.clsname) + + def varname(*name): """Make a valid Python variable name.""" name = "_".join(name) @@ -92,6 +97,7 @@ def __init__(self, args): self.__output = args.output self.__use_slots = args.use_slots self.__include_main = args.include_main == "yes" + self.__protocols = args.use_protocols == "yes" self.__version_str = args.version if args.version: self.__version = repr(convert_version_string(args.version)) @@ -126,6 +132,15 @@ def get_arguments(cls, parser): "--version", help="Specify model version", ) + parser.add_argument( + "--use-protocols", + choices=("yes", "no"), + default="no", + help=( + "Generate a protocols.py module with version-agnostic Protocol " + "types for every class. Default is '%(default)s'" + ), + ) def get_outputs(self): t = TEMPLATE_DIR / "python" @@ -141,9 +156,13 @@ def get_file(name): yield get_file("cmd.py") yield get_file("__main__.py") + if self.__protocols: + yield get_file("protocols.py") + def get_extra_env(self): return { "varname": varname, + "protocol_name": protocol_name, "DATATYPE_CLASSES": DATATYPE_CLASSES, "DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES, } @@ -158,6 +177,7 @@ def get_additional_render_args(self, model): return { "use_slots": use_slots, "include_main": self.__include_main, + "protocols": self.__protocols, "version_str": self.__version_str, "version": self.__version, } diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 0af8e7e3..b76f9284 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -11,5 +11,8 @@ from .model import * # noqa: F401, F403 {%- if include_main %} from .cmd import main # noqa: F401, I100, I202 {%- endif %} +{%- if protocols %} +from . import protocols # noqa: F401, I100, I202 +{%- endif %} {{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" # fmt on diff --git a/src/shacl2code/lang/templates/python/model.py.j2 b/src/shacl2code/lang/templates/python/model.py.j2 index b1c52de0..68d39758 100644 --- a/src/shacl2code/lang/templates/python/model.py.j2 +++ b/src/shacl2code/lang/templates/python/model.py.j2 @@ -3008,6 +3008,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 8906e671..8b83070d 100644 --- a/src/shacl2code/lang/templates/python/model.pyi.j2 +++ b/src/shacl2code/lang/templates/python/model.pyi.j2 @@ -465,6 +465,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..9494370b --- /dev/null +++ b/src/shacl2code/lang/templates/python/protocols.py.j2 @@ -0,0 +1,107 @@ +# {{ 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. + +Object-reference and list properties expose a typed getter (covariant read) and +an ``Any`` setter (version-agnostic write). Scalar properties are plain +read-write attributes. + +Construct objects with a concrete version module; use these Protocols only for +annotations. +""" + +from __future__ import annotations + +from datetime import datetime +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 {{ protocol_name(class) }}( +{%- if class.parent_ids %} + {%- for id in class.parent_ids -%} + {{- protocol_name(classes.get(id)) }}{%- 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_{{ protocol_name(class) }}(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..15327982 --- /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/test_python.py b/tests/test_python.py index e31d0330..a78ac069 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2276,3 +2276,261 @@ def test_version(model): assert model.VERSION_STRING == MODEL_VERSION assert model.VERSION == convert_version_string(MODEL_VERSION) + + +# --------------------------------------------------------------------------- +# Protocol generation tests +# --------------------------------------------------------------------------- + +TEST_V2_MODEL = THIS_DIR / "data" / "model" / "test-v2.ttl" + + +@pytest.fixture(scope="module") +def python_model_v1_protocols(tmp_path_factory, model_context_url): + """v1 model generated with --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], + ["--use-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, model_context_url): + """v2 model (backward-compatible extension) generated with --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], + ["--use-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, model_context_url): + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--use-protocols", "yes"], + output_dir, + ) + assert (output_dir / "protocols.py").exists() + + def test_protocols_file_not_generated_by_default(self, tmp_path, model_context_url): + 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_mypy(self, tmp_path, model_context_url): + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--use-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, model_context_url): + output_dir = tmp_path / "pymodel" + shacl2code_generate( + ["--input", TEST_MODEL, "--context", model_context_url], + ["--use-protocols", "yes"], + output_dir, + ) + subprocess.run( + ["flake8", "--config", TOP_DIR / ".flake8", output_dir / "protocols.py"], + 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, tmp_path): + """ + 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_discriminator_mypy(self, python_model_v1_protocols, tmp_path): + """ + 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 properties in v1 — structurally a subset of + # test_class. Without the discriminator, it would satisfy protocols.test_class. + script = tmp_path / "discriminator.py" + script.write_text(textwrap.dedent(f"""\ + import {module_name} + from {module_name} import protocols + + # This must be a type error: test_another_class != protocols.test_class + bad: protocols.test_class = {module_name}.test_another_class() # type: ignore[assignment] + """)) + + result = subprocess.run( + ["mypy", "--strict", str(script)], + encoding="utf-8", + env=env, + capture_output=True, + ) + # mypy must flag the assignment despite the type: ignore — we check that + # removing the ignore would produce an error by running without it. + script2 = tmp_path / "discriminator_no_ignore.py" + script2.write_text(textwrap.dedent(f"""\ + import {module_name} + from {module_name} import protocols + + bad: protocols.test_class = {module_name}.test_another_class() + """)) + result2 = subprocess.run( + ["mypy", "--strict", str(script2)], + encoding="utf-8", + env=env, + capture_output=True, + ) + assert result2.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, python_model_v2_protocols, tmp_path + ): + """ + 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" + ) From c748c4428df9bef75671d2fddb8e87d4c6a9d139 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 27 Jun 2026 22:50:07 +0100 Subject: [PATCH 02/16] sort command lines Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 30 +++++++++---------- .../lang/templates/python/__init__.py.j2 | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index ab230066..b46be098 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -94,10 +94,10 @@ class PythonRender(JinjaTemplateRender): def __init__(self, args): super().__init__(args) + self.__include_main = args.include_main == "yes" self.__output = args.output + self.__use_protocols = args.use_protocols == "yes" self.__use_slots = args.use_slots - self.__include_main = args.include_main == "yes" - self.__protocols = args.use_protocols == "yes" self.__version_str = args.version if args.version: self.__version = repr(convert_version_string(args.version)) @@ -119,6 +119,15 @@ def get_arguments(cls, parser): default="yes", help="Generate a main function for the module. Default is '%(default)s'", ) + parser.add_argument( + "--use-protocols", + choices=("yes", "no"), + default="no", + help=( + "Generate 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"), @@ -132,15 +141,6 @@ def get_arguments(cls, parser): "--version", help="Specify model version", ) - parser.add_argument( - "--use-protocols", - choices=("yes", "no"), - default="no", - help=( - "Generate a protocols.py module with version-agnostic Protocol " - "types for every class. Default is '%(default)s'" - ), - ) def get_outputs(self): t = TEMPLATE_DIR / "python" @@ -156,7 +156,7 @@ def get_file(name): yield get_file("cmd.py") yield get_file("__main__.py") - if self.__protocols: + if self.__use_protocols: yield get_file("protocols.py") def get_extra_env(self): @@ -175,9 +175,9 @@ def get_additional_render_args(self, model): else: use_slots = False return { - "use_slots": use_slots, "include_main": self.__include_main, - "protocols": self.__protocols, - "version_str": self.__version_str, + "use_protocols": self.__use_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 b76f9284..6c0f257c 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -11,7 +11,7 @@ from .model import * # noqa: F401, F403 {%- if include_main %} from .cmd import main # noqa: F401, I100, I202 {%- endif %} -{%- if protocols %} +{%- if use_protocols %} from . import protocols # noqa: F401, I100, I202 {%- endif %} {{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" From 258d3b8542062acea56af20d1fed8e708c8572d1 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 04:20:31 +0100 Subject: [PATCH 03/16] Lazy import model Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 2 +- .../lang/templates/python/__init__.py.j2 | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index b46be098..733ab377 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -94,8 +94,8 @@ class PythonRender(JinjaTemplateRender): def __init__(self, args): super().__init__(args) - self.__include_main = args.include_main == "yes" self.__output = args.output + self.__include_main = args.include_main == "yes" self.__use_protocols = args.use_protocols == "yes" self.__use_slots = args.use_slots self.__version_str = args.version diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 6c0f257c..6043f5e1 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -4,15 +4,33 @@ # # SPDX-License-Identifier: {{ spdx_license }} -from .model import * # noqa: F401, F403 +from __future__ import annotations -# fmt: off -"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +import importlib +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: +{%- if use_protocols %} + from . import protocols # noqa: F401 +{%- endif %} {%- if include_main %} -from .cmd import main # noqa: F401, I100, I202 + from .cmd import main # noqa: F401 {%- endif %} + from .model import * # noqa: F401, F403 + + +def __getattr__(name: str) -> Any: + """Lazily load model content or submodules on first attribute access (PEP 562).""" {%- if use_protocols %} -from . import protocols # noqa: F401, I100, I202 + if name == "protocols": + return importlib.import_module(f"{__name__}.protocols") +{%- endif %} +{%- if include_main %} + if name == "main": + return importlib.import_module(f"{__name__}.cmd").main {%- endif %} -{{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" -# fmt on + 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}") From 94f1b349de0b3f3b7977a55723a5ff395586ec95 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 09:47:56 +0100 Subject: [PATCH 04/16] Set Black target versions Signed-off-by: Arthit Suriyawongkul --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6695adc9..00bd5eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,4 +95,5 @@ relative_files = true patch = ["subprocess"] [tool.black] +target-version = ["py38", "py39", "py310", "py311", "py312", "py313", "py314"] include = '(\.pyi?(\.j2)?$)|(main\.py\.j2$)' From 0c886d35ce05923d6cce7b0d535e09f8820fcf12 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 09:54:39 +0100 Subject: [PATCH 05/16] Update Python version of lint CI to 3.14 Signed-off-by: Arthit Suriyawongkul --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bc055147..ac1d3cb3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -65,7 +65,7 @@ jobs: - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" # should be the latest in Black's target-version - uses: psf/black@87928e6d6761a4a6d22250e1fee5601b3998086e # 26.5.1 - uses: py-actions/flake8@84ec6726560b6d5bd68f2a5bed83d62b52bb50ba # v2.3.0 - name: Install dependencies From 840ce86ff4dd872145ea09992a5c8587efee39ef Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 10:05:28 +0100 Subject: [PATCH 06/16] Add back format guard Signed-off-by: Arthit Suriyawongkul --- .github/workflows/test.yaml | 2 +- .../lang/templates/python/__init__.py.j2 | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ac1d3cb3..e1ef2c41 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -65,7 +65,7 @@ jobs: - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.14" # should be the latest in Black's target-version + python-version: "3.14" # must matches the latest in Black's target-version - uses: psf/black@87928e6d6761a4a6d22250e1fee5601b3998086e # 26.5.1 - uses: py-actions/flake8@84ec6726560b6d5bd68f2a5bed83d62b52bb50ba # v2.3.0 - name: Install dependencies diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 6043f5e1..fbfd40e0 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -10,13 +10,14 @@ import importlib from typing import Any, TYPE_CHECKING if TYPE_CHECKING: + from .model import * # noqa: F401, F403 + +# fmt: off +"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }} {%- if use_protocols %} - from . import protocols # noqa: F401 -{%- endif %} -{%- if include_main %} - from .cmd import main # noqa: F401 +if TYPE_CHECKING: + from . import protocols # noqa: F401, I202 {%- endif %} - from .model import * # noqa: F401, F403 def __getattr__(name: str) -> Any: @@ -34,3 +35,7 @@ def __getattr__(name: str) -> Any: return getattr(mod, name) except AttributeError: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +{{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" +# fmt: on From 025cbdf07faf175112436f7c2d70b31d384343c3 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 10:20:53 +0100 Subject: [PATCH 07/16] Fix comment and Black target-version Signed-off-by: Arthit Suriyawongkul --- .github/workflows/test.yaml | 3 ++- pyproject.toml | 4 +++- src/shacl2code/lang/templates/python/__init__.py.j2 | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e1ef2c41..93870183 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -65,7 +65,8 @@ jobs: - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.14" # must matches the latest in Black's target-version + # Black in this Python version must support all target-versions listed in pyproject.toml + python-version: "3.14" - uses: psf/black@87928e6d6761a4a6d22250e1fee5601b3998086e # 26.5.1 - uses: py-actions/flake8@84ec6726560b6d5bd68f2a5bed83d62b52bb50ba # v2.3.0 - name: Install dependencies diff --git a/pyproject.toml b/pyproject.toml index 00bd5eea..dee8051e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,5 +95,7 @@ relative_files = true patch = ["subprocess"] [tool.black] -target-version = ["py38", "py39", "py310", "py311", "py312", "py313", "py314"] +# Note: "py314" can't be used as a target-version because Black 24.8.0 (latest +# supported version for Python 3.8) doesn't recognize it. +target-version = ["py38", "py39", "py310", "py311", "py312", "py313"] include = '(\.pyi?(\.j2)?$)|(main\.py\.j2$)' diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index fbfd40e0..64509598 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -21,7 +21,7 @@ if TYPE_CHECKING: def __getattr__(name: str) -> Any: - """Lazily load model content or submodules on first attribute access (PEP 562).""" + # Lazy attribute access: submodules and model content loaded on first use (PEP 562). {%- if use_protocols %} if name == "protocols": return importlib.import_module(f"{__name__}.protocols") From 5a288cea7464de689ca4da4b5f11bf952e17372a Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 10:29:27 +0100 Subject: [PATCH 08/16] Remove unused test Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 2 +- tests/test_python.py | 28 ++++++---------------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 733ab377..5d1b7d36 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -61,7 +61,7 @@ def protocol_name(cls): - """Protocol name for a class — same as the class name (identity mapping).""" + """Protocol name for a class - same as the class name (identity mapping).""" return varname(*cls.clsname) diff --git a/tests/test_python.py b/tests/test_python.py index a78ac069..fc3cc5f7 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2317,7 +2317,7 @@ def python_model_v2_protocols(tmp_path_factory, model_context_url): class TestProtocolOutput: """ - Tests for generated protocols.py — syntax, typing, and flake8. + Tests for generated protocols.py - syntax, typing, and flake8. """ def test_protocols_file_generated(self, tmp_path, model_context_url): @@ -2424,39 +2424,23 @@ def test_discriminator_mypy(self, python_model_v1_protocols, tmp_path): env = os.environ.copy() env["PYTHONPATH"] = str(module_path) - # test_another_class has no properties in v1 — structurally a subset of - # test_class. Without the discriminator, it would satisfy protocols.test_class. + # 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 - # This must be a type error: test_another_class != protocols.test_class - bad: protocols.test_class = {module_name}.test_another_class() # type: ignore[assignment] + bad: protocols.test_class = {module_name}.test_another_class() """)) - result = subprocess.run( ["mypy", "--strict", str(script)], encoding="utf-8", env=env, capture_output=True, ) - # mypy must flag the assignment despite the type: ignore — we check that - # removing the ignore would produce an error by running without it. - script2 = tmp_path / "discriminator_no_ignore.py" - script2.write_text(textwrap.dedent(f"""\ - import {module_name} - from {module_name} import protocols - - bad: protocols.test_class = {module_name}.test_another_class() - """)) - result2 = subprocess.run( - ["mypy", "--strict", str(script2)], - encoding="utf-8", - env=env, - capture_output=True, - ) - assert result2.returncode != 0, ( + assert result.returncode != 0, ( "Expected mypy to reject test_another_class as protocols.test_class " "(discriminator should prevent it)" ) From 2a7ae17019163da2a024e5decbf63a9c1574e9ef Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 28 Jun 2026 11:11:22 +0100 Subject: [PATCH 09/16] Rename --use-protocols -> --include-protocols Follows --include-main pattern Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 10 +++++----- .../lang/templates/python/__init__.py.j2 | 4 ++-- tests/test_python.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 5d1b7d36..7b882738 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -96,7 +96,7 @@ def __init__(self, args): super().__init__(args) self.__output = args.output self.__include_main = args.include_main == "yes" - self.__use_protocols = args.use_protocols == "yes" + self.__include_protocols = args.include_protocols == "yes" self.__use_slots = args.use_slots self.__version_str = args.version if args.version: @@ -120,11 +120,11 @@ def get_arguments(cls, parser): help="Generate a main function for the module. Default is '%(default)s'", ) parser.add_argument( - "--use-protocols", + "--include-protocols", choices=("yes", "no"), default="no", help=( - "Generate a protocols.py module with version-agnostic Protocol " + "Include a protocols.py module with version-agnostic Protocol " "types for every class. Default is '%(default)s'" ), ) @@ -156,7 +156,7 @@ def get_file(name): yield get_file("cmd.py") yield get_file("__main__.py") - if self.__use_protocols: + if self.__include_protocols: yield get_file("protocols.py") def get_extra_env(self): @@ -176,7 +176,7 @@ def get_additional_render_args(self, model): use_slots = False return { "include_main": self.__include_main, - "use_protocols": self.__use_protocols, + "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 64509598..3ef99961 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -14,7 +14,7 @@ if TYPE_CHECKING: # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} -{%- if use_protocols %} +{%- if include_protocols %} if TYPE_CHECKING: from . import protocols # noqa: F401, I202 {%- endif %} @@ -22,7 +22,7 @@ if TYPE_CHECKING: def __getattr__(name: str) -> Any: # Lazy attribute access: submodules and model content loaded on first use (PEP 562). -{%- if use_protocols %} +{%- if include_protocols %} if name == "protocols": return importlib.import_module(f"{__name__}.protocols") {%- endif %} diff --git a/tests/test_python.py b/tests/test_python.py index fc3cc5f7..4c0df80a 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2287,13 +2287,13 @@ def test_version(model): @pytest.fixture(scope="module") def python_model_v1_protocols(tmp_path_factory, model_context_url): - """v1 model generated with --protocols yes.""" + """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], - ["--use-protocols", "yes"], + ["--include-protocols", "yes"], output_dir, ) (output_dir / "py.typed").touch() @@ -2302,13 +2302,13 @@ def python_model_v1_protocols(tmp_path_factory, model_context_url): @pytest.fixture(scope="module") def python_model_v2_protocols(tmp_path_factory, model_context_url): - """v2 model (backward-compatible extension) generated with --protocols yes.""" + """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], - ["--use-protocols", "yes"], + ["--include-protocols", "yes"], output_dir, ) (output_dir / "py.typed").touch() @@ -2324,7 +2324,7 @@ def test_protocols_file_generated(self, tmp_path, model_context_url): output_dir = tmp_path / "pymodel" shacl2code_generate( ["--input", TEST_MODEL, "--context", model_context_url], - ["--use-protocols", "yes"], + ["--include-protocols", "yes"], output_dir, ) assert (output_dir / "protocols.py").exists() @@ -2342,7 +2342,7 @@ def test_mypy(self, tmp_path, model_context_url): output_dir = tmp_path / "pymodel" shacl2code_generate( ["--input", TEST_MODEL, "--context", model_context_url], - ["--use-protocols", "yes"], + ["--include-protocols", "yes"], output_dir, ) (output_dir / "py.typed").touch() @@ -2352,7 +2352,7 @@ def test_flake8(self, tmp_path, model_context_url): output_dir = tmp_path / "pymodel" shacl2code_generate( ["--input", TEST_MODEL, "--context", model_context_url], - ["--use-protocols", "yes"], + ["--include-protocols", "yes"], output_dir, ) subprocess.run( From e9fcdc371e098618891c5d575f2419a96f1c7a53 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Wed, 1 Jul 2026 22:23:27 +0100 Subject: [PATCH 10/16] Add test for case that datetime is not used in protocol Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 26 +++- .../lang/templates/python/protocols.py.j2 | 17 +- tests/data/no-datetime.ttl | 18 +++ tests/test_python.py | 146 ++++++++++++++++-- 4 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 tests/data/no-datetime.ttl diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 7b882738..40acf4f4 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 = { @@ -60,11 +62,6 @@ } -def protocol_name(cls): - """Protocol name for a class - same as the class name (identity mapping).""" - return varname(*cls.clsname) - - def varname(*name): """Make a valid Python variable name.""" name = "_".join(name) @@ -80,6 +77,23 @@ def varname(*name): return name +def protocols_use_datetime(classes: Iterable[Class]) -> bool: + """Whether any class has a plain (non-list, non-ref) datetime-typed property. + + Determines if protocols.py.j2 needs to import ``datetime`` -- mirrors the + scalar-property branch in that template exactly, so the import is only + emitted when it will actually be referenced (avoids flake8 F401). + """ + 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.""" @@ -162,7 +176,7 @@ def get_file(name): def get_extra_env(self): return { "varname": varname, - "protocol_name": protocol_name, + "protocols_use_datetime": protocols_use_datetime, "DATATYPE_CLASSES": DATATYPE_CLASSES, "DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES, } diff --git a/src/shacl2code/lang/templates/python/protocols.py.j2 b/src/shacl2code/lang/templates/python/protocols.py.j2 index 9494370b..b32decea 100644 --- a/src/shacl2code/lang/templates/python/protocols.py.j2 +++ b/src/shacl2code/lang/templates/python/protocols.py.j2 @@ -8,9 +8,12 @@ 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. -Object-reference and list properties expose a typed getter (covariant read) and -an ``Any`` setter (version-agnostic write). Scalar properties are plain -read-write attributes. +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. @@ -18,7 +21,7 @@ annotations. from __future__ import annotations -from datetime import datetime +{{"from datetime import datetime" if protocols_use_datetime(classes) else ""}} from typing import Any, Iterable, Iterator, Optional, Protocol, Set, Tuple @@ -64,10 +67,10 @@ class SHACLObjectSetProtocol(Protocol): # DOMAIN CLASSES {% for class in classes %} -class {{ protocol_name(class) }}( +class {{ varname(*class.clsname) }}( {%- if class.parent_ids %} {%- for id in class.parent_ids -%} - {{- protocol_name(classes.get(id)) }}{%- if not loop.last -%}, {% endif -%} + {{- varname(*classes.get(id).clsname) }}{%- if not loop.last -%}, {% endif -%} {%- endfor -%} {%- else -%} SHACLObjectProtocol @@ -81,7 +84,7 @@ class {{ protocol_name(class) }}( {{ '"' }}{{ '"' }}{{ '"' }} {%- endif %} - def _protocol_{{ protocol_name(class) }}(self) -> None: ... + def _protocol_{{ varname(*class.clsname) }}(self) -> None: ... {%- if class.id_property %} {{ class.id_property }}: Optional[str] {%- endif %} 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/test_python.py b/tests/test_python.py index 4c0df80a..a33b3499 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -13,6 +13,7 @@ import textwrap from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import Iterable, Tuple import jsonschema @@ -22,6 +23,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__) @@ -2283,10 +2288,40 @@ def test_version(model): # --------------------------------------------------------------------------- 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, model_context_url): +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" @@ -2301,7 +2336,9 @@ def python_model_v1_protocols(tmp_path_factory, model_context_url): @pytest.fixture(scope="module") -def python_model_v2_protocols(tmp_path_factory, model_context_url): +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" @@ -2320,7 +2357,9 @@ class TestProtocolOutput: Tests for generated protocols.py - syntax, typing, and flake8. """ - def test_protocols_file_generated(self, tmp_path, model_context_url): + 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], @@ -2329,7 +2368,9 @@ def test_protocols_file_generated(self, tmp_path, model_context_url): ) assert (output_dir / "protocols.py").exists() - def test_protocols_file_not_generated_by_default(self, tmp_path, model_context_url): + 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], @@ -2338,7 +2379,7 @@ def test_protocols_file_not_generated_by_default(self, tmp_path, model_context_u ) assert not (output_dir / "protocols.py").exists() - def test_mypy(self, tmp_path, model_context_url): + 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], @@ -2348,7 +2389,7 @@ def test_mypy(self, tmp_path, model_context_url): (output_dir / "py.typed").touch() subprocess.run(["mypy", output_dir], encoding="utf-8", check=True) - def test_flake8(self, tmp_path, model_context_url): + 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], @@ -2361,6 +2402,38 @@ def test_flake8(self, tmp_path, model_context_url): 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: """ @@ -2368,7 +2441,9 @@ class TestProtocolConformance: and the discriminator keeps structurally-identical classes distinct. """ - def test_conformance_mypy(self, python_model_v1_protocols, tmp_path): + 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. @@ -2414,7 +2489,55 @@ def get_scalar(o: protocols.test_class) -> Optional[str]: ) assert r.returncode == 0, r.stdout + r.stderr - def test_discriminator_mypy(self, python_model_v1_protocols, tmp_path): + 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 @@ -2453,8 +2576,11 @@ class TestProtocolCrossVersion: """ def test_cross_version_mypy( - self, python_model_v1_protocols, python_model_v2_protocols, tmp_path - ): + 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). From 0ef385aa9cd9ea37090962cbdd702fb09021e9d9 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 2 Jul 2026 03:01:40 +0100 Subject: [PATCH 11/16] Fix __dir__ gap Signed-off-by: Arthit Suriyawongkul --- .../lang/templates/python/__init__.py.j2 | 18 +++++- tests/test_python.py | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 3ef99961..7ec1f2a9 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -7,7 +7,7 @@ from __future__ import annotations import importlib -from typing import Any, TYPE_CHECKING +from typing import Any, List, TYPE_CHECKING if TYPE_CHECKING: from .model import * # noqa: F401, F403 @@ -16,7 +16,7 @@ if TYPE_CHECKING: """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} {%- if include_protocols %} if TYPE_CHECKING: - from . import protocols # noqa: F401, I202 + from . import protocols # noqa: F401, I100, I202 {%- endif %} @@ -37,5 +37,19 @@ def __getattr__(name: str) -> Any: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +def __dir__() -> List[str]: + # Import model here (not at module scope) so dir() stays opt-in: it costs + # a model load only when introspection is actually requested (PEP 562). + 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 diff --git a/tests/test_python.py b/tests/test_python.py index a33b3499..252bbf96 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2379,6 +2379,46 @@ def test_protocols_file_not_generated_by_default( ) 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). Without a custom __dir__, Python falls back + to only the raw module internals (imports, dunders) -- see + PLAN-python-protocols.md Lesson 7. + """ + 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( @@ -2402,6 +2442,26 @@ def test_flake8(self, tmp_path: Path, model_context_url: str) -> None: 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 (e.g. the I100 ordering regression fixed in + PLAN-python-protocols.md Lesson 7) 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 From 2c7e6dc0e1332639577c2a19c73b02f87f34a5d2 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Fri, 3 Jul 2026 22:15:00 +0100 Subject: [PATCH 12/16] Prevent model load when load cmd Signed-off-by: Arthit Suriyawongkul --- .../lang/templates/python/__init__.py.j2 | 19 ++++- .../lang/templates/python/cmd.py.j2 | 25 ++++-- tests/test_python.py | 85 +++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 7ec1f2a9..531da8fb 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -7,7 +7,8 @@ from __future__ import annotations import importlib -from typing import Any, List, TYPE_CHECKING +from types import ModuleType +from typing import Any, List, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from .model import * # noqa: F401, F403 @@ -21,7 +22,21 @@ if TYPE_CHECKING: def __getattr__(name: str) -> Any: - # Lazy attribute access: submodules and model content loaded on first use (PEP 562). + # 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") 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/tests/test_python.py b/tests/test_python.py index 252bbf96..31d55961 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2704,3 +2704,88 @@ def set_ref(o: v1p.test_class, v: {v2_name}.test_class) -> None: "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_test_class" in imported + assert "http_example_org_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] From 142612238ca77de19bf3bdadd7a4ed076775121c Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Tue, 7 Jul 2026 00:20:57 +0100 Subject: [PATCH 13/16] Update test-v2.ttl to new base Follow the updated test.ttl Signed-off-by: Arthit Suriyawongkul --- tests/data/model/test-v2.ttl | 2 +- tests/test_python.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/data/model/test-v2.ttl b/tests/data/model/test-v2.ttl index 15327982..12f05a41 100644 --- a/tests/data/model/test-v2.ttl +++ b/tests/data/model/test-v2.ttl @@ -1,7 +1,7 @@ # 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 . +@base . @prefix rdf: . @prefix rdfs: . @prefix sh: . diff --git a/tests/test_python.py b/tests/test_python.py index 1732bf4c..4bb66bce 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2766,8 +2766,8 @@ def test_wildcard_import_is_eager_and_matches_public_names( imported = {k for k in ns if not k.startswith("__")} # Domain classes, from the test fixture model. - assert "http_example_org_test_class" in imported - assert "http_example_org_parent_class" in imported + 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 From a0b8f7a527387ad07124f308c1835dbd024a19f8 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Tue, 7 Jul 2026 01:07:08 +0100 Subject: [PATCH 14/16] Add module-level IS_PRERELEASE For cheap check of is_release, without the need to actually load the model Signed-off-by: Arthit Suriyawongkul --- .../lang/templates/python/__init__.py.j2 | 5 +++ tests/data/prerelease.ttl | 25 ++++++++++++ tests/test_python.py | 39 ++++++++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/data/prerelease.ttl diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index 531da8fb..dc836ebc 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -13,6 +13,11 @@ from typing import Any, List, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from .model import * # noqa: F401, F403 +# True if any ontology behind this version's model is pre-release +# (a model can merge multiple --input files, each with its own owl:Ontology). +# Cheap check -- does not import model.py. +IS_PRERELEASE = {{ontologies | selectattr("is_prerelease") | list | length > 0}} + # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} {%- if include_protocols %} 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 4bb66bce..f55ee4ba 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2315,6 +2315,37 @@ 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 is a version-level constant a consumer can read without + loading model.py -- True when any ontology in the source TTL carries + sh-to-code:isPreRelease, False otherwise. + """ + 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: + 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] + # --------------------------------------------------------------------------- # Protocol generation tests # --------------------------------------------------------------------------- @@ -2417,9 +2448,7 @@ def test_dir_includes_lazy_names( """ __dir__() must expose model classes, "protocols", and "main" for dir()/tab-completion even though they are loaded lazily via - __getattr__ (PEP 562). Without a custom __dir__, Python falls back - to only the raw module internals (imports, dunders) -- see - PLAN-python-protocols.md Lesson 7. + __getattr__ (PEP 562). """ module_name = "pymodel_dir_check" output_dir = tmp_path / module_name @@ -2478,9 +2507,7 @@ 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 (e.g. the I100 ordering regression fixed in - PLAN-python-protocols.md Lesson 7) that a protocols.py-only check - would miss. + import inside __init__.py that a protocols.py-only check would miss. """ output_dir = tmp_path / "pymodel" shacl2code_generate( From 1118747eba4e996dbefa3de153a9e3151ff2681a Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Tue, 7 Jul 2026 02:02:06 +0100 Subject: [PATCH 15/16] Fix formatting Signed-off-by: Arthit Suriyawongkul --- tests/test_python.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_python.py b/tests/test_python.py index f55ee4ba..4e2f77a8 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2298,6 +2298,7 @@ def test_version(model): assert model.VERSION_STRING == MODEL_VERSION assert model.VERSION == convert_version_string(MODEL_VERSION) + # --------------------------------------------------------------------------- # Ontology metadata tests # --------------------------------------------------------------------------- @@ -2346,6 +2347,7 @@ def test_is_prerelease_constant(tmp_path: Path) -> None: if m == "pymodel_prerelease" or m.startswith("pymodel_prerelease."): del sys.modules[m] + # --------------------------------------------------------------------------- # Protocol generation tests # --------------------------------------------------------------------------- From e5c49c1aeac9c1d4268e6ea565f4e662870654d6 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Tue, 7 Jul 2026 13:01:22 +0100 Subject: [PATCH 16/16] Fires FutureWarning when import pre-release model Signed-off-by: Arthit Suriyawongkul --- src/shacl2code/lang/python.py | 7 +--- .../lang/templates/python/__init__.py.j2 | 15 ++++--- tests/test_python.py | 40 ++++++++++++++++--- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index 40acf4f4..632d5e57 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -78,12 +78,7 @@ def varname(*name): def protocols_use_datetime(classes: Iterable[Class]) -> bool: - """Whether any class has a plain (non-list, non-ref) datetime-typed property. - - Determines if protocols.py.j2 needs to import ``datetime`` -- mirrors the - scalar-property branch in that template exactly, so the import is only - emitted when it will actually be referenced (avoids flake8 F401). - """ + """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 diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2 index dc836ebc..a870ebc5 100644 --- a/src/shacl2code/lang/templates/python/__init__.py.j2 +++ b/src/shacl2code/lang/templates/python/__init__.py.j2 @@ -7,17 +7,23 @@ 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 version's model is pre-release -# (a model can merge multiple --input files, each with its own owl:Ontology). -# Cheap check -- does not import model.py. +# 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 %} @@ -58,8 +64,7 @@ def __getattr__(name: str) -> Any: def __dir__() -> List[str]: - # Import model here (not at module scope) so dir() stays opt-in: it costs - # a model load only when introspection is actually requested (PEP 562). + # 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 %} diff --git a/tests/test_python.py b/tests/test_python.py index 4e2f77a8..a7c7bd2b 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -11,6 +11,7 @@ import subprocess import sys import textwrap +import warnings from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Iterable, Tuple @@ -2303,6 +2304,7 @@ def test_version(model): # Ontology metadata tests # --------------------------------------------------------------------------- + def test_ontology(model): classes = list(model.SHACLObject.CLASSES.values()) assert classes @@ -2321,11 +2323,7 @@ def test_prerelease_warning(model): def test_is_prerelease_constant(tmp_path: Path) -> None: - """ - IS_PRERELEASE is a version-level constant a consumer can read without - loading model.py -- True when any ontology in the source TTL carries - sh-to-code:isPreRelease, False otherwise. - """ + """IS_PRERELEASE reflects sh-to-code:isPreRelease without loading model.py.""" prerelease_dir = tmp_path / "pymodel_prerelease" shacl2code_generate(["--input", PRERELEASE_MODEL], [], prerelease_dir) @@ -2337,7 +2335,8 @@ def test_is_prerelease_constant(tmp_path: Path) -> None: sys.path.insert(0, str(tmp_path)) try: - pkg = importlib.import_module("pymodel_prerelease") + 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 @@ -2348,6 +2347,35 @@ def test_is_prerelease_constant(tmp_path: Path) -> None: 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 # ---------------------------------------------------------------------------