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,337 @@
1
+ """Dataset — observation data paired with its DataStructure."""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ import polars as pl
7
+ from attrs import define
8
+
9
+ from sdmxlib.model.ref import Ref
10
+
11
+ if TYPE_CHECKING:
12
+ from sdmxlib.model.dataflow import Dataflow
13
+ from sdmxlib.model.datastructure import DataStructure
14
+
15
+
16
+ @define
17
+ class Dataset:
18
+ """Tabular SDMX observation data paired with its structural metadata.
19
+
20
+ Always lazy — ``data`` is a ``pl.LazyFrame``. Use :meth:`collect` to
21
+ materialise it. All transformation methods return a new ``Dataset`` and
22
+ stay lazy until a terminal is called.
23
+
24
+ The ``structure`` must be a fully resolved :class:`~sdmxlib.model.datastructure.DataStructure`.
25
+ Enumerated dimensions need their codelists resolved for ``pl.Enum`` typing
26
+ and label/validation operations.
27
+
28
+ Example:
29
+ import sdmxlib.polars as slpl
30
+
31
+ ds = slpl.scan_csv("data.csv", dsd)
32
+ df = ds.label().collect()
33
+
34
+ ds2 = ds.filter(pl.col("FREQ") == "M")
35
+ df2 = ds2.collect()
36
+
37
+ ds.sink_parquet("output.parquet") # streaming, no collect needed
38
+ """
39
+
40
+ structure: "DataStructure"
41
+ data: pl.LazyFrame
42
+ dataflow: "Ref[Dataflow] | None" = None
43
+ measure_dim_hint: str | None = None
44
+ """User-supplied hint for which regular dimension acts as the measure axis.
45
+
46
+ Set via ``measure_dim=`` on :meth:`~sdmxlib.api.registry.RestRegistry.data`
47
+ when the DSD lacks a formal ``<MeasureDimension>`` element but one of its
48
+ regular dimensions carries indicator codes (e.g. ``MEASURE``, ``INDICATOR``,
49
+ ``SUBJECT``). Controls :attr:`wide_dimension` and :meth:`pivot`.
50
+ """
51
+
52
+ # ── Schema / structure helpers ────────────────────────────────────────────
53
+
54
+ @property
55
+ def schema(self) -> dict[str, pl.DataType]:
56
+ """DSD-derived Polars schema for all components."""
57
+ from sdmxlib import polars as slpl # noqa: PLC0415
58
+
59
+ return slpl.schema(self.structure)
60
+
61
+ @property
62
+ def dimensions(self) -> list[str]:
63
+ """Ordered dimension column ids.
64
+
65
+ Includes the measure dimension (if any) and TIME_PERIOD last.
66
+ """
67
+ ids = [d.id for d in self.structure.dimensions]
68
+ if self.structure.measure_dimension is not None:
69
+ ids.append(self.structure.measure_dimension.id)
70
+ if self.structure.time_dimension is not None:
71
+ ids.append(self.structure.time_dimension.id)
72
+ return ids
73
+
74
+ @property
75
+ def wide_dimension(self) -> str | None:
76
+ """The measure dimension id for this long-format flow, else ``None``.
77
+
78
+ Sourced from the DSD's formal ``<MeasureDimension>`` if present,
79
+ otherwise from the ``measure_dim=`` hint passed to
80
+ :meth:`~sdmxlib.api.registry.RestRegistry.data`.
81
+
82
+ When set, the data arrives in long format — one ``OBS_VALUE`` per
83
+ indicator per row. Call :meth:`pivot` to reshape to wide format.
84
+ """
85
+ md = self.structure.measure_dimension
86
+ if md is not None:
87
+ return md.id
88
+ return self.measure_dim_hint
89
+
90
+ @property
91
+ def measures(self) -> list[str]:
92
+ """Measure column ids (typically ``["OBS_VALUE"]``)."""
93
+ return [m.id for m in self.structure.measures]
94
+
95
+ # ── Wide / pivot ──────────────────────────────────────────────────────────
96
+
97
+ def pivot(self) -> pl.LazyFrame:
98
+ """Pivot long-format data to wide on the measure dimension.
99
+
100
+ The pivot column is resolved from (in order):
101
+
102
+ 1. The DSD's formal ``<MeasureDimension>`` element.
103
+ 2. The ``measure_dim=`` hint passed to
104
+ :meth:`~sdmxlib.api.registry.RestRegistry.data`.
105
+
106
+ If neither is available, raises ``ValueError`` with a list of
107
+ enumerated dimensions that could act as the measure axis. Re-fetch
108
+ with ``measure_dim=`` to select one.
109
+
110
+ The pivot column codes (from the resolved codelist, or discovered from
111
+ the data) become column names; all remaining dimensions form the index.
112
+ Returns a ``pl.LazyFrame`` — call ``.collect()`` to materialise or
113
+ ``.sink_parquet()`` to stream to a file. DSD coupling ends here.
114
+
115
+ Example:
116
+ # DSD has formal MeasureDimension — automatic
117
+ if ds.wide_dimension:
118
+ wide = ds.pivot().collect()
119
+
120
+ # Regular dimension acting as measure axis — hint required
121
+ ds = reg.data(flow, measure_dim="MEASURE").fetch()
122
+ wide = ds.pivot().collect()
123
+ """
124
+ md = self.structure.measure_dimension
125
+
126
+ if md is not None:
127
+ # ── Formal MeasureDimension path ──────────────────────────────────
128
+ if not (isinstance(md.representation, Ref) and md.representation.resolved):
129
+ msg = "pivot() requires the MeasureDimension codelist to be resolved."
130
+ raise ValueError(msg)
131
+ pivot_id = md.id
132
+ on_columns = [c.id for c in md.representation().codes] # type: ignore[union-attr]
133
+ index = [d.id for d in self.structure.dimensions]
134
+
135
+ elif self.measure_dim_hint is not None:
136
+ # ── User-supplied hint path ────────────────────────────────────────
137
+ pivot_id = self.measure_dim_hint
138
+ hint_dim = next(
139
+ (d for d in self.structure.dimensions if d.id == pivot_id),
140
+ None,
141
+ )
142
+ if hint_dim is None:
143
+ msg = f"measure_dim {pivot_id!r} not found in this DSD's dimensions"
144
+ raise ValueError(msg)
145
+ rep = getattr(hint_dim, "representation", None)
146
+ if isinstance(rep, Ref) and rep.resolved:
147
+ on_columns = [c.id for c in rep().codes] # type: ignore[union-attr]
148
+ else:
149
+ # Codelist not resolved — discover column values from the data
150
+ on_columns = self.data.select(pivot_id).unique().collect()[pivot_id].cast(pl.String).to_list()
151
+ index = [d.id for d in self.structure.dimensions if d.id != pivot_id]
152
+
153
+ else:
154
+ # ── No measure axis — raise with helpful candidates ────────────────
155
+ def _cl_id(d: object) -> str:
156
+ rep = getattr(d, "representation", None)
157
+ if isinstance(rep, Ref):
158
+ return rep.urn.id
159
+ return "?"
160
+
161
+ enumerated = [
162
+ f" {d.id:<20} (codelist {_cl_id(d)})"
163
+ for d in self.structure.dimensions
164
+ if isinstance(getattr(d, "representation", None), Ref) and d.representation.resolved # type: ignore[union-attr]
165
+ ]
166
+ candidates = "\n".join(enumerated) if enumerated else " (none — no enumerated dimensions found)"
167
+ msg = (
168
+ "pivot() requires a measure dimension — none found in this DSD.\n\n"
169
+ "Enumerated dimensions that could act as a measure axis:\n"
170
+ f"{candidates}\n\n"
171
+ "Re-fetch with measure_dim= to select one, e.g.:\n"
172
+ ' reg.data(..., measure_dim="MEASURE").fetch()'
173
+ )
174
+ raise ValueError(msg)
175
+
176
+ if self.structure.time_dimension is not None:
177
+ index.append(self.structure.time_dimension.id)
178
+ measures = self.measures
179
+ values = measures[0] if len(measures) == 1 else measures
180
+ return self.data.pivot(
181
+ on=pivot_id,
182
+ on_columns=pl.Series(on_columns),
183
+ index=index,
184
+ values=values,
185
+ )
186
+
187
+ # ── SDMX-aware expressions ────────────────────────────────────────────────
188
+
189
+ def _find_component(self, column: str) -> "object":
190
+ """Return the DSD component for ``column``. Raises ValueError if absent."""
191
+ for comp in self.structure.components:
192
+ if comp.id == column:
193
+ return comp
194
+ td = self.structure.time_dimension
195
+ if td is not None and td.id == column:
196
+ return td
197
+ msg = f"{column!r} is not a component in this Dataset's DSD"
198
+ raise ValueError(msg)
199
+
200
+ def code(self, column: str) -> pl.Expr:
201
+ """Return a Polars expression for a DSD component by code value.
202
+
203
+ Equivalent to ``pl.col(column)`` but validates that ``column`` is a
204
+ known DSD component, giving an early error on typos.
205
+
206
+ Example:
207
+ ds.filter(ds.code("FREQ") == "M")
208
+ ds.filter(ds.code("FREQ").is_in(["M", "Q"]))
209
+ ds.lazy().sort(ds.code("TIME_PERIOD"))
210
+ """
211
+ self._find_component(column)
212
+ return pl.col(column)
213
+
214
+ def label(self, column: str, lang: str = "en") -> pl.Expr:
215
+ """Return a Polars expression mapping codes to labels for a DSD component.
216
+
217
+ The expression replaces code values with human-readable labels from
218
+ the resolved codelist. Works in any Polars context that accepts an
219
+ expression — ``filter``, ``sort``, ``with_columns``, etc.
220
+
221
+ Args:
222
+ column: DSD component id. Must be enumerated (resolved codelist).
223
+ lang: Language for labels. Defaults to ``"en"``.
224
+
225
+ Raises:
226
+ ValueError: If ``column`` is not a DSD component or is not enumerated.
227
+
228
+ Example:
229
+ ds.filter(ds.label("FREQ") == "Monthly")
230
+ ds.filter(
231
+ (ds.label("FREQ") == "Monthly") & (ds.code("REF_AREA") == "AT")
232
+ )
233
+ ds.lazy().sort(ds.label("FREQ")).collect()
234
+ """
235
+ comp = self._find_component(column)
236
+ rep = getattr(comp, "representation", None)
237
+ if not (isinstance(rep, Ref) and rep.resolved):
238
+ msg = f"{column!r} is not an enumerated component — no codelist to map labels from"
239
+ raise ValueError(msg)
240
+ codes = [c.id for c in rep().codes] # type: ignore[union-attr]
241
+ labels = [c.name.get(lang) or c.id for c in rep().codes] # type: ignore[union-attr]
242
+ return pl.col(column).cast(pl.String).replace_strict(old=codes, new=labels, default=None)
243
+
244
+ # ── Lazy transformations ──────────────────────────────────────────────────
245
+
246
+ def filter(self, predicate: pl.Expr) -> "Dataset":
247
+ """Return a new Dataset with a Polars filter applied. Stays lazy.
248
+
249
+ Example:
250
+ ds.filter(pl.col("FREQ") == "M").collect()
251
+ """
252
+ return Dataset(
253
+ structure=self.structure,
254
+ data=self.data.filter(predicate),
255
+ dataflow=self.dataflow,
256
+ measure_dim_hint=self.measure_dim_hint,
257
+ )
258
+
259
+ # ── Validation ────────────────────────────────────────────────────────────
260
+
261
+ def validate(self) -> pl.DataFrame:
262
+ """Validate data against DSD codelists.
263
+
264
+ Returns a DataFrame of violations (empty if all values are valid)::
265
+
266
+ ┌──────────┬───────────────┬───────┐
267
+ │ column │ invalid_value │ count │
268
+ └──────────┴───────────────┴───────┘
269
+
270
+ Example:
271
+ report = ds.validate()
272
+ if report.is_empty():
273
+ print("All values valid")
274
+ """
275
+ from sdmxlib import polars as slpl # noqa: PLC0415
276
+
277
+ return slpl.validation_report(self.structure, self.data)
278
+
279
+ # ── Terminals / escapes ───────────────────────────────────────────────────
280
+
281
+ def lazy(self, *, with_labels: bool = False, lang: str = "en") -> pl.LazyFrame:
282
+ """Return the underlying ``pl.LazyFrame``, leaving the Dataset boundary.
283
+
284
+ Use this to apply arbitrary Polars operations (select, rename, join,
285
+ group_by, …) that are not expressible as DSD-preserving transforms.
286
+ The DSD is no longer coupled to the result — you are in Polars land.
287
+
288
+ Args:
289
+ with_labels: If ``True``, replace enumerated code columns with
290
+ label columns (``{col}_label``). Values become
291
+ human-readable strings as ``pl.Enum`` in DSD-defined
292
+ order. Non-enumerated columns are unchanged.
293
+ lang: Language for labels. Defaults to ``"en"``.
294
+
295
+ Example:
296
+ ds.lazy().select("FREQ", "OBS_VALUE").collect()
297
+ ds.lazy().group_by("REF_AREA").agg(pl.col("OBS_VALUE").mean()).collect()
298
+ ds.lazy(with_labels=True).sort("FREQ_label").collect()
299
+ """
300
+ if not with_labels:
301
+ return self.data
302
+ from sdmxlib import polars as slpl # noqa: PLC0415
303
+
304
+ return slpl._label_view(self.data, self.structure, lang=lang) # noqa: SLF001
305
+
306
+ def collect(self, *, with_labels: bool = False, lang: str = "en") -> pl.DataFrame:
307
+ """Materialise the lazy frame. Terminal operation.
308
+
309
+ Args:
310
+ with_labels: If ``True``, replace enumerated code columns with
311
+ label columns (``{col}_label``). Values become
312
+ human-readable strings as ``pl.Enum`` in DSD-defined
313
+ order. Non-enumerated columns are unchanged.
314
+ lang: Language for labels. Defaults to ``"en"``.
315
+
316
+ Example:
317
+ ds.collect() # FREQ: pl.Enum(["A", "M", "Q"])
318
+ ds.collect(with_labels=True) # FREQ_label: pl.Enum(["Annual", ...])
319
+ ds.collect(with_labels=True, lang="fr")
320
+ """
321
+ return self.lazy(with_labels=with_labels, lang=lang).collect()
322
+
323
+ def sink_csv(self, path: str | Path) -> None:
324
+ """Sink observations to a CSV file. Streaming — no full collect needed.
325
+
326
+ Example:
327
+ ds.sink_csv("output.csv")
328
+ """
329
+ self.data.sink_csv(path)
330
+
331
+ def sink_parquet(self, path: str | Path) -> None:
332
+ """Sink observations to a Parquet file. Streaming — no full collect needed.
333
+
334
+ Example:
335
+ ds.sink_parquet("output.parquet")
336
+ """
337
+ self.data.sink_parquet(path)