sdmxlib 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdmxlib/__init__.py +155 -0
- sdmxlib/api/__init__.py +7 -0
- sdmxlib/api/client.py +246 -0
- sdmxlib/api/filters.py +67 -0
- sdmxlib/api/providers.py +66 -0
- sdmxlib/api/query.py +428 -0
- sdmxlib/api/registry.py +666 -0
- sdmxlib/api/session.py +124 -0
- sdmxlib/formats/__init__.py +166 -0
- sdmxlib/formats/sdmx_csv/__init__.py +5 -0
- sdmxlib/formats/sdmx_csv/reader.py +147 -0
- sdmxlib/formats/sdmx_json/__init__.py +1 -0
- sdmxlib/formats/sdmx_json/reader.py +897 -0
- sdmxlib/formats/sdmx_json/writer.py +698 -0
- sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
- sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
- sdmxlib/formats/sdmx_ml21/writer.py +709 -0
- sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
- sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
- sdmxlib/formats/sdmx_ml30/writer.py +730 -0
- sdmxlib/local/__init__.py +6 -0
- sdmxlib/local/_registry.py +368 -0
- sdmxlib/model/__init__.py +122 -0
- sdmxlib/model/annotations.py +103 -0
- sdmxlib/model/base.py +61 -0
- sdmxlib/model/category.py +88 -0
- sdmxlib/model/codelist.py +91 -0
- sdmxlib/model/collections.py +382 -0
- sdmxlib/model/concept.py +121 -0
- sdmxlib/model/constraint.py +92 -0
- sdmxlib/model/convert.py +268 -0
- sdmxlib/model/dataflow.py +49 -0
- sdmxlib/model/dataset.py +337 -0
- sdmxlib/model/datastructure.py +390 -0
- sdmxlib/model/expr.py +120 -0
- sdmxlib/model/hierarchy.py +111 -0
- sdmxlib/model/istring.py +81 -0
- sdmxlib/model/mapping.py +134 -0
- sdmxlib/model/message.py +70 -0
- sdmxlib/model/organisation.py +149 -0
- sdmxlib/model/provision.py +45 -0
- sdmxlib/model/ref.py +195 -0
- sdmxlib/model/registry.py +191 -0
- sdmxlib/model/representation.py +99 -0
- sdmxlib/model/urn.py +130 -0
- sdmxlib/model/validation.py +338 -0
- sdmxlib/polars.py +392 -0
- sdmxlib/py.typed +0 -0
- sdmxlib/storage/__init__.py +28 -0
- sdmxlib/storage/lazy.py +329 -0
- sdmxlib/storage/readers.py +310 -0
- sdmxlib/storage/schema.py +289 -0
- sdmxlib/storage/writers.py +503 -0
- sdmxlib-0.8.0.dist-info/METADATA +102 -0
- sdmxlib-0.8.0.dist-info/RECORD +58 -0
- sdmxlib-0.8.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""LocalRegistry — DuckDB-backed persistent SDMX artefact store.
|
|
2
|
+
|
|
3
|
+
Provides the same conceptual interface as the in-memory ``Registry``
|
|
4
|
+
with normalized tables for codelists, codes, hierarchies, DSDs,
|
|
5
|
+
dataflows, and concept schemes.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
import sdmxlib as sl
|
|
10
|
+
|
|
11
|
+
# Populate from a remote registry
|
|
12
|
+
with sl.RestRegistry("https://sdmx.oecd.org/public/rest/") as remote:
|
|
13
|
+
dsd = remote.DataStructure(agency="OECD", id="DSD_BLI").resolve()
|
|
14
|
+
local = sl.LocalRegistry("oecd.duckdb")
|
|
15
|
+
local.ingest(remote.registry)
|
|
16
|
+
local.close()
|
|
17
|
+
|
|
18
|
+
# Use later without network
|
|
19
|
+
with sl.LocalRegistry("oecd.duckdb") as local:
|
|
20
|
+
dsd = local.get(dsd_urn)
|
|
21
|
+
cm = local.code_map(codelist_urn, "en")
|
|
22
|
+
|
|
23
|
+
# Shared connection (e.g. with cataloglib)
|
|
24
|
+
import duckdb
|
|
25
|
+
|
|
26
|
+
conn = duckdb.connect("shared.duckdb")
|
|
27
|
+
local = sl.LocalRegistry(conn, languages=["en", "fr"])
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from collections.abc import Iterable, Sequence
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Self
|
|
34
|
+
|
|
35
|
+
import attrs
|
|
36
|
+
import duckdb
|
|
37
|
+
|
|
38
|
+
from sdmxlib.model.collections import ItemList
|
|
39
|
+
from sdmxlib.model.convert import from_dict
|
|
40
|
+
from sdmxlib.model.ref import Ref
|
|
41
|
+
from sdmxlib.model.registry import Registry
|
|
42
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
43
|
+
from sdmxlib.storage import readers, writers
|
|
44
|
+
from sdmxlib.storage.lazy import LazyCodelist
|
|
45
|
+
from sdmxlib.storage.readers import _TYPE_REGISTRY
|
|
46
|
+
from sdmxlib.storage.schema import create_tables, tables_exist
|
|
47
|
+
|
|
48
|
+
# Map item-level artefact class → (parent scheme type name, items attribute name)
|
|
49
|
+
_ITEM_TO_SCHEME: dict[str, tuple[str, str]] = {
|
|
50
|
+
"Concept": ("ConceptScheme", "concepts"),
|
|
51
|
+
"Code": ("Codelist", "codes"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LocalRegistry:
|
|
56
|
+
"""DuckDB-backed persistent SDMX artefact store.
|
|
57
|
+
|
|
58
|
+
Provides the same interface as the in-memory ``Registry``, plus
|
|
59
|
+
mutations (``drop``, ``replace``) and direct SQL helpers
|
|
60
|
+
(``code_map``, ``dsd_code_map``) that avoid creating Python objects.
|
|
61
|
+
|
|
62
|
+
Use as a context manager::
|
|
63
|
+
|
|
64
|
+
with LocalRegistry("store.duckdb") as local:
|
|
65
|
+
local.add(codelist)
|
|
66
|
+
cl = local.get(codelist_urn) # LazyCodelist
|
|
67
|
+
|
|
68
|
+
Accepts a DuckDB connection for shared-database workflows::
|
|
69
|
+
|
|
70
|
+
conn = duckdb.connect("shared.duckdb")
|
|
71
|
+
local = LocalRegistry(conn, languages=["en", "fr"])
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
path_or_connection: str | Path | duckdb.DuckDBPyConnection,
|
|
77
|
+
*,
|
|
78
|
+
languages: Sequence[str] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
if isinstance(path_or_connection, duckdb.DuckDBPyConnection):
|
|
81
|
+
self._conn = path_or_connection
|
|
82
|
+
self._owns_connection = False
|
|
83
|
+
else:
|
|
84
|
+
self._conn = duckdb.connect(str(path_or_connection))
|
|
85
|
+
self._owns_connection = True
|
|
86
|
+
|
|
87
|
+
if not tables_exist(self._conn):
|
|
88
|
+
create_tables(self._conn, languages=languages or ("en",))
|
|
89
|
+
elif languages is not None:
|
|
90
|
+
# Re-create with new languages (idempotent if same)
|
|
91
|
+
create_tables(self._conn, languages=languages)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def connection(self) -> duckdb.DuckDBPyConnection:
|
|
95
|
+
"""The underlying DuckDB connection (for sharing)."""
|
|
96
|
+
return self._conn
|
|
97
|
+
|
|
98
|
+
def __enter__(self) -> Self:
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, *args: object) -> None:
|
|
102
|
+
self.close()
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
"""Close the database connection (only if we own it)."""
|
|
106
|
+
if self._owns_connection:
|
|
107
|
+
self._conn.close()
|
|
108
|
+
|
|
109
|
+
# ── Add ───────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def add(self, artefact: Any) -> None:
|
|
112
|
+
"""Add or replace an artefact in the store.
|
|
113
|
+
|
|
114
|
+
Dispatches to the appropriate writer based on artefact type.
|
|
115
|
+
Codelists, DataStructures, Dataflows, ConceptSchemes, and
|
|
116
|
+
Hierarchies are written to normalized tables. Others are stored
|
|
117
|
+
as JSON blobs.
|
|
118
|
+
"""
|
|
119
|
+
writers.put_artefact(self._conn, artefact)
|
|
120
|
+
|
|
121
|
+
def add_all(self, artefacts: Iterable[Any]) -> None:
|
|
122
|
+
"""Add multiple artefacts."""
|
|
123
|
+
for artefact in artefacts:
|
|
124
|
+
self.add(artefact)
|
|
125
|
+
|
|
126
|
+
# ── Get ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def get[T](self, urn: "SdmxUrn[T]", *, eager: bool = False) -> "T | None":
|
|
129
|
+
"""Look up an artefact by URN.
|
|
130
|
+
|
|
131
|
+
For Codelists, returns a ``LazyCodelist`` by default (codes not
|
|
132
|
+
materialized). Pass ``eager=True`` to get a real ``Codelist``.
|
|
133
|
+
|
|
134
|
+
All ``Ref`` fields on the returned artefact are resolved from
|
|
135
|
+
DuckDB so that standard traversal syntax works::
|
|
136
|
+
|
|
137
|
+
dsd = local.get(dsd_urn)
|
|
138
|
+
dsd.dimensions["FREQ"].codelist() # → LazyCodelist
|
|
139
|
+
dsd.code_map("en") # works
|
|
140
|
+
"""
|
|
141
|
+
result = readers.get_artefact(self._conn, str(urn), eager=eager)
|
|
142
|
+
if result is None:
|
|
143
|
+
return None
|
|
144
|
+
if not isinstance(result, LazyCodelist):
|
|
145
|
+
self._resolve_refs(result, set())
|
|
146
|
+
return result # type: ignore[return-value]
|
|
147
|
+
|
|
148
|
+
def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
|
|
149
|
+
"""All artefacts of the given Python type."""
|
|
150
|
+
results = readers.get_all(self._conn, artefact_type)
|
|
151
|
+
for r in results:
|
|
152
|
+
if not isinstance(r, LazyCodelist):
|
|
153
|
+
self._resolve_refs(r, set())
|
|
154
|
+
return results # type: ignore[return-value]
|
|
155
|
+
|
|
156
|
+
# ── Ref resolution ─────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _resolve_refs(self, obj: Any, visited: set[int]) -> None:
|
|
159
|
+
"""Walk attrs fields, resolving ``Ref`` targets from DuckDB.
|
|
160
|
+
|
|
161
|
+
Codelist Refs resolve to ``LazyCodelist``. Other artefacts are
|
|
162
|
+
reconstructed via ``from_dict``. Missing targets stay unresolved.
|
|
163
|
+
"""
|
|
164
|
+
if not attrs.has(type(obj)) or isinstance(obj, type):
|
|
165
|
+
return
|
|
166
|
+
oid = id(obj)
|
|
167
|
+
if oid in visited:
|
|
168
|
+
return
|
|
169
|
+
visited.add(oid)
|
|
170
|
+
|
|
171
|
+
for f in attrs.fields(type(obj)):
|
|
172
|
+
value = getattr(obj, f.name, None)
|
|
173
|
+
if value is None:
|
|
174
|
+
continue
|
|
175
|
+
if isinstance(value, Ref):
|
|
176
|
+
self._resolve_ref_field(obj, f.name, value)
|
|
177
|
+
elif isinstance(value, ItemList):
|
|
178
|
+
for item in value:
|
|
179
|
+
self._resolve_refs(item, visited)
|
|
180
|
+
elif attrs.has(type(value)) and not isinstance(value, type):
|
|
181
|
+
self._resolve_refs(value, visited)
|
|
182
|
+
|
|
183
|
+
def _resolve_ref_field(self, obj: Any, name: str, ref: "Ref[Any]") -> None:
|
|
184
|
+
"""Resolve a single Ref field from DuckDB and set it on *obj*."""
|
|
185
|
+
if ref._resolved is not None: # noqa: SLF001
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
urn_str = str(ref.urn)
|
|
189
|
+
target = self._resolve_target(urn_str)
|
|
190
|
+
if target is not None:
|
|
191
|
+
resolved = Ref.of(ref.urn, target)
|
|
192
|
+
setattr(obj, name, resolved)
|
|
193
|
+
|
|
194
|
+
def _resolve_target(self, urn_str: str) -> Any | None:
|
|
195
|
+
"""Load an artefact by URN string for Ref resolution.
|
|
196
|
+
|
|
197
|
+
Returns LazyCodelist for codelists, reconstructed object for others,
|
|
198
|
+
or None if the URN is not in the store.
|
|
199
|
+
|
|
200
|
+
Handles item-level URNs (e.g. Concept within a ConceptScheme) by
|
|
201
|
+
loading the parent scheme and extracting the item.
|
|
202
|
+
"""
|
|
203
|
+
parsed = SdmxUrn.parse(urn_str)
|
|
204
|
+
if parsed.item_id is not None:
|
|
205
|
+
return self._resolve_item_urn(parsed)
|
|
206
|
+
|
|
207
|
+
row = self._conn.execute(
|
|
208
|
+
"""
|
|
209
|
+
SELECT artefact_type, _document
|
|
210
|
+
FROM artefacts
|
|
211
|
+
WHERE urn = $urn
|
|
212
|
+
""",
|
|
213
|
+
{"urn": urn_str},
|
|
214
|
+
).fetchone()
|
|
215
|
+
if row is None:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
artefact_type, doc_str = row
|
|
219
|
+
data = json.loads(doc_str)
|
|
220
|
+
|
|
221
|
+
if artefact_type == "Codelist":
|
|
222
|
+
return readers.make_lazy_codelist(self._conn, urn_str, data)
|
|
223
|
+
|
|
224
|
+
cls = _TYPE_REGISTRY.get(artefact_type)
|
|
225
|
+
if cls is None:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
if artefact_type == "Hierarchy":
|
|
229
|
+
data["tree"] = readers.load_hierarchy_tree(self._conn, urn_str)
|
|
230
|
+
|
|
231
|
+
return from_dict(cls, data)
|
|
232
|
+
|
|
233
|
+
def _resolve_item_urn(self, parsed: "SdmxUrn[Any]") -> Any | None:
|
|
234
|
+
"""Resolve an item-level URN by loading its parent scheme."""
|
|
235
|
+
scheme_info = _ITEM_TO_SCHEME.get(parsed.artefact_class)
|
|
236
|
+
if scheme_info is None:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
scheme_class_name, items_attr = scheme_info
|
|
240
|
+
scheme_cls = _TYPE_REGISTRY.get(scheme_class_name)
|
|
241
|
+
if scheme_cls is None:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
parent_urn = SdmxUrn.make(
|
|
245
|
+
scheme_cls,
|
|
246
|
+
package=parsed.package,
|
|
247
|
+
artefact_class=scheme_class_name,
|
|
248
|
+
agency=parsed.agency,
|
|
249
|
+
id=parsed.id,
|
|
250
|
+
version=parsed.version,
|
|
251
|
+
)
|
|
252
|
+
parent_urn_str = str(parent_urn)
|
|
253
|
+
|
|
254
|
+
row = self._conn.execute(
|
|
255
|
+
"""
|
|
256
|
+
SELECT urn, _document
|
|
257
|
+
FROM artefacts
|
|
258
|
+
WHERE urn = $urn AND artefact_type = $type
|
|
259
|
+
""",
|
|
260
|
+
{"urn": parent_urn_str, "type": scheme_class_name},
|
|
261
|
+
).fetchone()
|
|
262
|
+
|
|
263
|
+
if row is None and parsed.version == "latest":
|
|
264
|
+
row = self._conn.execute(
|
|
265
|
+
"""
|
|
266
|
+
SELECT urn, _document
|
|
267
|
+
FROM artefacts
|
|
268
|
+
WHERE artefact_type = $type AND agency = $agency AND id = $id
|
|
269
|
+
""",
|
|
270
|
+
{"type": scheme_class_name, "agency": parsed.agency, "id": parsed.id},
|
|
271
|
+
).fetchone()
|
|
272
|
+
|
|
273
|
+
if row is None:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
parent_urn_str, doc_str = row
|
|
277
|
+
data = json.loads(doc_str)
|
|
278
|
+
|
|
279
|
+
if scheme_class_name == "Codelist":
|
|
280
|
+
data["codes"] = readers.load_code_dicts(self._conn, parent_urn_str)
|
|
281
|
+
|
|
282
|
+
parent = from_dict(scheme_cls, data)
|
|
283
|
+
items = getattr(parent, items_attr, None)
|
|
284
|
+
if items is None:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
return items.get(parsed.item_id) if hasattr(items, "get") else None
|
|
288
|
+
|
|
289
|
+
# ── Ref ────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
def ref[T](self, urn: "SdmxUrn[T]") -> "Ref[T]":
|
|
292
|
+
"""Return an unresolved Ref for this URN."""
|
|
293
|
+
return Ref[T].unresolved(urn)
|
|
294
|
+
|
|
295
|
+
# ── Contains / len ────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def __contains__(self, urn: object) -> bool:
|
|
298
|
+
if isinstance(urn, SdmxUrn):
|
|
299
|
+
row = self._conn.execute(
|
|
300
|
+
"""
|
|
301
|
+
SELECT 1 FROM artefacts WHERE urn = $urn
|
|
302
|
+
""",
|
|
303
|
+
{"urn": str(urn)},
|
|
304
|
+
).fetchone()
|
|
305
|
+
return row is not None
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
def __len__(self) -> int:
|
|
309
|
+
row = self._conn.execute("""
|
|
310
|
+
SELECT COUNT(*) FROM artefacts
|
|
311
|
+
""").fetchone()
|
|
312
|
+
return row[0] # type: ignore[index]
|
|
313
|
+
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
rows = self._conn.execute("""
|
|
316
|
+
SELECT artefact_type, COUNT(*)
|
|
317
|
+
FROM artefacts
|
|
318
|
+
GROUP BY artefact_type
|
|
319
|
+
""").fetchall()
|
|
320
|
+
counts = ", ".join(f"{name}={cnt}" for name, cnt in rows)
|
|
321
|
+
return f"LocalRegistry({counts or 'empty'})"
|
|
322
|
+
|
|
323
|
+
# ── Mutations ─────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def drop(self, urn: "SdmxUrn[Any]") -> bool:
|
|
326
|
+
"""Remove an artefact by URN. Returns True if it existed."""
|
|
327
|
+
return writers.drop_artefact(self._conn, str(urn))
|
|
328
|
+
|
|
329
|
+
def replace(self, old_urn: "SdmxUrn[Any]", new: Any) -> None:
|
|
330
|
+
"""Remove the old artefact and add the new one."""
|
|
331
|
+
writers.drop_artefact(self._conn, str(old_urn))
|
|
332
|
+
self.add(new)
|
|
333
|
+
|
|
334
|
+
# ── Direct SQL helpers ────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def code_map(self, codelist_urn: "str | SdmxUrn[Any]", lang: str = "en") -> dict[str, str]:
|
|
337
|
+
"""Return {code_id: label} for a codelist without creating Code objects."""
|
|
338
|
+
return readers.code_map(self._conn, str(codelist_urn), lang)
|
|
339
|
+
|
|
340
|
+
def dsd_code_map(self, dsd_urn: "str | SdmxUrn[Any]", lang: str = "en") -> dict[str, dict[str, str]]:
|
|
341
|
+
"""Return {component_id: {code_id: label}} for all enumerated dimensions."""
|
|
342
|
+
return readers.code_map_for_dsd(self._conn, str(dsd_urn), lang)
|
|
343
|
+
|
|
344
|
+
# ── Bulk ──────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
def ingest(self, registry: Registry) -> None:
|
|
347
|
+
"""Copy all artefacts from an in-memory Registry into this store."""
|
|
348
|
+
self.add_all(registry._store.values()) # noqa: SLF001
|
|
349
|
+
|
|
350
|
+
# ── Escape hatch ──────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
def to_registry(self) -> Registry:
|
|
353
|
+
"""Load everything into an in-memory Registry with full Ref interning."""
|
|
354
|
+
reg = Registry()
|
|
355
|
+
rows = self._conn.execute("""
|
|
356
|
+
SELECT urn, artefact_type, _document FROM artefacts
|
|
357
|
+
""").fetchall()
|
|
358
|
+
for urn_str, artefact_type, doc_str in rows:
|
|
359
|
+
cls = _TYPE_REGISTRY.get(artefact_type)
|
|
360
|
+
if cls is None:
|
|
361
|
+
continue
|
|
362
|
+
data = json.loads(doc_str)
|
|
363
|
+
if artefact_type == "Codelist":
|
|
364
|
+
data["codes"] = readers.load_code_dicts(self._conn, urn_str)
|
|
365
|
+
elif artefact_type == "Hierarchy":
|
|
366
|
+
data["tree"] = readers.load_hierarchy_tree(self._conn, urn_str)
|
|
367
|
+
reg.add(from_dict(cls, data))
|
|
368
|
+
return reg
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""sdmxlib.model — domain model, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from sdmxlib.model.annotations import Annotation, Annotations
|
|
4
|
+
from sdmxlib.model.convert import from_dict, to_dict
|
|
5
|
+
from sdmxlib.model.validation import ReferenceIssue, ValidationIssue, validate, validate_references
|
|
6
|
+
from sdmxlib.model.base import MaintainableArtefact
|
|
7
|
+
from sdmxlib.model.category import Categorisation, Category, CategoryScheme
|
|
8
|
+
from sdmxlib.model.constraint import (
|
|
9
|
+
ConstraintAttachment,
|
|
10
|
+
ContentConstraint,
|
|
11
|
+
CubeRegion,
|
|
12
|
+
KeyValue,
|
|
13
|
+
ReferencePeriod,
|
|
14
|
+
ReleaseCalendar,
|
|
15
|
+
)
|
|
16
|
+
from sdmxlib.model.mapping import (
|
|
17
|
+
CategoryMap,
|
|
18
|
+
CategorySchemeMap,
|
|
19
|
+
CodelistMap,
|
|
20
|
+
CodeMap,
|
|
21
|
+
ComponentMap,
|
|
22
|
+
ConceptMap,
|
|
23
|
+
ConceptSchemeMap,
|
|
24
|
+
DataStructureMap,
|
|
25
|
+
DataflowMap,
|
|
26
|
+
StructureSet,
|
|
27
|
+
)
|
|
28
|
+
from sdmxlib.model.codelist import Code, Codelist
|
|
29
|
+
from sdmxlib.model.collections import ArtefactList, ItemList, Maintainable
|
|
30
|
+
from sdmxlib.model.expr import ItemExpr, ItemPredicate, item
|
|
31
|
+
from sdmxlib.model.concept import Concept, ConceptScheme
|
|
32
|
+
from sdmxlib.model.dataflow import Dataflow
|
|
33
|
+
from sdmxlib.model.dataset import Dataset
|
|
34
|
+
from sdmxlib.model.hierarchy import Hierarchy, HierarchicalCode
|
|
35
|
+
from sdmxlib.model.datastructure import (
|
|
36
|
+
AttachmentLevel,
|
|
37
|
+
Attribute,
|
|
38
|
+
DataStructure,
|
|
39
|
+
Dimension,
|
|
40
|
+
Group,
|
|
41
|
+
Measure,
|
|
42
|
+
MeasureDimension,
|
|
43
|
+
TimeDimension,
|
|
44
|
+
)
|
|
45
|
+
from sdmxlib.model.istring import InternationalString
|
|
46
|
+
from sdmxlib.model.message import StructureMessage
|
|
47
|
+
from sdmxlib.model.organisation import Agency, AgencyScheme, Contact, DataProvider, DataProviderScheme
|
|
48
|
+
from sdmxlib.model.provision import ProvisionAgreement
|
|
49
|
+
from sdmxlib.model.ref import AnyRef, Ref, SdmxRef, UnresolvedRef
|
|
50
|
+
from sdmxlib.model.registry import Registry
|
|
51
|
+
from sdmxlib.model.representation import DataType, Facet, Representation
|
|
52
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"Agency",
|
|
56
|
+
"AgencyScheme",
|
|
57
|
+
"Annotation",
|
|
58
|
+
"Annotations",
|
|
59
|
+
"AnyRef",
|
|
60
|
+
"ArtefactList",
|
|
61
|
+
"AttachmentLevel",
|
|
62
|
+
"Attribute",
|
|
63
|
+
"Categorisation",
|
|
64
|
+
"Category",
|
|
65
|
+
"CategoryMap",
|
|
66
|
+
"CategoryScheme",
|
|
67
|
+
"CategorySchemeMap",
|
|
68
|
+
"Code",
|
|
69
|
+
"CodeMap",
|
|
70
|
+
"Codelist",
|
|
71
|
+
"CodelistMap",
|
|
72
|
+
"ComponentMap",
|
|
73
|
+
"Concept",
|
|
74
|
+
"ConceptMap",
|
|
75
|
+
"ConceptScheme",
|
|
76
|
+
"ConceptSchemeMap",
|
|
77
|
+
"ConstraintAttachment",
|
|
78
|
+
"Contact",
|
|
79
|
+
"ContentConstraint",
|
|
80
|
+
"CubeRegion",
|
|
81
|
+
"DataProvider",
|
|
82
|
+
"DataProviderScheme",
|
|
83
|
+
"DataStructure",
|
|
84
|
+
"DataStructureMap",
|
|
85
|
+
"DataType",
|
|
86
|
+
"Dataflow",
|
|
87
|
+
"DataflowMap",
|
|
88
|
+
"Dataset",
|
|
89
|
+
"Dimension",
|
|
90
|
+
"Facet",
|
|
91
|
+
"Group",
|
|
92
|
+
"HierarchicalCode",
|
|
93
|
+
"Hierarchy",
|
|
94
|
+
"InternationalString",
|
|
95
|
+
"ItemExpr",
|
|
96
|
+
"ItemList",
|
|
97
|
+
"ItemPredicate",
|
|
98
|
+
"KeyValue",
|
|
99
|
+
"Maintainable",
|
|
100
|
+
"MaintainableArtefact",
|
|
101
|
+
"Measure",
|
|
102
|
+
"MeasureDimension",
|
|
103
|
+
"ProvisionAgreement",
|
|
104
|
+
"Ref",
|
|
105
|
+
"ReferenceIssue",
|
|
106
|
+
"ReferencePeriod",
|
|
107
|
+
"Registry",
|
|
108
|
+
"ReleaseCalendar",
|
|
109
|
+
"Representation",
|
|
110
|
+
"SdmxRef",
|
|
111
|
+
"SdmxUrn",
|
|
112
|
+
"StructureMessage",
|
|
113
|
+
"StructureSet",
|
|
114
|
+
"TimeDimension",
|
|
115
|
+
"UnresolvedRef",
|
|
116
|
+
"ValidationIssue",
|
|
117
|
+
"from_dict",
|
|
118
|
+
"item",
|
|
119
|
+
"to_dict",
|
|
120
|
+
"validate",
|
|
121
|
+
"validate_references",
|
|
122
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Annotation and Annotations — SDMX annotation support."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
from typing import overload
|
|
5
|
+
|
|
6
|
+
from attrs import define, field
|
|
7
|
+
|
|
8
|
+
from sdmxlib.model.istring import InternationalString
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _to_istring(v: InternationalString | dict[str, str]) -> InternationalString:
|
|
12
|
+
return InternationalString.of(v) if isinstance(v, dict) else v
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@define
|
|
16
|
+
class Annotation:
|
|
17
|
+
"""A single SDMX annotation attached to an artefact or item."""
|
|
18
|
+
|
|
19
|
+
id: str | None = None
|
|
20
|
+
title: str | None = None
|
|
21
|
+
type: str | None = None
|
|
22
|
+
url: str | None = None
|
|
23
|
+
text: InternationalString = field(factory=InternationalString, converter=_to_istring)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Annotations(Sequence[Annotation]):
|
|
27
|
+
"""Immutable collection of Annotation with type-based querying.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
"DEPRECATED" in annotations # checks annotation type
|
|
31
|
+
annotations.by_type("CONTACT") # list[Annotation]
|
|
32
|
+
annotations.first("CONTACT") # Annotation | None
|
|
33
|
+
annotations.types # list[str]
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("_items",)
|
|
37
|
+
|
|
38
|
+
def __init__(self, items: list[Annotation] | None = None) -> None:
|
|
39
|
+
self._items: tuple[Annotation, ...] = tuple(items or [])
|
|
40
|
+
|
|
41
|
+
# ── Sequence ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
def __getitem__(self, index: int) -> Annotation: ...
|
|
45
|
+
|
|
46
|
+
@overload
|
|
47
|
+
def __getitem__(self, index: slice) -> "Annotations": ...
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, index: int | slice) -> "Annotation | Annotations":
|
|
50
|
+
if isinstance(index, slice):
|
|
51
|
+
return Annotations(list(self._items[index]))
|
|
52
|
+
return self._items[index]
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self._items)
|
|
56
|
+
|
|
57
|
+
def __iter__(self) -> Iterator[Annotation]:
|
|
58
|
+
return iter(self._items)
|
|
59
|
+
|
|
60
|
+
def __bool__(self) -> bool:
|
|
61
|
+
return bool(self._items)
|
|
62
|
+
|
|
63
|
+
# ── Type-based querying ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def __contains__(self, key: object) -> bool:
|
|
66
|
+
"""True if any annotation has type == key."""
|
|
67
|
+
if isinstance(key, str):
|
|
68
|
+
return any(a.type == key for a in self._items)
|
|
69
|
+
return super().__contains__(key)
|
|
70
|
+
|
|
71
|
+
def by_type(self, annotation_type: str) -> list[Annotation]:
|
|
72
|
+
"""All annotations with the given type."""
|
|
73
|
+
return [a for a in self._items if a.type == annotation_type]
|
|
74
|
+
|
|
75
|
+
def first(self, annotation_type: str) -> Annotation | None:
|
|
76
|
+
"""First annotation with the given type, or None."""
|
|
77
|
+
for a in self._items:
|
|
78
|
+
if a.type == annotation_type:
|
|
79
|
+
return a
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def types(self) -> list[str]:
|
|
84
|
+
"""All type values present (deduplicated, order preserved)."""
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
result: list[str] = []
|
|
87
|
+
for a in self._items:
|
|
88
|
+
if a.type and a.type not in seen:
|
|
89
|
+
seen.add(a.type)
|
|
90
|
+
result.append(a.type)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# ── Equality & repr ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def __eq__(self, other: object) -> bool:
|
|
96
|
+
if isinstance(other, Annotations):
|
|
97
|
+
return self._items == other._items
|
|
98
|
+
return NotImplemented
|
|
99
|
+
|
|
100
|
+
__hash__ = None # type: ignore[assignment] # mutable container, explicitly unhashable
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"Annotations([{', '.join(repr(a) for a in self._items)}])"
|
sdmxlib/model/base.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""MaintainableArtefact mixin — provides the .urn() classmethod."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar, Self
|
|
4
|
+
|
|
5
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sdmxlib.model.organisation import Agency
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MaintainableArtefact:
|
|
12
|
+
"""Mixin for top-level SDMX artefacts that have a globally unique URN.
|
|
13
|
+
|
|
14
|
+
Subclasses must declare ``sdmx_package`` and ``sdmx_class`` as
|
|
15
|
+
class variables. The ``.urn()`` classmethod then constructs a typed
|
|
16
|
+
``SdmxUrn[Self]`` without repeating the logic in every class.
|
|
17
|
+
|
|
18
|
+
NOTE: Subclasses must redeclare the standard fields (id, maintainer,
|
|
19
|
+
version, name, description, is_final, annotations) because slotted
|
|
20
|
+
dataclasses cannot inherit slots from a non-dataclass parent.
|
|
21
|
+
Python requires that all ``__slots__`` are declared on the class that
|
|
22
|
+
uses them.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
Codelist.urn(agency="SDMX", id="CL_FREQ") # SdmxUrn[Codelist]
|
|
26
|
+
DataStructure.urn(agency="ESTAT", id="CPI", version="2.0")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
sdmx_package: ClassVar[str]
|
|
30
|
+
sdmx_class: ClassVar[str]
|
|
31
|
+
msg_field: ClassVar[str]
|
|
32
|
+
|
|
33
|
+
# Declared for type checking — concrete subclasses provide via dataclass fields
|
|
34
|
+
maintainer: "Agency | str"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def agency_id(self) -> str:
|
|
38
|
+
"""Maintainer agency identifier. Derived from the ``maintainer`` field."""
|
|
39
|
+
# After __post_init__, maintainer is always Agency (str is coerced)
|
|
40
|
+
m = self.maintainer
|
|
41
|
+
if isinstance(m, str):
|
|
42
|
+
return m
|
|
43
|
+
return m.id
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def urn(
|
|
47
|
+
cls,
|
|
48
|
+
*,
|
|
49
|
+
agency: str,
|
|
50
|
+
id: str, # noqa: A002
|
|
51
|
+
version: str = "latest",
|
|
52
|
+
) -> SdmxUrn[Self]:
|
|
53
|
+
"""Construct a typed SdmxUrn for this artefact class."""
|
|
54
|
+
return SdmxUrn.make(
|
|
55
|
+
cls,
|
|
56
|
+
package=cls.sdmx_package,
|
|
57
|
+
artefact_class=cls.sdmx_class,
|
|
58
|
+
agency=agency,
|
|
59
|
+
id=id,
|
|
60
|
+
version=version,
|
|
61
|
+
)
|