Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/tox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ authors = [
description = "Simple tool to check if python version is past EOL"
readme = "README.md"
requires-python = ">=3.7"
dependencies = [
"appdirs",
"requests",
"setuptools",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is setuptools really necessary?

]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
Expand All @@ -23,6 +28,13 @@ classifiers = [
[project.scripts]
eol = "python_eol.main:main"

[project.optional-dependencies]
test = [
"pytest",
"freezegun",
"pytest-cov",
]

[tool.setuptools]
packages = ["python_eol"]

Expand Down
82 changes: 82 additions & 0 deletions python_eol/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Cache management for python-eol."""
from __future__ import annotations

import json
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any

import appdirs
import requests

logger = logging.getLogger(__name__)

CACHE_DIR = Path(appdirs.user_cache_dir("python-eol"))
CACHE_FILE = CACHE_DIR / "eol_data.json"
CACHE_EXPIRY = timedelta(days=1)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Increase this to 1 month



def _fetch_eol_data() -> list[dict[str, Any]] | None:
"""Fetch EOL data from the API."""
api_url = "https://endoflife.date/api/python.json"
try:
response = requests.get(api_url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger.warning(f"Failed to fetch EOL data: {e}")
return None

processed_data = []
for entry in data:
raw_version = entry["latest"]
major_minor_parts = raw_version.split(".")[:2]
parsed_version = ".".join(major_minor_parts)
end_of_life_date = datetime.strptime(entry["eol"], "%Y-%m-%d").date()
entry_data = {"Version": parsed_version, "End of Life": str(end_of_life_date)}
processed_data.append(entry_data)
return processed_data


def _read_cache() -> list[dict[str, Any]] | None:
"""Read EOL data from cache."""
if not CACHE_FILE.exists():
return None

if datetime.fromtimestamp(CACHE_FILE.stat().st_mtime) < datetime.now() - CACHE_EXPIRY:
logger.debug("Cache is expired.")
return None

try:
with CACHE_FILE.open() as f:
return json.load(f)
except (IOError, json.JSONDecodeError) as e:
logger.warning(f"Failed to read cache: {e}")
return None


def _write_cache(data: list[dict[str, Any]]) -> None:
"""Write EOL data to cache."""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with CACHE_FILE.open("w") as f:
json.dump(data, f, indent=4)
except IOError as e:
logger.warning(f"Failed to write cache: {e}")


def get_eol_data() -> list[dict[str, Any]] | None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to support nep_mode

"""Get EOL data from cache or fetch if stale."""
cached_data = _read_cache()
if cached_data:
logger.debug("Using cached EOL data.")
return cached_data

logger.debug("Fetching new EOL data.")
fetched_data = _fetch_eol_data()
if fetched_data:
_write_cache(fetched_data)
return fetched_data

return None
16 changes: 12 additions & 4 deletions python_eol/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

from ._docker_utils import _extract_python_version_from_docker_file, _find_docker_files
from .cache import get_eol_data

EOL_WARN_DAYS = 60

Expand Down Expand Up @@ -47,7 +48,11 @@ def _check_eol(
fail_close_to_eol: bool = False,
prefix: str = "",
) -> int:
my_version_info = version_info[python_version]
my_version_info = version_info.get(python_version)
if not my_version_info:
logger.warning(f"Could not find EOL information for python {python_version}")
return 0

today = date.today()
eol_date = date.fromisoformat(my_version_info["End of Life"])
time_to_eol = eol_date - today
Expand Down Expand Up @@ -76,9 +81,12 @@ def _check_python_eol(
check_docker_files: bool = False,
nep_mode: bool = False,
) -> int:
db_file = _get_db_file_path(nep_mode=nep_mode)
with db_file.open() as f:
eol_data = json.load(f)
eol_data = get_eol_data()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to support nep_mode

if eol_data is None:
logger.debug("Falling back to packaged EOL data.")
db_file = _get_db_file_path(nep_mode=nep_mode)
with db_file.open() as f:
eol_data = json.load(f)

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

Expand Down
38 changes: 0 additions & 38 deletions scripts/eol_scraper.py

This file was deleted.

101 changes: 101 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from __future__ import annotations

import json
from datetime import datetime
from pathlib import Path
from unittest import mock

import pytest
import requests
from freezegun import freeze_time

from python_eol.cache import (
CACHE_EXPIRY,
_fetch_eol_data,
_read_cache,
_write_cache,
get_eol_data,
)

FAKE_EOL_DATA = [{"Version": "3.9", "End of Life": "2025-10-01"}]


@pytest.fixture
def mock_cache_file(tmp_path: Path) -> Path:
"""Mock the cache file and its directory."""
cache_dir = tmp_path / "python-eol"
cache_file = cache_dir / "eol_data.json"
with mock.patch("python_eol.cache.CACHE_DIR", cache_dir), \
mock.patch("python_eol.cache.CACHE_FILE", cache_file):
yield cache_file


def test_fetch_eol_data_success() -> None:
"""Test fetching EOL data successfully."""
with mock.patch("requests.get") as mock_get:
mock_get.return_value.raise_for_status.return_value = None
mock_get.return_value.json.return_value = [
{"latest": "3.9.0", "eol": "2025-10-01"}
]
data = _fetch_eol_data()
assert data == FAKE_EOL_DATA


def test_fetch_eol_data_failure() -> None:
"""Test fetching EOL data with a request failure."""
with mock.patch("requests.get", side_effect=requests.RequestException("API is down")):
data = _fetch_eol_data()
assert data is None


def test_read_write_cache(mock_cache_file: Path) -> None:
"""Test writing to and reading from the cache."""
_write_cache(FAKE_EOL_DATA)
assert mock_cache_file.exists()
with mock_cache_file.open() as f:
data = json.load(f)
assert data == FAKE_EOL_DATA

read_data = _read_cache()
assert read_data == FAKE_EOL_DATA


def test_read_cache_expired(mock_cache_file: Path) -> None:
"""Test that an expired cache returns None."""
_write_cache(FAKE_EOL_DATA)
with freeze_time(datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY):
assert _read_cache() is None


def test_read_cache_not_found(mock_cache_file: Path) -> None:
"""Test that a non-existent cache returns None."""
assert _read_cache() is None


def test_get_eol_data_from_cache(mock_cache_file: Path) -> None:
"""Test get_eol_data reads from a valid cache."""
_write_cache(FAKE_EOL_DATA)
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
data = get_eol_data()
mock_fetch.assert_not_called()
assert data == FAKE_EOL_DATA


def test_get_eol_data_fetches_when_cache_is_stale(mock_cache_file: Path) -> None:
"""Test get_eol_data fetches new data when cache is stale."""
_write_cache(FAKE_EOL_DATA)
with freeze_time(datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY):
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
mock_fetch.return_value = [{"Version": "3.10", "End of Life": "2026-10-01"}]
data = get_eol_data()
mock_fetch.assert_called_once()
assert data == [{"Version": "3.10", "End of Life": "2026-10-01"}]


def test_get_eol_data_fetches_when_no_cache(mock_cache_file: Path) -> None:
"""Test get_eol_data fetches new data when no cache exists."""
with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch:
mock_fetch.return_value = FAKE_EOL_DATA
data = get_eol_data()
mock_fetch.assert_called_once()
assert data == FAKE_EOL_DATA
29 changes: 8 additions & 21 deletions tests/test_docker_utils.py
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the changes to this file

Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,15 @@ class TestPath(Path):
"""Class to make MyPy happy (hack!)."""


@pytest.fixture()
def test_path_class(tmpdir: Path) -> type[TestPath]:
class TestPath(type(Path())): # type: ignore[misc]
def __new__(
cls: type[TestPath],
*pathsegments: list[Path],
) -> Any: # noqa: ANN401
return super().__new__(cls, *[tmpdir, *pathsegments])

return TestPath


def test_find_docker_files(tmpdir: Path, test_path_class: type[TestPath]) -> None:
p = Path(tmpdir / "Dockerfile")
def test_find_docker_files(tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmpdir)
p = Path("Dockerfile")
p.touch()
Path(tmpdir)
with mock.patch.object(
python_eol._docker_utils, # noqa: SLF001
"Path",
test_path_class,
):
assert _find_docker_files() == [p]
d = Path("a/b")
d.mkdir(parents=True)
p2 = d / "Dockerfile-test"
p2.touch()
assert sorted(_find_docker_files()) == sorted([p, p2])


@pytest.mark.parametrize(
Expand Down
8 changes: 8 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ def _mock_py37() -> Iterable[None]:
mock_py37 = pytest.mark.usefixtures("_mock_py37")


@pytest.fixture(autouse=True)
def _mock_get_eol_data() -> Iterable[None]:
"""Mock get_eol_data to avoid network calls."""
with mock.patch("python_eol.main.get_eol_data") as mocked_get_eol_data:
mocked_get_eol_data.return_value = None # Fallback to packaged db.json
yield


@pytest.fixture()
def _mock_py311() -> Iterable[None]:
with mock.patch("platform.python_version_tuple") as mocked_python_version_tuple:
Expand Down
7 changes: 5 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tox]
skip_missing_interpreters = {env:TOX_SKIP_MISSING_INTERPRETERS:True}
envlist = py{3.7,3.8,3.9,3.10,3.11},lint
envlist = py{3.7,3.8,3.9,3.10,3.11,3.12,3.13},lint
isolated_build = True

[testenv]
Expand All @@ -9,10 +9,13 @@ deps =
pytest
coverage
freezegun
requests
appdirs
setuptools
commands =
coverage run -m pytest {posargs}

[testenv:py{3.9,3.10,3.11}]
[testenv:py{3.9,3.10,3.11,3.12,3.13}]
commands =
{[testenv]commands}
coverage xml
Expand Down
Loading