Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
268c9a6
Add cross-version Protocols
bact Jun 27, 2026
c748c44
sort command lines
bact Jun 27, 2026
258d3b8
Lazy import model
bact Jun 28, 2026
94f1b34
Set Black target versions
bact Jun 28, 2026
0c886d3
Update Python version of lint CI to 3.14
bact Jun 28, 2026
840ce86
Add back format guard
bact Jun 28, 2026
025cbdf
Fix comment and Black target-version
bact Jun 28, 2026
5a288ce
Remove unused test
bact Jun 28, 2026
2a7ae17
Rename --use-protocols -> --include-protocols
bact Jun 28, 2026
3a1f0f1
Merge branch 'JPEWdev:main' into python-protocol
bact Jul 1, 2026
e9fcdc3
Add test for case that datetime is not used in protocol
bact Jul 1, 2026
0ef385a
Fix __dir__ gap
bact Jul 2, 2026
2c7e6dc
Prevent model load when load cmd
bact Jul 3, 2026
4315053
Merge branch 'JPEWdev:main' into python-protocol
bact Jul 4, 2026
136c63b
Merge branch 'main' into python-protocol
bact Jul 6, 2026
1426122
Update test-v2.ttl to new base
bact Jul 6, 2026
a0b8f7a
Add module-level IS_PRERELEASE
bact Jul 7, 2026
1118747
Fix formatting
bact Jul 7, 2026
e5c49c1
Fires FutureWarning when import pre-release model
bact Jul 7, 2026
8f9735b
Merge branch 'JPEWdev:main' into python-protocol
bact Jul 21, 2026
4e80877
Merge branch 'main' into python-protocol
bact Aug 4, 2026
9803d07
Fix formatting
bact Aug 4, 2026
0176d65
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 4, 2026
cfab2f9
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 5, 2026
c79b838
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/shacl2code/lang/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -75,6 +77,18 @@ def varname(*name):
return name


def protocols_use_datetime(classes: Iterable[Class]) -> bool:
"""Whether any class has a plain datetime-typed scalar property."""
for cls in classes:
for prop in cls.properties:
is_list = prop.max_count is None or prop.max_count != 1
has_ref = bool(prop.class_id) and not prop.enum_values
is_scalar = not (is_list or has_ref or prop.enum_values)
if is_scalar and DATATYPE_PYTHON_TYPES[prop.datatype] == "datetime":
return True
return False


