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: add ability to pass multiple source directories #381

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions deptry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: b


@click.command()
@click.argument("root", type=click.Path(exists=True, path_type=Path))
@click.argument("root", type=click.Path(exists=True, path_type=Path), nargs=-1, required=True)
@click.option(
"--verbose",
"-v",
Expand Down Expand Up @@ -268,7 +268,7 @@ def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: b
show_default=False,
)
def deptry(
root: Path,
root: tuple[Path, ...],
config: Path,
no_ansi: bool,
ignore_obsolete: tuple[str, ...],
Expand All @@ -290,8 +290,15 @@ def deptry(
) -> None:
"""Find dependency issues in your Python project.

[ROOT] is the path to the root directory of the project to be scanned.
All other arguments should be specified relative to [ROOT].
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

"""

Expand Down
8 changes: 5 additions & 3 deletions deptry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

@dataclass
class Core:
root: Path
root: tuple[Path, ...]
config: Path
no_ansi: bool
ignore_obsolete: tuple[str, ...]
Expand Down Expand Up @@ -148,13 +148,15 @@ def _get_dependencies(self, dependency_management_format: DependencyManagementFo

def _get_local_modules(self) -> set[str]:
"""
Get all local Python modules from the root directory and `known_first_party` list.
Get all local Python modules from the source directories and `known_first_party` list.
A module is considered a local Python module if it matches at least one of those conditions:
- it is a directory that contains at least one Python file
- it is a Python file that is not named `__init__.py` (since it is a special case)
- it is set in the `known_first_party` list
"""
guessed_local_modules = {path.stem for path in self.root.iterdir() if self._is_local_module(path)}
guessed_local_modules = {
path.stem for source in self.root for path in source.iterdir() if self._is_local_module(path)
}

return guessed_local_modules | set(self.known_first_party)

Expand Down
29 changes: 16 additions & 13 deletions deptry/python_file_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,34 @@ class PythonFileFinder:
using_default_exclude: bool
ignore_notebooks: bool = False

def get_all_python_files_in(self, directory: Path) -> list[Path]:
def get_all_python_files_in(self, directories: tuple[Path, ...]) -> list[Path]:
logging.debug("Collecting Python files to scan...")

source_files = []
source_files = set()

ignore_regex = re.compile("|".join(self.exclude + self.extend_exclude))
file_lookup_suffixes = {".py"} if self.ignore_notebooks else {".py", ".ipynb"}

gitignore_spec = self._generate_gitignore_pathspec(Path("."))

for root_str, dirs, files in os.walk(directory, topdown=True):
root = Path(root_str)
for directory in directories:
for root_str, dirs, files in os.walk(directory, topdown=True):
root = Path(root_str)

if self._is_directory_ignored(root, ignore_regex):
dirs[:] = []
continue
if self._is_directory_ignored(root, ignore_regex):
dirs[:] = []
continue

for file_str in files:
file = root / file_str
if not self._is_file_ignored(file, file_lookup_suffixes, ignore_regex, gitignore_spec):
source_files.append(file)
for file_str in files:
file = root / file_str
if not self._is_file_ignored(file, file_lookup_suffixes, ignore_regex, gitignore_spec):
source_files.add(file)

logging.debug("Python files to scan for imports:\n%s\n", "\n".join([str(file) for file in source_files]))
source_files_list = list(source_files)

return source_files
logging.debug("Python files to scan for imports:\n%s\n", "\n".join([str(file) for file in source_files_list]))

return source_files_list

def _is_directory_ignored(self, directory: Path, ignore_regex: Pattern[str]) -> bool:
return bool((self.exclude + self.extend_exclude) and ignore_regex.match(str(directory)))
Expand Down
8 changes: 7 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ _deptry_ can be run with:
deptry .
```

where `.` is the path to the root directory of the project to be scanned. All other arguments should be specified relative to this directory.
where `.` is the path to the root directory of the project to be scanned.

If your project has multiple source directories, multiple root directories can be provided:

```shell
deptry a_directory another_directory
```

If you want to configure _deptry_ using `pyproject.toml`, or if your dependencies are stored in `pyproject.toml`, but it is located in another location than the one _deptry_ is run from, you can specify the location to it by using `--config <path_to_pyproject.toml>` argument.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import a_non_existing_dependency
import toml
14 changes: 14 additions & 0 deletions tests/data/project_with_multiple_source_directories/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
# PEP 621 project metadata
# See https://www.python.org/dev/peps/pep-0621/
name = "foo"
version = "1.2.3"
requires-python = ">=3.7"
dependencies = ["toml"]

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

[tool.deptry]
ignore_obsolete = ["pkginfo"]
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import httpx

from foobar import a_local_method
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def a_local_method():
...
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import celery

from foobaz import a_local_method
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def a_local_method():
...
36 changes: 36 additions & 0 deletions tests/functional/cli/test_cli_multiple_source_directories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from click.testing import CliRunner

from deptry.cli import deptry
from tests.utils import get_issues_report, run_within_dir

if TYPE_CHECKING:
from tests.functional.types import ToolSpecificProjectBuilder


def test_cli_with_multiple_source_directories(pip_project_builder: ToolSpecificProjectBuilder) -> None:
with run_within_dir(pip_project_builder("project_with_multiple_source_directories")):
result = CliRunner().invoke(deptry, "src worker -o report.json")

assert result.exit_code == 1
assert get_issues_report() == [
{
"error": {"code": "DEP002", "message": "'toml' defined as a dependency but not used in the codebase"},
"module": "toml",
"location": {"file": str(Path("pyproject.toml")), "line": None, "column": None},
},
{
"error": {"code": "DEP001", "message": "'httpx' imported but missing from the dependency definitions"},
"module": "httpx",
"location": {"file": str(Path("src/foo.py")), "line": 1, "column": 0},
},
{
"error": {"code": "DEP001", "message": "'celery' imported but missing from the dependency definitions"},
"module": "celery",
"location": {"file": str(Path("worker/foo.py")), "line": 1, "column": 0},
},
]
2 changes: 1 addition & 1 deletion tests/unit/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test__get_local_modules(

assert (
Core(
root=tmp_path / root_suffix,
root=(tmp_path / root_suffix,),
config=Path("pyproject.toml"),
no_ansi=False,
ignore_obsolete=(),
Expand Down
62 changes: 58 additions & 4 deletions tests/unit/test_python_file_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_simple(tmp_path: Path) -> None:

files = PythonFileFinder(
exclude=(".venv",), extend_exclude=("other_dir",), using_default_exclude=False
).get_all_python_files_in(Path("."))
).get_all_python_files_in((Path("."),))

assert sorted(files) == [
Path("dir/subdir/file1.py"),
Expand All @@ -50,7 +50,7 @@ def test_only_matches_start(tmp_path: Path) -> None:

files = PythonFileFinder(
exclude=("subdir",), extend_exclude=(), using_default_exclude=False
).get_all_python_files_in(Path("."))
).get_all_python_files_in((Path("."),))

assert sorted(files) == [
Path("dir/subdir/file1.py"),
Expand Down Expand Up @@ -78,7 +78,7 @@ def test_matches_ipynb(ignore_notebooks: bool, expected: list[Path], tmp_path: P

files = PythonFileFinder(
exclude=(), extend_exclude=(), using_default_exclude=False, ignore_notebooks=ignore_notebooks
).get_all_python_files_in(Path("."))
).get_all_python_files_in((Path("."),))

assert sorted(files) == expected

Expand Down Expand Up @@ -128,11 +128,65 @@ def test_regex_argument(exclude: tuple[str], expected: list[Path], tmp_path: Pat

files = PythonFileFinder(
exclude=exclude, extend_exclude=(), using_default_exclude=False
).get_all_python_files_in(Path("."))
).get_all_python_files_in((Path("."),))

assert sorted(files) == expected


@pytest.mark.parametrize(
("exclude", "expected"),
[
(
(".*file1",),
[
Path("dir/subdir/file2.py"),
Path("dir/subdir/file3.py"),
Path("other_dir/subdir/file2.py"),
],
),
(
(".*file1|.*file2",),
[
Path("dir/subdir/file3.py"),
],
),
(
(".*/subdir/",),
[],
),
],
)
def test_multiple_source_directories(exclude: tuple[str], expected: list[Path], tmp_path: Path) -> None:
with run_within_dir(tmp_path):
create_files(
[
Path("dir/subdir/file1.py"),
Path("dir/subdir/file2.py"),
Path("dir/subdir/file3.py"),
Path("other_dir/subdir/file1.py"),
Path("other_dir/subdir/file2.py"),
Path("another_dir/subdir/file1.py"),
]
)

files = PythonFileFinder(
exclude=exclude, extend_exclude=(), using_default_exclude=False
).get_all_python_files_in((Path("dir"), Path("other_dir")))

assert sorted(files) == expected


def test_duplicates_are_removed(tmp_path: Path) -> None:
with run_within_dir(tmp_path):
create_files([Path("dir/subdir/file1.py")])

files = PythonFileFinder(exclude=(), extend_exclude=(), using_default_exclude=False).get_all_python_files_in(
(Path("."), Path("."))
)

assert sorted(files) == [Path("dir/subdir/file1.py")]


def test__generate_gitignore_pathspec_with_non_default_exclude(tmp_path: Path) -> None:
gitignore_pathspec = PythonFileFinder(
exclude=(), extend_exclude=(), using_default_exclude=False
Expand Down