python-introspect 0.1.10__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.
Files changed (33) hide show
  1. {python_introspect-0.1.10/src/python_introspect.egg-info → python_introspect-0.1.12}/PKG-INFO +7 -2
  2. {python_introspect-0.1.10 → python_introspect-0.1.12}/README.md +6 -1
  3. {python_introspect-0.1.10 → python_introspect-0.1.12}/pyproject.toml +1 -1
  4. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/__init__.py +4 -1
  5. python_introspect-0.1.12/src/python_introspect/docstring_annotations.py +193 -0
  6. python_introspect-0.1.12/src/python_introspect/runtime_parameter.py +85 -0
  7. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/signature_analyzer.py +49 -48
  8. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/validation.py +10 -7
  9. {python_introspect-0.1.10 → python_introspect-0.1.12/src/python_introspect.egg-info}/PKG-INFO +7 -2
  10. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect.egg-info/SOURCES.txt +4 -0
  11. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_init.py +9 -1
  12. python_introspect-0.1.12/tests/test_runtime_parameter.py +58 -0
  13. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_signature_analyzer.py +83 -1
  14. python_introspect-0.1.12/tests/test_validate_python_release_action.py +137 -0
  15. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_validation.py +16 -1
  16. {python_introspect-0.1.10 → python_introspect-0.1.12}/LICENSE +0 -0
  17. {python_introspect-0.1.10 → python_introspect-0.1.12}/setup.cfg +0 -0
  18. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/annotation_types.py +0 -0
  19. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/callable_declaration.py +0 -0
  20. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/dataclass_projection.py +0 -0
  21. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/enableable.py +0 -0
  22. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/environment_projection.py +0 -0
  23. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/exceptions.py +0 -0
  24. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect/unified_parameter_analyzer.py +0 -0
  25. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  26. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect.egg-info/requires.txt +0 -0
  27. {python_introspect-0.1.10 → python_introspect-0.1.12}/src/python_introspect.egg-info/top_level.txt +0 -0
  28. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_annotation_types.py +0 -0
  29. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_callable_declaration.py +0 -0
  30. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_dataclass_projection.py +0 -0
  31. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_environment_projection.py +0 -0
  32. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_exceptions.py +0 -0
  33. {python_introspect-0.1.10 → python_introspect-0.1.12}/tests/test_unified_parameter_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.10
3
+ Version: 0.1.12
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
@@ -37,7 +37,7 @@ Dynamic: license-file
37
37
  # python-introspect
38
38
 
39
39
  Extensible analysis of callable signatures, dataclass fields, type hints, and
40
- docstrings.
40
+ docstrings, plus signature-derived callable declaration projection.
41
41
 
