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

Allow specifying which groups under [project.optional-dependencies] are considered development dependencies #628

Merged
merged 14 commits into from
Mar 23, 2024
56 changes: 49 additions & 7 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ To determine the project's dependencies, _deptry_ will scan the directory it is
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]` sections
- development dependencies from `[tool.pdm.dev-dependencies]` section.
3. 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]` sections
- dependencies from `[project.dependencies]` and `[project.optional-dependencies]`.
- development dependencies from the groups under `[project.optional-dependencies]` passed via the [`--pep621-dev-dependency-groups`](#pep-621-dev-dependency-groups) argument.
4. If a `requirements.txt` file is found, _deptry_ will extract:
- dependencies from it
- development dependencies from `dev-dependencies.txt` and `dependencies-dev.txt`, if any exist
Expand Down Expand Up @@ -139,7 +140,7 @@ deptry . --no-ansi

List of patterns to exclude when searching for source files.

- Type: `List[str]`
- Type: `list[str]`
- Default: `["venv", "\.venv", "\.direnv", "tests", "\.git", "setup\.py"]`
- `pyproject.toml` option name: `exclude`
- CLI option name: `--exclude` (short: `-e`)
Expand All @@ -158,7 +159,7 @@ deptry . --exclude "a_directory|a_python_file\.py|a_pattern/.*"
Additional list of patterns to exclude when searching for source files.
This extends the patterns set in [Exclude](#exclude), to allow defining patterns while keeping the default list.

- Type: `List[str]`
- Type: `list[str]`
- Default: `[]`
- `pyproject.toml` option name: `extend_exclude`
- CLI option name: `--extend-exclude` (short: `-ee`)
Expand All @@ -176,7 +177,7 @@ deptry . --extend-exclude "a_directory|a_python_file\.py|a_pattern/.*"

A comma-separated list of [rules](rules-violations.md) to ignore.

- Type: `List[str]`
- Type: `list[str]`
- Default: `[]`
- `pyproject.toml` option name: `ignore`
- CLI option name: `--ignore` (short: `-i`)
Expand Down Expand Up @@ -231,7 +232,7 @@ deptry . --ignore-notebooks

List of [`pip` requirements files](https://pip.pypa.io/en/stable/user_guide/#requirements-files) that contain the source dependencies.

- Type: `List[str]`
- Type: `list[str]`
- Default: `["requirements.txt"]`
- `pyproject.toml` option name: `requirements_txt`
- CLI option name: `--requirements-txt` (short: `-rt`)
Expand All @@ -249,7 +250,7 @@ deptry . --requirements-txt requirements.txt,requirements-private.txt

List of [`pip` requirements files](https://pip.pypa.io/en/stable/user_guide/#requirements-files) that contain the source development dependencies.

- Type: `List[str]`
- Type: `list[str]`
- Default: `["dev-requirements.txt", "requirements-dev.txt"]`
- `pyproject.toml` option name: `requirements_txt_dev`
- CLI option name: `--requirements-txt-dev` (short: `-rtd`)
Expand All @@ -267,7 +268,7 @@ deptry . --requirements-txt-dev requirements-dev.txt,requirements-tests.txt

List of Python modules that should be considered as first party ones. This is useful in case _deptry_ is not able to automatically detect modules that should be considered as local ones.

- Type: `List[str]`
- Type: `list[str]`
- Default: `[]`
- `pyproject.toml` option name: `known_first_party`
- CLI option name: `--known-first-party` (short: `-kf`)
Expand Down Expand Up @@ -400,3 +401,44 @@ Multiple package name to module name mappings are joined by a comma (`,`):
```shell
deptry . --package-module-name-map 'foo-python=foo,bar-python=bar'
```

#### PEP 621 dev dependency groups

PEP 621 does [not define](https://peps.python.org/pep-0621/#recommend-that-tools-put-development-related-dependencies-into-a-dev-extra) a standard convention for specifying development dependencies. However, deptry offers a mechanism to interpret specific optional dependency groups as development dependencies.

By default, all dependencies under `[project.dependencies]` and `[project.optional-dependencies]` are extracted as regular dependencies. By using the `--pep621-dev-dependency-groups` argument, users can specify which groups defined under `[project.optional-dependencies]` should be treated as development dependencies instead. This is particularly useful for projects that adhere to PEP 621 but do not employ a separate build tool for declaring development dependencies.
fpgmaas marked this conversation as resolved.
Show resolved Hide resolved

For example, consider a project with the following `pyproject.toml`:

```toml
[project]
...
dependencies = [
"httpx",
]

[project.optional-dependencies]
test = [
"pytest < 5.0.0",
]
plot = [
"matplotlib",
]
```

By default, `httpx`, `pytest` and `matplotlib` are extracted as regular dependencies. By specifying `--pep621-dev-dependency-groups=test`,
the dependency `pytest` will be considered a development dependency instead.

- Type: `list[str]`
- Default: `[]`
- `pyproject.toml` option name: `pep621_dev_dependency_groups`
- CLI option name: `--pep621-dev-dependency-groups` (short: `-ddg`)
- `pyproject.toml` example:
```toml
[tool.deptry]
pep621_dev_dependency_groups = ["test", "docs"]
```
- CLI example:
```shell
deptry . --pep621-dev-dependency-groups "test,docs"
```
13 changes: 13 additions & 0 deletions python/deptry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@ def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: b
default={},
show_default=False,
)
@click.option(
"--pep621-dev-dependency-groups",
"-ddg",
type=COMMA_SEPARATED_TUPLE,
help="""For projects that use PEP621 and that do not use a build tool that has its own method of declaring development dependencies,
this argument provides the option to specify which groups under [project.optional-dependencies] in pyproject.toml
should be considered development dependencies. For example, use `--pep621-dev-dependency-groups tests,docs` to mark the dependencies in
the groups 'tests' and 'docs' as development dependencies.""",
default=(),
show_default=False,
)
def deptry(
root: tuple[Path, ...],
config: Path,
Expand All @@ -238,6 +249,7 @@ def deptry(
known_first_party: tuple[str, ...],
json_output: str,
package_module_name_map: MutableMapping[str, tuple[str, ...]],
pep621_dev_dependency_groups: tuple[str, ...],
) -> None:
"""Find dependency issues in your Python project.

Expand Down Expand Up @@ -267,4 +279,5 @@ def deptry(
known_first_party=known_first_party,
json_output=json_output,
package_module_name_map=package_module_name_map,
pep621_dev_dependency_groups=pep621_dev_dependency_groups,
).run()
25 changes: 23 additions & 2 deletions python/deptry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Core:
known_first_party: tuple[str, ...]
json_output: str
package_module_name_map: Mapping[str, tuple[str, ...]]
pep621_dev_dependency_groups: tuple[str, ...]

def run(self) -> None:
self._log_config()
Expand All @@ -62,6 +63,8 @@ def run(self) -> None:
).detect()
dependencies_extract = self._get_dependencies(dependency_management_format)

self._log_dependencies(dependencies_extract)

all_python_files = PythonFileFinder(
self.exclude, self.extend_exclude, self.using_default_exclude, self.ignore_notebooks
).get_all_python_files_in(self.root)
Expand Down Expand Up @@ -141,9 +144,13 @@ def _get_dependencies(self, dependency_management_format: DependencyManagementFo
if dependency_management_format is DependencyManagementFormat.POETRY:
return PoetryDependencyGetter(self.config, self.package_module_name_map).get()
if dependency_management_format is DependencyManagementFormat.PDM:
return PDMDependencyGetter(self.config, self.package_module_name_map).get()
return PDMDependencyGetter(
self.config, self.package_module_name_map, self.pep621_dev_dependency_groups
).get()
if dependency_management_format is DependencyManagementFormat.PEP_621:
return PEP621DependencyGetter(self.config, self.package_module_name_map).get()
return PEP621DependencyGetter(
self.config, self.package_module_name_map, self.pep621_dev_dependency_groups
).get()
if dependency_management_format is DependencyManagementFormat.REQUIREMENTS_TXT:
return RequirementsTxtDependencyGetter(
self.config, self.package_module_name_map, self.requirements_txt, self.requirements_txt_dev
Expand Down Expand Up @@ -188,6 +195,20 @@ def _log_config(self) -> None:
logging.debug("%s: %s", key, value)
logging.debug("")

@staticmethod
def _log_dependencies(dependencies_extract: DependenciesExtract) -> None:
if dependencies_extract.dependencies:
logging.debug("The project contains the following dependencies:")
for dependency in dependencies_extract.dependencies:
logging.debug(dependency)
logging.debug("")

if dependencies_extract.dev_dependencies:
logging.debug("The project contains the following dev dependencies:")
for dependency in dependencies_extract.dev_dependencies:
logging.debug(dependency)
logging.debug("")

@staticmethod
def _exit(violations: list[Violation]) -> None:
sys.exit(bool(violations))
10 changes: 0 additions & 10 deletions python/deptry/dependency_getter/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -36,12 +35,3 @@ class DependencyGetter(ABC):
def get(self) -> DependenciesExtract:
"""Get extracted dependencies and dev dependencies."""
raise NotImplementedError()

@staticmethod
def _log_dependencies(dependencies: list[Dependency], is_dev: bool = False) -> None:
logging.debug("The project contains the following %s:", "dev dependencies" if is_dev else "dependencies")

for dependency in dependencies:
logging.debug(dependency)

logging.debug("")
8 changes: 4 additions & 4 deletions python/deptry/dependency_getter/pdm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ class PDMDependencyGetter(PEP621DependencyGetter):
def get(self) -> DependenciesExtract:
pep_621_dependencies_extract = super().get()

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

return DependenciesExtract(pep_621_dependencies_extract.dependencies, dev_dependencies)
return DependenciesExtract(
pep_621_dependencies_extract.dependencies,
[*pep_621_dependencies_extract.dev_dependencies, *self._get_pdm_dev_dependencies()],
)

def _get_pdm_dev_dependencies(self) -> list[Dependency]:
"""
Expand Down
52 changes: 47 additions & 5 deletions python/deptry/dependency_getter/pep_621.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import itertools
import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
Expand All @@ -15,6 +16,7 @@

@dataclass
class PEP621DependencyGetter(DependencyGetter):
pep621_dev_dependency_groups: tuple[str, ...] = ()
"""
Class to extract dependencies from a pyproject.toml file in which dependencies are specified according to PEP 621. For example:
fpgmaas marked this conversation as resolved.
Show resolved Hide resolved

Expand All @@ -33,14 +35,25 @@ class PEP621DependencyGetter(DependencyGetter):
"pytest-cov[all]"
]

Note that both dependencies and optional-dependencies are extracted as regular dependencies. Since PEP-621 does not specify
a recommended way to extract development dependencies, we do not attempt to extract any from the pyproject.toml file.
Note that by default both dependencies and optional-dependencies are extracted as regular dependencies, since PEP-621 does not specify
a recommended way to extract development dependencies. However, if a value is passed for the `pep621_dev_dependency_groups`
argument, all dependencies from groups in that argument are considered to be development dependencies. e.g. in the example above, when
`pep621_dev_dependency_groups=(test,)`, both `pytest` and `pytest-cov` are returned as development dependencies.
"""

def get(self) -> DependenciesExtract:
dependencies = [*self._get_dependencies(), *itertools.chain(*self._get_optional_dependencies().values())]
self._log_dependencies(dependencies)

dependencies = self._get_dependencies()
optional_dependencies = self._get_optional_dependencies()

if self.pep621_dev_dependency_groups:
self._check_for_invalid_group_names(optional_dependencies)
dev_dependencies, leftover_optional_dependencies = (
self._split_development_dependencies_from_optional_dependencies(optional_dependencies)
)
dependencies = [*dependencies, *leftover_optional_dependencies]
return DependenciesExtract(dependencies, dev_dependencies)

dependencies = [*dependencies, *itertools.chain(*optional_dependencies.values())]
return DependenciesExtract(dependencies, [])

def _get_dependencies(self) -> list[Dependency]:
Expand All @@ -56,6 +69,35 @@ def _get_optional_dependencies(self) -> dict[str, list[Dependency]]:
for group, dependencies in pyproject_data["project"].get("optional-dependencies", {}).items()
}

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:
logging.warning(
"Warning: Trying to extract the dependencies from the optional dependency groups %s as development dependencies, "
"but the following groups were not found: %s",
list(self.pep621_dev_dependency_groups),
list(missing_groups),
)

def _split_development_dependencies_from_optional_dependencies(
self, optional_dependencies: dict[str, list[Dependency]]
) -> tuple[list[Dependency], list[Dependency]]:
"""
Split the optional dependencies into optional dependencies and development dependencies based on the `pep621_dev_dependency_groups`
parameter. Return a tuple with two values: a list of the development dependencies and a list of the remaining 'true' optional dependencies.
"""
dev_dependencies = list(
itertools.chain.from_iterable(
deps for group, deps in optional_dependencies.items() if group in self.pep621_dev_dependency_groups
)
)
regular_dependencies = list(
itertools.chain.from_iterable(
deps for group, deps in optional_dependencies.items() if group not in self.pep621_dev_dependency_groups
)
)
return dev_dependencies, regular_dependencies

def _extract_pep_508_dependencies(
self, dependencies: list[str], package_module_name_map: Mapping[str, Sequence[str]]
) -> list[Dependency]:
Expand Down
8 changes: 1 addition & 7 deletions python/deptry/dependency_getter/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,7 @@ class PoetryDependencyGetter(DependencyGetter):
"""Extract Poetry dependencies from pyproject.toml."""

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

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

return DependenciesExtract(dependencies, dev_dependencies)
return DependenciesExtract(self._get_poetry_dependencies(), self._get_poetry_dev_dependencies())

def _get_poetry_dependencies(self) -> list[Dependency]:
pyproject_data = load_pyproject_toml(self.config)
Expand Down
2 changes: 0 additions & 2 deletions python/deptry/dependency_getter/requirements_txt.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ def get(self) -> DependenciesExtract:
*(self._get_dependencies_from_requirements_file(file_name) for file_name in self.requirements_txt)
)
)
self._log_dependencies(dependencies=dependencies)

dev_dependencies = list(
itertools.chain(
Expand All @@ -35,7 +34,6 @@ def get(self) -> DependenciesExtract:
)
)
)
self._log_dependencies(dependencies=dev_dependencies, is_dev=True)

return DependenciesExtract(dependencies, dev_dependencies)

Expand Down
4 changes: 4 additions & 0 deletions tests/data/pep_621_project/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ dev = [
"mypy==0.982",
]
test = ["pytest==7.2.0"]
plot = ["matplotlib"]

[build-system]
requires = ["setuptools>=61.0.0"]
build-backend = "setuptools.build_meta"

[tool.deptry]
pep621_dev_dependency_groups = ["dev"]

[tool.deptry.per_rule_ignores]
DEP002 = ["pkginfo"]
3 changes: 3 additions & 0 deletions tests/data/project_with_pdm/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ test = [
"pytest-cov>=4.0.0",
]

[tool.deptry]
pep621_dev_dependency_groups = ["bar"]

[tool.deptry.per_rule_ignores]
DEP002 = ["pkginfo"]
1 change: 1 addition & 0 deletions tests/functional/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ def test_cli_verbose(poetry_venv_factory: PoetryVenvFactory) -> None:

assert result.returncode == 1
assert "The project contains the following dependencies:" in result.stderr
assert "The project contains the following dev dependencies:" in result.stderr
assert get_issues_report(Path(issue_report)) == [
{
"error": {
Expand Down
12 changes: 0 additions & 12 deletions tests/functional/cli/test_cli_pdm.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,6 @@ def test_cli_with_pdm(pdm_venv_factory: PDMVenvFactory) -> None:
"column": None,
},
},
{
"error": {
"code": "DEP002",
"message": "'requests' defined as a dependency but not used in the codebase",
},
"module": "requests",
"location": {
"file": str(Path("pyproject.toml")),
"line": None,
"column": None,
},
},
{
"error": {
"code": "DEP004",
Expand Down
Loading
Loading