Skip to content

Commit 9a02ccb

Browse files
authored
Merge pull request #252 from khaeru/issue/251
Miscellaneous fixes for 2025-W44
2 parents 71cdf84 + 39d14f2 commit 9a02ccb

File tree

14 files changed

+259
-188
lines changed

14 files changed

+259
-188
lines changed

doc/whatsnew.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ Next release
99
- Python 3.14 (`released 2025-10-07 <https://www.python.org/downloads/release/python-3140/>`_) is fully supported (:pull:`249`).
1010
- Python 3.9 support is dropped, as `it has reached end-of-life <https://peps.python.org/pep-0569/#lifespan>`__ (:pull:`249`).
1111
:mod:`sdmx` requires Python 3.10 or later.
12+
- :class:`.URN` parses letters in the version part of a URN (:issue:`230`, :pull:`252`).
13+
This fixes a bug in v2.16.0–v2.23.1 where creating :class:`.VersionableArtefact`
14+
with both :py:`version=...` and :py:`urn=...` would raise :class:`ValueError`
15+
even if the two were in agreement.
16+
- Fix two regressions in :func:`.to_pandas` introduced in v2.23.0 (:issue:`251`, :pull:`252`).
1217

1318
v2.23.1 (2025-10-01)
1419
====================

sdmx/convert/pandas.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,15 @@ def to_pandas(obj, **kwargs):
503503
504504
`kwargs` can include any of the attributes of :class:`.PandasConverter`.
505505
506+
.. versionchanged:: 1.0
507+
508+
:func:`.to_pandas` handles all types of objects,
509+
replacing the earlier, separate ``data2pandas`` and ``structure2pd`` writers.
510+
511+
.. versionchanged:: 2.23.0
512+
513+
:func:`.to_pandas` is a thin wrapper for :class:`.PandasConverter`.
514+
506515
Other parameters
507516
----------------
508517
format_options :
@@ -513,15 +522,6 @@ def to_pandas(obj, **kwargs):
513522
time_format :
514523
if given, the :attr:`.CSVFormatOptions.time_format` attribute of the
515524
`format_options` keyword argument is replaced.
516-
517-
.. versionchanged:: 1.0
518-
519-
:func:`.to_pandas` handles all types of objects,
520-
replacing the earlier, separate ``data2pandas`` and ``structure2pd`` writers.
521-
522-
.. versionchanged:: 2.23.0
523-
524-
:func:`.to_pandas` is a thin wrapper for :class:`.PandasConverter`.
525525
"""
526526
csv.common.kwargs_to_format_options(kwargs, csv.common.CSVFormatOptions)
527527
return PandasConverter(**kwargs).convert(obj)
@@ -685,6 +685,7 @@ def convert_dataset(c: "PandasConverter", obj: common.BaseDataSet):
685685
Otherwise.
686686
"""
687687
c._context[common.BaseDataSet] = obj
688+
c._context.setdefault(common.BaseDataStructureDefinition, obj.structured_by)
688689
c._columns = ColumnSpec(pc=c, ds=obj)
689690

