sdcvalidator 4.5.3__tar.gz → 4.6.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.
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/PKG-INFO +28 -2
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/README.md +27 -1
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/pyproject.toml +1 -1
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/__init__.py +7 -2
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/exceptions.py +27 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/mcp_server.py +1 -1
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/validator.py +107 -44
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/PKG-INFO +28 -2
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/SOURCES.txt +1 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_recovery.py +1 -1
- sdcvalidator-4.6.0/tests/test_recovery_460.py +119 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/LICENSE +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/setup.cfg +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/cli.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/constants.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/converters.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/error_classifier.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/error_mapper.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/instance_modifier.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/schema_checker.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator/xsd11_restriction.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/dependency_links.txt +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/entry_points.txt +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/requires.txt +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/top_level.txt +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_converters.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_error_classifier.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_error_mapper.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_instance_modifier.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_mcp_server.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_registry_launch.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_schema_checker.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_validator.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_xsd11_restriction.py +0 -0
- {sdcvalidator-4.5.3 → sdcvalidator-4.6.0}/tests/test_xsi_type.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcvalidator
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.6.0
|
|
4
4
|
Summary: SDC4 structural validator with ExceptionalValue recovery — thin wrapper over xmlschema
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -104,6 +104,32 @@ tier = classifier.classify(some_xmlschema_error)
|
|
|
104
104
|
# ErrorTier.STRUCTURAL or ErrorTier.SEMANTIC
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### Exceptional Value recovery
|
|
108
|
+
|
|
109
|
+
A semantic (Tier 2) error does not have to reject a record. Recovery keeps the value and inserts an ISO 21090
|
|
110
|
+
ExceptionalValue beside it (Invalid, for a value that fails its type, pattern, range or enumeration), so the record
|
|
111
|
+
says which value is unusable and why, and the rest of it stays usable.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from sdcvalidator import SDC4Validator, SDC4UnrecoverableError
|
|
115
|
+
|
|
116
|
+
v = SDC4Validator("dm-xxxx.xsd", validation="lax")
|
|
117
|
+
result = v.recover("record.xml") # RecoveryResult; the source is never mutated
|
|
118
|
+
for r in result.recoveries: # Recovery: path, code ("INV"), name, reason, inserted
|
|
119
|
+
print(r.code, r.reason, r.path)
|
|
120
|
+
if not result.ok: # an error no ExceptionalValue covers
|
|
121
|
+
print(result.unrecoverable)
|
|
122
|
+
|
|
123
|
+
tree = v.validate_with_recovery("record.xml", save=False) # raises SDC4UnrecoverableError instead
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- An ExceptionalValue the record already carries (a refusal, a masked field, written where the record was made) is
|
|
127
|
+
kept, and covers its value; recovery never inserts a second one. `remove_existing_ev=True` removes them first.
|
|
128
|
+
- An error recovery cannot cover is returned in `unrecoverable` by `recover()`, and raised as
|
|
129
|
+
`SDC4UnrecoverableError` by `validate_with_recovery()`; it is never skipped.
|
|
130
|
+
- Structural (Tier 1) errors raise `SDC4StructuralValidationError`.
|
|
131
|
+
- Pass an `XMLResource` when the record carries prefixed QNames in content (`xsi:type="sdc4:..."`).
|
|
132
|
+
|
|
107
133
|
### XML/JSON conversion
|
|
108
134
|
|
|
109
135
|
```python
|
|
@@ -173,7 +199,7 @@ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
|
|
|
173
199
|
| Tier | Type | Examples | Action |
|
|
174
200
|
|------|------|----------|--------|
|
|
175
201
|
| 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
|
|
176
|
-
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report
|
|
202
|
+
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
|
|
177
203
|
|
|
178
204
|
## SDC4 Schema Compliance
|
|
179
205
|
|
|
@@ -82,6 +82,32 @@ tier = classifier.classify(some_xmlschema_error)
|
|
|
82
82
|
# ErrorTier.STRUCTURAL or ErrorTier.SEMANTIC
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Exceptional Value recovery
|
|
86
|
+
|
|
87
|
+
A semantic (Tier 2) error does not have to reject a record. Recovery keeps the value and inserts an ISO 21090
|
|
88
|
+
ExceptionalValue beside it (Invalid, for a value that fails its type, pattern, range or enumeration), so the record
|
|
89
|
+
says which value is unusable and why, and the rest of it stays usable.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from sdcvalidator import SDC4Validator, SDC4UnrecoverableError
|
|
93
|
+
|
|
94
|
+
v = SDC4Validator("dm-xxxx.xsd", validation="lax")
|
|
95
|
+
result = v.recover("record.xml") # RecoveryResult; the source is never mutated
|
|
96
|
+
for r in result.recoveries: # Recovery: path, code ("INV"), name, reason, inserted
|
|
97
|
+
print(r.code, r.reason, r.path)
|
|
98
|
+
if not result.ok: # an error no ExceptionalValue covers
|
|
99
|
+
print(result.unrecoverable)
|
|
100
|
+
|
|
101
|
+
tree = v.validate_with_recovery("record.xml", save=False) # raises SDC4UnrecoverableError instead
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- An ExceptionalValue the record already carries (a refusal, a masked field, written where the record was made) is
|
|
105
|
+
kept, and covers its value; recovery never inserts a second one. `remove_existing_ev=True` removes them first.
|
|
106
|
+
- An error recovery cannot cover is returned in `unrecoverable` by `recover()`, and raised as
|
|
107
|
+
`SDC4UnrecoverableError` by `validate_with_recovery()`; it is never skipped.
|
|
108
|
+
- Structural (Tier 1) errors raise `SDC4StructuralValidationError`.
|
|
109
|
+
- Pass an `XMLResource` when the record carries prefixed QNames in content (`xsi:type="sdc4:..."`).
|
|
110
|
+
|
|
85
111
|
### XML/JSON conversion
|
|
86
112
|
|
|
87
113
|
```python
|
|
@@ -151,7 +177,7 @@ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
|
|
|
151
177
|
| Tier | Type | Examples | Action |
|
|
152
178
|
|------|------|----------|--------|
|
|
153
179
|
| 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
|
|
154
|
-
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report
|
|
180
|
+
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
|
|
155
181
|
|
|
156
182
|
## SDC4 Schema Compliance
|
|
157
183
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sdcvalidator"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.6.0"
|
|
8
8
|
description = "SDC4 structural validator with ExceptionalValue recovery — thin wrapper over xmlschema"
|
|
9
9
|
license = "Apache-2.0"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -14,7 +14,7 @@ Thin wrapper over xmlschema with two-tier error classification and the SDC4
|
|
|
14
14
|
"quarantine-and-tag" recovery pattern (ISO 21090 ExceptionalValues).
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
__version__ = "4.
|
|
17
|
+
__version__ = "4.6.0"
|
|
18
18
|
|
|
19
19
|
# etree_tostring is re-exported from xmlschema so callers can serialize the
|
|
20
20
|
# ElementTree returned by validate_with_recovery() without importing xmlschema
|
|
@@ -27,12 +27,14 @@ except ImportError: # pragma: no cover - fallback for older layouts
|
|
|
27
27
|
from .validator import (
|
|
28
28
|
SDC4Validator,
|
|
29
29
|
ValidationResult,
|
|
30
|
+
Recovery,
|
|
31
|
+
RecoveryResult,
|
|
30
32
|
validate_with_recovery,
|
|
31
33
|
)
|
|
32
34
|
from .error_classifier import ErrorClassifier
|
|
33
35
|
from .error_mapper import ErrorMapper
|
|
34
36
|
from .instance_modifier import InstanceModifier
|
|
35
|
-
from .exceptions import SDC4StructuralValidationError
|
|
37
|
+
from .exceptions import SDC4StructuralValidationError, SDC4UnrecoverableError
|
|
36
38
|
from .constants import (
|
|
37
39
|
ErrorTier,
|
|
38
40
|
ExceptionalValueType,
|
|
@@ -56,6 +58,9 @@ __all__ = [
|
|
|
56
58
|
"__version__",
|
|
57
59
|
"SDC4Validator",
|
|
58
60
|
"ValidationResult",
|
|
61
|
+
"Recovery",
|
|
62
|
+
"RecoveryResult",
|
|
63
|
+
"SDC4UnrecoverableError",
|
|
59
64
|
"ErrorClassifier",
|
|
60
65
|
"ErrorMapper",
|
|
61
66
|
"InstanceModifier",
|
|
@@ -61,3 +61,30 @@ class SDC4StructuralValidationError(Exception):
|
|
|
61
61
|
def error_count(self) -> int:
|
|
62
62
|
"""Return the number of structural errors."""
|
|
63
63
|
return len(self.errors)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SDC4UnrecoverableError(Exception):
|
|
67
|
+
"""
|
|
68
|
+
Raised by `validate_with_recovery` when a semantic (Tier 2) error cannot be
|
|
69
|
+
covered by an ExceptionalValue: no ExceptionalValue maps to it, or the
|
|
70
|
+
element it names cannot be found (4.6.0).
|
|
71
|
+
|
|
72
|
+
Before 4.6.0 such an error was logged and skipped, and the record came back
|
|
73
|
+
as recovered with an invalid value nobody had marked. `SDC4Validator.recover()`
|
|
74
|
+
returns these errors in `RecoveryResult.unrecoverable` instead of raising.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, errors: List['XMLSchemaValidationError'], message: str = None):
|
|
78
|
+
self.errors = errors
|
|
79
|
+
if message is None:
|
|
80
|
+
lines = ["Recovery could not cover every error:"]
|
|
81
|
+
for i, error in enumerate(errors, 1):
|
|
82
|
+
lines.append(f" {i}. {error.reason or error}")
|
|
83
|
+
if error.path:
|
|
84
|
+
lines.append(f" at: {error.path}")
|
|
85
|
+
message = "\n".join(lines)
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def error_count(self) -> int:
|
|
90
|
+
return len(self.errors)
|
|
@@ -36,7 +36,7 @@ from sdcvalidator.schema_checker import validate_sdc4_schema_compliance
|
|
|
36
36
|
JSONRPC_VERSION = "2.0"
|
|
37
37
|
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
38
38
|
SERVER_NAME = "sdcvalidator"
|
|
39
|
-
SERVER_VERSION = "4.
|
|
39
|
+
SERVER_VERSION = "4.6.0"
|
|
40
40
|
|
|
41
41
|
# Validator cache: schema_path -> SDC4Validator
|
|
42
42
|
_validators: dict[str, SDC4Validator] = {}
|
|
@@ -31,7 +31,8 @@ from xmlschema.validators.exceptions import XMLSchemaValidationError
|
|
|
31
31
|
from .error_classifier import ErrorClassifier
|
|
32
32
|
from .error_mapper import ErrorMapper
|
|
33
33
|
from .instance_modifier import InstanceModifier
|
|
34
|
-
from .exceptions import SDC4StructuralValidationError
|
|
34
|
+
from .exceptions import SDC4StructuralValidationError, SDC4UnrecoverableError
|
|
35
|
+
from .constants import EXCEPTIONAL_VALUE_CODES, ExceptionalValueType
|
|
35
36
|
from .schema_checker import (
|
|
36
37
|
validate_sdc4_schema_compliance,
|
|
37
38
|
SDC4SchemaValidationError
|
|
@@ -52,6 +53,33 @@ class ValidationResult:
|
|
|
52
53
|
return len(self.structural_errors) + len(self.semantic_errors)
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
@dataclass
|
|
57
|
+
class Recovery:
|
|
58
|
+
"""One value an ExceptionalValue covers after recovery."""
|
|
59
|
+
path: str # the Clark-form path of the element whose value failed (as xmlschema reports it)
|
|
60
|
+
code: str # the ISO 21090 code: "INV"
|
|
61
|
+
name: str # its name: "Invalid"
|
|
62
|
+
reason: str # what failed, from the validator: the constraint, the pattern, the type
|
|
63
|
+
inserted: bool # True if recovery inserted the ExceptionalValue; False if the record already carried one
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RecoveryResult:
|
|
68
|
+
"""What `SDC4Validator.recover()` did: the recovered tree, each value it covered, and each error it could not."""
|
|
69
|
+
tree: ET.ElementTree
|
|
70
|
+
recoveries: List[Recovery] = field(default_factory=list)
|
|
71
|
+
unrecoverable: List[XMLSchemaValidationError] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def ok(self) -> bool:
|
|
75
|
+
"""True when every error is covered by an ExceptionalValue."""
|
|
76
|
+
return not self.unrecoverable
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def inserted(self) -> List[Recovery]:
|
|
80
|
+
return [r for r in self.recoveries if r.inserted]
|
|
81
|
+
|
|
82
|
+
|
|
55
83
|
class SDC4Validator:
|
|
56
84
|
"""
|
|
57
85
|
Validates XML instances against SDC4 data model schemas.
|
|
@@ -296,35 +324,37 @@ class SDC4Validator:
|
|
|
296
324
|
# ExceptionalValue recovery (quarantine-and-tag)
|
|
297
325
|
# ------------------------------------------------------------------ #
|
|
298
326
|
|
|
299
|
-
def
|
|
327
|
+
def recover(
|
|
300
328
|
self,
|
|
301
329
|
xml_source: Union[str, Path, ET.Element, XMLResource],
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
save: bool = True,
|
|
305
|
-
) -> ET.ElementTree:
|
|
330
|
+
remove_existing_ev: bool = False,
|
|
331
|
+
) -> RecoveryResult:
|
|
306
332
|
"""
|
|
307
|
-
Validate an XML instance and
|
|
333
|
+
Validate an XML instance and cover each semantic error with an ExceptionalValue.
|
|
308
334
|
|
|
309
335
|
Tier 1 (structural) errors cause a hard reject (SDC4StructuralValidationError).
|
|
310
|
-
Tier 2 (semantic) errors are quarantined
|
|
336
|
+
Tier 2 (semantic) errors are quarantined: the value stays in the record and an
|
|
337
|
+
ExceptionalValue is inserted beside it, once per value. A value whose element
|
|
338
|
+
already carries an ExceptionalValue (one written where the record was made: a
|
|
339
|
+
refusal, a masked field) is covered by it; nothing is inserted and nothing is
|
|
340
|
+
removed. An error that cannot be covered (no ExceptionalValue maps to it, or
|
|
341
|
+
the element cannot be found) is returned in `unrecoverable`, never skipped.
|
|
311
342
|
|
|
312
|
-
|
|
313
|
-
|
|
343
|
+
Args:
|
|
344
|
+
xml_source: A path, an Element, or an XMLResource (use an XMLResource when
|
|
345
|
+
the instance carries prefixed QNames in content, such as xsi:type).
|
|
346
|
+
remove_existing_ev: Remove every ExceptionalValue already in the record
|
|
347
|
+
first. Off by default since 4.6.0: those written at the source are
|
|
348
|
+
data, and removing them loses why a value is missing.
|
|
314
349
|
|
|
315
350
|
Returns:
|
|
316
|
-
|
|
351
|
+
RecoveryResult: the recovered tree (a copy; the source is never mutated),
|
|
352
|
+
the recoveries, and the unrecoverable errors.
|
|
317
353
|
|
|
318
354
|
Raises:
|
|
319
355
|
SDC4StructuralValidationError: If structural (Tier 1) errors are present.
|
|
320
|
-
ValueError: If save=True but output_path cannot be determined.
|
|
321
356
|
"""
|
|
322
|
-
original_file_path = None
|
|
323
|
-
if isinstance(xml_source, (str, Path)):
|
|
324
|
-
original_file_path = Path(xml_source)
|
|
325
|
-
|
|
326
357
|
tree = self._to_tree(xml_source)
|
|
327
|
-
# Work on a deep copy so the caller's source is never mutated.
|
|
328
358
|
root = copy.deepcopy(tree.getroot())
|
|
329
359
|
tree = ET.ElementTree(root)
|
|
330
360
|
|
|
@@ -332,37 +362,70 @@ class SDC4Validator:
|
|
|
332
362
|
self.instance_modifier.remove_existing_exceptional_values(root)
|
|
333
363
|
|
|
334
364
|
errors = list(self._schema_errors(tree, xml_source))
|
|
365
|
+
structural = [e for e in errors if self.error_mapper.is_structural_error(e)]
|
|
366
|
+
if structural:
|
|
367
|
+
raise SDC4StructuralValidationError(structural)
|
|
335
368
|
|
|
336
|
-
|
|
337
|
-
|
|
369
|
+
result = RecoveryResult(tree=tree)
|
|
370
|
+
covered: Dict[int, Recovery] = {}
|
|
338
371
|
for error in errors:
|
|
339
|
-
|
|
340
|
-
|
|
372
|
+
ev_type = self.error_mapper.map_error(error)
|
|
373
|
+
reason = " ".join((error.reason or error.message or "").split())
|
|
374
|
+
target = self.instance_modifier._find_element_by_xpath(root, error.path) if error.path else None
|
|
375
|
+
parent = self.instance_modifier._find_parent_element(root, target) if target is not None else None
|
|
376
|
+
if ev_type is None or parent is None:
|
|
377
|
+
result.unrecoverable.append(error)
|
|
378
|
+
continue
|
|
379
|
+
if id(parent) in covered: # a second failure in a value already covered
|
|
380
|
+
continue
|
|
381
|
+
existing = next((c for c in parent if isinstance(c.tag, str)
|
|
382
|
+
and c.tag.rsplit("}", 1)[-1] in EXCEPTIONAL_VALUE_CODES), None)
|
|
383
|
+
if existing is not None:
|
|
384
|
+
code = existing.tag.rsplit("}", 1)[-1]
|
|
385
|
+
rec = Recovery(path=error.path, code=code, name=ExceptionalValueType.from_code(code).ev_name,
|
|
386
|
+
reason=reason, inserted=False)
|
|
387
|
+
elif self.instance_modifier.insert_exceptional_value(
|
|
388
|
+
root=root, xpath=error.path, ev_type=ev_type, reason=error.reason):
|
|
389
|
+
rec = Recovery(path=error.path, code=ev_type.code, name=ev_type.ev_name, reason=reason, inserted=True)
|
|
341
390
|
else:
|
|
342
|
-
|
|
391
|
+
result.unrecoverable.append(error)
|
|
392
|
+
continue
|
|
393
|
+
covered[id(parent)] = rec
|
|
394
|
+
result.recoveries.append(rec)
|
|
395
|
+
return result
|
|
343
396
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
397
|
+
def validate_with_recovery(
|
|
398
|
+
self,
|
|
399
|
+
xml_source: Union[str, Path, ET.Element, XMLResource],
|
|
400
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
401
|
+
remove_existing_ev: bool = False,
|
|
402
|
+
save: bool = True,
|
|
403
|
+
) -> ET.ElementTree:
|
|
404
|
+
"""
|
|
405
|
+
Validate an XML instance and insert ExceptionalValue elements for errors.
|
|
347
406
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
407
|
+
Tier 1 (structural) errors cause a hard reject (SDC4StructuralValidationError).
|
|
408
|
+
Tier 2 (semantic) errors are quarantined with ExceptionalValue tags (see
|
|
409
|
+
`recover()`, which also returns what was covered and why). An error that
|
|
410
|
+
cannot be covered raises SDC4UnrecoverableError rather than returning a
|
|
411
|
+
record with an invalid value nobody marked (4.6.0).
|
|
412
|
+
|
|
413
|
+
The recovered XML is saved unless save=False. When saving and no
|
|
414
|
+
output_path is given, defaults to '{stem}-ev{suffix}' beside the input.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Modified XML ElementTree with ExceptionalValue elements inserted.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
SDC4StructuralValidationError: If structural (Tier 1) errors are present.
|
|
421
|
+
SDC4UnrecoverableError: If a semantic error cannot be covered.
|
|
422
|
+
ValueError: If save=True but output_path cannot be determined.
|
|
423
|
+
"""
|
|
424
|
+
original_file_path = Path(xml_source) if isinstance(xml_source, (str, Path)) else None
|
|
425
|
+
result = self.recover(xml_source, remove_existing_ev=remove_existing_ev)
|
|
426
|
+
if result.unrecoverable:
|
|
427
|
+
raise SDC4UnrecoverableError(result.unrecoverable)
|
|
428
|
+
tree = result.tree
|
|
366
429
|
|
|
367
430
|
if save:
|
|
368
431
|
if output_path is None:
|
|
@@ -396,7 +459,7 @@ class SDC4Validator:
|
|
|
396
459
|
self,
|
|
397
460
|
output_path: Union[str, Path],
|
|
398
461
|
xml_source: Union[str, Path, ET.Element, XMLResource],
|
|
399
|
-
remove_existing_ev: bool =
|
|
462
|
+
remove_existing_ev: bool = False,
|
|
400
463
|
encoding: str = 'UTF-8',
|
|
401
464
|
xml_declaration: bool = True,
|
|
402
465
|
):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcvalidator
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.6.0
|
|
4
4
|
Summary: SDC4 structural validator with ExceptionalValue recovery — thin wrapper over xmlschema
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -104,6 +104,32 @@ tier = classifier.classify(some_xmlschema_error)
|
|
|
104
104
|
# ErrorTier.STRUCTURAL or ErrorTier.SEMANTIC
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### Exceptional Value recovery
|
|
108
|
+
|
|
109
|
+
A semantic (Tier 2) error does not have to reject a record. Recovery keeps the value and inserts an ISO 21090
|
|
110
|
+
ExceptionalValue beside it (Invalid, for a value that fails its type, pattern, range or enumeration), so the record
|
|
111
|
+
says which value is unusable and why, and the rest of it stays usable.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from sdcvalidator import SDC4Validator, SDC4UnrecoverableError
|
|
115
|
+
|
|
116
|
+
v = SDC4Validator("dm-xxxx.xsd", validation="lax")
|
|
117
|
+
result = v.recover("record.xml") # RecoveryResult; the source is never mutated
|
|
118
|
+
for r in result.recoveries: # Recovery: path, code ("INV"), name, reason, inserted
|
|
119
|
+
print(r.code, r.reason, r.path)
|
|
120
|
+
if not result.ok: # an error no ExceptionalValue covers
|
|
121
|
+
print(result.unrecoverable)
|
|
122
|
+
|
|
123
|
+
tree = v.validate_with_recovery("record.xml", save=False) # raises SDC4UnrecoverableError instead
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- An ExceptionalValue the record already carries (a refusal, a masked field, written where the record was made) is
|
|
127
|
+
kept, and covers its value; recovery never inserts a second one. `remove_existing_ev=True` removes them first.
|
|
128
|
+
- An error recovery cannot cover is returned in `unrecoverable` by `recover()`, and raised as
|
|
129
|
+
`SDC4UnrecoverableError` by `validate_with_recovery()`; it is never skipped.
|
|
130
|
+
- Structural (Tier 1) errors raise `SDC4StructuralValidationError`.
|
|
131
|
+
- Pass an `XMLResource` when the record carries prefixed QNames in content (`xsi:type="sdc4:..."`).
|
|
132
|
+
|
|
107
133
|
### XML/JSON conversion
|
|
108
134
|
|
|
109
135
|
```python
|
|
@@ -173,7 +199,7 @@ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
|
|
|
173
199
|
| Tier | Type | Examples | Action |
|
|
174
200
|
|------|------|----------|--------|
|
|
175
201
|
| 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
|
|
176
|
-
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report
|
|
202
|
+
| 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
|
|
177
203
|
|
|
178
204
|
## SDC4 Schema Compliance
|
|
179
205
|
|
|
@@ -72,7 +72,7 @@ class TestSDC4Recovery(unittest.TestCase):
|
|
|
72
72
|
<label>Adult Population</label>
|
|
73
73
|
<xdcount-value>not_a_number</xdcount-value>
|
|
74
74
|
<xdcount-units>
|
|
75
|
-
<label>
|
|
75
|
+
<label>Adult Population_units</label>
|
|
76
76
|
<xdstring-value>people</xdstring-value>
|
|
77
77
|
</xdcount-units>
|
|
78
78
|
</sdc4:ms-q1ey1sf5otsa97e76kb06hco>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Semantic Data Charter Foundation
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
"""
|
|
11
|
+
4.6.0: recovery keeps the ExceptionalValues a record already carries, reports
|
|
12
|
+
what it covered as data, and never skips an error it cannot cover.
|
|
13
|
+
|
|
14
|
+
Before 4.6.0, `validate_with_recovery` removed every ExceptionalValue in the
|
|
15
|
+
record first (so a refusal written where the record was made was lost whenever
|
|
16
|
+
another value failed) and logged and skipped an error no ExceptionalValue maps
|
|
17
|
+
to (so the record came back "recovered" with an invalid value nobody marked).
|
|
18
|
+
"""
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from xml.etree import ElementTree as ET
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from sdcvalidator import (SDC4UnrecoverableError, SDC4Validator, Recovery, RecoveryResult)
|
|
25
|
+
|
|
26
|
+
SCHEMA = Path(__file__).parent / "test_data" / "dm-jsi5yxnvzsmsisgn2bvelkni.xsd"
|
|
27
|
+
SDC4 = "https://semanticdatacharter.com/ns/sdc4/"
|
|
28
|
+
ASKR = '<sdc4:ASKR><ev-name>Asked and Refused</ev-name></sdc4:ASKR>'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def record(count="5", ev="", units_label="Adult Population_units"):
|
|
32
|
+
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<sdc4:dm-jsi5yxnvzsmsisgn2bvelkni xmlns:sdc4="{SDC4}">
|
|
34
|
+
<dm-label>StatePopulation</dm-label>
|
|
35
|
+
<dm-language>en-US</dm-language>
|
|
36
|
+
<dm-encoding>utf-8</dm-encoding>
|
|
37
|
+
<sdc4:ms-wnpz4qyrk369gnsivfsmysdf>
|
|
38
|
+
<label>StatePopulation Data Cluster</label>
|
|
39
|
+
<sdc4:ms-ahfdavxt7dpx960rqi1qtb0l>
|
|
40
|
+
<sdc4:ms-q1ey1sf5otsa97e76kb06hco>
|
|
41
|
+
<label>Adult Population</label>
|
|
42
|
+
{ev}
|
|
43
|
+
<xdcount-value>{count}</xdcount-value>
|
|
44
|
+
<xdcount-units>
|
|
45
|
+
<label>{units_label}</label>
|
|
46
|
+
<xdstring-value>people</xdstring-value>
|
|
47
|
+
</xdcount-units>
|
|
48
|
+
</sdc4:ms-q1ey1sf5otsa97e76kb06hco>
|
|
49
|
+
</sdc4:ms-ahfdavxt7dpx960rqi1qtb0l>
|
|
50
|
+
</sdc4:ms-wnpz4qyrk369gnsivfsmysdf>
|
|
51
|
+
</sdc4:dm-jsi5yxnvzsmsisgn2bvelkni>
|
|
52
|
+
'''
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture(scope="module")
|
|
56
|
+
def validator():
|
|
57
|
+
if not SCHEMA.exists():
|
|
58
|
+
pytest.skip("SDC4 example schema not found")
|
|
59
|
+
return SDC4Validator(SCHEMA, validation="lax")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def element(xml):
|
|
63
|
+
return ET.fromstring(xml)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def evs(tree):
|
|
67
|
+
comp = next(e for e in tree.getroot().iter() if e.tag == f"{{{SDC4}}}ms-q1ey1sf5otsa97e76kb06hco")
|
|
68
|
+
return [c.tag.split("}")[1] for c in comp if c.tag.startswith(f"{{{SDC4}}}")]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_recover_reports_what_it_covered_as_data(validator):
|
|
72
|
+
r = validator.recover(element(record(count="not_a_number")))
|
|
73
|
+
assert isinstance(r, RecoveryResult) and r.ok
|
|
74
|
+
(rec,) = r.recoveries
|
|
75
|
+
assert isinstance(rec, Recovery)
|
|
76
|
+
assert (rec.code, rec.name, rec.inserted) == ("INV", "Invalid", True)
|
|
77
|
+
assert "not_a_number" in rec.reason and rec.path.endswith("xdcount-value")
|
|
78
|
+
assert evs(r.tree) == ["INV"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_a_valid_record_has_nothing_to_recover(validator):
|
|
82
|
+
r = validator.recover(element(record()))
|
|
83
|
+
assert r.ok and r.recoveries == [] and evs(r.tree) == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_an_exceptional_value_written_at_the_source_is_kept(validator):
|
|
87
|
+
tree = validator.validate_with_recovery(element(record(ev=ASKR)), save=False)
|
|
88
|
+
assert evs(tree) == ["ASKR"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_one_already_covering_a_bad_value_is_kept_and_nothing_is_added(validator):
|
|
92
|
+
r = validator.recover(element(record(count="not_a_number", ev=ASKR)))
|
|
93
|
+
(rec,) = r.recoveries
|
|
94
|
+
assert (rec.code, rec.inserted) == ("ASKR", False)
|
|
95
|
+
assert evs(r.tree) == ["ASKR"], "one ExceptionalValue per value"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_removing_existing_ones_is_still_available_when_asked(validator):
|
|
99
|
+
r = validator.recover(element(record(count="not_a_number", ev=ASKR)), remove_existing_ev=True)
|
|
100
|
+
assert evs(r.tree) == ["INV"] and r.recoveries[0].inserted
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_an_error_nothing_covers_is_returned_not_skipped(validator):
|
|
104
|
+
r = validator.recover(element(record(units_label="Count Units")))
|
|
105
|
+
assert not r.ok and len(r.unrecoverable) == 1
|
|
106
|
+
assert "fixed value" in r.unrecoverable[0].reason
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_validate_with_recovery_raises_rather_than_return_an_unmarked_invalid_value(validator):
|
|
110
|
+
with pytest.raises(SDC4UnrecoverableError) as exc:
|
|
111
|
+
validator.validate_with_recovery(element(record(units_label="Count Units")), save=False)
|
|
112
|
+
assert exc.value.error_count == 1 and "fixed value" in str(exc.value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_the_source_is_never_mutated(validator):
|
|
116
|
+
root = element(record(count="not_a_number", ev=ASKR))
|
|
117
|
+
before = ET.tostring(root)
|
|
118
|
+
validator.recover(root)
|
|
119
|
+
assert ET.tostring(root) == before
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|