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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Validate domain objects against SDMX structural rules.
|
|
2
|
+
|
|
3
|
+
Validation is explicit and non-blocking — objects can be constructed in any
|
|
4
|
+
state, and validation is called when ready. This allows iterative building,
|
|
5
|
+
format readers to accept non-compliant data, and tests to construct invalid
|
|
6
|
+
objects deliberately.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
import sdmxlib as sl
|
|
10
|
+
|
|
11
|
+
issues = sl.validate(dsd)
|
|
12
|
+
for issue in issues:
|
|
13
|
+
print(f" {issue.severity}: {issue.path} — {issue.message}")
|
|
14
|
+
|
|
15
|
+
ref_issues = sl.validate_references(msg)
|
|
16
|
+
for issue in ref_issues:
|
|
17
|
+
print(f" BROKEN: {issue.source} → {issue.target_urn}")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
import attrs
|
|
26
|
+
from attrs import frozen
|
|
27
|
+
|
|
28
|
+
from sdmxlib.model.base import MaintainableArtefact
|
|
29
|
+
from sdmxlib.model.codelist import Codelist
|
|
30
|
+
from sdmxlib.model.concept import ConceptScheme
|
|
31
|
+
from sdmxlib.model.datastructure import DataStructure
|
|
32
|
+
from sdmxlib.model.hierarchy import Hierarchy
|
|
33
|
+
from sdmxlib.model.ref import AnyRef, Ref
|
|
34
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from sdmxlib.model.message import StructureMessage
|
|
38
|
+
|
|
39
|
+
# SDMX 2.1 / 3.0 formal type patterns from SDMXCommonReferences.xsd
|
|
40
|
+
# IDType: [A-Za-z0-9_@$-]+
|
|
41
|
+
# NestedIDType: [A-Za-z0-9_@$-]+(\.[A-Za-z0-9_@$-]+)* (dots for agency hierarchy)
|
|
42
|
+
# VersionType: [0-9]+(\.[0-9]+)*
|
|
43
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9_@$-]+$")
|
|
44
|
+
_AGENCY_RE = re.compile(r"^[A-Za-z0-9_@$-]+(\.[A-Za-z0-9_@$-]+)*$")
|
|
45
|
+
_VERSION_RE = re.compile(r"^\d+(\.\d+)*$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@frozen
|
|
49
|
+
class ValidationIssue:
|
|
50
|
+
"""A single validation problem found on an artefact.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
path: Dotted path to the offending field (e.g. ``"dimensions[FREQ].id"``).
|
|
54
|
+
message: Human-readable description of the problem.
|
|
55
|
+
severity: ``"error"`` or ``"warning"``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
path: str
|
|
59
|
+
message: str
|
|
60
|
+
severity: str = "error"
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
return f"{self.severity}: {self.path} — {self.message}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@frozen
|
|
67
|
+
class ReferenceIssue:
|
|
68
|
+
"""A broken cross-artefact reference within a StructureMessage.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
source: Path of the artefact/field that holds the Ref.
|
|
72
|
+
target_urn: The URN the Ref points to.
|
|
73
|
+
message: Human-readable description.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
source: str
|
|
77
|
+
target_urn: str
|
|
78
|
+
message: str = "referenced artefact not found in message"
|
|
79
|
+
|
|
80
|
+
def __str__(self) -> str:
|
|
81
|
+
return f"BROKEN: {self.source} → {self.target_urn}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Per-artefact validation ────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _ValidationError(Exception):
|
|
88
|
+
"""Internal: raised in strict mode on the first issue."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, issue: ValidationIssue) -> None:
|
|
91
|
+
self.issue = issue
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _issue(
|
|
95
|
+
issues: list[ValidationIssue],
|
|
96
|
+
path: str,
|
|
97
|
+
message: str,
|
|
98
|
+
severity: str = "error",
|
|
99
|
+
*,
|
|
100
|
+
strict: bool,
|
|
101
|
+
) -> None:
|
|
102
|
+
issue = ValidationIssue(path=path, message=message, severity=severity)
|
|
103
|
+
issues.append(issue)
|
|
104
|
+
if strict:
|
|
105
|
+
raise _ValidationError(issue)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_id(val: str, path: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
109
|
+
if not val:
|
|
110
|
+
_issue(issues, path, "id must not be empty", strict=strict)
|
|
111
|
+
elif not _ID_RE.match(val):
|
|
112
|
+
_issue(issues, path, f"id {val!r} contains invalid characters (allowed: A-Za-z0-9_@$-)", strict=strict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_agency_id(val: str, path: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
116
|
+
if not val:
|
|
117
|
+
_issue(issues, path, "agency_id must not be empty", strict=strict)
|
|
118
|
+
elif not _AGENCY_RE.match(val):
|
|
119
|
+
_issue(
|
|
120
|
+
issues,
|
|
121
|
+
path,
|
|
122
|
+
f"agency_id {val!r} is not valid (allowed: A-Za-z0-9_@$- segments separated by dots)",
|
|
123
|
+
strict=strict,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_version(val: str, path: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
128
|
+
if not _VERSION_RE.match(val):
|
|
129
|
+
_issue(issues, path, f"version {val!r} is not valid (expected digits separated by dots)", strict=strict)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_ref_urn(ref: AnyRef, path: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
133
|
+
try:
|
|
134
|
+
_ = str(ref.urn)
|
|
135
|
+
except (ValueError, TypeError, AttributeError):
|
|
136
|
+
_issue(issues, path, "Ref has an invalid or missing URN", strict=strict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_maintainable(
|
|
140
|
+
artefact: MaintainableArtefact,
|
|
141
|
+
prefix: str,
|
|
142
|
+
issues: list[ValidationIssue],
|
|
143
|
+
*,
|
|
144
|
+
strict: bool,
|
|
145
|
+
) -> None:
|
|
146
|
+
_check_id(artefact.id, f"{prefix}.id", issues, strict=strict) # type: ignore[union-attr]
|
|
147
|
+
_check_agency_id(artefact.agency_id, f"{prefix}.agency_id", issues, strict=strict) # type: ignore[union-attr]
|
|
148
|
+
_check_version(artefact.version, f"{prefix}.version", issues, strict=strict) # type: ignore[union-attr]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_codelist(cl: Codelist, prefix: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
152
|
+
_validate_maintainable(cl, prefix, issues, strict=strict)
|
|
153
|
+
seen: set[str] = set()
|
|
154
|
+
for code in cl.codes:
|
|
155
|
+
_check_id(code.id, f"{prefix}.codes[{code.id}].id", issues, strict=strict)
|
|
156
|
+
if code.id in seen:
|
|
157
|
+
_issue(issues, f"{prefix}.codes[{code.id}]", f"duplicate code id {code.id!r}", strict=strict)
|
|
158
|
+
seen.add(code.id)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_dsd(dsd: DataStructure, prefix: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
162
|
+
_validate_maintainable(dsd, prefix, issues, strict=strict)
|
|
163
|
+
|
|
164
|
+
for dim in dsd.dimensions:
|
|
165
|
+
_check_id(dim.id, f"{prefix}.dimensions[{dim.id}].id", issues, strict=strict)
|
|
166
|
+
if isinstance(dim.representation, Ref):
|
|
167
|
+
_check_ref_urn(dim.representation, f"{prefix}.dimensions[{dim.id}].representation", issues, strict=strict)
|
|
168
|
+
if isinstance(dim.concept, Ref):
|
|
169
|
+
_check_ref_urn(dim.concept, f"{prefix}.dimensions[{dim.id}].concept", issues, strict=strict)
|
|
170
|
+
|
|
171
|
+
for attr in dsd.attributes:
|
|
172
|
+
_check_id(attr.id, f"{prefix}.attributes[{attr.id}].id", issues, strict=strict)
|
|
173
|
+
if isinstance(attr.representation, Ref):
|
|
174
|
+
_check_ref_urn(attr.representation, f"{prefix}.attributes[{attr.id}].representation", issues, strict=strict)
|
|
175
|
+
|
|
176
|
+
for meas in dsd.measures:
|
|
177
|
+
_check_id(meas.id, f"{prefix}.measures[{meas.id}].id", issues, strict=strict)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validate_hierarchy(hierarchy: Hierarchy, prefix: str, issues: list[ValidationIssue], *, strict: bool) -> None:
|
|
181
|
+
_validate_maintainable(hierarchy, prefix, issues, strict=strict)
|
|
182
|
+
seen: set[str] = set()
|
|
183
|
+
for node in hierarchy.codes:
|
|
184
|
+
_check_id(node.id, f"{prefix}.codes[{node.id}].id", issues, strict=strict)
|
|
185
|
+
if node.id in seen:
|
|
186
|
+
_issue(issues, f"{prefix}.codes[{node.id}]", f"duplicate node id {node.id!r}", strict=strict)
|
|
187
|
+
seen.add(node.id)
|
|
188
|
+
if isinstance(node.code, Ref):
|
|
189
|
+
_check_ref_urn(node.code, f"{prefix}.codes[{node.id}].code", issues, strict=strict)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate(artefact: Any, *, strict: bool = False) -> list[ValidationIssue]:
|
|
193
|
+
"""Validate an artefact against SDMX structural rules.
|
|
194
|
+
|
|
195
|
+
Checks:
|
|
196
|
+
- ``id`` format (``[A-Za-z0-9_-]+``)
|
|
197
|
+
- ``agency_id`` format (``[A-Za-z0-9._-]+``)
|
|
198
|
+
- ``version`` format (digits separated by dots)
|
|
199
|
+
- Required fields present (id, agency_id for maintainables)
|
|
200
|
+
- Code id uniqueness within codelists
|
|
201
|
+
- Dimension position continuity (1, 2, 3, ...)
|
|
202
|
+
- Ref URN format validity
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
artefact: Any domain model object.
|
|
206
|
+
strict: If ``True``, raises ``ValueError`` on the first issue found
|
|
207
|
+
instead of collecting all issues.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of ``ValidationIssue``. Empty list means the artefact is valid.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If ``strict=True`` and an issue is found.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
issues = validate(dsd)
|
|
217
|
+
for issue in issues:
|
|
218
|
+
print(f"{issue.path}: {issue.message}")
|
|
219
|
+
"""
|
|
220
|
+
issues: list[ValidationIssue] = []
|
|
221
|
+
cls_name = type(artefact).__name__
|
|
222
|
+
try:
|
|
223
|
+
if isinstance(artefact, DataStructure):
|
|
224
|
+
_validate_dsd(artefact, cls_name, issues, strict=strict)
|
|
225
|
+
elif isinstance(artefact, Codelist):
|
|
226
|
+
_validate_codelist(artefact, cls_name, issues, strict=strict)
|
|
227
|
+
elif isinstance(artefact, Hierarchy):
|
|
228
|
+
_validate_hierarchy(artefact, cls_name, issues, strict=strict)
|
|
229
|
+
elif isinstance(artefact, MaintainableArtefact):
|
|
230
|
+
_validate_maintainable(artefact, cls_name, issues, strict=strict)
|
|
231
|
+
except _ValidationError as exc:
|
|
232
|
+
if strict:
|
|
233
|
+
raise ValueError(str(exc.issue)) from exc
|
|
234
|
+
return issues
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── Cross-artefact reference validation ──────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _collect_known_urns(msg: StructureMessage) -> set[str]:
|
|
241
|
+
"""Build the set of all artefact URN strings present in the message.
|
|
242
|
+
|
|
243
|
+
Includes both scheme-level URNs and item-level URNs for concept schemes
|
|
244
|
+
(so that concept refs used in DSD components resolve correctly).
|
|
245
|
+
"""
|
|
246
|
+
known: set[str] = set()
|
|
247
|
+
for artefact in msg.all_artefacts():
|
|
248
|
+
if isinstance(artefact, MaintainableArtefact):
|
|
249
|
+
try:
|
|
250
|
+
urn: SdmxUrn[Any] = SdmxUrn.make(
|
|
251
|
+
type(artefact),
|
|
252
|
+
package=artefact.sdmx_package, # type: ignore[union-attr]
|
|
253
|
+
artefact_class=artefact.sdmx_class, # type: ignore[union-attr]
|
|
254
|
+
agency=artefact.agency_id, # type: ignore[union-attr]
|
|
255
|
+
id=artefact.id, # type: ignore[union-attr]
|
|
256
|
+
version=artefact.version, # type: ignore[union-attr]
|
|
257
|
+
)
|
|
258
|
+
known.add(str(urn))
|
|
259
|
+
except (ValueError, TypeError, AttributeError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# Also add item-level URNs for concept schemes so DSD concept
|
|
263
|
+
# refs (which carry item_id) can be resolved.
|
|
264
|
+
if isinstance(artefact, ConceptScheme):
|
|
265
|
+
for concept in artefact.concepts:
|
|
266
|
+
try:
|
|
267
|
+
item_urn: SdmxUrn[Any] = SdmxUrn.make(
|
|
268
|
+
type(concept),
|
|
269
|
+
package="conceptscheme",
|
|
270
|
+
artefact_class="Concept",
|
|
271
|
+
agency=artefact.agency_id,
|
|
272
|
+
id=artefact.id,
|
|
273
|
+
version=artefact.version,
|
|
274
|
+
item_id=concept.id,
|
|
275
|
+
)
|
|
276
|
+
known.add(str(item_urn))
|
|
277
|
+
except (ValueError, TypeError, AttributeError):
|
|
278
|
+
pass
|
|
279
|
+
return known
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _collect_refs(artefact: Any, prefix: str) -> list[tuple[str, AnyRef]]:
|
|
283
|
+
"""Collect (path, ref) pairs for all Ref fields in an artefact.
|
|
284
|
+
|
|
285
|
+
Uses dataclass field introspection so new Ref fields are automatically
|
|
286
|
+
picked up without maintaining a hardcoded list.
|
|
287
|
+
"""
|
|
288
|
+
results: list[tuple[str, AnyRef]] = []
|
|
289
|
+
if attrs.has(type(artefact)) and not isinstance(artefact, type):
|
|
290
|
+
for field in attrs.fields(type(artefact)):
|
|
291
|
+
val = getattr(artefact, field.name, None)
|
|
292
|
+
if isinstance(val, Ref):
|
|
293
|
+
results.append((f"{prefix}.{field.name}", val))
|
|
294
|
+
|
|
295
|
+
# Recurse into components for DataStructure
|
|
296
|
+
if isinstance(artefact, DataStructure):
|
|
297
|
+
for dim in artefact.dimensions:
|
|
298
|
+
results.extend(_collect_refs(dim, f"{prefix}.dimensions[{dim.id}]"))
|
|
299
|
+
for attr in artefact.attributes:
|
|
300
|
+
results.extend(_collect_refs(attr, f"{prefix}.attributes[{attr.id}]"))
|
|
301
|
+
for meas in artefact.measures:
|
|
302
|
+
results.extend(_collect_refs(meas, f"{prefix}.measures[{meas.id}]"))
|
|
303
|
+
if isinstance(artefact, Hierarchy):
|
|
304
|
+
for node in artefact.codes:
|
|
305
|
+
results.extend(_collect_refs(node, f"{prefix}.codes[{node.id}]"))
|
|
306
|
+
return results
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def validate_references(msg: StructureMessage) -> list[ReferenceIssue]:
|
|
310
|
+
"""Check that all Refs within a message resolve to artefacts in that message.
|
|
311
|
+
|
|
312
|
+
For each unresolved Ref whose URN does not correspond to any artefact in
|
|
313
|
+
the message, a ``ReferenceIssue`` is reported.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
msg: The ``StructureMessage`` to check.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
List of ``ReferenceIssue``. Empty list means all references are satisfied.
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
issues = validate_references(msg)
|
|
323
|
+
for issue in issues:
|
|
324
|
+
print(f" BROKEN: {issue.source} → {issue.target_urn}")
|
|
325
|
+
"""
|
|
326
|
+
known = _collect_known_urns(msg)
|
|
327
|
+
issues: list[ReferenceIssue] = []
|
|
328
|
+
|
|
329
|
+
for artefact in msg.all_artefacts():
|
|
330
|
+
cls_name = type(artefact).__name__
|
|
331
|
+
artefact_id = getattr(artefact, "id", "?")
|
|
332
|
+
prefix = f"{cls_name}:{artefact_id}"
|
|
333
|
+
for path, ref in _collect_refs(artefact, prefix):
|
|
334
|
+
urn_str = str(ref.urn)
|
|
335
|
+
if urn_str not in known:
|
|
336
|
+
issues.append(ReferenceIssue(source=path, target_urn=urn_str))
|
|
337
|
+
|
|
338
|
+
return issues
|