diff --git a/README.md b/README.md index ff2d042..2a01306 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ True True ``` +### Check a version against a constraint + +`parse_constraints` parses a requirement spec like `"aiokafka >= 0.8, < 1.0"` +into the package name and a list of `(comparator, parsed_version)` pairs. +Apply every pair to a parsed version to decide whether it satisfies the spec. + +```python +>>> from picopip import parse_constraints, parse_version +>>> +>>> name, constraints = parse_constraints("aiokafka >= 0.8, < 1.0") +>>> name +'aiokafka' +>>> v = parse_version("0.9.1") +>>> all(op(v, cv) for op, cv in constraints) +True +>>> v = parse_version("1.0.0") +>>> all(op(v, cv) for op, cv in constraints) +False +``` + +The name is `None` for bare specs (`">= 0.8, < 1.0"`). + ## Releasing Releases are cut by pushing a `N.N.N` git tag. The `Release` workflow runs diff --git a/src/picopip.py b/src/picopip.py index 1226ae4..c39273c 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -17,12 +17,13 @@ import itertools import logging +import operator import os import re import site from importlib.metadata import PathDistribution from pathlib import Path -from typing import List, Optional, Tuple +from typing import Callable, List, Optional, Tuple log = logging.getLogger(__name__) @@ -181,6 +182,48 @@ def parse_version(version: str) -> Tuple[Tuple[int, ...], int]: return _VersionParser(version).parse_key() +def parse_constraints( + spec: str, +) -> Tuple[Optional[str], List[Tuple[Callable, Tuple[Tuple[int, ...], int]]]]: + """Parse a requirement spec into ``(name, [(comparator, parsed_version), ...])``. + + Accepts either a bare spec (``">= 0.8, < 1.0"``) or a full requirement + line (``"aiokafka >= 0.8, < 1.0"``). Any text before the first operator is + returned as the package name (with original case preserved); ``None`` when + the spec is bare. + + ``~= V`` is expanded into the equivalent ``>= V`` and ``< V'`` pair, where + ``V'`` drops the last segment of ``V`` and bumps the new last, per PEP 440. + """ + ops = { + "==": operator.eq, + "!=": operator.ne, + ">=": operator.ge, + "<=": operator.le, + ">": operator.gt, + "<": operator.lt, + } + matches = list(re.finditer(r"(===|==|!=|<=|>=|~=|<|>)\s*([^\s,]+)", spec)) + name = (spec[: matches[0].start()] if matches else spec).strip() or None + constraints = [] + for m in matches: + op, ver = m.group(1), m.group(2) + if op == "~=": + # PEP 440 ~= upper bound: drop the last segment, bump the new last. + # e.g. "1.4.5" -> head=["1","4"] -> ["1","5"] -> upper "1.5" + *head, _ = ver.split(".") + if not head: + raise ValueError( + f"~= requires a multi-segment version: {ver!r}", + ) + head[-1] = str(int(head[-1]) + 1) + constraints.append((operator.ge, parse_version(ver))) + constraints.append((operator.lt, parse_version(".".join(head)))) + else: + constraints.append((ops[op], parse_version(ver))) + return name, constraints + + class _VersionParser: """Parse and normalize a version string according to PEP 440. diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..0dfd379 --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,114 @@ +"""Acceptance tests for ``parse_constraints``. + +Each row pins down behavior in both directions: one version that must satisfy +the constraint and one that must not. +""" + +import pytest + +from picopip import parse_constraints, parse_version + +CASES = [ + ("openai >= 1.26.0", "1.26.0", "1.25.0"), + ("google-cloud-aiplatform >= 1.64", "1.70", "1.63"), + ("aio_pika >= 7.2.0, < 10.0.0", "9.4.0", "10.0.0"), + ("aiohttp ~= 3.0", "3.9.5", "4.0.0"), + ("aiohttp ~= 3.0", "3.0.0", "2.9.0"), + ("aiokafka >= 0.8, < 1.0", "0.9.0", "1.0.0"), + ("aiopg >= 0.13.0, < 2.0.0", "1.4.0", "2.0.0"), + ("asgiref ~= 3.0", "3.8.0", "2.0"), + ("asyncclick ~= 8.0", "8.1.0", "9.0"), + ("asyncpg >= 0.12.0", "0.30.0", "0.11.0"), + ("boto3 ~= 1.0", "1.35.0", "2.0"), + ("botocore ~= 1.0", "1.35.0", "2.0"), + ("aiobotocore ~= 2.0", "2.13.0", "3.0"), + ("cassandra-driver ~= 3.25", "3.29.0", "3.24.0"), + ("scylla-driver ~= 3.25", "3.26.0", "3.24.0"), + ("celery >= 4.0, < 6.0", "5.3.0", "6.0.0"), + ("click >= 8.1.3, < 9.0.0", "8.1.7", "8.1.2"), + ("confluent-kafka >= 1.8.2, < 3.0.0", "2.5.0", "1.8.1"), + ("django >= 2.0", "5.0.0", "1.11"), + ("elasticsearch >= 6.0", "8.0.0", "5.0"), + ("falcon >= 1.4.1, < 5.0.0", "3.1.0", "5.0.0"), + ("fastapi ~= 0.92", "0.95.0", "1.0"), + ("flask >= 1.0", "3.0.0", "0.12"), + ("grpcio >= 1.42.0", "1.62.0", "1.41.0"), + ("httpx >= 0.18.0", "0.27.0", "0.17.0"), + ("jinja2 >= 2.7, < 4.0", "3.1.0", "4.0.0"), + ("kafka-python >= 2.0, < 3.0", "2.0.2", "3.0.0"), + ("kafka-python-ng >= 2.0, < 3.0", "2.2.0", "3.0.0"), + ("mysql-connector-python >= 8.0, < 10.0", "9.0.0", "10.0.0"), + ("mysqlclient < 3", "2.2.0", "3.0.0"), + ("pika >= 0.12.0", "1.3.0", "0.11"), + ("psycopg >= 3.1.0", "3.2.0", "3.0.0"), + ("psycopg2 >= 2.7.3.1", "2.9.0", "2.7.3"), + ("psycopg2-binary >= 2.7.3.1", "2.9.0", "2.7.0"), + ("pymemcache >= 1.3.5, < 5", "4.0.0", "5.0.0"), + ("pymongo >= 3.1, < 5.0", "4.6.0", "5.0.0"), + ("pymssql >= 2.1.5, < 3", "2.2.0", "3.0.0"), + ("PyMySQL < 2", "1.1.0", "2.0.0"), + ("pyramid >= 1.7", "2.0.0", "1.6"), + ("redis >= 2.6", "5.0.0", "2.5"), + ("remoulade >= 0.50", "0.60", "0.49"), + ("requests ~= 2.0", "2.31.0", "3.0"), + ("sqlalchemy >= 1.0.0, < 2.1.0", "2.0.0", "2.1.0"), + ("starlette >= 0.13", "0.36", "0.12"), + ("psutil >= 5", "5.9.0", "4.0"), + ("tornado >= 5.1.1", "6.4", "5.1.0"), + ("tortoise-orm >= 0.17.0", "0.21.0", "0.16.0"), + ("pydantic >= 1.10.2", "2.0.0", "1.10.1"), + ("urllib3 >= 1.0.0, < 3.0.0", "2.2.0", "3.0.0"), + ("foo == 1.0.0", "1.0.0", "1.0.1"), + ("foo != 1.0.0", "1.0.1", "1.0.0"), + ("foo <= 2.0", "2.0", "2.1"), + ("foo > 1.0", "1.0.1", "1.0"), + ("foo >= 1.0, != 1.5, < 2.0", "1.4", "1.5"), + (">= 0.5, < 1.0", "0.7", "1.0"), # bare spec, no name + ("foo>=1.0,<2.0", "1.5", "2.0"), # no whitespace + ("foo ~= 1.4.5", "1.4.10", "1.5.0"), # 3-segment ~= + ("foo >= 1.0", "1.0", "1.0rc1"), # pre-release ordering +] + + +@pytest.mark.parametrize(("spec", "matching", "non_matching"), CASES) +def test_constraint_satisfaction(spec, matching, non_matching): + _name, constraints = parse_constraints(spec) + + matching_v = parse_version(matching) + assert all(op(matching_v, cv) for op, cv in constraints), ( + f"{matching!r} should satisfy {spec!r}" + ) + + non_matching_v = parse_version(non_matching) + assert not all(op(non_matching_v, cv) for op, cv in constraints), ( + f"{non_matching!r} should not satisfy {spec!r}" + ) + + +@pytest.mark.parametrize( + ("spec", "expected_name"), + [ + ("aiokafka >= 0.8, < 1.0", "aiokafka"), + ("aio_pika >= 7.2.0", "aio_pika"), + ("cassandra-driver ~= 3.25", "cassandra-driver"), + ("PyMySQL < 2", "PyMySQL"), + ("foo>=1.0", "foo"), + ("foo", "foo"), + (">= 0.5, < 1.0", None), + ("", None), + ], +) +def test_parse_constraints_extracts_name(spec, expected_name): + name, _ = parse_constraints(spec) + assert name == expected_name + + +def test_bare_name_has_no_constraints(): + name, constraints = parse_constraints("foo") + assert name == "foo" + assert constraints == [] + + +def test_tilde_eq_rejects_single_segment(): + with pytest.raises(ValueError, match="~= requires a multi-segment version"): + parse_constraints("foo ~= 1")