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