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.
- sdmxlib/__init__.py +155 -0
- sdmxlib/api/__init__.py +7 -0
- sdmxlib/api/client.py +246 -0
- sdmxlib/api/filters.py +67 -0
- sdmxlib/api/providers.py +66 -0
- sdmxlib/api/query.py +428 -0
- sdmxlib/api/registry.py +666 -0
- sdmxlib/api/session.py +124 -0
- sdmxlib/formats/__init__.py +166 -0
- sdmxlib/formats/sdmx_csv/__init__.py +5 -0
- sdmxlib/formats/sdmx_csv/reader.py +147 -0
- sdmxlib/formats/sdmx_json/__init__.py +1 -0
- sdmxlib/formats/sdmx_json/reader.py +897 -0
- sdmxlib/formats/sdmx_json/writer.py +698 -0
- sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
- sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
- sdmxlib/formats/sdmx_ml21/writer.py +709 -0
- sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
- sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
- sdmxlib/formats/sdmx_ml30/writer.py +730 -0
- sdmxlib/local/__init__.py +6 -0
- sdmxlib/local/_registry.py +368 -0
- sdmxlib/model/__init__.py +122 -0
- sdmxlib/model/annotations.py +103 -0
- sdmxlib/model/base.py +61 -0
- sdmxlib/model/category.py +88 -0
- sdmxlib/model/codelist.py +91 -0
- sdmxlib/model/collections.py +382 -0
- sdmxlib/model/concept.py +121 -0
- sdmxlib/model/constraint.py +92 -0
- sdmxlib/model/convert.py +268 -0
- sdmxlib/model/dataflow.py +49 -0
- sdmxlib/model/dataset.py +337 -0
- sdmxlib/model/datastructure.py +390 -0
- sdmxlib/model/expr.py +120 -0
- sdmxlib/model/hierarchy.py +111 -0
- sdmxlib/model/istring.py +81 -0
- sdmxlib/model/mapping.py +134 -0
- sdmxlib/model/message.py +70 -0
- sdmxlib/model/organisation.py +149 -0
- sdmxlib/model/provision.py +45 -0
- sdmxlib/model/ref.py +195 -0
- sdmxlib/model/registry.py +191 -0
- sdmxlib/model/representation.py +99 -0
- sdmxlib/model/urn.py +130 -0
- sdmxlib/model/validation.py +338 -0
- sdmxlib/polars.py +392 -0
- sdmxlib/py.typed +0 -0
- sdmxlib/storage/__init__.py +28 -0
- sdmxlib/storage/lazy.py +329 -0
- sdmxlib/storage/readers.py +310 -0
- sdmxlib/storage/schema.py +289 -0
- sdmxlib/storage/writers.py +503 -0
- sdmxlib-0.8.0.dist-info/METADATA +102 -0
- sdmxlib-0.8.0.dist-info/RECORD +58 -0
- sdmxlib-0.8.0.dist-info/WHEEL +4 -0
sdmxlib/api/registry.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""RestRegistry — HTTP client for an SDMX REST endpoint."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from sdmxlib.api.client import SdmxClient
|
|
7
|
+
from sdmxlib.api.providers import ProviderInfo
|
|
8
|
+
from sdmxlib.api.query import DataQuery, GetQuery, Query, _KeySpec
|
|
9
|
+
from sdmxlib.api.session import Session
|
|
10
|
+
from sdmxlib.formats import parse as _parse
|
|
11
|
+
from sdmxlib.model.dataflow import Dataflow
|
|
12
|
+
from sdmxlib.model.dataset import Dataset
|
|
13
|
+
from sdmxlib.model.datastructure import DataStructure
|
|
14
|
+
from sdmxlib.model.message import StructureMessage
|
|
15
|
+
from sdmxlib.model.ref import Ref
|
|
16
|
+
from sdmxlib.model.registry import Registry
|
|
17
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
18
|
+
|
|
19
|
+
SDMX_GLOBAL_REGISTRY = "https://registry.sdmx.org/sdmx/v2"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _serialise_key(structure: DataStructure, spec: _KeySpec) -> str: # noqa: C901, PLR0912
|
|
23
|
+
"""Serialise a _KeySpec to an SDMX REST positional key string.
|
|
24
|
+
|
|
25
|
+
Returns ``spec.raw_key`` unchanged when no slices or measures are set.
|
|
26
|
+
Otherwise validates dimension ids and builds ``DIM1+DIM2.DIM3....`` format.
|
|
27
|
+
Unspecified dimensions become empty strings (wildcard). Codes are sorted
|
|
28
|
+
for stable, deterministic URLs.
|
|
29
|
+
|
|
30
|
+
When ``spec.measure_dim_hint`` is set and the DSD has no formal
|
|
31
|
+
``MeasureDimension``, ``.measures()`` codes are injected as a regular
|
|
32
|
+
dimension slice at the hint dimension's positional slot.
|
|
33
|
+
"""
|
|
34
|
+
if not spec.slices and spec.measures is None:
|
|
35
|
+
return spec.raw_key
|
|
36
|
+
|
|
37
|
+
slice_map: dict[str, list[str]] = {f.id: f.codes for f in spec.slices}
|
|
38
|
+
|
|
39
|
+
# Validate all slice dim ids exist in the DSD
|
|
40
|
+
valid_ids = {d.id for d in structure.dimensions}
|
|
41
|
+
md = structure.measure_dimension
|
|
42
|
+
if md is not None:
|
|
43
|
+
valid_ids.add(md.id)
|
|
44
|
+
unknown = set(slice_map) - valid_ids
|
|
45
|
+
if unknown:
|
|
46
|
+
names = ", ".join(sorted(unknown))
|
|
47
|
+
msg = f"Dimension(s) not found in DSD: {names}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
# Validate hint dim exists when provided
|
|
51
|
+
if spec.measure_dim_hint is not None and spec.measure_dim_hint not in valid_ids:
|
|
52
|
+
msg = f"measure_dim {spec.measure_dim_hint!r} not found in DSD"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
|
|
55
|
+
if md is not None:
|
|
56
|
+
# ── Formal MeasureDimension path ──────────────────────────────────────
|
|
57
|
+
# Conflict: same dim targeted by both .slice() and .measures()
|
|
58
|
+
if spec.measures is not None and md.id in slice_map:
|
|
59
|
+
msg = f"Cannot filter {md.id!r} via both .slice() and .measures() — use one or the other"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
for dim in structure.dimensions:
|
|
64
|
+
codes = slice_map.get(dim.id)
|
|
65
|
+
parts.append("+".join(sorted(codes)) if codes else "")
|
|
66
|
+
|
|
67
|
+
# MeasureDimension appended after all regular dims
|
|
68
|
+
if spec.measures is not None:
|
|
69
|
+
parts.append("+".join(sorted(spec.measures)))
|
|
70
|
+
else:
|
|
71
|
+
codes = slice_map.get(md.id)
|
|
72
|
+
parts.append("+".join(sorted(codes)) if codes else "")
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
# ── No formal MeasureDimension — hint path ────────────────────────────
|
|
76
|
+
# .measures() with a hint injects codes at the hint dim's regular slot
|
|
77
|
+
effective_slice_map = dict(slice_map)
|
|
78
|
+
if spec.measures is not None and spec.measure_dim_hint is not None:
|
|
79
|
+
if spec.measure_dim_hint in effective_slice_map:
|
|
80
|
+
msg = (
|
|
81
|
+
f"Cannot filter {spec.measure_dim_hint!r} via both .slice() and .measures() — use one or the other"
|
|
82
|
+
)
|
|
83
|
+
raise ValueError(msg)
|
|
84
|
+
effective_slice_map[spec.measure_dim_hint] = sorted(spec.measures)
|
|
85
|
+
|
|
86
|
+
parts = []
|
|
87
|
+
for dim in structure.dimensions:
|
|
88
|
+
codes = effective_slice_map.get(dim.id)
|
|
89
|
+
parts.append("+".join(sorted(codes)) if codes else "")
|
|
90
|
+
|
|
91
|
+
key = ".".join(parts)
|
|
92
|
+
return key if any(p for p in parts) else "all"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RestRegistry:
|
|
96
|
+
"""Connection to an SDMX REST endpoint.
|
|
97
|
+
|
|
98
|
+
Use as a context manager. All artefact access happens within the scope.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
endpoint: Base URL or :class:`~sdmxlib.api.providers.ProviderInfo`.
|
|
102
|
+
Pass a ``ProviderInfo`` from :class:`~sdmxlib.api.providers.Provider`
|
|
103
|
+
to have the API version set automatically.
|
|
104
|
+
api: SDMX REST API version — ``"2.1"`` (default) or ``"3.0"``.
|
|
105
|
+
Ignored when *endpoint* is a ``ProviderInfo``.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
with sl.RestRegistry(sl.Provider.ECB) as reg:
|
|
109
|
+
dsd = reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve()
|
|
110
|
+
|
|
111
|
+
with sl.RestRegistry("https://data-api.ecb.europa.eu/service") as reg:
|
|
112
|
+
for df in reg.query(sl.Dataflow, agency="ECB").browse():
|
|
113
|
+
print(df.id)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
endpoint: "str | ProviderInfo",
|
|
119
|
+
*,
|
|
120
|
+
api: str = "2.1",
|
|
121
|
+
timeout: float = 30.0,
|
|
122
|
+
headers: dict[str, str] | None = None,
|
|
123
|
+
**httpx_kwargs: object,
|
|
124
|
+
) -> None:
|
|
125
|
+
if isinstance(endpoint, ProviderInfo):
|
|
126
|
+
url = endpoint.url
|
|
127
|
+
api = endpoint.api
|
|
128
|
+
else:
|
|
129
|
+
url = endpoint
|
|
130
|
+
self._client = SdmxClient(
|
|
131
|
+
url,
|
|
132
|
+
api=api,
|
|
133
|
+
timeout=timeout,
|
|
134
|
+
headers=headers,
|
|
135
|
+
**httpx_kwargs,
|
|
136
|
+
)
|
|
137
|
+
self._session = Session()
|
|
138
|
+
self._registry = Registry()
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def registry(self) -> Registry:
|
|
142
|
+
"""The domain Registry populated by every artefact fetched so far.
|
|
143
|
+
|
|
144
|
+
Artefacts from all ``get()``, ``query()``, ``fetch()``, and
|
|
145
|
+
``resolve()`` calls accumulate here with interned Refs. Cross-request
|
|
146
|
+
Ref resolution happens automatically — a codelist fetched in one
|
|
147
|
+
request resolves dimension Refs in a DSD fetched in another.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
with sl.RestRegistry(...) as rest:
|
|
151
|
+
dsd = rest.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve()
|
|
152
|
+
codelists = rest.registry.get_all(sl.Codelist)
|
|
153
|
+
"""
|
|
154
|
+
return self._registry
|
|
155
|
+
|
|
156
|
+
def load(self, source: "str | Path | bytes") -> StructureMessage:
|
|
157
|
+
"""Load artefacts from a local SDMX file into this registry.
|
|
158
|
+
|
|
159
|
+
Useful for pre-populating the registry with local files before making
|
|
160
|
+
network requests, or for mixing local and remote artefacts. All loaded
|
|
161
|
+
artefacts are treated as fully resolved — no follow-up HTTP requests
|
|
162
|
+
are made.
|
|
163
|
+
|
|
164
|
+
Ref interning applies: artefacts loaded from file resolve Refs in
|
|
165
|
+
artefacts already present (from earlier ``load()`` or HTTP calls), and
|
|
166
|
+
vice-versa.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
source: File path (``str`` or ``Path``) or raw bytes.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The parsed :class:`~sdmxlib.model.message.StructureMessage`.
|
|
173
|
+
|
|
174
|
+
Example::
|
|
175
|
+
|
|
176
|
+
with sl.RestRegistry("https://...") as reg:
|
|
177
|
+
reg.load("local_constraints.xml") # pre-populate
|
|
178
|
+
dsd = reg.get(sl.DataStructure, agency="ESTAT", id="CPI").resolve()
|
|
179
|
+
"""
|
|
180
|
+
data = Path(source).read_bytes() if isinstance(source, (str, Path)) else source
|
|
181
|
+
msg = _parse(data, registry=self._registry)
|
|
182
|
+
self._session.ingest(msg, resolved=True)
|
|
183
|
+
return msg
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
"""Close the underlying HTTP client."""
|
|
187
|
+
self._client.close()
|
|
188
|
+
|
|
189
|
+
def __enter__(self) -> Self:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *args: object) -> None:
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def _fetch_one[T](
|
|
198
|
+
self,
|
|
199
|
+
artefact_type: type[T],
|
|
200
|
+
agency: str,
|
|
201
|
+
id: str, # noqa: A002
|
|
202
|
+
version: str = "latest",
|
|
203
|
+
*,
|
|
204
|
+
detail: str,
|
|
205
|
+
references: str,
|
|
206
|
+
) -> T:
|
|
207
|
+
"""Fetch a single artefact with explicit wire parameters."""
|
|
208
|
+
resource = getattr(artefact_type, "sdmx_class", artefact_type.__name__)
|
|
209
|
+
package = getattr(artefact_type, "sdmx_package", resource.lower())
|
|
210
|
+
urn = SdmxUrn.make(
|
|
211
|
+
artefact_type,
|
|
212
|
+
package=package,
|
|
213
|
+
artefact_class=resource,
|
|
214
|
+
agency=agency,
|
|
215
|
+
id=id,
|
|
216
|
+
version=version,
|
|
217
|
+
)
|
|
218
|
+
resolved = references == "descendants"
|
|
219
|
+
if resolved:
|
|
220
|
+
cached = self._session.get_resolved(urn)
|
|
221
|
+
if cached is not None:
|
|
222
|
+
return cached
|
|
223
|
+
elif references == "none":
|
|
224
|
+
cached = self._session.get(urn)
|
|
225
|
+
if cached is not None:
|
|
226
|
+
return cached
|
|
227
|
+
msg = self._client.get_artefact(resource, agency, id, version, detail=detail, references=references)
|
|
228
|
+
self._session.ingest(msg, resolved=resolved)
|
|
229
|
+
self._registry.add_all(msg.all_artefacts())
|
|
230
|
+
result = self._session.extract(msg, urn)
|
|
231
|
+
if result is None:
|
|
232
|
+
err = f"{resource} {agency}:{id}({version}) not found in response"
|
|
233
|
+
raise KeyError(err)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
def _fetch_all_with[T](
|
|
237
|
+
self,
|
|
238
|
+
artefact_type: type[T],
|
|
239
|
+
agency: str | None,
|
|
240
|
+
*,
|
|
241
|
+
detail: str,
|
|
242
|
+
references: str,
|
|
243
|
+
) -> list[T]:
|
|
244
|
+
"""Fetch all artefacts of a type with explicit wire parameters."""
|
|
245
|
+
resource = getattr(artefact_type, "sdmx_class", artefact_type.__name__)
|
|
246
|
+
resolved = references == "descendants"
|
|
247
|
+
msg = self._client.get_all(resource, agency, detail=detail, references=references)
|
|
248
|
+
self._session.ingest(msg, resolved=resolved)
|
|
249
|
+
self._registry.add_all(msg.all_artefacts())
|
|
250
|
+
return self._session.all_of_class(artefact_type)
|
|
251
|
+
|
|
252
|
+
# ── Generic entry points ──────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def get[T](
|
|
255
|
+
self,
|
|
256
|
+
artefact_type: type[T],
|
|
257
|
+
*,
|
|
258
|
+
agency: str,
|
|
259
|
+
id: str, # noqa: A002
|
|
260
|
+
version: str = "latest",
|
|
261
|
+
) -> GetQuery[T]:
|
|
262
|
+
"""Point lookup for a single artefact. Returns a GetQuery.
|
|
263
|
+
|
|
264
|
+
Call ``.fetch()`` for a skeleton or ``.resolve()`` to follow references.
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").fetch()
|
|
268
|
+
reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve()
|
|
269
|
+
reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve(sl.Codelist)
|
|
270
|
+
reg.get(sl.DataStructure, agency="ECB", id="ECB_EXR1").resolve(partial=False)
|
|
271
|
+
"""
|
|
272
|
+
return GetQuery(
|
|
273
|
+
fetcher=lambda: self._fetch_one(
|
|
274
|
+
artefact_type,
|
|
275
|
+
agency,
|
|
276
|
+
id,
|
|
277
|
+
version,
|
|
278
|
+
detail="full",
|
|
279
|
+
references="none",
|
|
280
|
+
),
|
|
281
|
+
resolver=lambda refs, partial: self._fetch_one(
|
|
282
|
+
artefact_type,
|
|
283
|
+
agency,
|
|
284
|
+
id,
|
|
285
|
+
version,
|
|
286
|
+
detail="referencepartial" if partial else "full",
|
|
287
|
+
references=refs,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def query[T](self, artefact_type: type[T], *, agency: str | None = None) -> Query[T]:
|
|
292
|
+
"""Browse all artefacts of a type. Returns a chainable Query.
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
reg.query(sl.Dataflow, agency="ECB").browse()
|
|
296
|
+
reg.query(sl.Dataflow, agency="ECB").filter(...).fetch()
|
|
297
|
+
reg.query(sl.Codelist).sort_by("id").resolve()
|
|
298
|
+
reg.query(sl.Dataflow, agency="ECB").resolve(sl.DataStructure)
|
|
299
|
+
"""
|
|
300
|
+
return Query(
|
|
301
|
+
fetcher=lambda: self._fetch_all_with(
|
|
302
|
+
artefact_type,
|
|
303
|
+
agency,
|
|
304
|
+
detail="full",
|
|
305
|
+
references="none",
|
|
306
|
+
),
|
|
307
|
+
resolver=lambda refs, partial: self._fetch_all_with(
|
|
308
|
+
artefact_type,
|
|
309
|
+
agency,
|
|
310
|
+
detail="referencepartial" if partial else "full",
|
|
311
|
+
references=refs,
|
|
312
|
+
),
|
|
313
|
+
browser=lambda: self._fetch_all_with(
|
|
314
|
+
artefact_type,
|
|
315
|
+
agency,
|
|
316
|
+
detail="allstubs",
|
|
317
|
+
references="none",
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# ── URN / Ref-based access ────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
def fetch[T](self, urn: SdmxUrn[T]) -> T:
|
|
324
|
+
"""Fetch an artefact by URN with unresolved Refs."""
|
|
325
|
+
cached = self._session.get(urn)
|
|
326
|
+
if cached is not None:
|
|
327
|
+
return cached
|
|
328
|
+
msg = self._client.get_artefact(
|
|
329
|
+
urn.artefact_class,
|
|
330
|
+
urn.agency,
|
|
331
|
+
urn.id,
|
|
332
|
+
urn.version,
|
|
333
|
+
detail="full",
|
|
334
|
+
references="none",
|
|
335
|
+
)
|
|
336
|
+
self._session.ingest(msg)
|
|
337
|
+
self._registry.add_all(msg.all_artefacts())
|
|
338
|
+
result = self._session.extract(msg, urn)
|
|
339
|
+
if result is None:
|
|
340
|
+
err = f"{urn} not found in response"
|
|
341
|
+
raise KeyError(err)
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
def resolve[T](self, target: SdmxUrn[T] | Ref[T], *, partial: bool = True) -> T:
|
|
345
|
+
"""Fetch an artefact by URN or Ref with all Refs resolved.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
target: A ``SdmxUrn[T]`` or ``Ref[T]`` from graph traversal.
|
|
349
|
+
partial: If ``True`` (default), referenced ItemSchemes are trimmed
|
|
350
|
+
by ContentConstraints. Set to ``False`` for full audits.
|
|
351
|
+
|
|
352
|
+
Example:
|
|
353
|
+
reg.resolve(sl.Codelist.urn(agency="ECB", id="CL_CURRENCY"))
|
|
354
|
+
reg.resolve(dsd.dimensions["FREQ"].representation)
|
|
355
|
+
reg.resolve(dsd.dimensions["FREQ"].representation, partial=False)
|
|
356
|
+
"""
|
|
357
|
+
urn: SdmxUrn[T] = target.urn if isinstance(target, Ref) else target
|
|
358
|
+
cached = self._session.get_resolved(urn)
|
|
359
|
+
if cached is not None:
|
|
360
|
+
return cached
|
|
361
|
+
detail = "referencepartial" if partial else "full"
|
|
362
|
+
msg = self._client.get_artefact(
|
|
363
|
+
urn.artefact_class,
|
|
364
|
+
urn.agency,
|
|
365
|
+
urn.id,
|
|
366
|
+
urn.version,
|
|
367
|
+
detail=detail,
|
|
368
|
+
references="descendants",
|
|
369
|
+
)
|
|
370
|
+
self._session.ingest(msg, resolved=True)
|
|
371
|
+
self._registry.add_all(msg.all_artefacts())
|
|
372
|
+
result = self._session.extract(msg, urn)
|
|
373
|
+
if result is None:
|
|
374
|
+
err = f"{urn} not found in response"
|
|
375
|
+
raise KeyError(err)
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
# ── Data fetching ─────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def data( # noqa: C901
|
|
381
|
+
self,
|
|
382
|
+
dataflow: "type[Dataflow] | Dataflow",
|
|
383
|
+
*,
|
|
384
|
+
agency: str | None = None,
|
|
385
|
+
id: str | None = None, # noqa: A002
|
|
386
|
+
version: str = "latest",
|
|
387
|
+
key: str = "all",
|
|
388
|
+
start_period: str | None = None,
|
|
389
|
+
end_period: str | None = None,
|
|
390
|
+
measure_dim: str | None = None,
|
|
391
|
+
) -> DataQuery:
|
|
392
|
+
"""Start a data query for observation data. Returns a :class:`DataQuery`.
|
|
393
|
+
|
|
394
|
+
Pass the :class:`~sdmxlib.model.dataflow.Dataflow` *type* with
|
|
395
|
+
``agency`` and ``id`` to mirror :meth:`get` — the registry resolves
|
|
396
|
+
the structure internally on :meth:`~DataQuery.fetch`:
|
|
397
|
+
|
|
398
|
+
.. code-block:: python
|
|
399
|
+
|
|
400
|
+
reg.data(sl.Dataflow, agency="ECB", id="EXR", key="M.USD.EUR.SP00.A").fetch()
|
|
401
|
+
|
|
402
|
+
Or pass an already-resolved :class:`~sdmxlib.model.dataflow.Dataflow`
|
|
403
|
+
instance (e.g. from a previous :meth:`query`) to skip the extra fetch:
|
|
404
|
+
|
|
405
|
+
.. code-block:: python
|
|
406
|
+
|
|
407
|
+
reg.data(flow, key="M.USD.EUR.SP00.A").fetch()
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
dataflow: The :class:`~sdmxlib.model.dataflow.Dataflow` class or a
|
|
411
|
+
resolved instance.
|
|
412
|
+
agency: Maintainer agency id. Required when passing the type.
|
|
413
|
+
id: Dataflow id. Required when passing the type.
|
|
414
|
+
version: Dataflow version. Defaults to ``"latest"``.
|
|
415
|
+
key: SDMX key filter string, e.g. ``"M.USD.EUR.SP00.A"``.
|
|
416
|
+
start_period: ISO period start, e.g. ``"2020-01"``.
|
|
417
|
+
end_period: ISO period end.
|
|
418
|
+
measure_dim: Dimension id to treat as the measure axis when the DSD
|
|
419
|
+
has no formal ``<MeasureDimension>``. Enables :meth:`~DataQuery.measures`
|
|
420
|
+
filtering and :meth:`~sdmxlib.model.dataset.Dataset.pivot` on
|
|
421
|
+
providers that use a regular dimension (e.g. ``MEASURE``,
|
|
422
|
+
``INDICATOR``, ``SUBJECT``) instead of the SDMX 2.1 construct.
|
|
423
|
+
If absent and no ``<MeasureDimension>`` exists, ``pivot()``
|
|
424
|
+
raises with a list of candidate enumerated dimensions.
|
|
425
|
+
"""
|
|
426
|
+
if isinstance(dataflow, type):
|
|
427
|
+
if agency is None or id is None:
|
|
428
|
+
msg = "agency and id are required when passing the Dataflow type"
|
|
429
|
+
raise ValueError(msg)
|
|
430
|
+
_agency, _id, _version = agency, id, version
|
|
431
|
+
|
|
432
|
+
def _resolve_flow() -> "tuple[DataStructure, Dataflow]":
|
|
433
|
+
flow = self._fetch_one(
|
|
434
|
+
Dataflow,
|
|
435
|
+
_agency,
|
|
436
|
+
_id,
|
|
437
|
+
_version,
|
|
438
|
+
detail="referencepartial",
|
|
439
|
+
references="descendants",
|
|
440
|
+
)
|
|
441
|
+
if flow.structure is None:
|
|
442
|
+
msg = f"Dataflow {flow.id!r} has no structure reference"
|
|
443
|
+
raise ValueError(msg)
|
|
444
|
+
return flow.structure(), flow
|
|
445
|
+
|
|
446
|
+
def _fetcher(spec: _KeySpec) -> Dataset:
|
|
447
|
+
structure, flow = _resolve_flow()
|
|
448
|
+
ds = self._fetch_data(
|
|
449
|
+
_agency,
|
|
450
|
+
_id,
|
|
451
|
+
flow.version, # use resolved version — providers may reject "latest" for data
|
|
452
|
+
structure,
|
|
453
|
+
key=_serialise_key(structure, spec),
|
|
454
|
+
start_period=start_period,
|
|
455
|
+
end_period=end_period,
|
|
456
|
+
dataflow=flow,
|
|
457
|
+
)
|
|
458
|
+
if measure_dim is not None:
|
|
459
|
+
ds.measure_dim_hint = measure_dim
|
|
460
|
+
return ds
|
|
461
|
+
|
|
462
|
+
def _streamer(path: "Path", spec: _KeySpec) -> Dataset:
|
|
463
|
+
structure, flow = _resolve_flow()
|
|
464
|
+
ds = self._fetch_data_to(
|
|
465
|
+
path,
|
|
466
|
+
_agency,
|
|
467
|
+
_id,
|
|
468
|
+
flow.version, # use resolved version — providers may reject "latest" for data
|
|
469
|
+
structure,
|
|
470
|
+
key=_serialise_key(structure, spec),
|
|
471
|
+
start_period=start_period,
|
|
472
|
+
end_period=end_period,
|
|
473
|
+
dataflow=flow,
|
|
474
|
+
)
|
|
475
|
+
if measure_dim is not None:
|
|
476
|
+
ds.measure_dim_hint = measure_dim
|
|
477
|
+
return ds
|
|
478
|
+
else:
|
|
479
|
+
if dataflow.structure is None:
|
|
480
|
+
msg = f"Dataflow {dataflow.id!r} has no structure reference"
|
|
481
|
+
raise ValueError(msg)
|
|
482
|
+
_agency = dataflow.agency_id
|
|
483
|
+
_id = dataflow.id
|
|
484
|
+
_version = dataflow.version
|
|
485
|
+
structure: DataStructure = dataflow.structure()
|
|
486
|
+
|
|
487
|
+
def _fetcher(spec: _KeySpec) -> Dataset: # type: ignore[misc]
|
|
488
|
+
ds = self._fetch_data(
|
|
489
|
+
_agency,
|
|
490
|
+
_id,
|
|
491
|
+
_version,
|
|
492
|
+
structure,
|
|
493
|
+
key=_serialise_key(structure, spec),
|
|
494
|
+
start_period=start_period,
|
|
495
|
+
end_period=end_period,
|
|
496
|
+
dataflow=dataflow,
|
|
497
|
+
)
|
|
498
|
+
if measure_dim is not None:
|
|
499
|
+
ds.measure_dim_hint = measure_dim
|
|
500
|
+
return ds
|
|
501
|
+
|
|
502
|
+
def _streamer(path: "Path", spec: _KeySpec) -> Dataset: # type: ignore[misc]
|
|
503
|
+
ds = self._fetch_data_to(
|
|
504
|
+
path,
|
|
505
|
+
_agency,
|
|
506
|
+
_id,
|
|
507
|
+
_version,
|
|
508
|
+
structure,
|
|
509
|
+
key=_serialise_key(structure, spec),
|
|
510
|
+
start_period=start_period,
|
|
511
|
+
end_period=end_period,
|
|
512
|
+
dataflow=dataflow,
|
|
513
|
+
)
|
|
514
|
+
if measure_dim is not None:
|
|
515
|
+
ds.measure_dim_hint = measure_dim
|
|
516
|
+
return ds
|
|
517
|
+
|
|
518
|
+
return DataQuery(fetcher=_fetcher, streamer=_streamer, raw_key=key, measure_dim=measure_dim)
|
|
519
|
+
|
|
520
|
+
def _fetch_data(
|
|
521
|
+
self,
|
|
522
|
+
agency: str,
|
|
523
|
+
id: str, # noqa: A002
|
|
524
|
+
version: str,
|
|
525
|
+
structure: DataStructure,
|
|
526
|
+
*,
|
|
527
|
+
key: str,
|
|
528
|
+
start_period: str | None,
|
|
529
|
+
end_period: str | None,
|
|
530
|
+
dataflow: "Dataflow | None" = None,
|
|
531
|
+
) -> Dataset:
|
|
532
|
+
"""Fetch observation data and return a Dataset."""
|
|
533
|
+
from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415
|
|
534
|
+
|
|
535
|
+
csv_bytes = self._client.get_data(
|
|
536
|
+
agency,
|
|
537
|
+
id,
|
|
538
|
+
version,
|
|
539
|
+
key=key,
|
|
540
|
+
start_period=start_period,
|
|
541
|
+
end_period=end_period,
|
|
542
|
+
)
|
|
543
|
+
dataflow_ref = Ref.to(dataflow) if dataflow is not None else None
|
|
544
|
+
return scan_csv(csv_bytes, structure, dataflow=dataflow_ref)
|
|
545
|
+
|
|
546
|
+
def _fetch_data_to(
|
|
547
|
+
self,
|
|
548
|
+
path: Path,
|
|
549
|
+
agency: str,
|
|
550
|
+
id: str, # noqa: A002
|
|
551
|
+
version: str,
|
|
552
|
+
structure: DataStructure,
|
|
553
|
+
*,
|
|
554
|
+
key: str,
|
|
555
|
+
start_period: str | None,
|
|
556
|
+
end_period: str | None,
|
|
557
|
+
dataflow: "Dataflow | None" = None,
|
|
558
|
+
) -> Dataset:
|
|
559
|
+
"""Stream observation data to a file and return a file-backed Dataset."""
|
|
560
|
+
from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415
|
|
561
|
+
|
|
562
|
+
self._client.stream_data_to(
|
|
563
|
+
agency,
|
|
564
|
+
id,
|
|
565
|
+
version,
|
|
566
|
+
path=path,
|
|
567
|
+
key=key,
|
|
568
|
+
start_period=start_period,
|
|
569
|
+
end_period=end_period,
|
|
570
|
+
)
|
|
571
|
+
dataflow_ref = Ref.to(dataflow) if dataflow is not None else None
|
|
572
|
+
return scan_csv(path, structure, dataflow=dataflow_ref)
|
|
573
|
+
|
|
574
|
+
def scan_csv(self, source: "str | Path | bytes") -> Dataset:
|
|
575
|
+
"""Load an SDMX-CSV file or bytes, auto-resolving the structure via this registry.
|
|
576
|
+
|
|
577
|
+
Reads the ``DATAFLOW`` column to identify the dataflow, fetches and
|
|
578
|
+
resolves the :class:`~sdmxlib.model.datastructure.DataStructure` if it
|
|
579
|
+
is not already cached, then returns a lazy
|
|
580
|
+
:class:`~sdmxlib.model.dataset.Dataset`.
|
|
581
|
+
|
|
582
|
+
This is the preferred entry point when you have a local SDMX-CSV file:
|
|
583
|
+
the registry owns resolution, so you do not need to fetch the DSD
|
|
584
|
+
separately.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
source: File path or raw SDMX-CSV bytes.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
ValueError: If the ``DATAFLOW`` column is absent or unparseable.
|
|
591
|
+
|
|
592
|
+
Example:
|
|
593
|
+
with sl.RestRegistry(sl.Provider.ECB) as reg:
|
|
594
|
+
ds = reg.scan_csv("ecb_exr.csv")
|
|
595
|
+
df = ds.label().collect()
|
|
596
|
+
"""
|
|
597
|
+
from sdmxlib.formats.sdmx_csv.reader import _peek_dataflow_id, parse_dataflow_id, scan_csv # noqa: PLC0415
|
|
598
|
+
|
|
599
|
+
dataflow_str = _peek_dataflow_id(source)
|
|
600
|
+
if dataflow_str is None:
|
|
601
|
+
msg = (
|
|
602
|
+
"DATAFLOW column not found in CSV — cannot auto-resolve structure. "
|
|
603
|
+
"Pass the DataStructure explicitly to slpl.scan_csv()."
|
|
604
|
+
)
|
|
605
|
+
raise ValueError(msg)
|
|
606
|
+
|
|
607
|
+
agency, id_, version = parse_dataflow_id(dataflow_str)
|
|
608
|
+
flow = self._fetch_one(
|
|
609
|
+
Dataflow,
|
|
610
|
+
agency,
|
|
611
|
+
id_,
|
|
612
|
+
version,
|
|
613
|
+
detail="referencepartial",
|
|
614
|
+
references="descendants",
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if flow.structure is None:
|
|
618
|
+
msg = f"Dataflow {flow.id!r} has no structure reference"
|
|
619
|
+
raise ValueError(msg)
|
|
620
|
+
|
|
621
|
+
structure: DataStructure = flow.structure()
|
|
622
|
+
return scan_csv(source, structure, dataflow=Ref.to(flow))
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ── FmrRegistry ───────────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class BearerToken:
|
|
629
|
+
"""Bearer token for FMR authentication."""
|
|
630
|
+
|
|
631
|
+
def __init__(self, token: str) -> None:
|
|
632
|
+
self.token = token
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class FmrRegistry(RestRegistry):
|
|
636
|
+
"""Registry configured for Fusion Metadata Registry endpoints.
|
|
637
|
+
|
|
638
|
+
Defaults to the SDMX Global Registry.
|
|
639
|
+
|
|
640
|
+
Example:
|
|
641
|
+
with sl.FmrRegistry() as reg:
|
|
642
|
+
...
|
|
643
|
+
with sl.FmrRegistry("https://my-fmr.example.com/sdmxapi/rest") as reg:
|
|
644
|
+
...
|
|
645
|
+
with sl.FmrRegistry(auth=sl.BearerToken("eyJ...")) as reg:
|
|
646
|
+
...
|
|
647
|
+
with sl.FmrRegistry(auth=("user", "pass")) as reg:
|
|
648
|
+
...
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
def __init__(
|
|
652
|
+
self,
|
|
653
|
+
endpoint: str = SDMX_GLOBAL_REGISTRY,
|
|
654
|
+
*,
|
|
655
|
+
auth: "tuple[str, str] | BearerToken | None" = None,
|
|
656
|
+
timeout: float = 30.0,
|
|
657
|
+
**kwargs: object,
|
|
658
|
+
) -> None:
|
|
659
|
+
headers: dict[str, str] = {}
|
|
660
|
+
httpx_kwargs: dict[str, object] = dict(kwargs)
|
|
661
|
+
if isinstance(auth, BearerToken):
|
|
662
|
+
headers["Authorization"] = f"Bearer {auth.token}"
|
|
663
|
+
elif isinstance(auth, tuple):
|
|
664
|
+
httpx_kwargs["auth"] = auth
|
|
665
|
+
# The SDMX Global Registry currently serves 2.1 format responses.
|
|
666
|
+
super().__init__(endpoint, api="2.1", timeout=timeout, headers=headers, **httpx_kwargs)
|