Skip to content

Commit 5ebc5c0

Browse files
OrenMefpgmaasmkniewallner
authored
feat: add github annotations reporter (#1059)
* feat: add github annotations reporter * add docs * lint fix * lint fix * Update cli.py Co-authored-by: Florian Maas <[email protected]> * Update python/deptry/cli.py Co-authored-by: Mathieu Kniewallner <[email protected]> * CR comments * Update docs/usage.md Co-authored-by: Mathieu Kniewallner <[email protected]> * more CR fixes * feat(reporters): handle violations without line/column * test: add functional tests for GitHub reporter * docs(usage): tweak documentation * test: use tuple for `github_warning_errors` arg * fix(cli): use tuple for `github_warning_errors` arg * refactor(reporters): code/message are never `None` * test: windows, as usual * test: more windows --------- Co-authored-by: Florian Maas <[email protected]> Co-authored-by: Mathieu Kniewallner <[email protected]>
1 parent aa59b6f commit 5ebc5c0

File tree

8 files changed

+292
-2
lines changed

8 files changed

+292
-2
lines changed

docs/usage.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,50 @@ json_output = "deptry_report.txt"
384384
deptry . --json-output deptry_report.txt
385385
```
386386

387+
#### GitHub output
388+
389+
Print [GitHub Actions annotations](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands) in the console when dependency issues are detected.
390+
391+
Annotations follow this format:
392+
393+
```shell
394+
::error file=<file>,line=<line>,col=<column>,title=<error_code>::<error_message>
395+
```
396+
397+
By default, violations are annotated as errors. To report specific violation codes as warnings instead, use the [GitHub warning errors](#github-warning-errors) option.
398+
399+
- Type: `bool`
400+
- Default: `False`
401+
- `pyproject.toml` option name: `github_output`
402+
- CLI option name: `--github-output`
403+
- `pyproject.toml` example:
404+
```toml
405+
[tool.deptry]
406+
github_output = true
407+
```
408+
- CLI example:
409+
```shell
410+
deptry . --github-output
411+
```
412+
413+
#### GitHub warning errors
414+
415+
When [GitHub output](#github-output) option is enabled, this sets the severity of messages to `warning` instead of `error` for the specified error codes.
416+
417+
- Type: `list[str]`
418+
- Default: `[]`
419+
- `pyproject.toml` option name: `github_warning_errors`
420+
- CLI option name: `--github-warning-errors`
421+
- `pyproject.toml` example:
422+
```toml
423+
[tool.deptry]
424+
github_warning_errors = ["DEP001", "DEP002"]
425+
```
426+
- CLI example:
427+
```shell
428+
deptry . --github-warning-errors DEP001,DEP002
429+
```
430+
387431
#### Package module name map
388432

389433
Deptry will automatically detect top level modules names that belong to a

python/deptry/cli.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,21 @@ def display_deptry_version(ctx: click.Context, _param: click.Parameter, value: b
217217
help="""If specified, a summary of the dependency issues found will be written to the output location specified. e.g. `deptry . -o deptry.json`""",
218218
show_default=True,
219219
)
220+
@click.option(
221+
"--github-output",
222+
"-go",
223+
is_flag=True,
224+
help="""If specified, dependency issues found will be written in the format of GitHub annotation.""",
225+
)
226+
@click.option(
227+
"--github-warning-errors",
228+
"-gwe",
229+
type=COMMA_SEPARATED_TUPLE,
230+
help="""A comma-separated list of error codes that should be printed as warnings.
231+
If not specified, all violations will be reported as errors.""",
232+
default=(),
233+
show_default=False,
234+
)
220235
@click.option(
221236
"--package-module-name-map",
222237
"-pmnm",
@@ -254,6 +269,8 @@ def cli(
254269
requirements_files_dev: tuple[str, ...],
255270
known_first_party: tuple[str, ...],
256271
json_output: str,
272+
github_output: bool,
273+
github_warning_errors: tuple[str, ...],
257274
package_module_name_map: MutableMapping[str, tuple[str, ...]],
258275
pep621_dev_dependency_groups: tuple[str, ...],
259276
experimental_namespace_package: bool,
@@ -287,6 +304,8 @@ def cli(
287304
requirements_files_dev=requirements_files_dev,
288305
known_first_party=known_first_party,
289306
json_output=json_output,
307+
github_output=github_output,
308+
github_warning_errors=github_warning_errors,
290309
package_module_name_map=package_module_name_map,
291310
pep621_dev_dependency_groups=pep621_dev_dependency_groups,
292311
experimental_namespace_package=experimental_namespace_package,

python/deptry/core.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from deptry.imports.extract import get_imported_modules_from_list_of_files
1111
from deptry.module import ModuleBuilder, ModuleLocations
1212
from deptry.python_file_finder import get_all_python_files_in
13-
from deptry.reporters import JSONReporter, TextReporter
13+
from deptry.reporters import GithubReporter, JSONReporter, TextReporter
1414
from deptry.violations.finder import find_violations
1515

1616
if TYPE_CHECKING:
@@ -40,6 +40,8 @@ class Core:
4040
package_module_name_map: Mapping[str, tuple[str, ...]]
4141
pep621_dev_dependency_groups: tuple[str, ...]
4242
experimental_namespace_package: bool
43+
github_output: bool
44+
github_warning_errors: tuple[str, ...]
4345

4446
def run(self) -> None:
4547
self._log_config()
@@ -87,6 +89,9 @@ def run(self) -> None:
8789
if self.json_output:
8890
JSONReporter(violations, self.json_output).report()
8991

92+
if self.github_output:
93+
GithubReporter(violations, warning_ids=self.github_warning_errors).report()
94+
9095
self._exit(violations)
9196

9297
def _find_python_files(self) -> list[Path]:
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3+
from deptry.reporters.github import GithubReporter
34
from deptry.reporters.json import JSONReporter
45
from deptry.reporters.text import TextReporter
56

6-
__all__ = ("JSONReporter", "TextReporter")
7+
__all__ = ("GithubReporter", "JSONReporter", "TextReporter")

python/deptry/reporters/github.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from dataclasses import dataclass, field
5+
from typing import TYPE_CHECKING
6+
7+
from deptry.reporters.base import Reporter
8+
9+
if TYPE_CHECKING:
10+
from deptry.violations import Violation
11+
12+
13+
@dataclass
14+
class GithubReporter(Reporter):
15+
warning_ids: tuple[str, ...] = field(default_factory=tuple) # tuple of error codes to print as warnings
16+
17+
def report(self) -> None:
18+
self._log_and_exit()
19+
20+
def _log_and_exit(self) -> None:
21+
self._log_violations(self.violations)
22+
23+
def _log_violations(self, violations: list[Violation]) -> None:
24+
for violation in violations:
25+
self._print_github_annotation(violation)
26+
27+
def _print_github_annotation(self, violation: Violation) -> None:
28+
annotation_severity = "warning" if violation.error_code in self.warning_ids else "error"
29+
file_name = violation.location.file
30+
31+
ret = _build_workflow_command(
32+
annotation_severity,
33+
violation.error_code,
34+
violation.get_error_message(),
35+
str(file_name),
36+
# For dependency files (like "pyproject.toml"), we don't extract a line. Setting the first line in that case
37+
# allows a comment to be added in GitHub, even if it's not on the proper line, otherwise it doesn't appear
38+
# at all.
39+
line=violation.location.line or 1,
40+
column=violation.location.column,
41+
)
42+
logging.info(ret)
43+
44+
45+
def _build_workflow_command(
46+
command_name: str,
47+
title: str,
48+
message: str,
49+
file: str,
50+
line: int,
51+
end_line: int | None = None,
52+
column: int | None = None,
53+
end_column: int | None = None,
54+
) -> str:
55+
"""Build a command to annotate a workflow."""
56+
result = f"::{command_name} "
57+
58+
entries = [
59+
("file", file),
60+
("line", line),
61+
("endLine", end_line),
62+
("col", column),
63+
("endColumn", end_column),
64+
("title", title),
65+
]
66+
67+
result += ",".join(f"{k}={v}" for k, v in entries if v is not None)
68+
69+
return f"{result}::{_escape(message)}"
70+
71+
72+
def _escape(s: str) -> str:
73+
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")

tests/functional/cli/test_cli.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,90 @@ def test_cli_with_json_output(poetry_venv_factory: PoetryVenvFactory) -> None:
624624
]
625625

626626

627+
@pytest.mark.xdist_group(name=Project.EXAMPLE)
628+
def test_cli_with_github_output(poetry_venv_factory: PoetryVenvFactory) -> None:
629+
with poetry_venv_factory(Project.EXAMPLE) as virtual_env:
630+
result = virtual_env.run("deptry . --github-output")
631+
632+
expected_output = [
633+
"Scanning 2 files...",
634+
"",
635+
stylize(
636+
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'isort' defined as a dependency but not"
637+
" used in the codebase",
638+
file=Path("pyproject.toml"),
639+
),
640+
stylize(
641+
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'requests' defined as a dependency but"
642+
" not used in the codebase",
643+
file=Path("pyproject.toml"),
644+
),
645+
stylize(
646+
"{BOLD}{file}{RESET}{CYAN}:{RESET}4{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP004{RESET} 'black'"
647+
" imported but declared as a dev dependency",
648+
file=Path("src/main.py"),
649+
),
650+
stylize(
651+
"{BOLD}{file}{RESET}{CYAN}:{RESET}6{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP001{RESET} 'white'"
652+
" imported but missing from the dependency definitions",
653+
file=Path("src/main.py"),
654+
),
655+
stylize("{BOLD}{RED}Found 4 dependency issues.{RESET}"),
656+
"",
657+
"For more information, see the documentation: https://deptry.com/",
658+
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'isort' defined as a dependency but not used in the codebase",
659+
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'requests' defined as a dependency but not used in the codebase",
660+
f"::error file={Path('src/main.py')},line=4,col=8,title=DEP004::'black' imported but declared as a dev dependency",
661+
f"::error file={Path('src/main.py')},line=6,col=8,title=DEP001::'white' imported but missing from the dependency definitions",
662+
"",
663+
]
664+
665+
assert result.returncode == 1
666+
assert result.stderr == "\n".join(expected_output)
667+
668+
669+
@pytest.mark.xdist_group(name=Project.EXAMPLE)
670+
def test_cli_with_github_output_warning_errors(poetry_venv_factory: PoetryVenvFactory) -> None:
671+
with poetry_venv_factory(Project.EXAMPLE) as virtual_env:
672+
result = virtual_env.run("deptry . --github-output --github-warning-errors DEP001,DEP004")
673+
674+
expected_output = [
675+
"Scanning 2 files...",
676+
"",
677+
stylize(
678+
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'isort' defined as a dependency but not"
679+
" used in the codebase",
680+
file=Path("pyproject.toml"),
681+
),
682+
stylize(
683+
"{BOLD}{file}{RESET}{CYAN}:{RESET} {BOLD}{RED}DEP002{RESET} 'requests' defined as a dependency but"
684+
" not used in the codebase",
685+
file=Path("pyproject.toml"),
686+
),
687+
stylize(
688+
"{BOLD}{file}{RESET}{CYAN}:{RESET}4{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP004{RESET} 'black'"
689+
" imported but declared as a dev dependency",
690+
file=Path("src/main.py"),
691+
),
692+
stylize(
693+
"{BOLD}{file}{RESET}{CYAN}:{RESET}6{CYAN}:{RESET}8{CYAN}:{RESET} {BOLD}{RED}DEP001{RESET} 'white'"
694+
" imported but missing from the dependency definitions",
695+
file=Path("src/main.py"),
696+
),
697+
stylize("{BOLD}{RED}Found 4 dependency issues.{RESET}"),
698+
"",
699+
"For more information, see the documentation: https://deptry.com/",
700+
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'isort' defined as a dependency but not used in the codebase",
701+
f"::error file={Path('pyproject.toml')},line=1,title=DEP002::'requests' defined as a dependency but not used in the codebase",
702+
f"::warning file={Path('src/main.py')},line=4,col=8,title=DEP004::'black' imported but declared as a dev dependency",
703+
f"::warning file={Path('src/main.py')},line=6,col=8,title=DEP001::'white' imported but missing from the dependency definitions",
704+
"",
705+
]
706+
707+
assert result.returncode == 1
708+
assert result.stderr == "\n".join(expected_output)
709+
710+
627711
def test_cli_help() -> None:
628712
result = CliRunner().invoke(cli, "--help")
629713

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
9+
from deptry.imports.location import Location
10+
from deptry.module import Module
11+
from deptry.reporters.github import GithubReporter, _build_workflow_command, _escape
12+
from deptry.violations import DEP001MissingDependencyViolation
13+
14+
if TYPE_CHECKING:
15+
from _pytest.logging import LogCaptureFixture
16+
17+
from deptry.violations import Violation
18+
19+
# Extract violation instance as a parameter
20+
violation_instance = DEP001MissingDependencyViolation(
21+
Module("foo", package="foo-package"), Location(Path("foo.py"), 1, 2)
22+
)
23+
24+
expected_warning = _build_workflow_command(
25+
"warning",
26+
"DEP001",
27+
"'foo' imported but missing from the dependency definitions",
28+
"foo.py",
29+
line=1,
30+
column=2,
31+
)
32+
33+
expected_error = _build_workflow_command(
34+
"error", "DEP001", "'foo' imported but missing from the dependency definitions", "foo.py", line=1, column=2
35+
)
36+
37+
38+
@pytest.mark.parametrize(
39+
("violation", "warning_ids", "expected"),
40+
[
41+
(violation_instance, ["DEP001"], expected_warning),
42+
(violation_instance, [], expected_error),
43+
],
44+
)
45+
def test_github_annotation(
46+
caplog: LogCaptureFixture, violation: Violation, warning_ids: tuple[str, ...], expected: str
47+
) -> None:
48+
reporter = GithubReporter(violations=[violation], warning_ids=warning_ids)
49+
50+
with caplog.at_level(logging.INFO):
51+
reporter.report()
52+
53+
assert expected in caplog.text.strip()
54+
55+
56+
def test_build_workflow_command_escaping() -> None:
57+
# Directly test _build_workflow_command with characters needing escape.
58+
message = "Error % occurred\r\nNew line"
59+
escaped_message = _escape(message)
60+
command = _build_workflow_command("warning", "TEST", message, "file.py", line=10, column=2)
61+
assert "::warning file=file.py,line=10,col=2,title=TEST::" in command
62+
assert escaped_message in command

tests/unit/test_core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ def test__get_local_modules(
126126
pep621_dev_dependency_groups=(),
127127
using_default_requirements_files=True,
128128
experimental_namespace_package=experimental_namespace_package,
129+
github_output=False,
130+
github_warning_errors=(),
129131
)._get_local_modules()
130132
== expected
131133
)

0 commit comments

Comments
 (0)