sdmxlib 0.45.0__tar.gz → 0.46.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.
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/PKG-INFO +2 -2
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/pyproject.toml +20 -3
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/__init__.py +2 -0
- sdmxlib-0.46.0/src/sdmxlib/_duckdb.py +22 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/federated.py +2 -6
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/query.py +1 -2
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/registry.py +10 -11
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/session.py +10 -6
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/__init__.py +29 -9
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +21 -13
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_dto.py +208 -0
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_resolve.py +37 -0
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_structure_dto.py +913 -0
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +836 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +7 -6
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +7 -6
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/metadata.py +229 -0
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/reader.py +396 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +3 -1
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/writer.py +121 -0
- sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json_1_0/writer.py +194 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml/_common.py +64 -29
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +39 -38
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +1 -1
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +74 -88
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +47 -46
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +14 -9
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +47 -46
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +18 -11
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/data_store.py +13 -5
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/registry.py +48 -44
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/sql.py +27 -31
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/__init__.py +2 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/base.py +7 -6
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/binding.py +1 -1
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/code_query.py +11 -8
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/collections.py +41 -15
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/dataset.py +16 -50
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/hierarchy.py +2 -4
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/hierarchy_query.py +13 -6
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/in_memory_registry.py +13 -44
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/istring.py +29 -6
- sdmxlib-0.46.0/src/sdmxlib/model/metadata_provision.py +60 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadataset.py +26 -11
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/ref.py +46 -8
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/registry.py +2 -1
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/validation.py +3 -3
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/polars.py +92 -12
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/_kinds.py +2 -0
- sdmxlib-0.46.0/src/sdmxlib/storage/_projections.py +58 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/lazy.py +8 -9
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/readers.py +46 -57
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/schema.py +7 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/writers.py +35 -8
- sdmxlib-0.45.0/src/sdmxlib/_duckdb.py +0 -18
- sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/metadata.py +0 -200
- sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/reader.py +0 -1224
- sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/writer.py +0 -921
- sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -166
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/README.md +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/builders/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sdmxlib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.46.0
|
|
4
4
|
Summary: SDMX structural metadata library for Python
|
|
5
5
|
Keywords: sdmx,statistics,metadata,datastructure
|
|
6
6
|
Author: gabrielgellner
|
|
@@ -16,7 +16,7 @@ Requires-Dist: attrs>=26.1.0
|
|
|
16
16
|
Requires-Dist: duckdb>=1.5.3
|
|
17
17
|
Requires-Dist: httpx>=0.28.1
|
|
18
18
|
Requires-Dist: lxml>=6.0.2
|
|
19
|
-
Requires-Dist:
|
|
19
|
+
Requires-Dist: msgspec>=0.19
|
|
20
20
|
Requires-Dist: polars>=1.39.3
|
|
21
21
|
Requires-Dist: pyarrow>=18.0.0
|
|
22
22
|
Requires-Python: >=3.13
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sdmxlib"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.46.0"
|
|
4
4
|
description = "SDMX structural metadata library for Python"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
@@ -24,7 +24,7 @@ dependencies = [
|
|
|
24
24
|
"duckdb>=1.5.3",
|
|
25
25
|
"httpx>=0.28.1",
|
|
26
26
|
"lxml>=6.0.2",
|
|
27
|
-
"
|
|
27
|
+
"msgspec>=0.19",
|
|
28
28
|
"polars>=1.39.3",
|
|
29
29
|
"pyarrow>=18.0.0",
|
|
30
30
|
]
|
|
@@ -83,11 +83,28 @@ typeCheckingMode = "standard"
|
|
|
83
83
|
# we *don't* care about are tests.
|
|
84
84
|
reportUnusedCallResult = false
|
|
85
85
|
|
|
86
|
+
# Strict-mode rules that ARE clean on this codebase (0 findings), turned on
|
|
87
|
+
# to catch regressions the flood-prone `reportUnknown*` rules would bury.
|
|
88
|
+
# The first two are basedpyright's Any-degradation catchers — the closest it
|
|
89
|
+
# gets to pyrefly's `implicit-any` family without the duckdb/pyarrow noise:
|
|
90
|
+
# * reportMissingTypeArgument — a bare generic (`list` not `list[str]`) → Any.
|
|
91
|
+
# * reportMissingParameterType — an untyped parameter → Any.
|
|
92
|
+
# The rest are pure hardening: dead casts (aligns with "narrow, don't cast"),
|
|
93
|
+
# getter/setter type drift, __init__/__new__ disagreement, mutable-default
|
|
94
|
+
# gotchas, and always-true/false `in` checks.
|
|
95
|
+
reportMissingTypeArgument = "error"
|
|
96
|
+
reportMissingParameterType = "error"
|
|
97
|
+
reportUnnecessaryCast = "error"
|
|
98
|
+
reportPropertyTypeMismatch = "error"
|
|
99
|
+
reportInconsistentConstructor = "error"
|
|
100
|
+
reportCallInDefaultInitializer = "error"
|
|
101
|
+
reportUnnecessaryContains = "error"
|
|
102
|
+
|
|
86
103
|
[dependency-groups]
|
|
87
104
|
dev = [
|
|
88
105
|
"basedpyright>=1.38.4",
|
|
89
106
|
"catppuccin[pygments]>=2.5.0",
|
|
90
|
-
"lxml
|
|
107
|
+
"types-lxml>=2025.3.30",
|
|
91
108
|
"mkdocs-material>=9.7.6",
|
|
92
109
|
"mkdocstrings[python]>=1.0.3",
|
|
93
110
|
"polars>=1.39.3",
|
|
@@ -90,6 +90,7 @@ from sdmxlib.model import (
|
|
|
90
90
|
MeasureDimension,
|
|
91
91
|
MetadataAttribute,
|
|
92
92
|
MetadataMessage,
|
|
93
|
+
MetadataProvisionAgreement,
|
|
93
94
|
MetadataSection,
|
|
94
95
|
MetadataSet,
|
|
95
96
|
MetadataStructure,
|
|
@@ -199,6 +200,7 @@ __all__ = [
|
|
|
199
200
|
"MemberValue",
|
|
200
201
|
"MetadataAttribute",
|
|
201
202
|
"MetadataMessage",
|
|
203
|
+
"MetadataProvisionAgreement",
|
|
202
204
|
"MetadataSection",
|
|
203
205
|
"MetadataSet",
|
|
204
206
|
"MetadataStructure",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Private DuckDB helpers shared by the storage layer and model query DSLs.
|
|
2
|
+
|
|
3
|
+
Lives at the package root because both ``storage/`` and the model query DSLs
|
|
4
|
+
(``code_query``, ``hierarchy_query``) need it, and neither layer may import the
|
|
5
|
+
other.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import duckdb
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def count(cursor: duckdb.DuckDBPyConnection) -> int:
|
|
14
|
+
"""Return the integer in the first cell of the cursor's first row, or 0.
|
|
15
|
+
|
|
16
|
+
The ``__len__`` helper for ``SELECT COUNT(*)`` queries: centralises the
|
|
17
|
+
``row = cur.fetchone(); row[0] if row else 0`` idiom. Generalise to a typed
|
|
18
|
+
``scalar[T](cursor, *, default: T) -> T`` if a non-count scalar read ever
|
|
19
|
+
appears.
|
|
20
|
+
"""
|
|
21
|
+
row = cursor.fetchone()
|
|
22
|
+
return int(row[0]) if row is not None else 0
|
|
@@ -35,6 +35,8 @@ from typing import TYPE_CHECKING
|
|
|
35
35
|
import httpx
|
|
36
36
|
|
|
37
37
|
from sdmxlib.api.policy import FailurePolicy, RegistryUnavailable
|
|
38
|
+
from sdmxlib.model.binding import DriftError, _validate_binding
|
|
39
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
38
40
|
|
|
39
41
|
if TYPE_CHECKING:
|
|
40
42
|
from sdmxlib.model.binding import DataflowBinding, DriftIssue
|
|
@@ -140,8 +142,6 @@ class FederatedRegistry:
|
|
|
140
142
|
Like the underlying Registry membership: never triggers a network
|
|
141
143
|
round-trip. ``False`` if no source is routed for the URN's agency.
|
|
142
144
|
"""
|
|
143
|
-
from sdmxlib.model.urn import SdmxUrn # noqa: PLC0415
|
|
144
|
-
|
|
145
145
|
if not isinstance(urn, SdmxUrn):
|
|
146
146
|
return False
|
|
147
147
|
source = self._route(urn)
|
|
@@ -158,8 +158,6 @@ class FederatedRegistry:
|
|
|
158
158
|
want to enumerate or display drift; use ``validate_binding()``
|
|
159
159
|
when you want to raise on any drift.
|
|
160
160
|
"""
|
|
161
|
-
from sdmxlib.model.binding import _validate_binding # noqa: PLC0415
|
|
162
|
-
|
|
163
161
|
return _validate_binding(binding, self)
|
|
164
162
|
|
|
165
163
|
def validate_binding(self, binding: "DataflowBinding") -> None:
|
|
@@ -171,8 +169,6 @@ class FederatedRegistry:
|
|
|
171
169
|
DriftError: If any drift is detected. ``error.issues`` carries
|
|
172
170
|
the full accumulated list.
|
|
173
171
|
"""
|
|
174
|
-
from sdmxlib.model.binding import DriftError # noqa: PLC0415
|
|
175
|
-
|
|
176
172
|
issues = self.check_binding_drift(binding)
|
|
177
173
|
if issues:
|
|
178
174
|
raise DriftError(issues)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""GetQuery[T], Query[T], and DataQuery — artefact and data queries."""
|
|
2
2
|
|
|
3
|
+
import tempfile
|
|
3
4
|
from collections.abc import Callable
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import TYPE_CHECKING, Any, Self
|
|
@@ -396,8 +397,6 @@ class DataQuery:
|
|
|
396
397
|
Example:
|
|
397
398
|
reg.data(flow).measures(["EMP", "UNE"]).pivot_to("wide.parquet")
|
|
398
399
|
"""
|
|
399
|
-
import tempfile # noqa: PLC0415
|
|
400
|
-
|
|
401
400
|
suffix = ".csv"
|
|
402
401
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
|
403
402
|
tmp_path = Path(tmp.name)
|
|
@@ -11,11 +11,12 @@ from sdmxlib.api.providers import ProviderInfo
|
|
|
11
11
|
from sdmxlib.api.query import DataQuery, GetQuery, Query, _KeySpec
|
|
12
12
|
from sdmxlib.api.session import Session
|
|
13
13
|
from sdmxlib.formats import parse as _parse
|
|
14
|
+
from sdmxlib.model.binding import DriftError, _validate_binding
|
|
14
15
|
from sdmxlib.model.dataflow import Dataflow
|
|
15
16
|
from sdmxlib.model.dataset import Dataset
|
|
16
17
|
from sdmxlib.model.datastructure import DataStructure
|
|
17
18
|
from sdmxlib.model.message import StructureMessage
|
|
18
|
-
from sdmxlib.model.ref import Ref
|
|
19
|
+
from sdmxlib.model.ref import Ref, SupportsUrn
|
|
19
20
|
from sdmxlib.model.registry import InMemoryRegistry
|
|
20
21
|
from sdmxlib.model.urn import SdmxUrn
|
|
21
22
|
|
|
@@ -300,12 +301,10 @@ class RestRegistry:
|
|
|
300
301
|
if cached is None:
|
|
301
302
|
return None
|
|
302
303
|
# Warm the in-process state. Some artefacts (e.g. LazyCodelist) don't
|
|
303
|
-
# satisfy
|
|
304
|
+
# satisfy SupportsUrn cleanly; skip in-memory interning for those while
|
|
304
305
|
# keeping Session caching.
|
|
305
|
-
import contextlib # noqa: PLC0415
|
|
306
|
-
|
|
307
306
|
self._session._map[str(urn)] = cached # noqa: SLF001
|
|
308
|
-
|
|
307
|
+
if isinstance(cached, SupportsUrn):
|
|
309
308
|
self._registry.add(cached)
|
|
310
309
|
return cached
|
|
311
310
|
|
|
@@ -671,7 +670,7 @@ class RestRegistry:
|
|
|
671
670
|
it is streamed straight to the file, and the returned Dataset's
|
|
672
671
|
``LazyFrame`` is scanned from disk.
|
|
673
672
|
"""
|
|
674
|
-
from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415
|
|
673
|
+
from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415 # lazy: defer CSV import
|
|
675
674
|
|
|
676
675
|
dataflow_ref = Ref.to(spec.dataflow) if spec.dataflow is not None else None
|
|
677
676
|
if to is None:
|
|
@@ -718,7 +717,11 @@ class RestRegistry:
|
|
|
718
717
|
ds = reg.scan_csv("ecb_exr.csv")
|
|
719
718
|
df = ds.collect(with_labels=True, lang="en")
|
|
720
719
|
"""
|
|
721
|
-
from sdmxlib.formats.sdmx_csv.reader import
|
|
720
|
+
from sdmxlib.formats.sdmx_csv.reader import ( # noqa: PLC0415 # lazy: defer CSV import
|
|
721
|
+
_peek_dataflow_id,
|
|
722
|
+
parse_dataflow_id,
|
|
723
|
+
scan_csv,
|
|
724
|
+
)
|
|
722
725
|
|
|
723
726
|
dataflow_str = _peek_dataflow_id(source)
|
|
724
727
|
if dataflow_str is None:
|
|
@@ -755,8 +758,6 @@ class RestRegistry:
|
|
|
755
758
|
when you want to enumerate or display drift; use
|
|
756
759
|
``validate_binding()`` when you want to raise on any drift.
|
|
757
760
|
"""
|
|
758
|
-
from sdmxlib.model.binding import _validate_binding # noqa: PLC0415
|
|
759
|
-
|
|
760
761
|
return _validate_binding(binding, self)
|
|
761
762
|
|
|
762
763
|
def validate_binding(self, binding: "DataflowBinding") -> None:
|
|
@@ -768,8 +769,6 @@ class RestRegistry:
|
|
|
768
769
|
DriftError: If any drift is detected. ``error.issues`` carries
|
|
769
770
|
the full accumulated list.
|
|
770
771
|
"""
|
|
771
|
-
from sdmxlib.model.binding import DriftError # noqa: PLC0415
|
|
772
|
-
|
|
773
772
|
issues = self.check_binding_drift(binding)
|
|
774
773
|
if issues:
|
|
775
774
|
raise DriftError(issues)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Session — identity map and artefact construction."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
3
|
+
from sdmxlib.model.collections import Maintainable
|
|
5
4
|
from sdmxlib.model.message import StructureMessage
|
|
6
5
|
from sdmxlib.model.urn import SdmxUrn
|
|
7
6
|
|
|
@@ -37,10 +36,15 @@ class Session:
|
|
|
37
36
|
"""
|
|
38
37
|
|
|
39
38
|
def __init__(self) -> None:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
# ``_map``/``_resolved_map`` only store + return-via-get (the unsafe
|
|
40
|
+
# object→T narrowing is localized to ``get``), so ``object`` suffices
|
|
41
|
+
# and admits the registry-cache path that writes an unbounded ``T``.
|
|
42
|
+
# ``_latest``/``_resolved_latest`` are compared on ``.version``, so
|
|
43
|
+
# they carry the checked `Maintainable` type.
|
|
44
|
+
self._map: dict[str, object] = {}
|
|
45
|
+
self._latest: dict[str, Maintainable] = {}
|
|
46
|
+
self._resolved_map: dict[str, object] = {}
|
|
47
|
+
self._resolved_latest: dict[str, Maintainable] = {}
|
|
44
48
|
self._class_tomsg_field: dict[str, str] = {}
|
|
45
49
|
|
|
46
50
|
def ingest(self, msg: StructureMessage, *, resolved: bool = False) -> None:
|
|
@@ -126,6 +126,7 @@ def serialize(
|
|
|
126
126
|
*,
|
|
127
127
|
version: str = "2.1",
|
|
128
128
|
pretty_print: bool = False,
|
|
129
|
+
validate: bool = False,
|
|
129
130
|
) -> bytes:
|
|
130
131
|
"""Serialise a Structure or Metadata message to bytes.
|
|
131
132
|
|
|
@@ -136,12 +137,22 @@ def serialize(
|
|
|
136
137
|
- ``MetadataMessage`` → ``<mes:GenericMetadata>`` envelope (XML) or
|
|
137
138
|
metadata ``$schema`` (JSON).
|
|
138
139
|
|
|
140
|
+
Serialisation and validation are separate concerns: this function
|
|
141
|
+
serialises. Pass ``validate=True`` to run `validate_message` first and
|
|
142
|
+
refuse to emit a structurally invalid message, or call `validate_message`
|
|
143
|
+
yourself. The default skips it — messages built through the typed model or
|
|
144
|
+
read back by the parser are already well-formed, and re-validating on every
|
|
145
|
+
write is wasted work.
|
|
146
|
+
|
|
139
147
|
Args:
|
|
140
148
|
msg: The message to serialise.
|
|
141
149
|
version: Wire format — ``"2.1"`` (default), ``"3.0"`` (SDMX-ML XML),
|
|
142
150
|
or ``"json"`` (SDMX-JSON 2.0). MetadataMessage requires ``"3.0"``
|
|
143
151
|
or ``"json"`` (SDMX-ML 2.1 GenericMetadata is out of scope).
|
|
144
152
|
pretty_print: If True, indent the output.
|
|
153
|
+
validate: If True, `validate_message` runs first and raises
|
|
154
|
+
``ValidationError`` (accumulated across all artefacts) before any
|
|
155
|
+
bytes are produced. Default False.
|
|
145
156
|
"""
|
|
146
157
|
if isinstance(msg, MetadataMessage):
|
|
147
158
|
if version == "json":
|
|
@@ -151,9 +162,9 @@ def serialize(
|
|
|
151
162
|
err = f"MetadataMessage cannot be serialised as {version!r} (use '3.0' or 'json')"
|
|
152
163
|
raise ValueError(err)
|
|
153
164
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
if validate:
|
|
166
|
+
# Accumulated across every artefact so all problems surface at once.
|
|
167
|
+
validate_message(msg)
|
|
157
168
|
|
|
158
169
|
if version == "json":
|
|
159
170
|
return _write_json(msg, pretty_print=pretty_print)
|
|
@@ -167,20 +178,29 @@ def serialize(
|
|
|
167
178
|
raise ValueError(err)
|
|
168
179
|
|
|
169
180
|
|
|
170
|
-
def to_xml(
|
|
181
|
+
def to_xml(
|
|
182
|
+
msg: "StructureMessage | MetadataMessage",
|
|
183
|
+
*,
|
|
184
|
+
version: str = "2.1",
|
|
185
|
+
pretty_print: bool = False,
|
|
186
|
+
validate: bool = False,
|
|
187
|
+
) -> bytes:
|
|
171
188
|
"""Serialise a Structure or Metadata message to SDMX-ML XML bytes.
|
|
172
189
|
|
|
173
190
|
MetadataMessage requires ``version="3.0"`` (SDMX-ML 2.1 GenericMetadata
|
|
174
|
-
is intentionally not supported).
|
|
191
|
+
is intentionally not supported). See `serialize` for ``validate``.
|
|
175
192
|
"""
|
|
176
193
|
if isinstance(msg, MetadataMessage) and version != "3.0":
|
|
177
194
|
version = "3.0"
|
|
178
|
-
return serialize(msg, version=version, pretty_print=pretty_print)
|
|
195
|
+
return serialize(msg, version=version, pretty_print=pretty_print, validate=validate)
|
|
179
196
|
|
|
180
197
|
|
|
181
|
-
def to_json(msg: "StructureMessage | MetadataMessage", *, pretty_print: bool = False) -> bytes:
|
|
182
|
-
"""Serialise a Structure or Metadata message to SDMX-JSON 2.0 bytes.
|
|
183
|
-
|
|
198
|
+
def to_json(msg: "StructureMessage | MetadataMessage", *, pretty_print: bool = False, validate: bool = False) -> bytes:
|
|
199
|
+
"""Serialise a Structure or Metadata message to SDMX-JSON 2.0 bytes.
|
|
200
|
+
|
|
201
|
+
See `serialize` for ``validate``.
|
|
202
|
+
"""
|
|
203
|
+
return serialize(msg, version="json", pretty_print=pretty_print, validate=validate)
|
|
184
204
|
|
|
185
205
|
|
|
186
206
|
def _detect_version(data: bytes) -> str:
|
|
@@ -16,7 +16,6 @@ import re
|
|
|
16
16
|
from datetime import datetime, timedelta
|
|
17
17
|
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
|
|
18
18
|
|
|
19
|
-
import orjson
|
|
20
19
|
import polars as pl
|
|
21
20
|
|
|
22
21
|
if TYPE_CHECKING:
|
|
@@ -24,11 +23,13 @@ if TYPE_CHECKING:
|
|
|
24
23
|
|
|
25
24
|
from sdmxlib.model.annotations import Annotations
|
|
26
25
|
from sdmxlib.model.binding import DataflowBinding
|
|
27
|
-
from sdmxlib.model.datastructure import DataStructure
|
|
26
|
+
from sdmxlib.model.datastructure import DataStructure, Dimension
|
|
28
27
|
from sdmxlib.model.istring import InternationalString
|
|
29
28
|
|
|
30
29
|
from sdmxlib.model.ref import Ref
|
|
31
30
|
|
|
31
|
+
from ._dto import ENCODER
|
|
32
|
+
|
|
32
33
|
ComponentKind = Literal["dimension", "attribute"]
|
|
33
34
|
|
|
34
35
|
|
|
@@ -176,7 +177,7 @@ def _annotation_duration(annotations: Annotations, lang: str) -> _Duration | Non
|
|
|
176
177
|
return None
|
|
177
178
|
|
|
178
179
|
|
|
179
|
-
def _frequency_dimension(dsd: DataStructure) ->
|
|
180
|
+
def _frequency_dimension(dsd: DataStructure) -> Dimension | None:
|
|
180
181
|
"""The DSD's frequency dimension, by well-known id then concept role."""
|
|
181
182
|
for dim in dsd.dimensions:
|
|
182
183
|
if dim.id in ("FREQ", "FREQUENCY"):
|
|
@@ -357,10 +358,14 @@ class ComponentTable:
|
|
|
357
358
|
`_resolve_time_bounds` can pick each period's duration from the
|
|
358
359
|
frequencies it actually co-occurs with.
|
|
359
360
|
"""
|
|
360
|
-
|
|
361
|
+
# Bind to locals so the None-guard narrows `str | None` → `str` for the
|
|
362
|
+
# `dim_cols.index(...)` calls below (equivalent to `tracks_freq()`, but
|
|
363
|
+
# the method call can't narrow the attributes).
|
|
364
|
+
time_id, freq_id = self._time_dim_id, self._freq_dim_id
|
|
365
|
+
if time_id is None or freq_id is None or time_id not in dim_cols or freq_id not in dim_cols:
|
|
361
366
|
return
|
|
362
|
-
time_col = dim_phys[dim_cols.index(
|
|
363
|
-
freq_col = dim_phys[dim_cols.index(
|
|
367
|
+
time_col = dim_phys[dim_cols.index(time_id)]
|
|
368
|
+
freq_col = dim_phys[dim_cols.index(freq_id)]
|
|
364
369
|
pairs = df.select(
|
|
365
370
|
pl.col(time_col).cast(pl.String).fill_null("").alias("__t"),
|
|
366
371
|
pl.col(freq_col).cast(pl.String).fill_null("").alias("__f"),
|
|
@@ -433,7 +438,8 @@ class ComponentTable:
|
|
|
433
438
|
# Attribute schema (both 1.0 and 2.0) requires a relationship.
|
|
434
439
|
# We currently only emit observation-level attributes, so the
|
|
435
440
|
# relationship is the empty observation marker.
|
|
436
|
-
|
|
441
|
+
observation_marker: dict[str, object] = {}
|
|
442
|
+
entry["relationship"] = {"observation": observation_marker}
|
|
437
443
|
out.append(entry)
|
|
438
444
|
return out
|
|
439
445
|
|
|
@@ -479,7 +485,7 @@ def encode_batch(
|
|
|
479
485
|
|
|
480
486
|
Hot path: column-oriented Polars work for the things that vectorise
|
|
481
487
|
(key string construction, attribute position lookups) and one
|
|
482
|
-
|
|
488
|
+
msgspec encode over the whole batch's ``{key: row}`` dict. The
|
|
483
489
|
surrounding ``{}`` braces are stripped because the caller is
|
|
484
490
|
splicing this chunk into a streamed object whose braces it owns.
|
|
485
491
|
|
|
@@ -538,11 +544,13 @@ def encode_batch(
|
|
|
538
544
|
row.append(None if raw is None else attr_int_tables[col_idx][str(raw)])
|
|
539
545
|
rows.append(row)
|
|
540
546
|
|
|
541
|
-
# ── Step 4: one
|
|
547
|
+
# ── Step 4: one msgspec encode over the whole batch, strip braces ──
|
|
542
548
|
# ``{key1:[...], key2:[...], ...}`` with the leading ``{`` and
|
|
543
549
|
# trailing ``}`` removed becomes a valid chunk to splice into the
|
|
544
|
-
# caller's streamed observations map.
|
|
545
|
-
|
|
550
|
+
# caller's streamed observations map. Polars ``.to_list()`` already
|
|
551
|
+
# yields Python-native scalars (int/float/str/None), so no numpy
|
|
552
|
+
# encode hook is needed (#179).
|
|
553
|
+
payload = ENCODER.encode(dict(zip(keys, rows, strict=True)))
|
|
546
554
|
body = payload[1:-1] # strip outer { and }
|
|
547
555
|
if first_obs or not body:
|
|
548
556
|
return body
|
|
@@ -551,7 +559,7 @@ def encode_batch(
|
|
|
551
559
|
|
|
552
560
|
def dataflow_link(binding: DataflowBinding) -> bytes:
|
|
553
561
|
"""Serialise the dataSet ``links[]`` entry pointing at the bound dataflow."""
|
|
554
|
-
return
|
|
562
|
+
return ENCODER.encode(
|
|
555
563
|
{
|
|
556
564
|
"urn": str(binding.dataflow.urn),
|
|
557
565
|
"rel": "dataflow",
|
|
@@ -686,7 +694,7 @@ def decode_observations(
|
|
|
686
694
|
observations = dataset_block.get("observations") or {}
|
|
687
695
|
rows: list[list[Any]] = []
|
|
688
696
|
for key, value_array in observations.items():
|
|
689
|
-
positions = key.split(":") if key else []
|
|
697
|
+
positions: list[str] = key.split(":") if key else []
|
|
690
698
|
if len(positions) != len(obs_dim_ids):
|
|
691
699
|
msg = (
|
|
692
700
|
f"Observation key {key!r} has {len(positions)} positions but "
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Shared SDMX-JSON 2.0 wire Structs and helpers (msgspec).
|
|
2
|
+
|
|
3
|
+
Foundation of the msgspec migration (milestone: SDMX-JSON dict+orjson →
|
|
4
|
+
typed msgspec Structs). Holds the cross-cutting wire types every artefact DTO
|
|
5
|
+
reuses — annotations, links, the message ``meta`` envelope — plus the
|
|
6
|
+
``InternationalString`` ↔ ``(name, names)`` convention, the annotation
|
|
7
|
+
convert helpers, and the shared encoder. The per-artefact DTOs (Codelist,
|
|
8
|
+
DataStructure, …) live in the writer/reader modules and build on these.
|
|
9
|
+
|
|
10
|
+
Conventions for wire Structs — read before adding one
|
|
11
|
+
-----------------------------------------------------
|
|
12
|
+
|
|
13
|
+
- **Inherit `WireStruct`.** It carries ``omit_defaults=True`` + ``gc=False``
|
|
14
|
+
for every DTO (add ``rename=_camel_rename`` in the subclass when the wire
|
|
15
|
+
wants camelCase). ``gc=False`` opts these transient, tree-shaped structs out
|
|
16
|
+
of the cyclic collector — a large encode speedup — and is safe only because
|
|
17
|
+
wire DTOs never form reference cycles (see `WireStruct`).
|
|
18
|
+
|
|
19
|
+
- **``omit_defaults=True``** (from `WireStruct`): a field whose value equals
|
|
20
|
+
its default is skipped on encode, matching SDMX-JSON's "omit optional when
|
|
21
|
+
absent".
|
|
22
|
+
|
|
23
|
+
- **A field that must ALWAYS appear on the wire is REQUIRED — no default.**
|
|
24
|
+
This is the subtle one. With ``omit_defaults`` a field equal to its default
|
|
25
|
+
is *dropped*; if you give an artefact's ``version`` a default of ``"1.0"`` it
|
|
26
|
+
silently vanishes whenever the value is ``"1.0"``. So ``id`` / ``agencyID`` /
|
|
27
|
+
``version`` and any other always-present field must be declared without a
|
|
28
|
+
default (required). ``test_dto.py`` locks this with a guard test.
|
|
29
|
+
|
|
30
|
+
- **Unknown fields are ignored on decode** (msgspec's default) — forward-
|
|
31
|
+
compatible with producer extensions.
|
|
32
|
+
|
|
33
|
+
- **Wire names that differ from Python** use ``rename=`` (e.g. ``agencyID``).
|
|
34
|
+
|
|
35
|
+
- **An ``InternationalString`` splits into ``name: str`` (primary /
|
|
36
|
+
default-language) + ``names: dict[str, str]`` (full map)** per the SDMX-JSON
|
|
37
|
+
2.0 contract. Use `emit_istr` / `ingest_istr` — never hand-roll the split.
|
|
38
|
+
Annotations use the same pattern under ``text`` / ``texts``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import msgspec
|
|
44
|
+
|
|
45
|
+
from sdmxlib.model.annotations import Annotation as _ModelAnnotation
|
|
46
|
+
from sdmxlib.model.annotations import Annotations
|
|
47
|
+
from sdmxlib.model.istring import InternationalString
|
|
48
|
+
|
|
49
|
+
# Sentinel language used when a producer sends a singular ``name`` with no
|
|
50
|
+
# ``names`` map — we can't know the real language tag, so we mirror the
|
|
51
|
+
# dict-reader's behaviour and key it under ``_default``.
|
|
52
|
+
_DEFAULT_LANG = "_default"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Wire-name convention ─────────────────────────────────────────────────────
|
|
56
|
+
# Struct fields are snake_case; `rename=_camel_rename` maps them to the SDMX-JSON
|
|
57
|
+
# camelCase wire names (msgspec's equivalent of pydantic's alias_generator).
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _camel_rename(name: str) -> str:
|
|
61
|
+
"""snake_case field → SDMX-JSON wire name.
|
|
62
|
+
|
|
63
|
+
camelCase, except ``agency_id`` → ``agencyID`` (the SDMX-IM acronym).
|
|
64
|
+
Matches msgspec's built-in ``"camel"`` for every other field. msgspec calls
|
|
65
|
+
this once per field at class definition.
|
|
66
|
+
"""
|
|
67
|
+
if name == "agency_id":
|
|
68
|
+
return "agencyID"
|
|
69
|
+
head, *tail = name.split("_")
|
|
70
|
+
return head + "".join(w[:1].upper() + w[1:] for w in tail)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── InternationalString ↔ (name, names) ─────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit_istr(v: InternationalString | None) -> tuple[str | None, dict[str, str] | None]:
|
|
77
|
+
"""Split an ``InternationalString`` into ``(primary, full map)`` for a Struct.
|
|
78
|
+
|
|
79
|
+
Returns ``(None, None)`` for an empty/absent istring so ``omit_defaults``
|
|
80
|
+
drops both the singular and plural fields. Delegates to
|
|
81
|
+
`InternationalString.emit_pair`, which does the empty-check + best-match +
|
|
82
|
+
backing-map fetch in one call (the map is shared, not copied: it feeds a
|
|
83
|
+
transient DTO that msgspec encodes and we then discard, so it is never
|
|
84
|
+
mutated).
|
|
85
|
+
"""
|
|
86
|
+
return v.emit_pair() if v is not None else (None, None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ingest_istr(name: str | None, names: dict[str, str] | None) -> dict[str, str]:
|
|
90
|
+
"""Reconstruct a lang→text map from a wire ``(name, names)`` pair.
|
|
91
|
+
|
|
92
|
+
Prefers the full ``names`` map. Falls back to ``{_DEFAULT_LANG: name}`` when
|
|
93
|
+
only the singular ``name`` is present (a producer that omitted ``names``),
|
|
94
|
+
mirroring the dict-reader. Empty when neither is present. The caller builds
|
|
95
|
+
an ``InternationalString`` from the result.
|
|
96
|
+
"""
|
|
97
|
+
if names:
|
|
98
|
+
return names
|
|
99
|
+
if name:
|
|
100
|
+
return {_DEFAULT_LANG: name}
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Wire Struct base ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WireStruct(msgspec.Struct, omit_defaults=True, gc=False):
|
|
108
|
+
"""Base for every SDMX-JSON wire DTO.
|
|
109
|
+
|
|
110
|
+
Carries the two config knobs every DTO wants:
|
|
111
|
+
|
|
112
|
+
- ``omit_defaults=True`` — a field equal to its default is skipped on encode
|
|
113
|
+
(matches SDMX-JSON's "omit optional when absent"). Subclasses that also
|
|
114
|
+
need camelCase wire names add ``rename=_camel_rename``; it composes with
|
|
115
|
+
this base's config down the MRO.
|
|
116
|
+
- ``gc=False`` — opt these structs out of CPython's *cyclic* collector.
|
|
117
|
+
Reference counting still frees them promptly (they are transient: built
|
|
118
|
+
per-serialise, encoded, discarded), so nothing leaks; skipping cyclic-GC
|
|
119
|
+
tracking removes the dominant cost of building millions of them.
|
|
120
|
+
|
|
121
|
+
Invariant that makes ``gc=False`` safe: **wire DTOs are trees** — parent →
|
|
122
|
+
children only, built fresh from the (acyclic) domain model, never holding a
|
|
123
|
+
back-reference to an ancestor or sibling. A reference *cycle* would leak
|
|
124
|
+
under ``gc=False`` (refcounting alone can't break one). Do not introduce
|
|
125
|
+
one.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Link(WireStruct):
|
|
130
|
+
"""A SDMX-JSON ``links[*]`` entry. Annotation URLs ride here under ``rel=self``."""
|
|
131
|
+
|
|
132
|
+
href: str = ""
|
|
133
|
+
rel: str = ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Annotation(WireStruct):
|
|
137
|
+
"""SDMX-JSON 2.0 ``annotations[*]``. ``text``/``texts`` carry the localised body."""
|
|
138
|
+
|
|
139
|
+
id: str | None = None
|
|
140
|
+
title: str | None = None
|
|
141
|
+
type: str | None = None
|
|
142
|
+
text: str | None = None
|
|
143
|
+
texts: dict[str, str] | None = None
|
|
144
|
+
#: The annotation URL lives in ``links`` under ``rel="self"``; a bare ``url``
|
|
145
|
+
#: is tolerated on decode for non-conformant producers.
|
|
146
|
+
url: str | None = None
|
|
147
|
+
links: list[Link] | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Sender(WireStruct):
|
|
151
|
+
"""The ``meta.sender`` object."""
|
|
152
|
+
|
|
153
|
+
id: str = ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Meta(WireStruct):
|
|
157
|
+
"""The SDMX-JSON ``meta`` envelope object."""
|
|
158
|
+
|
|
159
|
+
id: str = ""
|
|
160
|
+
prepared: str = ""
|
|
161
|
+
sender: Sender | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Annotation ↔ model ───────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def emit_annotations(annotations: Annotations | None) -> list[Annotation] | None:
|
|
168
|
+
"""Model ``Annotations`` → wire ``list[Annotation]``, or ``None`` when empty."""
|
|
169
|
+
if not annotations:
|
|
170
|
+
return None
|
|
171
|
+
out: list[Annotation] = []
|
|
172
|
+
for a in annotations:
|
|
173
|
+
text, texts = emit_istr(a.text)
|
|
174
|
+
links = [Link(rel="self", href=a.url)] if a.url else None
|
|
175
|
+
out.append(Annotation(id=a.id, title=a.title, type=a.type, text=text, texts=texts, links=links))
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _annotation_url(a: Annotation) -> str | None:
|
|
180
|
+
"""The annotation URL from its ``links`` (``rel="self"``), or the bare ``url``."""
|
|
181
|
+
if a.links:
|
|
182
|
+
for link in a.links:
|
|
183
|
+
if link.rel == "self" and link.href:
|
|
184
|
+
return link.href
|
|
185
|
+
return a.url
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def ingest_annotations(annotations: list[Annotation] | None) -> Annotations:
|
|
189
|
+
"""Wire ``list[Annotation]`` → model ``Annotations``."""
|
|
190
|
+
if not annotations:
|
|
191
|
+
return Annotations()
|
|
192
|
+
return Annotations(
|
|
193
|
+
[
|
|
194
|
+
_ModelAnnotation(
|
|
195
|
+
id=a.id,
|
|
196
|
+
title=a.title,
|
|
197
|
+
type=a.type,
|
|
198
|
+
url=_annotation_url(a),
|
|
199
|
+
text=ingest_istr(a.text, a.texts),
|
|
200
|
+
)
|
|
201
|
+
for a in annotations
|
|
202
|
+
]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Shared encoder ───────────────────────────────────────────────────────────
|
|
207
|
+
# One reusable encoder; decode is per-type via ``msgspec.json.decode(data, type=T)``.
|
|
208
|
+
ENCODER = msgspec.json.Encoder()
|