Skip to content
Closed
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/check_scrapers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: Check Scrapers
on:
schedule:
- cron: "0 1 * * *"
- cron: 0 1 * * *
jobs:
test_scripts:
runs-on: ubuntu-latest
Expand Down
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
197 changes: 197 additions & 0 deletions coverage.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?xml version="1.0" ?>
<coverage version="7.10.3" timestamp="1755247428339" lines-valid="163" lines-covered="155" line-rate="0.9509" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.10.3 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
<source>/app/python_eol</source>
</sources>
<packages>
<package name="." line-rate="0.9509" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
</lines>
</class>
<class name="_docker_utils.py" filename="_docker_utils.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
</lines>
</class>
<class name="cache.py" filename="cache.py" complexity="0" line-rate="0.9211" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="27" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="35" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="54" hits="1"/>
<line number="58" hits="1"/>
<line number="60" hits="1"/>
<line number="61" hits="1"/>
<line number="62" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="66" hits="1"/>
<line number="70" hits="1"/>
<line number="71" hits="1"/>
<line number="74" hits="1"/>
<line number="76" hits="1"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/>
<line number="80" hits="1"/>
<line number="84" hits="1"/>
<line number="85" hits="1"/>
<line number="87" hits="1"/>
<line number="88" hits="1"/>
<line number="89" hits="1"/>
<line number="90" hits="1"/>
<line number="91" hits="0"/>
<line number="92" hits="0"/>
<line number="93" hits="0"/>
<line number="96" hits="1"/>
<line number="98" hits="1"/>
<line number="99" hits="1"/>
<line number="100" hits="1"/>
<line number="101" hits="1"/>
<line number="102" hits="1"/>
<line number="103" hits="0"/>
<line number="104" hits="0"/>
<line number="107" hits="1"/>
<line number="109" hits="1"/>
<line number="110" hits="1"/>
<line number="111" hits="1"/>
<line number="112" hits="1"/>
<line number="114" hits="1"/>
<line number="115" hits="1"/>
<line number="116" hits="1"/>
<line number="117" hits="1"/>
<line number="118" hits="1"/>
<line number="120" hits="0"/>
</lines>
</class>
<class name="main.py" filename="main.py" complexity="0" line-rate="0.9714" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="19" hits="1"/>
<line number="21" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="36" hits="1"/>
<line number="41" hits="1"/>
<line number="44" hits="1"/>
<line number="51" hits="1"/>
<line number="52" hits="1"/>
<line number="53" hits="0"/>
<line number="54" hits="0"/>
<line number="55" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="59" hits="1"/>
<line number="60" hits="1"/>
<line number="61" hits="1"/>
<line number="62" hits="1"/>
<line number="63" hits="1"/>
<line number="68" hits="1"/>
<line number="69" hits="1"/>
<line number="70" hits="1"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/>
<line number="74" hits="1"/>
<line number="77" hits="1"/>
<line number="83" hits="1"/>
<line number="84" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="87" hits="1"/>
<line number="88" hits="1"/>
<line number="90" hits="1"/>
<line number="92" hits="1"/>
<line number="94" hits="1"/>
<line number="95" hits="1"/>
<line number="96" hits="1"/>
<line number="97" hits="1"/>
<line number="98" hits="1"/>
<line number="99" hits="1"/>
<line number="108" hits="1"/>
<line number="114" hits="1"/>
<line number="115" hits="1"/>
<line number="118" hits="1"/>
<line number="123" hits="1"/>
<line number="131" hits="1"/>
<line number="136" hits="1"/>
<line number="139" hits="1"/>
<line number="141" hits="1"/>
<line number="142" hits="1"/>
<line number="143" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>
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",
]
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
1 change: 1 addition & 0 deletions python_eol/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
"""Top-level module for python-eol."""

from __future__ import annotations
120 changes: 120 additions & 0 deletions python_eol/cache.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading