From a1b550fde95bc4b6cd63cad2f7ffe69fd33094ae Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 9 Oct 2026 22:10:19 -0400 Subject: [PATCH 1/2] Own the JSON encoder, module public surfaces and lazy exports to_jsonable becomes the encoder paired with dataclass_from_mapping, moved from OpenHCS with its JsonValue/JsonObject/JsonScalar aliases. The module public-surface helpers (declared_public_names and friends) move from OpenHCS core.public_api. lazy_exports(globals(), {owner: names}) installs the PEP 562 __getattr__/__dir__ pair that first-party packages copied verbatim, and returns the exported names for __all__. Version 0.2.2: additive, within the <0.3 bound ObjectState pins. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- src/python_introspect/__init__.py | 26 +++++- src/python_introspect/jsonable.py | 120 +++++++++++++++++++++++++ src/python_introspect/public_api.py | 111 ++++++++++++++++++++++++ tests/test_jsonable.py | 130 ++++++++++++++++++++++++++++ tests/test_public_api.py | 93 ++++++++++++++++++++ 6 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/python_introspect/jsonable.py create mode 100644 src/python_introspect/public_api.py create mode 100644 tests/test_jsonable.py create mode 100644 tests/test_public_api.py diff --git a/pyproject.toml b/pyproject.toml index 360da38..6e98454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.1" +version = "0.2.2" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index 7dbbefe..432de68 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.2.1" +__version__ = "0.2.2" from .signature_analyzer import ( SignatureAnalyzer, @@ -50,6 +50,19 @@ dataclass_from_mapping, project_dataclass, ) +from .jsonable import ( + JsonObject, + JsonScalar, + JsonValue, + to_jsonable, +) +from .public_api import ( + declared_public_names, + exported_public_names, + is_declared_public_name, + lazy_exports, + public_names_from_objects, +) from .environment_projection import ( EnvironmentVariable, overlay_dataclass_from_environment, @@ -109,6 +122,17 @@ # Dataclass projection "dataclass_from_mapping", "project_dataclass", + # JSON-native projection + "JsonObject", + "JsonScalar", + "JsonValue", + "to_jsonable", + # Module public surfaces + "declared_public_names", + "exported_public_names", + "is_declared_public_name", + "lazy_exports", + "public_names_from_objects", # Environment projection "EnvironmentVariable", "overlay_dataclass_from_environment", diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py new file mode 100644 index 0000000..ba97e3d --- /dev/null +++ b/src/python_introspect/jsonable.py @@ -0,0 +1,120 @@ +"""JSON-native projection of declared Python values. + +``to_jsonable`` is the encoder paired with ``dataclass_from_mapping``: a +dataclass projects through its declared fields, and the decoder rebuilds it +from the same declarations. Further value types join through +``to_jsonable.register``. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from dataclasses import fields, is_dataclass +from enum import Enum +from functools import singledispatch +from pathlib import Path +from typing import ForwardRef, TypeAlias + +from metaclass_registry import AutoRegisterMeta + +from .signature_analyzer import signature_analysis_target + +JsonScalar: TypeAlias = str | int | float | bool | None +# Recursive aliases retain this declaration's namespace when imported by +# dataclasses; get_type_hints evaluates the reference in this module. +_JSON_VALUE_REFERENCE = ForwardRef("JsonValue", module=__name__) +JsonValue: TypeAlias = ( + JsonScalar + | Mapping[str, _JSON_VALUE_REFERENCE] + | tuple[_JSON_VALUE_REFERENCE, ...] + | list[_JSON_VALUE_REFERENCE] +) +JsonObject: TypeAlias = Mapping[str, JsonValue] + + +def _jsonable_dataclass(value: object) -> JsonValue: + """Project fields without ``asdict`` deep-copying immutable containers.""" + + return { + declared_field.name: to_jsonable(getattr(value, declared_field.name)) + for declared_field in fields(value) + } + + +@singledispatch +def to_jsonable(value: object) -> JsonValue: + """Project dataclasses and registered values into JSON-native data.""" + + if is_dataclass(value) and not isinstance(value, type): + return _jsonable_dataclass(value) + raise TypeError(f"Value is not JSON-serializable: {type(value).__name__}") + + +@to_jsonable.register(Mapping) +def _jsonable_mapping(value: Mapping) -> JsonValue: + return {str(to_jsonable(key)): to_jsonable(item) for key, item in value.items()} + + +@to_jsonable.register(tuple) +@to_jsonable.register(list) +@to_jsonable.register(set) +@to_jsonable.register(frozenset) +def _jsonable_sequence(value) -> JsonValue: + return [to_jsonable(item) for item in value] + + +@to_jsonable.register(Callable) +def _jsonable_callable(value: Callable[..., object]) -> JsonValue: + if is_dataclass(value) and not isinstance(value, type): + return _jsonable_dataclass(value) + target = signature_analysis_target(value) + module = inspect.getmodule(target) + module_name = type(target).__module__ if module is None else module.__name__ + if inspect.isfunction(target) or inspect.ismethod(target) or inspect.isclass(target): + qualname = target.__qualname__ + else: + qualname = type(target).__qualname__ + return { + "kind": "callable", + "name": qualname.rsplit(".", 1)[-1], + "module": module_name, + "qualname": qualname, + "import_path": f"{module_name}.{qualname}", + } + + +@to_jsonable.register(AutoRegisterMeta) +def _jsonable_registered_type(value: AutoRegisterMeta) -> JsonValue: + key_attribute = value.__registry_key__ + declaring_owner = next( + (owner for owner in value.__mro__ if key_attribute in vars(owner)), + None, + ) + if declaring_owner is None: + raise TypeError( + f"Registered type {value.__qualname__} has no declared " f"{key_attribute!r} key." + ) + key = vars(declaring_owner)[key_attribute] + if key is None: + raise TypeError(f"Registered type {value.__qualname__} has no registry key.") + return to_jsonable(key) + + +@to_jsonable.register(Enum) +def _jsonable_enum(value: Enum) -> JsonValue: + return to_jsonable(value.value) + + +@to_jsonable.register(Path) +def _jsonable_path(value: Path) -> JsonValue: + return str(value) + + +@to_jsonable.register(type(None)) +@to_jsonable.register(str) +@to_jsonable.register(int) +@to_jsonable.register(float) +@to_jsonable.register(bool) +def _jsonable_scalar(value) -> JsonValue: + return value diff --git a/src/python_introspect/public_api.py b/src/python_introspect/public_api.py new file mode 100644 index 0000000..33726e0 --- /dev/null +++ b/src/python_introspect/public_api.py @@ -0,0 +1,111 @@ +"""Module public surfaces derived from what a module declares.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from importlib import import_module +from inspect import isclass, isfunction +from types import ModuleType +from typing import Any + + +def declared_public_names( + module_globals: Mapping[str, object], + *, + constant_prefixes: Iterable[str] = (), + excluded_names: Iterable[str] = (), + extra_names: Iterable[str] = (), +) -> tuple[str, ...]: + """Return public names declared by the module represented by globals().""" + module_name = module_globals["__name__"] + prefixes = tuple(constant_prefixes) + excluded = set(excluded_names) + declared_names = tuple( + name + for name, value in module_globals.items() + if name not in excluded + if is_declared_public_name( + module_name, + name, + value, + constant_prefixes=prefixes, + ) + ) + return declared_names + tuple(name for name in extra_names if name not in excluded) + + +def exported_public_names( + module_globals: Mapping[str, object], + *, + excluded_names: Iterable[str] = (), +) -> tuple[str, ...]: + """Return public re-export names declared by explicit module imports.""" + excluded = set(excluded_names) + return tuple( + name + for name, value in module_globals.items() + if not name.startswith("_") + if name not in excluded + if not isinstance(value, ModuleType) + ) + + +def public_names_from_objects(*objects: Any, extra_names: Iterable[str] = ()) -> tuple[str, ...]: + """Return public names from exported object identities plus explicit aliases.""" + return tuple(item if isinstance(item, str) else item.__name__ for item in objects) + tuple( + extra_names + ) + + +def is_declared_public_name( + module_name: str, + name: str, + value: object, + *, + constant_prefixes: tuple[str, ...] = (), +) -> bool: + """Return whether a global is a public module declaration.""" + if name.startswith("_"): + return False + if name.isupper(): + return any(name.startswith(prefix) for prefix in constant_prefixes) + return (isclass(value) or isfunction(value)) and value.__module__ == module_name + + +def lazy_exports( + module_globals: dict[str, object], + exports: Mapping[str, Iterable[str]], +) -> tuple[str, ...]: + """Install PEP 562 ``__getattr__``/``__dir__`` resolving names on first access. + + ``exports`` maps each owning module path (absolute, or relative to the + package whose ``globals()`` are passed) to the names it supplies. A name is + imported from its owner on first access and cached in the module globals. + Returns the exported names in declaration order, for ``__all__``. + """ + + module_name = module_globals["__name__"] + package = module_globals.get("__package__") or module_name + owners: dict[str, str] = {} + for owner, names in exports.items(): + for name in names: + if name in owners: + raise ValueError( + f"{module_name} lazy export {name!r} is declared by both " + f"{owners[name]!r} and {owner!r}." + ) + owners[name] = owner + + def __getattr__(name: str) -> object: + if name not in owners: + raise AttributeError(f"module {module_name!r} has no attribute {name!r}") + value = getattr(import_module(owners[name], package), name) + module_globals[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(module_globals) | set(owners)) + + module_globals["__getattr__"] = __getattr__ + module_globals["__dir__"] = __dir__ + return tuple(owners) diff --git a/tests/test_jsonable.py b/tests/test_jsonable.py new file mode 100644 index 0000000..3e4c4a6 --- /dev/null +++ b/tests/test_jsonable.py @@ -0,0 +1,130 @@ +"""The encoder and decoder agree on every declared value family.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +import pytest +from metaclass_registry import AutoRegisterMeta + +from python_introspect import ( + JsonObject, + JsonValue, + dataclass_from_mapping, + to_jsonable, +) + + +class Mode(Enum): + IPC = "ipc" + TCP = "tcp" + + +@dataclass(frozen=True) +class Endpoint: + host: str + port: int + mode: Mode + + +@dataclass(frozen=True) +class Record: + endpoint: Endpoint + path: Path + tags: tuple[str, ...] + weights: dict[str, float] + payload: JsonObject + extra: JsonValue = None + optional_endpoint: Endpoint | None = None + values: list[JsonValue] = field(default_factory=list) + + +def sample_record() -> Record: + return Record( + endpoint=Endpoint(host="localhost", port=7777, mode=Mode.TCP), + path=Path("/plates/a"), + tags=("x", "y"), + weights={"a": 0.5}, + payload={"nested": [1, {"deep": None}], "flag": True, "ratio": 1.5}, + extra=["a", 2], + optional_endpoint=None, + values=[1, "two", {"three": [3]}], + ) + + +def test_dataclass_round_trips_through_declared_fields(): + record = sample_record() + encoded = to_jsonable(record) + + assert encoded == { + "endpoint": {"host": "localhost", "port": 7777, "mode": "tcp"}, + "path": "/plates/a", + "tags": ["x", "y"], + "weights": {"a": 0.5}, + "payload": {"nested": [1, {"deep": None}], "flag": True, "ratio": 1.5}, + "extra": ["a", 2], + "optional_endpoint": None, + "values": [1, "two", {"three": [3]}], + } + assert dataclass_from_mapping(Record, encoded) == record + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("text", "text"), + (3, 3), + (2.5, 2.5), + (True, True), + (Mode.IPC, "ipc"), + (Path("a/b"), "a/b"), + ((1, 2), [1, 2]), + (frozenset({1}), [1]), + ({Mode.TCP: (1,)}, {"tcp": [1]}), + ], +) +def test_value_families_project_to_json_native_data(value, expected): + assert to_jsonable(value) == expected + + +def test_callables_project_to_import_identity(): + assert to_jsonable(sample_record) == { + "kind": "callable", + "name": "sample_record", + "module": __name__, + "qualname": "sample_record", + "import_path": f"{__name__}.sample_record", + } + + +def test_registered_types_project_to_their_registry_key(): + class Family(metaclass=AutoRegisterMeta): + __registry_key__ = "family_key" + family_key = None + + class Member(Family): + family_key = Mode.TCP + + assert to_jsonable(Member) == "tcp" + with pytest.raises(TypeError, match="has no registry key"): + to_jsonable(Family) + + +def test_unregistered_values_are_rejected(): + with pytest.raises(TypeError, match="not JSON-serializable: object"): + to_jsonable(object()) + + +def test_new_value_family_joins_by_registration(): + class Celsius: + def __init__(self, degrees: float) -> None: + self.degrees = degrees + + @to_jsonable.register(Celsius) + def _celsius(value: Celsius) -> JsonValue: + return {"celsius": value.degrees} + + assert to_jsonable({"t": Celsius(21.0)}) == {"t": {"celsius": 21.0}} diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..00b8198 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,93 @@ +"""Module public surfaces derive from declarations, and lazy exports resolve once.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from python_introspect import ( + declared_public_names, + exported_public_names, + is_declared_public_name, + lazy_exports, + public_names_from_objects, +) + + +def make_module(name: str, source: str) -> types.ModuleType: + module = types.ModuleType(name) + module.__package__ = name.rpartition(".")[0] or name + sys.modules[name] = module + exec(source, module.__dict__) + return module + + +@pytest.fixture +def owner_package(): + names = ("lazy_pkg", "lazy_pkg.owner", "lazy_pkg.other") + package = make_module("lazy_pkg", "") + package.__path__ = [] + package.__package__ = "lazy_pkg" + make_module("lazy_pkg.owner", "class Widget: pass\ndef build(): return 1\n") + make_module("lazy_pkg.other", "VALUE = 7\n") + yield package + for name in names: + sys.modules.pop(name, None) + + +def test_declared_public_names_follow_module_declarations(): + module = make_module( + "declaring_module", + "from pathlib import Path\n" + "import os\n" + "class Local: pass\n" + "def helper(): pass\n" + "def _private(): pass\n" + "LIMIT_MAX = 1\n" + "OTHER = 2\n", + ) + try: + assert declared_public_names( + vars(module), constant_prefixes=("LIMIT_",), extra_names=("alias",) + ) == ("Local", "helper", "LIMIT_MAX", "alias") + assert declared_public_names(vars(module), excluded_names=("helper",)) == ("Local",) + assert "Path" in exported_public_names(vars(module)) + assert "os" not in exported_public_names(vars(module)) + assert not is_declared_public_name("declaring_module", "Path", module.Path) + assert public_names_from_objects(module.Local, "named", extra_names=("x",)) == ( + "Local", + "named", + "x", + ) + finally: + sys.modules.pop("declaring_module", None) + + +def test_lazy_exports_resolve_on_first_access_and_cache(owner_package): + namespace = vars(owner_package) + exported = lazy_exports( + namespace, + {".owner": ("Widget", "build"), "lazy_pkg.other": ("VALUE",)}, + ) + + assert exported == ("Widget", "build", "VALUE") + assert "Widget" not in namespace + assert set(exported) <= set(dir(owner_package)) + assert owner_package.Widget is sys.modules["lazy_pkg.owner"].Widget + assert namespace["Widget"] is owner_package.Widget + assert owner_package.VALUE == 7 + with pytest.raises(AttributeError, match="has no attribute 'missing'"): + owner_package.missing + + +def test_lazy_exports_reject_a_name_owned_twice(owner_package): + with pytest.raises(ValueError, match="declared by both"): + lazy_exports(vars(owner_package), {".owner": ("Widget",), ".other": ("Widget",)}) + + +def test_lazy_export_of_an_undeclared_owner_attribute_fails(owner_package): + lazy_exports(vars(owner_package), {".other": ("Widget",)}) + with pytest.raises(AttributeError): + owner_package.Widget From 95f1ed3312853d8988c2ddf2d8f2944499198e59 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 9 Oct 2026 23:58:24 -0400 Subject: [PATCH 2/2] Derive expected path text from the platform; add changelog The path assertions now compare against str(PurePath(...)), which matches the encoder's str(path) on Windows. The changelog records that 0.2.2 is the first 0.2.x on PyPI (the 0.2.0/0.2.1 publishes were cancelled). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++++++++++++++++ tests/test_jsonable.py | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c50dd64 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +0.2.2 is the first 0.2.x release published to PyPI. The 0.2.0 and 0.2.1 tags exist in git, but their publish runs were cancelled, so upgrading from PyPI goes straight from 0.1.16 to 0.2.2. The notes below cover every change in that jump. + +## 0.2.2 + +Added: +- `to_jsonable`, with the `JsonValue`, `JsonObject` and `JsonScalar` aliases. It is the JSON-native encoder paired with `dataclass_from_mapping`: dataclasses are encoded by their declared fields, along with mappings, sequences, enums, paths (`str(path)`), callables (by import identity) and `AutoRegisterMeta` types (by registry key). More types can be added with `to_jsonable.register`. +- Module public-surface helpers: `declared_public_names`, `exported_public_names`, `public_names_from_objects` and `is_declared_public_name`. +- `lazy_exports(globals(), {owner_module: names})`. It installs a PEP 562 `__getattr__`/`__dir__` pair and returns the exported names for `__all__`. + +## 0.2.1 (tagged, not published) + +- Recursive sequence declarations now resolve on Python 3.10. +- Release readiness is now derived from the declared project metadata. + +## 0.2.0 (tagged, not published) + +- Breaking: `UnifiedParameterInfo` was removed. Dataclass presentation is now derived from the canonical parameter declarations. +- Immutable declaration docs are now prepared through the existing source cache. diff --git a/tests/test_jsonable.py b/tests/test_jsonable.py index 3e4c4a6..a99d479 100644 --- a/tests/test_jsonable.py +++ b/tests/test_jsonable.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from enum import Enum -from pathlib import Path +from pathlib import Path, PurePath import pytest from metaclass_registry import AutoRegisterMeta @@ -60,7 +60,7 @@ def test_dataclass_round_trips_through_declared_fields(): assert encoded == { "endpoint": {"host": "localhost", "port": 7777, "mode": "tcp"}, - "path": "/plates/a", + "path": str(PurePath("/plates/a")), "tags": ["x", "y"], "weights": {"a": 0.5}, "payload": {"nested": [1, {"deep": None}], "flag": True, "ratio": 1.5}, @@ -80,7 +80,7 @@ def test_dataclass_round_trips_through_declared_fields(): (2.5, 2.5), (True, True), (Mode.IPC, "ipc"), - (Path("a/b"), "a/b"), + (Path("a/b"), str(PurePath("a/b"))), ((1, 2), [1, 2]), (frozenset({1}), [1]), ({Mode.TCP: (1,)}, {"tcp": [1]}),