Skip to content

Commit 596a7be

Browse files
feat: support setuptools dynamic dependencies (#894)
* add support for dynamic dependencies with setuptools * refactor(dependency_getter): move new methods * refactor(dependency_getter): make new methods static * test(functional): test against multiple requirements * refactor(dependency_getter): simplify logic a bit * feat(dependency_getter/setuptools): support dynamic `optional-dependencies` * test: prefix "dynamic_dependencies" with "setuptools" * feat(dependency_getter/setuptools): move logic to `PEP621DependencyGetter` * test(dependency_getter): add unit tests for `setuptools` * docs(usage): rework `setuptools` handling --------- Co-authored-by: Jake Faulkner <[email protected]>
1 parent fca9448 commit 596a7be

File tree

13 files changed

+409
-4
lines changed

13 files changed

+409
-4
lines changed

docs/usage.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ To determine the project's dependencies, _deptry_ will scan the directory it is
3636
- development dependencies from `[tool.poetry.group.dev.dependencies]` or `[tool.poetry.dev-dependencies]` section
3737
1. If a `pyproject.toml` file with a `[tool.pdm.dev-dependencies]` section is found, _deptry_ will assume it uses PDM and extract:
3838
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]` sections
39-
- 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.
39+
- 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
4040
1. If a `pyproject.toml` file with a `[tool.uv.dev-dependencies]` section is found, _deptry_ will assume it uses uv and extract:
4141
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]` sections
42-
- 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.
42+
- 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
4343
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:
44-
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]`.
45-
- 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.
44+
- dependencies from:
45+
- `[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"`)
46+
- `[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"`)
47+
- 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
4648
1. If a `requirements.in` or `requirements.txt` file is found, _deptry_ will:
4749
- extract dependencies from that file.
4850
- extract development dependencies from `dev-dependencies.txt` and `dependencies-dev.txt`, if any exist

python/deptry/dependency_getter/pep621/base.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
import logging
55
import re
66
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING
78

89
from deptry.dependency import Dependency
910
from deptry.dependency_getter.base import DependenciesExtract, DependencyGetter
11+
from deptry.dependency_getter.requirements_files import get_dependencies_from_requirements_files
1012
from deptry.utils import load_pyproject_toml
1113

14+
if TYPE_CHECKING:
15+
from typing import Any
16+
1217

1318
@dataclass
1419
class PEP621DependencyGetter(DependencyGetter):
@@ -54,13 +59,31 @@ def get(self) -> DependenciesExtract:
5459
def _get_dependencies(self) -> list[Dependency]:
5560
"""Extract dependencies from `[project.dependencies]` (https://packaging.python.org/en/latest/specifications/pyproject-toml/#dependencies-optional-dependencies)."""
5661
pyproject_data = load_pyproject_toml(self.config)
62+
63+
if self._project_uses_setuptools(pyproject_data) and "dependencies" in pyproject_data["project"].get(
64+
"dynamic", {}
65+
):
66+
return get_dependencies_from_requirements_files(
67+
pyproject_data["tool"]["setuptools"]["dynamic"]["dependencies"]["file"], self.package_module_name_map
68+
)
69+
5770
dependency_strings: list[str] = pyproject_data["project"].get("dependencies", [])
5871
return self._extract_pep_508_dependencies(dependency_strings)
5972

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

