Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,22 @@ using `get_package_version_from_env`
>>> version = get_package_version_from_env(venvdir, "pip")
>>> print(version)
'21.2.4'
```
```

### Parse a version string

`parse_version` normalizes a version into a tuple that follows the same ordering
rules used by `pip`/`packaging`. The first element is the release components,
the second is an offset encoding pre/dev/post markers so you can compare the
tuples with standard operators.

```python
>>> from picopip import parse_version
>>>
>>> parse_version("1.13.5")
((1, 13, 5), 0)
>>> parse_version("1.13.5a1") < parse_version("1.13.5")
True
>>> parse_version("1.13.5.post2") > parse_version("1.13.5")
True
```
2 changes: 1 addition & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ target-version = "py38"
select = [
"E", "F", "W", "I", "UP", "C90", "N", "D", "ANN", "S", "B", "A", "C4", "DTZ", "EM", "EXE", "FBT", "ICN", "INP", "ISC", "PIE", "PT", "Q", "RET", "RSE", "SIM", "T20", "TID", "TRY", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "RUF"
]
ignore = ["COM812", "D203", "D213"]
ignore = ["COM812", "D203", "D213", "EM101", "EM102", "TRY003", "RUF012"]
per-file-ignores = {"tests/*.py" = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "UP", "ANN", "S101", "S603"]}

[format]
Expand Down
170 changes: 170 additions & 0 deletions src/picopip.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import itertools
import logging
import re
import site
from importlib.metadata import PathDistribution
from pathlib import Path
Expand Down Expand Up @@ -130,3 +131,172 @@ def _find_system_packages(venv_path: str) -> List[Path]:
break

return scan_paths


def parse_version(version: str) -> Tuple[Tuple[int, ...], int]:
"""Parse the given version string and return a tuple suitable for comparison.

dev, pre, rc, alpha, beta, post releases are supported,
and represented as a numeric offset from the release number.
Negative offsets signal pre-releases, positive offsets signal post-releases.

Epochs and local versions are not supported.

Raises ValueError if the version string is invalid or unsupported.
"""
return _VersionParser(version).parse_key()


class _VersionParser:
"""Parse and normalize a version string according to PEP 440.

Implements a subset of PEP 440 sufficient for practical version comparison,
excluding epochs (e.g. "1!1.0.0") and local versions (e.g. "1.0.0+abc") which
are rarely used in released packages.

It also excludes support for combining pre-releases with dev or post
releases, which only make sense during development (e.g. "1.0.0rc1.dev2").
"""

# This comes from https://packaging.python.org/en/latest/specifications/version-specifiers/#appendix-parsing-version-strings-with-regular-expressions
VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>alpha|a|beta|b|preview|pre|c|rc)
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""

VERSION_REGEX = re.compile(
r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
)

TAG_NORMALIZE = {
"a": "a",
"alpha": "a",
"b": "b",
"beta": "b",
"c": "rc",
"pre": "rc",
"preview": "rc",
"rc": "rc",
"post": "post",
"rev": "post",
"r": "post",
"dev": "dev",
}

# To simplify representing pre/post/dev stages as integers for comparison,
# we assign each stage a base offset, and add the stage number to it.
# For example, "1.0.0rc2" becomes ( (1,0,0), -9998 ), while
# "1.0.0post3" becomes ( (1,0,0), 10003 ).
# This guarantees that releases are always sortable as simple numeric tuples.
OFFSET_STAGE_SPAN = 10_000 # 9999 pre/post/dev stages per release should be enough.
OFFSET_BASE = { # dev < a < b < rc < release < post
"dev": -4 * OFFSET_STAGE_SPAN,
"a": -3 * OFFSET_STAGE_SPAN,
"b": -2 * OFFSET_STAGE_SPAN,
"rc": -1 * OFFSET_STAGE_SPAN,
"release": 0,
"post": OFFSET_STAGE_SPAN,
}

def __init__(self, version: str) -> None:
self.version = version

def parse_key(self) -> Tuple[Tuple[int, ...], int]:
"""Return a tuple implementing practical PEP 440 ordering for the version."""
match = self.VERSION_REGEX.search(self.version)
if not match:
raise ValueError(f"Invalid version: {self.version!r}")

if match.group("epoch"):
raise ValueError(f"Epochs are not supported: {self.version!r}")
if match.group("local"):
raise ValueError(f"Local versions are not supported: {self.version!r}")

release_numbers = self._normalize_release(match.group("release"))

pre = None
pre_letter = match.group("pre_l")
pre_number = match.group("pre_n")
if pre_letter:
pre = self._parse_tagged_number(pre_letter, pre_number)

