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 }} 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 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/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) ==================== diff --git a/sdmx/model/common.py b/sdmx/model/common.py index 0ff7dc78d..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 @@ -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. @@ -2542,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 new file mode 100644 index 000000000..680067473 --- /dev/null +++ b/sdmx/model/version.py @@ -0,0 +1,236 @@ +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): + """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 + ---------- + 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 :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 + `. + """ + 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) 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 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..bb889a453 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,101 @@ 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 + + #: 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: + if kwargs: + self.__dict__.update(kwargs) + + 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`. + + Example + ------- + >>> expand("Code=BAZ:FOO(1.2.3).BAR") + "urn:sdmx:org.sdmx.infomodel.codelist.Code=BAZ:FOO(1.2.3).BAR" -def make(obj, maintainable_parent=None, strict=False): + Parameters + ---------- + value : str + 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. + """ + 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 +114,84 @@ 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. + + 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 + If `value` is not a well-formed SDMX URN. + """ + return URN(value).groupdict + + +def normalize(value: str) -> str: + """‘Normalize’ a URN. + + 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`. + + 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 `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