From 675a950b894cda17be53f9d2ce57d69e4feeb438 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 25 May 2026 16:09:00 +0200 Subject: [PATCH 1/4] requirements comparison helper --- src/picopip.py | 38 ++++++++++++++++- tests/test_constraints.py | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_constraints.py diff --git a/src/picopip.py b/src/picopip.py index 1226ae4..63de24c 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,41 @@ def parse_version(version: str) -> Tuple[Tuple[int, ...], int]: return _VersionParser(version).parse_key() +def parse_constraints( + spec: str, +) -> List[Tuple[Callable, Tuple[Tuple[int, ...], int]]]: + """Parse a constraint spec into a list of ``(comparator, parsed_version)`` pairs. + + 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 + treated as a package name and ignored. + + ``~= 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, + } + constraints = [] + for op, ver in re.findall(r"(===|==|!=|<=|>=|~=|<|>)\s*([^\s,]+)", spec): + 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 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..d3c839b --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,90 @@ +"""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): + 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}" + ) + + +def test_tilde_eq_rejects_single_segment(): + with pytest.raises(ValueError, match="~= requires a multi-segment version"): + parse_constraints("foo ~= 1") From 236eff6b4da284af6b40e485aebd18e74da31236 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 25 May 2026 16:11:12 +0200 Subject: [PATCH 2/4] parse_constraints doc --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index ff2d042..cac5739 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ True True ``` +### Check a version against a constraint + +`parse_constraints` turns a requirement spec like `"aiokafka >= 0.8, < 1.0"` +into 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 +>>> +>>> constraints = parse_constraints("aiokafka >= 0.8, < 1.0") +>>> 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 spec may also start with a package name, which is ignored; bare specs +(`">= 0.8, < 1.0"`) work too. PEP 440's `~= V` is supported and expanded +internally into the equivalent `>= V, < V'` pair. + ## Releasing Releases are cut by pushing a `N.N.N` git tag. The `Release` workflow runs From 0f820c0d9b47e57df8a1d0b5085c3e513af858ab Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 25 May 2026 16:11:33 +0200 Subject: [PATCH 3/4] format --- src/picopip.py | 9 ++++++--- tests/test_constraints.py | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/picopip.py b/src/picopip.py index 63de24c..99424d7 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -195,9 +195,12 @@ def parse_constraints( ``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, + "==": operator.eq, + "!=": operator.ne, + ">=": operator.ge, + "<=": operator.le, + ">": operator.gt, + "<": operator.lt, } constraints = [] for op, ver in re.findall(r"(===|==|!=|<=|>=|~=|<|>)\s*([^\s,]+)", spec): diff --git a/tests/test_constraints.py b/tests/test_constraints.py index d3c839b..c2c3c55 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -63,10 +63,10 @@ ("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 + (">= 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 ] From 7fc73fb03df099ec905da53156057caaed4ce0fb Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 25 May 2026 16:21:50 +0200 Subject: [PATCH 4/4] return name too --- README.md | 16 ++++++++-------- src/picopip.py | 18 +++++++++++------- tests/test_constraints.py | 26 +++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cac5739..2a01306 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,16 @@ True ### Check a version against a constraint -`parse_constraints` turns a requirement spec like `"aiokafka >= 0.8, < 1.0"` -into a list of `(comparator, parsed_version)` pairs. Apply every pair to a -parsed version to decide whether it satisfies the spec. +`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 >>> ->>> constraints = parse_constraints("aiokafka >= 0.8, < 1.0") +>>> 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 @@ -74,10 +76,8 @@ True False ``` -The spec may also start with a package name, which is ignored; bare specs -(`">= 0.8, < 1.0"`) work too. PEP 440's `~= V` is supported and expanded -internally into the equivalent `>= V, < V'` pair. - +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 99424d7..c39273c 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -184,12 +184,13 @@ def parse_version(version: str) -> Tuple[Tuple[int, ...], int]: def parse_constraints( spec: str, -) -> List[Tuple[Callable, Tuple[Tuple[int, ...], int]]]: - """Parse a constraint spec into a list of ``(comparator, parsed_version)`` pairs. +) -> 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 - treated as a package name and ignored. + 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. @@ -202,8 +203,11 @@ def parse_constraints( ">": 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 op, ver in re.findall(r"(===|==|!=|<=|>=|~=|<|>)\s*([^\s,]+)", spec): + 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" @@ -217,7 +221,7 @@ def parse_constraints( constraints.append((operator.lt, parse_version(".".join(head)))) else: constraints.append((ops[op], parse_version(ver))) - return constraints + return name, constraints class _VersionParser: diff --git a/tests/test_constraints.py b/tests/test_constraints.py index c2c3c55..0dfd379 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -72,7 +72,7 @@ @pytest.mark.parametrize(("spec", "matching", "non_matching"), CASES) def test_constraint_satisfaction(spec, matching, non_matching): - constraints = parse_constraints(spec) + _name, constraints = parse_constraints(spec) matching_v = parse_version(matching) assert all(op(matching_v, cv) for op, cv in constraints), ( @@ -85,6 +85,30 @@ def test_constraint_satisfaction(spec, matching, non_matching): ) +@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")