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/polars.py ADDED
@@ -0,0 +1,392 @@
1
+ """Polars integration for sdmxlib.
2
+
3
+ Low-level helpers for schema generation, validation, and label views.
4
+ Used internally by :class:`~sdmxlib.model.dataset.Dataset`; also importable
5
+ directly for standalone use::
6
+
7
+ import sdmxlib.polars as slpl
8
+
9
+ schema = slpl.schema(dsd)
10
+ df = pl.read_csv("data.csv", schema_overrides=schema)
11
+ report = slpl.validation_report(dsd, df)
12
+ labelled = slpl.label(df.lazy(), dsd).collect()
13
+ meta = slpl.structure_frame(dsd)
14
+
15
+ # Higher-level Dataset API
16
+ ds = slpl.scan_csv("data.csv", dsd)
17
+ df = ds.label().collect()
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ import polars as pl
25
+
26
+ from sdmxlib.model.ref import Ref
27
+ from sdmxlib.model.representation import Facet
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+ from sdmxlib.model.dataset import Dataset
33
+ from sdmxlib.model.datastructure import Attribute, DataStructure, Dimension, Measure, MeasureDimension
34
+
35
+ # ── Facet → Polars dtype mapping ─────────────────────────────────────────────
36
+
37
+ _TEXT_TYPE_MAP: dict[str, pl.DataType] = {
38
+ "String": pl.String(),
39
+ "Alpha": pl.String(),
40
+ "AlphaNumeric": pl.String(),
41
+ "Numeric": pl.Float64(),
42
+ "Integer": pl.Int64(),
43
+ "Long": pl.Int64(),
44
+ "Short": pl.Int32(),
45
+ "Float": pl.Float64(),
46
+ "Double": pl.Float64(),
47
+ "Decimal": pl.Float64(),
48
+ "Boolean": pl.Boolean(),
49
+ "Date": pl.Date(),
50
+ "DateTime": pl.Datetime(),
51
+ "Time": pl.Time(),
52
+ # All SDMX period types are represented as strings
53
+ "ObservationalTimePeriod": pl.String(),
54
+ "StandardTimePeriod": pl.String(),
55
+ "BasicTimePeriod": pl.String(),
56
+ "GregorianTimePeriod": pl.String(),
57
+ "GregorianYear": pl.String(),
58
+ "GregorianYearMonth": pl.String(),
59
+ "GregorianDay": pl.Date(),
60
+ "ReportingTimePeriod": pl.String(),
61
+ "ReportingYear": pl.String(),
62
+ "ReportingSemester": pl.String(),
63
+ "ReportingTrimester": pl.String(),
64
+ "ReportingQuarter": pl.String(),
65
+ "ReportingMonth": pl.String(),
66
+ "ReportingWeek": pl.String(),
67
+ "ReportingDay": pl.String(),
68
+ "Year": pl.String(),
69
+ "Month": pl.String(),
70
+ "YearMonth": pl.String(),
71
+ "Duration": pl.String(),
72
+ "URI": pl.String(),
73
+ "XHTML": pl.String(),
74
+ }
75
+
76
+
77
+ def _dtype_for(component: Dimension | MeasureDimension | Attribute | Measure) -> pl.DataType:
78
+ """Infer the Polars dtype for a single DSD component."""
79
+ if isinstance(component.representation, Ref) and component.representation.resolved:
80
+ code_ids = sorted(c.id for c in component.representation().codes) # type: ignore[union-attr]
81
+ return pl.Enum(code_ids)
82
+ match component.representation:
83
+ case Facet(type=dtype) if dtype is not None:
84
+ return _TEXT_TYPE_MAP.get(dtype, pl.String())
85
+ case _:
86
+ return pl.String()
87
+
88
+
89
+ # ── Public API ────────────────────────────────────────────────────────────────
90
+
91
+
92
+ def schema(dsd: DataStructure) -> dict[str, pl.DataType]:
93
+ """Return a Polars schema dict for all DSD components.
94
+
95
+ Enumerated components become ``pl.Enum``; text components are mapped
96
+ from their ``Facet.type``; everything else defaults to
97
+ ``pl.String``.
98
+
99
+ Example:
100
+ df = pl.read_csv("data.csv", schema_overrides=slpl.schema(dsd))
101
+ """
102
+ result: dict[str, pl.DataType] = {}
103
+ md = [dsd.measure_dimension] if dsd.measure_dimension is not None else []
104
+ for comp in [*dsd.dimensions, *md, *dsd.attributes, *dsd.measures]:
105
+ result[comp.id] = _dtype_for(comp)
106
+ if dsd.time_dimension is not None:
107
+ td = dsd.time_dimension
108
+ match td.representation:
109
+ case Facet(type=tt) if tt is not None:
110
+ result[td.id] = _TEXT_TYPE_MAP.get(tt, pl.String())
111
+ case _:
112
+ result[td.id] = pl.String()
113
+ return result
114
+
115
+
116
+ def validation_report(
117
+ dsd: DataStructure,
118
+ df: pl.LazyFrame | pl.DataFrame,
119
+ ) -> pl.DataFrame:
120
+ """Validate a DataFrame against enumerated DSD components.
121
+
122
+ Returns a DataFrame of violations::
123
+
124
+ ┌──────────┬───────────────┬───────┐
125
+ │ column │ invalid_value │ count │
126
+ └──────────┴───────────────┴───────┘
127
+
128
+ Only columns present in ``df`` are checked. Non-enumerated components
129
+ and unresolved codelists are skipped.
130
+
131
+ Example:
132
+ report = slpl.validation_report(dsd, df)
133
+ if report.is_empty():
134
+ print("All values valid")
135
+ """
136
+ lf = df.lazy() if isinstance(df, pl.DataFrame) else df
137
+ schema_cols = set(lf.collect_schema().names())
138
+
139
+ violations: list[pl.DataFrame] = []
140
+ md = [dsd.measure_dimension] if dsd.measure_dimension is not None else []
141
+ for comp in [*dsd.dimensions, *md, *dsd.attributes, *dsd.measures]:
142
+ if comp.id not in schema_cols:
143
+ continue
144
+ if not (isinstance(comp.representation, Ref) and comp.representation.resolved):
145
+ continue
146
+ valid_codes = {c.id for c in comp.representation().codes} # type: ignore[union-attr]
147
+ bad = (
148
+ lf.select(pl.col(comp.id).cast(pl.String))
149
+ .filter(~pl.col(comp.id).is_in(valid_codes) & pl.col(comp.id).is_not_null())
150
+ .group_by(comp.id)
151
+ .agg(pl.len().alias("count"))
152
+ .rename({comp.id: "invalid_value"})
153
+ .with_columns(pl.lit(comp.id).alias("column"))
154
+ .select("column", "invalid_value", "count")
155
+ .collect()
156
+ )
157
+ if not bad.is_empty():
158
+ violations.append(bad)
159
+
160
+ if violations:
161
+ return pl.concat(violations)
162
+ return pl.DataFrame(
163
+ {
164
+ "column": pl.Series([], dtype=pl.String),
165
+ "invalid_value": pl.Series([], dtype=pl.String),
166
+ "count": pl.Series([], dtype=pl.UInt32),
167
+ },
168
+ )
169
+
170
+
171
+ def _label_view(
172
+ lf: pl.LazyFrame,
173
+ dsd: DataStructure,
174
+ lang: str = "en",
175
+ suffix: str = "_label",
176
+ ) -> pl.LazyFrame:
177
+ """Replace enumerated code columns with label columns, preserving column order.
178
+
179
+ For each enumerated component present in ``lf``, the code column is replaced
180
+ by a ``{col}{suffix}`` column containing the human-readable label as a
181
+ ``pl.Enum`` (DSD-defined order preserved). Non-enumerated columns are
182
+ unchanged.
183
+ """
184
+ enum_map: dict[str, tuple[list[str], list[str]]] = {}
185
+ md = [dsd.measure_dimension] if dsd.measure_dimension is not None else []
186
+ for comp in [*dsd.dimensions, *md, *dsd.attributes, *dsd.measures]:
187
+ if not (isinstance(comp.representation, Ref) and comp.representation.resolved):
188
+ continue
189
+ codes = [c.id for c in comp.representation().codes] # type: ignore[union-attr]
190
+ labels = [c.name.get(lang) or c.id for c in comp.representation().codes] # type: ignore[union-attr]
191
+ enum_map[comp.id] = (codes, labels)
192
+
193
+ select_exprs = []
194
+ for col_name in lf.collect_schema().names():
195
+ if col_name in enum_map:
196
+ codes, labels = enum_map[col_name]
197
+ unique_labels = list(dict.fromkeys(labels))
198
+ select_exprs.append(
199
+ pl.col(col_name)
200
+ .cast(pl.String)
201
+ .replace_strict(old=codes, new=labels, default=None)
202
+ .cast(pl.Enum(unique_labels))
203
+ .alias(f"{col_name}{suffix}")
204
+ )
205
+ else:
206
+ select_exprs.append(pl.col(col_name))
207
+
208
+ return lf.select(select_exprs)
209
+
210
+
211
+ def label(
212
+ df: pl.LazyFrame,
213
+ dsd: DataStructure,
214
+ lang: str = "en",
215
+ suffix: str = "_label",
216
+ ) -> pl.LazyFrame:
217
+ """Add human-readable label columns for every enumerated component.
218
+
219
+ For each enumerated dimension/attribute/measure that exists in ``df``,
220
+ appends a ``{col}{suffix}`` column with the code label in ``lang``.
221
+ Codes with no label fall back to the code id.
222
+
223
+ Example:
224
+ labelled = slpl.label(pl.scan_csv("data.csv"), dsd).collect()
225
+ """
226
+ schema_cols = set(df.collect_schema().names())
227
+ mds = [dsd.measure_dimension] if dsd.measure_dimension is not None else []
228
+ for comp in [*dsd.dimensions, *mds, *dsd.attributes, *dsd.measures]:
229
+ if comp.id not in schema_cols:
230
+ continue
231
+ if not (isinstance(comp.representation, Ref) and comp.representation.resolved):
232
+ continue
233
+ mapping = {c.id: c.name.get(lang) or c.id for c in comp.representation().codes} # type: ignore[union-attr]
234
+ old = list(mapping.keys())
235
+ new = list(mapping.values())
236
+ label_col = f"{comp.id}{suffix}"
237
+ df = df.with_columns(
238
+ pl.col(comp.id).cast(pl.String).replace_strict(old=old, new=new, default=None).alias(label_col)
239
+ )
240
+ return df
241
+
242
+
243
+ def structure_frame(dsd: DataStructure, lang: str = "en") -> pl.DataFrame:
244
+ """Export the DSD structure as a tidy DataFrame.
245
+
246
+ Returns one row per component::
247
+
248
+ ┌────────────┬───────────┬──────────┬───────────┬──────────┬─────────┐
249
+ │ component │ role │ position │ concept │ codelist │ n_codes │
250
+ └────────────┴───────────┴──────────┴───────────┴──────────┴─────────┘
251
+
252
+ ``role`` is one of ``"dimension"``, ``"time_dimension"``,
253
+ ``"attribute"``, ``"measure"``.
254
+
255
+ Example:
256
+ meta = slpl.structure_frame(dsd)
257
+ """
258
+ rows: list[dict[str, object]] = []
259
+
260
+ for i, dim in enumerate(dsd.dimensions, start=1):
261
+ concept_label = (
262
+ dim.concept().name.get(lang) or dim.id if isinstance(dim.concept, Ref) and dim.concept.resolved else dim.id
263
+ )
264
+ _dim_rep = dim.representation if isinstance(dim.representation, Ref) and dim.representation.resolved else None
265
+ cl_id = _dim_rep().id if _dim_rep else None # type: ignore[union-attr]
266
+ n_codes = len(_dim_rep().codes) if _dim_rep else 0 # type: ignore[union-attr]
267
+ rows.append(
268
+ {
269
+ "component": dim.id,
270
+ "role": "dimension",
271
+ "position": i,
272
+ "concept": concept_label,
273
+ "codelist": cl_id,
274
+ "n_codes": n_codes,
275
+ }
276
+ )
277
+
278
+ if dsd.time_dimension is not None:
279
+ td = dsd.time_dimension
280
+ concept_label = (
281
+ td.concept().name.get(lang) or td.id if isinstance(td.concept, Ref) and td.concept.resolved else td.id
282
+ )
283
+ rows.append(
284
+ {
285
+ "component": td.id,
286
+ "role": "time_dimension",
287
+ "position": None,
288
+ "concept": concept_label,
289
+ "codelist": None,
290
+ "n_codes": 0,
291
+ }
292
+ )
293
+
294
+ if dsd.measure_dimension is not None:
295
+ md = dsd.measure_dimension
296
+ concept_label = (
297
+ md.concept().name.get(lang) or md.id if isinstance(md.concept, Ref) and md.concept.resolved else md.id
298
+ )
299
+ _md_rep = md.representation if isinstance(md.representation, Ref) and md.representation.resolved else None
300
+ cl_id = _md_rep().id if _md_rep else None # type: ignore[union-attr]
301
+ n_codes = len(_md_rep().codes) if _md_rep else 0 # type: ignore[union-attr]
302
+ rows.append(
303
+ {
304
+ "component": md.id,
305
+ "role": "measure_dimension",
306
+ "position": None,
307
+ "concept": concept_label,
308
+ "codelist": cl_id,
309
+ "n_codes": n_codes,
310
+ }
311
+ )
312
+
313
+ for attr in dsd.attributes:
314
+ if isinstance(attr.concept, Ref) and attr.concept.resolved:
315
+ concept_label = attr.concept().name.get(lang) or attr.id
316
+ else:
317
+ concept_label = attr.id
318
+ _attr_rep = (
319
+ attr.representation if isinstance(attr.representation, Ref) and attr.representation.resolved else None
320
+ )
321
+ cl_id = _attr_rep().id if _attr_rep else None # type: ignore[union-attr]
322
+ n_codes = len(_attr_rep().codes) if _attr_rep else 0 # type: ignore[union-attr]
323
+ rows.append(
324
+ {
325
+ "component": attr.id,
326
+ "role": "attribute",
327
+ "position": None,
328
+ "concept": concept_label,
329
+ "codelist": cl_id,
330
+ "n_codes": n_codes,
331
+ }
332
+ )
333
+
334
+ for meas in dsd.measures:
335
+ if isinstance(meas.concept, Ref) and meas.concept.resolved:
336
+ concept_label = meas.concept().name.get(lang) or meas.id
337
+ else:
338
+ concept_label = meas.id
339
+ rows.append(
340
+ {
341
+ "component": meas.id,
342
+ "role": "measure",
343
+ "position": None,
344
+ "concept": concept_label,
345
+ "codelist": None,
346
+ "n_codes": 0,
347
+ }
348
+ )
349
+
350
+ return pl.DataFrame(
351
+ {
352
+ "component": [r["component"] for r in rows],
353
+ "role": [r["role"] for r in rows],
354
+ "position": [r["position"] for r in rows],
355
+ "concept": [r["concept"] for r in rows],
356
+ "codelist": [r["codelist"] for r in rows],
357
+ "n_codes": [r["n_codes"] for r in rows],
358
+ },
359
+ schema={
360
+ "component": pl.String,
361
+ "role": pl.String,
362
+ "position": pl.Int32,
363
+ "concept": pl.String,
364
+ "codelist": pl.String,
365
+ "n_codes": pl.Int32,
366
+ },
367
+ )
368
+
369
+
370
+ def scan_csv(source: str | Path | bytes, structure: DataStructure) -> Dataset:
371
+ """Parse an SDMX-CSV file or bytes into a :class:`~sdmxlib.model.dataset.Dataset`.
372
+
373
+ File paths are scanned lazily (``pl.scan_csv``); bytes are read eagerly
374
+ and wrapped in a ``LazyFrame``. Either way ``Dataset.data`` is always a
375
+ ``pl.LazyFrame`` — nothing is executed until a terminal call.
376
+
377
+ Convenience entry point. For full options see
378
+ :func:`sdmxlib.formats.sdmx_csv.reader.scan_csv`.
379
+
380
+ Args:
381
+ source: File path or raw bytes from an SDMX-CSV response.
382
+ structure: Resolved :class:`~sdmxlib.model.datastructure.DataStructure`.
383
+
384
+ Example:
385
+ import sdmxlib.polars as slpl
386
+
387
+ ds = slpl.scan_csv("data.csv", dsd)
388
+ df = ds.label().collect()
389
+ """
390
+ from sdmxlib.formats.sdmx_csv.reader import scan_csv as _scan_csv # noqa: PLC0415
391
+
392
+ return _scan_csv(source, structure)
sdmxlib/py.typed ADDED
File without changes
@@ -0,0 +1,28 @@
1
+ """Shared DuckDB storage layer for SDMX structural metadata.
2
+
3
+ Provides normalized tables for codelists, DSDs, dataflows, concept
4
+ schemes, and hierarchies. Used internally by ``LocalRegistry`` and
5
+ importable by cataloglib for shared-database workflows.
6
+
7
+ Example::
8
+
9
+ import duckdb
10
+ from sdmxlib.storage import schema, writers, readers
11
+
12
+ conn = duckdb.connect("store.duckdb")
13
+ schema.create_tables(conn, languages=["en", "fr"])
14
+ writers.put_codelist(conn, codelist)
15
+ cm = readers.code_map(conn, codelist_urn, "en")
16
+ """
17
+
18
+ from sdmxlib.storage import lazy, readers, schema, writers
19
+ from sdmxlib.storage.schema import create_tables, get_languages
20
+
21
+ __all__ = [
22
+ "create_tables",
23
+ "get_languages",
24
+ "lazy",
25
+ "readers",
26
+ "schema",
27
+ "writers",
28
+ ]