from __future__ import annotations import logging import shutil import sys from collections import defaultdict from importlib.metadata import version from pathlib import Path from typing import TYPE_CHECKING import click from deptry.config import read_configuration_from_pyproject_toml from deptry.core import Core if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping, Sequence if sys.platform == "win32": from colorama import just_fix_windows_console just_fix_windows_console() DEFAULT_EXCLUDE = ("venv", r"\.venv", r"\.direnv", "tests", r"\.git", r"setup\.py") DEFAULT_REQUIREMENTS_FILES = ("requirements.txt",) class CommaSeparatedTupleParamType(click.ParamType): """ This class is used to uniformly handle configuration parameters that can be either passed as a comma-separated string, as a list of strings, or as a tuple of strings. For example, the value for a parameter can be a comma-separated string when passed as a command line argument, or as a list of strings when passed through pyproject.toml. """ name = "tuple" def convert( self, value: str | list[str] | tuple[str, ...], param: click.Parameter | None, ctx: click.Context | None, ) -> tuple[str, ...]: if isinstance(value, str): return tuple(value.split(",")) if isinstance(value, list): return tuple(value) return value COMMA_SEPARATED_TUPLE = CommaSeparatedTupleParamType() class CommaSeparatedMappingParamType(click.ParamType): """ This class is used to uniformly handle configuration parameters that can be either passed as a comma-separated pair string, or as a Mapping of strings to tuples of strings. Items in a pair string are separated by an equal sign, where multiple values are separated by a pipe: key1=value1,key2=value2|value3. For example, the value for a parameter can be a comma-separated pair string when passed as a command line argument, or as a mapping of string to tuples of strings when passed through pyproject.toml. """ name = "mapping" def convert( self, # In the mapping value below, although a str is a Sequence[str] itself, # they are treated differently from other sequences of str. value: str | MutableMapping[str, Sequence[str] | str], param: click.Parameter | None, ctx: click.Context | None, ) -> dict[str, tuple[str, ...]]: converted: dict[str, tuple[str, ...]] if isinstance(value, str): map_: defaultdict[str, list[str]] = defaultdict(list) for item in value.split(","): pair = tuple(item.split("=", 1)) if len(pair) != 2: error_msg = ( f"package name and module names pairs should be concatenated with an equal sign (=): {item}" ) raise ValueError(error_msg) package_name = pair[0] module_names = pair[1].split("|") map_[package_name].extend(module_names) converted = {k: tuple(v) for k, v in map_.items()} else: converted = {k: (v,) if isinstance(v, str) else tuple(v) for k, v in value.items()} return converted COMMA_SEPARATED_MAPPING = CommaSeparatedMappingParamType() def configure_logger(_ctx: click.Context, _param: click.Parameter, value: bool) -> None: log_level = logging.DEBUG if value else logging.INFO logging.basicConfig(level=log_level, handlers=[logging.StreamHandler()], format="%(message)s") def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None: if not value or ctx.resilient_parsing: return None click.echo(f"deptry {version('deptry')}") ctx.exit() @click.command() @click.argument("root", type=click.Path(exists=True, path_type=Path), nargs=-1, required=True) @click.option( "--verbose", "-v", is_flag=True, help=( "Boolean flag for verbosity. Using this flag will display more information about files, imports and" " dependencies while running." ), expose_value=False, is_eager=True, callback=configure_logger, ) @click.option( "--config", type=click.Path(path_type=Path), is_eager=True, callback=read_configuration_from_pyproject_toml, help="Path to the pyproject.toml file to read configuration from.", default="pyproject.toml", ) @click.option( "--no-ansi", is_flag=True, help="Disable ANSI characters in terminal output.", ) @click.option( "--ignore", "-i", type=COMMA_SEPARATED_TUPLE, help="""A comma-separated list of error codes to ignore. e.g. `deptry --ignore DEP001,DEP002` For more information regarding the error codes, see https://deptry.com/issue-codes/""", default=(), ) @click.option( "--per-rule-ignores", "-pri", type=COMMA_SEPARATED_MAPPING, help="""A comma-separated mapping of packages or modules to be ignored per error code. . e.g. ``deptry . --per-rule-ignores DEP001=matplotlib,DEP002=pandas|numpy`` For more information regarding the error codes, see https://deptry.com/issue-codes/""", default={}, ) @click.option( "--exclude", "-e", multiple=True, type=str, help=f"""A regular expression for directories or files in which .py files should not be scanned for imports to determine if there are dependency issues. Can be used multiple times by specifying the argument multiple times. re.match() is used to match the expressions, which by default checks for a match only at the beginning of a string. For example: `deptry . -e ".*/foo/" -e bar"` Note that this overwrites the defaults. [default: {", ".join(DEFAULT_EXCLUDE)}]""", ) @click.option( "--extend-exclude", "-ee", type=str, multiple=True, help="""Like --exclude, but adds additional files and directories on top of the excluded ones instead of overwriting the defaults. (Useful if you simply want to add to the default) `deptry . -ee ".*/foo/" -ee bar"`""", default=(), show_default=True, ) @click.option( "--ignore-notebooks", "-nb", is_flag=True, help="Boolean flag to specify if notebooks should be ignored while scanning for imports.", ) @click.option( "--version", is_flag=True, is_eager=True, expose_value=False, callback=display_deptry_version, help="Display the current version and exit.", ) @click.option( "--requirements-files", "-rf", type=COMMA_SEPARATED_TUPLE, help=f""".txt files to scan for dependencies. If a file called pyproject.toml with a [tool.poetry.dependencies] or [project] section is found, this argument is ignored and the dependencies are extracted from the pyproject.toml file instead. Can be multiple e.g. `deptry . --requirements-files req/prod.txt,req/extra.txt` [default: {", ".join(DEFAULT_REQUIREMENTS_FILES)}]""", ) @click.option( "--requirements-files-dev", "-rfd", type=COMMA_SEPARATED_TUPLE, help=""".txt files to scan for additional development dependencies. If a file called pyproject.toml with a [tool.poetry.dependencies] or [project] section is found, this argument is ignored and the dependencies are extracted from the pyproject.toml file instead. Can be multiple e.g. `deptry . --requirements-files-dev req/dev.txt,req/test.txt`""", default=("dev-requirements.txt", "requirements-dev.txt"), show_default=True, ) @click.option( "--known-first-party", "-kf", type=str, multiple=True, help="Modules to consider as first party ones.", default=(), show_default=True, ) @click.option( "--json-output", "-o", type=str, help="""If specified, a summary of the dependency issues found will be written to the output location specified. e.g. `deptry . -o deptry.json`""", show_default=True, ) @click.option( "--package-module-name-map", "-pmnm", type=COMMA_SEPARATED_MAPPING, help="""Manually defined module names belonging to packages. For example; `deptry . --package-module-name-map package_1=module_a,package_2=module_b|module_c`.""", 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, ) @click.option( "--experimental-namespace-package", is_flag=True, help="Enable experimental support for namespace package (PEP 420) when detecting local modules (https://peps.python.org/pep-0420/).", ) def cli( root: tuple[Path, ...], config: Path, no_ansi: bool, ignore: tuple[str, ...], per_rule_ignores: Mapping[str, tuple[str, ...]], exclude: tuple[str, ...], extend_exclude: tuple[str, ...], ignore_notebooks: bool, requirements_files: tuple[str, ...], requirements_files_dev: tuple[str, ...], known_first_party: tuple[str, ...], json_output: str, package_module_name_map: MutableMapping[str, tuple[str, ...]], pep621_dev_dependency_groups: tuple[str, ...], experimental_namespace_package: bool, ) -> None: """Find dependency issues in your Python project. ROOT is the path to the root directory of the project to be scanned. For instance, to invoke deptry in the current directory: deptry . If your project has multiple source directories, multiple ROOT can be specified. For instance, to invoke deptry in both 'src' and 'worker' directories: deptry src worker """ Core( root=root, config=config, no_ansi=no_ansi, exclude=exclude or DEFAULT_EXCLUDE, extend_exclude=extend_exclude, using_default_exclude=not exclude, ignore_notebooks=ignore_notebooks, ignore=ignore, per_rule_ignores=per_rule_ignores, requirements_files=requirements_files or DEFAULT_REQUIREMENTS_FILES, using_default_requirements_files=not requirements_files, requirements_files_dev=requirements_files_dev, 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, experimental_namespace_package=experimental_namespace_package, ).run() def deptry() -> None: column_size, _line_size = shutil.get_terminal_size() cli(max_content_width=column_size)