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.
Files changed (33) hide show
  1. {python_introspect-0.1.11/src/python_introspect.egg-info → python_introspect-0.1.12}/PKG-INFO +1 -1
  2. {python_introspect-0.1.11 → python_introspect-0.1.12}/pyproject.toml +1 -1
  3. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/__init__.py +1 -1
  4. python_introspect-0.1.12/src/python_introspect/docstring_annotations.py +193 -0
  5. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/signature_analyzer.py +49 -48
  6. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/validation.py +10 -7
  7. {python_introspect-0.1.11 → python_introspect-0.1.12/src/python_introspect.egg-info}/PKG-INFO +1 -1
  8. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/SOURCES.txt +2 -0
  9. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_init.py +1 -1
  10. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_signature_analyzer.py +83 -1
  11. python_introspect-0.1.12/tests/test_validate_python_release_action.py +137 -0
  12. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_validation.py +16 -1
  13. {python_introspect-0.1.11 → python_introspect-0.1.12}/LICENSE +0 -0
  14. {python_introspect-0.1.11 → python_introspect-0.1.12}/README.md +0 -0
  15. {python_introspect-0.1.11 → python_introspect-0.1.12}/setup.cfg +0 -0
  16. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/annotation_types.py +0 -0
  17. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/callable_declaration.py +0 -0
  18. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/dataclass_projection.py +0 -0
  19. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/enableable.py +0 -0
  20. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/environment_projection.py +0 -0
  21. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/exceptions.py +0 -0
  22. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/runtime_parameter.py +0 -0
  23. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect/unified_parameter_analyzer.py +0 -0
  24. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  25. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/requires.txt +0 -0
  26. {python_introspect-0.1.11 → python_introspect-0.1.12}/src/python_introspect.egg-info/top_level.txt +0 -0
  27. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_annotation_types.py +0 -0
  28. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_callable_declaration.py +0 -0
  29. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_dataclass_projection.py +0 -0
  30. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_environment_projection.py +0 -0
  31. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_exceptions.py +0 -0
  32. {python_introspect-0.1.11 → python_introspect-0.1.12}/tests/test_runtime_parameter.py +0 -0
  33. {python_introspect-0.1.11 → 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.11
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-introspect"
7
- version = "0.1.11"
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.11"
12
+ __version__ = "0.1.12"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -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
+ ]
@@ -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.11
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
@@ -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.11"
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"