sdmxlib 0.16.2__tar.gz → 0.17.1__tar.gz

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 (74) hide show
  1. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/PKG-INFO +2 -1
  2. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/pyproject.toml +2 -1
  3. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/__init__.py +3 -0
  4. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/catalog.py +65 -0
  5. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/data_store.py +47 -1
  6. sdmxlib-0.17.1/src/sdmxlib/formats/sdmx_csv/__init__.py +6 -0
  7. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_csv/reader.py +20 -5
  8. sdmxlib-0.17.1/src/sdmxlib/formats/sdmx_csv/writer.py +137 -0
  9. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/_data_store.py +102 -18
  10. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/base.py +27 -0
  11. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/datastructure.py +17 -0
  12. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/message.py +39 -0
  13. sdmxlib-0.17.1/src/sdmxlib/rest.py +130 -0
  14. sdmxlib-0.16.2/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -5
  15. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/README.md +0 -0
  16. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/__init__.py +0 -0
  17. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/client.py +0 -0
  18. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/federated.py +0 -0
  19. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/filters.py +0 -0
  20. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/policy.py +0 -0
  21. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/providers.py +0 -0
  22. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/query.py +0 -0
  23. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/registry.py +0 -0
  24. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/session.py +0 -0
  25. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/__init__.py +0 -0
  26. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  27. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  28. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  29. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  30. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  31. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  32. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  33. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  34. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  35. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  36. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  37. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  38. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  39. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/__init__.py +0 -0
  40. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/_registry.py +0 -0
  41. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/__init__.py +0 -0
  42. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/annotations.py +0 -0
  43. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/binding.py +0 -0
  44. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/category.py +0 -0
  45. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/code_query.py +0 -0
  46. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/codelist.py +0 -0
  47. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/collections.py +0 -0
  48. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/concept.py +0 -0
  49. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/constraint.py +0 -0
  50. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/convert.py +0 -0
  51. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/dataflow.py +0 -0
  52. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/dataset.py +0 -0
  53. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/expr.py +0 -0
  54. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/hierarchy.py +0 -0
  55. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/in_memory_registry.py +0 -0
  56. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/istring.py +0 -0
  57. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/mapping.py +0 -0
  58. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadataflow.py +0 -0
  59. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadataset.py +0 -0
  60. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadatastructure.py +0 -0
  61. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/organisation.py +0 -0
  62. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/provision.py +0 -0
  63. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/ref.py +0 -0
  64. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/registry.py +0 -0
  65. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/representation.py +0 -0
  66. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/urn.py +0 -0
  67. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/validation.py +0 -0
  68. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/polars.py +0 -0
  69. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/py.typed +0 -0
  70. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/__init__.py +0 -0
  71. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/lazy.py +0 -0
  72. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/readers.py +0 -0
  73. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/schema.py +0 -0
  74. {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.16.2
3
+ Version: 0.17.1
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -17,6 +17,7 @@ Requires-Dist: duckdb>=1.0.0
17
17
  Requires-Dist: httpx>=0.28.1
18
18
  Requires-Dist: lxml>=6.0.2
19
19
  Requires-Dist: polars>=1.39.3
20
+ Requires-Dist: pyarrow>=18.0.0
20
21
  Requires-Python: >=3.13
21
22
  Project-URL: Homepage, https://gitlab.com/pinax-suite/sdmxlib
22
23
  Project-URL: Repository, https://gitlab.com/pinax-suite/sdmxlib
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.16.2"
3
+ version = "0.17.1"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -21,6 +21,7 @@ dependencies = [
21
21
  "httpx>=0.28.1",
22
22
  "lxml>=6.0.2",
23
23
  "polars>=1.39.3",
24
+ "pyarrow>=18.0.0",
24
25
  ]
25
26
 
26
27
  [project.urls]
@@ -11,6 +11,7 @@ from sdmxlib.data_store import DataStore
11
11
  from sdmxlib.formats import parse, read, serialize, to_json, to_xml
12
12
  from sdmxlib.local import LazyCodelist, LocalRegistry
13
13
  from sdmxlib.local._data_store import DuckDBDataStore
14
+ from sdmxlib.rest import KeyFilter, parse_key
14
15
  from sdmxlib.model.convert import from_dict, to_dict
15
16
  from sdmxlib.model.validation import ReferenceIssue, ValidationIssue, validate, validate_references
16
17
  from sdmxlib.model import (
@@ -148,6 +149,7 @@ __all__ = [
148
149
  "ItemExpr",
149
150
  "ItemList",
150
151
  "ItemPredicate",
152
+ "KeyFilter",
151
153
  "KeyValue",
152
154
  "LazyCodelist",
153
155
  "Level",
@@ -186,6 +188,7 @@ __all__ = [
186
188
  "from_dict",
187
189
  "item",
188
190
  "parse",
191
+ "parse_key",
189
192
  "read",
190
193
  "serialize",
191
194
  "to_dict",
@@ -43,6 +43,7 @@ Example:
43
43
 
44
44
  from __future__ import annotations
45
45
 
46
+ from collections.abc import Iterator
46
47
  from typing import TYPE_CHECKING
47
48
 
48
49
  import polars as pl
@@ -55,9 +56,12 @@ from sdmxlib.model.istring import InternationalString
55
56
  from sdmxlib.model.ref import Ref
56
57
 
57
58
  if TYPE_CHECKING:
59
+ import pyarrow as pa
60
+
58
61
  from sdmxlib.data_store import DataStore
59
62
  from sdmxlib.model.registry import Registry
60
63
  from sdmxlib.model.urn import SdmxUrn
64
+ from sdmxlib.rest import KeyFilter
61
65
 
62
66
 
63
67
  @frozen
@@ -132,6 +136,7 @@ class FederatedCatalog:
132
136
  *,
133
137
  where: list[str] | None = None,
134
138
  params: list[object] | None = None,
139
+ key_filter: KeyFilter | None = None,
135
140
  columns: list[str] | None = None,
136
141
  order_by: str | None = None,
137
142
  limit: int | None = None,
@@ -146,6 +151,9 @@ class FederatedCatalog:
146
151
  from ``bindings``.
147
152
  where: Optional list of SQL WHERE-clause fragments.
148
153
  params: Positional parameters for the WHERE clauses.
154
+ key_filter: Optional :class:`~sdmxlib.rest.KeyFilter` parsed
155
+ from an SDMX REST dotted key. ANDed with ``where`` after
156
+ translation via the binding's dimension column map.
149
157
  columns: Optional column projection.
150
158
  order_by: Optional SQL ORDER BY clause.
151
159
  limit: Optional row cap.
@@ -189,6 +197,7 @@ class FederatedCatalog:
189
197
  binding,
190
198
  where=where,
191
199
  params=params,
200
+ key_filter=key_filter,
192
201
  columns=columns,
193
202
  order_by=order_by,
194
203
  limit=limit,
@@ -205,6 +214,62 @@ class FederatedCatalog:
205
214
 
206
215
  return df
207
216
 
217
+ def query_stream(
218
+ self,
219
+ dataflow_urn: SdmxUrn[Dataflow],
220
+ *,
221
+ where: list[str] | None = None,
222
+ params: list[object] | None = None,
223
+ key_filter: KeyFilter | None = None,
224
+ columns: list[str] | None = None,
225
+ order_by: str | None = None,
226
+ chunk_rows: int = 10_000,
227
+ ) -> Iterator[pa.RecordBatch]:
228
+ """Stream observations as Arrow record batches.
229
+
230
+ Mirrors :meth:`query` (binding lookup → scan) but skips the
231
+ labelling step — labels are a DataFrame transform that doesn't
232
+ compose cleanly with streaming. Callers that need labelled
233
+ streaming output should join codelists themselves on the way to
234
+ their sink.
235
+
236
+ Args:
237
+ dataflow_urn: As :meth:`query`.
238
+ where: As :meth:`query`.
239
+ params: As :meth:`query`.
240
+ key_filter: As :meth:`query`.
241
+ columns: As :meth:`query`.
242
+ order_by: As :meth:`query`.
243
+ chunk_rows: Target rows per emitted record batch.
244
+
245
+ Yields:
246
+ One :class:`pyarrow.RecordBatch` at a time, in scan order.
247
+
248
+ Raises:
249
+ KeyError: If no binding exists for ``dataflow_urn``.
250
+ """
251
+ binding_urn = DataflowBinding.urn(
252
+ agency=dataflow_urn.agency,
253
+ id=dataflow_urn.id,
254
+ version=dataflow_urn.version,
255
+ )
256
+ binding = self._bindings.get(binding_urn)
257
+ if binding is None:
258
+ err = f"No DataflowBinding found for {dataflow_urn}. Add a binding to the bindings registry first."
259
+ raise KeyError(err)
260
+ if not isinstance(binding, DataflowBinding):
261
+ err = f"Expected DataflowBinding for {binding_urn}, got {type(binding).__name__}"
262
+ raise TypeError(err)
263
+ yield from self._data.query_stream(
264
+ binding,
265
+ where=where,
266
+ params=params,
267
+ key_filter=key_filter,
268
+ columns=columns,
269
+ order_by=order_by,
270
+ chunk_rows=chunk_rows,
271
+ )
272
+
208
273
  def browse(self) -> list[DataflowEntry]:
209
274
  """Enumerate every locally-bound dataflow with provenance annotations.
210
275
 
@@ -16,12 +16,20 @@ or DSDs. Result annotation (joining labels onto code columns) is the
16
16
  structural side.
17
17
  """
18
18
 
19
- from typing import Protocol, runtime_checkable
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterator
22
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
20
23
 
21
24
  import polars as pl
22
25
 
23
26
  from sdmxlib.model.binding import DataflowBinding
24
27
 
28
+ if TYPE_CHECKING:
29
+ import pyarrow as pa
30
+
31
+ from sdmxlib.rest import KeyFilter
32
+
25
33
 
26
34
  @runtime_checkable
27
35
  class DataStore(Protocol):
@@ -37,6 +45,7 @@ class DataStore(Protocol):
37
45
  *,
38
46
  where: list[str] | None = None,
39
47
  params: list[object] | None = None,
48
+ key_filter: KeyFilter | None = None,
40
49
  columns: list[str] | None = None,
41
50
  order_by: str | None = None,
42
51
  limit: int | None = None,
@@ -51,6 +60,10 @@ class DataStore(Protocol):
51
60
  params: Positional parameters interpolated into the WHERE
52
61
  fragments via the backend's parameterisation. Must be
53
62
  separately passed (not f-stringed) to avoid SQL injection.
63
+ key_filter: Optional :class:`~sdmxlib.rest.KeyFilter` parsed
64
+ from an SDMX REST dotted key. Translated to additional
65
+ WHERE-IN clauses against ``binding.dimension_column_map``;
66
+ ANDed with any caller-supplied ``where`` fragments.
54
67
  columns: Optional projection. ``None`` = all columns. Names
55
68
  are SDMX-side; binding maps them to physical columns.
56
69
  order_by: Optional SQL ORDER BY clause (e.g.
@@ -62,3 +75,36 @@ class DataStore(Protocol):
62
75
  named on the SDMX side (mapped from physical via the binding).
63
76
  """
64
77
  ...
78
+
79
+ def query_stream(
80
+ self,
81
+ binding: DataflowBinding,
82
+ *,
83
+ where: list[str] | None = None,
84
+ params: list[object] | None = None,
85
+ key_filter: KeyFilter | None = None,
86
+ columns: list[str] | None = None,
87
+ order_by: str | None = None,
88
+ chunk_rows: int = 10_000,
89
+ ) -> Iterator[pa.RecordBatch]:
90
+ """Same scan as :meth:`query` but streamed in Arrow record batches.
91
+
92
+ Lets bulk callers process or stream a result of arbitrary size in
93
+ bounded memory — peak RSS is roughly ``chunk_rows * row_bytes``
94
+ rather than ``num_rows * row_bytes``. The natural sink is a
95
+ format writer (e.g. :func:`sdmxlib.formats.sdmx_csv.write_sdmx_csv`).
96
+
97
+ Args:
98
+ binding: As :meth:`query`.
99
+ where: As :meth:`query`.
100
+ params: As :meth:`query`.
101
+ key_filter: As :meth:`query`.
102
+ columns: As :meth:`query`.
103
+ order_by: As :meth:`query`. Note that ordering may force the
104
+ backend to materialise everything before streaming.
105
+ chunk_rows: Target number of rows per emitted batch.
106
+
107
+ Yields:
108
+ One :class:`pyarrow.RecordBatch` at a time, in scan order.
109
+ """
110
+ ...
@@ -0,0 +1,6 @@
1
+ """SDMX-CSV data message reader and writer."""
2
+
3
+ from sdmxlib.formats.sdmx_csv.reader import scan_csv
4
+ from sdmxlib.formats.sdmx_csv.writer import write_sdmx_csv
5
+
6
+ __all__ = ["scan_csv", "write_sdmx_csv"]
@@ -31,6 +31,10 @@ if TYPE_CHECKING:
31
31
 
32
32
  _DATAFLOW_COL = "DATAFLOW"
33
33
  _KEY_COL = "KEY"
34
+ # SDMX-CSV 2.0.0 envelope columns — not DSD components, dropped on read.
35
+ _STRUCTURE_COL = "STRUCTURE"
36
+ _STRUCTURE_ID_COL = "STRUCTURE_ID"
37
+ _ACTION_COL = "ACTION"
34
38
  _DATAFLOW_RE = re.compile(r"^([^:]+):([^(]+)\(([^)]+)\)$")
35
39
 
36
40
 
@@ -60,9 +64,12 @@ def _peek_dataflow_id(source: "str | Path | bytes") -> str | None:
60
64
  header = next(reader)
61
65
  except StopIteration:
62
66
  return None
63
- try:
64
- df_idx = header.index(_DATAFLOW_COL)
65
- except ValueError:
67
+ df_idx: int | None = None
68
+ for col in (_DATAFLOW_COL, _STRUCTURE_ID_COL):
69
+ if col in header:
70
+ df_idx = header.index(col)
71
+ break
72
+ if df_idx is None:
66
73
  return None
67
74
  try:
68
75
  first_row = next(reader)
@@ -138,9 +145,17 @@ def scan_csv(
138
145
  infer_schema_length=infer_schema_length,
139
146
  ).lazy()
140
147
 
141
- # Drop metadata columns that are not DSD components.
148
+ # Drop metadata columns that are not DSD components. Covers both
149
+ # SDMX-CSV 1.0 (DATAFLOW, KEY) and 2.0 (STRUCTURE, STRUCTURE_ID, ACTION).
142
150
  col_names = lf.collect_schema().names()
143
- to_drop = [c for c in (_DATAFLOW_COL, _KEY_COL) if c in col_names]
151
+ envelope_cols = (
152
+ _DATAFLOW_COL,
153
+ _KEY_COL,
154
+ _STRUCTURE_COL,
155
+ _STRUCTURE_ID_COL,
156
+ _ACTION_COL,
157
+ )
158
+ to_drop = [c for c in envelope_cols if c in col_names]
144
159
  if to_drop:
145
160
  lf = lf.drop(to_drop)
146
161
 
@@ -0,0 +1,137 @@
1
+ """SDMX-CSV 2.0.0 data writer (streaming).
2
+
3
+ Consumes an iterator of Arrow record batches (typically from
4
+ :meth:`sdmxlib.DataStore.query_stream`) and emits SDMX-CSV bytes one
5
+ chunk at a time. The header is emitted once with the first chunk;
6
+ subsequent chunks contain data rows only.
7
+
8
+ SDMX-CSV 2.0.0 header layout::
9
+
10
+ STRUCTURE,STRUCTURE_ID,ACTION,<dim cols>,<measure cols>,<attribute cols>
11
+
12
+ where:
13
+
14
+ - ``STRUCTURE`` is the artefact type ("dataflow" / "datastructure" /
15
+ "provisionagreement"). Defaults to ``"dataflow"``.
16
+ - ``STRUCTURE_ID`` is ``AGENCY:ID(VERSION)``. The caller passes this
17
+ in — typically derived from the bound dataflow's URN.
18
+ - ``ACTION`` is one of ``I`` / ``R`` / ``A`` / ``D`` (Insert / Replace /
19
+ Append / Delete). Defaults to ``"I"``.
20
+
21
+ Dim/measure/attribute column ordering is taken from the binding's
22
+ column maps in their dict-insertion order, which matches the order in
23
+ which they were declared on the binding.
24
+
25
+ Performance: rows are serialised via Polars' Rust CSV writer
26
+ (``pl.from_arrow(batch).write_csv``) rather than Python's :mod:`csv`,
27
+ which makes the writer fast enough that bulk SDMX-CSV downloads are
28
+ network-bound rather than writer-bound (#61).
29
+
30
+ Example:
31
+ from sdmxlib.formats.sdmx_csv import write_sdmx_csv
32
+
33
+ chunks = write_sdmx_csv(
34
+ catalog.query_stream(flow_urn, key_filter=kf),
35
+ binding,
36
+ structure_id="ESTAT:STS_INPR_M(1.0)",
37
+ )
38
+ with open("out.csv", "wb") as f:
39
+ for b in chunks:
40
+ f.write(b)
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import io
46
+ from typing import TYPE_CHECKING
47
+
48
+ import polars as pl
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Iterable, Iterator
52
+
53
+ import pyarrow as pa
54
+
55
+ from sdmxlib.model.binding import DataflowBinding
56
+
57
+
58
+ def write_sdmx_csv(
59
+ batches: Iterable[pa.RecordBatch],
60
+ binding: DataflowBinding,
61
+ *,
62
+ structure_id: str,
63
+ structure: str = "dataflow",
64
+ action: str = "I",
65
+ ) -> Iterator[bytes]:
66
+ """Stream SDMX-CSV 2.0.0 bytes for a sequence of Arrow record batches.
67
+
68
+ Args:
69
+ batches: Iterable of :class:`pyarrow.RecordBatch` (e.g. the output
70
+ of :meth:`sdmxlib.FederatedCatalog.query_stream`). Each batch
71
+ must contain every column listed in the binding's column maps.
72
+ binding: The :class:`~sdmxlib.model.binding.DataflowBinding`.
73
+ Determines column order in the output: dimensions, then
74
+ measures, then attributes, each in the binding's
75
+ dict-insertion order.
76
+ structure_id: The ``AGENCY:ID(VERSION)`` identifier emitted in
77
+ the ``STRUCTURE_ID`` column on every row.
78
+ structure: Value emitted in the ``STRUCTURE`` column. SDMX-CSV
79
+ allows ``"dataflow"``, ``"datastructure"``, or
80
+ ``"provisionagreement"``. Defaults to ``"dataflow"``.
81
+ action: Value emitted in the ``ACTION`` column. One of ``I``
82
+ (Insert), ``R`` (Replace), ``A`` (Append), ``D`` (Delete).
83
+ Defaults to ``"I"``.
84
+
85
+ Yields:
86
+ UTF-8 bytes — header on the first chunk, data rows on every
87
+ chunk including the first. Each yielded ``bytes`` is one record
88
+ batch's worth of CSV.
89
+
90
+ Raises:
91
+ KeyError: If a batch is missing a column the binding declares.
92
+ """
93
+ dim_cols = list(binding.dimension_column_map.values())
94
+ measure_cols = list(binding.measure_column_map.values())
95
+ attr_cols = list(binding.attribute_column_map.values())
96
+ data_cols = [*dim_cols, *measure_cols, *attr_cols]
97
+ out_cols = ["STRUCTURE", "STRUCTURE_ID", "ACTION", *data_cols]
98
+
99
+ # Header is plain bytes — one line, never quoted by polars' writer
100
+ # because none of the column names contain commas or newlines.
101
+ header_bytes = (",".join(out_cols) + "\n").encode("utf-8")
102
+ header_emitted = False
103
+
104
+ for batch in batches:
105
+ missing = [c for c in data_cols if c not in batch.schema.names]
106
+ if missing:
107
+ msg = (
108
+ f"Record batch is missing column(s) {missing!r} declared by "
109
+ f"the binding. Available columns: {batch.schema.names}"
110
+ )
111
+ raise KeyError(msg)
112
+
113
+ df = (
114
+ pl.from_arrow(batch)
115
+ .select(data_cols) # type: ignore[union-attr]
116
+ .with_columns(
117
+ pl.lit(structure).alias("STRUCTURE"),
118
+ pl.lit(structure_id).alias("STRUCTURE_ID"),
119
+ pl.lit(action).alias("ACTION"),
120
+ )
121
+ .select(out_cols)
122
+ )
123
+
124
+ buf = io.BytesIO()
125
+ df.write_csv(buf, include_header=False)
126
+ body = buf.getvalue()
127
+
128
+ if not header_emitted:
129
+ yield header_bytes + body
130
+ header_emitted = True
131
+ else:
132
+ yield body
133
+
134
+ if not header_emitted:
135
+ # Empty stream — emit just the header so consumers get a valid
136
+ # (if empty) SDMX-CSV file.
137
+ yield header_bytes
@@ -14,6 +14,7 @@ this lands, cenprof's bespoke engine becomes a 4-line catalog setup.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ from collections.abc import Iterator
17
18
  from typing import TYPE_CHECKING
18
19
 
19
20
  import polars as pl
@@ -22,8 +23,10 @@ from sdmxlib.model.binding import DataflowBinding
22
23
 
23
24
  if TYPE_CHECKING:
24
25
  import duckdb
26
+ import pyarrow as pa
25
27
 
26
28
  from sdmxlib.local._registry import LocalRegistry
29
+ from sdmxlib.rest import KeyFilter
27
30
 
28
31
 
29
32
  class DuckDBDataStore:
@@ -98,6 +101,7 @@ class DuckDBDataStore:
98
101
  *,
99
102
  where: list[str] | None = None,
100
103
  params: list[object] | None = None,
104
+ key_filter: KeyFilter | None = None,
101
105
  columns: list[str] | None = None,
102
106
  order_by: str | None = None,
103
107
  limit: int | None = None,
@@ -109,15 +113,24 @@ class DuckDBDataStore:
109
113
  assume identity mapping (SDMX id == physical column) — when
110
114
  non-identity mappings are needed, this method is the seam to
111
115
  wire in the rename.
116
+
117
+ When ``binding.series_attributes_location`` is set, the sidecar
118
+ table is LEFT JOINed on the columns common to both tables (found
119
+ via DuckDB ``DESCRIBE``). This is what makes
120
+ ``binding.attribute_column_map`` columns reachable when the
121
+ attributes live on a sibling table rather than denormalised into
122
+ the fact table.
112
123
  """
113
- sql = _build_select(
114
- table=binding.table_location,
115
- columns=columns,
124
+ sql, sql_params = _build_query(
125
+ conn=self._conn,
126
+ binding=binding,
116
127
  where=where,
128
+ params=params,
129
+ key_filter=key_filter,
130
+ columns=columns,
117
131
  order_by=order_by,
118
132
  limit=limit,
119
133
  )
120
- sql_params = list(params or [])
121
134
  cur = self._conn.execute(sql, sql_params)
122
135
  col_names = [d[0] for d in cur.description] if cur.description else []
123
136
  rows = cur.fetchall()
@@ -125,34 +138,105 @@ class DuckDBDataStore:
125
138
  return pl.DataFrame(schema=col_names)
126
139
  return pl.from_records(rows, schema=col_names, orient="row")
127
140
 
141
+ def query_stream(
142
+ self,
143
+ binding: DataflowBinding,
144
+ *,
145
+ where: list[str] | None = None,
146
+ params: list[object] | None = None,
147
+ key_filter: KeyFilter | None = None,
148
+ columns: list[str] | None = None,
149
+ order_by: str | None = None,
150
+ chunk_rows: int = 10_000,
151
+ ) -> Iterator[pa.RecordBatch]:
152
+ """Stream the same scan as :meth:`query` as Arrow record batches.
153
+
154
+ Uses DuckDB's Arrow integration (``to_arrow_reader``) so peak
155
+ memory stays roughly proportional to ``chunk_rows`` rather than
156
+ the full result size. See :meth:`sdmxlib.DataStore.query_stream`.
157
+ """
158
+ sql, sql_params = _build_query(
159
+ conn=self._conn,
160
+ binding=binding,
161
+ where=where,
162
+ params=params,
163
+ key_filter=key_filter,
164
+ columns=columns,
165
+ order_by=order_by,
166
+ limit=None,
167
+ )
168
+ reader = self._conn.execute(sql, sql_params).to_arrow_reader(chunk_rows)
169
+ yield from reader
170
+
128
171
 
129
172
  # ── SQL helpers ─────────────────────────────────────────────────────────
130
173
 
131
174
 
132
- def _build_select(
175
+ def _build_query(
133
176
  *,
134
- table: str,
135
- columns: list[str] | None,
177
+ conn: duckdb.DuckDBPyConnection,
178
+ binding: DataflowBinding,
136
179
  where: list[str] | None,
180
+ params: list[object] | None,
181
+ key_filter: KeyFilter | None,
182
+ columns: list[str] | None,
137
183
  order_by: str | None,
138
184
  limit: int | None,
139
- ) -> str:
140
- """Build a parameterised SELECT against ``table``.
141
-
142
- The table name is interpolated as-is (it comes from the binding,
143
- which is a sdmxlib-controlled artefact). WHERE clauses are also
144
- interpolated as-is (callers compose them) but their values must
145
- be passed via ``params`` for parameterisation — never f-stringed.
185
+ ) -> tuple[str, list[object]]:
186
+ """Build a parameterised SELECT + parameter list against the binding's table(s).
187
+
188
+ Table names come from the binding (sdmxlib-controlled artefact) and
189
+ are interpolated as-is. WHERE clauses are interpolated as-is too
190
+ (callers compose them) but their values must be passed via ``params``
191
+ for parameterisation — never f-stringed.
192
+
193
+ When ``binding.series_attributes_location`` is set, the sidecar table
194
+ is LEFT JOINed on the columns common to both tables. When ``key_filter``
195
+ is provided, it is translated to additional ``col IN (?, ?, ...)``
196
+ fragments via ``binding.dimension_column_map`` and ANDed in.
146
197
  """
147
198
  cols_sql = ", ".join(columns) if columns else "*"
148
- sql = f"SELECT {cols_sql} FROM {table}"
149
- if where:
150
- sql += " WHERE " + " AND ".join(where)
199
+ main = binding.table_location
200
+ sidecar = binding.series_attributes_location
201
+ if sidecar is None:
202
+ sql = f"SELECT {cols_sql} FROM {main}"
203
+ else:
204
+ join_keys = _shared_columns(conn, main, sidecar)
205
+ if not join_keys:
206
+ msg = (
207
+ f"DataflowBinding.series_attributes_location {sidecar!r} "
208
+ f"shares no columns with {main!r}; cannot derive JOIN keys"
209
+ )
210
+ raise ValueError(msg)
211
+ keys_sql = ", ".join(join_keys)
212
+ sql = f"SELECT {cols_sql} FROM {main} LEFT JOIN {sidecar} USING ({keys_sql})"
213
+
214
+ fragments = list(where or [])
215
+ sql_params: list[object] = list(params or [])
216
+ if key_filter is not None:
217
+ kf_fragments, kf_params = key_filter.to_where_clauses(binding.dimension_column_map)
218
+ fragments.extend(kf_fragments)
219
+ sql_params.extend(kf_params)
220
+
221
+ if fragments:
222
+ sql += " WHERE " + " AND ".join(fragments)
151
223
  if order_by:
152
224
  sql += f" ORDER BY {order_by}"
153
225
  if limit is not None:
154
226
  sql += f" LIMIT {int(limit)}"
155
- return sql
227
+ return sql, sql_params
228
+
229
+
230
+ def _describe_columns(conn: duckdb.DuckDBPyConnection, table: str) -> list[str]:
231
+ """Return column names for ``table`` via DuckDB ``DESCRIBE``."""
232
+ rows = conn.execute(f"DESCRIBE {table}").fetchall()
233
+ return [str(r[0]) for r in rows]
234
+
235
+
236
+ def _shared_columns(conn: duckdb.DuckDBPyConnection, table_a: str, table_b: str) -> list[str]:
237
+ """Columns present in both tables, in ``table_a`` order."""
238
+ cols_b = set(_describe_columns(conn, table_b))
239
+ return [c for c in _describe_columns(conn, table_a) if c in cols_b]
156
240
 
157
241
 
158
242
  def _sql_identifier(name: str) -> str:
@@ -59,3 +59,30 @@ class MaintainableArtefact:
59
59
  id=id,
60
60
  version=version,
61
61
  )
62
+
63
+ @classmethod
64
+ def parse_rest_ref(cls, ref: str) -> SdmxUrn[Self]:
65
+ """Parse an SDMX REST comma-separated artefact reference.
66
+
67
+ SDMX REST URLs identify a maintainable artefact as
68
+ ``AGENCY,ID,VERSION``. Symmetric with :meth:`urn`.
69
+
70
+ Example:
71
+ Dataflow.parse_rest_ref("ESTAT,STS_INPR_M,1.0")
72
+
73
+ Args:
74
+ ref: Comma-separated ``AGENCY,ID,VERSION`` string.
75
+
76
+ Returns:
77
+ A typed :class:`SdmxUrn` for this artefact class.
78
+
79
+ Raises:
80
+ ValueError: If ``ref`` does not have exactly three
81
+ comma-separated parts.
82
+ """
83
+ parts = ref.split(",")
84
+ if len(parts) != 3:
85
+ msg = f"REST ref {ref!r} must be 'AGENCY,ID,VERSION' (got {len(parts)} parts)"
86
+ raise ValueError(msg)
87
+ agency, id_, version = parts
88
+ return cls.urn(agency=agency, id=id_, version=version)
@@ -260,6 +260,23 @@ class DataStructure(MaintainableArtefact):
260
260
  md: list[MeasureDimension] = [self.measure_dimension] if self.measure_dimension is not None else []
261
261
  return ItemList([*self.dimensions, *md, *self.attributes, *self.measures])
262
262
 
263
+ @property
264
+ def key_dimensions(self) -> list[Dimension | TimeDimension]:
265
+ """Dimension components in SDMX-key order — regular dimensions then time.
266
+
267
+ This is the canonical ordering for SDMX REST keys (e.g. ``CA+US.M.2021``).
268
+ ``dimensions`` excludes the time dimension; this property includes it.
269
+ """
270
+ dims: list[Dimension | TimeDimension] = list(self.dimensions)
271
+ if self.time_dimension is not None:
272
+ dims.append(self.time_dimension)
273
+ return dims
274
+
275
+ @property
276
+ def key_dimension_ids(self) -> list[str]:
277
+ """Component ids in SDMX-key order. See :attr:`key_dimensions`."""
278
+ return [d.id for d in self.key_dimensions]
279
+
263
280
  @property
264
281
  def enumerated_dimensions(self) -> ItemList[Dimension]:
265
282
  """Dimensions that are constrained to a codelist."""
@@ -88,6 +88,45 @@ class StructureMessage:
88
88
  *self.metadataflows,
89
89
  ]
90
90
 
91
+ @classmethod
92
+ def containing(cls, artefact: Any) -> StructureMessage:
93
+ """Wrap a single structural artefact in a fresh :class:`StructureMessage`.
94
+
95
+ Dispatches on the artefact's runtime type to the matching
96
+ collection slot (``Codelist`` → ``codelists``, ``Dataflow`` →
97
+ ``dataflows``, …) using the artefact class's ``msg_field``
98
+ ClassVar. Saves REST handlers from a long ``isinstance`` chain.
99
+
100
+ Example:
101
+ return StructureMessage.containing(dataflow)
102
+ # equivalent to: StructureMessage(dataflows=[dataflow])
103
+
104
+ Args:
105
+ artefact: A maintainable artefact whose type maps to one of
106
+ this message's slots.
107
+
108
+ Raises:
109
+ TypeError: If the artefact's class has no ``msg_field`` or
110
+ its ``msg_field`` is not a slot on
111
+ :class:`StructureMessage`.
112
+ """
113
+ slot = getattr(type(artefact), "msg_field", None)
114
+ if not isinstance(slot, str):
115
+ msg = (
116
+ f"StructureMessage.containing: {type(artefact).__name__} has no "
117
+ f"msg_field — not a known maintainable artefact"
118
+ )
119
+ raise TypeError(msg)
120
+ valid_slots = {a.name for a in cls.__attrs_attrs__} # type: ignore[attr-defined]
121
+ if slot not in valid_slots:
122
+ msg = (
123
+ f"StructureMessage.containing: {type(artefact).__name__} maps to "
124
+ f"slot {slot!r} which is not a StructureMessage field "
125
+ f"(valid: {sorted(valid_slots)})"
126
+ )
127
+ raise TypeError(msg)
128
+ return cls(**{slot: [artefact]})
129
+
91
130
 
92
131
  @frozen
93
132
  class MetadataMessage:
@@ -0,0 +1,130 @@
1
+ """SDMX REST utilities — URL/key parsing shared by REST facades.
2
+
3
+ The SDMX REST API identifies a slice of a dataflow with a *dotted key*:
4
+ each dimension's allowed values, separated by ``.``, with ``+`` joining
5
+ alternatives within one dimension and an empty segment meaning "any value".
6
+ The shorthand ``all`` (or the empty string) means "every observation".
7
+
8
+ This module turns those keys into :class:`KeyFilter`, a structured filter
9
+ that data stores translate to their own query language. Keeping the parser
10
+ here means every facade gets the same wildcard / mismatch / OR semantics.
11
+
12
+ Example:
13
+ >>> kf = parse_key("CA+US.M.2021", ["GEO", "FREQ", "TIME_PERIOD"])
14
+ >>> kf.dimension_ids
15
+ ['GEO', 'FREQ', 'TIME_PERIOD']
16
+ >>> kf.segments
17
+ [['CA', 'US'], ['M'], ['2021']]
18
+ >>> kf = parse_key("all", ["GEO", "FREQ", "TIME_PERIOD"])
19
+ >>> kf.is_wildcard
20
+ True
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from attrs import frozen
26
+
27
+
28
+ @frozen
29
+ class KeyFilter:
30
+ """Structured form of an SDMX REST dotted key.
31
+
32
+ Self-describing: the filter carries the dimension ids it was built
33
+ against alongside the per-dimension allowed values (``None`` = wildcard,
34
+ list = OR semantics). Data stores translate it to WHERE-IN clauses
35
+ via :meth:`to_where_clauses`.
36
+
37
+ Attributes:
38
+ dimension_ids: Dimension ids in DSD-key order.
39
+ segments: Parallel list — ``segments[i]`` constrains
40
+ ``dimension_ids[i]``. ``None`` means no constraint.
41
+ """
42
+
43
+ dimension_ids: list[str]
44
+ segments: list[list[str] | None]
45
+
46
+ def __attrs_post_init__(self) -> None:
47
+ if len(self.dimension_ids) != len(self.segments):
48
+ msg = (
49
+ f"KeyFilter has {len(self.dimension_ids)} dimension_ids but "
50
+ f"{len(self.segments)} segments — must be parallel arrays"
51
+ )
52
+ raise ValueError(msg)
53
+
54
+ @property
55
+ def is_wildcard(self) -> bool:
56
+ """True if every dimension is unconstrained."""
57
+ return all(s is None for s in self.segments)
58
+
59
+ def to_where_clauses(self, column_map: dict[str, str]) -> tuple[list[str], list[object]]:
60
+ """Render this filter as ``(where_fragments, params)``.
61
+
62
+ Each constrained dimension becomes one ``"col IN (?, ?, ...)"``
63
+ fragment with values appended to ``params`` in order. Wildcard
64
+ segments are skipped.
65
+
66
+ Args:
67
+ column_map: SDMX dimension id → physical column name. Usually
68
+ ``binding.dimension_column_map``.
69
+
70
+ Returns:
71
+ A pair of ``(fragments, params)`` ready to pass through to
72
+ :meth:`sdmxlib.DataStore.query` as ``where=`` and ``params=``.
73
+
74
+ Raises:
75
+ KeyError: If a constrained dimension is not in ``column_map``.
76
+ """
77
+ fragments: list[str] = []
78
+ params: list[object] = []
79
+ for dim_id, seg in zip(self.dimension_ids, self.segments, strict=True):
80
+ if seg is None:
81
+ continue
82
+ if dim_id not in column_map:
83
+ msg = (
84
+ f"KeyFilter constrains dimension {dim_id!r} but it is not "
85
+ f"in column_map (have: {sorted(column_map)})"
86
+ )
87
+ raise KeyError(msg)
88
+ col = column_map[dim_id]
89
+ placeholders = ", ".join(["?"] * len(seg))
90
+ fragments.append(f"{col} IN ({placeholders})")
91
+ params.extend(seg)
92
+ return fragments, params
93
+
94
+
95
+ def parse_key(key: str, key_dimension_ids: list[str]) -> KeyFilter:
96
+ """Parse an SDMX REST dotted key into a :class:`KeyFilter`.
97
+
98
+ Args:
99
+ key: The dotted-key fragment from the URL. ``"all"`` and ``""``
100
+ both mean "every observation". Empty segments are wildcards.
101
+ ``+`` joins alternatives within a single dimension.
102
+ key_dimension_ids: Dimension ids in DSD key order. Used both to
103
+ size the result and to validate the segment count.
104
+
105
+ Returns:
106
+ A :class:`KeyFilter` with one entry per dimension.
107
+
108
+ Raises:
109
+ ValueError: If the key has a different number of segments than
110
+ ``key_dimension_ids`` (and is not the ``all`` wildcard).
111
+ """
112
+ n = len(key_dimension_ids)
113
+ if key in {"", "all"}:
114
+ return KeyFilter(dimension_ids=list(key_dimension_ids), segments=[None] * n)
115
+
116
+ raw_segments = key.split(".")
117
+ if len(raw_segments) != n:
118
+ msg = (
119
+ f"SDMX key has {len(raw_segments)} segments but DSD has {n} "
120
+ f"key dimensions ({', '.join(key_dimension_ids)}): {key!r}"
121
+ )
122
+ raise ValueError(msg)
123
+
124
+ segments: list[list[str] | None] = []
125
+ for raw in raw_segments:
126
+ if raw == "":
127
+ segments.append(None)
128
+ else:
129
+ segments.append(raw.split("+"))
130
+ return KeyFilter(dimension_ids=list(key_dimension_ids), segments=segments)
@@ -1,5 +0,0 @@
1
- """SDMX-CSV data message reader."""
2
-
3
- from sdmxlib.formats.sdmx_csv.reader import scan_csv
4
-
5
- __all__ = ["scan_csv"]
File without changes
File without changes
File without changes