Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ If your project has multiple source directories, multiple root directories can b
deptry a_directory another_directory
```

If you want to scan a single Python file instead of a whole directory, you can provide the path to that file:

```shell
deptry foo.py
```

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.

## Dependencies extraction
Expand Down
Empty file added foo.py
Empty file.
6 changes: 5 additions & 1 deletion python/deptry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ def cli(
) -> 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
ROOT is the path to the source directory or file to be scanned. For instance, to invoke deptry in the current
directory:

deptry .
Expand All @@ -301,6 +301,10 @@ def cli(

deptry src worker

You can also specify a single Python file:

deptry app.py

"""

handle_deprecations(ctx)
Expand Down
19 changes: 15 additions & 4 deletions python/deptry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,26 @@ def _find_python_files(self) -> list[Path]:

def _get_local_modules(self) -> set[str]:
"""
Get all local Python modules from the source directories and `known_first_party` list.
Get all local Python modules from the source paths 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

Source paths can be either directories (which are searched for modules) or individual
Python files (whose stem is used as the module name).
"""
guessed_local_modules = {
path.stem for source in self.root for path in source.iterdir() if self._is_local_module(path)
}
guessed_local_modules: set[str] = set()

for source in self.root:
if source.is_file(): # likely a single file program
if source.suffix == ".py" and source.name != "__init__.py":
guessed_local_modules.add(source.stem)
continue

for path in source.iterdir():
if self._is_local_module(path):
guessed_local_modules.add(path.stem)

return guessed_local_modules | set(self.known_first_party)

Expand Down
20 changes: 15 additions & 5 deletions python/deptry/python_file_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@


def get_all_python_files_in(
directories: tuple[Path, ...],
paths: tuple[Path, ...],
exclude: tuple[str, ...],
extend_exclude: tuple[str, ...],
using_default_exclude: bool,
ignore_notebooks: bool = False,
) -> list[Path]:
return [
Path(f)
for f in find_python_files(directories, exclude, extend_exclude, using_default_exclude, ignore_notebooks)
]
"""Find all Python files in the given paths.

Args:
paths: Tuple of paths to search. Either a single Python file (.py or .ipynb)
or one or more directories (which are walked recursively).
exclude: Regex patterns for paths to exclude.
extend_exclude: Additional regex patterns to exclude.
using_default_exclude: Whether to use default excludes (respects .gitignore, etc.).
ignore_notebooks: If True, .ipynb files are excluded.

Returns:
List of paths to Python files found.
"""
return [Path(f) for f in find_python_files(paths, exclude, extend_exclude, using_default_exclude, ignore_notebooks)]
2 changes: 1 addition & 1 deletion python/deptry/rust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ from .rust import Location as RustLocation
def get_imports_from_py_files(file_paths: list[str]) -> dict[str, list[RustLocation]]: ...
def get_imports_from_ipynb_files(file_paths: list[str]) -> dict[str, list[RustLocation]]: ...
def find_python_files(
directories: tuple[Path, ...],
paths: tuple[Path, ...],
exclude: tuple[str, ...],
extend_exclude: tuple[str, ...],
using_default_exclude: bool,
Expand Down
54 changes: 39 additions & 15 deletions src/python_file_finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,62 @@ use pyo3::{Bound, IntoPyObject, PyAny, Python, pyfunction};
use regex::Regex;
use std::path::PathBuf;

fn _build_single_file_result(unique_paths: &[PathBuf], ignore_notebooks: bool) -> Vec<String> {
let var_name = &unique_paths[0];
let path = var_name;
let is_valid = match path.extension().and_then(|ext| ext.to_str()) {
Some("py") => true,
Some("ipynb") => !ignore_notebooks,
_ => false,
};

let result: Vec<String> = if is_valid {
let path_str = path.to_string_lossy();
vec![path_str.strip_prefix("./").unwrap_or(&path_str).to_string()]
} else {
vec![]
};

result
}

#[pyfunction]
#[pyo3(signature = (directories, exclude, extend_exclude, using_default_exclude, ignore_notebooks=false))]
#[pyo3(signature = (paths, exclude, extend_exclude, using_default_exclude, ignore_notebooks=false))]
pub fn find_python_files(
py: Python<'_>,
directories: Vec<PathBuf>,
paths: Vec<PathBuf>,
exclude: Vec<String>,
extend_exclude: Vec<String>,
using_default_exclude: bool,
ignore_notebooks: bool,
) -> Bound<'_, PyAny> {
let mut unique_directories = directories;
unique_directories.dedup();
let mut unique_paths: Vec<PathBuf> = paths;
unique_paths.dedup();

let python_files: Vec<_> = build_walker(
unique_directories.as_ref(),
// Fast Path: If there's only one file passed
let is_single_file: bool = unique_paths.len() == 1 && unique_paths[0].is_file();
if is_single_file {
return _build_single_file_result(&unique_paths, ignore_notebooks)
.into_pyobject(py)
.unwrap();
}

// General Path: Multiple files or directories
build_walker(
unique_paths.as_ref(),
[exclude, extend_exclude].concat().as_ref(),
using_default_exclude,
ignore_notebooks,
)
.flatten()
.filter(|entry| entry.path().is_file())
.map(|entry| {
entry
.path()
.to_string_lossy()
.strip_prefix("./")
.unwrap_or(&entry.path().to_string_lossy())
.to_owned()
let path_str = entry.path().to_string_lossy();
path_str.strip_prefix("./").unwrap_or(&path_str).to_string()
})
.collect();

python_files.into_pyobject(py).unwrap()
.collect::<Vec<String>>()
.into_pyobject(py)
.unwrap()
}

fn build_walker(
Expand Down
65 changes: 64 additions & 1 deletion tests/unit/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
),
],
)
def test__get_local_modules(
def test__get_local_modules_with_directories(
tmp_path: Path,
known_first_party: tuple[str, ...],
root_suffix: str,
Expand Down Expand Up @@ -133,6 +133,69 @@ def test__get_local_modules(
)


def test__get_local_modules_with_single_file(tmp_path: Path) -> None:
"""Test that _get_local_modules works with a single Python file."""
with run_within_dir(tmp_path):
create_files([
Path("app.py"),
Path("other.py"),
])

result = Core(
root=(tmp_path / "app.py",),
config=Path("pyproject.toml"),
no_ansi=False,
per_rule_ignores={},
ignore=(),
exclude=(),
extend_exclude=(),
using_default_exclude=True,
ignore_notebooks=False,
requirements_files=(),
requirements_files_dev=(),
known_first_party=(),
json_output="",
package_module_name_map={},
optional_dependencies_dev_groups=(),
using_default_requirements_files=True,
experimental_namespace_package=False,
github_output=False,
github_warning_errors=(),
)._get_local_modules()

assert result == {"app"}


def test__get_local_modules_ignores_init_file(tmp_path: Path) -> None:
"""Test that __init__.py files are not treated as modules."""
with run_within_dir(tmp_path):
create_files([Path("__init__.py")])

result = Core(
root=(tmp_path / "__init__.py",),
config=Path("pyproject.toml"),
no_ansi=False,
per_rule_ignores={},
ignore=(),
exclude=(),
extend_exclude=(),
using_default_exclude=True,
ignore_notebooks=False,
requirements_files=(),
requirements_files_dev=(),
known_first_party=(),
json_output="",
package_module_name_map={},
optional_dependencies_dev_groups=(),
using_default_requirements_files=True,
experimental_namespace_package=False,
github_output=False,
github_warning_errors=(),
)._get_local_modules()

assert result == set()


def test__get_stdlib_packages_with_stdlib_module_names() -> None:
assert Core._get_standard_library_modules() == sys.stdlib_module_names

Expand Down
44 changes: 44 additions & 0 deletions tests/unit/test_python_file_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,47 @@ def test_gitignore_ignored_in_non_git_project(tmp_path: Path) -> None:
Path("file1.py"),
Path("file2.py"),
]


def test_single_python_file(tmp_path: Path) -> None:
"""Test that a single Python file can be passed directly."""
with run_within_dir(tmp_path):
create_files([Path("app.py"), Path("other.py")])

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

assert files == [Path("app.py")]


def test_single_notebook_file(tmp_path: Path) -> None:
"""Test that a single notebook file can be passed directly."""
with run_within_dir(tmp_path):
create_files([Path("notebook.ipynb")])

# With ignore_notebooks=False, notebook should be included
files = get_all_python_files_in(
(Path("notebook.ipynb"),),
exclude=(),
extend_exclude=(),
using_default_exclude=False,
ignore_notebooks=False,
)
assert files == [Path("notebook.ipynb")]

# With ignore_notebooks=True, notebook should be excluded
files = get_all_python_files_in(
(Path("notebook.ipynb"),), exclude=(), extend_exclude=(), using_default_exclude=False, ignore_notebooks=True
)
assert files == []


def test_single_non_python_file_excluded(tmp_path: Path) -> None:
"""Test that a non-Python file is excluded when passed directly."""
with run_within_dir(tmp_path):
create_files([Path("readme.txt")])

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

assert files == []