sdcreader 4.1.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.
@@ -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.1.0
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.1.0"
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.1.0"
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", "__version__"]
22
+ "PUBLISHER", "EXACT", "CLOSE", "SEE_ALSO", "HAS_UNIT", "read_record", "Record", "RecordValue", "RecordError", "ExceptionalValue",
23
+ "EXCEPTIONAL_VALUES", "RECORD_ATTRIBUTES", "CONTEXT_ELEMENTS", "__version__"]
@@ -0,0 +1,245 @@
1
+ #
2
+ # Copyright (c) 2026, Axius SDC, Inc.
3
+ # Licensed under the Apache License, Version 2.0.
4
+ #
5
+ """
6
+ The MCP scaffold the SDC servers share: JSON-RPC 2.0 over stdio, one message
7
+ per line, no SDK.
8
+
9
+ `sdcvalidator`, `sdcgovernance`, `sdcreceipt` and `sdcreader` each carried this
10
+ by hand. The four projection servers would have made eight copies, so it lives
11
+ here once, as the reader itself does: `sdcreader` is the package every
12
+ projection already depends on.
13
+
14
+ What a server built on this gets, and must not re-implement:
15
+
16
+ - `initialize` that negotiates rather than asserts: a client asking for a
17
+ revision in `SUPPORTED_PROTOCOL_VERSIONS` gets it echoed; anything else gets
18
+ the newest and decides for itself. 2026-07-28 removes the initialize
19
+ handshake and is a different architecture, so it is not claimed.
20
+ - Tool failures as `isError` results (SEP-1303), never JSON-RPC errors, so the
21
+ calling model sees what went wrong and can correct itself. A `ToolError`
22
+ is the recoverable kind and travels with its message; anything else is a
23
+ defect and travels without its traceback.
24
+ - `-32600` for a request or `params` that is not an object, `-32602` for
25
+ `arguments` that is not an object, `-32601` for an unknown method or tool,
26
+ `-32700` for a line that is not JSON.
27
+ - `PackageCache`: the `ct_id`-or-`directory` source argument every reader-based
28
+ tool takes, resolved once per process. A published package is immutable, so
29
+ caching it for the life of the server is safe.
30
+
31
+ No tool built on this may take a URL as an argument. The catalog host is fixed
32
+ when the server starts, so a caller cannot point a server at an arbitrary host.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import sys
39
+ from pathlib import Path
40
+ from typing import Any, Callable
41
+
42
+ from sdcreader.model import Model, read_model
43
+ from sdcreader.package import DEFAULT_HOST, ModelPackage, PackageError, fetch_package, load_package
44
+
45
+ JSONRPC_VERSION = "2.0"
46
+
47
+ #: Newest revision this scaffold implements, and the set it negotiates.
48
+ MCP_PROTOCOL_VERSION = "2025-11-25"
49
+ SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-11-25")
50
+
51
+ __all__ = [
52
+ "JSONRPC_VERSION", "MCP_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS",
53
+ "ToolError", "Server", "PackageCache", "SOURCE_ARGS", "source_properties", "int_arg", "str_arg",
54
+ ]
55
+
56
+
57
+ class ToolError(Exception):
58
+ """A tool failure the calling model can act on: bad input, or a package that does not hold up."""
59
+
60
+
61
+ #: The source argument every reader-based tool takes: one model by identifier (fetched from the fixed host) or one
62
+ #: package directory on disk, never both.
63
+ SOURCE_ARGS = {
64
+ "ct_id": {
65
+ "type": "string",
66
+ "description": "The model's identifier (CUID2) in the public catalog; the package is fetched from the host this server was started with.",
67
+ },
68
+ "directory": {
69
+ "type": "string",
70
+ "description": "A package directory already on disk (dm-<ct>.jsonld and dm-<ct>.xsd, optionally catalog.json and versions.json). Give this or ct_id, not both.",
71
+ },
72
+ }
73
+
74
+
75
+ def source_properties(plural: bool = False) -> dict:
76
+ """The JSON Schema properties for the source argument; `plural` gives the list forms a multi-model catalog takes."""
77
+ if not plural:
78
+ return dict(SOURCE_ARGS)
79
+ return {
80
+ "ct_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1,
81
+ "description": "Model identifiers (CUID2) in the public catalog, fetched from the host this server was started with."},
82
+ "directories": {"type": "array", "items": {"type": "string"}, "minItems": 1,
83
+ "description": "Package directories on disk. Give ct_ids or directories or both; at least one model."},
84
+ }
85
+
86
+
87
+ def int_arg(args: dict[str, Any], name: str, default: int, lo: int, hi: int) -> int:
88
+ v = args.get(name, default)
89
+ if isinstance(v, bool) or not isinstance(v, int):
90
+ raise ToolError(f"`{name}` must be an integer.")
91
+ if v < lo or v > hi:
92
+ raise ToolError(f"`{name}` must be between {lo} and {hi}.")
93
+ return v
94
+
95
+
96
+ def str_arg(args: dict[str, Any], name: str, default: str | None = None, required: bool = False) -> str | None:
97
+ v = args.get(name, default)
98
+ if v is None:
99
+ if required:
100
+ raise ToolError(f"`{name}` is required.")
101
+ return None
102
+ if not isinstance(v, str):
103
+ raise ToolError(f"`{name}` must be a string.")
104
+ return v
105
+
106
+
107
+ class PackageCache:
108
+ """Packages fetched or loaded in this process, by source, read once."""
109
+
110
+ def __init__(self, host: str = DEFAULT_HOST, storage: dict | None = None, fetcher: Callable | None = None, loader: Callable | None = None):
111
+ self.host = host.rstrip("/")
112
+ self.storage: dict[str, tuple[ModelPackage, Model]] = storage if storage is not None else {}
113
+ # Injectable so a server module can expose `fetch_package` as its own patchable name for tests.
114
+ self.fetcher = fetcher or (lambda ct_id, host: fetch_package(ct_id, host=host))
115
+ self.loader = loader or (lambda directory, host: load_package(directory, host=host))
116
+
117
+ def get(self, ct_id: str | None = None, directory: str | None = None) -> tuple[ModelPackage, Model]:
118
+ if bool(ct_id) == bool(directory):
119
+ raise ToolError("Give exactly one of `ct_id` (fetched from the catalog) or `directory` (a package on disk).")
120
+ if ct_id is not None and not isinstance(ct_id, str):
121
+ raise ToolError("`ct_id` must be a string.")
122
+ if directory is not None and not isinstance(directory, str):
123
+ raise ToolError("`directory` must be a string path.")
124
+ key = ct_id or f"dir:{Path(directory).resolve()}"
125
+ if key in self.storage:
126
+ return self.storage[key]
127
+ try:
128
+ pkg = self.fetcher(ct_id, self.host) if ct_id else self.loader(directory, self.host)
129
+ model = read_model(pkg)
130
+ except PackageError as exc:
131
+ raise ToolError(str(exc)) from exc
132
+ except OSError as exc:
133
+ raise ToolError(f"Could not read the package: {exc}") from exc
134
+ self.storage[key] = (pkg, model)
135
+ return pkg, model
136
+
137
+ def one(self, args: dict[str, Any]) -> tuple[ModelPackage, Model]:
138
+ """The single source a tool's arguments name."""
139
+ return self.get(args.get("ct_id"), args.get("directory"))
140
+
141
+ def many(self, args: dict[str, Any]) -> list[tuple[ModelPackage, Model]]:
142
+ """Every source a multi-model tool's arguments name, in the order given, identifiers first."""
143
+ ct_ids = args.get("ct_ids") or []
144
+ dirs = args.get("directories") or []
145
+ if not isinstance(ct_ids, list) or not isinstance(dirs, list) or not all(isinstance(x, str) for x in ct_ids + dirs):
146
+ raise ToolError("`ct_ids` and `directories` must be arrays of strings.")
147
+ if not ct_ids and not dirs:
148
+ raise ToolError("Give at least one model in `ct_ids` or `directories`.")
149
+ return [self.get(ct_id=c) for c in ct_ids] + [self.get(directory=d) for d in dirs]
150
+
151
+
152
+ class Server:
153
+ """One MCP server: a name, a version, its instructions, its tools and their handlers."""
154
+
155
+ def __init__(self, name: str, version: str, instructions: str, tools: list[dict],
156
+ handlers: dict[str, Callable[[dict[str, Any]], Any]], issues_url: str):
157
+ if set(handlers) != {t["name"] for t in tools}:
158
+ raise ValueError("every tool needs a handler and every handler a tool")
159
+ self.name, self.version, self.instructions = name, version, instructions
160
+ self.tools, self.handlers, self.issues_url = tools, handlers, issues_url
161
+
162
+ @staticmethod
163
+ def _response(id: Any, result: Any) -> dict:
164
+ return {"jsonrpc": JSONRPC_VERSION, "id": id, "result": result}
165
+
166
+ @staticmethod
167
+ def _error(id: Any, code: int, message: str, data: Any = None) -> dict:
168
+ error = {"code": code, "message": message}
169
+ if data is not None:
170
+ error["data"] = data
171
+ return {"jsonrpc": JSONRPC_VERSION, "id": id, "error": error}
172
+
173
+ def _tool_error(self, id: Any, text: str) -> dict:
174
+ return self._response(id, {"content": [{"type": "text", "text": text}], "isError": True})
175
+
176
+ def handle(self, request: Any) -> dict | None:
177
+ """Handle one JSON-RPC 2.0 request. Returns a response, or None for a notification."""
178
+ if not isinstance(request, dict):
179
+ return self._error(None, -32600, "Invalid request: expected a JSON object.")
180
+ method = request.get("method", "")
181
+ params = request.get("params", {})
182
+ req_id = request.get("id")
183
+ if params is None:
184
+ params = {}
185
+ if not isinstance(params, dict):
186
+ return self._error(req_id, -32600, "Invalid request: `params` must be an object.")
187
+
188
+ if method == "initialize":
189
+ requested = params.get("protocolVersion")
190
+ negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else MCP_PROTOCOL_VERSION
191
+ return self._response(req_id, {
192
+ "protocolVersion": negotiated,
193
+ "capabilities": {"tools": {"listChanged": False}},
194
+ "serverInfo": {"name": self.name, "version": self.version},
195
+ "instructions": self.instructions,
196
+ })
197
+ if method == "notifications/initialized":
198
+ return None
199
+ if method == "tools/list":
200
+ return self._response(req_id, {"tools": self.tools})
201
+ if method == "tools/call":
202
+ tool_name = params.get("name", "")
203
+ tool_args = params.get("arguments", {})
204
+ if tool_args is None:
205
+ tool_args = {}
206
+ if not isinstance(tool_args, dict):
207
+ return self._error(req_id, -32602, "Invalid params: `arguments` must be an object.")
208
+ handler = self.handlers.get(tool_name)
209
+ if handler is None:
210
+ return self._error(req_id, -32601, f"Unknown tool: {tool_name}")
211
+ try:
212
+ result = handler(tool_args)
213
+ except ToolError as exc:
214
+ return self._tool_error(req_id, f"Tool execution error: {exc}")
215
+ except Exception as exc: # a defect in the server, reported without its traceback
216
+ return self._tool_error(req_id, (
217
+ f"Tool execution error: the server could not process this input ({type(exc).__name__}). "
218
+ f"This is a defect worth reporting at {self.issues_url}"))
219
+ if isinstance(result, str):
220
+ text = result
221
+ else:
222
+ text = json.dumps(result, default=str)
223
+ return self._response(req_id, {"content": [{"type": "text", "text": text}]})
224
+ if method == "ping":
225
+ return self._response(req_id, {})
226
+ if req_id is not None:
227
+ return self._error(req_id, -32601, f"Method not found: {method}")
228
+ return None
229
+
230
+ def run_stdio(self) -> None:
231
+ """One JSON-RPC message per line on stdin, one response per line on stdout."""
232
+ for line in sys.stdin:
233
+ line = line.strip()
234
+ if not line:
235
+ continue
236
+ try:
237
+ request = json.loads(line)
238
+ except json.JSONDecodeError as exc:
239
+ sys.stdout.write(json.dumps(self._error(None, -32700, f"Parse error: {exc}")) + "\n")
240
+ sys.stdout.flush()
241
+ continue
242
+ response = self.handle(request)
243
+ if response is not None:
244
+ sys.stdout.write(json.dumps(response) + "\n")
245
+ sys.stdout.flush()