python-introspect 0.1.11__tar.gz → 0.1.12__tar.gz
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-0.1.11/src/python_introspect.egg-info → python_introspect-0.1.12}/PKG-INFO +1 -1
- {python_introspect-0.1.11 → python_introspect-0.1.12}/pyproject.toml +1 -1
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/__init__.py +1 -1
- python_introspect-0.1.12/src/python_introspect/docstring_annotations.py +193 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/signature_analyzer.py +49 -48
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/validation.py +10 -7
- {python_introspect-0.1.11 → python_introspect-0.1.12/src/python_introspect.egg-info}/PKG-INFO +1 -1
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/SOURCES.txt +2 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_init.py +1 -1
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_signature_analyzer.py +83 -1
- python_introspect-0.1.12/tests/test_validate_python_release_action.py +137 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_validation.py +16 -1
- {python_introspect-0.1.11 → python_introspect-0.1.12}/LICENSE +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/README.md +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/setup.cfg +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/annotation_types.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/callable_declaration.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/dataclass_projection.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/enableable.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/environment_projection.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/exceptions.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/runtime_parameter.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/unified_parameter_analyzer.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/dependency_links.txt +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/requires.txt +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/top_level.txt +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_annotation_types.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_callable_declaration.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_dataclass_projection.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_environment_projection.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_exceptions.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_runtime_parameter.py +0 -0
- {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_unified_parameter_analyzer.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-introspect"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.12"
|
|
8
8
|
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Typed annotation inference from callable defaults and docstring declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from operator import or_
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
from metaclass_registry import AutoRegisterMeta
|
|
14
|
+
|
|
15
|
+
from .annotation_types import make_optional
|
|
16
|
+
from .validation import validate_annotation_value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DocstringTypeDeclaration(ABC, metaclass=AutoRegisterMeta):
|
|
20
|
+
"""Nominal owner for one family of docstring type expressions."""
|
|
21
|
+
|
|
22
|
+
__registry_key__ = "declaration_name"
|
|
23
|
+
__skip_if_no_key__ = True
|
|
24
|
+
|
|
25
|
+
declaration_name: ClassVar[str | None] = None
|
|
26
|
+
expression_pattern: ClassVar[re.Pattern[str] | None] = None
|
|
27
|
+
annotation: ClassVar[object | None] = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def annotations_for(cls, description: str | None) -> tuple[object, ...]:
|
|
31
|
+
"""Return every declaration matched by the parameter's type expression."""
|
|
32
|
+
|
|
33
|
+
expression = _type_expression(description)
|
|
34
|
+
if not expression:
|
|
35
|
+
return ()
|
|
36
|
+
annotations = {
|
|
37
|
+
declaration.require_annotation()
|
|
38
|
+
for declaration_type in cls.__registry__.values()
|
|
39
|
+
for declaration in (declaration_type(),)
|
|
40
|
+
if declaration.matches(expression)
|
|
41
|
+
}
|
|
42
|
+
return tuple(sorted(annotations, key=_annotation_sort_key))
|
|
43
|
+
|
|
44
|
+
def matches(self, expression: str) -> bool:
|
|
45
|
+
"""Return whether this declaration owns a token in ``expression``."""
|
|
46
|
+
|
|
47
|
+
pattern = type(self).expression_pattern
|
|
48
|
+
return pattern is not None and pattern.search(expression) is not None
|
|
49
|
+
|
|
50
|
+
def require_annotation(self) -> object:
|
|
51
|
+
"""Return the annotation owned by this concrete declaration."""
|
|
52
|
+
|
|
53
|
+
annotation = type(self).annotation
|
|
54
|
+
if annotation is None:
|
|
55
|
+
raise TypeError(f"{type(self).__name__}.annotation must declare a Python type.")
|
|
56
|
+
return annotation
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BooleanDocstringType(DocstringTypeDeclaration):
|
|
60
|
+
declaration_name = "boolean"
|
|
61
|
+
expression_pattern = re.compile(r"\b(?:bool|boolean|true|false)\b")
|
|
62
|
+
annotation = bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class IntegerDocstringType(DocstringTypeDeclaration):
|
|
66
|
+
declaration_name = "integer"
|
|
67
|
+
expression_pattern = re.compile(r"\b(?:int|integer|ints|integers)\b")
|
|
68
|
+
annotation = int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FloatDocstringType(DocstringTypeDeclaration):
|
|
72
|
+
declaration_name = "float"
|
|
73
|
+
expression_pattern = re.compile(r"\b(?:float|double|scalar|scalars|number|numeric)\b")
|
|
74
|
+
annotation = float
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StringDocstringType(DocstringTypeDeclaration):
|
|
78
|
+
declaration_name = "string"
|
|
79
|
+
expression_pattern = re.compile(r"\b(?:str|string|strings)\b")
|
|
80
|
+
annotation = str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TupleDocstringType(DocstringTypeDeclaration):
|
|
84
|
+
declaration_name = "tuple"
|
|
85
|
+
expression_pattern = re.compile(r"\btuple\b")
|
|
86
|
+
annotation = tuple[Any, ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SequenceDocstringType(DocstringTypeDeclaration):
|
|
90
|
+
declaration_name = "sequence"
|
|
91
|
+
expression_pattern = re.compile(r"\b(?:ndarray|array(?:[_ -]?like)?|sequence|iterable)\b")
|
|
92
|
+
annotation = Sequence[Any]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ListDocstringType(DocstringTypeDeclaration):
|
|
96
|
+
declaration_name = "list"
|
|
97
|
+
expression_pattern = re.compile(r"\blist\b")
|
|
98
|
+
annotation = list[Any]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MappingDocstringType(DocstringTypeDeclaration):
|
|
102
|
+
declaration_name = "mapping"
|
|
103
|
+
expression_pattern = re.compile(r"\b(?:dict|dictionary|mapping)\b")
|
|
104
|
+
annotation = Mapping[str, Any]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CallableDocstringType(DocstringTypeDeclaration):
|
|
108
|
+
declaration_name = "callable"
|
|
109
|
+
expression_pattern = re.compile(r"\b(?:callable|function)\b")
|
|
110
|
+
annotation = Callable[..., Any]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def infer_parameter_annotation(
|
|
114
|
+
parameter: inspect.Parameter,
|
|
115
|
+
description: str | None,
|
|
116
|
+
) -> object:
|
|
117
|
+
"""Infer one missing annotation from declaration-owned runtime evidence."""
|
|
118
|
+
|
|
119
|
+
if parameter.annotation is not inspect.Parameter.empty:
|
|
120
|
+
return parameter.annotation
|
|
121
|
+
|
|
122
|
+
documented = DocstringTypeDeclaration.annotations_for(description)
|
|
123
|
+
default = parameter.default
|
|
124
|
+
if default is inspect.Parameter.empty:
|
|
125
|
+
return _union_annotation(documented) if documented else Any
|
|
126
|
+
if default is None:
|
|
127
|
+
inferred = _union_annotation(documented)
|
|
128
|
+
return Any if inferred is Any else make_optional(inferred)
|
|
129
|
+
if documented and any(
|
|
130
|
+
_annotation_accepts_default(annotation, default) for annotation in documented
|
|
131
|
+
):
|
|
132
|
+
return _union_annotation(documented)
|
|
133
|
+
return type(default)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _type_expression(description: str | None) -> str:
|
|
137
|
+
"""Return the first non-empty docstring line that declares a parameter type."""
|
|
138
|
+
|
|
139
|
+
if not description:
|
|
140
|
+
return ""
|
|
141
|
+
expression = next(
|
|
142
|
+
(line.strip().lower() for line in description.splitlines() if line.strip()),
|
|
143
|
+
"",
|
|
144
|
+
)
|
|
145
|
+
return _without_nested_type_arguments(expression)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _without_nested_type_arguments(expression: str) -> str:
|
|
149
|
+
"""Remove bracketed member signatures while retaining their owning type."""
|
|
150
|
+
|
|
151
|
+
depth = 0
|
|
152
|
+
projected: list[str] = []
|
|
153
|
+
for character in expression:
|
|
154
|
+
if character == "[":
|
|
155
|
+
depth += 1
|
|
156
|
+
continue
|
|
157
|
+
if character == "]":
|
|
158
|
+
depth = max(0, depth - 1)
|
|
159
|
+
continue
|
|
160
|
+
if depth == 0:
|
|
161
|
+
projected.append(character)
|
|
162
|
+
return "".join(projected)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _union_annotation(annotations: tuple[object, ...]) -> object:
|
|
166
|
+
if not annotations:
|
|
167
|
+
return Any
|
|
168
|
+
if len(annotations) == 1:
|
|
169
|
+
return annotations[0]
|
|
170
|
+
return reduce(or_, annotations)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _annotation_accepts_default(annotation: object, default: object) -> bool:
|
|
174
|
+
try:
|
|
175
|
+
validate_annotation_value(annotation, default, path="parameter default")
|
|
176
|
+
except (TypeError, ValueError):
|
|
177
|
+
return False
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _annotation_sort_key(annotation: object) -> tuple[str, str]:
|
|
182
|
+
origin = getattr(annotation, "__origin__", None)
|
|
183
|
+
owner = origin or annotation
|
|
184
|
+
return (
|
|
185
|
+
str(getattr(owner, "__module__", "")),
|
|
186
|
+
str(getattr(owner, "__qualname__", annotation)),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
__all__ = [
|
|
191
|
+
"DocstringTypeDeclaration",
|
|
192
|
+
"infer_parameter_annotation",
|
|
193
|
+
]
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/signature_analyzer.py
RENAMED
|
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
|
|
|
19
19
|
|
|
20
20
|
from metaclass_registry import AutoRegisterMeta
|
|
21
21
|
from .annotation_types import optional_member_type
|
|
22
|
+
from .docstring_annotations import infer_parameter_annotation
|
|
22
23
|
|
|
23
24
|
# =============================================================================
|
|
24
25
|
# PLUGIN REGISTRY - Allows frameworks to extend type resolution
|
|
@@ -173,19 +174,21 @@ class DocstringParseState:
|
|
|
173
174
|
parameters: Dict[str, str] = field(default_factory=dict)
|
|
174
175
|
returns: Optional[str] = None
|
|
175
176
|
examples: Optional[str] = None
|
|
176
|
-
|
|
177
|
+
current_params: Tuple[str, ...] = ()
|
|
177
178
|
current_param_lines: List[str] = field(default_factory=list)
|
|
178
179
|
|
|
179
180
|
def finalize_current_param(self) -> None:
|
|
180
181
|
"""Commit the active parameter description, if one is being parsed."""
|
|
181
|
-
if self.
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
if self.current_params and self.current_param_lines:
|
|
183
|
+
description = "\n".join(self.current_param_lines).strip()
|
|
184
|
+
self.parameters.update(
|
|
185
|
+
(parameter_name, description)
|
|
186
|
+
for parameter_name in self.current_params
|
|
184
187
|
)
|
|
185
188
|
|
|
186
189
|
def reset_current_param(self) -> None:
|
|
187
190
|
"""Clear parameter continuation state after a section transition."""
|
|
188
|
-
self.
|
|
191
|
+
self.current_params = ()
|
|
189
192
|
self.current_param_lines = []
|
|
190
193
|
|
|
191
194
|
def transition_to(self, section: "DocstringSection") -> "DocstringSection":
|
|
@@ -283,6 +286,13 @@ class ParametersDocstringSection(DocstringSection):
|
|
|
283
286
|
"additional parameters:",
|
|
284
287
|
)
|
|
285
288
|
numpy_headers = ("args", "arguments", "parameters", "additional parameters")
|
|
289
|
+
parameter_declaration_pattern = re.compile(
|
|
290
|
+
r"^(?:"
|
|
291
|
+
r":param\s+(?P<sphinx_name>\w+)"
|
|
292
|
+
r"|[-•*]\s*(?P<bullet_names>\w+(?:\s*,\s*\w+)*)"
|
|
293
|
+
r"|(?P<plain_names>\w+(?:\s*,\s*\w+)*)"
|
|
294
|
+
r")\s*:\s*(?P<description>.+)$"
|
|
295
|
+
)
|
|
286
296
|
|
|
287
297
|
def consume(
|
|
288
298
|
self,
|
|
@@ -290,46 +300,32 @@ class ParametersDocstringSection(DocstringSection):
|
|
|
290
300
|
original_line: str,
|
|
291
301
|
line: str,
|
|
292
302
|
) -> None:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
param_match_numpy = re.match(r"^(\w+)\s*:\s*(.+)", line)
|
|
296
|
-
param_match_inline = re.match(
|
|
297
|
-
r"^(\w+):\s*(\w+(?:\[.*?\])?|\w+(?:\s*\|\s*\w+)*)\s+(.+)",
|
|
298
|
-
line,
|
|
299
|
-
)
|
|
300
|
-
param_match_bullet = re.match(r"^[-•*]\s*(\w+):\s*(.+)", line)
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
param_match_google
|
|
304
|
-
or param_match_sphinx
|
|
305
|
-
or param_match_numpy
|
|
306
|
-
or param_match_inline
|
|
307
|
-
or param_match_bullet
|
|
308
|
-
):
|
|
303
|
+
declaration = self.parameter_declaration_pattern.fullmatch(line)
|
|
304
|
+
if declaration is not None:
|
|
309
305
|
state.finalize_current_param()
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
elif state.
|
|
306
|
+
declared_names = next(
|
|
307
|
+
value
|
|
308
|
+
for value in (
|
|
309
|
+
declaration.group("sphinx_name"),
|
|
310
|
+
declaration.group("bullet_names"),
|
|
311
|
+
declaration.group("plain_names"),
|
|
312
|
+
)
|
|
313
|
+
if value is not None
|
|
314
|
+
)
|
|
315
|
+
state.current_params = tuple(
|
|
316
|
+
name.strip() for name in declared_names.split(",")
|
|
317
|
+
)
|
|
318
|
+
state.current_param_lines = [
|
|
319
|
+
declaration.group("description").strip()
|
|
320
|
+
]
|
|
321
|
+
elif state.current_params and (
|
|
326
322
|
original_line.startswith(" ") or original_line.startswith("\t")
|
|
327
323
|
):
|
|
328
324
|
state.current_param_lines.append(line)
|
|
329
325
|
elif not line:
|
|
330
326
|
state.finalize_current_param()
|
|
331
327
|
state.reset_current_param()
|
|
332
|
-
elif state.
|
|
328
|
+
elif state.current_params:
|
|
333
329
|
state.current_param_lines.append(line)
|
|
334
330
|
else:
|
|
335
331
|
state.parameters.update(DocstringExtractor._parse_inline_parameters(line))
|
|
@@ -731,12 +727,16 @@ class CallableAnalysisContext:
|
|
|
731
727
|
|
|
732
728
|
@staticmethod
|
|
733
729
|
def annotation_namespace_targets(target: Callable) -> Tuple[Callable, ...]:
|
|
734
|
-
"""Return
|
|
730
|
+
"""Return runtime-to-declaration callables whose namespaces own annotations."""
|
|
735
731
|
|
|
736
732
|
unwrapped = inspect.unwrap(target)
|
|
737
733
|
if unwrapped is target:
|
|
738
734
|
return (target,)
|
|
739
|
-
|
|
735
|
+
# ``functools.wraps`` copies the declaration's annotation strings onto a
|
|
736
|
+
# wrapper whose runtime globals belong to the decorator module. Merge
|
|
737
|
+
# outward so the unwrapped declaration, which authored those strings,
|
|
738
|
+
# has final authority over colliding names.
|
|
739
|
+
return (target, unwrapped)
|
|
740
740
|
|
|
741
741
|
def type_hints(self) -> Dict[str, Any]:
|
|
742
742
|
"""Resolve type hints using the context-owned namespace."""
|
|
@@ -854,14 +854,6 @@ class SignatureAnalyzer:
|
|
|
854
854
|
continue
|
|
855
855
|
|
|
856
856
|
from typing import Any
|
|
857
|
-
param_type = type_hints.get(param_name)
|
|
858
|
-
if param_type is None:
|
|
859
|
-
param_type = (
|
|
860
|
-
param.annotation
|
|
861
|
-
if param.annotation is not inspect.Parameter.empty
|
|
862
|
-
else Any
|
|
863
|
-
)
|
|
864
|
-
param_type, annotation_description = _parameter_annotation_help(param_type)
|
|
865
857
|
default_value = param.default if param.default != inspect.Parameter.empty else None
|
|
866
858
|
is_required = param.default == inspect.Parameter.empty
|
|
867
859
|
|
|
@@ -872,8 +864,17 @@ class SignatureAnalyzer:
|
|
|
872
864
|
else None
|
|
873
865
|
)
|
|
874
866
|
if param_description is None:
|
|
867
|
+
annotation = type_hints.get(param_name, param.annotation)
|
|
868
|
+
_base_annotation, annotation_description = _parameter_annotation_help(
|
|
869
|
+
annotation
|
|
870
|
+
)
|
|
875
871
|
param_description = annotation_description
|
|
876
872
|
|
|
873
|
+
annotation = type_hints.get(param_name, param.annotation)
|
|
874
|
+
if annotation is inspect.Parameter.empty:
|
|
875
|
+
annotation = infer_parameter_annotation(param, param_description)
|
|
876
|
+
param_type, _annotation_description = _parameter_annotation_help(annotation)
|
|
877
|
+
|
|
877
878
|
parameters[param_name] = ParameterInfo(
|
|
878
879
|
name=param_name,
|
|
879
880
|
param_type=param_type,
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Mapping, Sequence
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from collections.abc import Set as AbstractSet
|
|
6
7
|
from dataclasses import fields, is_dataclass, replace
|
|
7
8
|
from functools import singledispatch
|
|
8
9
|
from typing import (
|
|
@@ -10,13 +11,14 @@ from typing import (
|
|
|
10
11
|
Any,
|
|
11
12
|
ClassVar,
|
|
12
13
|
Literal,
|
|
14
|
+
TypeVar,
|
|
13
15
|
get_args,
|
|
14
16
|
get_origin,
|
|
15
17
|
get_type_hints,
|
|
16
|
-
TypeVar,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
from annotated_types import Ge, Gt, Interval, Le, Len, Lt, MaxLen, MinLen, Predicate
|
|
21
|
+
|
|
20
22
|
from .annotation_types import is_union_type
|
|
21
23
|
|
|
22
24
|
|
|
@@ -133,21 +135,22 @@ def validate_annotation_value(
|
|
|
133
135
|
)
|
|
134
136
|
if origin is Literal:
|
|
135
137
|
choices = get_args(annotation)
|
|
136
|
-
if not any(
|
|
137
|
-
type(value) is type(choice) and value == choice
|
|
138
|
-
for choice in choices
|
|
139
|
-
):
|
|
138
|
+
if not any(type(value) is type(choice) and value == choice for choice in choices):
|
|
140
139
|
raise AnnotationValidationError(f"{path} must be one of {choices!r}.")
|
|
141
140
|
return
|
|
142
141
|
if origin is tuple:
|
|
143
142
|
_validate_tuple(annotation, value, path)
|
|
144
143
|
return
|
|
145
|
-
if origin in {list, set, frozenset, Sequence,
|
|
144
|
+
if origin in {list, set, frozenset, Sequence, AbstractSet}:
|
|
146
145
|
_validate_sequence(annotation, value, path)
|
|
147
146
|
return
|
|
148
147
|
if origin in {dict, Mapping}:
|
|
149
148
|
_validate_mapping(annotation, value, path)
|
|
150
149
|
return
|
|
150
|
+
if origin is Callable:
|
|
151
|
+
if not callable(value):
|
|
152
|
+
raise TypeError(f"{path} must be callable; got {type(value).__name__}.")
|
|
153
|
+
return
|
|
151
154
|
if origin is ClassVar:
|
|
152
155
|
return
|
|
153
156
|
if annotation is None or annotation is type(None):
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/SOURCES.txt
RENAMED
|
@@ -5,6 +5,7 @@ src/python_introspect/__init__.py
|
|
|
5
5
|
src/python_introspect/annotation_types.py
|
|
6
6
|
src/python_introspect/callable_declaration.py
|
|
7
7
|
src/python_introspect/dataclass_projection.py
|
|
8
|
+
src/python_introspect/docstring_annotations.py
|
|
8
9
|
src/python_introspect/enableable.py
|
|
9
10
|
src/python_introspect/environment_projection.py
|
|
10
11
|
src/python_introspect/exceptions.py
|
|
@@ -26,4 +27,5 @@ tests/test_init.py
|
|
|
26
27
|
tests/test_runtime_parameter.py
|
|
27
28
|
tests/test_signature_analyzer.py
|
|
28
29
|
tests/test_unified_parameter_analyzer.py
|
|
30
|
+
tests/test_validate_python_release_action.py
|
|
29
31
|
tests/test_validation.py
|
|
@@ -11,7 +11,7 @@ class TestPackageImports:
|
|
|
11
11
|
"""Test that __version__ is available."""
|
|
12
12
|
assert hasattr(python_introspect, "__version__")
|
|
13
13
|
assert isinstance(python_introspect.__version__, str)
|
|
14
|
-
assert python_introspect.__version__ == "0.1.
|
|
14
|
+
assert python_introspect.__version__ == "0.1.12"
|
|
15
15
|
|
|
16
16
|
def test_signature_analyzer_import(self):
|
|
17
17
|
"""Test SignatureAnalyzer is importable."""
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Tests for SignatureAnalyzer."""
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
+
from collections.abc import Callable as CallableABC, Sequence
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from enum import Enum
|
|
6
7
|
from functools import wraps
|
|
7
|
-
from typing import get_args
|
|
8
|
+
from typing import get_args, get_origin
|
|
8
9
|
from typing import Annotated, Optional, List, Dict, Any
|
|
9
10
|
from python_introspect import (
|
|
10
11
|
SignatureAnalyzer,
|
|
@@ -90,6 +91,49 @@ class TestSignatureAnalyzer:
|
|
|
90
91
|
assert params["slice_by_slice"].param_type is bool
|
|
91
92
|
assert params["slice_by_slice"].default_value is False
|
|
92
93
|
|
|
94
|
+
def test_unannotated_callable_parameters_derive_types_from_declarations(self):
|
|
95
|
+
"""Defaults and NumPy-style type lines form a typed authoring signature."""
|
|
96
|
+
|
|
97
|
+
def external(
|
|
98
|
+
image,
|
|
99
|
+
values=None,
|
|
100
|
+
exclude_border=False,
|
|
101
|
+
block_size=2,
|
|
102
|
+
reducer=max,
|
|
103
|
+
):
|
|
104
|
+
"""External array operation.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
image : ndarray
|
|
109
|
+
Input image.
|
|
110
|
+
values : ndarray, optional
|
|
111
|
+
Optional values.
|
|
112
|
+
exclude_border : tuple of ints, int, or False, optional
|
|
113
|
+
Border exclusion.
|
|
114
|
+
block_size : array_like or int
|
|
115
|
+
Block dimensions.
|
|
116
|
+
reducer : callable
|
|
117
|
+
Reduction function.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
params = SignatureAnalyzer.analyze(external)
|
|
121
|
+
|
|
122
|
+
assert set(get_args(params["values"].param_type)) == {
|
|
123
|
+
Sequence[Any],
|
|
124
|
+
type(None),
|
|
125
|
+
}
|
|
126
|
+
assert set(get_args(params["exclude_border"].param_type)) == {
|
|
127
|
+
bool,
|
|
128
|
+
int,
|
|
129
|
+
tuple[Any, ...],
|
|
130
|
+
}
|
|
131
|
+
assert set(get_args(params["block_size"].param_type)) == {
|
|
132
|
+
int,
|
|
133
|
+
Sequence[Any],
|
|
134
|
+
}
|
|
135
|
+
assert get_origin(params["reducer"].param_type) is CallableABC
|
|
136
|
+
|
|
93
137
|
def test_wrapped_callable_resolves_postponed_annotations_from_original_namespace(self):
|
|
94
138
|
"""Wrapper modules do not own postponed annotations copied from originals."""
|
|
95
139
|
def original(mode: "WrappedAnnotationMode | str" = WrappedAnnotationMode.A):
|
|
@@ -106,6 +150,27 @@ class TestSignatureAnalyzer:
|
|
|
106
150
|
|
|
107
151
|
assert get_args(params["mode"].param_type) == (WrappedAnnotationMode, str)
|
|
108
152
|
|
|
153
|
+
def test_wrapped_declaration_namespace_overrides_decorator_name_collision(self):
|
|
154
|
+
"""Copied annotation strings resolve against their declaration owner."""
|
|
155
|
+
|
|
156
|
+
def original(mode: "WrappedAnnotationMode" = WrappedAnnotationMode.A):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
wrapper_namespace = {"wraps": wraps, "original": original}
|
|
160
|
+
exec(
|
|
161
|
+
"@wraps(original)\n"
|
|
162
|
+
"def wrapper(*args, **kwargs):\n"
|
|
163
|
+
" return original(*args, **kwargs)\n",
|
|
164
|
+
wrapper_namespace,
|
|
165
|
+
)
|
|
166
|
+
wrapper = wrapper_namespace["wrapper"]
|
|
167
|
+
wrapper.__globals__["WrappedAnnotationMode"] = lambda: None
|
|
168
|
+
wrapper.__signature__ = inspect.signature(original)
|
|
169
|
+
|
|
170
|
+
params = SignatureAnalyzer.analyze(wrapper)
|
|
171
|
+
|
|
172
|
+
assert params["mode"].param_type is WrappedAnnotationMode
|
|
173
|
+
|
|
109
174
|
def test_analyze_function_with_docstring(self):
|
|
110
175
|
"""Test analyzing function with docstring parameters."""
|
|
111
176
|
def documented_func(name: str, age: int = 25):
|
|
@@ -419,6 +484,23 @@ class TestDocstringExtractor:
|
|
|
419
484
|
# NumPy style parsing support
|
|
420
485
|
assert "x" in info.parameters or info.parameters == {}
|
|
421
486
|
|
|
487
|
+
def test_extract_numpy_style_multi_name_declaration(self):
|
|
488
|
+
"""One NumPy declaration can author the same description for many names."""
|
|
489
|
+
|
|
490
|
+
def func(in_range="image", out_range="dtype"):
|
|
491
|
+
"""Rescale values.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
in_range, out_range : str or 2-tuple, optional
|
|
496
|
+
Accepted intensity bounds.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
info = DocstringExtractor.extract(func)
|
|
500
|
+
|
|
501
|
+
assert info.parameters["in_range"].startswith("str or 2-tuple")
|
|
502
|
+
assert info.parameters["out_range"] == info.parameters["in_range"]
|
|
503
|
+
|
|
422
504
|
def test_extract_multiline_parameter_description(self):
|
|
423
505
|
"""Test extracting multiline parameter descriptions."""
|
|
424
506
|
def func(description: str):
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from zipfile import ZipFile
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
ACTION_SCRIPT = (
|
|
13
|
+
Path(__file__).parents[1]
|
|
14
|
+
/ ".github"
|
|
15
|
+
/ "actions"
|
|
16
|
+
/ "validate-python-release"
|
|
17
|
+
/ "validate_python_release.py"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_action() -> ModuleType:
|
|
22
|
+
spec = importlib.util.spec_from_file_location("validate_python_release", ACTION_SCRIPT)
|
|
23
|
+
assert spec is not None and spec.loader is not None
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
sys.modules[spec.name] = module
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _git(repository: Path, *arguments: str) -> str:
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
("git", *arguments),
|
|
33
|
+
cwd=repository,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=True,
|
|
37
|
+
).stdout.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _release_repository(tmp_path: Path, tag: str, *, annotated: bool = True) -> str:
|
|
41
|
+
_git(tmp_path, "init")
|
|
42
|
+
_git(tmp_path, "config", "user.email", "release@example.test")
|
|
43
|
+
_git(tmp_path, "config", "user.name", "Release Test")
|
|
44
|
+
(tmp_path / "tracked.txt").write_text("release\n", encoding="utf-8")
|
|
45
|
+
_git(tmp_path, "add", "tracked.txt")
|
|
46
|
+
_git(tmp_path, "commit", "-m", "Release candidate")
|
|
47
|
+
if annotated:
|
|
48
|
+
_git(tmp_path, "tag", "-a", tag, "-m", tag)
|
|
49
|
+
else:
|
|
50
|
+
_git(tmp_path, "tag", tag)
|
|
51
|
+
return _git(tmp_path, "rev-parse", "HEAD")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _wheel(tmp_path: Path, version: str = "1.2.3") -> None:
|
|
55
|
+
distribution = tmp_path / "dist" / f"demo_package-{version}-py3-none-any.whl"
|
|
56
|
+
distribution.parent.mkdir()
|
|
57
|
+
with ZipFile(distribution, "w") as wheel:
|
|
58
|
+
wheel.writestr(
|
|
59
|
+
f"demo_package-{version}.dist-info/METADATA",
|
|
60
|
+
"Metadata-Version: 2.1\n" "Name: demo-package\n" f"Version: {version}\n",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_action_accepts_one_matching_annotated_release(tmp_path: Path) -> None:
|
|
65
|
+
action = _load_action()
|
|
66
|
+
commit = _release_repository(tmp_path, "v1.2.3")
|
|
67
|
+
_wheel(tmp_path)
|
|
68
|
+
|
|
69
|
+
assert (
|
|
70
|
+
action.main(
|
|
71
|
+
[
|
|
72
|
+
"--repository-root",
|
|
73
|
+
str(tmp_path),
|
|
74
|
+
"--tag",
|
|
75
|
+
"v1.2.3",
|
|
76
|
+
"--commit",
|
|
77
|
+
commit,
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
== 0
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize(
|
|
85
|
+
("tag", "version", "annotated"),
|
|
86
|
+
[
|
|
87
|
+
("v1.2.4", "1.2.3", True),
|
|
88
|
+
("v1.2.3", "1.2.3", False),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
def test_action_rejects_mismatched_or_lightweight_tags(
|
|
92
|
+
tmp_path: Path,
|
|
93
|
+
tag: str,
|
|
94
|
+
version: str,
|
|
95
|
+
annotated: bool,
|
|
96
|
+
) -> None:
|
|
97
|
+
action = _load_action()
|
|
98
|
+
commit = _release_repository(tmp_path, tag, annotated=annotated)
|
|
99
|
+
_wheel(tmp_path, version)
|
|
100
|
+
|
|
101
|
+
assert (
|
|
102
|
+
action.main(
|
|
103
|
+
[
|
|
104
|
+
"--repository-root",
|
|
105
|
+
str(tmp_path),
|
|
106
|
+
"--tag",
|
|
107
|
+
tag,
|
|
108
|
+
"--commit",
|
|
109
|
+
commit,
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
== 1
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_action_rejects_a_commit_other_than_the_tag_target(tmp_path: Path) -> None:
|
|
117
|
+
action = _load_action()
|
|
118
|
+
_release_repository(tmp_path, "v1.2.3")
|
|
119
|
+
_wheel(tmp_path)
|
|
120
|
+
(tmp_path / "tracked.txt").write_text("later\n", encoding="utf-8")
|
|
121
|
+
_git(tmp_path, "add", "tracked.txt")
|
|
122
|
+
_git(tmp_path, "commit", "-m", "Later commit")
|
|
123
|
+
later_commit = _git(tmp_path, "rev-parse", "HEAD")
|
|
124
|
+
|
|
125
|
+
assert (
|
|
126
|
+
action.main(
|
|
127
|
+
[
|
|
128
|
+
"--repository-root",
|
|
129
|
+
str(tmp_path),
|
|
130
|
+
"--tag",
|
|
131
|
+
"v1.2.3",
|
|
132
|
+
"--commit",
|
|
133
|
+
later_commit,
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
== 1
|
|
137
|
+
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
1
2
|
from dataclasses import dataclass, make_dataclass
|
|
2
3
|
from enum import Enum
|
|
3
|
-
from typing import Annotated, Literal
|
|
4
|
+
from typing import Annotated, Any, Literal
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
from annotated_types import Ge, Gt, Le, MinLen, Predicate
|
|
@@ -9,6 +10,7 @@ from python_introspect import (
|
|
|
9
10
|
AnnotatedDataclassValidationMixin,
|
|
10
11
|
overlay_non_none_dataclass,
|
|
11
12
|
validate_annotated_dataclass,
|
|
13
|
+
validate_annotation_value,
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
@@ -93,6 +95,19 @@ def test_literal_validation_preserves_nominal_identity() -> None:
|
|
|
93
95
|
LiteralConfig(value=True)
|
|
94
96
|
|
|
95
97
|
|
|
98
|
+
def test_callable_validation_uses_the_declared_runtime_contract() -> None:
|
|
99
|
+
class CallableInstance:
|
|
100
|
+
def __call__(self) -> None:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
annotation = Callable[..., Any]
|
|
104
|
+
validate_annotation_value(annotation, max, path="reducer")
|
|
105
|
+
validate_annotation_value(annotation, CallableInstance(), path="reducer")
|
|
106
|
+
|
|
107
|
+
with pytest.raises(TypeError, match="reducer must be callable"):
|
|
108
|
+
validate_annotation_value(annotation, 7, path="reducer")
|
|
109
|
+
|
|
110
|
+
|
|
96
111
|
@dataclass(frozen=True)
|
|
97
112
|
class ConnectionSpec:
|
|
98
113
|
host: str = "localhost"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/annotation_types.py
RENAMED
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/callable_declaration.py
RENAMED
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/dataclass_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/runtime_parameter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/requires.txt
RENAMED
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_unified_parameter_analyzer.py
RENAMED
|
File without changes
|