From 1ea4c7cc1ecfc4d46b935dafe2f8fc52a53b3f33 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 28 Oct 2025 16:57:51 +0100 Subject: [PATCH 1/9] Initial import of version parsing support --- src/picopip.py | 148 +++++++++++++++++++++++++++++++++ tests/test_version_ordering.py | 107 ++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 tests/test_version_ordering.py diff --git a/src/picopip.py b/src/picopip.py index 8c1d745..98b6e44 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -17,14 +17,78 @@ import itertools import logging +import re import site from importlib.metadata import PathDistribution from pathlib import Path from typing import List, Optional, Tuple + +# This comes from https://packaging.python.org/en/latest/specifications/version-specifiers/#appendix-parsing-version-strings-with-regular-expressions +_VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?Palpha|a|beta|b|preview|pre|c|rc)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+_VERSION_REGEX = re.compile(
+    r"^\s*" + _VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
+)
+
+_VERSION_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",
+}
+
+_VERSION_OFFSET_SPAN = 10_000
+_VERSION_OFFSET = {
+    "dev": -4 * _VERSION_OFFSET_SPAN,
+    "a": -3 * _VERSION_OFFSET_SPAN,
+    "b": -2 * _VERSION_OFFSET_SPAN,
+    "rc": -1 * _VERSION_OFFSET_SPAN,
+    "release": 0,
+    "post": _VERSION_OFFSET_SPAN,
+}
+
 log = logging.getLogger(__name__)
 
 
+
 def get_site_package_paths(venv_path: str) -> List[Path]:
     """Return all directories where packages might be installed for the given venv."""
     for version_dir in (Path(venv_path) / "lib").iterdir():
@@ -130,3 +194,87 @@ def _find_system_packages(venv_path: str) -> List[Path]:
                 break
 
     return scan_paths
+
+
+def parse_version(
+    version: str,
+) -> Tuple[int, ...]:
+    """Return a tuple implementing practical PEP 440 ordering for *version*.
+
+    The tuple contains the integer components of the release followed by a
+    single *offset* element that encodes pre-release, post-release, and dev
+    markers. Epochs, local versions, pre-release dev markers (e.g. ``a1.dev1``),
+    and post-release dev variants are not supported. Pre-release numbers up to
+    9999 and standalone ``.dev`` numbers up to 9999 are accepted. Post releases
+    accept numbers up to 9999.
+    """
+
+    match = _VERSION_REGEX.search(version)
+    if not match:
+        raise ValueError(f"Invalid version: {version!r}")
+
+    if match.group("epoch"):
+        raise ValueError(f"Epochs are not supported: {version!r}")
+    if match.group("local"):
+        raise ValueError(f"Local versions are not supported: {version!r}")
+
+    release_numbers = _normalize_release(match.group("release"))
+    pre = None
+    pre_letter = match.group("pre_l")
+    pre_number = match.group("pre_n")
+    if pre_letter or pre_number:
+        pre = _parse_tagged_number(pre_letter, pre_number)
+
+    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 and post_number is not None:
+        post = _parse_tagged_number(post_letter, post_number)
+
+    dev = None
+    dev_letter = match.group("dev_l")
+    dev_number = match.group("dev_n")
+    if dev_number and not dev_letter:
+        raise ValueError("Label required when number is provided")
+    if dev_letter:
+        dev = _parse_tagged_number(dev_letter, dev_number)
+
+    if post and dev:
+        raise ValueError(f"Post releases with dev segments are not supported: {version!r}")
+    if pre and dev:
+        raise ValueError(
+            f"Pre-release dev segments are not supported: {version!r}"
+        )
+
+    component = pre or dev or post or ("release", 0)
+    offset = _VERSION_OFFSET[component[0]] + component[1]
+    return (tuple(release_numbers), offset)
+
+
+def _normalize_release(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(
+    letter: Optional[str],
+    number: Optional[str],
+) -> Optional[Tuple[str, int]]:
+    if not letter:
+        return None
+
+    normalized = _VERSION_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 >= _VERSION_OFFSET_SPAN:
+        raise ValueError(f"Release number too large: {value}")
+
+    return normalized, value
\ No newline at end of file
diff --git a/tests/test_version_ordering.py b/tests/test_version_ordering.py
new file mode 100644
index 0000000..cc8325e
--- /dev/null
+++ b/tests/test_version_ordering.py
@@ -0,0 +1,107 @@
+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):
+        parse_version("not a version")
+
+
+def test_epoch_versions_raise_error():
+    with pytest.raises(ValueError):
+        parse_version("1!0.9")
+
+
+def test_local_versions_raise_error():
+    with pytest.raises(ValueError):
+        parse_version("1.0+abc")
+
+
+def test_dev_post_versions_raise_error():
+    with pytest.raises(ValueError):
+        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):
+        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):
+        parse_version("1.0a10000")
+
+
+def test_dev_release_out_of_range_is_rejected():
+    with pytest.raises(ValueError):
+        parse_version("1.0.dev10000")
+
+
+def test_pre_release_dev_is_rejected():
+    with pytest.raises(ValueError):
+        parse_version("1.0a2.dev1")
+
+
+@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]

