python-introspect 0.1.14__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 (34) hide show
  1. {python_introspect-0.1.14/src/python_introspect.egg-info → python_introspect-0.1.16}/PKG-INFO +1 -1
  2. {python_introspect-0.1.14 → python_introspect-0.1.16}/pyproject.toml +1 -1
  3. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/__init__.py +1 -1
  4. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py +26 -8
  5. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/signature_analyzer.py +55 -39
  6. {python_introspect-0.1.14 → python_introspect-0.1.16/src/python_introspect.egg-info}/PKG-INFO +1 -1
  7. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/SOURCES.txt +1 -0
  8. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_dataclass_projection.py +77 -1
  9. python_introspect-0.1.16/tests/test_dataclass_source_preparation.py +214 -0
  10. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_init.py +2 -1
  11. {python_introspect-0.1.14 → python_introspect-0.1.16}/LICENSE +0 -0
  12. {python_introspect-0.1.14 → python_introspect-0.1.16}/README.md +0 -0
  13. {python_introspect-0.1.14 → python_introspect-0.1.16}/setup.cfg +0 -0
  14. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py +0 -0
  15. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py +0 -0
  16. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/docstring_annotations.py +0 -0
  17. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/enableable.py +0 -0
  18. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/environment_projection.py +0 -0
  19. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/exceptions.py +0 -0
  20. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/runtime_parameter.py +0 -0
  21. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/unified_parameter_analyzer.py +0 -0
  22. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/validation.py +0 -0
  23. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  24. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt +0 -0
  25. {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/top_level.txt +0 -0
  26. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_annotation_types.py +0 -0
  27. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_callable_declaration.py +0 -0
  28. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_environment_projection.py +0 -0
  29. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_exceptions.py +0 -0
  30. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_runtime_parameter.py +0 -0
  31. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_signature_analyzer.py +0 -0
  32. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py +0 -0
  33. {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py +0 -0
  34. {python_introspect-0.1.14 → 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.14
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.14"
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,
@@ -26,7 +26,11 @@ def dataclass_from_mapping(
26
26
  target_type: type[DataclassT],
27
27
  values: Mapping[str, object],
28
28
  ) -> DataclassT:
29
- """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
+ """
30
34
 
31
35
  if not isinstance(target_type, type) or not is_dataclass(target_type):
32
36
  raise TypeError(
@@ -41,9 +45,7 @@ def dataclass_from_mapping(
41
45
  f"got {non_text_keys!r}."
42
46
  )
43
47
 
44
- declared_fields = tuple(
45
- declared_field for declared_field in fields(target_type) if declared_field.init
46
- )
48
+ declared_fields = fields(target_type)
47
49
  declared_names = {declared_field.name for declared_field in declared_fields}
48
50
  extras = tuple(sorted(set(values) - declared_names))
49
51
  if extras:
@@ -52,18 +54,19 @@ def dataclass_from_mapping(
52
54
  )
53
55
 
54
56
  annotations = get_type_hints(target_type, include_extras=True)
55
- constructor_values: dict[str, object] = {}
57
+ decoded_values: dict[str, object] = {}
56
58
  missing: list[str] = []
57
59
  for declared_field in declared_fields:
58
60
  if declared_field.name not in values:
59
61
  if (
60
- declared_field.default is MISSING
62
+ declared_field.init
63
+ and declared_field.default is MISSING
61
64
  and declared_field.default_factory is MISSING
62
65
  ):
63
66
  missing.append(declared_field.name)
64
67
  continue
65
68
  annotation = annotations.get(declared_field.name, declared_field.type)
66
- constructor_values[declared_field.name] = _mapping_value_for_annotation(
69
+ decoded_values[declared_field.name] = _mapping_value_for_annotation(
67
70
  annotation,
68
71
  values[declared_field.name],
69
72
  path=f"{target_type.__name__}.{declared_field.name}",
@@ -73,8 +76,23 @@ def dataclass_from_mapping(
73
76
  f"{target_type.__name__} is missing required field(s): {', '.join(missing)}."
74
77
  )
75
78
 
76
- 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
+ )
77
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
+ )
78
96
  return result
79
97
 
80
98
 
@@ -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.14
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
@@ -1,4 +1,4 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import asdict, dataclass, field
2
2
  from enum import Enum
3
3
  from collections.abc import Sequence
4
4
 
@@ -48,6 +48,82 @@ class SequenceEnvelope:
48
48
  nodes: Sequence[RecursiveSequenceNode]
49
49
 
50
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
+
51
127
  @pytest.mark.parametrize("container", (list, tuple))
52
128
  def test_dataclass_mapping_recurses_from_abstract_sequence_annotations(container):
53
129
  result = dataclass_from_mapping(
@@ -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."""