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

Add support for Poetry #402

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions pip_audit/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
PYPI_URL,
DependencySource,
PipSource,
PoetrySource,
PyProjectSource,
RequirementSource,
ResolveLibResolver,
Expand Down Expand Up @@ -330,8 +331,14 @@ def _dep_source_from_project_path(
project_path: Path, resolver: ResolveLibResolver, state: AuditState
) -> DependencySource: # pragma: no cover
# Check for a `pyproject.toml`
orsinium marked this conversation as resolved.
Show resolved Hide resolved
poetry_lock = project_path / "poetry.lock"
if poetry_lock.is_file():
logger.debug("using PoetrySource as dependency source")
return PoetrySource(path=poetry_lock)

pyproject_path = project_path / "pyproject.toml"
if pyproject_path.is_file():
logger.debug("using PyProjectSource as dependency source")
return PyProjectSource(pyproject_path, resolver, state)

# TODO: Checks for setup.py and other project files will go here.
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,
)
from .pip import PipSource, PipSourceError
from .poetry import PoetrySource
from .pyproject import PyProjectSource
from .requirement import RequirementSource
from .resolvelib import PYPI_URL, ResolveLibResolver
Expand All @@ -23,6 +24,7 @@
"DependencySourceError",
"PipSource",
"PipSourceError",
"PoetrySource",
"PyProjectSource",
"RequirementSource",
"ResolveLibResolver",
Expand Down
64 changes: 64 additions & 0 deletions pip_audit/_dependency_source/poetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Collect dependencies from `poetry.lock` files.
"""
from __future__ import annotations

import logging
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator

import toml
from packaging.version import InvalidVersion, Version

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

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class PoetrySource(DependencySource):
"""
Dependency sourcing from `poetry.lock`.
"""

path: Path

def collect(self) -> Iterator[Dependency]:
"""
Collect all of the dependencies discovered by this `PoetrySource`.
"""
with self.path.open("r") as stream:
packages = toml.load(stream)
for package in packages["package"]:
name = package["name"]
try:
version = Version(package["version"])
except InvalidVersion: # pragma: no cover
orsinium marked this conversation as resolved.
Show resolved Hide resolved
skip_reason = (
"Package has invalid version and could not be audited: "
f"{name} ({package['version']})"
)
logger.debug(skip_reason)
orsinium marked this conversation as resolved.
Show resolved Hide resolved
yield SkippedDependency(name=name, skip_reason=skip_reason)
else:
yield ResolvedDependency(name=name, version=version)

def fix(self, fix_version: ResolvedFixVersion) -> None:
"""
Fixes a dependency version for this `PoetrySource`.

Requires poetry to be installed in the same env.

Note that poetry ignores the version we want to update to,
and goes straight to the latest version allowed in metadata.
"""
subprocess.run(
[sys.executable, "-m", "poetry", "update", "--lock", fix_version.dep.name],
cwd=self.path.parent,
stdout=subprocess.DEVNULL,
).check_returncode()
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ requires-python = ">=3.7"
[project.optional-dependencies]
test = [
"coverage[toml]",
"poetry",
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
"pretend",
"pytest",
"pytest-cov",
Expand Down
63 changes: 63 additions & 0 deletions test/dependency_source/test_poetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import subprocess
import sys
from pathlib import Path
from textwrap import dedent
from typing import Callable

import pytest
from packaging.version import Version

from pip_audit._dependency_source import PoetrySource
from pip_audit._fix import ResolvedFixVersion
from pip_audit._service import ResolvedDependency


@pytest.fixture
def lock(tmp_path: Path) -> Callable:
def callback(*deps: str) -> Path:
metadata = """
[tool.poetry]
name = "poetry-demo"
version = "0.1.0"
description = ""
authors = ["someone <[email protected]>"]

[tool.poetry.dependencies]
python = "^3.7"
"""
metadata = dedent(metadata)
metadata += "\n".join(deps)
(tmp_path / "pyproject.toml").write_text(metadata)
cmd = [sys.executable, "-m", "poetry", "lock", "--no-update"]
subprocess.run(cmd, cwd=tmp_path).check_returncode()
lock_path = tmp_path / "poetry.lock"
assert lock_path.exists()
return lock_path

return callback
orsinium marked this conversation as resolved.
Show resolved Hide resolved


def test_collect_and_fix(lock: Callable) -> None:
lock_path: Path = lock("Jinja2 = '2.7.1'")
sourcer = PoetrySource(path=lock_path)

# collect
deps = list(sourcer.collect())
assert [dep.name for dep in deps] == ["jinja2", "markupsafe"]
assert isinstance(deps[0], ResolvedDependency)
assert isinstance(deps[1], ResolvedDependency)
assert str(deps[0].version) == "2.7.1"

# unlock the version in metadata
meta_path = lock_path.parent / "pyproject.toml"
meta_content = meta_path.read_text()
meta_content = meta_content.replace("2.7.1", "2.7.*")
meta_path.write_text(meta_content)

# fix
sourcer.fix(ResolvedFixVersion(dep=deps[0], version=Version("2.7.3")))
content = lock_path.read_text()
assert 'version = "2.7.3"' in content
assert "2.7.1" not in content