Skip to content

Commit

Permalink
Add .model.version module, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
khaeru committed Aug 14, 2024
1 parent 473f3af commit 20cd9c2
Show file tree
Hide file tree
Showing 2 changed files with 234 additions and 0 deletions.
176 changes: 176 additions & 0 deletions sdmx/model/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Handle SDMX version identifiers."""

import re
from typing import 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<release>[0-9]+(?:\.[0-9]+){1})$"),
"3_0": re.compile(r"^(?P<release>[0-9]+(?:\.[0-9]+){2})(-(?P<ext>.+))?$"),
"py": re.compile(
r"^\s*" + packaging.version.VERSION_PATTERN + r"\s*$",
re.VERBOSE | re.IGNORECASE,
),
}


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.match(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")
except IndexError:
local = None
else:
local = (ext,) if ext else 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__()

@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`.
.. todo:: Allow to increment or set :attr:`ext`.
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.
"""
if not kwargs:
kwargs["minor"] = 1

# Convert self._version 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,
)

# Increment parts according to kwargs
for part, value in kwargs.items():
try:
parts[part] += int(value)
except KeyError:
raise NotImplementedError(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=self._version.local,
)
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)
58 changes: 58 additions & 0 deletions sdmx/tests/model/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest
from packaging.version import InvalidVersion, Version

from sdmx.model.version import increment, parse


@pytest.mark.parametrize(
"value, expected",
(
# SDMX 2.1
("0.0", Version("0.0")),
("1.0", Version("1.0")),
# SDMX 3.0
("0.0.0-dev1", Version("0.0.0+dev1")),
("1.0.0-dev1", Version("1.0.0+dev1")),
# Python
("1!1.2.3+abc.dev1", Version("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(), Version("1.1.0")),
(dict(major=1), Version("2.0.0")),
(dict(minor=1), Version("1.1.0")),
(dict(patch=1), Version("1.0.1")),
pytest.param(
dict(ext=1), None, marks=pytest.mark.xfail(raises=NotImplementedError)
),
),
)
def test_increment(kwargs, expected):
# Version.increment() method
assert expected == parse("1.0.0").increment(**kwargs)

# increment() function
assert expected == increment("1.0.0", **kwargs)

0 comments on commit 20cd9c2

Please sign in to comment.