sdmxlib 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. sdmxlib/__init__.py +155 -0
  2. sdmxlib/api/__init__.py +7 -0
  3. sdmxlib/api/client.py +246 -0
  4. sdmxlib/api/filters.py +67 -0
  5. sdmxlib/api/providers.py +66 -0
  6. sdmxlib/api/query.py +428 -0
  7. sdmxlib/api/registry.py +666 -0
  8. sdmxlib/api/session.py +124 -0
  9. sdmxlib/formats/__init__.py +166 -0
  10. sdmxlib/formats/sdmx_csv/__init__.py +5 -0
  11. sdmxlib/formats/sdmx_csv/reader.py +147 -0
  12. sdmxlib/formats/sdmx_json/__init__.py +1 -0
  13. sdmxlib/formats/sdmx_json/reader.py +897 -0
  14. sdmxlib/formats/sdmx_json/writer.py +698 -0
  15. sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
  16. sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
  17. sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
  18. sdmxlib/formats/sdmx_ml21/writer.py +709 -0
  19. sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
  20. sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
  21. sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
  22. sdmxlib/formats/sdmx_ml30/writer.py +730 -0
  23. sdmxlib/local/__init__.py +6 -0
  24. sdmxlib/local/_registry.py +368 -0
  25. sdmxlib/model/__init__.py +122 -0
  26. sdmxlib/model/annotations.py +103 -0
  27. sdmxlib/model/base.py +61 -0
  28. sdmxlib/model/category.py +88 -0
  29. sdmxlib/model/codelist.py +91 -0
  30. sdmxlib/model/collections.py +382 -0
  31. sdmxlib/model/concept.py +121 -0
  32. sdmxlib/model/constraint.py +92 -0
  33. sdmxlib/model/convert.py +268 -0
  34. sdmxlib/model/dataflow.py +49 -0
  35. sdmxlib/model/dataset.py +337 -0
  36. sdmxlib/model/datastructure.py +390 -0
  37. sdmxlib/model/expr.py +120 -0
  38. sdmxlib/model/hierarchy.py +111 -0
  39. sdmxlib/model/istring.py +81 -0
  40. sdmxlib/model/mapping.py +134 -0
  41. sdmxlib/model/message.py +70 -0
  42. sdmxlib/model/organisation.py +149 -0
  43. sdmxlib/model/provision.py +45 -0
  44. sdmxlib/model/ref.py +195 -0
  45. sdmxlib/model/registry.py +191 -0
  46. sdmxlib/model/representation.py +99 -0
  47. sdmxlib/model/urn.py +130 -0
  48. sdmxlib/model/validation.py +338 -0
  49. sdmxlib/polars.py +392 -0
  50. sdmxlib/py.typed +0 -0
  51. sdmxlib/storage/__init__.py +28 -0
  52. sdmxlib/storage/lazy.py +329 -0
  53. sdmxlib/storage/readers.py +310 -0
  54. sdmxlib/storage/schema.py +289 -0
  55. sdmxlib/storage/writers.py +503 -0
  56. sdmxlib-0.8.0.dist-info/METADATA +102 -0
  57. sdmxlib-0.8.0.dist-info/RECORD +58 -0
  58. sdmxlib-0.8.0.dist-info/WHEEL +4 -0
@@ -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)