diff --git a/downloads/templatetags/download_tags.py b/downloads/templatetags/download_tags.py index 0bc50ff8f..de16b2cfc 100644 --- a/downloads/templatetags/download_tags.py +++ b/downloads/templatetags/download_tags.py @@ -4,30 +4,16 @@ 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__) -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 - - -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 +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 @register.simple_tag @@ -52,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: @@ -128,3 +112,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/downloads/tests/test_template_tags.py b/downloads/tests/test_template_tags.py index a4a1b4104..6f8a02d40 100644 --- a/downloads/tests/test_template_tags.py +++ b/downloads/tests/test_template_tags.py @@ -5,15 +5,20 @@ 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, + render_active_releases, +) 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", "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}, } @@ -31,11 +36,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 +56,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 +80,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 +96,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 +115,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 +130,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 +143,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), ] @@ -168,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(" + Python version + Maintenance status +   + First released + End of support + Release schedule + + \ No newline at end of file diff --git a/templates/downloads/index.html b/templates/downloads/index.html index a79d7c542..d87b32823 100644 --- a/templates/downloads/index.html +++ b/templates/downloads/index.html @@ -2,6 +2,7 @@ {% load boxes %} {% load banners %} {% load sponsors %} +{% load download_tags %} {% block page_title %}Download Python | {{ SITE_INFO.site_name }}{% endblock %} {% block og_title %}Download Python{% endblock %} @@ -55,10 +56,9 @@

Active Python releases

Python release cycle

- {% box 'downloads-active-releases' %} + {% render_active_releases %} -

Looking for a specific release?