From 9674a3481ef8221abb18bbc9afeabe28ba817f94 Mon Sep 17 00:00:00 2001 From: Matteo Vitali Date: Tue, 6 Jun 2023 08:35:07 +0200 Subject: [PATCH 1/9] Reintroduce preresolved dependencies optimization --- CHANGELOG.md | 5 + pip_audit/_dependency_source/__init__.py | 9 +- pip_audit/_dependency_source/interface.py | 8 ++ pip_audit/_dependency_source/requirement.py | 101 ++++++++++++++++++-- test/dependency_source/test_requirement.py | 91 ++++++++++++++++-- 5 files changed, 197 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a019662..dcf73625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All versions prior to 0.0.9 are untracked. ## [2.5.5] +### Changed + +* Reintroduce the preresolved dependency optimization + ([#610](https://github.com/pypa/pip-audit/pull/610)) + ### Fixed * Fixed a crash caused by incompatible dependency changes diff --git a/pip_audit/_dependency_source/__init__.py b/pip_audit/_dependency_source/__init__.py index 3e8eb6fc..3d19e722 100644 --- a/pip_audit/_dependency_source/__init__.py +++ b/pip_audit/_dependency_source/__init__.py @@ -2,7 +2,13 @@ Dependency source interfaces and implementations for `pip-audit`. """ -from .interface import PYPI_URL, DependencyFixError, DependencySource, DependencySourceError +from .interface import ( + PYPI_URL, + DependencyFixError, + DependencySource, + DependencySourceError, + InvalidRequirementSpecifier, +) from .pip import PipSource, PipSourceError from .pyproject import PyProjectSource from .requirement import RequirementSource @@ -12,6 +18,7 @@ "DependencyFixError", "DependencySource", "DependencySourceError", + "InvalidRequirementSpecifier", "PipSource", "PipSourceError", "PyProjectSource", diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index 8655ecc0..baf0ac02 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -57,3 +57,11 @@ class DependencyFixError(Exception): """ pass + +class InvalidRequirementSpecifier(DependencySourceError): + """ + A `DependencySourceError` specialized for the case of a non-PEP 440 requirements + specifier. + """ + + pass diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 33c9993f..8f5f0a0b 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -15,12 +15,22 @@ from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name -from pip_requirements_parser import InstallRequirement, InvalidRequirementLine, RequirementsFile - -from pip_audit._dependency_source import DependencyFixError, DependencySource, DependencySourceError +from packaging.version import Version +from pip_requirements_parser import ( + InstallRequirement, + InvalidRequirementLine, + RequirementsFile, +) + +from pip_audit._dependency_source import ( + DependencyFixError, + DependencySource, + DependencySourceError, + InvalidRequirementSpecifier, +) from pip_audit._fix import ResolvedFixVersion from pip_audit._service import Dependency -from pip_audit._service.interface import ResolvedDependency +from pip_audit._service.interface import ResolvedDependency, SkippedDependency from pip_audit._state import AuditState from pip_audit._virtual_env import VirtualEnv, VirtualEnvError @@ -123,11 +133,30 @@ def collect(self) -> Iterator[Dependency]: t.unlink() def _collect_from_files(self, filenames: list[Path]) -> Iterator[Dependency]: + # Figure out whether we have a fully resolved set of dependencies. + reqs: list[InstallRequirement] = [] + require_hashes: bool = self._require_hashes + for filename in filenames: + rf = RequirementsFile.from_file(filename) + 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}" + ) + + # If one or more requirements have a hash, this implies `--require-hashes`. + require_hashes = require_hashes or any(req.hash_options for req in rf.requirements) + reqs.extend(rf.requirements) + + # If the user has supplied `--no-deps` or there are hashed requirements, we should assume + # that we have a fully resolved set of dependencies and we should waste time by invoking + # `pip`. + if self._no_deps or require_hashes: + yield from self._collect_preresolved_deps(iter(reqs), require_hashes) + return + ve_args = [] - if self._no_deps: - ve_args.append("--no-deps") - if self._require_hashes: - ve_args.append("--require-hashes") for filename in filenames: ve_args.extend(["-r", str(filename)]) @@ -247,6 +276,62 @@ def _recover_files(self, tmp_files: list[IO[str]]) -> None: continue + def _collect_preresolved_deps( + self, reqs: Iterator[InstallRequirement], require_hashes: bool + ) -> Iterator[Dependency]: + """ + Collect pre-resolved (pinned) dependencies. + """ + req_names: set[str] = set() + for req in reqs: + if not req.hash_options and require_hashes: + raise RequirementSourceError(f"requirement {req.dumps()} does not contain a hash") + if req.req is None: + # PEP 508-style URL requirements don't have a pre-declared version, even + # when hashed; the `#egg=name==version` syntax is non-standard and not supported + # by `pip` itself. + # + # In this case, we can't audit the dependency so we should signal to the + # caller that we're skipping it. + yield SkippedDependency( + name=req.requirement_line.line, + skip_reason="could not deduce package version from URL requirement", + ) + continue + if self._skip_editable and req.is_editable: + yield SkippedDependency(name=req.name, skip_reason="requirement marked as editable") + if req.marker is not None and not req.marker.evaluate(): + continue # pragma: no cover + + # This means we have a duplicate requirement for the same package + if req.name in req_names: + raise RequirementSourceError( + f"package {req.name} has duplicate requirements: {str(req)}" + ) + req_names.add(req.name) + + # NOTE: URL dependencies cannot be pinned, so skipping them + # makes sense (under the same principle of skipping dependencies + # that can't be found on PyPI). This is also consistent with + # what `pip --no-deps` does (installs the URL dependency, but + # not any subdependencies). + if req.is_url: + yield SkippedDependency( + name=req.name, + skip_reason="URL requirements cannot be pinned to a specific package version", + ) + elif not req.specifier: + raise RequirementSourceError(f"requirement {req.name} is not pinned: {str(req)}") + else: + pinned_specifier = PINNED_SPECIFIER_RE.match(str(req.specifier)) + if pinned_specifier is None: + raise RequirementSourceError( + f"requirement {req.name} is not pinned to an exact version: {str(req)}" + ) + + yield ResolvedDependency(req.name, Version(pinned_specifier.group("version"))) + + class RequirementSourceError(DependencySourceError): """A requirements-parsing specific `DependencySourceError`.""" diff --git a/test/dependency_source/test_requirement.py b/test/dependency_source/test_requirement.py index a5ac69ad..1b322144 100644 --- a/test/dependency_source/test_requirement.py +++ b/test/dependency_source/test_requirement.py @@ -17,7 +17,7 @@ requirement, ) from pip_audit._fix import ResolvedFixVersion -from pip_audit._service import ResolvedDependency +from pip_audit._service import ResolvedDependency, SkippedDependency from pip_audit._state import AuditState from pip_audit._virtual_env import VirtualEnv, VirtualEnvError @@ -433,9 +433,8 @@ def test_requirement_source_require_hashes_not_fully_resolved(req_file): require_hashes=True, ) - with pytest.raises(DependencySourceError): - list(source.collect()) - + specs = list(source.collect()) + assert specs == [ResolvedDependency("flask", Version("2.0.1"))] def test_requirement_source_require_hashes_missing(req_file): source = _init_requirement([(req_file(), "wheel==0.38.1")], require_hashes=True) @@ -499,12 +498,20 @@ def test_requirement_source_require_hashes_incorrect_hash(req_file): list(source.collect()) -@pytest.mark.online -def test_requirement_source_no_deps(req_file): - source = _init_requirement([(req_file(), "flask==2.0.1")], no_deps=True) +def test_requirement_source_no_deps_editable_skip(req_file): + source = _init_requirement( + [(req_file(), "-e file:flask.py#egg=flask==2.0.1")], no_deps=True, skip_editable=True + ) specs = list(source.collect()) - assert specs == [ResolvedDependency("Flask", Version("2.0.1"))] + assert SkippedDependency(name="flask", skip_reason="requirement marked as editable") in specs + + +def test_requirement_source_no_deps_duplicate_dependencies(req_file): + source = _init_requirement([(req_file(), "flask==1.0\nflask==1.0")], no_deps=True) + + with pytest.raises(DependencySourceError): + list(source.collect()) def test_requirement_source_no_double_open(monkeypatch, req_file): @@ -706,3 +713,71 @@ def test_requirement_source_fix_invalid_lines(req_file): dep=ResolvedDependency(name="flask", version=Version("0.5")), version=Version("1.0") ) ) + + +def test_requirement_source_no_deps(req_file): + source = _init_requirement([(req_file(), "flask==2.0.1")], no_deps=True) + + specs = list(source.collect()) + assert specs == [ResolvedDependency("flask", Version("2.0.1"))] + + +def test_requirement_source_no_deps_unpinned(req_file): + source = _init_requirement([(req_file(), "flask\nrequests==1.0")], no_deps=True) + + # When dependency resolution is disabled, all requirements must be pinned. + with pytest.raises(DependencySourceError): + list(source.collect()) + + +def test_requirement_source_no_deps_not_exact_version(req_file): + source = _init_requirement([(req_file(), "flask==1.0\nrequests>=1.0")], no_deps=True) + + # When dependency resolution is disabled, all requirements must be pinned. + with pytest.raises(DependencySourceError): + list(source.collect()) + + +def test_requirement_source_no_deps_unpinned_url(req_file): + source = _init_requirement( + [ + ( + req_file(), + "https://github.com/pallets/flask/archive/refs/tags/2.0.1.tar.gz#egg=flask\n", + ) + ], + no_deps=True, + ) + + assert list(source.collect()) == [ + SkippedDependency( + name="flask", + skip_reason="URL requirements cannot be pinned to a specific package version", + ) + ] + + +def test_requirement_source_no_deps_editable_with_egg_fragment(req_file): + source = _init_requirement([(req_file(), "-e file:flask.py#egg=flask==2.0.1")], no_deps=True) + + specs = list(source.collect()) + assert ( + SkippedDependency( + name="flask", + skip_reason="URL requirements cannot be pinned to a specific package version", + ) + in specs + ) + + +def test_requirement_source_no_deps_editable_without_egg_fragment(req_file): + source = _init_requirement([(req_file(), "-e file:flask.py")], no_deps=True) + + specs = list(source.collect()) + assert ( + SkippedDependency( + name="-e file:flask.py", + skip_reason="could not deduce package version from URL requirement", + ) + in specs + ) From 9afdfb34d59e02b2ed0077a3e15ac4a8d4d0be9a Mon Sep 17 00:00:00 2001 From: Matteo Vitali Date: Thu, 8 Jun 2023 08:31:59 +0200 Subject: [PATCH 2/9] Fix lint --- pip_audit/_dependency_source/interface.py | 1 + pip_audit/_dependency_source/requirement.py | 7 +------ test/dependency_source/test_requirement.py | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index baf0ac02..0c888726 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -58,6 +58,7 @@ class DependencyFixError(Exception): pass + class InvalidRequirementSpecifier(DependencySourceError): """ A `DependencySourceError` specialized for the case of a non-PEP 440 requirements diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 8f5f0a0b..f7567514 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -16,11 +16,7 @@ from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name from packaging.version import Version -from pip_requirements_parser import ( - InstallRequirement, - InvalidRequirementLine, - RequirementsFile, -) +from pip_requirements_parser import InstallRequirement, InvalidRequirementLine, RequirementsFile from pip_audit._dependency_source import ( DependencyFixError, @@ -275,7 +271,6 @@ def _recover_files(self, tmp_files: list[IO[str]]) -> None: logger.warning(f"encountered an exception during file recovery: {e}") continue - def _collect_preresolved_deps( self, reqs: Iterator[InstallRequirement], require_hashes: bool ) -> Iterator[Dependency]: diff --git a/test/dependency_source/test_requirement.py b/test/dependency_source/test_requirement.py index 1b322144..7724a618 100644 --- a/test/dependency_source/test_requirement.py +++ b/test/dependency_source/test_requirement.py @@ -436,6 +436,7 @@ def test_requirement_source_require_hashes_not_fully_resolved(req_file): specs = list(source.collect()) assert specs == [ResolvedDependency("flask", Version("2.0.1"))] + def test_requirement_source_require_hashes_missing(req_file): source = _init_requirement([(req_file(), "wheel==0.38.1")], require_hashes=True) From 783779dbf89a2e791876fbcb338ddeda47eaa88e Mon Sep 17 00:00:00 2001 From: Matteo Vitali Date: Thu, 8 Jun 2023 14:10:30 +0200 Subject: [PATCH 3/9] Fix CHANGELOG --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eab260b..729a92ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All versions prior to 0.0.9 are untracked. ## [Unreleased] +### Changed + +* Reintroduce the preresolved dependency optimization + ([#610](https://github.com/pypa/pip-audit/pull/610)) + ## [2.5.6] ### Fixed @@ -17,11 +22,6 @@ All versions prior to 0.0.9 are untracked. ## [2.5.5] -### Changed - -* Reintroduce the preresolved dependency optimization - ([#610](https://github.com/pypa/pip-audit/pull/610)) - ### Fixed * Fixed a crash caused by incompatible dependency changes From 59b68f1d6a8441c2bd92ac665867f96811289743 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 22 Jun 2023 17:07:27 +1000 Subject: [PATCH 4/9] _cli, requirement: Introduce `--disable-pip` flag --- pip_audit/_cli.py | 13 +++++++++++++ pip_audit/_dependency_source/requirement.py | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index b8e14e58..6291bf9a 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -327,6 +327,13 @@ def _parser() -> argparse.ArgumentParser: # pragma: no cover "this option can be used multiple times" ), ) + parser.add_argument( + "--disable-pip", + action="store_true", + help="don't use `pip` for dependency resolution; " + "this can only be used with hashed requirements files or if the `--no-deps` flag has been " + "provided", + ) return parser @@ -380,11 +387,16 @@ def audit() -> None: # pragma: no cover parser.error("The --extra-index-url flag can only be used with --requirement (-r)") elif args.no_deps: parser.error("The --no-deps flag can only be used with --requirement (-r)") + elif args.disable_pip: + parser.error("The --disable-pip flag can only be used with --requirement (-r)") # Nudge users to consider alternate workflows. if args.require_hashes and args.no_deps: logger.warning("The --no-deps flag is redundant when used with --require-hashes") + if args.no_deps and args.disable_pip: + logger.warning("The --no-deps flag is redundant without --disable-pip") + if args.require_hashes and isinstance(service, OsvService): logger.warning( "The --require-hashes flag with --service osv only enforces hash presence NOT hash " @@ -414,6 +426,7 @@ def audit() -> None: # pragma: no cover req_files, require_hashes=args.require_hashes, no_deps=args.no_deps, + disable_pip=args.disable_pip, skip_editable=args.skip_editable, index_url=args.index_url, extra_index_urls=args.extra_index_urls, diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index f7567514..4c40c109 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -46,6 +46,7 @@ def __init__( *, require_hashes: bool = False, no_deps: bool = False, + disable_pip: bool = False, skip_editable: bool = False, index_url: str | None = None, extra_index_urls: list[str] = [], @@ -59,10 +60,14 @@ def __init__( `require_hashes` controls the hash policy: if `True`, dependency collection will fail unless all requirements include hashes. - `no_deps` controls the dependency resolution policy: if `True`, + `disable_pip` controls the dependency resolution policy: if `True`, dependency resolution is not performed and the inputs are checked and treated as "frozen". + `no_deps` controls whether dependency resolution can be disabled even without + hashed requirements (which implies a fully resolved requirements file): if `True`, + `disable_pip` is allowed without a hashed requirements file. + `skip_editable` controls whether requirements marked as "editable" are skipped. By default, editable requirements are not skipped. @@ -75,6 +80,7 @@ def __init__( self._filenames = filenames self._require_hashes = require_hashes self._no_deps = no_deps + self._disable_pip = disable_pip self._skip_editable = skip_editable self._index_url = index_url self._extra_index_urls = extra_index_urls @@ -148,7 +154,12 @@ def _collect_from_files(self, filenames: list[Path]) -> Iterator[Dependency]: # If the user has supplied `--no-deps` or there are hashed requirements, we should assume # that we have a fully resolved set of dependencies and we should waste time by invoking # `pip`. - if self._no_deps or require_hashes: + if self._disable_pip: + if not self._no_deps and not require_hashes: + raise RequirementSourceError( + "the --disable-pip flag can only be used with hashed requirements files or if " + "the --no-deps flag has been provided" + ) yield from self._collect_preresolved_deps(iter(reqs), require_hashes) return From 98626d006d4b131ae8018f7a26dcd90d5e7c43d4 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 22 Jun 2023 17:14:21 +1000 Subject: [PATCH 5/9] README: Update help text --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 3cdb4905..fc751b7b 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ usage: pip-audit [-h] [-V] [-l] [-r REQUIREMENT] [-f FORMAT] [-s SERVICE] [-d] [--path PATH] [-v] [--fix] [--require-hashes] [--index-url INDEX_URL] [--extra-index-url URL] [--skip-editable] [--no-deps] [-o FILE] [--ignore-vuln ID] + [--disable-pip] [project_path] audit the Python environment for dependencies with known vulnerabilities @@ -206,6 +207,9 @@ optional arguments: --ignore-vuln ID ignore a specific vulnerability by its vulnerability ID; this option can be used multiple times (default: []) + --disable-pip don't use `pip` for dependency resolution; this can + only be used with hashed requirements files or if the + `--no-deps` flag has been provided (default: False) ``` From 7d932aeb073639544e54fa5fb2d77e59e4e356ba Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 22 Jun 2023 17:23:09 +1000 Subject: [PATCH 6/9] requirement: Add back the `--require-hashes` flag in the dependency resolution path --- pip_audit/_dependency_source/requirement.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 4c40c109..38eed8b3 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -164,6 +164,8 @@ def _collect_from_files(self, filenames: list[Path]) -> Iterator[Dependency]: return ve_args = [] + if self._require_hashes: + ve_args.append("--require-hashes") for filename in filenames: ve_args.extend(["-r", str(filename)]) From bd34c415def5c99e0dc844286418b91820b7278c Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 22 Jun 2023 19:01:23 +1000 Subject: [PATCH 7/9] test: Fix tests and fill in test coverage --- pip_audit/_dependency_source/requirement.py | 4 +- test/dependency_source/test_requirement.py | 103 ++++++++++++++++---- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 38eed8b3..5d1ba493 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -157,8 +157,8 @@ def _collect_from_files(self, filenames: list[Path]) -> Iterator[Dependency]: if self._disable_pip: if not self._no_deps and not require_hashes: raise RequirementSourceError( - "the --disable-pip flag can only be used with hashed requirements files or if " - "the --no-deps flag has been provided" + "the --disable-pip flag can only be used with a hashed requirements files or " + "if the --no-deps flag has been provided" ) yield from self._collect_preresolved_deps(iter(reqs), require_hashes) return diff --git a/test/dependency_source/test_requirement.py b/test/dependency_source/test_requirement.py index 7724a618..8838cf88 100644 --- a/test/dependency_source/test_requirement.py +++ b/test/dependency_source/test_requirement.py @@ -433,8 +433,8 @@ def test_requirement_source_require_hashes_not_fully_resolved(req_file): require_hashes=True, ) - specs = list(source.collect()) - assert specs == [ResolvedDependency("flask", Version("2.0.1"))] + with pytest.raises(DependencySourceError): + list(source.collect()) def test_requirement_source_require_hashes_missing(req_file): @@ -499,17 +499,22 @@ def test_requirement_source_require_hashes_incorrect_hash(req_file): list(source.collect()) -def test_requirement_source_no_deps_editable_skip(req_file): +def test_requirement_source_disable_pip_editable_skip(req_file): source = _init_requirement( - [(req_file(), "-e file:flask.py#egg=flask==2.0.1")], no_deps=True, skip_editable=True + [(req_file(), "-e file:flask.py#egg=flask==2.0.1")], + disable_pip=True, + no_deps=True, + skip_editable=True, ) specs = list(source.collect()) assert SkippedDependency(name="flask", skip_reason="requirement marked as editable") in specs -def test_requirement_source_no_deps_duplicate_dependencies(req_file): - source = _init_requirement([(req_file(), "flask==1.0\nflask==1.0")], no_deps=True) +def test_requirement_source_disable_pip_duplicate_dependencies(req_file): + source = _init_requirement( + [(req_file(), "flask==1.0\nflask==1.0")], disable_pip=True, no_deps=True + ) with pytest.raises(DependencySourceError): list(source.collect()) @@ -716,30 +721,91 @@ def test_requirement_source_fix_invalid_lines(req_file): ) -def test_requirement_source_no_deps(req_file): - source = _init_requirement([(req_file(), "flask==2.0.1")], no_deps=True) +def test_requirement_source_disable_pip(req_file): + source = _init_requirement([(req_file(), "flask==2.0.1")], disable_pip=True, no_deps=True) specs = list(source.collect()) assert specs == [ResolvedDependency("flask", Version("2.0.1"))] -def test_requirement_source_no_deps_unpinned(req_file): - source = _init_requirement([(req_file(), "flask\nrequests==1.0")], no_deps=True) +def test_requirement_source_disable_pip_without_no_deps(req_file): + # In order to use `--disable-pip`, the requirements file must either be hashed or `--no-deps` + # must be provided. + # + # Since neither is true, we expect a failure. + source = _init_requirement( + [(req_file(), "flask==2.0.1")], + disable_pip=True, + ) + + with pytest.raises( + DependencySourceError, + match="the --disable-pip flag can only be used with a hashed requirements files or if the " + "--no-deps flag has been provided", + ): + list(source.collect()) + + +def test_requirement_source_disable_pip_hashes_without_no_deps(req_file): + # In this case, `--no-deps` is not provided but since the requirements file is hashed, providing + # `--disable-pip` is valid. + source = _init_requirement( + [ + ( + req_file(), + "flask==2.0.1 " + "--hash=sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9", + ) + ], + disable_pip=True, + ) + + specs = list(source.collect()) + assert specs == [ResolvedDependency("flask", Version("2.0.1"))] + + +def test_requirement_source_disable_pip_incomplete_hashes(req_file): + # In this case, `--no-deps` is not provided but since the requirements file is hashed, providing + # `--disable-pip` is valid. + source = _init_requirement( + [ + ( + req_file(), + "flask==2.0.1 " + "--hash=sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9\n" + "requests==1.0", + ) + ], + disable_pip=True, + ) + + with pytest.raises( + DependencySourceError, match="requirement requests==1.0 does not contain a hash" + ): + list(source.collect()) + + +def test_requirement_source_disable_pip_unpinned(req_file): + source = _init_requirement( + [(req_file(), "flask\nrequests==1.0")], disable_pip=True, no_deps=True + ) # When dependency resolution is disabled, all requirements must be pinned. with pytest.raises(DependencySourceError): list(source.collect()) -def test_requirement_source_no_deps_not_exact_version(req_file): - source = _init_requirement([(req_file(), "flask==1.0\nrequests>=1.0")], no_deps=True) +def test_requirement_source_disable_pip_not_exact_version(req_file): + source = _init_requirement( + [(req_file(), "flask==1.0\nrequests>=1.0")], disable_pip=True, no_deps=True + ) # When dependency resolution is disabled, all requirements must be pinned. with pytest.raises(DependencySourceError): list(source.collect()) -def test_requirement_source_no_deps_unpinned_url(req_file): +def test_requirement_source_disable_pip_unpinned_url(req_file): source = _init_requirement( [ ( @@ -747,6 +813,7 @@ def test_requirement_source_no_deps_unpinned_url(req_file): "https://github.com/pallets/flask/archive/refs/tags/2.0.1.tar.gz#egg=flask\n", ) ], + disable_pip=True, no_deps=True, ) @@ -758,8 +825,10 @@ def test_requirement_source_no_deps_unpinned_url(req_file): ] -def test_requirement_source_no_deps_editable_with_egg_fragment(req_file): - source = _init_requirement([(req_file(), "-e file:flask.py#egg=flask==2.0.1")], no_deps=True) +def test_requirement_source_disable_pip_editable_with_egg_fragment(req_file): + source = _init_requirement( + [(req_file(), "-e file:flask.py#egg=flask==2.0.1")], disable_pip=True, no_deps=True + ) specs = list(source.collect()) assert ( @@ -771,8 +840,8 @@ def test_requirement_source_no_deps_editable_with_egg_fragment(req_file): ) -def test_requirement_source_no_deps_editable_without_egg_fragment(req_file): - source = _init_requirement([(req_file(), "-e file:flask.py")], no_deps=True) +def test_requirement_source_disable_pip_editable_without_egg_fragment(req_file): + source = _init_requirement([(req_file(), "-e file:flask.py")], disable_pip=True, no_deps=True) specs = list(source.collect()) assert ( From 632085c8e34a4ca16ebfe1b9ce8f20dcc7391c9f Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 22 Jun 2023 19:05:32 +1000 Subject: [PATCH 8/9] CHANGELOG: Add more detail to changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729a92ec..77aed952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ All versions prior to 0.0.9 are untracked. ### Changed -* Reintroduce the preresolved dependency optimization +* Added option to skip dependency resolution via `pip` with the `--disable-pip` + flag. This option can only be used with hashed requirements files or when the + `--no-deps` flag has been provided. ([#610](https://github.com/pypa/pip-audit/pull/610)) ## [2.5.6] From fa0b146b4cd9d600d8b669b99238041e3a901ae9 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 28 Jun 2023 12:45:11 -0400 Subject: [PATCH 9/9] Update pip_audit/_cli.py --- pip_audit/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index 6291bf9a..8b6b8e83 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -395,7 +395,7 @@ def audit() -> None: # pragma: no cover logger.warning("The --no-deps flag is redundant when used with --require-hashes") if args.no_deps and args.disable_pip: - logger.warning("The --no-deps flag is redundant without --disable-pip") + logger.warning("The --no-deps flag is redundant when used with --disable-pip") if args.require_hashes and isinstance(service, OsvService): logger.warning(