From 1c12bbc993a878aa92b6e9bdf661ac1fecaf5d85 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 08:45:50 +0000 Subject: [PATCH] Fix PR comments This commit addresses the comments on pull request #7. - Increased cache expiry to 31 days. - Restored `tests/test_docker_utils.py` to the version in the PR. - Kept `setuptools` as a dependency as it is required for Python < 3.9. - Fixed various linting and mypy errors. --- .github/workflows/check_scrapers.yml | 2 +- .github/workflows/tox.yml | 2 +- coverage.xml | 197 +++++++++++++++++++++++++++ pyproject.toml | 12 ++ python_eol/__init__.py | 1 + python_eol/cache.py | 120 ++++++++++++++++ python_eol/main.py | 23 ++-- tests/test_cache.py | 155 +++++++++++++++++++++ tests/test_docker_utils.py | 37 ++--- tests/test_main.py | 38 ++++-- tox.ini | 9 +- 11 files changed, 545 insertions(+), 51 deletions(-) create mode 100644 coverage.xml create mode 100644 python_eol/cache.py create mode 100644 tests/test_cache.py diff --git a/.github/workflows/check_scrapers.yml b/.github/workflows/check_scrapers.yml index 65f16b6..b15850f 100644 --- a/.github/workflows/check_scrapers.yml +++ b/.github/workflows/check_scrapers.yml @@ -2,7 +2,7 @@ name: Check Scrapers on: schedule: - - cron: "0 1 * * *" + - cron: 0 1 * * * jobs: test_scripts: runs-on: ubuntu-latest diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index d483857..d3455e8 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -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 }} diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..e38e09f --- /dev/null +++ b/coverage.xml @@ -0,0 +1,197 @@ + + + + + + /app/python_eol + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 66aac54..8300859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", +] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -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"] diff --git a/python_eol/__init__.py b/python_eol/__init__.py index d3d57c7..5745236 100644 --- a/python_eol/__init__.py +++ b/python_eol/__init__.py @@ -1,2 +1,3 @@ """Top-level module for python-eol.""" + from __future__ import annotations diff --git a/python_eol/cache.py b/python_eol/cache.py new file mode 100644 index 0000000..07a07b1 --- /dev/null +++ b/python_eol/cache.py @@ -0,0 +1,120 @@ +"""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, cast + +import appdirs +import requests + +logger = logging.getLogger(__name__) + +CACHE_DIR = Path(appdirs.user_cache_dir("python-eol")) +CACHE_EXPIRY = timedelta(days=31) + + +def _get_cache_file(*, nep_mode: bool) -> Path: + """Get the cache file path.""" + if nep_mode: + return CACHE_DIR / "eol_data_nep.json" + return CACHE_DIR / "eol_data.json" + + +def _fetch_eol_data(*, nep_mode: bool) -> list[dict[str, Any]] | None: + """Fetch EOL data from the API.""" + if nep_mode: + api_url = ( + "https://raw.githubusercontent.com/scientific-python/specs/main/spec-0000/" + "python-support.json" + ) + else: + api_url = "https://endoflife.date/api/python.json" + + try: + response = requests.get(api_url, timeout=10) + response.raise_for_status() + raw_data = response.json() + except requests.RequestException as e: + logger.warning(f"Failed to fetch EOL data: {e}") + return None + + processed_data: list[dict[str, Any]] = [] + if nep_mode: + data = cast("dict[str, Any]", raw_data) + releases = cast("list[dict[str, Any]]", data.get("releases", [])) + for entry in releases: + end_of_life_date = datetime.strptime( + entry["eol"], + "%Y-%m-%d", + ).date() + entry_data = { + "Version": entry["version"], + "End of Life": str(end_of_life_date), + } + processed_data.append(entry_data) + else: + eol_data = cast("list[dict[str, Any]]", raw_data) + for entry in eol_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(*, nep_mode: bool) -> list[dict[str, Any]] | None: + """Read EOL data from cache.""" + cache_file = _get_cache_file(nep_mode=nep_mode) + 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: + data = json.load(f) + return cast("list[dict[str, Any]]", data) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to read cache: {e}") + return None + + +def _write_cache(data: list[dict[str, Any]], *, nep_mode: bool) -> None: + """Write EOL data to cache.""" + cache_file = _get_cache_file(nep_mode=nep_mode) + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with cache_file.open("w") as f: + json.dump(data, f, indent=4) + except OSError as e: + logger.warning(f"Failed to write cache: {e}") + + +def get_eol_data(*, nep_mode: bool) -> list[dict[str, Any]] | None: + """Get EOL data from cache or fetch if stale.""" + cached_data = _read_cache(nep_mode=nep_mode) + if cached_data: + logger.debug("Using cached EOL data.") + return cached_data + + logger.debug("Fetching new EOL data.") + fetched_data = _fetch_eol_data(nep_mode=nep_mode) + if fetched_data: + _write_cache(fetched_data, nep_mode=nep_mode) + return fetched_data + + return None diff --git a/python_eol/main.py b/python_eol/main.py index 2116897..4037c16 100644 --- a/python_eol/main.py +++ b/python_eol/main.py @@ -1,7 +1,9 @@ """python-eol checks if the current running python version is (close) to end of life.""" + from __future__ import annotations import argparse +import importlib.resources import json import logging import platform @@ -9,7 +11,10 @@ from pathlib import Path from typing import Any +import pkg_resources + from ._docker_utils import _extract_python_version_from_docker_file, _find_docker_files +from .cache import get_eol_data EOL_WARN_DAYS = 60 @@ -25,13 +30,9 @@ def _get_db_file_path(*, nep_mode: bool = False) -> Path: major, minor, _ = platform.python_version_tuple() filename = "db.json" if not nep_mode else "db_nep.json" if int(major) == 3 and int(minor) >= 9: # noqa: PLR2004 - import importlib.resources - data_path = importlib.resources.files("python_eol") db_file = f"{data_path}/{filename}" else: - import pkg_resources # pragma: no cover - db_file = pkg_resources.resource_filename( "python_eol", filename, @@ -47,7 +48,10 @@ 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 @@ -76,9 +80,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(nep_mode=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} diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..8dd8901 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import TYPE_CHECKING, Iterator +from unittest import mock + +if TYPE_CHECKING: + from pathlib import Path + +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"}] +FAKE_EOL_DATA_NEP = [{"Version": "3.8", "End of Life": "2023-10-01"}] + + +@pytest.fixture +def mock_cache_file(tmp_path: Path) -> Iterator[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): + yield cache_file + + +@pytest.fixture +def mock_cache_file_nep(tmp_path: Path) -> Iterator[Path]: + """Mock the NEP mode cache file and its directory.""" + cache_dir = tmp_path / "python-eol" + cache_file = cache_dir / "eol_data_nep.json" + with mock.patch("python_eol.cache.CACHE_DIR", cache_dir): + 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(nep_mode=False) + assert data == FAKE_EOL_DATA + + +def test_fetch_eol_data_success_nep() -> None: + """Test fetching EOL data successfully for NEP mode.""" + 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 = { + "releases": [{"version": "3.8", "eol": "2023-10-01"}], + } + data = _fetch_eol_data(nep_mode=True) + assert data == FAKE_EOL_DATA_NEP + + +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(nep_mode=False) + 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, nep_mode=False) + 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(nep_mode=False) + assert read_data == FAKE_EOL_DATA + + +def test_read_write_cache_nep(mock_cache_file_nep: Path) -> None: + """Test writing to and reading from the cache for NEP mode.""" + _write_cache(FAKE_EOL_DATA_NEP, nep_mode=True) + assert mock_cache_file_nep.exists() + with mock_cache_file_nep.open() as f: + data = json.load(f) + assert data == FAKE_EOL_DATA_NEP + read_data = _read_cache(nep_mode=True) + assert read_data == FAKE_EOL_DATA_NEP + + +@pytest.mark.usefixtures("mock_cache_file") +def test_read_cache_expired() -> None: + """Test that an expired cache returns None.""" + _write_cache(FAKE_EOL_DATA, nep_mode=False) + with freeze_time(datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY): + assert _read_cache(nep_mode=False) is None + + +@pytest.mark.usefixtures("mock_cache_file") +def test_read_cache_not_found() -> None: + """Test that a non-existent cache returns None.""" + assert _read_cache(nep_mode=False) is None + + +@pytest.mark.usefixtures("mock_cache_file") +def test_get_eol_data_from_cache() -> None: + """Test get_eol_data reads from a valid cache.""" + _write_cache(FAKE_EOL_DATA, nep_mode=False) + with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch: + data = get_eol_data(nep_mode=False) + mock_fetch.assert_not_called() + assert data == FAKE_EOL_DATA + + +@pytest.mark.usefixtures("mock_cache_file_nep") +def test_get_eol_data_from_cache_nep() -> None: + """Test get_eol_data reads from a valid cache for NEP mode.""" + _write_cache(FAKE_EOL_DATA_NEP, nep_mode=True) + with mock.patch("python_eol.cache._fetch_eol_data") as mock_fetch: + data = get_eol_data(nep_mode=True) + mock_fetch.assert_not_called() + assert data == FAKE_EOL_DATA_NEP + + +@pytest.mark.usefixtures("mock_cache_file") +def test_get_eol_data_fetches_when_cache_is_stale() -> None: + """Test get_eol_data fetches new data when cache is stale.""" + _write_cache(FAKE_EOL_DATA, nep_mode=False) + with freeze_time( + datetime.now() + CACHE_EXPIRY + CACHE_EXPIRY, + ), 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(nep_mode=False) + mock_fetch.assert_called_once_with(nep_mode=False) + assert data == [{"Version": "3.10", "End of Life": "2026-10-01"}] + + +@pytest.mark.usefixtures("mock_cache_file") +def test_get_eol_data_fetches_when_no_cache() -> 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(nep_mode=False) + mock_fetch.assert_called_once_with(nep_mode=False) + assert data == FAKE_EOL_DATA diff --git a/tests/test_docker_utils.py b/tests/test_docker_utils.py index 0ddb417..e2acc5c 100644 --- a/tests/test_docker_utils.py +++ b/tests/test_docker_utils.py @@ -1,45 +1,24 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any -from unittest import mock import pytest -import python_eol from python_eol._docker_utils import ( _extract_python_version_from_docker_file, _find_docker_files, ) -if TYPE_CHECKING: - 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( diff --git a/tests/test_main.py b/tests/test_main.py index 712ed84..5ededb8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -20,7 +20,7 @@ ) -@pytest.fixture() +@pytest.fixture def _mock_py37() -> Iterable[None]: with mock.patch("platform.python_version_tuple") as mocked_python_version_tuple: mocked_python_version_tuple.return_value = (3, 7, 0) @@ -30,7 +30,15 @@ def _mock_py37() -> Iterable[None]: mock_py37 = pytest.mark.usefixtures("_mock_py37") -@pytest.fixture() +@pytest.fixture +def mock_get_eol_data() -> Iterable[mock.MagicMock]: + """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 mocked_get_eol_data + + +@pytest.fixture def _mock_py311() -> Iterable[None]: with mock.patch("platform.python_version_tuple") as mocked_python_version_tuple: mocked_python_version_tuple.return_value = (3, 11, 0) @@ -64,9 +72,10 @@ def test_get_argparser2() -> None: @mock_py37 @freeze_time("2021-12-27") -def test_ep_mode() -> None: +def test_ep_mode(mock_get_eol_data: mock.MagicMock) -> None: result = _check_python_eol(nep_mode=True) assert result == 1 + mock_get_eol_data.assert_called_once_with(nep_mode=True) @mock_py37 @@ -74,34 +83,39 @@ def test_ep_mode() -> None: @pytest.mark.parametrize("fail_close_to_eol", [True, False]) def test_check_python_eol( fail_close_to_eol: bool, + mock_get_eol_data: mock.MagicMock, ) -> None: result = _check_python_eol(fail_close_to_eol=fail_close_to_eol) if fail_close_to_eol: assert result == 1 else: assert result == 0 + mock_get_eol_data.assert_called_once_with(nep_mode=False) @mock_py37 @freeze_time("2023-06-28") # python 3.7 eol is 2023-06-27 -def test_version_beyond_eol() -> None: +def test_version_beyond_eol(mock_get_eol_data: mock.MagicMock) -> None: assert _check_python_eol() == 1 + mock_get_eol_data.assert_called_once_with(nep_mode=False) @skip_py37 @skip_py38 @mock_py311 @freeze_time("2023-06-28") # python 3.11 eol is 2027-10-24 -def test_version_far_from_eol() -> None: +def test_version_far_from_eol(mock_get_eol_data: mock.MagicMock) -> None: assert _check_python_eol() == 0 + mock_get_eol_data.assert_called_once_with(nep_mode=False) @skip_py37 @skip_py38 @mock_py311 @freeze_time("2023-06-28") # python 3.11 eol is 2027-10-24 -def test_main() -> None: +def test_main(mock_get_eol_data: mock.MagicMock) -> None: assert main() == 0 + mock_get_eol_data.assert_called_once_with(nep_mode=False) @skip_py37 @@ -110,12 +124,14 @@ def test_main() -> None: @freeze_time("2023-06-28") # python 3.7 eol is 2023-06-27 def test_version_in_dockerfile_errors( tmpdir: Path, + mock_get_eol_data: mock.MagicMock, ) -> None: with mock.patch("python_eol.main._find_docker_files") as mocked_find_docker_files: p = Path(tmpdir / "Dockerfile") p.write_text("FROM python:3.7") mocked_find_docker_files.return_value = [p] assert _check_python_eol(check_docker_files=True) == 1 + mock_get_eol_data.assert_called_once_with(nep_mode=False) @skip_py37 @@ -123,16 +139,17 @@ def test_version_in_dockerfile_errors( @mock_py311 @freeze_time("2023-06-22") # python 3.7 eol is 2023-06-27 @pytest.mark.parametrize( - ("fail_close_to_eol", "expected_return_status", "log_level"), - [(True, 1, logging.ERROR), (False, 0, logging.WARNING)], + ("fail_close_to_eol", "expected"), + [(True, (1, logging.ERROR)), (False, (0, logging.WARNING))], ) def test_version_in_dockerfile_close_to_eol( tmpdir: Path, fail_close_to_eol: bool, - expected_return_status: int, - log_level: int, + expected: tuple[int, int], caplog: pytest.LogCaptureFixture, + mock_get_eol_data: mock.MagicMock, ) -> None: + expected_return_status, log_level = expected with mock.patch("python_eol.main._find_docker_files") as mocked_find_docker_files: p = Path(tmpdir / "Dockerfile") p.write_text("FROM python:3.7") @@ -148,3 +165,4 @@ def test_version_in_dockerfile_close_to_eol( " (2023-06-27)" ) assert caplog.record_tuples == [("python_eol.main", log_level, msg)] + mock_get_eol_data.assert_called_once_with(nep_mode=False) diff --git a/tox.ini b/tox.ini index fe53c52..dce7b56 100644 --- a/tox.ini +++ b/tox.ini @@ -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] @@ -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 @@ -42,6 +45,8 @@ commands = flake8 python_eol/ tests/ skip_install = true deps = mypy + types-appdirs + types-requests types-setuptools types-freezegun pytest