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

feat: support setuptools dynamic dependencies #894

Merged
merged 10 commits into from
Oct 21, 2024
Merged
10 changes: 6 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ To determine the project's dependencies, _deptry_ will scan the directory it is
- development dependencies from `[tool.poetry.group.dev.dependencies]` or `[tool.poetry.dev-dependencies]` section
1. If a `pyproject.toml` file with a `[tool.pdm.dev-dependencies]` section is found, _deptry_ will assume it uses PDM and extract:
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]` sections
- development dependencies from `[tool.pdm.dev-dependencies]` section and from the groups under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument.
- development dependencies from `[tool.pdm.dev-dependencies]` section and from the groups under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument
1. If a `pyproject.toml` file with a `[tool.uv.dev-dependencies]` section is found, _deptry_ will assume it uses uv and extract:
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]` sections
- development dependencies from `[tool.uv.dev-dependencies]` section and from the groups under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument.
- development dependencies from `[tool.uv.dev-dependencies]` section and from the groups under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument
1. If a `pyproject.toml` file with a `[project]` section is found, _deptry_ will assume it uses [PEP 621](https://peps.python.org/pep-0621/) for dependency specification and extract:
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]`.
- development dependencies from the groups under `[dependency-groups]`, and the ones under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument.
- dependencies from:
- `[project.dependencies]` (or `dependencies` requirements files under `[tool.setuptools.dynamic]` section if the project uses `setuptools.build_meta` as a build backend, and a `dynamic` key under `[project]` section includes `"dependencies"`)
- `[project.optional-dependencies]` (or requirements files under `[tool.setuptools.dynamic.optional-dependencies]` section if the project uses `setuptools.build_meta` as a build backend, and a `dynamic` key under `[project]` section includes `"optional-dependencies"`)
- development dependencies from the groups under `[dependency-groups]`, and the ones under `[project.optional-dependencies]` (or `setuptools` equivalent) passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument
1. If a `requirements.in` or `requirements.txt` file is found, _deptry_ will:
- extract dependencies from that file.
- extract development dependencies from `dev-dependencies.txt` and `dependencies-dev.txt`, if any exist
Expand Down
45 changes: 45 additions & 0 deletions python/deptry/dependency_getter/pep621/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
from typing import Any


@dataclass
class PEP621DependencyGetter(DependencyGetter):
Expand Down Expand Up @@ -54,13 +59,31 @@ def get(self) -> DependenciesExtract:
def _get_dependencies(self) -> list[Dependency]:
"""Extract dependencies from `[project.dependencies]` (https://packaging.python.org/en/latest/specifications/pyproject-toml/#dependencies-optional-dependencies)."""
pyproject_data = load_pyproject_toml(self.config)

if self._project_uses_setuptools(pyproject_data) and "dependencies" in pyproject_data["project"].get(
"dynamic", {}
):
return get_dependencies_from_requirements_files(
pyproject_data["tool"]["setuptools"]["dynamic"]["dependencies"]["file"], self.package_module_name_map
)

dependency_strings: list[str] = pyproject_data["project"].get("dependencies", [])
return self._extract_pep_508_dependencies(dependency_strings)

def _get_optional_dependencies(self) -> dict[str, list[Dependency]]:
"""Extract dependencies from `[project.optional-dependencies]` (https://packaging.python.org/en/latest/specifications/pyproject-toml/#dependencies-optional-dependencies)."""
pyproject_data = load_pyproject_toml(self.config)

if self._project_uses_setuptools(pyproject_data) and "optional-dependencies" in pyproject_data["project"].get(
"dynamic", {}
):
return {
group: get_dependencies_from_requirements_files(specification["file"], self.package_module_name_map)
for group, specification in pyproject_data["tool"]["setuptools"]["dynamic"]
.get("optional-dependencies", {})
.items()
}

return {
group: self._extract_pep_508_dependencies(dependencies)
for group, dependencies in pyproject_data["project"].get("optional-dependencies", {}).items()
Expand Down Expand Up @@ -88,6 +111,28 @@ def _get_dev_dependencies(
*dev_dependencies_from_optional,
]

@staticmethod
def _project_uses_setuptools(pyproject_toml: dict[str, Any]) -> bool:
try:
if pyproject_toml["build-system"]["build-backend"] == "setuptools.build_meta":
logging.debug(
"pyproject.toml has the entry build-system.build-backend == 'setuptools.build_meta', so setuptools"
"is used to specify the project's dependencies."
)
return True
else:
logging.debug(
"pyproject.toml does not have build-system.build-backend == 'setuptools.build_meta', so setuptools "
"is not used to specify the project's dependencies."
)
return False
except KeyError:
logging.debug(
"pyproject.toml does not contain a build-system.build-backend entry, so setuptools is not used to "
"specify the project's dependencies."
)
return False

def _check_for_invalid_group_names(self, optional_dependencies: dict[str, list[Dependency]]) -> None:
missing_groups = set(self.pep621_dev_dependency_groups) - set(optional_dependencies.keys())
if missing_groups:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
click==8.1.7
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
isort==5.13.2
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"

[project]
name = "foo"
version = "0.0.1"
dynamic = ["dependencies", "optional-dependencies"]

[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt", "requirements-2.txt"] }

[tool.setuptools.dynamic.optional-dependencies]
cli = { file = ["cli-requirements.txt"] }
dev = { file = ["dev-requirements.txt"] }

[tool.deptry]
pep621_dev_dependency_groups = ["dev"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
packaging==24.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pkginfo==1.11.1
requests==2.32.3
tomli==2.0.2
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from os import chdir, walk
from pathlib import Path

import click
import isort
import white as w
from urllib3 import contrib
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 2,
"id": "9f4924ec-2200-4801-9d49-d4833651cbc4",
"metadata": {},
"outputs": [],
"source": [
"import click\n",
"from urllib3 import contrib\n",
"import tomli"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
111 changes: 111 additions & 0 deletions tests/functional/cli/test_cli_setuptools_dynamic_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from __future__ import annotations

import uuid
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from tests.functional.utils import Project
from tests.utils import get_issues_report

if TYPE_CHECKING:
from tests.utils import PipVenvFactory


@pytest.mark.xdist_group(name=Project.SETUPTOOLS_DYNAMIC_DEPENDENCIES)
def test_cli_setuptools_dynamic_dependencies(pip_venv_factory: PipVenvFactory) -> None:
with pip_venv_factory(
Project.SETUPTOOLS_DYNAMIC_DEPENDENCIES,
install_command="pip install -r requirements.txt -r requirements-2.txt -r cli-requirements.txt -r dev-requirements.txt",
) as virtual_env:
issue_report = f"{uuid.uuid4()}.json"
result = virtual_env.run(f"deptry . -o {issue_report}")

assert result.returncode == 1
assert get_issues_report(Path(issue_report)) == [
{
"error": {
"code": "DEP002",
"message": "'packaging' defined as a dependency but not used in the codebase",
},
"module": "packaging",
"location": {
"file": "requirements-2.txt",
"line": None,
"column": None,
},
},
{
"error": {
"code": "DEP002",
"message": "'pkginfo' defined as a dependency but not used in the codebase",
},
"module": "pkginfo",
"location": {
"file": str(Path("requirements.txt")),
"line": None,
"column": None,
},
},
{
"error": {
"code": "DEP002",
"message": "'requests' defined as a dependency but not used in the codebase",
},
"module": "requests",
"location": {
"file": str(Path("requirements.txt")),
"line": None,
"column": None,
},
},
{
"error": {
"code": "DEP004",
"message": "'isort' imported but declared as a dev dependency",
},
"module": "isort",
"location": {
"file": str(Path("src/main.py")),
"line": 5,
"column": 8,
},
},
{
"error": {
"code": "DEP001",
"message": "'white' imported but missing from the dependency definitions",
},
"module": "white",
"location": {
"file": str(Path("src/main.py")),
"line": 6,
"column": 8,
},
},
{
"error": {
"code": "DEP003",
"message": "'urllib3' imported but it is a transitive dependency",
},
"module": "urllib3",
"location": {
"file": str(Path("src/main.py")),
"line": 7,
"column": 1,
},
},
{
"error": {
"code": "DEP003",
"message": "'urllib3' imported but it is a transitive dependency",
},
"module": "urllib3",
"location": {
"file": str(Path("src/notebook.ipynb")),
"line": 2,
"column": 1,
},
},
]
1 change: 1 addition & 0 deletions tests/functional/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Project(str, Enum):
PYPROJECT_DIFFERENT_DIRECTORY = "project_with_pyproject_different_directory"
REQUIREMENTS_TXT = "project_with_requirements_txt"
REQUIREMENTS_IN = "project_with_requirements_in"
SETUPTOOLS_DYNAMIC_DEPENDENCIES = "project_with_setuptools_dynamic_dependencies"
SRC_DIRECTORY = "project_with_src_directory"
UV = "project_with_uv"

Expand Down
18 changes: 18 additions & 0 deletions tests/unit/dependency_getter/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,24 @@ def test_both(tmp_path: Path) -> None:
assert isinstance(spec, PoetryDependencyGetter)


def test_setuptools_dynamic_dependencies(tmp_path: Path) -> None:
with run_within_dir(tmp_path):
with Path("requirements.txt").open("w") as f:
f.write("foo >= 1.0")
with Path("pyproject.toml").open("w") as f:
f.write("""
[build-system]
build-backend = "setuptools.build_meta"
[project]
dynamic = ["dependencies"]
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}
""")

spec = DependencyGetterBuilder(Path("pyproject.toml")).build()
assert isinstance(spec, PEP621DependencyGetter)


def test_requirements_files(tmp_path: Path) -> None:
with run_within_dir(tmp_path):
with Path("requirements.txt").open("w") as f:
Expand Down
Loading