sdmxlib 0.44.0__tar.gz → 0.45.0__tar.gz

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 (92) hide show
  1. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/pyproject.toml +1 -1
  3. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/__init__.py +3 -1
  4. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/metadata.py +7 -0
  5. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/reader.py +95 -14
  6. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +37 -3
  7. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/writer.py +124 -67
  8. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/_common.py +3 -0
  9. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +53 -1
  10. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +97 -9
  11. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +8 -0
  12. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +2 -0
  13. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +36 -0
  14. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/local/__init__.py +2 -1
  15. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/local/data_store.py +38 -32
  16. sdmxlib-0.45.0/src/sdmxlib/local/sql.py +166 -0
  17. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/code_query.py +2 -2
  18. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/istring.py +9 -0
  19. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/lazy.py +29 -0
  20. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/readers.py +80 -6
  21. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/schema.py +14 -1
  22. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/writers.py +40 -4
  23. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/README.md +0 -0
  24. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/_duckdb.py +0 -0
  25. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/__init__.py +0 -0
  26. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/client.py +0 -0
  27. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/federated.py +0 -0
  28. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/filters.py +0 -0
  29. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/policy.py +0 -0
  30. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/providers.py +0 -0
  31. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/query.py +0 -0
  32. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/registry.py +0 -0
  33. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/api/session.py +0 -0
  34. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/builders/__init__.py +0 -0
  35. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  36. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/catalog.py +0 -0
  37. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/data_store.py +0 -0
  38. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/__init__.py +0 -0
  39. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  40. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  41. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  42. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  43. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +0 -0
  44. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +0 -0
  45. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +0 -0
  46. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  47. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  48. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  49. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  50. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  51. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  52. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  53. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  54. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  55. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  56. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  57. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/local/registry.py +0 -0
  58. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/__init__.py +0 -0
  59. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/annotations.py +0 -0
  60. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/base.py +0 -0
  61. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/binding.py +0 -0
  62. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/category.py +0 -0
  63. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/codelist.py +0 -0
  64. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/collections.py +0 -0
  65. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/concept.py +0 -0
  66. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/constraint.py +0 -0
  67. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/convert.py +0 -0
  68. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/dataflow.py +0 -0
  69. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/dataset.py +0 -0
  70. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/datastructure.py +0 -0
  71. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/expr.py +0 -0
  72. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy.py +0 -0
  73. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy_query.py +0 -0
  74. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  75. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/mapping.py +0 -0
  76. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/message.py +0 -0
  77. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataflow.py +0 -0
  78. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataset.py +0 -0
  79. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  80. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/organisation.py +0 -0
  81. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/provision.py +0 -0
  82. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/ref.py +0 -0
  83. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/registry.py +0 -0
  84. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/representation.py +0 -0
  85. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/urn.py +0 -0
  86. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/model/validation.py +0 -0
  87. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/polars.py +0 -0
  88. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/py.typed +0 -0
  89. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/rest.py +0 -0
  90. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/sql.py +0 -0
  91. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/__init__.py +0 -0
  92. {sdmxlib-0.44.0 → sdmxlib-0.45.0}/src/sdmxlib/storage/_kinds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.44.0
3
+ Version: 0.45.0
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.44.0"
3
+ version = "0.45.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -10,7 +10,7 @@ from sdmxlib.catalog import DataflowEntry, FederatedCatalog
10
10
  from sdmxlib.data_store import DataStore
11
11
  from sdmxlib.sql import CompileContext, Predicate, SqlRelation
12
12
  from sdmxlib.formats import parse, read, serialize, to_json, to_xml
13
- from sdmxlib.local import LazyCodelist, LazyHierarchy, Registry
13
+ from sdmxlib.local import LazyCodelist, LazyHierarchy, QueryPlan, Registry, SchemaInspector
14
14
  from sdmxlib.local.data_store import DuckDBDataStore, DuckLakeVersionError
15
15
  from sdmxlib.rest import ComponentFilter, parse_components
16
16
  from sdmxlib.model.convert import from_dict, to_dict
