From 701f7dafcc9ae3ec4e42317391d80c9fe9ea84b1 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2024 16:59:50 +0200 Subject: [PATCH 1/9] Add .model.version module, tests --- sdmx/model/version.py | 221 +++++++++++++++++++++++++++++++ sdmx/tests/model/test_version.py | 106 +++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 sdmx/model/version.py create mode 100644 sdmx/tests/model/test_version.py diff --git a/sdmx/model/version.py b/sdmx/model/version.py new file mode 100644 index 000000000..7b9453c91 --- /dev/null +++ b/sdmx/model/version.py @@ -0,0 +1,221 @@ +"""Handle SDMX version identifiers.""" + +import operator +import re +from typing import Callable, Optional, Union + +import packaging.version + +#: Regular expressions (:class:`re.Pattern`) for version strings. +#: +#: - :py:`"2_1"` SDMX 2.1, e.g. "1.0" +#: - :py:`"3_0"` SDMX 3.0, e.g. "1.0.0-draft" +#: - :py:`"py"` Python-compatible versions, using :mod:`packaging.version`. +VERSION_PATTERNS = { + "2_1": re.compile(r"^(?P[0-9]+(?:\.[0-9]+){1})$"), + "3_0": re.compile(r"^(?P[0-9]+(?:\.[0-9]+){2})(-(?P.+))?$"), + "py": re.compile( + r"^\s*" + packaging.version.VERSION_PATTERN + r"\s*$", + re.VERBOSE | re.IGNORECASE, + ), +} + + +def _cmp_method(op: Callable) -> Callable: + def cmp(self, other) -> bool: + try: + return op(self._key, other._key) + except AttributeError: + if isinstance(other, str): + return op(self, Version(other)) + else: + return NotImplemented + + return cmp + + +class Version(packaging.version.Version): + """Version. + + Parameters + ---------- + version : str + String expression + """ + + #: Type of version expression; one of the keys of :data:`.VERSION_PATTERNS`. + kind: str + + def __init__(self, version: str): + for kind, pattern in VERSION_PATTERNS.items(): + match = pattern.fullmatch(version) + if match: + break + + if not match: + raise packaging.version.InvalidVersion(version) + + self.kind = kind + + if kind == "py": + tmp = packaging.version.Version(version) + self._version = tmp._version + else: + # Store the parsed out pieces of the version + try: + ext = match.group("ext") + local = None if ext is None else (ext,) + except IndexError: + local = None + self._version = packaging.version._Version( + epoch=0, + release=tuple(int(i) for i in match.group("release").split(".")), + pre=None, + post=None, + dev=None, + local=local, + ) + + self._update_key() + + def _update_key(self): + # Generate a key which will be used for sorting + self._key = packaging.version._cmpkey( + self._version.epoch, + self._version.release, + self._version.pre, + self._version.post, + self._version.dev, + self._version.local, + ) + + def __str__(self): + if self.kind == "3_0": + parts = [".".join(str(x) for x in self.release)] + if self.ext: + parts.append(f"-{self.ext}") + return "".join(parts) + else: + return super().__str__() + + __eq__ = _cmp_method(operator.eq) + __ge__ = _cmp_method(operator.ge) + __gt__ = _cmp_method(operator.gt) + __le__ = _cmp_method(operator.le) + __lt__ = _cmp_method(operator.lt) + __ne__ = _cmp_method(operator.ne) + + @property + def patch(self) -> int: + """Alias for :attr:`.Version.micro`.""" + return self.micro + + @property + def ext(self) -> Optional[str]: + """SDMX 3.0 version 'extension'. + + For :py:`kind="py"`, this is equivalent to :attr:`.Version.local`. + """ + if self._version.local is None: + return None + else: + return "".join(map(str, self._version.local)) + + def increment(self, **kwargs: Union[bool, int]) -> "Version": + """Return a Version that is incrementally greater than the current Version. + + If no arguments are given, then by default :py:`minor=True` and :py:`ext=1`. + + Parameters + ---------- + major : bool or int, *optional* + If given, increment the :attr:`.Version.major` part. + minor : bool or int, *optional* + If given, increment the :attr:`.Version.minor` part. + patch : bool or int, *optional* + If given, increment the :attr:`.Version.patch` part. + micro : bool or int, *optional* + Alias for `patch`. + ext : bool or int, *optional* + If given, increment the :attr:`.Version.ext` part. If this part is not + present, add "dev1". + local: bool or int, *optional* + Alias for `ext`. + """ + if not kwargs: + # Apply defaults + kwargs["minor"] = kwargs["ext"] = 1 + + # Convert self._version.release into a mutable dict + N_release = len(self._version.release) # Number of parts in `release` tuple + parts = dict( + major=self._version.release[0] if N_release > 0 else 0, + minor=self._version.release[1] if N_release > 1 else 0, + patch=self._version.release[2] if N_release > 2 else 0, + ) + # Convert self._version.local into a mutable list + local = list(self._version.local) if self._version.local is not None else [] + + # Increment parts according to kwargs + for part, value in kwargs.items(): + # Recognize kwarg aliases + part = {"local": "ext", "micro": "patch"}.get(part, part) + + # Update the extension/local part + if part == "ext": + if not len(local): + ext = "dev1" + elif match := re.fullmatch("([^0-9]+)([0-9]+)", str(local[0])): + _l, _n = match.group(1, 2) + ext = f"{_l}{int(_n) + value}" + else: + raise NotImplementedError( + f"Increment SDMX version extension {self.ext!r}" + ) + local = [ext] + continue + + try: + # Update the major/minor/patch parts + parts[part] += int(value) + except KeyError: + raise ValueError(f"increment(..., {part}={value})") + + # Construct a new Version object + result = type(self)(str(self)) + # Overwrite its private _version attribute and key + result._version = packaging.version._Version( + epoch=self._version.epoch, + release=tuple(parts.values()), + pre=self._version.pre, + post=self._version.post, + dev=self._version.dev, + local=tuple(local) if len(local) else None, + ) + result._update_key() + + return result + + +def increment(value: Union[packaging.version.Version, str], **kwargs) -> Version: + """Increment the version `existing`. + + Identical to :py:`Version(str(value)).increment(**kwargs)`. + + See also + -------- + Version.increment + """ + return Version(str(value)).increment(**kwargs) + + +def parse(value: str) -> Version: + """Parse the given version string. + + Identical to :py:`Version(value)`. + + See also + -------- + Version + """ + return Version(value) diff --git a/sdmx/tests/model/test_version.py b/sdmx/tests/model/test_version.py new file mode 100644 index 000000000..7ad588469 --- /dev/null +++ b/sdmx/tests/model/test_version.py @@ -0,0 +1,106 @@ +import operator + +import pytest +from packaging.version import InvalidVersion +from packaging.version import Version as PVVersion + +from sdmx.model.version import Version, increment, parse + + +class TestVersion: + @pytest.mark.parametrize("value, exp_kind", (("1.2.0+dev1", "py"),)) + def test_init(self, value, exp_kind) -> None: + assert exp_kind == Version(value).kind + + @pytest.mark.parametrize( + "op, value, exp", + ( + (operator.eq, "1.0.0", True), + (operator.ge, "1.0.0", True), + (operator.gt, "1.0.0", False), + (operator.le, "1.0.0", True), + (operator.lt, "1.0.0", False), + (operator.ne, "1.0.0", False), + # Not implemented + pytest.param( + operator.gt, 1.0, None, marks=pytest.mark.xfail(raises=TypeError) + ), + ), + ) + def test_binop_str(self, op, value, exp) -> None: + assert exp is op(Version("1.0.0"), value) + + +@pytest.mark.parametrize( + "value, expected", + ( + # SDMX 2.1 + ("0.0", PVVersion("0.0")), + ("1.0", PVVersion("1.0")), + # SDMX 3.0 + ("0.0.0-dev1", PVVersion("0.0.0+dev1")), + ("1.0.0-dev1", PVVersion("1.0.0+dev1")), + # Python + ("1!1.2.3+abc.dev1", PVVersion("1!1.2.3+abc.dev1")), + # Invalid + pytest.param("foo", None, marks=pytest.mark.xfail(raises=InvalidVersion)), + ), +) +def test_parse(value, expected) -> None: + v = parse(value) + + assert expected == v + + # Value round-trips + assert value == str(v) + + # Attributes can be accessed + v.major + v.minor + v.patch + v.local + v.ext + + # Object's increment() method can be called + assert v < v.increment(patch=1) < v.increment(minor=1) < v.increment(major=1) + + +@pytest.mark.parametrize( + "kwargs, expected", + ( + (dict(), PVVersion("1.1.0+dev1")), + (dict(major=True), PVVersion("2.0.0")), + (dict(major=1), PVVersion("2.0.0")), + (dict(minor=True), PVVersion("1.1.0")), + (dict(minor=1), PVVersion("1.1.0")), + (dict(patch=True), PVVersion("1.0.1")), + (dict(patch=1), PVVersion("1.0.1")), + (dict(micro=True), PVVersion("1.0.1")), + (dict(ext=1), PVVersion("1.0.0+dev1")), + pytest.param(dict(foo=True), None, marks=pytest.mark.xfail(raises=ValueError)), + ), +) +def test_increment0(kwargs, expected): + # PVVersion.increment() method + assert expected == parse("1.0.0").increment(**kwargs) + + # increment() function + assert expected == increment("1.0.0", **kwargs) + + +_NIE = pytest.mark.xfail(raises=NotImplementedError) + + +@pytest.mark.parametrize( + "base, kwarg, expected", + ( + ("1.0.0", dict(ext=1), PVVersion("1.0.0+dev1")), + ("1.0.0-dev1", dict(ext=1), PVVersion("1.0.0+dev2")), + ("1.0.0-dev1", dict(ext=2), PVVersion("1.0.0+dev3")), + ("1.0.0-foodev1", dict(ext=1), PVVersion("1.0.0+foodev2")), + pytest.param("1.0.0-draft", dict(ext=1), None, marks=_NIE), + ), +) +def test_increment1(base, kwarg, expected): + """Test incrementing the 'extension' version part.""" + assert expected == parse(base).increment(**kwarg) From 15e40dbf74bc51d3ef3db1a2f2733f97abc9a014 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2024 19:27:10 +0200 Subject: [PATCH 2/9] Add .urn.{URN,expand,normalize,shorten}, tests --- sdmx/tests/test_urn.py | 71 +++++++++++++++++- sdmx/urn.py | 159 +++++++++++++++++++++++++++++++++++------ 2 files changed, 205 insertions(+), 25 deletions(-) diff --git a/sdmx/tests/test_urn.py b/sdmx/tests/test_urn.py index 5f76afeb8..ea61c873d 100644 --- a/sdmx/tests/test_urn.py +++ b/sdmx/tests/test_urn.py @@ -3,10 +3,36 @@ import pytest from sdmx.model import v21 as m -from sdmx.urn import make, match +from sdmx.urn import expand, make, match, normalize, shorten -def test_make(): +@pytest.mark.parametrize( + "value, expected", + ( + # MaintainableArtefact + ( + "Codelist=BAZ:FOO(1.2.3)", + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + ), + # Item in a MaintainableArtefact + ( + "Code=BAZ:FOO(1.2.3).BAR", + "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR", + ), + # Expand an already-complete URN: pass-through + ( + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + ), + # Not a URN: pass-through + ("foo", "foo"), + ), +) +def test_expand(value, expected) -> None: + assert expected == expand(value) + + +def test_make() -> None: """:func:`.make` can look up and use information about the parent ItemScheme.""" c = m.Code(id="BAR") @@ -42,7 +68,7 @@ def test_make(): assert "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)" == make(cl) -def test_match(): +def test_match() -> None: # Value containing a "." in the ID urn = ( "urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=LSD:" @@ -54,3 +80,42 @@ def test_match(): urn = "urn:sdmx:org.sdmx.infomodel.codelist=BBK:CLA_BBK_COLLECTION(1.0)" with pytest.raises(ValueError, match=re.escape(f"not a valid SDMX URN: {urn}")): match(urn) + + +@pytest.mark.parametrize( + "value, expected", + ( + # Other URN: pass-through + ( + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + ), + # Not a URN: pass-through + ("foo", "foo"), + ), +) +def test_normalize(value, expected) -> None: + assert expected == normalize(value) + + +@pytest.mark.parametrize( + "value, expected", + ( + # MaintainableArtefact + ( + "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=BAZ:FOO(1.2.3)", + "Codelist=BAZ:FOO(1.2.3)", + ), + # Item in a MaintainableArtefact + ( + "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR", + "Code=BAZ:FOO(1.2.3).BAR", + ), + # Shorten an already-partial URN: pass-through + ("Codelist=BAZ:FOO(1.2.3)", "Codelist=BAZ:FOO(1.2.3)"), + # Not a URN: pass-through + ("foo", "foo"), + ), +) +def test_shorten(value, expected) -> None: + assert expected == shorten(value) diff --git a/sdmx/urn.py b/sdmx/urn.py index 8cc941298..da4d7c10f 100644 --- a/sdmx/urn.py +++ b/sdmx/urn.py @@ -1,10 +1,10 @@ import re -from typing import Dict +from typing import Dict, Optional from sdmx.model import PACKAGE, MaintainableArtefact #: Regular expression for URNs. -URN = re.compile( +_PATTERN = re.compile( r"urn:sdmx:org\.sdmx\.infomodel" r"\.(?P[^\.]*)" r"\.(?P[^=]*)=((?P[^:]*):)?" @@ -12,13 +12,92 @@ r"(\.(?P.*))?" ) -_BASE = ( - "urn:sdmx:org.sdmx.infomodel.{package}.{obj.__class__.__name__}=" - "{ma.maintainer.id}:{ma.id}({ma.version}){extra_id}" -) +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 :data:`.PACKAGE` corresponding to :attr:`klass`. + package: str + klass: str + agency: str + id: str + version: str + item_id: Optional[str] + + def __init__(self, value, **kwargs) -> None: + if kwargs: + self.__dict__.update(kwargs) -def make(obj, maintainable_parent=None, strict=False): + if value is None: + return + + try: + match = _PATTERN.match(value) + assert match is not None + except (AssertionError, TypeError): + raise ValueError(f"not a valid SDMX URN: {value}") + + g = self.groupdict = match.groupdict() + + self.package = ( + PACKAGE[g["class"]] if g["package"] == "package" else 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}:" + f"{self.id}({self.version})" + + (("." + self.item_id) if self.item_id else "") + ) + + +def expand(value: str) -> str: + """Return the full URN for `value`. + + Parameters + ---------- + value : str + Either the final part of a valid SDMX URN, for example + `Codelist=BAZ:FOO(1.2.3)`, or a full URN. + + Returns + ------- + str + The full SDMX URN. If `value` is not a partial or full URN, it is returned + unmodified. + + Raises + ------ + ValueError + If `value` is not a valid part of a SDMX URN. + """ + for candidate in (value, f"urn:sdmx:org.sdmx.infomodel.package.{value}"): + try: + return str(URN(candidate)) + except ValueError: + continue + + return value + + +def make( + obj, + maintainable_parent: Optional["MaintainableArtefact"] = None, + strict: bool = False, +) -> str: """Create an SDMX URN for `obj`. If `obj` is not :class:`.MaintainableArtefact`, then `maintainable_parent` @@ -26,31 +105,67 @@ def make(obj, maintainable_parent=None, strict=False): """ if not isinstance(obj, MaintainableArtefact): ma = maintainable_parent or obj.get_scheme() - extra_id = f".{obj.id}" + item_id = obj.id else: - ma = obj - extra_id = "" + ma, item_id = obj, None if not isinstance(ma, MaintainableArtefact): raise ValueError( - f"Neither {repr(obj)} nor {repr(maintainable_parent)} are maintainable" + f"Neither {obj!r} nor {maintainable_parent!r} are maintainable" ) elif ma.maintainer is None: - raise ValueError(f"Cannot construct URN for {repr(ma)} without maintainer") + raise ValueError(f"Cannot construct URN for {ma!r} without maintainer") elif strict and ma.version is None: - raise ValueError(f"Cannot construct URN for {repr(ma)} without version") + raise ValueError(f"Cannot construct URN for {ma!r} without version") - return _BASE.format( - package=PACKAGE[obj.__class__.__name__], obj=obj, ma=ma, extra_id=extra_id + return str( + URN( + None, + package=PACKAGE[obj.__class__.__name__], + klass=obj.__class__.__name__, + agency=ma.maintainer.id, + id=ma.id, + version=ma.version, + item_id=item_id, + ) ) def match(value: str) -> Dict[str, str]: - """Match :data:`URN` in `value`, returning a :class:`dict` with the match groups.""" + """Match :data:`URN` in `value`, returning a :class:`dict` with the match groups. + + Raises + ------ + ValueError + If `value` is not a well-formed SDMX URN. + """ + return URN(value).groupdict + + +def normalize(value: str) -> str: + """Normalize URNs. + + Handle "…DataFlow=…" (SDMX 3.0) vs. "…DataFlowDefinition=…" (SDMX 2.1) in URNs; + prefer the former. + """ + return value.replace("Definition=", "=") + + +def shorten(value: str) -> str: + """Return a partial URN based on `value`. + + Parameters + ---------- + value : str + A full SDMX URN. If the value is not a URN, it is returned unmodified. + + Returns + ------- + str + `value`, but without the leading text + :py:`"urn:sdmx:org.sdmx.infomodel.{package}."` + """ try: - match = URN.match(value) - assert match is not None - except (AssertionError, TypeError): - raise ValueError(f"not a valid SDMX URN: {value}") - else: - return match.groupdict() + return str(URN(value)).split(".", maxsplit=4)[-1] + except ValueError: + return value From 48f6fb3803ce8cd2175e36cfb81ed85681fb3f10 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Tue, 13 Aug 2024 19:29:45 +0200 Subject: [PATCH 3/9] Document BaseDataSet.{described_by,structured_by} --- sdmx/model/common.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdmx/model/common.py b/sdmx/model/common.py index 0ff7dc78d..c19f2908f 100644 --- a/sdmx/model/common.py +++ b/sdmx/model/common.py @@ -1996,9 +1996,12 @@ class BaseDataSet(AnnotableArtefact): action: Optional[ActionType] = None #: valid_from: Optional[str] = None - #: + + #: Association to the :class:`Dataflow <.BaseDataflow>` that contains the data set. described_by: Optional[BaseDataflow] = None - #: + + #: Association to the :class:`DataStructure <.BaseDataStructureDefinition` that + #: defines the structure of the data set. structured_by: Optional[BaseDataStructureDefinition] = None #: All observations in the DataSet. From b781d1c0e22927b0f8b07c57181863e35c069e79 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 15:15:37 +0200 Subject: [PATCH 4/9] Address mypy warnings in .test_docs --- sdmx/tests/test_docs.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sdmx/tests/test_docs.py b/sdmx/tests/test_docs.py index da4d64744..6f7ef2eef 100644 --- a/sdmx/tests/test_docs.py +++ b/sdmx/tests/test_docs.py @@ -65,7 +65,7 @@ def test_doc_example(): @pytest.mark.network -def test_doc_index1(): +def test_doc_index1() -> None: """A code example that formerly appeared in doc/index.rst.""" estat = Client("ESTAT") sm0 = estat.dataflow("UNE_RT_A") @@ -73,14 +73,15 @@ def test_doc_index1(): with pytest.raises(TypeError): # This presumes the DataStructureDefinition instance can conduct a # network request for its own content - sm1 = sm0.dataflow.UNE_RT_A.structure(request=True, target_only=False) + sm0.dataflow.UNE_RT_A.structure(request=True, target_only=False) # Same effect - sm1: "sdmx.message.StructureMessage" = estat.get( + sm1 = estat.get( "datastructure", sm0.dataflow.UNE_RT_A.structure.id, params=dict(references="descendants"), ) + assert isinstance(sm1, sdmx.message.StructureMessage) # Even better: Client.get(…) should examine the class and ID of the object # structure = estat.get(flow_response.dataflow.UNE_RT_A.structure) @@ -102,7 +103,7 @@ def test_doc_index1(): # StructureMessage is converted to DictLike assert isinstance(s, DictLike) # "codelist" key retrieves a second-level DictLike - assert isinstance(s.codelist, DictLike) + assert isinstance(s.codelist, DictLike) # type: ignore [attr-defined] # Same effect # NB At some times (e.g. between 2024-03-15 and 2024-06-18) this query retrieves From 6afa3ce5e7f5b9cce533985c21840ed294f678fc Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 16:00:40 +0200 Subject: [PATCH 5/9] Expand docs for #189 --- doc/api.rst | 8 ++++++++ doc/api/model.rst | 4 ++++ doc/api/writer.rst | 4 ++-- doc/conf.py | 1 + sdmx/model/common.py | 2 +- sdmx/model/version.py | 41 +++++++++++++++++++++++++++------------- sdmx/urn.py | 44 ++++++++++++++++++++++++++++++++++--------- 7 files changed, 79 insertions(+), 25 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 48df8c0b8..c3c037e70 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -17,6 +17,13 @@ Some parts of the API are described on separate pages: See also the :doc:`implementation`. +On this page: + +.. contents:: + :local: + :depth: 1 + :backlinks: none + Top-level methods and classes ============================= @@ -99,6 +106,7 @@ SDMX-ML ``urn``: Uniform Resource Names (URNs) for SDMX objects ======================================================= + .. automodule:: sdmx.urn :members: diff --git a/doc/api/model.rst b/doc/api/model.rst index 6cad45a48..54e2a22bf 100644 --- a/doc/api/model.rst +++ b/doc/api/model.rst @@ -30,6 +30,10 @@ Common to SDMX 2.1 and 3.0 :undoc-members: :show-inheritance: +.. automodule:: sdmx.model.version + :members: + :show-inheritance: + .. automodule:: sdmx.model.common :members: :ignore-module-all: diff --git a/doc/api/writer.rst b/doc/api/writer.rst index 1b205226a..607cb7c36 100644 --- a/doc/api/writer.rst +++ b/doc/api/writer.rst @@ -1,7 +1,7 @@ .. currentmodule:: sdmx.writer -Writer/convert :mod:`sdmx` objects -********************************** +Write/convert :mod:`sdmx` objects +********************************* The term **write** refers to both: diff --git a/doc/conf.py b/doc/conf.py index 6012c3749..d46644fcf 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -62,6 +62,7 @@ intersphinx_mapping = { "np": ("https://numpy.org/doc/stable/", None), + "packaging": ("https://packaging.pypa.io/en/stable", None), "pd": ("https://pandas.pydata.org/pandas-docs/stable/", None), "py": ("https://docs.python.org/3/", None), "requests": ("https://requests.readthedocs.io/en/latest/", None), diff --git a/sdmx/model/common.py b/sdmx/model/common.py index c19f2908f..f0c1f7ae0 100644 --- a/sdmx/model/common.py +++ b/sdmx/model/common.py @@ -2545,7 +2545,7 @@ class BaseContentConstraint: # Internal -#: The SDMX-IM defines 'packages'; these are used in URNs. +#: The SDMX-IM groups classes into 'packages'; these are used in :class:`URNs <.URN>`. PACKAGE = dict() _PACKAGE_CLASS: Dict[str, set] = { diff --git a/sdmx/model/version.py b/sdmx/model/version.py index 7b9453c91..680067473 100644 --- a/sdmx/model/version.py +++ b/sdmx/model/version.py @@ -1,5 +1,3 @@ -"""Handle SDMX version identifiers.""" - import operator import re from typing import Callable, Optional, Union @@ -35,7 +33,21 @@ def cmp(self, other) -> bool: class Version(packaging.version.Version): - """Version. + """Class representing a version. + + This class extends :class:`packaging.version.Version`, which provides a complete + interface for interacting with Python version specifiers. The extensions implement + the particular form of versioning laid out by the SDMX standards. Specifically: + + - :attr:`kind` to identify whether the version is an SDMX 2.1, SDMX 3.0, or Python- + style version string. + - Attribute aliases for particular terms used in the SDMX 3.0 standards: + :attr:`patch` and :attr:`ext`. + - The :class:`str` representation of a Version uses the SDMX 3.0 style of separating + the :attr:`ext` with a hyphen ("1.0.0-dev1"), which differs from the Python style + of using no separator for a ‘post-release’ ("1.0.0dev1") or a plus symbol for a + ‘local part’ ("1.0.0+dev1"). + - The class is comparable with :class:`str` version expressions. Parameters ---------- @@ -107,14 +119,15 @@ def __str__(self): @property def patch(self) -> int: - """Alias for :attr:`.Version.micro`.""" + """Alias for :any:`Version.micro `.""" return self.micro @property def ext(self) -> Optional[str]: """SDMX 3.0 version 'extension'. - For :py:`kind="py"`, this is equivalent to :attr:`.Version.local`. + For :py:`kind="py"`, this is equivalent to :attr:`Version.local + `. """ if self._version.local is None: return None @@ -128,18 +141,20 @@ def increment(self, **kwargs: Union[bool, int]) -> "Version": Parameters ---------- - major : bool or int, *optional* - If given, increment the :attr:`.Version.major` part. - minor : bool or int, *optional* - If given, increment the :attr:`.Version.minor` part. - patch : bool or int, *optional* + major : bool or int, optional + If given, increment the :attr:`Version.major + ` part. + minor : bool or int, optional + If given, increment the :attr:`Version.minor + ` part. + patch : bool or int, optional If given, increment the :attr:`.Version.patch` part. - micro : bool or int, *optional* + micro : bool or int, optional Alias for `patch`. - ext : bool or int, *optional* + ext : bool or int, optional If given, increment the :attr:`.Version.ext` part. If this part is not present, add "dev1". - local: bool or int, *optional* + local: bool or int, optional Alias for `ext`. """ if not kwargs: diff --git a/sdmx/urn.py b/sdmx/urn.py index da4d7c10f..bb889a453 100644 --- a/sdmx/urn.py +++ b/sdmx/urn.py @@ -26,10 +26,20 @@ class URN: #: SDMX :data:`.PACKAGE` corresponding to :attr:`klass`. package: str + + #: SDMX object class. klass: str + + #: ID of the :class:`.Agency` that is the :attr:`.MaintainableArtefact.maintainer`. agency: str + + #: ID of the :class:`.MaintainableArtefact`. id: str + + #: :attr:`.VersionableArtefact.version` of the maintainable artefact.parent. version: str + + #: ID of an item within a maintainable parent. Optional. item_id: Optional[str] def __init__(self, value, **kwargs) -> None: @@ -67,22 +77,21 @@ def __str__(self) -> str: def expand(value: str) -> str: """Return the full URN for `value`. + Example + ------- + >>> expand("Code=BAZ:FOO(1.2.3).BAR") + "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR" + Parameters ---------- value : str - Either the final part of a valid SDMX URN, for example - `Codelist=BAZ:FOO(1.2.3)`, or a full URN. + Either the final / :func:`.shorten`'d part of a valid SDMX URN, or a full URN. Returns ------- str The full SDMX URN. If `value` is not a partial or full URN, it is returned unmodified. - - Raises - ------ - ValueError - If `value` is not a valid part of a SDMX URN. """ for candidate in (value, f"urn:sdmx:org.sdmx.infomodel.package.{value}"): try: @@ -134,6 +143,18 @@ def make( def match(value: str) -> Dict[str, str]: """Match :data:`URN` in `value`, returning a :class:`dict` with the match groups. + Example + ------- + >>> match("urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR") + { + "package": "codelist", + "class": "Code", + "agency": "BAZ", + "id": "FOO", + "version": "1.2.3", + "item_id": "BAR", + } + Raises ------ ValueError @@ -143,7 +164,7 @@ def match(value: str) -> Dict[str, str]: def normalize(value: str) -> str: - """Normalize URNs. + """‘Normalize’ a URN. Handle "…DataFlow=…" (SDMX 3.0) vs. "…DataFlowDefinition=…" (SDMX 2.1) in URNs; prefer the former. @@ -154,10 +175,15 @@ def normalize(value: str) -> str: def shorten(value: str) -> str: """Return a partial URN based on `value`. + Example + ------- + >>> shorten("urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR") + "Code=BAZ:FOO(1.2.3).BAR" + Parameters ---------- value : str - A full SDMX URN. If the value is not a URN, it is returned unmodified. + A full SDMX URN. If `value` is not a URN, it is returned unmodified. Returns ------- From 8becdc83e6af6d11b9078ef9f56152fd317ddaba Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 16:00:54 +0200 Subject: [PATCH 6/9] Add #189 to doc/whatsnew --- doc/whatsnew.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst index 49859b7a4..67403bbeb 100644 --- a/doc/whatsnew.rst +++ b/doc/whatsnew.rst @@ -3,8 +3,11 @@ What's new? *********** -.. Next release -.. ============ +Next release +============ + +- New module :mod:`sdmx.model.version`, class :class:`.Version`, and convenience functions :func:`.version.increment` and :func:`.version.parse` (:pull:`189`). +- New functions :func:`.urn.expand`, :func:`.urn.normalize`, :func:`.urn.shorten` and supporting class :class:`.URN` (:pull:`189`). v2.15.0 (2024-04-28) ==================== From 9accd2f91271c9634a4090c51f2b2bc3a9b5ca93 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 16:03:20 +0200 Subject: [PATCH 7/9] Revert "Work around actions/setup-python#696" This reverts commit 440a8c31486a4bab97edf6565ecaceaf868b145b. --- .github/workflows/pytest.yaml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 6114fd34b..9f2922851 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -30,14 +30,6 @@ jobs: # commented: only enable once next Python version enters RC # - "3.13.0-rc.1" # Development version - # Work around https://github.com/actions/setup-python/issues/696 - exclude: - - {os: macos-latest, python-version: "3.8"} - - {os: macos-latest, python-version: "3.9"} - include: - - {os: macos-13, python-version: "3.8"} - - {os: macos-13, python-version: "3.9"} - fail-fast: false runs-on: ${{ matrix.os }} From 455338844d7c5e2df5cad42d3472e600724eb2c8 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 16:04:31 +0200 Subject: [PATCH 8/9] Bump mypy, ruff versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mypy v1.10.0 → v1.11.1 - ruff v0.4.2 → v0.6.0 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 270371e41..6589561af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.11.1 hooks: - id: mypy additional_dependencies: @@ -15,7 +15,7 @@ repos: - types-requests args: [] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.2 + rev: v0.6.0 hooks: - id: ruff - id: ruff-format From 4b129dfaa2ac564867ca75682d41617b4239609d Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Fri, 16 Aug 2024 16:39:19 +0200 Subject: [PATCH 9/9] Address E721 in Structure.replace_grouping --- sdmx/model/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdmx/model/common.py b/sdmx/model/common.py index f0c1f7ae0..55d822890 100644 --- a/sdmx/model/common.py +++ b/sdmx/model/common.py @@ -1121,7 +1121,7 @@ def replace_grouping(self, cl: ComponentList) -> None: field = None for f in fields(self): is_dictlike = get_origin(f.type) is DictLikeDescriptor - if f.type == type(cl) or (is_dictlike and get_args(f.type)[1] is type(cl)): + if f.type is type(cl) or (is_dictlike and get_args(f.type)[1] is type(cl)): field = f break