sdmxlib 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. sdmxlib/__init__.py +155 -0
  2. sdmxlib/api/__init__.py +7 -0
  3. sdmxlib/api/client.py +246 -0
  4. sdmxlib/api/filters.py +67 -0
  5. sdmxlib/api/providers.py +66 -0
  6. sdmxlib/api/query.py +428 -0
  7. sdmxlib/api/registry.py +666 -0
  8. sdmxlib/api/session.py +124 -0
  9. sdmxlib/formats/__init__.py +166 -0
  10. sdmxlib/formats/sdmx_csv/__init__.py +5 -0
  11. sdmxlib/formats/sdmx_csv/reader.py +147 -0
  12. sdmxlib/formats/sdmx_json/__init__.py +1 -0
  13. sdmxlib/formats/sdmx_json/reader.py +897 -0
  14. sdmxlib/formats/sdmx_json/writer.py +698 -0
  15. sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
  16. sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
  17. sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
  18. sdmxlib/formats/sdmx_ml21/writer.py +709 -0
  19. sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
  20. sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
  21. sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
  22. sdmxlib/formats/sdmx_ml30/writer.py +730 -0
  23. sdmxlib/local/__init__.py +6 -0
  24. sdmxlib/local/_registry.py +368 -0
  25. sdmxlib/model/__init__.py +122 -0
  26. sdmxlib/model/annotations.py +103 -0
  27. sdmxlib/model/base.py +61 -0
  28. sdmxlib/model/category.py +88 -0
  29. sdmxlib/model/codelist.py +91 -0
  30. sdmxlib/model/collections.py +382 -0
  31. sdmxlib/model/concept.py +121 -0
  32. sdmxlib/model/constraint.py +92 -0
  33. sdmxlib/model/convert.py +268 -0
  34. sdmxlib/model/dataflow.py +49 -0
  35. sdmxlib/model/dataset.py +337 -0
  36. sdmxlib/model/datastructure.py +390 -0
  37. sdmxlib/model/expr.py +120 -0
  38. sdmxlib/model/hierarchy.py +111 -0
  39. sdmxlib/model/istring.py +81 -0
  40. sdmxlib/model/mapping.py +134 -0
  41. sdmxlib/model/message.py +70 -0
  42. sdmxlib/model/organisation.py +149 -0
  43. sdmxlib/model/provision.py +45 -0
  44. sdmxlib/model/ref.py +195 -0
  45. sdmxlib/model/registry.py +191 -0
  46. sdmxlib/model/representation.py +99 -0
  47. sdmxlib/model/urn.py +130 -0
  48. sdmxlib/model/validation.py +338 -0
  49. sdmxlib/polars.py +392 -0
  50. sdmxlib/py.typed +0 -0
  51. sdmxlib/storage/__init__.py +28 -0
  52. sdmxlib/storage/lazy.py +329 -0
  53. sdmxlib/storage/readers.py +310 -0
  54. sdmxlib/storage/schema.py +289 -0
  55. sdmxlib/storage/writers.py +503 -0
  56. sdmxlib-0.8.0.dist-info/METADATA +102 -0
  57. sdmxlib-0.8.0.dist-info/RECORD +58 -0
  58. sdmxlib-0.8.0.dist-info/WHEEL +4 -0
