Skip to content

Commit ea93e9e

Browse files
committed
Addons: sorting algorithm for versions customizable on flyout
Allow users to choose one of the pre-defined algorithms: - Lexicographically - SemVer (Read the Docs) - CalVer - Custom pattern The sorting algorithm is implemented in the backend. So, the list returned under `addons.flyout.versions` will be sorted acordingly the algorithm the user chose. There is no need to do anything extra in the front-end. The algorithm follows the next general rule: _"all the versions that don't match the pattern defined, are considered invalid and sorted lexicographically between them and added to the end of the list"_. That means that valid versions will appear always first in the list and sorted together with other _valid versions_. There is one _key feature_ here and is that the user can define any pattern supported by `bumpver`, which is the module we use behind the scenes to perform the sorting. See https://github.com/mbarkhau/bumpver#pattern-examples On the other hand, `SemVer (Read the Docs)` is implemented used the exact same code we were using for the old flyout implementation. This is mainly to keep backward compatibility, but its usage is not recommended since it's pretty hard to explain to users and hide non-clear/weird behavior. Closes readthedocs/addons#222
1 parent 44e7368 commit ea93e9e

File tree

7 files changed

+216
-4
lines changed

7 files changed

+216
-4
lines changed

readthedocs/projects/constants.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,23 @@
431431
_("Single version without translations (/<filename>)"),
432432
),
433433
)
434+
435+
436+
ADDONS_FLYOUT_SORTING_LEXICOGRAPHYCALLY = "lexicographically"
437+
# Compatibility to keep the behavior of the old flyout.
438+
# This isn't a good algorithm, but it's a way to keep the old behavior in case we need it.
439+
ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE = "semver-readthedocs-compatible"
440+
# https://pypi.org/project/packaging/
441+
ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING = "python-packaging"
442+
ADDONS_FLYOUT_SORTING_CALVER = "calver"
443+
# Let the user to define a custom pattern and use BumpVer to parse and sort the versions.
444+
# https://github.com/mbarkhau/bumpver#pattern-examples
445+
ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN = "custom-pattern"
446+
447+
ADDONS_FLYOUT_SORTING_CHOICES = (
448+
(ADDONS_FLYOUT_SORTING_LEXICOGRAPHYCALLY, "Lexicographically"),
449+
(ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE, "SemVer (Read the Docs)"),
450+
(ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING, "Python Packaging (PEP 440 and PEP 425)"),
451+
(ADDONS_FLYOUT_SORTING_CALVER, "CalVer (YYYY.0M.0M)"),
452+
(ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN, "Define your own pattern"),
453+
)

readthedocs/projects/forms.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from readthedocs.integrations.models import Integration
1919
from readthedocs.invitations.models import Invitation
2020
from readthedocs.oauth.models import RemoteRepository
21+
from readthedocs.projects.constants import ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
2122
from readthedocs.projects.models import (
2223
AddonsConfig,
2324
Domain,
@@ -498,6 +499,8 @@ class Meta:
498499
"doc_diff_enabled",
499500
"external_version_warning_enabled",
500501
"flyout_enabled",
502+
"flyout_sorting",
503+
"flyout_sorting_custom_pattern",
501504
"hotkeys_enabled",
502505
"search_enabled",
503506
"stable_latest_version_warning_enabled",
@@ -522,6 +525,18 @@ def __init__(self, *args, **kwargs):
522525
kwargs["instance"] = addons
523526
super().__init__(*args, **kwargs)
524527

528+
def clean(self):
529+
if (
530+
self.cleaned_data["flyout_sorting"] == ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
531+
and not self.cleaned_data["flyout_sorting_custom_pattern"]
532+
):
533+
raise forms.ValidationError(
534+
_(
535+
"The flyout sorting custom pattern is required when selecting a custom pattern."
536+
),
537+
)
538+
return super().clean()
539+
525540
def clean_project(self):
526541
return self.project
527542

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 4.2.9 on 2024-01-26 12:08
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("projects", "0113_disable_analytics_addons"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="addonsconfig",
14+
name="flyout_sorting_custom_pattern",
15+
field=models.CharField(
16+
blank=True,
17+
default=None,
18+
help_text="Sorting pattern supported by BumpVer",
19+
max_length=32,
20+
null=True,
21+
),
22+
),
23+
migrations.AlterField(
24+
model_name="addonsconfig",
25+
name="flyout_sorting",
26+
field=models.CharField(
27+
choices=[
28+
("lexicographically", "Lexicographically"),
29+
("semver-readthedocs-compatible", "SemVer (Read the Docs)"),
30+
("python-packaging", "Python Packaging (PEP 440 and PEP 425)"),
31+
("calver", "CalVer (YYYY.0M.0M)"),
32+
("custom-pattern", "Define your own pattern"),
33+
],
34+
default="lexicographically",
35+
),
36+
),
37+
]

