-
Notifications
You must be signed in to change notification settings - Fork 203
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add pipeline to sort packages (#1686)
* Add pipeline to sort packages Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Add tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Add calculate_version_rank on Package Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Start enumerating from 1 Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Fix tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Return version rank anyhow Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Fix API tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Address review comments Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> --------- Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
- Loading branch information
Showing
7 changed files
with
241 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
vulnerabilities/migrations/0084_alter_package_options_package_version_rank.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
# Generated by Django 4.2.16 on 2024-12-04 11:50 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
("vulnerabilities", "0083_alter_packagechangelog_software_version_and_more"), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterModelOptions( | ||
name="package", | ||
options={ | ||
"ordering": [ | ||
"type", | ||
"namespace", | ||
"name", | ||
"version_rank", | ||
"version", | ||
"qualifiers", | ||
"subpath", | ||
] | ||
}, | ||
), | ||
migrations.AddField( | ||
model_name="package", | ||
name="version_rank", | ||
field=models.IntegerField( | ||
default=0, | ||
help_text="Rank of the version to support ordering by version. Rank zero means the rank has not been defined yet", | ||
), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
# | ||
# Copyright (c) nexB Inc. and others. All rights reserved. | ||
# VulnerableCode 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/vulnerablecode for support or download. | ||
# See https://aboutcode.org for more information about nexB OSS projects. | ||
# | ||
|
||
from itertools import groupby | ||
|
||
from aboutcode.pipeline import LoopProgress | ||
from django.db import transaction | ||
from univers.version_range import RANGE_CLASS_BY_SCHEMES | ||
from univers.versions import Version | ||
|
||
from vulnerabilities.models import Package | ||
from vulnerabilities.pipelines import VulnerableCodePipeline | ||
|
||
|
||
class ComputeVersionRankPipeline(VulnerableCodePipeline): | ||
""" | ||
A pipeline to compute and assign version ranks for all packages. | ||
""" | ||
|
||
pipeline_id = "compute_version_rank" | ||
license_expression = None | ||
|
||
@classmethod | ||
def steps(cls): | ||
return (cls.compute_and_store_version_rank,) | ||
|
||
def compute_and_store_version_rank(self): | ||
""" | ||
Compute and assign version ranks to all packages. | ||
""" | ||
groups = Package.objects.only("type", "namespace", "name").order_by( | ||
"type", "namespace", "name" | ||
) | ||
|
||
def key(package): | ||
return package.type, package.namespace, package.name | ||
|
||
groups = groupby(groups, key=key) | ||
|
||
groups = [(list(x), list(y)) for x, y in groups] | ||
|
||
total_groups = len(groups) | ||
self.log(f"Calculating `version_rank` for {total_groups:,d} groups of packages.") | ||
|
||
progress = LoopProgress( | ||
total_iterations=total_groups, | ||
logger=self.log, | ||
progress_step=5, | ||
) | ||
|
||
for group, packages in progress.iter(groups): | ||
type, namespace, name = group | ||
if type not in RANGE_CLASS_BY_SCHEMES: | ||
continue | ||
self.update_version_rank_for_group(packages) | ||
|
||
self.log("Successfully populated `version_rank` for all packages.") | ||
|
||
@transaction.atomic | ||
def update_version_rank_for_group(self, packages): | ||
""" | ||
Update the `version_rank` for all packages in a specific group. | ||
""" | ||
|
||
# Sort the packages by version | ||
sorted_packages = self.sort_packages_by_version(packages) | ||
|
||
# Assign version ranks | ||
updates = [] | ||
for rank, package in enumerate(sorted_packages, start=1): | ||
package.version_rank = rank | ||
updates.append(package) | ||
|
||
# Bulk update to save the ranks | ||
Package.objects.bulk_update(updates, fields=["version_rank"]) | ||
|
||
def sort_packages_by_version(self, packages): | ||
""" | ||
Sort packages by version using `version_class`. | ||
""" | ||
|
||
if not packages: | ||
return [] | ||
version_class = RANGE_CLASS_BY_SCHEMES.get(packages[0].type).version_class | ||
if not version_class: | ||
version_class = Version | ||
return sorted(packages, key=lambda p: version_class(p.version)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
vulnerabilities/tests/test_compute_package_version_rank.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
from unittest.mock import patch | ||
|
||
import pytest | ||
from univers.versions import Version | ||
|
||
from vulnerabilities.models import Package | ||
from vulnerabilities.pipelines.compute_package_version_rank import ComputeVersionRankPipeline | ||
|
||
|
||
@pytest.mark.django_db | ||
class TestComputeVersionRankPipeline: | ||
@pytest.fixture | ||
def pipeline(self): | ||
return ComputeVersionRankPipeline() | ||
|
||
@pytest.fixture | ||
def packages(self, db): | ||
package_type = "pypi" | ||
namespace = "test_namespace" | ||
name = "test_package" | ||
Package.objects.create(type=package_type, namespace=namespace, name=name, version="1.0.0") | ||
Package.objects.create(type=package_type, namespace=namespace, name=name, version="1.1.0") | ||
Package.objects.create(type=package_type, namespace=namespace, name=name, version="0.9.0") | ||
return Package.objects.filter(type=package_type, namespace=namespace, name=name) | ||
|
||
def test_compute_and_store_version_rank(self, pipeline, packages): | ||
with patch.object(pipeline, "log") as mock_log: | ||
pipeline.compute_and_store_version_rank() | ||
assert mock_log.call_count > 0 | ||
for package in packages: | ||
assert package.version_rank is not None | ||
|
||
def test_update_version_rank_for_group(self, pipeline, packages): | ||
with patch.object(Package.objects, "bulk_update") as mock_bulk_update: | ||
pipeline.update_version_rank_for_group(packages) | ||
mock_bulk_update.assert_called_once() | ||
updated_packages = mock_bulk_update.call_args[0][0] | ||
assert len(updated_packages) == len(packages) | ||
for idx, package in enumerate(sorted(packages, key=lambda p: Version(p.version))): | ||
assert updated_packages[idx].version_rank == idx | ||
|
||
def test_sort_packages_by_version(self, pipeline, packages): | ||
sorted_packages = pipeline.sort_packages_by_version(packages) | ||
versions = [p.version for p in sorted_packages] | ||
assert versions == sorted(versions, key=Version) | ||
|
||
def test_sort_packages_by_version_empty(self, pipeline): | ||
assert pipeline.sort_packages_by_version([]) == [] | ||
|
||
def test_sort_packages_by_version_invalid_scheme(self, pipeline, packages): | ||
for package in packages: | ||
package.type = "invalid" | ||
assert pipeline.sort_packages_by_version(packages) == [] | ||
|
||
def test_compute_and_store_version_rank_invalid_scheme(self, pipeline): | ||
Package.objects.create(type="invalid", namespace="test", name="package", version="1.0.0") | ||
with patch.object(pipeline, "log") as mock_log: | ||
pipeline.compute_and_store_version_rank() | ||
mock_log.assert_any_call("Successfully populated `version_rank` for all packages.") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters