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.
Files changed (58) hide show
  1. sdmxlib/__init__.py +155 -0
  2. sdmxlib/api/__init__.py +7 -0
  3. sdmxlib/api/client.py +246 -0
  4. sdmxlib/api/filters.py +67 -0
  5. sdmxlib/api/providers.py +66 -0
  6. sdmxlib/api/query.py +428 -0
  7. sdmxlib/api/registry.py +666 -0
  8. sdmxlib/api/session.py +124 -0
  9. sdmxlib/formats/__init__.py +166 -0
  10. sdmxlib/formats/sdmx_csv/__init__.py +5 -0
  11. sdmxlib/formats/sdmx_csv/reader.py +147 -0
  12. sdmxlib/formats/sdmx_json/__init__.py +1 -0
  13. sdmxlib/formats/sdmx_json/reader.py +897 -0
  14. sdmxlib/formats/sdmx_json/writer.py +698 -0
  15. sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
  16. sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
  17. sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
  18. sdmxlib/formats/sdmx_ml21/writer.py +709 -0
  19. sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
  20. sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
  21. sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
  22. sdmxlib/formats/sdmx_ml30/writer.py +730 -0
  23. sdmxlib/local/__init__.py +6 -0
  24. sdmxlib/local/_registry.py +368 -0
  25. sdmxlib/model/__init__.py +122 -0
  26. sdmxlib/model/annotations.py +103 -0
  27. sdmxlib/model/base.py +61 -0
  28. sdmxlib/model/category.py +88 -0
  29. sdmxlib/model/codelist.py +91 -0
  30. sdmxlib/model/collections.py +382 -0
  31. sdmxlib/model/concept.py +121 -0
  32. sdmxlib/model/constraint.py +92 -0
  33. sdmxlib/model/convert.py +268 -0
  34. sdmxlib/model/dataflow.py +49 -0
  35. sdmxlib/model/dataset.py +337 -0
  36. sdmxlib/model/datastructure.py +390 -0
  37. sdmxlib/model/expr.py +120 -0
  38. sdmxlib/model/hierarchy.py +111 -0
  39. sdmxlib/model/istring.py +81 -0
  40. sdmxlib/model/mapping.py +134 -0
  41. sdmxlib/model/message.py +70 -0
  42. sdmxlib/model/organisation.py +149 -0
  43. sdmxlib/model/provision.py +45 -0
  44. sdmxlib/model/ref.py +195 -0
  45. sdmxlib/model/registry.py +191 -0
  46. sdmxlib/model/representation.py +99 -0
  47. sdmxlib/model/urn.py +130 -0
  48. sdmxlib/model/validation.py +338 -0
  49. sdmxlib/polars.py +392 -0
  50. sdmxlib/py.typed +0 -0
  51. sdmxlib/storage/__init__.py +28 -0
  52. sdmxlib/storage/lazy.py +329 -0
  53. sdmxlib/storage/readers.py +310 -0
  54. sdmxlib/storage/schema.py +289 -0
  55. sdmxlib/storage/writers.py +503 -0
  56. sdmxlib-0.8.0.dist-info/METADATA +102 -0
  57. sdmxlib-0.8.0.dist-info/RECORD +58 -0
  58. sdmxlib-0.8.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,6 @@
1
+ """Local persistent SDMX artefact store backed by DuckDB."""
2
+
3
+ from sdmxlib.local._registry import LocalRegistry
4
+ from sdmxlib.storage.lazy import LazyCodelist, LazyItemList
5
+
6
+ __all__ = ["LazyCodelist", "LazyItemList", "LocalRegistry"]
@@ -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
+ )