Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d495627
feat: add github annotations reporter
OrenMe Feb 14, 2025
2ca9816
add docs
OrenMe Feb 15, 2025
9c66cb1
lint fix
OrenMe Feb 15, 2025
841c587
Merge branch 'main' into feat/githubAnnotations
OrenMe Feb 15, 2025
890c71e
Merge remote-tracking branch 'refs/remotes/origin/feat/githubAnnotati…
OrenMe Feb 15, 2025
239a7f2
Merge branch 'main' into feat/githubAnnotations
OrenMe Feb 15, 2025
fc9962b
lint fix
OrenMe Feb 15, 2025
6188552
Merge branch 'main' into feat/githubAnnotations
OrenMe Feb 15, 2025
a16f978
Update cli.py
OrenMe Feb 17, 2025
4ca19af
Update python/deptry/cli.py
OrenMe Feb 19, 2025
132d1f7
CR comments
OrenMe Feb 19, 2025
77bbe53
Merge remote-tracking branch 'refs/remotes/origin/feat/githubAnnotati…
OrenMe Feb 19, 2025
a33bae1
Update docs/usage.md
OrenMe Feb 19, 2025
a7f6f89
more CR fixes
OrenMe Feb 19, 2025
8950f31
Merge remote-tracking branch 'refs/remotes/origin/feat/githubAnnotati…
OrenMe Feb 19, 2025
b483f72
Merge branch 'main' into feat/githubAnnotations
OrenMe Feb 21, 2025
36bca11
Merge branch 'main' into feat/githubAnnotations
OrenMe Feb 23, 2025
54c6f05
Merge branch 'main' into feat/githubAnnotations
OrenMe Mar 16, 2025
4b457a1
Merge branch 'main' of github.com:fpgmaas/deptry into feat/githubAnno…
mkniewallner Nov 7, 2025
261aa89
feat(reporters): handle violations without line/column
mkniewallner Nov 7, 2025
c7fa470
test: add functional tests for GitHub reporter
mkniewallner Nov 7, 2025
a26e41c
docs(usage): tweak documentation
mkniewallner Nov 7, 2025
4324cff
test: use tuple for `github_warning_errors` arg
mkniewallner Nov 7, 2025
1c4bbbd
fix(cli): use tuple for `github_warning_errors` arg
mkniewallner Nov 7, 2025
e6f76d8
refactor(reporters): code/message are never `None`
mkniewallner Nov 7, 2025
7fe1324
test: windows, as usual
mkniewallner Nov 8, 2025
23031df
test: more windows
mkniewallner Nov 8, 2025
9517654
Merge branch 'main' into feat/githubAnnotations
mkniewallner Nov 8, 2025
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
44 changes: 44 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,50 @@ json_output = "deptry_report.txt"
deptry . --json-output deptry_report.txt
```

#### GitHub output

Print [GitHub Actions annotations](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands) in the console when dependency issues are detected.

Annotations follow this format:

```shell
::error file=<file>,line=<line>,col=<column>,title=<error_code>::<error_message>
```

By default, violations are annotated as errors. To report specific violation codes as warnings instead, use the [GitHub warning errors](#github-warning-errors) option.

- Type: `bool`
- Default: `False`
- `pyproject.toml` option name: `github_output`
- CLI option name: `--github-output`
- `pyproject.toml` example:
```toml
[tool.deptry]
github_output = true
```
- CLI example:
```shell
deptry . --github-output
```

#### GitHub warning errors

When [GitHub output](#github-output) option is enabled, this sets the severity of messages to `warning` instead of `error` for the specified error codes.

- Type: `list[str]`
- Default: `[]`
- `pyproject.toml` option name: `github_warning_errors`
- CLI option name: `--github-warning-errors`
- `pyproject.toml` example:
```toml
[tool.deptry]
github_warning_errors = ["DEP001", "DEP002"]
```
- CLI example:
```shell
deptry . --github-warning-errors DEP001,DEP002
```

#### Package module name map

Deptry will automatically detect top level modules names that belong to a
Expand Down
19 changes: 19 additions & 0 deletions python/deptry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@ def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: b
help="""If specified, a summary of the dependency issues found will be written to the output location specified. e.g. `deptry . -o deptry.json`""",
show_default=True,
)
@click.option(
"--github-output",
"-go",
is_flag=True,
help="""If specified, dependency issues found will be written in the format of GitHub annotation.""",
)
@click.option(
"--github-warning-errors",
"-gwe",
type=COMMA_SEPARATED_TUPLE,
help="""A comma-separated list of error codes that should be printed as warnings.
If not specified, all violations will be reported as errors.""",
default=(),
show_default=False,
)
@click.option(
"--package-module-name-map",
"-pmnm",
Expand Down Expand Up @@ -254,6 +269,8 @@ def cli(
requirements_files_dev: tuple[str, ...],
known_first_party: tuple[str, ...],
json_output: str,
github_output: bool,
github_warning_errors: tuple[str, ...],
package_module_name_map: MutableMapping[str, tuple[str, ...]],
pep621_dev_dependency_groups: tuple[str, ...],
experimental_namespace_package: bool,
Expand Down Expand Up @@ -287,6 +304,8 @@ def cli(
requirements_files_dev=requirements_files_dev,
known_first_party=known_first_party,
json_output=json_output,
github_output=github_output,
github_warning_errors=github_warning_errors,
package_module_name_map=package_module_name_map,
pep621_dev_dependency_groups=pep621_dev_dependency_groups,
experimental_namespace_package=experimental_namespace_package,
Expand Down
7 changes: 6 additions & 1 deletion python/deptry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from deptry.imports.extract import get_imported_modules_from_list_of_files
from deptry.module import ModuleBuilder, ModuleLocations
from deptry.python_file_finder import get_all_python_files_in
from deptry.reporters import JSONReporter, TextReporter
from deptry.reporters import GithubReporter, JSONReporter, TextReporter
from deptry.violations.finder import find_violations

if TYPE_CHECKING:
Expand Down Expand Up @@ -40,6 +40,8 @@ class Core:
package_module_name_map: Mapping[str, tuple[str, ...]]
pep621_dev_dependency_groups: tuple[str, ...]
experimental_namespace_package: bool
github_output: bool
github_warning_errors: tuple[str, ...]

def run(self) -> None:
self._log_config()
Expand Down Expand Up @@ -87,6 +89,9 @@ def run(self) -> None:
if self.json_output:
JSONReporter(violations, self.json_output).report()

if self.github_output:
GithubReporter(violations, warning_ids=self.github_warning_errors).report()

self._exit(violations)

def _find_python_files(self) -> list[Path]:
Expand Down
3 changes: 2 additions & 1 deletion python/deptry/reporters/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from deptry.reporters.github import GithubReporter
from deptry.reporters.json import JSONReporter
from deptry.reporters.text import TextReporter

__all__ = ("JSONReporter", "TextReporter")
__all__ = ("GithubReporter", "JSONReporter", "TextReporter")
73 changes: 73 additions & 0 deletions python/deptry/reporters/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from deptry.reporters.base import Reporter

if TYPE_CHECKING:
from deptry.violations import Violation


@dataclass
class GithubReporter(Reporter):
warning_ids: tuple[str, ...] = field(default_factory=tuple) # tuple of error codes to print as warnings

def report(self) -> None:
self._log_and_exit()

def _log_and_exit(self) -> None:
self._log_violations(self.violations)

def _log_violations(self, violations: list[Violation]) -> None:
for violation in violations:
self._print_github_annotation(violation)

def _print_github_annotation(self, violation: Violation) -> None:
annotation_severity = "warning" if violation.error_code in self.warning_ids else "error"
file_name = violation.location.file

ret = _build_workflow_command(
annotation_severity,
violation.error_code,
violation.get_error_message(),
str(file_name),
# For dependency files (like "pyproject.toml"), we don't extract a line. Setting the first line in that case
# allows a comment to be added in GitHub, even if it's not on the proper line, otherwise it doesn't appear
# at all.
line=violation.location.line or 1,
column=violation.location.column,
)
logging.info(ret)


def _build_workflow_command(
command_name: str,
title: str,
message: str,
file: str,
line: int,
end_line: int | None = None,
column: int | None = None,
end_column: int | None = None,
) -> str:
"""Build a command to annotate a workflow."""
result = f"::{command_name} "

entries = [
("file", file),
("line", line),
("endLine", end_line),
("col", column),
("endColumn", end_column),
("title", title),
]

result += ",".join(f"{k}={v}" for k, v in entries if v is not None)

return f"{result}::{_escape(message)}"


def _escape(s: str) -> str:
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
84 changes: 84 additions & 0 deletions tests/functional/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,90 @@ def test_cli_with_json_output(poetry_venv_factory: PoetryVenvFactory) -> None:
]


@pytest.mark.xdist_group(name=Project.EXAMPLE)
def test_cli_with_github_output(poetry_venv_factory: PoetryVenvFactory) -> None:
with poetry_venv_factory(Project.EXAMPLE) as virtual_env:
result = virtual_env.run("deptry . --github-output")

expected_output = [
"Scanning 2 files...",
"",
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'isort' defined as a dependency but not"
" used in the codebase",
file=Path("pyproject.toml"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'requests' defined as a dependency but"
" not used in the codebase",
file=Path("pyproject.toml"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET}4{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP004{RESET} 'black'"
" imported but declared as a dev dependency",
file=Path("src/main.py"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET}6{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP001{RESET} 'white'"
" imported but missing from the dependency definitions",
file=Path("src/main.py"),
),
stylize("{BOLD}{RED}Found 4 dependency issues.{RESET}"),
"",
"For more information, see the documentation: https://deptry.com/",
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'isort' defined as a dependency but not used in the codebase",
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'requests' defined as a dependency but not used in the codebase",
f"::error file={Path('src/main.py')},line=4,col=8,title=DEP004::'black' imported but declared as a dev dependency",
f"::error file={Path('src/main.py')},line=6,col=8,title=DEP001::'white' imported but missing from the dependency definitions",
"",
]

assert result.returncode == 1
assert result.stderr == "\n".join(expected_output)


@pytest.mark.xdist_group(name=Project.EXAMPLE)
def test_cli_with_github_output_warning_errors(poetry_venv_factory: PoetryVenvFactory) -> None:
with poetry_venv_factory(Project.EXAMPLE) as virtual_env:
result = virtual_env.run("deptry . --github-output --github-warning-errors DEP001,DEP004")

expected_output = [
"Scanning 2 files...",
"",
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'isort' defined as a dependency but not"
" used in the codebase",
file=Path("pyproject.toml"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'requests' defined as a dependency but"
" not used in the codebase",
file=Path("pyproject.toml"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET}4{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP004{RESET} 'black'"
" imported but declared as a dev dependency",
file=Path("src/main.py"),
),
stylize(
"{BOLD}{file}{RESET}{CYAN}:{RESET}6{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP001{RESET} 'white'"
" imported but missing from the dependency definitions",
file=Path("src/main.py"),
),
stylize("{BOLD}{RED}Found 4 dependency issues.{RESET}"),
"",
"For more information, see the documentation: https://deptry.com/",
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'isort' defined as a dependency but not used in the codebase",
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'requests' defined as a dependency but not used in the codebase",
f"::warning file={Path('src/main.py')},line=4,col=8,title=DEP004::'black' imported but declared as a dev dependency",
f"::warning file={Path('src/main.py')},line=6,col=8,title=DEP001::'white' imported but missing from the dependency definitions",
"",
]

assert result.returncode == 1
assert result.stderr == "\n".join(expected_output)


def test_cli_help() -> None:
result = CliRunner().invoke(cli, "--help")

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/reporters/test_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from deptry.imports.location import Location
from deptry.module import Module
from deptry.reporters.github import GithubReporter, _build_workflow_command, _escape
from deptry.violations import DEP001MissingDependencyViolation

if TYPE_CHECKING:
from _pytest.logging import LogCaptureFixture

from deptry.violations import Violation

# Extract violation instance as a parameter
violation_instance = DEP001MissingDependencyViolation(
Module("foo", package="foo-package"), Location(Path("foo.py"), 1, 2)
)

expected_warning = _build_workflow_command(
"warning",
"DEP001",
"'foo' imported but missing from the dependency definitions",
"foo.py",
line=1,
column=2,
)

expected_error = _build_workflow_command(
"error", "DEP001", "'foo' imported but missing from the dependency definitions", "foo.py", line=1, column=2
)


@pytest.mark.parametrize(
("violation", "warning_ids", "expected"),
[
(violation_instance, ["DEP001"], expected_warning),
(violation_instance, [], expected_error),
],
)
def test_github_annotation(
caplog: LogCaptureFixture, violation: Violation, warning_ids: tuple[str, ...], expected: str
) -> None:
reporter = GithubReporter(violations=[violation], warning_ids=warning_ids)

with caplog.at_level(logging.INFO):
reporter.report()

assert expected in caplog.text.strip()


def test_build_workflow_command_escaping() -> None:
# Directly test _build_workflow_command with characters needing escape.
message = "Error % occurred\r\nNew line"
escaped_message = _escape(message)
command = _build_workflow_command("warning", "TEST", message, "file.py", line=10, column=2)
assert "::warning file=file.py,line=10,col=2,title=TEST::" in command
assert escaped_message in command
2 changes: 2 additions & 0 deletions tests/unit/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ def test__get_local_modules(
pep621_dev_dependency_groups=(),
using_default_requirements_files=True,
experimental_namespace_package=experimental_namespace_package,
github_output=False,
github_warning_errors=(),
)._get_local_modules()
== expected
)
Expand Down