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.
- {python_introspect-0.1.13/src/python_introspect.egg-info → python_introspect-0.1.16}/PKG-INFO +1 -1
- {python_introspect-0.1.13 → python_introspect-0.1.16}/pyproject.toml +1 -1
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/__init__.py +1 -1
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py +50 -24
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/signature_analyzer.py +55 -39
- {python_introspect-0.1.13 → python_introspect-0.1.16/src/python_introspect.egg-info}/PKG-INFO +1 -1
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/SOURCES.txt +1 -0
- python_introspect-0.1.16/tests/test_dataclass_projection.py +256 -0
- python_introspect-0.1.16/tests/test_dataclass_source_preparation.py +214 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_init.py +2 -1
- python_introspect-0.1.13/tests/test_dataclass_projection.py +0 -126
- {python_introspect-0.1.13 → python_introspect-0.1.16}/LICENSE +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/README.md +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/setup.cfg +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/docstring_annotations.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/enableable.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/environment_projection.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/exceptions.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/runtime_parameter.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/unified_parameter_analyzer.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/validation.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/dependency_links.txt +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/top_level.txt +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_annotation_types.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_callable_declaration.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_environment_projection.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_exceptions.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_runtime_parameter.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_signature_analyzer.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py +0 -0
- {python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py +0 -0
- {python_introspect-0.1.13 → 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.13 → python_introspect-0.1.16}/src/python_introspect/dataclass_projection.py
RENAMED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
{python_introspect-0.1.13 → 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.13 → 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
|
|
@@ -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__ ==
|
|
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}})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/annotation_types.py
RENAMED
|
File without changes
|
{python_introspect-0.1.13 → python_introspect-0.1.16}/src/python_introspect/callable_declaration.py
RENAMED
|
File without changes
|
{python_introspect-0.1.13 → 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.13 → 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.13 → python_introspect-0.1.16}/src/python_introspect.egg-info/requires.txt
RENAMED
|
File without changes
|
{python_introspect-0.1.13 → 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.13 → python_introspect-0.1.16}/tests/test_unified_parameter_analyzer.py
RENAMED
|
File without changes
|
{python_introspect-0.1.13 → python_introspect-0.1.16}/tests/test_validate_python_release_action.py
RENAMED
|
File without changes
|
|
File without changes
|