# post releases are the only case where the number can be specified
# without a tag. In such case we treat it as "postN".
post = None
post_number = match.group("post_n1") or match.group("post_n2")
post_letter = match.group("post_l") or ("post" if post_number else None)
if post_letter:
post = self._parse_tagged_number(post_letter, post_number)

dev = None
dev_letter = match.group("dev_l")
dev_number = match.group("dev_n")
if dev_letter:
dev = self._parse_tagged_number(dev_letter, dev_number)

if post and dev:
raise ValueError(
f"Post releases with dev segments are not supported: {self.version!r}"
)
if pre and dev:
raise ValueError(
f"Pre-release dev segments are not supported: {self.version!r}"
)

component = pre or dev or post or ("release", 0)
offset = self.OFFSET_BASE[component[0]] + component[1]
return (tuple(release_numbers), offset)

def _normalize_release(self, release: str) -> List[int]:
numbers = [int(part) for part in release.split(".")]
while numbers and numbers[-1] == 0:
numbers.pop()
if not numbers:
numbers = [0]
return numbers

def _parse_tagged_number(
self,
letter: Optional[str],
number: Optional[str],
) -> Optional[Tuple[str, int]]:
if not letter:
return None

normalized = self.TAG_NORMALIZE.get(letter.lower())
if normalized is None:
raise ValueError(f"Unsupported release tag: {letter!r}")

value = int(number or 0)
if value < 0:
raise ValueError(f"Release number cannot be negative: {value}")
if value >= self.OFFSET_STAGE_SPAN:
raise ValueError(f"Release number too large: {value}")

return normalized, value
111 changes: 111 additions & 0 deletions tests/test_version_ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import pytest

from picopip import parse_version


def test_common_versions_sort_as_expected():
versions = [
"1.0.dev1",
"1.0a1",
"1.0a2",
"1.0b1",
"1.0rc1",
"1.0",
"1.0.post1",
]

assert sorted(versions, key=parse_version) == versions


@pytest.mark.parametrize(
("left", "right"),
[
("1.0", "1.0.0"),
("1.2.0", "1.2"),
("1.0c1", "1.0rc1"),
("1.0-5", "1.0.post5"),
],
)
def test_equivalent_versions(left, right):
assert parse_version(left) == parse_version(right)


def test_local_version_segments_order():
assert parse_version("1.0.dev1") < parse_version("1.0a1")
assert parse_version("1.0a1") < parse_version("1.0")
assert parse_version("1.0") < parse_version("1.0.post1")


def test_invalid_version_raises_value_error():
with pytest.raises(ValueError, match="Invalid version"):
parse_version("not a version")


def test_epoch_versions_raise_error():
with pytest.raises(ValueError, match="Epochs are not supported"):
parse_version("1!0.9")


def test_local_versions_raise_error():
with pytest.raises(ValueError, match="Local versions are not supported"):
parse_version("1.0+abc")


def test_dev_post_versions_raise_error():
with pytest.raises(ValueError, match="Post releases with dev segments"):
parse_version("1.0.post1.dev1")


def test_parse_version_matches_expected_offset():
assert parse_version("1.13.5") == ((1, 13, 5), 0)
assert parse_version("1.13.5a5")[1] < 0
assert parse_version("1.13.5.post2")[1] > 0


def test_pre_dev_numbers_larger_than_slot_raise_error():
with pytest.raises(ValueError, match="Pre-release dev segments are not supported"):
parse_version("1.0a1.dev99")


def test_large_dev_release_is_supported():
assert parse_version("1.0.dev9999")[1] < 0


def test_pre_number_out_of_range_is_rejected():
with pytest.raises(ValueError, match="Release number too large"):
parse_version("1.0a10000")


def test_dev_release_out_of_range_is_rejected():
with pytest.raises(ValueError, match="Release number too large"):
parse_version("1.0.dev10000")


def test_pre_release_dev_is_rejected():
with pytest.raises(ValueError, match="Pre-release dev segments are not supported"):
parse_version("1.0a2.dev1")


def test_post_without_number_defaults_to_zero():
assert parse_version("1.0.post") == parse_version("1.0.post0")


@pytest.mark.parametrize(
("longer", "tagged"),
[
("1.2.3.4", "1.2.3.post5"),
("1.2.3.4", "1.2.3"),
("1.2.3.4", "1.2.3a1"),
("1.2.3.4", "1.2.3.dev1"),
("1.2.3.4", "1.2.3.post1"),
("1.2.3.4", "1.2.3.4.post1"),
],
)
def test_release_length_vs_offset(longer, tagged):
longer_key = parse_version(longer)
tagged_key = parse_version(tagged)

if longer_key[0] == tagged_key[0]:
assert longer_key[1] < tagged_key[1]
else:
assert longer_key[0] > tagged_key[0]