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,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
|