Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
e775ef5
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 11, 2026
3ec2eba
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 13, 2026
d69b495
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 13, 2026
086b408
Merge branch 'JPEWdev:main' into python-protocol
bact Aug 13, 2026
3682fa5
Use shared prop_shape()
bact Aug 13, 2026
47808d5
Share code for lazy import
bact Aug 14, 2026
0fb4c5a
Guard protocols datetime
bact Aug 14, 2026
a27160f
Add missing protocol gates
bact Aug 14, 2026
b29fead
Fix sort import
bact Aug 14, 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
50 changes: 47 additions & 3 deletions src/shacl2code/lang/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
import keyword
import re
from pathlib import Path
from typing import Iterable

from jinja2 import TemplateRuntimeError

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 +79,30 @@ def varname(*name):
return name


def prop_shape(prop):
"""Classify a property's container shape: (is_list, has_ref, is_enum)."""
is_list = prop.max_count is None or prop.max_count != 1
is_enum = bool(prop.enum_values)
has_ref = bool(prop.class_id) and not is_enum
return is_list, has_ref, is_enum


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, has_ref, is_enum = prop_shape(prop)
is_scalar = not (is_list or has_ref or is_enum)
if not is_scalar:
continue
if prop.datatype not in DATATYPE_PYTHON_TYPES:
# Same error as model.py.j2's abort()
raise TemplateRuntimeError("Unknown data type " + prop.datatype)
if DATATYPE_PYTHON_TYPES[prop.datatype] == "datetime":
return True
return False


@language("python")
class PythonRender(JinjaTemplateRender):
"""Render Python Language Bindings."""
Expand All @@ -90,8 +118,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 +142,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 +179,14 @@ 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_shape": prop_shape,
"protocols_use_datetime": protocols_use_datetime,
"DATATYPE_CLASSES": DATATYPE_CLASSES,
"DATATYPE_PYTHON_TYPES": DATATYPE_PYTHON_TYPES,
}
Expand All @@ -156,8 +199,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,
}
69 changes: 66 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,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__":

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?

@bact bact Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

For first question, it is intentional since cmd and protocols will not be usually used (as a library) and we like to avoid loading anything that is not likely to be used.

For cmd and main, same rational as removing main from spdx-python-model binding generation at spdx/spdx-python-model#51

For protocols, its main use case is for type check and it is better to keep it separate because if we allow them in mix in top-level import * all *Protocol classes will be loaded during runtime (no exactly harmful, but unnecessary waste memory - every model class will have its own *Protocol class counterpart).

Users who want them still able to access them by using fully qualified name.

--

For 2nd one, we can use model.__all__ too but since currently model doesn't have __all__, we have to define it there.

Do it in model.py.j2 is more straightforward, we explicitly say what we like to export. It is also cheaper (do it a generation time). The downside is maintenance, you have to maintain the full list of __all__.

Do it in __init__.py.j2 retrospectively is more expensive but lower maintenance, you inspect actual available symbols, then filter it. You maintain only a small blacklist - which in this case tend to be stable. Since the imports tend to be only few times per process, I chose to do it in init.

Each approach has its own risks though. The static explicit list in model can under-export. The dynamic filter in init can over-export.

# 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
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
9 changes: 7 additions & 2 deletions src/shacl2code/lang/templates/python/model.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -3052,6 +3052,11 @@ class {{ varname(*class.clsname) }}(
{%- endfor %}
}
{%- endif %}
{%- if include_protocols %}

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

{%- if class.properties %}
PROPERTIES: ClassVar[List[ClassProp]] = [
Expand Down
15 changes: 9 additions & 6 deletions src/shacl2code/lang/templates/python/model.pyi.j2
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,10 @@ class {{ varname(*class.clsname) }}(
{{ class.id_property }}: Optional[str] = None,
{%- endif %}
{%- for prop in class.properties %}
{%- set is_list = prop.max_count is none or prop.max_count != 1 %}
{%- if prop.enum_values %}
{%- set is_list, has_ref, is_enum = prop_shape(prop) %}
{%- if is_enum %}
{%- set ptype = "str" %}
{%- elif prop.class_id %}
{%- elif has_ref %}
{%- set ptype = "Union[str, '" ~ varname(*classes.get(prop.class_id).clsname) ~ "']" %}
{%- else %}
{%- set ptype = DATATYPE_PYTHON_TYPES[prop.datatype] %}
Expand All @@ -475,15 +475,18 @@ class {{ varname(*class.clsname) }}(
{%- endif %}
**kwargs: Any
) -> None: ...
{%- if include_protocols %}
def _protocol_{{ varname(*class.clsname) }}(self) -> None: ...
{%- endif %}

{%- 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 %}
{%- if prop.enum_values %}
{%- set is_list, has_ref, is_enum = prop_shape(prop) %}
{%- if is_enum %}
{%- set ptype = "str" %}
{%- elif prop.class_id %}
{%- elif has_ref %}
{%- set ptype = "Union[str, '" ~ varname(*classes.get(prop.class_id).clsname) ~ "']" %}
{%- else %}
{%- set ptype = DATATYPE_PYTHON_TYPES[prop.datatype] %}
Expand Down
Loading
Loading