Skip to content

Commit 77dee87

Browse files
authored
Merge branch 'main' into rm-peps
2 parents e4cef51 + d846f82 commit 77dee87

File tree

19 files changed

+266
-58
lines changed

19 files changed

+266
-58
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
1919
steps:
2020
- name: Check out repository
21-
uses: actions/checkout@v5
21+
uses: actions/checkout@v6
2222
- name: Install platform dependencies
2323
run: |
2424
sudo apt -y update

.github/workflows/static.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
runs-on: ubuntu-latest
88
steps:
99
- name: Check out repository
10-
uses: actions/checkout@v5
10+
uses: actions/checkout@v6
1111
- uses: actions/setup-python@v6
1212
with:
1313
python-version-file: '.python-version'

downloads/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ class ReleaseAdmin(ContentManageableModelAdmin):
2525
list_filter = ['version', 'is_published', 'show_on_download_page']
2626
search_fields = ['name', 'slug']
2727
ordering = ['-release_date']
28+
29+
def formfield_for_dbfield(self, db_field, request, **kwargs):
30+
field = super().formfield_for_dbfield(db_field, request, **kwargs)
31+
if db_field.name == "name":
32+
field.widget.attrs["placeholder"] = "Python 3.X.YaN"
33+
return field

downloads/managers.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ def pymanager(self):
2929
def latest_python2(self):
3030
return self.python2().filter(is_latest=True)
3131

32-
def latest_python3(self):
33-
return self.python3().filter(is_latest=True)
32+
def latest_python3(self, minor_version: int | None = None):
33+
if minor_version is None:
34+
return self.python3().filter(is_latest=True)
35+
pattern = rf"^Python 3\.{minor_version}\."
36+
return self.python3().filter(name__regex=pattern).order_by("-release_date")
3437

3538
def latest_pymanager(self):
3639
return self.pymanager().filter(is_latest=True)
@@ -44,22 +47,10 @@ def released(self):
4447

4548
class ReleaseManager(Manager.from_queryset(ReleaseQuerySet)):
4649
def latest_python2(self):
47-
qs = self.get_queryset().latest_python2()
48-
if qs:
49-
return qs[0]
50-
else:
51-
return None
52-
53-
def latest_python3(self):
54-
qs = self.get_queryset().latest_python3()
55-
if qs:
56-
return qs[0]
57-
else:
58-
return None
50+
return self.get_queryset().latest_python2().first()
51+
52+
def latest_python3(self, minor_version: int | None = None):
53+
return self.get_queryset().latest_python3(minor_version).first()
5954

6055
def latest_pymanager(self):
61-
qs = self.get_queryset().latest_pymanager()
62-
if qs:
63-
return qs[0]
64-
else:
65-
return None
56+
return self.get_queryset().latest_pymanager().first()

downloads/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ def purge_fastly_download_pages(sender, instance, **kwargs):
292292
purge_url('/downloads/feed.rss')
293293
purge_url('/downloads/latest/python2/')
294294
purge_url('/downloads/latest/python3/')
295+
# Purge minor version specific URLs (like /downloads/latest/python3.14/)
296+
version = instance.get_version()
297+
if instance.version == Release.PYTHON3 and version:
298+
match = re.match(r'^3\.(\d+)', version)
299+
if match:
300+
purge_url(f'/downloads/latest/python3.{match.group(1)}/')
295301
purge_url('/downloads/latest/pymanager/')
296302
purge_url('/downloads/macos/')
297303
purge_url('/downloads/source/')

downloads/tests/base.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import datetime
1+
import datetime as dt
22

