Skip to content

Commit 0848b9a

Browse files
committed
Add property-based tests for PEP 440
325 Hypothesis tests that verify algebraic invariants from the version specifiers specification. Each test class quotes the spec paragraph it validates. Tests are excluded from coverage and run as a separate CI job via `nox -s property_tests`. Two tests are expected to fail until pypa#1140 and pypa#1141 are merged (exclusive comparison exclusion rules).
1 parent c901ded commit 0848b9a

12 files changed

Lines changed: 5478 additions & 1 deletion

.github/workflows/test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ jobs:
5050
- name: Run nox
5151
run: pipx run nox -s tests --force-python=${{ matrix.python_version }}
5252

53+
property-tests:
54+
name: Property tests
55+
runs-on: ubuntu-latest
56+
57+
steps:
58+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
59+
with:
60+
persist-credentials: false
61+
62+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
63+
name: Install Python 3.14
64+
with:
65+
python-version: "3.14"
66+
cache: "pip"
67+
68+
- name: Run nox
69+
run: pipx run nox -s property_tests
70+
5371
downstream:
5472
name: Downstream ${{ matrix.project }}
5573
runs-on: ubuntu-latest

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ repos:
2828
- id: mypy
2929
exclude: '^(docs)'
3030
args: []
31-
additional_dependencies: [nox, orjson, 'pytest<9', tomli, tomli_w, types-invoke, httpx]
31+
additional_dependencies: [hypothesis, nox, orjson, 'pytest<9', tomli, tomli_w, types-invoke, httpx]
3232

3333
- repo: https://github.com/crate-ci/typos
3434
rev: 631208b7aac2daa8b707f55e7331f9112b0e062d # frozen: v1.44.0

noxfile.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ def tests(session: nox.Session) -> None:
5656
session.install("-e.")
5757
env = {} if session.python != "3.14" else {"COVERAGE_CORE": "sysmon"}
5858

59+
# Property tests run in their own session to avoid slowing down
60+
# the coverage-tracked test suite.
61+
ignore = (
62+
["--ignore=tests/strategies.py"]
63+
+ [f"--ignore={p}" for p in sorted(glob.glob("tests/test_property_*.py"))]
64+
if not session.posargs
65+
else []
66+
)
67+
5968
assert session.python is not None
6069
assert not isinstance(session.python, bool)
6170
if "pypy" not in session.python:
@@ -64,6 +73,7 @@ def tests(session: nox.Session) -> None:
6473
"run",
6574
"-m",
6675
"pytest",
76+
*ignore,
6777
*session.posargs,
6878
env=env,
6979
)
@@ -75,10 +85,27 @@ def tests(session: nox.Session) -> None:
7585
"-m",
7686
"pytest",
7787
"--capture=no",
88+
*ignore,
7889
*session.posargs,
7990
)
8091

8192

