Skip to content

Commit 808d99e

Browse files
committed
feat: derive runtime projections from declared types
1 parent 0c34ae0 commit 808d99e

16 files changed

Lines changed: 1262 additions & 92 deletions

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
project = "python-introspect"
1717
copyright = "2025, Tristan Simas"
1818
author = "Tristan Simas"
19-
release = "0.1.5"
19+
release = "0.1.6"
2020
version = "0.1"
2121

2222
# -- General configuration ---------------------------------------------------

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-introspect"
7-
version = "0.1.5"
7+
version = "0.1.6"
88
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -26,6 +26,7 @@ classifiers = [
2626
]
2727

2828
dependencies = [
29+
"annotated-types>=0.7.0",
2930
"metaclass-registry>=0.1.0",
3031
]
3132

src/python_introspect/__init__.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
type resolution for framework-specific types (lazy configs, proxies, etc.)
1010
"""
1111

12-
__version__ = "0.1.5"
12+
__version__ = "0.1.6"
1313

1414
from .signature_analyzer import (
1515
SignatureAnalyzer,
@@ -40,6 +40,31 @@
4040
is_enableable,
4141
mark_enableable,
4242
)
43+
from .validation import (
44+
AnnotationValidationError,
45+
overlay_non_none_dataclass,
46+
validate_annotated_dataclass,
47+
validate_annotation_value,
48+
)
49+
from .dataclass_projection import (
50+
dataclass_from_mapping,
51+
project_dataclass,
52+
)
53+
from .environment_projection import (
54+
EnvironmentVariable,
55+
overlay_dataclass_from_environment,
56+
)
57+
from .annotation_types import (
58+
enum_member_type,
59+
get_enum_from_list,
60+
is_enum_type,
61+
is_list_of_enums,
62+
is_union_type,
63+
make_optional,
64+
optional_member_type,
65+
resolve_annotated,
66+
resolve_optional,
67+
)
4368

4469
__all__ = [
4570
# Version
@@ -69,4 +94,25 @@
6994
"Enableable",
7095
"is_enableable",
7196
"mark_enableable",
97+
# Runtime annotation validation
98+
"AnnotationValidationError",
99+
"overlay_non_none_dataclass",
100+
"validate_annotated_dataclass",
101+
"validate_annotation_value",
102+
# Dataclass projection
103+
"dataclass_from_mapping",
104+
"project_dataclass",
105+
# Environment projection
106+
"EnvironmentVariable",
107+
"overlay_dataclass_from_environment",
108+
# Annotation type operations
109+
"enum_member_type",
110+
"get_enum_from_list",
111+
"is_enum_type",
112+
"is_list_of_enums",
113+
"is_union_type",
114+
"make_optional",
115+
"optional_member_type",
116+
"resolve_annotated",
117+
"resolve_optional",
72118
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Generic operations derived directly from Python type annotations."""
2+
3+
from __future__ import annotations
4+
5+
import types
6+
from enum import Enum
7+
from typing import Annotated, Union, get_args, get_origin
8+
9+
10+
def is_union_type(annotation: object) -> bool:
11+
"""Return whether ``annotation`` is a typing or PEP 604 union."""
12+
13+
return get_origin(annotation) in {Union, types.UnionType}
14+
15+
16+
def optional_member_type(annotation: object) -> object | None:
17+
"""Return ``T`` only for the simple optional declaration ``T | None``."""
18+
19+
if not is_union_type(annotation):
20+
return None
21+
members = get_args(annotation)
22+
if len(members) != 2 or type(None) not in members:
23+
return None
24+
return next(member for member in members if member is not type(None))
25+
26+
27+
def make_optional(annotation: object) -> object:
28+
"""Return the same declaration with ``None`` admitted exactly once."""
29+
30+
if is_union_type(annotation) and type(None) in get_args(annotation):
31+
return annotation
32+
return Union[annotation, type(None)]
33+
34+
35+
def resolve_optional(annotation: object) -> object:
36+
"""Resolve an optional declaration to its non-None member declaration."""
37+
38+
member_type = optional_member_type(annotation)
39+
return annotation if member_type is None else member_type
40+
41+
42+
def resolve_annotated(annotation: object) -> object:
43+
"""Resolve an ``Annotated[T, ...]`` declaration to its owned type ``T``."""
44+
45+
if get_origin(annotation) is Annotated:
46+
return get_args(annotation)[0]
47+
return annotation
48+
49+
50+
def is_enum_type(annotation: object) -> bool:
51+
"""Return whether the declaration is an enum type."""
52+
53+
return isinstance(annotation, type) and issubclass(annotation, Enum)
54+
55+
56+
def enum_member_type(annotation: object) -> type[Enum] | None:
57+
"""Return the enum type declared directly or as a simple optional."""
58+
59+
if is_enum_type(annotation):
60+
return annotation
61+
if not is_union_type(annotation):
62+
return None
63+
members = tuple(
64+
member for member in get_args(annotation) if member is not type(None)
65+
)
66+
if len(members) != 1 or not is_enum_type(members[0]):
67+
return None
68+
return members[0]
69+
70+
71+
def is_list_of_enums(annotation: object) -> bool:
72+
"""Return whether the declaration is ``list[SomeEnum]``."""
73+
74+
members = get_args(annotation)
75+
return (
76+
get_origin(annotation) is list
77+
and len(members) == 1
78+
and is_enum_type(members[0])
79+
)
80+
81+
82+
def get_enum_from_list(annotation: object) -> type[Enum] | None:
83+
"""Return the enum type from ``list[SomeEnum]`` when declared."""
84+
85+
if not is_list_of_enums(annotation):
86+
return None
87+
return get_args(annotation)[0]

0 commit comments

Comments
 (0)