# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import json
from dataclasses import asdict
from itertools import chain
from pathlib import Path
from string import ascii_lowercase, digits
import pytest
from meta_package_manager.package import (
PurlRuleError,
manager_purl,
parse_purl,
purl_quote,
)
from meta_package_manager.pool import pool
from meta_package_manager.specifier import PURL_MAP, EmptyReduction, Solver, Specifier
[docs]
def test_purl_map():
# Check pURL type IDs. See:
# https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component
for purl_type in PURL_MAP:
assert purl_type.isascii()
assert purl_type[0] in ascii_lowercase
assert set(purl_type).issubset(ascii_lowercase + digits + ".+-")
# Check that keys are sorted.
assert list(PURL_MAP.keys()) == sorted(PURL_MAP.keys())
# Check that all registered managers are known to the pool.
referenced_manager_ids = set(
chain.from_iterable(ids for ids in PURL_MAP.values() if ids is not None)
)
assert set(pool.all_manager_ids).issuperset(referenced_manager_ids), (
"Unknown manager IDs found in PURL_MAP: "
+ ", ".join(referenced_manager_ids.difference(pool.all_manager_ids))
)
# Check that each manager whose ID is also a pURL type is registered with itself.
for manager_id in pool.all_manager_ids:
if manager_id in PURL_MAP:
purl_managers = PURL_MAP[manager_id]
if purl_managers is not None:
assert manager_id in purl_managers, (
f"Manager ID '{manager_id}' is not registered with itself in "
"PURL_MAP."
)
[docs]
def props(spec: Specifier):
"""Utility to help compares specifiers between themselves.
I.e. all properties of specifiers are the same but the `raw_specs` property.
"""
return sorted((k, v) for k, v in asdict(spec).items() if k != "raw_spec")
[docs]
@pytest.mark.parametrize(
("spec_string", "expected"),
(
pytest.param(
"leftpad",
({"package_id": "leftpad", "manager_id": None, "version": None},),
id="ascii",
),
pytest.param(
"left_pad",
({"package_id": "left_pad", "manager_id": None, "version": None},),
id="underscore",
),
pytest.param(
"left.pad",
({"package_id": "left.pad", "manager_id": None, "version": None},),
id="dot",
),
pytest.param(
"left-pad",
({"package_id": "left-pad", "manager_id": None, "version": None},),
id="dash",
),
pytest.param(
"left/pad",
({"package_id": "left/pad", "manager_id": None, "version": None},),
id="slash",
),
pytest.param(
"left-pad@",
({"package_id": "left-pad@", "manager_id": None, "version": None},),
id="version_separator",
),
pytest.param(
"[email protected]",
({"package_id": "left-pad", "manager_id": None, "version": "8.a7"},),
id="version_spec",
),
pytest.param(
"[email protected]@1.2.3",
({"package_id": "[email protected]", "manager_id": None, "version": "1.2.3"},),
id="double_version_separator",
),
pytest.param(
"@eslint/json",
({"package_id": "@eslint/json", "manager_id": None, "version": None},),
id="starting_with_at_sign",
),
pytest.param(
"@eslint/[email protected]",
({"package_id": "@eslint/json", "manager_id": None, "version": "0.9.0"},),
id="at_sign_and_version",
),
pytest.param(
"pkg:gem/dummy",
({"package_id": "dummy", "manager_id": "gem", "version": None},),
id="purl_with_identical_manager",
),
pytest.param(
"pkg:gem/[email protected]",
({"package_id": "dummy", "manager_id": "gem", "version": "1.2.3"},),
id="purl_with_version",
),
pytest.param(
"pkg:rubygems/[email protected]",
({"package_id": "dummy", "manager_id": "gem", "version": "21.0-b"},),
id="purl_single_alias",
),
pytest.param(
"pkg:steamcmd/half-life@3",
({"package_id": "half-life", "manager_id": "steamcmd", "version": "3"},),
id="purl_unregistered_manager",
),
pytest.param(
"pkg:deb/debian/[email protected]",
(
{"package_id": "curl", "manager_id": "apt", "version": "8.14.1"},
{"package_id": "curl", "manager_id": "apt-mint", "version": "8.14.1"},
{"package_id": "curl", "manager_id": "aptitude", "version": "8.14.1"},
{"package_id": "curl", "manager_id": "nala", "version": "8.14.1"},
),
id="purl_qualifying_namespace",
),
pytest.param(
"pkg:npm/[email protected]",
(
{
"package_id": "left-pad",
"manager_id": "bun",
"version": "2011-04.gamma",
},
{
"package_id": "left-pad",
"manager_id": "npm",
"version": "2011-04.gamma",
},
{
"package_id": "left-pad",
"manager_id": "pnpm",
"version": "2011-04.gamma",
},
{
"package_id": "left-pad",
"manager_id": "volta",
"version": "2011-04.gamma",
},
{
"package_id": "left-pad",
"manager_id": "yarn",
"version": "2011-04.gamma",
},
{
"package_id": "left-pad",
"manager_id": "yarn-berry",
"version": "2011-04.gamma",
},
),
id="purl_multiple_aliases",
),
pytest.param(
"pkg:brew/homebrew/cask/[email protected]",
({"package_id": "firefox", "manager_id": "cask", "version": "120.0"},),
id="purl_brew_cask_tap",
),
pytest.param(
"pkg:brew/Homebrew/Core/[email protected]",
({"package_id": "sqlite", "manager_id": "brew", "version": "3.43.2"},),
id="purl_brew_core_tap",
),
pytest.param(
"pkg:brew/hashicorp/tap/terraform",
(
{
"package_id": "hashicorp/tap/terraform",
"manager_id": "brew",
"version": None,
},
),
id="purl_brew_third_party_tap",
),
pytest.param(
"pkg:vscode-extension/ms-python/[email protected]",
(
{
"package_id": "ms-python.python",
"manager_id": "vscode",
"version": "2023.25.10292213",
},
{
"package_id": "ms-python.python",
"manager_id": "vscodium",
"version": "2023.25.10292213",
},
),
id="purl_vscode_extension",
),
),
)
def test_parse_specs(spec_string, expected):
specs = tuple(Specifier.from_string(spec_string))
spec_props = []
for spec in specs:
assert spec.raw_spec == spec_string
# Serialize each specifier into an hashable set without the 'raw_spec'
# field (which we checked above).
spec_props.append(props(spec))
assert sorted(spec_props) == sorted(sorted(s.items()) for s in expected)
PURL_CASES = json.loads(
(Path(__file__).parent / "purl-cases.json").read_text(encoding="UTF-8")
)
"""Manager-tied pURLs shared with `tests/gnome/run-tests.js`, which holds the GNOME
Shell extension's builder to the same strings."""
[docs]
@pytest.mark.parametrize("case", PURL_CASES, ids=lambda case: case["purl"])
def test_manager_purl_reads_back(case):
"""A manager-tied pURL renders as the shared table says.
It reads back to the same manager and package.
"""
manager_id, package_id, purl = case["manager_id"], case["package_id"], case["purl"]
assert manager_purl(manager_id, package_id) == purl
specs = Specifier.from_string(purl)
assert {spec.package_id for spec in specs} == {package_id}
assert manager_id in {spec.manager_id for spec in specs}
spec = Specifier(raw_spec=purl, package_id=package_id, manager_id=manager_id)
assert str(spec) == purl
[docs]
@pytest.mark.parametrize(
("purl", "expected"),
(
("pkg:npm/[email protected]", ("npm", None, "JSONStream", "1.3.5")),
("pkg:npm/%40babel/[email protected]", ("npm", "@babel", "core", "7.26.0")),
("pkg:npm/@babel/[email protected]", ("npm", "@babel", "core", "7.26.0")),
("pkg:npm/@babel/core", ("npm", "@babel", "core", None)),
("pkg:NPM/left-pad", ("npm", None, "left-pad", None)),
("pkg://npm/left-pad", ("npm", None, "left-pad", None)),
("pkg:npm/left-pad@", ("npm", None, "left-pad", None)),
(
"pkg:brew/Homebrew%2FCask/[email protected]",
("brew", "homebrew/cask", "firefox", "120.0"),
),
(
"pkg:deb/debian/[email protected]?arch=i386&distro=jessie",
("deb", "debian", "curl", "7.50.3-1"),
),
(
"pkg:pypi/[email protected]#src/rest_framework",
("pypi", None, "django-rest-framework", "3.15"),
),
("pkg:apt/libc6:[email protected]", ("apt", None, "libc6:amd64", "2.31")),
(
"pkg:golang/google.golang.org/genproto#googleapis/api/annotations",
("golang", "google.golang.org", "genproto", None),
),
(
"pkg:generic/[email protected]?download_url=https://openssl.org/openssl.tar.gz",
("generic", None, "openssl", "1.1.0g"),
),
("pkg:gem/[email protected]?", ("gem", None, "rails", "7.1.3")),
("pkg:npm/left-pad?arch=&platform=linux", ("npm", None, "left-pad", None)),
(
"pkg:bin/%2Fhome%2Fuser%2F.local%2Fbin%2Ffzf",
("bin", None, "/home/user/.local/bin/fzf", None),
),
(
"pkg:git/codeberg.org/forgejo/forgejo@a72d2c07",
("git", "codeberg.org", "forgejo/forgejo", "a72d2c07"),
),
("", None),
(" ", None),
("left-pad", None),
("brew:firefox", None),
("npm/left-pad", None),
("PKG:npm/left-pad", None),
("pkg:", None),
("pkg:npm", None),
("pkg:npm/", None),
("pkg:1npm/left-pad", None),
("pkg:n%70m/left-pad", None),
("pkg:npm/left-pad?flag", None),
("pkg:npm/left-pad?1arch=x86", None),
("pkg:npm/left-pad?ar%20ch=x86", None),
("pkg:npm/@babel/core/extra", None),
),
)
def test_parse_purl(purl, expected):
"""`parse_purl()` decodes and normalizes the parts, or refuses a string.
`None` marks a string that is not a pURL. `tests/test_purl_spec.py` runs the whole
purl-spec test suite.
"""
if expected is None:
with pytest.raises(ValueError):
parse_purl(purl)
else:
assert parse_purl(purl) == expected
[docs]
@pytest.mark.parametrize(
("purl", "lenient"),
(
("pkg:cpan/Perl::[email protected]", ("cpan", None, "Perl::Version", "1.013")),
("pkg:deb/[email protected]", ("deb", None, "curl", "8.14.1")),
("pkg:julia/[email protected]", ("julia", None, "Example", "0.5.3")),
("pkg:pypi/pallets/flask", ("pypi", "pallets", "flask", None)),
("pkg:gem/rails?Platform=java", ("gem", None, "rails", None)),
),
)
def test_parse_purl_breaking_a_type_rule(purl, lenient):
"""A pURL breaking a rule of its type is refused, and read outside strict mode."""
with pytest.raises(PurlRuleError):
parse_purl(purl)
assert parse_purl(purl, strict=False) == lenient
[docs]
def test_specifier_reads_a_purl_breaking_a_type_rule(caplog):
"""A pURL breaking a rule of its type resolves, with a warning naming the rule."""
(spec,) = Specifier.from_string("pkg:rubygems/[email protected]?Platform=java")
assert (spec.manager_id, spec.package_id) == ("gem", "rails")
assert "takes its qualifier keys in lowercase" in caplog.text
caplog.clear()
# `cpan` is also a manager ID: the pURL is one `mpm` writes, so no warning.
(spec,) = Specifier.from_string("pkg:cpan/Perl::[email protected]")
assert (spec.manager_id, spec.package_id) == ("cpan", "Perl::Version")
assert not caplog.text
[docs]
def test_purl_quote_encodes_a_slash():
"""A slash inside a segment is encoded.
packageurl-python leaves the one in a name alone.
"""
assert purl_quote("a/b:c") == "a%2Fb:c"
[docs]
@pytest.mark.parametrize(
("spec_strings", "target_managers", "expected"),
(
pytest.param(
("leftpad",),
None,
Specifier.from_string("leftpad"),
id="tuple_specs",
),
pytest.param(
["leftpad"],
None,
Specifier.from_string("leftpad"),
id="list_specs",
),
pytest.param(
{"leftpad"},
None,
Specifier.from_string("leftpad"),
id="set_specs",
),
pytest.param(
("leftpad", "leftpad"),
None,
Specifier.from_string("leftpad"),
id="duplicates",
),
pytest.param(
{"[email protected]"},
None,
Specifier.from_string("[email protected]"),
id="version_spec",
),
pytest.param(
{"left-pad", "[email protected]"},
None,
Specifier.from_string("[email protected]"),
id="version_and_unversioned_mix",
),
pytest.param(
{"pkg:gem/leftpad"},
None,
Specifier.from_string("pkg:gem/leftpad"),
id="purl_with_manager",
),
pytest.param(
{"pkg:gem/[email protected]"},
None,
Specifier.from_string("pkg:gem/[email protected]"),
id="purl_with_version",
),
pytest.param(
{"pkg:rubygems/[email protected]"},
None,
Specifier.from_string("pkg:gem/[email protected]"),
id="purl_single_alias",
),
pytest.param(
{"pkg:rpm/[email protected]"},
["dnf", "yum", "zypper"],
Specifier.from_string("pkg:dnf/[email protected]"),
id="purl_multiple_aliases_priority1",
),
pytest.param(
{"pkg:rpm/[email protected]"},
["zypper", "yum"],
Specifier.from_string("pkg:zypper/[email protected]"),
id="purl_multiple_aliases_priority2",
),
pytest.param(
{"pkg:rpm/[email protected]"},
["pypi"],
EmptyReduction,
id="purl_multiple_aliases_unmatching_manager_skip_package",
),
pytest.param(
{"pkg:rpm/[email protected]"},
None,
ValueError,
id="unresolvable_multiple_aliases",
),
pytest.param(
{"pkg:gem/leftpad", "[email protected]"},
None,
Specifier.from_string("pkg:gem/leftpad"),
id="purl_takes_precedence",
),
pytest.param(
{"leftpad", "[email protected]", "pkg:gem/[email protected]"},
None,
Specifier.from_string("pkg:gem/[email protected]"),
id="mixed_package_specs",
),
pytest.param(
{"[email protected]", "left-pad@0100"},
None,
Specifier.from_string("left-pad@0100"),
id="multiple_versions",
),
pytest.param(
{"[email protected]"},
None,
Specifier.from_string("[email protected]"),
id="duplicate_version_spec",
),
pytest.param(
{"[email protected]", "left-pad@99"},
None,
Specifier.from_string("[email protected]"),
id="similar_version_spec",
),
pytest.param(
{"[email protected]", "[email protected]"},
None,
ValueError,
id="equivalent_version_spec",
),
),
)
def test_reduce_specs(spec_strings, target_managers, expected):
solver = Solver(spec_strings, manager_priority=target_managers)
if isinstance(expected, type) and issubclass(expected, Exception):
with pytest.raises(expected):
solver.reduce_specs(solver.spec_pool)
else:
reduced_spec = solver.reduce_specs(solver.spec_pool)
expected = tuple(expected)
assert len(expected) == 1
assert props(reduced_spec) == props(expected[0])
[docs]
@pytest.mark.parametrize(
("spec_strings", "manager_priority", "expected"),
(
pytest.param(
{"pkg:uv/rich", "pkg:brew/rich"},
["uv", "brew"],
{"pkg:uv/rich", "pkg:brew/rich"},
id="distinct_explicit_managers_each_kept",
),
pytest.param(
{"pkg:rpm/[email protected]"},
["dnf", "yum"],
{"pkg:dnf/[email protected]"},
id="alias_purl_reduced_to_top_priority",
),
pytest.param(
{"rich", "[email protected]"},
None,
{"[email protected]"},
id="version_constraints_merged",
),
pytest.param(
{"pkg:gem/leftpad", "[email protected]"},
None,
{"pkg:gem/leftpad"},
id="explicit_manager_wins_over_manager_less",
),
pytest.param(
{"rich"},
None,
{"rich"},
id="single_manager_less",
),
),
)
def test_resolve_package_specs(spec_strings, manager_priority, expected):
"""Distinct explicit managers each survive as their own target.
Alias pURLs, version constraints and manager-less specs still reduce to one.
"""
solver = Solver(spec_strings, manager_priority=manager_priority)
resolved = {str(spec) for _package_id, spec in solver.resolve_package_specs()}
assert resolved == expected