python-introspect 0.1.13__tar.gz → 0.1.16__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 (35) hide show
  1. {python_introspect-0.1.13/src/python_introspect.egg-info → python_introspect-0.1.16}/PKG-INFO +1 -1
  2. {python_introspect-0.1.13 → python_introspect-0.1.16}/pyproject.toml +1 -1
  3. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/__init__.py +1 -1
  4. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py +50 -24
  5. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/signature_analyzer.py +55 -39
  6. {python_introspect-0.1.13 → python_introspect-0.1.16/src/python_introspect.egg-info}/PKG-INFO +1 -1
  7. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/SOURCES.txt +1 -0
  8. python_introspect-0.1.16/tests/test_dataclass_projection.py +256 -0
  9. python_introspect-0.1.16/tests/test_dataclass_source_preparation.py +214 -0
  10. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_init.py +2 -1
  11. python_introspect-0.1.13/tests/test_dataclass_projection.py +0 -126
  12. {python_introspect-0.1.13 → python_introspect-0.1.16}/LICENSE +0 -0
  13. {python_introspect-0.1.13 → python_introspect-0.1.16}/README.md +0 -0
  14. {python_introspect-0.1.13 → python_introspect-0.1.16}/setup.cfg +0 -0
  15. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py +0 -0
  16. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py +0 -0
  17. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/docstring_annotations.py +0 -0
  18. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/enableable.py +0 -0
  19. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/environment_projection.py +0 -0
  20. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/exceptions.py +0 -0
  21. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/runtime_parameter.py +0 -0
  22. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/unified_parameter_analyzer.py +0 -0
  23. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/validation.py +0 -0
  24. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  25. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt +0 -0
  26. {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/top_level.txt +0 -0
  27. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_annotation_types.py +0 -0
  28. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_callable_declaration.py +0 -0
  29. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_environment_projection.py +0 -0
  30. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_exceptions.py +0 -0
  31. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_runtime_parameter.py +0 -0
  32. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_signature_analyzer.py +0 -0
  33. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py +0 -0
  34. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py +0 -0
  35. {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.13
3
+ Version: 0.1.16
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.13"
7
+ version = "0.1.16"
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.12"
12
+ __version__ = "0.1.16"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Mapping
5
+ from collections.abc import Mapping, Sequence
6
6
  from dataclasses import MISSING, fields, is_dataclass
7
7
  from enum import Enum
8
8
  from pathlib import Path
@@ -19,7 +19,6 @@ from typing import (
19
19
  from .annotation_types import is_union_type
20
20
  from .validation import validate_annotated_dataclass, validate_annotation_value
21
21
 
22
-
23
22
  DataclassT = TypeVar("DataclassT")
24
23
 
25
24
 
@@ -27,12 +26,15 @@ def dataclass_from_mapping(
27
26
  target_type: type[DataclassT],
28
27
  values: Mapping[str, object],
29
28
  ) -> DataclassT:
30
- """Construct one dataclass from the fields declared by its class."""
29
+ """Construct declared init fields and verify supplied constructor-owned fields.
30
+
31
+ Non-init fields belong to the dataclass's defaults or post-init behavior,
32
+ not the input mapping. If supplied, they must agree with that owner.
33
+ """
31
34
 
32
35
  if not isinstance(target_type, type) or not is_dataclass(target_type):
33
36
  raise TypeError(
34
- "dataclass_from_mapping requires a dataclass type; "
35
- f"got {target_type!r}."
37
+ "dataclass_from_mapping requires a dataclass type; " f"got {target_type!r}."
36
38
  )
37
39
  if not isinstance(values, Mapping):
38
40
  raise TypeError("dataclass_from_mapping requires a mapping.")
@@ -43,9 +45,7 @@ def dataclass_from_mapping(
43
45
  f"got {non_text_keys!r}."
44
46
  )
45
47
 
46
- declared_fields = tuple(
47
- declared_field for declared_field in fields(target_type) if declared_field.init
48
- )
48
+ declared_fields = fields(target_type)
49
49
  declared_names = {declared_field.name for declared_field in declared_fields}
50
50
  extras = tuple(sorted(set(values) - declared_names))
51
51
  if extras:
@@ -54,15 +54,19 @@ def dataclass_from_mapping(
54
54
  )
55
55
 
56
56
  annotations = get_type_hints(target_type, include_extras=True)
57
- constructor_values: dict[str, object] = {}
57
+ decoded_values: dict[str, object] = {}
58
58
  missing: list[str] = []
59
59
  for declared_field in declared_fields:
60
60
  if declared_field.name not in values:
61
- if declared_field.default is MISSING and declared_field.default_factory is MISSING:
61
+ if (
62
+ declared_field.init
63
+ and declared_field.default is MISSING
64
+ and declared_field.default_factory is MISSING
65
+ ):
62
66
  missing.append(declared_field.name)
63
67
  continue
64
68
  annotation = annotations.get(declared_field.name, declared_field.type)
65
- constructor_values[declared_field.name] = _mapping_value_for_annotation(
69
+ decoded_values[declared_field.name] = _mapping_value_for_annotation(
66
70
  annotation,
67
71
  values[declared_field.name],
68
72
  path=f"{target_type.__name__}.{declared_field.name}",
@@ -72,8 +76,23 @@ def dataclass_from_mapping(
72
76
  f"{target_type.__name__} is missing required field(s): {', '.join(missing)}."
73
77
  )
74
78
 
75
- result = target_type(**constructor_values)
79
+ result = target_type(
80
+ **{
81
+ declared_field.name: decoded_values[declared_field.name]
82
+ for declared_field in declared_fields
83
+ if declared_field.init and declared_field.name in decoded_values
84
+ }
85
+ )
76
86
  validate_annotated_dataclass(result)
87
+ for declared_field in declared_fields:
88
+ if not declared_field.init and declared_field.name in decoded_values:
89
+ if decoded_values[declared_field.name] != object.__getattribute__(
90
+ result, declared_field.name
91
+ ):
92
+ raise ValueError(
93
+ f"{target_type.__name__}.{declared_field.name} disagrees with "
94
+ "its constructed value."
95
+ )
77
96
  return result
78
97
 
79
98
 
@@ -154,9 +173,7 @@ def _mapping_value_for_annotation(
154
173
  if len(successes) == 1:
155
174
  return successes[0]
156
175
  exact_matches = tuple(
157
- converted
158
- for converted in successes
159
- if type(converted) is type(value)
176
+ converted for converted in successes if type(converted) is type(value)
160
177
  )
161
178
  if len(exact_matches) == 1:
162
179
  return exact_matches[0]
@@ -164,9 +181,7 @@ def _mapping_value_for_annotation(
164
181
  raise TypeError(
165
182
  f"{path} ambiguously matches multiple members of {annotation!r}."
166
183
  )
167
- value_errors = tuple(
168
- error for error in errors if isinstance(error, ValueError)
169
- )
184
+ value_errors = tuple(error for error in errors if isinstance(error, ValueError))
170
185
  if len(value_errors) == 1:
171
186
  raise value_errors[0]
172
187
  if value_errors:
@@ -197,7 +212,9 @@ def _mapping_value_for_annotation(
197
212
  for index, item in enumerate(value)
198
213
  )
199
214
  if member_types and len(value) != len(member_types):
200
- raise ValueError(f"{path} must contain {len(member_types)} item(s); got {len(value)}.")
215
+ raise ValueError(
216
+ f"{path} must contain {len(member_types)} item(s); got {len(value)}."
217
+ )
201
218
  return tuple(
202
219
  _mapping_value_for_annotation(
203
220
  member_type,
@@ -206,13 +223,19 @@ def _mapping_value_for_annotation(
206
223
  )
207
224
  for index, (member_type, item) in enumerate(zip(member_types, value))
208
225
  )
209
- if origin is list:
210
- if not isinstance(value, list):
226
+ if origin in {list, Sequence}:
227
+ if (origin is list and not isinstance(value, list)) or (
228
+ origin is Sequence
229
+ and (
230
+ not isinstance(value, Sequence)
231
+ or isinstance(value, (str, bytes, bytearray))
232
+ )
233
+ ):
211
234
  raise TypeError(f"{path} must be an array.")
212
235
  member_types = get_args(annotation)
213
236
  if not member_types:
214
- return list(value)
215
- return [
237
+ return list(value) if origin is list else tuple(value)
238
+ converted = [
216
239
  _mapping_value_for_annotation(
217
240
  member_types[0],
218
241
  item,
@@ -220,6 +243,7 @@ def _mapping_value_for_annotation(
220
243
  )
221
244
  for index, item in enumerate(value)
222
245
  ]
246
+ return converted if origin is list else tuple(converted)
223
247
  if origin in {dict, Mapping}:
224
248
  if not isinstance(value, Mapping):
225
249
  raise TypeError(f"{path} must be an object.")
@@ -250,7 +274,9 @@ def _mapping_value_for_annotation(
250
274
  return annotation(value)
251
275
  except ValueError as error:
252
276
  choices = tuple(member.value for member in annotation)
253
- raise ValueError(f"{path} must be one of {choices!r}; got {value!r}.") from error
277
+ raise ValueError(
278
+ f"{path} must be one of {choices!r}; got {value!r}."
279
+ ) from error
254
280
  if isinstance(annotation, type) and is_dataclass(annotation):
255
281
  if isinstance(value, annotation):
256
282
  return value
@@ -9,6 +9,9 @@ resolvers to extend functionality without modifying this code.
9
9
 
10
10
  import ast
11
11
  import inspect
12
+ import sys
13
+ import linecache
14
+ from functools import lru_cache
12
15
  import dataclasses
13
16
  import re
14
17
  from abc import ABC, abstractmethod
@@ -434,12 +437,7 @@ class DocstringExtractor:
434
437
  if not docstring:
435
438
  return DocstringInfo(parameters={})
436
439
 
437
- # Try AST-based parsing first for better accuracy
438
- try:
439
- return DocstringExtractor._parse_docstring_ast(actual_target, docstring)
440
- except Exception:
441
- # Fall back to regex-based parsing
442
- return DocstringExtractor._parse_docstring(docstring)
440
+ return DocstringExtractor._parse_docstring(docstring)
443
441
 
444
442
  @staticmethod
445
443
  def _resolve_lazy_target(target: Union[Callable, type]) -> Union[Callable, type]:
@@ -448,38 +446,6 @@ class DocstringExtractor:
448
446
  return target
449
447
  return _resolve_type(target)
450
448
 
451
- @staticmethod
452
- def _parse_docstring_ast(target: Union[Callable, type], docstring: str) -> DocstringInfo:
453
- """Parse docstring using AST for more accurate extraction.
454
-
455
- This method uses AST to parse the source code and extract docstring
456
- information more accurately, especially for complex multiline descriptions.
457
- """
458
- try:
459
- # Get source code
460
- source = inspect.getsource(target)
461
- tree = ast.parse(source)
462
-
463
- # Find the function/class node
464
- for node in ast.walk(tree):
465
- if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
466
- if ast.get_docstring(node) == docstring:
467
- return DocstringExtractor._parse_ast_docstring(node, docstring)
468
-
469
- # Fallback to regex parsing if AST parsing fails
470
- return DocstringExtractor._parse_docstring(docstring)
471
-
472
- except Exception:
473
- # Fallback to regex parsing
474
- return DocstringExtractor._parse_docstring(docstring)
475
-
476
- @staticmethod
477
- def _parse_ast_docstring(node: Union[ast.FunctionDef, ast.ClassDef], docstring: str) -> DocstringInfo:
478
- """Parse docstring from AST node with enhanced multiline support."""
479
- # For now, use the improved regex parser
480
- # This can be extended later with more sophisticated AST-based parsing
481
- return DocstringExtractor._parse_docstring(docstring)
482
-
483
449
  @staticmethod
484
450
  def _parse_docstring(docstring: str) -> DocstringInfo:
485
451
  """Parse a docstring into structured components with improved multiline support.
@@ -1006,6 +972,56 @@ class SignatureAnalyzer:
1006
972
  # Return empty dict on error (don't cache errors)
1007
973
  return {}
1008
974
 
975
+ @staticmethod
976
+ def prepare_dataclass_declaration(dataclass_type: type) -> None:
977
+ """Warm pure source lookup without evaluating fields or default factories."""
978
+ SignatureAnalyzer._extract_inline_field_docs(dataclass_type)
979
+
980
+ @staticmethod
981
+ def _dataclass_source(dataclass_type: type) -> str:
982
+ """Read current loader source, deriving its class block by immutable content."""
983
+ # Python 3.13+ locates class source from __firstlineno__ directly;
984
+ # its native locator does not perform the repeated module AST traversal.
985
+ if sys.version_info >= (3, 13):
986
+ return inspect.getsource(dataclass_type)
987
+ target = inspect.unwrap(dataclass_type)
988
+ if not inspect.isclass(target):
989
+ return inspect.getsource(target)
990
+ file = inspect.getsourcefile(target)
991
+ if file:
992
+ linecache.checkcache(file)
993
+ else:
994
+ file = inspect.getfile(target)
995
+ if not (file.startswith('<') and file.endswith('>')):
996
+ raise OSError('source code not available')
997
+ module = inspect.getmodule(target, file)
998
+ lines = linecache.getlines(file, module.__dict__) if module else linecache.getlines(file)
999
+ if not lines:
1000
+ raise OSError('could not get source code')
1001
+ source = SignatureAnalyzer._qualified_class_source(''.join(lines), target.__qualname__)
1002
+ if source is None:
1003
+ raise OSError('could not find class definition')
1004
+ return source
1005
+
1006
+ @staticmethod
1007
+ @lru_cache(maxsize=32)
1008
+ def _module_source_tree(source: str) -> ast.AST:
1009
+ """Parse current immutable module bytes once; never expose the retained AST."""
1010
+ return ast.parse(source)
1011
+
1012
+ @staticmethod
1013
+ @lru_cache(maxsize=256)
1014
+ def _qualified_class_source(source: str, qualname: str) -> Optional[str]:
1015
+ """Use Python's qualified class/decorator locator on current source content."""
1016
+ tree = SignatureAnalyzer._module_source_tree(source)
1017
+ finder = inspect._ClassFinder(qualname)
1018
+ try:
1019
+ finder.visit(tree)
1020
+ except inspect.ClassFoundException as found:
1021
+ lines = source.splitlines(keepends=True)
1022
+ return ''.join(inspect.getblock(lines[found.args[0]:]))
1023
+ return None
1024
+
1009
1025
  @staticmethod
1010
1026
  def _extract_inline_field_docs(dataclass_type: type) -> Dict[str, str]:
1011
1027
  """Extract inline field documentation strings using AST parsing.
@@ -1039,7 +1055,7 @@ class SignatureAnalyzer:
1039
1055
  # Try to get source code - handle cases where it might not be available
1040
1056
  source = None
1041
1057
  try:
1042
- source = inspect.getsource(dataclass_type)
1058
+ source = SignatureAnalyzer._dataclass_source(dataclass_type)
1043
1059
  except (OSError, TypeError):
1044
1060
  try:
1045
1061
  source_file = inspect.getfile(dataclass_type)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.13
3
+ Version: 0.1.16
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
@@ -21,6 +21,7 @@ src/python_introspect.egg-info/top_level.txt
21
21
  tests/test_annotation_types.py
22
22
  tests/test_callable_declaration.py
23
23
  tests/test_dataclass_projection.py
24
+ tests/test_dataclass_source_preparation.py
24
25
  tests/test_environment_projection.py
25
26
  tests/test_exceptions.py
26
27
  tests/test_init.py
@@ -0,0 +1,256 @@
1
+ from dataclasses import asdict, dataclass, field
2
+ from enum import Enum
3
+ from collections.abc import Sequence
4
+
5
+ import pytest
6
+
7
+ from python_introspect import dataclass_from_mapping, project_dataclass
8
+
9
+
10
+ class Mode(Enum):
11
+ IPC = "ipc"
12
+ TCP = "tcp"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Connection:
17
+ host: str
18
+ port: int
19
+ mode: Mode | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Envelope:
24
+ connection: Connection
25
+ started_at: float
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StoredEnvelope:
30
+ connection: Connection
31
+ started_at: float
32
+ path: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class UntypedTupleEnvelope:
37
+ values: tuple
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RecursiveSequenceNode:
42
+ name: str
43
+ children: Sequence["RecursiveSequenceNode"] = ()
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SequenceEnvelope:
48
+ nodes: Sequence[RecursiveSequenceNode]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ConnectionWithSummary(Connection):
53
+ summary: tuple[str, int] = field(init=False)
54
+ effective_mode: Mode = field(init=False)
55
+
56
+ def __post_init__(self) -> None:
57
+ object.__setattr__(self, "summary", (self.host, self.port))
58
+ object.__setattr__(self, "effective_mode", self.mode or Mode.IPC)
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class DerivedDefaults:
63
+ count: int
64
+ label: str = field(init=False, default="counter")
65
+ connections: list[Connection] = field(init=False, default_factory=list)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DerivedEnvelope:
70
+ connection: ConnectionWithSummary
71
+
72
+
73
+ def test_dataclass_mapping_verifies_inherited_post_init_fields() -> None:
74
+ original = ConnectionWithSummary("localhost", 7888, Mode.TCP)
75
+ values = asdict(original)
76
+ values.update(mode="tcp", effective_mode="tcp", summary=["localhost", 7888])
77
+ assert dataclass_from_mapping(ConnectionWithSummary, values) == original
78
+ assert dataclass_from_mapping(
79
+ ConnectionWithSummary, {"host": "localhost", "port": 7888}
80
+ ) == ConnectionWithSummary("localhost", 7888)
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "overrides,error_type,match",
85
+ (
86
+ ({"summary": ["elsewhere", 7888]}, ValueError, "summary.*disagrees"),
87
+ ({"effective_mode": "tcp"}, ValueError, "effective_mode.*disagrees"),
88
+ ({"summary": ["localhost", True]}, TypeError, "must be int"),
89
+ ({"effective_mode": "invalid"}, ValueError, "must be one of"),
90
+ ({"extra": 1}, ValueError, "undeclared.*extra"),
91
+ ),
92
+ )
93
+ def test_dataclass_mapping_cannot_override_derived_owner(overrides, error_type, match):
94
+ values = {"host": "localhost", "port": 7888, **overrides}
95
+ with pytest.raises(error_type, match=match):
96
+ dataclass_from_mapping(ConnectionWithSummary, values)
97
+
98
+
99
+ def test_dataclass_mapping_verifies_non_init_defaults_and_nested_derived_fields() -> None:
100
+ original = DerivedDefaults(3)
101
+ assert dataclass_from_mapping(DerivedDefaults, asdict(original)) == original
102
+ assert dataclass_from_mapping(DerivedDefaults, {"count": 3}) == original
103
+ with pytest.raises(ValueError, match="connections.*disagrees"):
104
+ dataclass_from_mapping(
105
+ DerivedDefaults,
106
+ {"count": 3, "connections": [{"host": "localhost", "port": 7888}]},
107
+ )
108
+ values = {
109
+ "connection": {
110
+ "host": "localhost", "port": 7888,
111
+ "summary": ["localhost", 7888], "effective_mode": "ipc",
112
+ }
113
+ }
114
+ assert dataclass_from_mapping(DerivedEnvelope, values) == DerivedEnvelope(
115
+ ConnectionWithSummary("localhost", 7888)
116
+ )
117
+ values["connection"]["effective_mode"] = "tcp"
118
+ with pytest.raises(ValueError, match="effective_mode.*disagrees"):
119
+ dataclass_from_mapping(DerivedEnvelope, values)
120
+
121
+
122
+ def test_dataclass_mapping_derived_defaults_do_not_supply_missing_init_fields() -> None:
123
+ with pytest.raises(ValueError, match="missing required field.*count"):
124
+ dataclass_from_mapping(DerivedDefaults, {"label": "counter"})
125
+
126
+
127
+ @pytest.mark.parametrize("container", (list, tuple))
128
+ def test_dataclass_mapping_recurses_from_abstract_sequence_annotations(container):
129
+ result = dataclass_from_mapping(
130
+ SequenceEnvelope,
131
+ {
132
+ "nodes": container([{"name": "parent", "children": [{"name": "child"}]}]),
133
+ },
134
+ )
135
+ assert result == SequenceEnvelope(
136
+ (RecursiveSequenceNode("parent", (RecursiveSequenceNode("child"),)),)
137
+ )
138
+
139
+
140
+ @pytest.mark.parametrize(
141
+ "value", ("text", b"bytes", bytearray(b"bytes"), {"name": "mapping"}, 7)
142
+ )
143
+ def test_dataclass_mapping_sequence_rejects_non_array_values(value):
144
+ with pytest.raises(TypeError, match="must be an array"):
145
+ dataclass_from_mapping(SequenceEnvelope, {"nodes": value})
146
+
147
+
148
+ def test_dataclass_mapping_recursive_sequence_rejects_unknown_fields_and_wrong_elements():
149
+ with pytest.raises(ValueError, match="undeclared.*extra"):
150
+ dataclass_from_mapping(
151
+ SequenceEnvelope,
152
+ {
153
+ "nodes": [
154
+ {"name": "parent", "children": [{"name": "child", "extra": 1}]}
155
+ ]
156
+ },
157
+ )
158
+ with pytest.raises(TypeError, match="must be an object"):
159
+ dataclass_from_mapping(SequenceEnvelope, {"nodes": [42]})
160
+
161
+
162
+ def test_dataclass_mapping_abstract_sequence_preserves_typed_members():
163
+ node = RecursiveSequenceNode("typed")
164
+ assert dataclass_from_mapping(
165
+ SequenceEnvelope, {"nodes": [node]}
166
+ ) == SequenceEnvelope((node,))
167
+
168
+
169
+ def test_dataclass_mapping_uses_nested_types_as_the_schema() -> None:
170
+ result = dataclass_from_mapping(
171
+ Envelope,
172
+ {
173
+ "connection": {
174
+ "host": "localhost",
175
+ "port": 7888,
176
+ "mode": "ipc",
177
+ },
178
+ "started_at": 1,
179
+ },
180
+ )
181
+
182
+ assert result == Envelope(
183
+ connection=Connection(
184
+ host="localhost",
185
+ port=7888,
186
+ mode=Mode.IPC,
187
+ ),
188
+ started_at=1.0,
189
+ )
190
+
191
+
192
+ def test_dataclass_mapping_rejects_missing_and_undeclared_fields() -> None:
193
+ with pytest.raises(ValueError, match="missing required field"):
194
+ dataclass_from_mapping(Envelope, {"started_at": 1.0})
195
+
196
+ with pytest.raises(ValueError, match="undeclared field"):
197
+ dataclass_from_mapping(
198
+ Connection,
199
+ {"host": "localhost", "port": 7888, "extra": True},
200
+ )
201
+
202
+ with pytest.raises(ValueError, match="must be one of.*ipc.*tcp"):
203
+ dataclass_from_mapping(
204
+ Connection,
205
+ {"host": "localhost", "port": 7888, "mode": "invalid"},
206
+ )
207
+
208
+ with pytest.raises(TypeError, match="field names must be strings"):
209
+ dataclass_from_mapping(Connection, {1: "localhost"})
210
+
211
+
212
+ def test_dataclass_mapping_preserves_bare_tuple_values() -> None:
213
+ result = dataclass_from_mapping(UntypedTupleEnvelope, {"values": [1, "two"]})
214
+
215
+ assert result.values == (1, "two")
216
+
217
+
218
+ def test_dataclass_projection_derives_shared_fields_from_both_declarations() -> None:
219
+ source = Envelope(
220
+ connection=Connection("localhost", 7888, Mode.TCP),
221
+ started_at=2.0,
222
+ )
223
+
224
+ result = project_dataclass(StoredEnvelope, source, path="/tmp/bridge.json")
225
+
226
+ assert result == StoredEnvelope(
227
+ connection=source.connection,
228
+ started_at=2.0,
229
+ path="/tmp/bridge.json",
230
+ )
231
+
232
+
233
+ def test_dataclass_mapping_union_projection_does_not_use_member_order() -> None:
234
+ @dataclass(frozen=True)
235
+ class NumericValue:
236
+ value: float | int
237
+
238
+ assert dataclass_from_mapping(NumericValue, {"value": 1}).value == 1
239
+ assert type(dataclass_from_mapping(NumericValue, {"value": 1}).value) is int
240
+
241
+
242
+ def test_dataclass_mapping_rejects_ambiguous_structured_unions() -> None:
243
+ @dataclass(frozen=True)
244
+ class Left:
245
+ value: int
246
+
247
+ @dataclass(frozen=True)
248
+ class Right:
249
+ value: int
250
+
251
+ @dataclass(frozen=True)
252
+ class Envelope:
253
+ payload: Left | Right
254
+
255
+ with pytest.raises(TypeError, match="ambiguously matches multiple members"):
256
+ dataclass_from_mapping(Envelope, {"payload": {"value": 1}})
@@ -0,0 +1,214 @@
1
+ """Source preparation must preserve live declarations and first-use values."""
2
+ import dataclasses
3
+ import importlib.util
4
+ import inspect
5
+ import linecache
6
+ import os
7
+ import sys
8
+ import types
9
+ from typing import Annotated
10
+
11
+ import pytest
12
+ from python_introspect import SignatureAnalyzer
13
+
14
+
15
+ def _load_module(monkeypatch, name, path):
16
+ spec = importlib.util.spec_from_file_location(name, path)
17
+ module = importlib.util.module_from_spec(spec)
18
+ monkeypatch.setitem(sys.modules, name, module)
19
+ spec.loader.exec_module(module)
20
+ return module
21
+
22
+
23
+ def test_preparation_does_not_run_factories_or_snapshot_annotations():
24
+ calls = []
25
+ current = {"value": 1}
26
+
27
+ def first():
28
+ calls.append("first")
29
+ return current["value"]
30
+
31
+ def second():
32
+ calls.append("second")
33
+ return []
34
+
35
+ @dataclasses.dataclass
36
+ class Declaration:
37
+ value: int = dataclasses.field(default_factory=first)
38
+ values: list = dataclasses.field(default_factory=second)
39
+
40
+ SignatureAnalyzer.prepare_dataclass_declaration(Declaration)
41
+ assert calls == []
42
+ current["value"] = 4
43
+ Declaration.__annotations__["value"] = str
44
+ result = SignatureAnalyzer._analyze_dataclass(Declaration)
45
+ assert calls == ["first", "second"]
46
+ assert result["value"].default_value == 4
47
+ assert result["value"].param_type is str
48
+ assert SignatureAnalyzer._analyze_dataclass(Declaration) is result
49
+ assert calls == ["first", "second"]
50
+
51
+
52
+ def test_throwing_factory_stays_uncached_and_retryable():
53
+ calls = []
54
+
55
+ def throwing():
56
+ calls.append(1)
57
+ raise RuntimeError("first-use factory")
58
+
59
+ @dataclasses.dataclass
60
+ class Declaration:
61
+ value: int = dataclasses.field(default_factory=throwing)
62
+
63
+ SignatureAnalyzer.prepare_dataclass_declaration(Declaration)
64
+ assert calls == []
65
+ assert SignatureAnalyzer._analyze_dataclass(Declaration) == {}
66
+ assert Declaration not in SignatureAnalyzer._dataclass_analysis_cache
67
+ assert SignatureAnalyzer._analyze_dataclass(Declaration) == {}
68
+ assert calls == [1, 1]
69
+
70
+
71
+ def test_factory_effects_precede_metadata_and_inherited_documentation():
72
+ metadata = {"description": "before"}
73
+
74
+ def factory():
75
+ metadata["description"] = "after"
76
+ Parent.__doc__ = "Args:\n inherited: after factory"
77
+ return 3
78
+
79
+ @dataclasses.dataclass
80
+ class Parent:
81
+ """Args:
82
+ inherited: before factory
83
+ """
84
+ owned: int = dataclasses.field(default_factory=factory, metadata=metadata)
85
+ inherited: int = 1
86
+
87
+ @dataclasses.dataclass
88
+ class Child(Parent):
89
+ pass
90
+
91
+ SignatureAnalyzer.prepare_dataclass_declaration(Parent)
92
+ SignatureAnalyzer.prepare_dataclass_declaration(Child)
93
+ result = SignatureAnalyzer._analyze_dataclass(Child)
94
+ assert result["owned"].description == "after"
95
+ assert result["inherited"].description == "after factory"
96
+
97
+
98
+ def test_optional_and_required_field_semantics_survive_preparation():
99
+ marker = object()
100
+
101
+ @dataclasses.dataclass
102
+ class Declaration:
103
+ required: Annotated[int, marker]
104
+ inherited: int | None = None
105
+
106
+ SignatureAnalyzer.prepare_dataclass_declaration(Declaration)
107
+ result = SignatureAnalyzer._analyze_dataclass(Declaration)
108
+ assert result["required"].is_required
109
+ assert result["required"].param_type == Annotated[int, marker]
110
+ assert result["inherited"].default_value is None
111
+ assert not result["inherited"].is_required
112
+
113
+
114
+ def test_source_lookup_uses_qualified_nested_and_decorated_classes(tmp_path, monkeypatch):
115
+ path = tmp_path / "declarations.py"
116
+ path.write_text('''from dataclasses import dataclass
117
+
118
+ def identity(target):
119
+ return target
120
+
121
+ @identity
122
+ @dataclass
123
+ class Outer:
124
+ value: int = 1
125
+ "Outer documentation"
126
+ @dataclass
127
+ class Inner:
128
+ value: int = 2
129
+ "Inner documentation"
130
+
131
+ def factory():
132
+ @dataclass
133
+ class Inner:
134
+ value: int = 3
135
+ "Local documentation"
136
+ return Inner
137
+ ''')
138
+ module = _load_module(monkeypatch, "qualified_source_preparation", path)
139
+ for declaration in (module.Outer, module.Outer.Inner, module.factory()):
140
+ SignatureAnalyzer.prepare_dataclass_declaration(declaration)
141
+ assert SignatureAnalyzer._dataclass_source(declaration) == inspect.getsource(declaration)
142
+ # The original inline extractor does not dedent nested blocks before parsing.
143
+ # Source preparation preserves that behavior rather than repairing it here.
144
+ assert SignatureAnalyzer._extract_inline_field_docs(module.Outer.Inner) == {}
145
+ assert SignatureAnalyzer._extract_inline_field_docs(module.factory()) == {}
146
+
147
+
148
+ def test_warmed_lookup_observes_changed_file_content_and_qualname(tmp_path, monkeypatch):
149
+ path = tmp_path / "declarations.py"
150
+ source = '''from dataclasses import dataclass
151
+ @dataclass
152
+ class First:
153
+ value: int = 1
154
+ "First documentation"
155
+ @dataclass
156
+ class Second:
157
+ value: int = 2
158
+ "Second documentation"
159
+ '''
160
+ path.write_text(source)
161
+ module = _load_module(monkeypatch, "live_source_preparation", path)
162
+ SignatureAnalyzer.prepare_dataclass_declaration(module.First)
163
+ module.First.__qualname__ = "Second"
164
+ assert SignatureAnalyzer._dataclass_source(module.First) == inspect.getsource(module.First)
165
+ module.First.__qualname__ = "First"
166
+ path.write_text(source.replace("First documentation", "Changed documentation"))
167
+ os.utime(path, (path.stat().st_atime, path.stat().st_mtime + 2))
168
+ assert SignatureAnalyzer._extract_inline_field_docs(module.First) == {"value": "Changed documentation"}
169
+
170
+
171
+ def test_loader_source_is_read_at_original_observation_point(tmp_path, monkeypatch):
172
+ class Loader:
173
+ source = 'from dataclasses import dataclass\n@dataclass\nclass Loaded:\n value:int=1\n "Original documentation"\n'
174
+
175
+ def get_source(self, name):
176
+ return self.source
177
+
178
+ loader = Loader()
179
+ module = types.ModuleType("loader_source_preparation")
180
+ module.__file__ = str(tmp_path / "not_on_disk.py")
181
+ module.__loader__ = loader
182
+ monkeypatch.setitem(sys.modules, module.__name__, module)
183
+ exec(compile(loader.source, module.__file__, "exec"), module.__dict__)
184
+ SignatureAnalyzer.prepare_dataclass_declaration(module.Loaded)
185
+ loader.source = loader.source.replace("Original documentation", "Current documentation")
186
+ linecache.cache.pop(module.__file__, None)
187
+ assert SignatureAnalyzer._dataclass_source(module.Loaded) == inspect.getsource(module.Loaded)
188
+ assert SignatureAnalyzer._extract_inline_field_docs(module.Loaded) == {"value": "Current documentation"}
189
+
190
+
191
+ def test_generated_type_keeps_original_fallback_without_inventing_source(tmp_path, monkeypatch):
192
+ path = tmp_path / "declarations.py"
193
+ path.write_text('''from dataclasses import dataclass
194
+ @dataclass
195
+ class Public:
196
+ value: int = 1
197
+ "Public documentation"
198
+ ''')
199
+ module = _load_module(monkeypatch, "generated_source_preparation", path)
200
+ generated = dataclasses.make_dataclass("Public", [("value", int, 1)])
201
+ generated.__module__ = module.__name__
202
+ generated.__qualname__ = "GeneratedPublic"
203
+ SignatureAnalyzer.prepare_dataclass_declaration(generated)
204
+ assert SignatureAnalyzer._extract_inline_field_docs(generated) == {"value": "Public documentation"}
205
+
206
+
207
+ def test_missing_source_keeps_original_error():
208
+ with pytest.raises(TypeError) as original:
209
+ inspect.getsource(int)
210
+ with pytest.raises(TypeError) as prepared:
211
+ SignatureAnalyzer._dataclass_source(int)
212
+ assert str(prepared.value) == str(original.value)
213
+ SignatureAnalyzer.prepare_dataclass_declaration(int)
214
+ assert SignatureAnalyzer._extract_inline_field_docs(int) == {}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import pytest
4
4
  import python_introspect
5
+ from packaging.version import Version
5
6
 
6
7
 
7
8
  class TestPackageImports:
@@ -11,7 +12,7 @@ class TestPackageImports:
11
12
  """Test that __version__ is available."""
12
13
  assert hasattr(python_introspect, "__version__")
13
14
  assert isinstance(python_introspect.__version__, str)
14
- assert python_introspect.__version__ == "0.1.12"
15
+ assert str(Version(python_introspect.__version__)) == python_introspect.__version__
15
16
 
16
17
  def test_signature_analyzer_import(self):
17
18
  """Test SignatureAnalyzer is importable."""
@@ -1,126 +0,0 @@
1
- from dataclasses import dataclass
2
- from enum import Enum
3
-
4
- import pytest
5
-
6
- from python_introspect import dataclass_from_mapping, project_dataclass
7
-
8
-
9
- class Mode(Enum):
10
- IPC = "ipc"
11
- TCP = "tcp"
12
-
13
-
14
- @dataclass(frozen=True)
15
- class Connection:
16
- host: str
17
- port: int
18
- mode: Mode | None = None
19
-
20
-
21
- @dataclass(frozen=True)
22
- class Envelope:
23
- connection: Connection
24
- started_at: float
25
-
26
-
27
- @dataclass(frozen=True)
28
- class StoredEnvelope:
29
- connection: Connection
30
- started_at: float
31
- path: str
32
-
33
-
34
- @dataclass(frozen=True)
35
- class UntypedTupleEnvelope:
36
- values: tuple
37
-
38
-
39
- def test_dataclass_mapping_uses_nested_types_as_the_schema() -> None:
40
- result = dataclass_from_mapping(
41
- Envelope,
42
- {
43
- "connection": {
44
- "host": "localhost",
45
- "port": 7888,
46
- "mode": "ipc",
47
- },
48
- "started_at": 1,
49
- },
50
- )
51
-
52
- assert result == Envelope(
53
- connection=Connection(
54
- host="localhost",
55
- port=7888,
56
- mode=Mode.IPC,
57
- ),
58
- started_at=1.0,
59
- )
60
-
61
-
62
- def test_dataclass_mapping_rejects_missing_and_undeclared_fields() -> None:
63
- with pytest.raises(ValueError, match="missing required field"):
64
- dataclass_from_mapping(Envelope, {"started_at": 1.0})
65
-
66
- with pytest.raises(ValueError, match="undeclared field"):
67
- dataclass_from_mapping(
68
- Connection,
69
- {"host": "localhost", "port": 7888, "extra": True},
70
- )
71
-
72
- with pytest.raises(ValueError, match="must be one of.*ipc.*tcp"):
73
- dataclass_from_mapping(
74
- Connection,
75
- {"host": "localhost", "port": 7888, "mode": "invalid"},
76
- )
77
-
78
- with pytest.raises(TypeError, match="field names must be strings"):
79
- dataclass_from_mapping(Connection, {1: "localhost"})
80
-
81
-
82
- def test_dataclass_mapping_preserves_bare_tuple_values() -> None:
83
- result = dataclass_from_mapping(UntypedTupleEnvelope, {"values": [1, "two"]})
84
-
85
- assert result.values == (1, "two")
86
-
87
-
88
- def test_dataclass_projection_derives_shared_fields_from_both_declarations() -> None:
89
- source = Envelope(
90
- connection=Connection("localhost", 7888, Mode.TCP),
91
- started_at=2.0,
92
- )
93
-
94
- result = project_dataclass(StoredEnvelope, source, path="/tmp/bridge.json")
95
-
96
- assert result == StoredEnvelope(
97
- connection=source.connection,
98
- started_at=2.0,
99
- path="/tmp/bridge.json",
100
- )
101
-
102
-
103
- def test_dataclass_mapping_union_projection_does_not_use_member_order() -> None:
104
- @dataclass(frozen=True)
105
- class NumericValue:
106
- value: float | int
107
-
108
- assert dataclass_from_mapping(NumericValue, {"value": 1}).value == 1
109
- assert type(dataclass_from_mapping(NumericValue, {"value": 1}).value) is int
110
-
111
-
112
- def test_dataclass_mapping_rejects_ambiguous_structured_unions() -> None:
113
- @dataclass(frozen=True)
114
- class Left:
115
- value: int
116
-
117
- @dataclass(frozen=True)
118
- class Right:
119
- value: int
120
-
121
- @dataclass(frozen=True)
122
- class Envelope:
123
- payload: Left | Right
124
-
125
- with pytest.raises(TypeError, match="ambiguously matches multiple members"):
126
- dataclass_from_mapping(Envelope, {"payload": {"value": 1}})