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
34 changes: 31 additions & 3 deletions packagedb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from django.utils.translation import gettext_lazy as _

import natsort
from univers.version_range import RANGE_CLASS_BY_SCHEMES
from aboutcode.federatedcode.contrib.django.models import (
FederatedCodePackageActivityMixin,
)
Expand All @@ -46,9 +47,36 @@
logger.setLevel(logging.INFO)


def sort_version(packages):
"""Return the packages sorted by version."""
return natsort.natsorted(packages, key=lambda p: p.version.replace(".", "~") + "z")
def sort_version(packages, package_type=None):
"""Return the packages sorted by version using proper version scheme."""
if not packages:
return []

# Get the first package to determine the type
try:
sample_package = packages[0]
except TypeError:
# Fallback for generators
packages = list(packages)
if not packages:
return []
sample_package = packages[0]

pkg_type = package_type or sample_package.type

# Get the appropriate version class for this package type
range_class = RANGE_CLASS_BY_SCHEMES.get(pkg_type)
if range_class:
version_class = range_class.version_class
try:
return sorted(packages, key=lambda p: version_class(p.version))
except Exception as e:
logger.warning(
f"Version parsing failed for {package_type}, using natsort fallback: {e}"
)

# Fallback to natural sorting
return natsort.natsorted(packages, key=lambda p: p.version)


class PackageQuerySet(PackageURLQuerySetMixin, models.QuerySet):
Expand Down
123 changes: 123 additions & 0 deletions packagedb/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from packagedb.models import PackageWatch
from packagedb.models import Party
from packagedb.models import Resource
from packagedb.models import sort_version


class ResourceModelTestCase(TransactionTestCase):
Expand Down Expand Up @@ -494,3 +495,125 @@ def test_get_or_none(self):
package = Package.objects.filter(download_url="http://a.ab").get_or_none()
assert package
assert Package.objects.filter(download_url="http://a.ab-foobar").get_or_none() is None


class SortVersionTestCase(TransactionTestCase):
"""Comprehensive tests for the sort_version function."""

def tearDown(self):
Package.objects.all().delete()

def _create_packages(self, pkg_type, versions, **kwargs):
"""Create packages with given versions."""
return [
Package.objects.create(
download_url=f"http://{pkg_type}-{hash(version)}.com",
type=pkg_type,
version=version,
**kwargs,
)
for version in versions
]

def test_sort_version_empty_list(self):
"""Test sorting an empty list."""
self.assertEqual([], sort_version([]))

def test_ecosystem_versions(self):
"""Test version sorting across multiple package ecosystems."""
test_cases = [
(
"npm",
["1.10.0", "2.0.0", "1.0.0", "1.2.0"],
["1.0.0", "1.2.0", "1.10.0", "2.0.0"],
{"name": "lodash"},
),
(
"pypi",
["1.0.1", "1.0rc1", "1.0", "1.0a1", "1.0b1"],
["1.0a1", "1.0b1", "1.0rc1", "1.0", "1.0.1"],
{"name": "django"},
),
(
"maven",
["4.10", "4.0", "4.2"],
["4.0", "4.2", "4.10"],
{"namespace": "junit", "name": "junit"},
),
(
"swift",
["2.0.0", "1.1.5", "1.0.0", "1.1.5^{}"],
["1.0.0", "1.1.5", "1.1.5^{}", "2.0.0"],
{"name": "alamofire"},
),
("gem", ["4.0.0", "3.0.0", "3.2.0"], ["3.0.0", "3.2.0", "4.0.0"], {"name": "rails"}),
(
"deb",
["1.0-10", "1.0-1", "1.0-2"],
["1.0-1", "1.0-2", "1.0-10"],
{"name": "deb-pkg"},
),
(
"nuget",
["11.0.0", "10.0.0", "9.0.0"],
["9.0.0", "10.0.0", "11.0.0"],
{"name": "Newtonsoft.Json"},
),
("generic", ["1.10", "1.0", "1.2"], ["1.0", "1.2", "1.10"], {"name": "gen-pkg"}),
(
"cargo",
["1.0.100", "1.0.0", "1.0.20"],
["1.0.0", "1.0.20", "1.0.100"],
{"name": "serde"},
),
(
"composer",
["4.0.0", "3.0.0", "3.1.0"],
["3.0.0", "3.1.0", "4.0.0"],
{"name": "sf-console"},
),
(
"golang",
["v0.9.1", "v0.8.0", "v0.9.0"],
["v0.8.0", "v0.9.0", "v0.9.1"],
{"namespace": "github.com/pkg", "name": "errors"},
),
(
"rpm",
["3.10.0-10", "3.10.0-1", "3.10.0-2"],
["3.10.0-1", "3.10.0-2", "3.10.0-10"],
{"name": "kernel"},
),
("unknown-type", ["1.10", "1.0", "1.2"], ["1.0", "1.2", "1.10"], {"name": "unk-pkg"}),
(
"npm",
["invalid-10", "invalid-1", "invalid-2"],
["invalid-1", "invalid-2", "invalid-10"],
{"name": "inv-test"},
),
]

