sdc-mapper 4.1.0__py3-none-any.whl
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.
- sdc_mapper/__init__.py +29 -0
- sdc_mapper/cli.py +101 -0
- sdc_mapper/contract.py +79 -0
- sdc_mapper/engine.py +108 -0
- sdc_mapper/gate.py +180 -0
- sdc_mapper/log.py +108 -0
- sdc_mapper/mapping.py +113 -0
- sdc_mapper/mcp_server.py +199 -0
- sdc_mapper/placement.py +216 -0
- sdc_mapper/report.py +126 -0
- sdc_mapper/resolve.py +210 -0
- sdc_mapper/schemas/log-entry.schema.json +42 -0
- sdc_mapper/schemas/mapping.schema.json +508 -0
- sdc_mapper/schemas/sdc4.xsd +3492 -0
- sdc_mapper/service.py +104 -0
- sdc_mapper-4.1.0.dist-info/METADATA +110 -0
- sdc_mapper-4.1.0.dist-info/RECORD +22 -0
- sdc_mapper-4.1.0.dist-info/WHEEL +5 -0
- sdc_mapper-4.1.0.dist-info/entry_points.txt +3 -0
- sdc_mapper-4.1.0.dist-info/licenses/LICENSE +201 -0
- sdc_mapper-4.1.0.dist-info/licenses/NOTICE +11 -0
- sdc_mapper-4.1.0.dist-info/top_level.txt +1 -0
sdc_mapper/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""sdc-mapper: the shared core of the Semantic Data Charter's Governed Data Record mappers.
|
|
2
|
+
|
|
3
|
+
A record is checked by the validation gate (valid, valid with Exceptional Values, or refused), read and joined to its
|
|
4
|
+
model by sdcreader, resolved into one shape every mapper receives, mapped by a target mapper, and reported value by
|
|
5
|
+
value: how each value reached the target, or that the target cannot hold it, and for every Exceptional Value whether
|
|
6
|
+
the target could say it too. Every call is appended to a hash-chained log with no values in it.
|
|
7
|
+
|
|
8
|
+
Outbound only. Each target format is its own package (`sdc-gdr2<target>`) registered under the entry-point group
|
|
9
|
+
`sdc_mapper.targets`.
|
|
10
|
+
"""
|
|
11
|
+
from .contract import (CARRIED, DECLARED, DEGRADED, ENTRY_POINT_GROUP, EV_CARRIED, EV_CLASSES, LOOKUP, LOST,
|
|
12
|
+
NOT_REPRESENTABLE, PRESERVED, VALUE_CLASSES, EVRule, MapResult, Mapper, ValueOutcome,
|
|
13
|
+
installed_mappers)
|
|
14
|
+
from .engine import ERROR, CallResult, Engine
|
|
15
|
+
from .gate import REFUSED, VALID, VALID_WITH_EV, Gate, GateResult, Recovery
|
|
16
|
+
from .log import Log, entry_hash
|
|
17
|
+
from .mapping import Mapping, MappingError, Rule, load_mapping
|
|
18
|
+
from .placement import ClusterPlacement, Extra, Placement, place, text_of
|
|
19
|
+
from .report import CoverageReport, IncompleteMapping, build_report
|
|
20
|
+
from .resolve import Exceptional, ResolvedRecord, ResolvedValue, Units, resolve
|
|
21
|
+
|
|
22
|
+
__version__ = "4.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Engine", "CallResult", "Gate", "GateResult", "Recovery", "Log", "entry_hash", "Mapping", "MappingError", "Rule",
|
|
25
|
+
"load_mapping", "place", "Placement", "ClusterPlacement", "Extra", "text_of", "CoverageReport", "IncompleteMapping", "build_report", "ResolvedRecord", "ResolvedValue",
|
|
26
|
+
"Exceptional", "Units", "resolve", "Mapper", "MapResult", "ValueOutcome", "EVRule", "installed_mappers",
|
|
27
|
+
"VALID", "VALID_WITH_EV", "REFUSED", "ERROR", "LOOKUP", "DECLARED", "CARRIED", "NOT_REPRESENTABLE", "VALUE_CLASSES",
|
|
28
|
+
"PRESERVED", "DEGRADED", "EV_CARRIED", "LOST", "EV_CLASSES", "ENTRY_POINT_GROUP", "__version__",
|
|
29
|
+
]
|
sdc_mapper/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""`sdc-map`: the command line for the Governed Data Record mappers.
|
|
2
|
+
|
|
3
|
+
sdc-map targets
|
|
4
|
+
sdc-map map TARGET RECORD [--package DIR | --ct-id CT] [--mapping FILE] [-o DIR] [--log-dir DIR]
|
|
5
|
+
sdc-map check-mapping FILE [--package DIR | --ct-id CT]
|
|
6
|
+
sdc-map fidelity RECORD [--package DIR | --ct-id CT] [--mapping NAME=FILE ...]
|
|
7
|
+
sdc-map log summary [--days N] | sdc-map log verify [--log-dir DIR]
|
|
8
|
+
|
|
9
|
+
Exit status: 0 mapped, 1 refused, 2 error.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from sdcreader import DEFAULT_HOST
|
|
19
|
+
|
|
20
|
+
from .engine import ERROR, Engine
|
|
21
|
+
from .gate import REFUSED
|
|
22
|
+
from .log import Log
|
|
23
|
+
from .mapping import MappingError, load_mapping
|
|
24
|
+
from .service import ServiceError, describe, fidelity, fidelity_text, map_one, targets
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _source(p):
|
|
28
|
+
g = p.add_mutually_exclusive_group()
|
|
29
|
+
g.add_argument("--package", help="A model package directory (dm-<ct>.jsonld and dm-<ct>.xsd).")
|
|
30
|
+
g.add_argument("--ct-id", help="A published model's identifier, fetched from --host.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: list[str] | None = None) -> int:
|
|
34
|
+
p = argparse.ArgumentParser(prog="sdc-map", description="Map Governed Data Records to other formats, every value reported.")
|
|
35
|
+
p.add_argument("--host", default=DEFAULT_HOST)
|
|
36
|
+
p.add_argument("--log-dir", default=os.environ.get("SDC_MAPPER_LOG_DIR", "sdc-mapper-log"))
|
|
37
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
38
|
+
sub.add_parser("targets")
|
|
39
|
+
m = sub.add_parser("map")
|
|
40
|
+
m.add_argument("target")
|
|
41
|
+
m.add_argument("record")
|
|
42
|
+
_source(m)
|
|
43
|
+
m.add_argument("--mapping")
|
|
44
|
+
m.add_argument("-o", "--output-dir")
|
|
45
|
+
c = sub.add_parser("check-mapping")
|
|
46
|
+
c.add_argument("mapping")
|
|
47
|
+
_source(c)
|
|
48
|
+
f = sub.add_parser("fidelity")
|
|
49
|
+
f.add_argument("record")
|
|
50
|
+
_source(f)
|
|
51
|
+
f.add_argument("--mapping", action="append", default=[], metavar="NAME=FILE")
|
|
52
|
+
f.add_argument("--json", action="store_true")
|
|
53
|
+
lg = sub.add_parser("log")
|
|
54
|
+
lg.add_argument("action", choices=["summary", "verify"])
|
|
55
|
+
lg.add_argument("--days", type=float, default=7)
|
|
56
|
+
a = p.parse_args(argv)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
if a.cmd == "targets":
|
|
60
|
+
print(json.dumps(targets(), indent=2))
|
|
61
|
+
return 0
|
|
62
|
+
if a.cmd == "log":
|
|
63
|
+
log = Log(a.log_dir)
|
|
64
|
+
if a.action == "verify":
|
|
65
|
+
ok, msg = log.verify()
|
|
66
|
+
print(msg)
|
|
67
|
+
return 0 if ok else 2
|
|
68
|
+
print(json.dumps(log.summary(days=a.days), indent=2))
|
|
69
|
+
return 0
|
|
70
|
+
engine = Engine(log_dir=a.log_dir, host=a.host)
|
|
71
|
+
if a.cmd == "map":
|
|
72
|
+
r = map_one(engine, a.target, a.record, mapping=a.mapping, ct_id=a.ct_id, directory=a.package, caller="cli")
|
|
73
|
+
out = describe(r, output_dir=a.output_dir or "sdc-map-out")
|
|
74
|
+
if r.report:
|
|
75
|
+
print(r.report.summary())
|
|
76
|
+
print("written:", ", ".join(out["written"]))
|
|
77
|
+
else:
|
|
78
|
+
print(f"{r.outcome}:", *(f"\n {e.get('kind')}: {e.get('reason', '')}" for e in r.errors))
|
|
79
|
+
return {REFUSED: 1, ERROR: 2}.get(r.outcome, 0)
|
|
80
|
+
if a.cmd == "check-mapping":
|
|
81
|
+
mp = load_mapping(a.mapping)
|
|
82
|
+
print(f"ok: {mp.id} {mp.version}, {len(mp.rules)} rules, sha256 {mp.sha256}")
|
|
83
|
+
if a.package or a.ct_id:
|
|
84
|
+
pkg, model = engine.packages.one({"directory": a.package} if a.package else {"ct_id": a.ct_id})
|
|
85
|
+
if (mp.model_ct_id, mp.model_schema_sha256) != (model.ct_id, pkg.sha256):
|
|
86
|
+
print(f"not bound to this model's current schema ({model.ct_id} {pkg.sha256[:12]})")
|
|
87
|
+
return 2
|
|
88
|
+
return 0
|
|
89
|
+
if a.cmd == "fidelity":
|
|
90
|
+
mappings = dict(x.split("=", 1) for x in a.mapping)
|
|
91
|
+
table = fidelity(engine, a.record, mappings=mappings, ct_id=a.ct_id, directory=a.package, caller="cli")
|
|
92
|
+
print(json.dumps(table, indent=2) if a.json else fidelity_text(table))
|
|
93
|
+
return 0
|
|
94
|
+
except (ServiceError, MappingError, OSError) as e:
|
|
95
|
+
print(f"error: {e}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
sdc_mapper/contract.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The contract between the core and a target mapper (PRD sections 10, 11 and the mapper contract).
|
|
2
|
+
|
|
3
|
+
A mapper receives a `ResolvedRecord` and an optional `Mapping`, and returns a `MapResult`: the output files and one
|
|
4
|
+
`ValueOutcome` for every value it was given. It never writes a rejected value as a value, and it accounts for every
|
|
5
|
+
value: the core checks that, and fails the call if any value is missing from the outcomes.
|
|
6
|
+
|
|
7
|
+
Mappers register under the entry-point group `sdc_mapper.targets`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from importlib.metadata import entry_points
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from .mapping import Mapping
|
|
16
|
+
from .resolve import ResolvedRecord
|
|
17
|
+
|
|
18
|
+
#: How a value that holds a value reached the target, in the order the classification tries them (section 11).
|
|
19
|
+
LOOKUP = "lookup"
|
|
20
|
+
DECLARED = "declared"
|
|
21
|
+
CARRIED = "carried"
|
|
22
|
+
NOT_REPRESENTABLE = "not_representable"
|
|
23
|
+
VALUE_CLASSES = (LOOKUP, DECLARED, CARRIED, NOT_REPRESENTABLE)
|
|
24
|
+
|
|
25
|
+
#: What the target did with an Exceptional Value (section 10).
|
|
26
|
+
PRESERVED = "preserved"
|
|
27
|
+
DEGRADED = "degraded"
|
|
28
|
+
EV_CARRIED = "carried"
|
|
29
|
+
LOST = "lost"
|
|
30
|
+
EV_CLASSES = (PRESERVED, DEGRADED, EV_CARRIED, LOST)
|
|
31
|
+
|
|
32
|
+
ENTRY_POINT_GROUP = "sdc_mapper.targets"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ValueOutcome:
|
|
37
|
+
"""What happened to one value. `cls` is a value class, or an Exceptional Value class when the value carries one."""
|
|
38
|
+
position: int
|
|
39
|
+
cls: str
|
|
40
|
+
location: str = "" # where in the target it went: "MEASUREMENT.value_as_number", "OBX-5", ...
|
|
41
|
+
target_code: str = "" # for an Exceptional Value: the code the target wrote ("asked-declined"), if any
|
|
42
|
+
merged_with: list[str] = field(default_factory=list) # for degraded: the ISO 21090 codes the target code also covers
|
|
43
|
+
note: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EVRule:
|
|
48
|
+
"""One row of a mapper's Exceptional Value table: what the target writes for an ISO 21090 code."""
|
|
49
|
+
cls: str # preserved, degraded, carried or lost
|
|
50
|
+
target_code: str = ""
|
|
51
|
+
merged_with: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class MapResult:
|
|
56
|
+
files: dict[str, bytes]
|
|
57
|
+
outcomes: list[ValueOutcome]
|
|
58
|
+
cannot_hold: list[str] = field(default_factory=list) # what the target structurally cannot hold for any record
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class Mapper(Protocol):
|
|
63
|
+
name: str # "edifact", "omop", ...
|
|
64
|
+
target: str # "UN/EDIFACT"
|
|
65
|
+
target_version: str # "D.24A"
|
|
66
|
+
package: str # the distribution name, "sdc-gdr2edifact"
|
|
67
|
+
version: str # the mapper's own version
|
|
68
|
+
ev_table: dict[str, EVRule] # one rule per ISO 21090 code
|
|
69
|
+
|
|
70
|
+
def map(self, record: ResolvedRecord, mapping: Mapping | None) -> MapResult: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def installed_mappers() -> dict[str, type]:
|
|
74
|
+
"""The mappers installed in this environment, by name."""
|
|
75
|
+
found = {}
|
|
76
|
+
for ep in entry_points(group=ENTRY_POINT_GROUP):
|
|
77
|
+
cls = ep.load()
|
|
78
|
+
found[getattr(cls, "name", ep.name)] = cls
|
|
79
|
+
return found
|
sdc_mapper/engine.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""One call, end to end: find the model, check the mapping, gate, read, resolve, map, report, log.
|
|
2
|
+
|
|
3
|
+
Every call ends in exactly one of four outcomes, and every one is logged:
|
|
4
|
+
- `valid` or `valid_with_ev`: mapped; the files and the report are returned;
|
|
5
|
+
- `refused`: the gate refused the record; the errors are returned, no output is written;
|
|
6
|
+
- `error`: the call could not be completed (a mapping for another model, a mapper that did not account for every
|
|
7
|
+
value); the reason is returned.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from sdcreader import DEFAULT_HOST
|
|
17
|
+
from sdcreader.mcp import PackageCache
|
|
18
|
+
from sdcreader.record import read_record
|
|
19
|
+
|
|
20
|
+
from .gate import REFUSED, Gate
|
|
21
|
+
from .log import Log
|
|
22
|
+
from .mapping import Mapping, MappingError
|
|
23
|
+
from .report import CoverageReport, IncompleteMapping, build_report
|
|
24
|
+
from .resolve import resolve
|
|
25
|
+
|
|
26
|
+
ERROR = "error"
|
|
27
|
+
_ROOT = re.compile(rb"<[A-Za-z0-9_]+:dm-([a-z0-9]{24,32})[\s>]")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha(data: bytes) -> str:
|
|
31
|
+
return hashlib.sha256(data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CallResult:
|
|
36
|
+
outcome: str # valid, valid_with_ev, refused or error
|
|
37
|
+
files: dict[str, bytes] = field(default_factory=dict)
|
|
38
|
+
report: CoverageReport | None = None
|
|
39
|
+
errors: list[dict] = field(default_factory=list)
|
|
40
|
+
log_hash: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def mapped(self) -> bool:
|
|
44
|
+
return self.report is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Engine:
|
|
48
|
+
def __init__(self, log_dir: str | Path | None = None, host: str = DEFAULT_HOST, schema_dir: str | Path | None = None):
|
|
49
|
+
self.gate = Gate(schema_dir)
|
|
50
|
+
self.log = Log(log_dir) if log_dir else None
|
|
51
|
+
self.packages = PackageCache(host=host, storage={})
|
|
52
|
+
|
|
53
|
+
def model_for(self, xml: bytes, ct_id: str | None = None, directory: str | None = None):
|
|
54
|
+
source = {k: v for k, v in (("ct_id", ct_id), ("directory", directory)) if v}
|
|
55
|
+
if not source:
|
|
56
|
+
m = _ROOT.search(xml[:4000])
|
|
57
|
+
if not m:
|
|
58
|
+
raise MappingError("the record does not name a model (no sdc4:dm-<ct_id> root); give ct_id or directory")
|
|
59
|
+
source = {"ct_id": m.group(1).decode()}
|
|
60
|
+
return self.packages.one(source)
|
|
61
|
+
|
|
62
|
+
def map_record(self, xml: bytes, mapper, mapping: Mapping | None = None, ct_id: str | None = None,
|
|
63
|
+
directory: str | None = None, caller: str | None = None) -> CallResult:
|
|
64
|
+
base = {"caller": caller or "", "record_sha256": _sha(xml),
|
|
65
|
+
"mapper": {"package": mapper.package, "version": mapper.version, "name": mapper.name},
|
|
66
|
+
"target": {"name": mapper.target, "version": mapper.target_version},
|
|
67
|
+
"mapping": {"id": mapping.id, "version": mapping.version, "sha256": mapping.sha256} if mapping else None}
|
|
68
|
+
try:
|
|
69
|
+
pkg, model = self.model_for(xml, ct_id, directory)
|
|
70
|
+
except Exception as e: # noqa: BLE001 an unreachable model is an error result, logged, never a crash
|
|
71
|
+
return self._finish(CallResult(ERROR, errors=[{"kind": "model", "reason": str(e)}]), base)
|
|
72
|
+
base["model"] = {"ct_id": model.ct_id, "schema_sha256": pkg.sha256}
|
|
73
|
+
try:
|
|
74
|
+
if mapping:
|
|
75
|
+
mapping.check(model.ct_id, pkg.sha256, mapper.target, mapper.target_version)
|
|
76
|
+
except MappingError as e:
|
|
77
|
+
return self._finish(CallResult(ERROR, errors=[{"kind": "mapping", "reason": str(e)}]), base)
|
|
78
|
+
|
|
79
|
+
gate = self.gate.check(xml, pkg, model)
|
|
80
|
+
if gate.outcome == REFUSED:
|
|
81
|
+
return self._finish(CallResult(REFUSED, errors=gate.errors), base)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
resolved = resolve(read_record(gate.xml, model), model, pkg, gate)
|
|
85
|
+
result = mapper.map(resolved, mapping)
|
|
86
|
+
report = build_report(resolved, result, mapper, mapping)
|
|
87
|
+
except IncompleteMapping as e:
|
|
88
|
+
return self._finish(CallResult(ERROR, errors=[{"kind": "incomplete mapping", "reason": str(e)}]), base)
|
|
89
|
+
except MappingError as e: # a code-table miss, a path the target refuses: never a guess
|
|
90
|
+
return self._finish(CallResult(ERROR, errors=[{"kind": "mapping", "reason": str(e)}]), base)
|
|
91
|
+
except Exception as e: # noqa: BLE001 a mapper's failure is reported and logged, not raised into the stream
|
|
92
|
+
return self._finish(CallResult(ERROR, errors=[{"kind": type(e).__name__, "reason": str(e)}]), base)
|
|
93
|
+
|
|
94
|
+
base["record"] = {"instance_id": resolved.instance_id,
|
|
95
|
+
"instance_version": resolved.attributes.get("instance_version", "")}
|
|
96
|
+
base["counts"] = report.counts
|
|
97
|
+
base["exceptional"] = [{"position": e["position"], "ct_id": e["ct_id"], "code": e["code"], "origin": e["origin"],
|
|
98
|
+
"class": e["class"]} for e in report.exceptional]
|
|
99
|
+
base["outputs"] = {name: _sha(data) for name, data in sorted(result.files.items())}
|
|
100
|
+
return self._finish(CallResult(gate.outcome, files=result.files, report=report), base)
|
|
101
|
+
|
|
102
|
+
def _finish(self, result: CallResult, base: dict) -> CallResult:
|
|
103
|
+
if self.log is not None:
|
|
104
|
+
entry = {**base, "outcome": result.outcome,
|
|
105
|
+
# kinds and paths only: a failure reason can quote the value that failed
|
|
106
|
+
"errors": [{"kind": e.get("kind", ""), "path": e.get("path", "")} for e in result.errors]}
|
|
107
|
+
result.log_hash = self.log.append(entry)["hash"]
|
|
108
|
+
return result
|
sdc_mapper/gate.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The validation gate (PRD section 5): every record is valid, valid with Exceptional Values, or refused.
|
|
2
|
+
|
|
3
|
+
The gate uses sdcvalidator's public recovery API (`SDC4Validator.recover()`, 4.6.0), which keeps the Exceptional
|
|
4
|
+
Values a record already carries (a refusal, a masked field: what this line exists to report), inserts one beside each
|
|
5
|
+
value that fails its constraint and reports it as data with the constraint that failed, and returns every error it
|
|
6
|
+
cannot cover rather than skipping it. The gate refuses a record with any such error: nothing is projected that the
|
|
7
|
+
gate has not accounted for.
|
|
8
|
+
|
|
9
|
+
The model's schema includes the SDC4 reference model from https://semanticdatacharter.com/ns/sdc4/sdc4.xsd. The gate
|
|
10
|
+
maps that include to the copy shipped in this package, so validation never touches the network, and a schema is built
|
|
11
|
+
once per SHA-256 and kept for the life of the process.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import io
|
|
17
|
+
import re
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from sdcreader import LEAF_TYPES, Model, ModelPackage
|
|
24
|
+
from sdcvalidator import (SDC4StructuralValidationError, SDC4Validator, build_xsd11_schema, etree_tostring,
|
|
25
|
+
validate_sdc4_schema_compliance)
|
|
26
|
+
from xmlschema import XMLResource
|
|
27
|
+
|
|
28
|
+
SDC4_NS = "https://semanticdatacharter.com/ns/sdc4/"
|
|
29
|
+
SDC4_XSD = Path(__file__).with_name("schemas") / "sdc4.xsd"
|
|
30
|
+
|
|
31
|
+
VALID = "valid"
|
|
32
|
+
VALID_WITH_EV = "valid_with_ev"
|
|
33
|
+
REFUSED = "refused"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Recovery:
|
|
38
|
+
"""One value the gate covered with an Exceptional Value."""
|
|
39
|
+
position: int # the value's position in the record, as sdcreader numbers it
|
|
40
|
+
ct_id: str
|
|
41
|
+
code: str # the ISO 21090 code inserted
|
|
42
|
+
constraint: str # what failed, from the validator
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class GateResult:
|
|
47
|
+
outcome: str # VALID, VALID_WITH_EV or REFUSED
|
|
48
|
+
xml: bytes | None = None # the record to read: the input, or the input with recoveries
|
|
49
|
+
recoveries: list[Recovery] = field(default_factory=list)
|
|
50
|
+
errors: list[dict] = field(default_factory=list) # for REFUSED: each error that could not be covered
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def refused(self) -> bool:
|
|
54
|
+
return self.outcome == REFUSED
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _map_sdc4(url: str) -> str:
|
|
58
|
+
return str(SDC4_XSD) if url.rstrip("/").endswith("/ns/sdc4/sdc4.xsd") else url
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Gate:
|
|
62
|
+
"""Builds and caches one validator per model schema, keyed by the schema's SHA-256."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, workdir: str | Path | None = None):
|
|
65
|
+
self._dir = Path(workdir) if workdir else Path(tempfile.mkdtemp(prefix="sdc-mapper-schemas-"))
|
|
66
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
self._validators: dict[str, SDC4Validator] = {}
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
def validator(self, pkg: ModelPackage) -> SDC4Validator:
|
|
71
|
+
sha = pkg.sha256
|
|
72
|
+
with self._lock:
|
|
73
|
+
v = self._validators.get(sha)
|
|
74
|
+
if v is None:
|
|
75
|
+
path = self._dir / f"{sha}.xsd"
|
|
76
|
+
if not path.exists():
|
|
77
|
+
path.write_bytes(pkg.xsd_bytes)
|
|
78
|
+
ok, problems = validate_sdc4_schema_compliance(str(path))
|
|
79
|
+
if not ok:
|
|
80
|
+
raise ValueError("the model's schema violates SDC4 compliance: " + "; ".join(problems))
|
|
81
|
+
schema = build_xsd11_schema(str(path), validation="lax", uri_mapper=_map_sdc4, allow="local")
|
|
82
|
+
v = SDC4Validator(schema, check_sdc4_compliance=False, validation="lax")
|
|
83
|
+
self._validators[sha] = v
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
def check(self, xml: bytes, pkg: ModelPackage, model: Model) -> GateResult:
|
|
87
|
+
try:
|
|
88
|
+
resource = XMLResource(io.BytesIO(xml))
|
|
89
|
+
except Exception as e: # noqa: BLE001 xmlschema raises its own and the parser's errors
|
|
90
|
+
return GateResult(REFUSED, errors=[{"kind": "not well-formed", "reason": str(e)}])
|
|
91
|
+
if resource.root.tag != f"{{{SDC4_NS}}}dm-{model.ct_id}":
|
|
92
|
+
return GateResult(REFUSED, errors=[{"kind": "wrong model",
|
|
93
|
+
"reason": f"the record's root is {resource.root.tag}, not sdc4:dm-{model.ct_id}"}])
|
|
94
|
+
try:
|
|
95
|
+
result = self.validator(pkg).recover(resource, remove_existing_ev=False)
|
|
96
|
+
except SDC4StructuralValidationError as e:
|
|
97
|
+
return GateResult(REFUSED, errors=[_summary(err, "structural") for err in e.errors])
|
|
98
|
+
if not result.ok:
|
|
99
|
+
return GateResult(REFUSED, errors=[_summary(err, "not recoverable") for err in result.unrecoverable])
|
|
100
|
+
if not result.recoveries:
|
|
101
|
+
return GateResult(VALID, xml=xml)
|
|
102
|
+
|
|
103
|
+
root = result.tree.getroot()
|
|
104
|
+
positions = _value_positions(root, model)
|
|
105
|
+
recoveries = []
|
|
106
|
+
for r in result.inserted: # an Exceptional Value the record already carried is the source's, not the gate's
|
|
107
|
+
comp = _component_element(root, _find(root, r.path), model)
|
|
108
|
+
if comp is None:
|
|
109
|
+
return GateResult(REFUSED, errors=[{"kind": "not recoverable", "path": r.path,
|
|
110
|
+
"reason": "the recovered value is not inside a value component"}])
|
|
111
|
+
recoveries.append(Recovery(position=positions[id(comp)], ct_id=comp.tag.split("ms-", 1)[1], code=r.code,
|
|
112
|
+
constraint=r.reason))
|
|
113
|
+
recovered = etree_tostring(root, namespaces=resource.get_namespaces(root_only=False) or {"sdc4": SDC4_NS},
|
|
114
|
+
xml_declaration=True, encoding="utf-8")
|
|
115
|
+
return GateResult(VALID_WITH_EV, xml=recovered if isinstance(recovered, bytes) else recovered.encode("utf-8"),
|
|
116
|
+
recoveries=recoveries)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _reason(e) -> str:
|
|
120
|
+
return " ".join((e.reason or e.message or "").split())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _summary(e, kind: str) -> dict:
|
|
124
|
+
return {"kind": kind, "path": e.path or "", "reason": _reason(e)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_STEP = re.compile(r"/((?:\{[^}]*\})?[^/{\[]+)(?:\[(\d+)\])?")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _find(root, path: str):
|
|
131
|
+
"""The element a Clark-form error path names (`/{ns}dm-x/{ns}ms-y/xdcount-value`), or None. The namespace URI
|
|
132
|
+
holds slashes, so the path is tokenized by step, not split."""
|
|
133
|
+
steps = _STEP.findall(path)
|
|
134
|
+
if not steps or steps[0][0] != root.tag:
|
|
135
|
+
return None
|
|
136
|
+
el = root
|
|
137
|
+
for name, idx in steps[1:]:
|
|
138
|
+
matches = [c for c in el if c.tag == name]
|
|
139
|
+
i = int(idx) - 1 if idx else 0
|
|
140
|
+
if i >= len(matches):
|
|
141
|
+
return None
|
|
142
|
+
el = matches[i]
|
|
143
|
+
return el
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _component_element(root, el, model: Model):
|
|
147
|
+
"""The leaf component element (`sdc4:ms-<ct_id>` of a value type) that holds `el`, or None."""
|
|
148
|
+
parents = {c: p for p in root.iter() for c in p}
|
|
149
|
+
while el is not None:
|
|
150
|
+
if el.tag.startswith(f"{{{SDC4_NS}}}ms-"):
|
|
151
|
+
comp = model.components.get(el.tag.split("ms-", 1)[1])
|
|
152
|
+
if comp is not None and comp.sdc_type in LEAF_TYPES:
|
|
153
|
+
return el
|
|
154
|
+
el = parents.get(el)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _value_positions(root, model: Model) -> dict[int, int]:
|
|
159
|
+
"""Each leaf component element's position among the record's values, in document order: the numbering
|
|
160
|
+
sdcreader's `read_record` gives the same record."""
|
|
161
|
+
out, n = {}, 0
|
|
162
|
+
|
|
163
|
+
def walk(el):
|
|
164
|
+
nonlocal n
|
|
165
|
+
for c in el:
|
|
166
|
+
if not (isinstance(c.tag, str) and c.tag.startswith(f"{{{SDC4_NS}}}ms-")):
|
|
167
|
+
continue
|
|
168
|
+
comp = model.components.get(c.tag.split("ms-", 1)[1])
|
|
169
|
+
if comp is not None and comp.sdc_type in LEAF_TYPES:
|
|
170
|
+
out[id(c)] = n
|
|
171
|
+
n += 1
|
|
172
|
+
else:
|
|
173
|
+
walk(c)
|
|
174
|
+
|
|
175
|
+
walk(root)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def sha256(data: bytes) -> str:
|
|
180
|
+
return hashlib.sha256(data).hexdigest()
|
sdc_mapper/log.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The log (PRD section 12): one JSON Lines entry per call, whatever the outcome, anchored and chained.
|
|
2
|
+
|
|
3
|
+
Each entry carries the SHA-256 of the input record, the model schema, the mapping file and each output, so it can be
|
|
4
|
+
checked against the artifacts it names, and the SHA-256 of the previous entry, so a deleted or edited entry shows.
|
|
5
|
+
No values: identifiers, classes, codes and hashes only. A failure reason is not logged either, because a validator's
|
|
6
|
+
reason can quote the rejected value.
|
|
7
|
+
|
|
8
|
+
Files are `mapper-YYYY-MM-DD.jsonl` (UTC) in the log directory; the chain runs across files. Writes are serialized
|
|
9
|
+
within a process by a lock and across processes by an exclusive file lock on `mapper.lock`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fcntl
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
GENESIS = "0" * 64
|
|
22
|
+
LOG_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def entry_hash(entry: dict) -> str:
|
|
26
|
+
"""The SHA-256 of an entry's canonical JSON, without its own `hash` field."""
|
|
27
|
+
body = {k: v for k, v in entry.items() if k != "hash"}
|
|
28
|
+
return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Log:
|
|
32
|
+
def __init__(self, directory: str | Path):
|
|
33
|
+
self.dir = Path(directory)
|
|
34
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def _files(self) -> list[Path]:
|
|
38
|
+
return sorted(self.dir.glob("mapper-*.jsonl"))
|
|
39
|
+
|
|
40
|
+
def _last_hash(self) -> str:
|
|
41
|
+
for f in reversed(self._files()):
|
|
42
|
+
lines = [l for l in f.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
43
|
+
if lines:
|
|
44
|
+
return json.loads(lines[-1])["hash"]
|
|
45
|
+
return GENESIS
|
|
46
|
+
|
|
47
|
+
def append(self, entry: dict, now: datetime | None = None) -> dict:
|
|
48
|
+
now = now or datetime.now(timezone.utc)
|
|
49
|
+
with self._lock, open(self.dir / "mapper.lock", "a") as lockf:
|
|
50
|
+
fcntl.flock(lockf, fcntl.LOCK_EX)
|
|
51
|
+
try:
|
|
52
|
+
full = {"log_version": LOG_VERSION, "time": now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), **entry,
|
|
53
|
+
"previous": self._last_hash()}
|
|
54
|
+
full["hash"] = entry_hash(full)
|
|
55
|
+
path = self.dir / f"mapper-{now.strftime('%Y-%m-%d')}.jsonl"
|
|
56
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
57
|
+
f.write(json.dumps(full, sort_keys=True, ensure_ascii=False) + "\n")
|
|
58
|
+
return full
|
|
59
|
+
finally:
|
|
60
|
+
fcntl.flock(lockf, fcntl.LOCK_UN)
|
|
61
|
+
|
|
62
|
+
def entries(self, since: datetime | None = None) -> list[dict]:
|
|
63
|
+
out = []
|
|
64
|
+
for f in self._files():
|
|
65
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
66
|
+
if line.strip():
|
|
67
|
+
e = json.loads(line)
|
|
68
|
+
if since is None or e["time"] >= since.strftime("%Y-%m-%dT%H:%M:%S.%fZ"):
|
|
69
|
+
out.append(e)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
def verify(self) -> tuple[bool, str]:
|
|
73
|
+
"""Walk the chain from the first entry: every hash recomputes and every entry names its predecessor."""
|
|
74
|
+
prev = GENESIS
|
|
75
|
+
n = 0
|
|
76
|
+
for e in self.entries():
|
|
77
|
+
n += 1
|
|
78
|
+
if e.get("previous") != prev:
|
|
79
|
+
return False, f"entry {n} ({e.get('time')}) does not follow the entry before it"
|
|
80
|
+
if entry_hash(e) != e.get("hash"):
|
|
81
|
+
return False, f"entry {n} ({e.get('time')}) has been altered"
|
|
82
|
+
prev = e["hash"]
|
|
83
|
+
return True, f"{n} entries, chain intact"
|
|
84
|
+
|
|
85
|
+
def summary(self, days: float = 7, now: datetime | None = None) -> dict:
|
|
86
|
+
now = now or datetime.now(timezone.utc)
|
|
87
|
+
rows = self.entries(since=now - timedelta(days=days))
|
|
88
|
+
by_target: dict[str, dict] = {}
|
|
89
|
+
for e in rows:
|
|
90
|
+
t = by_target.setdefault(e.get("target", {}).get("name", "?"), {
|
|
91
|
+
"calls": 0, "outcomes": Counter(), "values": Counter(), "exceptional": Counter(), "models": set(),
|
|
92
|
+
"mappings": set()})
|
|
93
|
+
t["calls"] += 1
|
|
94
|
+
t["outcomes"][e.get("outcome", "?")] += 1
|
|
95
|
+
for k, n in (e.get("counts") or {}).items():
|
|
96
|
+
if k == "exceptional":
|
|
97
|
+
t["exceptional"].update(n)
|
|
98
|
+
else:
|
|
99
|
+
t["values"][k] += n
|
|
100
|
+
if e.get("model", {}).get("ct_id"):
|
|
101
|
+
t["models"].add(e["model"]["ct_id"])
|
|
102
|
+
if (e.get("mapping") or {}).get("sha256"):
|
|
103
|
+
t["mappings"].add(e["mapping"]["sha256"])
|
|
104
|
+
return {"days": days, "calls": len(rows),
|
|
105
|
+
"targets": {k: {"calls": v["calls"], "outcomes": dict(v["outcomes"]), "values": {k: n for k, n in v["values"].items() if n},
|
|
106
|
+
"exceptional": {k: n for k, n in v["exceptional"].items() if n},
|
|
107
|
+
"models": sorted(v["models"]),
|
|
108
|
+
"mappings": sorted(v["mappings"])} for k, v in by_target.items()}}
|