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.
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/PKG-INFO +2 -1
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/pyproject.toml +2 -1
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/__init__.py +3 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/catalog.py +65 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/data_store.py +47 -1
- sdmxlib-0.17.1/src/sdmxlib/formats/sdmx_csv/__init__.py +6 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_csv/reader.py +20 -5
- sdmxlib-0.17.1/src/sdmxlib/formats/sdmx_csv/writer.py +137 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/_data_store.py +102 -18
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/base.py +27 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/datastructure.py +17 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/message.py +39 -0
- sdmxlib-0.17.1/src/sdmxlib/rest.py +130 -0
- sdmxlib-0.16.2/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -5
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/README.md +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/local/_registry.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/code_query.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/lazy.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/readers.py +0 -0
- {sdmxlib-0.16.2 → sdmxlib-0.17.1}/src/sdmxlib/storage/schema.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
+
...
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 =
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
175
|
+
def _build_query(
|
|
133
176
|
*,
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|