python-introspect 0.1.16__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_introspect/__init__.py +25 -3
- python_introspect/annotation_types.py +46 -1
- python_introspect/dataclass_projection.py +2 -3
- python_introspect/jsonable.py +120 -0
- python_introspect/public_api.py +111 -0
- python_introspect/signature_analyzer.py +243 -116
- python_introspect/unified_parameter_analyzer.py +24 -123
- python_introspect/validation.py +2 -3
- {python_introspect-0.1.16.dist-info → python_introspect-0.2.2.dist-info}/METADATA +18 -1
- python_introspect-0.2.2.dist-info/RECORD +19 -0
- python_introspect-0.1.16.dist-info/RECORD +0 -17
- {python_introspect-0.1.16.dist-info → python_introspect-0.2.2.dist-info}/WHEEL +0 -0
- {python_introspect-0.1.16.dist-info → python_introspect-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {python_introspect-0.1.16.dist-info → python_introspect-0.2.2.dist-info}/top_level.txt +0 -0
python_introspect/__init__.py
CHANGED
|
@@ -9,7 +9,7 @@ Extensibility:
|
|
|
9
9
|
type resolution for framework-specific types (lazy configs, proxies, etc.)
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
__version__ = "0.
|
|
12
|
+
__version__ = "0.2.2"
|
|
13
13
|
|
|
14
14
|
from .signature_analyzer import (
|
|
15
15
|
SignatureAnalyzer,
|
|
@@ -24,7 +24,6 @@ from .signature_analyzer import (
|
|
|
24
24
|
)
|
|
25
25
|
from .unified_parameter_analyzer import (
|
|
26
26
|
UnifiedParameterAnalyzer,
|
|
27
|
-
UnifiedParameterInfo,
|
|
28
27
|
add_parameter_exclusions,
|
|
29
28
|
set_parameter_exclusions,
|
|
30
29
|
parameter_exclusions,
|
|
@@ -51,6 +50,19 @@ from .dataclass_projection import (
|
|
|
51
50
|
dataclass_from_mapping,
|
|
52
51
|
project_dataclass,
|
|
53
52
|
)
|
|
53
|
+
from .jsonable import (
|
|
54
|
+
JsonObject,
|
|
55
|
+
JsonScalar,
|
|
56
|
+
JsonValue,
|
|
57
|
+
to_jsonable,
|
|
58
|
+
)
|
|
59
|
+
from .public_api import (
|
|
60
|
+
declared_public_names,
|
|
61
|
+
exported_public_names,
|
|
62
|
+
is_declared_public_name,
|
|
63
|
+
lazy_exports,
|
|
64
|
+
public_names_from_objects,
|
|
65
|
+
)
|
|
54
66
|
from .environment_projection import (
|
|
55
67
|
EnvironmentVariable,
|
|
56
68
|
overlay_dataclass_from_environment,
|
|
@@ -89,7 +101,6 @@ __all__ = [
|
|
|
89
101
|
"signature_analysis_target",
|
|
90
102
|
# Unified analysis
|
|
91
103
|
"UnifiedParameterAnalyzer",
|
|
92
|
-
"UnifiedParameterInfo",
|
|
93
104
|
"add_parameter_exclusions",
|
|
94
105
|
"set_parameter_exclusions",
|
|
95
106
|
"parameter_exclusions",
|
|
@@ -111,6 +122,17 @@ __all__ = [
|
|
|
111
122
|
# Dataclass projection
|
|
112
123
|
"dataclass_from_mapping",
|
|
113
124
|
"project_dataclass",
|
|
125
|
+
# JSON-native projection
|
|
126
|
+
"JsonObject",
|
|
127
|
+
"JsonScalar",
|
|
128
|
+
"JsonValue",
|
|
129
|
+
"to_jsonable",
|
|
130
|
+
# Module public surfaces
|
|
131
|
+
"declared_public_names",
|
|
132
|
+
"exported_public_names",
|
|
133
|
+
"is_declared_public_name",
|
|
134
|
+
"lazy_exports",
|
|
135
|
+
"public_names_from_objects",
|
|
114
136
|
# Environment projection
|
|
115
137
|
"EnvironmentVariable",
|
|
116
138
|
"overlay_dataclass_from_environment",
|
|
@@ -2,9 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
import types
|
|
6
7
|
from enum import Enum
|
|
7
|
-
from typing import Annotated, Union, get_args, get_origin
|
|
8
|
+
from typing import Annotated, Literal, Union, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolved_class_annotations(owner: type) -> dict[str, object]:
|
|
12
|
+
"""Resolve nested type strings in the class that declared each field.
|
|
13
|
+
|
|
14
|
+
Python 3.10 leaves strings inside PEP 585 aliases unresolved. Complete
|
|
15
|
+
those declarations without interpreting Literal values or Annotated metadata.
|
|
16
|
+
"""
|
|
17
|
+
annotations = get_type_hints(owner, include_extras=True)
|
|
18
|
+
unresolved = set(annotations)
|
|
19
|
+
for declaration in owner.__mro__:
|
|
20
|
+
class_namespace = dict(vars(declaration))
|
|
21
|
+
module = sys.modules.get(declaration.__module__)
|
|
22
|
+
module_namespace = {} if module is None else vars(module)
|
|
23
|
+
for name in class_namespace.get("__annotations__", {}):
|
|
24
|
+
if name in unresolved:
|
|
25
|
+
annotations[name] = _resolve_nested_type_strings(
|
|
26
|
+
annotations[name], class_namespace, module_namespace
|
|
27
|
+
)
|
|
28
|
+
unresolved.remove(name)
|
|
29
|
+
return annotations
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_nested_type_strings(annotation: object, globalns: dict, localns: dict) -> object:
|
|
33
|
+
if isinstance(annotation, str):
|
|
34
|
+
return _resolve_nested_type_strings(eval(annotation, globalns, localns), globalns, localns)
|
|
35
|
+
if isinstance(annotation, list):
|
|
36
|
+
resolved = [_resolve_nested_type_strings(member, globalns, localns) for member in annotation]
|
|
37
|
+
return annotation if all(a is b for a, b in zip(resolved, annotation)) else resolved
|
|
38
|
+
origin = get_origin(annotation)
|
|
39
|
+
members = get_args(annotation)
|
|
40
|
+
if not members or origin is Literal:
|
|
41
|
+
return annotation
|
|
42
|
+
if origin is Annotated:
|
|
43
|
+
base = _resolve_nested_type_strings(members[0], globalns, localns)
|
|
44
|
+
return annotation if base is members[0] else Annotated[(base, *members[1:])]
|
|
45
|
+
resolved = tuple(_resolve_nested_type_strings(member, globalns, localns) for member in members)
|
|
46
|
+
if all(member is original for member, original in zip(resolved, members)):
|
|
47
|
+
return annotation
|
|
48
|
+
if isinstance(annotation, types.GenericAlias):
|
|
49
|
+
return types.GenericAlias(origin, resolved)
|
|
50
|
+
if is_union_type(annotation):
|
|
51
|
+
return Union[resolved] # noqa: UP007 - dynamically derived member tuple
|
|
52
|
+
return origin[resolved]
|
|
8
53
|
|
|
9
54
|
|
|
10
55
|
def is_union_type(annotation: object) -> bool:
|
|
@@ -13,10 +13,9 @@ from typing import (
|
|
|
13
13
|
TypeVar,
|
|
14
14
|
get_args,
|
|
15
15
|
get_origin,
|
|
16
|
-
get_type_hints,
|
|
17
16
|
)
|
|
18
17
|
|
|
19
|
-
from .annotation_types import is_union_type
|
|
18
|
+
from .annotation_types import is_union_type, resolved_class_annotations
|
|
20
19
|
from .validation import validate_annotated_dataclass, validate_annotation_value
|
|
21
20
|
|
|
22
21
|
DataclassT = TypeVar("DataclassT")
|
|
@@ -53,7 +52,7 @@ def dataclass_from_mapping(
|
|
|
53
52
|
f"{target_type.__name__} received undeclared field(s): {', '.join(extras)}."
|
|
54
53
|
)
|
|
55
54
|
|
|
56
|
-
annotations =
|
|
55
|
+
annotations = resolved_class_annotations(target_type)
|
|
57
56
|
decoded_values: dict[str, object] = {}
|
|
58
57
|
missing: list[str] = []
|
|
59
58
|
for declared_field in declared_fields:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""JSON-native projection of declared Python values.
|
|
2
|
+
|
|
3
|
+
``to_jsonable`` is the encoder paired with ``dataclass_from_mapping``: a
|
|
4
|
+
dataclass projects through its declared fields, and the decoder rebuilds it
|
|
5
|
+
from the same declarations. Further value types join through
|
|
6
|
+
``to_jsonable.register``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import fields, is_dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from functools import singledispatch
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import ForwardRef, TypeAlias
|
|
18
|
+
|
|
19
|
+
from metaclass_registry import AutoRegisterMeta
|
|
20
|
+
|
|
21
|
+
from .signature_analyzer import signature_analysis_target
|
|
22
|
+
|
|
23
|
+
JsonScalar: TypeAlias = str | int | float | bool | None
|
|
24
|
+
# Recursive aliases retain this declaration's namespace when imported by
|
|
25
|
+
# dataclasses; get_type_hints evaluates the reference in this module.
|
|
26
|
+
_JSON_VALUE_REFERENCE = ForwardRef("JsonValue", module=__name__)
|
|
27
|
+
JsonValue: TypeAlias = (
|
|
28
|
+
JsonScalar
|
|
29
|
+
| Mapping[str, _JSON_VALUE_REFERENCE]
|
|
30
|
+
| tuple[_JSON_VALUE_REFERENCE, ...]
|
|
31
|
+
| list[_JSON_VALUE_REFERENCE]
|
|
32
|
+
)
|
|
33
|
+
JsonObject: TypeAlias = Mapping[str, JsonValue]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _jsonable_dataclass(value: object) -> JsonValue:
|
|
37
|
+
"""Project fields without ``asdict`` deep-copying immutable containers."""
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
declared_field.name: to_jsonable(getattr(value, declared_field.name))
|
|
41
|
+
for declared_field in fields(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@singledispatch
|
|
46
|
+
def to_jsonable(value: object) -> JsonValue:
|
|
47
|
+
"""Project dataclasses and registered values into JSON-native data."""
|
|
48
|
+
|
|
49
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
50
|
+
return _jsonable_dataclass(value)
|
|
51
|
+
raise TypeError(f"Value is not JSON-serializable: {type(value).__name__}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@to_jsonable.register(Mapping)
|
|
55
|
+
def _jsonable_mapping(value: Mapping) -> JsonValue:
|
|
56
|
+
return {str(to_jsonable(key)): to_jsonable(item) for key, item in value.items()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@to_jsonable.register(tuple)
|
|
60
|
+
@to_jsonable.register(list)
|
|
61
|
+
@to_jsonable.register(set)
|
|
62
|
+
@to_jsonable.register(frozenset)
|
|
63
|
+
def _jsonable_sequence(value) -> JsonValue:
|
|
64
|
+
return [to_jsonable(item) for item in value]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@to_jsonable.register(Callable)
|
|
68
|
+
def _jsonable_callable(value: Callable[..., object]) -> JsonValue:
|
|
69
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
70
|
+
return _jsonable_dataclass(value)
|
|
71
|
+
target = signature_analysis_target(value)
|
|
72
|
+
module = inspect.getmodule(target)
|
|
73
|
+
module_name = type(target).__module__ if module is None else module.__name__
|
|
74
|
+
if inspect.isfunction(target) or inspect.ismethod(target) or inspect.isclass(target):
|
|
75
|
+
qualname = target.__qualname__
|
|
76
|
+
else:
|
|
77
|
+
qualname = type(target).__qualname__
|
|
78
|
+
return {
|
|
79
|
+
"kind": "callable",
|
|
80
|
+
"name": qualname.rsplit(".", 1)[-1],
|
|
81
|
+
"module": module_name,
|
|
82
|
+
"qualname": qualname,
|
|
83
|
+
"import_path": f"{module_name}.{qualname}",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@to_jsonable.register(AutoRegisterMeta)
|
|
88
|
+
def _jsonable_registered_type(value: AutoRegisterMeta) -> JsonValue:
|
|
89
|
+
key_attribute = value.__registry_key__
|
|
90
|
+
declaring_owner = next(
|
|
91
|
+
(owner for owner in value.__mro__ if key_attribute in vars(owner)),
|
|
92
|
+
None,
|
|
93
|
+
)
|
|
94
|
+
if declaring_owner is None:
|
|
95
|
+
raise TypeError(
|
|
96
|
+
f"Registered type {value.__qualname__} has no declared " f"{key_attribute!r} key."
|
|
97
|
+
)
|
|
98
|
+
key = vars(declaring_owner)[key_attribute]
|
|
99
|
+
if key is None:
|
|
100
|
+
raise TypeError(f"Registered type {value.__qualname__} has no registry key.")
|
|
101
|
+
return to_jsonable(key)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@to_jsonable.register(Enum)
|
|
105
|
+
def _jsonable_enum(value: Enum) -> JsonValue:
|
|
106
|
+
return to_jsonable(value.value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@to_jsonable.register(Path)
|
|
110
|
+
def _jsonable_path(value: Path) -> JsonValue:
|
|
111
|
+
return str(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@to_jsonable.register(type(None))
|
|
115
|
+
@to_jsonable.register(str)
|
|
116
|
+
@to_jsonable.register(int)
|
|
117
|
+
@to_jsonable.register(float)
|
|
118
|
+
@to_jsonable.register(bool)
|
|
119
|
+
def _jsonable_scalar(value) -> JsonValue:
|
|
120
|
+
return value
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Module public surfaces derived from what a module declares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from inspect import isclass, isfunction
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def declared_public_names(
|
|
13
|
+
module_globals: Mapping[str, object],
|
|
14
|
+
*,
|
|
15
|
+
constant_prefixes: Iterable[str] = (),
|
|
16
|
+
excluded_names: Iterable[str] = (),
|
|
17
|
+
extra_names: Iterable[str] = (),
|
|
18
|
+
) -> tuple[str, ...]:
|
|
19
|
+
"""Return public names declared by the module represented by globals()."""
|
|
20
|
+
module_name = module_globals["__name__"]
|
|
21
|
+
prefixes = tuple(constant_prefixes)
|
|
22
|
+
excluded = set(excluded_names)
|
|
23
|
+
declared_names = tuple(
|
|
24
|
+
name
|
|
25
|
+
for name, value in module_globals.items()
|
|
26
|
+
if name not in excluded
|
|
27
|
+
if is_declared_public_name(
|
|
28
|
+
module_name,
|
|
29
|
+
name,
|
|
30
|
+
value,
|
|
31
|
+
constant_prefixes=prefixes,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return declared_names + tuple(name for name in extra_names if name not in excluded)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exported_public_names(
|
|
38
|
+
module_globals: Mapping[str, object],
|
|
39
|
+
*,
|
|
40
|
+
excluded_names: Iterable[str] = (),
|
|
41
|
+
) -> tuple[str, ...]:
|
|
42
|
+
"""Return public re-export names declared by explicit module imports."""
|
|
43
|
+
excluded = set(excluded_names)
|
|
44
|
+
return tuple(
|
|
45
|
+
name
|
|
46
|
+
for name, value in module_globals.items()
|
|
47
|
+
if not name.startswith("_")
|
|
48
|
+
if name not in excluded
|
|
49
|
+
if not isinstance(value, ModuleType)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def public_names_from_objects(*objects: Any, extra_names: Iterable[str] = ()) -> tuple[str, ...]:
|
|
54
|
+
"""Return public names from exported object identities plus explicit aliases."""
|
|
55
|
+
return tuple(item if isinstance(item, str) else item.__name__ for item in objects) + tuple(
|
|
56
|
+
extra_names
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_declared_public_name(
|
|
61
|
+
module_name: str,
|
|
62
|
+
name: str,
|
|
63
|
+
value: object,
|
|
64
|
+
*,
|
|
65
|
+
constant_prefixes: tuple[str, ...] = (),
|
|
66
|
+
) -> bool:
|
|
67
|
+
"""Return whether a global is a public module declaration."""
|
|
68
|
+
if name.startswith("_"):
|
|
69
|
+
return False
|
|
70
|
+
if name.isupper():
|
|
71
|
+
return any(name.startswith(prefix) for prefix in constant_prefixes)
|
|
72
|
+
return (isclass(value) or isfunction(value)) and value.__module__ == module_name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def lazy_exports(
|
|
76
|
+
module_globals: dict[str, object],
|
|
77
|
+
exports: Mapping[str, Iterable[str]],
|
|
78
|
+
) -> tuple[str, ...]:
|
|
79
|
+
"""Install PEP 562 ``__getattr__``/``__dir__`` resolving names on first access.
|
|
80
|
+
|
|
81
|
+
``exports`` maps each owning module path (absolute, or relative to the
|
|
82
|
+
package whose ``globals()`` are passed) to the names it supplies. A name is
|
|
83
|
+
imported from its owner on first access and cached in the module globals.
|
|
84
|
+
Returns the exported names in declaration order, for ``__all__``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
module_name = module_globals["__name__"]
|
|
88
|
+
package = module_globals.get("__package__") or module_name
|
|
89
|
+
owners: dict[str, str] = {}
|
|
90
|
+
for owner, names in exports.items():
|
|
91
|
+
for name in names:
|
|
92
|
+
if name in owners:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"{module_name} lazy export {name!r} is declared by both "
|
|
95
|
+
f"{owners[name]!r} and {owner!r}."
|
|
96
|
+
)
|
|
97
|
+
owners[name] = owner
|
|
98
|
+
|
|
99
|
+
def __getattr__(name: str) -> object:
|
|
100
|
+
if name not in owners:
|
|
101
|
+
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
|
|
102
|
+
value = getattr(import_module(owners[name], package), name)
|
|
103
|
+
module_globals[name] = value
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
def __dir__() -> list[str]:
|
|
107
|
+
return sorted(set(module_globals) | set(owners))
|
|
108
|
+
|
|
109
|
+
module_globals["__getattr__"] = __getattr__
|
|
110
|
+
module_globals["__dir__"] = __dir__
|
|
111
|
+
return tuple(owners)
|
|
@@ -15,7 +15,7 @@ from functools import lru_cache
|
|
|
15
15
|
import dataclasses
|
|
16
16
|
import re
|
|
17
17
|
from abc import ABC, abstractmethod
|
|
18
|
-
from typing import Annotated, Any, Dict, Callable, get_type_hints, NamedTuple, Union, Optional, Type, List, ClassVar, Tuple, get_args, get_origin
|
|
18
|
+
from typing import Annotated, Any, Dict, Callable, get_type_hints, NamedTuple, Union, Optional, Type, List, ClassVar, Tuple, get_args, get_origin, Iterable
|
|
19
19
|
from weakref import WeakKeyDictionary
|
|
20
20
|
|
|
21
21
|
from dataclasses import dataclass, field
|
|
@@ -146,13 +146,71 @@ class AnalysisConstants:
|
|
|
146
146
|
CONSTANTS = AnalysisConstants()
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
@dataclass(frozen=True, init=False, eq=False)
|
|
150
|
+
class ParameterInfo:
|
|
151
|
+
"""Admitted parameter values with presentation derived from their declaration.
|
|
152
|
+
|
|
153
|
+
Callable descriptions remain concrete because they can determine inferred
|
|
154
|
+
types. Dataclass descriptions are requested independently of value admission.
|
|
155
|
+
"""
|
|
156
|
+
|
|
151
157
|
name: str
|
|
152
158
|
param_type: type
|
|
153
159
|
default_value: Any
|
|
154
160
|
is_required: bool
|
|
155
|
-
|
|
161
|
+
_description: Optional[str]
|
|
162
|
+
_description_owner: Optional[type]
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self, name: str, param_type: type, default_value: Any, is_required: bool,
|
|
166
|
+
description: Optional[str] = None, *, description_owner: Optional[type] = None,
|
|
167
|
+
):
|
|
168
|
+
object.__setattr__(self, "name", name)
|
|
169
|
+
object.__setattr__(self, "param_type", param_type)
|
|
170
|
+
object.__setattr__(self, "default_value", default_value)
|
|
171
|
+
object.__setattr__(self, "is_required", is_required)
|
|
172
|
+
object.__setattr__(self, "_description", description)
|
|
173
|
+
object.__setattr__(self, "_description_owner", description_owner)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def description(self) -> Optional[str]:
|
|
177
|
+
if self._description_owner is not None:
|
|
178
|
+
SignatureAnalyzer._materialize_dataclass_descriptions(self._description_owner, self)
|
|
179
|
+
return self._description
|
|
180
|
+
|
|
181
|
+
def __eq__(self, other: object) -> bool:
|
|
182
|
+
if not isinstance(other, ParameterInfo):
|
|
183
|
+
return NotImplemented
|
|
184
|
+
return (
|
|
185
|
+
self.name, self.param_type, self.default_value, self.is_required, self.description,
|
|
186
|
+
) == (
|
|
187
|
+
other.name, other.param_type, other.default_value, other.is_required, other.description,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def __hash__(self) -> int:
|
|
191
|
+
return hash((self.name, self.param_type, self.default_value, self.is_required, self.description))
|
|
192
|
+
|
|
193
|
+
def _replace(self, **changes: Any) -> "ParameterInfo":
|
|
194
|
+
"""Replace declared fields through the same public parameter owner."""
|
|
195
|
+
values = dict(
|
|
196
|
+
name=self.name, param_type=self.param_type,
|
|
197
|
+
default_value=self.default_value, is_required=self.is_required,
|
|
198
|
+
description=self._description,
|
|
199
|
+
)
|
|
200
|
+
unknown = changes.keys() - values.keys()
|
|
201
|
+
if unknown:
|
|
202
|
+
raise ValueError(f"Got unexpected field names: {sorted(unknown)!r}")
|
|
203
|
+
owner = self._description_owner
|
|
204
|
+
if "description" in changes:
|
|
205
|
+
owner = None
|
|
206
|
+
elif "name" in changes and changes["name"] != self.name:
|
|
207
|
+
# A renamed field keeps the original declaration's help, rather
|
|
208
|
+
# than querying a different field in the same source class.
|
|
209
|
+
values["description"] = self.description
|
|
210
|
+
owner = None
|
|
211
|
+
values.update(changes)
|
|
212
|
+
return type(self)(**values, description_owner=owner)
|
|
213
|
+
|
|
156
214
|
|
|
157
215
|
class DocstringInfo(NamedTuple):
|
|
158
216
|
"""Information extracted from a docstring."""
|
|
@@ -713,6 +771,70 @@ class CallableAnalysisContext:
|
|
|
713
771
|
)
|
|
714
772
|
|
|
715
773
|
|
|
774
|
+
class ClassSourceDeclaration(NamedTuple):
|
|
775
|
+
"""Immutable located class source and its source-only documentation views."""
|
|
776
|
+
|
|
777
|
+
source: str
|
|
778
|
+
inline_docs: Tuple[Tuple[str, Tuple[Tuple[str, str], ...]], ...]
|
|
779
|
+
|
|
780
|
+
@classmethod
|
|
781
|
+
def from_block(cls, source: str, tree: ast.AST, first_line: int = 1) -> "ClassSourceDeclaration":
|
|
782
|
+
# The public extractor historically parses the selected block without
|
|
783
|
+
# dedenting. Keep nested/local blocks' empty inline-documentation view.
|
|
784
|
+
first = next((line for line in source.splitlines() if line.strip()), '')
|
|
785
|
+
if first[:1].isspace():
|
|
786
|
+
return cls(source, ())
|
|
787
|
+
source_lines = source.split('\n')
|
|
788
|
+
declarations = []
|
|
789
|
+
for class_node in ast.walk(tree):
|
|
790
|
+
if not isinstance(class_node, ast.ClassDef):
|
|
791
|
+
continue
|
|
792
|
+
field_docs = {}
|
|
793
|
+
|
|
794
|
+
# Method 1: Look for field assignments followed by string literals (next line)
|
|
795
|
+
for i, node in enumerate(class_node.body):
|
|
796
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
797
|
+
field_name = node.target.id
|
|
798
|
+
|
|
799
|
+
# Check if the next node is a string literal (documentation)
|
|
800
|
+
if i + 1 < len(class_node.body):
|
|
801
|
+
next_node = class_node.body[i + 1]
|
|
802
|
+
if isinstance(next_node, ast.Expr):
|
|
803
|
+
if isinstance(next_node.value, ast.Constant) and isinstance(next_node.value.value, str):
|
|
804
|
+
field_docs[field_name] = next_node.value.value.strip()
|
|
805
|
+
continue
|
|
806
|
+
|
|
807
|
+
# Method 2: Check for inline comments on the same line
|
|
808
|
+
# Get the line number of the field definition
|
|
809
|
+
field_line_num = node.lineno - first_line
|
|
810
|
+
if 0 <= field_line_num < len(source_lines):
|
|
811
|
+
line = source_lines[field_line_num]
|
|
812
|
+
|
|
813
|
+
# Look for string literals in comments on the same line
|
|
814
|
+
# Pattern: field: type = value # """Documentation"""
|
|
815
|
+
comment_match = re.search(r'#\s*["\']([^"\']+)["\']', line)
|
|
816
|
+
if comment_match:
|
|
817
|
+
field_docs[field_name] = comment_match.group(1).strip()
|
|
818
|
+
continue
|
|
819
|
+
|
|
820
|
+
# Look for triple-quoted strings on the same line
|
|
821
|
+
# Pattern: field: type = value """Documentation"""
|
|
822
|
+
triple_quote_match = re.search(r'"""([^"]+)"""|\'\'\'([^\']+)\'\'\'', line)
|
|
823
|
+
if triple_quote_match:
|
|
824
|
+
doc_text = triple_quote_match.group(1) or triple_quote_match.group(2)
|
|
825
|
+
field_docs[field_name] = doc_text.strip()
|
|
826
|
+
|
|
827
|
+
declarations.append((class_node.name, tuple(field_docs.items())))
|
|
828
|
+
return cls(source, tuple(declarations))
|
|
829
|
+
|
|
830
|
+
def field_documentation(self, class_name: str) -> Dict[str, str]:
|
|
831
|
+
"""Return a fresh view selected by the caller's current public name."""
|
|
832
|
+
for name, entries in self.inline_docs:
|
|
833
|
+
if name == class_name:
|
|
834
|
+
return dict(entries)
|
|
835
|
+
return {}
|
|
836
|
+
|
|
837
|
+
|
|
716
838
|
class SignatureAnalyzer:
|
|
717
839
|
"""Universal analyzer for extracting parameter information from any target."""
|
|
718
840
|
|
|
@@ -907,16 +1029,6 @@ class SignatureAnalyzer:
|
|
|
907
1029
|
except Exception:
|
|
908
1030
|
type_hints = inspect.get_annotations(dataclass_type, eval_str=False)
|
|
909
1031
|
|
|
910
|
-
# Extract docstring information from dataclass
|
|
911
|
-
docstring_info = DocstringExtractor.extract(dataclass_type)
|
|
912
|
-
|
|
913
|
-
# Extract inline field documentation using AST
|
|
914
|
-
inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type)
|
|
915
|
-
|
|
916
|
-
# ENHANCEMENT: For dataclasses modified by decorators,
|
|
917
|
-
# also extract field documentation from the field types themselves
|
|
918
|
-
field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type)
|
|
919
|
-
|
|
920
1032
|
parameters = {}
|
|
921
1033
|
|
|
922
1034
|
for field in dataclasses.fields(dataclass_type):
|
|
@@ -937,31 +1049,12 @@ class SignatureAnalyzer:
|
|
|
937
1049
|
default_value = None
|
|
938
1050
|
is_required = True
|
|
939
1051
|
|
|
940
|
-
# Get field description from multiple sources (priority order)
|
|
941
|
-
field_description = None
|
|
942
|
-
|
|
943
|
-
# 1. Field metadata (highest priority)
|
|
944
|
-
if 'description' in field.metadata:
|
|
945
|
-
field_description = field.metadata['description']
|
|
946
|
-
# 2. Inline documentation strings (from AST parsing)
|
|
947
|
-
elif field.name in inline_docs:
|
|
948
|
-
field_description = inline_docs[field.name]
|
|
949
|
-
# 3. Field type documentation (for decorator-modified classes)
|
|
950
|
-
elif field.name in field_type_docs:
|
|
951
|
-
field_description = field_type_docs[field.name]
|
|
952
|
-
# 4. Docstring parameters (fallback)
|
|
953
|
-
elif docstring_info.parameters and field.name in docstring_info.parameters:
|
|
954
|
-
field_description = docstring_info.parameters.get(field.name)
|
|
955
|
-
# 5. CRITICAL FIX: Use inheritance-aware field documentation extraction
|
|
956
|
-
else:
|
|
957
|
-
field_description = SignatureAnalyzer.extract_field_documentation(dataclass_type, field.name)
|
|
958
|
-
|
|
959
1052
|
parameters[field.name] = ParameterInfo(
|
|
960
1053
|
name=field.name,
|
|
961
1054
|
param_type=param_type,
|
|
962
1055
|
default_value=default_value,
|
|
963
1056
|
is_required=is_required,
|
|
964
|
-
|
|
1057
|
+
description_owner=dataclass_type,
|
|
965
1058
|
)
|
|
966
1059
|
|
|
967
1060
|
# PERFORMANCE: Cache the result to avoid re-parsing
|
|
@@ -972,21 +1065,110 @@ class SignatureAnalyzer:
|
|
|
972
1065
|
# Return empty dict on error (don't cache errors)
|
|
973
1066
|
return {}
|
|
974
1067
|
|
|
1068
|
+
@staticmethod
|
|
1069
|
+
def _materialize_dataclass_descriptions(dataclass_type: type, requested: ParameterInfo) -> None:
|
|
1070
|
+
"""Fill presentation on the existing admitted declarations, once requested.
|
|
1071
|
+
|
|
1072
|
+
Presentation extraction errors no longer discard usable value/type
|
|
1073
|
+
declarations. They yield absent help, matching the existing help fallback.
|
|
1074
|
+
"""
|
|
1075
|
+
parameters = SignatureAnalyzer._dataclass_analysis_cache.get(dataclass_type, {})
|
|
1076
|
+
admitted = parameters.get(requested.name)
|
|
1077
|
+
if admitted is not None and admitted is not requested:
|
|
1078
|
+
object.__setattr__(requested, "_description", admitted.description)
|
|
1079
|
+
object.__setattr__(requested, "_description_owner", None)
|
|
1080
|
+
return
|
|
1081
|
+
if admitted is not requested:
|
|
1082
|
+
parameters = {requested.name: requested}
|
|
1083
|
+
pending = [info for info in parameters.values() if info._description_owner is not None]
|
|
1084
|
+
if not pending:
|
|
1085
|
+
return
|
|
1086
|
+
try:
|
|
1087
|
+
docstring_info = DocstringExtractor.extract(dataclass_type)
|
|
1088
|
+
inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type)
|
|
1089
|
+
field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type)
|
|
1090
|
+
descriptions = {}
|
|
1091
|
+
for field in dataclasses.fields(dataclass_type):
|
|
1092
|
+
if field.name not in parameters:
|
|
1093
|
+
continue
|
|
1094
|
+
if 'description' in field.metadata:
|
|
1095
|
+
description = field.metadata['description']
|
|
1096
|
+
elif field.name in inline_docs:
|
|
1097
|
+
description = inline_docs[field.name]
|
|
1098
|
+
elif field.name in field_type_docs:
|
|
1099
|
+
description = field_type_docs[field.name]
|
|
1100
|
+
elif field.name in docstring_info.parameters_dict:
|
|
1101
|
+
description = docstring_info.parameters_dict[field.name]
|
|
1102
|
+
else:
|
|
1103
|
+
description = SignatureAnalyzer.extract_field_documentation(dataclass_type, field.name)
|
|
1104
|
+
descriptions[field.name] = description
|
|
1105
|
+
except Exception:
|
|
1106
|
+
descriptions = {}
|
|
1107
|
+
for info in pending:
|
|
1108
|
+
object.__setattr__(info, "_description", descriptions.get(info.name))
|
|
1109
|
+
object.__setattr__(info, "_description_owner", None)
|
|
1110
|
+
|
|
1111
|
+
@staticmethod
|
|
1112
|
+
def prepare_dataclass_declarations(declarations: Iterable[type]) -> None:
|
|
1113
|
+
"""Prepare source facts for declared schemas without resolving values.
|
|
1114
|
+
|
|
1115
|
+
MRO, raw annotations and declared dataclass defaults expose schema
|
|
1116
|
+
relationships. Unresolved strings/ForwardRefs remain live for analysis;
|
|
1117
|
+
factories, namespace providers and type resolvers are never invoked.
|
|
1118
|
+
"""
|
|
1119
|
+
pending = list(declarations)
|
|
1120
|
+
visited = set()
|
|
1121
|
+
while pending:
|
|
1122
|
+
declaration = pending.pop()
|
|
1123
|
+
if not inspect.isclass(declaration) or declaration in visited:
|
|
1124
|
+
continue
|
|
1125
|
+
visited.add(declaration)
|
|
1126
|
+
if declaration is object:
|
|
1127
|
+
continue
|
|
1128
|
+
if not dataclasses.is_dataclass(declaration):
|
|
1129
|
+
continue
|
|
1130
|
+
SignatureAnalyzer._extract_inline_field_docs(declaration)
|
|
1131
|
+
pending.extend(
|
|
1132
|
+
base for base in inspect.getmro(declaration)[1:]
|
|
1133
|
+
if dataclasses.is_dataclass(base)
|
|
1134
|
+
)
|
|
1135
|
+
for declared_field in dataclasses.fields(declaration):
|
|
1136
|
+
field_types = [declared_field.type]
|
|
1137
|
+
while field_types:
|
|
1138
|
+
field_type = field_types.pop()
|
|
1139
|
+
if inspect.isclass(field_type) and dataclasses.is_dataclass(field_type):
|
|
1140
|
+
pending.append(field_type)
|
|
1141
|
+
field_types.extend(get_args(field_type))
|
|
1142
|
+
if dataclasses.is_dataclass(declared_field.default):
|
|
1143
|
+
default = declared_field.default
|
|
1144
|
+
pending.append(default if inspect.isclass(default) else type(default))
|
|
1145
|
+
factory = declared_field.default_factory
|
|
1146
|
+
if inspect.isclass(factory) and dataclasses.is_dataclass(factory):
|
|
1147
|
+
pending.append(factory)
|
|
1148
|
+
|
|
975
1149
|
@staticmethod
|
|
976
1150
|
def prepare_dataclass_declaration(dataclass_type: type) -> None:
|
|
977
|
-
"""
|
|
978
|
-
SignatureAnalyzer.
|
|
1151
|
+
"""Prepare one declaration's source closure without evaluating defaults."""
|
|
1152
|
+
SignatureAnalyzer.prepare_dataclass_declarations((dataclass_type,))
|
|
979
1153
|
|
|
980
1154
|
@staticmethod
|
|
981
|
-
def _dataclass_source(dataclass_type: type) ->
|
|
1155
|
+
def _dataclass_source(dataclass_type: type) -> ClassSourceDeclaration:
|
|
982
1156
|
"""Read current loader source, deriving its class block by immutable content."""
|
|
983
1157
|
# Python 3.13+ locates class source from __firstlineno__ directly;
|
|
984
1158
|
# its native locator does not perform the repeated module AST traversal.
|
|
985
1159
|
if sys.version_info >= (3, 13):
|
|
986
|
-
|
|
1160
|
+
source = inspect.getsource(dataclass_type)
|
|
1161
|
+
if source[:1].isspace():
|
|
1162
|
+
return ClassSourceDeclaration(source, ())
|
|
1163
|
+
tree = SignatureAnalyzer._module_source_tree(source)
|
|
1164
|
+
class_node = next((node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)), None)
|
|
1165
|
+
if class_node is None:
|
|
1166
|
+
return ClassSourceDeclaration(source, ())
|
|
1167
|
+
return SignatureAnalyzer._qualified_class_source(source, class_node.name)
|
|
987
1168
|
target = inspect.unwrap(dataclass_type)
|
|
988
1169
|
if not inspect.isclass(target):
|
|
989
|
-
|
|
1170
|
+
source = inspect.getsource(target)
|
|
1171
|
+
return ClassSourceDeclaration.from_block(source, SignatureAnalyzer._module_source_tree(source))
|
|
990
1172
|
file = inspect.getsourcefile(target)
|
|
991
1173
|
if file:
|
|
992
1174
|
linecache.checkcache(file)
|
|
@@ -1011,7 +1193,7 @@ class SignatureAnalyzer:
|
|
|
1011
1193
|
|
|
1012
1194
|
@staticmethod
|
|
1013
1195
|
@lru_cache(maxsize=256)
|
|
1014
|
-
def _qualified_class_source(source: str, qualname: str) -> Optional[
|
|
1196
|
+
def _qualified_class_source(source: str, qualname: str) -> Optional[ClassSourceDeclaration]:
|
|
1015
1197
|
"""Use Python's qualified class/decorator locator on current source content."""
|
|
1016
1198
|
tree = SignatureAnalyzer._module_source_tree(source)
|
|
1017
1199
|
finder = inspect._ClassFinder(qualname)
|
|
@@ -1019,7 +1201,14 @@ class SignatureAnalyzer:
|
|
|
1019
1201
|
finder.visit(tree)
|
|
1020
1202
|
except inspect.ClassFoundException as found:
|
|
1021
1203
|
lines = source.splitlines(keepends=True)
|
|
1022
|
-
|
|
1204
|
+
first_line = found.args[0] + 1
|
|
1205
|
+
block = ''.join(inspect.getblock(lines[found.args[0]:]))
|
|
1206
|
+
class_node = next(
|
|
1207
|
+
node for node in ast.walk(tree)
|
|
1208
|
+
if isinstance(node, ast.ClassDef)
|
|
1209
|
+
and min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)]) == first_line
|
|
1210
|
+
)
|
|
1211
|
+
return ClassSourceDeclaration.from_block(block, class_node, first_line)
|
|
1023
1212
|
return None
|
|
1024
1213
|
|
|
1025
1214
|
@staticmethod
|
|
@@ -1049,84 +1238,22 @@ class SignatureAnalyzer:
|
|
|
1049
1238
|
field_name: str = "default"
|
|
1050
1239
|
"""
|
|
1051
1240
|
try:
|
|
1052
|
-
import ast
|
|
1053
|
-
import re
|
|
1054
|
-
|
|
1055
|
-
# Try to get source code - handle cases where it might not be available
|
|
1056
|
-
source = None
|
|
1057
1241
|
try:
|
|
1058
|
-
|
|
1242
|
+
declaration = SignatureAnalyzer._dataclass_source(dataclass_type)
|
|
1059
1243
|
except (OSError, TypeError):
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
tree = ast.parse(source)
|
|
1072
|
-
|
|
1073
|
-
# Find the class definition - be more flexible with class name matching
|
|
1074
|
-
class_node = None
|
|
1075
|
-
target_class_name = dataclass_type.__name__
|
|
1076
|
-
|
|
1077
|
-
# Handle cases where the class might have been renamed or modified
|
|
1078
|
-
for node in ast.walk(tree):
|
|
1079
|
-
if isinstance(node, ast.ClassDef):
|
|
1080
|
-
# Try exact match first
|
|
1081
|
-
if node.name == target_class_name:
|
|
1082
|
-
class_node = node
|
|
1083
|
-
break
|
|
1084
|
-
# Also try without common prefixes/suffixes that decorators might add
|
|
1085
|
-
|
|
1086
|
-
if not class_node:
|
|
1087
|
-
return {}
|
|
1088
|
-
|
|
1089
|
-
field_docs = {}
|
|
1090
|
-
source_lines = source.split('\n')
|
|
1091
|
-
|
|
1092
|
-
# Method 1: Look for field assignments followed by string literals (next line)
|
|
1093
|
-
for i, node in enumerate(class_node.body):
|
|
1094
|
-
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
1095
|
-
field_name = node.target.id
|
|
1096
|
-
|
|
1097
|
-
# Check if the next node is a string literal (documentation)
|
|
1098
|
-
if i + 1 < len(class_node.body):
|
|
1099
|
-
next_node = class_node.body[i + 1]
|
|
1100
|
-
if isinstance(next_node, ast.Expr):
|
|
1101
|
-
if isinstance(next_node.value, ast.Constant) and isinstance(next_node.value.value, str):
|
|
1102
|
-
field_docs[field_name] = next_node.value.value.strip()
|
|
1103
|
-
continue
|
|
1104
|
-
|
|
1105
|
-
# Method 2: Check for inline comments on the same line
|
|
1106
|
-
# Get the line number of the field definition
|
|
1107
|
-
field_line_num = node.lineno - 1 # Convert to 0-based indexing
|
|
1108
|
-
if 0 <= field_line_num < len(source_lines):
|
|
1109
|
-
line = source_lines[field_line_num]
|
|
1110
|
-
|
|
1111
|
-
# Look for string literals in comments on the same line
|
|
1112
|
-
# Pattern: field: type = value # """Documentation"""
|
|
1113
|
-
comment_match = re.search(r'#\s*["\']([^"\']+)["\']', line)
|
|
1114
|
-
if comment_match:
|
|
1115
|
-
field_docs[field_name] = comment_match.group(1).strip()
|
|
1116
|
-
continue
|
|
1117
|
-
|
|
1118
|
-
# Look for triple-quoted strings on the same line
|
|
1119
|
-
# Pattern: field: type = value """Documentation"""
|
|
1120
|
-
triple_quote_match = re.search(r'"""([^"]+)"""|\'\'\'([^\']+)\'\'\'', line)
|
|
1121
|
-
if triple_quote_match:
|
|
1122
|
-
doc_text = triple_quote_match.group(1) or triple_quote_match.group(2)
|
|
1123
|
-
field_docs[field_name] = doc_text.strip()
|
|
1124
|
-
|
|
1125
|
-
return field_docs
|
|
1126
|
-
|
|
1244
|
+
source_file = inspect.getfile(dataclass_type)
|
|
1245
|
+
with open(source_file, 'r', encoding='utf-8') as source_stream:
|
|
1246
|
+
source = SignatureAnalyzer._extract_class_source_from_file(
|
|
1247
|
+
source_stream.read(), dataclass_type.__name__
|
|
1248
|
+
)
|
|
1249
|
+
if not source:
|
|
1250
|
+
return {}
|
|
1251
|
+
declaration = SignatureAnalyzer._qualified_class_source(source, dataclass_type.__name__)
|
|
1252
|
+
if declaration is None:
|
|
1253
|
+
return {}
|
|
1254
|
+
return declaration.field_documentation(dataclass_type.__name__)
|
|
1127
1255
|
except Exception:
|
|
1128
|
-
#
|
|
1129
|
-
# Could add logging here for debugging: logger.debug(f"AST parsing failed: {e}")
|
|
1256
|
+
# Preserve the extractor's unavailable/unparsable-source boundary.
|
|
1130
1257
|
return {}
|
|
1131
1258
|
|
|
1132
1259
|
@staticmethod
|
|
@@ -13,7 +13,6 @@ import inspect
|
|
|
13
13
|
import dataclasses
|
|
14
14
|
from abc import ABC, abstractmethod
|
|
15
15
|
from typing import Dict, Union, Callable, Type, Any, Optional, ClassVar
|
|
16
|
-
from dataclasses import dataclass
|
|
17
16
|
from weakref import WeakKeyDictionary
|
|
18
17
|
|
|
19
18
|
from metaclass_registry import AutoRegisterMeta
|
|
@@ -59,29 +58,6 @@ def add_parameter_exclusions(
|
|
|
59
58
|
set_parameter_exclusions(target, (*parameter_exclusions(target), *normalized))
|
|
60
59
|
|
|
61
60
|
|
|
62
|
-
@dataclass
|
|
63
|
-
class UnifiedParameterInfo:
|
|
64
|
-
"""Unified parameter information that works for all parameter sources."""
|
|
65
|
-
name: str
|
|
66
|
-
param_type: Type
|
|
67
|
-
default_value: Any
|
|
68
|
-
is_required: bool
|
|
69
|
-
description: Optional[str] = None
|
|
70
|
-
source_type: str = "unknown" # "function", "dataclass", "nested"
|
|
71
|
-
|
|
72
|
-
@classmethod
|
|
73
|
-
def from_parameter_info(cls, param_info: ParameterInfo, source_type: str = "function") -> "UnifiedParameterInfo":
|
|
74
|
-
"""Convert from existing ParameterInfo to unified format."""
|
|
75
|
-
return cls(
|
|
76
|
-
name=param_info.name,
|
|
77
|
-
param_type=param_info.param_type,
|
|
78
|
-
default_value=param_info.default_value,
|
|
79
|
-
is_required=param_info.is_required,
|
|
80
|
-
description=param_info.description,
|
|
81
|
-
source_type=source_type
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
61
|
class UnifiedParameterTargetAnalyzer(ABC, metaclass=AutoRegisterMeta):
|
|
86
62
|
"""Nominal target-kind family for unified parameter analysis."""
|
|
87
63
|
|
|
@@ -91,7 +67,7 @@ class UnifiedParameterTargetAnalyzer(ABC, metaclass=AutoRegisterMeta):
|
|
|
91
67
|
target_kind: ClassVar[Optional[str]] = None
|
|
92
68
|
|
|
93
69
|
@classmethod
|
|
94
|
-
def analyze_target(cls, target: Union[Callable, Type, object]) -> Dict[str,
|
|
70
|
+
def analyze_target(cls, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
95
71
|
"""Analyze a target using the first registered target-kind analyzer."""
|
|
96
72
|
for analyzer_cls in cls.__registry__.values():
|
|
97
73
|
analyzer = analyzer_cls()
|
|
@@ -104,7 +80,7 @@ class UnifiedParameterTargetAnalyzer(ABC, metaclass=AutoRegisterMeta):
|
|
|
104
80
|
"""Return whether this analyzer owns the target."""
|
|
105
81
|
|
|
106
82
|
@abstractmethod
|
|
107
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
83
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
108
84
|
"""Analyze the target."""
|
|
109
85
|
|
|
110
86
|
|
|
@@ -116,7 +92,7 @@ class CallableTargetAnalyzer(UnifiedParameterTargetAnalyzer):
|
|
|
116
92
|
def matches(self, target: Union[Callable, Type, object]) -> bool:
|
|
117
93
|
return callable(target) and not inspect.isclass(target)
|
|
118
94
|
|
|
119
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
95
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
120
96
|
return UnifiedParameterAnalyzer._analyze_callable(target)
|
|
121
97
|
|
|
122
98
|
|
|
@@ -128,7 +104,7 @@ class DataclassTypeTargetAnalyzer(UnifiedParameterTargetAnalyzer):
|
|
|
128
104
|
def matches(self, target: Union[Callable, Type, object]) -> bool:
|
|
129
105
|
return inspect.isclass(target) and dataclasses.is_dataclass(target)
|
|
130
106
|
|
|
131
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
107
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
132
108
|
return UnifiedParameterAnalyzer._analyze_dataclass_type(target)
|
|
133
109
|
|
|
134
110
|
|
|
@@ -140,7 +116,7 @@ class ClassTargetAnalyzer(UnifiedParameterTargetAnalyzer):
|
|
|
140
116
|
def matches(self, target: Union[Callable, Type, object]) -> bool:
|
|
141
117
|
return inspect.isclass(target)
|
|
142
118
|
|
|
143
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
119
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
144
120
|
return UnifiedParameterAnalyzer._analyze_callable(target.__init__)
|
|
145
121
|
|
|
146
122
|
|
|
@@ -152,7 +128,7 @@ class DataclassInstanceTargetAnalyzer(UnifiedParameterTargetAnalyzer):
|
|
|
152
128
|
def matches(self, target: Union[Callable, Type, object]) -> bool:
|
|
153
129
|
return dataclasses.is_dataclass(target)
|
|
154
130
|
|
|
155
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
131
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
156
132
|
return UnifiedParameterAnalyzer._analyze_dataclass_instance(target)
|
|
157
133
|
|
|
158
134
|
|
|
@@ -164,7 +140,7 @@ class ObjectInstanceTargetAnalyzer(UnifiedParameterTargetAnalyzer):
|
|
|
164
140
|
def matches(self, target: Union[Callable, Type, object]) -> bool:
|
|
165
141
|
return True
|
|
166
142
|
|
|
167
|
-
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str,
|
|
143
|
+
def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]:
|
|
168
144
|
return UnifiedParameterAnalyzer._analyze_object_instance(target)
|
|
169
145
|
|
|
170
146
|
|
|
@@ -177,7 +153,7 @@ class UnifiedParameterAnalyzer:
|
|
|
177
153
|
"""
|
|
178
154
|
|
|
179
155
|
@staticmethod
|
|
180
|
-
def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list] = None) -> Dict[str,
|
|
156
|
+
def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list] = None) -> Dict[str, ParameterInfo]:
|
|
181
157
|
"""Analyze parameters from any source.
|
|
182
158
|
|
|
183
159
|
Args:
|
|
@@ -185,7 +161,7 @@ class UnifiedParameterAnalyzer:
|
|
|
185
161
|
exclude_params: Optional list of parameter names to exclude from analysis
|
|
186
162
|
|
|
187
163
|
Returns:
|
|
188
|
-
Dictionary mapping parameter names to
|
|
164
|
+
Dictionary mapping parameter names to ParameterInfo objects
|
|
189
165
|
|
|
190
166
|
Examples:
|
|
191
167
|
# Function analysis
|
|
@@ -229,40 +205,17 @@ class UnifiedParameterAnalyzer:
|
|
|
229
205
|
return frozenset(names)
|
|
230
206
|
|
|
231
207
|
@staticmethod
|
|
232
|
-
def _analyze_callable(callable_obj: Callable) -> Dict[str,
|
|
208
|
+
def _analyze_callable(callable_obj: Callable) -> Dict[str, ParameterInfo]:
|
|
233
209
|
"""Analyze a callable (function, method, etc.)."""
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
# Convert to unified format
|
|
238
|
-
unified_params = {}
|
|
239
|
-
for name, param_info in param_info_dict.items():
|
|
240
|
-
unified_params[name] = UnifiedParameterInfo.from_parameter_info(
|
|
241
|
-
param_info,
|
|
242
|
-
source_type="function"
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
return unified_params
|
|
246
|
-
|
|
210
|
+
return SignatureAnalyzer.analyze(callable_obj)
|
|
211
|
+
|
|
247
212
|
@staticmethod
|
|
248
|
-
def _analyze_dataclass_type(dataclass_type: Type) -> Dict[str,
|
|
249
|
-
"""Analyze a dataclass
|
|
250
|
-
|
|
251
|
-
# which already handles all the docstring extraction properly
|
|
252
|
-
param_info_dict = SignatureAnalyzer._analyze_dataclass(dataclass_type)
|
|
253
|
-
|
|
254
|
-
# Convert to unified format
|
|
255
|
-
unified_params = {}
|
|
256
|
-
for name, param_info in param_info_dict.items():
|
|
257
|
-
unified_params[name] = UnifiedParameterInfo.from_parameter_info(
|
|
258
|
-
param_info,
|
|
259
|
-
source_type="dataclass"
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
return unified_params
|
|
213
|
+
def _analyze_dataclass_type(dataclass_type: Type) -> Dict[str, ParameterInfo]:
|
|
214
|
+
"""Analyze a dataclass through its authoritative field declarations."""
|
|
215
|
+
return SignatureAnalyzer._analyze_dataclass(dataclass_type)
|
|
263
216
|
|
|
264
217
|
@staticmethod
|
|
265
|
-
def _analyze_object_instance(instance: object) -> Dict[str,
|
|
218
|
+
def _analyze_object_instance(instance: object) -> Dict[str, ParameterInfo]:
|
|
266
219
|
"""Analyze a regular object instance by examining its full inheritance hierarchy.
|
|
267
220
|
|
|
268
221
|
For dynamic containers like SimpleNamespace (which use **kwargs in __init__),
|
|
@@ -295,8 +248,9 @@ class UnifiedParameterAnalyzer:
|
|
|
295
248
|
class_params = UnifiedParameterAnalyzer._analyze_callable(cls.__init__)
|
|
296
249
|
|
|
297
250
|
# Remove 'self' parameter
|
|
298
|
-
|
|
299
|
-
|
|
251
|
+
class_params = {
|
|
252
|
+
name: info for name, info in class_params.items() if name != 'self'
|
|
253
|
+
}
|
|
300
254
|
|
|
301
255
|
_logger.debug(f"🔧 _analyze_object_instance: cls={cls.__name__}, class_params after removing self={list(class_params.keys())}")
|
|
302
256
|
|
|
@@ -312,14 +266,7 @@ class UnifiedParameterAnalyzer:
|
|
|
312
266
|
# Add parameters that haven't been seen yet (most specific wins)
|
|
313
267
|
for param_name, param_info in class_params.items():
|
|
314
268
|
if param_name not in all_params and param_name != 'kwargs':
|
|
315
|
-
all_params[param_name] =
|
|
316
|
-
name=param_name,
|
|
317
|
-
param_type=param_info.param_type,
|
|
318
|
-
default_value=param_info.default_value,
|
|
319
|
-
is_required=param_info.is_required,
|
|
320
|
-
description=param_info.description,
|
|
321
|
-
source_type="object_instance"
|
|
322
|
-
)
|
|
269
|
+
all_params[param_name] = param_info
|
|
323
270
|
|
|
324
271
|
except Exception:
|
|
325
272
|
# Skip classes that can't be analyzed - this is legitimate since some classes
|
|
@@ -337,67 +284,21 @@ class UnifiedParameterAnalyzer:
|
|
|
337
284
|
continue
|
|
338
285
|
# Infer type from value
|
|
339
286
|
attr_type = type(attr_value) if attr_value is not None else type(None)
|
|
340
|
-
all_params[attr_name] =
|
|
287
|
+
all_params[attr_name] = ParameterInfo(
|
|
341
288
|
name=attr_name,
|
|
342
289
|
param_type=attr_type,
|
|
343
290
|
default_value=attr_value,
|
|
344
291
|
is_required=False,
|
|
345
|
-
description=None
|
|
346
|
-
source_type="dynamic_attr"
|
|
292
|
+
description=None
|
|
347
293
|
)
|
|
348
294
|
_logger.debug(f"🔧 _analyze_object_instance: after fallback, all_params={list(all_params.keys())}")
|
|
349
295
|
|
|
350
296
|
return all_params
|
|
351
297
|
|
|
352
298
|
@staticmethod
|
|
353
|
-
def _analyze_dataclass_instance(instance: object) -> Dict[str,
|
|
299
|
+
def _analyze_dataclass_instance(instance: object) -> Dict[str, ParameterInfo]:
|
|
354
300
|
"""Analyze a dataclass instance.
|
|
355
301
|
|
|
356
302
|
Uses current instance values as defaults.
|
|
357
303
|
"""
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
name: UnifiedParameterInfo.from_parameter_info(
|
|
361
|
-
param_info,
|
|
362
|
-
source_type="dataclass_instance",
|
|
363
|
-
)
|
|
364
|
-
for name, param_info in param_info_dict.items()
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
@staticmethod
|
|
368
|
-
def analyze_nested(
|
|
369
|
-
target: Union[Callable, Type, object],
|
|
370
|
-
parent_info: Dict[str, UnifiedParameterInfo] = None,
|
|
371
|
-
) -> Dict[str, UnifiedParameterInfo]:
|
|
372
|
-
"""Analyze parameters with nested dataclass support.
|
|
373
|
-
|
|
374
|
-
This method provides enhanced analysis that can handle nested dataclasses
|
|
375
|
-
and maintain parent context information.
|
|
376
|
-
|
|
377
|
-
Args:
|
|
378
|
-
target: The target to analyze
|
|
379
|
-
parent_info: Optional parent parameter information for context
|
|
380
|
-
|
|
381
|
-
Returns:
|
|
382
|
-
Dictionary of unified parameter information with nested support
|
|
383
|
-
"""
|
|
384
|
-
base_params = UnifiedParameterAnalyzer.analyze(target)
|
|
385
|
-
|
|
386
|
-
# For each parameter, check if it's a nested dataclass
|
|
387
|
-
enhanced_params = {}
|
|
388
|
-
for name, param_info in base_params.items():
|
|
389
|
-
enhanced_params[name] = param_info
|
|
390
|
-
|
|
391
|
-
# If this parameter is a dataclass, mark it as having nested structure
|
|
392
|
-
if dataclasses.is_dataclass(param_info.param_type):
|
|
393
|
-
# Update source type to indicate nesting capability
|
|
394
|
-
enhanced_params[name] = UnifiedParameterInfo(
|
|
395
|
-
name=param_info.name,
|
|
396
|
-
param_type=param_info.param_type,
|
|
397
|
-
default_value=param_info.default_value,
|
|
398
|
-
is_required=param_info.is_required,
|
|
399
|
-
description=param_info.description,
|
|
400
|
-
source_type=f"{param_info.source_type}_nested"
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
return enhanced_params
|
|
304
|
+
return SignatureAnalyzer.analyze(instance)
|
python_introspect/validation.py
CHANGED
|
@@ -14,12 +14,11 @@ from typing import (
|
|
|
14
14
|
TypeVar,
|
|
15
15
|
get_args,
|
|
16
16
|
get_origin,
|
|
17
|
-
get_type_hints,
|
|
18
17
|
)
|
|
19
18
|
|
|
20
19
|
from annotated_types import Ge, Gt, Interval, Le, Len, Lt, MaxLen, MinLen, Predicate
|
|
21
20
|
|
|
22
|
-
from .annotation_types import is_union_type
|
|
21
|
+
from .annotation_types import is_union_type, resolved_class_annotations
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
class AnnotationValidationError(ValueError):
|
|
@@ -86,7 +85,7 @@ def validate_annotated_dataclass(instance: object) -> None:
|
|
|
86
85
|
f"got {owner_type.__name__}."
|
|
87
86
|
)
|
|
88
87
|
|
|
89
|
-
annotations =
|
|
88
|
+
annotations = resolved_class_annotations(owner_type)
|
|
90
89
|
for declared_field in fields(instance):
|
|
91
90
|
annotation = annotations.get(declared_field.name, declared_field.type)
|
|
92
91
|
value = object.__getattribute__(instance, declared_field.name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-introspect
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Pure Python introspection toolkit for function signatures, dataclasses, and type hints
|
|
5
5
|
Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
|
|
6
6
|
License: MIT
|
|
@@ -24,6 +24,7 @@ Requires-Dist: annotated-types>=0.7.0
|
|
|
24
24
|
Requires-Dist: metaclass-registry>=0.1.0
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: tomli>=2.0.1; python_version < "3.11" and extra == "dev"
|
|
27
28
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
28
29
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
29
30
|
Requires-Dist: black>=23.0; extra == "dev"
|
|
@@ -94,3 +95,19 @@ changes are checked by the repository's [documentation
|
|
|
94
95
|
workflow](https://github.com/OpenHCSDev/python-introspect/actions/workflows/docs.yml);
|
|
95
96
|
the local warnings-as-errors build command is documented in
|
|
96
97
|
[`development.rst`](docs/source/development.rst).
|
|
98
|
+
|
|
99
|
+
### Parameter declarations in 0.2
|
|
100
|
+
|
|
101
|
+
`UnifiedParameterAnalyzer` now returns the original `ParameterInfo` declarations
|
|
102
|
+
from `SignatureAnalyzer`. `UnifiedParameterInfo`, its `source_type` tag, and
|
|
103
|
+
`analyze_nested` were removed. Use `analyze` and the declared `param_type` for
|
|
104
|
+
parameter topology.
|
|
105
|
+
|
|
106
|
+
`ParameterInfo` retains its five public fields, constructor and `_replace`
|
|
107
|
+
operation, and is now a frozen declaration rather than a tuple. Tuple unpacking
|
|
108
|
+
and NamedTuple helpers are no longer supported. Dataclass descriptions are
|
|
109
|
+
derived on first help access; value/default overlays preserve that deferred
|
|
110
|
+
source, and renaming preserves the original field's help. Callable descriptions
|
|
111
|
+
still participate eagerly in inferred parameter types. A presentation extraction
|
|
112
|
+
error yields absent help without discarding valid types/defaults. Factory errors
|
|
113
|
+
remain uncached and retryable.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
python_introspect/__init__.py,sha256=IHmyKt23hfkRVeRaVD9mDuoWAG7Q7BorK-cP4B9o0Eg,3985
|
|
2
|
+
python_introspect/annotation_types.py,sha256=KZDv0fUAuvVQDEj0U_cozA_31vl3MyNsOfSOcdo3aJk,7557
|
|
3
|
+
python_introspect/callable_declaration.py,sha256=O7i0dGJP5rKWSlwfmmqlQLJwNQC_oSTngImVy3idaVE,1409
|
|
4
|
+
python_introspect/dataclass_projection.py,sha256=531PIrAunWNuQBwnZDBRpDa0Gy8iK_F6B4ThwYqOhCU,10869
|
|
5
|
+
python_introspect/docstring_annotations.py,sha256=RE6AZPgz8aBEIIvUoVEHpraItZfjareKxYvuaZa1H8k,6129
|
|
6
|
+
python_introspect/enableable.py,sha256=Al6t1kpqbxZIvDb70XJPYEk1WgubGiWUQySNcBcLjrY,5253
|
|
7
|
+
python_introspect/environment_projection.py,sha256=irfR4B6aRSnqC3fYmWpf7rlL3C6QXdYmOvso0RvCF6A,4654
|
|
8
|
+
python_introspect/exceptions.py,sha256=TG8Vo2JdArBbL8hlHmfmt31LtEKtGAuFEh2Rh07DQHs,488
|
|
9
|
+
python_introspect/jsonable.py,sha256=j-BlM6bc4Xm313eStqx2jCpkoennm76SaJNJj64DW7Q,3914
|
|
10
|
+
python_introspect/public_api.py,sha256=8wkxQKmnxl_46U49Dzq4XXIOqmrYQOD0S0cl9bc-YFk,3738
|
|
11
|
+
python_introspect/runtime_parameter.py,sha256=Wf7S1GfnUWhkRzIvjhmoYz2WR0HT2K_MuFDcUEphmJc,3067
|
|
12
|
+
python_introspect/signature_analyzer.py,sha256=DlFaxudB-dANV0FD9MmLJWdvxBKobJbtEn5n6tda2JU,60926
|
|
13
|
+
python_introspect/unified_parameter_analyzer.py,sha256=Qw39mQsYDYq5zEiB6ALRoZqwQeZ4PJmlkFxpTaKkccg,12231
|
|
14
|
+
python_introspect/validation.py,sha256=vj3OwJFShA2n4Zg45rXj8qghsJxDe1JhND8gkuNbfLQ,10447
|
|
15
|
+
python_introspect-0.2.2.dist-info/licenses/LICENSE,sha256=xagEoeTAj1WT64RmyR3E6HH-eTGdgXN6gqPMUUt7L_Y,1070
|
|
16
|
+
python_introspect-0.2.2.dist-info/METADATA,sha256=rwnVJA2UW6z5V1pznJtSjYqb0-H9_Y55ynK00qji8SE,4677
|
|
17
|
+
python_introspect-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
python_introspect-0.2.2.dist-info/top_level.txt,sha256=TZq9Yj1LeXI7A96PDqf7ZbWGSB1zLlQDww0esC91bwY,18
|
|
19
|
+
python_introspect-0.2.2.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
python_introspect/__init__.py,sha256=NIhjamPBxGzCEI1RBGUbwgB8tvTYATnSZ4OdM7WgPWI,3518
|
|
2
|
-
python_introspect/annotation_types.py,sha256=3BJoeailgc6gdY7sTfGOwgxHykseE1q94jaZtlt5C94,5346
|
|
3
|
-
python_introspect/callable_declaration.py,sha256=O7i0dGJP5rKWSlwfmmqlQLJwNQC_oSTngImVy3idaVE,1409
|
|
4
|
-
python_introspect/dataclass_projection.py,sha256=0r4ncFGwcNc1AwJaOt3N6AFnh-GKNpxFtgvgwO22X-o,10870
|
|
5
|
-
python_introspect/docstring_annotations.py,sha256=RE6AZPgz8aBEIIvUoVEHpraItZfjareKxYvuaZa1H8k,6129
|
|
6
|
-
python_introspect/enableable.py,sha256=Al6t1kpqbxZIvDb70XJPYEk1WgubGiWUQySNcBcLjrY,5253
|
|
7
|
-
python_introspect/environment_projection.py,sha256=irfR4B6aRSnqC3fYmWpf7rlL3C6QXdYmOvso0RvCF6A,4654
|
|
8
|
-
python_introspect/exceptions.py,sha256=TG8Vo2JdArBbL8hlHmfmt31LtEKtGAuFEh2Rh07DQHs,488
|
|
9
|
-
python_introspect/runtime_parameter.py,sha256=Wf7S1GfnUWhkRzIvjhmoYz2WR0HT2K_MuFDcUEphmJc,3067
|
|
10
|
-
python_introspect/signature_analyzer.py,sha256=fiWgNVFZyG02GiGnr8RJ7HWMv4oy6bctBt5mqSTwQVk,54471
|
|
11
|
-
python_introspect/unified_parameter_analyzer.py,sha256=rFKrmX-zhUJZIGFEUejMdU_s4Bdrq0zJsTLq6MkwqxU,16284
|
|
12
|
-
python_introspect/validation.py,sha256=Rvuni9Box1yJuRuqSly-jNypaS51ooXq92MRGD33e2Q,10448
|
|
13
|
-
python_introspect-0.1.16.dist-info/licenses/LICENSE,sha256=xagEoeTAj1WT64RmyR3E6HH-eTGdgXN6gqPMUUt7L_Y,1070
|
|
14
|
-
python_introspect-0.1.16.dist-info/METADATA,sha256=8FHNfnKDQy2tCCHZ3KAulNTLX0zr6SMxImBSwb_XN9I,3733
|
|
15
|
-
python_introspect-0.1.16.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
-
python_introspect-0.1.16.dist-info/top_level.txt,sha256=TZq9Yj1LeXI7A96PDqf7ZbWGSB1zLlQDww0esC91bwY,18
|
|
17
|
-
python_introspect-0.1.16.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|