Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better errors when an invalid requirement is encountered #507

Merged
merged 6 commits into from
Feb 1, 2023
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All versions prior to 0.0.9 are untracked.

## [Unreleased]

### Changed

* Improved error messaging when a requirements input or indirect dependency
has an invalid (non-PEP 440) requirements specifier
([#507](https://github.com/pypa/pip-audit/pull/507))

## [2.4.15]

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions pip_audit/_dependency_source/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DependencySourceError,
HashMismatchError,
HashMissingError,
InvalidRequirementSpecifier,
RequirementHashes,
UnsupportedHashAlgorithm,
)
Expand All @@ -27,6 +28,7 @@
"DependencySourceError",
"HashMismatchError",
"HashMissingError",
"InvalidRequirementSpecifier",
"PipSource",
"PipSourceError",
"PyProjectSource",
Expand Down
7 changes: 7 additions & 0 deletions pip_audit/_dependency_source/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ class UnsupportedHashAlgorithm(Exception):
pass


class InvalidRequirementSpecifier(DependencySourceError):
"""
A `DependencySourceError` specialized for the case of a non-PEP 440 requirements
specifier.
"""


class RequirementHashes:
"""
Represents the hashes contained within a requirements file.
Expand Down
10 changes: 6 additions & 4 deletions pip_audit/_dependency_source/requirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
DependencyResolverError,
DependencySource,
DependencySourceError,
InvalidRequirementSpecifier,
RequirementHashes,
)
from pip_audit._fix import ResolvedFixVersion
Expand Down Expand Up @@ -89,10 +90,11 @@ def collect(self) -> Iterator[Dependency]:
for filename in self._filenames:
try:
rf = RequirementsFile.from_file(filename)
if rf.invalid_lines:
raise RequirementSourceError(
f"requirement file {filename} contains invalid lines: "
f"{str(rf.invalid_lines)}"
if len(rf.invalid_lines) > 0:
invalid = rf.invalid_lines[0]
raise InvalidRequirementSpecifier(
f"requirement file {filename} contains invalid specifier at "
f"line {invalid.line_number}: {invalid.error_message}"
)

reqs: list[InstallRequirement] = []
Expand Down
99 changes: 57 additions & 42 deletions pip_audit/_dependency_source/resolvelib/pypi_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@
import html5lib
import requests
from cachecontrol import CacheControl
from packaging.requirements import Requirement
from packaging.requirements import InvalidRequirement, Requirement
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
from resolvelib.resolvers import RequirementInformation

from pip_audit._dependency_source import RequirementHashes, UnsupportedHashAlgorithm
from pip_audit._dependency_source import (
InvalidRequirementSpecifier,
RequirementHashes,
UnsupportedHashAlgorithm,
)
from pip_audit._service import SkippedDependency
from pip_audit._state import AuditState
from pip_audit._util import python_version
Expand Down Expand Up @@ -134,7 +138,16 @@ def _get_dependencies(self) -> Iterator[Requirement]:
extras = self.extras if self.extras else [""]

for d in deps:
r = Requirement(d)
# NOTE: packaging 22 and later are more strict about PEP 440 requirements,
# meaning that pre-existing packages might have invalid requirements
# specifiers in their dependency sets (e.g. `pytz (>dev)`).
try:
r = Requirement(d)
except InvalidRequirement as exc:
raise InvalidRequirementSpecifier(
f"{self.name} has invalid requirement '{d}': {exc}"
)

if r.marker is None:
yield r
else:
Expand Down Expand Up @@ -440,49 +453,51 @@ def find_matches(
for r in requirements:
extras |= r.extras

# Need to pass the extras to the search, so they
# are added to the candidate at creation - we
# treat candidates as immutable once created.
all_candidates = get_project_from_indexes(
self.index_urls,
self.session,
identifier,
requirements,
extras,
self.req_hashes,
self.timeout,
self._state,
)

candidates: list[ResolvedCandidate]
try:
candidates = sorted(
[
candidate
for candidate in all_candidates
if candidate.version not in bad_versions
# NOTE(ww): We use `filter(...)` instead of checking
# `candidate.version in r.specifier` because the former has subtle (and PEP 440
# mandated) behavior around prereleases. Specifically, `filter(...)`
# returns prereleases even if not explicitly configured, but only if
# there are no non-prereleases.
# See: https://github.com/pypa/pip-audit/issues/472
and all([any(r.specifier.filter((candidate.version,))) for r in requirements])
# HACK(ww): Additionally check that each candidate's name matches the
# expected project name (identifier).
# This technically shouldn't be required, but parsing distribution names
# from package indices is imprecise/unreliable when distribution filenames
# are PEP 440 compliant but not normalized.
# See: https://github.com/pypa/packaging/issues/527
and candidate.name == identifier
],
key=attrgetter("version", "is_wheel"),
reverse=True,
# Need to pass the extras to the search, so that they are added to the candidate at
# creation, since we treat candidates as immutable once created.
# We also eagerly collect the candidate list here, to pull any "not found"
# exceptions out before doing candidate filtering below.
all_candidates = list(
get_project_from_indexes(
self.index_urls,
self.session,
identifier,
requirements,
extras,
self.req_hashes,
self.timeout,
self._state,
)
)
except PyPINotFoundError as e:
yield SkippedCandidate(name=identifier, skip_reason=str(e))
except PyPINotFoundError as exc:
yield SkippedCandidate(name=identifier, skip_reason=str(exc))
return

candidates: list[ResolvedCandidate] = sorted(
[
candidate
for candidate in all_candidates
if candidate.version not in bad_versions
# NOTE(ww): We use `filter(...)` instead of checking
# `candidate.version in r.specifier` because the former has subtle (and PEP 440
# mandated) behavior around prereleases. Specifically, `filter(...)`
# returns prereleases even if not explicitly configured, but only if
# there are no non-prereleases.
# See: https://github.com/pypa/pip-audit/issues/472
and all([any(r.specifier.filter((candidate.version,))) for r in requirements])
# HACK(ww): Additionally check that each candidate's name matches the
# expected project name (identifier).
# This technically shouldn't be required, but parsing distribution names
# from package indices is imprecise/unreliable when distribution filenames
# are PEP 440 compliant but not normalized.
# See: https://github.com/pypa/packaging/issues/527
and candidate.name == identifier
],
key=attrgetter("version", "is_wheel"),
reverse=True,
)

logger.debug(f"{identifier} has candidates: {candidates}")

# If we have multiple candidates for a single version and some are wheels,
Expand Down
28 changes: 27 additions & 1 deletion test/dependency_source/resolvelib/test_pypi_provider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from email.message import Message
from pathlib import Path
from subprocess import CalledProcessError

Expand All @@ -6,12 +7,13 @@
from packaging.version import Version

from pip_audit._dependency_source import RequirementHashes
from pip_audit._dependency_source.interface import InvalidRequirementSpecifier
from pip_audit._dependency_source.resolvelib import pypi_provider
from pip_audit._dependency_source.resolvelib.pypi_provider import ResolvedCandidate
from pip_audit._virtual_env import VirtualEnvError


class TestCandidate:
class TestResolvedCandidate:
def test_get_metadata_for_sdist_venv_create_fails(self, monkeypatch):
virtualenv_obj = pretend.stub(
create=pretend.call_recorder(
Expand Down Expand Up @@ -53,6 +55,30 @@ def test_get_metadata_for_sdist_venv_create_fails(self, monkeypatch):
"Installing source distribution in isolated environment for fakepkg (1.0.0)"
)

@pytest.mark.parametrize("invalid", ["pytz (>dev)", "fakedep>=3.*"])
def test_get_dependencies_invalid_req_specifer(self, invalid, monkeypatch):
candidate = ResolvedCandidate(
"fakepkg",
"fakepkg",
Path("fakepath"),
Version("1.0.0"),
url="hxxps://fake.url",
extras=set(),
is_wheel=False,
reqs=[],
session=pretend.stub(),
timeout=None,
state=pretend.stub(),
req_hashes=RequirementHashes(),
)

metadata = Message()
metadata["Requires-Dist"] = invalid
monkeypatch.setattr(candidate, "_metadata", metadata)

with pytest.raises(InvalidRequirementSpecifier):
list(candidate._get_dependencies())


def test_get_project_from_index_relative_url():
data = """
Expand Down