Skip to content

Commit

Permalink
Add support for PEP621 with PDM (#155)
Browse files Browse the repository at this point in the history
* feature: added a pdm dependency parser
* Added support for PDM in dependency specification detector
* added support for PDM in core.py
* updated README and docs
* added `DependencyManagementFormat` as `Enum` to replace string logic

Co-authored-by: Mathieu Kniewallner <mathieu.kniewallner@gmail.com>
  • Loading branch information
fpgmaas and mkniewallner authored Oct 8, 2022
1 parent 26e55ac commit bc461e9
Show file tree
Hide file tree
Showing 15 changed files with 500 additions and 122 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
_deptry_ is a command line tool to check for issues with dependencies in a Python project, such as obsolete or missing dependencies. It supports the following types of projects:

- Projects that use [Poetry](https://python-poetry.org/) and a corresponding _pyproject.toml_ file
- Projects that use [PDM](https://pdm.fming.dev/latest/) and a corresponding _pyproject.toml_ file
- Projects that use a _requirements.txt_ file according to the [pip](https://pip.pypa.io/en/stable/user_guide/) standards

Dependency issues are detected by scanning for imported modules within all Python files in a directory and its subdirectories, and comparing those to the dependencies listed in the project's requirements.
Expand Down
16 changes: 11 additions & 5 deletions deptry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@

from deptry.dependency import Dependency
from deptry.dependency_getter.base import DependenciesExtract
from deptry.dependency_getter.pdm import PDMDependencyGetter
from deptry.dependency_getter.poetry import PoetryDependencyGetter
from deptry.dependency_getter.requirements_txt import RequirementsTxtDependencyGetter
from deptry.dependency_specification_detector import DependencySpecificationDetector
from deptry.dependency_specification_detector import (
DependencyManagementFormat,
DependencySpecificationDetector,
)
from deptry.import_parser import ImportParser
from deptry.issues_finder.misplaced_dev import MisplacedDevDependenciesFinder
from deptry.issues_finder.missing import MissingDependenciesFinder
Expand Down Expand Up @@ -77,12 +81,14 @@ def _find_issues(self, imported_modules: List[Module], dependencies: List[Depend
).find()
return result

def _get_dependencies(self, dependency_management_format: str) -> DependenciesExtract:
if dependency_management_format == "pyproject_toml":
def _get_dependencies(self, dependency_management_format: DependencyManagementFormat) -> DependenciesExtract:
if dependency_management_format is DependencyManagementFormat.POETRY:
return PoetryDependencyGetter().get()
if dependency_management_format == "requirements_txt":
if dependency_management_format is DependencyManagementFormat.PDM:
return PDMDependencyGetter().get()
if dependency_management_format is DependencyManagementFormat.REQUIREMENTS_TXT:
return RequirementsTxtDependencyGetter(self.requirements_txt, self.requirements_txt_dev).get()
raise ValueError("Incorrect dependency manage format. Only pyproject.toml and requirements.txt are supported.")
raise ValueError("Incorrect dependency manage format. Only poetry, pdm and requirements.txt are supported.")

def _log_config(self) -> None:
logging.debug("Running with the following configuration:")
Expand Down
82 changes: 82 additions & 0 deletions deptry/dependency_getter/pdm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import logging
import re
from typing import Dict, List, Optional

from deptry.dependency import Dependency
from deptry.dependency_getter.base import DependenciesExtract, DependencyGetter
from deptry.utils import load_pyproject_toml


class PDMDependencyGetter(DependencyGetter):
"""
Class to get dependencies that are specified according to PEP 621 from a `pyproject.toml` file for a project that uses PDM for its dependency management.
"""

def get(self) -> DependenciesExtract:
dependencies = self._get_pdm_dependencies()
self._log_dependencies(dependencies)

dev_dependencies = self._get_pdm_dev_dependencies()
self._log_dependencies(dev_dependencies, is_dev=True)

return DependenciesExtract(dependencies, dev_dependencies)

@classmethod
def _get_pdm_dependencies(cls) -> List[Dependency]:
pyproject_data = load_pyproject_toml()
dependency_strings: List[str] = pyproject_data["project"]["dependencies"]
return cls._extract_dependency_objects_from(dependency_strings)

@classmethod
def _get_pdm_dev_dependencies(cls) -> List[Dependency]:
"""
Try to get development dependencies from pyproject.toml, which with PDM are specified as:
[tool.pdm.dev-dependencies]
test = [
"pytest",
"pytest-cov",
]
tox = [
"tox",
"tox-pdm>=0.5",
]
"""
pyproject_data = load_pyproject_toml()

dev_dependency_strings: List[str] = []
try:
dev_dependencies_dict: Dict[str, str] = pyproject_data["tool"]["pdm"]["dev-dependencies"]
for deps in dev_dependencies_dict.values():
dev_dependency_strings += deps
except KeyError:
logging.debug("No section [tool.pdm.dev-dependencies] found in pyproject.toml")

return cls._extract_dependency_objects_from(dev_dependency_strings)

@classmethod
def _extract_dependency_objects_from(cls, pdm_dependencies: List[str]) -> List[Dependency]:
dependencies = []
for spec in pdm_dependencies:
# An example of a spec is `"tomli>=1.1.0; python_version < \"3.11\""`
name = cls._find_dependency_name_in(spec)
if name:
optional = cls._is_optional(spec)
conditional = cls._is_conditional(spec)
dependencies.append(Dependency(name, conditional=conditional, optional=optional))
return dependencies

@staticmethod
def _is_optional(dependency_specification: str) -> bool:
return bool(re.findall(r"\[([a-zA-Z0-9-]+?)\]", dependency_specification))

@staticmethod
def _is_conditional(dependency_specification: str) -> bool:
return ";" in dependency_specification

@staticmethod
def _find_dependency_name_in(spec: str) -> Optional[str]:
match = re.search("[a-zA-Z0-9-_]+", spec)
if match:
return match.group(0)
return None
111 changes: 66 additions & 45 deletions deptry/dependency_specification_detector.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,91 @@
import logging
import os
from enum import Enum
from typing import Tuple

from deptry.utils import load_pyproject_toml


class DependencyManagementFormat(Enum):
POETRY = "poetry"
PDM = "pdm"
REQUIREMENTS_TXT = "requirements_txt"


class DependencySpecificationDetector:
"""
Class to detect how dependencies are specified:
- Either find a pyproject.toml with a [poetry.tool.dependencies] section
- Or find a requirements.txt.
If both are found, pyproject.toml is preferred.
- Otherwise, find a pyproject.toml with a [tool.pdm] section
- Otherwise, find a requirements.txt.
"""

def __init__(self, requirements_txt: Tuple[str, ...] = ("requirements.txt",)) -> None:
self.requirements_txt = requirements_txt

def detect(self) -> str:
uses_pyproject_toml = self._check_if_project_uses_pyproject_toml_for_dependencies()
uses_requirements_txt = self._check_if_project_uses_requirements_txt_for_dependencies()
if uses_pyproject_toml and uses_requirements_txt:
logging.info(
"Found both 'pyproject.toml' with a [tool.poetry.dependencies] section and"
f" '{', '.join(self.requirements_txt)}' requirements file(s). Defaulting to 'pyproject.toml'.\n"
)
return "pyproject_toml"
elif uses_pyproject_toml:
def detect(self) -> DependencyManagementFormat:
pyproject_toml_found = self._project_contains_pyproject_toml()
if pyproject_toml_found and self._project_uses_poetry():
return DependencyManagementFormat.POETRY
if pyproject_toml_found and self._project_uses_pdm():
return DependencyManagementFormat.PDM
if self._project_uses_requirements_txt():
return DependencyManagementFormat.REQUIREMENTS_TXT
raise FileNotFoundError(
"No file called 'pyproject.toml' with a [tool.poetry.dependencies] or [tool.pdm] section or file(s)"
f" called '{', '.join(self.requirements_txt)}' found. Exiting."
)

@staticmethod
def _project_contains_pyproject_toml() -> bool:
if "pyproject.toml" in os.listdir():
logging.debug("pyproject.toml found!")
return True
else:
logging.debug("No pyproject.toml found.")
return False

@staticmethod
def _project_uses_poetry() -> bool:
pyproject_toml = load_pyproject_toml()
try:
pyproject_toml["tool"]["poetry"]["dependencies"]
logging.debug(
"Dependency specification found in 'pyproject.toml'. Will use this to determine the project's"
" dependencies.\n"
"pyproject.toml contains a [tool.poetry.dependencies] section, so Poetry is used to specify the"
" project's dependencies."
)
return "pyproject_toml"
elif uses_requirements_txt:
return True
except KeyError:
logging.debug(
f"Dependency specification found in '{', '.join(self.requirements_txt)}'. Will use this to determine"
" the project's dependencies.\n"
)
return "requirements_txt"
else:
raise FileNotFoundError(
"No file called 'pyproject.toml' with a [tool.poetry.dependencies] section or called"
f" '{', '.join(self.requirements_txt)}' found. Exiting."
"pyproject.toml does not contain a [tool.poetry.dependencies] section, so PDM is not used to specify"
" the project's dependencies."
)
pass
return False

@staticmethod
def _check_if_project_uses_pyproject_toml_for_dependencies() -> bool:
if "pyproject.toml" in os.listdir():
logging.debug("pyproject.toml found!")
pyproject_toml = load_pyproject_toml()
try:
pyproject_toml["tool"]["poetry"]["dependencies"]
logging.debug(
"pyproject.toml contains a [tool.poetry.dependencies] section, so it is used to specify the"
" project's dependencies."
)
return True
except KeyError:
logging.debug(
"pyproject.toml does not contain a [tool.poetry.dependencies] section, so it is not used to specify"
" the project's dependencies."
)
pass

def _project_uses_pdm() -> bool:
pyproject_toml = load_pyproject_toml()
try:
pyproject_toml["tool"]["pdm"]
logging.debug(
"pyproject.toml contains a [tool.pdm] section, so PDM is used to specify the project's dependencies."
)
return True
except KeyError:
logging.debug(
"pyproject.toml does not contain a [tool.pdm] section, so PDM is not used to specify"
" the project's dependencies."
)
pass
return False

def _check_if_project_uses_requirements_txt_for_dependencies(self) -> bool:
return any(os.path.isfile(requirements_txt) for requirements_txt in self.requirements_txt)
def _project_uses_requirements_txt(self) -> bool:
check = any(os.path.isfile(requirements_txt) for requirements_txt in self.requirements_txt)
if check:
logging.debug(
f"Dependency specification found in '{', '.join(self.requirements_txt)}'. Will use this to determine"
" the project's dependencies.\n"
)
return check
1 change: 1 addition & 0 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
_deptry_ is a command line tool to check for issues with dependencies in a Python project, such as obsolete or missing dependencies. It supports the following types of projects:

- Projects that use [Poetry](https://python-poetry.org/) and a corresponding _pyproject.toml_ file
- Projects that use [PDM](https://pdm.fming.dev/latest/) and a corresponding _pyproject.toml_ file
- Projects that use a _requirements.txt_ file according to the [pip](https://pip.pypa.io/en/stable/user_guide/) standards

Dependency issues are detected by scanning for imported modules within all Python files in a directory and its subdirectories, and comparing those to the dependencies listed in the project's requirements.
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ Where `.` is the path to the root directory of the project to be scanned. All ot

## pyproject.toml vs requirements.txt

To determine the project's dependencies, _deptry_ will scan the root directory for a `pyproject.toml` file with a `[tool.poetry.dependencies]` section and for a file called `requirements.txt`.
To determine the project's dependencies, _deptry_ will scan the root directory for files in the following order:

- If a `pyproject.toml` file with dependency specification is found, _deptry_ will extract both the projects dependencies and its development dependencies from there.
- If a `pyproject.toml` file with a `[tool.poetry.dependencies]` section is found, _deptry_ will extract both the projects dependencies and its development dependencies from `pyproject.toml`.
- If a `pyproject.toml` file with a `[tool.pdm]` section is found, _deptry_ will extract the projects dependencies from `dependencies` in the `[project]` section, and the development dependencies from `[tool.pdm.dev-dependencies]`.
- If a `requirements.txt` file is found, _deptry_ will extract the project's dependencies from there, and additionally it will look for the files `dev-dependencies.txt` and `dependencies-dev.txt` to determine the project's development dependencies.
- If both a `pyproject.toml` file and `requirements.txt` are found, `pyproject.toml` takes priority, and that file is used to determine the project's dependencies.

_deptry_ can also be configured to look for a `requirements.txt` file with another name or in another directory. See [requirements.txt files](#requirementstxt-files).
_deptry_ can also be configured to look for `requirements.txt` files with other names or in other directories. See [requirements.txt files](#requirementstxt-files).

## Excluding files and directories

Expand Down
61 changes: 0 additions & 61 deletions tests/test_cli.py → tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,6 @@ def dir_with_venv_installed(tmp_path_factory):
return tmp_path_proj


@pytest.fixture(scope="session")
def requirements_txt_dir_with_venv_installed(tmp_path_factory):
tmp_path_proj = tmp_path_factory.getbasetemp() / "project_with_requirements_txt"
shutil.copytree("tests/data/project_with_requirements_txt", str(tmp_path_proj))
with run_within_dir(tmp_path_proj):
assert (
subprocess.check_call(
shlex.split(
"pip install -r requirements.txt -r requirements-dev.txt -r requirements-2.txt -r"
" requirements-typing.txt"
)
)
== 0
)
return tmp_path_proj


def test_cli_returns_error(dir_with_venv_installed):
with run_within_dir(dir_with_venv_installed):
result = subprocess.run(shlex.split("poetry run deptry ."), capture_output=True, text=True)
Expand Down Expand Up @@ -90,50 +73,6 @@ def test_cli_extend_exclude(dir_with_venv_installed):
assert "The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\ttoml\n\n" in result.stderr


def test_cli_single_requirements_txt(requirements_txt_dir_with_venv_installed):
with run_within_dir(requirements_txt_dir_with_venv_installed):
result = subprocess.run(
shlex.split("deptry . --requirements-txt requirements.txt --requirements-txt-dev requirements-dev.txt"),
capture_output=True,
text=True,
)
assert result.returncode == 1
assert (
"The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\nConsider removing them from your"
" project's dependencies. If a package is used for development purposes, you should add it to your"
" development dependencies instead.\n\n-----------------------------------------------------\n\nThere are"
" dependencies missing from the project's list of dependencies:\n\n\twhite\n\nConsider adding them to your"
" project's dependencies. \n\n-----------------------------------------------------\n\nThere are transitive"
" dependencies that should be explicitly defined as dependencies:\n\n\turllib3\n\nThey are currently"
" imported but not specified directly as your project's"
" dependencies.\n\n-----------------------------------------------------\n\nThere are imported modules from"
" development dependencies detected:\n\n\tblack\n\n"
in result.stderr
)


def test_cli_multiple_requirements_txt(requirements_txt_dir_with_venv_installed):
with run_within_dir(requirements_txt_dir_with_venv_installed):
result = subprocess.run(
shlex.split(
"deptry . --requirements-txt requirements.txt,requirements-2.txt --requirements-txt-dev"
" requirements-dev.txt,requirements-typing.txt"
),
capture_output=True,
text=True,
)
assert result.returncode == 1
assert (
"The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\nConsider removing them from your"
" project's dependencies. If a package is used for development purposes, you should add it to your"
" development dependencies instead.\n\n-----------------------------------------------------\n\nThere are"
" dependencies missing from the project's list of dependencies:\n\n\twhite\n\nConsider adding them to your"
" project's dependencies. \n\n-----------------------------------------------------\n\nThere are imported"
" modules from development dependencies detected:\n\n\tblack\n\n"
in result.stderr
)


def test_cli_verbose(dir_with_venv_installed):
with run_within_dir(dir_with_venv_installed):
result = subprocess.run(shlex.split("poetry run deptry . "), capture_output=True, text=True)
Expand Down
26 changes: 26 additions & 0 deletions tests/cli/test_cli_pdm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import shlex
import shutil
import subprocess

import pytest

from deptry.utils import run_within_dir


@pytest.fixture(scope="session")
def pdm_dir_with_venv_installed(tmp_path_factory):
tmp_path_proj = tmp_path_factory.getbasetemp() / "project_with_pdm"
shutil.copytree("tests/data/project_with_pdm", str(tmp_path_proj))
with run_within_dir(tmp_path_proj):
assert subprocess.check_call(shlex.split("pip install pdm; pdm install")) == 0
return tmp_path_proj


def test_cli_with_pdm(pdm_dir_with_venv_installed):
with run_within_dir(pdm_dir_with_venv_installed):
result = subprocess.run(shlex.split("deptry ."), capture_output=True, text=True)
assert result.returncode == 1
print(result.stderr)
assert "The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\n" in result.stderr
assert "There are dependencies missing from the project's list of dependencies:\n\n\twhite\n\n" in result.stderr
assert "There are imported modules from development dependencies detected:\n\n\tblack\n\n" in result.stderr
Loading

0 comments on commit bc461e9

Please sign in to comment.