From d34ad8fd251a7fd191847a7b5a920eaded0d98ed Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 00:53:37 -0400 Subject: [PATCH] Prepare dataclass source lookup without evaluating defaults (#5) --- src/python_introspect/signature_analyzer.py | 55 ++++- tests/test_dataclass_source_preparation.py | 214 ++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/test_dataclass_source_preparation.py diff --git a/src/python_introspect/signature_analyzer.py b/src/python_introspect/signature_analyzer.py index 33800e7..f48f58f 100644 --- a/src/python_introspect/signature_analyzer.py +++ b/src/python_introspect/signature_analyzer.py @@ -9,6 +9,9 @@ import ast import inspect +import sys +import linecache +from functools import lru_cache import dataclasses import re from abc import ABC, abstractmethod @@ -969,6 +972,56 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: # Return empty dict on error (don't cache errors) return {} + @staticmethod + def prepare_dataclass_declaration(dataclass_type: type) -> None: + """Warm pure source lookup without evaluating fields or default factories.""" + SignatureAnalyzer._extract_inline_field_docs(dataclass_type) + + @staticmethod + def _dataclass_source(dataclass_type: type) -> str: + """Read current loader source, deriving its class block by immutable content.""" + # Python 3.13+ locates class source from __firstlineno__ directly; + # its native locator does not perform the repeated module AST traversal. + if sys.version_info >= (3, 13): + return inspect.getsource(dataclass_type) + target = inspect.unwrap(dataclass_type) + if not inspect.isclass(target): + return inspect.getsource(target) + file = inspect.getsourcefile(target) + if file: + linecache.checkcache(file) + else: + file = inspect.getfile(target) + if not (file.startswith('<') and file.endswith('>')): + raise OSError('source code not available') + module = inspect.getmodule(target, file) + lines = linecache.getlines(file, module.__dict__) if module else linecache.getlines(file) + if not lines: + raise OSError('could not get source code') + source = SignatureAnalyzer._qualified_class_source(''.join(lines), target.__qualname__) + if source is None: + raise OSError('could not find class definition') + return source + + @staticmethod + @lru_cache(maxsize=32) + def _module_source_tree(source: str) -> ast.AST: + """Parse current immutable module bytes once; never expose the retained AST.""" + return ast.parse(source) + + @staticmethod + @lru_cache(maxsize=256) + def _qualified_class_source(source: str, qualname: str) -> Optional[str]: + """Use Python's qualified class/decorator locator on current source content.""" + tree = SignatureAnalyzer._module_source_tree(source) + finder = inspect._ClassFinder(qualname) + try: + finder.visit(tree) + except inspect.ClassFoundException as found: + lines = source.splitlines(keepends=True) + return ''.join(inspect.getblock(lines[found.args[0]:])) + return None + @staticmethod def _extract_inline_field_docs(dataclass_type: type) -> Dict[str, str]: """Extract inline field documentation strings using AST parsing. @@ -1002,7 +1055,7 @@ class Config: # Try to get source code - handle cases where it might not be available source = None try: - source = inspect.getsource(dataclass_type) + source = SignatureAnalyzer._dataclass_source(dataclass_type) except (OSError, TypeError): try: source_file = inspect.getfile(dataclass_type) diff --git a/tests/test_dataclass_source_preparation.py b/tests/test_dataclass_source_preparation.py new file mode 100644 index 0000000..6e80103 --- /dev/null +++ b/tests/test_dataclass_source_preparation.py @@ -0,0 +1,214 @@ +"""Source preparation must preserve live declarations and first-use values.""" +import dataclasses +import importlib.util +import inspect +import linecache +import os +import sys +import types +from typing import Annotated + +import pytest +from python_introspect import SignatureAnalyzer + + +def _load_module(monkeypatch, name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +def test_preparation_does_not_run_factories_or_snapshot_annotations(): + calls = [] + current = {"value": 1} + + def first(): + calls.append("first") + return current["value"] + + def second(): + calls.append("second") + return [] + + @dataclasses.dataclass + class Declaration: + value: int = dataclasses.field(default_factory=first) + values: list = dataclasses.field(default_factory=second) + + SignatureAnalyzer.prepare_dataclass_declaration(Declaration) + assert calls == [] + current["value"] = 4 + Declaration.__annotations__["value"] = str + result = SignatureAnalyzer._analyze_dataclass(Declaration) + assert calls == ["first", "second"] + assert result["value"].default_value == 4 + assert result["value"].param_type is str + assert SignatureAnalyzer._analyze_dataclass(Declaration) is result + assert calls == ["first", "second"] + + +def test_throwing_factory_stays_uncached_and_retryable(): + calls = [] + + def throwing(): + calls.append(1) + raise RuntimeError("first-use factory") + + @dataclasses.dataclass + class Declaration: + value: int = dataclasses.field(default_factory=throwing) + + SignatureAnalyzer.prepare_dataclass_declaration(Declaration) + assert calls == [] + assert SignatureAnalyzer._analyze_dataclass(Declaration) == {} + assert Declaration not in SignatureAnalyzer._dataclass_analysis_cache + assert SignatureAnalyzer._analyze_dataclass(Declaration) == {} + assert calls == [1, 1] + + +def test_factory_effects_precede_metadata_and_inherited_documentation(): + metadata = {"description": "before"} + + def factory(): + metadata["description"] = "after" + Parent.__doc__ = "Args:\n inherited: after factory" + return 3 + + @dataclasses.dataclass + class Parent: + """Args: + inherited: before factory + """ + owned: int = dataclasses.field(default_factory=factory, metadata=metadata) + inherited: int = 1 + + @dataclasses.dataclass + class Child(Parent): + pass + + SignatureAnalyzer.prepare_dataclass_declaration(Parent) + SignatureAnalyzer.prepare_dataclass_declaration(Child) + result = SignatureAnalyzer._analyze_dataclass(Child) + assert result["owned"].description == "after" + assert result["inherited"].description == "after factory" + + +def test_optional_and_required_field_semantics_survive_preparation(): + marker = object() + + @dataclasses.dataclass + class Declaration: + required: Annotated[int, marker] + inherited: int | None = None + + SignatureAnalyzer.prepare_dataclass_declaration(Declaration) + result = SignatureAnalyzer._analyze_dataclass(Declaration) + assert result["required"].is_required + assert result["required"].param_type == Annotated[int, marker] + assert result["inherited"].default_value is None + assert not result["inherited"].is_required + + +def test_source_lookup_uses_qualified_nested_and_decorated_classes(tmp_path, monkeypatch): + path = tmp_path / "declarations.py" + path.write_text('''from dataclasses import dataclass + +def identity(target): + return target + +@identity +@dataclass +class Outer: + value: int = 1 + "Outer documentation" + @dataclass + class Inner: + value: int = 2 + "Inner documentation" + +def factory(): + @dataclass + class Inner: + value: int = 3 + "Local documentation" + return Inner +''') + module = _load_module(monkeypatch, "qualified_source_preparation", path) + for declaration in (module.Outer, module.Outer.Inner, module.factory()): + SignatureAnalyzer.prepare_dataclass_declaration(declaration) + assert SignatureAnalyzer._dataclass_source(declaration) == inspect.getsource(declaration) + # The original inline extractor does not dedent nested blocks before parsing. + # Source preparation preserves that behavior rather than repairing it here. + assert SignatureAnalyzer._extract_inline_field_docs(module.Outer.Inner) == {} + assert SignatureAnalyzer._extract_inline_field_docs(module.factory()) == {} + + +def test_warmed_lookup_observes_changed_file_content_and_qualname(tmp_path, monkeypatch): + path = tmp_path / "declarations.py" + source = '''from dataclasses import dataclass +@dataclass +class First: + value: int = 1 + "First documentation" +@dataclass +class Second: + value: int = 2 + "Second documentation" +''' + path.write_text(source) + module = _load_module(monkeypatch, "live_source_preparation", path) + SignatureAnalyzer.prepare_dataclass_declaration(module.First) + module.First.__qualname__ = "Second" + assert SignatureAnalyzer._dataclass_source(module.First) == inspect.getsource(module.First) + module.First.__qualname__ = "First" + path.write_text(source.replace("First documentation", "Changed documentation")) + os.utime(path, (path.stat().st_atime, path.stat().st_mtime + 2)) + assert SignatureAnalyzer._extract_inline_field_docs(module.First) == {"value": "Changed documentation"} + + +def test_loader_source_is_read_at_original_observation_point(tmp_path, monkeypatch): + class Loader: + source = 'from dataclasses import dataclass\n@dataclass\nclass Loaded:\n value:int=1\n "Original documentation"\n' + + def get_source(self, name): + return self.source + + loader = Loader() + module = types.ModuleType("loader_source_preparation") + module.__file__ = str(tmp_path / "not_on_disk.py") + module.__loader__ = loader + monkeypatch.setitem(sys.modules, module.__name__, module) + exec(compile(loader.source, module.__file__, "exec"), module.__dict__) + SignatureAnalyzer.prepare_dataclass_declaration(module.Loaded) + loader.source = loader.source.replace("Original documentation", "Current documentation") + linecache.cache.pop(module.__file__, None) + assert SignatureAnalyzer._dataclass_source(module.Loaded) == inspect.getsource(module.Loaded) + assert SignatureAnalyzer._extract_inline_field_docs(module.Loaded) == {"value": "Current documentation"} + + +def test_generated_type_keeps_original_fallback_without_inventing_source(tmp_path, monkeypatch): + path = tmp_path / "declarations.py" + path.write_text('''from dataclasses import dataclass +@dataclass +class Public: + value: int = 1 + "Public documentation" +''') + module = _load_module(monkeypatch, "generated_source_preparation", path) + generated = dataclasses.make_dataclass("Public", [("value", int, 1)]) + generated.__module__ = module.__name__ + generated.__qualname__ = "GeneratedPublic" + SignatureAnalyzer.prepare_dataclass_declaration(generated) + assert SignatureAnalyzer._extract_inline_field_docs(generated) == {"value": "Public documentation"} + + +def test_missing_source_keeps_original_error(): + with pytest.raises(TypeError) as original: + inspect.getsource(int) + with pytest.raises(TypeError) as prepared: + SignatureAnalyzer._dataclass_source(int) + assert str(prepared.value) == str(original.value) + SignatureAnalyzer.prepare_dataclass_declaration(int) + assert SignatureAnalyzer._extract_inline_field_docs(int) == {}