diff --git a/CHANGELOG.md b/CHANGELOG.md index 33207547..182a9b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All versions prior to 0.0.9 are untracked. ## [Unreleased] +### Fixed + +* Fixed a crash triggered when a package specifies an invalid version + specifier for its `requires-python` version + ([#447](https://github.com/pypa/pip-audit/pull/447)) + ## [2.4.10] ### Fixed diff --git a/pip_audit/_dependency_source/resolvelib/pypi_provider.py b/pip_audit/_dependency_source/resolvelib/pypi_provider.py index 484531cd..b3a176e8 100644 --- a/pip_audit/_dependency_source/resolvelib/pypi_provider.py +++ b/pip_audit/_dependency_source/resolvelib/pypi_provider.py @@ -8,6 +8,7 @@ from __future__ import annotations import itertools +import logging from email.message import EmailMessage, Message from email.parser import BytesParser from io import BytesIO @@ -23,7 +24,7 @@ import requests from cachecontrol import CacheControl from packaging.requirements import Requirement -from packaging.specifiers import SpecifierSet +from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.utils import canonicalize_name, parse_sdist_filename, parse_wheel_filename from packaging.version import Version from resolvelib.providers import AbstractProvider @@ -34,6 +35,8 @@ from pip_audit._util import python_version from pip_audit._virtual_env import VirtualEnv, VirtualEnvError +logger = logging.getLogger(__name__) + # TODO: Final[Version] when our minimal Python is 3.8. PYTHON_VERSION: Version = python_version() @@ -246,9 +249,19 @@ def get_project_from_index( py_req = i.attrib.get("data-requires-python") # Skip items that need a different Python version if py_req: - spec = SpecifierSet(py_req) - if PYTHON_VERSION not in spec: - continue + try: + # NOTE: Starting with packaging==22.0, specifier parsing is + # stricter: specifier components can only use the wildcard + # comparison syntax on exact comparison operators (== and !=), + # not on ordered operators like `>=`. There are existing + # packages that use the invalid syntax in their metadata + # however (like nltk==3.6, which does requires-python >= 3.5.*), + # so we follow pip`'s behavior and ignore these specifiers. + spec = SpecifierSet(py_req) + if PYTHON_VERSION not in spec: + continue + except InvalidSpecifier: + logger.warning(f"invalid specifier set for Python version: {py_req}") path = parsed_dist_url.path filename = path.rpartition("/")[-1] diff --git a/test/dependency_source/resolvelib/test_resolvelib.py b/test/dependency_source/resolvelib/test_resolvelib.py index 8f8214b0..286312bb 100644 --- a/test/dependency_source/resolvelib/test_resolvelib.py +++ b/test/dependency_source/resolvelib/test_resolvelib.py @@ -2,6 +2,7 @@ from email.message import EmailMessage +import pretend import pytest import requests from packaging.requirements import Requirement @@ -221,6 +222,42 @@ def test_resolvelib_wheel_python_version(monkeypatch): dict(resolver.resolve_all(iter([req]))) +def test_resolvelib_wheel_python_version_invalid_specifier(monkeypatch): + # requires-python is meant to be a valid specifier version, but earlier + # versions of packaging allowed LegacyVersion parsing for invalid versions. + # This changed in packaging==22.0, so we follow pip's lead and ignore + # any Python version specifiers that aren't valid. + # Note that we intentionally test that version that *should* be skipped + # with a valid specifier (<=3.5.*) is instead included. + data = ( + 'Flask-2.0.1-py3-none-any.whl
' + ) + + logger = pretend.stub(warning=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(pypi_provider, "logger", logger) + + monkeypatch.setattr( + pypi_provider.Candidate, "_get_metadata_for_wheel", lambda _: get_metadata_mock() + ) + + resolver = resolvelib.ResolveLibResolver() + monkeypatch.setattr( + resolver.provider.session, "get", lambda _url, **kwargs: get_package_mock(data) + ) + + req = Requirement("flask==2.0.1") + resolved_deps = dict(resolver.resolve_all(iter([req]))) + assert req in resolved_deps + assert resolved_deps[req] == [ResolvedDependency("flask", Version("2.0.1"))] + + assert logger.warning.calls == [ + pretend.call("invalid specifier set for Python version: <=3.5.*") + ] + + def test_resolvelib_wheel_canonical_name_mismatch(monkeypatch): # Call the underlying wheel, Mask instead of Flask. This should throw an `ResolutionImpossible` # error.