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 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 +``` diff --git a/ruff.toml b/ruff.toml index ac2f889..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"] +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 8c1d745..7f2dc10 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -17,6 +17,7 @@ import itertools import logging +import re import site from importlib.metadata import PathDistribution from pathlib import Path @@ -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[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
+    )
+
+    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
diff --git a/tests/test_version_ordering.py b/tests/test_version_ordering.py
new file mode 100644
index 0000000..f6e9e4f
--- /dev/null
+++ b/tests/test_version_ordering.py
@@ -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]