diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py index ac26bf93..66d3a8bd 100644 --- a/src/shacl2code/lang/python.py +++ b/src/shacl2code/lang/python.py @@ -36,15 +36,22 @@ } +# Names a generated property would collide with; varname() renames it instead. +# +# Gap: class names are unguarded. Under a context they compact to short names, +# so a class compacting to "Property", "Ontology", "SHACLObjectSet", etc. +# silently shadows it. SHACLOBJECT_RESERVED_WORDS = { "AUTO_NAMED_INDIVIDUALS", "CLASSES", + "CLOSED", "COMPACT_TYPE", "ID_ALIAS", "IS_ABSTRACT", "IS_DEPRECATED", "NAMED_INDIVIDUALS", "NODE_KIND", + "ONTOLOGY", "PROPERTIES", "TYPE", "decode", diff --git a/src/shacl2code/lang/templates/python/model.py.j2 b/src/shacl2code/lang/templates/python/model.py.j2 index 6724e49d..ffb924fa 100644 --- a/src/shacl2code/lang/templates/python/model.py.j2 +++ b/src/shacl2code/lang/templates/python/model.py.j2 @@ -8,7 +8,6 @@ from __future__ import annotations import decimal -import functools import hashlib import json import re @@ -33,7 +32,7 @@ from typing import ( List, Optional, Set, - TYPE_CHECKING, + TYPE_CHECKING as _TYPE_CHECKING, Tuple, Type, TypeVar, @@ -42,6 +41,70 @@ from typing import ( overload, ) +# fmt: off +"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +__all__ = [ + "AnyURIProp", + "BooleanProp", + "CONTEXT_URLS", + "ClassProp", + "DataPath", + "DateTimeProp", + "DateTimeStampProp", + "DecodeError", + "DecodeState", + "Decoder", + "EncodeError", + "EncodeState", + "Encoder", + "EnumProp", + "FloatProp", + "IRIProp", + "IntegerProp", + "JSONLDDecoder", + "JSONLDDeserializer", + "JSONLDEncoder", + "JSONLDInlineEncoder", + "JSONLDInlineSerializer", + "JSONLDSerializer", + "ListProp", + "ListProxy", + "NodeKind", + "NonNegativeIntegerProp", + "ObjectProp", + "Ontology", + "PathError", + "PositiveIntegerProp", + "Property", + "SHACLExtensibleObject", + "SHACLObject", + "SHACLObjectMeta", + "SHACLObjectSet", + "StringProp", + "T_PropV", + "T_SHACLObject", + "ValidationError", + "check_type", + "decode_context", + "encode_context", + "is_IRI", + "is_blank_node", +{%- if version_str %} + "VERSION_STRING", +{%- endif %} +{%- if version %} + "VERSION", +{%- endif %} +{%- for o in ontologies %} + "{{ varname(o.name).upper() }}", +{%- endfor %} +{%- for class in classes %} + "{{ varname(*class.clsname) }}", +{%- endfor %} +] +{{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" +# fmt: on + T_PropV = TypeVar("T_PropV") @@ -1021,7 +1084,6 @@ _ALL_NAMED_INDIVIDUAL_IDS: Set[str] = set() T_SHACLObject = TypeVar("T_SHACLObject", bound="SHACLObject") -@functools.total_ordering class SHACLObject(metaclass=SHACLObjectMeta): """Base class for all SHACL-defined objects, providing property access, encoding, and decoding.""" @@ -1537,6 +1599,15 @@ class SHACLObject(metaclass=SHACLObjectMeta): def __lt__(self, other: Any) -> bool: return SHACLObject._sort_key(self) < SHACLObject._sort_key(other) + def __le__(self, other: Any) -> bool: + return SHACLObject._sort_key(self) <= SHACLObject._sort_key(other) + + def __gt__(self, other: Any) -> bool: + return SHACLObject._sort_key(self) > SHACLObject._sort_key(other) + + def __ge__(self, other: Any) -> bool: + return SHACLObject._sort_key(self) >= SHACLObject._sort_key(other) + class SHACLExtensibleObject(SHACLObject): """A SHACLObject that accepts and round-trips arbitrary IRI-keyed extension properties.""" @@ -2960,7 +3031,7 @@ try: from rdflib.namespace import RDF from rdflib.term import IdentifiedNode - if TYPE_CHECKING: + if _TYPE_CHECKING: from rdflib import Graph # noqa: I300 from rdflib.term import Node @@ -3218,6 +3289,8 @@ try: state = EncodeState(objset) objset.encode(e, state) + __all__ += ["RDFDecoder", "RDFDeserializer", "RDFEncoder", "RDFSerializer"] + except ImportError: pass diff --git a/src/shacl2code/lang/templates/python/model.pyi.j2 b/src/shacl2code/lang/templates/python/model.pyi.j2 index 66786d2c..31d03d40 100644 --- a/src/shacl2code/lang/templates/python/model.pyi.j2 +++ b/src/shacl2code/lang/templates/python/model.pyi.j2 @@ -5,7 +5,7 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, ABCMeta, abstractmethod from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -28,6 +28,79 @@ from typing import ( overload, ) +# Optional dependency: falls back to Any when rdflib is not installed +from rdflib import Graph # type: ignore[import-not-found] +from rdflib.term import Node # type: ignore[import-not-found] + +# fmt: off +"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }} +__all__ = [ + "AnyURIProp", + "BooleanProp", + "CONTEXT_URLS", + "ClassProp", + "DataPath", + "DateTimeProp", + "DateTimeStampProp", + "DecodeError", + "DecodeState", + "Decoder", + "EncodeError", + "EncodeState", + "Encoder", + "EnumProp", + "FloatProp", + "IRIProp", + "IntegerProp", + "JSONLDDecoder", + "JSONLDDeserializer", + "JSONLDEncoder", + "JSONLDInlineEncoder", + "JSONLDInlineSerializer", + "JSONLDSerializer", + "ListProp", + "ListProxy", + "NodeKind", + "NonNegativeIntegerProp", + "ObjectProp", + "Ontology", + "PathError", + "PositiveIntegerProp", + "Property", + # Only exist at runtime with the optional rdflib dependency installed + "RDFDecoder", + "RDFDeserializer", + "RDFEncoder", + "RDFSerializer", + "SHACLExtensibleObject", + "SHACLObject", + "SHACLObjectMeta", + "SHACLObjectSet", + "StringProp", + "T_PropV", + "T_SHACLObject", + "ValidationError", + "check_type", + "decode_context", + "encode_context", + "is_IRI", + "is_blank_node", +{%- if version_str %} + "VERSION_STRING", +{%- endif %} +{%- if version %} + "VERSION", +{%- endif %} +{%- for o in ontologies %} + "{{ varname(o.name).upper() }}", +{%- endfor %} +{%- for class in classes %} + "{{ varname(*class.clsname) }}", +{%- endfor %} +] +{{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" +# fmt: on + T_PropV = TypeVar("T_PropV") @@ -98,9 +171,11 @@ class Property(ABC, Generic[T_PropV]): path: DataPath, ) -> Optional[T_PropV]: ... def to_string(self, path: DataPath, value: T_PropV) -> str: ... + @abstractmethod def encode( self, encoder: Encoder, value: T_PropV, state: EncodeState, path: DataPath ) -> None: ... + @abstractmethod def decode( self, decoder: Decoder, state: DecodeState, path: DataPath ) -> Optional[T_PropV]: ... @@ -186,7 +261,7 @@ class FloatProp(Property[float]): ) -> Optional[float]: ... -class IRIProp(Property[T_PropV]): +class IRIProp(Property[T_PropV], metaclass=ABCMeta): context: Tuple[Tuple[str, str], ...] def __init__( @@ -342,7 +417,7 @@ def is_blank_node(s: Any) -> bool: ... # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} {% if version_str %}VERSION_STRING: str{% endif %} -{% if version %}VERSION: tuple[int, ...]{% endif %} +{% if version %}VERSION: Tuple[Union[int, str], ...]{% endif %} {{ '"' }}{{ '"' }}{{ '"' }}Format Guard""" # fmt: on @@ -392,15 +467,23 @@ class DecodeState: class Encoder(ABC): + @abstractmethod def write_string(self, v: str) -> None: ... + @abstractmethod def write_datetime(self, v: str) -> None: ... + @abstractmethod def write_integer(self, v: int) -> None: ... + @abstractmethod def write_iri(self, v: str, compact: Optional[str] = None) -> None: ... + @abstractmethod def write_enum( self, v: str, e: Property[Any], compact: Optional[str] = None ) -> None: ... + @abstractmethod def write_bool(self, v: bool) -> None: ... + @abstractmethod def write_float(self, v: float) -> None: ... + @abstractmethod def write_object( self, typ: str, @@ -410,30 +493,50 @@ class Encoder(ABC): compact_id: Optional[str], needs_id: bool, ) -> Iterator[Encoder]: ... + @abstractmethod def write_property( self, iri: str, compact: Optional[str] = None ) -> Iterator[Encoder]: ... + @abstractmethod def write_list(self) -> Iterator[Encoder]: ... + @abstractmethod def write_list_item(self) -> Iterator[Encoder]: ... + @abstractmethod def write_object_list(self) -> Iterator[Encoder]: ... + @abstractmethod def write_dict(self) -> Iterator[Encoder]: ... class Decoder(ABC): + @abstractmethod def read_value(self) -> Any: ... + @abstractmethod def read_string(self) -> Optional[str]: ... + @abstractmethod def read_datetime(self) -> Optional[str]: ... + @abstractmethod def read_integer(self) -> Optional[int]: ... + @abstractmethod def read_iri(self) -> Optional[str]: ... + @abstractmethod def read_enum(self, e: EnumProp) -> Optional[str]: ... + @abstractmethod def read_bool(self) -> Optional[bool]: ... + @abstractmethod def read_float(self) -> Optional[float]: ... + @abstractmethod def read_list(self) -> Iterator[Decoder]: ... + @abstractmethod def is_list(self) -> bool: ... + @abstractmethod def read_object(self) -> Tuple[Any, Decoder]: ... + @abstractmethod def read_property(self, key: str) -> Iterator[Optional[Decoder]]: ... + @abstractmethod def is_object(self) -> bool: ... + @abstractmethod def object_keys(self) -> Iterator[str]: ... + @abstractmethod def read_object_id(self, alias: Optional[str] = None) -> Optional[Any]: ... @@ -495,6 +598,9 @@ class SHACLObject(metaclass=SHACLObjectMeta): def __hash__(self) -> int: ... def __eq__(self, other: Any) -> bool: ... def __lt__(self, other: Any) -> bool: ... + def __le__(self, other: Any) -> bool: ... + def __gt__(self, other: Any) -> bool: ... + def __ge__(self, other: Any) -> bool: ... class SHACLExtensibleObject(SHACLObject): @@ -558,6 +664,84 @@ class SHACLObjectSet: ) -> None: ... +class JSONLDDecoder(Decoder): + def __init__(self, data: Any, root: bool = False) -> None: ... + def read_value(self) -> Any: ... + def read_string(self) -> Optional[str]: ... + def read_datetime(self) -> Optional[str]: ... + def read_integer(self) -> Optional[int]: ... + def read_iri(self) -> Optional[str]: ... + def read_enum(self, e: EnumProp) -> Optional[str]: ... + def read_bool(self) -> Optional[bool]: ... + def read_float(self) -> Optional[float]: ... + def read_list(self) -> Iterator[JSONLDDecoder]: ... + def is_list(self) -> bool: ... + def read_object(self) -> Tuple[Any, JSONLDDecoder]: ... + def read_property(self, key: str) -> Iterator[Optional[JSONLDDecoder]]: ... + def is_object(self) -> bool: ... + def object_keys(self) -> Iterator[str]: ... + def read_object_id(self, alias: Optional[str] = None) -> Optional[Any]: ... + + +class JSONLDEncoder(Encoder): + def __init__(self, data: Optional[Any] = None) -> None: ... + def write_string(self, v: str) -> None: ... + def write_datetime(self, v: str) -> None: ... + def write_integer(self, v: int) -> None: ... + def write_iri(self, v: str, compact: Optional[str] = None) -> None: ... + def write_enum( + self, v: str, e: Property[Any], compact: Optional[str] = None + ) -> None: ... + def write_bool(self, v: bool) -> None: ... + def write_float(self, v: float) -> None: ... + def write_object( + self, + typ: str, + compact_type: Optional[str], + id_alias: Optional[str], + _id: str, + compact_id: Optional[str], + needs_id: bool, + ) -> Iterator[JSONLDEncoder]: ... + def write_property( + self, iri: str, compact: Optional[str] = None + ) -> Iterator[JSONLDEncoder]: ... + def write_list(self) -> Iterator[JSONLDEncoder]: ... + def write_list_item(self) -> Iterator[JSONLDEncoder]: ... + def write_object_list(self) -> Iterator[JSONLDEncoder]: ... + def write_dict(self) -> Iterator[JSONLDEncoder]: ... + + +class JSONLDInlineEncoder(Encoder): + def __init__(self, f: BinaryIO, sha1: Any, in_dict: bool = False) -> None: ... + def write(self, s: str) -> None: ... + def write_string(self, v: str) -> None: ... + def write_datetime(self, v: str) -> None: ... + def write_integer(self, v: int) -> None: ... + def write_iri(self, v: str, compact: Optional[str] = None) -> None: ... + def write_enum( + self, v: str, e: Property[Any], compact: Optional[str] = None + ) -> None: ... + def write_bool(self, v: bool) -> None: ... + def write_float(self, v: float) -> None: ... + def write_object( + self, + typ: str, + compact_type: Optional[str], + id_alias: Optional[str], + _id: str, + compact_id: Optional[str], + needs_id: bool, + ) -> Iterator[JSONLDInlineEncoder]: ... + def write_property( + self, iri: str, compact: Optional[str] = None + ) -> Iterator[JSONLDInlineEncoder]: ... + def write_list(self) -> Iterator[JSONLDInlineEncoder]: ... + def write_list_item(self) -> Iterator[JSONLDInlineEncoder]: ... + def write_object_list(self) -> Iterator[JSONLDInlineEncoder]: ... + def write_dict(self) -> Iterator[JSONLDInlineEncoder]: ... + + class JSONLDDeserializer: def deserialize_data(self, data: Any, objectset: SHACLObjectSet) -> None: ... def read(self, f: BinaryIO, objectset: SHACLObjectSet) -> None: ... @@ -586,10 +770,80 @@ class JSONLDInlineSerializer: ) -> str: ... +class RDFDecoder(Decoder): + def __init__( + self, + graph: Graph, + subject: Optional[Node] = None, + predicate: Optional[Node] = None, + value: Optional[Node] = None, + ) -> None: ... + def read_value(self) -> Any: ... + def read_string(self) -> Optional[str]: ... + def read_datetime(self) -> Optional[str]: ... + def read_integer(self) -> Optional[int]: ... + def read_iri(self) -> Optional[str]: ... + def read_enum(self, e: EnumProp) -> Optional[str]: ... + def read_bool(self) -> Optional[bool]: ... + def read_float(self) -> Optional[float]: ... + def read_list(self) -> Iterator[RDFDecoder]: ... + def is_list(self) -> bool: ... + def read_object(self) -> Tuple[Any, RDFDecoder]: ... + def read_property(self, key: str) -> Iterator[Optional[RDFDecoder]]: ... + def is_object(self) -> bool: ... + def object_keys(self) -> Iterator[str]: ... + def read_object_id(self, alias: Optional[str] = None) -> Optional[Any]: ... + + +class RDFDeserializer: + def read(self, graph: Graph, objset: SHACLObjectSet) -> None: ... + + +class RDFEncoder(Encoder): + def __init__( + self, + graph: Graph, + subject: Optional[Node] = None, + predicate: Optional[Node] = None, + ) -> None: ... + def write_string(self, v: str) -> None: ... + def write_datetime(self, v: str) -> None: ... + def write_integer(self, v: int) -> None: ... + def write_iri(self, v: str, compact: Optional[str] = None) -> None: ... + def write_enum( + self, v: str, e: Property[Any], compact: Optional[str] = None + ) -> None: ... + def write_bool(self, v: bool) -> None: ... + def write_float(self, v: float) -> None: ... + def write_object( + self, + typ: str, + compact_type: Optional[str], + id_alias: Optional[str], + _id: str, + compact_id: Optional[str], + needs_id: bool, + ) -> Iterator[Encoder]: ... + def write_property( + self, iri: str, compact: Optional[str] = None + ) -> Iterator[Encoder]: ... + def write_list(self) -> Iterator[RDFEncoder]: ... + def write_list_item(self) -> Iterator[RDFEncoder]: ... + def write_object_list(self) -> Iterator[RDFEncoder]: ... + def write_dict(self) -> Iterator[RDFEncoder]: ... + + +class RDFSerializer: + def write(self, objset: SHACLObjectSet, g: Graph) -> None: ... + + def encode_context(encoder: Any, objectset: SHACLObjectSet) -> None: ... def decode_context(decoder: Any, objectset: SHACLObjectSet) -> List[str]: ... +CONTEXT_URLS: List[str] + + # fmt: off """Format Guard{{ '"' }}{{ '"' }}{{ '"' }} # ONTOLOGIES diff --git a/tests/data/reserved-words.ttl b/tests/data/reserved-words.ttl index a95dc1f6..4835a109 100644 --- a/tests/data/reserved-words.ttl +++ b/tests/data/reserved-words.ttl @@ -30,5 +30,17 @@ sh:name "class" ; sh:path ; sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:name "ONTOLOGY" ; + sh:path ; + sh:maxCount 1 + ], + [ + sh:datatype xsd:string ; + sh:name "CLOSED" ; + sh:path ; + sh:maxCount 1 ] . diff --git a/tests/data/stubtest/allow.txt b/tests/data/stubtest/allow.txt index 8e1b73b7..29532a24 100644 --- a/tests/data/stubtest/allow.txt +++ b/tests/data/stubtest/allow.txt @@ -1,83 +1 @@ -pymodel.CONTEXT_URLS -pymodel.TYPE_CHECKING -pymodel.VERSION -pymodel.VERSION_STRING -pymodel.model.CONTEXT_URLS -pymodel.model.ClassProp.__init__ -pymodel.model.Decoder.is_list -pymodel.model.Decoder.is_object -pymodel.model.Decoder.object_keys -pymodel.model.Decoder.read_bool -pymodel.model.Decoder.read_datetime -pymodel.model.Decoder.read_enum -pymodel.model.Decoder.read_float -pymodel.model.Decoder.read_integer -pymodel.model.Decoder.read_iri -pymodel.model.Decoder.read_list -pymodel.model.Decoder.read_object -pymodel.model.Decoder.read_object_id -pymodel.model.Decoder.read_property -pymodel.model.Decoder.read_string -pymodel.model.Decoder.read_value -pymodel.model.Encoder.write_bool -pymodel.model.Encoder.write_datetime -pymodel.model.Encoder.write_dict -pymodel.model.Encoder.write_enum -pymodel.model.Encoder.write_float -pymodel.model.Encoder.write_integer -pymodel.model.Encoder.write_iri -pymodel.model.Encoder.write_list -pymodel.model.Encoder.write_list_item -pymodel.model.Encoder.write_object -pymodel.model.Encoder.write_object_list -pymodel.model.Encoder.write_property -pymodel.model.Encoder.write_string -pymodel.model.JSONLDDecoder -pymodel.model.JSONLDEncoder -pymodel.model.JSONLDInlineEncoder -pymodel.Property.decode -pymodel.model.Property.decode -pymodel.Property.encode -pymodel.model.Property.encode -pymodel.model.RDFDecoder -pymodel.model.RDFDeserializer -pymodel.model.RDFEncoder -pymodel.model.RDFSerializer -pymodel.model.SHACLObject.__ge__ -pymodel.model.SHACLObject.__gt__ -pymodel.model.SHACLObject.__le__ -pymodel.cmd.SHACLObject.__ge__ -pymodel.cmd.SHACLObject.__gt__ -pymodel.cmd.SHACLObject.__le__ -pymodel.SHACLObject.__ge__ -pymodel.SHACLObject.__gt__ -pymodel.SHACLObject.__le__ -pymodel.Decoder.is_list -pymodel.Decoder.is_object -pymodel.Decoder.object_keys -pymodel.Decoder.read_bool -pymodel.Decoder.read_datetime -pymodel.Decoder.read_enum -pymodel.Decoder.read_float -pymodel.Decoder.read_integer -pymodel.Decoder.read_iri -pymodel.Decoder.read_list -pymodel.Decoder.read_object -pymodel.Decoder.read_object_id -pymodel.Decoder.read_property -pymodel.Decoder.read_string -pymodel.Decoder.read_value -pymodel.Encoder.write_bool -pymodel.Encoder.write_datetime -pymodel.Encoder.write_dict -pymodel.Encoder.write_enum -pymodel.Encoder.write_float -pymodel.Encoder.write_integer -pymodel.Encoder.write_iri -pymodel.Encoder.write_list -pymodel.Encoder.write_list_item -pymodel.Encoder.write_object -pymodel.Encoder.write_object_list -pymodel.Encoder.write_property -pymodel.Encoder.write_string - +# stubtest allowlist for the generated Python bindings. diff --git a/tests/test_python.py b/tests/test_python.py index a4c54b77..b2586382 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: MIT +import ast import hashlib import importlib import json @@ -11,6 +12,7 @@ import subprocess import sys import textwrap +import types from datetime import datetime, timedelta, timezone from pathlib import Path @@ -22,6 +24,8 @@ import rdflib +from shacl2code.lang.python import SHACLOBJECT_RESERVED_WORDS + from testfixtures import jsonvalidation, timetests THIS_FILE = Path(__file__) @@ -234,21 +238,22 @@ def test_stubtest(self, tmp_path, args, python_args): pythonpath = os.environ.get("PYTHONPATH") if pythonpath: - pythonpath = os.pathsep.join(str(tmp_path), pythonpath) + pythonpath = os.pathsep.join([str(tmp_path), pythonpath]) else: pythonpath = str(tmp_path) env = os.environ.copy() env["PYTHONPATH"] = pythonpath + # No --ignore-missing-stub: a new public symbol must get a stub. + # No --ignore-unused-allowlist: a stale allowlist entry must be + # removed instead of silently hiding whatever it matches next. subprocess.run( [ "stubtest", "pymodel", "--allow", DATA_DIR / "stubtest" / "allow.txt", - "--ignore-unused-allowlist", - "--ignore-missing-stub", ], encoding="utf-8", check=True, @@ -2099,11 +2104,18 @@ def test_varname_reserved_words(tmp_path): # Generated source must contain the renamed names, not the originals text = (output_dir / "model.py").read_text() - for renamed in ("get_id_", "set_id_", "encode_", "class_"): + for renamed in ("get_id_", "set_id_", "encode_", "class_", "ONTOLOGY_", "CLOSED_"): assert ( renamed in text ), f"expected renamed property '{renamed}' in generated code" - for original in ('"get_id"', '"set_id"', '"encode"', '"class"'): + for original in ( + '"get_id"', + '"set_id"', + '"encode"', + '"class"', + '"ONTOLOGY"', + '"CLOSED"', + ): assert ( f"ClassProp({original}," not in text ), f"unrenamed property {original} found as ClassProp pyname" @@ -2117,11 +2129,22 @@ def test_varname_reserved_words(tmp_path): cls = m.SHACLObject.CLASSES["http://example.org/shacl2code-test/test-rw-class"] # Renamed kwargs must work at construction time - obj = cls(get_id_="a", set_id_="b", encode_="c", class_="d") + obj = cls( + get_id_="a", + set_id_="b", + encode_="c", + class_="d", + ONTOLOGY_="e", + CLOSED_="f", + ) assert obj.get_id_ == "a" assert obj.set_id_ == "b" assert obj.encode_ == "c" assert obj.class_ == "d" + assert obj.ONTOLOGY_ == "e" + assert obj.CLOSED_ == "f" + + assert cls.ONTOLOGY is None # SHACLObject.get_id() must still return the object IRI, # not the value of the prop (get_id_) @@ -2135,6 +2158,88 @@ def test_varname_reserved_words(tmp_path): del sys.modules[mod] +def _module_level_defs(path): + """Public names defined (not imported) at module level of a .py file.""" + names = set() + + def walk(body): + for n in body: + if isinstance(n, (ast.ClassDef, ast.FunctionDef)): + names.add(n.name) + elif isinstance(n, ast.Assign): + names.update(t.id for t in n.targets if isinstance(t, ast.Name)) + elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name): + names.add(n.target.id) + elif isinstance(n, ast.Try): + # rdflib classes live in a try/except ImportError block + walk(n.body) + + walk(ast.parse(Path(path).read_text()).body) + return {n for n in names if not n.startswith("_") and n != "__all__"} + + +def test_model_all_matches_public_symbols(model): + """ + __all__ must list exactly the module's own public symbols, so that + `from .model import *` cannot re-export imported names. + + stubtest already checks __all__ against the stub, but not against what + the module actually defines. + """ + mm = model.model + + assert len(mm.__all__) == len(set(mm.__all__)), ( + f"duplicate entries in __all__: " + f"{sorted(n for n in mm.__all__ if mm.__all__.count(n) > 1)}" + ) + + defined = _module_level_defs(mm.__file__) + assert set(mm.__all__) == defined, ( + f"missing from __all__: {sorted(defined - set(mm.__all__))}; " + f"in __all__ but not defined: {sorted(set(mm.__all__) - defined)}" + ) + + # Star-import must expose exactly __all__ + ns = {} + exec(f"from {mm.__name__} import *", ns) # noqa: S102 + exported = {n for n in ns if not n.startswith("__")} + assert exported == set(mm.__all__) + leaked = sorted(n for n in exported if isinstance(ns[n], types.ModuleType)) + assert not leaked, f"modules leaked by star-import: {leaked}" + + +def test_reserved_words_cover_shaclobject_names(model): + """ + SHACLOBJECT_RESERVED_WORDS must cover every name a generated property + could collide with: the attributes emitted into each generated class body, + plus those inherited from SHACLObject / SHACLExtensibleObject. + """ + mm = model.model + + emitted = set() + for node in ast.parse(Path(mm.__file__).read_text()).body: + if not isinstance(node, ast.ClassDef): + continue + if not any( + isinstance(b, ast.Name) and b.id.endswith("SHACLObject") for b in node.bases + ): + continue + for st in node.body: + if isinstance(st, ast.AnnAssign) and isinstance(st.target, ast.Name): + emitted.add(st.target.id) + elif isinstance(st, ast.Assign): + emitted |= {t.id for t in st.targets if isinstance(t, ast.Name)} + + inherited = { + k for cls in (mm.SHACLObject, mm.SHACLExtensibleObject) for k in dir(cls) + } + required = {n for n in emitted | inherited if not n.startswith("_")} + + assert required, "found no class-level names to check" + missing = sorted(required - SHACLOBJECT_RESERVED_WORDS) + assert not missing, f"not in SHACLOBJECT_RESERVED_WORDS: {missing}" + + def test_extensible_properties(model, test_context_url): class Extension(model.extensible_class):