Skip to content

Commit f034c09

Browse files
authored
Merge branch 'main' into edit-this-release-vary
2 parents 97046f2 + 7cfd705 commit f034c09

File tree

15 files changed

+357
-48
lines changed

15 files changed

+357
-48
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
with:
3636
python-version-file: '.python-version'
3737
- name: Cache Python dependencies
38-
uses: actions/cache@v4
38+
uses: actions/cache@v5
3939
env:
4040
cache-name: pythondotorg-cache-pip
4141
with:

.github/workflows/static.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
with:
1313
python-version-file: '.python-version'
1414
- name: Cache Python dependencies
15-
uses: actions/cache@v4
15+
uses: actions/cache@v5
1616
env:
1717
cache-name: pythondotorg-cache-pip
1818
with:

downloads/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class Meta(GenericResource.Meta):
6868
'name', 'slug',
6969
'creator', 'last_modified_by',
7070
'os', 'release', 'description', 'is_source', 'url', 'gpg_signature_file',
71-
'md5_sum', 'filesize', 'download_button', 'sigstore_signature_file',
71+
'md5_sum', 'sha256_sum', 'filesize', 'download_button', 'sigstore_signature_file',
7272
'sigstore_cert_file', 'sigstore_bundle_file', 'sbom_spdx2_file',
7373
]
7474
filtering = {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.26 on 2025-11-27 16:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('downloads', '0013_alter_release_content_markup_type'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='releasefile',
15+
name='sha256_sum',
16+
field=models.CharField(blank=True, max_length=200, verbose_name='SHA256 Sum'),
17+
),
18+
]

downloads/models.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,16 +175,13 @@ def update_supernav():
175175
if latest_pymanager:
176176
data['pymanager'] = latest_pymanager.download_file_for_os(o.slug)
177177

178-
python_files.append(data)
178+
# Only include OSes that have at least one download file
179+
if data['python3'] or data['pymanager']:
180+
python_files.append(data)
179181

180182
if not python_files:
181183
return
182184

183-
if not all(f['python3'] or f['pymanager'] for f in python_files):
184-
# We have a latest Python release, different OSes, but don't have release
185-
# files for the release, so return early.
186-
return
187-
188185
content = render_to_string('downloads/supernav.html', {
189186
'python_files': python_files,
190187
'last_updated': timezone.now(),
@@ -360,6 +357,7 @@ class ReleaseFile(ContentManageable, NameSlugModel):
360357
"SPDX-2 SBOM URL", blank=True, help_text="SPDX-2 SBOM URL"
361358
)
362359
md5_sum = models.CharField('MD5 Sum', max_length=200, blank=True)
360+
sha256_sum = models.CharField('SHA256 Sum', max_length=200, blank=True)
363361
filesize = models.IntegerField(default=0)
364362
download_button = models.BooleanField(default=False, help_text="Use for the supernav download button for this OS")
365363

downloads/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Meta:
4343
'url',
4444
'gpg_signature_file',
4545
'md5_sum',
46+
'sha256_sum',
4647
'filesize',
4748
'download_button',
4849
'resource_uri',

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

downloads/tests/test_models.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def test_update_supernav(self):
157157
release=self.python_3,
158158
slug=slug,
159159
name='Python 3.10',
160-
url='/ftp/python/{}.zip'.format(slug),
160+
url=f'/ftp/python/{slug}.zip',
161161
download_button=True,
162162
)
163163

@@ -186,3 +186,47 @@ def test_update_supernav(self):
186186
self.assertIn('class="download-os-windows"', content)
187187
self.assertIn('pymanager-25.0.msix', content)
188188
self.assertIn('python3.10-windows.zip', content)
189+
190+
def test_update_supernav_skips_os_without_files(self):
191+
"""Test that update_supernav works when an OS has no download files.
192+
193+
Regression test for a bug where adding an OS (like Android) without
194+
any release files would cause update_supernav to silently abort,
195+
leaving the supernav showing outdated version information.
196+
"""
197+
# Arrange
198+
from ..models import OS, update_supernav
199+
from boxes.models import Box
200+
201+
# Create an OS without any release files
202+
OS.objects.create(name="Android", slug="android")
203+
204+
# Create download files for other operating systems
205+
for os, slug in [
206+
(self.osx, "python3.10-macos"),
207+
(self.linux, "python3.10-linux"),
208+
(self.windows, "python3.10-windows"),
209+
]:
210+
ReleaseFile.objects.create(
211+
os=os,
212+
release=self.python_3,
213+
slug=slug,
214+
name="Python 3.10",
215+
url=f"/ftp/python/{slug}.zip",
216+
download_button=True,
217+
)
218+
219+
# Act
220+
update_supernav()
221+
222+
# Assert: verify supernav was updated
223+
box = Box.objects.get(label="supernav-python-downloads")
224+
content = box.content.rendered
225+
226+
# OSes with files should be present
227+
self.assertIn('class="download-os-windows"', content)
228+
self.assertIn('class="download-os-macos"', content)
229+
self.assertIn('class="download-os-linux"', content)
230+
231+
# Android (no files) should not be present
232+
self.assertNotIn("android", content.lower())
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")

0 commit comments

Comments
 (0)