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.
- pathbridge-0.1.0/.gitignore +20 -0
- pathbridge-0.1.0/LICENSE +20 -0
- pathbridge-0.1.0/PKG-INFO +74 -0
- pathbridge-0.1.0/README.md +45 -0
- pathbridge-0.1.0/pyproject.toml +66 -0
- pathbridge-0.1.0/src/pathbridge/__init__.py +16 -0
- pathbridge-0.1.0/src/pathbridge/adapter.py +146 -0
- pathbridge-0.1.0/src/pathbridge/compiler.py +294 -0
- pathbridge-0.1.0/src/pathbridge/py.typed +0 -0
- pathbridge-0.1.0/src/pathbridge/types.py +86 -0
|
@@ -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
|
pathbridge-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pathbridge/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](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
|
+
[](https://pypi.org/project/pathbridge/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](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
|
+
]
|