42
42
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
43
43
  [![PyPI version](https://badge.fury.io/py/python-introspect.svg)](https://badge.fury.io/py/python-introspect)
@@ -66,6 +66,11 @@ for name, info in parameters.items():
66
66
  dataclass types, and instances. It returns a mapping of names to
67
67
  ``ParameterInfo`` records.
68
68
 
69
+ Use ``callable_declaration_kwargs`` when declaration identity should omit
70
+ keyword arguments equal to their signature defaults. The caller supplies value
71
+ equality so array, lazy, or other framework-specific values keep their owning
72
+ semantics.
73
+
69
74
  ## Extension points
70
75
 
71
76
  Use ``register_namespace_provider`` to contribute names used while resolving
@@ -1,7 +1,7 @@
1
1
  # python-introspect
2
2
 
3
3
  Extensible analysis of callable signatures, dataclass fields, type hints, and
4
- docstrings.
4
+ docstrings, plus signature-derived callable declaration projection.
5
5
 
6
6
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
7
  [![PyPI version](https://badge.fury.io/py/python-introspect.svg)](https://badge.fury.io/py/python-introspect)
@@ -30,6 +30,11 @@ for name, info in parameters.items():
30
30
  dataclass types, and instances. It returns a mapping of names to
31
31
  ``ParameterInfo`` records.
32
32
 
33
+ Use ``callable_declaration_kwargs`` when declaration identity should omit
34
+ keyword arguments equal to their signature defaults. The caller supplies value
35
+ equality so array, lazy, or other framework-specific values keep their owning
36
+ semantics.
37
+
33
38
  ## Extension points
34
39
 
35
40
  Use ``register_namespace_provider`` to contribute names used while resolving
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-introspect"
7
- version = "0.1.10"
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"
@@ -9,7 +9,7 @@ Extensibility:
9
9
  type resolution for framework-specific types (lazy configs, proxies, etc.)
10
10
  """
11
11
 
12
- __version__ = "0.1.10"
12
+ __version__ = "0.1.12"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -72,6 +72,7 @@ from .annotation_types import (
72
72
  resolve_optional,
73
73
  )
74
74
  from .callable_declaration import callable_declaration_kwargs
75
+ from .runtime_parameter import RuntimeParameterDeclarationABC
75
76
 
76
77
  __all__ = [
77
78
  # Version
@@ -130,4 +131,6 @@ __all__ = [
130
131
  "resolve_optional",
131
132
  # Callable declarations
132
133
  "callable_declaration_kwargs",
134
+ # Runtime-supplied callable parameters
135
+ "RuntimeParameterDeclarationABC",
133
136
  ]
@@ -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
+ ]
@@ -0,0 +1,85 @@
1
+ """Nominal declarations for runtime-supplied callable parameters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Iterable
8
+ from typing import ClassVar
9
+
10
+
11
+ class RuntimeParameterDeclarationABC(ABC):
12
+ """Semantic owner for one parameter supplied by runtime infrastructure."""
13
+
14
+ preserve_for_execution: ClassVar[bool] = False
15
+ is_semantic_control: ClassVar[bool] = False
16
+
17
+ @classmethod
18
+ @abstractmethod
19
+ def require_parameter_name(cls) -> str:
20
+ """Return the public callable parameter name."""
21
+
22
+ @classmethod
23
+ @abstractmethod
24
+ def parameter(cls) -> inspect.Parameter:
25
+ """Return the complete callable signature parameter declaration."""
26
+
27
+ @classmethod
28
+ def validated_parameter(cls) -> inspect.Parameter:
29
+ """Return the parameter after proving its nominal declaration is coherent."""
30
+
31
+ parameter = cls.parameter()
32
+ if not isinstance(parameter, inspect.Parameter):
33
+ raise TypeError(f"{cls.__name__}.parameter() must return inspect.Parameter.")
34
+ parameter_name = cls.require_parameter_name()
35
+ if not isinstance(parameter_name, str) or not parameter_name.strip():
36
+ raise TypeError(
37
+ f"{cls.__name__}.require_parameter_name() must return a non-empty string."
38
+ )
39
+ if parameter.name != parameter_name:
40
+ raise TypeError(
41
+ f"{cls.__name__}.parameter() name {parameter.name!r} does not "
42
+ f"match require_parameter_name() {parameter_name!r}."
43
+ )
44
+ return parameter
45
+
46
+ @classmethod
47
+ def require_declaration_type(
48
+ cls,
49
+ candidate: object,
50
+ *,
51
+ boundary: str,
52
+ ) -> type[RuntimeParameterDeclarationABC]:
53
+ """Require one nominal runtime-parameter declaration type."""
54
+
55
+ if not isinstance(candidate, type) or not issubclass(candidate, cls):
56
+ raise TypeError(
57
+ f"{boundary} must contain {cls.__name__} subclasses, got {candidate!r}."
58
+ )
59
+ candidate.validated_parameter()
60
+ return candidate
61
+
62
+ @classmethod
63
+ def require_declaration_types(
64
+ cls,
65
+ candidates: Iterable[object],
66
+ *,
67
+ boundary: str,
68
+ ) -> tuple[type[RuntimeParameterDeclarationABC], ...]:
69
+ """Require coherent declarations with unique public parameter names."""
70
+
71
+ declarations: list[type[RuntimeParameterDeclarationABC]] = []
72
+ seen_names: set[str] = set()
73
+ for candidate in candidates:
74
+ declaration = cls.require_declaration_type(candidate, boundary=boundary)
75
+ parameter_name = declaration.require_parameter_name()
76
+ if parameter_name in seen_names:
77
+ raise ValueError(
78
+ f"{boundary} declares duplicate runtime parameter {parameter_name!r}."
79
+ )
80
+ declarations.append(declaration)
81
+ seen_names.add(parameter_name)
82
+ return tuple(declarations)
83
+
84
+
85
+ __all__ = ("RuntimeParameterDeclarationABC",)
@@ -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
- current_param: Optional[str] = None
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.current_param and self.current_param_lines:
182
- self.parameters[self.current_param] = (
183
- "\n".join(self.current_param_lines).strip()
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.current_param = None
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
- param_match_google = re.match(r"^(\w+):\s*(.+)", line)
294
- param_match_sphinx = re.match(r"^:param\s+(\w+):\s*(.+)", line)
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
- if param_match_google:
312
- param_name, param_desc = param_match_google.groups()
313
- elif param_match_sphinx:
314
- param_name, param_desc = param_match_sphinx.groups()
315
- elif param_match_numpy:
316
- param_name, param_desc = param_match_numpy.groups()
317
- elif param_match_inline:
318
- param_name, param_type, param_desc = param_match_inline.groups()
319
- param_desc = f"{param_type} - {param_desc}"
320
- else:
321
- param_name, param_desc = param_match_bullet.groups()
322
-
323
- state.current_param = param_name
324
- state.current_param_lines = [param_desc.strip()]
325
- elif state.current_param and (
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.current_param:
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 wrapped-to-public callables whose namespaces can own annotations."""
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
- return (unwrapped, target)
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, Set
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, Set}:
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.10
3
+ Version: 0.1.12
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
@@ -37,7 +37,7 @@ Dynamic: license-file
37
37
  # python-introspect
38
38
 
39
39
  Extensible analysis of callable signatures, dataclass fields, type hints, and
40
- docstrings.
40
+ docstrings, plus signature-derived callable declaration projection.
41
41
 
42
42
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
43
43
  [![PyPI version](https://badge.fury.io/py/python-introspect.svg)](https://badge.fury.io/py/python-introspect)
@@ -66,6 +66,11 @@ for name, info in parameters.items():
66
66
  dataclass types, and instances. It returns a mapping of names to
67
67
  ``ParameterInfo`` records.
68
68
 
69
+ Use ``callable_declaration_kwargs`` when declaration identity should omit
70
+ keyword arguments equal to their signature defaults. The caller supplies value
71
+ equality so array, lazy, or other framework-specific values keep their owning
72
+ semantics.
73
+
69
74
  ## Extension points
70
75
 
71
76
  Use ``register_namespace_provider`` to contribute names used while resolving
@@ -5,9 +5,11 @@ 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
12
+ src/python_introspect/runtime_parameter.py
11
13
  src/python_introspect/signature_analyzer.py
12
14
  src/python_introspect/unified_parameter_analyzer.py
13
15
  src/python_introspect/validation.py
@@ -22,6 +24,8 @@ tests/test_dataclass_projection.py
22
24
  tests/test_environment_projection.py
23
25
  tests/test_exceptions.py
24
26
  tests/test_init.py
27
+ tests/test_runtime_parameter.py
25
28
  tests/test_signature_analyzer.py
26
29
  tests/test_unified_parameter_analyzer.py
30
+ tests/test_validate_python_release_action.py
27
31
  tests/test_validation.py
@@ -11,42 +11,48 @@ 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.10"
14
+ assert python_introspect.__version__ == "0.1.12"
15
15
 
16
16
  def test_signature_analyzer_import(self):
17
17
  """Test SignatureAnalyzer is importable."""
18
18
  assert hasattr(python_introspect, "SignatureAnalyzer")
19
19
  from python_introspect import SignatureAnalyzer
20
+
20
21
  assert SignatureAnalyzer is not None
21
22
 
22
23
  def test_parameter_info_import(self):
23
24
  """Test ParameterInfo is importable."""
24
25
  assert hasattr(python_introspect, "ParameterInfo")
25
26
  from python_introspect import ParameterInfo
27
+
26
28
  assert ParameterInfo is not None
27
29
 
28
30
  def test_docstring_info_import(self):
29
31
  """Test DocstringInfo is importable."""
30
32
  assert hasattr(python_introspect, "DocstringInfo")
31
33
  from python_introspect import DocstringInfo
34
+
32
35
  assert DocstringInfo is not None
33
36
 
34
37
  def test_docstring_extractor_import(self):
35
38
  """Test DocstringExtractor is importable."""
36
39
  assert hasattr(python_introspect, "DocstringExtractor")
37
40
  from python_introspect import DocstringExtractor
41
+
38
42
  assert DocstringExtractor is not None
39
43
 
40
44
  def test_unified_parameter_analyzer_import(self):
41
45
  """Test UnifiedParameterAnalyzer is importable."""
42
46
  assert hasattr(python_introspect, "UnifiedParameterAnalyzer")
43
47
  from python_introspect import UnifiedParameterAnalyzer
48
+
44
49
  assert UnifiedParameterAnalyzer is not None
45
50
 
46
51
  def test_unified_parameter_info_import(self):
47
52
  """Test UnifiedParameterInfo is importable."""
48
53
  assert hasattr(python_introspect, "UnifiedParameterInfo")
49
54
  from python_introspect import UnifiedParameterInfo
55
+
50
56
  assert UnifiedParameterInfo is not None
51
57
 
52
58
  def test_exceptions_import(self):
@@ -57,6 +63,7 @@ class TestPackageImports:
57
63
  DocstringParsingError,
58
64
  TypeResolutionError,
59
65
  )
66
+
60
67
  assert IntrospectionError is not None
61
68
  assert SignatureAnalysisError is not None
62
69
  assert DocstringParsingError is not None
@@ -98,6 +105,7 @@ class TestPublicAPI:
98
105
  """Test that 'from python_introspect import *' works."""
99
106
  # This is a sanity check that __all__ is properly defined
100
107
  import python_introspect
108
+
101
109
  all_names = python_introspect.__all__
102
110
 
103
111
  for name in all_names:
@@ -0,0 +1,58 @@
1
+ """Tests for nominal runtime-parameter declarations."""
2
+
3
+ import inspect
4
+
5
+ import pytest
6
+
7
+ from python_introspect import RuntimeParameterDeclarationABC
8
+
9
+
10
+ class ExampleRuntimeParameter(RuntimeParameterDeclarationABC):
11
+ @classmethod
12
+ def require_parameter_name(cls) -> str:
13
+ return "runtime_value"
14
+
15
+ @classmethod
16
+ def parameter(cls) -> inspect.Parameter:
17
+ return inspect.Parameter(
18
+ cls.require_parameter_name(),
19
+ inspect.Parameter.KEYWORD_ONLY,
20
+ annotation=int,
21
+ )
22
+
23
+
24
+ class StructuralImpostor:
25
+ @classmethod
26
+ def require_parameter_name(cls) -> str:
27
+ return "runtime_value"
28
+
29
+ @classmethod
30
+ def parameter(cls) -> inspect.Parameter:
31
+ return inspect.Parameter("runtime_value", inspect.Parameter.KEYWORD_ONLY)
32
+
33
+
34
+ class MismatchedRuntimeParameter(ExampleRuntimeParameter):
35
+ @classmethod
36
+ def parameter(cls) -> inspect.Parameter:
37
+ return inspect.Parameter("different_name", inspect.Parameter.KEYWORD_ONLY)
38
+
39
+
40
+ def test_runtime_parameter_requires_nominal_subclass() -> None:
41
+ with pytest.raises(TypeError, match="RuntimeParameterDeclarationABC subclasses"):
42
+ RuntimeParameterDeclarationABC.require_declaration_type(
43
+ StructuralImpostor,
44
+ boundary="test declarations",
45
+ )
46
+
47
+
48
+ def test_runtime_parameter_requires_matching_declared_name() -> None:
49
+ with pytest.raises(TypeError, match="does not match require_parameter_name"):
50
+ MismatchedRuntimeParameter.validated_parameter()
51
+
52
+
53
+ def test_runtime_parameter_collection_requires_unique_names() -> None:
54
+ with pytest.raises(ValueError, match="duplicate runtime parameter 'runtime_value'"):
55
+ RuntimeParameterDeclarationABC.require_declaration_types(
56
+ (ExampleRuntimeParameter, ExampleRuntimeParameter),
57
+ boundary="test declarations",
58
+ )
@@ -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"