Skip to content

Commit ac1fb59

Browse files
committed
Add EOL banner to EOL downloads
1 parent e94fa01 commit ac1fb59

File tree

5 files changed

+265
-2
lines changed

5 files changed

+265
-2
lines changed

downloads/templatetags/download_tags.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,76 @@
1+
import logging
2+
import re
3+
4+
import requests
15
from django import template
6+
from django.core.cache import cache
27

38
register = template.Library()
9+
logger = logging.getLogger(__name__)
10+
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
31+
32+
33+
@register.simple_tag
34+
def get_eol_info(release) -> dict:
35+
"""
36+
Check if a release's minor version is end-of-life.
37+
38+
Returns a dict with 'is_eol' boolean and 'eol_date' if available.
39+
Python 2 releases not found in the release cycle data, assumes EOL.
40+
"""
41+
result = {"is_eol": False, "eol_date": None}
42+
43+
version = release.get_version()
44+
if not version:
45+
return result
46+
47+
# Extract minor version (e.g. "3.9" from "3.9.14")
48+
match = re.match(r"^(\d+)\.(\d+)", version)
49+
if not match:
50+
return result
51+
52+
major = int(match.group(1))
53+
minor_version = f"{match.group(1)}.{match.group(2)}"
54+
55+
python_releases = get_python_releases_data()
56+
if python_releases is None:
57+
# Can't determine EOL status, don't show warning
58+
return result
59+
60+
metadata = python_releases.get("metadata", {})
61+
version_info = metadata.get(minor_version)
62+
63+
if version_info is None:
64+
# Python 2 releases not in the list are EOL
65+
if major <= 2:
66+
result["is_eol"] = True
67+
return result
68+
69+
if version_info.get("status") == "end-of-life":
70+
result["is_eol"] = True
71+
result["eol_date"] = version_info.get("end_of_life")
72+
73+
return result
474

575

676
@register.filter
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import unittest.mock as mock
2+
3+
import requests
4+
from django.core.cache import cache
5+
from django.test import TestCase, override_settings
6+
from django.urls import reverse
7+
8+
from ..templatetags.download_tags import get_eol_info, get_python_releases_data
9+
from .base import BaseDownloadTests
10+
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+
}
17+
}
18+
19+
20+
TEST_CACHES = {
21+
"default": {
22+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
23+
"LOCATION": "test-cache",
24+
}
25+
}
26+
27+
28+
@override_settings(CACHES=TEST_CACHES)
29+
class GetEOLInfoTests(BaseDownloadTests):
30+
def setUp(self):
31+
super().setUp()
32+
cache.clear()
33+
34+
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
35+
def test_eol_status(self, mock_get_data):
36+
"""Test get_eol_info returns correct EOL status."""
37+
# Arrange
38+
mock_get_data.return_value = MOCK_PYTHON_RELEASE
39+
tests = [
40+
(self.release_275, True, "2020-01-01"), # EOL
41+
(self.python_3_8_20, True, "2024-10-07"), # EOL
42+
(self.python_3_10_18, False, None), # security
43+
]
44+
45+
for release, expected_is_eol, expected_eol_date in tests:
46+
with self.subTest(release=release.name):
47+
# Act
48+
result = get_eol_info(release)
49+
50+
# Assert
51+
self.assertEqual(result["is_eol"], expected_is_eol)
52+
self.assertEqual(result["eol_date"], expected_eol_date)
53+
54+
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
55+
def test_eol_status_api_failure(self, mock_get_data):
56+
"""Test that API failure results in not showing EOL warning."""
57+
# Arrange
58+
mock_get_data.return_value = None
59+
60+
# Act
61+
result = get_eol_info(self.python_3_8_20)
62+
63+
# Assert
64+
self.assertFalse(result["is_eol"])
65+
self.assertIsNone(result["eol_date"])
66+
67+
68+
@override_settings(CACHES=TEST_CACHES)
69+
class GetReleaseCycleDataTests(TestCase):
70+
def setUp(self):
71+
cache.clear()
72+
73+
@mock.patch("downloads.templatetags.download_tags.requests.get")
74+
def test_successful_fetch(self, mock_get):
75+
"""Test successful API fetch."""
76+
# Arrange
77+
mock_response = mock.Mock()
78+
mock_response.json.return_value = MOCK_PYTHON_RELEASE
79+
mock_response.raise_for_status = mock.Mock()
80+
mock_get.return_value = mock_response
81+
82+
# Act
83+
result = get_python_releases_data()
84+
85+
# Assert
86+
self.assertEqual(result, MOCK_PYTHON_RELEASE)
87+
mock_get.assert_called_once()
88+
89+
@mock.patch("downloads.templatetags.download_tags.requests.get")
90+
def test_caches_result(self, mock_get):
91+
"""Test that the result is cached."""
92+
# Arrange
93+
mock_response = mock.Mock()
94+
mock_response.json.return_value = MOCK_PYTHON_RELEASE
95+
mock_response.raise_for_status = mock.Mock()
96+
mock_get.return_value = mock_response
97+
98+
# Act
99+
result1 = get_python_releases_data()
100+
result2 = get_python_releases_data()
101+
102+
# Assert
103+
self.assertEqual(result1, result2)
104+
mock_get.assert_called_once()
105+
106+
@mock.patch("downloads.templatetags.download_tags.requests.get")
107+
def test_request_exception_returns_none(self, mock_get):
108+
"""Test that request exceptions return None."""
109+
# Arrange
110+
mock_get.side_effect = requests.RequestException("Connection error")
111+
112+
# Act
113+
result = get_python_releases_data()
114+
115+
# Assert
116+
self.assertIsNone(result)
117+
118+
@mock.patch("downloads.templatetags.download_tags.requests.get")
119+
def test_json_decode_error_returns_none(self, mock_get):
120+
"""Test that JSON decode errors return None."""
121+
# Arrange
122+
mock_response = mock.Mock()
123+
mock_response.raise_for_status = mock.Mock()
124+
mock_response.json.side_effect = ValueError("Invalid JSON")
125+
mock_get.return_value = mock_response
126+
127+
# Act
128+
result = get_python_releases_data()
129+
130+
# Assert
131+
self.assertIsNone(result)
132+
133+
134+
@override_settings(CACHES=TEST_CACHES)
135+
class EOLBannerViewTests(BaseDownloadTests):
136+
137+
def setUp(self):
138+
super().setUp()
139+
cache.clear()
140+
141+
@mock.patch("downloads.templatetags.download_tags.get_python_releases_data")
142+
def test_eol_banner_visibility(self, mock_get_data):
143+
"""Test EOL banner is shown or hidden correctly."""
144+
# Arrange
145+
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),
149+
("python_3_8_20", None, False),
150+
]
151+
152+
for release_attr, mock_data, expect_banner in tests:
153+
with self.subTest(release=release_attr):
154+
mock_get_data.return_value = mock_data
155+
release = getattr(self, release_attr)
156+
url = reverse(
157+
"download:download_release_detail",
158+
kwargs={"release_slug": release.slug},
159+
)
160+
161+
# Act
162+
response = self.client.get(url)
163+
164+
# Assert
165+
self.assertEqual(response.status_code, 200)
166+
if expect_banner:
167+
self.assertContains(response, "level-error")
168+
self.assertContains(response, "no longer supported")
169+
else:
170+
self.assertNotContains(response, "level-error")

