Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addons: sorting algorithm for versions customizable on flyout #11069

Merged
merged 20 commits into from
Mar 12, 2024
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
23 changes: 23 additions & 0 deletions readthedocs/projects/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,26 @@
_("Single version without translations (/<filename>)"),
),
)


ADDONS_FLYOUT_SORTING_ALPHABETICALLY = "alphabetically"
# Compatibility to keep the behavior of the old flyout.
# This isn't a good algorithm, but it's a way to keep the old behavior in case we need it.
ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE = "semver-readthedocs-compatible"
# https://pypi.org/project/packaging/
ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING = "python-packaging"
ADDONS_FLYOUT_SORTING_CALVER = "calver"
# Let the user to define a custom pattern and use BumpVer to parse and sort the versions.
# https://github.com/mbarkhau/bumpver#pattern-examples
ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN = "custom-pattern"

ADDONS_FLYOUT_SORTING_CHOICES = (
(ADDONS_FLYOUT_SORTING_ALPHABETICALLY, _("Alphabetically")),
(ADDONS_FLYOUT_SORTING_SEMVER_READTHEDOCS_COMPATIBLE, _("SemVer (Read the Docs)")),
(
ADDONS_FLYOUT_SORTING_PYTHON_PACKAGING,
_("Python Packaging (PEP 440 and PEP 425)"),
),
(ADDONS_FLYOUT_SORTING_CALVER, _("CalVer (YYYY.0M.0M)")),
(ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN, _("Define your own pattern")),
)
16 changes: 16 additions & 0 deletions readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from readthedocs.invitations.models import Invitation
from readthedocs.oauth.models import RemoteRepository
from readthedocs.organizations.models import Team
from readthedocs.projects.constants import ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
from readthedocs.projects.models import (
AddonsConfig,
Domain,
Expand Down Expand Up @@ -588,6 +589,9 @@ class Meta:
"doc_diff_enabled",
"external_version_warning_enabled",
"flyout_enabled",
"flyout_sorting",
"flyout_sorting_latest_stable_at_beginning",
"flyout_sorting_custom_pattern",
ericholscher marked this conversation as resolved.
Show resolved Hide resolved
"hotkeys_enabled",
"search_enabled",
"stable_latest_version_warning_enabled",
Expand All @@ -612,6 +616,18 @@ def __init__(self, *args, **kwargs):
kwargs["instance"] = addons
super().__init__(*args, **kwargs)

def clean(self):
if (
self.cleaned_data["flyout_sorting"] == ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
and not self.cleaned_data["flyout_sorting_custom_pattern"]
):
raise forms.ValidationError(
_(
"The flyout sorting custom pattern is required when selecting a custom pattern."
),
)
return super().clean()

def clean_project(self):
return self.project

Expand Down
83 changes: 83 additions & 0 deletions readthedocs/projects/migrations/0118_addons_flyout_sorting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-03-04 13:32

from django.db import migrations, models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.before_deploy

dependencies = [
("projects", "0117_remove_old_fields"),
]

operations = [
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting",
field=models.CharField(
choices=[
("alphabetically", "Alphabetically"),
("semver-readthedocs-compatible", "SemVer (Read the Docs)"),
("python-packaging", "Python Packaging (PEP 440 and PEP 425)"),
("calver", "CalVer (YYYY.0M.0M)"),
("custom-pattern", "Define your own pattern"),
],
default="alphabetically",
max_length=64,
),
),
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting_custom_pattern",
field=models.CharField(
blank=True,
default=None,
help_text='Sorting pattern supported by BumpVer (<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>',
max_length=32,
null=True,
),
),
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting_latest_stable_at_beginning",
field=models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting",
field=models.CharField(
choices=[
("alphabetically", "Alphabetically"),
("semver-readthedocs-compatible", "SemVer (Read the Docs)"),
("python-packaging", "Python Packaging (PEP 440 and PEP 425)"),
("calver", "CalVer (YYYY.0M.0M)"),
("custom-pattern", "Define your own pattern"),
],
default="alphabetically",
max_length=64,
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting_custom_pattern",
field=models.CharField(
blank=True,
default=None,
help_text='Sorting pattern supported by BumpVer (<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>',
max_length=32,
null=True,
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting_latest_stable_at_beginning",
field=models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
),
),
]
19 changes: 19 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
from readthedocs.vcs_support.backends import backend_cls

