Skip to content

Commit e88f8d3

Browse files
authored
Merge pull request #198 from khaeru/fix/write-xml-version
Write .model.Version to SDMX-ML
2 parents 9186c89 + 64fd68f commit e88f8d3

File tree

12 files changed

+130
-90
lines changed

12 files changed

+130
-90
lines changed

doc/api/model-common-list.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,5 @@
8282
:obj:`~.common.VTLConceptMapping`
8383
:obj:`~.common.VTLDataflowMapping`
8484
:obj:`~.common.VTLMappingScheme`
85+
:obj:`~.common.Version`
8586
:obj:`~.common.VersionableArtefact`

doc/api/writer.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ Some of the internal methods take specific arguments and return varying values.
3838
These arguments can be passed to :func:`.to_pandas` when `obj` is of the appropriate type:
3939

4040
.. autosummary::
41-
sdmx.writer.pandas.write_dataset
42-
sdmx.writer.pandas.write_datamessage
43-
sdmx.writer.pandas.write_itemscheme
44-
sdmx.writer.pandas.write_structuremessage
45-
sdmx.writer.pandas.DEFAULT_RTYPE
41+
write_dataset
42+
write_datamessage
43+
write_itemscheme
44+
write_structuremessage
45+
DEFAULT_RTYPE
4646

4747
Other objects are converted as follows:
4848

doc/howto/create.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,16 @@ There are different classes to describe dimensions, measures, and attributes.
6363
6464
import sdmx
6565
from sdmx.model.v21 import (
66+
Agency,
6667
DataStructureDefinition,
6768
Dimension,
6869
PrimaryMeasure,
6970
DataAttribute,
7071
)
7172
7273
# Create an empty DSD
73-
dsd = DataStructureDefinition(id="CUSTOM_DSD")
74+
m = Agency(id="EXAMPLE")
75+
dsd = DataStructureDefinition(id="CUSTOM_DSD", maintainer=m)
7476
7577
# Add 1 Dimension object to the DSD for each dimension of the data.
7678
# Dimensions must have a explicit order for make_key(), below.

doc/whatsnew.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
What's new?
44
***********
55

6-
.. Next release
7-
.. ============
6+
Next release
7+
============
8+
9+
- Bug fix for writing :class:`.VersionableArtefact` to SDMX-ML 2.1: :class:`KeyError` was raised if :attr:`.VersionableArtefact.version` was an instance of :class:`.Version` (:pull:`198`).
810

911
v2.18.0 (2024-10-15)
1012
====================

