diff --git a/deptry/core.py b/deptry/core.py index 70cb0948..62a24944 100644 --- a/deptry/core.py +++ b/deptry/core.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import operator import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -16,7 +17,7 @@ from deptry.issues_finder.missing import MissingDependenciesFinder from deptry.issues_finder.obsolete import ObsoleteDependenciesFinder from deptry.issues_finder.transitive import TransitiveDependenciesFinder -from deptry.module import ModuleBuilder +from deptry.module import ModuleBuilder, ModuleLocations from deptry.python_file_finder import PythonFileFinder from deptry.reporters import JSONReporter, TextReporter from deptry.stdlibs import STDLIBS_PYTHON @@ -27,7 +28,6 @@ from deptry.dependency import Dependency from deptry.dependency_getter.base import DependenciesExtract - from deptry.module import Module from deptry.violations import Violation @@ -68,19 +68,26 @@ def run(self) -> None: local_modules = self._get_local_modules() stdlib_modules = self._get_stdlib_modules() - imported_modules = [ - ModuleBuilder( - mod, - local_modules, - stdlib_modules, - dependencies_extract.dependencies, - dependencies_extract.dev_dependencies, - ).build() - for mod in get_imported_modules_for_list_of_files(all_python_files) + imported_modules_with_locations = [ + ModuleLocations( + ModuleBuilder( + module, + local_modules, + stdlib_modules, + dependencies_extract.dependencies, + dependencies_extract.dev_dependencies, + ).build(), + locations, + ) + for module, locations in get_imported_modules_for_list_of_files(all_python_files).items() + ] + imported_modules_with_locations = [ + module_with_locations + for module_with_locations in imported_modules_with_locations + if not module_with_locations.module.standard_library ] - imported_modules = [mod for mod in imported_modules if not mod.standard_library] - violations = self._find_violations(imported_modules, dependencies_extract.dependencies) + violations = self._find_violations(imported_modules_with_locations, dependencies_extract.dependencies) TextReporter(violations).report() if self.json_output: @@ -89,27 +96,41 @@ def run(self) -> None: self._exit(violations) def _find_violations( - self, imported_modules: list[Module], dependencies: list[Dependency] - ) -> dict[str, list[Violation]]: - result = {} + self, imported_modules_with_locations: list[ModuleLocations], dependencies: list[Dependency] + ) -> list[Violation]: + violations = [] if not self.skip_obsolete: - result["obsolete"] = ObsoleteDependenciesFinder(imported_modules, dependencies, self.ignore_obsolete).find() + violations.extend( + ObsoleteDependenciesFinder(imported_modules_with_locations, dependencies, self.ignore_obsolete).find() + ) if not self.skip_missing: - result["missing"] = MissingDependenciesFinder(imported_modules, dependencies, self.ignore_missing).find() + violations.extend( + MissingDependenciesFinder(imported_modules_with_locations, dependencies, self.ignore_missing).find() + ) if not self.skip_transitive: - result["transitive"] = TransitiveDependenciesFinder( - imported_modules, dependencies, self.ignore_transitive - ).find() + violations.extend( + TransitiveDependenciesFinder( + imported_modules_with_locations, dependencies, self.ignore_transitive + ).find() + ) if not self.skip_misplaced_dev: - result["misplaced_dev"] = MisplacedDevDependenciesFinder( - imported_modules, dependencies, self.ignore_misplaced_dev - ).find() + violations.extend( + MisplacedDevDependenciesFinder( + imported_modules_with_locations, dependencies, self.ignore_misplaced_dev + ).find() + ) + + return self._get_sorted_violations(violations) - return result + @staticmethod + def _get_sorted_violations(violations: list[Violation]) -> list[Violation]: + return sorted( + violations, key=operator.attrgetter("location.file", "location.line", "location.column", "error_code") + ) def _get_dependencies(self, dependency_management_format: DependencyManagementFormat) -> DependenciesExtract: if dependency_management_format is DependencyManagementFormat.POETRY: @@ -161,5 +182,5 @@ def _log_config(self) -> None: logging.debug("") @staticmethod - def _exit(violations: dict[str, list[Violation]]) -> None: - sys.exit(int(any(violations.values()))) + def _exit(violations: list[Violation]) -> None: + sys.exit(bool(violations)) diff --git a/deptry/imports/extract.py b/deptry/imports/extract.py index c7205b63..999e5b2b 100644 --- a/deptry/imports/extract.py +++ b/deptry/imports/extract.py @@ -1,7 +1,7 @@ from __future__ import annotations -import itertools import logging +from collections import defaultdict from typing import TYPE_CHECKING from deptry.imports.extractors import NotebookImportExtractor, PythonImportExtractor @@ -10,19 +10,25 @@ from pathlib import Path from deptry.imports.extractors.base import ImportExtractor + from deptry.imports.location import Location -def get_imported_modules_for_list_of_files(list_of_files: list[Path]) -> list[str]: +def get_imported_modules_for_list_of_files(list_of_files: list[Path]) -> dict[str, list[Location]]: logging.info(f"Scanning {len(list_of_files)} files...") - modules = sorted(set(itertools.chain.from_iterable(get_imported_modules_from_file(file) for file in list_of_files))) + modules: dict[str, list[Location]] = defaultdict(list) + + for file in list_of_files: + for module, locations in get_imported_modules_from_file(file).items(): + for location in locations: + modules[module].append(location) logging.debug(f"All imported modules: {modules}\n") return modules -def get_imported_modules_from_file(path_to_file: Path) -> set[str]: +def get_imported_modules_from_file(path_to_file: Path) -> dict[str, list[Location]]: logging.debug(f"Scanning {path_to_file}...") modules = _get_extractor_class(path_to_file)(path_to_file).extract_imports() diff --git a/deptry/imports/extractors/base.py b/deptry/imports/extractors/base.py index c0679783..a0bbd49e 100644 --- a/deptry/imports/extractors/base.py +++ b/deptry/imports/extractors/base.py @@ -2,11 +2,14 @@ import ast from abc import ABC, abstractmethod +from collections import defaultdict from dataclasses import dataclass from typing import TYPE_CHECKING import chardet +from deptry.imports.location import Location + if TYPE_CHECKING: from pathlib import Path @@ -20,11 +23,10 @@ class ImportExtractor(ABC): file: Path @abstractmethod - def extract_imports(self) -> set[str]: + def extract_imports(self) -> dict[str, list[Location]]: raise NotImplementedError() - @staticmethod - def _extract_imports_from_ast(tree: ast.AST) -> set[str]: + def _extract_imports_from_ast(self, tree: ast.AST) -> dict[str, list[Location]]: """ Given an Abstract Syntax Tree, find the imported top-level modules. For example, given the source tree of a file with contents: @@ -34,13 +36,16 @@ def _extract_imports_from_ast(tree: ast.AST) -> set[str]: Will return the set {"pandas"}. """ - imported_modules: set[str] = set() + imported_modules: dict[str, list[Location]] = defaultdict(list) for node in ast.walk(tree): if isinstance(node, ast.Import): - imported_modules |= {module.name.split(".")[0] for module in node.names} + for module in node.names: + imported_modules[module.name.split(".")[0]].append( + Location(self.file, node.lineno, node.col_offset) + ) elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: - imported_modules.add(node.module.split(".")[0]) + imported_modules[node.module.split(".")[0]].append(Location(self.file, node.lineno, node.col_offset)) return imported_modules diff --git a/deptry/imports/extractors/notebook_import_extractor.py b/deptry/imports/extractors/notebook_import_extractor.py index c36089b2..d4d776c3 100644 --- a/deptry/imports/extractors/notebook_import_extractor.py +++ b/deptry/imports/extractors/notebook_import_extractor.py @@ -13,16 +13,18 @@ if TYPE_CHECKING: from pathlib import Path + from deptry.imports.location import Location + @dataclass class NotebookImportExtractor(ImportExtractor): """Extract import statements from a Jupyter notebook.""" - def extract_imports(self) -> set[str]: + def extract_imports(self) -> dict[str, list[Location]]: """Extract the imported top-level modules from all code cells in the Jupyter Notebook.""" notebook = self._read_ipynb_file(self.file) if not notebook: - return set() + return {} cells = self._keep_code_cells(notebook) import_statements = [self._extract_import_statements_from_cell(cell) for cell in cells] diff --git a/deptry/imports/extractors/python_import_extractor.py b/deptry/imports/extractors/python_import_extractor.py index dae04588..436b6303 100644 --- a/deptry/imports/extractors/python_import_extractor.py +++ b/deptry/imports/extractors/python_import_extractor.py @@ -3,15 +3,19 @@ import ast import logging from dataclasses import dataclass +from typing import TYPE_CHECKING from deptry.imports.extractors.base import ImportExtractor +if TYPE_CHECKING: + from deptry.imports.location import Location + @dataclass class PythonImportExtractor(ImportExtractor): """Extract import statements from a Python module.""" - def extract_imports(self) -> set[str]: + def extract_imports(self) -> dict[str, list[Location]]: """Extract all imported top-level modules from the Python file.""" try: with open(self.file) as python_file: @@ -22,6 +26,6 @@ def extract_imports(self) -> set[str]: tree = ast.parse(python_file.read(), str(self.file)) except UnicodeDecodeError: logging.warning(f"Warning: File {self.file} could not be decoded. Skipping...") - return set() + return {} return self._extract_imports_from_ast(tree) diff --git a/deptry/imports/location.py b/deptry/imports/location.py new file mode 100644 index 00000000..fd28d3d1 --- /dev/null +++ b/deptry/imports/location.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(frozen=True) +class Location: + file: Path + line: int | None = None + column: int | None = None + + def format_for_terminal(self) -> str: + if self.line is not None and self.column is not None: + return f"{self.file}:{self.line}:{self.column}" + return str(self.file) diff --git a/deptry/issues_finder/base.py b/deptry/issues_finder/base.py index 5b4afaf9..fb2cb32a 100644 --- a/deptry/issues_finder/base.py +++ b/deptry/issues_finder/base.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from deptry.dependency import Dependency - from deptry.module import Module + from deptry.module import ModuleLocations from deptry.violations import Violation @@ -14,7 +14,7 @@ class IssuesFinder(ABC): """Base class for all issues finders.""" - imported_modules: list[Module] + imported_modules_with_locations: list[ModuleLocations] dependencies: list[Dependency] ignored_modules: tuple[str, ...] = () diff --git a/deptry/issues_finder/misplaced_dev.py b/deptry/issues_finder/misplaced_dev.py index 716ae697..0de7c886 100644 --- a/deptry/issues_finder/misplaced_dev.py +++ b/deptry/issues_finder/misplaced_dev.py @@ -30,12 +30,15 @@ def find(self) -> list[Violation]: logging.debug("\nScanning for incorrect development dependencies...") misplaced_dev_dependencies: list[Violation] = [] - for module in self.imported_modules: + for module_with_locations in self.imported_modules_with_locations: + module = module_with_locations.module + logging.debug(f"Scanning module {module.name}...") corresponding_package_name = self._get_package_name(module) if corresponding_package_name and self._is_development_dependency(module, corresponding_package_name): - misplaced_dev_dependencies.append(MisplacedDevDependencyViolation(module)) + for location in module_with_locations.locations: + misplaced_dev_dependencies.append(MisplacedDevDependencyViolation(module, location)) return misplaced_dev_dependencies diff --git a/deptry/issues_finder/missing.py b/deptry/issues_finder/missing.py index 515d9220..904346d3 100644 --- a/deptry/issues_finder/missing.py +++ b/deptry/issues_finder/missing.py @@ -22,11 +22,14 @@ def find(self) -> list[Violation]: logging.debug("\nScanning for missing dependencies...") missing_dependencies: list[Violation] = [] - for module in self.imported_modules: + for module_with_locations in self.imported_modules_with_locations: + module = module_with_locations.module + logging.debug(f"Scanning module {module.name}...") if self._is_missing(module): - missing_dependencies.append(MissingDependencyViolation(module)) + for location in module_with_locations.locations: + missing_dependencies.append(MissingDependencyViolation(module, location)) return missing_dependencies diff --git a/deptry/issues_finder/obsolete.py b/deptry/issues_finder/obsolete.py index b46185e4..2f12499a 100644 --- a/deptry/issues_finder/obsolete.py +++ b/deptry/issues_finder/obsolete.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from deptry.imports.location import Location from deptry.issues_finder.base import IssuesFinder from deptry.violations import ObsoleteDependencyViolation @@ -32,7 +33,9 @@ def find(self) -> list[Violation]: logging.debug(f"Scanning module {dependency.name}...") if self._is_obsolete(dependency): - obsolete_dependencies.append(ObsoleteDependencyViolation(dependency)) + obsolete_dependencies.append( + ObsoleteDependencyViolation(dependency, Location(dependency.definition_file)) + ) return obsolete_dependencies @@ -48,12 +51,19 @@ def _is_obsolete(self, dependency: Dependency) -> bool: return True def _dependency_found_in_imported_modules(self, dependency: Dependency) -> bool: - return any(module.package == dependency.name for module in self.imported_modules) + return any( + module_with_locations.module.package == dependency.name + for module_with_locations in self.imported_modules_with_locations + ) def _any_of_the_top_levels_imported(self, dependency: Dependency) -> bool: if not dependency.top_levels: return False return any( - any(module.name == top_level for module in self.imported_modules) for top_level in dependency.top_levels + any( + module_with_locations.module.name == top_level + for module_with_locations in self.imported_modules_with_locations + ) + for top_level in dependency.top_levels ) diff --git a/deptry/issues_finder/transitive.py b/deptry/issues_finder/transitive.py index 559bb3da..c90ad7fb 100644 --- a/deptry/issues_finder/transitive.py +++ b/deptry/issues_finder/transitive.py @@ -29,12 +29,15 @@ def find(self) -> list[Violation]: logging.debug("\nScanning for transitive dependencies...") transitive_dependencies: list[Violation] = [] - for module in self.imported_modules: + for module_with_locations in self.imported_modules_with_locations: + module = module_with_locations.module + logging.debug(f"Scanning module {module.name}...") if self._is_transitive(module): # `self._is_transitive` only returns `True` if the package is not None. - transitive_dependencies.append(TransitiveDependencyViolation(module)) + for location in module_with_locations.locations: + transitive_dependencies.append(TransitiveDependencyViolation(module, location)) return transitive_dependencies diff --git a/deptry/module.py b/deptry/module.py index 12dedbb2..03cc2948 100644 --- a/deptry/module.py +++ b/deptry/module.py @@ -1,12 +1,13 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from importlib.metadata import PackageNotFoundError, metadata from typing import TYPE_CHECKING if TYPE_CHECKING: from deptry.dependency import Dependency + from deptry.imports.location import Location @dataclass @@ -35,6 +36,12 @@ def __str__(self) -> str: return "\n".join("{}: {}".format(*item) for item in vars(self).items()) +@dataclass +class ModuleLocations: + module: Module + locations: list[Location] = field(default_factory=list) + + class ModuleBuilder: def __init__( self, diff --git a/deptry/reporters/base.py b/deptry/reporters/base.py index 8e8db7db..47ac0fd6 100644 --- a/deptry/reporters/base.py +++ b/deptry/reporters/base.py @@ -12,7 +12,7 @@ class Reporter(ABC): """Base class for all violation reporters.""" - violations: dict[str, list[Violation]] + violations: list[Violation] @abstractmethod def report(self) -> None: diff --git a/deptry/reporters/json.py b/deptry/reporters/json.py index 8a5f18f2..c9ab579a 100644 --- a/deptry/reporters/json.py +++ b/deptry/reporters/json.py @@ -2,9 +2,12 @@ import json from dataclasses import dataclass +from typing import TYPE_CHECKING from deptry.reporters.base import Reporter -from deptry.violations import TransitiveDependencyViolation + +if TYPE_CHECKING: + from typing import Any @dataclass @@ -12,17 +15,23 @@ class JSONReporter(Reporter): json_output: str def report(self) -> None: - output = {} - - for issue_type, violations in self.violations.items(): - output[issue_type] = [ - ( - violation.issue.package - if isinstance(violation, TransitiveDependencyViolation) - else violation.issue.name - ) - for violation in violations - ] + output: list[dict[str, str | dict[str, Any]]] = [] + + for violation in self.violations: + output.append( + { + "error": { + "code": violation.error_code, + "message": violation.get_error_message(), + }, + "module": violation.issue.name, + "location": { + "file": str(violation.location.file), + "line": violation.location.line, + "column": violation.location.column, + }, + }, + ) with open(self.json_output, "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=4) diff --git a/deptry/reporters/text.py b/deptry/reporters/text.py index bb588c15..fa57986f 100644 --- a/deptry/reporters/text.py +++ b/deptry/reporters/text.py @@ -2,9 +2,8 @@ import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -from deptry.module import Module from deptry.reporters.base import Reporter if TYPE_CHECKING: @@ -17,103 +16,24 @@ def report(self) -> None: self._log_and_exit() def _log_and_exit(self) -> None: - total_violations_found = sum([len(v) for k, v in self.violations.items()]) + self._log_violations(self.violations) - self._log_total_number_of_violations_found(total_violations_found) - - if "obsolete" in self.violations and self.violations["obsolete"]: - self._log_obsolete_dependencies(self.violations["obsolete"]) - - if "missing" in self.violations and self.violations["missing"]: - self._log_missing_dependencies(self.violations["missing"]) - - if "transitive" in self.violations and self.violations["transitive"]: - self._log_transitive_dependencies(self.violations["transitive"]) - - if "misplaced_dev" in self.violations and self.violations["misplaced_dev"]: - self._log_misplaced_develop_dependencies(self.violations["misplaced_dev"]) - - if total_violations_found > 0: - self._log_additional_info() + self._log_total_number_of_violations_found(self.violations) @staticmethod - def _log_total_number_of_violations_found(number: int) -> None: - if number == 0: - logging.info("Success! No dependency issues found.") - elif number == 1: - logging.info("There was 1 dependency issue found.") + def _log_total_number_of_violations_found(violations: list[Violation]) -> None: + if violations: + logging.info(f"Found {len(violations)} dependency {'issues' if len(violations) > 1 else 'issue'}.") + logging.info("\nFor more information, see the documentation: https://fpgmaas.github.io/deptry/") else: - logging.info(f"There were {number} dependency issues found.") - - def _log_obsolete_dependencies(self, violations: list[Violation], sep: str = "\n\t") -> None: - logging.info("\n-----------------------------------------------------\n") - logging.info( - "The project contains obsolete" - f" dependencies:\n{sep}{sep.join(sorted([violation.issue.name for violation in violations]))}\n" - ) - logging.info( - """Consider removing them from your project's dependencies. If a package is used for development purposes, you should add it to your development dependencies instead.""" - ) - - def _log_missing_dependencies(self, violations: list[Violation], sep: str = "\n\t") -> None: - logging.info("\n-----------------------------------------------------\n") - logging.info( - "There are dependencies missing from the project's list of" - f" dependencies:\n{sep}{sep.join(sorted([violation.issue.name for violation in violations]))}\n" - ) - logging.info("""Consider adding them to your project's dependencies. """) + logging.info("Success! No dependency issues found.") - def _log_transitive_dependencies(self, violations: list[Violation], sep: str = "\n\t") -> None: - sorted_dependencies = [] + def _log_violations(self, violations: list[Violation]) -> None: + logging.info("") for violation in violations: - # `violations` only contain transitive dependency violations, which are always `Module` that always have a - # non-null `package` attribute. - module = cast(Module, violation.issue) - package_name = cast(str, module.package) - sorted_dependencies.append(package_name) - - logging.info("\n-----------------------------------------------------\n") - logging.info( - "There are transitive dependencies that should be explicitly defined as" - f" dependencies:\n{sep}{sep.join(sorted(sorted_dependencies))}\n" - ) - logging.info("""They are currently imported but not specified directly as your project's dependencies.""") - - def _log_misplaced_develop_dependencies(self, violations: list[Violation], sep: str = "\n\t") -> None: - logging.info("\n-----------------------------------------------------\n") - logging.info( - "There are imported modules from development dependencies" - f" detected:\n{sep}{sep.join(sorted([violation.issue.name for violation in violations]))}\n" - ) - logging.info( - """Consider moving them to your project's 'regular' dependencies. If this is not correct and the dependencies listed above are indeed development dependencies, it's likely that files were scanned that are only used for development purposes. Run `deptry -v .` to see a list of scanned files.""" - ) - - def _log_additional_info(self) -> None: - logging.info("\n-----------------------------------------------------\n") - logging.info( - """Dependencies and directories can be ignored by passing additional command-line arguments. See `deptry --help` for more details. -Alternatively, deptry can be configured through `pyproject.toml`. An example: - - ``` - [tool.deptry] - ignore_obsolete = [ - "foo" - ] - ignore_missing = [ - "bar" - ] - ignore_transitive = [ - "baz" - ] - extend_exclude = [ - ".*/foo/", - "bar/baz.py" - ] - ``` + logging.info(self._format_error(violation)) -For more information, see the documentation: https://fpgmaas.github.io/deptry/ -If you have encountered a bug, have a feature request or if you have any other feedback, please file a bug report at https://github.com/fpgmaas/deptry/issues/new/choose -""" - ) + @classmethod + def _format_error(cls, violation: Violation) -> str: + return f"{(violation.location.format_for_terminal())}: {violation.error_code} {violation.get_error_message()}" diff --git a/deptry/violations/base.py b/deptry/violations/base.py index 798bd8b7..13cf40f1 100644 --- a/deptry/violations/base.py +++ b/deptry/violations/base.py @@ -1,14 +1,22 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: from deptry.dependency import Dependency + from deptry.imports.location import Location from deptry.module import Module @dataclass class Violation(ABC): + error_code: ClassVar[str] = "" + error_template: ClassVar[str] = "" issue: Dependency | Module + location: Location + + @abstractmethod + def get_error_message(self) -> str: + raise NotImplementedError() diff --git a/deptry/violations/misplaced_dev.py b/deptry/violations/misplaced_dev.py index be61d169..4d4898bc 100644 --- a/deptry/violations/misplaced_dev.py +++ b/deptry/violations/misplaced_dev.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from deptry.violations import Violation @@ -11,4 +11,9 @@ @dataclass class MisplacedDevDependencyViolation(Violation): + error_code: ClassVar[str] = "DEP004" + error_template: ClassVar[str] = "{name} imported but declared as a dev dependency" issue: Module + + def get_error_message(self) -> str: + return self.error_template.format(name=self.issue.name) diff --git a/deptry/violations/missing.py b/deptry/violations/missing.py index 6dcb5f02..d9f55961 100644 --- a/deptry/violations/missing.py +++ b/deptry/violations/missing.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from deptry.violations import Violation @@ -11,4 +11,9 @@ @dataclass class MissingDependencyViolation(Violation): + error_code: ClassVar[str] = "DEP001" + error_template: ClassVar[str] = "{name} imported but missing from the dependency definitions" issue: Module + + def get_error_message(self) -> str: + return self.error_template.format(name=self.issue.name) diff --git a/deptry/violations/obsolete.py b/deptry/violations/obsolete.py index 64dd298b..c77db022 100644 --- a/deptry/violations/obsolete.py +++ b/deptry/violations/obsolete.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from deptry.violations import Violation @@ -11,4 +11,9 @@ @dataclass class ObsoleteDependencyViolation(Violation): + error_code: ClassVar[str] = "DEP002" + error_template: ClassVar[str] = "{name} defined as a dependency but not used in the codebase" issue: Dependency + + def get_error_message(self) -> str: + return self.error_template.format(name=self.issue.name) diff --git a/deptry/violations/transitive.py b/deptry/violations/transitive.py index f4c4adf3..3c93102d 100644 --- a/deptry/violations/transitive.py +++ b/deptry/violations/transitive.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from deptry.violations import Violation @@ -11,4 +11,9 @@ @dataclass class TransitiveDependencyViolation(Violation): + error_code: ClassVar[str] = "DEP003" + error_template: ClassVar[str] = "{name} imported but it is a transitive dependency" issue: Module + + def get_error_message(self) -> str: + return self.error_template.format(name=self.issue.package) diff --git a/docs/usage.md b/docs/usage.md index 55c16c6a..2d343fbf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -374,6 +374,59 @@ deptry . --known-first-party bar --known-first-party foo Write the detected issues to a JSON file. This will write the following kind of output: +```json +[ + { + "error": { + "code": "DEP002", + "message": "uvicorn defined as a dependency but not used in the codebase" + }, + "module": "uvicorn", + "location": { + "file": "pyproject.toml", + "line": null, + "column": null + } + }, + { + "error": { + "code": "DEP002", + "message": "uvloop defined as a dependency but not used in the codebase" + }, + "module": "uvloop", + "location": { + "file": "pyproject.toml", + "line": null, + "column": null + } + }, + { + "error": { + "code": "DEP004", + "message": "black imported but declared as a dev dependency" + }, + "module": "black", + "location": { + "file": "src/main.py", + "line": 4, + "column": 0 + } + }, + { + "error": { + "code": "DEP003", + "message": "httpx imported but it is a transitive dependency" + }, + "module": "httpx", + "location": { + "file": "src/main.py", + "line": 6, + "column": 0 + } + } +] +``` + ```json { "obsolete": ["uvicorn", "uvloop"], diff --git a/tests/data/pep_621_project/.gitignore b/tests/data/pep_621_project/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/tests/data/pep_621_project/.gitignore @@ -0,0 +1 @@ +/build diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index 54807dd1..9c6ea441 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -20,12 +20,56 @@ def test_cli_returns_error(poetry_project_builder: ToolSpecificProjectBuilder) - result = CliRunner().invoke(deptry, ". -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_ignore_notebooks(project_builder: ToolSpecificProjectBuilder) -> None: @@ -33,12 +77,68 @@ def test_cli_ignore_notebooks(project_builder: ToolSpecificProjectBuilder) -> No result = CliRunner().invoke(deptry, ". --ignore-notebooks -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["toml", "isort", "requests"], - "transitive": [], - } + 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": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_ignore_flags(project_builder: ToolSpecificProjectBuilder) -> None: @@ -60,12 +160,68 @@ def test_cli_exclude(project_builder: ToolSpecificProjectBuilder) -> None: result = CliRunner().invoke(deptry, ". --exclude src/notebook.ipynb -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["toml", "isort", "requests"], - "transitive": [], - } + 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": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_extend_exclude(project_builder: ToolSpecificProjectBuilder) -> None: @@ -73,12 +229,68 @@ def test_cli_extend_exclude(project_builder: ToolSpecificProjectBuilder) -> None result = CliRunner().invoke(deptry, ". -ee src/notebook.ipynb -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["toml", "isort", "requests"], - "transitive": [], - } + 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": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_known_first_party(project_builder: ToolSpecificProjectBuilder) -> None: @@ -86,12 +298,44 @@ def test_cli_known_first_party(project_builder: ToolSpecificProjectBuilder) -> N result = CliRunner().invoke(deptry, ". --known-first-party white -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": [], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + ] def test_cli_not_verbose(project_builder: ToolSpecificProjectBuilder) -> None: @@ -100,12 +344,56 @@ def test_cli_not_verbose(project_builder: ToolSpecificProjectBuilder) -> None: assert result.returncode == 1 assert "The project contains the following dependencies:" not in result.stderr - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_verbose(project_builder: ToolSpecificProjectBuilder) -> None: @@ -116,12 +404,56 @@ def test_cli_verbose(project_builder: ToolSpecificProjectBuilder) -> None: assert result.returncode == 1 assert "The project contains the following dependencies:" in result.stderr - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_with_not_json_output(project_builder: ToolSpecificProjectBuilder) -> None: @@ -134,13 +466,13 @@ def test_cli_with_not_json_output(project_builder: ToolSpecificProjectBuilder) - assert result.returncode == 1 assert len(list(Path(".").glob("*.json"))) == 0 assert ( - "The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\nConsider removing them from your" - " project's dependencies. If a package is used for development purposes, you should add it to your" - " development dependencies instead.\n\n-----------------------------------------------------\n\nThere are" - " dependencies missing from the project's list of dependencies:\n\n\twhite\n\nConsider adding them to your" - " project's dependencies. \n\n-----------------------------------------------------\n\nThere are imported" - " modules from development dependencies detected:\n\n\tblack\n\n" - in result.stderr + result.stderr + == f"Scanning 2 files...\n\n{str(Path('pyproject.toml'))}: DEP002 isort defined as a dependency but not" + f" used in the codebase\n{str(Path('pyproject.toml'))}: DEP002 requests defined as a dependency but not" + f" used in the codebase\n{str(Path('src/main.py'))}:4:0: DEP004 black imported but declared as a dev" + f" dependency\n{str(Path('src/main.py'))}:6:0: DEP001 white imported but missing from the dependency" + " definitions\nFound 4 dependency issues.\n\nFor more information, see the documentation:" + " https://fpgmaas.github.io/deptry/\n" ) @@ -150,20 +482,64 @@ def test_cli_with_json_output(project_builder: ToolSpecificProjectBuilder) -> No # Assert that we still write to console when generating a JSON report. assert ( - "The project contains obsolete dependencies:\n\n\tisort\n\trequests\n\nConsider removing them from your" - " project's dependencies. If a package is used for development purposes, you should add it to your" - " development dependencies instead.\n\n-----------------------------------------------------\n\nThere are" - " dependencies missing from the project's list of dependencies:\n\n\twhite\n\nConsider adding them to your" - " project's dependencies. \n\n-----------------------------------------------------\n\nThere are imported" - " modules from development dependencies detected:\n\n\tblack\n\n" - in result.stderr + result.stderr + == f"Scanning 2 files...\n\n{str(Path('pyproject.toml'))}: DEP002 isort defined as a dependency but not" + f" used in the codebase\n{str(Path('pyproject.toml'))}: DEP002 requests defined as a dependency but not" + f" used in the codebase\n{str(Path('src/main.py'))}:4:0: DEP004 black imported but declared as a dev" + f" dependency\n{str(Path('src/main.py'))}:6:0: DEP001 white imported but missing from the dependency" + " definitions\nFound 4 dependency issues.\n\nFor more information, see the documentation:" + " https://fpgmaas.github.io/deptry/\n" ) - assert get_issues_report("deptry.json") == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report("deptry.json") == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] def test_cli_help() -> None: diff --git a/tests/functional/cli/test_cli_gitignore.py b/tests/functional/cli/test_cli_gitignore.py index 88c1dc88..f90e8e61 100644 --- a/tests/functional/cli/test_cli_gitignore.py +++ b/tests/functional/cli/test_cli_gitignore.py @@ -1,6 +1,7 @@ from __future__ import annotations import shlex +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -17,12 +18,44 @@ def test_cli_gitignore_is_used(pip_project_builder: ToolSpecificProjectBuilder) result = CliRunner().invoke(deptry, shlex.split(". -o report.json")) assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": [], - "missing": [], - "obsolete": ["requests", "mypy", "pytest"], - "transitive": [], - } + assert get_issues_report() == [ + { + "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": "DEP002", + "message": "mypy defined as a dependency but not used in the codebase", + }, + "module": "mypy", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "pytest defined as a dependency but not used in the codebase", + }, + "module": "pytest", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + ] def test_cli_gitignore_is_not_used(pip_project_builder: ToolSpecificProjectBuilder) -> None: @@ -30,9 +63,41 @@ def test_cli_gitignore_is_not_used(pip_project_builder: ToolSpecificProjectBuild result = CliRunner().invoke(deptry, ". --exclude build/|src/bar.py -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": [], - "missing": [], - "obsolete": ["isort", "requests", "pytest"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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": "DEP002", + "message": "pytest defined as a dependency but not used in the codebase", + }, + "module": "pytest", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + ] diff --git a/tests/functional/cli/test_cli_pdm.py b/tests/functional/cli/test_cli_pdm.py index 9f0f2081..50a95482 100644 --- a/tests/functional/cli/test_cli_pdm.py +++ b/tests/functional/cli/test_cli_pdm.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -16,9 +17,53 @@ def test_cli_with_pdm(pdm_project_builder: ToolSpecificProjectBuilder) -> None: result = CliRunner().invoke(deptry, ". -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] diff --git a/tests/functional/cli/test_cli_pep_621.py b/tests/functional/cli/test_cli_pep_621.py index 5111cda7..044a40bf 100644 --- a/tests/functional/cli/test_cli_pep_621.py +++ b/tests/functional/cli/test_cli_pep_621.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -16,9 +17,65 @@ def test_cli_with_pep_621(pip_project_builder: ToolSpecificProjectBuilder) -> No result = CliRunner().invoke(deptry, ". -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": [], - "missing": ["white"], - "obsolete": ["isort", "requests", "mypy", "pytest"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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": "DEP002", + "message": "mypy defined as a dependency but not used in the codebase", + }, + "module": "mypy", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "pytest defined as a dependency but not used in the codebase", + }, + "module": "pytest", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] diff --git a/tests/functional/cli/test_cli_pyproject_different_directory.py b/tests/functional/cli/test_cli_pyproject_different_directory.py index c451791a..f966d6ea 100644 --- a/tests/functional/cli/test_cli_pyproject_different_directory.py +++ b/tests/functional/cli/test_cli_pyproject_different_directory.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -16,9 +17,65 @@ def test_cli_with_pyproject_different_directory(pip_project_builder: ToolSpecifi result = CliRunner().invoke(deptry, "src --config a_sub_directory/pyproject.toml -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": [], - "missing": ["white"], - "obsolete": ["isort", "requests", "mypy", "pytest"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("a_sub_directory/pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "requests defined as a dependency but not used in the codebase", + }, + "module": "requests", + "location": { + "file": str(Path("a_sub_directory/pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "mypy defined as a dependency but not used in the codebase", + }, + "module": "mypy", + "location": { + "file": str(Path("a_sub_directory/pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "pytest defined as a dependency but not used in the codebase", + }, + "module": "pytest", + "location": { + "file": str(Path("a_sub_directory/pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/project_with_src_directory/foo.py")), + "line": 6, + "column": 0, + }, + }, + ] diff --git a/tests/functional/cli/test_cli_requirements_txt.py b/tests/functional/cli/test_cli_requirements_txt.py index 95e5b617..1ddcd0ef 100644 --- a/tests/functional/cli/test_cli_requirements_txt.py +++ b/tests/functional/cli/test_cli_requirements_txt.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -19,12 +20,80 @@ def test_cli_single_requirements_txt(requirements_txt_project_builder: ToolSpeci ) assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": ["urllib3"], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("requirements.txt")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "requests defined as a dependency but not used in the codebase", + }, + "module": "requests", + "location": { + "file": str(Path("requirements.txt")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP004", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + { + "error": { + "code": "DEP003", + "message": "urllib3 imported but it is a transitive dependency", + }, + "module": "urllib3", + "location": { + "file": str(Path("src/main.py")), + "line": 7, + "column": 0, + }, + }, + { + "error": { + "code": "DEP003", + "message": "urllib3 imported but it is a transitive dependency", + }, + "module": "urllib3", + "location": { + "file": str(Path("src/notebook.ipynb")), + "line": 3, + "column": 0, + }, + }, + ] def test_cli_multiple_requirements_txt(requirements_txt_project_builder: ToolSpecificProjectBuilder) -> None: @@ -38,9 +107,53 @@ def test_cli_multiple_requirements_txt(requirements_txt_project_builder: ToolSpe ) assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": ["black"], - "missing": ["white"], - "obsolete": ["isort", "requests"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("requirements.txt")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "requests defined as a dependency but not used in the codebase", + }, + "module": "requests", + "location": { + "file": str(Path("requirements.txt")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP004", + "message": "black imported but declared as a dev dependency", + }, + "module": "black", + "location": { + "file": str(Path("src/main.py")), + "line": 4, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/main.py")), + "line": 6, + "column": 0, + }, + }, + ] diff --git a/tests/functional/cli/test_cli_src_directory.py b/tests/functional/cli/test_cli_src_directory.py index 13578111..0871e3ec 100644 --- a/tests/functional/cli/test_cli_src_directory.py +++ b/tests/functional/cli/test_cli_src_directory.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from click.testing import CliRunner @@ -16,9 +17,77 @@ def test_cli_with_src_directory(pip_project_builder: ToolSpecificProjectBuilder) result = CliRunner().invoke(deptry, "src -o report.json") assert result.exit_code == 1 - assert get_issues_report() == { - "misplaced_dev": [], - "missing": ["httpx", "white"], - "obsolete": ["isort", "requests", "mypy", "pytest"], - "transitive": [], - } + assert get_issues_report() == [ + { + "error": { + "code": "DEP002", + "message": "isort defined as a dependency but not used in the codebase", + }, + "module": "isort", + "location": { + "file": str(Path("pyproject.toml")), + "line": 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": "DEP002", + "message": "mypy defined as a dependency but not used in the codebase", + }, + "module": "mypy", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": { + "code": "DEP002", + "message": "pytest defined as a dependency but not used in the codebase", + }, + "module": "pytest", + "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/foobar.py")), + "line": 1, + "column": 0, + }, + }, + { + "error": { + "code": "DEP001", + "message": "white imported but missing from the dependency definitions", + }, + "module": "white", + "location": { + "file": str(Path("src/project_with_src_directory/foo.py")), + "line": 6, + "column": 0, + }, + }, + ] diff --git a/tests/unit/imports/test_extract.py b/tests/unit/imports/test_extract.py index 7eebbbd2..9ce2b71d 100644 --- a/tests/unit/imports/test_extract.py +++ b/tests/unit/imports/test_extract.py @@ -10,6 +10,7 @@ import pytest from deptry.imports.extract import get_imported_modules_from_file +from deptry.imports.location import Location from tests.utils import run_within_dir if TYPE_CHECKING: @@ -17,30 +18,33 @@ def test_import_parser_py() -> None: - imported_modules = get_imported_modules_from_file(Path("tests/data/some_imports.py")) - - assert imported_modules == { - "barfoo", - "baz", - "click", - "foobar", - "httpx", - "module_in_class", - "module_in_func", - "not_click", - "numpy", - "os", - "pandas", - "pathlib", - "randomizer", - "typing", + assert get_imported_modules_from_file(Path("tests/data/some_imports.py")) == { + "barfoo": [Location(Path("tests/data/some_imports.py"), 20, 0)], + "baz": [Location(Path("tests/data/some_imports.py"), 16, 4)], + "click": [Location(Path("tests/data/some_imports.py"), 24, 4)], + "foobar": [Location(Path("tests/data/some_imports.py"), 18, 4)], + "httpx": [Location(Path("tests/data/some_imports.py"), 14, 4)], + "module_in_class": [Location(Path("tests/data/some_imports.py"), 35, 8)], + "module_in_func": [Location(Path("tests/data/some_imports.py"), 30, 4)], + "not_click": [Location(Path("tests/data/some_imports.py"), 26, 4)], + "numpy": [ + Location(Path("tests/data/some_imports.py"), 5, 0), + Location(Path("tests/data/some_imports.py"), 7, 0), + ], + "os": [Location(Path("tests/data/some_imports.py"), 1, 0)], + "pandas": [Location(Path("tests/data/some_imports.py"), 6, 0)], + "pathlib": [Location(Path("tests/data/some_imports.py"), 2, 0)], + "randomizer": [Location(Path("tests/data/some_imports.py"), 21, 0)], + "typing": [Location(Path("tests/data/some_imports.py"), 3, 0)], } def test_import_parser_ipynb() -> None: - imported_modules = get_imported_modules_from_file(Path("tests/data/example_project/src/notebook.ipynb")) - - assert imported_modules == {"click", "urllib3", "toml"} + assert get_imported_modules_from_file(Path("tests/data/example_project/src/notebook.ipynb")) == { + "click": [Location(Path("tests/data/example_project/src/notebook.ipynb"), 1, 0)], + "toml": [Location(Path("tests/data/example_project/src/notebook.ipynb"), 5, 0)], + "urllib3": [Location(Path("tests/data/example_project/src/notebook.ipynb"), 3, 0)], + } @pytest.mark.parametrize( @@ -59,7 +63,7 @@ def test_import_parser_ipynb() -> None: "utf-16", ), ( - "import foo\nmy_string = '🐺'", + "\nimport foo\nmy_string = '🐺'", "utf-16", ), ], @@ -71,7 +75,9 @@ def test_import_parser_file_encodings(file_content: str, encoding: str | None, t with open(random_file_name, "w", encoding=encoding) as f: f.write(file_content) - assert get_imported_modules_from_file(Path(random_file_name)) == {"foo"} + assert get_imported_modules_from_file(Path(random_file_name)) == { + "foo": [Location(Path(f"{random_file_name}"), 2, 0)] + } @pytest.mark.parametrize( @@ -111,7 +117,9 @@ def test_import_parser_file_encodings_ipynb(code_cell_content: list[str], encodi } f.write(json.dumps(file_content)) - assert get_imported_modules_from_file(Path(random_file_name)) == {"foo"} + assert get_imported_modules_from_file(Path(random_file_name)) == { + "foo": [Location(Path(f"{random_file_name}"), 1, 0)] + } def test_import_parser_file_encodings_warning(tmp_path: Path, caplog: LogCaptureFixture) -> None: @@ -123,6 +131,6 @@ def test_import_parser_file_encodings_warning(tmp_path: Path, caplog: LogCapture "deptry.imports.extractors.python_import_extractor.ast.parse", side_effect=UnicodeDecodeError("fakecodec", b"\x00\x00", 1, 2, "Fake reason!"), ): - assert get_imported_modules_from_file(Path("file1.py")) == set() + assert get_imported_modules_from_file(Path("file1.py")) == {} assert "Warning: File file1.py could not be decoded. Skipping..." in caplog.text diff --git a/tests/unit/issues_finder/test_misplaced_dev.py b/tests/unit/issues_finder/test_misplaced_dev.py index 699324fe..250c97ef 100644 --- a/tests/unit/issues_finder/test_misplaced_dev.py +++ b/tests/unit/issues_finder/test_misplaced_dev.py @@ -3,16 +3,20 @@ from pathlib import Path from deptry.dependency import Dependency +from deptry.imports.location import Location from deptry.issues_finder.misplaced_dev import MisplacedDevDependenciesFinder -from deptry.module import Module +from deptry.module import Module, ModuleLocations from deptry.violations import MisplacedDevDependencyViolation def test_simple() -> None: dependencies = [Dependency("bar", Path("pyproject.toml"))] + + module_foo_locations = [Location(Path("foo.py"), 1, 2), Location(Path("bar.py"), 3, 4)] module_foo = Module("foo", dev_top_levels=["foo"], is_dev_dependency=True) - modules = [module_foo] - deps = MisplacedDevDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() + modules_locations = [ModuleLocations(module_foo, module_foo_locations)] - assert deps == [MisplacedDevDependencyViolation(module_foo)] + assert MisplacedDevDependenciesFinder(modules_locations, dependencies).find() == [ + MisplacedDevDependencyViolation(module_foo, location) for location in module_foo_locations + ] diff --git a/tests/unit/issues_finder/test_missing.py b/tests/unit/issues_finder/test_missing.py index 79c1ad7e..d72b08c1 100644 --- a/tests/unit/issues_finder/test_missing.py +++ b/tests/unit/issues_finder/test_missing.py @@ -3,39 +3,46 @@ from pathlib import Path from deptry.dependency import Dependency +from deptry.imports.location import Location from deptry.issues_finder.missing import MissingDependenciesFinder -from deptry.module import ModuleBuilder +from deptry.module import ModuleBuilder, ModuleLocations from deptry.violations import MissingDependencyViolation def test_simple() -> None: dependencies: list[Dependency] = [] + + module_foobar_locations = [Location(Path("foo.py"), 1, 2), Location(Path("bar.py"), 3, 4)] module_foobar = ModuleBuilder("foobar", {"foo"}, frozenset(), dependencies).build() - modules = [module_foobar] - deps = MissingDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() + modules_locations = [ModuleLocations(module_foobar, module_foobar_locations)] - assert deps == [MissingDependencyViolation(module_foobar)] + assert MissingDependenciesFinder(modules_locations, dependencies).find() == [ + MissingDependencyViolation(module_foobar, location) for location in module_foobar_locations + ] def test_local_module() -> None: dependencies: list[Dependency] = [] - modules = [ModuleBuilder("foobar", {"foo", "foobar"}, frozenset(), dependencies).build()] - - deps = MissingDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() + modules_locations = [ + ModuleLocations( + ModuleBuilder("foobar", {"foo", "foobar"}, frozenset(), dependencies).build(), + [Location(Path("foo.py"), 1, 2)], + ) + ] - assert deps == [] + assert MissingDependenciesFinder(modules_locations, dependencies).find() == [] def test_simple_with_ignore() -> None: dependencies: list[Dependency] = [] - modules = [ModuleBuilder("foobar", {"foo", "bar"}, frozenset(), dependencies).build()] + modules_locations = [ + ModuleLocations( + ModuleBuilder("foobar", {"foo", "bar"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - deps = MissingDependenciesFinder( - imported_modules=modules, dependencies=dependencies, ignored_modules=("foobar",) - ).find() - - assert deps == [] + assert MissingDependenciesFinder(modules_locations, dependencies, ignored_modules=("foobar",)).find() == [] def test_no_error() -> None: @@ -44,8 +51,10 @@ def test_no_error() -> None: """ dependencies = [Dependency("foo", Path("pyproject.toml"))] - module = ModuleBuilder("foo", {"bar"}, frozenset(), dependencies).build() - - deps = MissingDependenciesFinder(imported_modules=[module], dependencies=dependencies).find() + modules_locations = [ + ModuleLocations( + ModuleBuilder("foo", {"bar"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - assert deps == [] + assert MissingDependenciesFinder(modules_locations, dependencies).find() == [] diff --git a/tests/unit/issues_finder/test_obsolete.py b/tests/unit/issues_finder/test_obsolete.py index 16c12637..1c74dec9 100644 --- a/tests/unit/issues_finder/test_obsolete.py +++ b/tests/unit/issues_finder/test_obsolete.py @@ -3,30 +3,35 @@ from pathlib import Path from deptry.dependency import Dependency +from deptry.imports.location import Location from deptry.issues_finder.obsolete import ObsoleteDependenciesFinder -from deptry.module import ModuleBuilder +from deptry.module import ModuleBuilder, ModuleLocations from deptry.violations import ObsoleteDependencyViolation def test_simple() -> None: dependency_toml = Dependency("toml", Path("pyproject.toml")) dependencies = [Dependency("click", Path("pyproject.toml")), dependency_toml] - modules = [ModuleBuilder("click", {"foo"}, frozenset(), dependencies).build()] + modules_locations = [ + ModuleLocations( + ModuleBuilder("click", {"foo"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - deps = ObsoleteDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() - - assert deps == [ObsoleteDependencyViolation(dependency_toml)] + assert ObsoleteDependenciesFinder(modules_locations, dependencies).find() == [ + ObsoleteDependencyViolation(dependency_toml, Location(Path("pyproject.toml"))) + ] def test_simple_with_ignore() -> None: dependencies = [Dependency("click", Path("pyproject.toml")), Dependency("toml", Path("pyproject.toml"))] - modules = [ModuleBuilder("toml", {"foo"}, frozenset(), dependencies).build()] - - deps = ObsoleteDependenciesFinder( - imported_modules=modules, dependencies=dependencies, ignored_modules=("click",) - ).find() + modules_locations = [ + ModuleLocations( + ModuleBuilder("toml", {"foo"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - assert deps == [] + assert ObsoleteDependenciesFinder(modules_locations, dependencies, ignored_modules=("click",)).find() == [] def test_top_level() -> None: @@ -35,9 +40,13 @@ def test_top_level() -> None: blackd is in the top-level of black, so black should not be marked as an obsolete dependency. """ dependencies = [Dependency("black", Path("pyproject.toml"))] - modules = [ModuleBuilder("blackd", {"foo"}, frozenset(), dependencies).build()] + modules_locations = [ + ModuleLocations( + ModuleBuilder("blackd", {"foo"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - deps = ObsoleteDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() + deps = ObsoleteDependenciesFinder(modules_locations, dependencies).find() assert deps == [] @@ -47,8 +56,10 @@ def test_without_top_level() -> None: Test if packages without top-level information are correctly maked as obsolete """ dependencies = [Dependency("isort", Path("pyproject.toml"))] - modules = [ModuleBuilder("isort", {"foo"}, frozenset(), dependencies).build()] + modules_locations = [ + ModuleLocations( + ModuleBuilder("isort", {"foo"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - deps = ObsoleteDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() - - assert deps == [] + assert ObsoleteDependenciesFinder(modules_locations, dependencies).find() == [] diff --git a/tests/unit/issues_finder/test_transitive.py b/tests/unit/issues_finder/test_transitive.py index 5b335e4b..e3c07f1e 100644 --- a/tests/unit/issues_finder/test_transitive.py +++ b/tests/unit/issues_finder/test_transitive.py @@ -1,9 +1,11 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING +from deptry.imports.location import Location from deptry.issues_finder.transitive import TransitiveDependenciesFinder -from deptry.module import ModuleBuilder +from deptry.module import ModuleBuilder, ModuleLocations from deptry.violations import TransitiveDependencyViolation if TYPE_CHECKING: @@ -15,20 +17,23 @@ def test_simple() -> None: black is in testing environment which requires platformdirs, so platformdirs should be found as transitive. """ dependencies: list[Dependency] = [] + + module_platformdirs_locations = [Location(Path("foo.py"), 1, 2), Location(Path("bar.py"), 3, 4)] module_platformdirs = ModuleBuilder("platformdirs", {"foo"}, frozenset(), dependencies).build() - modules = [module_platformdirs] - deps = TransitiveDependenciesFinder(imported_modules=modules, dependencies=dependencies).find() + modules_locations = [ModuleLocations(module_platformdirs, module_platformdirs_locations)] - assert deps == [TransitiveDependencyViolation(module_platformdirs)] + assert TransitiveDependenciesFinder(modules_locations, dependencies).find() == [ + TransitiveDependencyViolation(module_platformdirs, location) for location in module_platformdirs_locations + ] def test_simple_with_ignore() -> None: dependencies: list[Dependency] = [] - modules = [ModuleBuilder("foobar", {"foo"}, frozenset(), dependencies).build()] - - deps = TransitiveDependenciesFinder( - imported_modules=modules, dependencies=dependencies, ignored_modules=("foobar",) - ).find() + modules_locations = [ + ModuleLocations( + ModuleBuilder("foobar", {"foo"}, frozenset(), dependencies).build(), [Location(Path("foo.py"), 1, 2)] + ) + ] - assert deps == [] + assert TransitiveDependenciesFinder(modules_locations, dependencies, ignored_modules=("foobar",)).find() == [] diff --git a/tests/unit/reporters/test_json_reporter.py b/tests/unit/reporters/test_json_reporter.py index d63e6b46..74f43217 100644 --- a/tests/unit/reporters/test_json_reporter.py +++ b/tests/unit/reporters/test_json_reporter.py @@ -4,6 +4,7 @@ from pathlib import Path from deptry.dependency import Dependency +from deptry.imports.location import Location from deptry.module import Module from deptry.reporters import JSONReporter from deptry.violations import ( @@ -18,21 +19,55 @@ def test_simple(tmp_path: Path) -> None: with run_within_dir(tmp_path): JSONReporter( - { - "missing": [MissingDependencyViolation(Module("foo", package="foo_package"))], - "obsolete": [ObsoleteDependencyViolation(Dependency("foo", Path("pyproject.toml")))], - "transitive": [TransitiveDependencyViolation(Module("foo", package="foo_package"))], - "misplaced_dev": [MisplacedDevDependencyViolation(Module("foo", package="foo_package"))], - }, + [ + MissingDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo.py"), 1, 2)), + ObsoleteDependencyViolation( + Dependency("foo", Path("pyproject.toml")), Location(Path("pyproject.toml")) + ), + TransitiveDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo/bar.py"), 1, 2)), + MisplacedDevDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo.py"), 1, 2)), + ], "output.json", ).report() with open("output.json") as f: data = json.load(f) - assert data == { - "missing": ["foo"], - "obsolete": ["foo"], - "transitive": ["foo_package"], - "misplaced_dev": ["foo"], - } + assert data == [ + { + "error": {"code": "DEP001", "message": "foo imported but missing from the dependency definitions"}, + "module": "foo", + "location": { + "file": str(Path("foo.py")), + "line": 1, + "column": 2, + }, + }, + { + "error": {"code": "DEP002", "message": "foo defined as a dependency but not used in the codebase"}, + "module": "foo", + "location": { + "file": str(Path("pyproject.toml")), + "line": None, + "column": None, + }, + }, + { + "error": {"code": "DEP003", "message": "foo_package imported but it is a transitive dependency"}, + "module": "foo", + "location": { + "file": str(Path("foo/bar.py")), + "line": 1, + "column": 2, + }, + }, + { + "error": {"code": "DEP004", "message": "foo imported but declared as a dev dependency"}, + "module": "foo", + "location": { + "file": str(Path("foo.py")), + "line": 1, + "column": 2, + }, + }, + ] diff --git a/tests/unit/reporters/test_text_reporter.py b/tests/unit/reporters/test_text_reporter.py index 5d97428b..85067ff3 100644 --- a/tests/unit/reporters/test_text_reporter.py +++ b/tests/unit/reporters/test_text_reporter.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from deptry.dependency import Dependency +from deptry.imports.location import Location from deptry.module import Module from deptry.reporters import TextReporter from deptry.violations import ( @@ -17,46 +18,47 @@ if TYPE_CHECKING: from _pytest.logging import LogCaptureFixture - from deptry.violations import Violation - def test_logging_number_multiple(caplog: LogCaptureFixture) -> None: with caplog.at_level(logging.INFO): - violations: dict[str, list[Violation]] = { - "missing": [MissingDependencyViolation(Module("foo", package="foo_package"))], - "obsolete": [ObsoleteDependencyViolation(Dependency("foo", Path("pyproject.toml")))], - "transitive": [TransitiveDependencyViolation(Module("foo", package="foo_package"))], - "misplaced_dev": [MisplacedDevDependencyViolation(Module("foo", package="foo_package"))], - } + violations = [ + MissingDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo.py"), 1, 2)), + ObsoleteDependencyViolation(Dependency("foo", Path("pyproject.toml")), Location(Path("pyproject.toml"))), + TransitiveDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo/bar.py"), 1, 2)), + MisplacedDevDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo.py"), 1, 2)), + ] TextReporter(violations).report() - assert "There were 4 dependency issues found" in caplog.text - assert "The project contains obsolete dependencies" in caplog.text - assert "There are dependencies missing from the project's list of dependencies" in caplog.text - assert "There are transitive dependencies that should be explicitly defined as dependencies" in caplog.text - assert "There are imported modules from development dependencies detected" in caplog.text - assert "For more information, see the documentation" in caplog.text + assert caplog.messages == [ + "", + f"{str(Path('foo.py'))}:1:2: DEP001 foo imported but missing from the dependency definitions", + f"{str(Path('pyproject.toml'))}: DEP002 foo defined as a dependency but not used in the codebase", + f"{str(Path('foo/bar.py'))}:1:2: DEP003 foo_package imported but it is a transitive dependency", + f"{str(Path('foo.py'))}:1:2: DEP004 foo imported but declared as a dev dependency", + "Found 4 dependency issues.", + "\nFor more information, see the documentation: https://fpgmaas.github.io/deptry/", + ] def test_logging_number_single(caplog: LogCaptureFixture) -> None: with caplog.at_level(logging.INFO): - violations: dict[str, list[Violation]] = { - "missing": [MissingDependencyViolation(Module("foo", package="foo_package"))] - } - TextReporter(violations).report() + TextReporter( + [MissingDependencyViolation(Module("foo", package="foo_package"), Location(Path("foo.py"), 1, 2))] + ).report() - assert "There was 1 dependency issue found" in caplog.text + assert caplog.messages == [ + "", + "foo.py:1:2: DEP001 foo imported but missing from the dependency definitions", + "Found 1 dependency issue.", + "\nFor more information, see the documentation: https://fpgmaas.github.io/deptry/", + ] def test_logging_number_none(caplog: LogCaptureFixture) -> None: with caplog.at_level(logging.INFO): - violations: dict[str, list[Violation]] = {"missing": []} - TextReporter(violations).report() + TextReporter([]).report() - assert "No dependency issues found" in caplog.text - assert "There were 4 dependency issues found" not in caplog.text - assert "The project contains obsolete dependencies" not in caplog.text - assert "There are dependencies missing from the project's list of dependencies" not in caplog.text - assert "There are transitive dependencies that should be explicitly defined as dependencies" not in caplog.text - assert "There are imported modules from development dependencies detected" not in caplog.text - assert "For more information, see the documentation" not in caplog.text + assert caplog.messages == [ + "", + "Success! No dependency issues found.", + ] diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 4880449a..6981733e 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -3,7 +3,6 @@ import re import sys from pathlib import Path -from typing import TYPE_CHECKING from unittest import mock import pytest @@ -11,6 +10,7 @@ from deptry.core import Core from deptry.dependency import Dependency from deptry.exceptions import UnsupportedPythonVersionError +from deptry.imports.location import Location from deptry.module import Module from deptry.stdlibs import STDLIBS_PYTHON from deptry.violations import ( @@ -21,8 +21,25 @@ ) from tests.utils import create_files, run_within_dir -if TYPE_CHECKING: - from deptry.violations import Violation + +def test__get_sorted_violations() -> None: + violations = [ + MisplacedDevDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 0)), + MissingDependencyViolation(Module("foo"), Location(Path("foo.py"), 2, 0)), + MissingDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 0)), + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 3, 1)), + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 2, 1)), + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 3, 0)), + ] + + assert Core._get_sorted_violations(violations) == [ + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 2, 1)), + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 3, 0)), + MissingDependencyViolation(Module("foo"), Location(Path("bar.py"), 3, 1)), + MissingDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 0)), + MisplacedDevDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 0)), + MissingDependencyViolation(Module("foo"), Location(Path("foo.py"), 2, 0)), + ] @pytest.mark.parametrize( @@ -137,12 +154,13 @@ def test__get_stdlib_packages_unsupported(version_info: tuple[int | str, ...]) - def test__exit_with_violations() -> None: - violations: dict[str, list[Violation]] = { - "missing": [MissingDependencyViolation(Module("foo"))], - "obsolete": [ObsoleteDependencyViolation(Dependency("foo", Path("pyproject.toml")))], - "transitive": [TransitiveDependencyViolation(Module("foo"))], - "misplaced_dev": [MisplacedDevDependencyViolation(Module("foo"))], - } + violations = [ + MissingDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 2)), + ObsoleteDependencyViolation(Dependency("foo", Path("pyproject.toml")), Location(Path("pyproject.toml"))), + TransitiveDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 2)), + MisplacedDevDependencyViolation(Module("foo"), Location(Path("foo.py"), 1, 2)), + ] + with pytest.raises(SystemExit) as e: Core._exit(violations) @@ -151,9 +169,8 @@ def test__exit_with_violations() -> None: def test__exit_without_violations() -> None: - violations: dict[str, list[Violation]] = {} with pytest.raises(SystemExit) as e: - Core._exit(violations) + Core._exit([]) assert e.type == SystemExit assert e.value.code == 0 diff --git a/tests/utils.py b/tests/utils.py index a93e76e5..ae35aed2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -30,9 +30,9 @@ def run_within_dir(path: Path) -> Generator[None, None, None]: os.chdir(oldpwd) -def get_issues_report(path: str = "report.json") -> dict[str, Any]: +def get_issues_report(path: str = "report.json") -> list[dict[str, Any]]: with open(path) as file: - report: dict[str, Any] = json.load(file) + report: list[dict[str, Any]] = json.load(file) return report