sdcreader 4.2.0__tar.gz → 4.3.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.
- {sdcreader-4.2.0 → sdcreader-4.3.0}/NOTICE +5 -0
- {sdcreader-4.2.0/src/sdcreader.egg-info → sdcreader-4.3.0}/PKG-INFO +22 -2
- {sdcreader-4.2.0 → sdcreader-4.3.0}/README.md +21 -1
- {sdcreader-4.2.0 → sdcreader-4.3.0}/pyproject.toml +1 -1
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader/__init__.py +7 -2
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader/mcp_server.py +71 -3
- sdcreader-4.3.0/src/sdcreader/record.py +180 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0/src/sdcreader.egg-info}/PKG-INFO +22 -2
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader.egg-info/SOURCES.txt +3 -1
- {sdcreader-4.2.0 → sdcreader-4.3.0}/tests/test_mcp_server.py +32 -3
- sdcreader-4.3.0/tests/test_record.py +130 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/LICENSE +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/setup.cfg +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader/mcp.py +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader/model.py +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader/package.py +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader.egg-info/dependency_links.txt +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader.egg-info/entry_points.txt +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader.egg-info/requires.txt +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/src/sdcreader.egg-info/top_level.txt +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/tests/test_mcp_scaffold.py +0 -0
- {sdcreader-4.2.0 → sdcreader-4.3.0}/tests/test_reader.py +0 -0
|
@@ -7,3 +7,8 @@ The test fixture under samples/nhanes-participant/ is the published package of t
|
|
|
7
7
|
FAIR Data Demo (https://github.com/SemanticDataCharter/FAIR_Data_Demo), fetched from SDCStudio's public catalog, a
|
|
8
8
|
Semantic Data Charter model of National Health and Nutrition Examination Survey (CDC) variables; it is used as
|
|
9
9
|
evidence, unchanged.
|
|
10
|
+
|
|
11
|
+
The test fixture under samples/halvorsen-credit-note/ is the published package of the Credit Note model of the
|
|
12
|
+
business documents library and one Credit Note record generated by HalvorsenDemo
|
|
13
|
+
(https://github.com/SemanticDataCharter/HalvorsenDemo, Apache 2.0), whose companies and documents are fictitious; it
|
|
14
|
+
is used as evidence, unchanged.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcreader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included.
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -89,6 +89,25 @@ which is complete.
|
|
|
89
89
|
- Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
|
|
90
90
|
`CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
|
|
91
91
|
|
|
92
|
+
## Reading a record (4.3.0)
|
|
93
|
+
|
|
94
|
+
`read_record(source, model)` reads a Governed Data Record, the XML instance, and joins each value to the component
|
|
95
|
+
that defines it. `source` is the record's bytes, its text or a path; a record of another model is refused
|
|
96
|
+
(`RecordError`), as is anything that is not an `sdc4:dm-<ct_id>` record.
|
|
97
|
+
|
|
98
|
+
- **`Record`**: `attributes` (`instance_id`, `instance_version`, `creation_timestamp`, `current-state`, ...),
|
|
99
|
+
`values` in document order, and `exceptional`, the values that carry an Exceptional Value.
|
|
100
|
+
- **`RecordValue`**: the `component`, its `path` of clusters (root first), an `occurrence` index for a component
|
|
101
|
+
repeated under the same path, its `position`, its `elements` as the record holds them (a quantity's units nested:
|
|
102
|
+
`{"xdquantity-value": "1", "xdquantity-units": {"label": "Trade Units", "xdstring-value": "EA"}}`), and its
|
|
103
|
+
`context` (`act`, `vtb`, `vte`, `tr`, `modified`, `latitude`, `longitude`).
|
|
104
|
+
- **`ExceptionalValue`**: the ISO 21090 null flavor (`code`, `name`) and its `origin`: `source` when it stands in
|
|
105
|
+
place of the value, `recovery` when sdcvalidator's recovery inserted it beside a value that failed its constraint.
|
|
106
|
+
For `recovery`, `RecordValue.rejected` is that value, kept so a person can see it.
|
|
107
|
+
|
|
108
|
+
Cluster members sit inside adapter elements that are not components of the model; the reader passes through them.
|
|
109
|
+
It does not type, validate or interpret values: that is the caller's work (a mapper, an exporter, a checker).
|
|
110
|
+
|
|
92
111
|
## MCP server
|
|
93
112
|
|
|
94
113
|
`sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
|
|
@@ -107,8 +126,9 @@ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's
|
|
|
107
126
|
| `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
|
|
108
127
|
| `list_codes` | one component's enumerated values with IRI, definition and code system |
|
|
109
128
|
| `check_package` | the reader's integrity check on a package directory |
|
|
129
|
+
| `read_record` | a Governed Data Record on disk read against its model: attributes, then each value with its component, path, elements, context and Exceptional Value, paged; the model is the one the record names unless a source is given |
|
|
110
130
|
|
|
111
|
-
Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
131
|
+
Each model tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
112
132
|
No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
|
|
113
133
|
same functions as the library; the two interfaces describe one model the same way.
|
|
114
134
|
|
|
@@ -64,6 +64,25 @@ which is complete.
|
|
|
64
64
|
- Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
|
|
65
65
|
`CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
|
|
66
66
|
|
|
67
|
+
## Reading a record (4.3.0)
|
|
68
|
+
|
|
69
|
+
`read_record(source, model)` reads a Governed Data Record, the XML instance, and joins each value to the component
|
|
70
|
+
that defines it. `source` is the record's bytes, its text or a path; a record of another model is refused
|
|
71
|
+
(`RecordError`), as is anything that is not an `sdc4:dm-<ct_id>` record.
|
|
72
|
+
|
|
73
|
+
- **`Record`**: `attributes` (`instance_id`, `instance_version`, `creation_timestamp`, `current-state`, ...),
|
|
74
|
+
`values` in document order, and `exceptional`, the values that carry an Exceptional Value.
|
|
75
|
+
- **`RecordValue`**: the `component`, its `path` of clusters (root first), an `occurrence` index for a component
|
|
76
|
+
repeated under the same path, its `position`, its `elements` as the record holds them (a quantity's units nested:
|
|
77
|
+
`{"xdquantity-value": "1", "xdquantity-units": {"label": "Trade Units", "xdstring-value": "EA"}}`), and its
|
|
78
|
+
`context` (`act`, `vtb`, `vte`, `tr`, `modified`, `latitude`, `longitude`).
|
|
79
|
+
- **`ExceptionalValue`**: the ISO 21090 null flavor (`code`, `name`) and its `origin`: `source` when it stands in
|
|
80
|
+
place of the value, `recovery` when sdcvalidator's recovery inserted it beside a value that failed its constraint.
|
|
81
|
+
For `recovery`, `RecordValue.rejected` is that value, kept so a person can see it.
|
|
82
|
+
|
|
83
|
+
Cluster members sit inside adapter elements that are not components of the model; the reader passes through them.
|
|
84
|
+
It does not type, validate or interpret values: that is the caller's work (a mapper, an exporter, a checker).
|
|
85
|
+
|
|
67
86
|
## MCP server
|
|
68
87
|
|
|
69
88
|
`sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
|
|
@@ -82,8 +101,9 @@ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's
|
|
|
82
101
|
| `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
|
|
83
102
|
| `list_codes` | one component's enumerated values with IRI, definition and code system |
|
|
84
103
|
| `check_package` | the reader's integrity check on a package directory |
|
|
104
|
+
| `read_record` | a Governed Data Record on disk read against its model: attributes, then each value with its component, path, elements, context and Exceptional Value, paged; the model is the one the record names unless a source is given |
|
|
85
105
|
|
|
86
|
-
Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
106
|
+
Each model tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
87
107
|
No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
|
|
88
108
|
same functions as the library; the two interfaces describe one model the same way.
|
|
89
109
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sdcreader"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.3.0"
|
|
8
8
|
description = "Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -7,12 +7,17 @@ versions. This package loads or fetches that evidence and reads it into one shap
|
|
|
7
7
|
record tree, its leaves in document order, the enumerated values with the codes the schema annotates them with,
|
|
8
8
|
and the model's own Dublin Core with SDCStudio's field defaults read as unset. It knows nothing about any
|
|
9
9
|
projection.
|
|
10
|
+
|
|
11
|
+
Since 4.3.0 it also reads a Governed Data Record itself (`read_record`): the record's attributes and each value joined
|
|
12
|
+
to the component that defines it, Exceptional Values included, for record-level mappers.
|
|
10
13
|
"""
|
|
11
14
|
from .model import (CLOSE, DC_DEFAULTS, EXACT, HAS_UNIT, IDENTIFIER, LEAF_TYPES, NUMERIC_TYPES, PUBLISHER, QUANTIFIED_TYPES, SEE_ALSO,
|
|
12
15
|
SOURCE, Code, Component, Leaf, Model, enumeration_iri, read_header, read_model)
|
|
13
16
|
from .package import DEFAULT_HOST, ModelPackage, PackageError, fetch_package, load_package
|
|
17
|
+
from .record import CONTEXT_ELEMENTS, EXCEPTIONAL_VALUES, RECORD_ATTRIBUTES, ExceptionalValue, Record, RecordError, RecordValue, read_record
|
|
14
18
|
|
|
15
|
-
__version__ = "4.
|
|
19
|
+
__version__ = "4.3.0"
|
|
16
20
|
__all__ = ["ModelPackage", "PackageError", "DEFAULT_HOST", "load_package", "fetch_package", "read_model", "read_header", "enumeration_iri",
|
|
17
21
|
"Model", "Component", "Leaf", "Code", "LEAF_TYPES", "NUMERIC_TYPES", "QUANTIFIED_TYPES", "DC_DEFAULTS", "IDENTIFIER", "SOURCE",
|
|
18
|
-
"PUBLISHER", "EXACT", "CLOSE", "SEE_ALSO", "HAS_UNIT", "
|
|
22
|
+
"PUBLISHER", "EXACT", "CLOSE", "SEE_ALSO", "HAS_UNIT", "read_record", "Record", "RecordValue", "RecordError", "ExceptionalValue",
|
|
23
|
+
"EXCEPTIONAL_VALUES", "RECORD_ATTRIBUTES", "CONTEXT_ELEMENTS", "__version__"]
|
|
@@ -12,11 +12,13 @@ published model in the public catalog, read its record structure, its
|
|
|
12
12
|
enumerated values with their codes and definitions, its Dublin Core as the
|
|
13
13
|
modeler wrote it, and the pinned schema URL, with no account anywhere.
|
|
14
14
|
|
|
15
|
-
**
|
|
15
|
+
**Six tools, read-only.** `find_models` searches the public catalog.
|
|
16
16
|
`describe_model` summarizes one model. `list_leaves` walks its leaves in
|
|
17
17
|
document order, paged, because a model can have more than a thousand.
|
|
18
18
|
`list_codes` gives one component's enumerated values. `check_package` runs the
|
|
19
|
-
reader's integrity check on a package directory.
|
|
19
|
+
reader's integrity check on a package directory. `read_record` (4.3.0) reads a
|
|
20
|
+
Governed Data Record on disk against its model: the record's attributes and
|
|
21
|
+
each value with its component, paged, Exceptional Values included.
|
|
20
22
|
|
|
21
23
|
**No tool takes a URL.** The catalog host is fixed when the server starts
|
|
22
24
|
(`--host`, default SDCStudio's public catalog), so a caller cannot point this
|
|
@@ -123,6 +125,27 @@ TOOLS = [
|
|
|
123
125
|
"additionalProperties": False,
|
|
124
126
|
},
|
|
125
127
|
},
|
|
128
|
+
{
|
|
129
|
+
"name": "read_record",
|
|
130
|
+
"description": (
|
|
131
|
+
"Read a Governed Data Record (an XML file on disk) against its model: the record's attributes and each "
|
|
132
|
+
"value in document order with its component, path of clusters, elements, context and any Exceptional "
|
|
133
|
+
"Value (ISO 21090 code, name, and whether it was written at the source or inserted by recovery beside a "
|
|
134
|
+
"rejected value). The model is the one the record names, fetched by its identifier unless ct_id or "
|
|
135
|
+
"directory is given. Paged. Does not validate the record."
|
|
136
|
+
),
|
|
137
|
+
"inputSchema": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"record": {"type": "string", "description": "Path to the record's XML file on disk."},
|
|
141
|
+
**source_properties(),
|
|
142
|
+
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
|
143
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE, "default": DEFAULT_PAGE},
|
|
144
|
+
},
|
|
145
|
+
"required": ["record"],
|
|
146
|
+
"additionalProperties": False,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
126
149
|
{
|
|
127
150
|
"name": "check_package",
|
|
128
151
|
"description": (
|
|
@@ -258,6 +281,49 @@ def _handle_list_codes(args: dict[str, Any]) -> Any:
|
|
|
258
281
|
}
|
|
259
282
|
|
|
260
283
|
|
|
284
|
+
def _handle_read_record(args: dict[str, Any]) -> Any:
|
|
285
|
+
import re
|
|
286
|
+
from pathlib import Path
|
|
287
|
+
|
|
288
|
+
from sdcreader.record import RecordError, read_record
|
|
289
|
+
|
|
290
|
+
path = args.get("record")
|
|
291
|
+
if not isinstance(path, str) or not path:
|
|
292
|
+
raise ToolError("`record` must be a path to the record's XML file.")
|
|
293
|
+
try:
|
|
294
|
+
data = Path(path).read_bytes()
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
raise ToolError(f"Could not read the record: {exc}") from exc
|
|
297
|
+
source = {k: args[k] for k in ("ct_id", "directory") if args.get(k)}
|
|
298
|
+
if not source:
|
|
299
|
+
m = re.search(rb"<[A-Za-z0-9_]+:dm-([a-z0-9]{24})[\s>]", data[:4000])
|
|
300
|
+
if not m:
|
|
301
|
+
raise ToolError("The record does not name a model (no sdc4:dm-<ct_id> root); give ct_id or directory.")
|
|
302
|
+
source = {"ct_id": m.group(1).decode()}
|
|
303
|
+
pkg, model = _cache().one(source)
|
|
304
|
+
try:
|
|
305
|
+
record = read_record(data, model)
|
|
306
|
+
except RecordError as exc:
|
|
307
|
+
raise ToolError(str(exc)) from exc
|
|
308
|
+
offset = int_arg(args, "offset", 0, 0, 10_000_000)
|
|
309
|
+
limit = int_arg(args, "limit", DEFAULT_PAGE, 1, MAX_PAGE)
|
|
310
|
+
page = record.values[offset : offset + limit]
|
|
311
|
+
values = [
|
|
312
|
+
{
|
|
313
|
+
"position": v.position, "ct_id": v.component.ct_id, "label": v.component.label, "sdc_type": v.component.sdc_type,
|
|
314
|
+
"path": [p.label for p in v.path], "occurrence": v.occurrence, "elements": v.elements, "context": v.context,
|
|
315
|
+
"exceptional": ({"code": v.exceptional.code, "name": v.exceptional.name, "origin": v.exceptional.origin}
|
|
316
|
+
if v.exceptional else None),
|
|
317
|
+
}
|
|
318
|
+
for v in page
|
|
319
|
+
]
|
|
320
|
+
return {
|
|
321
|
+
"ct_id": model.ct_id, "title": model.title, "schema_sha256": pkg.sha256, "attributes": record.attributes,
|
|
322
|
+
"total": len(record.values), "exceptional": len(record.exceptional), "offset": offset, "limit": limit,
|
|
323
|
+
"returned": len(values), "next_offset": offset + limit if offset + limit < len(record.values) else None, "values": values,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
261
327
|
def _handle_check_package(args: dict[str, Any]) -> Any:
|
|
262
328
|
from sdcreader.package import PackageError, load_package
|
|
263
329
|
|
|
@@ -283,6 +349,7 @@ TOOL_HANDLERS = {
|
|
|
283
349
|
"list_leaves": _handle_list_leaves,
|
|
284
350
|
"list_codes": _handle_list_codes,
|
|
285
351
|
"check_package": _handle_check_package,
|
|
352
|
+
"read_record": _handle_read_record,
|
|
286
353
|
}
|
|
287
354
|
|
|
288
355
|
|
|
@@ -293,7 +360,8 @@ def _server() -> Server:
|
|
|
293
360
|
"Read published Semantic Data Charter models. `find_models` searches "
|
|
294
361
|
f"the public catalog at {_HOST}; `describe_model`, `list_leaves` and "
|
|
295
362
|
"`list_codes` read one model by its ct_id from that host or from a "
|
|
296
|
-
"package directory on disk; `check_package` verifies a directory
|
|
363
|
+
"package directory on disk; `check_package` verifies a directory; "
|
|
364
|
+
"`read_record` reads a Governed Data Record on disk against its model. "
|
|
297
365
|
"All tools are read-only and need no account. No tool takes a URL."
|
|
298
366
|
),
|
|
299
367
|
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Read a Governed Data Record (the XML instance) and join each value to the model component that defines it.
|
|
2
|
+
|
|
3
|
+
A record's root is `sdc4:dm-<ct_id>`, carrying the record's own attributes (`instance_id`, `instance_version`,
|
|
4
|
+
`creation_timestamp`, `current-state`, ...). Below it, every component appears as an `sdc4:ms-<ct_id>` element. A
|
|
5
|
+
cluster's members sit inside adapter elements, also named `sdc4:ms-<ct_id>` but not components of the model; the
|
|
6
|
+
reader passes through them. A data-bearing component holds its label, its per-value context (`act`, `vtb`, `vte`, `tr`,
|
|
7
|
+
`modified`, `latitude`, `longitude`), its value elements (`xdstring-value`, `xdquantity-value` with
|
|
8
|
+
`xdquantity-units`, `xdtemporal-datetime`, ...) and, where the value is missing or unusable, an Exceptional Value: an
|
|
9
|
+
`sdc4:<code>` element with the ISO 21090 null flavor code (`sdc4:ASKR`, `sdc4:INV`, ...) and its `ev-name`.
|
|
10
|
+
|
|
11
|
+
An Exceptional Value written where the record was made stands in place of the value. One inserted by recovery
|
|
12
|
+
(sdcvalidator's `validate_with_recovery`, which tags the record `valid_with_ev`) stands beside the value that failed
|
|
13
|
+
its constraint, which stays in the record; the reader keeps that value, marked as rejected.
|
|
14
|
+
|
|
15
|
+
The reader keeps every value element's text as the record holds it. It does not type values, validate them or know
|
|
16
|
+
any target; that is the reader's caller's work.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from lxml import etree
|
|
24
|
+
|
|
25
|
+
from .model import LEAF_TYPES, SDC4, Component, Model
|
|
26
|
+
|
|
27
|
+
#: ISO 21090 null flavors, as the SDC4 reference model names its Exceptional Value elements.
|
|
28
|
+
EXCEPTIONAL_VALUES = {
|
|
29
|
+
"INV": "Invalid", "OTH": "Other", "UNC": "Unencoded", "NI": "No Information", "NA": "Not Applicable",
|
|
30
|
+
"UNK": "Unknown", "ASKU": "Asked but Unknown", "ASKR": "Asked and Refused", "NASK": "Not Asked",
|
|
31
|
+
"NAV": "Not Available", "MSK": "Masked", "DER": "Derived", "PINF": "Positive Infinity",
|
|
32
|
+
"NINF": "Negative Infinity", "TRC": "Trace", "QS": "Sufficient Quantity",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: The record attributes a Governed Data Record carries under its root, by element name.
|
|
36
|
+
RECORD_ATTRIBUTES = ("dm-label", "dm-language", "dm-encoding", "creation_timestamp", "instance_id", "instance_version",
|
|
37
|
+
"source_instance_id", "source_version_id", "current-state")
|
|
38
|
+
|
|
39
|
+
#: Per-value context elements: what the record says about a value rather than the value itself.
|
|
40
|
+
CONTEXT_ELEMENTS = ("act", "vtb", "vte", "tr", "modified", "latitude", "longitude")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RecordError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ExceptionalValue:
|
|
49
|
+
code: str # "ASKR"
|
|
50
|
+
name: str # "Asked and Refused"
|
|
51
|
+
origin: str # "source": in place of the value; "recovery": beside a value that failed its constraint
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RecordValue:
|
|
56
|
+
component: Component
|
|
57
|
+
path: list[Component] # the clusters above it, root first
|
|
58
|
+
occurrence: int # 0 for the first time this component appears under this path, 1 for the next, ...
|
|
59
|
+
position: int # document order among the record's values, 0-based
|
|
60
|
+
elements: dict # the value elements as the record holds them: name -> text, or name -> dict for a
|
|
61
|
+
# nested element (a quantity's units: {"label": "Trade Units", "xdstring-value": "EA"})
|
|
62
|
+
context: dict[str, str] # act, vtb, vte, tr, modified, latitude, longitude, where present
|
|
63
|
+
exceptional: ExceptionalValue | None = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def rejected(self) -> dict:
|
|
67
|
+
"""The value elements a recovery Exceptional Value stands beside: the value that failed its constraint."""
|
|
68
|
+
return self.elements if self.exceptional and self.exceptional.origin == "recovery" else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Record:
|
|
73
|
+
model: Model
|
|
74
|
+
attributes: dict[str, str]
|
|
75
|
+
values: list[RecordValue] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def instance_id(self) -> str:
|
|
79
|
+
return self.attributes.get("instance_id", "")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def exceptional(self) -> list[RecordValue]:
|
|
83
|
+
return [v for v in self.values if v.exceptional]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _local(tag) -> str:
|
|
87
|
+
return tag.split("}", 1)[1] if isinstance(tag, str) and "}" in tag else (tag if isinstance(tag, str) else "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_sdc4(el) -> bool:
|
|
91
|
+
return isinstance(el.tag, str) and el.tag.startswith(f"{{{SDC4}}}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _text(el) -> str:
|
|
95
|
+
return (el.text or "").strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _nested(el) -> dict | str:
|
|
99
|
+
"""An element's content: its text if it has no element children, else a dict of its children's content."""
|
|
100
|
+
kids = [c for c in el if isinstance(c.tag, str)]
|
|
101
|
+
if not kids:
|
|
102
|
+
return _text(el)
|
|
103
|
+
return {_local(c.tag): _nested(c) for c in kids}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_record(source: bytes | str | Path, model: Model) -> Record:
|
|
107
|
+
"""Read a Governed Data Record (XML) and join it to the model it names.
|
|
108
|
+
|
|
109
|
+
`source` is the record's bytes, its text, or a path to it. The record's root must be `sdc4:dm-<ct_id>` for the
|
|
110
|
+
model's ct_id; a record of another model is refused rather than read against the wrong definitions.
|
|
111
|
+
"""
|
|
112
|
+
if isinstance(source, Path) or (isinstance(source, str) and not source.lstrip().startswith("<")):
|
|
113
|
+
data = Path(source).read_bytes()
|
|
114
|
+
else:
|
|
115
|
+
data = source.encode("utf-8") if isinstance(source, str) else source
|
|
116
|
+
try:
|
|
117
|
+
root = etree.fromstring(data, parser=etree.XMLParser(resolve_entities=False, no_network=True))
|
|
118
|
+
except etree.XMLSyntaxError as e:
|
|
119
|
+
raise RecordError(f"the record is not well-formed XML: {e}") from e
|
|
120
|
+
|
|
121
|
+
name = _local(root.tag)
|
|
122
|
+
if not (_is_sdc4(root) and name.startswith("dm-")):
|
|
123
|
+
raise RecordError(f"the record's root is {root.tag!r}, not an sdc4:dm-<ct_id> element")
|
|
124
|
+
if name[3:] != model.ct_id:
|
|
125
|
+
raise RecordError(f"the record is of model {name[3:]}, not {model.ct_id}")
|
|
126
|
+
|
|
127
|
+
attributes = {}
|
|
128
|
+
for el in root:
|
|
129
|
+
n = _local(el.tag)
|
|
130
|
+
if n in RECORD_ATTRIBUTES:
|
|
131
|
+
attributes[n] = _text(el)
|
|
132
|
+
|
|
133
|
+
record = Record(model=model, attributes=attributes)
|
|
134
|
+
seen: dict[tuple, int] = {}
|
|
135
|
+
|
|
136
|
+
def walk(el, path: list[Component]):
|
|
137
|
+
for child in el:
|
|
138
|
+
if not _is_sdc4(child):
|
|
139
|
+
continue
|
|
140
|
+
n = _local(child.tag)
|
|
141
|
+
if not n.startswith("ms-"):
|
|
142
|
+
continue
|
|
143
|
+
comp = model.components.get(n[3:])
|
|
144
|
+
if comp is None: # an adapter: pass through to the component inside
|
|
145
|
+
walk(child, path)
|
|
146
|
+
elif comp.sdc_type == "Cluster" or comp.sdc_type not in LEAF_TYPES:
|
|
147
|
+
walk(child, path + [comp]) # a cluster, or a structural component (audit, party, ...) holding values
|
|
148
|
+
else:
|
|
149
|
+
key = (comp.ct_id, tuple(c.ct_id for c in path))
|
|
150
|
+
occurrence = seen.get(key, 0)
|
|
151
|
+
seen[key] = occurrence + 1
|
|
152
|
+
record.values.append(_value(child, comp, path, occurrence, len(record.values)))
|
|
153
|
+
|
|
154
|
+
walk(root, [])
|
|
155
|
+
return record
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _value(el, comp: Component, path: list[Component], occurrence: int, position: int) -> RecordValue:
|
|
159
|
+
elements: dict = {}
|
|
160
|
+
context: dict[str, str] = {}
|
|
161
|
+
ev_code = None
|
|
162
|
+
for c in el:
|
|
163
|
+
if not isinstance(c.tag, str):
|
|
164
|
+
continue
|
|
165
|
+
n = _local(c.tag)
|
|
166
|
+
if _is_sdc4(c) and n in EXCEPTIONAL_VALUES:
|
|
167
|
+
ev_code = n
|
|
168
|
+
elif n == "label":
|
|
169
|
+
continue
|
|
170
|
+
elif n in CONTEXT_ELEMENTS:
|
|
171
|
+
context[n] = _text(c)
|
|
172
|
+
else:
|
|
173
|
+
elements[n] = _nested(c)
|
|
174
|
+
exceptional = None
|
|
175
|
+
if ev_code:
|
|
176
|
+
value_present = any(k.endswith("-value") or k.startswith("xdtemporal-") for k in elements)
|
|
177
|
+
exceptional = ExceptionalValue(code=ev_code, name=EXCEPTIONAL_VALUES[ev_code],
|
|
178
|
+
origin="recovery" if value_present else "source")
|
|
179
|
+
return RecordValue(component=comp, path=path, occurrence=occurrence, position=position, elements=elements,
|
|
180
|
+
context=context, exceptional=exceptional)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcreader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included.
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -89,6 +89,25 @@ which is complete.
|
|
|
89
89
|
- Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
|
|
90
90
|
`CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
|
|
91
91
|
|
|
92
|
+
## Reading a record (4.3.0)
|
|
93
|
+
|
|
94
|
+
`read_record(source, model)` reads a Governed Data Record, the XML instance, and joins each value to the component
|
|
95
|
+
that defines it. `source` is the record's bytes, its text or a path; a record of another model is refused
|
|
96
|
+
(`RecordError`), as is anything that is not an `sdc4:dm-<ct_id>` record.
|
|
97
|
+
|
|
98
|
+
- **`Record`**: `attributes` (`instance_id`, `instance_version`, `creation_timestamp`, `current-state`, ...),
|
|
99
|
+
`values` in document order, and `exceptional`, the values that carry an Exceptional Value.
|
|
100
|
+
- **`RecordValue`**: the `component`, its `path` of clusters (root first), an `occurrence` index for a component
|
|
101
|
+
repeated under the same path, its `position`, its `elements` as the record holds them (a quantity's units nested:
|
|
102
|
+
`{"xdquantity-value": "1", "xdquantity-units": {"label": "Trade Units", "xdstring-value": "EA"}}`), and its
|
|
103
|
+
`context` (`act`, `vtb`, `vte`, `tr`, `modified`, `latitude`, `longitude`).
|
|
104
|
+
- **`ExceptionalValue`**: the ISO 21090 null flavor (`code`, `name`) and its `origin`: `source` when it stands in
|
|
105
|
+
place of the value, `recovery` when sdcvalidator's recovery inserted it beside a value that failed its constraint.
|
|
106
|
+
For `recovery`, `RecordValue.rejected` is that value, kept so a person can see it.
|
|
107
|
+
|
|
108
|
+
Cluster members sit inside adapter elements that are not components of the model; the reader passes through them.
|
|
109
|
+
It does not type, validate or interpret values: that is the caller's work (a mapper, an exporter, a checker).
|
|
110
|
+
|
|
92
111
|
## MCP server
|
|
93
112
|
|
|
94
113
|
`sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
|
|
@@ -107,8 +126,9 @@ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's
|
|
|
107
126
|
| `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
|
|
108
127
|
| `list_codes` | one component's enumerated values with IRI, definition and code system |
|
|
109
128
|
| `check_package` | the reader's integrity check on a package directory |
|
|
129
|
+
| `read_record` | a Governed Data Record on disk read against its model: attributes, then each value with its component, path, elements, context and Exceptional Value, paged; the model is the one the record names unless a source is given |
|
|
110
130
|
|
|
111
|
-
Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
131
|
+
Each model tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
|
|
112
132
|
No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
|
|
113
133
|
same functions as the library; the two interfaces describe one model the same way.
|
|
114
134
|
|
|
@@ -7,6 +7,7 @@ src/sdcreader/mcp.py
|
|
|
7
7
|
src/sdcreader/mcp_server.py
|
|
8
8
|
src/sdcreader/model.py
|
|
9
9
|
src/sdcreader/package.py
|
|
10
|
+
src/sdcreader/record.py
|
|
10
11
|
src/sdcreader.egg-info/PKG-INFO
|
|
11
12
|
src/sdcreader.egg-info/SOURCES.txt
|
|
12
13
|
src/sdcreader.egg-info/dependency_links.txt
|
|
@@ -15,4 +16,5 @@ src/sdcreader.egg-info/requires.txt
|
|
|
15
16
|
src/sdcreader.egg-info/top_level.txt
|
|
16
17
|
tests/test_mcp_scaffold.py
|
|
17
18
|
tests/test_mcp_server.py
|
|
18
|
-
tests/test_reader.py
|
|
19
|
+
tests/test_reader.py
|
|
20
|
+
tests/test_record.py
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The MCP server: JSON-RPC 2.0 handling and the
|
|
1
|
+
"""The MCP server: JSON-RPC 2.0 handling and the six read-only tools, offline against the NHANES and Halvorsen fixtures."""
|
|
2
2
|
import json
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
@@ -60,9 +60,10 @@ class TestProtocol:
|
|
|
60
60
|
def test_initialized_notification_has_no_response(self):
|
|
61
61
|
assert _handle_request({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def test_tools_list_names_the_six_tools_with_schemas(self):
|
|
64
64
|
tools = rpc("tools/list", {})["result"]["tools"]
|
|
65
|
-
assert {t["name"] for t in tools} == {"find_models", "describe_model", "list_leaves", "list_codes", "check_package"
|
|
65
|
+
assert {t["name"] for t in tools} == {"find_models", "describe_model", "list_leaves", "list_codes", "check_package",
|
|
66
|
+
"read_record"}
|
|
66
67
|
assert set(TOOL_HANDLERS) == {t["name"] for t in TOOLS}
|
|
67
68
|
for t in tools:
|
|
68
69
|
assert t["inputSchema"]["type"] == "object"
|
|
@@ -174,3 +175,31 @@ class TestTools:
|
|
|
174
175
|
def test_find_models_against_production():
|
|
175
176
|
r = call_ok("find_models", {"project": "FAIR Data Demo", "page_size": 3})
|
|
176
177
|
assert r["count"] >= 1 and r["models"][0]["project"] == "FAIR Data Demo"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
HALVORSEN = Path(__file__).resolve().parents[1] / "samples" / "halvorsen-credit-note"
|
|
181
|
+
HALVORSEN_CT = "jkuyr38hydqaiwlq5vhp2rjr"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestReadRecord:
|
|
185
|
+
def test_reads_a_record_against_a_package_directory(self):
|
|
186
|
+
out = call_ok("read_record", {"record": str(HALVORSEN / "record.xml"), "directory": str(HALVORSEN), "limit": 5})
|
|
187
|
+
assert out["ct_id"] == HALVORSEN_CT and out["attributes"]["dm-label"] == "Credit Note"
|
|
188
|
+
assert out["returned"] == 5 and out["total"] > 5 and out["next_offset"] == 5 and out["exceptional"] == 0
|
|
189
|
+
v = out["values"][0]
|
|
190
|
+
assert v["path"][0] == "Credit Note Governed Record" and v["elements"] and v["exceptional"] is None
|
|
191
|
+
|
|
192
|
+
def test_the_model_comes_from_the_record_when_no_source_is_given(self, monkeypatch):
|
|
193
|
+
from sdcreader.package import load_package
|
|
194
|
+
asked = []
|
|
195
|
+
monkeypatch.setattr(srv, "fetch_package", lambda ct_id, host=None: asked.append(ct_id) or load_package(HALVORSEN, ct_id))
|
|
196
|
+
monkeypatch.setattr(srv, "_CACHE", {})
|
|
197
|
+
out = call_ok("read_record", {"record": str(HALVORSEN / "record.xml"), "limit": 1})
|
|
198
|
+
assert asked == [HALVORSEN_CT] and out["ct_id"] == HALVORSEN_CT
|
|
199
|
+
|
|
200
|
+
def test_a_record_of_another_model_is_a_tool_error(self):
|
|
201
|
+
text = call_err("read_record", {"record": str(HALVORSEN / "record.xml"), "directory": str(SAMPLE)})
|
|
202
|
+
assert "not " + CT in text
|
|
203
|
+
|
|
204
|
+
def test_a_missing_file_is_a_tool_error(self, tmp_path):
|
|
205
|
+
assert "Could not read the record" in call_err("read_record", {"record": str(tmp_path / "none.xml")})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The record reader: a Governed Data Record (XML) read and joined to its model.
|
|
2
|
+
|
|
3
|
+
Fixture: a Credit Note record from HalvorsenDemo and the published package of its model (samples/halvorsen-credit-note).
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from lxml import etree
|
|
9
|
+
|
|
10
|
+
from sdcreader import load_package, read_model
|
|
11
|
+
from sdcreader.model import SDC4
|
|
12
|
+
from sdcreader.record import EXCEPTIONAL_VALUES, RecordError, read_record
|
|
13
|
+
|
|
14
|
+
SAMPLE = Path(__file__).resolve().parents[1] / "samples" / "halvorsen-credit-note"
|
|
15
|
+
CT = "jkuyr38hydqaiwlq5vhp2rjr"
|
|
16
|
+
RECORD = SAMPLE / "record.xml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="module")
|
|
20
|
+
def model():
|
|
21
|
+
return read_model(load_package(SAMPLE, CT))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _first_string(tree):
|
|
25
|
+
"""The first XdString value element in the record, and its component element."""
|
|
26
|
+
value = tree.find(".//xdstring-value")
|
|
27
|
+
return value, value.getparent()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_attributes_and_values(model):
|
|
31
|
+
r = read_record(RECORD, model)
|
|
32
|
+
assert r.attributes["dm-label"] == "Credit Note"
|
|
33
|
+
assert r.instance_id == r.attributes["instance_id"] and len(r.instance_id) == 24
|
|
34
|
+
assert r.attributes["instance_version"] == "1"
|
|
35
|
+
assert r.values and all(v.elements or v.exceptional for v in r.values)
|
|
36
|
+
assert [v.position for v in r.values] == list(range(len(r.values)))
|
|
37
|
+
assert all(v.path and v.path[0] is model.root for v in r.values)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_every_value_is_a_component_of_the_model_and_adapters_are_passed_through(model):
|
|
41
|
+
r = read_record(RECORD, model)
|
|
42
|
+
assert {v.component.ct_id for v in r.values} <= set(model.components)
|
|
43
|
+
adapters = {el.tag.split("ms-", 1)[1] for el in etree.parse(str(RECORD)).iter(f"{{{SDC4}}}*")
|
|
44
|
+
if el.tag.split("}", 1)[1].startswith("ms-") and el.tag.split("ms-", 1)[1] not in model.components}
|
|
45
|
+
assert adapters, "the fixture should contain adapter elements"
|
|
46
|
+
assert not adapters & {c.ct_id for v in r.values for c in [v.component, *v.path]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_a_quantity_keeps_its_units_nested(model):
|
|
50
|
+
r = read_record(RECORD, model)
|
|
51
|
+
q = next(v for v in r.values if v.component.sdc_type == "XdQuantity")
|
|
52
|
+
assert "xdquantity-value" in q.elements
|
|
53
|
+
units = q.elements["xdquantity-units"]
|
|
54
|
+
assert isinstance(units, dict) and units["xdstring-value"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_same_record_as_text_and_bytes(model):
|
|
58
|
+
a = read_record(RECORD, model)
|
|
59
|
+
b = read_record(RECORD.read_bytes(), model)
|
|
60
|
+
c = read_record(RECORD.read_text(encoding="utf-8"), model)
|
|
61
|
+
assert [v.elements for v in a.values] == [v.elements for v in b.values] == [v.elements for v in c.values]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_a_record_of_another_model_is_refused(model):
|
|
65
|
+
other = RECORD.read_text(encoding="utf-8").replace(f"dm-{CT}", "dm-" + "a" * 24)
|
|
66
|
+
with pytest.raises(RecordError, match="not " + CT):
|
|
67
|
+
read_record(other, model)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("text, match", [
|
|
71
|
+
("<notarecord/>", "not an sdc4:dm-"),
|
|
72
|
+
("<sdc4:dm-x", "not well-formed"),
|
|
73
|
+
])
|
|
74
|
+
def test_what_is_not_a_record_is_refused(model, text, match):
|
|
75
|
+
with pytest.raises(RecordError, match=match):
|
|
76
|
+
read_record(text, model)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_an_exceptional_value_written_at_the_source_stands_in_place_of_the_value(model):
|
|
80
|
+
tree = etree.parse(str(RECORD))
|
|
81
|
+
value, comp = _first_string(tree)
|
|
82
|
+
comp.remove(value)
|
|
83
|
+
ev = etree.SubElement(comp, f"{{{SDC4}}}ASKR")
|
|
84
|
+
etree.SubElement(ev, "ev-name").text = "Asked and Refused"
|
|
85
|
+
comp.insert(1, ev)
|
|
86
|
+
r = read_record(etree.tostring(tree), model)
|
|
87
|
+
v = next(v for v in r.values if v.exceptional)
|
|
88
|
+
assert (v.exceptional.code, v.exceptional.name, v.exceptional.origin) == ("ASKR", "Asked and Refused", "source")
|
|
89
|
+
assert "xdstring-value" not in v.elements and v.rejected == {}
|
|
90
|
+
assert r.exceptional == [v]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_an_exceptional_value_from_recovery_stands_beside_the_rejected_value(model):
|
|
94
|
+
tree = etree.parse(str(RECORD))
|
|
95
|
+
value, comp = _first_string(tree)
|
|
96
|
+
ev = etree.Element(f"{{{SDC4}}}INV")
|
|
97
|
+
etree.SubElement(ev, "ev-name").text = "Invalid"
|
|
98
|
+
comp.insert(1, ev)
|
|
99
|
+
r = read_record(etree.tostring(tree), model)
|
|
100
|
+
v = next(v for v in r.values if v.exceptional)
|
|
101
|
+
assert (v.exceptional.code, v.exceptional.origin) == ("INV", "recovery")
|
|
102
|
+
assert v.rejected == {"xdstring-value": value.text}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_every_iso_21090_code_is_known():
|
|
106
|
+
assert set(EXCEPTIONAL_VALUES) == {"INV", "OTH", "UNC", "NI", "NA", "UNK", "ASKU", "ASKR", "NASK", "NAV", "MSK",
|
|
107
|
+
"DER", "PINF", "NINF", "TRC", "QS"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_a_repeated_component_is_numbered_in_document_order(model):
|
|
111
|
+
tree = etree.parse(str(RECORD))
|
|
112
|
+
_, comp = _first_string(tree)
|
|
113
|
+
twin = etree.fromstring(etree.tostring(comp))
|
|
114
|
+
twin.find("xdstring-value").text = "second"
|
|
115
|
+
comp.addnext(twin)
|
|
116
|
+
r = read_record(etree.tostring(tree), model)
|
|
117
|
+
pair = [v for v in r.values if v.component.ct_id == comp.tag.split("ms-", 1)[1]]
|
|
118
|
+
assert [v.occurrence for v in pair[:2]] == [0, 1]
|
|
119
|
+
assert pair[1].elements["xdstring-value"] == "second"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_context_elements_are_kept_apart_from_the_value(model):
|
|
123
|
+
tree = etree.parse(str(RECORD))
|
|
124
|
+
value, comp = _first_string(tree)
|
|
125
|
+
vtb = etree.Element("vtb")
|
|
126
|
+
vtb.text = "2026-01-01T00:00:00"
|
|
127
|
+
value.addprevious(vtb)
|
|
128
|
+
r = read_record(etree.tostring(tree), model)
|
|
129
|
+
v = next(v for v in r.values if v.context)
|
|
130
|
+
assert v.context == {"vtb": "2026-01-01T00:00:00"} and "vtb" not in v.elements
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|