diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst
index 11e113dc..4b021387 100644
--- a/doc/whatsnew.rst
+++ b/doc/whatsnew.rst
@@ -9,6 +9,11 @@ Next release
- Python 3.14 (`released 2025-10-07 `_) is fully supported (:pull:`249`).
- Python 3.9 support is dropped, as `it has reached end-of-life `__ (:pull:`249`).
:mod:`sdmx` requires Python 3.10 or later.
+- :class:`.URN` parses letters in the version part of a URN (:issue:`230`, :pull:`252`).
+ This fixes a bug in v2.16.0–v2.23.1 where creating :class:`.VersionableArtefact`
+ with both :py:`version=...` and :py:`urn=...` would raise :class:`ValueError`
+ even if the two were in agreement.
+- Fix two regressions in :func:`.to_pandas` introduced in v2.23.0 (:issue:`251`, :pull:`252`).
v2.23.1 (2025-10-01)
====================
diff --git a/sdmx/convert/pandas.py b/sdmx/convert/pandas.py
index 1ba8ea18..71ba5448 100644
--- a/sdmx/convert/pandas.py
+++ b/sdmx/convert/pandas.py
@@ -503,6 +503,15 @@ def to_pandas(obj, **kwargs):
`kwargs` can include any of the attributes of :class:`.PandasConverter`.
+ .. versionchanged:: 1.0
+
+ :func:`.to_pandas` handles all types of objects,
+ replacing the earlier, separate ``data2pandas`` and ``structure2pd`` writers.
+
+ .. versionchanged:: 2.23.0
+
+ :func:`.to_pandas` is a thin wrapper for :class:`.PandasConverter`.
+
Other parameters
----------------
format_options :
@@ -513,15 +522,6 @@ def to_pandas(obj, **kwargs):
time_format :
if given, the :attr:`.CSVFormatOptions.time_format` attribute of the
`format_options` keyword argument is replaced.
-
- .. versionchanged:: 1.0
-
- :func:`.to_pandas` handles all types of objects,
- replacing the earlier, separate ``data2pandas`` and ``structure2pd`` writers.
-
- .. versionchanged:: 2.23.0
-
- :func:`.to_pandas` is a thin wrapper for :class:`.PandasConverter`.
"""
csv.common.kwargs_to_format_options(kwargs, csv.common.CSVFormatOptions)
return PandasConverter(**kwargs).convert(obj)
@@ -685,6 +685,7 @@ def convert_dataset(c: "PandasConverter", obj: common.BaseDataSet):
Otherwise.
"""
c._context[common.BaseDataSet] = obj
+ c._context.setdefault(common.BaseDataStructureDefinition, obj.structured_by)
c._columns = ColumnSpec(pc=c, ds=obj)
# - Apply convert_obs() to every obs → iterable of list.
@@ -697,7 +698,11 @@ def convert_dataset(c: "PandasConverter", obj: common.BaseDataSet):
# - (Possibly) convert certain columns to datetime.
# - (Possibly) reshape.
result = (
- pd.DataFrame(map(c._columns.convert_obs, obj.obs))
+ pd.DataFrame(
+ map(c._columns.convert_obs, obj.obs)
+ if obj.obs
+ else [[None] * len(c._columns.obs)]
+ )
.dropna(how="all")
.set_axis(c._columns.obs, axis=1) # NB This must come after DataFrame(map(…))
.assign(**c._columns.assign)
diff --git a/sdmx/model/common.py b/sdmx/model/common.py
index 5385ca07..97c8f132 100644
--- a/sdmx/model/common.py
+++ b/sdmx/model/common.py
@@ -184,7 +184,7 @@ def value(self) -> str | None:
return None
-@dataclass
+@dataclass(slots=True)
class AnnotableArtefact(Comparable):
#: :class:`Annotations <.Annotation>` of the object.
#:
@@ -244,7 +244,7 @@ def eval_annotation(self, id: str, globals=None):
return value
-@dataclass
+@dataclass(slots=True)
class IdentifiableArtefact(AnnotableArtefact):
#: Unique identifier of the object.
id: str = MissingID
@@ -349,8 +349,8 @@ def __post_init__(self):
super().__post_init__()
if not self.version:
- self.version = self._urn.version
- elif isinstance(self.version, str) and self.version == "None":
+ self.version = self._urn.version or None
+ elif isinstance(self.version, str) and self.version in ("", "None"):
self.version = None
elif self.urn and self.version != self._urn.version:
raise ValueError(
@@ -1756,7 +1756,7 @@ def __add__(self, other):
if not isinstance(other, Key) and other is not None:
raise NotImplementedError
else:
- result.values.update_fast(other.values)
+ result.values.update_fast(getattr(other, "values", []))
return result
def __radd__(self, other):
diff --git a/sdmx/model/v21.py b/sdmx/model/v21.py
index ccf7b456..532257bf 100644
--- a/sdmx/model/v21.py
+++ b/sdmx/model/v21.py
@@ -300,6 +300,8 @@ class Observation(common.BaseObservation):
class DataSet(common.BaseDataSet):
"""SDMX 2.1 DataSet."""
+ structured_by: DataStructureDefinition | None = None
+
#: Named ``attachedAttribute`` in the IM.
attrib: DictLikeDescriptor[str, common.AttributeValue] = DictLikeDescriptor()
diff --git a/sdmx/model/v30.py b/sdmx/model/v30.py
index 90771568..63ac59a6 100644
--- a/sdmx/model/v30.py
+++ b/sdmx/model/v30.py
@@ -394,6 +394,8 @@ class Observation(common.BaseObservation):
class DataSet(common.BaseDataSet):
"""SDMX 3.0 Data Set."""
+ structured_by: DataStructureDefinition | None = None
+
class StructureSpecificDataSet(DataSet):
"""SDMX 3.0 StructureSpecificDataSet.
diff --git a/sdmx/reader/xml/common.py b/sdmx/reader/xml/common.py
index 22f26d1b..46b314b5 100644
--- a/sdmx/reader/xml/common.py
+++ b/sdmx/reader/xml/common.py
@@ -5,7 +5,7 @@
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from importlib import import_module
from itertools import chain, count
-from typing import TYPE_CHECKING, Any, ClassVar, cast
+from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
from lxml import etree
from lxml.etree import QName
@@ -13,13 +13,20 @@
import sdmx.urn
from sdmx import message
from sdmx.exceptions import XMLParseError # noqa: F401
-from sdmx.format import Version, list_media_types
+from sdmx.format import Version as FormatVersion
+from sdmx.format import list_media_types
from sdmx.model import common
+from sdmx.model.version import Version
from sdmx.reader.base import BaseReader
if TYPE_CHECKING:
import types
+ AA = TypeVar("AA", bound=common.AnnotableArtefact)
+ IA = TypeVar("IA", bound=common.IdentifiableArtefact)
+ NA = TypeVar("NA", bound=common.NameableArtefact)
+ MA = TypeVar("MA", bound=common.MaintainableArtefact)
+
# Sentinel value for a missing Agency
_NO_AGENCY = common.Agency()
@@ -50,7 +57,9 @@ class BaseReference:
"version",
)
- def __init__(self, reader, elem, cls_hint=None):
+ def __init__(
+ self, reader: "XMLEventReader", elem, cls_hint: type | None = None
+ ) -> None:
parent_tag = elem.tag
info = self.info_from_element(elem)
@@ -93,7 +102,7 @@ def __init__(self, reader, elem, cls_hint=None):
@abstractmethod
def info_from_element(cls, elem) -> dict[str, Any]: ...
- def __str__(self):
+ def __str__(self) -> str:
# NB for debugging only
return ( # pragma: no cover
f"{self.cls.__name__}={self.agency.id}:{self.id}({self.version}) → "
@@ -108,7 +117,7 @@ class XMLEventReader(BaseReader):
suffixes = [".xml"]
#: SDMX-ML version handled by this reader.
- xml_version: ClassVar[Version]
+ xml_version: ClassVar[FormatVersion]
#: Reference to the module defining the format read.
format: ClassVar["types.ModuleType"]
@@ -129,7 +138,9 @@ def __init_subclass__(cls: type["XMLEventReader"]):
# Empty dictionary
cls.parser = {}
- name = {Version["2.1"]: "v21", Version["3.0.0"]: "v30"}[cls.xml_version]
+ name = {FormatVersion["2.1"]: "v21", FormatVersion["3.0.0"]: "v30"}[
+ cls.xml_version
+ ]
cls.format = import_module(f"sdmx.format.xml.{name}")
cls.model = import_module(f"sdmx.model.{name}")
cls.media_types = list_media_types(base="xml", version=cls.xml_version)
@@ -306,7 +317,7 @@ def _dump(self): # pragma: no cover
)
print("\nIgnore:\n", self.ignore)
- def push(self, stack_or_obj, obj=None):
+ def push(self, stack_or_obj, obj=None) -> None:
"""Push an object onto a stack."""
if stack_or_obj is None:
return
@@ -335,11 +346,11 @@ def push(self, stack_or_obj, obj=None):
self.stack[s][id] = obj
- def stash(self, *stacks, name: str = "_stash"):
+ def stash(self, *stacks, name: str = "_stash") -> None:
"""Temporarily hide all objects in the given `stacks`."""
self.push(name, {s: self.stack.pop(s, dict()) for s in stacks})
- def unstash(self, name: str = "_stash"):
+ def unstash(self, name: str = "_stash") -> None:
"""Restore the objects hidden by the last :meth:`stash` call to their stacks.
Calls to :meth:`.stash` and :meth:`.unstash` should be matched 1-to-1; if the
@@ -361,7 +372,7 @@ def get_single(
self,
cls_or_name: type | str,
id: str | None = None,
- version: str | None = None,
+ version: str | Version | None = None,
subclass: bool = False,
) -> Any | None:
"""Return a reference to an object while leaving it in its stack.
@@ -475,7 +486,9 @@ def resolve(self, ref):
return parent.get_hierarchical(ref.target_id)
raise # pragma: no cover
- def annotable(self, cls, elem, **kwargs):
+ AA = TypeVar("AA", bound=common.AnnotableArtefact)
+
+ def annotable(self, cls: type["AA"], elem, **kwargs) -> "AA":
"""Create a AnnotableArtefact of `cls` from `elem` and `kwargs`.
Collects all parsed .
@@ -485,12 +498,12 @@ def annotable(self, cls, elem, **kwargs):
kwargs["annotations"].extend(self.pop_all(self.model.Annotation))
return cls(**kwargs)
- def identifiable(self, cls, elem, **kwargs):
+ def identifiable(self, cls: type["IA"], elem, **kwargs) -> "IA":
"""Create a IdentifiableArtefact of `cls` from `elem` and `kwargs`."""
setdefault_attrib(kwargs, elem, "id", "urn", "uri")
return self.annotable(cls, elem, **kwargs)
- def nameable(self, cls, elem, **kwargs):
+ def nameable(self, cls: type["NA"], elem, **kwargs) -> "NA":
"""Create a NameableArtefact of `cls` from `elem` and `kwargs`.
Collects all parsed :class:`.InternationalString` localizations of
@@ -502,7 +515,7 @@ def nameable(self, cls, elem, **kwargs):
add_localizations(obj.description, self.pop_all("Description"))
return obj
- def maintainable(self, cls, elem, **kwargs):
+ def maintainable(self, cls: type["MA"], elem, **kwargs) -> "MA":
"""Create or retrieve a MaintainableArtefact of `cls` from `elem` and `kwargs`.
Following the SDMX-IM class hierarchy, :meth:`maintainable` calls
@@ -578,7 +591,7 @@ def maintainable(self, cls, elem, **kwargs):
return obj
-def add_localizations(target: common.InternationalString, values: list) -> None:
+def add_localizations(target: common.InternationalString, values: Sequence) -> None:
"""Add localized strings from *values* to *target*."""
target.localizations.update({locale: label for locale, label in values})
diff --git a/sdmx/reader/xml/v21.py b/sdmx/reader/xml/v21.py
index d4a8ee30..d14694cb 100644
--- a/sdmx/reader/xml/v21.py
+++ b/sdmx/reader/xml/v21.py
@@ -499,7 +499,7 @@ def _item_start(reader, elem):
@possible_reference(unstash=True)
def _item_end(reader: Reader, elem):
cls = reader.class_for_tag(elem.tag)
- item = reader.nameable(cls, elem)
+ item: "common.Item" = reader.nameable(cls, elem)
# Hierarchy is stored in two ways
@@ -718,7 +718,7 @@ def _cl(reader: Reader, elem):
assert dsd is not None
# Determine the class
- cls = reader.class_for_tag(elem.tag)
+ cls: type[common.ComponentList] = reader.class_for_tag(elem.tag)
args = dict(
# Retrieve the components
@@ -746,11 +746,8 @@ def _cl(reader: Reader, elem):
cl = reader.identifiable(cls, elem, **args)
- try:
- # DimensionDescriptor only
+ if isinstance(cl, common.DimensionDescriptor):
cl.assign_order()
- except AttributeError:
- pass
# Assign to the DSD eagerly (instead of in _dsd_end()) for reference by next
# ComponentList e.g. so that AttributeRelationship can reference the
@@ -1040,7 +1037,7 @@ def _ar(reader, elem):
def _structure_start(reader: Reader, elem):
# Get any external reference created earlier, or instantiate a new object
cls = reader.class_for_tag(elem.tag)
- obj = reader.maintainable(cls, elem)
+ obj: "common.Structure" = reader.maintainable(cls, elem)
if obj not in reader.stack[cls]:
# A new object was created
@@ -1541,9 +1538,7 @@ def _hc_end(reader: Reader, elem):
level = common.Level(id=level_ref.id)
# Create the HierarchicalCode
- obj = reader.identifiable(
- reader.class_for_tag(elem.tag), elem, code=code, level=level
- )
+ obj = reader.identifiable(common.HierarchicalCode, elem, code=code, level=level)
# Count children represented as XML sub-elements of the parent
n_child = sum(e.tag == elem.tag for e in elem)
@@ -1573,7 +1568,7 @@ def _h_start(reader: Reader, elem):
@end("str:Hierarchy", only=False)
def _h_end(reader: Reader, elem):
- result = reader.nameable(
+ result: "v21.Hierarchy" = reader.nameable(
reader.class_for_tag(elem.tag),
elem,
has_formal_levels=eval(elem.attrib.get("leveled", "false").title()),
diff --git a/sdmx/tests/convert/test_pandas.py b/sdmx/tests/convert/test_pandas.py
index c14364ec..20862733 100644
--- a/sdmx/tests/convert/test_pandas.py
+++ b/sdmx/tests/convert/test_pandas.py
@@ -505,6 +505,18 @@ def expected(df, axis=0, cls=pd.DatetimeIndex):
sdmx.to_pandas(ds, datetime=43)
+def test_dataset_empty() -> None:
+ """Dataset with 0 observations can be converted.
+
+ https://github.com/khaeru/sdmx/issues/251.
+ """
+ dsd = v21.DataStructureDefinition()
+ dsd.dimensions.getdefault(id="DIM_0")
+ ds = v21.DataSet(structured_by=dsd)
+
+ sdmx.to_pandas(ds)
+
+
def test_list_of_obs(specimen) -> None:
"""Bare list of observations can be written."""
with specimen("ng-ts.xml") as f:
diff --git a/sdmx/tests/model/test_common.py b/sdmx/tests/model/test_common.py
index 8aed70c7..d5aa09ec 100644
--- a/sdmx/tests/model/test_common.py
+++ b/sdmx/tests/model/test_common.py
@@ -10,13 +10,19 @@
from sdmx.model.common import (
Agency,
AnnotableArtefact,
+ AttributeValue,
BaseAnnotation,
+ Code,
Component,
ComponentList,
Contact,
+ Dimension,
+ DimensionDescriptor,
IdentifiableArtefact,
Item,
ItemScheme,
+ Key,
+ KeyValue,
NameableArtefact,
Representation,
)
@@ -222,6 +228,13 @@ def obj(self) -> common.VersionableArtefact:
def test_compare(self, obj: common.VersionableArtefact, callback) -> None:
super().test_compare(obj, callback)
+ def test_init_gh_230(self) -> None:
+ """Test of https://github.com/khaeru/sdmx/issues/230."""
+ urn = "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=M:VA_FOO(0.1.dev1)"
+
+ # No conflict between identical str version= kwarg and in URN
+ common.VersionableArtefact(id="VA_FOO", version="0.1.dev1", urn=urn)
+
def test_urn(self) -> None:
va = common.VersionableArtefact(id="VARIAB_ALL", urn=URN)
@@ -504,6 +517,27 @@ class Foo(model.ComponentList):
obj.replace_grouping(Foo())
+class TestKeyValue:
+ @pytest.fixture
+ def kv(self) -> KeyValue:
+ return KeyValue(id="DIM", value="3")
+
+ def test_init(self) -> None:
+ dsd = v21.DataStructureDefinition.from_keys(
+ [Key(foo=1, bar=2, baz=3), Key(foo=4, bar=5, baz=6)]
+ )
+
+ kv = KeyValue(id="qux", value_for="baz", value="3", dsd=dsd) # type: ignore
+ assert kv.value_for is dsd.dimensions.get("baz")
+
+ def test_repr(self, kv) -> None:
+ assert "" == repr(kv)
+
+ def test_sort(self, kv) -> None:
+ assert kv < KeyValue(id="DIM", value="foo")
+ assert kv < "foo"
+
+
class TestAttributeValue(CompareTests):
@pytest.fixture
def obj(self) -> common.AttributeValue:
@@ -526,6 +560,92 @@ def test_compare(self, obj: common.AttributeValue, callback) -> None:
""":py:`compare(…)` is :any:`False` when attributes are changed."""
super().test_compare(obj, callback)
+ def test_str(self):
+ assert "FOO" == str(AttributeValue(value="FOO"))
+ assert "FOO" == str(AttributeValue(value=Code(id="FOO", name="Foo")))
+
+
+class TestKey:
+ @pytest.fixture
+ def k1(self):
+ # Construct with a dict
+ yield Key({"foo": 1, "bar": 2})
+
+ @pytest.fixture
+ def k2(self):
+ # Construct with kwargs
+ yield Key(foo=1, bar=2)
+
+ def test_init(self):
+ # Construct with a dict and kwargs is an error
+ with pytest.raises(ValueError):
+ Key({"foo": 1}, bar=2)
+
+ # Construct with a DimensionDescriptor
+ d = Dimension(id="FOO")
+ dd = DimensionDescriptor(components=[d])
+
+ k = Key(FOO=1, described_by=dd)
+
+ # KeyValue is associated with Dimension
+ assert k["FOO"].value_for is d
+
+ def test_add(self, k1) -> None:
+ """:any:`None` can be added to Key.
+
+ https://github.com/khaeru/sdmx/issues/251.
+ """
+ result = k1 + None
+ assert result == k1
+
+ def test_eq(self, k1) -> None:
+ # Invalid comparison
+ with pytest.raises(ValueError):
+ k1 == (("foo", 1), ("bar", 2))
+
+ def test_others(self, k1, k2) -> None:
+ # Results are __eq__ each other
+ assert k1 == k2
+
+ # __len__
+ assert len(k1) == 2
+
+ # __contains__: symmetrical if keys are identical
+ assert k1 in k2
+ assert k2 in k1
+ assert Key(foo=1) in k1
+ assert k1 not in Key(foo=1)
+
+ # Set and get using item convenience
+ k1["baz"] = 3 # bare value is converted to a KeyValue
+ assert k1["foo"] == 1
+
+ # __str__
+ assert str(k1) == "(foo=1, bar=2, baz=3)"
+
+ # copying: returns a new object equal to the old one
+ k2 = k1.copy()
+ assert id(k1) != id(k2) and k1 == k2
+ # copy with changes
+ k2 = Key(foo=1, bar=2).copy(baz=3)
+ assert id(k1) != id(k2) and k1 == k2
+
+ # __add__: Key with something else
+ with pytest.raises(NotImplementedError):
+ k1 + 4
+ # Two Keys
+ k2 = Key(foo=1) + Key(bar=2)
+ assert k2 == k1
+
+ # __radd__: adding a Key to None produces a Key
+ assert None + k1 == k1
+ # anything else is an error
+ with pytest.raises(NotImplementedError):
+ 4 + k1
+
+ # get_values(): preserve ordering
+ assert k1.get_values() == (1, 2, 3)
+
class TestBaseObservation(CompareTests):
@pytest.fixture
diff --git a/sdmx/tests/model/test_v21.py b/sdmx/tests/model/test_v21.py
index 11c937b8..028cf5dc 100644
--- a/sdmx/tests/model/test_v21.py
+++ b/sdmx/tests/model/test_v21.py
@@ -7,11 +7,17 @@
import sdmx.message
from sdmx.model import common, v21
from sdmx.model import v21 as model
+from sdmx.model.common import (
+ AttributeValue,
+ Code,
+ Dimension,
+ DimensionDescriptor,
+ Key,
+ KeyValue,
+)
from sdmx.model.v21 import (
Annotation,
AttributeDescriptor,
- AttributeValue,
- Code,
Codelist,
Component,
ComponentList,
@@ -27,11 +33,7 @@
DataKeySet,
DataSet,
DataStructureDefinition,
- Dimension,
- DimensionDescriptor,
GroupKey,
- Key,
- KeyValue,
MemberSelection,
MemberValue,
Observation,
@@ -450,107 +452,6 @@ def test_value_for_dsd_ref(self, dsd) -> None:
assert kwargs == result_kw
-class TestKeyValue:
- @pytest.fixture
- def kv(self) -> KeyValue:
- return KeyValue(id="DIM", value="3")
-
- def test_init(self) -> None:
- dsd = DataStructureDefinition.from_keys(
- [Key(foo=1, bar=2, baz=3), Key(foo=4, bar=5, baz=6)]
- )
-
- kv = KeyValue(id="qux", value_for="baz", value="3", dsd=dsd) # type: ignore
- assert kv.value_for is dsd.dimensions.get("baz")
-
- def test_repr(self, kv) -> None:
- assert "" == repr(kv)
-
- def test_sort(self, kv) -> None:
- assert kv < KeyValue(id="DIM", value="foo")
- assert kv < "foo"
-
-
-class TestAttributeValue:
- def test_str(self):
- assert "FOO" == str(AttributeValue(value="FOO"))
- assert "FOO" == str(AttributeValue(value=Code(id="FOO", name="Foo")))
-
-
-class TestKey:
- @pytest.fixture
- def k1(self):
- # Construct with a dict
- yield Key({"foo": 1, "bar": 2})
-
- @pytest.fixture
- def k2(self):
- # Construct with kwargs
- yield Key(foo=1, bar=2)
-
- def test_init(self):
- # Construct with a dict and kwargs is an error
- with pytest.raises(ValueError):
- Key({"foo": 1}, bar=2)
-
- # Construct with a DimensionDescriptor
- d = Dimension(id="FOO")
- dd = DimensionDescriptor(components=[d])
-
- k = Key(FOO=1, described_by=dd)
-
- # KeyValue is associated with Dimension
- assert k["FOO"].value_for is d
-
- def test_eq(self, k1) -> None:
- # Invalid comparison
- with pytest.raises(ValueError):
- k1 == (("foo", 1), ("bar", 2))
-
- def test_others(self, k1, k2) -> None:
- # Results are __eq__ each other
- assert k1 == k2
-
- # __len__
- assert len(k1) == 2
-
- # __contains__: symmetrical if keys are identical
- assert k1 in k2
- assert k2 in k1
- assert Key(foo=1) in k1
- assert k1 not in Key(foo=1)
-
- # Set and get using item convenience
- k1["baz"] = 3 # bare value is converted to a KeyValue
- assert k1["foo"] == 1
-
- # __str__
- assert str(k1) == "(foo=1, bar=2, baz=3)"
-
- # copying: returns a new object equal to the old one
- k2 = k1.copy()
- assert id(k1) != id(k2) and k1 == k2
- # copy with changes
- k2 = Key(foo=1, bar=2).copy(baz=3)
- assert id(k1) != id(k2) and k1 == k2
-
- # __add__: Key with something else
- with pytest.raises(NotImplementedError):
- k1 + 4
- # Two Keys
- k2 = Key(foo=1) + Key(bar=2)
- assert k2 == k1
-
- # __radd__: adding a Key to None produces a Key
- assert None + k1 == k1
- # anything else is an error
- with pytest.raises(NotImplementedError):
- 4 + k1
-
- # get_values(): preserve ordering
- assert k1.get_values() == (1, 2, 3)
-
-
class TestObservation(CompareTests):
@pytest.fixture
def obj(self) -> v21.Observation:
diff --git a/sdmx/tests/reader/test_xml_v21.py b/sdmx/tests/reader/test_xml_v21.py
index ce238b64..bb94f999 100644
--- a/sdmx/tests/reader/test_xml_v21.py
+++ b/sdmx/tests/reader/test_xml_v21.py
@@ -422,14 +422,17 @@ def test_parse_elem(elem, expected):
# The element is parsed successfully
result = reader.convert(tmp)
- if not result:
- stack = list(chain(*[s.values() for s in reader.stack.values()]))
- assert len(stack) == 1
- result = stack[0]
+ # For non-top-level XML, reader.convert() does not return anything
+ assert result is None
+
+ # Retrieve a single object stored on one or another of the reader stacks
+ objects = list(chain(*[s.values() for s in reader.stack.values()]))
+ assert len(objects) == 1
+ obj = objects[0]
if expected:
# Expected value supplied
- assert expected == result
+ assert expected == obj
def test_availableconstraint_xml_response(specimen):
diff --git a/sdmx/tests/writer/test_xml.py b/sdmx/tests/writer/test_xml.py
index e3420aaf..16381d03 100644
--- a/sdmx/tests/writer/test_xml.py
+++ b/sdmx/tests/writer/test_xml.py
@@ -19,6 +19,7 @@
if TYPE_CHECKING:
from sdmx.model.common import Structure
+ from sdmx.types import MaintainableArtefactArgs as MAArgs
log = logging.getLogger(__name__)
@@ -43,12 +44,12 @@ def header(agency) -> message.Header:
@pytest.fixture
def metadata_message(header) -> message.MetadataMessage:
"""A metadata message with the minimum content to write valid SDMX-ML 2.1."""
- a = common.Agency(id="TEST")
- dfd = v21.DataflowDefinition(id="DFD", maintainer=a)
+ ma_kw: "MAArgs" = dict(version="1.0", maintainer=common.Agency(id="TEST"))
+ dfd = v21.DataflowDefinition(id="DFD", **ma_kw)
ma = v21.MetadataAttribute(id="MA")
rs = v21.ReportStructure(id="RS", components=[ma])
mdsd = v21.MetadataStructureDefinition(
- id="MDS", maintainer=a, report_structure={rs.id: rs}
+ id="MDS", **ma_kw, report_structure={rs.id: rs}
)
iot = v21.IdentifiableObjectTarget(id="IOT")
mdt = v21.MetadataTarget(id="MDT", components=[iot])
@@ -323,7 +324,7 @@ def test_DataMessage(datamessage):
sdmx.to_xml(datamessage)
-def test_MetadataMessage(metadata_message, *, debug: bool = False) -> None:
+def test_MetadataMessage(metadata_message, *, debug: bool = True) -> None:
""":class:`.MetadataMessage` can be written."""
# Write to SDMX-ML
buf = io.BytesIO(sdmx.to_xml(metadata_message, pretty_print=debug))
diff --git a/sdmx/urn.py b/sdmx/urn.py
index 7edb3089..b9d50653 100644
--- a/sdmx/urn.py
+++ b/sdmx/urn.py
@@ -1,4 +1,5 @@
import re
+from dataclasses import InitVar, dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -9,46 +10,43 @@
r"urn:sdmx:org\.sdmx\.infomodel"
r"\.(?P[^\.]*)"
r"\.(?P[^=]*)=((?P[^:]*):)?"
- r"(?P[^\(]*)(\((?P[\d\.]*)\))?"
+ r"(?P[^\(]*)(\((?P[\w\.-]*)\))?"
r"(\.(?P.*))?"
)
+@dataclass(slots=True)
class URN:
- """SDMX Uniform Resource Name (URN).
-
- For example: "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR". The
- maintainer ID ("BAZ") and version ("1.2.3") must refer to a
- :class:`.MaintainableArtefact`. If (as in this example) the URN is for a
- non-maintainable child (for example, a :class:`.Item` in a :class:`.ItemScheme`),
- these are the maintainer ID and version of the containing scheme/other maintainable
- parent object.
- """
+ """SDMX Uniform Resource Name (URN)."""
+
+ #: Existing URN to parse, for example
+ #: "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR". The maintainer ID
+ #: ("BAZ") and version ("1.2.3") must refer to a :class:`.MaintainableArtefact`. If
+ #: (as in this example) the URN is for a non-maintainable child (for example, a
+ #: :class:`.Item` in a :class:`.ItemScheme`), these are the maintainer ID and
+ #: version of the containing scheme/other maintainable parent object.
+ value: InitVar[str | None] = None
#: SDMX :data:`.PACKAGE` corresponding to :attr:`klass`.
- package: str
+ package: str = ""
#: SDMX object class.
- klass: str
+ klass: str = ""
#: ID of the :class:`.Agency` that is the :attr:`.MaintainableArtefact.maintainer`.
- agency: str | None = None
+ agency: str = ""
#: ID of the :class:`.MaintainableArtefact`.
- id: str | None = None
+ id: str = ""
#: :attr:`.VersionableArtefact.version` of the maintainable artefact.parent.
- version: str | None = None
+ version: str = ""
#: ID of an item within a maintainable parent. Optional.
- item_id: str | None = None
-
- def __init__(self, value: str | None, **kwargs) -> None:
- if kwargs:
- self.__dict__.update(kwargs)
+ item_id: str = ""
+ def __post_init__(self, value: str | None) -> None:
if value is None:
- self.groupdict = {} # Needed by match()
return
try:
@@ -57,21 +55,23 @@ def __init__(self, value: str | None, **kwargs) -> None:
except (AssertionError, TypeError):
raise ValueError(f"not a valid SDMX URN: {value}")
- g = self.groupdict = match.groupdict()
+ # Update attributes from the match
+ g = match.groupdict()
+ self.klass = g["class"]
+ self.agency = g["agency"]
+ self.id = g["id"]
+ self.version = g["version"]
+ self.item_id = g["item_id"]
if g["package"] == "package":
+ # Replace the placeholder value "package" with the actual package associated
+ # with class
from sdmx.model.v21 import PACKAGE
self.package = PACKAGE[g["class"]]
else:
self.package = g["package"]
- self.klass = g["class"]
- self.agency = g["agency"]
- self.id = g["id"]
- self.version = g["version"]
- self.item_id = g["item_id"]
-
def __str__(self) -> str:
return (
f"urn:sdmx:org.sdmx.infomodel.{self.package}.{self.klass}={self.agency}:"
@@ -141,7 +141,7 @@ def make(
klass=obj.__class__.__name__,
agency=ma.maintainer.id,
id=ma.id,
- version=ma.version,
+ version=str(ma.version) if ma.version else "",
item_id=item_id,
)
)
@@ -167,7 +167,16 @@ def match(value: str) -> dict[str, str]:
ValueError
If `value` is not a well-formed SDMX URN.
"""
- return URN(value).groupdict
+ urn = URN(value)
+
+ return {
+ "package": urn.package,
+ "class": urn.klass,
+ "agency": urn.agency,
+ "id": urn.id,
+ "version": urn.version,
+ "item_id": urn.item_id,
+ }
def normalize(value: str) -> str:
diff --git a/sdmx/writer/xml.py b/sdmx/writer/xml.py
index 083b67c0..5c446459 100644
--- a/sdmx/writer/xml.py
+++ b/sdmx/writer/xml.py
@@ -895,7 +895,10 @@ def _tov(obj: model.TargetObjectValue):
elem.append(Element("md:ReportPeriod", obj.report_period))
elif isinstance(obj, model.TargetIdentifiableObject):
elem.append(
- Element("md:ObjectReference", Element("URN", sdmx.urn.make(obj.obj)))
+ Element(
+ "md:ObjectReference",
+ Element("URN", sdmx.urn.make(obj.obj, strict=True)),
+ )
)
else: # pragma: no cover
raise NotImplementedError(type(obj))