Skip to content

Commit 8b757d4

Browse files
committed
Release v2025.5.0
1 parent c95a2d6 commit 8b757d4

File tree

10 files changed

+374
-69
lines changed

10 files changed

+374
-69
lines changed

.bumper.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[tool.bumper]
2-
current_version = "0.1.0"
2+
current_version = "2025.5.0"
3+
versioning_type = "calver"
34

45
[[tool.bumper.files]]
56
file = "./pyproject.toml"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ Add this to your `.pre-commit-config.yaml`
1111

1212
```yaml
1313
- repo: https://github.com/sco1/pre-commit-python-eol
14-
rev: v0.1.0
14+
rev: v2025.5.0
1515
hooks:
1616
- id: check-eol
1717
```
1818
1919
## Hooks
20-
**NOTE:** Only pyproject.toml is currently inspected. It is assumed that project metadata is supported per [PyPA Guidance](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
20+
**NOTE:** Only pyproject.toml is currently inspected. It is assumed that project metadata is specified per [PyPA Guidance](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
2121
2222
### `check-eol`
2323
Check `requires-python` against the current Python lifecycle & fail if an EOL version is included.

cached_release_cycle.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
{
2-
"3.14": {
2+
"3.15": {
33
"branch": "main",
4-
"pep": 745,
4+
"pep": 790,
55
"status": "feature",
6-
"first_release": "2025-10-01",
6+
"first_release": "2026-10-01",
7+
"end_of_life": "2031-10",
8+
"release_manager": "Hugo van Kemenade"
9+
},
10+
"3.14": {
11+
"branch": "3.14",
12+
"pep": 745,
13+
"status": "prerelease",
14+
"first_release": "2025-10-07",
715
"end_of_life": "2030-10",
816
"release_manager": "Hugo van Kemenade"
917
},

pre_commit_python_eol/check_eol.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections import abc
1010
from dataclasses import dataclass
1111
from enum import StrEnum
12+
from operator import attrgetter
1213
from pathlib import Path
1314

1415
from packaging import specifiers, version
@@ -58,25 +59,12 @@ def _parse_eol_date(date_str: str) -> dt.date:
5859

5960

6061
@dataclass(frozen=True)
61-
class PythonRelease:
62-
"""
63-
Represent the relevant metadata information for a Python release.
64-
65-
For the purposes of this tool, instances of `PythonRelease` are considered equal if their
66-
respective `python_ver` attributes are equal.
67-
"""
68-
62+
class PythonRelease: # noqa: D101
6963
python_ver: version.Version
7064
status: ReleasePhase
7165
end_of_life: dt.date
7266

73-
def __eq__(self, other: object) -> bool:
74-
if not isinstance(other, PythonRelease):
75-
return NotImplemented
76-
77-
return self.python_ver == other.python_ver
78-
79-
def __str__(self) -> str:
67+
def __str__(self) -> str: # pragma: no cover
8068
return f"Python {self.python_ver} - Status: {self.status}, EOL: {self.end_of_life}"
8169

8270
@classmethod
@@ -94,15 +82,25 @@ def from_json(cls, ver: str, metadata: dict[str, t.Any]) -> PythonRelease:
9482
)
9583

9684

97-
def _get_cached_release_cycle(cache_json: Path = CACHED_RELEASE_CYCLE) -> set[PythonRelease]:
98-
"""Parse the locally cached Python release cycle into `PythonRelease` instance(s)."""
85+
def _get_cached_release_cycle(cache_json: Path) -> list[PythonRelease]:
86+
"""
87+
Parse the locally cached Python release cycle into `PythonRelease` instance(s).
88+
89+
Results are sorted by Python version in descending order.
90+
"""
9991
with cache_json.open("r", encoding="utf-8") as f:
10092
contents = json.load(f)
10193

102-
return {PythonRelease.from_json(v, m) for v, m in contents.items()}
94+
# The sorting is probably unnecessary since the JSON should already be sorted, but going to
95+
# retain since it's expected downstream
96+
return sorted(
97+
(PythonRelease.from_json(v, m) for v, m in contents.items()),
98+
key=attrgetter("python_ver"),
99+
reverse=True,
100+
)
103101

104102

105-
def check_python_support(toml_file: Path) -> None:
103+
def check_python_support(toml_file: Path, cache_json: Path = CACHED_RELEASE_CYCLE) -> None:
106104
with toml_file.open("rb") as f:
107105
contents = tomllib.load(f)
108106

@@ -111,8 +109,24 @@ def check_python_support(toml_file: Path) -> None:
111109
raise RequiresPythonNotFoundError
112110

113111
package_spec = specifiers.SpecifierSet(requires_python)
112+
release_cycle = _get_cached_release_cycle(cache_json)
113+
utc_today = dt.datetime.now(dt.timezone.utc).date()
114+
115+
eol_supported = []
116+
for r in release_cycle:
117+
if r.python_ver in package_spec:
118+
if r.status == ReleasePhase.EOL:
119+
eol_supported.append(r)
120+
continue
121+
122+
if r.end_of_life <= utc_today:
123+
eol_supported.append(r)
124+
continue
114125

115-
raise NotImplementedError
126+
if eol_supported:
127+
eol_supported.sort(key=attrgetter("python_ver")) # Sort ascending for error msg generation
128+
joined_vers = ", ".join(str(r.python_ver) for r in eol_supported)
129+
raise EOLPythonError(f"EOL Python support found: {joined_vers}")
116130

117131

118132
def main(argv: abc.Sequence[str] | None = None) -> int: # noqa: D103
@@ -124,8 +138,8 @@ def main(argv: abc.Sequence[str] | None = None) -> int: # noqa: D103
124138
for file in args.filenames:
125139
try:
126140
check_python_support(file)
127-
except EOLPythonError:
128-
print(f"{file}: Fail.")
141+
except EOLPythonError as e:
142+
print(f"{file}: {e}")
129143
ec = 1
130144
except RequiresPythonNotFoundError:
131145
print(f"{file} 'requires-python' could not be located, or it is empty.")

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pre-commit-python-eol"
3-
version = "0.1.0"
3+
version = "2025.5.0"
44
description = "A pre-commit hook for enforcing supported Python EOL"
55
license = "MIT"
66
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
2222

2323
requires-python = ">=3.11"
2424
dependencies = [
25-
"packaging~=24.2",
25+
"packaging~=25.0",
2626
]
2727

2828
[project.urls]
@@ -47,7 +47,8 @@ dev-dependencies = [
4747
"pytest-cov~=6.0",
4848
"pytest-randomly~=3.16",
4949
"ruff~=0.9",
50-
"sco1-bumper~=1.0",
50+
"sco1-bumper~=2.0",
51+
"time-machine~=2.16",
5152
"tox~=4.23",
5253
"tox-uv~=1.17",
5354
]

tests/__init__.py

Whitespace-only changes.

tests/test_check_eol.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import datetime as dt
2+
from pathlib import Path
3+
4+
import pytest
5+
import time_machine
6+
from packaging import version
7+
8+
from pre_commit_python_eol.check_eol import (
9+
EOLPythonError,
10+
PythonRelease,
11+
ReleasePhase,
12+
RequiresPythonNotFoundError,
13+
_get_cached_release_cycle,
14+
_parse_eol_date,
15+
check_python_support,
16+
)
17+
18+
EOL_DATE_PARSE_CASES = (
19+
("2025-01-01", dt.date(year=2025, month=1, day=1)),
20+
("2025-01", dt.date(year=2025, month=1, day=1)),
21+
)
22+
23+
24+
@pytest.mark.parametrize(("date_str", "truth_date"), EOL_DATE_PARSE_CASES)
25+
def test_parse_eol_date(date_str: str, truth_date: dt.date) -> None:
26+
assert _parse_eol_date(date_str) == truth_date
27+
28+
29+
def test_parse_eol_date_unknown_fmt_raises() -> None:
30+
with pytest.raises(ValueError, match="Unknown date format"):
31+
_ = _parse_eol_date("123456")
32+
33+
34+
def test_python_release_from_json() -> None:
35+
sample_metadata = {
36+
"branch": "3.14",
37+
"pep": 745,
38+
"status": "prerelease",
39+
"first_release": "2025-10-07",
40+
"end_of_life": "2030-10",
41+
"release_manager": "Hugo van Kemenade",
42+
}
43+
44+
truth_rel = PythonRelease(
45+
python_ver=version.Version("3.14"),
46+
status=ReleasePhase.PRERELEASE,
47+
end_of_life=dt.date(year=2030, month=10, day=1),
48+
)
49+
50+
assert PythonRelease.from_json("3.14", metadata=sample_metadata) == truth_rel
51+
52+
53+
# Intentionally out of expected order so sorting can be checked
54+
SAMPLE_JSON = """\
55+
{
56+
"3.14": {
57+
"branch": "3.14",
58+
"pep": 745,
59+
"status": "prerelease",
60+
"first_release": "2025-10-07",
61+
"end_of_life": "2030-10",
62+
"release_manager": "Hugo van Kemenade"
63+
},
64+
"3.15": {
65+
"branch": "main",
66+
"pep": 790,
67+
"status": "feature",
68+
"first_release": "2026-10-01",
69+
"end_of_life": "2031-10",
70+
"release_manager": "Hugo van Kemenade"
71+
}
72+
}
73+
"""
74+
75+
TRUTH_RELEASE_CYCLE = [
76+
PythonRelease(
77+
python_ver=version.Version("3.15"),
78+
status=ReleasePhase.FEATURE,
79+
end_of_life=dt.date(year=2031, month=10, day=1),
80+
),
81+
PythonRelease(
82+
python_ver=version.Version("3.14"),
83+
status=ReleasePhase.PRERELEASE,
84+
end_of_life=dt.date(year=2030, month=10, day=1),
85+
),
86+
]
87+
88+
89+
def test_get_cached_release_cycle(tmp_path: Path) -> None:
90+
json_file = tmp_path / "cache.json"
91+
json_file.write_text(SAMPLE_JSON)
92+
93+
release_cycle = _get_cached_release_cycle(cache_json=json_file)
94+
assert release_cycle == TRUTH_RELEASE_CYCLE
95+
96+
97+
RELEASE_CACHE_WITH_EOL = """\
98+
{
99+
"3.14": {
100+
"branch": "3.14",
101+
"pep": 745,
102+
"status": "prerelease",
103+
"first_release": "2025-10-07",
104+
"end_of_life": "2030-10",
105+
"release_manager": "Hugo van Kemenade"
106+
},
107+
"3.8": {
108+
"branch": "3.8",
109+
"pep": 569,
110+
"status": "end-of-life",
111+
"first_release": "2019-10-14",
112+
"end_of_life": "2024-10-07",
113+
"release_manager": "Lukasz Langa"
114+
},
115+
"3.7": {
116+
"branch": "3.7",
117+
"pep": 537,
118+
"status": "end-of-life",
119+
"first_release": "2018-06-27",
120+
"end_of_life": "2023-06-27",
121+
"release_manager": "Ned Deily"
122+
}
123+
}
124+
"""
125+
126+
127+
@pytest.fixture
128+
def path_with_cache(tmp_path: Path) -> tuple[Path, Path]:
129+
json_file = tmp_path / "cache.json"
130+
json_file.write_text(RELEASE_CACHE_WITH_EOL)
131+
132+
return tmp_path, json_file
133+
134+
135+
SAMPLE_PYPROJECT_NO_VERSION = """\
136+
[project]
137+
"""
138+
139+
140+
def test_check_python_no_version_spec_raises(path_with_cache: tuple[Path, Path]) -> None:
141+
base_path, cache_path = path_with_cache
142+
pyproject = base_path / "pyproject.toml"
143+
pyproject.write_text(SAMPLE_PYPROJECT_NO_VERSION)
144+
145+
with pytest.raises(RequiresPythonNotFoundError):
146+
check_python_support(pyproject, cache_json=cache_path)
147+
148+
149+
SAMPLE_PYPROJECT_NO_EOL = """\
150+
[project]
151+
requires-python = ">=3.11"
152+
"""
153+
154+
155+
def test_check_python_support_no_eol(path_with_cache: tuple[Path, Path]) -> None:
156+
base_path, cache_path = path_with_cache
157+
pyproject = base_path / "pyproject.toml"
158+
pyproject.write_text(SAMPLE_PYPROJECT_NO_EOL)
159+
160+
with time_machine.travel(dt.date(year=2025, month=5, day=1)):
161+
check_python_support(pyproject, cache_json=cache_path)
162+
163+
164+
SAMPLE_PYPROJECT_SINGLE_EOL = """\
165+
[project]
166+
requires-python = ">=3.8"
167+
"""
168+
169+
170+
def test_check_python_support_single_eol_raises(path_with_cache: tuple[Path, Path]) -> None:
171+
base_path, cache_path = path_with_cache
172+
pyproject = base_path / "pyproject.toml"
173+
pyproject.write_text(SAMPLE_PYPROJECT_SINGLE_EOL)
174+
175+
with time_machine.travel(dt.date(year=2025, month=5, day=1)):
176+
with pytest.raises(EOLPythonError) as e:
177+
check_python_support(pyproject, cache_json=cache_path)
178+
179+
assert str(e.value).endswith("3.8")
180+
181+
182+
SAMPLE_PYPROJECT_SINGLE_EOL_BY_DATE = """\
183+
[project]
184+
requires-python = ">=3.11"
185+
"""
186+
187+
188+
def test_check_python_support_single_eol_raises_by_date(path_with_cache: tuple[Path, Path]) -> None:
189+
base_path, cache_path = path_with_cache
190+
pyproject = base_path / "pyproject.toml"
191+
pyproject.write_text(SAMPLE_PYPROJECT_SINGLE_EOL_BY_DATE)
192+
193+
with time_machine.travel(dt.date(year=2031, month=11, day=1)):
194+
with pytest.raises(EOLPythonError) as e:
195+
check_python_support(pyproject, cache_json=cache_path)
196+
197+
assert str(e.value).endswith("3.14")
198+
199+
200+
SAMPLE_PYPROJECT_MULTI_EOL = """\
201+
[project]
202+
requires-python = ">=3.7"
203+
"""
204+
205+
206+
def test_check_python_support_multi_eol_raises(path_with_cache: tuple[Path, Path]) -> None:
207+
base_path, cache_path = path_with_cache
208+
pyproject = base_path / "pyproject.toml"
209+
pyproject.write_text(SAMPLE_PYPROJECT_MULTI_EOL)
210+
211+
with time_machine.travel(dt.date(year=2025, month=5, day=1)):
212+
with pytest.raises(EOLPythonError) as e:
213+
check_python_support(pyproject, cache_json=cache_path)
214+
215+
assert str(e.value).endswith("3.7, 3.8")

tests/test_hello.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)