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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion src/picopip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.

Expand Down
114 changes: 114 additions & 0 deletions tests/test_constraints.py
Original file line number Diff line number Diff line change
@@ -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")
Loading