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/session.py ADDED
@@ -0,0 +1,124 @@
1
+ """Session — identity map and artefact construction."""
2
+
3
+ from typing import Any
4
+
5
+ from sdmxlib.model.message import StructureMessage
6
+ from sdmxlib.model.urn import SdmxUrn
7
+
8
+
9
+ def _version_key(version: str) -> tuple[int, ...]:
10
+ """Parse a version string into a tuple of ints for correct ordering.
11
+
12
+ Falls back to ``(0,)`` if the string is not a valid dotted-integer version.
13
+ """
14
+ try:
15
+ return tuple(int(x) for x in version.split("."))
16
+ except (ValueError, AttributeError):
17
+ return (0,)
18
+
19
+
20
+ class Session:
21
+ """Session-scoped identity map for SDMX artefacts.
22
+
23
+ Interns artefacts by URN so the same object is returned for the
24
+ same URN within a session. Also merges successive StructureMessages
25
+ into the identity map.
26
+
27
+ Four separate indices:
28
+ - ``_map``: versioned URN string → artefact (all ingested)
29
+ - ``_latest``: ``cls:agency:id`` → artefact (latest version, all)
30
+ - ``_resolved_map``: versioned URN string → artefact (resolved-only)
31
+ - ``_resolved_latest``: ``cls:agency:id`` → artefact (latest resolved)
32
+
33
+ The resolved indices are populated only when ``ingest(..., resolved=True)``
34
+ is called. This lets ``get_resolved()`` return a cached object only when
35
+ it was fetched with ``references=all``, preventing unresolved objects
36
+ from being returned by named methods or ``resolve()``.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._map: dict[str, Any] = {}
41
+ self._latest: dict[str, Any] = {}
42
+ self._resolved_map: dict[str, Any] = {}
43
+ self._resolved_latest: dict[str, Any] = {}
44
+ self._class_tomsg_field: dict[str, str] = {}
45
+
46
+ def ingest(self, msg: StructureMessage, *, resolved: bool = False) -> None:
47
+ """Add all artefacts from a StructureMessage to the identity map.
48
+
49
+ Pass ``resolved=True`` when the message was fetched with
50
+ ``references=all`` so that ``get_resolved()`` can return the cached
51
+ object on subsequent calls.
52
+ """
53
+ for artefact in msg.all_artefacts():
54
+ cls_name = type(artefact).__name__
55
+ package = getattr(type(artefact), "sdmx_package", None)
56
+ if package is None:
57
+ continue
58
+ urn = SdmxUrn.make(
59
+ type(artefact),
60
+ package=package,
61
+ artefact_class=cls_name,
62
+ agency=artefact.agency_id,
63
+ id=artefact.id,
64
+ version=artefact.version,
65
+ )
66
+ msg_field = getattr(type(artefact), "msg_field", None)
67
+ if msg_field and cls_name not in self._class_tomsg_field:
68
+ self._class_tomsg_field[cls_name] = msg_field
69
+ key = str(urn)
70
+ self._map[key] = artefact
71
+ latest_key = f"{cls_name}:{artefact.agency_id}:{artefact.id}"
72
+ existing = self._latest.get(latest_key)
73
+ if existing is None or _version_key(artefact.version) > _version_key(existing.version):
74
+ self._latest[latest_key] = artefact
75
+ if resolved:
76
+ self._resolved_map[key] = artefact
77
+ existing_r = self._resolved_latest.get(latest_key)
78
+ if existing_r is None or artefact.version > existing_r.version:
79
+ self._resolved_latest[latest_key] = artefact
80
+
81
+ def get[T](self, urn: SdmxUrn[T]) -> T | None:
82
+ """Return a cached artefact by URN, or None if not cached."""
83
+ hit = self._map.get(str(urn))
84
+ if hit is not None:
85
+ return hit # type: ignore[return-value]
86
+ if urn.version == "latest":
87
+ latest_key = f"{urn.artefact_class}:{urn.agency}:{urn.id}"
88
+ return self._latest.get(latest_key) # type: ignore[return-value]
89
+ return None
90
+
91
+ def get_resolved[T](self, urn: SdmxUrn[T]) -> T | None:
92
+ """Return a cached artefact only if it was ingested as resolved."""
93
+ hit = self._resolved_map.get(str(urn))
94
+ if hit is not None:
95
+ return hit # type: ignore[return-value]
96
+ if urn.version == "latest":
97
+ latest_key = f"{urn.artefact_class}:{urn.agency}:{urn.id}"
98
+ return self._resolved_latest.get(latest_key) # type: ignore[return-value]
99
+ return None
100
+
101
+ def first_of_class[T](self, artefact_type: type[T]) -> T | None:
102
+ """Return the first cached artefact whose type matches artefact_type."""
103
+ for v in self._map.values():
104
+ if isinstance(v, artefact_type):
105
+ return v
106
+ return None
107
+
108
+ def all_of_class[T](self, artefact_type: type[T]) -> list[T]:
109
+ """Return all cached artefacts whose type matches artefact_type."""
110
+ return [v for v in self._map.values() if isinstance(v, artefact_type)]
111
+
112
+ def extract[T](self, msg: StructureMessage, urn: SdmxUrn[T]) -> T | None:
113
+ """Pull a specific artefact out of a freshly-parsed message by URN.
114
+
115
+ Looks in the StructureMessage collection named by the artefact
116
+ type's ``msg_field`` class attribute.
117
+ """
118
+ field = self._class_tomsg_field.get(urn.artefact_class)
119
+ if field is None:
120
+ return None
121
+ collection = getattr(msg, field, None)
122
+ if collection is None:
123
+ return None
124
+ return collection.get(urn) # type: ignore[return-value]
@@ -0,0 +1,166 @@
1
+ """sdmxlib.formats — SDMX wire format readers and writers.
2
+
3
+ Entry points:
4
+
5
+ from sdmxlib.formats import read, parse, to_xml, to_json
6
+
7
+ msg = read("path/to/file.xml")
8
+ msg = read(xml_bytes)
9
+
10
+ reg = sl.Registry()
11
+ read("codelists.xml", registry=reg)
12
+ read("datastructure.xml", registry=reg) # cross-file Refs resolve automatically
13
+
14
+ xml_bytes = to_xml(msg)
15
+ json_bytes = to_json(msg)
16
+ """
17
+
18
+ from pathlib import Path
19
+
20
+ from sdmxlib.formats.sdmx_json.reader import read as _read_json
21
+ from sdmxlib.formats.sdmx_json.writer import write as _write_json
22
+ from sdmxlib.formats.sdmx_ml21.namespaces import MES as _MES_21
23
+ from sdmxlib.formats.sdmx_ml21.reader import read as _read_21
24
+ from sdmxlib.formats.sdmx_ml21.writer import write as _write_21
25
+ from sdmxlib.formats.sdmx_ml30.namespaces import MES as _MES_30
26
+ from sdmxlib.formats.sdmx_ml30.reader import read as _read_30
27
+ from sdmxlib.formats.sdmx_ml30.writer import write as _write_30
28
+ from sdmxlib.model.message import StructureMessage
29
+ from sdmxlib.model.registry import Registry
30
+
31
+
32
+ def read(
33
+ source: "str | Path | bytes",
34
+ *,
35
+ version: str | None = None,
36
+ registry: Registry | None = None,
37
+ ) -> StructureMessage:
38
+ """Read an SDMX file (or raw bytes) into a StructureMessage.
39
+
40
+ A convenience wrapper over :func:`parse` that accepts a file path in
41
+ addition to raw bytes. Format is auto-detected when *version* is omitted.
42
+
43
+ If *registry* is provided all parsed artefacts are added to it, enabling
44
+ cross-file Ref resolution when multiple files are loaded into the same
45
+ registry.
46
+
47
+ Args:
48
+ source: File path (``str`` or ``Path``) or raw bytes (XML or JSON).
49
+ version: Optional format hint: ``"json"``, ``"2.1"``, or ``"3.0"``.
50
+ Auto-detected if omitted.
51
+ registry: Optional :class:`~sdmxlib.model.registry.Registry` to intern
52
+ artefacts into after parsing.
53
+
54
+ Example::
55
+
56
+ msg = sl.read("path/to/datastructure.xml")
57
+
58
+ reg = sl.Registry()
59
+ sl.read("codelists.xml", registry=reg)
60
+ sl.read("datastructure.xml", registry=reg)
61
+ dsd = reg.get(sl.DataStructure.urn(agency="ESTAT", id="CPI", version="1.0"))
62
+ """
63
+ data = Path(source).read_bytes() if isinstance(source, (str, Path)) else source
64
+ return parse(data, version=version, registry=registry)
65
+
66
+
67
+ def parse(
68
+ data: bytes,
69
+ *,
70
+ version: str | None = None,
71
+ registry: Registry | None = None,
72
+ ) -> StructureMessage:
73
+ """Parse SDMX bytes into a StructureMessage.
74
+
75
+ Auto-detects the format (SDMX-JSON 2.0 or SDMX-ML 2.1/3.0) when
76
+ *version* is not supplied. Cross-references within the message are
77
+ auto-resolved.
78
+
79
+ If *registry* is provided all parsed artefacts are added to it after
80
+ parsing, interning their Refs and enabling cross-message resolution.
81
+
82
+ Args:
83
+ data: Raw bytes — JSON or XML.
84
+ version: Optional hint: ``"json"``, ``"2.1"``, or ``"3.0"``.
85
+ Auto-detected if omitted.
86
+ registry: Optional Registry to intern artefacts into after parsing.
87
+
88
+ Raises:
89
+ ValueError: If the format cannot be detected or is not supported.
90
+ """
91
+ detected = version or _detect_version(data)
92
+ if detected == "json":
93
+ return _read_json(data, registry=registry)
94
+ if detected == "2.1":
95
+ return _read_21(data, registry=registry)
96
+ if detected == "3.0":
97
+ return _read_30(data, registry=registry)
98
+ msg = f"Unsupported format: {detected!r}"
99
+ raise ValueError(msg)
100
+
101
+
102
+ def serialize(msg: StructureMessage, *, version: str = "2.1", pretty_print: bool = False) -> bytes:
103
+ """Serialise a StructureMessage to bytes.
104
+
105
+ Args:
106
+ msg: The StructureMessage to serialise.
107
+ version: Format to write — ``"2.1"``, ``"3.0"`` (SDMX-ML XML) or
108
+ ``"json"`` (SDMX-JSON 2.0).
109
+ pretty_print: If True, indent the output.
110
+
111
+ Raises:
112
+ ValueError: If the requested version is not supported.
113
+ """
114
+ if version == "json":
115
+ return _write_json(msg, pretty_print=pretty_print)
116
+ if version == "2.1":
117
+ return _write_21(msg, pretty_print=pretty_print)
118
+ if version == "3.0":
119
+ return _write_30(msg, pretty_print=pretty_print)
120
+ err = f"Unsupported format for writing: {version!r}"
121
+ raise ValueError(err)
122
+
123
+
124
+ def to_xml(msg: StructureMessage, *, version: str = "2.1", pretty_print: bool = False) -> bytes:
125
+ """Serialise a StructureMessage to SDMX-ML XML bytes.
126
+
127
+ Args:
128
+ msg: The StructureMessage to serialise.
129
+ version: SDMX-ML version — ``"2.1"`` (default) or ``"3.0"``.
130
+ pretty_print: If True, indent the output.
131
+
132
+ Example:
133
+ xml_bytes = to_xml(msg)
134
+ xml_bytes = to_xml(msg, version="3.0", pretty_print=True)
135
+ """
136
+ return serialize(msg, version=version, pretty_print=pretty_print)
137
+
138
+
139
+ def to_json(msg: StructureMessage, *, pretty_print: bool = False) -> bytes:
140
+ """Serialise a StructureMessage to SDMX-JSON 2.0 bytes.
141
+
142
+ Args:
143
+ msg: The StructureMessage to serialise.
144
+ pretty_print: If True, indent the output.
145
+
146
+ Example:
147
+ json_bytes = to_json(msg)
148
+ json_bytes = to_json(msg, pretty_print=True)
149
+ """
150
+ return serialize(msg, version="json", pretty_print=pretty_print)
151
+
152
+
153
+ def _detect_version(data: bytes) -> str:
154
+ """Detect the wire format from the first bytes of the payload."""
155
+ # JSON messages start with '{' (possibly after whitespace)
156
+ chunk = data[:2048]
157
+ stripped = chunk.lstrip()
158
+ if stripped and stripped[0:1] == b"{":
159
+ return "json"
160
+ text = chunk.decode("utf-8", errors="replace")
161
+ if _MES_30 in text:
162
+ return "3.0"
163
+ if _MES_21 in text:
164
+ return "2.1"
165
+ msg = "Cannot detect format: not JSON and no recognised SDMX-ML namespace"
166
+ raise ValueError(msg)
@@ -0,0 +1,5 @@
1
+ """SDMX-CSV data message reader."""
2
+
3
+ from sdmxlib.formats.sdmx_csv.reader import scan_csv
4
+
5
+ __all__ = ["scan_csv"]
@@ -0,0 +1,147 @@
1
+ """SDMX-CSV data reader.
2
+
3
+ Parses SDMX-CSV messages into :class:`~sdmxlib.model.dataset.Dataset` objects.
4
+
5
+ SDMX-CSV format::
6
+
7
+ DATAFLOW,FREQ,REF_AREA,INDICATOR,TIME_PERIOD,OBS_VALUE,OBS_STATUS
8
+ ESTAT:STS_INPR_M(1.0),M,AT,INDPRO,2024-01,98.5,A
9
+
10
+ The ``DATAFLOW`` column identifies the dataflow but is not a DSD component.
11
+ It is extracted and then dropped; the remaining columns are parsed using the
12
+ DSD schema.
13
+ """
14
+
15
+ import csv
16
+ import io
17
+ import re
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ import polars as pl
22
+
23
+ import sdmxlib.polars as slpl
24
+ from sdmxlib.model.dataflow import Dataflow
25
+ from sdmxlib.model.dataset import Dataset
26
+ from sdmxlib.model.ref import Ref
27
+ from sdmxlib.model.urn import SdmxUrn
28
+
29
+ if TYPE_CHECKING:
30
+ from sdmxlib.model.datastructure import DataStructure
31
+
32
+ _DATAFLOW_COL = "DATAFLOW"
33
+ _KEY_COL = "KEY"
34
+ _DATAFLOW_RE = re.compile(r"^([^:]+):([^(]+)\(([^)]+)\)$")
35
+
36
+
37
+ def parse_dataflow_id(dataflow_id: str) -> tuple[str, str, str]:
38
+ """Parse an SDMX-CSV DATAFLOW value into ``(agency, id, version)``.
39
+
40
+ Example:
41
+ parse_dataflow_id("ESTAT:STS_INPR_M(1.0)") # ("ESTAT", "STS_INPR_M", "1.0")
42
+ """
43
+ m = _DATAFLOW_RE.match(dataflow_id)
44
+ if m is None:
45
+ msg = f"Cannot parse DATAFLOW value {dataflow_id!r} — expected 'AGENCY:ID(VERSION)'"
46
+ raise ValueError(msg)
47
+ return m.group(1), m.group(2), m.group(3)
48
+
49
+
50
+ def _peek_dataflow_id(source: "str | Path | bytes") -> str | None:
51
+ """Extract the DATAFLOW value from the first data row without reading the whole file."""
52
+ if isinstance(source, (str, Path)):
53
+ with Path(source).open(newline="", encoding="utf-8") as f:
54
+ head = f.read(4096)
55
+ else:
56
+ head = source[:4096].decode("utf-8", errors="replace")
57
+
58
+ reader = csv.reader(io.StringIO(head))
59
+ try:
60
+ header = next(reader)
61
+ except StopIteration:
62
+ return None
63
+ try:
64
+ df_idx = header.index(_DATAFLOW_COL)
65
+ except ValueError:
66
+ return None
67
+ try:
68
+ first_row = next(reader)
69
+ except StopIteration:
70
+ return None
71
+ return first_row[df_idx] if df_idx < len(first_row) else None
72
+
73
+
74
+ def _dataflow_ref(dataflow_str: str) -> "Ref[Dataflow]":
75
+ """Build an unresolved Ref[Dataflow] from an SDMX-CSV DATAFLOW column value."""
76
+ agency, id_, version = parse_dataflow_id(dataflow_str)
77
+ urn = SdmxUrn.make(
78
+ Dataflow,
79
+ package="datastructure",
80
+ artefact_class="Dataflow",
81
+ agency=agency,
82
+ id=id_,
83
+ version=version,
84
+ )
85
+ return Ref[Dataflow].unresolved(urn)
86
+
87
+
88
+ def scan_csv(
89
+ source: "str | Path | bytes",
90
+ structure: "DataStructure",
91
+ *,
92
+ infer_schema_length: "int | None" = 0,
93
+ dataflow: "Ref[Dataflow] | None" = None,
94
+ ) -> Dataset:
95
+ """Parse an SDMX-CSV message into a :class:`~sdmxlib.model.dataset.Dataset`.
96
+
97
+ File paths are scanned lazily via ``pl.scan_csv``; bytes (e.g. from an
98
+ HTTP response) are read eagerly and wrapped in a ``LazyFrame``. Either
99
+ way the returned ``Dataset.data`` is always a ``pl.LazyFrame``.
100
+
101
+ Args:
102
+ source: File path or raw bytes from an SDMX-CSV response.
103
+ structure: Fully resolved :class:`~sdmxlib.model.datastructure.DataStructure`.
104
+ Codelists should be resolved for ``pl.Enum`` typing.
105
+ infer_schema_length: Passed to Polars. ``0`` (default) means
106
+ schema is fully DSD-driven with no row sampling,
107
+ which is required for true streaming on paths.
108
+ dataflow: Optional resolved :class:`~sdmxlib.model.dataflow.Dataflow` ref.
109
+ When ``None`` and a ``DATAFLOW`` column is present, an
110
+ unresolved ref is built from the column value.
111
+
112
+ Returns:
113
+ A :class:`~sdmxlib.model.dataset.Dataset` with a lazy ``data`` frame.
114
+
115
+ Example:
116
+ import sdmxlib.polars as slpl
117
+
118
+ ds = slpl.scan_csv("data.csv", dsd)
119
+ df = ds.label().collect()
120
+ """
121
+ dataflow_str = _peek_dataflow_id(source)
122
+ dataflow_ref = dataflow
123
+ if dataflow_ref is None and dataflow_str is not None:
124
+ dataflow_ref = _dataflow_ref(dataflow_str)
125
+
126
+ schema_overrides = slpl.schema(structure)
127
+
128
+ if isinstance(source, (str, Path)):
129
+ lf = pl.scan_csv(
130
+ source,
131
+ schema_overrides=schema_overrides,
132
+ infer_schema_length=infer_schema_length,
133
+ )
134
+ else:
135
+ lf = pl.read_csv(
136
+ io.BytesIO(source),
137
+ schema_overrides=schema_overrides,
138
+ infer_schema_length=infer_schema_length,
139
+ ).lazy()
140
+
141
+ # Drop metadata columns that are not DSD components.
142
+ col_names = lf.collect_schema().names()
143
+ to_drop = [c for c in (_DATAFLOW_COL, _KEY_COL) if c in col_names]
144
+ if to_drop:
145
+ lf = lf.drop(to_drop)
146
+
147
+ return Dataset(structure=structure, data=lf, dataflow=dataflow_ref)
@@ -0,0 +1 @@
1
+ """sdmxlib.formats.sdmx_json — SDMX-JSON 2.0 structure reader."""