PathBridge 0.1.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.
@@ -0,0 +1,20 @@
1
+ *.pyo
2
+ *.pyc
3
+ *.swp
4
+ .DS_Store
5
+ *~
6
+ build/
7
+ dist/
8
+ forgery-py.sublime-project
9
+ forgery-py.sublime-workspace
10
+ docs/_build/
11
+ .idea/
12
+ tmp/
13
+ .coverage
14
+ TODO.rst
15
+ *.egg-info/
16
+ .mypy_cache/
17
+ .pytest_cache/
18
+ htmlcov/
19
+ __pycache__/
20
+ _version.py
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Vitaly Samigullin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: PathBridge
3
+ Version: 0.1.0
4
+ Summary: Translate validator error locations back to your application's schema paths and emit structured errors
5
+ Project-URL: Homepage, https://github.com/pilosus/pathbridge
6
+ Project-URL: Repository, https://github.com/pilosus/pathbridge
7
+ Project-URL: Issues, https://github.com/pilosus/pathbridge/issues
8
+ Author-email: Vitaly Samigullin <vrs@pilosus.org>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.0; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # PathBridge
31
+
32
+ > Bridge validator locations (XPath/JSONPath/JSON Pointer) back to your application model paths, and emit structured errors (Marshmallow-ready).
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/pathbridge.svg)](https://pypi.org/project/pathbridge/)
35
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
+ [![Python](https://img.shields.io/pypi/pyversions/pathbridge.svg)](https://pypi.org/project/pathbridge/)
37
+
38
+ ## Why
39
+
40
+ Validators (XSD/Schematron, JSON Schema) report failures at **document locations** (XPath/JSONPath).
41
+ Your users need errors on **your model** (Pydantic/Marshmallow/dataclasses). PathBridge converts between the two.
42
+
43
+ - Prefix & case tolerant (e.g., `hd:`, `MTR:`).
44
+ - Fixes 1-based indices to Python 0-based.
45
+ - Works with plain mappings or an optional tracer (add-on) that learns rules from your converter.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install pathbridge
51
+ ```
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from pathbridge import compile_rules, translate_location, to_marshmallow
57
+
58
+ # 1. Provide or load rules: destination path -> facade (your app models) path
59
+ rules = {
60
+ "Return[1]/Contact[1]/Phone[1]": "person/phones[0]",
61
+ "Return[1]/Contact[1]/Phone[2]": "person/phones[1]",
62
+ }
63
+
64
+ compiled = compile_rules(rules)
65
+
66
+ # 2. Translate validator location (e.g. from Schematron SVRL)
67
+ loc = "/Return[1]/Contact[1]/Phone[2]"
68
+ print(translate_location(loc, compiled))
69
+ # "person/phones[1]"
70
+
71
+ # 3. Transform error location into a Marshmallow-style error dict
72
+ errors = to_marshmallow([(loc, "Invalid phone")], compiled)
73
+ # {'person': {'phones': {1: ['Invalid phone']}}}
74
+ ```
@@ -0,0 +1,45 @@
1
+ # PathBridge
2
+
3
+ > Bridge validator locations (XPath/JSONPath/JSON Pointer) back to your application model paths, and emit structured errors (Marshmallow-ready).
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/pathbridge.svg)](https://pypi.org/project/pathbridge/)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![Python](https://img.shields.io/pypi/pyversions/pathbridge.svg)](https://pypi.org/project/pathbridge/)
8
+
9
+ ## Why
10
+
11
+ Validators (XSD/Schematron, JSON Schema) report failures at **document locations** (XPath/JSONPath).
12
+ Your users need errors on **your model** (Pydantic/Marshmallow/dataclasses). PathBridge converts between the two.
13
+
14
+ - Prefix & case tolerant (e.g., `hd:`, `MTR:`).
15
+ - Fixes 1-based indices to Python 0-based.
16
+ - Works with plain mappings or an optional tracer (add-on) that learns rules from your converter.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install pathbridge
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ from pathbridge import compile_rules, translate_location, to_marshmallow
28
+
29
+ # 1. Provide or load rules: destination path -> facade (your app models) path
30
+ rules = {
31
+ "Return[1]/Contact[1]/Phone[1]": "person/phones[0]",
32
+ "Return[1]/Contact[1]/Phone[2]": "person/phones[1]",
33
+ }
34
+
35
+ compiled = compile_rules(rules)
36
+
37
+ # 2. Translate validator location (e.g. from Schematron SVRL)
38
+ loc = "/Return[1]/Contact[1]/Phone[2]"
39
+ print(translate_location(loc, compiled))
40
+ # "person/phones[1]"
41
+
42
+ # 3. Transform error location into a Marshmallow-style error dict
43
+ errors = to_marshmallow([(loc, "Invalid phone")], compiled)
44
+ # {'person': {'phones': {1: ['Invalid phone']}}}
45
+ ```
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "PathBridge"
7
+ version = "0.1.0"
8
+ description = "Translate validator error locations back to your application's schema paths and emit structured errors"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Vitaly Samigullin", email = "vrs@pilosus.org" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Typing :: Typed",
27
+ ]
28
+ keywords = []
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "pytest-cov>=4.0",
35
+ "mypy>=1.0",
36
+ "ruff>=0.4",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/pilosus/pathbridge"
41
+ Repository = "https://github.com/pilosus/pathbridge"
42
+ Issues = "https://github.com/pilosus/pathbridge/issues"
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = [
46
+ "/src",
47
+ ]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/pathbridge"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.mypy]
56
+ python_version = "3.10"
57
+ strict = true
58
+ files = ["src/pathbridge"]
59
+
60
+ [tool.ruff]
61
+ target-version = "py310"
62
+ line-length = 88
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
66
+ ignore = ["E501"] # doctrings length
@@ -0,0 +1,16 @@
1
+ import sys
2
+ from importlib.metadata import version as _get_version
3
+
4
+ from .adapter import to_marshmallow
5
+ from .compiler import compile_rules, insert_error, translate_location
6
+
7
+ __version__ = _get_version("pathbridge")
8
+
9
+ version = f"{__version__}, Python {sys.version}"
10
+
11
+ __all__ = [
12
+ "compile_rules",
13
+ "translate_location",
14
+ "insert_error",
15
+ "to_marshmallow",
16
+ ]
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Sequence
4
+ from typing import Any
5
+
6
+ from .compiler import insert_error, translate_location
7
+ from .types import (
8
+ CompiledRulesT,
9
+ ErrorInputT,
10
+ ErrorItemT,
11
+ LocationT,
12
+ MarshmallowValidationErrorT,
13
+ )
14
+
15
+ #
16
+ # Public API
17
+ #
18
+
19
+
20
+ def to_marshmallow(
21
+ items: ErrorInputT,
22
+ compiled_rules: CompiledRulesT,
23
+ *,
24
+ default_message: str = "Invalid",
25
+ include_meta: bool = False,
26
+ ) -> dict[str | int, Any]:
27
+ """
28
+ Translate validator locations to facade paths and fold into a Marshmallow-style nested dict.
29
+
30
+ include_meta adds a `_meta` key-val with translation stats and misses.
31
+
32
+ Returns
33
+ -------
34
+ dict
35
+ Nested structure compatible with Marshmallow errors, e.g.:
36
+ {
37
+ 'mtr': {
38
+ 'sa103s': {
39
+ 6: {'net_profit_or_loss': ['The amount must equal ...']}
40
+ }
41
+ },
42
+ '_meta': { ... } # only when include_meta=True
43
+ }
44
+ """
45
+ errors: MarshmallowValidationErrorT = {}
46
+ total = 0
47
+ matched = 0
48
+ misses: list[tuple[LocationT, str]] = []
49
+
50
+ for loc, msg in _iter_error_pairs(items, default_message):
51
+ total += 1
52
+ facade = translate_location(loc, compiled_rules)
53
+ if facade is None:
54
+ misses.append((loc, msg))
55
+ continue
56
+ insert_error(errors, facade, msg)
57
+ matched += 1
58
+
59
+ # shallow copy for meta injection
60
+ result: dict[str | int, Any] = dict(errors)
61
+ if include_meta:
62
+ result["_meta"] = {
63
+ "total": total,
64
+ "matched": matched,
65
+ "missed": len(misses),
66
+ "misses": [{"location": loc, "message": msg} for (loc, msg) in misses],
67
+ }
68
+ return result
69
+
70
+
71
+ #
72
+ # Helpers
73
+ #
74
+
75
+
76
+ def _iter_error_pairs(items: ErrorInputT, default_message: str) -> Iterator[ErrorItemT]:
77
+ """
78
+ Normalize different error input shapes into (location, message) pairs.
79
+
80
+ Supports:
81
+ - Iterable[tuple[str, str]]
82
+ - Iterable[dict] with 'location' (str|list[str]) and optional 'message' or 'text'
83
+ - Iterable[objects] with .location (Sequence[str]) and .text (Sequence[str])
84
+ """
85
+ for item in items:
86
+ # Case 1: already a (location, message) pair
87
+ if (
88
+ isinstance(item, tuple)
89
+ and len(item) == 2
90
+ and all(isinstance(x, str) for x in item)
91
+ ):
92
+ yield item
93
+ continue
94
+
95
+ # Case 2: dict-like (JSON-derived)
96
+ if isinstance(item, dict) and "location" in item:
97
+ locs = _as_list(item.get("location"))
98
+ msgs = _as_list(item.get("message", item.get("text")))
99
+ for idx, loc in enumerate(locs):
100
+ if not isinstance(loc, str):
101
+ continue
102
+ msg = (
103
+ msgs[idx]
104
+ if idx < len(msgs) and isinstance(msgs[idx], str)
105
+ else default_message
106
+ )
107
+ yield (loc, msg)
108
+ continue
109
+
110
+ # Case 3: Dataclasses with .location / .text
111
+ locs = _as_list(getattr(item, "location", None))
112
+ if locs is not None:
113
+ msgs = _as_list(getattr(item, "text", None))
114
+ loc_list = list(
115
+ locs
116
+ if isinstance(locs, Sequence) and not isinstance(locs, str)
117
+ else [locs]
118
+ )
119
+ msg_list = (
120
+ list(msgs)
121
+ if isinstance(msgs, Sequence) and not isinstance(msgs, str)
122
+ else ([] if msgs is None else [str(msgs)])
123
+ )
124
+ for idx, loc in enumerate(loc_list):
125
+ if not isinstance(loc, str):
126
+ continue
127
+ msg = msg_list[idx] if idx < len(msg_list) else default_message
128
+ yield (loc, msg)
129
+ continue
130
+
131
+ # Fallback: ignore unknown shapes
132
+
133
+
134
+ def _as_list(x: Any) -> list[Any]:
135
+ if x is None:
136
+ return []
137
+ if isinstance(x, list):
138
+ return x
139
+ if isinstance(x, tuple):
140
+ return list(x)
141
+ return [x]
142
+
143
+
144
+ __all__ = [
145
+ "to_marshmallow",
146
+ ]
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .types import (
6
+ CompiledRulesT,
7
+ CompiledRuleT,
8
+ FacadePathT,
9
+ FacadePathTemplateT,
10
+ LocationT,
11
+ MarshmallowValidationErrorT,
12
+ RawRulesMapT,
13
+ )
14
+
15
+ #
16
+ # Public API
17
+ #
18
+
19
+
20
+ def compile_rules(rules: RawRulesMapT) -> CompiledRulesT:
21
+ """
22
+ Compile mappings of {destination_path -> facade_path} into case-insensitive
23
+ regex matchers and facade templates with capture placeholders like '{i0-1}'.
24
+
25
+ - Destination path is typically XPath-like with one-based indices and optional prefixes
26
+ - Each step tolerates an optional namespace prefix and case-insensitive names
27
+ - Any arbitrary leading prefixes are skipped (e.g., '/hd:GovTalkMessage[1]/.../IRenvelope[1]/')
28
+ - Every captured index is available as a named group 'iK'; facade templates use '{iK-1}'
29
+ """
30
+ compiled: list[CompiledRuleT] = []
31
+ for dest, facade in rules.items():
32
+ pattern_str, _, name_to_caps = _dest_segments_with_captures(dest)
33
+ template = _build_facade_template(facade, name_to_caps)
34
+ pat = re.compile(pattern_str, re.IGNORECASE | re.UNICODE)
35
+ compiled.append((pat, template))
36
+ return compiled
37
+
38
+
39
+ def translate_location(
40
+ location: LocationT, compiled_rules: CompiledRulesT
41
+ ) -> FacadePathT | None:
42
+ """
43
+ Return a first matching facade path (e.g., 'mtr/sa103s[6]/foo/bar') if any rule matches the given location.
44
+ If no matches found return None.
45
+ """
46
+ for pat, template in compiled_rules:
47
+ match = pat.match(location)
48
+ if match:
49
+ return _render_template(template, match)
50
+ return None
51
+
52
+
53
+ def insert_error(
54
+ root: MarshmallowValidationErrorT, facade_path: FacadePathT | None, message: str
55
+ ) -> None:
56
+ """
57
+ Insert `message` into a nested dict at `facade_path`.
58
+
59
+ Example result shape:
60
+ {'mtr': {'sa103s': {6: {'net_profit_or_loss': ['...']}}}}
61
+ """
62
+ if not facade_path:
63
+ return
64
+
65
+ parts = _split_path(facade_path)
66
+ node: MarshmallowValidationErrorT = root # current nesting
67
+
68
+ for idx, part in enumerate(parts):
69
+ match = _FACADE_STEP_REGEX.match(part)
70
+ if not match:
71
+ # Fallback: treat the entire token as a plain key
72
+ key = part
73
+ is_last = idx == len(parts) - 1
74
+ if is_last:
75
+ leaf = node.setdefault(key, [])
76
+ if isinstance(leaf, list):
77
+ leaf.append(message)
78
+ else:
79
+ node[key] = [message]
80
+ else:
81
+ node = node.setdefault(key, {}) # type: ignore[assignment]
82
+ continue
83
+
84
+ name = match.group("name")
85
+ idx_str = match.group("idx")
86
+ is_last = idx == len(parts) - 1
87
+
88
+ if idx_str is None:
89
+ # plain object step
90
+ if is_last:
91
+ leaf = node.setdefault(name, [])
92
+ if isinstance(leaf, list):
93
+ leaf.append(message)
94
+ else:
95
+ node[name] = [message]
96
+ else:
97
+ next_node = node.get(name)
98
+ if not isinstance(next_node, dict):
99
+ next_node = {}
100
+ node[name] = next_node
101
+ node = next_node
102
+ else:
103
+ # indexed collection step
104
+ idx = int(idx_str)
105
+ bucket = node.get(name)
106
+ if not isinstance(bucket, dict):
107
+ bucket = {}
108
+ node[name] = bucket
109
+ next_node = bucket.get(idx)
110
+ if is_last:
111
+ if isinstance(next_node, list):
112
+ next_node.append(message)
113
+ else:
114
+ bucket[idx] = [message]
115
+ else:
116
+ if not isinstance(next_node, dict):
117
+ next_node = {}
118
+ bucket[idx] = next_node
119
+ node = next_node
120
+
121
+
122
+ #
123
+ # Const
124
+ #
125
+
126
+ # Placeholder like {i0-1} or {i3} (we support both; -1 means convert 1-based -> 0-based)
127
+ _PLACEHOLDER_REGEX = re.compile(r"\{i(?P<n>\d+)(?P<delta>-1)?\}")
128
+
129
+ # Token parser for facade paths like 'mtr/sa103s[6]/foo/bar'
130
+ _FACADE_STEP_REGEX = re.compile(r"^(?P<name>[^/\[\]]+)(?:\[(?P<idx>\d+)\])?$")
131
+
132
+ # Matches one step of an XPath-like path, with an optional namespace prefix and optional [index]
133
+ # Examples it should match:
134
+ # MTR:Sa103S[7]
135
+ # Sa103S[1]
136
+ # hd:Body[1]
137
+ # NetBusinessLossForTax[2]
138
+ # SelfEmployment[*] (wildcard)
139
+ # Key[@Type='UTR'] (predicate)
140
+ _STEP_REGEX = re.compile(
141
+ r"""
142
+ ^ # start of segment
143
+ (?:(?P<prefix>[A-Za-z][\w.-]*)\:)? # optional ns prefix (e.g. 'MTR:')
144
+ (?P<name>[A-Za-z_][\w.-]*) # local name
145
+ (?:\[(?P<idx>\d+|\*|@[^\]]+)\])? # optional [index], [*] wildcard, or [@predicate]
146
+ $ # end of segment
147
+ """,
148
+ re.VERBOSE,
149
+ )
150
+
151
+ # Allow arbitrary leading prefixes like /hd:GovTalkMessage[1]/.../
152
+ # before the destination root. Anchor at start, optionally skip any prefix.
153
+ _PREFIX_SKIP = r"^.*?"
154
+
155
+ # Optional namespace prefix on each step
156
+ _NS_OPT = r"(?:[A-Za-z][\w.-]*:)?"
157
+
158
+ #
159
+ # Helpers: path parsing, normalization, etc.
160
+ #
161
+
162
+
163
+ def _render_template(
164
+ template: FacadePathTemplateT, match: re.Match[str]
165
+ ) -> FacadePathT:
166
+ def sub_one(mm: re.Match[str]) -> str:
167
+ n = int(mm.group("n"))
168
+ group_name = f"i{n}"
169
+ raw = match.group(group_name)
170
+ if raw is None:
171
+ # If a referenced capture was not present, keep as-is
172
+ return mm.group(0)
173
+ val = int(raw)
174
+ if mm.group("delta"):
175
+ val -= 1
176
+ return str(val)
177
+
178
+ return _PLACEHOLDER_REGEX.sub(sub_one, template)
179
+
180
+
181
+ def _split_path(path: str) -> list[str]:
182
+ """
183
+ Split a path like 'MTR:MTR[1]/MTR:Sa103S[7]/MTR:Foo[1]' into segments
184
+ """
185
+ p = path.strip("/")
186
+ return [s for s in p.split("/") if s]
187
+
188
+
189
+ def _normalize_name(s: str) -> str:
190
+ """
191
+ Normalize a step name for comparison (case-insensitive)
192
+ """
193
+ return re.sub(r"[^A-Za-z0-9]+", "", s).lower()
194
+
195
+
196
+ def _dest_segments_with_captures(
197
+ dest: str,
198
+ ) -> tuple[str, list[tuple[str, str | None]], dict[str, list[int]]]:
199
+ """
200
+ Build a tolerant regex for the destination path and produce a mapping from
201
+ normalized local-names to the capture indices we assign.
202
+
203
+ Returns:
204
+ pattern_str, segments, name_to_capture_indices
205
+
206
+ Where:
207
+ segments = list of (local_name, idx_str_or_None)
208
+ name_to_capture_indices maps normalized local-name -> [capture_ordinals]
209
+ """
210
+ segments = _split_path(dest)
211
+ regex_parts: list[str] = []
212
+ name_to_caps: dict[str, list[int]] = {}
213
+ cap_counter = 0
214
+
215
+ for raw_segment in segments:
216
+ match = _STEP_REGEX.match(raw_segment)
217
+ if not match:
218
+ # Be conservative: treat as literal name without index
219
+ name, idx_val = raw_segment, None
220
+ else:
221
+ name = match.group("name")
222
+ idx_val = match.group("idx") # could be digit, '*', or '@...'
223
+
224
+ norm = _normalize_name(name)
225
+ if idx_val == "*":
226
+ # Wildcard: capture any numeric index
227
+ cap_name = f"i{cap_counter}"
228
+ cap_counter += 1
229
+ name_to_caps.setdefault(norm, []).append(int(cap_name[1:]))
230
+ regex_parts.append(rf"/{_NS_OPT}{re.escape(name)}\[(?P<{cap_name}>\d+)\]")
231
+ elif idx_val is not None and idx_val.isdigit():
232
+ # Explicit numeric index: capture it
233
+ cap_name = f"i{cap_counter}"
234
+ cap_counter += 1
235
+ name_to_caps.setdefault(norm, []).append(int(cap_name[1:]))
236
+ regex_parts.append(rf"/{_NS_OPT}{re.escape(name)}\[(?P<{cap_name}>\d+)\]")
237
+ elif idx_val is not None and idx_val.startswith("@"):
238
+ # XPath predicate like [@Type='UTR']: match it literally (escaped)
239
+ regex_parts.append(rf"/{_NS_OPT}{re.escape(name)}\[{re.escape(idx_val)}\]")
240
+ else:
241
+ # No index or unrecognized: tolerate presence/absence of [n]
242
+ regex_parts.append(rf"/{_NS_OPT}{re.escape(name)}(?:\[\d+\])?")
243
+
244
+ # Anchor, permit any leading prefix, and force end-of-string
245
+ pattern = _PREFIX_SKIP + "".join(regex_parts) + r"$"
246
+ # Also return segments parsed into (local_name, idx_str_or_None)
247
+ parsed_segments: list[tuple[str, str | None]] = []
248
+ for raw_segment in segments:
249
+ match = _STEP_REGEX.match(raw_segment)
250
+ if match:
251
+ parsed_segments.append((match.group("name"), match.group("idx")))
252
+ else:
253
+ parsed_segments.append((raw_segment, None))
254
+ return pattern, parsed_segments, name_to_caps
255
+
256
+
257
+ #
258
+ # Facade template construction
259
+ #
260
+
261
+ _FACADE_INDEX_REGEX = re.compile(r"(?P<name>[^/\[\]]+)\[(?P<idx>\d+)\]")
262
+
263
+
264
+ def _build_facade_template(
265
+ facade: FacadePathT,
266
+ name_to_caps: dict[str, list[int]],
267
+ ) -> FacadePathTemplateT:
268
+ """
269
+ Replace numeric indices in the facade path with placeholders that reference
270
+ the right destination capture groups by normalized element name.
271
+
272
+ Example:
273
+ facade: 'mtr/sa103s[0]/profits_losses_nics_and_cis/net_business_loss_for_tax'
274
+ name_to_caps: {'mtr': [0], 'sa103s': [1], 'profitslossesnicsandcis': [2], ...}
275
+
276
+ -> 'mtr/sa103s[{i1-1}]/profits_losses_nics_and_cis/net_business_loss_for_tax'
277
+ """
278
+ used_per_name: dict[str, int] = {}
279
+
280
+ def repl(m: re.Match[str]) -> str:
281
+ local = m.group("name")
282
+ norm = _normalize_name(local)
283
+ indices = name_to_caps.get(norm)
284
+ if indices:
285
+ # Use the next available capture index for this element name
286
+ used = used_per_name.get(norm, 0)
287
+ # Guard in case of more facade indices than captures of same name
288
+ idx_ord = indices[used] if used < len(indices) else indices[-1]
289
+ used_per_name[norm] = min(used + 1, len(indices))
290
+ return f"{local}" + f"[{{i{idx_ord}-1}}]"
291
+ # If we cannot bind by name, leave the original numeric index as-is
292
+ return m.group(0)
293
+
294
+ return _FACADE_INDEX_REGEX.sub(repl, facade)
File without changes
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable, Mapping, MutableMapping, Sequence
5
+ from typing import (
6
+ Protocol,
7
+ TypeAlias,
8
+ TypedDict,
9
+ Union,
10
+ )
11
+
12
+ #
13
+ # Aliases
14
+ #
15
+
16
+ LocationT = str # validator-reported path (e.g., XPath/JSONPath/Pointer)
17
+ ErrorMessageT = str # error message text
18
+ DestinationPathT = str # key in raw rules (validator/document path)
19
+ FacadePathT = str # your app/model path (facades destination)
20
+ FacadePathTemplateT = (
21
+ str # your app/model path with capture placeholders, e.g 'mtr/sa103s[{i1-1}]'
22
+ )
23
+
24
+ #
25
+ # Rules
26
+ #
27
+
28
+ # Raw rules
29
+ RawRulesMapT = Mapping[DestinationPathT, FacadePathT]
30
+
31
+ # Compiled rule used at runtime
32
+ CompiledRuleT = tuple[re.Pattern[str], FacadePathTemplateT]
33
+ CompiledRulesT = Sequence[CompiledRuleT]
34
+
35
+ #
36
+ # Errors
37
+ #
38
+
39
+ ErrorItemT = tuple[LocationT, ErrorMessageT]
40
+
41
+
42
+ # Think of GovTalk-style error items
43
+ class ErrorItemClassT(Protocol):
44
+ location: Sequence[LocationT] # list of validator locations
45
+ text: Sequence[str] # list of messages
46
+
47
+
48
+ class ErrorItemDictT(TypedDict):
49
+ location: LocationT
50
+ text: str
51
+
52
+
53
+ # Union accepted by adapters
54
+ ErrorInputT = (
55
+ Iterable[ErrorItemT] | Iterable[ErrorItemClassT] | Iterable[ErrorItemDictT]
56
+ )
57
+
58
+ #
59
+ # Adapter related types
60
+ #
61
+
62
+ # A recursive mapping of str/int -> (subtree | list[str] messages)
63
+ # This keeps type-checkers happy when building nested error dicts
64
+ MarshmallowValidationErrorT: TypeAlias = MutableMapping[
65
+ str | int,
66
+ Union["MarshmallowValidationErrorT", list[str]],
67
+ ]
68
+
69
+ #
70
+ # Public API
71
+ #
72
+
73
+ __all__ = [
74
+ "LocationT",
75
+ "DestinationPathT",
76
+ "FacadePathT",
77
+ "FacadePathTemplateT",
78
+ "RawRulesMapT",
79
+ "CompiledRuleT",
80
+ "CompiledRulesT",
81
+ "ErrorItemT",
82
+ "ErrorItemClassT",
83
+ "ErrorItemDictT",
84
+ "ErrorInputT",
85
+ "MarshmallowValidationErrorT",
86
+ ]