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 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()}}