Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 25 additions & 1 deletion src/python_introspect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions src/python_introspect/jsonable.py
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions src/python_introspect/public_api.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading