Skip to content

Commit fcf9eb0

Browse files
committed
Fix: PURL version sorting with univers library and validation
Signed-off-by: Rejwanul Hoque <hoquerejwanulrh@gmail.com>
1 parent 2577ba0 commit fcf9eb0

File tree

3 files changed

+287
-3
lines changed

3 files changed

+287
-3
lines changed

packagedb/models.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django.utils.translation import gettext_lazy as _
2727

2828
import natsort
29+
from univers.version_range import RANGE_CLASS_BY_SCHEMES
2930
from aboutcode.federatedcode.contrib.django.models import (
3031
FederatedCodePackageActivityMixin,
3132
)
@@ -46,9 +47,36 @@
4647
logger.setLevel(logging.INFO)
4748

4849

49-
def sort_version(packages):
50-
"""Return the packages sorted by version."""
51-
return natsort.natsorted(packages, key=lambda p: p.version.replace(".", "~") + "z")
50+
def sort_version(packages, package_type=None):
51+
"""Return the packages sorted by version using proper version scheme."""
52+
if not packages:
53+
return []
54+
55+
# Get the first package to determine the type
56+
try:
57+
sample_package = packages[0]
58+
except TypeError:
59+
# Fallback for generators
60+
packages = list(packages)
61+
if not packages:
62+
return []
63+
sample_package = packages[0]
64+
65+
pkg_type = package_type or sample_package.type
66+
67+
# Get the appropriate version class for this package type
68+
range_class = RANGE_CLASS_BY_SCHEMES.get(pkg_type)
69+
if range_class:
70+
version_class = range_class.version_class
71+
try:
72+
return sorted(packages, key=lambda p: version_class(p.version))
73+
except Exception as e:
74+
logger.warning(
75+
f"Version parsing failed for {package_type}, using natsort fallback: {e}"
76+
)
77+
78+
# Fallback to natural sorting
79+
return natsort.natsorted(packages, key=lambda p: p.version)
5280

5381

5482
class PackageQuerySet(PackageURLQuerySetMixin, models.QuerySet):

packagedb/tests/test_models.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from packagedb.models import PackageWatch
2121
from packagedb.models import Party
2222
from packagedb.models import Resource
23+
from packagedb.models import sort_version
2324

2425

2526
class ResourceModelTestCase(TransactionTestCase):
@@ -494,3 +495,125 @@ def test_get_or_none(self):
494495
package = Package.objects.filter(download_url="http://a.ab").get_or_none()
495496
assert package
496497
assert Package.objects.filter(download_url="http://a.ab-foobar").get_or_none() is None
498+
499+
500+
class SortVersionTestCase(TransactionTestCase):
501+
"""Comprehensive tests for the sort_version function."""
502+
503+
def tearDown(self):
504+
Package.objects.all().delete()
505+
506+
def _create_packages(self, pkg_type, versions, **kwargs):
507+
"""Create packages with given versions."""
508+
return [
509+
Package.objects.create(
510+
download_url=f"http://{pkg_type}-{hash(version)}.com",
511+
type=pkg_type,
512+
version=version,
513+
**kwargs,
514+
)
515+
for version in versions
516+
]
517+
518+
def test_sort_version_empty_list(self):
519+
"""Test sorting an empty list."""
520+
self.assertEqual([], sort_version([]))
521+
522+
def test_ecosystem_versions(self):
523+
"""Test version sorting across multiple package ecosystems."""
524+
test_cases = [
525+
(
526+
"npm",
527+
["1.10.0", "2.0.0", "1.0.0", "1.2.0"],
528+
["1.0.0", "1.2.0", "1.10.0", "2.0.0"],
529+
{"name": "lodash"},
530+
),
531+
(
532+
"pypi",
533+
["1.0.1", "1.0rc1", "1.0", "1.0a1", "1.0b1"],
534+
["1.0a1", "1.0b1", "1.0rc1", "1.0", "1.0.1"],
535+
{"name": "django"},
536+
),
537+
(
538+
"maven",
539+
["4.10", "4.0", "4.2"],
540+
["4.0", "4.2", "4.10"],
541+
{"namespace": "junit", "name": "junit"},
542+
),
543+
(
544+
"swift",
545+
["2.0.0", "1.1.5", "1.0.0", "1.1.5^{}"],
546+
["1.0.0", "1.1.5", "1.1.5^{}", "2.0.0"],
547+
{"name": "alamofire"},
548+
),
549+
("gem", ["4.0.0", "3.0.0", "3.2.0"], ["3.0.0", "3.2.0", "4.0.0"], {"name": "rails"}),
550+
(
551+
"deb",
552+
["1.0-10", "1.0-1", "1.0-2"],
553+
["1.0-1", "1.0-2", "1.0-10"],
554+
{"name": "deb-pkg"},
555+
),
556+
(
557+
"nuget",
558+
["11.0.0", "10.0.0", "9.0.0"],
559+
["9.0.0", "10.0.0", "11.0.0"],
560+
{"name": "Newtonsoft.Json"},
561+
),
562+
("generic", ["1.10", "1.0", "1.2"], ["1.0", "1.2", "1.10"], {"name": "gen-pkg"}),
563+
(
564+
"cargo",
565+
["1.0.100", "1.0.0", "1.0.20"],
566+
["1.0.0", "1.0.20", "1.0.100"],
567+
{"name": "serde"},
568+
),
569+
(
570+
"composer",
571+
["4.0.0", "3.0.0", "3.1.0"],
572+
["3.0.0", "3.1.0", "4.0.0"],
573+
{"name": "sf-console"},
574+
),
575+
(
576+
"golang",
577+
["v0.9.1", "v0.8.0", "v0.9.0"],
578+
["v0.8.0", "v0.9.0", "v0.9.1"],
579+
{"namespace": "github.com/pkg", "name": "errors"},
580+
),
581+
(
582+
"rpm",
583+
["3.10.0-10", "3.10.0-1", "3.10.0-2"],
584+
["3.10.0-1", "3.10.0-2", "3.10.0-10"],
585+
{"name": "kernel"},
586+
),
587+
("unknown-type", ["1.10", "1.0", "1.2"], ["1.0", "1.2", "1.10"], {"name": "unk-pkg"}),
588+
(
589+
"npm",
590+
["invalid-10", "invalid-1", "invalid-2"],
591+
["invalid-1", "invalid-2", "invalid-10"],
592+
{"name": "inv-test"},
593+
),
594+
]
595+
596+
for pkg_type, unsorted, expected, kwargs in test_cases:
597+
with self.subTest(pkg_type=pkg_type):
598+
packages = self._create_packages(pkg_type, unsorted, **kwargs)
599+
sorted_versions = [p.version for p in sort_version(packages)]
600+
self.assertEqual(expected, sorted_versions, f"Failed for {pkg_type}")
601+
602+
def test_sort_version_generator_input(self):
603+
"""Test with generator input."""
604+
packages = self._create_packages("npm", ["1.10.0", "1.0.0", "1.5.0"], name="gen-pkg")
605+
gen = (p for p in packages)
606+
sorted_packages = sort_version(gen)
607+
self.assertEqual(3, len(sorted_packages))
608+
609+
def test_sort_version_explicit_type(self):
610+
"""Test with explicit package_type parameter."""
611+
packages = self._create_packages("npm", ["1.10.0", "1.2.0", "1.0.0"], name="exp-pkg")
612+
sorted_packages = sort_version(packages, package_type="npm")
613+
self.assertEqual("1.0.0", sorted_packages[0].version)
614+
615+
def test_get_latest_version_integration(self):
616+
"""Test get_latest_version uses sort_version correctly."""
617+
packages = self._create_packages("npm", ["1.0.0", "1.10.0", "1.2.0"], name="test-pkg")
618+
latest = packages[0].get_latest_version()
619+
self.assertEqual("1.10.0", latest.version)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# purldb is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/purldb for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from django.test import TransactionTestCase
11+
12+
from packagedb.models import Package
13+
from packagedb.models import sort_version
14+
15+
16+
class SortVersionIntegrationTestCase(TransactionTestCase):
17+
"""Integration tests for sort_version with real-world PURL data."""
18+
19+
def tearDown(self):
20+
Package.objects.all().delete()
21+
22+
def _test_ecosystem_sorting(self, pkg_type, versions_unordered, expected_ordered, **kwargs):
23+
"""Test version sorting for any ecosystem."""
24+
packages = [
25+
Package.objects.create(
26+
download_url=f"http://{pkg_type}-{hash(version)}.com",
27+
type=pkg_type,
28+
version=version,
29+
**kwargs,
30+
)
31+
for version in versions_unordered
32+
]
33+
sorted_versions = [p.version for p in sort_version(packages)]
34+
self.assertEqual(expected_ordered, sorted_versions)
35+
36+
def test_ecosystem_versions(self):
37+
"""Test version sorting for multiple real-world ecosystems."""
38+
test_cases = [
39+
(
40+
"npm",
41+
["4.17.21", "4.17.20", "4.17.10", "4.17.4", "4.16.6", "4.0.0", "3.10.1", "1.3.1"],
42+
["1.3.1", "3.10.1", "4.0.0", "4.16.6", "4.17.4", "4.17.10", "4.17.20", "4.17.21"],
43+
{"name": "lodash"},
44+
),
45+
(
46+
"pypi",
47+
["4.1", "4.1rc1", "4.1b1", "4.1a1", "4.0.8", "4.0", "3.2.16", "2.1.15"],
48+
["2.1.15", "3.2.16", "4.0", "4.0.8", "4.1a1", "4.1b1", "4.1rc1", "4.1"],
49+
{"name": "django"},
50+
),
51+
(
52+
"maven",
53+
["4.13.2", "4.13", "4.10", "4.8.2", "4.5", "3.8.2", "3.8.1"],
54+
["3.8.1", "3.8.2", "4.5", "4.8.2", "4.10", "4.13", "4.13.2"],
55+
{"namespace": "junit", "name": "junit"},
56+
),
57+
(
58+
"gem",
59+
["7.0.4", "7.0.3.1", "6.1.6.1", "6.0.6", "5.2.8.1", "5.2.0"],
60+
["5.2.0", "5.2.8.1", "6.0.6", "6.1.6.1", "7.0.3.1", "7.0.4"],
61+
{"name": "rails"},
62+
),
63+
(
64+
"nuget",
65+
["13.0.1", "12.0.3", "10.0.3", "9.0.1", "8.0.3", "6.0.8"],
66+
["6.0.8", "8.0.3", "9.0.1", "10.0.3", "12.0.3", "13.0.1"],
67+
{"name": "Newtonsoft.Json"},
68+
),
69+
(
70+
"cargo",
71+
["1.0.147", "1.0.100", "1.0.10", "1.0.0", "0.9.15", "0.9.0"],
72+
["0.9.0", "0.9.15", "1.0.0", "1.0.10", "1.0.100", "1.0.147"],
73+
{"name": "serde"},
74+
),
75+
(
76+
"deb",
77+
["2.31-13+deb11u5", "2.31-13", "2.28-10", "2.27-3ubuntu1", "2.24-11+deb9u4"],
78+
["2.24-11+deb9u4", "2.27-3ubuntu1", "2.28-10", "2.31-13", "2.31-13+deb11u5"],
79+
{"name": "libc6"},
80+
),
81+
(
82+
"golang",
83+
["v1.8.1", "v1.7.0", "v1.5.0", "v1.2.0", "v1.0.0", "v0.9.0"],
84+
["v0.9.0", "v1.0.0", "v1.2.0", "v1.5.0", "v1.7.0", "v1.8.1"],
85+
{"namespace": "github.com/pkg", "name": "errors"},
86+
),
87+
]
88+
for pkg_type, unsorted, expected, kwargs in test_cases:
89+
with self.subTest(pkg_type=pkg_type):
90+
self._test_ecosystem_sorting(pkg_type, unsorted, expected, **kwargs)
91+
92+
def test_swift_with_git_tag_suffix(self):
93+
"""
94+
Test Swift packages with Git tag suffixes (issue #808).
95+
96+
Swift is unsupported by univers, so uses natsort fallback.
97+
Versions with ^{} suffix should come after their base versions.
98+
"""
99+
versions = ["5.6.4", "5.6.4^{}", "5.4.4", "5.4.4^{}", "5.2.2", "5.2.2^{}", "4.8.2"]
100+
packages = [
101+
Package.objects.create(
102+
download_url=f"http://swift-{i}.com",
103+
type="swift",
104+
name="Alamofire",
105+
version=version,
106+
)
107+
for i, version in enumerate(versions)
108+
]
109+
sorted_versions = [p.version for p in sort_version(packages)]
110+
111+
# Base versions should come before their ^{} suffixed versions
112+
self.assertLess(sorted_versions.index("5.2.2"), sorted_versions.index("5.2.2^{}"))
113+
self.assertLess(sorted_versions.index("5.4.4"), sorted_versions.index("5.4.4^{}"))
114+
115+
def test_cross_ecosystem_latest_version(self):
116+
"""Test get_latest_version across different ecosystems."""
117+
# npm
118+
npm_pkgs = [
119+
Package.objects.create(
120+
download_url=f"http://npm-{i}.com", type="npm", name="test", version=v
121+
)
122+
for i, v in enumerate(["1.0.0", "1.10.0", "1.2.0"])
123+
]
124+
self.assertEqual(npm_pkgs[1], npm_pkgs[0].get_latest_version())
125+
126+
# pypi
127+
pypi_pkgs = [
128+
Package.objects.create(
129+
download_url=f"http://pypi-{i}.com", type="pypi", name="pkg", version=v
130+
)
131+
for i, v in enumerate(["2.0", "2.0.1", "2.0a1"])
132+
]
133+
self.assertEqual(pypi_pkgs[1], pypi_pkgs[0].get_latest_version())

0 commit comments

Comments
 (0)