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,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