PathBridge 0.2.0__tar.gz → 0.3.0__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.
- {pathbridge-0.2.0 → pathbridge-0.3.0}/.gitignore +3 -1
- {pathbridge-0.2.0 → pathbridge-0.3.0}/PKG-INFO +22 -2
- {pathbridge-0.2.0 → pathbridge-0.3.0}/README.md +20 -1
- {pathbridge-0.2.0 → pathbridge-0.3.0}/pyproject.toml +2 -1
- pathbridge-0.3.0/src/pathbridge/extras/shaper.py +356 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/extras/trace.py +188 -148
- pathbridge-0.2.0/src/pathbridge/extras/shaper.py +0 -246
- {pathbridge-0.2.0 → pathbridge-0.3.0}/LICENSE +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/__init__.py +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/adapter.py +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/compiler.py +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/extras/__init__.py +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/py.typed +0 -0
- {pathbridge-0.2.0 → pathbridge-0.3.0}/src/pathbridge/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PathBridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Translate validator error locations back to your application's schema paths and emit structured errors
|
|
5
5
|
Project-URL: Homepage, https://github.com/pilosus/pathbridge
|
|
6
6
|
Project-URL: Repository, https://github.com/pilosus/pathbridge
|
|
@@ -25,6 +25,7 @@ Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
|
25
25
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
26
26
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
27
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
28
|
+
Requires-Dist: xsdata[cli]>=26.0; extra == 'dev'
|
|
28
29
|
Description-Content-Type: text/markdown
|
|
29
30
|
|
|
30
31
|
# PathBridge
|
|
@@ -78,7 +79,7 @@ errors = to_marshmallow([(loc, "Invalid phone")], compiled)
|
|
|
78
79
|
`pathbridge.extras` provides helper utilities for generating rules from your
|
|
79
80
|
converter:
|
|
80
81
|
|
|
81
|
-
- `make_shape(...)`: build a
|
|
82
|
+
- `make_shape(...)`: build a populated sample facade object.
|
|
82
83
|
- `build_rules(...)`: trace a sample conversion and produce `Destination -> Facade`
|
|
83
84
|
mapping rules.
|
|
84
85
|
|
|
@@ -157,3 +158,22 @@ print(errors)
|
|
|
157
158
|
# }
|
|
158
159
|
# }
|
|
159
160
|
```
|
|
161
|
+
|
|
162
|
+
### Custom shape defaults
|
|
163
|
+
|
|
164
|
+
`make_shape(...)` accepts `type_defaults` so you can override generated defaults
|
|
165
|
+
for specific types:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from decimal import Decimal
|
|
169
|
+
|
|
170
|
+
shape = make_shape(
|
|
171
|
+
Facade,
|
|
172
|
+
list_len=2,
|
|
173
|
+
type_defaults={
|
|
174
|
+
str: "sample",
|
|
175
|
+
int: 42,
|
|
176
|
+
Decimal: Decimal("1.23"),
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
```
|
|
@@ -49,7 +49,7 @@ errors = to_marshmallow([(loc, "Invalid phone")], compiled)
|
|
|
49
49
|
`pathbridge.extras` provides helper utilities for generating rules from your
|
|
50
50
|
converter:
|
|
51
51
|
|
|
52
|
-
- `make_shape(...)`: build a
|
|
52
|
+
- `make_shape(...)`: build a populated sample facade object.
|
|
53
53
|
- `build_rules(...)`: trace a sample conversion and produce `Destination -> Facade`
|
|
54
54
|
mapping rules.
|
|
55
55
|
|
|
@@ -128,3 +128,22 @@ print(errors)
|
|
|
128
128
|
# }
|
|
129
129
|
# }
|
|
130
130
|
```
|
|
131
|
+
|
|
132
|
+
### Custom shape defaults
|
|
133
|
+
|
|
134
|
+
`make_shape(...)` accepts `type_defaults` so you can override generated defaults
|
|
135
|
+
for specific types:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from decimal import Decimal
|
|
139
|
+
|
|
140
|
+
shape = make_shape(
|
|
141
|
+
Facade,
|
|
142
|
+
list_len=2,
|
|
143
|
+
type_defaults={
|
|
144
|
+
str: "sample",
|
|
145
|
+
int: 42,
|
|
146
|
+
Decimal: Decimal("1.23"),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "PathBridge"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Translate validator error locations back to your application's schema paths and emit structured errors"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -34,6 +34,7 @@ dev = [
|
|
|
34
34
|
"pytest-cov>=4.0",
|
|
35
35
|
"mypy>=1.0",
|
|
36
36
|
"ruff>=0.4",
|
|
37
|
+
"xsdata[cli]>=26.0",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
[project.urls]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
import typing as t
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
T = t.TypeVar("T")
|
|
15
|
+
_NO_DEFAULT = object()
|
|
16
|
+
TypeDefaultMapping = dict[t.Any, t.Any | t.Callable[[], t.Any]]
|
|
17
|
+
|
|
18
|
+
DEFAULT_TYPE_DEFAULTS: TypeDefaultMapping = {
|
|
19
|
+
str: "",
|
|
20
|
+
int: 0,
|
|
21
|
+
float: 0.0,
|
|
22
|
+
bool: True,
|
|
23
|
+
Decimal: Decimal("0"),
|
|
24
|
+
dt.date: dt.date(1970, 1, 1),
|
|
25
|
+
dt.datetime: dt.datetime(1970, 1, 1),
|
|
26
|
+
UUID: UUID(int=0),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_dataclass_type(tp: t.Any) -> t.TypeGuard[type[t.Any]]:
|
|
31
|
+
return inspect.isclass(tp) and dataclasses.is_dataclass(tp)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dataclass_fields(cls: type[t.Any]) -> tuple[dataclasses.Field[t.Any], ...]:
|
|
35
|
+
return dataclasses.fields(cls)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_annotated(tp: t.Any) -> t.Any:
|
|
39
|
+
origin = t.get_origin(tp)
|
|
40
|
+
if origin is t.Annotated:
|
|
41
|
+
args = t.get_args(tp)
|
|
42
|
+
if args:
|
|
43
|
+
return args[0]
|
|
44
|
+
return tp
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_type_hints(cls: type[t.Any]) -> dict[str, t.Any]:
|
|
48
|
+
module = sys.modules.get(cls.__module__)
|
|
49
|
+
module_ns: dict[str, t.Any] = vars(module).copy() if module is not None else {}
|
|
50
|
+
try:
|
|
51
|
+
return t.get_type_hints(
|
|
52
|
+
cls,
|
|
53
|
+
globalns=module_ns,
|
|
54
|
+
localns=module_ns,
|
|
55
|
+
include_extras=True,
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
return {f.name: f.type for f in _dataclass_fields(cls)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _default_for_custom_scalar(origin: t.Any) -> t.Any:
|
|
62
|
+
if not inspect.isclass(origin):
|
|
63
|
+
return _NO_DEFAULT
|
|
64
|
+
|
|
65
|
+
cls_name = origin.__name__
|
|
66
|
+
|
|
67
|
+
# Factory methods are often the safest constructor path for xsdata wrappers.
|
|
68
|
+
from_string = getattr(origin, "from_string", None)
|
|
69
|
+
if callable(from_string):
|
|
70
|
+
for literal in ("1970-01-01", "1970-01-01T00:00:00", "00:00:00"):
|
|
71
|
+
with contextlib.suppress(Exception):
|
|
72
|
+
return from_string(literal)
|
|
73
|
+
|
|
74
|
+
from_date = getattr(origin, "from_date", None)
|
|
75
|
+
if callable(from_date):
|
|
76
|
+
with contextlib.suppress(Exception):
|
|
77
|
+
return from_date(dt.date(1970, 1, 1))
|
|
78
|
+
|
|
79
|
+
from_datetime = getattr(origin, "from_datetime", None)
|
|
80
|
+
if callable(from_datetime):
|
|
81
|
+
with contextlib.suppress(Exception):
|
|
82
|
+
return from_datetime(dt.datetime(1970, 1, 1))
|
|
83
|
+
|
|
84
|
+
# Common constructor style used by xsdata scalar date/time wrappers.
|
|
85
|
+
if cls_name in {"XmlDate", "XmlDateTime", "XmlTime"} or cls_name.startswith("Xml"):
|
|
86
|
+
for args in (
|
|
87
|
+
(),
|
|
88
|
+
(1970, 1, 1),
|
|
89
|
+
(1970, 1, 1, 0, 0, 0),
|
|
90
|
+
(0, 0, 0),
|
|
91
|
+
):
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
return origin(*args)
|
|
94
|
+
|
|
95
|
+
return _NO_DEFAULT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mapping_default(
|
|
99
|
+
tp: t.Any,
|
|
100
|
+
origin: t.Any,
|
|
101
|
+
type_defaults: TypeDefaultMapping,
|
|
102
|
+
) -> t.Any:
|
|
103
|
+
for key in (tp, origin):
|
|
104
|
+
if key in type_defaults:
|
|
105
|
+
configured = type_defaults[key]
|
|
106
|
+
return configured() if callable(configured) else configured
|
|
107
|
+
return _NO_DEFAULT
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_optional(tp: t.Any) -> tuple[bool, t.Any]:
|
|
111
|
+
"""Return (is_optional, inner_type)."""
|
|
112
|
+
tp = _strip_annotated(tp)
|
|
113
|
+
origin = t.get_origin(tp)
|
|
114
|
+
if origin in (t.Union, types.UnionType): # Optional[T] is Union[T, NoneType]
|
|
115
|
+
args = [a for a in t.get_args(tp) if a is not type(None)]
|
|
116
|
+
if args:
|
|
117
|
+
return True, args[0]
|
|
118
|
+
return False, tp
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_list(tp: t.Any) -> tuple[bool, t.Any]:
|
|
122
|
+
tp = _strip_annotated(tp)
|
|
123
|
+
origin = t.get_origin(tp)
|
|
124
|
+
if origin in (list, list):
|
|
125
|
+
args = t.get_args(tp)
|
|
126
|
+
return True, (args[0] if args else t.Any)
|
|
127
|
+
return False, tp
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_dict(tp: t.Any) -> tuple[bool, t.Any, t.Any]:
|
|
131
|
+
tp = _strip_annotated(tp)
|
|
132
|
+
origin = t.get_origin(tp)
|
|
133
|
+
if origin in (dict, dict):
|
|
134
|
+
args = t.get_args(tp)
|
|
135
|
+
key_t = args[0] if args else t.Any
|
|
136
|
+
val_t = args[1] if len(args) > 1 else t.Any
|
|
137
|
+
return True, key_t, val_t
|
|
138
|
+
return False, tp, tp
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _safe_first_enum(enum_cls: type[Enum]) -> Enum:
|
|
142
|
+
try:
|
|
143
|
+
return next(iter(enum_cls))
|
|
144
|
+
except StopIteration:
|
|
145
|
+
raise ValueError(f"Enum {enum_cls!r} has no members") from None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _default_for_type(tp: t.Any) -> t.Any:
|
|
149
|
+
"""Produce a non-empty, truthy default for a scalar-ish type."""
|
|
150
|
+
return _default_for_type_with_mapping(tp, DEFAULT_TYPE_DEFAULTS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _default_for_type_with_mapping(
|
|
154
|
+
tp: t.Any,
|
|
155
|
+
type_defaults: TypeDefaultMapping,
|
|
156
|
+
) -> t.Any:
|
|
157
|
+
"""Produce a default scalar value using built-ins and user overrides."""
|
|
158
|
+
tp = _strip_annotated(tp)
|
|
159
|
+
origin = t.get_origin(tp) or tp
|
|
160
|
+
|
|
161
|
+
mapped = _mapping_default(tp, origin, type_defaults)
|
|
162
|
+
if mapped is not _NO_DEFAULT:
|
|
163
|
+
return mapped
|
|
164
|
+
|
|
165
|
+
if inspect.isclass(origin) and issubclass(origin, Enum):
|
|
166
|
+
return _safe_first_enum(origin)
|
|
167
|
+
custom_default = _default_for_custom_scalar(origin)
|
|
168
|
+
if custom_default is not _NO_DEFAULT:
|
|
169
|
+
return custom_default
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _apply_overrides(obj: t.Any, overrides: dict[str, t.Any]) -> t.Any:
|
|
174
|
+
"""
|
|
175
|
+
Apply dotted-path overrides like:
|
|
176
|
+
"foo.bar[2].baz": 42
|
|
177
|
+
Works with dataclasses, lists, and dicts.
|
|
178
|
+
"""
|
|
179
|
+
for path, value in overrides.items():
|
|
180
|
+
parts = [p for p in path.split(".") if p]
|
|
181
|
+
target = obj
|
|
182
|
+
for i, token in enumerate(parts):
|
|
183
|
+
# handle [idx] suffix
|
|
184
|
+
name, idx = token, None
|
|
185
|
+
if "[" in token and token.endswith("]"):
|
|
186
|
+
name, idx = (
|
|
187
|
+
token[: token.index("[")],
|
|
188
|
+
int(token[token.index("[") + 1 : -1]),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if i == len(parts) - 1:
|
|
192
|
+
# set leaf
|
|
193
|
+
if idx is None:
|
|
194
|
+
if dataclasses.is_dataclass(target):
|
|
195
|
+
setattr(target, name, value)
|
|
196
|
+
elif isinstance(target, dict):
|
|
197
|
+
target[name] = value
|
|
198
|
+
else:
|
|
199
|
+
setattr(target, name, value)
|
|
200
|
+
else:
|
|
201
|
+
seq = getattr(target, name)
|
|
202
|
+
seq[idx] = value
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
# descend
|
|
206
|
+
if dataclasses.is_dataclass(target):
|
|
207
|
+
target = getattr(target, name)
|
|
208
|
+
elif isinstance(target, dict):
|
|
209
|
+
target = target[name]
|
|
210
|
+
else:
|
|
211
|
+
target = getattr(target, name)
|
|
212
|
+
|
|
213
|
+
if idx is not None:
|
|
214
|
+
target = target[idx]
|
|
215
|
+
return obj
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _shape_for_type(
|
|
219
|
+
tp: t.Any,
|
|
220
|
+
*,
|
|
221
|
+
list_len: int,
|
|
222
|
+
seen: set[type],
|
|
223
|
+
type_defaults: TypeDefaultMapping,
|
|
224
|
+
) -> t.Any:
|
|
225
|
+
tp = _strip_annotated(tp)
|
|
226
|
+
is_opt, inner = _is_optional(tp)
|
|
227
|
+
if is_opt:
|
|
228
|
+
tp = inner
|
|
229
|
+
|
|
230
|
+
is_list, elem_t = _is_list(tp)
|
|
231
|
+
if is_list:
|
|
232
|
+
return [
|
|
233
|
+
_shape_for_type(
|
|
234
|
+
elem_t,
|
|
235
|
+
list_len=list_len,
|
|
236
|
+
seen=seen,
|
|
237
|
+
type_defaults=type_defaults,
|
|
238
|
+
)
|
|
239
|
+
for _ in range(list_len)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
is_dict, _key_t, _val_t = _is_dict(tp)
|
|
243
|
+
if is_dict:
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
origin = t.get_origin(tp)
|
|
247
|
+
if origin in (t.Union, types.UnionType):
|
|
248
|
+
args = t.get_args(tp)
|
|
249
|
+
if args:
|
|
250
|
+
return _shape_for_type(
|
|
251
|
+
args[0],
|
|
252
|
+
list_len=list_len,
|
|
253
|
+
seen=seen,
|
|
254
|
+
type_defaults=type_defaults,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
resolved = origin or tp
|
|
258
|
+
if _is_dataclass_type(resolved):
|
|
259
|
+
return _shape_dataclass(
|
|
260
|
+
resolved,
|
|
261
|
+
list_len=list_len,
|
|
262
|
+
overrides=None,
|
|
263
|
+
seen=seen,
|
|
264
|
+
type_defaults=type_defaults,
|
|
265
|
+
)
|
|
266
|
+
return _default_for_type_with_mapping(resolved, type_defaults)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _shape_dataclass(
|
|
270
|
+
cls: type[T],
|
|
271
|
+
*,
|
|
272
|
+
list_len: int,
|
|
273
|
+
overrides: dict[str, t.Any] | None,
|
|
274
|
+
seen: set[type],
|
|
275
|
+
type_defaults: TypeDefaultMapping,
|
|
276
|
+
) -> T:
|
|
277
|
+
if cls in seen:
|
|
278
|
+
# stop infinite recursion; build minimal
|
|
279
|
+
minimal_kwargs: dict[str, t.Any] = {}
|
|
280
|
+
for f in _dataclass_fields(cls):
|
|
281
|
+
minimal_kwargs[f.name] = None
|
|
282
|
+
return cls(**minimal_kwargs)
|
|
283
|
+
seen.add(cls)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
type_hints = _resolve_type_hints(cls)
|
|
287
|
+
kwargs: dict[str, t.Any] = {}
|
|
288
|
+
for f in _dataclass_fields(cls):
|
|
289
|
+
field_type = type_hints.get(f.name, f.type)
|
|
290
|
+
kwargs[f.name] = _shape_for_type(
|
|
291
|
+
field_type,
|
|
292
|
+
list_len=list_len,
|
|
293
|
+
seen=seen,
|
|
294
|
+
type_defaults=type_defaults,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
inst = cls(**kwargs)
|
|
298
|
+
if overrides:
|
|
299
|
+
_apply_overrides(inst, overrides)
|
|
300
|
+
return inst
|
|
301
|
+
finally:
|
|
302
|
+
seen.remove(cls)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def make_shape(
|
|
306
|
+
spec: type[T] | T | t.Callable[[], T],
|
|
307
|
+
*,
|
|
308
|
+
list_len: int = 1,
|
|
309
|
+
overrides: dict[str, t.Any] | None = None,
|
|
310
|
+
type_defaults: TypeDefaultMapping | None = None,
|
|
311
|
+
) -> T:
|
|
312
|
+
"""
|
|
313
|
+
Construct a 'truthy' instance for the given facade class/instance/factory.
|
|
314
|
+
|
|
315
|
+
- spec: dataclass class, an instance (will be cloned shallowly), or a zero-arg factory
|
|
316
|
+
- list_len: default length for list fields
|
|
317
|
+
- overrides: dotted-path -> value to force (supports [index])
|
|
318
|
+
- type_defaults: optional mapping `{type: default_or_factory}` to override
|
|
319
|
+
built-in scalar defaults.
|
|
320
|
+
|
|
321
|
+
Heuristics:
|
|
322
|
+
- dataclasses: recursively instantiate with non-empty defaults
|
|
323
|
+
- Optional[T]: pick T with a non-empty value
|
|
324
|
+
- enums: pick first member
|
|
325
|
+
- str: ""; int: 0; Decimal: 0; bool: True; date: 1970-01-01
|
|
326
|
+
- lists: [truthy(T) for _ in range(list_len)]
|
|
327
|
+
"""
|
|
328
|
+
resolved_type_defaults: TypeDefaultMapping = dict(DEFAULT_TYPE_DEFAULTS)
|
|
329
|
+
if type_defaults:
|
|
330
|
+
resolved_type_defaults.update(type_defaults)
|
|
331
|
+
|
|
332
|
+
if inspect.isclass(spec):
|
|
333
|
+
if dataclasses.is_dataclass(spec):
|
|
334
|
+
base = _shape_dataclass(
|
|
335
|
+
t.cast(type[T], spec),
|
|
336
|
+
list_len=list_len,
|
|
337
|
+
overrides=overrides,
|
|
338
|
+
seen=set(),
|
|
339
|
+
type_defaults=resolved_type_defaults,
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
raise TypeError(
|
|
343
|
+
"make_shape expects a dataclass class/instance or a zero-arg factory"
|
|
344
|
+
)
|
|
345
|
+
elif dataclasses.is_dataclass(spec):
|
|
346
|
+
# already an instance: (re)apply overrides and return
|
|
347
|
+
base = t.cast(T, spec)
|
|
348
|
+
if overrides:
|
|
349
|
+
_apply_overrides(base, overrides)
|
|
350
|
+
elif callable(spec):
|
|
351
|
+
base = spec()
|
|
352
|
+
else:
|
|
353
|
+
raise TypeError(
|
|
354
|
+
"make_shape expects a dataclass class/instance or a zero-arg factory"
|
|
355
|
+
)
|
|
356
|
+
return base
|
|
@@ -4,6 +4,7 @@ import contextlib
|
|
|
4
4
|
import dataclasses
|
|
5
5
|
import enum
|
|
6
6
|
import inspect
|
|
7
|
+
import re
|
|
7
8
|
import types
|
|
8
9
|
import typing as t
|
|
9
10
|
|
|
@@ -20,6 +21,7 @@ def trace_converter(
|
|
|
20
21
|
converter: t.Callable[[t.Any], t.Any],
|
|
21
22
|
lift: t.Iterable[str] | None = None,
|
|
22
23
|
root_tag: str = "root",
|
|
24
|
+
destination_prefix: str | None = None,
|
|
23
25
|
) -> TraceContext:
|
|
24
26
|
"""
|
|
25
27
|
Context manager that enables tracing patches. Yields a callable:
|
|
@@ -31,6 +33,8 @@ def trace_converter(
|
|
|
31
33
|
Accepts simple names resolvable in converter's globals, or fully
|
|
32
34
|
qualified 'pkg.mod:func' strings.
|
|
33
35
|
- root_tag: starting facade segment, e.g. "mtr"
|
|
36
|
+
- destination_prefix: optional prefix for destination XML path segments,
|
|
37
|
+
e.g. "MTR" to render "MTR:ElementName[1]"
|
|
34
38
|
|
|
35
39
|
Returns a context manager. Inside it, call the yielded function with a *facade*
|
|
36
40
|
instance (your shape); it returns (result_object, rules_dict).
|
|
@@ -40,6 +44,7 @@ def trace_converter(
|
|
|
40
44
|
converter=converter,
|
|
41
45
|
lift=tuple(lift or ()),
|
|
42
46
|
root_tag=root_tag,
|
|
47
|
+
destination_prefix=destination_prefix,
|
|
43
48
|
)
|
|
44
49
|
|
|
45
50
|
|
|
@@ -50,6 +55,7 @@ def build_rules(
|
|
|
50
55
|
shape: t.Any,
|
|
51
56
|
lift: t.Iterable[str] | None = None,
|
|
52
57
|
root_tag: str = "root",
|
|
58
|
+
destination_prefix: str | None = None,
|
|
53
59
|
) -> RawRulesMapT:
|
|
54
60
|
"""
|
|
55
61
|
Build a destination-to-facade rules map from one traced conversion run.
|
|
@@ -68,12 +74,18 @@ def build_rules(
|
|
|
68
74
|
lift: Optional helper function names that should preserve path tags
|
|
69
75
|
(`"name"` or `"pkg.mod:func"`).
|
|
70
76
|
root_tag: Root token used as the facade-path prefix in recorded rules.
|
|
77
|
+
destination_prefix: Optional destination segment prefix
|
|
78
|
+
(e.g. `"MTR"` -> `MTR:Element[1]`).
|
|
71
79
|
|
|
72
80
|
Returns:
|
|
73
81
|
Mapping of destination paths to facade paths (`DEST -> FACADE`).
|
|
74
82
|
"""
|
|
75
83
|
with trace_converter(
|
|
76
|
-
model_module=model_module,
|
|
84
|
+
model_module=model_module,
|
|
85
|
+
converter=converter,
|
|
86
|
+
lift=lift,
|
|
87
|
+
root_tag=root_tag,
|
|
88
|
+
destination_prefix=destination_prefix,
|
|
77
89
|
) as run:
|
|
78
90
|
_, rules = run(shape)
|
|
79
91
|
return rules
|
|
@@ -87,11 +99,13 @@ def build_rules(
|
|
|
87
99
|
U = t.TypeVar("U")
|
|
88
100
|
InitCallable = t.Callable[..., None]
|
|
89
101
|
LiftRecord = tuple[types.ModuleType, str, t.Callable[..., t.Any]]
|
|
102
|
+
LeafRecord = tuple[list[str], str]
|
|
90
103
|
ParentStack = tuple[tuple[str, int], ...]
|
|
91
104
|
CounterKey = tuple[ParentStack, str]
|
|
105
|
+
NodeKey = tuple[ParentStack, str, str]
|
|
106
|
+
TRACER_LEAVES_ATTR = "__tracer_leaves__"
|
|
92
107
|
|
|
93
108
|
|
|
94
|
-
# A minimal tag that carries a source facade path alongside a value.
|
|
95
109
|
class Tagged(t.Generic[U]):
|
|
96
110
|
__slots__ = ("value", "path")
|
|
97
111
|
|
|
@@ -103,30 +117,17 @@ class Tagged(t.Generic[U]):
|
|
|
103
117
|
return f"Tagged({self.value!r}, {self.path})"
|
|
104
118
|
|
|
105
119
|
|
|
106
|
-
def _is_scalar(x: t.Any) -> bool:
|
|
107
|
-
return not dataclasses.is_dataclass(x) and not isinstance(x, (list, tuple, dict))
|
|
108
|
-
|
|
109
|
-
|
|
110
120
|
def _wrap_src(obj: t.Any, path: str) -> t.Any:
|
|
111
|
-
"""Wrap a facade object with proxies that yield Tagged leaves."""
|
|
112
|
-
# dataclass -> attribute-proxy
|
|
113
121
|
if dataclasses.is_dataclass(obj):
|
|
114
122
|
return _DataclassProxy(obj, path)
|
|
115
|
-
# list/tuple -> collection of proxies
|
|
116
123
|
if isinstance(obj, (list, tuple)):
|
|
117
124
|
return type(obj)(_wrap_src(v, f"{path}[{i}]") for i, v in enumerate(obj))
|
|
118
|
-
# dict -> proxy each value
|
|
119
125
|
if isinstance(obj, dict):
|
|
120
126
|
return {k: _wrap_src(v, f"{path}/{k}") for k, v in obj.items()}
|
|
121
|
-
# leaf -> Tagged
|
|
122
127
|
return Tagged(obj, path)
|
|
123
128
|
|
|
124
129
|
|
|
125
130
|
class _DataclassProxy:
|
|
126
|
-
"""
|
|
127
|
-
Lightweight facade proxy that mirrors a dataclass and returns Tagged leaves.
|
|
128
|
-
"""
|
|
129
|
-
|
|
130
131
|
__slots__ = ("__obj", "__path")
|
|
131
132
|
|
|
132
133
|
def __init__(self, obj: t.Any, path: str) -> None:
|
|
@@ -136,114 +137,128 @@ class _DataclassProxy:
|
|
|
136
137
|
def __getattr__(self, name: str) -> t.Any:
|
|
137
138
|
obj = object.__getattribute__(self, "_DataclassProxy__obj")
|
|
138
139
|
path = object.__getattribute__(self, "_DataclassProxy__path")
|
|
139
|
-
|
|
140
|
-
# Normalize to dotted attr path on facade side
|
|
141
|
-
sub_path = f"{path}/{name}"
|
|
142
|
-
return _wrap_src(val, sub_path)
|
|
143
|
-
|
|
144
|
-
# for explicit indexing on proxied lists nested under attributes, users will get
|
|
145
|
-
# real list proxies returned from __getattr__, so normal indexing works.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# Destination path builders (xsdata-friendly but generic)
|
|
140
|
+
return _wrap_src(getattr(obj, name), f"{path}/{name}")
|
|
149
141
|
|
|
150
142
|
|
|
151
143
|
def _xml_class_name(cls: type) -> str:
|
|
152
|
-
"""Try xsdata's Meta.name; otherwise use class name."""
|
|
153
144
|
meta = getattr(cls, "Meta", None)
|
|
154
145
|
return getattr(meta, "name", cls.__name__)
|
|
155
146
|
|
|
156
147
|
|
|
157
148
|
def _xml_field_name(cls: type, py_name: str) -> str:
|
|
158
|
-
"""Try xsdata field metadata 'name'; otherwise Python field name."""
|
|
159
149
|
fld = cls.__dataclass_fields__[py_name] # type: ignore[attr-defined]
|
|
160
150
|
meta = fld.metadata or {}
|
|
161
151
|
return t.cast(str, meta.get("name", py_name))
|
|
162
152
|
|
|
163
153
|
|
|
164
|
-
class _TracerState:
|
|
165
|
-
def __init__(self, root_tag: str) -> None:
|
|
166
|
-
self.root_tag = root_tag
|
|
167
|
-
# stack of (xml_element_name, index)
|
|
168
|
-
self.stack: list[tuple[str, int]] = []
|
|
169
|
-
# per-parent-element counters to assign 1-based [n] to siblings
|
|
170
|
-
self.counters: dict[CounterKey, int] = {}
|
|
171
|
-
# collected rules: DEST -> FACADE
|
|
172
|
-
self.rules: RawRulesMapT = {}
|
|
173
|
-
|
|
174
|
-
# ---- stack management ----
|
|
175
|
-
|
|
176
|
-
def push(self, name: str) -> int:
|
|
177
|
-
parent = tuple(self.stack)
|
|
178
|
-
key = (parent, name)
|
|
179
|
-
idx = self.counters.get(key, 0) + 1 # 1-based for DEST
|
|
180
|
-
self.counters[key] = idx
|
|
181
|
-
self.stack.append((name, idx))
|
|
182
|
-
return idx
|
|
183
|
-
|
|
184
|
-
def pop(self) -> None:
|
|
185
|
-
self.stack.pop()
|
|
186
|
-
|
|
187
|
-
# ---- path formatting ----
|
|
188
|
-
|
|
189
|
-
def current_prefix(self) -> str:
|
|
190
|
-
# e.g., "MTR:MTR[1]/MTR:Sa103S[2]" without namespaces, generic:
|
|
191
|
-
if not self.stack:
|
|
192
|
-
return _escape_step(self.root_tag) + "[1]"
|
|
193
|
-
return "/".join(f"{_escape_step(name)}[{idx}]" for (name, idx) in self.stack)
|
|
194
|
-
|
|
195
|
-
def add_leaf(self, field_xml_name: str, src_path: str) -> None:
|
|
196
|
-
# Build full destination path including the leaf element
|
|
197
|
-
dest = self.current_prefix() + f"/{_escape_step(field_xml_name)}[1]"
|
|
198
|
-
# rules map DEST -> FACADE (src_path is in facade token format)
|
|
199
|
-
# Keep the last writer if duplicates occur (first-win/last-win both OK)
|
|
200
|
-
self.rules[dest] = src_path
|
|
201
|
-
|
|
202
|
-
|
|
203
154
|
def _escape_step(name: str) -> str:
|
|
204
|
-
"""Escape a step name safely for later regex compilation (kept literal here)."""
|
|
205
|
-
# We keep it as-is; compiler will escape and normalize.
|
|
206
155
|
return name
|
|
207
156
|
|
|
208
157
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
158
|
+
def _format_step(name: str, destination_prefix: str | None) -> str:
|
|
159
|
+
escaped = _escape_step(name)
|
|
160
|
+
if destination_prefix:
|
|
161
|
+
return f"{destination_prefix}:{escaped}"
|
|
162
|
+
return escaped
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _normalize_xml_name(name: str) -> str:
|
|
166
|
+
return re.sub(r"\W+", "", name).upper()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _nested_segment_name(parent_cls: type, field_name: str, child_value: t.Any) -> str:
|
|
170
|
+
field_xml = _xml_field_name(parent_cls, field_name)
|
|
171
|
+
child_xml = _xml_class_name(type(child_value))
|
|
172
|
+
if _normalize_xml_name(field_xml) == _normalize_xml_name(child_xml):
|
|
173
|
+
return child_xml
|
|
174
|
+
return field_xml
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_leaves(obj: t.Any) -> list[LeafRecord]:
|
|
178
|
+
leaves = getattr(obj, TRACER_LEAVES_ATTR, None)
|
|
179
|
+
if isinstance(leaves, list):
|
|
180
|
+
return t.cast(list[LeafRecord], leaves)
|
|
181
|
+
return []
|
|
182
|
+
|
|
215
183
|
|
|
184
|
+
def _unwrap_field_value(
|
|
185
|
+
cls: type,
|
|
186
|
+
field_name: str,
|
|
187
|
+
value: t.Any,
|
|
188
|
+
) -> tuple[t.Any, list[LeafRecord]]:
|
|
189
|
+
field_xml_name = _xml_field_name(cls, field_name)
|
|
190
|
+
|
|
191
|
+
if isinstance(value, Tagged):
|
|
192
|
+
return value.value, [([field_xml_name], value.path)]
|
|
216
193
|
|
|
217
|
-
def _record_list_field(
|
|
218
|
-
state: _TracerState, cls: type, field_name: str, value: t.Any
|
|
219
|
-
) -> None:
|
|
220
|
-
# If the list elements are Tagged scalars, record per index.
|
|
221
194
|
if isinstance(value, (list, tuple)):
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
195
|
+
unwrapped_items: list[t.Any] = []
|
|
196
|
+
leaves: list[LeafRecord] = []
|
|
197
|
+
for item in value:
|
|
198
|
+
if isinstance(item, Tagged):
|
|
199
|
+
# Keep two steps to preserve item index in generated destination path.
|
|
200
|
+
leaves.append(([field_xml_name, field_xml_name], item.path))
|
|
201
|
+
unwrapped_items.append(item.value)
|
|
202
|
+
else:
|
|
203
|
+
unwrapped_items.append(item)
|
|
204
|
+
if isinstance(value, tuple):
|
|
205
|
+
return tuple(unwrapped_items), leaves
|
|
206
|
+
return unwrapped_items, leaves
|
|
232
207
|
|
|
208
|
+
return value, []
|
|
233
209
|
|
|
234
|
-
# Monkey-patch machinery
|
|
235
210
|
|
|
211
|
+
def _split_facade_path(path: str) -> list[str]:
|
|
212
|
+
return [part for part in path.split("/") if part]
|
|
236
213
|
|
|
237
|
-
class TraceContext:
|
|
238
|
-
"""
|
|
239
|
-
Context manager that patches destination dataclasses' __init__ and enum lookups,
|
|
240
|
-
and (optionally) lifts helper functions to preserve Tagged values.
|
|
241
214
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
215
|
+
def _leaves_to_rules(
|
|
216
|
+
leaves: list[LeafRecord], destination_prefix: str | None
|
|
217
|
+
) -> RawRulesMapT:
|
|
218
|
+
rules: RawRulesMapT = {}
|
|
219
|
+
counters: dict[CounterKey, int] = {}
|
|
220
|
+
nodes: dict[NodeKey, int] = {}
|
|
221
|
+
|
|
222
|
+
for segments, src_path in leaves:
|
|
223
|
+
if not segments:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
src_parts = _split_facade_path(src_path)
|
|
227
|
+
stack: list[tuple[str, int]] = []
|
|
228
|
+
rendered_parts: list[str] = []
|
|
229
|
+
for depth, name in enumerate(segments):
|
|
230
|
+
if depth < len(src_parts):
|
|
231
|
+
src_prefix = "/".join(src_parts[: depth + 1])
|
|
232
|
+
else:
|
|
233
|
+
src_prefix = f"{src_path}#d{depth}"
|
|
234
|
+
|
|
235
|
+
parent = tuple(stack)
|
|
236
|
+
node_key = (parent, name, src_prefix)
|
|
237
|
+
if node_key in nodes:
|
|
238
|
+
idx = nodes[node_key]
|
|
239
|
+
else:
|
|
240
|
+
counter_key = (parent, name)
|
|
241
|
+
idx = counters.get(counter_key, 0) + 1
|
|
242
|
+
counters[counter_key] = idx
|
|
243
|
+
nodes[node_key] = idx
|
|
244
|
+
|
|
245
|
+
stack.append((name, idx))
|
|
246
|
+
rendered_parts.append(f"{_format_step(name, destination_prefix)}[{idx}]")
|
|
247
|
+
|
|
248
|
+
# Include intermediate parent path mappings when facade depth exists.
|
|
249
|
+
# Depth 0 is the root object and usually too broad to be useful.
|
|
250
|
+
if depth > 0 and depth < len(src_parts) - 1:
|
|
251
|
+
parent_dest = "/".join(rendered_parts)
|
|
252
|
+
parent_src = "/".join(src_parts[: depth + 1])
|
|
253
|
+
if parent_dest not in rules:
|
|
254
|
+
rules[parent_dest] = parent_src
|
|
255
|
+
|
|
256
|
+
rules["/".join(rendered_parts)] = src_path
|
|
257
|
+
|
|
258
|
+
return rules
|
|
246
259
|
|
|
260
|
+
|
|
261
|
+
class TraceContext:
|
|
247
262
|
def __init__(
|
|
248
263
|
self,
|
|
249
264
|
*,
|
|
@@ -251,19 +266,19 @@ class TraceContext:
|
|
|
251
266
|
converter: t.Callable[[t.Any], t.Any],
|
|
252
267
|
lift: tuple[str, ...],
|
|
253
268
|
root_tag: str,
|
|
269
|
+
destination_prefix: str | None,
|
|
254
270
|
) -> None:
|
|
255
271
|
self.model_module = model_module
|
|
256
272
|
self.converter = converter
|
|
257
273
|
self.lift = lift
|
|
258
274
|
self.root_tag = root_tag
|
|
275
|
+
self.destination_prefix = destination_prefix
|
|
259
276
|
|
|
260
277
|
self._orig_inits: dict[type, InitCallable] = {}
|
|
261
|
-
self._patched_classes: list[type] = []
|
|
262
278
|
self._orig_enummeta_getitem: (
|
|
263
279
|
t.Callable[[type[enum.Enum], t.Any], t.Any] | None
|
|
264
280
|
) = None
|
|
265
281
|
self._lifted: list[LiftRecord] = []
|
|
266
|
-
self._state = _TracerState(root_tag=root_tag)
|
|
267
282
|
|
|
268
283
|
def __enter__(self) -> t.Callable[[t.Any], tuple[t.Any, RawRulesMapT]]:
|
|
269
284
|
self._patch_dataclasses()
|
|
@@ -282,27 +297,38 @@ class TraceContext:
|
|
|
282
297
|
self._restore_enum_meta()
|
|
283
298
|
self._restore_dataclasses()
|
|
284
299
|
|
|
285
|
-
# ---- core run ----
|
|
286
|
-
|
|
287
300
|
def _run(self, facade_root: t.Any) -> tuple[t.Any, RawRulesMapT]:
|
|
288
|
-
# Wrap the facade so reads return Tagged (or proxies)
|
|
289
301
|
proxied = _wrap_src(facade_root, self.root_tag)
|
|
290
|
-
# Execute converter under a fresh state
|
|
291
|
-
self._state = _TracerState(root_tag=self.root_tag)
|
|
292
302
|
result = self.converter(proxied)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
303
|
+
if dataclasses.is_dataclass(result):
|
|
304
|
+
return result, _leaves_to_rules(
|
|
305
|
+
_get_leaves(result), self.destination_prefix
|
|
306
|
+
)
|
|
307
|
+
return result, {}
|
|
296
308
|
|
|
297
309
|
def _iter_destination_classes(self) -> list[type]:
|
|
298
310
|
out: list[type] = []
|
|
311
|
+
visited: set[type] = set()
|
|
312
|
+
|
|
313
|
+
def visit(candidate: type) -> None:
|
|
314
|
+
if candidate in visited:
|
|
315
|
+
return
|
|
316
|
+
visited.add(candidate)
|
|
317
|
+
if dataclasses.is_dataclass(candidate):
|
|
318
|
+
out.append(candidate)
|
|
319
|
+
for _, child in vars(candidate).items():
|
|
320
|
+
if isinstance(child, type):
|
|
321
|
+
visit(child)
|
|
322
|
+
|
|
299
323
|
for _, obj in vars(self.model_module).items():
|
|
300
|
-
if isinstance(obj, type)
|
|
301
|
-
|
|
324
|
+
if isinstance(obj, type):
|
|
325
|
+
visit(obj)
|
|
302
326
|
return out
|
|
303
327
|
|
|
304
328
|
def _patch_dataclasses(self) -> None:
|
|
305
329
|
for cls in self._iter_destination_classes():
|
|
330
|
+
if cls in self._orig_inits:
|
|
331
|
+
continue
|
|
306
332
|
orig_init_obj = vars(cls).get("__init__")
|
|
307
333
|
if not callable(orig_init_obj):
|
|
308
334
|
continue
|
|
@@ -316,36 +342,58 @@ class TraceContext:
|
|
|
316
342
|
__orig: InitCallable = orig_init,
|
|
317
343
|
**kwargs: t.Any,
|
|
318
344
|
) -> None:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
xml_self_name = _xml_class_name(__cls)
|
|
346
|
+
direct_leaves: list[LeafRecord] = []
|
|
347
|
+
kwargs_for_init: dict[str, t.Any] = dict(kwargs)
|
|
348
|
+
|
|
349
|
+
for f in dataclasses.fields(__cls):
|
|
350
|
+
if f.name not in kwargs_for_init:
|
|
351
|
+
continue
|
|
352
|
+
raw_value = kwargs_for_init[f.name]
|
|
353
|
+
unwrapped, leaves = _unwrap_field_value(__cls, f.name, raw_value)
|
|
354
|
+
kwargs_for_init[f.name] = unwrapped
|
|
355
|
+
for segments, src_path in leaves:
|
|
356
|
+
direct_leaves.append(([xml_self_name] + segments, src_path))
|
|
357
|
+
|
|
358
|
+
__orig(_self, *args, **kwargs_for_init)
|
|
359
|
+
|
|
360
|
+
all_leaves = list(direct_leaves)
|
|
361
|
+
for f in dataclasses.fields(__cls):
|
|
362
|
+
with contextlib.suppress(Exception):
|
|
363
|
+
field_value = getattr(_self, f.name)
|
|
364
|
+
if dataclasses.is_dataclass(field_value):
|
|
365
|
+
nested_name = _nested_segment_name(
|
|
366
|
+
__cls, f.name, field_value
|
|
367
|
+
)
|
|
368
|
+
for segments, src_path in _get_leaves(field_value):
|
|
369
|
+
tail = segments[1:] if segments else []
|
|
370
|
+
all_leaves.append(
|
|
371
|
+
([xml_self_name, nested_name] + tail, src_path)
|
|
372
|
+
)
|
|
373
|
+
elif isinstance(field_value, (list, tuple)):
|
|
374
|
+
for item in field_value:
|
|
375
|
+
if dataclasses.is_dataclass(item):
|
|
376
|
+
nested_name = _nested_segment_name(
|
|
377
|
+
__cls, f.name, item
|
|
378
|
+
)
|
|
379
|
+
for segments, src_path in _get_leaves(item):
|
|
380
|
+
tail = segments[1:] if segments else []
|
|
381
|
+
all_leaves.append(
|
|
382
|
+
(
|
|
383
|
+
[xml_self_name, nested_name] + tail,
|
|
384
|
+
src_path,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
setattr(_self, TRACER_LEAVES_ATTR, all_leaves)
|
|
336
389
|
|
|
337
390
|
type.__setattr__(cls, "__init__", wrapper)
|
|
338
|
-
self._patched_classes.append(cls)
|
|
339
391
|
|
|
340
392
|
def _restore_dataclasses(self) -> None:
|
|
341
393
|
with contextlib.suppress(Exception):
|
|
342
394
|
for cls, orig in self._orig_inits.items():
|
|
343
395
|
type.__setattr__(cls, "__init__", orig)
|
|
344
|
-
|
|
345
396
|
self._orig_inits.clear()
|
|
346
|
-
self._patched_classes.clear()
|
|
347
|
-
|
|
348
|
-
# ---- patch enum meta (unwrap Tagged keys) ----
|
|
349
397
|
|
|
350
398
|
def _patch_enum_meta(self) -> None:
|
|
351
399
|
if self._orig_enummeta_getitem is not None:
|
|
@@ -358,11 +406,14 @@ class TraceContext:
|
|
|
358
406
|
)
|
|
359
407
|
|
|
360
408
|
def patched_getitem(cls: type[enum.Enum], name: t.Any) -> t.Any:
|
|
361
|
-
if isinstance(name, Tagged):
|
|
362
|
-
name = name.value
|
|
363
409
|
getter = self._orig_enummeta_getitem
|
|
364
410
|
if getter is None:
|
|
365
411
|
raise RuntimeError("EnumMeta.__getitem__ patch is not installed")
|
|
412
|
+
|
|
413
|
+
if isinstance(name, Tagged):
|
|
414
|
+
member = getter(cls, name.value)
|
|
415
|
+
return Tagged(member, name.path)
|
|
416
|
+
|
|
366
417
|
return getter(cls, name)
|
|
367
418
|
|
|
368
419
|
type.__setattr__(enum.EnumMeta, "__getitem__", patched_getitem)
|
|
@@ -372,18 +423,12 @@ class TraceContext:
|
|
|
372
423
|
type.__setattr__(enum.EnumMeta, "__getitem__", self._orig_enummeta_getitem)
|
|
373
424
|
self._orig_enummeta_getitem = None
|
|
374
425
|
|
|
375
|
-
# ---- lift helper functions ----
|
|
376
|
-
|
|
377
426
|
def _resolve_lift_target(self, spec: str) -> tuple[types.ModuleType, str] | None:
|
|
378
|
-
"""
|
|
379
|
-
Resolve 'name' in converter's globals, or 'pkg.mod:func' fully-qualified.
|
|
380
|
-
Returns (module_object, attribute_name).
|
|
381
|
-
"""
|
|
382
427
|
if ":" in spec:
|
|
383
428
|
mod_name, attr = spec.split(":", 1)
|
|
384
429
|
imported_mod = __import__(mod_name, fromlist=[attr])
|
|
385
430
|
return (imported_mod, attr)
|
|
386
|
-
|
|
431
|
+
|
|
387
432
|
converter_mod = inspect.getmodule(self.converter)
|
|
388
433
|
if converter_mod and hasattr(converter_mod, spec):
|
|
389
434
|
return (converter_mod, spec)
|
|
@@ -400,7 +445,6 @@ class TraceContext:
|
|
|
400
445
|
def lifted(
|
|
401
446
|
*args: t.Any, __orig: t.Callable[..., t.Any] = orig, **kwargs: t.Any
|
|
402
447
|
) -> t.Any:
|
|
403
|
-
# If any arg is Tagged, unwrap the value(s), remember first path
|
|
404
448
|
first_tag_path: str | None = None
|
|
405
449
|
|
|
406
450
|
def unwrap(x: t.Any) -> t.Any:
|
|
@@ -413,12 +457,9 @@ class TraceContext:
|
|
|
413
457
|
ua = tuple(unwrap(a) for a in args)
|
|
414
458
|
uk = {k: unwrap(v) for k, v in kwargs.items()}
|
|
415
459
|
out = __orig(*ua, **uk)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
and not dataclasses.is_dataclass(out)
|
|
420
|
-
and not isinstance(out, (list, tuple, dict))
|
|
421
|
-
):
|
|
460
|
+
if first_tag_path is not None:
|
|
461
|
+
if isinstance(out, Tagged):
|
|
462
|
+
return out
|
|
422
463
|
return Tagged(out, first_tag_path)
|
|
423
464
|
return out
|
|
424
465
|
|
|
@@ -429,5 +470,4 @@ class TraceContext:
|
|
|
429
470
|
with contextlib.suppress(Exception):
|
|
430
471
|
for mod, attr, orig in self._lifted:
|
|
431
472
|
setattr(mod, attr, orig)
|
|
432
|
-
|
|
433
473
|
self._lifted.clear()
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import dataclasses
|
|
4
|
-
import datetime as dt
|
|
5
|
-
import inspect
|
|
6
|
-
import types
|
|
7
|
-
import typing as t
|
|
8
|
-
from decimal import Decimal
|
|
9
|
-
from enum import Enum
|
|
10
|
-
from uuid import UUID
|
|
11
|
-
|
|
12
|
-
T = t.TypeVar("T")
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _is_dataclass_type(tp: t.Any) -> t.TypeGuard[type[t.Any]]:
|
|
16
|
-
return inspect.isclass(tp) and dataclasses.is_dataclass(tp)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _dataclass_fields(cls: type[t.Any]) -> tuple[dataclasses.Field[t.Any], ...]:
|
|
20
|
-
return dataclasses.fields(cls)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _is_optional(tp: t.Any) -> tuple[bool, t.Any]:
|
|
24
|
-
"""Return (is_optional, inner_type)."""
|
|
25
|
-
origin = t.get_origin(tp)
|
|
26
|
-
if origin in (t.Union, types.UnionType): # Optional[T] is Union[T, NoneType]
|
|
27
|
-
args = [a for a in t.get_args(tp) if a is not type(None)]
|
|
28
|
-
if len(args) == 1:
|
|
29
|
-
return True, args[0]
|
|
30
|
-
return False, tp
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _is_list(tp: t.Any) -> tuple[bool, t.Any]:
|
|
34
|
-
origin = t.get_origin(tp)
|
|
35
|
-
if origin in (list, list):
|
|
36
|
-
args = t.get_args(tp)
|
|
37
|
-
return True, (args[0] if args else t.Any)
|
|
38
|
-
return False, tp
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _is_dict(tp: t.Any) -> tuple[bool, t.Any, t.Any]:
|
|
42
|
-
origin = t.get_origin(tp)
|
|
43
|
-
if origin in (dict, dict):
|
|
44
|
-
args = t.get_args(tp)
|
|
45
|
-
key_t = args[0] if args else t.Any
|
|
46
|
-
val_t = args[1] if len(args) > 1 else t.Any
|
|
47
|
-
return True, key_t, val_t
|
|
48
|
-
return False, tp, tp
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _safe_first_enum(enum_cls: type[Enum]) -> Enum:
|
|
52
|
-
try:
|
|
53
|
-
return next(iter(enum_cls))
|
|
54
|
-
except StopIteration:
|
|
55
|
-
raise ValueError(f"Enum {enum_cls!r} has no members") from None
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _default_for_type(tp: t.Any) -> t.Any:
|
|
59
|
-
"""Produce a non-empty, truthy default for a scalar-ish type."""
|
|
60
|
-
if tp in (str, str, t.Any):
|
|
61
|
-
return "x"
|
|
62
|
-
if tp in (int,):
|
|
63
|
-
return 1
|
|
64
|
-
if tp in (float,):
|
|
65
|
-
return 1.0
|
|
66
|
-
if tp is Decimal:
|
|
67
|
-
return Decimal("1")
|
|
68
|
-
if tp is bool:
|
|
69
|
-
return True
|
|
70
|
-
if tp is dt.date:
|
|
71
|
-
return dt.date.today()
|
|
72
|
-
if tp is dt.datetime:
|
|
73
|
-
return dt.datetime.now()
|
|
74
|
-
if tp is UUID:
|
|
75
|
-
return UUID(int=1)
|
|
76
|
-
return "x" # generic truthy fallback
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _apply_overrides(obj: t.Any, overrides: dict[str, t.Any]) -> t.Any:
|
|
80
|
-
"""
|
|
81
|
-
Apply dotted-path overrides like:
|
|
82
|
-
"foo.bar[2].baz": 42
|
|
83
|
-
Works with dataclasses, lists, and dicts.
|
|
84
|
-
"""
|
|
85
|
-
for path, value in overrides.items():
|
|
86
|
-
parts = [p for p in path.split(".") if p]
|
|
87
|
-
target = obj
|
|
88
|
-
for i, token in enumerate(parts):
|
|
89
|
-
# handle [idx] suffix
|
|
90
|
-
name, idx = token, None
|
|
91
|
-
if "[" in token and token.endswith("]"):
|
|
92
|
-
name, idx = (
|
|
93
|
-
token[: token.index("[")],
|
|
94
|
-
int(token[token.index("[") + 1 : -1]),
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
if i == len(parts) - 1:
|
|
98
|
-
# set leaf
|
|
99
|
-
if idx is None:
|
|
100
|
-
if dataclasses.is_dataclass(target):
|
|
101
|
-
setattr(target, name, value)
|
|
102
|
-
elif isinstance(target, dict):
|
|
103
|
-
target[name] = value
|
|
104
|
-
else:
|
|
105
|
-
setattr(target, name, value)
|
|
106
|
-
else:
|
|
107
|
-
seq = getattr(target, name)
|
|
108
|
-
seq[idx] = value
|
|
109
|
-
break
|
|
110
|
-
|
|
111
|
-
# descend
|
|
112
|
-
if dataclasses.is_dataclass(target):
|
|
113
|
-
target = getattr(target, name)
|
|
114
|
-
elif isinstance(target, dict):
|
|
115
|
-
target = target[name]
|
|
116
|
-
else:
|
|
117
|
-
target = getattr(target, name)
|
|
118
|
-
|
|
119
|
-
if idx is not None:
|
|
120
|
-
target = target[idx]
|
|
121
|
-
return obj
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _shape_dataclass(
|
|
125
|
-
cls: type[T],
|
|
126
|
-
*,
|
|
127
|
-
list_len: int,
|
|
128
|
-
overrides: dict[str, t.Any] | None,
|
|
129
|
-
seen: set[type],
|
|
130
|
-
) -> T:
|
|
131
|
-
if cls in seen:
|
|
132
|
-
# stop infinite recursion; build minimal
|
|
133
|
-
minimal_kwargs: dict[str, t.Any] = {}
|
|
134
|
-
for f in _dataclass_fields(cls):
|
|
135
|
-
minimal_kwargs[f.name] = None
|
|
136
|
-
return cls(**minimal_kwargs)
|
|
137
|
-
seen.add(cls)
|
|
138
|
-
|
|
139
|
-
kwargs: dict[str, t.Any] = {}
|
|
140
|
-
for f in _dataclass_fields(cls):
|
|
141
|
-
tp = f.type
|
|
142
|
-
is_opt, inner = _is_optional(tp)
|
|
143
|
-
if is_opt:
|
|
144
|
-
tp = inner
|
|
145
|
-
|
|
146
|
-
is_list, elem_t = _is_list(tp)
|
|
147
|
-
if is_list:
|
|
148
|
-
elems: list[t.Any] = []
|
|
149
|
-
for _ in range(list_len):
|
|
150
|
-
if _is_dataclass_type(elem_t):
|
|
151
|
-
elems.append(
|
|
152
|
-
_shape_dataclass(
|
|
153
|
-
elem_t, list_len=list_len, overrides=None, seen=seen
|
|
154
|
-
)
|
|
155
|
-
)
|
|
156
|
-
else:
|
|
157
|
-
# nested optionals, enums, scalars
|
|
158
|
-
opt2, inner2 = _is_optional(elem_t)
|
|
159
|
-
if opt2:
|
|
160
|
-
elem_t = inner2
|
|
161
|
-
if inspect.isclass(elem_t) and issubclass(elem_t, Enum):
|
|
162
|
-
elems.append(_safe_first_enum(elem_t))
|
|
163
|
-
else:
|
|
164
|
-
elems.append(_default_for_type(elem_t))
|
|
165
|
-
kwargs[f.name] = elems
|
|
166
|
-
continue
|
|
167
|
-
|
|
168
|
-
is_dict, key_t, val_t = _is_dict(tp)
|
|
169
|
-
if is_dict:
|
|
170
|
-
# make a tiny dict with one truthy item
|
|
171
|
-
k = "k" if key_t is str else 1
|
|
172
|
-
if _is_dataclass_type(val_t):
|
|
173
|
-
v = _shape_dataclass(
|
|
174
|
-
val_t, list_len=list_len, overrides=None, seen=seen
|
|
175
|
-
)
|
|
176
|
-
else:
|
|
177
|
-
v = _default_for_type(val_t)
|
|
178
|
-
kwargs[f.name] = {k: v}
|
|
179
|
-
continue
|
|
180
|
-
|
|
181
|
-
# nested dataclass
|
|
182
|
-
if _is_dataclass_type(tp):
|
|
183
|
-
kwargs[f.name] = _shape_dataclass(
|
|
184
|
-
tp, list_len=list_len, overrides=None, seen=seen
|
|
185
|
-
)
|
|
186
|
-
continue
|
|
187
|
-
|
|
188
|
-
# enums
|
|
189
|
-
if inspect.isclass(tp) and issubclass(tp, Enum):
|
|
190
|
-
kwargs[f.name] = _safe_first_enum(tp)
|
|
191
|
-
continue
|
|
192
|
-
|
|
193
|
-
# scalar fallback
|
|
194
|
-
kwargs[f.name] = _default_for_type(tp)
|
|
195
|
-
|
|
196
|
-
inst = cls(**kwargs)
|
|
197
|
-
if overrides:
|
|
198
|
-
_apply_overrides(inst, overrides)
|
|
199
|
-
seen.remove(cls)
|
|
200
|
-
return inst
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def make_shape(
|
|
204
|
-
spec: type[T] | T | t.Callable[[], T],
|
|
205
|
-
*,
|
|
206
|
-
list_len: int = 1,
|
|
207
|
-
overrides: dict[str, t.Any] | None = None,
|
|
208
|
-
) -> T:
|
|
209
|
-
"""
|
|
210
|
-
Construct a 'truthy' instance for the given facade class/instance/factory.
|
|
211
|
-
|
|
212
|
-
- spec: dataclass class, an instance (will be cloned shallowly), or a zero-arg factory
|
|
213
|
-
- list_len: default length for list fields
|
|
214
|
-
- overrides: dotted-path -> value to force (supports [index])
|
|
215
|
-
|
|
216
|
-
Heuristics:
|
|
217
|
-
- dataclasses: recursively instantiate with non-empty defaults
|
|
218
|
-
- Optional[T]: pick T with a non-empty value
|
|
219
|
-
- enums: pick first member
|
|
220
|
-
- str: "x"; int: 1; Decimal: 1; bool: True; date: today
|
|
221
|
-
- lists: [truthy(T) for _ in range(list_len)]
|
|
222
|
-
"""
|
|
223
|
-
if inspect.isclass(spec):
|
|
224
|
-
if dataclasses.is_dataclass(spec):
|
|
225
|
-
base = _shape_dataclass(
|
|
226
|
-
t.cast(type[T], spec),
|
|
227
|
-
list_len=list_len,
|
|
228
|
-
overrides=overrides,
|
|
229
|
-
seen=set(),
|
|
230
|
-
)
|
|
231
|
-
else:
|
|
232
|
-
raise TypeError(
|
|
233
|
-
"make_shape expects a dataclass class/instance or a zero-arg factory"
|
|
234
|
-
)
|
|
235
|
-
elif dataclasses.is_dataclass(spec):
|
|
236
|
-
# already an instance: (re)apply overrides and return
|
|
237
|
-
base = t.cast(T, spec)
|
|
238
|
-
if overrides:
|
|
239
|
-
_apply_overrides(base, overrides)
|
|
240
|
-
elif callable(spec):
|
|
241
|
-
base = spec()
|
|
242
|
-
else:
|
|
243
|
-
raise TypeError(
|
|
244
|
-
"make_shape expects a dataclass class/instance or a zero-arg factory"
|
|
245
|
-
)
|
|
246
|
-
return base
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|