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.
- sdmxlib/__init__.py +155 -0
- sdmxlib/api/__init__.py +7 -0
- sdmxlib/api/client.py +246 -0
- sdmxlib/api/filters.py +67 -0
- sdmxlib/api/providers.py +66 -0
- sdmxlib/api/query.py +428 -0
- sdmxlib/api/registry.py +666 -0
- sdmxlib/api/session.py +124 -0
- sdmxlib/formats/__init__.py +166 -0
- sdmxlib/formats/sdmx_csv/__init__.py +5 -0
- sdmxlib/formats/sdmx_csv/reader.py +147 -0
- sdmxlib/formats/sdmx_json/__init__.py +1 -0
- sdmxlib/formats/sdmx_json/reader.py +897 -0
- sdmxlib/formats/sdmx_json/writer.py +698 -0
- sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
- sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
- sdmxlib/formats/sdmx_ml21/writer.py +709 -0
- sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
- sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
- sdmxlib/formats/sdmx_ml30/writer.py +730 -0
- sdmxlib/local/__init__.py +6 -0
- sdmxlib/local/_registry.py +368 -0
- sdmxlib/model/__init__.py +122 -0
- sdmxlib/model/annotations.py +103 -0
- sdmxlib/model/base.py +61 -0
- sdmxlib/model/category.py +88 -0
- sdmxlib/model/codelist.py +91 -0
- sdmxlib/model/collections.py +382 -0
- sdmxlib/model/concept.py +121 -0
- sdmxlib/model/constraint.py +92 -0
- sdmxlib/model/convert.py +268 -0
- sdmxlib/model/dataflow.py +49 -0
- sdmxlib/model/dataset.py +337 -0
- sdmxlib/model/datastructure.py +390 -0
- sdmxlib/model/expr.py +120 -0
- sdmxlib/model/hierarchy.py +111 -0
- sdmxlib/model/istring.py +81 -0
- sdmxlib/model/mapping.py +134 -0
- sdmxlib/model/message.py +70 -0
- sdmxlib/model/organisation.py +149 -0
- sdmxlib/model/provision.py +45 -0
- sdmxlib/model/ref.py +195 -0
- sdmxlib/model/registry.py +191 -0
- sdmxlib/model/representation.py +99 -0
- sdmxlib/model/urn.py +130 -0
- sdmxlib/model/validation.py +338 -0
- sdmxlib/polars.py +392 -0
- sdmxlib/py.typed +0 -0
- sdmxlib/storage/__init__.py +28 -0
- sdmxlib/storage/lazy.py +329 -0
- sdmxlib/storage/readers.py +310 -0
- sdmxlib/storage/schema.py +289 -0
- sdmxlib/storage/writers.py +503 -0
- sdmxlib-0.8.0.dist-info/METADATA +102 -0
- sdmxlib-0.8.0.dist-info/RECORD +58 -0
- sdmxlib-0.8.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Registry — pure-domain SDMX artefact store.
|
|
2
|
+
|
|
3
|
+
The ``Registry`` is the SDMX Information Model concept of a local artefact
|
|
4
|
+
graph: a URN-keyed store that connects artefacts through interned ``Ref``
|
|
5
|
+
objects.
|
|
6
|
+
|
|
7
|
+
Key behaviours:
|
|
8
|
+
|
|
9
|
+
- **One Ref per URN** — interning means every holder of a given Ref shares
|
|
10
|
+
the same object. Resolving it once propagates everywhere automatically.
|
|
11
|
+
- **Eager accept, lazy resolve** — add artefacts in any order; Refs get
|
|
12
|
+
resolved as their targets arrive.
|
|
13
|
+
- **Pure domain** — no network, no HTTP, no serialisation.
|
|
14
|
+
|
|
15
|
+
Example::
|
|
16
|
+
|
|
17
|
+
reg = Registry()
|
|
18
|
+
reg.add(cl_freq)
|
|
19
|
+
reg.add(dsd) # Refs to cl_freq resolve here
|
|
20
|
+
dsd.dimensions["FREQ"].codelist() # Codelist — no fetch needed
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import attrs
|
|
27
|
+
|
|
28
|
+
from sdmxlib.model.collections import ItemList
|
|
29
|
+
from sdmxlib.model.ref import AnyRef, HasUrn, Ref
|
|
30
|
+
from sdmxlib.model.urn import SdmxUrn
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Registry:
|
|
34
|
+
"""In-memory SDMX artefact store with URN identity and Ref interning.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
reg = Registry()
|
|
39
|
+
reg.add_all([cl_freq, concept_scheme, dsd])
|
|
40
|
+
|
|
41
|
+
dsd = reg.get(DataStructure.urn(agency="ESTAT", id="CPI", version="1.0"))
|
|
42
|
+
dims = reg.get_all(Dimension) # not typically used; prefer dsd.dimensions
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._store: dict[str, Any] = {} # str(urn) → artefact
|
|
47
|
+
self._refs: dict[str, AnyRef] = {} # str(urn) → canonical Ref
|
|
48
|
+
self._by_type: dict[type, list[Any]] = {} # Python type → [artefact, ...]
|
|
49
|
+
|
|
50
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def add(self, artefact: Any) -> None:
|
|
53
|
+
"""Add an artefact to the registry.
|
|
54
|
+
|
|
55
|
+
- Interns the artefact under its URN.
|
|
56
|
+
- Resolves the canonical ``Ref`` for this URN so all existing
|
|
57
|
+
holders see the artefact immediately.
|
|
58
|
+
- Walks all outgoing ``Ref`` fields, replaces them with canonical
|
|
59
|
+
interned Refs, and resolves any that point to artefacts already
|
|
60
|
+
present.
|
|
61
|
+
|
|
62
|
+
Raises ``TypeError`` if the artefact does not satisfy ``HasUrn``.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(artefact, HasUrn):
|
|
65
|
+
msg = (
|
|
66
|
+
f"{type(artefact).__name__} does not satisfy HasUrn — "
|
|
67
|
+
"it needs sdmx_package, sdmx_class, agency_id, id, version"
|
|
68
|
+
)
|
|
69
|
+
raise TypeError(msg)
|
|
70
|
+
|
|
71
|
+
urn = _artefact_urn(artefact)
|
|
72
|
+
urn_str = str(urn)
|
|
73
|
+
|
|
74
|
+
# Store under exact URN
|
|
75
|
+
self._store[urn_str] = artefact
|
|
76
|
+
|
|
77
|
+
# Track by Python type for get_all()
|
|
78
|
+
t = type(artefact)
|
|
79
|
+
if t not in self._by_type:
|
|
80
|
+
self._by_type[t] = []
|
|
81
|
+
if artefact not in self._by_type[t]:
|
|
82
|
+
self._by_type[t].append(artefact)
|
|
83
|
+
|
|
84
|
+
# Resolve the canonical Ref for this artefact (propagates to all holders)
|
|
85
|
+
canonical = self._canonical_ref(urn)
|
|
86
|
+
canonical._resolved = artefact # noqa: SLF001
|
|
87
|
+
|
|
88
|
+
# Walk outgoing Refs on the artefact and intern them
|
|
89
|
+
self._walk_refs(artefact, set())
|
|
90
|
+
|
|
91
|
+
def add_all(self, artefacts: Iterable[Any]) -> None:
|
|
92
|
+
"""Add multiple artefacts in any order."""
|
|
93
|
+
for artefact in artefacts:
|
|
94
|
+
self.add(artefact)
|
|
95
|
+
|
|
96
|
+
def get[T](self, urn: "SdmxUrn[T]") -> "T | None":
|
|
97
|
+
"""Look up an artefact by exact URN. Returns ``None`` if absent."""
|
|
98
|
+
return self._store.get(str(urn)) # type: ignore[return-value]
|
|
99
|
+
|
|
100
|
+
def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
|
|
101
|
+
"""All artefacts of the given Python type (exact match, not subclasses)."""
|
|
102
|
+
return list(self._by_type.get(artefact_type, [])) # type: ignore[return-value]
|
|
103
|
+
|
|
104
|
+
def ref[T](self, urn: "SdmxUrn[T]") -> "Ref[T]":
|
|
105
|
+
"""Return the canonical interned Ref for this URN.
|
|
106
|
+
|
|
107
|
+
Creates an unresolved Ref if this URN has not been seen before.
|
|
108
|
+
If the target artefact is already in the registry, the returned
|
|
109
|
+
Ref will be resolved.
|
|
110
|
+
"""
|
|
111
|
+
return self._canonical_ref(urn) # type: ignore[return-value]
|
|
112
|
+
|
|
113
|
+
def __contains__(self, urn: object) -> bool:
|
|
114
|
+
"""True if an artefact with this URN is stored."""
|
|
115
|
+
if isinstance(urn, SdmxUrn):
|
|
116
|
+
return str(urn) in self._store
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def __len__(self) -> int:
|
|
120
|
+
"""Number of artefacts stored."""
|
|
121
|
+
return len(self._store)
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
counts = ", ".join(f"{t.__name__}={len(v)}" for t, v in self._by_type.items())
|
|
125
|
+
return f"Registry({counts or 'empty'})"
|
|
126
|
+
|
|
127
|
+
# ── Internals ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def _canonical_ref(self, urn: "SdmxUrn[Any]") -> AnyRef:
|
|
130
|
+
"""Get or create the canonical (interned) Ref for a URN."""
|
|
131
|
+
urn_str = str(urn)
|
|
132
|
+
if urn_str not in self._refs:
|
|
133
|
+
new_ref: AnyRef = Ref.unresolved(urn)
|
|
134
|
+
self._refs[urn_str] = new_ref
|
|
135
|
+
# Resolve immediately if the target is already stored
|
|
136
|
+
artefact = self._store.get(urn_str)
|
|
137
|
+
if artefact is not None:
|
|
138
|
+
new_ref._resolved = artefact # noqa: SLF001
|
|
139
|
+
return self._refs[urn_str]
|
|
140
|
+
|
|
141
|
+
def _walk_refs(self, obj: Any, visited: set[int]) -> None:
|
|
142
|
+
"""Walk dataclass fields of *obj*, interning every ``Ref`` found.
|
|
143
|
+
|
|
144
|
+
Recurses into ``ItemList`` items and nested dataclass fields.
|
|
145
|
+
The *visited* set prevents cycles.
|
|
146
|
+
"""
|
|
147
|
+
if not attrs.has(type(obj)) or isinstance(obj, type):
|
|
148
|
+
return
|
|
149
|
+
oid = id(obj)
|
|
150
|
+
if oid in visited:
|
|
151
|
+
return
|
|
152
|
+
visited.add(oid)
|
|
153
|
+
|
|
154
|
+
for f in attrs.fields(type(obj)):
|
|
155
|
+
self._process_field(obj, f.name, getattr(obj, f.name, None), visited)
|
|
156
|
+
|
|
157
|
+
def _process_field(self, obj: Any, name: str, value: Any, visited: set[int]) -> None:
|
|
158
|
+
"""Intern a single field value; recurse if it contains more Refs."""
|
|
159
|
+
if value is None:
|
|
160
|
+
return
|
|
161
|
+
if isinstance(value, Ref):
|
|
162
|
+
self._intern_ref_field(obj, name, value)
|
|
163
|
+
elif isinstance(value, ItemList):
|
|
164
|
+
for item in value:
|
|
165
|
+
self._walk_refs(item, visited)
|
|
166
|
+
elif attrs.has(type(value)) and not isinstance(value, type):
|
|
167
|
+
self._walk_refs(value, visited)
|
|
168
|
+
|
|
169
|
+
def _intern_ref_field(self, obj: Any, name: str, ref: AnyRef) -> None:
|
|
170
|
+
"""Replace *ref* on *obj.name* with the canonical interned Ref."""
|
|
171
|
+
interned = self._canonical_ref(ref.urn)
|
|
172
|
+
# Transfer resolution from incoming ref to canonical if needed
|
|
173
|
+
if ref._resolved is not None and interned._resolved is None: # noqa: SLF001
|
|
174
|
+
interned._resolved = ref._resolved # noqa: SLF001
|
|
175
|
+
if interned is not ref:
|
|
176
|
+
setattr(obj, name, interned)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Module-level helper ────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _artefact_urn(artefact: Any) -> "SdmxUrn[Any]":
|
|
183
|
+
"""Build the canonical SdmxUrn for a HasUrn artefact."""
|
|
184
|
+
return SdmxUrn.make(
|
|
185
|
+
type(artefact),
|
|
186
|
+
package=artefact.sdmx_package,
|
|
187
|
+
artefact_class=artefact.sdmx_class,
|
|
188
|
+
agency=artefact.agency_id,
|
|
189
|
+
id=artefact.id,
|
|
190
|
+
version=artefact.version,
|
|
191
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Representation types — DataType, Facet, Representation alias."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from attrs import frozen
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from sdmxlib.model.codelist import Codelist
|
|
10
|
+
from sdmxlib.model.ref import Ref
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataType(StrEnum):
|
|
14
|
+
"""SDMX data types for non-enumerated representations.
|
|
15
|
+
|
|
16
|
+
Values match the SDMX-ML ``textType`` attribute strings exactly, so
|
|
17
|
+
``DataType.String == "String"`` and existing string-passing code continues
|
|
18
|
+
to work unchanged.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
Facet(type=DataType.String, max_length=100)
|
|
22
|
+
Facet(type=DataType.ObservationalTimePeriod)
|
|
23
|
+
Facet(type="String") # str also accepted
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
String = "String"
|
|
27
|
+
Alpha = "Alpha"
|
|
28
|
+
AlphaNumeric = "AlphaNumeric"
|
|
29
|
+
Numeric = "Numeric"
|
|
30
|
+
BigInteger = "BigInteger"
|
|
31
|
+
Integer = "Integer"
|
|
32
|
+
Long = "Long"
|
|
33
|
+
Short = "Short"
|
|
34
|
+
Decimal = "Decimal"
|
|
35
|
+
Float = "Float"
|
|
36
|
+
Double = "Double"
|
|
37
|
+
Boolean = "Boolean"
|
|
38
|
+
URI = "URI"
|
|
39
|
+
URL = "URL"
|
|
40
|
+
Count = "Count"
|
|
41
|
+
InclusiveValueRange = "InclusiveValueRange"
|
|
42
|
+
ExclusiveValueRange = "ExclusiveValueRange"
|
|
43
|
+
Incremental = "Incremental"
|
|
44
|
+
ObservationalTimePeriod = "ObservationalTimePeriod"
|
|
45
|
+
StandardTimePeriod = "StandardTimePeriod"
|
|
46
|
+
BasicTimePeriod = "BasicTimePeriod"
|
|
47
|
+
GregorianTimePeriod = "GregorianTimePeriod"
|
|
48
|
+
GregorianYear = "GregorianYear"
|
|
49
|
+
GregorianYearMonth = "GregorianYearMonth"
|
|
50
|
+
GregorianDay = "GregorianDay"
|
|
51
|
+
ReportingTimePeriod = "ReportingTimePeriod"
|
|
52
|
+
ReportingYear = "ReportingYear"
|
|
53
|
+
ReportingSemester = "ReportingSemester"
|
|
54
|
+
ReportingTrimester = "ReportingTrimester"
|
|
55
|
+
ReportingQuarter = "ReportingQuarter"
|
|
56
|
+
ReportingMonth = "ReportingMonth"
|
|
57
|
+
ReportingWeek = "ReportingWeek"
|
|
58
|
+
ReportingDay = "ReportingDay"
|
|
59
|
+
ReportingHour = "ReportingHour"
|
|
60
|
+
DateTime = "DateTime"
|
|
61
|
+
Time = "Time"
|
|
62
|
+
Duration = "Duration"
|
|
63
|
+
Month = "Month"
|
|
64
|
+
MonthDay = "MonthDay"
|
|
65
|
+
Day = "Day"
|
|
66
|
+
Year = "Year"
|
|
67
|
+
XHTML = "XHTML"
|
|
68
|
+
Attachment = "Attachment"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@frozen
|
|
72
|
+
class Facet:
|
|
73
|
+
"""Non-enumerated representation with optional constraints.
|
|
74
|
+
|
|
75
|
+
``type`` accepts a :class:`DataType` member or a raw string (for
|
|
76
|
+
non-standard values from registry responses).
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
Facet(type=DataType.String, max_length=100)
|
|
80
|
+
Facet(type=DataType.ObservationalTimePeriod)
|
|
81
|
+
Facet(type="String") # str accepted; DataType.String preferred
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
type: DataType | str | None = None
|
|
85
|
+
max_length: int | None = None
|
|
86
|
+
min_length: int | None = None
|
|
87
|
+
max_value: float | None = None
|
|
88
|
+
min_value: float | None = None
|
|
89
|
+
decimals: int | None = None
|
|
90
|
+
pattern: str | None = None
|
|
91
|
+
start_value: float | None = None
|
|
92
|
+
end_value: float | None = None
|
|
93
|
+
is_sequence: bool | None = None
|
|
94
|
+
is_multi_lingual: bool | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Type alias — a component's representation is a codelist ref (enumerated)
|
|
98
|
+
# or a facet (free text with optional constraints), or absent.
|
|
99
|
+
type Representation = "Ref[Codelist] | Facet | None"
|
sdmxlib/model/urn.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""SdmxUrn[T] — typed artefact identity.
|
|
2
|
+
|
|
3
|
+
T is a phantom type parameter — no field stores it. It exists so the type
|
|
4
|
+
checker can carry the target type through ``reg.fetch(urn) -> T``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from attrs import frozen
|
|
11
|
+
|
|
12
|
+
# urn:sdmx:org.sdmx.infomodel.<package>.<Class>=<AGENCY>:<ID>(<version>)
|
|
13
|
+
# optional item suffix: .<item_id>
|
|
14
|
+
_URN_RE = re.compile(
|
|
15
|
+
r"urn:sdmx:org\.sdmx\.infomodel\.(?P<package>[^.]+)\.(?P<artefact_class>[^=]+)"
|
|
16
|
+
r"=(?P<agency>[^:]+):(?P<id>[^.(]+)(?:\((?P<version>[^)]+)\))?(?:\.(?P<item_id>.+))?$"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# SDMX 2.1 / 3.0 formal type patterns from SDMXCommonReferences.xsd
|
|
20
|
+
# IDType: [A-Za-z0-9_@$-]+
|
|
21
|
+
# NestedIDType: [A-Za-z0-9_@$-]+(\.[A-Za-z0-9_@$-]+)* (dots for agency hierarchy)
|
|
22
|
+
_PACKAGE_RE = re.compile(r"^[a-z][a-z_]*$")
|
|
23
|
+
_CLASS_RE = re.compile(r"^[A-Z][A-Za-z]+$")
|
|
24
|
+
_AGENCY_RE = re.compile(r"^[A-Za-z0-9_@$-]+(\.[A-Za-z0-9_@$-]+)*$")
|
|
25
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9_@$-]+$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@frozen
|
|
29
|
+
class SdmxUrn[T]:
|
|
30
|
+
"""Globally unique identifier for an SDMX artefact.
|
|
31
|
+
|
|
32
|
+
``T`` is a phantom type — not stored at runtime, used by the type
|
|
33
|
+
checker to propagate the target type through
|
|
34
|
+
``reg.fetch(urn: SdmxUrn[T]) -> T``.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
SdmxUrn.parse("urn:sdmx:org.sdmx.infomodel.codelist.Codelist=SDMX:CL_FREQ(1.0)")
|
|
38
|
+
SdmxUrn.make(
|
|
39
|
+
Codelist,
|
|
40
|
+
package="codelist",
|
|
41
|
+
artefact_class="Codelist",
|
|
42
|
+
agency="SDMX",
|
|
43
|
+
id="CL_FREQ",
|
|
44
|
+
version="1.0",
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_PACKAGE_PREFIX: ClassVar[str] = "urn:sdmx:org.sdmx.infomodel"
|
|
49
|
+
|
|
50
|
+
package: str
|
|
51
|
+
artefact_class: str
|
|
52
|
+
agency: str
|
|
53
|
+
id: str
|
|
54
|
+
version: str = "latest"
|
|
55
|
+
item_id: str | None = None
|
|
56
|
+
|
|
57
|
+
# ── Factories ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def parse(cls, urn: str) -> "SdmxUrn[Any]":
|
|
61
|
+
"""Parse a full URN string. Returns unparameterised SdmxUrn."""
|
|
62
|
+
m = _URN_RE.match(urn)
|
|
63
|
+
if not m:
|
|
64
|
+
msg = f"Invalid SDMX URN: {urn!r}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
return cls(
|
|
67
|
+
package=m.group("package"),
|
|
68
|
+
artefact_class=m.group("artefact_class"),
|
|
69
|
+
agency=m.group("agency"),
|
|
70
|
+
id=m.group("id"),
|
|
71
|
+
version=m.group("version") or "latest",
|
|
72
|
+
item_id=m.group("item_id"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def make[U](
|
|
77
|
+
cls,
|
|
78
|
+
artefact_type: type[U], # noqa: ARG003 # phantom type witness; unused at runtime
|
|
79
|
+
/,
|
|
80
|
+
*,
|
|
81
|
+
package: str,
|
|
82
|
+
artefact_class: str,
|
|
83
|
+
agency: str,
|
|
84
|
+
id: str, # noqa: A002
|
|
85
|
+
version: str = "latest",
|
|
86
|
+
item_id: str | None = None,
|
|
87
|
+
) -> "SdmxUrn[U]":
|
|
88
|
+
"""Low-level factory with basic validation.
|
|
89
|
+
|
|
90
|
+
``artefact_type`` is a phantom type witness — unused at runtime,
|
|
91
|
+
used by the type checker to bind ``U`` and propagate the artefact
|
|
92
|
+
type through ``session.get`` and ``session.extract``.
|
|
93
|
+
"""
|
|
94
|
+
if not _AGENCY_RE.match(agency):
|
|
95
|
+
msg = f"Invalid agency id: {agency!r}"
|
|
96
|
+
raise ValueError(msg)
|
|
97
|
+
if not _ID_RE.match(id):
|
|
98
|
+
msg = f"Invalid artefact id: {id!r}"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
return cls( # type: ignore[return-value]
|
|
101
|
+
package=package,
|
|
102
|
+
artefact_class=artefact_class,
|
|
103
|
+
agency=agency,
|
|
104
|
+
id=id,
|
|
105
|
+
version=version,
|
|
106
|
+
item_id=item_id,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ── String representation ──────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def __str__(self) -> str:
|
|
112
|
+
"""Round-trip to full URN string."""
|
|
113
|
+
base = f"{self._PACKAGE_PREFIX}.{self.package}.{self.artefact_class}={self.agency}:{self.id}"
|
|
114
|
+
if self.version and self.version != "latest":
|
|
115
|
+
base = f"{base}({self.version})"
|
|
116
|
+
if self.item_id:
|
|
117
|
+
base = f"{base}.{self.item_id}"
|
|
118
|
+
return base
|
|
119
|
+
|
|
120
|
+
# ── Properties ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_latest(self) -> bool:
|
|
124
|
+
"""True if version is the sentinel "latest"."""
|
|
125
|
+
return self.version == "latest"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_item_urn(self) -> bool:
|
|
129
|
+
"""True if this URN points to an item within a scheme (e.g. a Code)."""
|
|
130
|
+
return self.item_id is not None
|