From ae1120d8ef7ce9d54115f3afb6901c4db5ed4702 Mon Sep 17 00:00:00 2001
From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Date: Tue, 30 Dec 2025 14:47:33 +0200
Subject: [PATCH 1/3] Generate table of active Python releases
---
downloads/templatetags/download_tags.py | 77 ++++++++++++++++++++++++
templates/downloads/active-releases.html | 20 ++++++
templates/downloads/index.html | 4 +-
3 files changed, 99 insertions(+), 2 deletions(-)
create mode 100644 templates/downloads/active-releases.html
diff --git a/downloads/templatetags/download_tags.py b/downloads/templatetags/download_tags.py
index 0bc50ff8f..409950a36 100644
--- a/downloads/templatetags/download_tags.py
+++ b/downloads/templatetags/download_tags.py
@@ -4,6 +4,9 @@
import requests
from django import template
from django.core.cache import cache
+from django.utils.html import format_html
+
+from downloads.models import Release
register = template.Library()
logger = logging.getLogger(__name__)
@@ -12,6 +15,10 @@
PYTHON_RELEASES_CACHE_KEY = "python_python_releases"
PYTHON_RELEASES_CACHE_TIMEOUT = 3600 # 1 hour
+RELEASE_CYCLE_URL = "https://peps.python.org/api/release-cycle.json"
+RELEASE_CYCLE_CACHE_KEY = "python_release_cycle"
+RELEASE_CYCLE_CACHE_TIMEOUT = 3600 # 1 hour
+
def get_python_releases_data() -> dict | None:
"""Fetch and cache the Python release cycle data from PEPs API."""
@@ -128,3 +135,73 @@ def sort_windows(files):
other_files.append(file)
return other_files + windows_files
+
+
+def get_release_cycle_data() -> dict | None:
+ """Fetch and cache the release cycle data from PEPs API."""
+ data = cache.get(RELEASE_CYCLE_CACHE_KEY)
+ if data is not None:
+ return data
+
+ try:
+ response = requests.get(RELEASE_CYCLE_URL, timeout=5)
+ response.raise_for_status()
+ data = response.json()
+ cache.set(RELEASE_CYCLE_CACHE_KEY, data, RELEASE_CYCLE_CACHE_TIMEOUT)
+ return data
+ except (requests.RequestException, ValueError) as e:
+ logger.warning("Failed to fetch release cycle data: %s", e)
+ return None
+
+
+@register.inclusion_tag("downloads/active-releases.html")
+def render_active_releases():
+ """Render the active Python releases table from PEPs API data."""
+ releases = []
+ release_cycle = get_release_cycle_data()
+
+ if release_cycle:
+ # Sort releases in descending order (newest first)
+ sorted_releases = sorted(
+ release_cycle.keys(),
+ key=lambda v: [int(x) for x in v.split(".")],
+ reverse=True,
+ )
+
+ found_eol = False
+ for release in sorted_releases:
+ info = release_cycle[release]
+ status = info.get("status", "")
+ first_release = info.get("first_release", "")
+
+ if status == "feature" and first_release:
+ first_release = f"{first_release} (planned)"
+
+ if status == "feature":
+ status = "pre-release"
+
+ if status == "end-of-life":
+ # Include only the most recent EOL release
+ if found_eol:
+ continue
+ found_eol = True
+
+ # Get last release for EOL versions
+ minor = int(release.split(".")[1])
+ last_release = Release.objects.latest_python3(minor)
+ if last_release:
+ status = format_html(
+ 'end-of-life, last release was {}',
+ last_release.get_absolute_url(),
+ last_release.get_version(),
+ )
+
+ releases.append({
+ "version": release,
+ "status": status,
+ "first_release": first_release,
+ "end_of_life": info.get("end_of_life", ""),
+ "pep": info.get("pep"),
+ })
+
+ return {"releases": releases}
diff --git a/templates/downloads/active-releases.html b/templates/downloads/active-releases.html
new file mode 100644
index 000000000..c5743dc2b
--- /dev/null
+++ b/templates/downloads/active-releases.html
@@ -0,0 +1,20 @@
+
From 3204d530ae147cc215ea61bf2175a46e9eeb3445 Mon Sep 17 00:00:00 2001
From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Date: Tue, 30 Dec 2025 15:39:11 +0200
Subject: [PATCH 2/3] Refactor older code to also use (smaller)
release-cycle.json
---
downloads/templatetags/download_tags.py | 29 ++---------------
downloads/tests/test_template_tags.py | 42 ++++++++++++-------------
2 files changed, 23 insertions(+), 48 deletions(-)
diff --git a/downloads/templatetags/download_tags.py b/downloads/templatetags/download_tags.py
index 409950a36..de16b2cfc 100644
--- a/downloads/templatetags/download_tags.py
+++ b/downloads/templatetags/download_tags.py
@@ -11,32 +11,11 @@
register = template.Library()
logger = logging.getLogger(__name__)
-PYTHON_RELEASES_URL = "https://peps.python.org/api/python-releases.json"
-PYTHON_RELEASES_CACHE_KEY = "python_python_releases"
-PYTHON_RELEASES_CACHE_TIMEOUT = 3600 # 1 hour
-
RELEASE_CYCLE_URL = "https://peps.python.org/api/release-cycle.json"
RELEASE_CYCLE_CACHE_KEY = "python_release_cycle"
RELEASE_CYCLE_CACHE_TIMEOUT = 3600 # 1 hour
-def get_python_releases_data() -> dict | None:
- """Fetch and cache the Python release cycle data from PEPs API."""
- data = cache.get(PYTHON_RELEASES_CACHE_KEY)
- if data is not None:
- return data
-
- try:
- response = requests.get(PYTHON_RELEASES_URL, timeout=5)
- response.raise_for_status()
- data = response.json()
- cache.set(PYTHON_RELEASES_CACHE_KEY, data, PYTHON_RELEASES_CACHE_TIMEOUT)
- return data
- except (requests.RequestException, ValueError) as e:
- logger.warning("Failed to fetch release cycle data: %s", e)
- return None
-
-
@register.simple_tag
def get_eol_info(release) -> dict:
"""
@@ -59,14 +38,12 @@ def get_eol_info(release) -> dict:
major = int(match.group(1))
minor_version = f"{match.group(1)}.{match.group(2)}"
- python_releases = get_python_releases_data()
- if python_releases is None:
+ release_cycle = get_release_cycle_data()
+ if release_cycle is None:
# Can't determine EOL status, don't show warning
return result
- metadata = python_releases.get("metadata", {})
- version_info = metadata.get(minor_version)
-
+ version_info = release_cycle.get(minor_version)
if version_info is None:
# Python 2 releases not in the list are EOL
if major <= 2:
diff --git a/downloads/tests/test_template_tags.py b/downloads/tests/test_template_tags.py
index a4a1b4104..a29103a49 100644
--- a/downloads/tests/test_template_tags.py
+++ b/downloads/tests/test_template_tags.py
@@ -5,15 +5,13 @@
from django.test import TestCase, override_settings
from django.urls import reverse
-from ..templatetags.download_tags import get_eol_info, get_python_releases_data
+from ..templatetags.download_tags import get_eol_info, get_release_cycle_data
from .base import BaseDownloadTests
-MOCK_PYTHON_RELEASE = {
- "metadata": {
- "2.7": {"status": "end-of-life", "end_of_life": "2020-01-01"},
- "3.8": {"status": "end-of-life", "end_of_life": "2024-10-07"},
- "3.10": {"status": "security", "end_of_life": "2026-10-04"},
- }
+MOCK_RELEASE_CYCLE = {
+ "2.7": {"status": "end-of-life", "end_of_life": "2020-01-01"},
+ "3.8": {"status": "end-of-life", "end_of_life": "2024-10-07"},
+ "3.10": {"status": "security", "end_of_life": "2026-10-04"},
}
@@ -31,11 +29,11 @@ def setUp(self):
super().setUp()
cache.clear()
- @mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
def test_eol_status(self, mock_get_data):
"""Test get_eol_info returns correct EOL status."""
# Arrange
- mock_get_data.return_value = MOCK_PYTHON_RELEASE
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
tests = [
(self.release_275, True, "2020-01-01"), # EOL
(self.python_3_8_20, True, "2024-10-07"), # EOL
@@ -51,7 +49,7 @@ def test_eol_status(self, mock_get_data):
self.assertEqual(result["is_eol"], expected_is_eol)
self.assertEqual(result["eol_date"], expected_eol_date)
- @mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
def test_eol_status_api_failure(self, mock_get_data):
"""Test that API failure results in not showing EOL warning."""
# Arrange
@@ -75,15 +73,15 @@ def test_successful_fetch(self, mock_get):
"""Test successful API fetch."""
# Arrange
mock_response = mock.Mock()
- mock_response.json.return_value = MOCK_PYTHON_RELEASE
+ mock_response.json.return_value = MOCK_RELEASE_CYCLE
mock_response.raise_for_status = mock.Mock()
mock_get.return_value = mock_response
# Act
- result = get_python_releases_data()
+ result = get_release_cycle_data()
# Assert
- self.assertEqual(result, MOCK_PYTHON_RELEASE)
+ self.assertEqual(result, MOCK_RELEASE_CYCLE)
mock_get.assert_called_once()
@mock.patch("downloads.templatetags.download_tags.requests.get")
@@ -91,13 +89,13 @@ def test_caches_result(self, mock_get):
"""Test that the result is cached."""
# Arrange
mock_response = mock.Mock()
- mock_response.json.return_value = MOCK_PYTHON_RELEASE
+ mock_response.json.return_value = MOCK_RELEASE_CYCLE
mock_response.raise_for_status = mock.Mock()
mock_get.return_value = mock_response
# Act
- result1 = get_python_releases_data()
- result2 = get_python_releases_data()
+ result1 = get_release_cycle_data()
+ result2 = get_release_cycle_data()
# Assert
self.assertEqual(result1, result2)
@@ -110,7 +108,7 @@ def test_request_exception_returns_none(self, mock_get):
mock_get.side_effect = requests.RequestException("Connection error")
# Act
- result = get_python_releases_data()
+ result = get_release_cycle_data()
# Assert
self.assertIsNone(result)
@@ -125,7 +123,7 @@ def test_json_decode_error_returns_none(self, mock_get):
mock_get.return_value = mock_response
# Act
- result = get_python_releases_data()
+ result = get_release_cycle_data()
# Assert
self.assertIsNone(result)
@@ -138,14 +136,14 @@ def setUp(self):
super().setUp()
cache.clear()
- @mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
def test_eol_banner_visibility(self, mock_get_data):
"""Test EOL banner is shown or hidden correctly."""
# Arrange
tests = [
- ("release_275", MOCK_PYTHON_RELEASE, True),
- ("python_3_8_20", MOCK_PYTHON_RELEASE, True),
- ("python_3_10_18", MOCK_PYTHON_RELEASE, False),
+ ("release_275", MOCK_RELEASE_CYCLE, True),
+ ("python_3_8_20", MOCK_RELEASE_CYCLE, True),
+ ("python_3_10_18", MOCK_RELEASE_CYCLE, False),
("python_3_8_20", None, False),
]
From 985071731244829cb6d576fcc806b09b13416502 Mon Sep 17 00:00:00 2001
From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Date: Tue, 30 Dec 2025 23:47:43 +0200
Subject: [PATCH 3/3] Add tests
---
downloads/tests/test_template_tags.py | 89 +++++++++++++++++++++++++--
1 file changed, 85 insertions(+), 4 deletions(-)
diff --git a/downloads/tests/test_template_tags.py b/downloads/tests/test_template_tags.py
index a29103a49..6f8a02d40 100644
--- a/downloads/tests/test_template_tags.py
+++ b/downloads/tests/test_template_tags.py
@@ -5,13 +5,20 @@
from django.test import TestCase, override_settings
from django.urls import reverse
-from ..templatetags.download_tags import get_eol_info, get_release_cycle_data
+from ..templatetags.download_tags import (
+ get_eol_info,
+ get_release_cycle_data,
+ render_active_releases,
+)
from .base import BaseDownloadTests
MOCK_RELEASE_CYCLE = {
- "2.7": {"status": "end-of-life", "end_of_life": "2020-01-01"},
- "3.8": {"status": "end-of-life", "end_of_life": "2024-10-07"},
- "3.10": {"status": "security", "end_of_life": "2026-10-04"},
+ "2.7": {"status": "end-of-life", "end_of_life": "2020-01-01", "pep": 373},
+ "3.8": {"status": "end-of-life", "end_of_life": "2024-10-07", "pep": 569},
+ "3.9": {"status": "end-of-life", "end_of_life": "2025-10-31", "pep": 596},
+ "3.10": {"status": "security", "end_of_life": "2026-10-04", "pep": 619},
+ "3.14": {"status": "bugfix", "first_release": "2025-10-07", "end_of_life": "2030-10", "pep": 745},
+ "3.15": {"status": "feature", "first_release": "2026-10-01", "end_of_life": "2031-10", "pep": 790},
}
@@ -166,3 +173,77 @@ def test_eol_banner_visibility(self, mock_get_data):
self.assertContains(response, "no longer supported")
else:
self.assertNotContains(response, "level-error")
+
+
+@override_settings(CACHES=TEST_CACHES)
+class RenderActiveReleasesTests(BaseDownloadTests):
+ def setUp(self):
+ super().setUp()
+ cache.clear()
+
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
+ def test_versions_sorted_descending(self, mock_get_data):
+ """Test that versions are sorted in descending order."""
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
+
+ result = render_active_releases()
+
+ versions = [r["version"] for r in result["releases"]]
+ # 3.15, 3.14, 3.10, 3.9 (first EOL); 3.8 and 2.7 skipped (older EOL)
+ self.assertEqual(versions, ["3.15", "3.14", "3.10", "3.9"])
+
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
+ def test_feature_status_becomes_prerelease(self, mock_get_data):
+ """Test that 'feature' status is converted to 'pre-release'."""
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
+
+ result = render_active_releases()
+
+ prerelease = result["releases"][0]
+ self.assertEqual(prerelease["version"], "3.15")
+ self.assertEqual(prerelease["status"], "pre-release")
+
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
+ def test_feature_first_release_shows_planned(self, mock_get_data):
+ """Test that feature releases show (planned) in first_release."""
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
+
+ result = render_active_releases()
+
+ prerelease = result["releases"][0]
+ self.assertEqual(prerelease["first_release"], "2026-10-01 (planned)")
+
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
+ def test_only_one_eol_release_included(self, mock_get_data):
+ """Test that only the most recent EOL release is included."""
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
+
+ result = render_active_releases()
+
+ versions = [r["version"] for r in result["releases"]]
+ # 3.9 is included (most recent EOL), 3.8 and 2.7 are not
+ self.assertIn("3.9", versions)
+ self.assertNotIn("3.8", versions)
+ self.assertNotIn("2.7", versions)
+
+ @mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
+ def test_eol_status_includes_last_release_link(self, mock_get_data):
+ """Test that EOL status includes last release link."""
+ mock_get_data.return_value = MOCK_RELEASE_CYCLE
+
+ result = render_active_releases()
+
+ eol_release = next(r for r in result["releases"] if r["version"] == "3.9")
+ status = str(eol_release["status"])
+ self.assertIn("end-of-life", status)
+ self.assertIn("last release was", status)
+ self.assertIn("