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))