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,897 @@
1
+ """SDMX-JSON 2.0 reader: JSON → domain model.
2
+
3
+ Parses SDMX-JSON 2.0 Structure messages into the sdmxlib domain model.
4
+ All references within the message are auto-resolved where possible.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from sdmxlib.model.annotations import Annotation, Annotations
12
+ from sdmxlib.model.category import Categorisation, Category, CategoryScheme
13
+ from sdmxlib.model.codelist import Code, Codelist
14
+ from sdmxlib.model.collections import ArtefactList, ItemList
15
+ from sdmxlib.model.concept import Concept, ConceptScheme
16
+ from sdmxlib.model.constraint import (
17
+ ConstraintAttachment,
18
+ ContentConstraint,
19
+ CubeRegion,
20
+ KeyValue,
21
+ ReferencePeriod,
22
+ ReleaseCalendar,
23
+ )
24
+ from sdmxlib.model.dataflow import Dataflow
25
+ from sdmxlib.model.datastructure import (
26
+ AttachmentLevel,
27
+ Attribute,
28
+ DataStructure,
29
+ Dimension,
30
+ Group,
31
+ Measure,
32
+ TimeDimension,
33
+ )
34
+ from sdmxlib.model.hierarchy import HierarchicalCode, Hierarchy
35
+ from sdmxlib.model.istring import InternationalString
36
+ from sdmxlib.model.mapping import (
37
+ CategoryMap,
38
+ CategorySchemeMap,
39
+ CodelistMap,
40
+ CodeMap,
41
+ ComponentMap,
42
+ ConceptMap,
43
+ ConceptSchemeMap,
44
+ DataflowMap,
45
+ DataStructureMap,
46
+ StructureSet,
47
+ )
48
+ from sdmxlib.model.message import StructureMessage
49
+ from sdmxlib.model.organisation import Agency, AgencyScheme, Contact, DataProvider, DataProviderScheme
50
+ from sdmxlib.model.provision import ProvisionAgreement
51
+ from sdmxlib.model.ref import AnyRef, Ref
52
+ from sdmxlib.model.registry import Registry
53
+ from sdmxlib.model.representation import Facet
54
+ from sdmxlib.model.urn import SdmxUrn
55
+
56
+ # ── Common helpers ────────────────────────────────────────────────────────────
57
+
58
+
59
+ def _istring(obj: dict[str, Any], key: str) -> InternationalString:
60
+ """Extract a multilingual string field from a JSON object.
61
+
62
+ Tries ``key`` first, then ``key + "s"`` (e.g. ``"name"`` → ``"names"``),
63
+ because the SDMX Global Registry sends ``"names": {"en": "..."}`` while
64
+ locally-authored fixtures may use ``"name": {"en": "..."}``.
65
+ """
66
+ val = obj.get(key)
67
+ if not isinstance(val, dict) or not val:
68
+ val = obj.get(key + "s")
69
+ if not isinstance(val, dict) or not val:
70
+ return InternationalString()
71
+ return InternationalString.of({k: v for k, v in val.items() if isinstance(v, str)})
72
+
73
+
74
+ def _read_datetime_str(obj: dict[str, Any], key: str) -> datetime | None:
75
+ """Parse an optional datetime string field."""
76
+ val = obj.get(key)
77
+ if not isinstance(val, str) or not val:
78
+ return None
79
+ return datetime.fromisoformat(val)
80
+
81
+
82
+ def _read_annotations(obj: dict[str, Any]) -> Annotations:
83
+ """Parse the optional ``annotations`` array on any artefact."""
84
+ raw = obj.get("annotations")
85
+ if not raw:
86
+ return Annotations()
87
+ items: list[Annotation] = []
88
+ for ann in raw:
89
+ text_raw = ann.get("text")
90
+ text = InternationalString.of(text_raw) if isinstance(text_raw, dict) else InternationalString()
91
+ items.append(
92
+ Annotation(
93
+ id=ann.get("id"),
94
+ title=ann.get("title"),
95
+ type=ann.get("type"),
96
+ url=ann.get("url"),
97
+ text=text,
98
+ )
99
+ )
100
+ return Annotations(items)
101
+
102
+
103
+ # ── Ref / URN helpers ─────────────────────────────────────────────────────────
104
+
105
+
106
+ def _resolve_urn(urn_val: Any, index: dict[str, Any]) -> AnyRef | None:
107
+ """Parse a URN string and resolve it against the artefact index."""
108
+ if not isinstance(urn_val, str) or not urn_val.startswith("urn:"):
109
+ return None
110
+ try:
111
+ urn = SdmxUrn.parse(urn_val)
112
+ except (ValueError, TypeError):
113
+ return None
114
+ obj = index.get(str(urn))
115
+ if obj is not None:
116
+ return Ref.of(urn, obj)
117
+ return Ref.unresolved(urn)
118
+
119
+
120
+ # ── Index helpers ─────────────────────────────────────────────────────────────
121
+
122
+
123
+ def _index_artefact(index: dict[str, Any], obj: Any, artefact_type: type[Any]) -> None:
124
+ """Add a maintainable artefact to the resolution index."""
125
+ package = artefact_type.sdmx_package
126
+ cls = artefact_type.sdmx_class
127
+ urn = SdmxUrn.make(
128
+ artefact_type,
129
+ package=package,
130
+ artefact_class=cls,
131
+ agency=obj.agency_id,
132
+ id=obj.id,
133
+ version=obj.version,
134
+ )
135
+ index[str(urn)] = obj
136
+ urn_latest = SdmxUrn.make(artefact_type, package=package, artefact_class=cls, agency=obj.agency_id, id=obj.id)
137
+ index[str(urn_latest)] = obj
138
+
139
+
140
+ def _index_item(index: dict[str, Any], item: Any, parent: Any, package: str, cls: str) -> None:
141
+ """Add an item (e.g. Concept) to the index under its item URN."""
142
+ urn = SdmxUrn.make(
143
+ object,
144
+ package=package,
145
+ artefact_class=cls,
146
+ agency=parent.agency_id,
147
+ id=parent.id,
148
+ version=parent.version,
149
+ item_id=item.id,
150
+ )
151
+ index[str(urn)] = item
152
+ urn_latest = SdmxUrn.make(
153
+ object,
154
+ package=package,
155
+ artefact_class=cls,
156
+ agency=parent.agency_id,
157
+ id=parent.id,
158
+ item_id=item.id,
159
+ )
160
+ index[str(urn_latest)] = item
161
+
162
+
163
+ # ── Representation ────────────────────────────────────────────────────────────
164
+
165
+
166
+ def _read_representation(rep: dict[str, Any] | None, index: dict[str, Any]) -> AnyRef | Facet | None:
167
+ """Parse a ``localRepresentation`` object → Ref[Codelist] | Facet | None."""
168
+ if not rep:
169
+ return None
170
+
171
+ enum_val = rep.get("enumeration")
172
+ if enum_val is not None:
173
+ return _resolve_urn(enum_val, index)
174
+
175
+ tf_raw = rep.get("textFormat")
176
+ if isinstance(tf_raw, dict):
177
+ return Facet(
178
+ type=tf_raw.get("textType"),
179
+ max_length=tf_raw.get("maxLength"),
180
+ min_length=tf_raw.get("minLength"),
181
+ max_value=tf_raw.get("maxValue"),
182
+ min_value=tf_raw.get("minValue"),
183
+ decimals=tf_raw.get("decimals"),
184
+ pattern=tf_raw.get("pattern"),
185
+ start_value=tf_raw.get("startValue"),
186
+ end_value=tf_raw.get("endValue"),
187
+ is_sequence=tf_raw.get("isSequence"),
188
+ is_multi_lingual=tf_raw.get("isMultiLingual"),
189
+ )
190
+
191
+ return None
192
+
193
+
194
+ def _resolve_agency(agency_id: str, agencies: dict[str, Agency]) -> Agency:
195
+ """Return the rich Agency if parsed from an AgencyScheme, else a minimal one."""
196
+ return agencies.get(agency_id, Agency(id=agency_id))
197
+
198
+
199
+ # ── OrganisationSchemes ───────────────────────────────────────────────────────
200
+
201
+
202
+ def _read_contact(obj: dict[str, Any]) -> Contact:
203
+ return Contact(
204
+ name=_istring(obj, "name"),
205
+ department=_istring(obj, "department"),
206
+ role=_istring(obj, "role"),
207
+ phones=tuple(obj.get("phones") or []),
208
+ faxes=tuple(obj.get("faxes") or []),
209
+ uris=tuple(obj.get("uris") or []),
210
+ emails=tuple(obj.get("emails") or []),
211
+ )
212
+
213
+
214
+ def _read_organisation_schemes(
215
+ data: dict[str, Any],
216
+ ) -> tuple[list[AgencyScheme], list[DataProviderScheme]]:
217
+ agency_schemes: list[AgencyScheme] = []
218
+ provider_schemes: list[DataProviderScheme] = []
219
+
220
+ for as_obj in data.get("agencySchemes") or []:
221
+ agencies = [
222
+ Agency(
223
+ id=item["id"],
224
+ name=_istring(item, "name"),
225
+ description=_istring(item, "description"),
226
+ contacts=tuple(_read_contact(c) for c in (item.get("contacts") or [])),
227
+ annotations=_read_annotations(item),
228
+ )
229
+ for item in (as_obj.get("items") or [])
230
+ ]
231
+ agency_schemes.append(
232
+ AgencyScheme(
233
+ id=as_obj["id"],
234
+ maintainer=as_obj.get("agencyID", ""),
235
+ version=as_obj.get("version", "1.0"),
236
+ name=_istring(as_obj, "name"),
237
+ description=_istring(as_obj, "description"),
238
+ is_final=bool(as_obj.get("isFinal", False)),
239
+ agencies=ItemList(agencies),
240
+ annotations=_read_annotations(as_obj),
241
+ valid_from=_read_datetime_str(as_obj, "validFrom"),
242
+ valid_to=_read_datetime_str(as_obj, "validTo"),
243
+ )
244
+ )
245
+
246
+ for ps_obj in data.get("dataProviderSchemes") or []:
247
+ providers = [
248
+ DataProvider(
249
+ id=item["id"],
250
+ name=_istring(item, "name"),
251
+ description=_istring(item, "description"),
252
+ contacts=tuple(_read_contact(c) for c in (item.get("contacts") or [])),
253
+ annotations=_read_annotations(item),
254
+ )
255
+ for item in (ps_obj.get("items") or [])
256
+ ]
257
+ provider_schemes.append(
258
+ DataProviderScheme(
259
+ id=ps_obj["id"],
260
+ maintainer=ps_obj.get("agencyID", ""),
261
+ version=ps_obj.get("version", "1.0"),
262
+ name=_istring(ps_obj, "name"),
263
+ description=_istring(ps_obj, "description"),
264
+ is_final=bool(ps_obj.get("isFinal", False)),
265
+ providers=ItemList(providers),
266
+ annotations=_read_annotations(ps_obj),
267
+ valid_from=_read_datetime_str(ps_obj, "validFrom"),
268
+ valid_to=_read_datetime_str(ps_obj, "validTo"),
269
+ )
270
+ )
271
+
272
+ return agency_schemes, provider_schemes
273
+
274
+
275
+ # ── Codelists ─────────────────────────────────────────────────────────────────
276
+
277
+
278
+ def _read_codelists(data: dict[str, Any], agencies: dict[str, Agency]) -> list[Codelist]:
279
+ result: list[Codelist] = []
280
+ for cl in data.get("codelists") or []:
281
+ codes = [
282
+ Code(
283
+ id=item["id"],
284
+ name=_istring(item, "name"),
285
+ description=_istring(item, "description"),
286
+ parent_id=item.get("parent"),
287
+ annotations=_read_annotations(item),
288
+ )
289
+ for item in (cl.get("codes") or cl.get("items") or [])
290
+ ]
291
+ result.append(
292
+ Codelist(
293
+ id=cl["id"],
294
+ maintainer=_resolve_agency(cl.get("agencyID", ""), agencies),
295
+ version=cl.get("version", "1.0"),
296
+ name=_istring(cl, "name"),
297
+ description=_istring(cl, "description"),
298
+ is_final=bool(cl.get("isFinal", False)),
299
+ codes=ItemList(codes),
300
+ annotations=_read_annotations(cl),
301
+ valid_from=_read_datetime_str(cl, "validFrom"),
302
+ valid_to=_read_datetime_str(cl, "validTo"),
303
+ )
304
+ )
305
+ return result
306
+
307
+
308
+ # ── ConceptSchemes ────────────────────────────────────────────────────────────
309
+
310
+
311
+ def _read_concept_schemes(data: dict[str, Any], agencies: dict[str, Agency]) -> list[ConceptScheme]:
312
+ result: list[ConceptScheme] = []
313
+ for cs in data.get("conceptSchemes") or []:
314
+ concepts = [
315
+ Concept(
316
+ id=item["id"],
317
+ name=_istring(item, "name"),
318
+ description=_istring(item, "description"),
319
+ annotations=_read_annotations(item),
320
+ )
321
+ for item in cs.get("items") or []
322
+ ]
323
+ result.append(
324
+ ConceptScheme(
325
+ id=cs["id"],
326
+ maintainer=_resolve_agency(cs.get("agencyID", ""), agencies),
327
+ version=cs.get("version", "1.0"),
328
+ name=_istring(cs, "name"),
329
+ description=_istring(cs, "description"),
330
+ concepts=ItemList(concepts),
331
+ annotations=_read_annotations(cs),
332
+ valid_from=_read_datetime_str(cs, "validFrom"),
333
+ valid_to=_read_datetime_str(cs, "validTo"),
334
+ )
335
+ )
336
+ return result
337
+
338
+
339
+ # ── CategorySchemes ───────────────────────────────────────────────────────────
340
+
341
+
342
+ def _read_categories(items: list[dict[str, Any]]) -> list[Category]:
343
+ """Recursively build Category objects from a JSON items array."""
344
+ return [
345
+ Category(
346
+ id=item["id"],
347
+ name=_istring(item, "name"),
348
+ description=_istring(item, "description"),
349
+ categories=ItemList(_read_categories(item.get("items") or [])),
350
+ annotations=_read_annotations(item),
351
+ )
352
+ for item in items
353
+ ]
354
+
355
+
356
+ def _read_category_schemes(data: dict[str, Any], agencies: dict[str, Agency]) -> list[CategoryScheme]:
357
+ return [
358
+ CategoryScheme(
359
+ id=cs["id"],
360
+ maintainer=_resolve_agency(cs.get("agencyID", ""), agencies),
361
+ version=cs.get("version", "1.0"),
362
+ name=_istring(cs, "name"),
363
+ description=_istring(cs, "description"),
364
+ categories=ItemList(_read_categories(cs.get("items") or [])),
365
+ annotations=_read_annotations(cs),
366
+ valid_from=_read_datetime_str(cs, "validFrom"),
367
+ valid_to=_read_datetime_str(cs, "validTo"),
368
+ )
369
+ for cs in data.get("categorySchemes") or []
370
+ ]
371
+
372
+
373
+ # ── DataStructures ────────────────────────────────────────────────────────────
374
+
375
+
376
+ def _read_attachment(ar: dict[str, Any]) -> tuple[AttachmentLevel | None, list[str] | None, str | None]:
377
+ """Parse ``attributeRelationship`` → (level, related_dimensions, related_measure)."""
378
+ if "observation" in ar or "primaryMeasure" in ar:
379
+ return AttachmentLevel.OBSERVATION, None, "OBS_VALUE"
380
+ if "dataSet" in ar or "none" in ar:
381
+ return AttachmentLevel.DATASET, None, None
382
+ dims = ar.get("dimensions")
383
+ if dims:
384
+ related = [d if isinstance(d, str) else (d.get("componentId") or d.get("id", "")) for d in dims]
385
+ return AttachmentLevel.SERIES, related or None, None
386
+ if "group" in ar:
387
+ return AttachmentLevel.GROUP, None, None
388
+ return AttachmentLevel.DATASET, None, None
389
+
390
+
391
+ def _read_dimension(dim: dict[str, Any], index: dict[str, Any]) -> Dimension:
392
+ concept_ref = _resolve_urn(dim.get("conceptIdentity"), index)
393
+ return Dimension(
394
+ id=dim["id"],
395
+ concept=concept_ref,
396
+ representation=_read_representation(dim.get("localRepresentation"), index),
397
+ annotations=_read_annotations(dim),
398
+ )
399
+
400
+
401
+ def _read_time_dimension(td: dict[str, Any], index: dict[str, Any]) -> TimeDimension:
402
+ rep = _read_representation(td.get("localRepresentation"), index)
403
+ concept_ref = _resolve_urn(td.get("conceptIdentity"), index)
404
+ return TimeDimension(
405
+ id=td.get("id", "TIME_PERIOD"),
406
+ concept=concept_ref,
407
+ representation=rep if isinstance(rep, Facet) else None,
408
+ annotations=_read_annotations(td),
409
+ )
410
+
411
+
412
+ def _read_attribute(attr: dict[str, Any], index: dict[str, Any]) -> Attribute:
413
+ concept_ref = _resolve_urn(attr.get("conceptIdentity"), index)
414
+ attachment: AttachmentLevel | None = None
415
+ related_dims: list[str] | None = None
416
+ related_measure: str | None = None
417
+ ar = attr.get("attributeRelationship")
418
+ if isinstance(ar, dict):
419
+ attachment, related_dims, related_measure = _read_attachment(ar)
420
+ return Attribute(
421
+ id=attr["id"],
422
+ usage=attr.get("usage") or attr.get("assignmentStatus"),
423
+ attachment=attachment,
424
+ related_dimensions=related_dims,
425
+ related_measure=related_measure,
426
+ concept=concept_ref,
427
+ representation=_read_representation(attr.get("localRepresentation"), index),
428
+ annotations=_read_annotations(attr),
429
+ )
430
+
431
+
432
+ def _read_measure(meas: dict[str, Any], index: dict[str, Any]) -> Measure:
433
+ concept_ref = _resolve_urn(meas.get("conceptIdentity"), index)
434
+ return Measure(
435
+ id=meas.get("id", "OBS_VALUE"),
436
+ concept=concept_ref,
437
+ representation=_read_representation(meas.get("localRepresentation"), index),
438
+ annotations=_read_annotations(meas),
439
+ )
440
+
441
+
442
+ def _read_groups(components: dict[str, Any]) -> list[Group]:
443
+ result: list[Group] = []
444
+ for grp in components.get("groups") or []:
445
+ dim_ids = [gd.get("componentId") or gd.get("id", "") for gd in grp.get("groupDimensions") or []]
446
+ result.append(Group(id=grp["id"], dimension_ids=dim_ids))
447
+ return result
448
+
449
+
450
+ def _read_data_structures(
451
+ data: dict[str, Any],
452
+ index: dict[str, Any],
453
+ agencies: dict[str, Agency],
454
+ ) -> list[DataStructure]:
455
+ result: list[DataStructure] = []
456
+ for dsd in data.get("dataStructures") or []:
457
+ components = dsd.get("dataStructureComponents") or {}
458
+
459
+ dim_list = components.get("dimensionList") or {}
460
+ dimensions = [_read_dimension(d, index) for d in dim_list.get("dimensions") or []]
461
+
462
+ td_raw = dim_list.get("timeDimension")
463
+ time_dimension = _read_time_dimension(td_raw, index) if td_raw else None
464
+
465
+ attr_list = components.get("attributeList") or {}
466
+ attributes = [_read_attribute(a, index) for a in attr_list.get("attributes") or []]
467
+
468
+ measure_list = components.get("measureList") or {}
469
+ # SDMX 3.0 style: measures array; SDMX 2.1 fallback: primaryMeasure object
470
+ raw_measures = measure_list.get("measures") or []
471
+ if not raw_measures and (pm := measure_list.get("primaryMeasure")):
472
+ raw_measures = [pm]
473
+ measures = [_read_measure(m, index) for m in raw_measures]
474
+
475
+ groups = _read_groups(components)
476
+
477
+ result.append(
478
+ DataStructure(
479
+ id=dsd["id"],
480
+ maintainer=_resolve_agency(dsd.get("agencyID", ""), agencies),
481
+ version=dsd.get("version", "1.0"),
482
+ name=_istring(dsd, "name"),
483
+ description=_istring(dsd, "description"),
484
+ is_final=bool(dsd.get("isFinal", False)),
485
+ dimensions=ItemList(dimensions),
486
+ time_dimension=time_dimension,
487
+ attributes=ItemList(attributes),
488
+ measures=ItemList(measures),
489
+ groups=ItemList(groups) if groups else None,
490
+ annotations=_read_annotations(dsd),
491
+ valid_from=_read_datetime_str(dsd, "validFrom"),
492
+ valid_to=_read_datetime_str(dsd, "validTo"),
493
+ )
494
+ )
495
+ return result
496
+
497
+
498
+ # ── Dataflows ─────────────────────────────────────────────────────────────────
499
+
500
+
501
+ def _read_dataflows(
502
+ data: dict[str, Any],
503
+ index: dict[str, Any],
504
+ agencies: dict[str, Agency],
505
+ ) -> list[Dataflow]:
506
+ result: list[Dataflow] = []
507
+ for df in data.get("dataflows") or []:
508
+ structure_ref = _resolve_urn(df.get("structure"), index)
509
+ result.append(
510
+ Dataflow(
511
+ id=df["id"],
512
+ maintainer=_resolve_agency(df.get("agencyID", ""), agencies),
513
+ version=df.get("version", "1.0"),
514
+ name=_istring(df, "name"),
515
+ description=_istring(df, "description"),
516
+ annotations=_read_annotations(df),
517
+ structure=structure_ref,
518
+ valid_from=_read_datetime_str(df, "validFrom"),
519
+ valid_to=_read_datetime_str(df, "validTo"),
520
+ )
521
+ )
522
+ return result
523
+
524
+
525
+ # ── Categorisations ───────────────────────────────────────────────────────────
526
+
527
+
528
+ def _read_categorisations(
529
+ data: dict[str, Any],
530
+ index: dict[str, Any],
531
+ agencies: dict[str, Agency],
532
+ ) -> list[Categorisation]:
533
+ return [
534
+ Categorisation(
535
+ id=cat["id"],
536
+ maintainer=_resolve_agency(cat.get("agencyID", ""), agencies),
537
+ version=cat.get("version", "1.0"),
538
+ name=_istring(cat, "name"),
539
+ description=_istring(cat, "description"),
540
+ annotations=_read_annotations(cat),
541
+ source=_resolve_urn(cat.get("source"), index),
542
+ target=_resolve_urn(cat.get("target"), index),
543
+ valid_from=_read_datetime_str(cat, "validFrom"),
544
+ valid_to=_read_datetime_str(cat, "validTo"),
545
+ )
546
+ for cat in data.get("categorisations") or []
547
+ ]
548
+
549
+
550
+ # ── ProvisionAgreements ───────────────────────────────────────────────────────
551
+
552
+
553
+ def _read_provision_agreements(
554
+ data: dict[str, Any],
555
+ index: dict[str, Any],
556
+ agencies: dict[str, Agency],
557
+ ) -> list[ProvisionAgreement]:
558
+ return [
559
+ ProvisionAgreement(
560
+ id=pa["id"],
561
+ maintainer=_resolve_agency(pa.get("agencyID", ""), agencies),
562
+ version=pa.get("version", "1.0"),
563
+ name=_istring(pa, "name"),
564
+ description=_istring(pa, "description"),
565
+ annotations=_read_annotations(pa),
566
+ dataflow=_resolve_urn(pa.get("structureUsage") or pa.get("dataflow"), index),
567
+ data_provider=_resolve_urn(pa.get("dataProvider"), index),
568
+ valid_from=_read_datetime_str(pa, "validFrom"),
569
+ valid_to=_read_datetime_str(pa, "validTo"),
570
+ )
571
+ for pa in data.get("provisionAgreements") or []
572
+ ]
573
+
574
+
575
+ def _read_constraints(
576
+ data: dict[str, Any],
577
+ index: dict[str, Any],
578
+ agencies: dict[str, Agency],
579
+ ) -> list[ContentConstraint]:
580
+ """Parse dataConstraints (SDMX-JSON key) into ContentConstraint objects."""
581
+ result: list[ContentConstraint] = []
582
+ for cc in data.get("dataConstraints") or []:
583
+ # ConstraintAttachment
584
+ attach = ConstraintAttachment()
585
+ ca = cc.get("constraintAttachment")
586
+ if ca:
587
+ if dataflow_urn := ca.get("dataflow"):
588
+ ref = _resolve_urn(dataflow_urn, index)
589
+ attach = ConstraintAttachment(dataflow=ref)
590
+ elif ds_urn := ca.get("dataStructure"):
591
+ ref = _resolve_urn(ds_urn, index)
592
+ attach = ConstraintAttachment(data_structure=ref)
593
+ elif pa_urn := ca.get("provisionAgreement"):
594
+ ref = _resolve_urn(pa_urn, index)
595
+ attach = ConstraintAttachment(provision_agreement=ref)
596
+
597
+ regions: list[CubeRegion] = []
598
+ for region in cc.get("cubeRegions") or []:
599
+ include = region.get("include", True)
600
+ keys: list[KeyValue] = []
601
+ for kv in region.get("keyValues") or []:
602
+ dim_id = kv.get("id", "")
603
+ values = frozenset(str(v) for v in kv.get("values") or [])
604
+ keys.append(KeyValue(dimension_id=dim_id, values=values))
605
+ regions.append(CubeRegion(include=include, keys=tuple(keys)))
606
+
607
+ reference_period: ReferencePeriod | None = None
608
+ rp_raw = cc.get("referencePeriod")
609
+ if isinstance(rp_raw, dict):
610
+ reference_period = ReferencePeriod(
611
+ start_period=datetime.fromisoformat(rp_raw["startPeriod"]),
612
+ end_period=datetime.fromisoformat(rp_raw["endPeriod"]),
613
+ )
614
+
615
+ release_calendar: ReleaseCalendar | None = None
616
+ rc_raw = cc.get("releaseCalendar")
617
+ if isinstance(rc_raw, dict):
618
+ release_calendar = ReleaseCalendar(
619
+ periodicity=rc_raw["periodicity"],
620
+ offset=rc_raw["offset"],
621
+ tolerance=rc_raw["tolerance"],
622
+ )
623
+
624
+ result.append(
625
+ ContentConstraint(
626
+ id=cc.get("id", ""),
627
+ maintainer=_resolve_agency(cc.get("agencyID", ""), agencies),
628
+ version=cc.get("version", "1.0"),
629
+ name=_istring(cc, "name"),
630
+ description=_istring(cc, "description"),
631
+ is_final=cc.get("isFinal", False),
632
+ constraint_type=cc.get("type", "Allowed"),
633
+ attachment=attach,
634
+ regions=tuple(regions),
635
+ reference_period=reference_period,
636
+ release_calendar=release_calendar,
637
+ annotations=_read_annotations(cc),
638
+ valid_from=_read_datetime_str(cc, "validFrom"),
639
+ valid_to=_read_datetime_str(cc, "validTo"),
640
+ )
641
+ )
642
+ return result
643
+
644
+
645
+ def _read_structure_sets(
646
+ data: dict[str, Any],
647
+ index: dict[str, Any],
648
+ agencies: dict[str, Agency],
649
+ ) -> list[StructureSet]:
650
+ """Parse structureSets (SDMX-JSON key) into StructureSet objects."""
651
+ result: list[StructureSet] = []
652
+ for ss in data.get("structureSets") or []:
653
+ maps: list[CodelistMap] = []
654
+ for cm in ss.get("codelistMaps") or []:
655
+ source_ref = _resolve_urn(cm.get("source"), index)
656
+ target_ref = _resolve_urn(cm.get("target"), index)
657
+ code_maps = [
658
+ CodeMap(source_id=m["source"], target_id=m["target"])
659
+ for m in cm.get("codeMaps") or []
660
+ if "source" in m and "target" in m
661
+ ]
662
+ maps.append(
663
+ CodelistMap(
664
+ id=cm.get("id", ""),
665
+ name=_istring(cm, "name"),
666
+ description=_istring(cm, "description"),
667
+ source=source_ref,
668
+ target=target_ref,
669
+ maps=tuple(code_maps),
670
+ )
671
+ )
672
+
673
+ concept_scheme_maps: list[ConceptSchemeMap] = []
674
+ for csm in ss.get("conceptSchemeMaps") or []:
675
+ source_ref = _resolve_urn(csm.get("source"), index)
676
+ target_ref = _resolve_urn(csm.get("target"), index)
677
+ concept_maps = [
678
+ ConceptMap(source_id=m["sourceId"], target_id=m["targetId"])
679
+ for m in csm.get("conceptMaps") or []
680
+ if "sourceId" in m and "targetId" in m
681
+ ]
682
+ concept_scheme_maps.append(
683
+ ConceptSchemeMap(
684
+ id=csm.get("id", ""),
685
+ name=_istring(csm, "name"),
686
+ description=_istring(csm, "description"),
687
+ source=source_ref,
688
+ target=target_ref,
689
+ maps=tuple(concept_maps),
690
+ )
691
+ )
692
+
693
+ category_scheme_maps: list[CategorySchemeMap] = []
694
+ for catm in ss.get("categorySchemeMaps") or []:
695
+ source_ref = _resolve_urn(catm.get("source"), index)
696
+ target_ref = _resolve_urn(catm.get("target"), index)
697
+ cat_maps = [
698
+ CategoryMap(source_id=m["sourceId"], target_id=m["targetId"])
699
+ for m in catm.get("categoryMaps") or []
700
+ if "sourceId" in m and "targetId" in m
701
+ ]
702
+ category_scheme_maps.append(
703
+ CategorySchemeMap(
704
+ id=catm.get("id", ""),
705
+ name=_istring(catm, "name"),
706
+ description=_istring(catm, "description"),
707
+ source=source_ref,
708
+ target=target_ref,
709
+ maps=tuple(cat_maps),
710
+ )
711
+ )
712
+
713
+ data_structure_maps: list[DataStructureMap] = []
714
+ for dsm in ss.get("dataStructureMaps") or []:
715
+ source_ref = _resolve_urn(dsm.get("source"), index)
716
+ target_ref = _resolve_urn(dsm.get("target"), index)
717
+ comp_maps = [
718
+ ComponentMap(source_id=m["sourceId"], target_id=m["targetId"])
719
+ for m in dsm.get("componentMaps") or []
720
+ if "sourceId" in m and "targetId" in m
721
+ ]
722
+ data_structure_maps.append(
723
+ DataStructureMap(
724
+ id=dsm.get("id", ""),
725
+ name=_istring(dsm, "name"),
726
+ description=_istring(dsm, "description"),
727
+ source=source_ref,
728
+ target=target_ref,
729
+ maps=tuple(comp_maps),
730
+ )
731
+ )
732
+
733
+ dataflow_maps: list[DataflowMap] = []
734
+ for dfm in ss.get("dataflowMaps") or []:
735
+ source_ref = _resolve_urn(dfm.get("source"), index)
736
+ target_ref = _resolve_urn(dfm.get("target"), index)
737
+ dataflow_maps.append(
738
+ DataflowMap(
739
+ id=dfm.get("id", ""),
740
+ name=_istring(dfm, "name"),
741
+ description=_istring(dfm, "description"),
742
+ source=source_ref,
743
+ target=target_ref,
744
+ )
745
+ )
746
+
747
+ result.append(
748
+ StructureSet(
749
+ id=ss.get("id", ""),
750
+ maintainer=_resolve_agency(ss.get("agencyID", ""), agencies),
751
+ version=ss.get("version", "1.0"),
752
+ name=_istring(ss, "name"),
753
+ description=_istring(ss, "description"),
754
+ is_final=ss.get("isFinal", False),
755
+ codelist_maps=tuple(maps),
756
+ concept_scheme_maps=tuple(concept_scheme_maps),
757
+ category_scheme_maps=tuple(category_scheme_maps),
758
+ data_structure_maps=tuple(data_structure_maps),
759
+ dataflow_maps=tuple(dataflow_maps),
760
+ annotations=_read_annotations(ss),
761
+ valid_from=_read_datetime_str(ss, "validFrom"),
762
+ valid_to=_read_datetime_str(ss, "validTo"),
763
+ )
764
+ )
765
+ return result
766
+
767
+
768
+ # ── Hierarchies ──────────────────────────────────────────────────────────────
769
+
770
+
771
+ def _read_hierarchical_code(obj: dict[str, Any]) -> HierarchicalCode:
772
+ """Recursively parse a hierarchicalCode JSON object."""
773
+ code_ref: Ref[Code] | None = None
774
+ code_urn = obj.get("code")
775
+ if isinstance(code_urn, str) and code_urn.startswith("urn:"):
776
+ try:
777
+ urn = SdmxUrn.parse(code_urn)
778
+ code_ref = Ref[Code].unresolved(urn)
779
+ except (ValueError, TypeError):
780
+ pass
781
+
782
+ children = [_read_hierarchical_code(child) for child in obj.get("hierarchicalCodes") or []]
783
+
784
+ return HierarchicalCode(
785
+ id=obj["id"],
786
+ code=code_ref,
787
+ name=_istring(obj, "name"),
788
+ description=_istring(obj, "description"),
789
+ children=children,
790
+ annotations=_read_annotations(obj),
791
+ )
792
+
793
+
794
+ def _read_hierarchies(
795
+ data: dict[str, Any],
796
+ agencies: dict[str, Agency],
797
+ ) -> list[Hierarchy]:
798
+ """Parse hierarchies array into Hierarchy objects."""
799
+ result: list[Hierarchy] = []
800
+ for h in data.get("hierarchies") or []:
801
+ tree = [_read_hierarchical_code(hc) for hc in h.get("hierarchicalCodes") or []]
802
+ result.append(
803
+ Hierarchy(
804
+ id=h["id"],
805
+ maintainer=_resolve_agency(h.get("agencyID", ""), agencies),
806
+ version=h.get("version", "1.0"),
807
+ name=_istring(h, "name"),
808
+ description=_istring(h, "description"),
809
+ is_final=bool(h.get("isFinal", False)),
810
+ tree=tree,
811
+ annotations=_read_annotations(h),
812
+ valid_from=_read_datetime_str(h, "validFrom"),
813
+ valid_to=_read_datetime_str(h, "validTo"),
814
+ )
815
+ )
816
+ return result
817
+
818
+
819
+ # ── Entry point ───────────────────────────────────────────────────────────────
820
+
821
+
822
+ def read(data: bytes, *, registry: Registry | None = None) -> StructureMessage:
823
+ """Parse SDMX-JSON 2.0 bytes into a StructureMessage.
824
+
825
+ References within the message are auto-resolved where URNs match
826
+ artefacts present in the same message.
827
+
828
+ If *registry* is provided all parsed artefacts are added to it via
829
+ ``registry.add_all()``, interning their Refs for cross-message resolution.
830
+
831
+ Args:
832
+ data: Raw JSON bytes of an SDMX-JSON 2.0 Structure message.
833
+ registry: Optional Registry to intern artefacts into after parsing.
834
+
835
+ Returns:
836
+ A fully constructed StructureMessage with resolved cross-references.
837
+ """
838
+ doc = json.loads(data)
839
+ payload: dict[str, Any] = doc.get("data") or {}
840
+
841
+ # ── Phase 1: parse leaf artefacts (no refs to other artefacts) ──────────
842
+ agency_schemes, data_provider_schemes = _read_organisation_schemes(payload)
843
+
844
+ # Build agency lookup for opportunistic resolution in subsequent phases
845
+ agencies: dict[str, Agency] = {a.id: a for s in agency_schemes for a in s.agencies}
846
+
847
+ codelists = _read_codelists(payload, agencies)
848
+ concept_schemes = _read_concept_schemes(payload, agencies)
849
+ category_schemes = _read_category_schemes(payload, agencies)
850
+
851
+ # ── Phase 2: build resolution index ─────────────────────────────────────
852
+ index: dict[str, Any] = {}
853
+
854
+ for cl in codelists:
855
+ _index_artefact(index, cl, Codelist)
856
+
857
+ for cs in concept_schemes:
858
+ _index_artefact(index, cs, ConceptScheme)
859
+ for concept in cs.concepts:
860
+ _index_item(index, concept, cs, ConceptScheme.sdmx_package, "Concept")
861
+
862
+ for cat_s in category_schemes:
863
+ _index_artefact(index, cat_s, CategoryScheme)
864
+
865
+ # ── Phase 3: parse artefacts that reference codelists / concepts ─────────
866
+ data_structures = _read_data_structures(payload, index, agencies)
867
+ for dsd in data_structures:
868
+ _index_artefact(index, dsd, DataStructure)
869
+
870
+ dataflows = _read_dataflows(payload, index, agencies)
871
+ for df in dataflows:
872
+ _index_artefact(index, df, Dataflow)
873
+
874
+ # ── Phase 4: parse artefacts that reference DSDs / dataflows ────────────
875
+ categorisations = _read_categorisations(payload, index, agencies)
876
+ provision_agreements = _read_provision_agreements(payload, index, agencies)
877
+ constraints = _read_constraints(payload, index, agencies)
878
+ structure_sets = _read_structure_sets(payload, index, agencies)
879
+ hierarchies = _read_hierarchies(payload, agencies)
880
+
881
+ msg = StructureMessage(
882
+ codelists=ArtefactList(codelists),
883
+ concept_schemes=ArtefactList(concept_schemes),
884
+ data_structures=ArtefactList(data_structures),
885
+ dataflows=ArtefactList(dataflows),
886
+ category_schemes=ArtefactList(category_schemes),
887
+ categorisations=ArtefactList(categorisations),
888
+ provision_agreements=ArtefactList(provision_agreements),
889
+ agency_schemes=ArtefactList(agency_schemes),
890
+ data_provider_schemes=ArtefactList(data_provider_schemes),
891
+ constraints=ArtefactList(constraints),
892
+ structure_sets=ArtefactList(structure_sets),
893
+ hierarchies=ArtefactList(hierarchies),
894
+ )
895
+ if registry is not None:
896
+ registry.add_all(msg.all_artefacts())
897
+ return msg