static/sass/style.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2652,7 +2652,12 @@ p.quote-by-organization {
26522652
background-color: #ecd4d7;
26532653
border: 2px solid #b55863; }
26542654
.level-error span {
2655-
color: #b55863; }
2655+
color: #853b44;
2656+
font-weight: bold; }
2657+
.level-error a {
2658+
color: #2b5b84; }
2659+
.level-error a:hover, .level-error a:focus {
2660+
color: #1e415e; }
26562661

26572662
/* Yeah! It worked correctly */
26582663
.level-success {

static/sass/style.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1766,7 +1766,14 @@ $colors: $blue, $psf, $yellow, $green, $purple, $red;
17661766
background-color: lighten( $red, 35% );
17671767
border: 2px solid $red;
17681768

1769-
span { color: $red; }
1769+
span {
1770+
color: darken( $red, 15% );
1771+
font-weight: bold;
1772+
}
1773+
a {
1774+
color: darken( $blue, 10% );
1775+
&:hover, &:focus { color: darken( $blue, 20% ); }
1776+
}
17701777
}
17711778

17721779
/* Yeah! It worked correctly */

templates/downloads/release_detail.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{% load has_sigstore_materials from download_tags %}
66
{% load has_sbom from download_tags %}
77
{% load sort_windows from download_tags %}
8+
{% load get_eol_info from download_tags %}
89

910
{% block body_attributes %}class="python downloads"{% endblock %}
1011

@@ -26,6 +27,16 @@
2627
<h1 class="page-title">{{ release.name }}</h1>
2728
</header>
2829

30+
{% get_eol_info release as eol_info %}
31+
{% if eol_info.is_eol %}
32+
<div class="user-feedback level-error">
33+
<span>Warning:</span>
34+
Python {{ release.get_version|default:release.name }} reached end-of-life{% if eol_info.eol_date %} on {{ eol_info.eol_date }}{% endif %}.
35+
It is no longer supported and does not receive security updates.
36+
We recommend upgrading to the <a href="{% url 'downloads:download_latest_python3' %}">latest Python release</a>.
37+
</div>
38+
{% endif %}
39+
2940
{% if latest_in_series %}
3041
<p><strong>Note:</strong> {{ release.name }} has been superseded by <a href="{{ latest_in_series.get_absolute_url }}">{{ latest_in_series.name }}</a>.</p>
3142
{% endif %}

0 commit comments

Comments
 (0)