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.
- {python_introspect-0.1.14/src/python_introspect.egg-info → python_introspect-0.1.16}/PKG-INFO +1 -1
- {python_introspect-0.1.14 → python_introspect-0.1.16}/pyproject.toml +1 -1
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/__init__.py +1 -1
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py +26 -8
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/signature_analyzer.py +55 -39
- {python_introspect-0.1.14 → python_introspect-0.1.16/src/python_introspect.egg-info}/PKG-INFO +1 -1
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/SOURCES.txt +1 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_dataclass_projection.py +77 -1
- python_introspect-0.1.16/tests/test_dataclass_source_preparation.py +214 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_init.py +2 -1
- {python_introspect-0.1.14 → python_introspect-0.1.16}/LICENSE +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/README.md +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/setup.cfg +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/docstring_annotations.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/enableable.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/environment_projection.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/exceptions.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/runtime_parameter.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/unified_parameter_analyzer.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/validation.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/dependency_links.txt +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/top_level.txt +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_annotation_types.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_callable_declaration.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_environment_projection.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_exceptions.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_runtime_parameter.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_signature_analyzer.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py +0 -0
- {python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_validation.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-introspect"
|
|
7
|
-
version = "0.1.
|
|
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"
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py
RENAMED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/signature_analyzer.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
1058
|
+
source = SignatureAnalyzer._dataclass_source(dataclass_type)
|
|
1043
1059
|
except (OSError, TypeError):
|
|
1044
1060
|
try:
|
|
1045
1061
|
source_file = inspect.getfile(dataclass_type)
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/SOURCES.txt
RENAMED
|
@@ -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__ ==
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py
RENAMED
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py
RENAMED
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/docstring_annotations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect/runtime_parameter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt
RENAMED
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/src/python_introspect.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py
RENAMED
|
File without changes
|
{python_introspect-0.1.14 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py
RENAMED
|
File without changes
|
|
File without changes
|