690691
# - Apply convert_obs() to every obs → iterable of list.
@@ -697,7 +698,11 @@ def convert_dataset(c: "PandasConverter", obj: common.BaseDataSet):
697698
# - (Possibly) convert certain columns to datetime.
698699
# - (Possibly) reshape.
699700
result = (
700-
pd.DataFrame(map(c._columns.convert_obs, obj.obs))
701+
pd.DataFrame(
702+
map(c._columns.convert_obs, obj.obs)
703+
if obj.obs
704+
else [[None] * len(c._columns.obs)]
705+
)
701706
.dropna(how="all")
702707
.set_axis(c._columns.obs, axis=1) # NB This must come after DataFrame(map(…))
703708
.assign(**c._columns.assign)

sdmx/model/common.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def value(self) -> str | None:
184184
return None
185185

186186

187-
@dataclass
187+
@dataclass(slots=True)
188188
class AnnotableArtefact(Comparable):
189189
#: :class:`Annotations <.Annotation>` of the object.
190190
#:
@@ -244,7 +244,7 @@ def eval_annotation(self, id: str, globals=None):
244244
return value
245245

246246

247-
@dataclass
247+
@dataclass(slots=True)
248248
class IdentifiableArtefact(AnnotableArtefact):
249249
#: Unique identifier of the object.
250250
id: str = MissingID
@@ -349,8 +349,8 @@ def __post_init__(self):
349349
super().__post_init__()
350350

351351
if not self.version:
352-
self.version = self._urn.version
353-
elif isinstance(self.version, str) and self.version == "None":
352+
self.version = self._urn.version or None
353+
elif isinstance(self.version, str) and self.version in ("", "None"):
354354
self.version = None
355355
elif self.urn and self.version != self._urn.version:
356356
raise ValueError(
@@ -1756,7 +1756,7 @@ def __add__(self, other):
17561756
if not isinstance(other, Key) and other is not None:
17571757
raise NotImplementedError
17581758
else:
1759-
result.values.update_fast(other.values)
1759+
result.values.update_fast(getattr(other, "values", []))
17601760
return result
17611761

17621762
def __radd__(self, other):

sdmx/model/v21.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ class Observation(common.BaseObservation):
300300
class DataSet(common.BaseDataSet):
301301
"""SDMX 2.1 DataSet."""
302302

303+
structured_by: DataStructureDefinition | None = None
304+
303305
#: Named ``attachedAttribute`` in the IM.
304306
attrib: DictLikeDescriptor[str, common.AttributeValue] = DictLikeDescriptor()
305307

sdmx/model/v30.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ class Observation(common.BaseObservation):
394394
class DataSet(common.BaseDataSet):
395395
"""SDMX 3.0 Data Set."""
396396

397+
structured_by: DataStructureDefinition | None = None
398+
397399

398400
class StructureSpecificDataSet(DataSet):
399401
"""SDMX 3.0 StructureSpecificDataSet.

sdmx/reader/xml/common.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@
55
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
66
from importlib import import_module
77
from itertools import chain, count
8-
from typing import TYPE_CHECKING, Any, ClassVar, cast
8+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
99

1010
from lxml import etree
1111
from lxml.etree import QName
1212

1313
import sdmx.urn
1414
from sdmx import message
1515
from sdmx.exceptions import XMLParseError # noqa: F401
16-
from sdmx.format import Version, list_media_types
16+
from sdmx.format import Version as FormatVersion
17+
from sdmx.format import list_media_types
1718
from sdmx.model import common
19+
from sdmx.model.version import Version
1820
from sdmx.reader.base import BaseReader
1921

2022
if TYPE_CHECKING:
2123
import types
2224

25+
AA = TypeVar("AA", bound=common.AnnotableArtefact)
26+
IA = TypeVar("IA", bound=common.IdentifiableArtefact)
27+
NA = TypeVar("NA", bound=common.NameableArtefact)
28+
MA = TypeVar("MA", bound=common.MaintainableArtefact)
29+
2330
# Sentinel value for a missing Agency
2431
_NO_AGENCY = common.Agency()
2532

@@ -50,7 +57,9 @@ class BaseReference:
5057
"version",
5158
)
5259

53-
def __init__(self, reader, elem, cls_hint=None):
60+
def __init__(
61+
self, reader: "XMLEventReader", elem, cls_hint: type | None = None
62+
) -> None:
5463
parent_tag = elem.tag
5564

5665
info = self.info_from_element(elem)
@@ -93,7 +102,7 @@ def __init__(self, reader, elem, cls_hint=None):
93102
@abstractmethod
94103
def info_from_element(cls, elem) -> dict[str, Any]: ...
95104

96-
def __str__(self):
105+
def __str__(self) -> str:
97106
# NB for debugging only
98107
return ( # pragma: no cover
99108
f"{self.cls.__name__}={self.agency.id}:{self.id}({self.version}) → "
@@ -108,7 +117,7 @@ class XMLEventReader(BaseReader):
108117
suffixes = [".xml"]
109118

110119
#: SDMX-ML version handled by this reader.
111-
xml_version: ClassVar[Version]
120+
xml_version: ClassVar[FormatVersion]
112121

113122
#: Reference to the module defining the format read.
114123
format: ClassVar["types.ModuleType"]
@@ -129,7 +138,9 @@ def __init_subclass__(cls: type["XMLEventReader"]):
129138
# Empty dictionary
130139
cls.parser = {}
131140

132-
name = {Version["2.1"]: "v21", Version["3.0.0"]: "v30"}[cls.xml_version]
141+
name = {FormatVersion["2.1"]: "v21", FormatVersion["3.0.0"]: "v30"}[
142+
cls.xml_version
143+
]
133144
cls.format = import_module(f"sdmx.format.xml.{name}")
134145
cls.model = import_module(f"sdmx.model.{name}")
135146
cls.media_types = list_media_types(base="xml", version=cls.xml_version)
@@ -306,7 +317,7 @@ def _dump(self): # pragma: no cover
306317
)
307318
print("\nIgnore:\n", self.ignore)
308319

309-
def push(self, stack_or_obj, obj=None):
320+
def push(self, stack_or_obj, obj=None) -> None:
310321
"""Push an object onto a stack."""
311322
if stack_or_obj is None:
312323
return
@@ -335,11 +346,11 @@ def push(self, stack_or_obj, obj=None):
335346

336347
self.stack[s][id] = obj
337348

338-
def stash(self, *stacks, name: str = "_stash"):
349+
def stash(self, *stacks, name: str = "_stash") -> None:
339350
"""Temporarily hide all objects in the given `stacks`."""
340351
self.push(name, {s: self.stack.pop(s, dict()) for s in stacks})
341352

342-
def unstash(self, name: str = "_stash"):
353+
def unstash(self, name: str = "_stash") -> None:
343354
"""Restore the objects hidden by the last :meth:`stash` call to their stacks.
344355
345356
Calls to :meth:`.stash` and :meth:`.unstash` should be matched 1-to-1; if the
@@ -361,7 +372,7 @@ def get_single(
361372
self,
362373
cls_or_name: type | str,
363374
id: str | None = None,
364-
version: str | None = None,
375+
version: str | Version | None = None,
365376
subclass: bool = False,
366377
) -> Any | None:
367378
"""Return a reference to an object while leaving it in its stack.
@@ -475,7 +486,9 @@ def resolve(self, ref):
475486
return parent.get_hierarchical(ref.target_id)
476487
raise # pragma: no cover
477488

478-
def annotable(self, cls, elem, **kwargs):
489+
AA = TypeVar("AA", bound=common.AnnotableArtefact)
490+
491+
def annotable(self, cls: type["AA"], elem, **kwargs) -> "AA":
479492
"""Create a AnnotableArtefact of `cls` from `elem` and `kwargs`.
480493
481494
Collects all parsed <com:Annotation>.
@@ -485,12 +498,12 @@ def annotable(self, cls, elem, **kwargs):
485498
kwargs["annotations"].extend(self.pop_all(self.model.Annotation))
486499
return cls(**kwargs)
487500

488-
def identifiable(self, cls, elem, **kwargs):
501+
def identifiable(self, cls: type["IA"], elem, **kwargs) -> "IA":
489502
"""Create a IdentifiableArtefact of `cls` from `elem` and `kwargs`."""
490503
setdefault_attrib(kwargs, elem, "id", "urn", "uri")
491504
return self.annotable(cls, elem, **kwargs)
492505

493-
def nameable(self, cls, elem, **kwargs):
506+
def nameable(self, cls: type["NA"], elem, **kwargs) -> "NA":
494507
"""Create a NameableArtefact of `cls` from `elem` and `kwargs`.
495508
496509
Collects all parsed :class:`.InternationalString` localizations of <com:Name>
@@ -502,7 +515,7 @@ def nameable(self, cls, elem, **kwargs):
502515
add_localizations(obj.description, self.pop_all("Description"))
503516
return obj
504517

505-
def maintainable(self, cls, elem, **kwargs):
518+
def maintainable(self, cls: type["MA"], elem, **kwargs) -> "MA":
506519
"""Create or retrieve a MaintainableArtefact of `cls` from `elem` and `kwargs`.
507520
508521
Following the SDMX-IM class hierarchy, :meth:`maintainable` calls
@@ -578,7 +591,7 @@ def maintainable(self, cls, elem, **kwargs):
578591
return obj
579592

580593

581-
def add_localizations(target: common.InternationalString, values: list) -> None:
594+
def add_localizations(target: common.InternationalString, values: Sequence) -> None:
582595
"""Add localized strings from *values* to *target*."""
583596
target.localizations.update({locale: label for locale, label in values})
584597

sdmx/reader/xml/v21.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ def _item_start(reader, elem):
499499
@possible_reference(unstash=True)
500500
def _item_end(reader: Reader, elem):
501501
cls = reader.class_for_tag(elem.tag)
502-
item = reader.nameable(cls, elem)
502+
item: "common.Item" = reader.nameable(cls, elem)
503503

504504
# Hierarchy is stored in two ways
505505

@@ -718,7 +718,7 @@ def _cl(reader: Reader, elem):
718718
assert dsd is not None
719719

720720
# Determine the class
721-
cls = reader.class_for_tag(elem.tag)
721+
cls: type[common.ComponentList] = reader.class_for_tag(elem.tag)
722722

723723
args = dict(
724724
# Retrieve the components
@@ -746,11 +746,8 @@ def _cl(reader: Reader, elem):
746746

747747
cl = reader.identifiable(cls, elem, **args)
748748

749-
try:
750-
# DimensionDescriptor only
749+
if isinstance(cl, common.DimensionDescriptor):
751750
cl.assign_order()
752-
except AttributeError:
753-
pass
754751

755752
# Assign to the DSD eagerly (instead of in _dsd_end()) for reference by next
756753
# ComponentList e.g. so that AttributeRelationship can reference the
@@ -1040,7 +1037,7 @@ def _ar(reader, elem):
10401037
def _structure_start(reader: Reader, elem):
10411038
# Get any external reference created earlier, or instantiate a new object
10421039
cls = reader.class_for_tag(elem.tag)
1043-
obj = reader.maintainable(cls, elem)
1040+
obj: "common.Structure" = reader.maintainable(cls, elem)
10441041

10451042
if obj not in reader.stack[cls]:
10461043
# A new object was created
@@ -1541,9 +1538,7 @@ def _hc_end(reader: Reader, elem):
15411538
level = common.Level(id=level_ref.id)
15421539

15431540
# Create the HierarchicalCode
1544-
obj = reader.identifiable(
1545-
reader.class_for_tag(elem.tag), elem, code=code, level=level
1546-
)
1541+
obj = reader.identifiable(common.HierarchicalCode, elem, code=code, level=level)
15471542

15481543
# Count children represented as XML sub-elements of the parent
15491544
n_child = sum(e.tag == elem.tag for e in elem)
@@ -1573,7 +1568,7 @@ def _h_start(reader: Reader, elem):
15731568

15741569
@end("str:Hierarchy", only=False)
15751570
def _h_end(reader: Reader, elem):
1576-
result = reader.nameable(
1571+
result: "v21.Hierarchy" = reader.nameable(
15771572
reader.class_for_tag(elem.tag),
15781573
elem,
15791574
has_formal_levels=eval(elem.attrib.get("leveled", "false").title()),

sdmx/tests/convert/test_pandas.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,18 @@ def expected(df, axis=0, cls=pd.DatetimeIndex):
505505
sdmx.to_pandas(ds, datetime=43)
506506

507507

508+
def test_dataset_empty() -> None:
509+
"""Dataset with 0 observations can be converted.
510+
511+
https://github.com/khaeru/sdmx/issues/251.
512+
"""
513+
dsd = v21.DataStructureDefinition()
514+
dsd.dimensions.getdefault(id="DIM_0")
515+
ds = v21.DataSet(structured_by=dsd)
516+
517+
sdmx.to_pandas(ds)
518+
519+
508520
def test_list_of_obs(specimen) -> None:
509521
"""Bare list of observations can be written."""
510522
with specimen("ng-ts.xml") as f:

0 commit comments

Comments
 (0)