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.
@@ -17,4 +17,6 @@ TODO.rst
17
17
  .pytest_cache/
18
18
  htmlcov/
19
19
  __pycache__/
20
- _version.py
20
+ _version.py
21
+ .claude/settings.local.json
22
+ CLAUDE.local.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PathBridge
3
- Version: 0.2.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 truthy sample facade object.
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 truthy sample facade object.
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.2.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, converter=converter, lift=lift, root_tag=root_tag
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
- val = getattr(obj, name)
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 _record_scalar_field(
210
- state: _TracerState, cls: type, field_name: str, value: t.Any
211
- ) -> None:
212
- if isinstance(value, Tagged):
213
- xml_field = _xml_field_name(cls, field_name)
214
- state.add_leaf(xml_field, value.path)
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
- for _, elem in enumerate(value):
223
- if isinstance(elem, Tagged):
224
- xml_field = _xml_field_name(cls, field_name)
225
- # Temporarily push the list item context to compute [i+1]
226
- state.push(xml_field)
227
- # Leaf under this item
228
- state.add_leaf(
229
- _xml_field_name(cls, field_name), elem.path
230
- ) # element name reused
231
- state.pop()
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
- Usage:
243
- with trace_converter(model_module=..., converter=..., ...) as run:
244
- result, rules = run(facade_shape)
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
- return result, dict(self._state.rules)
294
-
295
- # ---- patch dataclasses ----
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) and dataclasses.is_dataclass(obj):
301
- out.append(obj)
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
- # assign a sibling index for this instance (1-based)
320
- name = _xml_class_name(__cls)
321
- self._state.push(name)
322
- try:
323
- # Call the real __init__ first so attributes exist
324
- __orig(_self, *args, **kwargs)
325
- # Now inspect assigned fields from kwargs by name
326
- for f in dataclasses.fields(__cls):
327
- if f.name in kwargs:
328
- val = kwargs[f.name]
329
- if isinstance(val, Tagged):
330
- _record_scalar_field(self._state, __cls, f.name, val)
331
- elif isinstance(val, (list, tuple)):
332
- _record_list_field(self._state, __cls, f.name, val)
333
- # nested dataclasses will record deeper leaves on their own in their own init
334
- finally:
335
- self._state.pop()
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
- # try converter's globals
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
- # Re-wrap scalar outputs to keep the path flowing
417
- if (
418
- first_tag_path is not None
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