Skip to content

PEP 751 support #888

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

Merged
merged 9 commits into from
Apr 7, 2025
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ All versions prior to 0.0.9 are untracked.

* `pip-audit` now allows some CLI flags to be configured via environment
variables ([#755](https://github.com/pypa/pip-audit/pull/755))
* `pip-audit` now supports [PEP 751](https://peps.python.org/pep-0751/)
lockfiles. These lockfiles can be audited in "project" mode by
passing `--locked` to `pip-audit`
([#888](https://github.com/pypa/pip-audit/pull/888))

### Changed

Expand Down
56 changes: 39 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,14 @@ python -m pip_audit --help

<!-- @begin-pip-audit-help@ -->
```
usage: pip-audit [-h] [-V] [-l] [-r REQUIREMENT] [-f FORMAT] [-s SERVICE] [-d]
[-S] [--desc [{on,off,auto}]] [--aliases [{on,off,auto}]]
[--cache-dir CACHE_DIR] [--progress-spinner {on,off}]
[--timeout TIMEOUT] [--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]
usage: pip-audit [-h] [-V] [-l] [-r REQUIREMENT] [--locked] [-f FORMAT]
[-s SERVICE] [-d] [-S] [--desc [{on,off,auto}]]
[--aliases [{on,off,auto}]] [--cache-dir CACHE_DIR]
[--progress-spinner {on,off}] [--timeout TIMEOUT]
[--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
Expand All @@ -154,6 +155,9 @@ optional arguments:
-r REQUIREMENT, --requirement REQUIREMENT
audit the given requirements file; this option can be
used multiple times (default: None)
--locked audit lock files from the local Python project. This
flag only applies to auditing from project paths
(default: False)
-f FORMAT, --format FORMAT
the format to emit audit results in (choices: columns,
json, cyclonedx-json, cyclonedx-xml, markdown)
Expand Down Expand Up @@ -260,32 +264,46 @@ an audit (or fix) step is actually performed.
## Examples

Audit dependencies for the current Python environment:
```

```console
$ pip-audit
No known vulnerabilities found
```

Audit dependencies for a given requirements file:
```

```console
$ pip-audit -r ./requirements.txt
No known vulnerabilities found
```

Audit dependencies for a requirements file, excluding system packages:
```

```console
$ pip-audit -r ./requirements.txt -l
No known vulnerabilities found
```

Audit dependencies for a local Python project:
```

```console
$ pip-audit .
No known vulnerabilities found
```
`pip-audit` searches the provided path for various Python "project" files. At the moment, only `pyproject.toml` is supported.

Audit dependencies when there are vulnerabilities present:
Audit lockfiles for a local Python project:

```console
$ pip-audit --locked .
No known vulnerabilities found
```

`pip-audit` searches the provided path for various Python "project" files.
At the moment, only `pyproject.toml` and `pylock.*.toml` are supported.

Audit dependencies when there are vulnerabilities present:

```console
$ pip-audit
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions
Expand All @@ -295,7 +313,8 @@ Flask 0.5 PYSEC-2018-66 0.12.3
```

Audit dependencies including aliases:
```

```console
$ pip-audit --aliases
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions Aliases
Expand All @@ -305,7 +324,8 @@ Flask 0.5 PYSEC-2018-66 0.12.3 CVE-2018-1000656, GHSA-562c-5r94-xh97
```

Audit dependencies including descriptions:
```

```console
$ pip-audit --desc
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions Description
Expand All @@ -315,7 +335,8 @@ Flask 0.5 PYSEC-2018-66 0.12.3 The Pallets Project flask version Befo
```

Audit dependencies in JSON format:
```

```console
$ pip-audit -f json | python -m json.tool
Found 2 known vulnerabilities in 1 package
[
Expand Down Expand Up @@ -376,7 +397,8 @@ Found 2 known vulnerabilities in 1 package
```

Audit and attempt to automatically upgrade vulnerable dependencies:
```

```console
$ pip-audit --fix
Found 2 known vulnerabilities in 1 package and fixed 2 vulnerabilities in 1 package
Name Version ID Fix Versions Applied Fix
Expand Down
27 changes: 26 additions & 1 deletion pip_audit/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
PyProjectSource,
RequirementSource,
)
from pip_audit._dependency_source.pylock import PyLockSource
from pip_audit._fix import ResolvedFixVersion, SkippedFixVersion, resolve_fix_versions
from pip_audit._format import (
ColumnsFormat,
Expand Down Expand Up @@ -221,6 +222,12 @@ def _parser() -> argparse.ArgumentParser: # pragma: no cover
nargs="?",
help="audit a local Python project at the given path",
)
parser.add_argument(
"--locked",
action="store_true",
help="audit lock files from the local Python project. This "
"flag only applies to auditing from project paths",
)
parser.add_argument(
"-f",
"--format",
Expand Down Expand Up @@ -395,8 +402,20 @@ def _parse_args(parser: argparse.ArgumentParser) -> argparse.Namespace: # pragm


def _dep_source_from_project_path(
project_path: Path, index_url: str, extra_index_urls: list[str], state: AuditState
project_path: Path, index_url: str, extra_index_urls: list[str], locked: bool, state: AuditState
) -> DependencySource: # pragma: no cover
# If the user has passed `--locked`, we check for `pylock.*.toml` files.
if locked:
all_pylocks = list(project_path.glob("pylock.*.toml"))
generic_pylock = project_path / "pylock.toml"
if generic_pylock.is_file():
all_pylocks.append(generic_pylock)

if not all_pylocks:
_fatal(f"no lockfiles found in {project_path}")

return PyLockSource(all_pylocks)

# Check for a `pyproject.toml`
pyproject_path = project_path / "pyproject.toml"
if pyproject_path.is_file():
Expand Down Expand Up @@ -424,6 +443,11 @@ def audit() -> None: # pragma: no cover
output_aliases = args.aliases.to_bool(args.format)
formatter = args.format.to_format(output_desc, output_aliases)

# Check for flags that are only valid with project paths
if args.project_path is None:
if args.locked:
parser.error("The --locked flag can only be used with a project path")

# Check for flags that are only valid with requirements files
if args.requirements is None:
if args.require_hashes:
Expand Down Expand Up @@ -488,6 +512,7 @@ def audit() -> None: # pragma: no cover
args.project_path,
args.index_url,
args.extra_index_urls,
args.locked,
state,
)
else:
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 @@
InvalidRequirementSpecifier,
)
from .pip import PipSource, PipSourceError
from .pylock import PyLockSource
from .pyproject import PyProjectSource
from .requirement import RequirementSource

Expand All @@ -21,6 +22,7 @@
"InvalidRequirementSpecifier",
"PipSource",
"PipSourceError",
"PyLockSource",
"PyProjectSource",
"RequirementSource",
]
112 changes: 112 additions & 0 deletions pip_audit/_dependency_source/pylock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Collect dependencies from `pylock.toml` files.
"""

import logging
from collections.abc import Iterator
from pathlib import Path

import toml
from packaging.version import Version

from pip_audit._dependency_source import DependencyFixError, DependencySource, DependencySourceError
from pip_audit._fix import ResolvedFixVersion
from pip_audit._service import Dependency, ResolvedDependency
from pip_audit._service.interface import SkippedDependency

logger = logging.getLogger(__name__)


class PyLockSource(DependencySource):
"""
Wraps `pylock.*.toml` dependency collection as a dependency source.
"""

def __init__(self, filenames: list[Path]) -> None:
"""
Create a new `PyLockSource`.

`filenames` provides a list of `pylock.*.toml` files to parse.
"""

self._filenames = filenames

def collect(self) -> Iterator[Dependency]:
"""
Collect all of the dependencies discovered by this `PyLockSource`.

Raises a `PyLockSourceError` on any errors.
"""
for filename in self._filenames:
yield from self._collect_from_file(filename)

def _collect_from_file(self, filename: Path) -> Iterator[Dependency]:
"""
Collect dependencies from a single `pylock.*.toml` file.

Raises a `PyLockSourceError` on any errors.
"""
try:
pylock = toml.load(filename)
except toml.TomlDecodeError as e:
raise PyLockSourceError(f"{filename}: invalid TOML in lockfile") from e

lock_version = pylock.get("lock-version")
if not lock_version:
raise PyLockSourceError(f"{filename}: missing lock-version in lockfile")

lock_version = Version(lock_version)
if lock_version.major != 1:
raise PyLockSourceError(f"{filename}: lockfile version {lock_version} is not supported")

packages = pylock.get("packages")
if not packages:
raise PyLockSourceError(f"{filename}: missing packages in lockfile")

try:
yield from self._collect_from_packages(packages)
except PyLockSourceError as e:
raise PyLockSourceError(f"{filename}: {e}") from e

def _collect_from_packages(self, packages: list[dict]) -> Iterator[Dependency]:
"""
Collect dependencies from a list of packages.

Raises a `PyLockSourceError` on any errors.
"""
for idx, package in enumerate(packages):
name = package.get("name")
if not name:
raise PyLockSourceError(f"invalid package #{idx}: no name")

version = package.get("version")
if version:
yield ResolvedDependency(name, Version(version))
else:
# Versions are optional in PEP 751, e.g. for source tree specifiers.
# We mark these as skipped.
yield SkippedDependency(name, "no version specified")

def fix(self, fix_version: ResolvedFixVersion) -> None: # pragma: no cover
"""
Raises `NotImplementedError` if called.

We don't support fixing dependencies in lockfiles, since
lockfiles should be managed/updated by their packaging tool.
"""

raise NotImplementedError(
"lockfiles cannot be fixed directly; use your packaging tool to perform upgrades"
)


class PyLockSourceError(DependencySourceError):
"""A pylock-parsing specific `DependencySourceError`."""

pass


class PyLockFixError(DependencyFixError):
"""A pylock-fizing specific `DependencyFixError`."""

pass
48 changes: 48 additions & 0 deletions test/assets/pylock.basic.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
lock-version = '1.0'
environments = ["sys_platform == 'win32'", "sys_platform == 'linux'"]
requires-python = '==3.12'
created-by = 'mousebender'

[[packages]]
name = 'attrs'
version = '25.1.0'
requires-python = '>=3.8'
wheels = [
{ name = 'attrs-25.1.0-py3-none-any.whl', upload-time = 2025-01-25T11:30:10.164985+00:00, url = 'https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl', size = 63152, hashes = { sha256 = 'c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a' } },
]
[[packages.attestation-identities]]
environment = 'release-pypi'
kind = 'GitHub'
repository = 'python-attrs/attrs'
workflow = 'pypi-package.yml'

[[packages]]
name = 'cattrs'
version = '24.1.2'
requires-python = '>=3.8'
dependencies = [{ name = 'attrs' }]
wheels = [
{ name = 'cattrs-24.1.2-py3-none-any.whl', upload-time = 2024-09-22T14:58:34.812643+00:00, url = 'https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl', size = 66446, hashes = { sha256 = '67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0' } },
]

[[packages]]
name = 'numpy'
version = '2.2.3'
requires-python = '>=3.10'
wheels = [
{ name = 'numpy-2.2.3-cp312-cp312-win_amd64.whl', upload-time = 2025-02-13T16:51:21.821880+00:00, url = 'https://files.pythonhosted.org/packages/42/6e/55580a538116d16ae7c9aa17d4edd56e83f42126cb1dfe7a684da7925d2c/numpy-2.2.3-cp312-cp312-win_amd64.whl', size = 12626357, hashes = { sha256 = '83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d' } },
{ name = 'numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', upload-time = 2025-02-13T16:50:00.079662+00:00, url = 'https://files.pythonhosted.org/packages/39/04/78d2e7402fb479d893953fb78fa7045f7deb635ec095b6b4f0260223091a/numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', size = 16116679, hashes = { sha256 = '3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe' } },
]

[tool.mousebender]
command = [
'.',
'lock',
'--platform',
'cpython3.12-windows-x64',
'--platform',
'cpython3.12-manylinux2014-x64',
'cattrs',
'numpy',
]
run-on = 2025-03-06T12:28:57.760769
1 change: 1 addition & 0 deletions test/assets/pylock.invalid-version.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lock-version = '666'
1 change: 1 addition & 0 deletions test/assets/pylock.invalid.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
this is not valid toml!
4 changes: 4 additions & 0 deletions test/assets/pylock.missing-packages.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
lock-version = '1.0'
environments = ["sys_platform == 'win32'", "sys_platform == 'linux'"]
requires-python = '==3.12'
created-by = 'mousebender'
Loading
Loading