diff --git a/CHANGES/1277.feature b/CHANGES/1277.feature new file mode 100644 index 00000000..f0164264 --- /dev/null +++ b/CHANGES/1277.feature @@ -0,0 +1 @@ +Added `version_specifier` filter to package content API for PEP 440 version matching. diff --git a/pulp_python/app/viewsets.py b/pulp_python/app/viewsets.py index 58a41187..ca55f623 100644 --- a/pulp_python/app/viewsets.py +++ b/pulp_python/app/viewsets.py @@ -5,7 +5,9 @@ from django_filters import CharFilter from django_filters.rest_framework import filters as drf_filters from drf_spectacular.utils import extend_schema, extend_schema_view +from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version from rest_framework import status from rest_framework.decorators import action from rest_framework.mixins import ( @@ -466,6 +468,28 @@ class NormalizedNameInFilter(drf_filters.BaseInFilter, NormalizedNameFilter): """In-filter that normalizes each input value and queries name_normalized.""" +class VersionSpecifierFilter(CharFilter): + """Filter that matches versions against a PEP 440 specifier string.""" + + def filter(self, qs, value): + if not value: + return qs + try: + spec = SpecifierSet(value, prereleases=True) + except InvalidSpecifier: + raise ValidationError( + {"version_specifier": f"Invalid PEP 440 version specifier: {value}"} + ) + matching_pks = [] + for pk, version in qs.values_list("pk", self.field_name): + try: + if Version(version) in spec: + matching_pks.append(pk) + except InvalidVersion: + pass + return qs.filter(pk__in=matching_pks) + + class PythonPackageContentFilter(core_viewsets.ContentFilter): """ FilterSet for PythonPackageContent. @@ -474,6 +498,10 @@ class PythonPackageContentFilter(core_viewsets.ContentFilter): name = NormalizedNameFilter(field_name="name_normalized", lookup_expr="exact") name__in = NormalizedNameInFilter(field_name="name_normalized", lookup_expr="in") name__contains = CharFilter(field_name="name", lookup_expr="contains") + version_specifier = VersionSpecifierFilter( + field_name="version", + help_text="Filter by PEP 440 version specifier (e.g., >=2.4,<3.0 or ~=1.26)", + ) class Meta: model = python_models.PythonPackageContent diff --git a/pulp_python/tests/functional/api/test_version_specifier_filter.py b/pulp_python/tests/functional/api/test_version_specifier_filter.py new file mode 100644 index 00000000..442cb0dd --- /dev/null +++ b/pulp_python/tests/functional/api/test_version_specifier_filter.py @@ -0,0 +1,28 @@ +import pytest + +from pulp_python.tests.functional.constants import PYTHON_SM_PROJECT_SPECIFIER + + +@pytest.mark.parallel +def test_version_specifier_filter(python_bindings, python_repo_with_sync, python_remote_factory): + """Test filtering content by PEP 440 version specifier.""" + remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER) + repo = python_repo_with_sync(remote=remote) + + result = python_bindings.ContentPackagesApi.list( + repository_version=repo.latest_version_href, + name="Django", + version_specifier=">=1.10.2,<1.10.4", + ) + versions = {c.version for c in result.results} + assert "1.10.2" in versions + assert "1.10.3" in versions + assert "1.10.4" not in versions + + +@pytest.mark.parallel +def test_version_specifier_filter_invalid(python_bindings): + """Test that an invalid specifier returns a 400 error.""" + with pytest.raises(python_bindings.ApiException) as exc: + python_bindings.ContentPackagesApi.list(version_specifier=">=invalid!version") + assert exc.value.status == 400