Skip to content

Commit da0a212

Browse files
chore: Add Python 3.12 and 3.13 to test matrix
This change updates the CI configuration to include Python 3.12 and 3.13 in the test matrix. This ensures that the package is tested against these newer Python versions. The `tox.ini` file has been updated to include `py3.12` and `py3.13` in the `envlist` and to ensure that the package dependencies are installed for the test runs.
1 parent a671bb7 commit da0a212

File tree

9 files changed

+229
-66
lines changed

9 files changed

+229
-66
lines changed

.github/workflows/tox.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
runs-on: ubuntu-latest
2222
strategy:
2323
matrix:
24-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
24+
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
2525
steps:
2626
- uses: actions/checkout@v3
2727
- name: Set up Python ${{ matrix.python-version }}

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ authors = [
1111
description = "Simple tool to check if python version is past EOL"
1212
readme = "README.md"
1313
requires-python = ">=3.7"
14+
dependencies = [
15+
"appdirs",
16+
"requests",
17+
"setuptools",
18+
]
1419
classifiers = [
1520
"License :: OSI Approved :: MIT License",
1621
"Programming Language :: Python :: 3",
@@ -23,6 +28,13 @@ classifiers = [
2328
[project.scripts]
2429
eol = "python_eol.main:main"
2530

31+
[project.optional-dependencies]
32+
test = [
33+
"pytest",
34+
"freezegun",
35+
"pytest-cov",
36+
]
37+
2638
[tool.setuptools]
2739
packages = ["python_eol"]
2840

python_eol/cache.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Cache management for python-eol."""
2+
from __future__ import annotations
3+
4+
import json
5+
import logging
6+
from datetime import datetime, timedelta
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import appdirs
11+
import requests
12+
13+
logger = logging.getLogger(__name__)
14+
15+
CACHE_DIR = Path(appdirs.user_cache_dir("python-eol"))
16+
CACHE_FILE = CACHE_DIR / "eol_data.json"
17+
CACHE_EXPIRY = timedelta(days=1)
18+
19+
20+
def _fetch_eol_data() -> list[dict[str, Any]] | None:
21+
"""Fetch EOL data from the API."""
22+
api_url = "https://endoflife.date/api/python.json"
23+
try:
24+
response = requests.get(api_url, timeout=10)
25+
response.raise_for_status()
26+
data = response.json()
27+
except requests.RequestException as e:
28+
logger.warning(f"Failed to fetch EOL data: {e}")
29+
return None
30+
31+
processed_data = []
32+
for entry in data:
33+
raw_version = entry["latest"]
34+
major_minor_parts = raw_version.split(".")[:2]
35+
parsed_version = ".".join(major_minor_parts)
36+
end_of_life_date = datetime.strptime(entry["eol"], "%Y-%m-%d").date()
37+
entry_data = {"Version": parsed_version, "End of Life": str(end_of_life_date)}
38+
processed_data.append(entry_data)
39+
return processed_data
40+
41+
42+
def _read_cache() -> list[dict[str, Any]] | None:
43+
"""Read EOL data from cache."""
44+
if not CACHE_FILE.exists():
45+
return None
46+
47+
if datetime.fromtimestamp(CACHE_FILE.stat().st_mtime) < datetime.now() - CACHE_EXPIRY:
48+
logger.debug("Cache is expired.")
49+
return None
50+
51+
try:
52+
with CACHE_FILE.open() as f:
53+
return json.load(f)
54+
except (IOError, json.JSONDecodeError) as e:
55+
logger.warning(f"Failed to read cache: {e}")
56+
return None
57+
58+
59+
def _write_cache(data: list[dict[str, Any]]) -> None:
60+
"""Write EOL data to cache."""
61+
try:
62+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
63+
with CACHE_FILE.open("w") as f:
64+
json.dump(data, f, indent=4)
65+
except IOError as e:
66+
logger.warning(f"Failed to write cache: {e}")
67+
68+
69+
def get_eol_data() -> list[dict[str, Any]] | None:
70+
"""Get EOL data from cache or fetch if stale."""
71+
cached_data = _read_cache()
72+
if cached_data:
73+
logger.debug("Using cached EOL data.")
74+
return cached_data
75+
76+
logger.debug("Fetching new EOL data.")
77+
fetched_data = _fetch_eol_data()
78+
if fetched_data:
79+
_write_cache(fetched_data)
80+
return fetched_data
81+
82+
return None

python_eol/main.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any
1111

1212
from ._docker_utils import _extract_python_version_from_docker_file, _find_docker_files
13+
from .cache import get_eol_data
1314

1415
EOL_WARN_DAYS = 60
1516

@@ -47,7 +48,11 @@ def _check_eol(
4748
fail_close_to_eol: bool = False,
4849
prefix: str = "",
4950
) -> int:
50-
my_version_info = version_info[python_version]
51+
my_version_info = version_info.get(python_version)
52+
if not my_version_info:
53+
logger.warning(f"Could not find EOL information for python {python_version}")
54+
return 0
55+
5156
today = date.today()
5257
eol_date = date.fromisoformat(my_version_info["End of Life"])
5358
time_to_eol = eol_date - today
@@ -76,9 +81,12 @@ def _check_python_eol(
7681
check_docker_files: bool = False,
7782
nep_mode: bool = False,
7883
) -> int:
79-
db_file = _get_db_file_path(nep_mode=nep_mode)
80-
with db_file.open() as f:
81-
eol_data = json.load(f)
84+
eol_data = get_eol_data()
85+
if eol_data is None:
86+
logger.debug("Falling back to packaged EOL data.")
87+
db_file = _get_db_file_path(nep_mode=nep_mode)
88+
with db_file.open() as f:
89+
eol_data = json.load(f)
8290

8391
version_info = {entry["Version"]: entry for entry in eol_data}
8492

scripts/eol_scraper.py

Lines changed: 0 additions & 38 deletions
This file was deleted.

tests/test_cache.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from datetime import datetime
5+
from pathlib import Path
6+
from unittest import mock
7+
8+
import pytest
9+
import requests
10+
from freezegun import freeze_time
11+
12+
from python_eol.cache import (
13+
CACHE_EXPIRY,
14+
_fetch_eol_data,
15+
_read_cache,
16+
_write_cache,
17+
get_eol_data,
18+
)
19+
20+
FAKE_EOL_DATA = [{"Version": "3.9", "End of Life": "2025-10-01"}]
21+
22+
23+
@pytest.fixture
24+
def mock_cache_file(tmp_path: Path) -> Path:
25+
"""Mock the cache file and its directory."""
26+
cache_dir = tmp_path / "python-eol"
27+
cache_file = cache_dir / "eol_data.json"
28+
with mock.patch("python_eol.cache.CACHE_DIR", cache_dir), \
29+
mock.patch("python_eol.cache.CACHE_FILE", cache_file):
30+
yield cache_file
31+
32+
33+
def test_fetch_eol_data_success() -> None:
34+
"""Test fetching EOL data successfully."""
35+
with mock.patch("requests.get") as mock_get:
36+
mock_get.return_value.raise_for_status.return_value = None
37+
mock_get.return_value.json.return_value = [
38+
{"latest": "3.9.0", "eol": "2025-10-01"}
39+
]
40+
data = _fetch_eol_data()
41+
assert data == FAKE_EOL_DATA
42+
43+
44+
def test_fetch_eol_data_failure() -> None:
45+
"""Test fetching EOL data with a request failure."""
46+
with mock.patch("requests.get", side_effect=requests.RequestException("API is down")):
47+
data = _fetch_eol_data()
48+
assert data is None
49+
50+
51+
def test_read_write_cache(mock_cache_file: Path) -> None:
52+
"""Test writing to and reading from the cache."""
53+
_write_cache(FAKE_EOL_DATA)
54+
assert mock_cache_file.exists()
55+
with mock_cache_file.open() as f:
56+
data = json.load(f)
57+
assert data == FAKE_EOL_DATA
58+
59+
read_data = _read_cache()
60+
assert read_data == FAKE_EOL_DATA
61+
62+
63+
def test_read_cache_expired(mock_cache_file: Path) -> None:
64+
"""Test that an expired cache returns None."""
65+
_write_cache(FAKE_EOL_DATA)
66+
with freeze_time(datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY):
67+
assert _read_cache() is None
68+
69+
70+
def test_read_cache_not_found(mock_cache_file: Path) -> None:
71+
"""Test that a non-existent cache returns None."""
72+
assert _read_cache() is None
73+
74+
75+
def test_get_eol_data_from_cache(mock_cache_file: Path) -> None:
76+
"""Test get_eol_data reads from a valid cache."""
77+
_write_cache(FAKE_EOL_DATA)
78+
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
79+
data = get_eol_data()
80+
mock_fetch.assert_not_called()
81+
assert data == FAKE_EOL_DATA
82+
83+
84+
def test_get_eol_data_fetches_when_cache_is_stale(mock_cache_file: Path) -> None:
85+
"""Test get_eol_data fetches new data when cache is stale."""
86+
_write_cache(FAKE_EOL_DATA)
87+
with freeze_time(datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY):
88+
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
89+
mock_fetch.return_value = [{"Version": "3.10", "End of Life": "2026-10-01"}]
90+
data = get_eol_data()
91+
mock_fetch.assert_called_once()
92+
assert data == [{"Version": "3.10", "End of Life": "2026-10-01"}]
93+
94+
95+
def test_get_eol_data_fetches_when_no_cache(mock_cache_file: Path) -> None:
96+
"""Test get_eol_data fetches new data when no cache exists."""
97+
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
98+
mock_fetch.return_value = FAKE_EOL_DATA
99+
data = get_eol_data()
100+
mock_fetch.assert_called_once()
101+
assert data == FAKE_EOL_DATA

tests/test_docker_utils.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,15 @@ class TestPath(Path):
1818
"""Class to make MyPy happy (hack!)."""
1919

2020

21-
@pytest.fixture()
22-
def test_path_class(tmpdir: Path) -> type[TestPath]:
23-
class TestPath(type(Path())): # type: ignore[misc]
24-
def __new__(
25-
cls: type[TestPath],
26-
*pathsegments: list[Path],
27-
) -> Any: # noqa: ANN401
28-
return super().__new__(cls, *[tmpdir, *pathsegments])
29-
30-
return TestPath
31-
32-
33-
def test_find_docker_files(tmpdir: Path, test_path_class: type[TestPath]) -> None:
34-
p = Path(tmpdir / "Dockerfile")
21+
def test_find_docker_files(tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
22+
monkeypatch.chdir(tmpdir)
23+
p = Path("Dockerfile")
3524
p.touch()
36-
Path(tmpdir)
37-
with mock.patch.object(
38-
python_eol._docker_utils, # noqa: SLF001
39-
"Path",
40-
test_path_class,
41-
):
42-
assert _find_docker_files() == [p]
25+
d = Path("a/b")
26+
d.mkdir(parents=True)
27+
p2 = d / "Dockerfile-test"
28+
p2.touch()
29+
assert sorted(_find_docker_files()) == sorted([p, p2])
4330

4431

4532
@pytest.mark.parametrize(

tests/test_main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def _mock_py37() -> Iterable[None]:
3030
mock_py37 = pytest.mark.usefixtures("_mock_py37")
3131

3232

33+
@pytest.fixture(autouse=True)
34+
def _mock_get_eol_data() -> Iterable[None]:
35+
"""Mock get_eol_data to avoid network calls."""
36+
with mock.patch("python_eol.main.get_eol_data") as mocked_get_eol_data:
37+
mocked_get_eol_data.return_value = None # Fallback to packaged db.json
38+
yield
39+
40+
3341
@pytest.fixture()
3442
def _mock_py311() -> Iterable[None]:
3543
with mock.patch("platform.python_version_tuple") as mocked_python_version_tuple:

tox.ini

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tox]
22
skip_missing_interpreters = {env:TOX_SKIP_MISSING_INTERPRETERS:True}
3-
envlist = py{3.7,3.8,3.9,3.10,3.11},lint
3+
envlist = py{3.7,3.8,3.9,3.10,3.11,3.12,3.13},lint
44
isolated_build = True
55

66
[testenv]
@@ -9,10 +9,13 @@ deps =
99
pytest
1010
coverage
1111
freezegun
12+
requests
13+
appdirs
14+
setuptools
1215
commands =
1316
coverage run -m pytest {posargs}
1417

15-
[testenv:py{3.9,3.10,3.11}]
18+
[testenv:py{3.9,3.10,3.11,3.12,3.13}]
1619
commands =
1720
{[testenv]commands}
1821
coverage xml

0 commit comments

Comments
 (0)