From 2cdf30690b92ac7f99403ae6488d8848db366dee Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 17:15:58 +0100
Subject: [PATCH 2/9] Tweak and prepare for release

---
 src/picopip.py | 291 ++++++++++++++++++++++++++-----------------------
 1 file changed, 155 insertions(+), 136 deletions(-)

diff --git a/src/picopip.py b/src/picopip.py
index 98b6e44..858deb5 100644
--- a/src/picopip.py
+++ b/src/picopip.py
@@ -24,71 +24,9 @@
 from typing import List, Optional, Tuple
 
 
-# This comes from https://packaging.python.org/en/latest/specifications/version-specifiers/#appendix-parsing-version-strings-with-regular-expressions
-_VERSION_PATTERN = r"""
-    v?
-    (?:
-        (?:(?P[0-9]+)!)?                           # epoch
-        (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
-        (?P
                                          # pre-release
-            [-_\.]?
-            (?Palpha|a|beta|b|preview|pre|c|rc)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-        (?P                                         # post release
-            (?:-(?P[0-9]+))
-            |
-            (?:
-                [-_\.]?
-                (?Ppost|rev|r)
-                [-_\.]?
-                (?P[0-9]+)?
-            )
-        )?
-        (?P                                          # dev release
-            [-_\.]?
-            (?Pdev)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-    )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-"""
-
-_VERSION_REGEX = re.compile(
-    r"^\s*" + _VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
-)
-
-_VERSION_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",
-}
-
-_VERSION_OFFSET_SPAN = 10_000
-_VERSION_OFFSET = {
-    "dev": -4 * _VERSION_OFFSET_SPAN,
-    "a": -3 * _VERSION_OFFSET_SPAN,
-    "b": -2 * _VERSION_OFFSET_SPAN,
-    "rc": -1 * _VERSION_OFFSET_SPAN,
-    "release": 0,
-    "post": _VERSION_OFFSET_SPAN,
-}
-
 log = logging.getLogger(__name__)
 
 
-
 def get_site_package_paths(venv_path: str) -> List[Path]:
     """Return all directories where packages might be installed for the given venv."""
     for version_dir in (Path(venv_path) / "lib").iterdir():
@@ -196,85 +134,166 @@ def _find_system_packages(venv_path: str) -> List[Path]:
     return scan_paths
 
 
-def parse_version(
-    version: str,
-) -> Tuple[int, ...]:
-    """Return a tuple implementing practical PEP 440 ordering for *version*.
-
-    The tuple contains the integer components of the release followed by a
-    single *offset* element that encodes pre-release, post-release, and dev
-    markers. Epochs, local versions, pre-release dev markers (e.g. ``a1.dev1``),
-    and post-release dev variants are not supported. Pre-release numbers up to
-    9999 and standalone ``.dev`` numbers up to 9999 are accepted. Post releases
-    accept numbers up to 9999.
-    """
-
-    match = _VERSION_REGEX.search(version)
-    if not match:
-        raise ValueError(f"Invalid version: {version!r}")
-
-    if match.group("epoch"):
-        raise ValueError(f"Epochs are not supported: {version!r}")
-    if match.group("local"):
-        raise ValueError(f"Local versions are not supported: {version!r}")
-
-    release_numbers = _normalize_release(match.group("release"))
-    pre = None
-    pre_letter = match.group("pre_l")
-    pre_number = match.group("pre_n")
-    if pre_letter or pre_number:
-        pre = _parse_tagged_number(pre_letter, pre_number)
-
-    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 and post_number is not None:
-        post = _parse_tagged_number(post_letter, post_number)
-
-    dev = None
-    dev_letter = match.group("dev_l")
-    dev_number = match.group("dev_n")
-    if dev_number and not dev_letter:
-        raise ValueError("Label required when number is provided")
-    if dev_letter:
-        dev = _parse_tagged_number(dev_letter, dev_number)
-
-    if post and dev:
-        raise ValueError(f"Post releases with dev segments are not supported: {version!r}")
-    if pre and dev:
-        raise ValueError(
-            f"Pre-release dev segments are not supported: {version!r}"
-        )
+def parse_version(version: str) -> Tuple[Tuple[int, ...], int]:
+    """Parse the given version string and return a tuple suitable for comparison.
 
-    component = pre or dev or post or ("release", 0)
-    offset = _VERSION_OFFSET[component[0]] + component[1]
-    return (tuple(release_numbers), offset)
+    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.
 
-def _normalize_release(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
+    Raises ValueError if the version string is invalid or unsupported.
+    """
+    return _VersionParser(version).parse_key()
 
 
-def _parse_tagged_number(
-    letter: Optional[str],
-    number: Optional[str],
-) -> Optional[Tuple[str, int]]:
-    if not letter:
-        return None
+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 if ever used in released packages.
 