sdmxlib/__init__.py ADDED
@@ -0,0 +1,155 @@
1
+ """sdmxlib — SDMX structural metadata library for Python."""
2
+
3
+ from sdmxlib.api.filters import DimFilter, dim
4
+ from sdmxlib.api.providers import Provider
5
+ from sdmxlib.api.query import DataQuery
6
+ from sdmxlib.api.registry import BearerToken, FmrRegistry, RestRegistry
7
+ from sdmxlib.formats import parse, read, serialize, to_json, to_xml
8
+ from sdmxlib.local import LazyCodelist, LocalRegistry
9
+ from sdmxlib.model.convert import from_dict, to_dict
10
+ from sdmxlib.model.validation import ReferenceIssue, ValidationIssue, validate, validate_references
11
+ from sdmxlib.model import (
12
+ Agency,
13
+ AgencyScheme,
14
+ Annotation,
15
+ Annotations,
16
+ ArtefactList,
17
+ AttachmentLevel,
18
+ Attribute,
19
+ Categorisation,
20
+ Category,
21
+ CategoryScheme,
22
+ Code,
23
+ Codelist,
24
+ Concept,
25
+ ConceptScheme,
26
+ CategoryMap,
27
+ CategorySchemeMap,
28
+ CodeMap,
29
+ CodelistMap,
30
+ ComponentMap,
31
+ ConceptMap,
32
+ ConceptSchemeMap,
33
+ ConstraintAttachment,
34
+ Contact,
35
+ ContentConstraint,
36
+ CubeRegion,
37
+ ReferencePeriod,
38
+ ReleaseCalendar,
39
+ DataProvider,
40
+ DataProviderScheme,
41
+ DataStructure,
42
+ Dataset,
43
+ DataStructureMap,
44
+ Dataflow,
45
+ DataflowMap,
46
+ Dimension,
47
+ Hierarchy,
48
+ HierarchicalCode,
49
+ Group,
50
+ InternationalString,
51
+ ItemExpr,
52
+ ItemList,
53
+ ItemPredicate,
54
+ KeyValue,
55
+ item,
56
+ Maintainable,
57
+ MaintainableArtefact,
58
+ Measure,
59
+ MeasureDimension,
60
+ ProvisionAgreement,
61
+ DataType,
62
+ Facet,
63
+ Ref,
64
+ Registry,
65
+ Representation,
66
+ SdmxRef,
67
+ SdmxUrn,
68
+ StructureMessage,
69
+ StructureSet,
70
+ TimeDimension,
71
+ UnresolvedRef,
72
+ )
73
+
74
+ __all__ = [
75
+ "Agency",
76
+ "AgencyScheme",
77
+ "Annotation",
78
+ "Annotations",
79
+ "ArtefactList",
80
+ "AttachmentLevel",
81
+ "Attribute",
82
+ "BearerToken",
83
+ "Categorisation",
84
+ "Category",
85
+ "CategoryMap",
86
+ "CategoryScheme",
87
+ "CategorySchemeMap",
88
+ "Code",
89
+ "CodeMap",
90
+ "Codelist",
91
+ "CodelistMap",
92
+ "ComponentMap",
93
+ "Concept",
94
+ "ConceptMap",
95
+ "ConceptScheme",
96
+ "ConceptSchemeMap",
97
+ "ConstraintAttachment",
98
+ "Contact",
99
+ "ContentConstraint",
100
+ "CubeRegion",
101
+ "DataProvider",
102
+ "DataProviderScheme",
103
+ "DataQuery",
104
+ "DataStructure",
105
+ "DataStructureMap",
106
+ "DataType",
107
+ "Dataflow",
108
+ "DataflowMap",
109
+ "Dataset",
110
+ "DimFilter",
111
+ "Dimension",
112
+ "Facet",
113
+ "FmrRegistry",
114
+ "Group",
115
+ "HierarchicalCode",
116
+ "Hierarchy",
117
+ "InternationalString",
118
+ "ItemExpr",
119
+ "ItemList",
120
+ "ItemPredicate",
121
+ "KeyValue",
122
+ "LazyCodelist",
123
+ "LocalRegistry",
124
+ "Maintainable",
125
+ "MaintainableArtefact",
126
+ "Measure",
127
+ "MeasureDimension",
128
+ "Provider",
129
+ "ProvisionAgreement",
130
+ "Ref",
131
+ "ReferenceIssue",
132
+ "ReferencePeriod",
133
+ "Registry",
134
+ "ReleaseCalendar",
135
+ "Representation",
136
+ "RestRegistry",
137
+ "SdmxRef",
138
+ "SdmxUrn",
139
+ "StructureMessage",
140
+ "StructureSet",
141
+ "TimeDimension",
142
+ "UnresolvedRef",
143
+ "ValidationIssue",
144
+ "dim",
145
+ "from_dict",
146
+ "item",
147
+ "parse",
148
+ "read",
149
+ "serialize",
150
+ "to_dict",
151
+ "to_json",
152
+ "to_xml",
153
+ "validate",
154
+ "validate_references",
155
+ ]
@@ -0,0 +1,7 @@
1
+ """sdmxlib.api — HTTP client and registry."""
2
+
3
+ from sdmxlib.api.providers import Provider
4
+ from sdmxlib.api.query import GetQuery, Query
5
+ from sdmxlib.api.registry import BearerToken, FmrRegistry, RestRegistry
6
+
7
+ __all__ = ["BearerToken", "FmrRegistry", "GetQuery", "Provider", "Query", "RestRegistry"]
sdmxlib/api/client.py ADDED
@@ -0,0 +1,246 @@
1
+ """SdmxClient — thin httpx wrapper around the SDMX REST API."""
2
+
3
+ from pathlib import Path
4
+ from typing import Self
5
+
6
+ import httpx
7
+
8
+ from sdmxlib.formats import parse
9
+ from sdmxlib.model.message import StructureMessage
10
+
11
+ # SDMX REST resource path segments (lowercase endpoint name)
12
+ _RESOURCE_PATH: dict[str, str] = {
13
+ "DataStructure": "datastructure",
14
+ "Codelist": "codelist",
15
+ "Dataflow": "dataflow",
16
+ "ConceptScheme": "conceptscheme",
17
+ "ProvisionAgreement": "provisionagreement",
18
+ "CategoryScheme": "categoryscheme",
19
+ "Categorisation": "categorisation",
20
+ "ContentConstraint": "contentconstraint",
21
+ "AgencyScheme": "agencyscheme",
22
+ "DataProviderScheme": "dataproviderorganisationscheme",
23
+ "Hierarchy": "hierarchy",
24
+ "StructureSet": "structureset",
25
+ }
26
+
27
+ # Wire parameter presets — (detail, references)
28
+ _BROWSE_PARAMS = {"detail": "allstubs", "references": "none"}
29
+ _FETCH_PARAMS = {"detail": "full", "references": "none"}
30
+ _RESOLVE_PARAMS = {"detail": "referencepartial", "references": "descendants"}
31
+ _RESOLVE_FULL = {"detail": "full", "references": "descendants"}
32
+
33
+
34
+ class SdmxClient:
35
+ """Thin httpx wrapper that fetches SDMX REST resources and parses them.
36
+
37
+ Handles URL construction, Accept headers, and response parsing.
38
+ Does not cache — caching is the Session's responsibility.
39
+
40
+ Args:
41
+ endpoint: Base URL of the SDMX REST endpoint.
42
+ api: SDMX REST API version — ``"2.1"`` (default) or ``"3.0"``.
43
+ Controls URL scheme and response format.
44
+ timeout: HTTP request timeout in seconds.
45
+ headers: Extra HTTP headers merged with defaults.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ endpoint: str,
51
+ *,
52
+ api: str = "2.1",
53
+ timeout: float = 30.0,
54
+ headers: dict[str, str] | None = None,
55
+ **httpx_kwargs: object,
56
+ ) -> None:
57
+ self._endpoint = endpoint.rstrip("/")
58
+ self._api = api
59
+ self._v3 = api.startswith(("3", "2.0"))
60
+ default_headers: dict[str, str]
61
+ if self._v3:
62
+ default_headers = {"Accept": "application/vnd.sdmx.structure+xml;version=3.0"}
63
+ else:
64
+ default_headers = {"Accept": "application/xml"}
65
+ if headers:
66
+ default_headers.update(headers)
67
+ self._client = httpx.Client(
68
+ headers=default_headers,
69
+ timeout=timeout,
70
+ **httpx_kwargs, # type: ignore[arg-type]
71
+ )
72
+
73
+ def close(self) -> None:
74
+ """Close the underlying HTTP client."""
75
+ self._client.close()
76
+
77
+ def __enter__(self) -> Self:
78
+ return self
79
+
80
+ def __exit__(self, *args: object) -> None:
81
+ self.close()
82
+
83
+ # ── URL building ──────────────────────────────────────────────────────────
84
+
85
+ def _resource_url(
86
+ self,
87
+ resource: str,
88
+ agency: str,
89
+ id: str, # noqa: A002
90
+ version: str,
91
+ ) -> str:
92
+ """Build the resource URL for the given API version."""
93
+ path = _RESOURCE_PATH.get(resource, resource.lower())
94
+ if self._v3:
95
+ # v2.x: /structure/{resource}/{agency}/{id}/{version}
96
+ # "latest" sentinel becomes "~" on the wire
97
+ ver = "~" if version == "latest" else version
98
+ return f"{self._endpoint}/structure/{path}/{agency}/{id}/{ver}"
99
+ # v1.5: /{resource}/{agency}/{id}/{version}
100
+ return f"{self._endpoint}/{path}/{agency}/{id}/{version}"
101
+
102
+ def _collection_url(self, resource: str, agency: str | None) -> str:
103
+ """Build the collection URL (all artefacts of a type)."""
104
+ path = _RESOURCE_PATH.get(resource, resource.lower())
105
+ agency_seg = agency or ("*" if self._v3 else "all")
106
+ if self._v3:
107
+ return f"{self._endpoint}/structure/{path}/{agency_seg}"
108
+ return f"{self._endpoint}/{path}/{agency_seg}"
109
+
110
+ # ── Parse version ─────────────────────────────────────────────────────────
111
+
112
+ @property
113
+ def _parse_version(self) -> str:
114
+ """Format version string passed to the parser."""
115
+ return "3.0" if self._v3 else "2.1"
116
+
117
+ # ── HTTP methods ──────────────────────────────────────────────────────────
118
+
119
+ def get_artefact(
120
+ self,
121
+ resource: str,
122
+ agency: str,
123
+ id: str, # noqa: A002
124
+ version: str = "latest",
125
+ *,
126
+ detail: str = "full",
127
+ references: str = "none",
128
+ ) -> StructureMessage:
129
+ """Fetch a single maintainable artefact."""
130
+ url = self._resource_url(resource, agency, id, version)
131
+ params: dict[str, str] = {"detail": detail}
132
+ if references != "none":
133
+ params["references"] = references
134
+ response = self._client.get(url, params=params)
135
+ response.raise_for_status()
136
+ return parse(response.content, version=self._parse_version)
137
+
138
+ def get_all(
139
+ self,
140
+ resource: str,
141
+ agency: str | None = None,
142
+ *,
143
+ detail: str = "full",
144
+ references: str = "none",
145
+ ) -> StructureMessage:
146
+ """Fetch all artefacts of a type, optionally filtered by agency."""
147
+ url = self._collection_url(resource, agency)
148
+ params: dict[str, str] = {"detail": detail}
149
+ if references != "none":
150
+ params["references"] = references
151
+ response = self._client.get(url, params=params)
152
+ response.raise_for_status()
153
+ return parse(response.content, version=self._parse_version)
154
+
155
+ def _data_url(
156
+ self,
157
+ agency: str,
158
+ id: str, # noqa: A002
159
+ version: str,
160
+ key: str,
161
+ ) -> str:
162
+ """Build the data URL for the given API version."""
163
+ if self._v3:
164
+ ver = "~" if version == "latest" else version
165
+ return f"{self._endpoint}/data/dataflow/{agency}/{id}/{ver}/{key}"
166
+ return f"{self._endpoint}/data/{agency},{id},{version}/{key}"
167
+
168
+ def get_data(
169
+ self,
170
+ agency: str,
171
+ id: str, # noqa: A002
172
+ version: str = "latest",
173
+ *,
174
+ key: str = "all",
175
+ start_period: str | None = None,
176
+ end_period: str | None = None,
177
+ ) -> bytes:
178
+ """Fetch observation data as SDMX-CSV bytes.
179
+
180
+ Args:
181
+ agency: Dataflow maintainer agency id.
182
+ id: Dataflow id.
183
+ version: Dataflow version. Defaults to ``"latest"``.
184
+ key: SDMX key filter string, e.g. ``"M.AT+DE.INDPRO"``.
185
+ Defaults to ``"all"`` (all observations).
186
+ start_period: ISO period start, e.g. ``"2020-01"``.
187
+ end_period: ISO period end.
188
+
189
+ Returns:
190
+ Raw SDMX-CSV bytes.
191
+ """
192
+ url = self._data_url(agency, id, version, key)
193
+ params: dict[str, str] = {}
194
+ if start_period is not None:
195
+ params["startPeriod"] = start_period
196
+ if end_period is not None:
197
+ params["endPeriod"] = end_period
198
+ response = self._client.get(
199
+ url,
200
+ params=params,
201
+ headers={"Accept": "text/csv"},
202
+ )
203
+ response.raise_for_status()
204
+ return response.content
205
+
206
+ def stream_data_to(
207
+ self,
208
+ agency: str,
209
+ id: str, # noqa: A002
210
+ version: str = "latest",
211
+ *,
212
+ path: Path,
213
+ key: str = "all",
214
+ start_period: str | None = None,
215
+ end_period: str | None = None,
216
+ ) -> None:
217
+ """Stream observation data directly to a file.
218
+
219
+ Uses httpx chunked streaming — the response is never fully loaded into
220
+ memory. Suitable for multi-GB datasets.
221
+
222
+ Args:
223
+ agency: Dataflow maintainer agency id.
224
+ id: Dataflow id.
225
+ version: Dataflow version.
226
+ path: Destination file path.
227
+ key: SDMX key filter string.
228
+ start_period: ISO period start.
229
+ end_period: ISO period end.
230
+ """
231
+ url = self._data_url(agency, id, version, key)
232
+ params: dict[str, str] = {}
233
+ if start_period is not None:
234
+ params["startPeriod"] = start_period
235
+ if end_period is not None:
236
+ params["endPeriod"] = end_period
237
+ with self._client.stream(
238
+ "GET",
239
+ url,
240
+ params=params,
241
+ headers={"Accept": "text/csv"},
242
+ ) as response:
243
+ response.raise_for_status()
244
+ with path.open("wb") as f:
245
+ for chunk in response.iter_bytes():
246
+ f.write(chunk)
sdmxlib/api/filters.py ADDED
@@ -0,0 +1,67 @@
1
+ """Server-side dimension filter expressions for DataQuery.slice()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from attrs import frozen
6
+
7
+
8
+ @frozen
9
+ class DimFilter:
10
+ """A resolved server-side filter on a single dimension.
11
+
12
+ Produced by :func:`dim` expressions — not constructed directly.
13
+
14
+ Attributes:
15
+ id: DSD dimension id.
16
+ codes: One or more codes to match (OR semantics, ``+``-joined on the wire).
17
+ """
18
+
19
+ id: str
20
+ codes: list[str]
21
+
22
+
23
+ class _DimExpr:
24
+ """Builder returned by :func:`dim`. Produces a :class:`DimFilter` via ``==`` or ``.isin()``."""
25
+
26
+ def __init__(self, id: str) -> None: # noqa: A002
27
+ self._id = id
28
+
29
+ def __eq__(self, other: object) -> DimFilter: # type: ignore[override]
30
+ if not isinstance(other, str):
31
+ msg = f"Expected a code string, got {type(other).__name__!r}"
32
+ raise TypeError(msg)
33
+ return DimFilter(id=self._id, codes=[other])
34
+
35
+ def isin(self, codes: list[str]) -> DimFilter:
36
+ """Match any of the given codes (server-side OR — ``+``-joined in the key).
37
+
38
+ Codes are sorted for stable, deterministic URLs.
39
+
40
+ Example:
41
+ sl.dim("REF_AREA").isin(["AT", "DE", "FR"])
42
+ """
43
+ return DimFilter(id=self._id, codes=sorted(codes))
44
+
45
+ def __hash__(self) -> int:
46
+ return hash(self._id)
47
+
48
+ def __repr__(self) -> str:
49
+ return f"dim({self._id!r})"
50
+
51
+
52
+ def dim(id: str) -> _DimExpr: # noqa: A002
53
+ """Create a server-side dimension filter expression for :meth:`DataQuery.slice`.
54
+
55
+ Use ``==`` for a single code or ``.isin()`` for multiple codes (OR semantics).
56
+ Dimensions not mentioned in any filter become wildcards in the SDMX REST key.
57
+
58
+ Example:
59
+ sl.dim("FREQ") == "M"
60
+ sl.dim("REF_AREA").isin(["AT", "DE", "FR"])
61
+
62
+ reg.data(flow).slice(
63
+ sl.dim("FREQ") == "M",
64
+ sl.dim("REF_AREA").isin(["AT", "DE"]),
65
+ ).fetch()
66
+ """
67
+ return _DimExpr(id)
@@ -0,0 +1,66 @@
1
+ """Known SDMX REST endpoint URLs and API versions.
2
+
3
+ Use as a shortcut when constructing a RestRegistry::
4
+
5
+ with sl.RestRegistry(sl.Provider.ECB) as reg:
6
+ ...
7
+ with sl.RestRegistry(sl.Provider["ECB"]) as reg:
8
+ ...
9
+ """
10
+
11
+ from attrs import frozen
12
+
13
+
14
+ @frozen
15
+ class ProviderInfo:
16
+ """URL and REST API version for a known SDMX endpoint."""
17
+
18
+ url: str
19
+ api: str = "2.1"
20
+
21
+
22
+ class Provider:
23
+ """Built-in SDMX REST endpoint URLs.
24
+
25
+ Each entry is a :class:`ProviderInfo` carrying the endpoint URL and its
26
+ REST API version. Pass directly to ``RestRegistry``::
27
+
28
+ with sl.RestRegistry(sl.Provider.ECB) as reg:
29
+ ...
30
+ with sl.RestRegistry(sl.Provider.BIS) as reg:
31
+ ...
32
+
33
+ Supports both attribute and subscript access::
34
+
35
+ sl.Provider.ECB # ProviderInfo(url="...", api="2.1")
36
+ sl.Provider["ECB"] # same
37
+ sl.Provider.ECB.url # "https://data-api.ecb.europa.eu/service"
38
+ sl.Provider.ECB.api # "2.1"
39
+ """
40
+
41
+ #: European Central Bank
42
+ ECB = ProviderInfo("https://data-api.ecb.europa.eu/service", api="2.1")
43
+ #: Bank for International Settlements
44
+ BIS = ProviderInfo("https://stats.bis.org/api/v1", api="2.1")
45
+ #: Eurostat
46
+ ESTAT = ProviderInfo("https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1", api="2.1")
47
+ #: International Monetary Fund
48
+ IMF = ProviderInfo("https://sdmxcentral.imf.org/ws/public/sdmxapi/rest", api="2.1")
49
+ #: OECD
50
+ OECD = ProviderInfo("https://sdmx.oecd.org/public/rest", api="2.1")
51
+ #: SDMX Global Registry (FMR — SDMX 3.0 endpoint)
52
+ SDMX_GLOBAL = ProviderInfo("https://registry.sdmx.org/sdmx/v2", api="3.0")
53
+
54
+ def __class_getitem__(cls, key: str) -> ProviderInfo:
55
+ val = getattr(cls, key, None)
56
+ if not isinstance(val, ProviderInfo):
57
+ available = ", ".join(
58
+ k for k in vars(cls) if not k.startswith("_") and isinstance(getattr(cls, k), ProviderInfo)
59
+ )
60
+ msg = f"{key!r} — available: {available}"
61
+ raise KeyError(msg)
62
+ return val
63
+
64
+ def __init_subclass__(cls, **kwargs: object) -> None:
65
+ msg = "Provider cannot be subclassed"
66
+ raise TypeError(msg)