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,1147 @@
1
+ """SDMX-ML 2.1 reader: lxml → domain model.
2
+
3
+ Collapses thirteen XML wrapper layers into clean domain objects.
4
+ All references within the message are auto-resolved.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from lxml import etree
11
+
12
+ from sdmxlib.model.annotations import Annotation, Annotations
13
+ from sdmxlib.model.category import Categorisation, Category, CategoryScheme
14
+ from sdmxlib.model.codelist import Code, Codelist
15
+ from sdmxlib.model.collections import ArtefactList, ItemList
16
+ from sdmxlib.model.concept import Concept, ConceptScheme
17
+ from sdmxlib.model.constraint import (
18
+ ConstraintAttachment,
19
+ ContentConstraint,
20
+ CubeRegion,
21
+ KeyValue,
22
+ ReferencePeriod,
23
+ ReleaseCalendar,
24
+ )
25
+ from sdmxlib.model.dataflow import Dataflow
26
+ from sdmxlib.model.datastructure import (
27
+ AttachmentLevel,
28
+ Attribute,
29
+ DataStructure,
30
+ Dimension,
31
+ Group,
32
+ Measure,
33
+ MeasureDimension,
34
+ TimeDimension,
35
+ )
36
+ from sdmxlib.model.hierarchy import HierarchicalCode, Hierarchy
37
+ from sdmxlib.model.istring import InternationalString
38
+ from sdmxlib.model.mapping import (
39
+ CategoryMap,
40
+ CategorySchemeMap,
41
+ CodelistMap,
42
+ CodeMap,
43
+ ComponentMap,
44
+ ConceptMap,
45
+ ConceptSchemeMap,
46
+ DataflowMap,
47
+ DataStructureMap,
48
+ StructureSet,
49
+ )
50
+ from sdmxlib.model.message import StructureMessage
51
+ from sdmxlib.model.organisation import Agency, AgencyScheme, DataProvider, DataProviderScheme
52
+ from sdmxlib.model.provision import ProvisionAgreement
53
+ from sdmxlib.model.ref import AnyRef, Ref
54
+ from sdmxlib.model.registry import Registry
55
+ from sdmxlib.model.representation import Facet
56
+ from sdmxlib.model.urn import SdmxUrn
57
+
58
+ from .namespaces import COM, MES, STR, XML_LANG
59
+
60
+ # ── Clark-notation tag builders ───────────────────────────────────────────────
61
+
62
+
63
+ def _c(tag: str) -> str:
64
+ return f"{{{COM}}}{tag}"
65
+
66
+
67
+ def _s(tag: str) -> str:
68
+ return f"{{{STR}}}{tag}"
69
+
70
+
71
+ def _m(tag: str) -> str:
72
+ return f"{{{MES}}}{tag}"
73
+
74
+
75
+ # ── Common XML helpers ────────────────────────────────────────────────────────
76
+
77
+
78
+ def _read_istring(el: etree._Element, child_tag: str = "Name") -> InternationalString:
79
+ """Read com:Name (or com:Description) children as InternationalString."""
80
+ langs: dict[str, str] = {}
81
+ for child in el.findall(_c(child_tag)):
82
+ lang = child.get(XML_LANG, "")
83
+ if lang and child.text:
84
+ langs[lang] = child.text
85
+ return InternationalString(**langs)
86
+
87
+
88
+ def _read_annotations(el: etree._Element) -> Annotations:
89
+ """Read com:Annotations block."""
90
+ anns_el = el.find(_c("Annotations"))
91
+ if anns_el is None:
92
+ return Annotations()
93
+ items: list[Annotation] = []
94
+ for ann_el in anns_el.findall(_c("Annotation")):
95
+ title_el = ann_el.find(_c("AnnotationTitle"))
96
+ type_el = ann_el.find(_c("AnnotationType"))
97
+ url_el = ann_el.find(_c("AnnotationURL"))
98
+ text_langs: dict[str, str] = {}
99
+ for text_el in ann_el.findall(_c("AnnotationText")):
100
+ lang = text_el.get(XML_LANG, "")
101
+ if lang and text_el.text:
102
+ text_langs[lang] = text_el.text
103
+ items.append(
104
+ Annotation(
105
+ id=ann_el.get("id"),
106
+ title=title_el.text if title_el is not None else None,
107
+ type=type_el.text if type_el is not None else None,
108
+ url=url_el.text if url_el is not None else None,
109
+ text=InternationalString(**text_langs),
110
+ )
111
+ )
112
+ return Annotations(items)
113
+
114
+
115
+ def _read_datetime_attr(el: etree._Element, attr: str) -> datetime | None:
116
+ """Parse an optional xs:dateTime attribute (e.g. validFrom, validTo)."""
117
+ val = el.get(attr)
118
+ if not val:
119
+ return None
120
+ return datetime.fromisoformat(val)
121
+
122
+
123
+ # ── Agency lookup ─────────────────────────────────────────────────────────────
124
+
125
+
126
+ def _resolve_agency(agency_id: str, agencies: dict[str, Agency]) -> Agency:
127
+ """Return the rich Agency if parsed from an AgencyScheme, else a minimal one."""
128
+ return agencies.get(agency_id, Agency(id=agency_id))
129
+
130
+
131
+ # ── Ref / URN helpers ─────────────────────────────────────────────────────────
132
+
133
+
134
+ def _ref_el_to_urn(ref_el: etree._Element) -> SdmxUrn[Any] | None:
135
+ """Convert a Ref element to a SdmxUrn.
136
+
137
+ Handles both URN-attribute refs and component-attribute refs, including
138
+ item refs that carry maintainableParentID.
139
+ """
140
+ if urn_str := ref_el.get("urn"):
141
+ return SdmxUrn.parse(urn_str)
142
+
143
+ agency = ref_el.get("agencyID", "")
144
+ id_ = ref_el.get("id", "")
145
+ version = ref_el.get("version", "latest")
146
+ package = ref_el.get("package", "")
147
+ cls = ref_el.get("class", "")
148
+
149
+ if not (agency and id_ and package and cls):
150
+ return None
151
+
152
+ parent_id = ref_el.get("maintainableParentID")
153
+ parent_version = ref_el.get("maintainableParentVersion", "latest")
154
+
155
+ if parent_id:
156
+ # Item URN: id_ is the item, parent_id is the maintainable container
157
+ return SdmxUrn.make(
158
+ object,
159
+ package=package,
160
+ artefact_class=cls,
161
+ agency=agency,
162
+ id=parent_id,
163
+ version=parent_version,
164
+ item_id=id_,
165
+ )
166
+ return SdmxUrn.make(
167
+ object,
168
+ package=package,
169
+ artefact_class=cls,
170
+ agency=agency,
171
+ id=id_,
172
+ version=version,
173
+ )
174
+
175
+
176
+ def _resolve_ref(container_el: etree._Element, index: dict[str, Any]) -> AnyRef | None:
177
+ """Find a direct Ref child and resolve it against the index."""
178
+ ref_el = container_el.find("Ref")
179
+ if ref_el is None:
180
+ return None
181
+ urn = _ref_el_to_urn(ref_el)
182
+ if urn is None:
183
+ return None
184
+ obj = index.get(str(urn))
185
+ if obj is not None:
186
+ return Ref.of(urn, obj)
187
+ return Ref.unresolved(urn)
188
+
189
+
190
+ # ── Index helpers ─────────────────────────────────────────────────────────────
191
+
192
+
193
+ def _index_artefact(index: dict[str, Any], obj: Any, artefact_type: type[Any]) -> None:
194
+ """Add a maintainable artefact to the resolution index under both versioned and latest keys."""
195
+ package = artefact_type.sdmx_package
196
+ cls = artefact_type.sdmx_class
197
+ urn = SdmxUrn.make(
198
+ artefact_type,
199
+ package=package,
200
+ artefact_class=cls,
201
+ agency=obj.agency_id,
202
+ id=obj.id,
203
+ version=obj.version,
204
+ )
205
+ index[str(urn)] = obj
206
+ urn_latest = SdmxUrn.make(artefact_type, package=package, artefact_class=cls, agency=obj.agency_id, id=obj.id)
207
+ index[str(urn_latest)] = obj
208
+
209
+
210
+ def _index_item(
211
+ index: dict[str, Any],
212
+ item: Any,
213
+ parent: Any,
214
+ package: str,
215
+ cls: str,
216
+ ) -> None:
217
+ """Add an item (e.g. Concept) to the index under its item URN."""
218
+ urn = SdmxUrn.make(
219
+ object,
220
+ package=package,
221
+ artefact_class=cls,
222
+ agency=parent.agency_id,
223
+ id=parent.id,
224
+ version=parent.version,
225
+ item_id=item.id,
226
+ )
227
+ index[str(urn)] = item
228
+ urn_latest = SdmxUrn.make(
229
+ object,
230
+ package=package,
231
+ artefact_class=cls,
232
+ agency=parent.agency_id,
233
+ id=parent.id,
234
+ item_id=item.id,
235
+ )
236
+ index[str(urn_latest)] = item
237
+
238
+
239
+ # ── Representation ────────────────────────────────────────────────────────────
240
+
241
+
242
+ def _read_text_format(tf_el: etree._Element) -> Facet:
243
+ def _int(v: str | None) -> int | None:
244
+ return int(v) if v is not None else None
245
+
246
+ def _float(v: str | None) -> float | None:
247
+ return float(v) if v is not None else None
248
+
249
+ def _bool(v: str | None) -> bool | None:
250
+ if v is None:
251
+ return None
252
+ return v.lower() in ("true", "1")
253
+
254
+ return Facet(
255
+ type=tf_el.get("textType"),
256
+ max_length=_int(tf_el.get("maxLength")),
257
+ min_length=_int(tf_el.get("minLength")),
258
+ max_value=_float(tf_el.get("maxValue")),
259
+ min_value=_float(tf_el.get("minValue")),
260
+ decimals=_int(tf_el.get("decimals")),
261
+ pattern=tf_el.get("pattern"),
262
+ start_value=_float(tf_el.get("startValue")),
263
+ end_value=_float(tf_el.get("endValue")),
264
+ is_sequence=_bool(tf_el.get("isSequence")),
265
+ is_multi_lingual=_bool(tf_el.get("isMultiLingual")),
266
+ )
267
+
268
+
269
+ def _read_representation(local_rep_el: etree._Element, index: dict[str, Any] | None = None) -> AnyRef | Facet | None:
270
+ """Parse a LocalRepresentation or CoreRepresentation element.
271
+
272
+ When *index* is provided the codelist ref is resolved against it.
273
+ Without *index* (concept CoreRepresentation parsing) an unresolved Ref is returned.
274
+ """
275
+ enum_el = local_rep_el.find(_s("Enumeration"))
276
+ if enum_el is not None:
277
+ if index is not None:
278
+ return _resolve_ref(enum_el, index)
279
+ ref_el = enum_el.find("Ref")
280
+ if ref_el is not None:
281
+ urn = _ref_el_to_urn(ref_el)
282
+ if urn is not None:
283
+ return Ref.unresolved(urn)
284
+
285
+ tf_el = local_rep_el.find(_s("TextFormat"))
286
+ if tf_el is not None:
287
+ return _read_text_format(tf_el)
288
+
289
+ return None
290
+
291
+
292
+ # ── OrganisationSchemes ───────────────────────────────────────────────────────
293
+
294
+
295
+ def _read_organisation_schemes(
296
+ structures: etree._Element,
297
+ ) -> tuple[list[AgencyScheme], list[DataProviderScheme]]:
298
+ container = structures.find(_s("OrganisationSchemes"))
299
+ if container is None:
300
+ return [], []
301
+
302
+ agency_schemes: list[AgencyScheme] = []
303
+ provider_schemes: list[DataProviderScheme] = []
304
+
305
+ for as_el in container.findall(_s("AgencyScheme")):
306
+ agencies = [
307
+ Agency(
308
+ id=a.get("id", ""),
309
+ name=_read_istring(a),
310
+ description=_read_istring(a, "Description"),
311
+ annotations=_read_annotations(a),
312
+ )
313
+ for a in as_el.findall(_s("Agency"))
314
+ ]
315
+ scheme = AgencyScheme(
316
+ id=as_el.get("id", ""),
317
+ maintainer=as_el.get("agencyID", ""),
318
+ version=as_el.get("version", "1.0"),
319
+ name=_read_istring(as_el),
320
+ description=_read_istring(as_el, "Description"),
321
+ is_final=as_el.get("isFinal", "false").lower() == "true",
322
+ agencies=ItemList(agencies),
323
+ annotations=_read_annotations(as_el),
324
+ valid_from=_read_datetime_attr(as_el, "validFrom"),
325
+ valid_to=_read_datetime_attr(as_el, "validTo"),
326
+ )
327
+ agency_schemes.append(scheme)
328
+
329
+ for ps_el in container.findall(_s("DataProviderScheme")):
330
+ providers = [
331
+ DataProvider(
332
+ id=p.get("id", ""),
333
+ name=_read_istring(p),
334
+ description=_read_istring(p, "Description"),
335
+ annotations=_read_annotations(p),
336
+ )
337
+ for p in ps_el.findall(_s("DataProvider"))
338
+ ]
339
+ scheme_p = DataProviderScheme(
340
+ id=ps_el.get("id", ""),
341
+ maintainer=ps_el.get("agencyID", ""),
342
+ version=ps_el.get("version", "1.0"),
343
+ name=_read_istring(ps_el),
344
+ description=_read_istring(ps_el, "Description"),
345
+ is_final=ps_el.get("isFinal", "false").lower() == "true",
346
+ providers=ItemList(providers),
347
+ annotations=_read_annotations(ps_el),
348
+ valid_from=_read_datetime_attr(ps_el, "validFrom"),
349
+ valid_to=_read_datetime_attr(ps_el, "validTo"),
350
+ )
351
+ provider_schemes.append(scheme_p)
352
+
353
+ return agency_schemes, provider_schemes
354
+
355
+
356
+ # ── Codelists ─────────────────────────────────────────────────────────────────
357
+
358
+
359
+ def _read_codelists(structures: etree._Element, agencies: dict[str, Agency]) -> list[Codelist]:
360
+ container = structures.find(_s("Codelists"))
361
+ if container is None:
362
+ return []
363
+ result: list[Codelist] = []
364
+ for cl_el in container.findall(_s("Codelist")):
365
+ codes: list[Code] = []
366
+ for code_el in cl_el.findall(_s("Code")):
367
+ parent_id: str | None = None
368
+ parent_el = code_el.find(_s("Parent"))
369
+ if parent_el is not None:
370
+ ref_el = parent_el.find("Ref")
371
+ if ref_el is not None:
372
+ parent_id = ref_el.get("id")
373
+ codes.append(
374
+ Code(
375
+ id=code_el.get("id", ""),
376
+ name=_read_istring(code_el),
377
+ description=_read_istring(code_el, "Description"),
378
+ parent_id=parent_id,
379
+ annotations=_read_annotations(code_el),
380
+ )
381
+ )
382
+ result.append(
383
+ Codelist(
384
+ id=cl_el.get("id", ""),
385
+ maintainer=_resolve_agency(cl_el.get("agencyID", ""), agencies),
386
+ version=cl_el.get("version", "1.0"),
387
+ name=_read_istring(cl_el),
388
+ description=_read_istring(cl_el, "Description"),
389
+ is_final=cl_el.get("isFinal", "false").lower() == "true",
390
+ codes=ItemList(codes),
391
+ annotations=_read_annotations(cl_el),
392
+ valid_from=_read_datetime_attr(cl_el, "validFrom"),
393
+ valid_to=_read_datetime_attr(cl_el, "validTo"),
394
+ )
395
+ )
396
+ return result
397
+
398
+
399
+ # ── ConceptSchemes ────────────────────────────────────────────────────────────
400
+
401
+
402
+ def _read_concept_schemes(structures: etree._Element, agencies: dict[str, Agency]) -> list[ConceptScheme]:
403
+ container = structures.find(_s("Concepts"))
404
+ if container is None:
405
+ return []
406
+ result: list[ConceptScheme] = []
407
+ for cs_el in container.findall(_s("ConceptScheme")):
408
+ concepts = []
409
+ for concept_el in cs_el.findall(_s("Concept")):
410
+ core_rep = None
411
+ core_rep_el = concept_el.find(_s("CoreRepresentation"))
412
+ if core_rep_el is not None:
413
+ core_rep = _read_representation(core_rep_el)
414
+ concepts.append(
415
+ Concept(
416
+ id=concept_el.get("id", ""),
417
+ name=_read_istring(concept_el),
418
+ description=_read_istring(concept_el, "Description"),
419
+ core_representation=core_rep,
420
+ annotations=_read_annotations(concept_el),
421
+ )
422
+ )
423
+ result.append(
424
+ ConceptScheme(
425
+ id=cs_el.get("id", ""),
426
+ maintainer=_resolve_agency(cs_el.get("agencyID", ""), agencies),
427
+ version=cs_el.get("version", "1.0"),
428
+ name=_read_istring(cs_el),
429
+ description=_read_istring(cs_el, "Description"),
430
+ concepts=ItemList(concepts),
431
+ annotations=_read_annotations(cs_el),
432
+ valid_from=_read_datetime_attr(cs_el, "validFrom"),
433
+ valid_to=_read_datetime_attr(cs_el, "validTo"),
434
+ )
435
+ )
436
+ return result
437
+
438
+
439
+ # ── DataStructures ────────────────────────────────────────────────────────────
440
+
441
+
442
+ def _read_dimension(dim_el: etree._Element, index: dict[str, Any]) -> Dimension:
443
+ concept_ref: AnyRef | None = None
444
+ ci_el = dim_el.find(_s("ConceptIdentity"))
445
+ if ci_el is not None:
446
+ concept_ref = _resolve_ref(ci_el, index)
447
+
448
+ representation = None
449
+ local_rep_el = dim_el.find(_s("LocalRepresentation"))
450
+ if local_rep_el is not None:
451
+ representation = _read_representation(local_rep_el, index)
452
+
453
+ return Dimension(
454
+ id=dim_el.get("id", ""),
455
+ concept=concept_ref,
456
+ representation=representation,
457
+ annotations=_read_annotations(dim_el),
458
+ )
459
+
460
+
461
+ def _read_measure_dimension(md_el: etree._Element, index: dict[str, Any]) -> MeasureDimension:
462
+ concept_ref: AnyRef | None = None
463
+ ci_el = md_el.find(_s("ConceptIdentity"))
464
+ if ci_el is not None:
465
+ concept_ref = _resolve_ref(ci_el, index)
466
+
467
+ representation = None
468
+ local_rep_el = md_el.find(_s("LocalRepresentation"))
469
+ if local_rep_el is not None:
470
+ representation = _read_representation(local_rep_el, index)
471
+
472
+ return MeasureDimension(
473
+ id=md_el.get("id", ""),
474
+ concept=concept_ref,
475
+ representation=representation,
476
+ annotations=_read_annotations(md_el),
477
+ )
478
+
479
+
480
+ def _read_time_dimension(td_el: etree._Element, index: dict[str, Any]) -> TimeDimension:
481
+ concept_ref: AnyRef | None = None
482
+ ci_el = td_el.find(_s("ConceptIdentity"))
483
+ if ci_el is not None:
484
+ concept_ref = _resolve_ref(ci_el, index)
485
+
486
+ facet_rep: Facet | None = None
487
+ local_rep_el = td_el.find(_s("LocalRepresentation"))
488
+ if local_rep_el is not None:
489
+ tf_el = local_rep_el.find(_s("TextFormat"))
490
+ if tf_el is not None:
491
+ facet_rep = _read_text_format(tf_el)
492
+
493
+ return TimeDimension(
494
+ id=td_el.get("id", "TIME_PERIOD"),
495
+ concept=concept_ref,
496
+ representation=facet_rep,
497
+ annotations=_read_annotations(td_el),
498
+ )
499
+
500
+
501
+ def _read_attachment(
502
+ ar_el: etree._Element,
503
+ ) -> tuple[AttachmentLevel | None, list[str] | None, str | None]:
504
+ """Parse AttributeRelationship → (level, related_dimensions, related_measure)."""
505
+ pm_el = ar_el.find(_s("PrimaryMeasure"))
506
+ if pm_el is not None:
507
+ ref_el = pm_el.find("Ref")
508
+ measure_id = ref_el.get("id") if ref_el is not None else "OBS_VALUE"
509
+ return AttachmentLevel.OBSERVATION, None, measure_id
510
+
511
+ dim_refs = ar_el.findall(_s("Dimension"))
512
+ if dim_refs:
513
+ related = [ref_el.get("id", "") for d in dim_refs if (ref_el := d.find("Ref")) is not None]
514
+ return AttachmentLevel.SERIES, related or None, None
515
+
516
+ if ar_el.find(_s("Group")) is not None:
517
+ return AttachmentLevel.GROUP, None, None
518
+
519
+ return AttachmentLevel.DATASET, None, None
520
+
521
+
522
+ def _read_attribute(attr_el: etree._Element, index: dict[str, Any]) -> Attribute:
523
+ concept_ref: AnyRef | None = None
524
+ ci_el = attr_el.find(_s("ConceptIdentity"))
525
+ if ci_el is not None:
526
+ concept_ref = _resolve_ref(ci_el, index)
527
+
528
+ representation = None
529
+ local_rep_el = attr_el.find(_s("LocalRepresentation"))
530
+ if local_rep_el is not None:
531
+ representation = _read_representation(local_rep_el, index)
532
+
533
+ attachment: AttachmentLevel | None = None
534
+ related_dims: list[str] | None = None
535
+ related_measure: str | None = None
536
+ ar_el = attr_el.find(_s("AttributeRelationship"))
537
+ if ar_el is not None:
538
+ attachment, related_dims, related_measure = _read_attachment(ar_el)
539
+
540
+ return Attribute(
541
+ id=attr_el.get("id", ""),
542
+ usage=attr_el.get("assignmentStatus"),
543
+ attachment=attachment,
544
+ related_dimensions=related_dims,
545
+ related_measure=related_measure,
546
+ concept=concept_ref,
547
+ representation=representation,
548
+ annotations=_read_annotations(attr_el),
549
+ )
550
+
551
+
552
+ def _read_measure(pm_el: etree._Element, index: dict[str, Any]) -> Measure:
553
+ concept_ref: AnyRef | None = None
554
+ ci_el = pm_el.find(_s("ConceptIdentity"))
555
+ if ci_el is not None:
556
+ concept_ref = _resolve_ref(ci_el, index)
557
+
558
+ representation = None
559
+ local_rep_el = pm_el.find(_s("LocalRepresentation"))
560
+ if local_rep_el is not None:
561
+ representation = _read_representation(local_rep_el, index)
562
+
563
+ return Measure(
564
+ id=pm_el.get("id", "OBS_VALUE"),
565
+ concept=concept_ref,
566
+ representation=representation,
567
+ annotations=_read_annotations(pm_el),
568
+ )
569
+
570
+
571
+ def _read_groups(components_el: etree._Element) -> list[Group]:
572
+ result: list[Group] = []
573
+ for group_el in components_el.findall(_s("Group")):
574
+ dim_ids: list[str] = []
575
+ for gd_el in group_el.findall(_s("GroupDimension")):
576
+ dr_el = gd_el.find(_s("DimensionReference"))
577
+ if dr_el is not None:
578
+ ref_el = dr_el.find("Ref")
579
+ if ref_el is not None:
580
+ dim_ids.append(ref_el.get("id", ""))
581
+ result.append(Group(id=group_el.get("id", ""), dimension_ids=dim_ids))
582
+ return result
583
+
584
+
585
+ def _read_dsd_components(
586
+ components_el: etree._Element, index: dict[str, Any]
587
+ ) -> tuple[list[Dimension], TimeDimension | None, MeasureDimension | None, list[Attribute], list[Measure], list[Group]]:
588
+ """Parse DataStructureComponents into component lists."""
589
+ dimensions: list[Dimension] = []
590
+ time_dimension: TimeDimension | None = None
591
+ measure_dimension: MeasureDimension | None = None
592
+ attributes: list[Attribute] = []
593
+ measures: list[Measure] = []
594
+
595
+ dim_list_el = components_el.find(_s("DimensionList"))
596
+ if dim_list_el is not None:
597
+ dimensions.extend(_read_dimension(d, index) for d in dim_list_el.findall(_s("Dimension")))
598
+ td_el = dim_list_el.find(_s("TimeDimension"))
599
+ if td_el is not None:
600
+ time_dimension = _read_time_dimension(td_el, index)
601
+ md_el = dim_list_el.find(_s("MeasureDimension"))
602
+ if md_el is not None:
603
+ measure_dimension = _read_measure_dimension(md_el, index)
604
+
605
+ attr_list_el = components_el.find(_s("AttributeList"))
606
+ if attr_list_el is not None:
607
+ attributes.extend(_read_attribute(a, index) for a in attr_list_el.findall(_s("Attribute")))
608
+
609
+ measure_list_el = components_el.find(_s("MeasureList"))
610
+ if measure_list_el is not None:
611
+ pm_el = measure_list_el.find(_s("PrimaryMeasure"))
612
+ if pm_el is not None:
613
+ measures.append(_read_measure(pm_el, index))
614
+
615
+ groups = _read_groups(components_el)
616
+ return dimensions, time_dimension, measure_dimension, attributes, measures, groups
617
+
618
+
619
+ def _read_data_structures(
620
+ structures: etree._Element,
621
+ index: dict[str, Any],
622
+ agencies: dict[str, Agency],
623
+ ) -> list[DataStructure]:
624
+ container = structures.find(_s("DataStructures"))
625
+ if container is None:
626
+ return []
627
+ result: list[DataStructure] = []
628
+ for dsd_el in container.findall(_s("DataStructure")):
629
+ dimensions: list[Dimension] = []
630
+ time_dimension: TimeDimension | None = None
631
+ measure_dimension: MeasureDimension | None = None
632
+ attributes: list[Attribute] = []
633
+ measures: list[Measure] = []
634
+ groups: list[Group] = []
635
+
636
+ components_el = dsd_el.find(_s("DataStructureComponents"))
637
+ if components_el is not None:
638
+ dimensions, time_dimension, measure_dimension, attributes, measures, groups = _read_dsd_components(
639
+ components_el, index
640
+ )
641
+
642
+ result.append(
643
+ DataStructure(
644
+ id=dsd_el.get("id", ""),
645
+ maintainer=_resolve_agency(dsd_el.get("agencyID", ""), agencies),
646
+ version=dsd_el.get("version", "1.0"),
647
+ name=_read_istring(dsd_el),
648
+ description=_read_istring(dsd_el, "Description"),
649
+ is_final=dsd_el.get("isFinal", "false").lower() == "true",
650
+ dimensions=ItemList(dimensions),
651
+ time_dimension=time_dimension,
652
+ measure_dimension=measure_dimension,
653
+ attributes=ItemList(attributes),
654
+ measures=ItemList(measures),
655
+ groups=ItemList(groups) if groups else None,
656
+ annotations=_read_annotations(dsd_el),
657
+ valid_from=_read_datetime_attr(dsd_el, "validFrom"),
658
+ valid_to=_read_datetime_attr(dsd_el, "validTo"),
659
+ )
660
+ )
661
+ return result
662
+
663
+
664
+ # ── Dataflows ─────────────────────────────────────────────────────────────────
665
+
666
+
667
+ def _read_dataflows(
668
+ structures: etree._Element,
669
+ index: dict[str, Any],
670
+ agencies: dict[str, Agency],
671
+ ) -> list[Dataflow]:
672
+ container = structures.find(_s("Dataflows"))
673
+ if container is None:
674
+ return []
675
+ result: list[Dataflow] = []
676
+ for df_el in container.findall(_s("Dataflow")):
677
+ structure_ref: AnyRef | None = None
678
+ struct_el = df_el.find(_s("Structure"))
679
+ if struct_el is not None:
680
+ structure_ref = _resolve_ref(struct_el, index)
681
+ result.append(
682
+ Dataflow(
683
+ id=df_el.get("id", ""),
684
+ maintainer=_resolve_agency(df_el.get("agencyID", ""), agencies),
685
+ version=df_el.get("version", "1.0"),
686
+ name=_read_istring(df_el),
687
+ description=_read_istring(df_el, "Description"),
688
+ annotations=_read_annotations(df_el),
689
+ structure=structure_ref,
690
+ valid_from=_read_datetime_attr(df_el, "validFrom"),
691
+ valid_to=_read_datetime_attr(df_el, "validTo"),
692
+ )
693
+ )
694
+ return result
695
+
696
+
697
+ # ── CategorySchemes & Categorisations ─────────────────────────────────────────
698
+
699
+
700
+ def _read_categories(parent_el: etree._Element) -> list[Category]:
701
+ """Recursively read Category elements."""
702
+ return [
703
+ Category(
704
+ id=cat_el.get("id", ""),
705
+ name=_read_istring(cat_el),
706
+ description=_read_istring(cat_el, "Description"),
707
+ categories=ItemList(_read_categories(cat_el)),
708
+ annotations=_read_annotations(cat_el),
709
+ )
710
+ for cat_el in parent_el.findall(_s("Category"))
711
+ ]
712
+
713
+
714
+ def _read_category_schemes(structures: etree._Element, agencies: dict[str, Agency]) -> list[CategoryScheme]:
715
+ container = structures.find(_s("CategorySchemes"))
716
+ if container is None:
717
+ return []
718
+ return [
719
+ CategoryScheme(
720
+ id=cs_el.get("id", ""),
721
+ maintainer=_resolve_agency(cs_el.get("agencyID", ""), agencies),
722
+ version=cs_el.get("version", "1.0"),
723
+ name=_read_istring(cs_el),
724
+ description=_read_istring(cs_el, "Description"),
725
+ categories=ItemList(_read_categories(cs_el)),
726
+ annotations=_read_annotations(cs_el),
727
+ valid_from=_read_datetime_attr(cs_el, "validFrom"),
728
+ valid_to=_read_datetime_attr(cs_el, "validTo"),
729
+ )
730
+ for cs_el in container.findall(_s("CategoryScheme"))
731
+ ]
732
+
733
+
734
+ def _read_categorisations(
735
+ structures: etree._Element,
736
+ index: dict[str, Any],
737
+ agencies: dict[str, Agency],
738
+ ) -> list[Categorisation]:
739
+ container = structures.find(_s("Categorisations"))
740
+ if container is None:
741
+ return []
742
+ result: list[Categorisation] = []
743
+ for cat_el in container.findall(_s("Categorisation")):
744
+ source_ref: AnyRef | None = None
745
+ source_el = cat_el.find(_s("Source"))
746
+ if source_el is not None:
747
+ source_ref = _resolve_ref(source_el, index)
748
+
749
+ target_ref: AnyRef | None = None
750
+ target_el = cat_el.find(_s("Target"))
751
+ if target_el is not None:
752
+ target_ref = _resolve_ref(target_el, index)
753
+
754
+ result.append(
755
+ Categorisation(
756
+ id=cat_el.get("id", ""),
757
+ maintainer=_resolve_agency(cat_el.get("agencyID", ""), agencies),
758
+ version=cat_el.get("version", "1.0"),
759
+ name=_read_istring(cat_el),
760
+ description=_read_istring(cat_el, "Description"),
761
+ annotations=_read_annotations(cat_el),
762
+ source=source_ref,
763
+ target=target_ref,
764
+ valid_from=_read_datetime_attr(cat_el, "validFrom"),
765
+ valid_to=_read_datetime_attr(cat_el, "validTo"),
766
+ )
767
+ )
768
+ return result
769
+
770
+
771
+ # ── ProvisionAgreements ───────────────────────────────────────────────────────
772
+
773
+
774
+ def _read_provision_agreements(
775
+ structures: etree._Element,
776
+ index: dict[str, Any],
777
+ agencies: dict[str, Agency],
778
+ ) -> list[ProvisionAgreement]:
779
+ container = structures.find(_s("ProvisionAgreements"))
780
+ if container is None:
781
+ return []
782
+ result: list[ProvisionAgreement] = []
783
+ for pa_el in container.findall(_s("ProvisionAgreement")):
784
+ dataflow_ref: AnyRef | None = None
785
+ su_el = pa_el.find(_s("StructureUsage"))
786
+ if su_el is not None:
787
+ dataflow_ref = _resolve_ref(su_el, index)
788
+
789
+ provider_ref: AnyRef | None = None
790
+ dp_el = pa_el.find(_s("DataProvider"))
791
+ if dp_el is not None:
792
+ provider_ref = _resolve_ref(dp_el, index)
793
+
794
+ result.append(
795
+ ProvisionAgreement(
796
+ id=pa_el.get("id", ""),
797
+ maintainer=_resolve_agency(pa_el.get("agencyID", ""), agencies),
798
+ version=pa_el.get("version", "1.0"),
799
+ name=_read_istring(pa_el),
800
+ description=_read_istring(pa_el, "Description"),
801
+ annotations=_read_annotations(pa_el),
802
+ dataflow=dataflow_ref,
803
+ data_provider=provider_ref,
804
+ valid_from=_read_datetime_attr(pa_el, "validFrom"),
805
+ valid_to=_read_datetime_attr(pa_el, "validTo"),
806
+ )
807
+ )
808
+ return result
809
+
810
+
811
+ # ── Constraints ───────────────────────────────────────────────────────────────
812
+
813
+
814
+ def _read_reference_period(cc_el: etree._Element) -> ReferencePeriod | None:
815
+ rp_el = cc_el.find(_s("ReferencePeriod"))
816
+ if rp_el is None:
817
+ return None
818
+ return ReferencePeriod(
819
+ start_period=datetime.fromisoformat(rp_el.get("startTime", "")),
820
+ end_period=datetime.fromisoformat(rp_el.get("endTime", "")),
821
+ )
822
+
823
+
824
+ def _read_release_calendar(cc_el: etree._Element) -> ReleaseCalendar | None:
825
+ rc_el = cc_el.find(_s("ReleaseCalendar"))
826
+ if rc_el is None:
827
+ return None
828
+ periodicity_el = rc_el.find(_s("Periodicity"))
829
+ offset_el = rc_el.find(_s("Offset"))
830
+ tolerance_el = rc_el.find(_s("Tolerance"))
831
+ return ReleaseCalendar(
832
+ periodicity=periodicity_el.text if periodicity_el is not None and periodicity_el.text else "",
833
+ offset=offset_el.text if offset_el is not None and offset_el.text else "",
834
+ tolerance=tolerance_el.text if tolerance_el is not None and tolerance_el.text else "",
835
+ )
836
+
837
+
838
+ def _read_constraints(
839
+ structures: etree._Element,
840
+ index: dict[str, Any],
841
+ agencies: dict[str, Agency],
842
+ ) -> list[ContentConstraint]:
843
+ container = structures.find(_s("Constraints"))
844
+ if container is None:
845
+ return []
846
+ result: list[ContentConstraint] = []
847
+ for cc_el in container.findall(_s("ContentConstraint")):
848
+ # ConstraintAttachment
849
+ attach = ConstraintAttachment()
850
+ att_el = cc_el.find(_s("ConstraintAttachment"))
851
+ if att_el is not None:
852
+ df_el = att_el.find(_s("Dataflow"))
853
+ if df_el is not None:
854
+ ref = _resolve_ref(df_el, index)
855
+ attach = ConstraintAttachment(dataflow=ref)
856
+ else:
857
+ ds_el = att_el.find(_s("DataStructure"))
858
+ if ds_el is not None:
859
+ ref = _resolve_ref(ds_el, index)
860
+ attach = ConstraintAttachment(data_structure=ref)
861
+ else:
862
+ pa_el = att_el.find(_s("ProvisionAgreement"))
863
+ if pa_el is not None:
864
+ ref = _resolve_ref(pa_el, index)
865
+ attach = ConstraintAttachment(provision_agreement=ref)
866
+
867
+ # CubeRegions
868
+ regions: list[CubeRegion] = []
869
+ for region_el in cc_el.findall(_s("CubeRegion")):
870
+ include_str = region_el.get("include", "true")
871
+ include = include_str.lower() != "false"
872
+ keys: list[KeyValue] = []
873
+ for kv_el in region_el.findall(_c("KeyValue")):
874
+ dim_id = kv_el.get("id", "")
875
+ values = frozenset(v.text for v in kv_el.findall(_c("Value")) if v.text)
876
+ keys.append(KeyValue(dimension_id=dim_id, values=values))
877
+ regions.append(CubeRegion(include=include, keys=tuple(keys)))
878
+
879
+ result.append(
880
+ ContentConstraint(
881
+ id=cc_el.get("id", ""),
882
+ maintainer=_resolve_agency(cc_el.get("agencyID", ""), agencies),
883
+ version=cc_el.get("version", "1.0"),
884
+ name=_read_istring(cc_el),
885
+ description=_read_istring(cc_el, "Description"),
886
+ is_final=cc_el.get("isFinal", "false").lower() == "true",
887
+ constraint_type=cc_el.get("type", "Allowed"),
888
+ attachment=attach,
889
+ regions=tuple(regions),
890
+ reference_period=_read_reference_period(cc_el),
891
+ release_calendar=_read_release_calendar(cc_el),
892
+ annotations=_read_annotations(cc_el),
893
+ valid_from=_read_datetime_attr(cc_el, "validFrom"),
894
+ valid_to=_read_datetime_attr(cc_el, "validTo"),
895
+ )
896
+ )
897
+ return result
898
+
899
+
900
+ # ── StructureSets ─────────────────────────────────────────────────────────────
901
+
902
+
903
+ def _read_source_target_refs(el: etree._Element, index: dict[str, Any]) -> tuple[AnyRef | None, AnyRef | None]:
904
+ """Read Source and Target ref children from a map element."""
905
+ source_ref = None
906
+ src_el = el.find(_s("Source"))
907
+ if src_el is not None:
908
+ source_ref = _resolve_ref(src_el, index)
909
+ target_ref = None
910
+ tgt_el = el.find(_s("Target"))
911
+ if tgt_el is not None:
912
+ target_ref = _resolve_ref(tgt_el, index)
913
+ return source_ref, target_ref
914
+
915
+
916
+ def _read_id_pair_maps(parent: etree._Element, tag: str, cls: type[Any]) -> list[Any]:
917
+ """Read item maps that have Source/Target children with Ref id attributes."""
918
+ results: list[Any] = []
919
+ for m_el in parent.findall(_s(tag)):
920
+ m_src = m_el.find(_s("Source"))
921
+ m_tgt = m_el.find(_s("Target"))
922
+ if m_src is not None and m_tgt is not None:
923
+ src_ref_el = m_src.find("Ref")
924
+ tgt_ref_el = m_tgt.find("Ref")
925
+ if src_ref_el is not None and tgt_ref_el is not None:
926
+ results.append(
927
+ cls(
928
+ source_id=src_ref_el.get("id", ""),
929
+ target_id=tgt_ref_el.get("id", ""),
930
+ )
931
+ )
932
+ return results
933
+
934
+
935
+ def _read_scheme_map(
936
+ parent: etree._Element,
937
+ scheme_tag: str,
938
+ scheme_cls: type[Any],
939
+ item_tag: str,
940
+ item_cls: type[Any],
941
+ index: dict[str, Any],
942
+ ) -> list[Any]:
943
+ """Read a list of scheme maps (CodelistMap, ConceptSchemeMap, etc.)."""
944
+ results: list[Any] = []
945
+ for el in parent.findall(_s(scheme_tag)):
946
+ source_ref, target_ref = _read_source_target_refs(el, index)
947
+ item_maps = _read_id_pair_maps(el, item_tag, item_cls)
948
+ results.append(
949
+ scheme_cls(
950
+ id=el.get("id", ""),
951
+ name=_read_istring(el),
952
+ description=_read_istring(el, "Description"),
953
+ source=source_ref,
954
+ target=target_ref,
955
+ maps=tuple(item_maps),
956
+ )
957
+ )
958
+ return results
959
+
960
+
961
+ def _read_structure_sets(
962
+ structures: etree._Element,
963
+ index: dict[str, Any],
964
+ agencies: dict[str, Agency],
965
+ ) -> list[StructureSet]:
966
+ container = structures.find(_s("StructureSets"))
967
+ if container is None:
968
+ return []
969
+ result: list[StructureSet] = []
970
+ for ss_el in container.findall(_s("StructureSet")):
971
+ # DataflowMaps (no item maps)
972
+ dataflow_maps: list[DataflowMap] = []
973
+ for dfm_el in ss_el.findall(_s("DataflowMap")):
974
+ source_ref, target_ref = _read_source_target_refs(dfm_el, index)
975
+ dataflow_maps.append(
976
+ DataflowMap(
977
+ id=dfm_el.get("id", ""),
978
+ name=_read_istring(dfm_el),
979
+ description=_read_istring(dfm_el, "Description"),
980
+ source=source_ref,
981
+ target=target_ref,
982
+ )
983
+ )
984
+
985
+ result.append(
986
+ StructureSet(
987
+ id=ss_el.get("id", ""),
988
+ maintainer=_resolve_agency(ss_el.get("agencyID", ""), agencies),
989
+ version=ss_el.get("version", "1.0"),
990
+ name=_read_istring(ss_el),
991
+ description=_read_istring(ss_el, "Description"),
992
+ is_final=ss_el.get("isFinal", "false").lower() == "true",
993
+ codelist_maps=tuple(_read_scheme_map(ss_el, "CodelistMap", CodelistMap, "CodeMap", CodeMap, index)),
994
+ concept_scheme_maps=tuple(
995
+ _read_scheme_map(ss_el, "ConceptSchemeMap", ConceptSchemeMap, "ConceptMap", ConceptMap, index)
996
+ ),
997
+ category_scheme_maps=tuple(
998
+ _read_scheme_map(ss_el, "CategorySchemeMap", CategorySchemeMap, "CategoryMap", CategoryMap, index)
999
+ ),
1000
+ data_structure_maps=tuple(
1001
+ _read_scheme_map(ss_el, "DataStructureMap", DataStructureMap, "ComponentMap", ComponentMap, index)
1002
+ ),
1003
+ dataflow_maps=tuple(dataflow_maps),
1004
+ annotations=_read_annotations(ss_el),
1005
+ valid_from=_read_datetime_attr(ss_el, "validFrom"),
1006
+ valid_to=_read_datetime_attr(ss_el, "validTo"),
1007
+ )
1008
+ )
1009
+ return result
1010
+
1011
+
1012
+ # ── Hierarchies ───────────────────────────────────────────────────────────────
1013
+
1014
+
1015
+ def _read_hierarchical_code(hc_el: etree._Element) -> HierarchicalCode:
1016
+ """Recursively parse a HierarchicalCode element."""
1017
+ code_ref: SdmxUrn[Any] | None = None
1018
+ code_id_el = hc_el.find(_s("CodeID"))
1019
+ if code_id_el is not None:
1020
+ ref_el = code_id_el.find("Ref")
1021
+ if ref_el is not None:
1022
+ agency = ref_el.get("agencyID", "")
1023
+ parent_id = ref_el.get("maintainableParentID", "")
1024
+ item_id = ref_el.get("id", "")
1025
+ version = ref_el.get("version", "latest")
1026
+ if agency and parent_id and item_id:
1027
+ code_ref = SdmxUrn.make(
1028
+ object,
1029
+ package="codelist",
1030
+ artefact_class="Code",
1031
+ agency=agency,
1032
+ id=parent_id,
1033
+ version=version,
1034
+ item_id=item_id,
1035
+ )
1036
+
1037
+ children = [_read_hierarchical_code(child_el) for child_el in hc_el.findall(_s("HierarchicalCode"))]
1038
+
1039
+ return HierarchicalCode(
1040
+ id=hc_el.get("id", ""),
1041
+ code=code_ref,
1042
+ name=_read_istring(hc_el),
1043
+ description=_read_istring(hc_el, "Description"),
1044
+ children=children,
1045
+ )
1046
+
1047
+
1048
+ def _read_hierarchies(
1049
+ structures: etree._Element,
1050
+ agencies: dict[str, Agency],
1051
+ ) -> list[Hierarchy]:
1052
+ container = structures.find(_s("HierarchicalCodelists"))
1053
+ if container is None:
1054
+ return []
1055
+ result: list[Hierarchy] = []
1056
+ for hcl_el in container.findall(_s("HierarchicalCodelist")):
1057
+ hcl_agency = hcl_el.get("agencyID", "")
1058
+ hcl_version = hcl_el.get("version", "1.0")
1059
+ for h_el in hcl_el.findall(_s("Hierarchy")):
1060
+ tree = [_read_hierarchical_code(hc_el) for hc_el in h_el.findall(_s("HierarchicalCode"))]
1061
+ result.append(
1062
+ Hierarchy(
1063
+ id=h_el.get("id", ""),
1064
+ maintainer=_resolve_agency(hcl_agency, agencies),
1065
+ version=hcl_version,
1066
+ name=_read_istring(h_el),
1067
+ description=_read_istring(h_el, "Description"),
1068
+ annotations=_read_annotations(h_el),
1069
+ tree=tree,
1070
+ )
1071
+ )
1072
+ return result
1073
+
1074
+
1075
+ # ── Entry point ───────────────────────────────────────────────────────────────
1076
+
1077
+
1078
+ def read(data: bytes, *, registry: Registry | None = None) -> StructureMessage:
1079
+ """Parse SDMX-ML 2.1 bytes into a StructureMessage.
1080
+
1081
+ References within the message are auto-resolved.
1082
+
1083
+ If *registry* is provided all parsed artefacts are added to it via
1084
+ ``registry.add_all()``, interning their Refs for cross-message resolution.
1085
+ """
1086
+ root = etree.fromstring(data)
1087
+ structures = root.find(_m("Structures"))
1088
+ if structures is None:
1089
+ return StructureMessage()
1090
+
1091
+ # ── Phase 1: parse leaf artefacts (no refs to other artefacts) ──────────
1092
+ agency_schemes, data_provider_schemes = _read_organisation_schemes(structures)
1093
+
1094
+ # Build agency lookup for opportunistic resolution in subsequent phases
1095
+ agencies: dict[str, Agency] = {a.id: a for s in agency_schemes for a in s.agencies}
1096
+
1097
+ codelists = _read_codelists(structures, agencies)
1098
+ concept_schemes = _read_concept_schemes(structures, agencies)
1099
+ category_schemes = _read_category_schemes(structures, agencies)
1100
+
1101
+ # ── Phase 2: build resolution index ─────────────────────────────────────
1102
+ index: dict[str, Any] = {}
1103
+
1104
+ for cl in codelists:
1105
+ _index_artefact(index, cl, Codelist)
1106
+
1107
+ for cs in concept_schemes:
1108
+ _index_artefact(index, cs, ConceptScheme)
1109
+ for concept in cs.concepts:
1110
+ _index_item(index, concept, cs, ConceptScheme.sdmx_package, "Concept")
1111
+
1112
+ for cat_s in category_schemes:
1113
+ _index_artefact(index, cat_s, CategoryScheme)
1114
+
1115
+ # ── Phase 3: parse artefacts that reference codelists / concepts ─────────
1116
+ data_structures = _read_data_structures(structures, index, agencies)
1117
+ for dsd in data_structures:
1118
+ _index_artefact(index, dsd, DataStructure)
1119
+
1120
+ dataflows = _read_dataflows(structures, index, agencies)
1121
+ for df in dataflows:
1122
+ _index_artefact(index, df, Dataflow)
1123
+
1124
+ # ── Phase 4: parse artefacts that reference DSDs / dataflows ────────────
1125
+ categorisations = _read_categorisations(structures, index, agencies)
1126
+ provision_agreements = _read_provision_agreements(structures, index, agencies)
1127
+ constraints = _read_constraints(structures, index, agencies)
1128
+ structure_sets = _read_structure_sets(structures, index, agencies)
1129
+ hierarchies = _read_hierarchies(structures, agencies)
1130
+
1131
+ msg = StructureMessage(
1132
+ codelists=ArtefactList(codelists),
1133
+ concept_schemes=ArtefactList(concept_schemes),
1134
+ data_structures=ArtefactList(data_structures),
1135
+ dataflows=ArtefactList(dataflows),
1136
+ category_schemes=ArtefactList(category_schemes),
1137
+ categorisations=ArtefactList(categorisations),
1138
+ provision_agreements=ArtefactList(provision_agreements),
1139
+ agency_schemes=ArtefactList(agency_schemes),
1140
+ data_provider_schemes=ArtefactList(data_provider_schemes),
1141
+ constraints=ArtefactList(constraints),
1142
+ structure_sets=ArtefactList(structure_sets),
1143
+ hierarchies=ArtefactList(hierarchies),
1144
+ )
1145
+ if registry is not None:
1146
+ registry.add_all(msg.all_artefacts())
1147
+ return msg