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
sdmxlib/api/query.py ADDED
@@ -0,0 +1,428 @@
1
+ """GetQuery[T], Query[T], and DataQuery — artefact and data queries."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any, Self
6
+
7
+ import polars as pl
8
+ from attrs import frozen
9
+
10
+ from sdmxlib.api.filters import DimFilter
11
+
12
+ if TYPE_CHECKING:
13
+ from sdmxlib.model.dataset import Dataset
14
+
15
+
16
+ @frozen
17
+ class _KeySpec:
18
+ """Bundled key inputs passed from DataQuery to registry closures."""
19
+
20
+ raw_key: str = "all"
21
+ slices: tuple[DimFilter, ...] = ()
22
+ measures: tuple[str, ...] | None = None
23
+ measure_dim_hint: str | None = None
24
+
25
+
26
+ def _to_references(reference_type: type) -> str:
27
+ """Map a model type to its SDMX REST ``references=`` parameter string."""
28
+ sdmx_class: str = getattr(reference_type, "sdmx_class", reference_type.__name__)
29
+ return sdmx_class.lower()
30
+
31
+
32
+ class GetQuery[T]:
33
+ """Single-artefact lookup with configurable resolution depth.
34
+
35
+ Returned by ``reg.get()``. Call ``.fetch()`` for an unresolved skeleton
36
+ or ``.resolve()`` to follow references.
37
+
38
+ Example:
39
+ reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").fetch()
40
+ reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve()
41
+ reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve(sl.Codelist)
42
+ reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve(partial=False)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ fetcher: Callable[[], T],
48
+ resolver: Callable[[str, bool], T],
49
+ ) -> None:
50
+ self._fetcher = fetcher
51
+ self._resolver = resolver
52
+
53
+ def fetch(self) -> T:
54
+ """Fetch the artefact skeleton only (``detail=full``, ``references=none``)."""
55
+ return self._fetcher()
56
+
57
+ def resolve(
58
+ self,
59
+ reference_type: type | None = None,
60
+ *,
61
+ partial: bool = True,
62
+ ) -> T:
63
+ """Fetch the artefact with references resolved.
64
+
65
+ Args:
66
+ reference_type: If given, resolve only Refs of this type.
67
+ If ``None`` (default), resolve all references.
68
+ partial: If ``True`` (default), referenced ItemSchemes are trimmed
69
+ to items allowed by ContentConstraints
70
+ (``detail=referencepartial``). Produces smaller, more
71
+ useful responses for the common case. Set to ``False``
72
+ for full metadata audits (``detail=full``).
73
+
74
+ Wire mapping:
75
+ resolve() → detail=referencepartial, references=descendants
76
+ resolve(partial=False) → detail=full, references=descendants
77
+ resolve(sl.Codelist) → detail=referencepartial, references=codelist
78
+ resolve(sl.Codelist, partial=False) → detail=full, references=codelist
79
+ """
80
+ ref_str = "descendants" if reference_type is None else _to_references(reference_type)
81
+ return self._resolver(ref_str, partial)
82
+
83
+
84
+ class Query[T]:
85
+ """Lazy collection search. Fetches on terminal call.
86
+
87
+ Server-side constraint (agency) is set at construction.
88
+ Client-side refinements (filter, sort_by, limit) chain before the terminal.
89
+
90
+ Terminals:
91
+ browse() — identity stubs only (no items populated)
92
+ fetch() — full artefacts, Refs unresolved
93
+ resolve() — full artefacts, Refs resolved (constrained by default)
94
+ resolve(partial=False) — full artefacts, Refs resolved, all codes
95
+
96
+ Example:
97
+ reg.query(sl.Codelist, agency="ESTAT").fetch()
98
+
99
+ (
100
+ reg.query(sl.Dataflow, agency="ECB")
101
+ .filter(lambda df: "EXR" in df.id)
102
+ .sort_by("id")
103
+ .limit(5)
104
+ .resolve()
105
+ )
106
+
107
+ reg.query(sl.Dataflow, agency="ECB").resolve(sl.DataStructure)
108
+ reg.query(sl.Dataflow, agency="ECB").browse()
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ fetcher: Callable[[], list[T]],
114
+ resolver: Callable[[str, bool], list[T]],
115
+ browser: Callable[[], list[T]],
116
+ ) -> None:
117
+ self._fetcher = fetcher
118
+ self._resolver = resolver
119
+ self._browser = browser
120
+ self._filters: list[Callable[[T], bool]] = []
121
+ self._order_key: str | Callable[[T], Any] | None = None
122
+ self._descending: bool = False
123
+ self._limit: int | None = None
124
+
125
+ # ── Refinement (return Self) ──────────────────────────────────────────────
126
+
127
+ def filter(self, predicate: Callable[[T], bool]) -> Self:
128
+ """Add a client-side filter predicate."""
129
+ self._filters.append(predicate)
130
+ return self
131
+
132
+ def sort_by(self, key: str | Callable[[T], Any], *, descending: bool = False) -> Self:
133
+ """Sort results by attribute name or key function."""
134
+ self._order_key = key
135
+ self._descending = descending
136
+ return self
137
+
138
+ def limit(self, n: int) -> Self:
139
+ """Truncate results to at most n items."""
140
+ self._limit = n
141
+ return self
142
+
143
+ # ── Internal pipeline ─────────────────────────────────────────────────────
144
+
145
+ def _apply(self, items: list[T]) -> list[T]:
146
+ for pred in self._filters:
147
+ items = [item for item in items if pred(item)]
148
+ if self._order_key is not None:
149
+ order_key = self._order_key
150
+ key_fn: Callable[[T], Any] = order_key if callable(order_key) else lambda item: getattr(item, order_key)
151
+ items = sorted(items, key=key_fn, reverse=self._descending)
152
+ if self._limit is not None:
153
+ items = items[: self._limit]
154
+ return items
155
+
156
+ # ── Terminals ─────────────────────────────────────────────────────────────
157
+
158
+ def browse(self) -> list[T]:
159
+ """Fetch lightweight identity stubs (``detail=allstubs``, ``references=none``).
160
+
161
+ Each artefact has identity fields (id, agency, version, name) but
162
+ item collections are empty. Use for catalogue scanning before deciding
163
+ which artefacts to fetch in full.
164
+
165
+ Example:
166
+ for df in reg.query(sl.Dataflow, agency="ECB").browse():
167
+ print(df.id, df.name.get("en"))
168
+ """
169
+ return self._apply(self._browser())
170
+
171
+ def fetch(self) -> list[T]:
172
+ """Fetch artefacts with unresolved Refs (``detail=full``, ``references=none``)."""
173
+ return self._apply(self._fetcher())
174
+
175
+ def fetch_one(self) -> T:
176
+ """Fetch exactly one artefact. Raises if count != 1."""
177
+ results = self.fetch()
178
+ if len(results) != 1:
179
+ msg = f"Expected exactly 1 result, got {len(results)}"
180
+ raise ValueError(msg)
181
+ return results[0]
182
+
183
+ def fetch_first(self) -> T:
184
+ """Fetch the first matching artefact. Raises if none found."""
185
+ results = self.fetch()
186
+ if not results:
187
+ msg = "No results found"
188
+ raise ValueError(msg)
189
+ return results[0]
190
+
191
+ def fetch_optional(self) -> T | None:
192
+ """Fetch the first matching artefact, or None if none found."""
193
+ results = self.fetch()
194
+ return results[0] if results else None
195
+
196
+ def resolve(
197
+ self,
198
+ reference_type: type | None = None,
199
+ *,
200
+ partial: bool = True,
201
+ ) -> list[T]:
202
+ """Fetch artefacts with references resolved.
203
+
204
+ Args:
205
+ reference_type: If given, resolve only Refs of this type.
206
+ partial: Controls whether referenced ItemSchemes are trimmed by
207
+ ContentConstraints. ``True`` (default) is faster and
208
+ more useful for data queries; ``False`` for full audits.
209
+ """
210
+ ref_str = "descendants" if reference_type is None else _to_references(reference_type)
211
+ return self._apply(self._resolver(ref_str, partial))
212
+
213
+ def resolve_one(
214
+ self,
215
+ reference_type: type | None = None,
216
+ *,
217
+ partial: bool = True,
218
+ ) -> T:
219
+ """Resolve exactly one artefact. Raises if count != 1."""
220
+ results = self.resolve(reference_type, partial=partial)
221
+ if len(results) != 1:
222
+ msg = f"Expected exactly 1 result, got {len(results)}"
223
+ raise ValueError(msg)
224
+ return results[0]
225
+
226
+ def resolve_first(
227
+ self,
228
+ reference_type: type | None = None,
229
+ *,
230
+ partial: bool = True,
231
+ ) -> T:
232
+ """Resolve the first matching artefact. Raises if none found."""
233
+ results = self.resolve(reference_type, partial=partial)
234
+ if not results:
235
+ msg = "No results found"
236
+ raise ValueError(msg)
237
+ return results[0]
238
+
239
+ def resolve_optional(
240
+ self,
241
+ reference_type: type | None = None,
242
+ *,
243
+ partial: bool = True,
244
+ ) -> T | None:
245
+ """Resolve the first matching artefact, or None if none found."""
246
+ results = self.resolve(reference_type, partial=partial)
247
+ return results[0] if results else None
248
+
249
+ def count(self) -> int:
250
+ """Return the number of matching artefacts."""
251
+ return len(self.fetch())
252
+
253
+
254
+ class DataQuery:
255
+ """Lazy data fetch — resolves to a :class:`~sdmxlib.model.dataset.Dataset`.
256
+
257
+ Returned by :meth:`~sdmxlib.api.registry.RestRegistry.data`. Use
258
+ :meth:`fetch` for in-memory loading or :meth:`fetch_to` to stream
259
+ directly to a file for large datasets. Use :meth:`slice` and
260
+ :meth:`measures` to filter what comes over the wire.
261
+
262
+ Example:
263
+ # Raw positional key string (backwards compatible)
264
+ ds = reg.data(flow, key="M.USD.EUR.SP00.A").fetch()
265
+
266
+ # Named dimension filter — no positional knowledge needed
267
+ ds = (
268
+ reg.data(flow)
269
+ .slice(sl.dim("FREQ") == "M", sl.dim("CURRENCY") == "USD")
270
+ .fetch()
271
+ )
272
+
273
+ # Measure selection + pivot for long-format SDMX 2.1 flows
274
+ df = (
275
+ reg.data(flow)
276
+ .slice(sl.dim("REF_AREA").isin(["AT", "DE"]))
277
+ .measures(["EMP", "UNE"])
278
+ .pivot()
279
+ .collect()
280
+ )
281
+
282
+ # File-backed streaming for large data
283
+ ds = reg.data(sl.Dataflow, agency="ESTAT", id="LFS").fetch_to("lfs.csv")
284
+ ds.sink_parquet("lfs.parquet")
285
+ """
286
+
287
+ def __init__(
288
+ self,
289
+ fetcher: "Callable[[_KeySpec], Dataset]",
290
+ streamer: "Callable[[Path, _KeySpec], Dataset]",
291
+ raw_key: str = "all",
292
+ measure_dim: str | None = None,
293
+ ) -> None:
294
+ self._fetcher = fetcher
295
+ self._streamer = streamer
296
+ self._raw_key = raw_key
297
+ self._measure_dim = measure_dim
298
+ self._slices: list[DimFilter] = []
299
+ self._measures: list[str] | None = None
300
+
301
+ def slice(self, *filters: DimFilter) -> Self:
302
+ """Add server-side dimension filters to the SDMX key.
303
+
304
+ Each filter is a :class:`~sdmxlib.api.filters.DimFilter` produced by
305
+ :func:`~sdmxlib.dim`. Calling ``.slice()`` multiple times accumulates
306
+ filters — each call is additive.
307
+
308
+ Unspecified dimensions become wildcards. Raises ``ValueError`` if
309
+ ``key=`` was also passed to
310
+ :meth:`~sdmxlib.api.registry.RestRegistry.data`.
311
+
312
+ Example:
313
+ import sdmxlib as sl
314
+
315
+ reg.data(flow).slice(sl.dim("FREQ") == "M").fetch()
316
+ reg.data(flow).slice(
317
+ sl.dim("FREQ") == "M",
318
+ sl.dim("REF_AREA").isin(["AT", "DE"]),
319
+ ).fetch()
320
+
321
+ # Chained — equivalent to a single call
322
+ (
323
+ reg.data(flow)
324
+ .slice(sl.dim("FREQ") == "M")
325
+ .slice(sl.dim("REF_AREA") == "AT")
326
+ .fetch()
327
+ )
328
+ """
329
+ if self._raw_key != "all":
330
+ msg = "Cannot combine .slice() with key= — use one or the other"
331
+ raise ValueError(msg)
332
+ self._slices.extend(filters)
333
+ return self
334
+
335
+ def measures(self, codes: "str | list[str]") -> Self:
336
+ """Select which measures or indicators to fetch.
337
+
338
+ For SDMX 2.1 flows with a ``MeasureDimension`` (e.g. ``INDIC_EM``),
339
+ this filters the indicator key segment — controlling which columns
340
+ appear after :meth:`pivot`. For SDMX 3.0 multi-measure flows this
341
+ will become the ``measures=`` query parameter.
342
+
343
+ ``codes`` is a single code string or a list of code strings.
344
+
345
+ Example:
346
+ # Fetch only EMP and UNE indicators, then pivot to wide
347
+ reg.data(flow).measures(["EMP", "UNE"]).pivot().collect()
348
+
349
+ # Single measure
350
+ reg.data(flow).measures("EMP").fetch()
351
+ """
352
+ self._measures = [codes] if isinstance(codes, str) else list(codes)
353
+ return self
354
+
355
+ def _spec(self) -> _KeySpec:
356
+ return _KeySpec(
357
+ raw_key=self._raw_key,
358
+ slices=tuple(self._slices),
359
+ measures=tuple(self._measures) if self._measures is not None else None,
360
+ measure_dim_hint=self._measure_dim,
361
+ )
362
+
363
+ def fetch(self) -> "Dataset":
364
+ """Fetch data into memory and return a Dataset.
365
+
366
+ The full response is loaded as bytes then wrapped in a ``pl.LazyFrame``.
367
+ Suitable for datasets that fit comfortably in memory.
368
+ """
369
+ return self._fetcher(self._spec())
370
+
371
+ def pivot(self) -> pl.LazyFrame:
372
+ """Fetch data and pivot to wide format in one step.
373
+
374
+ Equivalent to ``reg.data(...).fetch().pivot()``. Requires a
375
+ ``MeasureDimension`` in the DSD — raises ``ValueError`` otherwise.
376
+
377
+ Returns a ``pl.LazyFrame`` — call ``.collect()`` to materialise or
378
+ ``.sink_parquet()`` to stream directly to a file.
379
+
380
+ Example:
381
+ wide = reg.data(flow).measures(["EMP", "UNE"]).pivot().collect()
382
+ reg.data(flow).pivot().sink_parquet("wide.parquet")
383
+ """
384
+ return self._fetcher(self._spec()).pivot()
385
+
386
+ def pivot_to(self, path: "str | Path") -> None:
387
+ """Stream data to a file and sink the pivoted wide table.
388
+
389
+ Equivalent to ``fetch_to(path).pivot().sink_parquet(...)`` but uses
390
+ a single temp file internally. The long-format file is written by
391
+ httpx chunked streaming; the pivot happens lazily when sinking.
392
+
393
+ Args:
394
+ path: Destination Parquet file path.
395
+
396
+ Example:
397
+ reg.data(flow).measures(["EMP", "UNE"]).pivot_to("wide.parquet")
398
+ """
399
+ import tempfile # noqa: PLC0415
400
+
401
+ suffix = ".csv"
402
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
403
+ tmp_path = Path(tmp.name)
404
+ try:
405
+ ds = self._streamer(tmp_path, self._spec())
406
+ ds.pivot().sink_parquet(Path(path))
407
+ finally:
408
+ tmp_path.unlink(missing_ok=True)
409
+
410
+ def fetch_to(self, path: "str | Path") -> "Dataset":
411
+ """Stream data directly to a file and return a file-backed Dataset.
412
+
413
+ Uses httpx chunked streaming — the response is never fully loaded into
414
+ memory. The returned ``Dataset.data`` is a genuine ``pl.LazyFrame``
415
+ backed by ``pl.scan_csv``, so :meth:`~sdmxlib.model.dataset.Dataset.sink_parquet`
416
+ and :meth:`~sdmxlib.model.dataset.Dataset.sink_csv` are truly streaming.
417
+
418
+ The caller owns the file — it is not deleted when the Dataset is
419
+ garbage collected.
420
+
421
+ Args:
422
+ path: Destination file path.
423
+
424
+ Example:
425
+ ds = reg.data(sl.Dataflow, agency="ESTAT", id="LFS").fetch_to("lfs.csv")
426
+ ds.sink_parquet("lfs.parquet")
427
+ """
428
+ return self._streamer(Path(path), self._spec())