@language("python")
class PythonRender(JinjaTemplateRender):
"""Render Python Language Bindings."""
Expand All @@ -90,8 +104,9 @@ class PythonRender(JinjaTemplateRender):
def __init__(self, args):
super().__init__(args)
self.__output = args.output
self.__use_slots = args.use_slots
self.__include_main = args.include_main == "yes"
self.__include_protocols = args.include_protocols == "yes"
self.__use_slots = args.use_slots
self.__version_str = args.version
if args.version:
self.__version = repr(convert_version_string(args.version))
Expand All @@ -113,6 +128,15 @@ def get_arguments(cls, parser):
default="yes",
help="Generate a main function for the module. Default is '%(default)s'",
)
parser.add_argument(
"--include-protocols",
choices=("yes", "no"),
default="no",
help=(
"Include a protocols.py module with version-agnostic Protocol "
"types for every class. Default is '%(default)s'"
),
)
parser.add_argument(
"--use-slots",
choices=("auto", "yes", "no"),
Expand Down Expand Up @@ -141,9 +165,13 @@ def get_file(name):
yield get_file("cmd.py")
yield get_file("__main__.py")

if self.__include_protocols:
yield get_file("protocols.py")

def get_extra_env(self):
return {
"varname": varname,
"protocols_use_datetime": protocols_use_datetime,
"DATATYPE_CLASSES": DATATYPE_CLASSES,
"DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES,
}
Expand All @@ -156,8 +184,9 @@ def get_additional_render_args(self, model):
else:
use_slots = False
return {
"use_slots": use_slots,
"include_main": self.__include_main,
"version_str": self.__version_str,
"include_protocols": self.__include_protocols,
"use_slots": use_slots,
"version": self.__version,
"version_str": self.__version_str,
}
71 changes: 68 additions & 3 deletions src/shacl2code/lang/templates/python/__init__.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,77 @@
#
# SPDX-License-Identifier: {{ spdx_license }}

from .model import * # noqa: F401, F403
from __future__ import annotations

import importlib
import warnings
from types import ModuleType
from typing import Any, List, TYPE_CHECKING, TypeVar

if TYPE_CHECKING:
from .model import * # noqa: F401, F403

# True if any ontology behind this model is pre-release.
IS_PRERELEASE = {{ontologies | selectattr("is_prerelease") | list | length > 0}}

if IS_PRERELEASE:
# Fires once on first import, regardless of import form.
warnings.warn(
f"{__name__!r} is a pre-release model version and may change without notice.",
FutureWarning,
)

# fmt: off
"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }}
{%- if include_protocols %}
if TYPE_CHECKING:
from . import protocols # noqa: F401, I100, I202
{%- endif %}


def __getattr__(name: str) -> Any:
# PEP 562 lazy access: each branch imports only what it needs.
if name == "__all__":

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't this need 'main' from cmd (and protocols)?

Also, is it possible to maybe use model.__all__ instead of manually filtering?

# Only "import *" needs this; it must load the model to compute it.
mod = importlib.import_module(f"{__name__}.model")
return sorted(
n
for n, o in vars(mod).items()
if not n.startswith("_")
and n != "TYPE_CHECKING" # imported flag, not model content
and not isinstance(o, (TypeVar, ModuleType))
and (
getattr(o, "__module__", None) == mod.__name__
or getattr(o, "__module__", None) is None # plain constants
)
)
{%- if include_protocols %}
if name == "protocols":
return importlib.import_module(f"{__name__}.protocols")
{%- endif %}
{%- if include_main %}
from .cmd import main # noqa: F401, I100, I202
if name == "main":
return importlib.import_module(f"{__name__}.cmd").main
{%- endif %}
mod = importlib.import_module(f"{__name__}.model")
try:
return getattr(mod, name)
except AttributeError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__() -> List[str]:
# Opt-in: model loads only when dir() is actually called.
mod = importlib.import_module(f"{__name__}.model")
names = set(globals()) | set(dir(mod))
{%- if include_protocols %}
names.add("protocols")
{%- endif %}
{%- if include_main %}
names.add("main")
{%- endif %}
return sorted(names)


{{ '"' }}{{ '"' }}{{ '"' }}Format Guard"""
# fmt on
# fmt: on
25 changes: 17 additions & 8 deletions src/shacl2code/lang/templates/python/cmd.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions src/shacl2code/lang/templates/python/model.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3049,6 +3049,9 @@ class {{ varname(*class.clsname) }}(
}
{%- endif %}

def _protocol_{{ varname(*class.clsname) }}(self) -> None:
pass

{%- if class.properties %}
PROPERTIES: ClassVar[List[ClassProp]] = [
{%- for prop in class.properties %}
Expand Down
1 change: 1 addition & 0 deletions src/shacl2code/lang/templates/python/model.pyi.j2
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ class {{ varname(*class.clsname) }}(
{%- endif %}
**kwargs: Any
) -> None: ...
def _protocol_{{ varname(*class.clsname) }}(self) -> None: ...

{%- if class.id_property %}
{{ class.id_property }}: Optional[str]
Expand Down
110 changes: 110 additions & 0 deletions src/shacl2code/lang/templates/python/protocols.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# {{ disclaimer }}
#
# SPDX-License-Identifier: {{ spdx_license }}
"""Version-agnostic Protocol types, one per generated class.

Each Protocol is satisfied by the corresponding concrete class of any model
version that is backward-compatible with the baseline from which these Protocols
were generated. Use these types to write functions and classes that work across
multiple model versions and still pass strict static type checking.

Scalar properties are typed precisely and are plain read-write attributes.
Object-reference and list properties are typed ``Any`` for both read and write:
cross-module structural typing cannot resolve the circular reference between a
protocol class and the sibling protocol type its property would otherwise
return, so these properties fall back to ``Any``. Read them through the
concrete version type when a typed value is needed.

Construct objects with a concrete version module; use these Protocols only for
annotations.
"""

from __future__ import annotations

{{"from datetime import datetime" if protocols_use_datetime(classes) else ""}}
from typing import Any, Iterable, Iterator, Optional, Protocol, Set, Tuple


class SHACLObjectProtocol(Protocol):
"""Version-agnostic view of the SHACLObject machinery base."""

def get_id(self) -> Optional[str]: ...
def set_id(self, value: Optional[str]) -> None: ...
def get_type(self) -> str: ...
def get_compact_type(self) -> Optional[str]: ...
def property_keys(self) -> Iterator[Tuple[Optional[str], str, Optional[str]]]: ...
def __getitem__(self, iri: str) -> Any: ...
def __setitem__(self, iri: str, value: Any) -> None: ...


class SHACLObjectSetProtocol(Protocol):
"""Version-agnostic view of the SHACLObjectSet collection.

Raw index attributes (``objects``, ``obj_by_id``, etc.) and version-coupled
methods (``encode``/``decode``, ``merge``) are omitted; use a concrete
version type for those.
"""

def foreach(self) -> Iterable[SHACLObjectProtocol]: ...

def foreach_type(
self, typ: str, *, match_subclass: bool = True
) -> Iterable[SHACLObjectProtocol]: ...

def find_by_id(
self, _id: str, default: Any = None
) -> Optional[SHACLObjectProtocol]: ...

def link(self) -> Set[str]: ...
def add(self, obj: Any) -> Any: ...
def remove(self, obj: Any) -> None: ...
def update(self, *others: Iterable[Any]) -> None: ...
def __contains__(self, item: Any) -> bool: ...


# fmt: off
"""Format Guard{{ '"' }}{{ '"' }}{{ '"' }}
# DOMAIN CLASSES
{% for class in classes %}

class {{ varname(*class.clsname) }}(
{%- if class.parent_ids %}
{%- for id in class.parent_ids -%}
{{- varname(*classes.get(id).clsname) }}{%- if not loop.last -%}, {% endif -%}
{%- endfor -%}
{%- else -%}
SHACLObjectProtocol
{%- endif -%}
, Protocol):
{%- if class.comment %}
{{ '"' }}{{ '"' }}{{ '"' }}
{%- for l in class.comment.split("\n") %}
{{ l.rstrip() }}
{%- endfor %}
{{ '"' }}{{ '"' }}{{ '"' }}
{%- endif %}

def _protocol_{{ varname(*class.clsname) }}(self) -> None: ...
{%- if class.id_property %}
{{ class.id_property }}: Optional[str]
{%- endif %}
{%- for prop in class.properties %}
{%- set is_list = prop.max_count is none or prop.max_count != 1 %}
{%- set has_ref = prop.class_id and not prop.enum_values %}
{%- if prop.enum_values %}
{%- set ptype = "str" %}
{%- elif prop.class_id %}
{%- set ptype = "Any" %}
{%- else %}
{%- set ptype = DATATYPE_PYTHON_TYPES[prop.datatype] %}
{%- endif %}
{%- if is_list or has_ref %}
{{ varname(prop.varname) }}: Any
{%- else %}
{{ varname(prop.varname) }}: Optional[{{ ptype }}]
{%- endif %}
{%- endfor %}
{% endfor %}

{{ '"' }}{{ '"' }}{{ '"' }}Format Guard"""
# fmt: on
Loading
Loading