Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,6 @@ cython_debug/
src/spdx_python_model/bindings/
gen/**/*.py
gen/**/*.pyi

# Generated at build time by gen/generate-protocols
src/spdx_python_model/protocols/_domain.py
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,63 @@ to get started with spdx-python-model.

[tutorial]: https://spdx.github.io/spdx-python-model/tutorial/using-spdx3.html

### Version-agnostic types

While SPDX 3 keeps backward compatibility across minor versions, internally in
`spdx-python-model` each minor version has its own set of generated types, so
`v3_0_1.SHACLObjectSet` and `v3_1.SHACLObjectSet` are technically distinct.
To assist strict type checking, the package exposes version-neutral Python
[`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)
structural types you can use to write functions that work with any 3.x version
and still pass strict type checking:

```python
from spdx_python_model import SpdxObjectSet, load

def count_persons(objset: SpdxObjectSet) -> int:
return sum(1 for _ in objset.foreach_type("Person"))

model, objset = load(path) # objset is typed as SpdxObjectSet
print(count_persons(objset))
```

Three core structural types are available:

- `SpdxObjectSet` — a collection of SPDX objects (`SHACLObjectSet`)
- `SpdxObject` — a single SPDX object (`SHACLObject`)
- `SpdxModelModule` — a version submodule (e.g. the `model` returned by `load()`)

In addition, the `spdx_python_model.protocols` submodule provides a
version-agnostic protocol for every SPDX class (`protocols.Element`,
`protocols.Relationship`, `protocols.CreationInfo`, …). A function annotated with
these reads any property (typed) and writes scalar or object-reference properties,
for any 3.x version:

```python
from typing import Optional
from spdx_python_model import protocols

def relabel(e: protocols.Element, ci: protocols.CreationInfo) -> Optional[str]:
e.name = "renamed" # write a scalar property
e.creationInfo = ci # write an object-reference property
return e.name # read it back (typed)
```

These are for static typing only. Construct objects using a concrete version:

```python
# From a known version module
p = v3_0_1.Person()

# Or from the model returned by load()
model, objset = load(path)
p = model.Person()
```

When writing object-reference properties, the assigned value is accepted as
`Any` — the value must belong to the same SPDX version as the object it is added
to (this is enforced at runtime). Construction always uses a concrete version.

## Testing

This repository has support for running tests against the bindings using `pytest`.
Expand Down
177 changes: 177 additions & 0 deletions gen/generate-protocols
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#! /usr/bin/env python3
#
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
#
# Generate version-agnostic domain Protocols (_domain.py) from the baseline
# version's model.pyi stub. Run after generate-bindings, from the gen/ dir.
#
# SPDX 3 keeps minor versions backward compatible, so the baseline (oldest)
# version's surface is the common subset every newer version satisfies. One
# Protocol is emitted per SPDX domain class, named identically to the binding
# class (so callers write e.g. ``protocols.Element``):
# - scalar properties -> plain attribute (read + write)
# - object-ref / list props -> typed @property getter (covariant read) plus an
# Any setter (version-agnostic write)

from __future__ import annotations

import ast
import re
import sys
from pathlib import Path

# Machinery base classes that map to the hand-written core SpdxObject protocol.
_BASE_TO_CORE = {"SHACLObject", "SHACLExtensibleObject"}

HEADER = '''\
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
#
# Generated from {baseline}/model.pyi at build time. DO NOT EDIT.
"""Version-agnostic domain Protocols, one per SPDX 3 class.

Each protocol is satisfied by the corresponding class of every supported SPDX 3
minor version. Object-reference properties expose a typed getter (read) and an
Any setter (write); construct objects with a concrete version.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any, Iterable, Optional, Protocol, Union

from .core import SpdxObject
'''


def _kwonly_props(cls: ast.ClassDef) -> list[str]:
"""Property names = the __init__ keyword-only arguments of this class.

Using __init__ (rather than the body annotations) reliably excludes ClassVars
and named-individual string constants, which are not constructor arguments.
"""
for node in cls.body:
if isinstance(node, ast.FunctionDef) and node.name == "__init__":
return [a.arg for a in node.args.kwonlyargs]
return []


def _body_read_types(cls: ast.ClassDef) -> dict[str, str]:
"""Map each property name to its READ-type annotation (as source)."""
types: dict[str, str] = {}
for node in cls.body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
ann = ast.unparse(node.annotation)
if ann.startswith("ClassVar["):
continue
types[node.target.id] = ann
elif isinstance(node, ast.FunctionDef) and node.returns is not None:
if any(
isinstance(d, ast.Name) and d.id == "property"
for d in node.decorator_list
):
types[node.name] = ast.unparse(node.returns)
return types


def _collect(tree: ast.Module) -> dict[str, ast.ClassDef]:
return {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)}


def _domain_classes(classes: dict[str, ast.ClassDef]) -> list[str]:
"""Names of classes that (transitively) derive from SHACLObject, in source
order, excluding the machinery bases themselves."""
out: list[str] = []
for name, cls in classes.items():
if name in _BASE_TO_CORE:
continue
seen: set[str] = set()
stack = list(cls.bases)
while stack:
b = stack.pop()
if not isinstance(b, ast.Name) or b.id in seen:
continue
seen.add(b.id)
if b.id in _BASE_TO_CORE:
out.append(name)
break
parent = classes.get(b.id)
if parent is not None:
stack.extend(parent.bases)
return out


def _map_base(name: str) -> str:
return "SpdxObject" if name in _BASE_TO_CORE else name


def _is_object_ref(ann: str, domain: set[str]) -> bool:
if "ListProxy[" in ann:
return True
return bool(set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", ann)) & domain)


def _emit_class(cls: ast.ClassDef, domain: set[str]) -> str:
bases = [_map_base(b.id) for b in cls.bases if isinstance(b, ast.Name)] or [
"SpdxObject"
]
base_list = ", ".join(dict.fromkeys(bases + ["Protocol"]))

read_types = _body_read_types(cls)
lines = [f"class {cls.name}({base_list}):"]
body: list[str] = []
for p in _kwonly_props(cls):
ann = read_types.get(p)
if ann is None:
continue
# The read view of a list property is an Iterable, not the ListProxy.
ann = ann.replace("ListProxy[", "Iterable[")
if _is_object_ref(ann, domain):
body.append(" @property")
body.append(f" def {p}(self) -> {ann}: ...")
body.append(f" @{p}.setter")
body.append(f" def {p}(self, value: Any) -> None: ...")
else:
body.append(f" {p}: {ann}")
if not body:
body.append(" pass")
return "\n".join(lines + body)


def generate(stub_src: str, baseline: str) -> str:
tree = ast.parse(stub_src)
classes = _collect(tree)
domain = _domain_classes(classes)
domain_set = set(domain)
blocks = [_emit_class(classes[name], domain_set) for name in domain]
all_block = "".join(f' "{d}",\n' for d in domain)
return (
HEADER.format(baseline=baseline)
+ f"\n__all__ = [\n{all_block}]\n\n\n"
+ "\n\n\n".join(blocks)
+ "\n"
)


def _find_baseline(gen_dir: Path) -> Path:
"""The oldest generated version directory (lexicographically first vN_...)."""
candidates = sorted(
p for p in gen_dir.iterdir() if p.is_dir() and re.match(r"v\d", p.name)
)
for p in candidates:
if (p / "model.pyi").exists():
return p
raise SystemExit("generate-protocols: no baseline model.pyi found")


def main() -> int:
gen_dir = Path(__file__).resolve().parent
baseline = _find_baseline(gen_dir)
out = generate((baseline / "model.pyi").read_text(), baseline.name)
(gen_dir / "_domain.py").write_text(out)
return 0


if __name__ == "__main__":
sys.exit(main())
21 changes: 19 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ artifacts = [
"*.pyi",
]

# Generate the version-agnostic domain Protocols from the baseline bindings.
# Runs after generate-bindings (reads its baseline model.pyi). clean_out_dir is
# false so the hand-written core.py / __init__.py in the package are preserved.
[[tool.hatch.build.hooks.build-scripts.scripts]]
out_dir = "src/spdx_python_model/protocols"
work_dir = "gen"
clean_out_dir = false
commands = [
"./generate-protocols"
]
artifacts = [
"_domain.py",
]

[tool.hatch.version]
path = "src/spdx_python_model/version.py"

Expand All @@ -83,5 +97,8 @@ addopts = [
]

[tool.pylint.main]
# The generated binding fails style checks. Ignore it.
ignore-paths = ['^src/spdx_python_model/bindings/v3_0_1/.*$']
# The generated binding and domain protocols fail style checks. Ignore them.
ignore-paths = [
'^src/spdx_python_model/bindings/v3_0_1/.*$',
'^src/spdx_python_model/protocols/_domain.py$',
]
22 changes: 16 additions & 6 deletions src/spdx_python_model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import json
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING, Any, List, Tuple
from typing import TYPE_CHECKING, Any, List, Tuple, cast

from .bindings import _CONTEXT_TABLE
from .protocols import SpdxModelModule, SpdxObject, SpdxObjectSet
from .version import VERSION
from .version import VERSION as __version__

Expand All @@ -22,6 +23,9 @@
__all__ = [
"bindings", # generated # noqa: F405
"LoadError",
"SpdxModelModule",
"SpdxObject",
"SpdxObjectSet",
"VERSION",
"__version__",
"load",
Expand All @@ -47,14 +51,17 @@ def __dir__() -> List[str]:
return sorted(set(globals()) | _VERSION_MODULES)


def load_data(data: Any) -> Tuple[ModuleType, Any]:
def load_data(data: Any) -> Tuple[ModuleType, "SpdxObjectSet"]:
"""
Automatically load a SPDX 3 JSON document with the correct model based on
its context

:param data: The decoded JSON data as a Python dict

:returns: A tuple that contains the model and the decoded SHACLObjectSet
:returns: A tuple that contains the model and the decoded SHACLObjectSet.
The object set is typed as the version-agnostic
:class:`~spdx_python_model.protocols.SpdxObjectSet`; the model is the
concrete version submodule (a ``ModuleType``).

:raises LoadError: If the data is missing a context or if the context is
not recognized
Expand Down Expand Up @@ -92,17 +99,20 @@ def load_data(data: Any) -> Tuple[ModuleType, Any]:

d.deserialize_data(data, objset)

return model, objset
return model, cast("SpdxObjectSet", objset)


def load(path: Path) -> Tuple[ModuleType, Any]:
def load(path: Path) -> Tuple[ModuleType, "SpdxObjectSet"]:
"""
Automatically load a SPDX 3 JSON document with the correct model based on
its context

:param path: The path to the SPDX 3 JSON file

:returns: A tuple that contains the model and the decoded SHACLObjectSet
:returns: A tuple that contains the model and the decoded SHACLObjectSet.
The object set is typed as the version-agnostic
:class:`~spdx_python_model.protocols.SpdxObjectSet`; the model is the
concrete version submodule (a ``ModuleType``).

:raises LoadError: If the data is missing a context or if the context is
not recognized
Expand Down
25 changes: 25 additions & 0 deletions src/spdx_python_model/protocols/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""
Version-agnostic structural types (PEP 544 Protocols) for the SPDX 3 bindings.

- Core protocols (:mod:`.core`, hand-written): ``SpdxObject``, ``SpdxObjectSet``,
``SpdxModelModule`` — the version-neutral machinery surface.
- Domain protocols (:mod:`._domain`, generated at build time from the baseline
version's stub): one per SPDX class, e.g. ``protocols.Element``,
``protocols.CreationInfo``.

See the package README for guidance on when to annotate with a protocol versus a
concrete version type.
"""

from . import _domain
from ._domain import * # noqa: F401, F403
from .core import SpdxModelModule, SpdxObject, SpdxObjectSet

__all__ = [
"SpdxModelModule",
"SpdxObject",
"SpdxObjectSet",
*_domain.__all__,
]
Loading