Skip to content

Commit 72aef05

Browse files
hugovkJacobCoffee
andauthored
Generate table of active Python releases (#2862)
Co-authored-by: Jacob Coffee <[email protected]>
1 parent e740aa6 commit 72aef05

File tree

4 files changed

+202
-49
lines changed

4 files changed

+202
-49
lines changed

downloads/templatetags/download_tags.py

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,16 @@
44
import requests
55
from django import template
66
from django.core.cache import cache
7+
from django.utils.html import format_html
8+
9+
from downloads.models import Release
710

811
register = template.Library()
912
logger = logging.getLogger(__name__)
1013

11-
PYTHON_RELEASES_URL = "https://peps.python.org/api/python-releases.json"
12-
PYTHON_RELEASES_CACHE_KEY = "python_python_releases"
13-
PYTHON_RELEASES_CACHE_TIMEOUT = 3600 # 1 hour
14-
15-
16-
def get_python_releases_data() -> dict | None:
17-
"""Fetch and cache the Python release cycle data from PEPs API."""
18-
data = cache.get(PYTHON_RELEASES_CACHE_KEY)
19-
if data is not None:
20-
return data
21-
22-
try:
23-
response = requests.get(PYTHON_RELEASES_URL, timeout=5)
24-
response.raise_for_status()
25-
data = response.json()
26-
cache.set(PYTHON_RELEASES_CACHE_KEY, data, PYTHON_RELEASES_CACHE_TIMEOUT)
27-
return data
28-
except (requests.RequestException, ValueError) as e:
29-
logger.warning("Failed to fetch release cycle data: %s", e)
30-
return None
14+
RELEASE_CYCLE_URL = "https://peps.python.org/api/release-cycle.json"
15+
RELEASE_CYCLE_CACHE_KEY = "python_release_cycle"
16+
RELEASE_CYCLE_CACHE_TIMEOUT = 3600 # 1 hour
3117

3218

3319
@register.simple_tag
@@ -52,14 +38,12 @@ def get_eol_info(release) -> dict:
5238
major = int(match.group(1))
5339
minor_version = f"{match.group(1)}.{match.group(2)}"
5440

55-
python_releases = get_python_releases_data()
56-
if python_releases is None:
41+
release_cycle = get_release_cycle_data()
42+
if release_cycle is None:
5743
# Can't determine EOL status, don't show warning
5844
return result
5945

60-
metadata = python_releases.get("metadata", {})
61-
version_info = metadata.get(minor_version)
62-
46+
version_info = release_cycle.get(minor_version)
6347
if version_info is None:
6448
# Python 2 releases not in the list are EOL
6549
if major <= 2:
@@ -128,3 +112,73 @@ def sort_windows(files):
128112
other_files.append(file)
129113

130114
return other_files + windows_files
115+
116+
117+
def get_release_cycle_data() -> dict | None:
118+
"""Fetch and cache the release cycle data from PEPs API."""
119+
data = cache.get(RELEASE_CYCLE_CACHE_KEY)
120+
if data is not None:
121+
return data
122+
123+
try:
124+
response = requests.get(RELEASE_CYCLE_URL, timeout=5)
125+
response.raise_for_status()
126+
data = response.json()
127+
cache.set(RELEASE_CYCLE_CACHE_KEY, data, RELEASE_CYCLE_CACHE_TIMEOUT)
128+
return data
129+
except (requests.RequestException, ValueError) as e:
130+
logger.warning("Failed to fetch release cycle data: %s", e)
131+
return None
132+
133+
134+
@register.inclusion_tag("downloads/active-releases.html")
135+
def render_active_releases():
136+
"""Render the active Python releases table from PEPs API data."""
137+
releases = []
138+
release_cycle = get_release_cycle_data()
139+
140+
if release_cycle:
141+
# Sort releases in descending order (newest first)
142+
sorted_releases = sorted(
143+
release_cycle.keys(),
144+
key=lambda v: [int(x) for x in v.split(".")],
145+
reverse=True,
146+
)
147+
148+
found_eol = False
149+
for release in sorted_releases:
150+
info = release_cycle[release]
151+
status = info.get("status", "")
152+
first_release = info.get("first_release", "")
153+
154+
if status == "feature" and first_release:
155+
first_release = f"{first_release} (planned)"
156+
157+
if status == "feature":
158+
status = "pre-release"
159+
160+
if status == "end-of-life":
161+
# Include only the most recent EOL release
162+
if found_eol:
163+
continue
164+
found_eol = True
165+
166+
# Get last release for EOL versions
167+
minor = int(release.split(".")[1])
168+
last_release = Release.objects.latest_python3(minor)
169+
if last_release:
170+
status = format_html(
171+
'end-of-life, last release was <a href="{}">{}</a>',
172+
last_release.get_absolute_url(),
173+
last_release.get_version(),
174+
)
175+
176+
releases.append({
177+
"version": release,
178+
"status": status,
179+
"first_release": first_release,
180+
"end_of_life": info.get("end_of_life", ""),
181+
"pep": info.get("pep"),
182+
})
183+
184+
return {"releases": releases}

downloads/tests/test_template_tags.py

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
from django.test import TestCase, override_settings
66
from django.urls import reverse
77

8-
from ..templatetags.download_tags import get_eol_info, get_python_releases_data
8+
from ..templatetags.download_tags import (
9+
get_eol_info,
10+
get_release_cycle_data,
11+
render_active_releases,
12+
)
913
from .base import BaseDownloadTests
1014

11-
MOCK_PYTHON_RELEASE = {
12-
"metadata": {
13-
"2.7": {"status": "end-of-life", "end_of_life": "2020-01-01"},
14-
"3.8": {"status": "end-of-life", "end_of_life": "2024-10-07"},
15-
"3.10": {"status": "security", "end_of_life": "2026-10-04"},
16-
}
15+
MOCK_RELEASE_CYCLE = {
16+
"2.7": {"status": "end-of-life", "end_of_life": "2020-01-01", "pep": 373},
17+
"3.8": {"status": "end-of-life", "end_of_life": "2024-10-07", "pep": 569},
18+
"3.9": {"status": "end-of-life", "end_of_life": "2025-10-31", "pep": 596},
19+
"3.10": {"status": "security", "end_of_life": "2026-10-04", "pep": 619},
20+
"3.14": {"status": "bugfix", "first_release": "2025-10-07", "end_of_life": "2030-10", "pep": 745},
21+
"3.15": {"status": "feature", "first_release": "2026-10-01", "end_of_life": "2031-10", "pep": 790},
1722
}
1823

1924

@@ -31,11 +36,11 @@ def setUp(self):
3136
super().setUp()
3237
cache.clear()
3338

34-
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
39+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
3540
def test_eol_status(self, mock_get_data):
3641
"""Test get_eol_info returns correct EOL status."""
3742
# Arrange
38-
mock_get_data.return_value = MOCK_PYTHON_RELEASE
43+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
3944
tests = [
4045
(self.release_275, True, "2020-01-01"), # EOL
4146
(self.python_3_8_20, True, "2024-10-07"), # EOL
@@ -51,7 +56,7 @@ def test_eol_status(self, mock_get_data):
5156
self.assertEqual(result["is_eol"], expected_is_eol)
5257
self.assertEqual(result["eol_date"], expected_eol_date)
5358

54-
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
59+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
5560
def test_eol_status_api_failure(self, mock_get_data):
5661
"""Test that API failure results in not showing EOL warning."""
5762
# Arrange
@@ -75,29 +80,29 @@ def test_successful_fetch(self, mock_get):
7580
"""Test successful API fetch."""
7681
# Arrange
7782
mock_response = mock.Mock()
78-
mock_response.json.return_value = MOCK_PYTHON_RELEASE
83+
mock_response.json.return_value = MOCK_RELEASE_CYCLE
7984
mock_response.raise_for_status = mock.Mock()
8085
mock_get.return_value = mock_response
8186

8287
# Act
83-
result = get_python_releases_data()
88+
result = get_release_cycle_data()
8489

8590
# Assert
86-
self.assertEqual(result, MOCK_PYTHON_RELEASE)
91+
self.assertEqual(result, MOCK_RELEASE_CYCLE)
8792
mock_get.assert_called_once()
8893

8994
@mock.patch("downloads.templatetags.download_tags.requests.get")
9095
def test_caches_result(self, mock_get):
9196
"""Test that the result is cached."""
9297
# Arrange
9398
mock_response = mock.Mock()
94-
mock_response.json.return_value = MOCK_PYTHON_RELEASE
99+
mock_response.json.return_value = MOCK_RELEASE_CYCLE
95100
mock_response.raise_for_status = mock.Mock()
96101
mock_get.return_value = mock_response
97102

98103
# Act
99-
result1 = get_python_releases_data()
100-
result2 = get_python_releases_data()
104+
result1 = get_release_cycle_data()
105+
result2 = get_release_cycle_data()
101106

102107
# Assert
103108
self.assertEqual(result1, result2)
@@ -110,7 +115,7 @@ def test_request_exception_returns_none(self, mock_get):
110115
mock_get.side_effect = requests.RequestException("Connection error")
111116

112117
# Act
113-
result = get_python_releases_data()
118+
result = get_release_cycle_data()
114119

115120
# Assert
116121
self.assertIsNone(result)
@@ -125,7 +130,7 @@ def test_json_decode_error_returns_none(self, mock_get):
125130
mock_get.return_value = mock_response
126131

127132
# Act
128-
result = get_python_releases_data()
133+
result = get_release_cycle_data()
129134

130135
# Assert
131136
self.assertIsNone(result)
@@ -138,14 +143,14 @@ def setUp(self):
138143
super().setUp()
139144
cache.clear()
140145

141-
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
146+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
142147
def test_eol_banner_visibility(self, mock_get_data):
143148
"""Test EOL banner is shown or hidden correctly."""
144149
# Arrange
145150
tests = [
146-
("release_275", MOCK_PYTHON_RELEASE, True),
147-
("python_3_8_20", MOCK_PYTHON_RELEASE, True),
148-
("python_3_10_18", MOCK_PYTHON_RELEASE, False),
151+
("release_275", MOCK_RELEASE_CYCLE, True),
152+
("python_3_8_20", MOCK_RELEASE_CYCLE, True),
153+
("python_3_10_18", MOCK_RELEASE_CYCLE, False),
149154
("python_3_8_20", None, False),
150155
]
151156

@@ -168,3 +173,77 @@ def test_eol_banner_visibility(self, mock_get_data):
168173
self.assertContains(response, "no longer supported")
169174
else:
170175
self.assertNotContains(response, "level-error")
176+
177+
178+
@override_settings(CACHES=TEST_CACHES)
179+
class RenderActiveReleasesTests(BaseDownloadTests):
180+
def setUp(self):
181+
super().setUp()
182+
cache.clear()
183+
184+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
185+
def test_versions_sorted_descending(self, mock_get_data):
186+
"""Test that versions are sorted in descending order."""
187+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
188+
189+
result = render_active_releases()
190+
191+
versions = [r["version"] for r in result["releases"]]
192+
# 3.15, 3.14, 3.10, 3.9 (first EOL); 3.8 and 2.7 skipped (older EOL)
193+
self.assertEqual(versions, ["3.15", "3.14", "3.10", "3.9"])
194+
195+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
196+
def test_feature_status_becomes_prerelease(self, mock_get_data):
197+
"""Test that 'feature' status is converted to 'pre-release'."""
198+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
199+
200+
result = render_active_releases()
201+
202+
prerelease = result["releases"][0]
203+
self.assertEqual(prerelease["version"], "3.15")
204+
self.assertEqual(prerelease["status"], "pre-release")
205+
206+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
207+
def test_feature_first_release_shows_planned(self, mock_get_data):
208+
"""Test that feature releases show (planned) in first_release."""
209+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
210+
211+
result = render_active_releases()
212+
213+
prerelease = result["releases"][0]
214+
self.assertEqual(prerelease["first_release"], "2026-10-01 (planned)")
215+
216+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
217+
def test_only_one_eol_release_included(self, mock_get_data):
218+
"""Test that only the most recent EOL release is included."""
219+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
220+
221+
result = render_active_releases()
222+
223+
versions = [r["version"] for r in result["releases"]]
224+
# 3.9 is included (most recent EOL), 3.8 and 2.7 are not
225+
self.assertIn("3.9", versions)
226+
self.assertNotIn("3.8", versions)
227+
self.assertNotIn("2.7", versions)
228+
229+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
230+
def test_eol_status_includes_last_release_link(self, mock_get_data):
231+
"""Test that EOL status includes last release link."""
232+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
233+
234+
result = render_active_releases()
235+
236+
eol_release = next(r for r in result["releases"] if r["version"] == "3.9")
237+
status = str(eol_release["status"])
238+
self.assertIn("end-of-life", status)
239+
self.assertIn("last release was", status)
240+
self.assertIn("<a href=", status)
241+
242+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
243+
def test_api_failure_returns_empty_releases(self, mock_get_data):
244+
"""Test that API failure returns empty releases list."""
245+
mock_get_data.return_value = None
246+
247+
result = render_active_releases()
248+
249+
self.assertEqual(result["releases"], [])
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<div class="list-row-headings">
2+
<span class="release-version">Python version</span>
3+
<span class="release-status">Maintenance status</span>
4+
<span class="release-dl">&nbsp;</span>
5+
<span class="release-start">First released</span>
6+
<span class="release-end">End of support</span>
7+
<span class="release-pep">Release schedule</span>
8+
</div>
9+
<ol class="list-row-container menu">
10+
{% for release in releases %}
11+
<li>
12+
<span class="release-version">{{ release.version }}</span>
13+
<span class="release-status">{{ release.status }}</span>
14+
<span class="release-dl"><a href="/downloads/latest/python{{ release.version }}/"><span aria-hidden="true" class="icon-download"></span>Download</a></span>
15+
<span class="release-start">{{ release.first_release }}</span>
16+
<span class="release-end">{{ release.end_of_life }}</span>
17+
<span class="release-pep">{% if release.pep %}<a href="https://peps.python.org/pep-{{ release.pep|stringformat:"04d" }}/">PEP {{ release.pep }}</a>{% endif %}</span>
18+
</li>
19+
{% endfor %}
20+
</ol>

templates/downloads/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{% load boxes %}
33
{% load banners %}
44
{% load sponsors %}
5+
{% load download_tags %}
56

67
{% block page_title %}Download Python | {{ SITE_INFO.site_name }}{% endblock %}
78
{% block og_title %}Download Python{% endblock %}
@@ -55,10 +56,9 @@ <h2 class="widget-title">Active Python releases</h2>
5556

5657
<p class="release-cycle-chart"><img src="https://devguide.python.org/_static/release-cycle.svg" alt="Python release cycle"></p>
5758

58-
{% box 'downloads-active-releases' %}
59+
{% render_active_releases %}
5960
</div>
6061

61-
6262
<div class="row download-list-widget">
6363

6464
<h2 class="widget-title">Looking for a specific release?</h2>

0 commit comments

Comments
 (0)