for pkg_type, unsorted, expected, kwargs in test_cases:
with self.subTest(pkg_type=pkg_type):
packages = self._create_packages(pkg_type, unsorted, **kwargs)
sorted_versions = [p.version for p in sort_version(packages)]
self.assertEqual(expected, sorted_versions, f"Failed for {pkg_type}")

def test_sort_version_generator_input(self):
"""Test with generator input."""
packages = self._create_packages("npm", ["1.10.0", "1.0.0", "1.5.0"], name="gen-pkg")
gen = (p for p in packages)
sorted_packages = sort_version(gen)
self.assertEqual(3, len(sorted_packages))

def test_sort_version_explicit_type(self):
"""Test with explicit package_type parameter."""
packages = self._create_packages("npm", ["1.10.0", "1.2.0", "1.0.0"], name="exp-pkg")
sorted_packages = sort_version(packages, package_type="npm")
self.assertEqual("1.0.0", sorted_packages[0].version)

def test_get_latest_version_integration(self):
"""Test get_latest_version uses sort_version correctly."""
packages = self._create_packages("npm", ["1.0.0", "1.10.0", "1.2.0"], name="test-pkg")
latest = packages[0].get_latest_version()
self.assertEqual("1.10.0", latest.version)
133 changes: 133 additions & 0 deletions packagedb/tests/test_sort_version_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# purldb is a trademark of nexB Inc.
# SPDX-License-Identifier: Apache-2.0
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
# See https://github.com/aboutcode-org/purldb for support or download.
# See https://aboutcode.org for more information about nexB OSS projects.
#

from django.test import TransactionTestCase

from packagedb.models import Package
from packagedb.models import sort_version


class SortVersionIntegrationTestCase(TransactionTestCase):
"""Integration tests for sort_version with real-world PURL data."""

def tearDown(self):
Package.objects.all().delete()

def _test_ecosystem_sorting(self, pkg_type, versions_unordered, expected_ordered, **kwargs):
"""Test version sorting for any ecosystem."""
packages = [
Package.objects.create(
download_url=f"http://{pkg_type}-{hash(version)}.com",
type=pkg_type,
version=version,
**kwargs,
)
for version in versions_unordered
]
sorted_versions = [p.version for p in sort_version(packages)]
self.assertEqual(expected_ordered, sorted_versions)

