-
Notifications
You must be signed in to change notification settings - Fork 0
chore: Add Python 3.12 and 3.13 to test matrix #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
|
|
||
|
|
||
This file was deleted.
| 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 |
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the changes to this file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is setuptools really necessary?