diff --git a/gen/generate-bindings b/gen/generate-bindings index c1a1c1a..5108b64 100755 --- a/gen/generate-bindings +++ b/gen/generate-bindings @@ -1,20 +1,21 @@ #! /bin/sh # +# SPDX-FileType: SOURCE # SPDX-License-Identifier: Apache-2.0 +# +# Generate the per-version bindings and the bindings package __init__.py. set -e # SPDX versions to generate SPDX_VERSIONS="3.0.1" -mkdir -p "gen" - -echo "# Import all versions" > __init__.py - get_context_url() { echo "https://spdx.org/rdf/$1/spdx-context.jsonld" } +# Generate the per-version bindings with shacl2code. +# Each version ends up in its own subpackage (e.g. "v3_0_1"). for v in $SPDX_VERSIONS; do MODNAME="v$(echo "$v" | sed 's/[^a-zA-Z0-9_]/_/g')" @@ -35,19 +36,50 @@ for v in $SPDX_VERSIONS; do python \ --output "$MODNAME" fi - - echo "from . import $MODNAME" >> __init__.py done -MODNAME="" -CONTEXT_URL="" +# Generate the bindings package __init__.py. +{ + echo '# SPDX-FileType: SOURCE' + echo '# SPDX-License-Identifier: Apache-2.0' + echo '#' + echo '# Generated by generate-bindings at build time. DO NOT EDIT.' + echo '"""SPDX model bindings, one submodule per version.' + echo + echo 'Versions are not imported here, so importing one does not load the others.' + echo '"""' + echo + echo '# JSON-LD @context URL -> version submodule name, for lazy lookup by load().' + echo '_CONTEXT_TABLE = {' + for v in $SPDX_VERSIONS; do + MODNAME="v$(echo "$v" | sed 's/[^a-zA-Z0-9_]/_/g')" + CONTEXT_URL="$(get_context_url "$v")" + echo " \"$CONTEXT_URL\": \"$MODNAME\"," + done + echo '}' +} > __init__.py -echo >> __init__.py -echo "# Generate context table" >> __init__.py -echo "_CONTEXT_TABLE = {" >> __init__.py -for v in $SPDX_VERSIONS; do - MODNAME="v$(echo "$v" | sed 's/[^a-zA-Z0-9_]/_/g')" - CONTEXT_URL="$(get_context_url "$v")" - echo " '$CONTEXT_URL': $MODNAME" >> __init__.py -done -echo "}" >> __init__.py +# Generate _reexport.pyi: lets type checkers resolve spdx_python_model.vX types +# while the runtime loads versions lazily (imported only under TYPE_CHECKING). +{ + echo '# SPDX-FileType: SOURCE' + echo '# SPDX-License-Identifier: Apache-2.0' + echo '#' + echo '# Generated by generate-bindings at build time. DO NOT EDIT.' + echo '"""Type-checking-only re-exports so ``spdx_python_model.vX`` types resolve.' + echo + echo 'Imported under TYPE_CHECKING only; the runtime loads versions lazily via the' + echo 'package __getattr__. __all__ marks the names as re-exports for --no-implicit-reexport.' + echo '"""' + for v in $SPDX_VERSIONS; do + MODNAME="v$(echo "$v" | sed 's/[^a-zA-Z0-9_]/_/g')" + echo "from . import $MODNAME" + done + echo + echo '__all__ = [' + for v in $SPDX_VERSIONS; do + MODNAME="v$(echo "$v" | sed 's/[^a-zA-Z0-9_]/_/g')" + echo " \"$MODNAME\"," + done + echo ']' +} > _reexport.pyi diff --git a/pyproject.toml b/pyproject.toml index df011e8..af0ee34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ license = "Apache-2.0" [project.optional-dependencies] dev = [ + "mypy >= 1.19.1", + "pylint >= 3.3.9", "pytest >= 7.4", ] @@ -48,14 +50,6 @@ requires = [ ] build-backend = "hatchling.build" -[tool.hatch.version] -path = "src/spdx_python_model/version.py" - -[tool.pytest.ini_options] -addopts = [ - "--import-mode=importlib", -] - [[tool.hatch.build.hooks.build-scripts.scripts]] out_dir = "src/spdx_python_model/bindings" work_dir = "gen" @@ -67,3 +61,24 @@ artifacts = [ "*.py", "*.pyi", ] + +[tool.hatch.version] +path = "src/spdx_python_model/version.py" + +[tool.mypy] +disallow_any_decorated = true +disallow_any_unimported = true +extra_checks = true +python_version = "3.9" +strict = true +strict_bytes = true +warn_unreachable = true + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] + +[tool.pylint.main] +# The generated binding fails style checks. Ignore it. +ignore-paths = ['^src/spdx_python_model/bindings/v3_0_1/.*$'] diff --git a/src/spdx_python_model/__init__.py b/src/spdx_python_model/__init__.py index 4eb3237..f6df1d1 100644 --- a/src/spdx_python_model/__init__.py +++ b/src/spdx_python_model/__init__.py @@ -1,21 +1,45 @@ -# +# SPDX-FileType: SOURCE # SPDX-License-Identifier: Apache-2.0 # +"""SPDX 3 model.""" -from .bindings import * -from .version import VERSION +import importlib +import json +from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING, Any, List, Tuple from .bindings import _CONTEXT_TABLE +from .version import VERSION +from .version import VERSION as __version__ -from pathlib import Path -import json +if TYPE_CHECKING: + # Generated re-exports to give type checkers version types. + # No imported during runtime. + from .bindings._reexport import * # noqa: F403 + +__all__ = ["LoadError", "VERSION", "__version__", "load", "load_data"] + +# Version submodule names accepted by __getattr__ for top-level import. +_VERSION_MODULES = frozenset(_CONTEXT_TABLE.values()) class LoadError(Exception): - pass + """Raised when a SPDX document cannot be loaded.""" + + +def __getattr__(name: str) -> ModuleType: + """Lazily import a version's bindings on first top-level access (PEP 562).""" + if name in _VERSION_MODULES: + return importlib.import_module(f"{__name__}.bindings.{name}") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> List[str]: + return sorted(set(globals()) | _VERSION_MODULES) -def load_data(data): +def load_data(data: Any) -> Tuple[ModuleType, Any]: """ Automatically load a SPDX 3 JSON document with the correct model based on its context @@ -32,15 +56,16 @@ def load_data(data): if not isinstance(data, dict): raise TypeError("Data must be a dictionary") - if "@context" not in data: + context = data.get("@context") + if not context: raise LoadError("No @context in data") context_url = None - if isinstance(data["@context"], str): - context_url = data["@context"] - elif isinstance(data["@context"], list): - for item in data["@context"]: + if isinstance(context, str): + context_url = context + elif isinstance(context, list): + for item in context: if isinstance(item, str): context_url = item break @@ -48,10 +73,11 @@ def load_data(data): if not context_url: raise LoadError("No valid @context URL string found in data") - if context_url not in _CONTEXT_TABLE: - raise LoadError(f"Unknown context URL '{context}'") + module_name = _CONTEXT_TABLE.get(context_url) + if module_name is None: + raise LoadError(f"Unknown context URL '{context_url}'") - model = _CONTEXT_TABLE[context_url] + model = importlib.import_module(f"{__name__}.bindings.{module_name}") d = model.JSONLDDeserializer() objset = model.SHACLObjectSet() @@ -61,12 +87,12 @@ def load_data(data): return model, objset -def load(path: Path): +def load(path: Path) -> Tuple[ModuleType, Any]: """ Automatically load a SPDX 3 JSON document with the correct model based on its context - :param data: The path to the SPDX 3 JSON file + :param path: The path to the SPDX 3 JSON file :returns: A tuple that contains the model and the decoded SHACLObjectSet diff --git a/src/spdx_python_model/version.py b/src/spdx_python_model/version.py index 9336b04..df9f055 100644 --- a/src/spdx_python_model/version.py +++ b/src/spdx_python_model/version.py @@ -1,5 +1,6 @@ -# +# SPDX-FileType: SOURCE # SPDX-License-Identifier: Apache-2.0 # +"""Package version information.""" VERSION = "0.0.5" diff --git a/tests/test_import.py b/tests/test_import.py index 83de4c2..567615d 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -119,6 +119,28 @@ def test_direct_create(): def test_alias(): - from spdx_python_model import v3_0_1 as spdx + # The eager path under spdx_python_model.bindings stays available too. + from spdx_python_model.bindings import v3_0_1 as spdx p = spdx.Person() + + +def test_lazy_import(): + """Importing the package must not eagerly load any version bindings.""" + import importlib + import sys + + for mod in list(sys.modules): + if mod.startswith("spdx_python_model"): + del sys.modules[mod] + + importlib.import_module("spdx_python_model") + assert not any( + m.startswith("spdx_python_model.bindings.v") for m in sys.modules + ), "no version bindings should be loaded on 'import spdx_python_model'" + + # Accessing a version triggers a lazy import of just that version. + import spdx_python_model + + assert spdx_python_model.v3_0_1 is not None + assert "spdx_python_model.bindings.v3_0_1" in sys.modules diff --git a/tests/test_load.py b/tests/test_load.py index 9f31eb4..849ce9a 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -1,7 +1,6 @@ # SPDX-FileType: SOURCE # SPDX-License-Identifier: Apache-2.0 -import importlib import re from pathlib import Path