Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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__":
# 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