Skip to content

Commit

Permalink
Merge pull request #189 from khaeru/enh/urn-version
Browse files Browse the repository at this point in the history
Add `.model.version`, improve `.urn`
  • Loading branch information
khaeru authored Aug 16, 2024
2 parents 473f3af + 4b129df commit 3b322e3
Show file tree
Hide file tree
Showing 13 changed files with 607 additions and 47 deletions.
8 changes: 0 additions & 8 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============================

Expand Down Expand Up @@ -99,6 +106,7 @@ SDMX-ML

``urn``: Uniform Resource Names (URNs) for SDMX objects
=======================================================

.. automodule:: sdmx.urn
:members:

Expand Down
4 changes: 4 additions & 0 deletions doc/api/model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions doc/api/writer.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.. currentmodule:: sdmx.writer

Writer/convert :mod:`sdmx` objects
**********************************
Write/convert :mod:`sdmx` objects
*********************************

The term **write** refers to both:

Expand Down
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 5 additions & 2 deletions doc/whatsnew.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
====================
Expand Down
11 changes: 7 additions & 4 deletions sdmx/model/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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] = {
Expand Down
236 changes: 236 additions & 0 deletions sdmx/model/version.py
Original file line number Diff line number Diff line change
@@ -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<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,
),
}


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 <packaging.version.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
<packaging.version.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
<packaging.version.Version.major>` part.
minor : bool or int, optional
If given, increment the :attr:`Version.minor
<packaging.version.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)
Loading

0 comments on commit 3b322e3

Please sign in to comment.