From da0a21232e61a1426ec84c075793fa237e543d8c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:54:59 +0000 Subject: [PATCH] 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. --- .github/workflows/tox.yml | 2 +- pyproject.toml | 12 +++++ python_eol/cache.py | 82 ++++++++++++++++++++++++++++++ python_eol/main.py | 16 ++++-- scripts/eol_scraper.py | 38 -------------- tests/test_cache.py | 101 +++++++++++++++++++++++++++++++++++++ tests/test_docker_utils.py | 29 +++-------- tests/test_main.py | 8 +++ tox.ini | 7 ++- 9 files changed, 229 insertions(+), 66 deletions(-) create mode 100644 python_eol/cache.py delete mode 100644 scripts/eol_scraper.py create mode 100644 tests/test_cache.py 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/pyproject.toml b/pyproject.toml index 66aac54..ca4debf 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/cache.py b/python_eol/cache.py new file mode 100644 index 0000000..24a907c --- /dev/null +++ b/python_eol/cache.py @@ -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) + + +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: + """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 diff --git a/python_eol/main.py b/python_eol/main.py index 2116897..02d825b 100644 --- a/python_eol/main.py +++ b/python_eol/main.py @@ -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 @@ -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 @@ -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() + 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/scripts/eol_scraper.py b/scripts/eol_scraper.py deleted file mode 100644 index ef041f7..0000000 --- a/scripts/eol_scraper.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from datetime import datetime - -import requests - -# URL of the API -api_url = "https://endoflife.date/api/python.json" - -# Send a GET request to the API -response = requests.get(api_url) - -# Parse the JSON response -data = json.loads(response.content) - -# Initialize an empty list to store the processed data -processed_data = [] - -# Iterate over the entries in the API response -for entry in data: - raw_version = entry["latest"] - # Strip out the patch part of the version - major_minor_parts = raw_version.split(".")[:2] - parsed_version = ".".join(major_minor_parts) - - # Convert end_of_life to datetime object - end_of_life_date = datetime.strptime(entry["eol"], "%Y-%m-%d").date() - - # Create a new dictionary for the entry data - entry_data = {"Version": parsed_version, "End of Life": end_of_life_date} - - # Append the entry data to the list - processed_data.append(entry_data) - -# Convert the processed data list to JSON format -json_data = json.dumps(processed_data, indent=4, default=str) - -# Print the JSON data -print(json_data) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..61b06b8 --- /dev/null +++ b/tests/test_cache.py @@ -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 diff --git a/tests/test_docker_utils.py b/tests/test_docker_utils.py index 0ddb417..e3c0053 100644 --- a/tests/test_docker_utils.py +++ b/tests/test_docker_utils.py @@ -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( diff --git a/tests/test_main.py b/tests/test_main.py index 712ed84..5d77a66 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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: diff --git a/tox.ini b/tox.ini index fe53c52..8646f42 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