77+
if self._project_uses_setuptools(pyproject_data) and "optional-dependencies" in pyproject_data["project"].get(
78+
"dynamic", {}
79+
):
80+
return {
81+
group: get_dependencies_from_requirements_files(specification["file"], self.package_module_name_map)
82+
for group, specification in pyproject_data["tool"]["setuptools"]["dynamic"]
83+
.get("optional-dependencies", {})
84+
.items()
85+
}
86+
6487
return {
6588
group: self._extract_pep_508_dependencies(dependencies)
6689
for group, dependencies in pyproject_data["project"].get("optional-dependencies", {}).items()
@@ -88,6 +111,28 @@ def _get_dev_dependencies(
88111
*dev_dependencies_from_optional,
89112
]
90113

114+
@staticmethod
115+
def _project_uses_setuptools(pyproject_toml: dict[str, Any]) -> bool:
116+
try:
117+
if pyproject_toml["build-system"]["build-backend"] == "setuptools.build_meta":
118+
logging.debug(
119+
"pyproject.toml has the entry build-system.build-backend == 'setuptools.build_meta', so setuptools"
120+
"is used to specify the project's dependencies."
121+
)
122+
return True
123+
else:
124+
logging.debug(
125+
"pyproject.toml does not have build-system.build-backend == 'setuptools.build_meta', so setuptools "
126+
"is not used to specify the project's dependencies."
127+
)
128+
return False
129+
except KeyError:
130+
logging.debug(
131+
"pyproject.toml does not contain a build-system.build-backend entry, so setuptools is not used to "
132+
"specify the project's dependencies."
133+
)
134+
return False
135+
91136
def _check_for_invalid_group_names(self, optional_dependencies: dict[str, list[Dependency]]) -> None:
92137
missing_groups = set(self.pep621_dev_dependency_groups) - set(optional_dependencies.keys())
93138
if missing_groups:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
click==8.1.7
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
isort==5.13.2
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[build-system]
2+
requires = ["setuptools", "setuptools-scm"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "foo"
7+
version = "0.0.1"
8+
dynamic = ["dependencies", "optional-dependencies"]
9+
10+
[tool.setuptools.dynamic]
11+
dependencies = { file = ["requirements.txt", "requirements-2.txt"] }
12+
13+
[tool.setuptools.dynamic.optional-dependencies]
14+
cli = { file = ["cli-requirements.txt"] }
15+
dev = { file = ["dev-requirements.txt"] }
16+
17+
[tool.deptry]
18+
pep621_dev_dependency_groups = ["dev"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
packaging==24.1
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pkginfo==1.11.1
2+
requests==2.32.3
3+
tomli==2.0.2
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from os import chdir, walk
2+
from pathlib import Path
3+
4+
import click
5+
import isort
6+
import white as w
7+
from urllib3 import contrib
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": 2,
6+
"id": "9f4924ec-2200-4801-9d49-d4833651cbc4",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"import click\n",
11+
"from urllib3 import contrib\n",
12+
"import tomli"
13+
]
14+
}
15+
],
16+
"metadata": {
17+
"kernelspec": {
18+
"display_name": "Python 3 (ipykernel)",
19+
"language": "python",
20+
"name": "python3"
21+
},
22+
"language_info": {
23+
"codemirror_mode": {
24+
"name": "ipython",
25+
"version": 3
26+
},
27+
"file_extension": ".py",
28+
"mimetype": "text/x-python",
29+
"name": "python",
30+
"nbconvert_exporter": "python",
31+
"pygments_lexer": "ipython3",
32+
"version": "3.9.11"
33+
}
34+
},
35+
"nbformat": 4,
36+
"nbformat_minor": 5
37+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
9+
from tests.functional.utils import Project
10+
from tests.utils import get_issues_report
11+
12+
if TYPE_CHECKING:
13+
from tests.utils import PipVenvFactory
14+
15+
16+
@pytest.mark.xdist_group(name=Project.SETUPTOOLS_DYNAMIC_DEPENDENCIES)
17+
def test_cli_setuptools_dynamic_dependencies(pip_venv_factory: PipVenvFactory) -> None:
18+
with pip_venv_factory(
19+
Project.SETUPTOOLS_DYNAMIC_DEPENDENCIES,
20+
install_command="pip install -r requirements.txt -r requirements-2.txt -r cli-requirements.txt -r dev-requirements.txt",
21+
) as virtual_env:
22+
issue_report = f"{uuid.uuid4()}.json"
23+
result = virtual_env.run(f"deptry . -o {issue_report}")
24+
25+
assert result.returncode == 1
26+
assert get_issues_report(Path(issue_report)) == [
27+
{
28+
"error": {
29+
"code": "DEP002",
30+
"message": "'packaging' defined as a dependency but not used in the codebase",
31+
},
32+
"module": "packaging",
33+
"location": {
34+
"file": "requirements-2.txt",
35+
"line": None,
36+
"column": None,
37+
},
38+
},
39+
{
40+
"error": {
41+
"code": "DEP002",
42+
"message": "'pkginfo' defined as a dependency but not used in the codebase",
43+
},
44+
"module": "pkginfo",
45+
"location": {
46+
"file": str(Path("requirements.txt")),
47+
"line": None,
48+
"column": None,
49+
},
50+
},
51+
{
52+
"error": {
53+
"code": "DEP002",
54+
"message": "'requests' defined as a dependency but not used in the codebase",
55+
},
56+
"module": "requests",
57+
"location": {
58+
"file": str(Path("requirements.txt")),
59+
"line": None,
60+
"column": None,
61+
},
62+
},
63+
{
64+
"error": {
65+
"code": "DEP004",
66+
"message": "'isort' imported but declared as a dev dependency",
67+
},
68+
"module": "isort",
69+
"location": {
70+
"file": str(Path("src/main.py")),
71+
"line": 5,
72+
"column": 8,
73+
},
74+
},
75+
{
76+
"error": {
77+
"code": "DEP001",
78+
"message": "'white' imported but missing from the dependency definitions",
79+
},
80+
"module": "white",
81+
"location": {
82+
"file": str(Path("src/main.py")),
83+
"line": 6,
84+
"column": 8,
85+
},
86+
},
87+
{
88+
"error": {
89+
"code": "DEP003",
90+
"message": "'urllib3' imported but it is a transitive dependency",
91+
},
92+
"module": "urllib3",
93+
"location": {
94+
"file": str(Path("src/main.py")),
95+
"line": 7,
96+
"column": 1,
97+
},
98+
},
99+
{
100+
"error": {
101+
"code": "DEP003",
102+
"message": "'urllib3' imported but it is a transitive dependency",
103+
},
104+
"module": "urllib3",
105+
"location": {
106+
"file": str(Path("src/notebook.ipynb")),
107+
"line": 2,
108+
"column": 1,
109+
},
110+
},
111+
]

0 commit comments

Comments
 (0)