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,390 @@
1
+ """DataStructure and its components — Dimension, TimeDimension, Attribute, Measure, Group."""
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import ClassVar
6
+
7
+ from attrs import define, evolve, field
8
+
9
+ from sdmxlib.model.annotations import Annotations
10
+ from sdmxlib.model.base import MaintainableArtefact
11
+ from sdmxlib.model.codelist import Code, Codelist
12
+ from sdmxlib.model.collections import ItemList
13
+ from sdmxlib.model.concept import Concept
14
+ from sdmxlib.model.istring import InternationalString
15
+ from sdmxlib.model.organisation import Agency, _to_agency, _to_annotations, _to_istring
16
+ from sdmxlib.model.ref import Ref
17
+ from sdmxlib.model.representation import Facet
18
+ from sdmxlib.model.urn import SdmxUrn
19
+
20
+
21
+ class AttachmentLevel(Enum):
22
+ """Where an attribute attaches within the observation structure."""
23
+
24
+ DATASET = "dataset"
25
+ OBSERVATION = "observation"
26
+ SERIES = "series"
27
+ GROUP = "group"
28
+
29
+
30
+ class ConceptIdentityMixin:
31
+ """Typed access to the concept_identity Ref on a DSD component."""
32
+
33
+ __slots__ = ()
34
+
35
+ # Declared for type checking — concrete subclasses provide via attrs fields
36
+ concept: Ref[Concept] | None
37
+
38
+ @property
39
+ def concept_identity(self) -> Concept:
40
+ """The resolved Concept for this component.
41
+
42
+ Raises TypeError if no concept reference is set.
43
+ Raises UnresolvedRef if the concept has not been fetched.
44
+ """
45
+ concept = self.concept
46
+ if not isinstance(concept, Ref):
47
+ msg = f"No concept reference set: {concept!r}"
48
+ raise TypeError(msg)
49
+ return concept()
50
+
51
+
52
+ class RepresentationMixin:
53
+ """Typed access to the codelist Ref on a DSD component."""
54
+
55
+ __slots__ = ()
56
+
57
+ # Declared for type checking — concrete subclasses provide via attrs fields
58
+ representation: Ref[Codelist] | Facet | None
59
+
60
+ @property
61
+ def codelist(self) -> Codelist:
62
+ """The resolved Codelist for this component.
63
+
64
+ Raises TypeError if the representation is not a codelist reference (e.g. Facet).
65
+ Raises UnresolvedRef if the codelist has not been fetched.
66
+ """
67
+ representation = self.representation
68
+ if not isinstance(representation, Ref):
69
+ msg = f"Representation is not a codelist reference: {type(representation).__name__}"
70
+ raise TypeError(msg)
71
+ return representation()
72
+
73
+
74
+ def _to_concept_ref(v: "Ref[Concept] | SdmxUrn[Concept] | None") -> "Ref[Concept] | None":
75
+ if isinstance(v, SdmxUrn):
76
+ return Ref[Concept].unresolved(v)
77
+ return v
78
+
79
+
80
+ def _to_codelist_ref(
81
+ v: "Ref[Codelist] | SdmxUrn[Codelist] | Codelist | Facet | None",
82
+ ) -> "Ref[Codelist] | Facet | None":
83
+ if isinstance(v, SdmxUrn):
84
+ return Ref[Codelist].unresolved(v)
85
+ if isinstance(v, Codelist):
86
+ return Ref.to(v)
87
+ return v
88
+
89
+
90
+ def _to_dimensions(v: "ItemList[Dimension] | list[Dimension]") -> "ItemList[Dimension]":
91
+ return v if isinstance(v, ItemList) else ItemList(v)
92
+
93
+
94
+ def _to_attributes(v: "ItemList[Attribute] | list[Attribute]") -> "ItemList[Attribute]":
95
+ return v if isinstance(v, ItemList) else ItemList(v)
96
+
97
+
98
+ def _to_measures(v: "ItemList[Measure] | list[Measure]") -> "ItemList[Measure]":
99
+ return v if isinstance(v, ItemList) else ItemList(v)
100
+
101
+
102
+ def _to_groups(v: "ItemList[Group] | list[Group] | None") -> "ItemList[Group] | None":
103
+ if isinstance(v, list):
104
+ return ItemList(v)
105
+ return v
106
+
107
+
108
+ @define
109
+ class Dimension(ConceptIdentityMixin, RepresentationMixin):
110
+ """An identifying component of a DataStructure."""
111
+
112
+ id: str
113
+ concept: Ref[Concept] | None = field(default=None, converter=_to_concept_ref)
114
+ representation: Ref[Codelist] | Facet | None = field(default=None, converter=_to_codelist_ref)
115
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
116
+
117
+ def __attrs_post_init__(self) -> None:
118
+ if self.representation is None and isinstance(self.concept, Ref) and self.concept.resolved:
119
+ core = self.concept_identity.core_representation
120
+ if core is not None:
121
+ self.representation = core
122
+
123
+ @property
124
+ def is_enumerated(self) -> bool:
125
+ """True if this dimension is constrained to a codelist."""
126
+ return isinstance(self.representation, Ref)
127
+
128
+ @property
129
+ def text_type(self) -> str | None:
130
+ """Text type string if representation is Facet, else None."""
131
+ return self.representation.type if isinstance(self.representation, Facet) else None
132
+
133
+ @property
134
+ def enumeration_urn(self) -> SdmxUrn[Codelist] | None:
135
+ """URN of the referenced codelist, or None."""
136
+ return self.representation.urn if isinstance(self.representation, Ref) else None
137
+
138
+ @property
139
+ def codes(self) -> ItemList[Code] | None:
140
+ """Codes from the resolved codelist, or None if unresolved."""
141
+ if isinstance(self.representation, Ref) and self.representation.resolved:
142
+ return self.representation().codes
143
+ return None
144
+
145
+
146
+ @define
147
+ class TimeDimension(ConceptIdentityMixin):
148
+ """The time dimension of a DataStructure (always id=TIME_PERIOD)."""
149
+
150
+ id: str = "TIME_PERIOD"
151
+ concept: Ref[Concept] | None = field(default=None, converter=_to_concept_ref)
152
+ representation: Facet | None = None
153
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
154
+
155
+ @property
156
+ def text_type(self) -> str | None:
157
+ """Text type from the representation, or None."""
158
+ return self.representation.type if self.representation else None
159
+
160
+
161
+ @define
162
+ class Attribute(ConceptIdentityMixin, RepresentationMixin):
163
+ """A descriptive component attached to an observation, series, or dataset."""
164
+
165
+ id: str
166
+ usage: str | None = None
167
+ attachment: AttachmentLevel | None = None
168
+ related_dimensions: list[str] | None = None
169
+ related_measure: str | None = None
170
+ concept: Ref[Concept] | None = field(default=None, converter=_to_concept_ref)
171
+ representation: Ref[Codelist] | Facet | None = field(default=None, converter=_to_codelist_ref)
172
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
173
+
174
+ def __attrs_post_init__(self) -> None:
175
+ if self.representation is None and isinstance(self.concept, Ref) and self.concept.resolved:
176
+ core = self.concept_identity.core_representation
177
+ if core is not None:
178
+ self.representation = core
179
+
180
+ @property
181
+ def is_enumerated(self) -> bool:
182
+ """True if this attribute is constrained to a codelist."""
183
+ return isinstance(self.representation, Ref)
184
+
185
+ @property
186
+ def codes(self) -> ItemList[Code] | None:
187
+ """Codes from the resolved codelist, or None if unresolved."""
188
+ if isinstance(self.representation, Ref) and self.representation.resolved:
189
+ return self.representation().codes
190
+ return None
191
+
192
+
193
+ @define
194
+ class Measure(ConceptIdentityMixin, RepresentationMixin):
195
+ """An observed value component (primary measure in v2.1, Measure in v3.0)."""
196
+
197
+ id: str
198
+ usage: str | None = None
199
+ concept: Ref[Concept] | None = field(default=None, converter=_to_concept_ref)
200
+ representation: Ref[Codelist] | Facet | None = field(default=None, converter=_to_codelist_ref)
201
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
202
+
203
+ def __attrs_post_init__(self) -> None:
204
+ if self.representation is None and isinstance(self.concept, Ref) and self.concept.resolved:
205
+ core = self.concept_identity.core_representation
206
+ if core is not None:
207
+ self.representation = core
208
+
209
+ @property
210
+ def codes(self) -> ItemList[Code] | None:
211
+ """Codes from the resolved codelist, or None if unresolved."""
212
+ if isinstance(self.representation, Ref) and self.representation.resolved:
213
+ return self.representation().codes
214
+ return None
215
+
216
+
217
+ @define
218
+ class MeasureDimension(ConceptIdentityMixin, RepresentationMixin):
219
+ """The measure dimension in an SDMX 2.1 cross-sectional DSD.
220
+
221
+ Acts as a regular dimension whose codes name the measures present in the
222
+ dataset (e.g. EMPLOYMENT, UNEMPLOYMENT for an LFS flow). Its presence is
223
+ the formal SDMX 2.1 signal that data arrives in long format with one
224
+ ``OBS_VALUE`` per row — a pivot on this dimension produces the wide
225
+ (multi-measure) view analysts expect.
226
+
227
+ In SDMX 3.0 DSDs, multiple :class:`Measure` objects replace this pattern.
228
+ """
229
+
230
+ id: str
231
+ concept: Ref[Concept] | None = field(default=None, converter=_to_concept_ref)
232
+ representation: Ref[Codelist] | Facet | None = field(default=None, converter=_to_codelist_ref)
233
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
234
+
235
+ def __attrs_post_init__(self) -> None:
236
+ if self.representation is None and isinstance(self.concept, Ref) and self.concept.resolved:
237
+ core = self.concept_identity.core_representation
238
+ if core is not None:
239
+ self.representation = core
240
+
241
+ @property
242
+ def is_enumerated(self) -> bool:
243
+ """True if this dimension is constrained to a codelist."""
244
+ return isinstance(self.representation, Ref)
245
+
246
+ @property
247
+ def codes(self) -> ItemList[Code] | None:
248
+ """Codes from the resolved codelist, or None if unresolved."""
249
+ if isinstance(self.representation, Ref) and self.representation.resolved:
250
+ return self.representation().codes
251
+ return None
252
+
253
+
254
+ @define
255
+ class Group:
256
+ """A dimension grouping (v2.1). None for v3.0 sources."""
257
+
258
+ id: str
259
+ dimension_ids: list[str] = field(factory=list)
260
+
261
+
262
+ @define
263
+ class DataStructure(MaintainableArtefact):
264
+ """Defines the shape of a dataset — dimensions, attributes, measures.
265
+
266
+ Example:
267
+ dsd.dimensions["FREQ"].codes # ItemList[Code] if resolved
268
+ dsd.code_map("en") # {dim_id: {code_id: label}}
269
+ dsd.all_codelists # {dim_id: Codelist}
270
+ dsd.column_labels("en") # {dim_id: concept label}
271
+ dsd.enumerated_dimensions # ItemList[Dimension]
272
+ """
273
+
274
+ sdmx_package: ClassVar[str] = "datastructure"
275
+ sdmx_class: ClassVar[str] = "DataStructure"
276
+ msg_field: ClassVar[str] = "data_structures"
277
+
278
+ id: str
279
+ maintainer: Agency = field(converter=_to_agency)
280
+ version: str = "1.0"
281
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
282
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
283
+ is_final: bool = False
284
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
285
+ valid_from: datetime | None = None
286
+ valid_to: datetime | None = None
287
+ dimensions: ItemList[Dimension] = field(factory=ItemList, converter=_to_dimensions)
288
+ time_dimension: TimeDimension | None = None
289
+ measure_dimension: MeasureDimension | None = None
290
+ attributes: ItemList[Attribute] = field(factory=ItemList, converter=_to_attributes)
291
+ measures: ItemList[Measure] = field(factory=ItemList, converter=_to_measures)
292
+ groups: ItemList[Group] | None = field(default=None, converter=_to_groups)
293
+
294
+ @property
295
+ def components(self) -> "ItemList[Dimension | MeasureDimension | Attribute | Measure]":
296
+ """All components: dimensions + measure_dimension (if any) + attributes + measures."""
297
+ md: list[MeasureDimension] = [self.measure_dimension] if self.measure_dimension is not None else []
298
+ return ItemList([*self.dimensions, *md, *self.attributes, *self.measures])
299
+
300
+ @property
301
+ def enumerated_dimensions(self) -> ItemList[Dimension]:
302
+ """Dimensions that are constrained to a codelist."""
303
+ return self.dimensions.filter(lambda d: d.is_enumerated)
304
+
305
+ @property
306
+ def free_text_dimensions(self) -> ItemList[Dimension]:
307
+ """Dimensions that are not enumerated."""
308
+ return self.dimensions.filter(lambda d: not d.is_enumerated)
309
+
310
+ @property
311
+ def all_codelists(self) -> dict[str, Codelist]:
312
+ """All resolved codelists keyed by component id."""
313
+ result: dict[str, Codelist] = {}
314
+ for comp in [*self.dimensions, *self.attributes, *self.measures]:
315
+ if isinstance(comp.representation, Ref) and comp.representation.resolved:
316
+ result[comp.id] = comp.codelist
317
+ return result
318
+
319
+ def code_map(self, lang: str = "en") -> dict[str, dict[str, str]]:
320
+ """Build ``{component_id: {code_id: label}}`` for all resolved codelists."""
321
+ return {
322
+ comp_id: {code.id: code.name.get(lang) or code.id for code in cl.codes}
323
+ for comp_id, cl in self.all_codelists.items()
324
+ }
325
+
326
+ def column_labels(self, lang: str = "en") -> dict[str, str]:
327
+ """Map component ids to human labels from their concepts."""
328
+ result: dict[str, str] = {}
329
+ for comp in [*self.dimensions, *self.attributes, *self.measures]:
330
+ if isinstance(comp.concept, Ref) and comp.concept.resolved:
331
+ result[comp.id] = comp.concept_identity.name.get(lang) or comp.id
332
+ else:
333
+ result[comp.id] = comp.id
334
+ return result
335
+
336
+ def add(
337
+ self,
338
+ items: "Dimension | MeasureDimension | Attribute | Measure | list[Dimension | MeasureDimension | Attribute | Measure]", # noqa: E501
339
+ ) -> "DataStructure":
340
+ """Return a new DataStructure with *items* added to the appropriate list(s).
341
+
342
+ Dispatches by type — dimensions go to dimensions, attributes to
343
+ attributes, measures to measures. Accepts a single component or a
344
+ mixed list.
345
+
346
+ Example:
347
+ dsd = dsd.add(sl.Dimension(id="PARTNER", representation=cl))
348
+ dsd = dsd.add([sl.Dimension(id="PARTNER", representation=cl),
349
+ sl.Attribute(id="OBS_STATUS")])
350
+ """
351
+ item_list = [items] if not isinstance(items, list) else items
352
+ dims = list(self.dimensions)
353
+ attrs = list(self.attributes)
354
+ meas = list(self.measures)
355
+ md = self.measure_dimension
356
+ for item in item_list:
357
+ match item:
358
+ case MeasureDimension():
359
+ md = item
360
+ case Dimension():
361
+ dims.append(item)
362
+ case Attribute():
363
+ attrs.append(item)
364
+ case Measure():
365
+ meas.append(item)
366
+ return evolve(self, dimensions=dims, measure_dimension=md, attributes=attrs, measures=meas)
367
+
368
+ def drop(self, component_ids: str | list[str]) -> "DataStructure":
369
+ """Return a new DataStructure with the given component(s) removed.
370
+
371
+ Searches dimensions, attributes, and measures by id.
372
+ Accepts a single id string or a list of id strings.
373
+ Raises KeyError if any id is not found in any component list.
374
+
375
+ Example:
376
+ dsd = dsd.drop("SECTOR")
377
+ dsd = dsd.drop(["SECTOR", "OBS_STATUS"])
378
+ """
379
+ ids = [component_ids] if isinstance(component_ids, str) else list(component_ids)
380
+ all_ids = {d.id for d in self.dimensions} | {a.id for a in self.attributes} | {m.id for m in self.measures}
381
+ missing = [i for i in ids if i not in all_ids]
382
+ if missing:
383
+ raise KeyError(missing[0] if len(missing) == 1 else missing)
384
+ id_set = set(ids)
385
+ return evolve(
386
+ self,
387
+ dimensions=[d for d in self.dimensions if d.id not in id_set],
388
+ attributes=[a for a in self.attributes if a.id not in id_set],
389
+ measures=[m for m in self.measures if m.id not in id_set],
390
+ )
sdmxlib/model/expr.py ADDED
@@ -0,0 +1,120 @@
1
+ """ItemExpr, ItemPredicate, item() — expression-based filtering for ItemList.
2
+
3
+ Provides a Polars-style expression API for ItemList.filter():
4
+
5
+ dims.filter(item("id") == "FREQ")
6
+ dims.filter(item("is_enumerated"))
7
+ dims.filter(~item("is_enumerated"))
8
+ dims.filter(item("id").is_in(["FREQ", "GEO"]))
9
+ dims.filter(item("codelist").is_null())
10
+
11
+ Both lambda and expression forms are accepted by ItemList.filter():
12
+
13
+ dims.filter(lambda d: d.id == "FREQ") # lambda
14
+ dims.filter(item("id") == "FREQ") # expression
15
+ """
16
+
17
+ from collections.abc import Callable, Collection
18
+ from typing import Any
19
+
20
+
21
+ class ItemPredicate:
22
+ """A composable predicate returned by ItemExpr operations.
23
+
24
+ Supports negation via ``~``:
25
+
26
+ ~item("is_enumerated") # not is_enumerated
27
+ """
28
+
29
+ __slots__ = ("_fn",)
30
+
31
+ def __init__(self, fn: Callable[[Any], bool]) -> None:
32
+ self._fn = fn
33
+
34
+ def __call__(self, x: Any) -> bool:
35
+ """Evaluate the predicate against *x*."""
36
+ return self._fn(x)
37
+
38
+ def __invert__(self) -> "ItemPredicate":
39
+ fn = self._fn
40
+ return ItemPredicate(lambda x: not fn(x))
41
+
42
+ def __repr__(self) -> str:
43
+ return f"ItemPredicate({self._fn!r})"
44
+
45
+
46
+ class ItemExpr:
47
+ """Expression builder for a single item attribute.
48
+
49
+ Created via ``item("attr_name")``. Calling the expression directly
50
+ performs a truthy check; comparison operators and methods return
51
+ composable ``ItemPredicate`` objects.
52
+
53
+ Example:
54
+ item("is_enumerated") # truthy check
55
+ ~item("is_enumerated") # negated truthy
56
+ item("id") == "FREQ" # equality
57
+ item("id") != "FREQ" # inequality
58
+ item("id").is_in(["FREQ", "GEO"]) # membership
59
+ item("codelist").is_null() # None check
60
+ """
61
+
62
+ __slots__ = ("_attr",)
63
+
64
+ def __init__(self, attr: str) -> None:
65
+ self._attr = attr
66
+
67
+ # ── Direct use as predicate ───────────────────────────────────────────
68
+
69
+ def __call__(self, x: Any) -> bool:
70
+ """Truthy check — allows using ItemExpr directly as a filter predicate."""
71
+ return bool(getattr(x, self._attr))
72
+
73
+ def __invert__(self) -> ItemPredicate:
74
+ """Negated truthy check."""
75
+ attr = self._attr
76
+ return ItemPredicate(lambda x: not bool(getattr(x, attr)))
77
+
78
+ # ── Comparison operators ──────────────────────────────────────────────
79
+
80
+ def __eq__(self, other: object) -> ItemPredicate: # type: ignore[override]
81
+ attr = self._attr
82
+ return ItemPredicate(lambda x: getattr(x, attr) == other)
83
+
84
+ def __ne__(self, other: object) -> ItemPredicate: # type: ignore[override]
85
+ attr = self._attr
86
+ return ItemPredicate(lambda x: getattr(x, attr) != other)
87
+
88
+ # ── Methods ───────────────────────────────────────────────────────────
89
+
90
+ def is_in(self, values: Collection[Any]) -> ItemPredicate:
91
+ """True if the attribute value is in *values*."""
92
+ attr = self._attr
93
+ value_set = frozenset(values)
94
+ return ItemPredicate(lambda x: getattr(x, attr) in value_set)
95
+
96
+ def is_null(self) -> ItemPredicate:
97
+ """True if the attribute value is None."""
98
+ attr = self._attr
99
+ return ItemPredicate(lambda x: getattr(x, attr) is None)
100
+
101
+ # ── Identity ─────────────────────────────────────────────────────────
102
+
103
+ def __hash__(self) -> int:
104
+ return hash(self._attr)
105
+
106
+ def __repr__(self) -> str:
107
+ return f"item({self._attr!r})"
108
+
109
+
110
+ def item(attr: str) -> ItemExpr:
111
+ """Build a filter expression for an item attribute.
112
+
113
+ Example:
114
+ dims.filter(item("id") == "FREQ")
115
+ dims.filter(item("is_enumerated"))
116
+ dims.filter(~item("is_enumerated"))
117
+ dims.filter(item("id").is_in(["FREQ", "GEO"]))
118
+ dims.filter(item("codelist").is_null())
119
+ """
120
+ return ItemExpr(attr)
@@ -0,0 +1,111 @@
1
+ """Hierarchy and HierarchicalCode — cross-codelist hierarchical views.
2
+
3
+ SDMX 3.0 artefact that replaces HierarchicalCodelist from 2.1.
4
+ The 2.1 reader maps the older structure into this model.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import ClassVar
9
+
10
+ from attrs import define, field
11
+
12
+ from sdmxlib.model.annotations import Annotations
13
+ from sdmxlib.model.base import MaintainableArtefact
14
+ from sdmxlib.model.codelist import Code
15
+ from sdmxlib.model.collections import ItemList
16
+ from sdmxlib.model.istring import InternationalString
17
+ from sdmxlib.model.organisation import Agency, _to_agency, _to_annotations, _to_istring
18
+ from sdmxlib.model.ref import Ref
19
+ from sdmxlib.model.urn import SdmxUrn
20
+
21
+
22
+ def _to_hcode_ref(v: "Ref[Code] | SdmxUrn[Code] | None") -> "Ref[Code] | None":
23
+ if isinstance(v, SdmxUrn):
24
+ return Ref[Code].unresolved(v)
25
+ return v
26
+
27
+
28
+ def _to_hcodes(v: "ItemList[HierarchicalCode] | list[HierarchicalCode]") -> "ItemList[HierarchicalCode]":
29
+ return v if isinstance(v, ItemList) else ItemList(v)
30
+
31
+
32
+ def _to_tree(v: "ItemList[HierarchicalCode] | list[HierarchicalCode]") -> "ItemList[HierarchicalCode]":
33
+ return v if isinstance(v, ItemList) else ItemList(v)
34
+
35
+
36
+ @define
37
+ class HierarchicalCode:
38
+ """A node in a Hierarchy tree, referencing a code from a Codelist.
39
+
40
+ Provides tree navigation via ``children`` and flat lookup via the
41
+ parent ``Hierarchy.codes`` index.
42
+
43
+ Example:
44
+ node = hierarchy.tree["CANADA"]
45
+ node.children["ON"] # tree navigation
46
+ hierarchy.codes["ON"] # flat O(1) lookup
47
+ node.code() # Ref[Code] unwrap → Code
48
+ """
49
+
50
+ id: str
51
+ code: Ref[Code] | None = field(default=None, converter=_to_hcode_ref)
52
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
53
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
54
+ children: "ItemList[HierarchicalCode]" = field(factory=ItemList, converter=_to_hcodes)
55
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
56
+ valid_from: datetime | None = None
57
+ valid_to: datetime | None = None
58
+
59
+
60
+ def _flatten_nodes(nodes: ItemList["HierarchicalCode"]) -> ItemList["HierarchicalCode"]:
61
+ """Recursively collect all nodes into a flat ItemList."""
62
+ result: list[HierarchicalCode] = []
63
+
64
+ def _walk(node: HierarchicalCode) -> None:
65
+ result.append(node)
66
+ for child in node.children:
67
+ _walk(child)
68
+
69
+ for node in nodes:
70
+ _walk(node)
71
+ return ItemList(result)
72
+
73
+
74
+ @define
75
+ class Hierarchy(MaintainableArtefact):
76
+ """A standalone hierarchy of codes from one or more codelists.
77
+
78
+ Provides two access patterns:
79
+
80
+ - ``tree``: top-level entry points (roots) with ``children`` navigation
81
+ - ``codes``: flat O(1) index over ALL nodes in the hierarchy
82
+
83
+ Example:
84
+ hierarchy.tree["CANADA"].children["ON"] # tree navigation
85
+ hierarchy.codes["OTTAWA"] # flat lookup
86
+ hierarchy.codes["OTTAWA"].code() # Ref[Code] unwrap → Code
87
+ """
88
+
89
+ sdmx_package: ClassVar[str] = "codelist"
90
+ sdmx_class: ClassVar[str] = "Hierarchy"
91
+ msg_field: ClassVar[str] = "hierarchies"
92
+
93
+ id: str = ""
94
+ maintainer: Agency = field(factory=lambda: Agency(id=""), converter=_to_agency)
95
+ version: str = "1.0"
96
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
97
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
98
+ is_final: bool = False
99
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
100
+ valid_from: datetime | None = None
101
+ valid_to: datetime | None = None
102
+ tree: ItemList[HierarchicalCode] = field(factory=ItemList, converter=_to_tree)
103
+ _codes: ItemList[HierarchicalCode] = field(init=False, repr=False, factory=ItemList)
104
+
105
+ def __attrs_post_init__(self) -> None:
106
+ self._codes = _flatten_nodes(self.tree)
107
+
108
+ @property
109
+ def codes(self) -> ItemList[HierarchicalCode]:
110
+ """Flat index of ALL nodes in the hierarchy. O(1) lookup by id."""
111
+ return self._codes