readthedocs/projects/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
from readthedocs.vcs_support.backends import backend_cls
6262

6363
from .constants import (
64+
ADDONS_FLYOUT_SORTING_CHOICES,
65+
ADDONS_FLYOUT_SORTING_LEXICOGRAPHYCALLY,
6466
DOWNLOADABLE_MEDIA_TYPES,
6567
MEDIA_TYPES,
6668
MULTIPLE_VERSIONS_WITH_TRANSLATIONS,
@@ -179,6 +181,17 @@ class AddonsConfig(TimeStampedModel):
179181

180182
# Flyout
181183
flyout_enabled = models.BooleanField(default=True)
184+
flyout_sorting = models.CharField(
185+
choices=ADDONS_FLYOUT_SORTING_CHOICES,
186+
default=ADDONS_FLYOUT_SORTING_LEXICOGRAPHYCALLY,
187+
)
188+
flyout_sorting_custom_pattern = models.CharField(
189+
max_length=32,
190+
default=None,
191+
null=True,
192+
blank=True,
193+
help_text="Sorting pattern supported by BumpVer",
194+
)
182195

183196
# Hotkeys
184197
hotkeys_enabled = models.BooleanField(default=True)

readthedocs/projects/version_handling.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Project version handling."""
2+
import operator
23
import unicodedata
34

4-
from packaging.version import InvalidVersion, Version
5+
from bumpver.v2version import parse_version_info
6+
from bumpver.version import PatternError
7+
from packaging.version import InvalidVersion, Version, parse
58

69
from readthedocs.builds.constants import LATEST_VERBOSE_NAME, STABLE_VERBOSE_NAME, TAG
710
from readthedocs.vcs_support.backends import backend_cls
@@ -158,3 +161,86 @@ def determine_stable_version(version_list):
158161
version_obj, comparable = versions[0]
159162
return version_obj
160163
return None
164+
165+
166+
def sort_versions_python_packaging(version_list):
167+
"""
168+
Sort Read the Docs versions list using ``packaging`` algorithm.
169+
170+
All the invalid version (raise ``InvalidVersion``) are added at the end
171+
sorted lexicographically.
172+
173+
https://pypi.org/project/packaging/
174+
https://packaging.python.org/en/latest/specifications/version-specifiers/
175+
"""
176+
lexicographically_sorted_version_list = sorted(
177+
version_list,
178+
key=operator.attrgetter("slug"),
179+
)
180+
181+
valid_versions = []
182+
invalid_versions = []
183+
for i, version in enumerate(lexicographically_sorted_version_list):
184+
try:
185+
valid_versions.append((version, Version(version.slug)))
186+
except InvalidVersion:
187+
# When the version is invalid, we put it at the end while keeping
188+
# the lexicographically sorting between the invalid ones.
189+
invalid_versions.append((version, parse(str(100000 + i))))
190+
191+
return [
192+
item[0]
193+
for item in sorted(valid_versions, key=operator.itemgetter(1))
194+
+ invalid_versions
195+
]
196+
197+
198+
def sort_versions_calver(version_list):
199+
"""
200+
Sort Read the Docs versions using CalVer pattern: ``YYYY.0M.0M``.
201+
202+
All the invalid version are added at the end sorted lexicographically.
203+
"""
204+
raw_pattern = "YYYY.0M.0D"
205+
return sort_versions_custom_pattern(version_list, raw_pattern)
206+
207+
208+
def sort_versions_custom_pattern(version_list, raw_pattern):
209+
"""
210+
Sort Read the Docs versions using a custom pattern.
211+
212+
All the invalid version (raise ``PatternError``) are added at the end
213+
sorted lexicographically.
214+
215+
It uses ``Bumpver`` behinds the scenes for the parsing and sorting.
216+
https://github.com/mbarkhau/bumpver
217+
"""
218+
raw_pattern = "YYYY.0M.0D"
219+
lexicographically_sorted_version_list = sorted(
220+
version_list,
221+
key=operator.attrgetter("slug"),
222+
)
223+
224+
valid_versions = []
225+
invalid_versions = []
226+
for i, version in enumerate(lexicographically_sorted_version_list):
227+
try:
228+
valid_versions.append(
229+
(
230+
version,
231+
parse_version_info(
232+
version.slug,
233+
raw_pattern=raw_pattern,
234+
),
235+
)
236+
)
237+
except PatternError:
238+
# When the version is invalid, we put it at the end while keeping
239+
# the lexicographically sorting between the invalid ones.
240+
invalid_versions.append((version, parse(str(100000 + i))))
241+
242+
return [
243+
item[0]
244+
for item in sorted(valid_versions, key=operator.itemgetter(1))
245+
+ invalid_versions
246+
]

readthedocs/proxito/views/hosting.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@
2323
from readthedocs.core.resolver import Resolver
2424
from readthedocs.core.unresolver import UnresolverError, unresolver
2525
from readthedocs.core.utils.extend import SettingsOverrideObject
26+
from readthedocs.projects.constants import (
27+
ADDONS_FLYOUT_SORTING_CALVER,
28+
ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN,
29+
ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING,
30+
ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE,
31+
)
2632
from readthedocs.projects.models import AddonsConfig, Project
33+
from readthedocs.projects.version_handling import (
34+
comparable_version,
35+
sort_versions_calver,
36+
sort_versions_custom_pattern,
37+
sort_versions_python_packaging,
38+
)
2739

2840
log = structlog.get_logger(__name__) # noqa
2941

@@ -265,6 +277,32 @@ def _v0(self, project, version, build, filename, url, user):
265277
.only("slug", "type")
266278
.order_by("slug")
267279
)
280+
if (
281+
project.addons.flyout_sorting
282+
== ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE
283+
):
284+
versions_active_built_not_hidden = sorted(
285+
versions_active_built_not_hidden,
286+
key=lambda version: comparable_version(
287+
version.verbose_name,
288+
repo_type=project.repo_type,
289+
),
290+
)
291+
elif (
292+
project.addons.flyout_sorting == ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING
293+
):
294+
versions_active_built_not_hidden = sort_versions_python_packaging(
295+
versions_active_built_not_hidden
296+
)
297+
elif project.addons.flyout_sorting == ADDONS_FLYOUT_SORTING_CALVER:
298+
versions_active_built_not_hidden = sort_versions_calver(
299+
versions_active_built_not_hidden
300+
)
301+
elif project.addons.flyout_sorting == ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN:
302+
versions_active_built_not_hidden = sort_versions_custom_pattern(
303+
versions_active_built_not_hidden,
304+
project.addons.flyout_sorting_custom_pattern,
305+
)
268306

269307
if version:
270308
version_downloads = version.get_downloads(pretty=True).items()
@@ -332,9 +370,9 @@ def _v0(self, project, version, build, filename, url, user):
332370
# NOTE: I think we are moving away from these selectors
333371
# since we are doing floating noticications now.
334372
# "query_selector": "[role=main]",
335-
"versions": list(
336-
versions_active_built_not_hidden.values_list("slug", flat=True)
337-
),
373+
"versions": [
374+
version_.slug for version_ in versions_active_built_not_hidden
375+
],
338376
},
339377
"flyout": {
340378
"enabled": project.addons.flyout_enabled,

requirements/pip.in

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,6 @@ dparse
176176
gunicorn
177177

178178
django-cacheops
179+
180+
# Used by Addons for sorting patterns
181+
bumpver

0 commit comments

Comments
 (0)