-    normalized = _VERSION_TAG_NORMALIZE.get(letter.lower())
-    if normalized is None:
-        raise ValueError(f"Unsupported release tag: {letter!r}")
+    It also explicitly excludes support for combining pre-releases
+    with dev or post releases, which are not used for released packages,
+    they 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[0-9]+)!)?                           # epoch
+            (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
+            (?P
                                          # pre-release
+                [-_\.]?
+                (?Palpha|a|beta|b|preview|pre|c|rc)
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
+            (?P                                         # post release
+                (?:-(?P[0-9]+))
+                |
+                (?:
+                    [-_\.]?
+                    (?Ppost|rev|r)
+                    [-_\.]?
+                    (?P[0-9]+)?
+                )
+            )?
+            (?P                                          # dev release
+                [-_\.]?
+                (?Pdev)
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
+        )
+        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+    """
 
-    value = int(number or 0)
-    if value < 0:
-        raise ValueError(f"Release number cannot be negative: {value}")
-    if value >= _VERSION_OFFSET_SPAN:
-        raise ValueError(f"Release number too large: {value}")
+    VERSION_REGEX = re.compile(
+        r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
+    )
 
-    return normalized, value
\ No newline at end of file
+    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 releases should be "enough for anyone"
+    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 or pre_number:
+            pre = self._parse_tagged_number(pre_letter, pre_number)
+
+        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 and post_number is not None:
+            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_number and not dev_letter:
+            raise ValueError("Label required when number is provided")
+        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

From c00cec0b409693b079a268c8610ca727bf1f1f3f Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 17:24:40 +0100
Subject: [PATCH 3/9] Document versions

---
 README.md | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 4414185..def9516 100644
--- a/README.md
+++ b/README.md
@@ -36,4 +36,22 @@ using `get_package_version_from_env`
 >>> version = get_package_version_from_env(venvdir, "pip")
 >>> print(version)
 '21.2.4'
-```
\ No newline at end of file
+```
+
+### 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
+```

From b9b5998de061614388bde2053c8b86d2412b0c88 Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 18:09:45 +0100
Subject: [PATCH 4/9] make ruff happy

---
 ruff.toml      |  2 +-
 src/picopip.py | 23 +++++++++++------------
 2 files changed, 12 insertions(+), 13 deletions(-)

diff --git a/ruff.toml b/ruff.toml
index ac2f889..67bdcbd 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -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", "EM102", "TRY003", "RUF012"]
 per-file-ignores = {"tests/*.py" = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "UP", "ANN", "S101", "S603"]}
 
 [format]
diff --git a/src/picopip.py b/src/picopip.py
index 858deb5..46dacff 100644
--- a/src/picopip.py
+++ b/src/picopip.py
@@ -23,7 +23,6 @@
 from pathlib import Path
 from typing import List, Optional, Tuple
 
-
 log = logging.getLogger(__name__)
 
 
@@ -137,7 +136,7 @@ def _find_system_packages(venv_path: str) -> List[Path]:
 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, 
+    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.
 
@@ -150,15 +149,15 @@ def parse_version(version: str) -> Tuple[Tuple[int, ...], int]:
 
 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 if ever used in released packages.
-
-    It also explicitly excludes support for combining pre-releases
-    with dev or post releases, which are not used for released packages,
-    they only make sense during development (e.g. "1.0.0rc1.dev2").
+
+    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?
@@ -215,8 +214,8 @@ class _VersionParser:
     # 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 releases should be "enough for anyone"
-    OFFSET_BASE = {   # dev < a < b < rc < release < post
+    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,

From bfe891c7b4c4178b6a39fe945b1739c8fb6fcd26 Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 18:10:18 +0100
Subject: [PATCH 5/9] Formatting

---
 ruff.toml      | 2 +-
 src/picopip.py | 8 ++++++--
 2 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/ruff.toml b/ruff.toml
index 67bdcbd..403c13f 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -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", "EM102", "TRY003", "RUF012"]
+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]
diff --git a/src/picopip.py b/src/picopip.py
index 46dacff..4bc02b2 100644
--- a/src/picopip.py
+++ b/src/picopip.py
@@ -261,9 +261,13 @@ def parse_key(self) -> Tuple[Tuple[int, ...], int]:
             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}")
+            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}")
+            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]

From cd2911727c9243c5c703bd4c28539c8604dad89c Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 18:17:44 +0100
Subject: [PATCH 6/9] Add explicit matches to tests

---
 tests/test_version_ordering.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/tests/test_version_ordering.py b/tests/test_version_ordering.py
index cc8325e..81cfbd4 100644
--- a/tests/test_version_ordering.py
+++ b/tests/test_version_ordering.py
@@ -37,22 +37,22 @@ def test_local_version_segments_order():
 
 
 def test_invalid_version_raises_value_error():
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="Invalid version"):
         parse_version("not a version")
 
 
 def test_epoch_versions_raise_error():
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="Epochs are not supported"):
         parse_version("1!0.9")
 
 
 def test_local_versions_raise_error():
-    with pytest.raises(ValueError):
+    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):
+    with pytest.raises(ValueError, match="Post releases with dev segments"):
         parse_version("1.0.post1.dev1")
 
 
@@ -63,7 +63,7 @@ def test_parse_version_matches_expected_offset():
 
 
 def test_pre_dev_numbers_larger_than_slot_raise_error():
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="Pre-release dev segments are not supported"):
         parse_version("1.0a1.dev99")
 
 
@@ -72,17 +72,17 @@ def test_large_dev_release_is_supported():
 
 
 def test_pre_number_out_of_range_is_rejected():
-    with pytest.raises(ValueError):
+    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):
+    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):
+    with pytest.raises(ValueError, match="Pre-release dev segments are not supported"):
         parse_version("1.0a2.dev1")
 
 

From 4c87b029a11659fdf600dff05790260487bb2bc8 Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 18:19:23 +0100
Subject: [PATCH 7/9] Switch to tuple format

---
 tests/test_version_ordering.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_version_ordering.py b/tests/test_version_ordering.py
index 81cfbd4..2f120eb 100644
--- a/tests/test_version_ordering.py
+++ b/tests/test_version_ordering.py
@@ -87,7 +87,7 @@ def test_pre_release_dev_is_rejected():
 
 
 @pytest.mark.parametrize(
-    "longer, tagged",
+    ("longer", "tagged"),
     [
         ("1.2.3.4", "1.2.3.post5"),
         ("1.2.3.4", "1.2.3"),

From bb3f0c08ed2cd9c75a8ed1808e7956831ccdcfa2 Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 18:20:09 +0100
Subject: [PATCH 8/9] Test on 3.14 too

---
 .github/workflows/tests.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1eeb3a3..69204eb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -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

From 12c99e1b006eb74e4ea7785c3124c9f1f6a581c8 Mon Sep 17 00:00:00 2001
From: Alessandro Molina 
Date: Tue, 28 Oct 2025 19:12:16 +0100
Subject: [PATCH 9/9] Support post releases without number

---
 src/picopip.py                 | 8 ++++----
 tests/test_version_ordering.py | 4 ++++
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/src/picopip.py b/src/picopip.py
index 4bc02b2..7f2dc10 100644
--- a/src/picopip.py
+++ b/src/picopip.py
@@ -243,20 +243,20 @@ def parse_key(self) -> Tuple[Tuple[int, ...], int]:
         pre = None
         pre_letter = match.group("pre_l")
         pre_number = match.group("pre_n")
-        if pre_letter or pre_number:
+        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 and post_number is not 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_number and not dev_letter:
-            raise ValueError("Label required when number is provided")
         if dev_letter:
             dev = self._parse_tagged_number(dev_letter, dev_number)
 
diff --git a/tests/test_version_ordering.py b/tests/test_version_ordering.py
index 2f120eb..f6e9e4f 100644
--- a/tests/test_version_ordering.py
+++ b/tests/test_version_ordering.py
@@ -86,6 +86,10 @@ def test_pre_release_dev_is_rejected():
         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"),
     [