def test_ecosystem_versions(self):
"""Test version sorting for multiple real-world ecosystems."""
test_cases = [
(
"npm",
["4.17.21", "4.17.20", "4.17.10", "4.17.4", "4.16.6", "4.0.0", "3.10.1", "1.3.1"],
["1.3.1", "3.10.1", "4.0.0", "4.16.6", "4.17.4", "4.17.10", "4.17.20", "4.17.21"],
{"name": "lodash"},
),
(
"pypi",
["4.1", "4.1rc1", "4.1b1", "4.1a1", "4.0.8", "4.0", "3.2.16", "2.1.15"],
["2.1.15", "3.2.16", "4.0", "4.0.8", "4.1a1", "4.1b1", "4.1rc1", "4.1"],
{"name": "django"},
),
(
"maven",
["4.13.2", "4.13", "4.10", "4.8.2", "4.5", "3.8.2", "3.8.1"],
["3.8.1", "3.8.2", "4.5", "4.8.2", "4.10", "4.13", "4.13.2"],
{"namespace": "junit", "name": "junit"},
),
(
"gem",
["7.0.4", "7.0.3.1", "6.1.6.1", "6.0.6", "5.2.8.1", "5.2.0"],
["5.2.0", "5.2.8.1", "6.0.6", "6.1.6.1", "7.0.3.1", "7.0.4"],
{"name": "rails"},
),
(
"nuget",
["13.0.1", "12.0.3", "10.0.3", "9.0.1", "8.0.3", "6.0.8"],
["6.0.8", "8.0.3", "9.0.1", "10.0.3", "12.0.3", "13.0.1"],
{"name": "Newtonsoft.Json"},
),
(
"cargo",
["1.0.147", "1.0.100", "1.0.10", "1.0.0", "0.9.15", "0.9.0"],
["0.9.0", "0.9.15", "1.0.0", "1.0.10", "1.0.100", "1.0.147"],
{"name": "serde"},
),
(
"deb",
["2.31-13+deb11u5", "2.31-13", "2.28-10", "2.27-3ubuntu1", "2.24-11+deb9u4"],
["2.24-11+deb9u4", "2.27-3ubuntu1", "2.28-10", "2.31-13", "2.31-13+deb11u5"],
{"name": "libc6"},
),
(
"golang",
["v1.8.1", "v1.7.0", "v1.5.0", "v1.2.0", "v1.0.0", "v0.9.0"],
["v0.9.0", "v1.0.0", "v1.2.0", "v1.5.0", "v1.7.0", "v1.8.1"],
{"namespace": "github.com/pkg", "name": "errors"},
),
]
for pkg_type, unsorted, expected, kwargs in test_cases:
with self.subTest(pkg_type=pkg_type):
self._test_ecosystem_sorting(pkg_type, unsorted, expected, **kwargs)

def test_swift_with_git_tag_suffix(self):
"""
Test Swift packages with Git tag suffixes (issue #808).

Swift is unsupported by univers, so uses natsort fallback.
Versions with ^{} suffix should come after their base versions.
"""
versions = ["5.6.4", "5.6.4^{}", "5.4.4", "5.4.4^{}", "5.2.2", "5.2.2^{}", "4.8.2"]
packages = [
Package.objects.create(
download_url=f"http://swift-{i}.com",
type="swift",
name="Alamofire",
version=version,
)
for i, version in enumerate(versions)
]
sorted_versions = [p.version for p in sort_version(packages)]

# Base versions should come before their ^{} suffixed versions
self.assertLess(sorted_versions.index("5.2.2"), sorted_versions.index("5.2.2^{}"))
self.assertLess(sorted_versions.index("5.4.4"), sorted_versions.index("5.4.4^{}"))

def test_cross_ecosystem_latest_version(self):
"""Test get_latest_version across different ecosystems."""
# npm
npm_pkgs = [
Package.objects.create(
download_url=f"http://npm-{i}.com", type="npm", name="test", version=v
)
for i, v in enumerate(["1.0.0", "1.10.0", "1.2.0"])
]
self.assertEqual(npm_pkgs[1], npm_pkgs[0].get_latest_version())

# pypi
pypi_pkgs = [
Package.objects.create(
download_url=f"http://pypi-{i}.com", type="pypi", name="pkg", version=v
)
for i, v in enumerate(["2.0", "2.0.1", "2.0a1"])
]
self.assertEqual(pypi_pkgs[1], pypi_pkgs[0].get_latest_version())