93+
@nox.session(default=False)
94+
def property_tests(session: nox.Session) -> None:
95+
"""
96+
Run property-based tests (no coverage).
97+
"""
98+
session.install(*nox.project.dependency_groups(PYPROJECT, "test"))
99+
session.install("-e.")
100+
session.run(
101+
"python",
102+
"-m",
103+
"pytest",
104+
*sorted(glob.glob("tests/test_property_*.py")),
105+
*session.posargs,
106+
)
107+
108+
82109
PROJECTS = {
83110
"packaging_legacy": "https://github.com/di/packaging_legacy/archive/refs/tags/23.0.post0.tar.gz",
84111
"build": "https://github.com/pypa/build/archive/refs/tags/1.4.0.tar.gz",

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Source = "https://github.com/pypa/packaging"
3939
[dependency-groups]
4040
test = [
4141
"coverage[toml]>=7.2.0",
42+
"hypothesis>=6.0.0",
4243
"pip>=21.1",
4344
"pretend",
4445
"pytest>=6.2.0",

tests/strategies.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
"""Shared Hypothesis strategies for property-based tests.
6+
7+
All version and specifier strategies live here so they are defined once
8+
and reused across the test_property_* files.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from hypothesis import settings
14+
from hypothesis import strategies as st
15+
16+
from packaging.specifiers import SpecifierSet
17+
from packaging.version import Version
18+
19+
SETTINGS = settings(max_examples=200, deadline=None)
20+
21+
# Primitive building blocks.
22+
_small_ints = st.integers(min_value=0, max_value=20)
23+
_pre_tags = st.sampled_from(["a", "b", "rc"])
24+
25+
# Local version segments per the spec: numeric (compared as ints),
26+
# lexicographic (compared case-insensitively), and multi-segment
27+
# (dot-separated combinations of both).
28+
_local_numeric = st.integers(min_value=0, max_value=100).map(str)
29+
_local_text = st.sampled_from(["abc", "ubuntu", "local", "patch", "dev"])
30+
_local_segment = st.one_of(_local_numeric, _local_text)
31+
_local_labels = st.lists(_local_segment, min_size=1, max_size=3).map(".".join)
32+
33+
34+
@st.composite
35+
def pep440_versions(
36+
draw: st.DrawFn,
37+
*,
38+
include_local: bool = True,
39+
min_segments: int = 1,
40+
) -> Version:
41+
"""Generate a random PEP 440 version.
42+
43+
Parameters
44+
----------
45+
include_local:
46+
When True (the default), roughly half the generated versions
47+
will carry a local segment.
48+
min_segments:
49+
Minimum number of release segments (use 2 for ~= testing).
50+
"""
51+
epoch = draw(st.sampled_from([None, 0, 1]))
52+
num_segments = draw(st.integers(min_value=min_segments, max_value=4))
53+
release = tuple(draw(_small_ints) for _ in range(num_segments))
54+
55+
pre = None
56+
if draw(st.booleans()):
57+
pre = (draw(_pre_tags), draw(_small_ints))
58+
59+
post: int | None = None
60+
if draw(st.booleans()):
61+
post = draw(_small_ints)
62+
63+
dev: int | None = None
64+
if draw(st.booleans()):
65+
dev = draw(_small_ints)
66+
67+
local: str | None = None
68+
if include_local and draw(st.booleans()):
69+
local = draw(_local_labels)
70+
71+
parts: list[str] = []
72+
if epoch is not None and epoch != 0:
73+
parts.append(f"{epoch}!")
74+
parts.append(".".join(str(s) for s in release))
75+
if pre is not None:
76+
parts.append(f"{pre[0]}{pre[1]}")
77+
if post is not None:
78+
parts.append(f".post{post}")
79+
if dev is not None:
80+
parts.append(f".dev{dev}")
81+
if local is not None:
82+
parts.append(f"+{local}")
83+
84+
return Version("".join(parts))
85+
86+
87+
@st.composite
88+
def nonlocal_versions(draw: st.DrawFn) -> Version:
89+
"""Generate a PEP 440 version without a local segment."""
90+
v: Version = draw(pep440_versions(include_local=False))
91+
return v
92+
93+
94+
@st.composite
95+
def release_versions(draw: st.DrawFn, *, min_segments: int = 1) -> Version:
96+
"""Generate a final release version (no pre/post/dev/local)."""
97+
num_segments = draw(st.integers(min_value=min_segments, max_value=4))
98+
release = tuple(draw(_small_ints) for _ in range(num_segments))
99+
return Version(".".join(str(s) for s in release))
100+
101+
102+
@st.composite
103+
def multi_segment_versions(draw: st.DrawFn) -> Version:
104+
"""Generate a version with at least 2 release segments (for ~=)."""
105+
v: Version = draw(pep440_versions(include_local=False, min_segments=2))
106+
return v
107+
108+
109+
@st.composite
110+
def versions_with_local(draw: st.DrawFn) -> Version:
111+
"""Generate a PEP 440 version that always has a local segment.
112+
113+
Covers all three local segment types from the spec: pure numeric,
114+
pure lexicographic, and multi-segment combinations.
115+
"""
116+
base = draw(pep440_versions(include_local=False))
117+
local_part = draw(_local_labels)
118+
return Version(f"{base}+{local_part}")
119+
120+
121+
@st.composite
122+
def specifier_sets(draw: st.DrawFn) -> SpecifierSet:
123+
"""Generate a random SpecifierSet from common operator/version pairs."""
124+
num = draw(st.integers(min_value=1, max_value=3))
125+
parts: list[str] = []
126+
for _ in range(num):
127+
op = draw(st.sampled_from([">=", "<=", ">", "<", "==", "!="]))
128+
major = draw(_small_ints)
129+
minor = draw(_small_ints)
130+
parts.append(f"{op}{major}.{minor}")
131+
return SpecifierSet(",".join(parts))

0 commit comments

Comments
 (0)