33
from django.test import TestCase
44
from django.utils import timezone
@@ -32,7 +32,7 @@ def setUp(self):
3232
is_latest=True,
3333
is_published=True,
3434
release_page=self.release_275_page,
35-
release_date=timezone.now() - datetime.timedelta(days=-1)
35+
release_date=dt.datetime.fromisoformat("2013-05-15T00:00Z"),
3636
)
3737
self.release_275_windows_32bit = ReleaseFile.objects.create(
3838
os=self.windows,
@@ -102,9 +102,31 @@ def setUp(self):
102102

103103
self.python_3 = Release.objects.create(
104104
version=Release.PYTHON3,
105-
name='Python 3.10',
105+
name="Python 3.10.19",
106106
is_latest=True,
107107
is_published=True,
108108
show_on_download_page=True,
109-
release_page=self.release_275_page
109+
release_page=self.release_275_page,
110+
release_date=dt.datetime.fromisoformat("2025-10-09T00:00Z"),
111+
)
112+
113+
self.python_3_10_18 = Release.objects.create(
114+
version=Release.PYTHON3,
115+
name="Python 3.10.18",
116+
is_published=True,
117+
release_date=dt.datetime.fromisoformat("2025-06-03T00:00Z"),
118+
)
119+
120+
self.python_3_8_20 = Release.objects.create(
121+
version=Release.PYTHON3,
122+
name="Python 3.8.20",
123+
is_published=True,
124+
release_date=dt.datetime.fromisoformat("2024-09-06T00:00Z"),
125+
)
126+
127+
self.python_3_8_19 = Release.objects.create(
128+
version=Release.PYTHON3,
129+
name="Python 3.8.19",
130+
is_published=True,
131+
release_date=dt.datetime.fromisoformat("2024-03-19T00:00Z"),
110132
)

downloads/tests/test_models.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime as dt
2+
13
from ..models import Release, ReleaseFile
24
from .base import BaseDownloadTests
35

@@ -10,14 +12,14 @@ def test_stringification(self):
1012

1113
def test_published(self):
1214
published_releases = Release.objects.published()
13-
self.assertEqual(len(published_releases), 4)
15+
self.assertEqual(len(published_releases), 7)
1416
self.assertIn(self.release_275, published_releases)
1517
self.assertIn(self.hidden_release, published_releases)
1618
self.assertNotIn(self.draft_release, published_releases)
1719

1820
def test_release(self):
1921
released_versions = Release.objects.released()
20-
self.assertEqual(len(released_versions), 3)
22+
self.assertEqual(len(released_versions), 6)
2123
self.assertIn(self.release_275, released_versions)
2224
self.assertIn(self.hidden_release, released_versions)
2325
self.assertNotIn(self.draft_release, released_versions)
@@ -37,7 +39,7 @@ def test_draft(self):
3739

3840
def test_downloads(self):
3941
downloads = Release.objects.downloads()
40-
self.assertEqual(len(downloads), 2)
42+
self.assertEqual(len(downloads), 5)
4143
self.assertIn(self.release_275, downloads)
4244
self.assertNotIn(self.hidden_release, downloads)
4345
self.assertNotIn(self.draft_release, downloads)
@@ -50,12 +52,28 @@ def test_python2(self):
5052

5153
def test_python3(self):
5254
versions = Release.objects.python3()
53-
self.assertEqual(len(versions), 3)
55+
self.assertEqual(len(versions), 6)
5456
self.assertNotIn(self.release_275, versions)
5557
self.assertNotIn(self.draft_release, versions)
5658
self.assertIn(self.hidden_release, versions)
5759
self.assertIn(self.pre_release, versions)
5860

61+
def test_latest_python3(self):
62+
latest_3 = Release.objects.latest_python3()
63+
self.assertEqual(latest_3, self.python_3)
64+
self.assertNotEqual(latest_3, self.python_3_10_18)
65+
66+
latest_3_10 = Release.objects.latest_python3(minor_version=10)
67+
self.assertEqual(latest_3_10, self.python_3)
68+
self.assertNotEqual(latest_3_10, self.python_3_10_18)
69+
70+
latest_3_8 = Release.objects.latest_python3(minor_version=8)
71+
self.assertEqual(latest_3_8, self.python_3_8_20)
72+
self.assertNotEqual(latest_3_8, self.python_3_8_19)
73+
74+
latest_3_99 = Release.objects.latest_python3(minor_version=99)
75+
self.assertIsNone(latest_3_99)
76+
5977
def test_get_version(self):
6078
self.assertEqual(self.release_275.name, 'Python 2.7.5')
6179
self.assertEqual(self.release_275.get_version(), '2.7.5')

downloads/tests/test_views.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ def test_download(self):
5656
response = self.client.get(url)
5757
self.assertEqual(response.status_code, 200)
5858

59+
def test_download_releases_ordered_by_version(self):
60+
url = reverse("download:download")
61+
response = self.client.get(url)
62+
releases = response.context["releases"]
63+
self.assertEqual(
64+
releases,
65+
[
66+
self.python_3,
67+
self.python_3_10_18,
68+
self.python_3_8_20,
69+
self.python_3_8_19,
70+
self.release_275,
71+
],
72+
)
73+
5974
def test_latest_redirects(self):
6075
latest_python2 = Release.objects.released().python2().latest()
6176
url = reverse('download:download_latest_python2')
@@ -67,6 +82,19 @@ def test_latest_redirects(self):
6782
response = self.client.get(url)
6883
self.assertRedirects(response, latest_python3.get_absolute_url())
6984

85+
def test_latest_python3x_redirects(self):
86+
url = reverse("download:download_latest_python3x", kwargs={"minor": "10"})
87+
response = self.client.get(url)
88+
self.assertRedirects(response, self.python_3.get_absolute_url())
89+
90+
url = reverse("download:download_latest_python3x", kwargs={"minor": "8"})
91+
response = self.client.get(url)
92+
self.assertRedirects(response, self.python_3_8_20.get_absolute_url())
93+
94+
url = reverse("download:download_latest_python3x", kwargs={"minor": "99"})
95+
response = self.client.get(url)
96+
self.assertRedirects(response, reverse("download:download"))
97+
7098
def test_redirect_page_object_to_release_detail_page(self):
7199
self.release_275.release_page = None
72100
self.release_275.save()
@@ -218,13 +246,13 @@ def test_get_release(self):
218246
self.assertEqual(response.status_code, 200)
219247
content = self.get_json(response)
220248
# 'self.draft_release' won't shown here.
221-
self.assertEqual(len(content), 4)
249+
self.assertEqual(len(content), 7)
222250

223251
# Login to get all releases.
224252
response = self.client.get(url, headers={"authorization": self.Authorization})
225253
self.assertEqual(response.status_code, 200)
226254
content = self.get_json(response)
227-
self.assertEqual(len(content), 5)
255+
self.assertEqual(len(content), 8)
228256
self.assertFalse(content[0]['is_latest'])
229257

230258
def test_post_release(self):
@@ -594,5 +622,5 @@ def test_feed_item_count(self) -> None:
594622
response = self.client.get(self.url)
595623
content = response.content.decode()
596624

597-
# In BaseDownloadTests, we create 5 releases, 4 of which are published, 1 of those published are hidden..
598-
self.assertEqual(content.count("<item>"), 4)
625+
# In BaseDownloadTests, we create 8 releases, 7 of which are published, 1 of those published are hidden..
626+
self.assertEqual(content.count("<item>"), 7)

downloads/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
urlpatterns = [
66
re_path(r'latest/python2/?$', views.DownloadLatestPython2.as_view(), name='download_latest_python2'),
77
re_path(r'latest/python3/?$', views.DownloadLatestPython3.as_view(), name='download_latest_python3'),
8+
re_path(r'latest/python3\.(?P<minor>\d+)/?$', views.DownloadLatestPython3.as_view(), name='download_latest_python3x'),
89
re_path(r'latest/pymanager/?$', views.DownloadLatestPyManager.as_view(), name='download_latest_pymanager'),
910
re_path(r'latest/?$', views.DownloadLatestPython3.as_view(), name='download_latest_python3'),
1011
path('operating-systems/', views.DownloadFullOSList.as_view(), name='download_full_os_list'),

downloads/views.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from datetime import datetime
44

5-
from django.db.models import Prefetch
5+
from django.db.models import Case, IntegerField, Prefetch, When
66
from django.urls import reverse
77
from django.utils import timezone
88
from django.views.generic import DetailView, TemplateView, ListView, RedirectView
@@ -30,19 +30,21 @@ def get_redirect_url(self, **kwargs):
3030

3131

3232
class DownloadLatestPython3(RedirectView):
33-
""" Redirect to latest Python 3 release """
33+
"""Redirect to latest Python 3 release, optionally for a specific minor"""
34+
3435
permanent = False
3536

3637
def get_redirect_url(self, **kwargs):
38+
minor_version = kwargs.get('minor')
3739
try:
38-
latest_python3 = Release.objects.latest_python3()
39-
except Release.DoesNotExist:
40-
latest_python3 = None
40+
minor_version_int = int(minor_version) if minor_version else None
41+
latest_release = Release.objects.latest_python3(minor_version_int)
42+
except (ValueError, Release.DoesNotExist):
43+
latest_release = None
4144

42-
if latest_python3:
43-
return latest_python3.get_absolute_url()
44-
else:
45-
return reverse('download')
45+
if latest_release:
46+
return latest_release.get_absolute_url()
47+
return reverse("downloads:download")
4648

4749

4850
class DownloadLatestPyManager(RedirectView):
@@ -103,8 +105,17 @@ def get_context_data(self, **kwargs):
103105
data['pymanager'] = latest_pymanager.download_file_for_os(o.slug)
104106
python_files.append(data)
105107

108+
def version_key(release: Release) -> tuple[int, ...]:
109+
try:
110+
return tuple(int(x) for x in release.get_version().split("."))
111+
except ValueError:
112+
return (0,)
113+
114+
releases = list(Release.objects.downloads())
115+
releases.sort(key=version_key, reverse=True)
116+
106117
context.update({
107-
'releases': Release.objects.downloads(),
118+
'releases': releases,
108119
'latest_python2': latest_python2,
109120
'latest_python3': latest_python3,
110121
'python_files': python_files,
@@ -157,6 +168,20 @@ def get_object(self):
157168
def get_context_data(self, **kwargs):
158169
context = super().get_context_data(**kwargs)
159170

171+
# Add featured files (files with download_button=True)
172+
# Order: macOS first, Windows second, Source last
173+
context['featured_files'] = self.object.files.filter(
174+
download_button=True
175+
).annotate(
176+
os_order=Case(
177+
When(os__slug='macos', then=1),
178+
When(os__slug='windows', then=2),
179+
When(os__slug='source', then=3),
180+
default=4,
181+
output_field=IntegerField(),
182+
)
183+
).order_by('os_order')
184+
160185
# Manually add release files for better ordering
161186
context['release_files'] = []
162187

0 commit comments

Comments
 (0)