Skip to content
Merged
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
104 changes: 79 additions & 25 deletions downloads/templatetags/download_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 <a href="{}">{}</a>',
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}
123 changes: 101 additions & 22 deletions downloads/tests/test_template_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}


Expand All @@ -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
Expand All @@ -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
Expand All @@ -75,29 +80,29 @@ 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")
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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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),
]

Expand All @@ -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("<a href=", status)

@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
def test_api_failure_returns_empty_releases(self, mock_get_data):
"""Test that API failure returns empty releases list."""
mock_get_data.return_value = None

result = render_active_releases()

self.assertEqual(result["releases"], [])
20 changes: 20 additions & 0 deletions templates/downloads/active-releases.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="list-row-headings">
<span class="release-version">Python version</span>
<span class="release-status">Maintenance status</span>
<span class="release-dl">&nbsp;</span>
<span class="release-start">First released</span>
<span class="release-end">End of support</span>
<span class="release-pep">Release schedule</span>
</div>
<ol class="list-row-container menu">
{% for release in releases %}
<li>
<span class="release-version">{{ release.version }}</span>
<span class="release-status">{{ release.status }}</span>
<span class="release-dl"><a href="/downloads/latest/python{{ release.version }}/"><span aria-hidden="true" class="icon-download"></span>Download</a></span>
<span class="release-start">{{ release.first_release }}</span>
<span class="release-end">{{ release.end_of_life }}</span>
<span class="release-pep">{% if release.pep %}<a href="https://peps.python.org/pep-{{ release.pep|stringformat:"04d" }}/">PEP {{ release.pep }}</a>{% endif %}</span>
</li>
{% endfor %}
</ol>
4 changes: 2 additions & 2 deletions templates/downloads/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -55,10 +56,9 @@ <h2 class="widget-title">Active Python releases</h2>

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

{% box 'downloads-active-releases' %}
{% render_active_releases %}
</div>


<div class="row download-list-widget">

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