@@ -206,6 +206,7 @@ __all__ = [
206
206
  "Predicate",
207
207
  "Provider",
208
208
  "ProvisionAgreement",
209
+ "QueryPlan",
209
210
  "Ref",
210
211
  "ReferenceIssue",
211
212
  "ReferencePeriod",
@@ -220,6 +221,7 @@ __all__ = [
220
221
  "Representation",
221
222
  "RepresentationMap",
222
223
  "RestRegistry",
224
+ "SchemaInspector",
223
225
  "SdmxRef",
224
226
  "SdmxUrn",
225
227
  "SqlRelation",
@@ -111,6 +111,10 @@ def _read_metadata_set(obj: dict[str, Any]) -> MetadataSet:
111
111
  targets = [Ref[Any].unresolved(SdmxUrn.parse(t)) for t in obj.get("targets") or []]
112
112
  attributes = [_read_reported_attribute(a) for a in obj.get("attributes") or []]
113
113
 
114
+ def _dt(key: str) -> datetime | None:
115
+ val = obj.get(key)
116
+ return datetime.fromisoformat(val) if isinstance(val, str) and val else None
117
+
114
118
  return MetadataSet(
115
119
  id=obj["id"],
116
120
  maintainer=Agency(id=obj.get("agencyID", "")),
@@ -119,6 +123,9 @@ def _read_metadata_set(obj: dict[str, Any]) -> MetadataSet:
119
123
  name=_read_istring(obj, "name"),
120
124
  description=_read_istring(obj, "description"),
121
125
  is_final=bool(obj.get("isFinal", False)),
126
+ # Writer emits validFrom/validTo; parse them back so they round-trip (#159).
127
+ valid_from=_dt("validFrom"),
128
+ valid_to=_dt("validTo"),
122
129
  targets=targets,
123
130
  attributes=ItemList(attributes),
124
131
  )
@@ -5,7 +5,7 @@ All references within the message are auto-resolved where possible.
5
5
  """
6
6
 
7
7
  import warnings
8
- from datetime import datetime
8
+ from datetime import date, datetime
9
9
  from typing import Any
10
10
 
11
11
  import orjson
@@ -45,11 +45,14 @@ from sdmxlib.model.mapping import (
45
45
  CategorySchemeMap,
46
46
  ComponentMap,
47
47
  ConceptSchemeMap,
48
+ IndexValueMap,
48
49
  ItemMap,
49
50
  LiteralValueMap,
50
51
  MapDataType,
52
+ RegexValueMap,
51
53
  RepresentationMap,
52
54
  StructureMap,
55
+ ValueMap,
53
56
  )
54
57
  from sdmxlib.model.message import StructureMessage
55
58
  from sdmxlib.model.metadataflow import Metadataflow
@@ -90,25 +93,43 @@ def _read_datetime_str(obj: dict[str, Any], key: str) -> datetime | None:
90
93
  return datetime.fromisoformat(val)
91
94
 
92
95
 
96
+ def _annotation_url(ann: dict[str, Any]) -> str | None:
97
+ """Extract the annotation URL from its ``links`` entry (``rel: "self"``).
98
+
99
+ Per the SDMX-JSON 2.0 structure schema an annotation's URL rides in
100
+ the ``links`` array under the ``self`` relationship. Fall back to a
101
+ bare ``url`` key for lenience toward non-conformant producers.
102
+ """
103
+ links = ann.get("links")
104
+ if isinstance(links, list):
105
+ for link in links:
106
+ if isinstance(link, dict) and link.get("rel") == "self":
107
+ href = link.get("href")
108
+ if isinstance(href, str):
109
+ return href
110
+ url = ann.get("url")
111
+ return url if isinstance(url, str) else None
112
+
113
+
93
114
  def _read_annotations(obj: dict[str, Any]) -> Annotations:
94
115
  """Parse the optional ``annotations`` array on any artefact."""
95
116
  raw = obj.get("annotations")
96
117
  if not raw:
97
118
  return Annotations()
98
- items: list[Annotation] = []
99
- for ann in raw:
100
- text_raw = ann.get("text")
101
- text = InternationalString.of(text_raw) if isinstance(text_raw, dict) else InternationalString()
102
- items.append(
119
+ return Annotations(
120
+ [
103
121
  Annotation(
104
122
  id=ann.get("id"),
105
123
  title=ann.get("title"),
106
124
  type=ann.get("type"),
107
- url=ann.get("url"),
108
- text=text,
125
+ url=_annotation_url(ann),
126
+ # SDMX-JSON 2.0: localised body splits into ``text`` (best
127
+ # match) + ``texts`` (lang map), same shape as name/desc.
128
+ text=_istring(ann, "text"),
109
129
  )
110
- )
111
- return Annotations(items)
130
+ for ann in raw
131
+ ]
132
+ )
112
133
 
113
134
 
114
135
  # ── Ref / URN helpers ─────────────────────────────────────────────────────────
@@ -459,6 +480,21 @@ def _read_attachment(
459
480
  return AttachmentLevel.DATASET, None, None, None
460
481
 
461
482
 
483
+ def _read_concept_roles(obj: dict[str, Any]) -> tuple[str, ...]:
484
+ """Parse ``conceptRoles`` URN strings → tuple of role ids (item id)."""
485
+ roles: list[str] = []
486
+ for raw in obj.get("conceptRoles") or []:
487
+ if not isinstance(raw, str):
488
+ continue
489
+ try:
490
+ urn = SdmxUrn.parse(raw)
491
+ except (ValueError, TypeError):
492
+ continue
493
+ if urn.item_id is not None:
494
+ roles.append(urn.item_id)
495
+ return tuple(roles)
496
+
497
+
462
498
  def _read_dimension(dim: dict[str, Any], index: dict[str, Any]) -> Dimension:
463
499
  concept_ref = _resolve_urn(dim.get("conceptIdentity"), index)
464
500
  return Dimension(
@@ -466,6 +502,7 @@ def _read_dimension(dim: dict[str, Any], index: dict[str, Any]) -> Dimension:
466
502
  concept=concept_ref,
467
503
  representation=_read_representation(dim.get("localRepresentation"), index),
468
504
  annotations=_read_annotations(dim),
505
+ concept_role=_read_concept_roles(dim),
469
506
  )
470
507
 
471
508
 
@@ -499,6 +536,7 @@ def _read_attribute(attr: dict[str, Any], index: dict[str, Any]) -> Attribute:
499
536
  concept=concept_ref,
500
537
  representation=_read_representation(attr.get("localRepresentation"), index),
501
538
  annotations=_read_annotations(attr),
539
+ concept_role=_read_concept_roles(attr),
502
540
  )
503
541
 
504
542
 
@@ -509,6 +547,7 @@ def _read_measure(meas: dict[str, Any], index: dict[str, Any]) -> Measure:
509
547
  concept=concept_ref,
510
548
  representation=_read_representation(meas.get("localRepresentation"), index),
511
549
  annotations=_read_annotations(meas),
550
+ concept_role=_read_concept_roles(meas),
512
551
  )
513
552
 
514
553
 
@@ -742,6 +781,37 @@ def _read_mapped_value(arr: Any) -> str | None:
742
781
  return str(entry)
743
782
 
744
783
 
784
+ def _read_value_map(m: dict[str, Any]) -> ValueMap | None:
785
+ """Reconstruct the correct ``ValueMap`` variant from a representationMapping.
786
+
787
+ The ``sourceValues`` MappedValueType entry discriminates the operation
788
+ via ``isRegEx`` (regex) or ``startIndex``/``endIndex`` (substring
789
+ slice); absent both it is a literal equality map. Reading only the
790
+ ``value`` collapses all three to literal (#159).
791
+ """
792
+ source_arr = m.get("sourceValues")
793
+ s_val = _read_mapped_value(source_arr)
794
+ t_val = _read_mapped_value(m.get("targetValues"))
795
+ if s_val is None or t_val is None:
796
+ return None
797
+ entry = source_arr[0] if isinstance(source_arr, list) and source_arr else {}
798
+ entry = entry if isinstance(entry, dict) else {}
799
+
800
+ def _d(key: str) -> date | None:
801
+ val = m.get(key)
802
+ return date.fromisoformat(val) if isinstance(val, str) and val else None
803
+
804
+ validity = {"valid_from": _d("validFrom"), "valid_to": _d("validTo")}
805
+ if entry.get("isRegEx") is True:
806
+ return RegexValueMap(source_value=s_val, target_value=t_val, **validity)
807
+ start, end = entry.get("startIndex"), entry.get("endIndex")
808
+ if isinstance(start, int) and isinstance(end, int):
809
+ return IndexValueMap(
810
+ source_value=s_val, target_value=t_val, source_start_index=start, source_end_index=end, **validity
811
+ )
812
+ return LiteralValueMap(source_value=s_val, target_value=t_val, **validity)
813
+
814
+
745
815
  def _read_structure_sets(
746
816
  data: dict[str, Any],
747
817
  index: dict[str, Any],
@@ -772,10 +842,9 @@ def _read_structure_sets(
772
842
  for rm in data.get("representationMaps") or []:
773
843
  value_maps = []
774
844
  for m in rm.get("representationMappings") or []:
775
- s_val = _read_mapped_value(m.get("sourceValues"))
776
- t_val = _read_mapped_value(m.get("targetValues"))
777
- if s_val is not None and t_val is not None:
778
- value_maps.append(LiteralValueMap(source_value=s_val, target_value=t_val))
845
+ vm = _read_value_map(m)
846
+ if vm is not None:
847
+ value_maps.append(vm)
779
848
  rep_maps.append(
780
849
  RepresentationMap(
781
850
  id=rm.get("id", ""),
@@ -786,6 +855,7 @@ def _read_structure_sets(
786
855
  source=_read_map_endpoint(rm.get("source"), index),
787
856
  target=_read_map_endpoint(rm.get("target"), index),
788
857
  maps=tuple(value_maps),
858
+ annotations=_read_annotations(rm),
789
859
  )
790
860
  )
791
861
  for csm in data.get("conceptSchemeMaps") or []:
@@ -804,6 +874,7 @@ def _read_structure_sets(
804
874
  source=_resolve_urn(csm.get("source"), index),
805
875
  target=_resolve_urn(csm.get("target"), index),
806
876
  maps=tuple(item_maps),
877
+ annotations=_read_annotations(csm),
807
878
  )
808
879
  )
809
880
  for catm in data.get("categorySchemeMaps") or []:
@@ -822,6 +893,7 @@ def _read_structure_sets(
822
893
  source=_resolve_urn(catm.get("source"), index),
823
894
  target=_resolve_urn(catm.get("target"), index),
824
895
  maps=tuple(item_maps),
896
+ annotations=_read_annotations(catm),
825
897
  )
826
898
  )
827
899
  for sm in data.get("structureMaps") or []:
@@ -840,6 +912,7 @@ def _read_structure_sets(
840
912
  source=_resolve_urn(sm.get("source"), index),
841
913
  target=_resolve_urn(sm.get("target"), index),
842
914
  maps=tuple(comp_maps),
915
+ annotations=_read_annotations(sm),
843
916
  )
844
917
  )
845
918
 
@@ -883,6 +956,8 @@ def _read_hierarchical_code(
883
956
  children=children,
884
957
  annotations=_read_annotations(obj),
885
958
  level=level,
959
+ valid_from=_read_datetime_str(obj, "validFrom"),
960
+ valid_to=_read_datetime_str(obj, "validTo"),
886
961
  )
887
962
 
888
963
 
@@ -899,10 +974,15 @@ def _read_hierarchies(
899
974
  levels_by_id: dict[str, Level] = {}
900
975
  node = h.get("level")
901
976
  while isinstance(node, dict):
977
+ coding = node.get("codingFormat")
978
+ # codingFormat is always a text format (never an enumeration), so
979
+ # the resolution index is unused — pass an empty one.
980
+ coding_facet = _read_representation({"format": coding}, {}) if isinstance(coding, dict) else None
902
981
  lv = Level(
903
982
  id=node["id"],
904
983
  name=_istring(node, "name"),
905
984
  description=_istring(node, "description"),
985
+ coding_format=coding_facet if isinstance(coding_facet, Facet) else None,
906
986
  annotations=_read_annotations(node),
907
987
  )
908
988
  levels.append(lv)
@@ -918,6 +998,7 @@ def _read_hierarchies(
918
998
  name=_istring(h, "name"),
919
999
  description=_istring(h, "description"),
920
1000
  is_final=bool(h.get("isFinal", False)),
1001
+ has_formal_levels=bool(h.get("hasFormalLevels", False)),
921
1002
  levels=levels,
922
1003
  tree=tree,
923
1004
  annotations=_read_annotations(h),
@@ -20,6 +20,7 @@ What it does NOT own:
20
20
 
21
21
  from typing import Any
22
22
 
23
+ from sdmxlib.model.annotations import Annotation
23
24
  from sdmxlib.model.codelist import Code, Codelist
24
25
  from sdmxlib.model.concept import Concept, ConceptScheme
25
26
  from sdmxlib.model.istring import InternationalString
@@ -37,13 +38,44 @@ def _emit_istring(obj: dict[str, Any], key: str, value: InternationalString | No
37
38
  """
38
39
  if not value:
39
40
  return
40
- pairs = dict(value.items())
41
- if not pairs:
42
- return
41
+ pairs = value.as_dict()
43
42
  obj[key] = next(iter(pairs.values()))
44
43
  obj[key + "s"] = pairs
45
44
 
46
45
 
46
+ def _annotation_dict(ann: Annotation) -> dict[str, Any]:
47
+ """Render one ``Annotation`` as a SDMX-JSON 2.0 ``annotations[*]`` entry.
48
+
49
+ Follows the structure-schema shape: ``id`` / ``title`` / ``type``
50
+ scalars (omitted when None), ``text`` + ``texts`` for the localised
51
+ body, and the annotation ``url`` as a ``links`` entry with
52
+ ``rel: "self"`` (per the schema's Annotation.links contract).
53
+ """
54
+ out: dict[str, Any] = {}
55
+ if ann.id is not None:
56
+ out["id"] = ann.id
57
+ if ann.title is not None:
58
+ out["title"] = ann.title
59
+ if ann.type is not None:
60
+ out["type"] = ann.type
61
+ _emit_istring(out, "text", ann.text)
62
+ if ann.url is not None:
63
+ out["links"] = [{"rel": "self", "href": ann.url}]
64
+ return out
65
+
66
+
67
+ def emit_annotations(obj: dict[str, Any], artefact: Any) -> None:
68
+ """Set ``obj["annotations"]`` from ``artefact.annotations`` when present.
69
+
70
+ Shared by every artefact- and item-level SDMX-JSON writer so annotations
71
+ round-trip through the reader. No-op when the artefact carries none.
72
+ """
73
+ annotations = getattr(artefact, "annotations", None)
74
+ if not annotations:
75
+ return
76
+ obj["annotations"] = [_annotation_dict(a) for a in annotations]
77
+
78
+
47
79
  def serialize_code(code: Code) -> dict[str, Any]:
48
80
  """Render one ``Code`` as a SDMX-JSON ``codelists[*].codes[*]`` entry.
49
81
 
@@ -59,6 +91,7 @@ def serialize_code(code: Code) -> dict[str, Any]:
59
91
  _emit_istring(obj, "description", code.description)
60
92
  if code.parent_id is not None:
61
93
  obj["parent"] = code.parent_id
94
+ emit_annotations(obj, code)
62
95
  return obj
63
96
 
64
97
 
@@ -100,6 +133,7 @@ def serialize_concept(concept: Concept) -> dict[str, Any]:
100
133
  tf = {k: v for k, v in pairs if v is not None}
101
134
  if tf:
102
135
  obj["coreRepresentation"] = {"format": tf}
136
+ emit_annotations(obj, concept)
103
137
  return obj
104
138
 
105
139