diff --git a/README.md b/README.md
index de4833ea..df18a5dd 100644
--- a/README.md
+++ b/README.md
@@ -330,3 +330,39 @@ status using the following order of precedence (1 = the highest priority):
[pytest]: https://www.pytest.org
[pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/
[semver]: https://semver.org/
+
+### Version-agnostic Protocol types (Python)
+
+By default, code written against one generated Python module can't accept
+objects from another generated module, even if the two were generated from
+compatible versions of the same model.
+
+Passing `--include-protocols yes` to the Python generator adds a
+`protocols.py` module with a [`typing.Protocol`][typing-protocol] for every
+class in the model. A Protocol accepts an object from _any_ generated module
+whose version is compatible with the one the Protocol came from, so you can
+write functions and classes that work across model versions instead of
+being tied to one:
+
+```shell
+shacl2code generate -i model.jsonld python --include-protocols yes -o out
+```
+
+```python
+from out import protocols
+
+def describe(obj: protocols.MyClass) -> str:
+ return f"{obj.get_type()}: {obj.my_property}"
+```
+
+`describe()` accepts a `MyClass` instance from `out`, or from any other
+generated module whose `MyClass` is compatible with `out`'s.
+
+A couple of things to keep in mind:
+
+- Object-reference properties are typed `Any` on a Protocol; read those
+ through the concrete module when you need a precise type.
+- Protocols are for type annotations only -- construct objects using a
+ concrete generated module, not a Protocol.
+
+[typing-protocol]: https://docs.python.org/3/library/typing.html#typing.Protocol
diff --git a/src/shacl2code/lang/python.py b/src/shacl2code/lang/python.py
index cad9f41d..13b4a781 100644
--- a/src/shacl2code/lang/python.py
+++ b/src/shacl2code/lang/python.py
@@ -3,12 +3,17 @@
# SPDX-License-Identifier: MIT
"""Python language binding renderer"""
+import hashlib
import keyword
import re
from pathlib import Path
+from typing import Iterable
-from .common import JinjaTemplateRender
+from jinja2 import TemplateRuntimeError
+
+from .common import JinjaTemplateRender, prop_is_list
from .lang import TEMPLATE_DIR, language
+from ..model import Class
from ..util import convert_version_string
DATATYPE_CLASSES = {
@@ -75,6 +80,13 @@ def varname(*name):
return name
+def prop_shape(prop):
+ """Classify a property's container shape: (is_list, has_ref, is_enum)."""
+ is_enum = bool(prop.enum_values)
+ has_ref = bool(prop.class_id) and not is_enum
+ return prop_is_list(prop), has_ref, is_enum
+
+
def prop_element_pytype(prop, classes):
"""Python type of a single element of prop, ignoring container shape.
@@ -85,9 +97,60 @@ def prop_element_pytype(prop, classes):
return "str"
if prop.class_id:
return "Union[str, '" + varname(*classes.get(prop.class_id).clsname) + "']"
+ if prop.datatype not in DATATYPE_PYTHON_TYPES:
+ # Same error as model.py.j2's abort()
+ raise TemplateRuntimeError("Unknown data type " + prop.datatype)
return DATATYPE_PYTHON_TYPES[prop.datatype]
+def protocols_use_datetime(classes: Iterable[Class]) -> bool:
+ """Whether any class has a datetime-typed scalar or list property."""
+ for cls in classes:
+ for prop in cls.properties:
+ _, has_ref, is_enum = prop_shape(prop)
+ if has_ref or is_enum:
+ continue
+ if prop_element_pytype(prop, classes) == "datetime":
+ return True
+ return False
+
+
+def protocol_discriminator_name(cls: Class) -> str:
+ """Stable, collision-resistant name for cls's Protocol discriminator method.
+
+ Keyed by the class IRI (not the --context-compacted class name), so it
+ matches across generations with different --context flags. varname()
+ alone can sanitize two distinct IRIs to the same string (e.g. IRIs that
+ differ only in punctuation runs both collapsing to "_"), so a short hash
+ of the raw IRI is appended to disambiguate while staying stable across
+ regenerations of the same class.
+ """
+ digest = hashlib.sha256(cls._id.encode("utf-8")).hexdigest()[:8]
+ return varname(cls._id, digest)
+
+
+def protocols_extra_imports(classes: Iterable[Class]) -> str:
+ """Conditionally-needed stdlib imports for protocols.py.j2.
+
+ Rendered as a single ``{{ }}`` expression (not a ``{% if %}`` block) so
+ black can parse the .j2 source as Python. The blank lines black then
+ requires around that expression separate these imports from the ones
+ above by more than flake8-import-order allows within one group, so each
+ line silences that deliberate exception. Always returns a non-blank
+ line (a comment when there's nothing to import) so the surrounding
+ black-mandated blank-line groups above and below never merge into one
+ run long enough to trip flake8's too-many-blank-lines check.
+ """
+ lines = []
+ if protocols_use_datetime(classes):
+ lines.append("from datetime import datetime # noqa: E402, I100, I202")
+ if any(cls.named_individuals for cls in classes):
+ lines.append("from typing import ClassVar, Dict # noqa: E402, I100, I202")
+ if not lines:
+ lines.append("# No extra imports needed for this model.")
+ return "\n".join(lines)
+
+
@language("python")
class PythonRender(JinjaTemplateRender):
"""Render Python Language Bindings."""
@@ -103,8 +166,9 @@ class PythonRender(JinjaTemplateRender):
def __init__(self, args):
super().__init__(args)
self.__output = args.output
- self.__use_slots = args.use_slots
self.__include_main = args.include_main == "yes"
+ self.__include_protocols = args.include_protocols == "yes"
+ self.__use_slots = args.use_slots
self.__version_str = args.version
if args.version:
self.__version = repr(convert_version_string(args.version))
@@ -126,6 +190,15 @@ def get_arguments(cls, parser):
default="yes",
help="Generate a main function for the module. Default is '%(default)s'",
)
+ parser.add_argument(
+ "--include-protocols",
+ choices=("yes", "no"),
+ default="no",
+ help=(
+ "Include a protocols.py module with version-agnostic Protocol "
+ "types for every class. Default is '%(default)s'"
+ ),
+ )
parser.add_argument(
"--use-slots",
choices=("auto", "yes", "no"),
@@ -154,10 +227,16 @@ def get_file(name):
yield get_file("cmd.py")
yield get_file("__main__.py")
+ if self.__include_protocols:
+ yield get_file("protocols.py")
+
def get_extra_env(self):
return {
"varname": varname,
"prop_element_pytype": prop_element_pytype,
+ "prop_shape": prop_shape,
+ "protocol_discriminator_name": protocol_discriminator_name,
+ "protocols_extra_imports": protocols_extra_imports,
"DATATYPE_CLASSES": DATATYPE_CLASSES,
"DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES,
}
@@ -170,8 +249,9 @@ def get_additional_render_args(self, model):
else:
use_slots = False
return {
- "use_slots": use_slots,
"include_main": self.__include_main,
- "version_str": self.__version_str,
+ "include_protocols": self.__include_protocols,
+ "use_slots": use_slots,
"version": self.__version,
+ "version_str": self.__version_str,
}
diff --git a/src/shacl2code/lang/templates/python/__init__.py.j2 b/src/shacl2code/lang/templates/python/__init__.py.j2
index 0af8e7e3..47379bae 100644
--- a/src/shacl2code/lang/templates/python/__init__.py.j2
+++ b/src/shacl2code/lang/templates/python/__init__.py.j2
@@ -4,12 +4,75 @@
#
# SPDX-License-Identifier: {{ spdx_license }}
-from .model import * # noqa: F401, F403
+from __future__ import annotations
+
+import importlib
+import warnings
+from types import ModuleType
+from typing import Any, Callable, Dict, List, TYPE_CHECKING, TypeVar
+
+if TYPE_CHECKING:
+ from .model import * # noqa: F401, F403
+
+# True if any ontology behind this model is pre-release.
+IS_PRERELEASE = {{ontologies | selectattr("is_prerelease") | list | length > 0}}
+
+if IS_PRERELEASE:
+ # Fires once on first import, regardless of import form.
+ warnings.warn(
+ f"{__name__!r} is a pre-release model version and may change without notice.",
+ FutureWarning,
+ )
# fmt: off
"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }}
+{%- if include_protocols %}
+if TYPE_CHECKING:
+ from . import protocols # noqa: F401, I100, I202
+{%- endif %}
+
+
+_LAZY_SUBMODULES: Dict[str, Callable[[], Any]] = {
+{%- if include_protocols %}
+ "protocols": lambda: importlib.import_module(f"{__name__}.protocols"),
+{%- endif %}
{%- if include_main %}
-from .cmd import main # noqa: F401, I100, I202
+ "main": lambda: importlib.import_module(f"{__name__}.cmd").main,
{%- endif %}
+}
+
+
+def __getattr__(name: str) -> Any:
+ # PEP 562 lazy access: each branch imports only what it needs.
+ if name == "__all__":
+ # Only "import *" needs this; it must load the model to compute it.
+ mod = importlib.import_module(f"{__name__}.model")
+ return sorted(
+ n
+ for n, o in vars(mod).items()
+ if not n.startswith("_")
+ and n != "TYPE_CHECKING" # imported flag, not model content
+ and not isinstance(o, (TypeVar, ModuleType))
+ and (
+ getattr(o, "__module__", None) == mod.__name__
+ or getattr(o, "__module__", None) is None # plain constants
+ )
+ )
+ if name in _LAZY_SUBMODULES:
+ return _LAZY_SUBMODULES[name]()
+ mod = importlib.import_module(f"{__name__}.model")
+ try:
+ return getattr(mod, name)
+ except AttributeError:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+def __dir__() -> List[str]:
+ # Opt-in: model loads only when dir() is actually called.
+ mod = importlib.import_module(f"{__name__}.model")
+ names = set(globals()) | set(dir(mod)) | set(_LAZY_SUBMODULES)
+ return sorted(names)
+
+
{{ '"' }}{{ '"' }}{{ '"' }}Format Guard"""
-# fmt on
+# fmt: on
diff --git a/src/shacl2code/lang/templates/python/cmd.py.j2 b/src/shacl2code/lang/templates/python/cmd.py.j2
index 82b1dd23..eefd440d 100644
--- a/src/shacl2code/lang/templates/python/cmd.py.j2
+++ b/src/shacl2code/lang/templates/python/cmd.py.j2
@@ -4,23 +4,26 @@
#
# SPDX-License-Identifier: {{ spdx_license }}
+from __future__ import annotations
+
import argparse
from pathlib import Path
-from typing import Any, Iterable, List
+from typing import Any, Iterable, List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .model import SHACLObject
-from .model import (
- JSONLDDeserializer,
- JSONLDSerializer,
- ListProxy,
- SHACLObject,
- SHACLObjectSet,
-)
+# NOTE: .model is imported inside each function below, not here, because
+# __init__.py can import this module just to fetch "main" (e.g. dir()),
+# without calling it -- that must not force the model to load.
def print_tree(objects: Iterable[SHACLObject], all_fields: bool = False) -> None:
"""
Print object tree
"""
+ from .model import ListProxy, SHACLObject
+
seen = set()
def callback(value: Any, path: List[str]) -> bool:
@@ -52,6 +55,12 @@ def print_tree(objects: Iterable[SHACLObject], all_fields: bool = False) -> None
def main() -> int:
+ from .model import (
+ JSONLDDeserializer,
+ JSONLDSerializer,
+ SHACLObjectSet,
+ )
+
parser = argparse.ArgumentParser(description="Python SHACL model test")
parser.add_argument("infile", type=Path, help="Input file")
parser.add_argument("--print", action="store_true", help="Print object tree")
diff --git a/src/shacl2code/lang/templates/python/model.py.j2 b/src/shacl2code/lang/templates/python/model.py.j2
index 17cae1a3..043966d6 100644
--- a/src/shacl2code/lang/templates/python/model.py.j2
+++ b/src/shacl2code/lang/templates/python/model.py.j2
@@ -899,7 +899,7 @@ class SHACLObjectMeta(type):
SHACLObject.CLASSES[key] = c
-register_lock = threading.Lock()
+_register_lock = threading.Lock()
_ALL_NAMED_INDIVIDUAL_IDS: Set[str] = set()
T_SHACLObject = TypeVar("T_SHACLObject", bound="SHACLObject")
@@ -1002,7 +1002,7 @@ class SHACLObject(metaclass=SHACLObjectMeta):
if self.ONTOLOGY:
_warn_ontology(self.ONTOLOGY)
- with register_lock:
+ with _register_lock:
cls = self.__class__
if cls._NEEDS_REG:
for p in cls._OBJ_PY_PROPS.values():
@@ -3010,7 +3010,7 @@ CONTEXT_URLS: List[str] = [
]
# ONTOLOGIES
-{%- for o in ontologies %}
+{% for o in ontologies %}
{%- if o.comment %}
{{ '"' }}{{ '"' }}{{ '"' }}
{%- for l in o.comment.split("\n") %}
@@ -3071,11 +3071,18 @@ class {{ varname(*class.clsname) }}(
{%- endfor %}
}
{%- endif %}
+ {%- if include_protocols %}
+
+ # Discriminator keyed by IRI, not the --context-compacted class name, so
+ # it matches across generations with different --context flags.
+ def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None:
+ pass
+ {%- endif %}
{%- if class.properties %}
PROPERTIES: ClassVar[List[ClassProp]] = [
{%- for prop in class.properties %}
- {%- set is_list = prop_is_list(prop) %}
+ {%- set is_list, has_ref, is_enum = prop_shape(prop) %}
{%- if prop.comment %}
{%- for l in prop.comment.split("\n") %}
#{{ (" " + l).rstrip() }}
@@ -3085,13 +3092,13 @@ class {{ varname(*class.clsname) }}(
"{{ varname(prop.varname) }}",
lambda:
{% if is_list -%}ListProp({% endif %}
- {%- if prop.enum_values -%}
+ {%- if is_enum -%}
EnumProp((
{%- for value in prop.enum_values %}
("{{ value }}", "{{ context.compact_vocab(value, prop.path) }}"),
{%- endfor %}
))
- {%- elif prop.class_id -%}
+ {%- elif has_ref -%}
{%- set ctx = [] %}
{%- for value in get_all_named_individuals(classes.get(prop.class_id)) %}
{%- if context.compact_vocab(value, prop.path) != value %}
diff --git a/src/shacl2code/lang/templates/python/model.pyi.j2 b/src/shacl2code/lang/templates/python/model.pyi.j2
index 365f41b5..cb6c3a75 100644
--- a/src/shacl2code/lang/templates/python/model.pyi.j2
+++ b/src/shacl2code/lang/templates/python/model.pyi.j2
@@ -464,6 +464,9 @@ class {{ varname(*class.clsname) }}(
{%- endif %}
**kwargs: Any
) -> None: ...
+ {%- if include_protocols %}
+ def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None: ...
+ {%- endif %}
{%- if class.id_property %}
{{ class.id_property }}: Optional[str]
diff --git a/src/shacl2code/lang/templates/python/protocols.py.j2 b/src/shacl2code/lang/templates/python/protocols.py.j2
new file mode 100644
index 00000000..a3fc07d2
--- /dev/null
+++ b/src/shacl2code/lang/templates/python/protocols.py.j2
@@ -0,0 +1,105 @@
+# {{ disclaimer }}
+# {%- import "_macros.j2" as pymacros %}
+# SPDX-License-Identifier: {{ spdx_license }}
+"""Version-agnostic Protocol types, one per generated class.
+
+Satisfied by the corresponding concrete class of any model version
+backward-compatible with the baseline these Protocols were generated from.
+Use them to write functions and classes that work across model versions.
+
+Scalar and list-of-scalar properties are typed precisely. Object-reference
+properties are typed ``Any``: structural typing can't resolve the circular
+reference to the sibling protocol type they'd otherwise return. Read them
+through the concrete version type when a typed value is needed.
+
+Construct objects with a concrete version module; use these Protocols only
+for annotations.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Iterable, Iterator, Optional, Protocol, Set, Tuple
+
+{{protocols_extra_imports(classes)}}
+
+
+class SHACLObjectProtocol(Protocol):
+ """Version-agnostic view of the SHACLObject machinery base."""
+
+ def get_id(self) -> Optional[str]: ...
+ def set_id(self, value: Optional[str]) -> None: ...
+ def get_type(self) -> str: ...
+ def get_compact_type(self) -> Optional[str]: ...
+ def property_keys(self) -> Iterator[Tuple[Optional[str], str, Optional[str]]]: ...
+ def __getitem__(self, iri: str) -> Any: ...
+ def __setitem__(self, iri: str, value: Any) -> None: ...
+
+
+class SHACLObjectSetProtocol(Protocol):
+ """Version-agnostic view of the SHACLObjectSet collection.
+
+ Raw index attributes (``objects``, ``obj_by_id``, etc.) and version-coupled
+ methods (``encode``/``decode``, ``merge``) are omitted; use a concrete
+ version type for those.
+ """
+
+ def foreach(self) -> Iterable[SHACLObjectProtocol]: ...
+
+ def foreach_type(
+ self, typ: str, *, match_subclass: bool = True
+ ) -> Iterable[SHACLObjectProtocol]: ...
+
+ def find_by_id(
+ self, _id: str, default: Any = None
+ ) -> Optional[SHACLObjectProtocol]: ...
+
+ def link(self) -> Set[str]: ...
+ def add(self, obj: Any) -> Any: ...
+ def remove(self, obj: Any) -> None: ...
+ def update(self, *others: Iterable[Any]) -> None: ...
+ def __contains__(self, item: Any) -> bool: ...
+
+
+# fmt: off
+"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }}
+# DOMAIN CLASSES
+{% for class in classes %}
+
+class {{ varname(*class.clsname) }}(
+{%- if class.parent_ids %}
+ {%- for id in class.parent_ids -%}
+ {{- varname(*classes.get(id).clsname) }}{%- if not loop.last -%}, {% endif -%}
+ {%- endfor -%}
+{%- else -%}
+ SHACLObjectProtocol
+{%- endif -%}
+, Protocol):
+{%- if class.comment %}{{ pymacros.class_docstring(class.comment) }}{% endif %}
+
+ def _protocol_{{ protocol_discriminator_name(class) }}(self) -> None: ...
+ {%- if class.id_property %}
+ {{ class.id_property }}: Optional[str]
+ {%- endif %}
+ {%- if class.named_individuals %}
+ NAMED_INDIVIDUALS: ClassVar[Dict[str, str]]
+ {%- for member in class.named_individuals %}
+ {{ varname(member.varname) }}: str
+ {%- endfor %}
+ {%- endif %}
+ {%- for prop in class.properties %}
+ {%- set is_list, has_ref, is_enum = prop_shape(prop) %}
+ {%- if not has_ref %}
+ {%- set ptype = prop_element_pytype(prop, classes) %}
+ {%- endif %}
+ {%- if has_ref %}
+ {{ varname(prop.varname) }}: Any
+ {%- elif is_list %}
+ {{ varname(prop.varname) }}: Iterable[{{ ptype }}]
+ {%- else %}
+ {{ varname(prop.varname) }}: Optional[{{ ptype }}]
+ {%- endif %}
+ {%- endfor %}
+{% endfor %}
+
+{{ '"' }}{{ '"' }}{{ '"' }}Format Guard"""
+# fmt: on
diff --git a/tests/data/model/test-v2.ttl b/tests/data/model/test-v2.ttl
new file mode 100644
index 00000000..12f05a41
--- /dev/null
+++ b/tests/data/model/test-v2.ttl
@@ -0,0 +1,624 @@
+# Backward-compatible extension of test.ttl for cross-version Protocol tests.
+# Adds: parent-class/v2-new-prop (new optional scalar) and test-another-class
+# (same property set as test-class, for discriminator testing).
+@base .
+@prefix rdf: .
+@prefix rdfs: .
+@prefix sh: .
+@prefix owl: .
+@prefix xsd: .
+@prefix sh-to-code: .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "Derived class that sorts before the parent to test ordering"
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:comment "The parent class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "The test class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path
+ ],
+ [
+ sh:path
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:name "named_property" ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ ],
+ [
+ sh:datatype xsd:dateTimeStamp ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:positiveInteger ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:nonNegativeInteger ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:integer ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:anyURI ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:boolean ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:decimal ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:pattern "^foo\\d" ;
+ sh:path ;
+ sh:maxCount 1
+
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:pattern "^foo\\d" ;
+ sh:path ;
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$"
+ ],
+ [
+ sh:datatype xsd:dateTimeStamp ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$"
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:path ;
+ sh:datatype xsd:string
+ ],
+ [
+ sh:path ;
+ sh:name "split" ;
+ sh:maxCount 1
+ ]
+ .
+
+ a owl:NamedIndividual, ;
+ rdfs:label "A named individual of the test class"
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:minCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:minCount 1 ;
+ sh:maxCount 2
+ ]
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class derived from test-class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:comment "Another class"
+ .
+
+ a sh:NodeShape, owl:DeprecatedClass ;
+ rdfs:subClassOf ;
+ rdfs:comment "A deprecated class" ;
+ sh:property [
+ sh:datatype: xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+ a owl:DeprecatedProperty ;
+ rdfs:comment "A deprecated property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A string list property" ;
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A string list property with no sh:datatype" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A scalar string propery" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A required scalar string property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A required string list property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A named property";
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A scalar datetime property";
+ rdfs:range xsd:dateTime
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A datetime list property" ;
+ rdfs:range xsd:dateTime
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A scalar dateTimeStamp property";
+ rdfs:range xsd:dateTimeStamp
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A positive integer" ;
+ rdfs:range xsd:positiveInteger
+ .
+
+ a rdf:Property ;
+ rdfs:comment "a non-negative integer" ;
+ rdfs:range xsd:nonNegativeInteger
+ .
+
+ a rdf:Property ;
+ rdfs:comment "a non-negative integer" ;
+ rdfs:range xsd:integer
+ .
+
+ a rdf:Property ;
+ rdfs:comment "a URI" ;
+ rdfs:range xsd:anyURI
+ .
+
+ a rdf:Property ;
+ rdfs:comment "a boolean property" ;
+ rdfs:range xsd:boolean
+ .
+
+ a rdf:Property ;
+ rdfs:comment "a float property" ;
+ rdfs:range xsd:decimal
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A test-class property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A test-class property with no sh:class" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A test-class list property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A enum property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A enum list property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A enum property with no sh:class" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A regex validated string" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A regex validated string list" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A split string property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A property that is a keyword" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A property that conflicts with an existing SHACLObject property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A regex dateTime" ;
+ rdfs:range xsd:dateTime
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A regex dateTimeStamp" ;
+ rdfs:range xsd:dateTimeStamp
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A class with no shape" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A string property in a derived class" ;
+ rdfs:range xsd:string
+ .
+
+ a owl:Class ;
+ rdfs:comment "A class that is not a nodeshape"
+ .
+
+ a owl:Class ;
+ rdfs:comment "An enumerated type"
+ .
+
+ a owl:NamedIndividual, ;
+ rdfs:label "foo" ;
+ rdfs:comment "The foo value of enumType"
+ .
+
+ a owl:NamedIndividual, ;
+ rdfs:label "bar" ;
+ rdfs:comment "The bar value of enumType"
+ .
+
+ a owl:NamedIndividual, ;
+ rdfs:comment "This value has no label"
+ .
+
+ a ;
+ rdfs:comment "This value is not a named individual and won't appear in the output"
+ .
+
+# Classes to test links
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:comment "A class to test links" ;
+ sh:property [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+# Note: link-derived-class and link-derived-2-class should both have no
+# properties to test an edge case in the go bindings
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class derived from link-class"
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class derived from link-class"
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A link-class property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A link-class property with no sh:class" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A link-class list property" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A link to an extensible-class" ;
+ rdfs:range
+ .
+
+ a rdf:Property ;
+ rdfs:comment "Tag used to identify object for testing" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A link to a derived class" ;
+ rdfs:range
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:comment "A class with an ID alias" ;
+ sh-to-code:idPropertyName "testid"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class that inherits its idPropertyName from the parent"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class that must be a blank node" ;
+ sh:nodeKind sh:BlankNode
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class that must be an IRI" ;
+ sh:nodeKind sh:IRI
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class that can be either a blank node or an IRI" ;
+ sh:nodeKind sh:BlankNodeOrIRI
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class that derives its nodeKind from parent" ;
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ sh-to-code:isExtensible true ;
+ rdfs:comment "An extensible class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:minCount 0
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:minCount 1
+ ]
+ .
+
+ a rdf:Property ;
+ rdfs:comment "An extensible property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A required extensible property" ;
+ rdfs:range xsd:string
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:comment "An Abstract class" ;
+ sh-to-code:isAbstract true
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class, ;
+ rdfs:comment "An Abstract class using the SPDX type"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:comment: "An Abstract class using SHACL validation" ;
+ sh:property [
+ sh:path rdf:type ;
+ sh:not [ sh:hasValue ]
+ ] .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A concrete class"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A concrete class"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A concrete class"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:comment "A class with a mandatory abstract class" ;
+ sh:property [
+ sh:class ;
+ sh:path ;
+ sh:minCount 1 ;
+ sh:maxCount 1
+ ]
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A required abstract class property" ;
+ rdfs:range
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ sh-to-code:isExtensible true ;
+ sh-to-code:isAbstract true ;
+ rdfs:comment "An extensible abstract class"
+ .
+
+ a rdf:Class, sh:NodeShape, owl:Class ;
+ rdfs:comment "A class that uses an abstract extensible class" ;
+ sh:property [
+ sh:path ;
+ sh:minCount 1 ;
+ sh:maxCount 1
+ ]
+ .
+
+ a rdf:Property ;
+ rdfs:comment "A property that references and abstract extensible class" ;
+ rdfs:range
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:comment "Another class with the same own property set as test-class (for discriminator testing)" ;
+ sh:property [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
diff --git a/tests/data/model/test-v3.ttl b/tests/data/model/test-v3.ttl
new file mode 100644
index 00000000..31f3e0ad
--- /dev/null
+++ b/tests/data/model/test-v3.ttl
@@ -0,0 +1,657 @@
+# Backward-compatible extension of test-v2.ttl for cross-version Protocol tests.
+# Adds (on top of v2's parent-class/v2-new-prop and test-another-class):
+# - test-class/v3-new-prop: new optional scalar on test-class.
+# - test-derived-class-v3: new class, one level deeper than test-derived-class.
+# - enumType/baz: new named individual (enum value growth).
+# - test-class/split-string-prop marked owl:DeprecatedProperty (still present).
+@base .
+@prefix rdf: .
+@prefix rdfs: .
+@prefix sh: .
+@prefix owl: .
+@prefix xsd: .
+@prefix sh-to-code: .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "Derived class that sorts before the parent to test ordering"
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:comment "The parent class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "The test class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path
+ ],
+ [
+ sh:path
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:name "named_property" ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ ],
+ [
+ sh:datatype xsd:dateTimeStamp ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:positiveInteger ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:nonNegativeInteger ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:integer ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:anyURI ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:boolean ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:decimal ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:in (
+
+
+
+
+ )
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:pattern "^foo\\d" ;
+ sh:path ;
+ sh:maxCount 1
+
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:pattern "^foo\\d" ;
+ sh:path ;
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:dateTime ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$"
+ ],
+ [
+ sh:datatype xsd:dateTimeStamp ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:pattern "^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$"
+ ],
+ [
+ sh:class ;
+ sh:path ;
+ sh:maxCount 1
+ ],
+ [
+ sh:path ;
+ sh:datatype xsd:string
+ ],
+ [
+ sh:path ;
+ sh:name "split" ;
+ sh:maxCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+ a owl:NamedIndividual, ;
+ rdfs:label "A named individual of the test class"
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1 ;
+ sh:minCount 1
+ ],
+ [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:minCount 1 ;
+ sh:maxCount 2
+ ]
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class derived from test-class" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path ;
+ sh:maxCount 1
+ ]
+ .
+
+ a sh:NodeShape, owl:Class ;
+ rdfs:subClassOf ;
+ rdfs:comment "A class derived from test-derived-class, added in v3" ;
+ sh:property [
+ sh:datatype xsd:string ;
+ sh:path