sdmx/message.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,12 @@ def get(
285285
:attr:`~.IdentifiableArtefact.id`; if :class:`str`, an object with this ID
286286
*or* this string as part of its :attr:`~.IdentifiableArtefact.urn`.
287287
288+
.. todo:: Support passing a URN.
289+
288290
Returns
289291
-------
290292
.IdentifiableArtefact
291-
with the given ID and possibly class.
292-
None
293-
if there is no match.
293+
with the given ID and possibly class, or :any:`None` if there is no match.
294294
295295
Raises
296296
------
@@ -299,8 +299,6 @@ def get(
299299
of different classes, or two objects of the same class with different
300300
:attr:`~.MaintainableArtefact.maintainer` or
301301
:attr:`~.VersionableArtefact.version`.
302-
303-
.. todo:: Support passing a URN.
304302
"""
305303
id_ = (
306304
obj_or_id.id

sdmx/model/common.py

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,20 @@
3131

3232
from sdmx.dictlike import DictLikeDescriptor
3333
from sdmx.rest import Resource
34+
from sdmx.urn import URN
3435
from sdmx.util import compare, direct_fields, only
3536

3637
from .internationalstring import (
3738
DEFAULT_LOCALE,
3839
InternationalString,
3940
InternationalStringDescriptor,
4041
)
42+
from .version import Version
4143

4244
__all__ = [
4345
"DEFAULT_LOCALE",
4446
"InternationalString",
47+
"Version",
4548
# In the order they appear in this file
4649
"ConstrainableArtefact",
4750
"Annotation",
@@ -238,25 +241,20 @@ class IdentifiableArtefact(AnnotableArtefact):
238241
#: a URN.
239242
urn: Optional[str] = None
240243

241-
urn_group: dict = field(default_factory=dict, repr=False)
242-
243244
def __post_init__(self):
244-
if not isinstance(self.id, str):
245+
# Validate URN, if any
246+
self._urn = URN(self.urn)
247+
248+
if not self.id:
249+
self.id = self._urn.item_id or self._urn.id or MissingID
250+
elif self.urn and self.id not in (self._urn.item_id or self._urn.id):
251+
# Ensure explicit ID is consistent with URN
252+
raise ValueError(f"ID {self.id} does not match URN {self.urn}")
253+
elif not isinstance(self.id, str):
245254
raise TypeError(
246255
f"IdentifiableArtefact.id must be str; got {type(self.id).__name__}"
247256
)
248257

249-
if self.urn:
250-
import sdmx.urn
251-
252-
self.urn_group = sdmx.urn.match(self.urn)
253-
254-
try:
255-
if self.id not in (self.urn_group["item_id"] or self.urn_group["id"]):
256-
raise ValueError(f"ID {self.id} does not match URN {self.urn}")
257-
except KeyError:
258-
pass
259-
260258
def __eq__(self, other):
261259
"""Equality comparison.
262260
@@ -362,23 +360,23 @@ def __repr__(self) -> str:
362360
@dataclass
363361
class VersionableArtefact(NameableArtefact):
364362
#: A version string following an agreed convention.
365-
version: Optional[str] = None
363+
version: Union[str, Version, None] = None
366364
#: Date from which the version is valid.
367365
valid_from: Optional[str] = None
368366
#: Date from which the version is superseded.
369367
valid_to: Optional[str] = None
370368

371369
def __post_init__(self):
372370
super().__post_init__()
373-
try:
374-
if self.version and self.version != self.urn_group["version"]:
375-
raise ValueError(
376-
f"Version {self.version} does not match URN {self.urn}"
377-
)
378-
else:
379-
self.version = self.urn_group["version"]
380-
except KeyError:
381-
pass
371+
372+
if not self.version:
373+
self.version = self._urn.version
374+
elif isinstance(self.version, str) and self.version == "None":
375+
self.version = None
376+
elif self.urn and self.version != self._urn.version:
377+
raise ValueError(
378+
f"Version {self.version!r} does not match URN {self.urn!r}"
379+
)
382380

383381
def compare(self, other, strict=True):
384382
"""Return :obj:`True` if `self` is the same as `other`.
@@ -420,15 +418,14 @@ class MaintainableArtefact(VersionableArtefact):
420418

421419
def __post_init__(self):
422420
super().__post_init__()
423-
try:
424-
if self.maintainer and self.maintainer.id != self.urn_group["agency"]:
421+
422+
if self.urn:
423+
if self.maintainer and self.maintainer.id != self._urn.agency:
425424
raise ValueError(
426425
f"Maintainer {self.maintainer} does not match URN {self.urn}"
427426
)
428427
else:
429-
self.maintainer = Agency(id=self.urn_group["agency"])
430-
except KeyError:
431-
pass
428+
self.maintainer = Agency(id=self._urn.agency)
432429

433430
def compare(self, other, strict=True):
434431
"""Return :obj:`True` if `self` is the same as `other`.

sdmx/model/version.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,26 @@ def cmp(self, other) -> bool:
3535
class Version(packaging.version.Version):
3636
"""Class representing a version.
3737
38-
This class extends :class:`packaging.version.Version`, which provides a complete
39-
interface for interacting with Python version specifiers. The extensions implement
40-
the particular form of versioning laid out by the SDMX standards. Specifically:
41-
42-
- :attr:`kind` to identify whether the version is an SDMX 2.1, SDMX 3.0, or Python-
43-
style version string.
38+
The SDMX Information Model **does not** specify a Version class; instead,
39+
:attr:`.VersionableArtefact.version` is described as “a version **string** following
40+
SDMX versioning rules.”
41+
42+
In order to simplify application of those ‘rules’, and to handle the differences
43+
between SDMX 2.1 and 3.0.0, this class extends :class:`packaging.version.Version`,
44+
which provides a complete interface for interacting with Python version specifiers.
45+
The extensions implement the particular form of versioning laid out by the SDMX
46+
standards. Specifically:
47+
48+
- :attr:`kind` as added to identify whether a Version instance is an SDMX 2.1, SDMX
49+
3.0, or Python-style version string.
4450
- Attribute aliases for particular terms used in the SDMX 3.0 standards:
4551
:attr:`patch` and :attr:`ext`.
4652
- The :class:`str` representation of a Version uses the SDMX 3.0 style of separating
4753
the :attr:`ext` with a hyphen ("1.0.0-dev1"), which differs from the Python style
4854
of using no separator for a ‘post-release’ ("1.0.0dev1") or a plus symbol for a
4955
‘local part’ ("1.0.0+dev1").
50-
- The class is comparable with :class:`str` version expressions.
56+
- :meth:`increment`, an added convenience method.
57+
- The class is comparable and interchangeable with :class:`str` version expressions.
5158
5259
Parameters
5360
----------

sdmx/tests/model/test_common.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
import sdmx.model as model
8-
from sdmx.model import v21
8+
from sdmx.model import common, v21
99
from sdmx.model.common import (
1010
Agency,
1111
AnnotableArtefact,
@@ -16,7 +16,6 @@
1616
IdentifiableArtefact,
1717
Item,
1818
ItemScheme,
19-
MaintainableArtefact,
2019
NameableArtefact,
2120
Representation,
2221
)
@@ -90,18 +89,22 @@ def test_eval_annotation(self, caplog) -> None:
9089
)
9190

9291

92+
URN = "urn:sdmx:org.sdmx.infomodel.conceptscheme.ConceptScheme=IT1:VARIAB_ALL(9.6)"
93+
94+
9395
class TestIdentifiableArtefact:
94-
def test_general(self):
95-
urn = (
96-
"urn:sdmx:org.sdmx.infomodel.conceptscheme.ConceptScheme=IT1:VARIAB_ALL"
97-
"(9.6)"
98-
)
99-
urn_pat = urn.replace("(", r"\(").replace(")", r"\)")
96+
def test_init_urn(self):
97+
"""IdentifiableArtefact can be initialized with URN."""
98+
ia = IdentifiableArtefact(urn=URN)
99+
assert "VARIAB_ALL" == ia.id
100+
101+
def test_general(self) -> None:
102+
urn_pat = URN.replace("(", r"\(").replace(")", r"\)")
100103

101104
with pytest.raises(
102105
ValueError, match=f"ID BAD_URN does not match URN {urn_pat}"
103106
):
104-
IdentifiableArtefact(id="BAD_URN", urn=urn)
107+
IdentifiableArtefact(id="BAD_URN", urn=URN)
105108

106109
# IdentifiableArtefact is hashable
107110
ia = IdentifiableArtefact()
@@ -162,27 +165,32 @@ def test_namea(self, caplog) -> None:
162165
assert na1.compare(na2)
163166

164167

165-
class TestMaintainableArtefact:
166-
def test_urn(self):
167-
urn = (
168-
"urn:sdmx:org.sdmx.infomodel.conceptscheme.ConceptScheme="
169-
"IT1:VARIAB_ALL(9.6)"
170-
)
171-
ma = MaintainableArtefact(id="VARIAB_ALL", urn=urn)
168+
class TestVersionableArtefact:
169+
def test_urn(self) -> None:
170+
va = common.VersionableArtefact(id="VARIAB_ALL", urn=URN)
172171

173172
# Version is parsed from URN
174-
assert ma.version == "9.6"
173+
assert va.version == "9.6"
175174

176175
# Mismatch raises an exception
177-
with pytest.raises(ValueError, match="Version 9.7 does not match URN"):
178-
MaintainableArtefact(version="9.7", urn=urn)
176+
with pytest.raises(ValueError, match="Version '9.7' does not match URN"):
177+
common.VersionableArtefact(version="9.7", urn=URN)
178+
179+
def test_version_none(self) -> None:
180+
va = common.VersionableArtefact(version="None")
181+
assert va.version is None
182+
183+
184+
class TestMaintainableArtefact:
185+
def test_urn(self) -> None:
186+
ma = common.MaintainableArtefact(id="VARIAB_ALL", urn=URN)
179187

180188
# Maintainer is parsed from URN
181189
assert ma.maintainer == Agency(id="IT1")
182190

183191
# Mismatch raises an exception
184192
with pytest.raises(ValueError, match="Maintainer FOO does not match URN"):
185-
MaintainableArtefact(maintainer=Agency(id="FOO"), urn=urn)
193+
common.MaintainableArtefact(maintainer=Agency(id="FOO"), urn=URN)
186194

187195

188196
class TestItem:

sdmx/tests/test_model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@
9696
# Appearing in model.InternationalString
9797
"DEFAULT_LOCALE",
9898
"InternationalString",
99+
# Appearing in model.Version
100+
"Version",
99101
# Classes that are distinct in .model.v21 versus .model.v30
100102
"SelectionValue",
101103
"MemberValue",

sdmx/tests/writer/test_writer_xml.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sdmx
88
import sdmx.writer.xml
99
from sdmx import message
10+
from sdmx.model import common
1011
from sdmx.model import v21 as m
1112
from sdmx.model.v21 import DataSet, DataStructureDefinition, Dimension, Key, Observation
1213
from sdmx.writer.xml import writer as XMLWriter
@@ -166,14 +167,23 @@ def test_reference() -> None:
166167
assert 'version="1.0"' in result_str
167168

168169

169-
def test_Footer(footer):
170-
""":class:`.Footer` can be written."""
171-
sdmx.to_xml(footer)
170+
def test_VersionableArtefact() -> None:
171+
""":class:`VersionableArtefact` with :class:`.Version` instance can be written."""
172+
cl: common.Codelist = common.Codelist(id="FOO", version=common.Version("1.2.3"))
173+
174+
# Written to XML without error
175+
result = sdmx.to_xml(cl).decode()
176+
assert 'version="1.2.3"' in result
172177

173178

174179
# sdmx.message classes
175180

176181

182+
def test_Footer(footer):
183+
""":class:`.Footer` can be written."""
184+
sdmx.to_xml(footer)
185+
186+
177187
def test_structuremessage(tmp_path, structuremessage):
178188
result = sdmx.to_xml(structuremessage, pretty_print=True)
179189

0 commit comments

Comments
 (0)