Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGES/1277.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `version_specifier` filter to package content API for PEP 440 version matching.
28 changes: 28 additions & 0 deletions pulp_python/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions pulp_python/tests/functional/api/test_version_specifier_filter.py
Original file line number Diff line number Diff line change
@@ -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