sdcvalidator 4.5.2__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.
Files changed (35) hide show
  1. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/PKG-INFO +33 -3
  2. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/README.md +32 -2
  3. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/pyproject.toml +1 -1
  4. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/__init__.py +7 -2
  5. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/exceptions.py +27 -0
  6. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/mcp_server.py +22 -7
  7. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/validator.py +107 -44
  8. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/PKG-INFO +33 -3
  9. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/SOURCES.txt +2 -0
  10. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_recovery.py +1 -1
  11. sdcvalidator-4.6.0/tests/test_recovery_460.py +119 -0
  12. sdcvalidator-4.6.0/tests/test_registry_launch.py +79 -0
  13. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/LICENSE +0 -0
  14. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/setup.cfg +0 -0
  15. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/cli.py +0 -0
  16. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/constants.py +0 -0
  17. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/converters.py +0 -0
  18. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/error_classifier.py +0 -0
  19. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/error_mapper.py +0 -0
  20. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/instance_modifier.py +0 -0
  21. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/schema_checker.py +0 -0
  22. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator/xsd11_restriction.py +0 -0
  23. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/dependency_links.txt +0 -0
  24. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/entry_points.txt +0 -0
  25. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/requires.txt +0 -0
  26. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/src/sdcvalidator.egg-info/top_level.txt +0 -0
  27. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_converters.py +0 -0
  28. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_error_classifier.py +0 -0
  29. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_error_mapper.py +0 -0
  30. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_instance_modifier.py +0 -0
  31. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_mcp_server.py +0 -0
  32. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_schema_checker.py +0 -0
  33. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_validator.py +0 -0
  34. {sdcvalidator-4.5.2 → sdcvalidator-4.6.0}/tests/test_xsd11_restriction.py +0 -0
  35. {sdcvalidator-4.5.2 → 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.5.2
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
@@ -151,9 +177,13 @@ sdcvalidator-json2xml data.json schema.xsd -o output.xml
151
177
  `sdcvalidator` ships a stdio MCP (Model Context Protocol) server so any MCP-capable agent can validate SDC4 data without importing the Python library. It implements JSON-RPC 2.0 directly over stdio — no external MCP SDK dependency.
152
178
 
153
179
  ```bash
154
- sdcvalidator-mcp serve --mcp
180
+ sdcvalidator # what an MCP registry client runs (uvx sdcvalidator)
181
+ sdcvalidator mcp # the same, spelled like the other Semantic Data Charter servers
182
+ sdcvalidator-mcp serve --mcp # still works for existing client configurations
155
183
  ```
156
184
 
185
+ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
186
+
157
187
  ### Tools
158
188
 
159
189
  | Tool | Purpose |
@@ -169,7 +199,7 @@ sdcvalidator-mcp serve --mcp
169
199
  | Tier | Type | Examples | Action |
170
200
  |------|------|----------|--------|
171
201
  | 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
172
- | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report** |
202
+ | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
173
203
 
174
204
  ## SDC4 Schema Compliance
175
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
@@ -129,9 +155,13 @@ sdcvalidator-json2xml data.json schema.xsd -o output.xml
129
155
  `sdcvalidator` ships a stdio MCP (Model Context Protocol) server so any MCP-capable agent can validate SDC4 data without importing the Python library. It implements JSON-RPC 2.0 directly over stdio — no external MCP SDK dependency.
130
156
 
131
157
  ```bash
132
- sdcvalidator-mcp serve --mcp
158
+ sdcvalidator # what an MCP registry client runs (uvx sdcvalidator)
159
+ sdcvalidator mcp # the same, spelled like the other Semantic Data Charter servers
160
+ sdcvalidator-mcp serve --mcp # still works for existing client configurations
133
161
  ```
134
162
 
163
+ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
164
+
135
165
  ### Tools
136
166
 
137
167
  | Tool | Purpose |
@@ -147,7 +177,7 @@ sdcvalidator-mcp serve --mcp
147
177
  | Tier | Type | Examples | Action |
148
178
  |------|------|----------|--------|
149
179
  | 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
150
- | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report** |
180
+ | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
151
181
 
152
182
  ## SDC4 Schema Compliance
153
183
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sdcvalidator"
7
- version = "4.5.2"
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.5.2"
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.5.2"
39
+ SERVER_VERSION = "4.6.0"
40
40
 
41
41
  # Validator cache: schema_path -> SDC4Validator
42
42
  _validators: dict[str, SDC4Validator] = {}
@@ -290,22 +290,37 @@ def run_stdio() -> None:
290
290
  sys.stdout.flush()
291
291
 
292
292
 
293
- def main() -> None:
294
- """Entry point for the sdcvalidator MCP server."""
293
+ def main(argv: list[str] | None = None) -> None:
294
+ """Entry point of the ``sdcvalidator`` and ``sdcvalidator-mcp`` console scripts.
295
+
296
+ Three spellings start the stdio server: no arguments at all (what an MCP
297
+ registry client runs: ``uvx sdcvalidator``), ``mcp`` (the spelling the other
298
+ Semantic Data Charter servers use) and ``serve --mcp`` (kept for existing
299
+ client configurations). Until 4.5.3 the bare command printed this help and
300
+ exited, so the registry entry never started a server (issue #15).
301
+ """
295
302
  import argparse
296
303
 
297
304
  parser = argparse.ArgumentParser(
298
- prog="sdcvalidator-mcp",
299
- description="SDC4 structural validator - MCP server",
305
+ prog="sdcvalidator",
306
+ description="SDC4 structural validator - MCP stdio server",
307
+ epilog="With no command the MCP server starts, reading JSON-RPC on stdin.",
300
308
  )
301
309
  subparsers = parser.add_subparsers(dest="command")
302
310
 
311
+ subparsers.add_parser("mcp", help="Run the MCP stdio server")
303
312
  serve_parser = subparsers.add_parser("serve", help="Start the validation server")
304
313
  serve_parser.add_argument("--mcp", action="store_true", help="Run as MCP stdio server")
305
314
 
306
- args = parser.parse_args()
315
+ args = parser.parse_args(argv)
307
316
 
308
- if args.command == "serve" and args.mcp:
317
+ if args.command is None or args.command == "mcp" or (args.command == "serve" and args.mcp):
318
+ if args.command is None and sys.stdin.isatty():
319
+ sys.stderr.write(
320
+ "sdcvalidator: MCP server reading JSON-RPC 2.0 on stdin "
321
+ "(sdcvalidator --help for options, Ctrl-D to stop)\n"
322
+ )
323
+ sys.stderr.flush()
309
324
  run_stdio()
310
325
  else:
311
326
  parser.print_help()
@@ -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 validate_with_recovery(
327
+ def recover(
300
328
  self,
301
329
  xml_source: Union[str, Path, ET.Element, XMLResource],
302
- output_path: Optional[Union[str, Path]] = None,
303
- remove_existing_ev: bool = True,
304
- save: bool = True,
305
- ) -> ET.ElementTree:
330
+ remove_existing_ev: bool = False,
331
+ ) -> RecoveryResult:
306
332
  """
307
- Validate an XML instance and insert ExceptionalValue elements for errors.
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 with ExceptionalValue tags.
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
- The recovered XML is saved unless save=False. When saving and no
313
- output_path is given, defaults to '{stem}-ev{suffix}' beside the input.
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
- Modified XML ElementTree with ExceptionalValue elements inserted.
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
- structural_errors = []
337
- semantic_errors = []
369
+ result = RecoveryResult(tree=tree)
370
+ covered: Dict[int, Recovery] = {}
338
371
  for error in errors:
339
- if self.error_mapper.is_structural_error(error):
340
- structural_errors.append(error)
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
- semantic_errors.append(error)
391
+ result.unrecoverable.append(error)
392
+ continue
393
+ covered[id(parent)] = rec
394
+ result.recoveries.append(rec)
395
+ return result
343
396
 
344
- # Tier 1: hard reject.
345
- if structural_errors:
346
- raise SDC4StructuralValidationError(structural_errors)
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
- # Tier 2: quarantine-and-tag.
349
- for error in semantic_errors:
350
- ev_type = self.error_mapper.map_error(error)
351
- if ev_type is None:
352
- continue
353
- xpath = error.path
354
- if xpath:
355
- success = self.instance_modifier.insert_exceptional_value(
356
- root=root,
357
- xpath=xpath,
358
- ev_type=ev_type,
359
- reason=error.reason,
360
- )
361
- if not success:
362
- logger.warning(
363
- "Failed to insert ExceptionalValue at xpath '%s': %s",
364
- xpath, error.reason,
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 = True,
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.5.2
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
@@ -151,9 +177,13 @@ sdcvalidator-json2xml data.json schema.xsd -o output.xml
151
177
  `sdcvalidator` ships a stdio MCP (Model Context Protocol) server so any MCP-capable agent can validate SDC4 data without importing the Python library. It implements JSON-RPC 2.0 directly over stdio — no external MCP SDK dependency.
152
178
 
153
179
  ```bash
154
- sdcvalidator-mcp serve --mcp
180
+ sdcvalidator # what an MCP registry client runs (uvx sdcvalidator)
181
+ sdcvalidator mcp # the same, spelled like the other Semantic Data Charter servers
182
+ sdcvalidator-mcp serve --mcp # still works for existing client configurations
155
183
  ```
156
184
 
185
+ A client configuration needs only `"command": "uvx", "args": ["sdcvalidator"]`.
186
+
157
187
  ### Tools
158
188
 
159
189
  | Tool | Purpose |
@@ -169,7 +199,7 @@ sdcvalidator-mcp serve --mcp
169
199
  | Tier | Type | Examples | Action |
170
200
  |------|------|----------|--------|
171
201
  | 1 | Structural | Unknown elements, cardinality violations, wrong nesting | **Reject** |
172
- | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report** |
202
+ | 2 | Semantic | Type errors, pattern violations, enumeration mismatches | **Report**, or recover with an ExceptionalValue |
173
203
 
174
204
  ## SDC4 Schema Compliance
175
205
 
@@ -25,6 +25,8 @@ tests/test_error_mapper.py
25
25
  tests/test_instance_modifier.py
26
26
  tests/test_mcp_server.py
27
27
  tests/test_recovery.py
28
+ tests/test_recovery_460.py
29
+ tests/test_registry_launch.py
28
30
  tests/test_schema_checker.py
29
31
  tests/test_validator.py
30
32
  tests/test_xsd11_restriction.py
@@ -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>Count Units</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
@@ -0,0 +1,79 @@
1
+ """
2
+ The MCP registry entry starts the server (issue #15).
3
+
4
+ ``server.json`` declared no ``packageArguments``, so ``uvx sdcvalidator`` ran
5
+ ``main()`` with no arguments, which printed the CLI help and exited. These
6
+ tests start the console script's entry point the way a registry client, a
7
+ hand-written configuration and the pre-4.5.3 configuration do, and check that
8
+ ``server.json`` names the released version.
9
+ """
10
+ import json
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ import sdcvalidator
19
+ from sdcvalidator.mcp_server import SERVER_VERSION
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ SERVER_JSON = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
23
+
24
+ INITIALIZE = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
25
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}}
26
+
27
+
28
+ def _launch(args):
29
+ proc = subprocess.run(
30
+ [sys.executable, "-m", "sdcvalidator.mcp_server", *args],
31
+ input=json.dumps(INITIALIZE) + "\n",
32
+ capture_output=True, text=True, timeout=60,
33
+ )
34
+ return proc
35
+
36
+
37
+ def _registry_args():
38
+ package = SERVER_JSON["packages"][0]
39
+ return [a["value"] for a in package.get("packageArguments", []) if a["type"] == "positional"]
40
+
41
+
42
+ @pytest.mark.parametrize("args", [
43
+ pytest.param([], id="bare, what a registry client without packageArguments runs"),
44
+ pytest.param(_registry_args(), id="server.json packageArguments"),
45
+ pytest.param(["mcp"], id="mcp"),
46
+ pytest.param(["serve", "--mcp"], id="serve --mcp (pre-4.5.3 configurations)"),
47
+ ])
48
+ def test_each_launch_spelling_answers_initialize(args):
49
+ proc = _launch(args)
50
+ assert proc.returncode == 0, proc.stderr
51
+ lines = [line for line in proc.stdout.splitlines() if line.strip()]
52
+ assert lines, f"no JSON-RPC response; stdout was help text?\n{proc.stdout}"
53
+ response = json.loads(lines[0])
54
+ assert response["id"] == 1
55
+ assert response["result"]["serverInfo"]["name"] == "sdcvalidator"
56
+ assert response["result"]["serverInfo"]["version"] == SERVER_VERSION
57
+ assert "usage:" not in proc.stdout
58
+
59
+
60
+ def test_serve_without_mcp_still_prints_help():
61
+ proc = _launch(["serve"])
62
+ assert proc.returncode == 0
63
+ assert "usage:" in proc.stdout
64
+
65
+
66
+ def test_server_json_declares_the_mcp_launch():
67
+ package = SERVER_JSON["packages"][0]
68
+ assert package["identifier"] == "sdcvalidator"
69
+ assert package["runtimeHint"] == "uvx"
70
+ assert _registry_args() == ["mcp"]
71
+
72
+
73
+ def test_server_json_names_the_released_version():
74
+ # A regex, not tomllib: the test matrix includes Python 3.10.
75
+ pyproject = re.search(r'^version = "([^"]+)"', (ROOT / "pyproject.toml").read_text(encoding="utf-8"), re.M).group(1)
76
+ assert SERVER_JSON["version"] == pyproject
77
+ assert SERVER_JSON["packages"][0]["version"] == pyproject
78
+ assert sdcvalidator.__version__ == pyproject
79
+ assert SERVER_VERSION == pyproject
File without changes
File without changes