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,81 @@
1
+ """InternationalString — dict-like multilingual string."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Iterator
9
+
10
+
11
+ class InternationalString:
12
+ """A multilingual string where language access is always explicit.
13
+
14
+ Stores key-value pairs of language code → text. Immutable after
15
+ construction (no ``__setitem__``).
16
+
17
+ Example:
18
+ s = InternationalString(en="Annual", fr="Annuel")
19
+ s["en"] # "Annual"
20
+ s.get("de", "en") # "Annual" (fallback)
21
+ "fr" in s # True
22
+ s.langs # ["en", "fr"]
23
+ """
24
+
25
+ __slots__ = ("_data",)
26
+
27
+ def __init__(self, **kwargs: str) -> None:
28
+ self._data: dict[str, str] = dict(kwargs)
29
+
30
+ # ── Construction helpers ───────────────────────────────────────────────
31
+
32
+ @classmethod
33
+ def of(cls, pairs: dict[str, str]) -> InternationalString:
34
+ """Build from an existing dict."""
35
+ obj = cls.__new__(cls)
36
+ obj._data = dict(pairs) # noqa: SLF001
37
+ return obj
38
+
39
+ # ── Access ────────────────────────────────────────────────────────────
40
+
41
+ def __getitem__(self, lang: str) -> str:
42
+ """Return the string for *lang*, raising KeyError if absent."""
43
+ return self._data[lang]
44
+
45
+ def get(self, *langs: str) -> str | None:
46
+ """Return the first available language in priority order, or None."""
47
+ for lang in langs:
48
+ if lang in self._data:
49
+ return self._data[lang]
50
+ return None
51
+
52
+ def __contains__(self, lang: object) -> bool:
53
+ return lang in self._data
54
+
55
+ def __iter__(self) -> Iterator[str]:
56
+ return iter(self._data)
57
+
58
+ def __len__(self) -> int:
59
+ return len(self._data)
60
+
61
+ def __bool__(self) -> bool:
62
+ return bool(self._data)
63
+
64
+ @property
65
+ def langs(self) -> list[str]:
66
+ """List of available language codes."""
67
+ return list(self._data)
68
+
69
+ # ── Equality & repr ───────────────────────────────────────────────────
70
+
71
+ def __eq__(self, other: object) -> bool:
72
+ if isinstance(other, InternationalString):
73
+ return self._data == other._data
74
+ return NotImplemented
75
+
76
+ def __hash__(self) -> int:
77
+ return hash(tuple(sorted(self._data.items())))
78
+
79
+ def __repr__(self) -> str:
80
+ pairs = ", ".join(f"{k}={v!r}" for k, v in self._data.items())
81
+ return f"InternationalString({pairs})"
@@ -0,0 +1,134 @@
1
+ """StructureSet, CodelistMap, and CodeMap — SDMX structural mapping types."""
2
+
3
+ from datetime import datetime
4
+ from typing import ClassVar
5
+
6
+ from attrs import define, field
7
+
8
+ from sdmxlib.model.annotations import Annotations
9
+ from sdmxlib.model.base import MaintainableArtefact
10
+ from sdmxlib.model.istring import InternationalString
11
+ from sdmxlib.model.organisation import Agency, _to_agency, _to_annotations, _to_istring
12
+ from sdmxlib.model.ref import AnyRef
13
+
14
+
15
+ @define
16
+ class CodeMap:
17
+ """Single code-level mapping: source code id → target code id."""
18
+
19
+ source_id: str
20
+ target_id: str
21
+
22
+
23
+ @define
24
+ class CodelistMap:
25
+ """Maps one codelist onto another, code by code."""
26
+
27
+ id: str
28
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
29
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
30
+ source: AnyRef | None = None
31
+ target: AnyRef | None = None
32
+ maps: tuple[CodeMap, ...] = ()
33
+
34
+ def translate(self, source_code: str) -> str | None:
35
+ """Return the target code id for a source code id, or None if unmapped."""
36
+ for m in self.maps:
37
+ if m.source_id == source_code:
38
+ return m.target_id
39
+ return None
40
+
41
+
42
+ @define
43
+ class ConceptMap:
44
+ """Single concept-level mapping: source concept id → target concept id."""
45
+
46
+ source_id: str
47
+ target_id: str
48
+
49
+
50
+ @define
51
+ class ConceptSchemeMap:
52
+ """Maps one concept scheme onto another, concept by concept."""
53
+
54
+ id: str
55
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
56
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
57
+ source: AnyRef | None = None
58
+ target: AnyRef | None = None
59
+ maps: tuple[ConceptMap, ...] = ()
60
+
61
+
62
+ @define
63
+ class CategoryMap:
64
+ """Single category-level mapping: source category id → target category id."""
65
+
66
+ source_id: str
67
+ target_id: str
68
+
69
+
70
+ @define
71
+ class CategorySchemeMap:
72
+ """Maps one category scheme onto another, category by category."""
73
+
74
+ id: str
75
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
76
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
77
+ source: AnyRef | None = None
78
+ target: AnyRef | None = None
79
+ maps: tuple[CategoryMap, ...] = ()
80
+
81
+
82
+ @define
83
+ class ComponentMap:
84
+ """Single component-level mapping: source component id → target component id."""
85
+
86
+ source_id: str
87
+ target_id: str
88
+
89
+
90
+ @define
91
+ class DataStructureMap:
92
+ """Maps one DSD onto another, component by component."""
93
+
94
+ id: str
95
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
96
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
97
+ source: AnyRef | None = None
98
+ target: AnyRef | None = None
99
+ maps: tuple[ComponentMap, ...] = ()
100
+
101
+
102
+ @define
103
+ class DataflowMap:
104
+ """Maps one dataflow onto another (artefact-level only, no item maps)."""
105
+
106
+ id: str
107
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
108
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
109
+ source: AnyRef | None = None
110
+ target: AnyRef | None = None
111
+
112
+
113
+ @define
114
+ class StructureSet(MaintainableArtefact):
115
+ """Maintainable container for structural mappings between artefacts."""
116
+
117
+ sdmx_package: ClassVar[str] = "mapping"
118
+ sdmx_class: ClassVar[str] = "StructureSet"
119
+ msg_field: ClassVar[str] = "structure_sets"
120
+
121
+ id: str = ""
122
+ maintainer: Agency = field(factory=lambda: Agency(id=""), converter=_to_agency)
123
+ version: str = "1.0"
124
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
125
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
126
+ is_final: bool = False
127
+ codelist_maps: tuple[CodelistMap, ...] = ()
128
+ concept_scheme_maps: tuple[ConceptSchemeMap, ...] = ()
129
+ category_scheme_maps: tuple[CategorySchemeMap, ...] = ()
130
+ data_structure_maps: tuple[DataStructureMap, ...] = ()
131
+ dataflow_maps: tuple[DataflowMap, ...] = ()
132
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
133
+ valid_from: datetime | None = None
134
+ valid_to: datetime | None = None
@@ -0,0 +1,70 @@
1
+ """StructureMessage — version-neutral envelope for SDMX structural artefacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from attrs import field, frozen
8
+
9
+ from sdmxlib.model.collections import ArtefactList
10
+
11
+ if TYPE_CHECKING:
12
+ from sdmxlib.model.category import Categorisation, CategoryScheme
13
+ from sdmxlib.model.codelist import Codelist
14
+ from sdmxlib.model.concept import ConceptScheme
15
+ from sdmxlib.model.constraint import ContentConstraint
16
+ from sdmxlib.model.dataflow import Dataflow
17
+ from sdmxlib.model.datastructure import DataStructure
18
+ from sdmxlib.model.hierarchy import Hierarchy
19
+ from sdmxlib.model.mapping import StructureSet
20
+ from sdmxlib.model.organisation import AgencyScheme, DataProviderScheme
21
+ from sdmxlib.model.provision import ProvisionAgreement
22
+
23
+
24
+ def _to_artefact_list(v: Any) -> ArtefactList: # type: ignore[type-arg]
25
+ return v if isinstance(v, ArtefactList) else ArtefactList(v)
26
+
27
+
28
+ @frozen
29
+ class StructureMessage:
30
+ """Version-neutral envelope returned by the format layer.
31
+
32
+ The format reader populates whichever collections are present in the
33
+ parsed message. Cross-references within the message are auto-resolved
34
+ by the reader.
35
+
36
+ Example:
37
+ msg = parse(xml_bytes)
38
+ dsd = msg.data_structures[0]
39
+ cl = msg.codelists["SDMX:CL_FREQ(1.0)"]
40
+ """
41
+
42
+ codelists: ArtefactList[Codelist] = field(factory=ArtefactList, converter=_to_artefact_list)
43
+ concept_schemes: ArtefactList[ConceptScheme] = field(factory=ArtefactList, converter=_to_artefact_list)
44
+ data_structures: ArtefactList[DataStructure] = field(factory=ArtefactList, converter=_to_artefact_list)
45
+ dataflows: ArtefactList[Dataflow] = field(factory=ArtefactList, converter=_to_artefact_list)
46
+ category_schemes: ArtefactList[CategoryScheme] = field(factory=ArtefactList, converter=_to_artefact_list)
47
+ categorisations: ArtefactList[Categorisation] = field(factory=ArtefactList, converter=_to_artefact_list)
48
+ provision_agreements: ArtefactList[ProvisionAgreement] = field(factory=ArtefactList, converter=_to_artefact_list)
49
+ agency_schemes: ArtefactList[AgencyScheme] = field(factory=ArtefactList, converter=_to_artefact_list)
50
+ data_provider_schemes: ArtefactList[DataProviderScheme] = field(factory=ArtefactList, converter=_to_artefact_list)
51
+ constraints: ArtefactList[ContentConstraint] = field(factory=ArtefactList, converter=_to_artefact_list)
52
+ structure_sets: ArtefactList[StructureSet] = field(factory=ArtefactList, converter=_to_artefact_list)
53
+ hierarchies: ArtefactList[Hierarchy] = field(factory=ArtefactList, converter=_to_artefact_list)
54
+
55
+ def all_artefacts(self) -> list[Any]:
56
+ """Flat list of every artefact in the message."""
57
+ return [
58
+ *self.codelists,
59
+ *self.concept_schemes,
60
+ *self.data_structures,
61
+ *self.dataflows,
62
+ *self.category_schemes,
63
+ *self.categorisations,
64
+ *self.provision_agreements,
65
+ *self.agency_schemes,
66
+ *self.data_provider_schemes,
67
+ *self.constraints,
68
+ *self.structure_sets,
69
+ *self.hierarchies,
70
+ ]
@@ -0,0 +1,149 @@
1
+ """Organisation types — Agency, DataProvider, AgencyScheme, DataProviderScheme."""
2
+
3
+ from datetime import datetime
4
+ from typing import ClassVar
5
+
6
+ from attrs import define, evolve, field
7
+
8
+ from sdmxlib.model.annotations import Annotation, Annotations
9
+ from sdmxlib.model.base import MaintainableArtefact
10
+ from sdmxlib.model.collections import ItemList
11
+ from sdmxlib.model.istring import InternationalString
12
+
13
+
14
+ def _to_istring(v: InternationalString | dict[str, str]) -> InternationalString:
15
+ return InternationalString.of(v) if isinstance(v, dict) else v
16
+
17
+
18
+ def _to_annotations(v: Annotations | list[Annotation]) -> Annotations:
19
+ return Annotations(v) if isinstance(v, list) else v
20
+
21
+
22
+ @define
23
+ class Contact:
24
+ """Contact information for an agency or data provider."""
25
+
26
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
27
+ department: InternationalString = field(factory=InternationalString, converter=_to_istring)
28
+ role: InternationalString = field(factory=InternationalString, converter=_to_istring)
29
+ phones: tuple[str, ...] = ()
30
+ faxes: tuple[str, ...] = ()
31
+ uris: tuple[str, ...] = ()
32
+ emails: tuple[str, ...] = ()
33
+
34
+
35
+ @define
36
+ class Agency:
37
+ """An organisation that maintains SDMX artefacts."""
38
+
39
+ id: str
40
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
41
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
42
+ contacts: tuple[Contact, ...] = ()
43
+ parent: "Agency | None" = None
44
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
45
+
46
+
47
+ def _to_agency(v: "Agency | str") -> "Agency":
48
+ return Agency(id=v) if isinstance(v, str) else v
49
+
50
+
51
+ def _to_item_list_agency(v: "ItemList[Agency] | list[Agency]") -> "ItemList[Agency]":
52
+ return v if isinstance(v, ItemList) else ItemList(v)
53
+
54
+
55
+ def _to_item_list_provider(v: "ItemList[DataProvider] | list[DataProvider]") -> "ItemList[DataProvider]":
56
+ return v if isinstance(v, ItemList) else ItemList(v)
57
+
58
+
59
+ @define
60
+ class DataProvider:
61
+ """An organisation that provides data under a ProvisionAgreement."""
62
+
63
+ id: str
64
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
65
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
66
+ contacts: tuple[Contact, ...] = ()
67
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
68
+
69
+
70
+ @define
71
+ class AgencyScheme(MaintainableArtefact):
72
+ """Maintainable container for Agency items."""
73
+
74
+ sdmx_package: ClassVar[str] = "base"
75
+ sdmx_class: ClassVar[str] = "AgencyScheme"
76
+ msg_field: ClassVar[str] = "agency_schemes"
77
+
78
+ id: str
79
+ maintainer: Agency = field(converter=_to_agency)
80
+ version: str = "1.0"
81
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
82
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
83
+ is_final: bool = False
84
+ agencies: ItemList[Agency] = field(factory=ItemList, converter=_to_item_list_agency)
85
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
86
+ valid_from: datetime | None = None
87
+ valid_to: datetime | None = None
88
+
89
+ def add(self, agencies: "Agency | list[Agency]") -> "AgencyScheme":
90
+ """Return a new AgencyScheme with *agencies* added.
91
+
92
+ Accepts a single Agency or a list of Agencies.
93
+ """
94
+ new = [agencies] if isinstance(agencies, Agency) else list(agencies)
95
+ return evolve(self, agencies=[*self.agencies, *new])
96
+
97
+ def drop(self, agency_ids: str | list[str]) -> "AgencyScheme":
98
+ """Return a new AgencyScheme with the given agency(ies) removed.
99
+
100
+ Accepts a single id string or a list of id strings.
101
+ Raises KeyError if any id is not present.
102
+ """
103
+ ids = [agency_ids] if isinstance(agency_ids, str) else list(agency_ids)
104
+ missing = [i for i in ids if i not in self.agencies]
105
+ if missing:
106
+ raise KeyError(missing[0] if len(missing) == 1 else missing)
107
+ id_set = set(ids)
108
+ return evolve(self, agencies=[a for a in self.agencies if a.id not in id_set])
109
+
110
+
111
+ @define
112
+ class DataProviderScheme(MaintainableArtefact):
113
+ """Maintainable container for DataProvider items."""
114
+
115
+ sdmx_package: ClassVar[str] = "base"
116
+ sdmx_class: ClassVar[str] = "DataProviderScheme"
117
+ msg_field: ClassVar[str] = "data_provider_schemes"
118
+
119
+ id: str
120
+ maintainer: Agency = field(converter=_to_agency)
121
+ version: str = "1.0"
122
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
123
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
124
+ is_final: bool = False
125
+ providers: ItemList[DataProvider] = field(factory=ItemList, converter=_to_item_list_provider)
126
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
127
+ valid_from: datetime | None = None
128
+ valid_to: datetime | None = None
129
+
130
+ def add(self, providers: "DataProvider | list[DataProvider]") -> "DataProviderScheme":
131
+ """Return a new DataProviderScheme with *providers* added.
132
+
133
+ Accepts a single DataProvider or a list of DataProviders.
134
+ """
135
+ new = [providers] if isinstance(providers, DataProvider) else list(providers)
136
+ return evolve(self, providers=[*self.providers, *new])
137
+
138
+ def drop(self, provider_ids: str | list[str]) -> "DataProviderScheme":
139
+ """Return a new DataProviderScheme with the given provider(s) removed.
140
+
141
+ Accepts a single id string or a list of id strings.
142
+ Raises KeyError if any id is not present.
143
+ """
144
+ ids = [provider_ids] if isinstance(provider_ids, str) else list(provider_ids)
145
+ missing = [i for i in ids if i not in self.providers]
146
+ if missing:
147
+ raise KeyError(missing[0] if len(missing) == 1 else missing)
148
+ id_set = set(ids)
149
+ return evolve(self, providers=[p for p in self.providers if p.id not in id_set])
@@ -0,0 +1,45 @@
1
+ """ProvisionAgreement — binds a data provider to a dataflow."""
2
+
3
+ from datetime import datetime
4
+ from typing import ClassVar
5
+
6
+ from attrs import define, field
7
+
8
+ from sdmxlib.model.annotations import Annotations
9
+ from sdmxlib.model.base import MaintainableArtefact
10
+ from sdmxlib.model.dataflow import Dataflow
11
+ from sdmxlib.model.istring import InternationalString
12
+ from sdmxlib.model.organisation import Agency, _to_agency, _to_annotations, _to_istring
13
+ from sdmxlib.model.ref import AnyRef, Ref
14
+
15
+
16
+ def _to_dataflow_ref(v: "Ref[Dataflow] | Dataflow | None") -> "Ref[Dataflow] | None":
17
+ if isinstance(v, Dataflow):
18
+ return Ref.to(v)
19
+ return v
20
+
21
+
22
+ @define
23
+ class ProvisionAgreement(MaintainableArtefact):
24
+ """Binds a data provider to a dataflow.
25
+
26
+ Example:
27
+ pa.dataflow() # Dataflow (if resolved)
28
+ pa.dataflow().structure() # DataStructure
29
+ pa.dataflow().structure.dimensions # ItemList[Dimension]
30
+ """
31
+
32
+ sdmx_package: ClassVar[str] = "registry"
33
+ sdmx_class: ClassVar[str] = "ProvisionAgreement"
34
+ msg_field: ClassVar[str] = "provision_agreements"
35
+
36
+ id: str
37
+ maintainer: Agency = field(converter=_to_agency)
38
+ version: str = "1.0"
39
+ name: InternationalString = field(factory=InternationalString, converter=_to_istring)
40
+ description: InternationalString = field(factory=InternationalString, converter=_to_istring)
41
+ annotations: Annotations = field(factory=Annotations, converter=_to_annotations)
42
+ valid_from: datetime | None = None
43
+ valid_to: datetime | None = None
44
+ dataflow: Ref[Dataflow] | None = field(default=None, converter=_to_dataflow_ref)
45
+ data_provider: AnyRef | None = None
sdmxlib/model/ref.py ADDED
@@ -0,0 +1,195 @@
1
+ """Ref[T], HasUrn, UnresolvedRef, SdmxRef — SDMX reference abstraction."""
2
+
3
+ from typing import Any, ClassVar, Protocol, runtime_checkable
4
+
5
+ from attrs import define, field, frozen
6
+
7
+ from sdmxlib.model.urn import SdmxUrn
8
+
9
+
10
+ @runtime_checkable
11
+ class HasUrn(Protocol):
12
+ """Protocol for SDMX artefacts that can be referenced by URN.
13
+
14
+ Any MaintainableArtefact satisfies this protocol. Accepts anywhere
15
+ a Ref target is needed — allows ``Ref(cl_freq)`` instead of
16
+ ``Ref.unresolved(cl_freq.urn)``.
17
+ """
18
+
19
+ sdmx_package: ClassVar[str]
20
+ sdmx_class: ClassVar[str]
21
+ agency_id: str
22
+ id: str
23
+ version: str
24
+
25
+
26
+ @define
27
+ class Ref[T]:
28
+ """A reference to an SDMX artefact.
29
+
30
+ Always carries the URN. May also carry the resolved object once
31
+ the artefact has been added to a Registry.
32
+
33
+ ``None`` on a parent field means "no reference exists."
34
+ ``Ref[T]`` means "a reference exists, resolved or not."
35
+
36
+ Access tiers:
37
+
38
+ - ``ref.urn``, ``ref.id``, ``ref.agency`` — URN metadata, always available
39
+ - ``ref.resolved`` — bool check
40
+ - ``ref.codes``, ``ref.name`` etc — proxy to resolved object, raises
41
+ ``UnresolvedRef`` if not yet resolved
42
+
43
+ Construction:
44
+
45
+ Ref(cl_freq) # from domain object — resolved
46
+ Ref(cl_freq.urn) # from SdmxUrn — unresolved
47
+ Ref.unresolved(urn) # explicit unresolved factory
48
+ Ref.of(urn, obj) # explicit resolved factory
49
+
50
+ Example:
51
+ ref = Ref(cl_freq_urn)
52
+ ref.id # "CL_FREQ"
53
+ ref.resolved # False
54
+ ref.codes # raises UnresolvedRef
55
+
56
+ reg.add(cl_freq)
57
+ ref.resolved # True (if interned through registry)
58
+ ref.codes # ItemList[Code]
59
+ """
60
+
61
+ urn: SdmxUrn[T]
62
+ _resolved: T | None = field(default=None, repr=False, alias="_resolved")
63
+
64
+ def __attrs_post_init__(self) -> None:
65
+ # Allow passing a HasUrn (MaintainableArtefact) directly
66
+ if not isinstance(self.urn, SdmxUrn):
67
+ obj = self.urn # type: ignore[assignment]
68
+ self.urn = SdmxUrn.make( # type: ignore[assignment]
69
+ type(obj),
70
+ package=obj.sdmx_package, # type: ignore[union-attr]
71
+ artefact_class=obj.sdmx_class, # type: ignore[union-attr]
72
+ agency=obj.agency_id, # type: ignore[union-attr]
73
+ id=obj.id, # type: ignore[union-attr]
74
+ version=obj.version, # type: ignore[union-attr]
75
+ )
76
+ if self._resolved is None:
77
+ self._resolved = obj # type: ignore[assignment]
78
+
79
+ # ── Resolution ────────────────────────────────────────────────────────
80
+
81
+ @property
82
+ def resolved(self) -> bool:
83
+ """True if the referenced object has been resolved."""
84
+ return self._resolved is not None
85
+
86
+ def __call__(self) -> T:
87
+ """Unwrap to the resolved object. Raises UnresolvedRef if not resolved."""
88
+ if self._resolved is None:
89
+ raise UnresolvedRef(self.urn)
90
+ return self._resolved
91
+
92
+ def __getattr__(self, name: str) -> Any:
93
+ """Proxy attribute access to the resolved object.
94
+
95
+ Allows ``ref.codes`` instead of unwrapping manually.
96
+ Raises ``UnresolvedRef`` if the ref has not been resolved.
97
+ """
98
+ try:
99
+ resolved = self._resolved
100
+ except AttributeError:
101
+ raise AttributeError(name) from None
102
+ if resolved is None:
103
+ raise UnresolvedRef(self.urn)
104
+ return getattr(resolved, name)
105
+
106
+ # ── URN metadata ──────────────────────────────────────────────────────
107
+
108
+ @property
109
+ def id(self) -> str:
110
+ """Artefact id from the URN."""
111
+ return self.urn.id
112
+
113
+ @property
114
+ def agency(self) -> str:
115
+ """Agency id from the URN."""
116
+ return self.urn.agency
117
+
118
+ @property
119
+ def version(self) -> str:
120
+ """Version from the URN."""
121
+ return self.urn.version
122
+
123
+ @property
124
+ def item_id(self) -> str | None:
125
+ """Item id from the URN (e.g. the concept id within a ConceptScheme)."""
126
+ return self.urn.item_id
127
+
128
+ # ── Factories ─────────────────────────────────────────────────────────
129
+
130
+ @classmethod
131
+ def unresolved(cls, urn: SdmxUrn[T]) -> "Ref[T]":
132
+ """Create an unresolved Ref carrying the URN only."""
133
+ return cls(urn=urn)
134
+
135
+ @classmethod
136
+ def of(cls, urn: SdmxUrn[T], obj: T) -> "Ref[T]":
137
+ """Create a resolved Ref. Useful in tests and format readers."""
138
+ return cls(urn=urn, _resolved=obj)
139
+
140
+ @classmethod
141
+ def to(cls, obj: T) -> "Ref[T]":
142
+ """Create a resolved Ref from a MaintainableArtefact instance.
143
+
144
+ The object must satisfy HasUrn — it needs ``agency_id``, ``id``,
145
+ ``version``, ``sdmx_package``, and ``sdmx_class`` attributes.
146
+
147
+ Example:
148
+ cl = Codelist(id="CL_FREQ", maintainer="SDMX", version="1.0", ...)
149
+ ref = Ref.to(cl)
150
+ ref.resolved # True
151
+ """
152
+ return cls(urn=obj) # type: ignore[arg-type] # delegates to __post_init__
153
+
154
+ # ── Boolean ───────────────────────────────────────────────────────────
155
+
156
+ def __bool__(self) -> bool:
157
+ """Always True — the Ref exists (use .resolved to check resolution)."""
158
+ return True
159
+
160
+ # ── Repr ──────────────────────────────────────────────────────────────
161
+
162
+ def __repr__(self) -> str:
163
+ if self._resolved is not None:
164
+ return f"Ref({self.urn.id} → resolved)"
165
+ return f"Ref({self.urn.id} [unresolved])"
166
+
167
+
168
+ class UnresolvedRef(Exception): # noqa: N818
169
+ """Raised when accessing an unresolved Ref."""
170
+
171
+ def __init__(self, urn: SdmxUrn[Any]) -> None:
172
+ self.urn = urn
173
+ super().__init__(f"{urn} has not been resolved — add the artefact to a Registry first.")
174
+
175
+
176
+ @frozen
177
+ class SdmxRef:
178
+ """Partial artefact identity for filtering and lookup.
179
+
180
+ All fields optional. ``None`` means "match any."
181
+ Used as ``ArtefactList`` key and in ``Query``.
182
+
183
+ Example:
184
+ SdmxRef(agency="SDMX", id="CL_FREQ")
185
+ SdmxRef(id="CL_FREQ", version="1.0")
186
+ """
187
+
188
+ agency: str | None = None
189
+ id: str | None = None
190
+ version: str | None = None
191
+
192
+
193
+ # Type alias for Ref[Any] — used in format layers and internal code where the
194
+ # specific artefact type is not yet known or not relevant.
195
+ AnyRef = Ref[Any]