from .constants import (
ADDONS_FLYOUT_SORTING_ALPHABETICALLY,
ADDONS_FLYOUT_SORTING_CHOICES,
DOWNLOADABLE_MEDIA_TYPES,
MEDIA_TYPES,
MULTIPLE_VERSIONS_WITH_TRANSLATIONS,
Expand Down Expand Up @@ -181,6 +183,23 @@ class AddonsConfig(TimeStampedModel):

# Flyout
flyout_enabled = models.BooleanField(default=True)
flyout_sorting = models.CharField(
choices=ADDONS_FLYOUT_SORTING_CHOICES,
default=ADDONS_FLYOUT_SORTING_ALPHABETICALLY,
max_length=64,
)
flyout_sorting_custom_pattern = models.CharField(
max_length=32,
default=None,
null=True,
blank=True,
help_text="Sorting pattern supported by BumpVer "
'(<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>)',
)
flyout_sorting_latest_stable_at_beginning = models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
)

# Hotkeys
hotkeys_enabled = models.BooleanField(default=True)
Expand Down
207 changes: 207 additions & 0 deletions readthedocs/projects/tests/test_version_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import django_dynamic_fixture as fixture
import pytest

from readthedocs.builds.models import Build, Version
from readthedocs.projects.models import AddonsConfig, Project
from readthedocs.projects.version_handling import (
sort_versions_calver,
sort_versions_custom_pattern,
sort_versions_python_packaging,
)


@pytest.mark.django_db(databases="__all__")
class TestVersionHandling:
@pytest.fixture(autouse=True)
def setup(self, requests_mock):
# Save the reference to query it from inside the test
self.requests_mock = requests_mock

self.project = fixture.get(Project, slug="project")
self.addons = fixture.get(AddonsConfig, project=self.project)
self.version = self.project.versions.get(slug="latest")
self.build = fixture.get(
Build,
version=self.version,
commit="a1b2c3",
)

def test_sort_versions_python_packaging(self):
slugs = [
"v1.0",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"2.5.3",
"1.1",
"1.1.0",
"v1.0",
# Invalid versions are at the end sorted alphabetically.
"another-invalid",
"invalid",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

sorted_versions = sort_versions_python_packaging(
self.project.versions.all(),
latest_stable_at_beginning=True,
)
assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_python_packaging_latest_stable_not_at_beginning(self):
slugs = [
"v1.0",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
"2.5.3",
"1.1",
"1.1.0",
"v1.0",
# Invalid versions are at the end sorted alphabetically.
"another-invalid",
"invalid",
"latest",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

sorted_versions = sort_versions_python_packaging(
self.project.versions.all(),
latest_stable_at_beginning=False,
)
assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_calver(self):
slugs = [
"2022.01.22",
"2023.04.22",
"2021.01.22",
"2022.05.02",
# invalid ones
"2001.16.32",
"2001.02.2",
"2001-02-27",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"stable",
"2023.04.22",
"2022.05.02",
"2022.01.22",
"2021.01.22",
# invalid ones (alphabetically)
"1.1",
"1.1.0",
"2.5.3",
"2001-02-27",
"2001.02.2",
"2001.16.32",
"another-invalid",
"invalid",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

fixture.get(
Version,
slug="stable",
machine=True,
project=self.project,
)

sorted_versions = sort_versions_calver(
self.project.versions.all(),
latest_stable_at_beginning=True,
)

assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_custom_pattern(self):
slugs = [
"v1.0",
"v1.1",
"v2.3",
# invalid ones
"v1.1.0",
"v2.3rc1",
"invalid",
"2.5.3",
"2022.01.22",
"1.1",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"stable",
"v2.3",
"v1.1",
"v1.0",
# invalid ones (alphabetically)
"1.1",
"2.5.3",
"2022.01.22",
"another-invalid",
"invalid",
"v1.1.0",
"v2.3rc1",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

fixture.get(
Version,
slug="stable",
machine=True,
project=self.project,
)

sorted_versions = sort_versions_custom_pattern(
self.project.versions.all(),
raw_pattern="vMAJOR.MINOR",
latest_stable_at_beginning=True,
)

assert expected == [version.slug for version in sorted_versions]
Loading