Skip to content

Commit 2d418e7

Browse files
committed
feat: allow to get results as json
1 parent 3a6495c commit 2d418e7

6 files changed

Lines changed: 124 additions & 70 deletions

File tree

src/twyn/cli.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Optional
44

55
import click
6+
from rich.console import Console
67
from rich.logging import RichHandler
78

89
from twyn.__version__ import __version__
@@ -22,7 +23,7 @@
2223
logging.basicConfig(
2324
format="%(message)s",
2425
datefmt="[%X]",
25-
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
26+
handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))],
2627
)
2728
logger = logging.getLogger("twyn")
2829

@@ -82,6 +83,12 @@ def entry_point() -> None:
8283
default=False,
8384
help="Do not show the progress bar while processing packages.",
8485
)
86+
@click.option(
87+
"--json",
88+
is_flag=True,
89+
default=False,
90+
help="Display the results in json format. It implies --no-track.",
91+
)
8592
def run(
8693
config: str,
8794
dependency_file: Optional[str],
@@ -91,16 +98,12 @@ def run(
9198
vv: bool,
9299
no_cache: bool,
93100
no_track: bool,
101+
json: bool,
94102
) -> int:
95-
if v and vv:
96-
raise click.UsageError(
97-
"Only one verbosity level is allowed. Choose either -v or -vv.", ctx=click.get_current_context()
98-
)
99-
100-
if v:
101-
verbosity = AvailableLoggingLevels.info
102-
elif vv:
103+
if vv:
103104
verbosity = AvailableLoggingLevels.debug
105+
elif v:
106+
verbosity = AvailableLoggingLevels.info
104107
else:
105108
verbosity = AvailableLoggingLevels.none
106109

@@ -113,27 +116,29 @@ def run(
113116
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())
114117

115118
try:
116-
errors = check_dependencies(
119+
possible_typos = check_dependencies(
117120
dependencies=set(dependency) or None,
118121
config_file=config,
119122
dependency_file=dependency_file,
120123
selector_method=selector_method,
121124
verbosity=verbosity,
122125
use_cache=not no_cache,
123-
use_track=not no_track,
126+
use_track=False if json else not no_track,
124127
)
125128
except TwynError as e:
126129
raise CliError(e.message) from e
127130
except Exception as e:
128131
raise CliError("Unhandled exception occured.") from e
129132

130-
if errors:
131-
for possible_typosquats in errors:
132-
click.echo(
133-
click.style("Possible typosquat detected: ", fg="red")
134-
+ f"`{possible_typosquats.candidate_dependency}`, "
135-
f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?",
136-
color=True,
133+
if json:
134+
print(possible_typos.model_dump_json())
135+
sys.exit(int(bool(possible_typos.errors)))
136+
elif possible_typos.errors:
137+
for possible_typosquats in possible_typos.errors:
138+
print(
139+
f"\033[91mPossible typosquat detected: \033[0m"
140+
f"`{possible_typosquats.dependency}`, "
141+
f"did you mean any of [{', '.join(possible_typosquats.similarities)}]?"
137142
)
138143
sys.exit(1)
139144
else:

src/twyn/main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from twyn.trusted_packages.selectors import AbstractSelector
2020
from twyn.trusted_packages.trusted_packages import (
2121
TrustedPackages,
22-
TyposquatCheckResult,
22+
TyposquatCheckResultList,
2323
)
2424

2525
logger = logging.getLogger("twyn")
@@ -33,7 +33,7 @@ def check_dependencies(
3333
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
3434
use_cache: bool = True,
3535
use_track: bool = False,
36-
) -> list[TyposquatCheckResult]:
36+
) -> TyposquatCheckResultList:
3737
"""Check if dependencies could be typosquats."""
3838
config_file_handler = FileHandler(config_file or DEFAULT_PROJECT_TOML_FILE)
3939
config = ConfigHandler(config_file_handler, enforce_file=False).resolve_config(
@@ -52,7 +52,7 @@ def check_dependencies(
5252
dependencies = dependencies if dependencies else get_parsed_dependencies_from_file(config.dependency_file)
5353
normalized_dependencies = normalize_packages(dependencies)
5454

55-
errors: list[TyposquatCheckResult] = []
55+
typos_list = TyposquatCheckResultList()
5656
dependencies_list = (
5757
track(normalized_dependencies, description="Processing...") if use_track else normalized_dependencies
5858
)
@@ -63,9 +63,9 @@ def check_dependencies(
6363

6464
logger.info("Analyzing %s", dependency)
6565
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
66-
errors.append(typosquat_results)
66+
typos_list.errors.append(typosquat_results)
6767

68-
return errors
68+
return typos_list
6969

7070

7171
def _set_logging_level(logging_level: AvailableLoggingLevels) -> None:

src/twyn/trusted_packages/trusted_packages.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from collections import defaultdict
2-
from dataclasses import dataclass, field
32
from typing import Any
43

4+
from pydantic import BaseModel
5+
56
from twyn.similarity.algorithm import (
67
AbstractSimilarityAlgorithm,
78
SimilarityThreshold,
@@ -11,19 +12,26 @@
1112
_PackageNames = defaultdict[str, set[str]]
1213

1314

14-
@dataclass
15-
class TyposquatCheckResult:
15+
class TyposquatCheckResult(BaseModel):
1616
"""Represents the result of analyzing a dependency for a possible typosquat."""
1717

18-
candidate_dependency: str
19-
similar_dependencies: list[str] = field(default_factory=list)
18+
dependency: str
19+
similarities: list[str] = []
2020

2121
def __bool__(self) -> bool:
22-
return bool(self.similar_dependencies)
22+
return bool(self.similarities)
2323

2424
def add(self, similar_name: str) -> None:
2525
"""Add a similar dependency to this typosquat check result."""
26-
self.similar_dependencies.append(similar_name)
26+
self.similarities.append(similar_name)
27+
28+
29+
class TyposquatCheckResultList(BaseModel):
30+
errors: list[TyposquatCheckResult] = []
31+
32+
def get_typosquats(self) -> set[str]:
33+
"""Return a set containing all the detected packages with a typo."""
34+
return {typo.dependency for typo in self.errors}
2735

2836

2937
class TrustedPackages:
@@ -65,7 +73,7 @@ def get_typosquat(
6573
are used to determine if the package name can be considered similar.
6674
"""
6775
threshold = self.threshold_class.from_name(package_name)
68-
typosquat_result = TyposquatCheckResult(package_name)
76+
typosquat_result = TyposquatCheckResult(dependency=package_name)
6977
for trusted_package_name in self.selector.select_similar_names(names=self.names, name=package_name):
7078
distance = self.algorithm.get_distance(package_name, trusted_package_name)
7179
if threshold.is_inside_threshold(distance):

tests/main/test_cli.py

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from pathlib import Path
2-
from unittest.mock import call, patch
2+
from unittest.mock import Mock, call, patch
33

4+
import pytest
45
from click.testing import CliRunner
56
from twyn import cli
67
from twyn.base.constants import AvailableLoggingLevels
78
from twyn.base.exceptions import TwynError
89
from twyn.trusted_packages.cache_handler import CacheEntry, CacheHandler
9-
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult
10+
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult, TyposquatCheckResultList
1011

1112

1213
class TestCli:
@@ -36,7 +37,7 @@ def test_cache_clear_removes_all_cache_files(self, tmp_path: Path) -> None:
3637
assert len(cache_files) == 0
3738

3839
@patch("twyn.cli.check_dependencies")
39-
def test_no_cache_option_disables_cache(self, mock_check_dependencies):
40+
def test_no_cache_option_disables_cache(self, mock_check_dependencies: Mock) -> None:
4041
runner = CliRunner()
4142
runner.invoke(
4243
cli.run,
@@ -46,7 +47,7 @@ def test_no_cache_option_disables_cache(self, mock_check_dependencies):
4647
assert mock_check_dependencies.call_args[1]["dependencies"] == {"requests"}
4748

4849
@patch("twyn.config.config_handler.ConfigHandler.add_package_to_allowlist")
49-
def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add):
50+
def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add: Mock) -> None:
5051
runner = CliRunner()
5152
runner.invoke(
5253
cli.add,
@@ -56,7 +57,7 @@ def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add):
5657
assert mock_allowlist_add.call_args == call("requests")
5758

5859
@patch("twyn.config.config_handler.ConfigHandler.remove_package_from_allowlist")
59-
def test_allowlist_remove(self, mock_allowlist_add):
60+
def test_allowlist_remove(self, mock_allowlist_add: Mock) -> None:
6061
runner = CliRunner()
6162
runner.invoke(
6263
cli.remove,
@@ -66,7 +67,7 @@ def test_allowlist_remove(self, mock_allowlist_add):
6667
assert mock_allowlist_add.call_args == call("requests")
6768

6869
@patch("twyn.cli.check_dependencies")
69-
def test_click_arguments_dependency_file(self, mock_check_dependencies):
70+
def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) -> None:
7071
runner = CliRunner()
7172
runner.invoke(
7273
cli.run,
@@ -94,7 +95,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies):
9495
]
9596

9697
@patch("twyn.cli.check_dependencies")
97-
def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies):
98+
def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies: Mock) -> None:
9899
runner = CliRunner()
99100
runner.invoke(
100101
cli.run,
@@ -117,7 +118,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
117118
]
118119

119120
@patch("twyn.cli.check_dependencies")
120-
def test_click_arguments_single_dependency_cli(self, mock_check_dependencies):
121+
def test_click_arguments_single_dependency_cli(self, mock_check_dependencies: Mock) -> None:
121122
runner = CliRunner()
122123
runner.invoke(
123124
cli.run,
@@ -148,7 +149,7 @@ def test_click_raises_error_dependency_and_dependency_file_set(self):
148149
assert "Only one of --dependency or --dependency-file can be set at a time." in result.output
149150

150151
@patch("twyn.cli.check_dependencies")
151-
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
152+
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies: Mock) -> None:
152153
runner = CliRunner()
153154
runner.invoke(
154155
cli.run,
@@ -173,7 +174,7 @@ def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
173174
]
174175

175176
@patch("twyn.cli.check_dependencies")
176-
def test_click_arguments_default(self, mock_check_dependencies):
177+
def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None:
177178
runner = CliRunner()
178179
runner.invoke(cli.run)
179180

@@ -190,32 +191,60 @@ def test_click_arguments_default(self, mock_check_dependencies):
190191
]
191192

192193
@patch("twyn.cli.check_dependencies")
193-
def test_return_code_1(self, mock_check_dependencies):
194+
def test_return_code_1(self, mock_check_dependencies: Mock) -> None:
194195
runner = CliRunner()
195-
mock_check_dependencies.return_value = [
196-
TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])
197-
]
196+
mock_check_dependencies.return_value = TyposquatCheckResultList(
197+
errors=[TyposquatCheckResult(dependency="my-package", similarities=["mypackage"])]
198+
)
198199

199200
result = runner.invoke(cli.run)
200201
assert result.exit_code == 1
202+
assert (
203+
result.output
204+
== "\x1b[91mPossible typosquat detected: \x1b[0m`my-package`, did you mean any of [mypackage]?\n"
205+
)
201206

202207
@patch("twyn.cli.check_dependencies")
203-
def test_return_code_0(self, mock_check_dependencies):
208+
def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None:
209+
mock_check_dependencies.return_value = TyposquatCheckResultList(
210+
errors=[TyposquatCheckResult(dependency="my-package", similarities=["mypackage"])]
211+
)
204212
runner = CliRunner()
205-
mock_check_dependencies.return_value = []
213+
result = runner.invoke(
214+
cli.run,
215+
[
216+
"--json",
217+
],
218+
)
219+
220+
assert result.exit_code == 1
221+
assert result.output == '{"errors":[{"dependency":"my-package","similarities":["mypackage"]}]}\n'
222+
223+
@patch("twyn.cli.check_dependencies")
224+
def test_json_no_typo(self, mock_check_dependencies: Mock) -> None:
225+
mock_check_dependencies.return_value = TyposquatCheckResultList(errors=[])
226+
runner = CliRunner()
227+
result = runner.invoke(
228+
cli.run,
229+
[
230+
"--json",
231+
],
232+
)
206233

207-
result = runner.invoke(cli.run)
208234
assert result.exit_code == 0
235+
assert result.output == '{"errors":[]}\n'
209236

210-
def test_only_one_verbosity_level_is_allowed(self):
237+
@patch("twyn.cli.check_dependencies")
238+
def test_return_code_0(self, mock_check_dependencies: Mock) -> None:
211239
runner = CliRunner()
212-
result = runner.invoke(cli.run, ["-v", "-vv"], catch_exceptions=False)
240+
mock_check_dependencies.return_value = TyposquatCheckResultList()
213241

214-
assert isinstance(result.exception, SystemExit)
215-
assert result.exit_code == 2
216-
assert "Only one verbosity level is allowed. Choose either -v or -vv." in result.output
242+
result = runner.invoke(cli.run)
243+
244+
assert result.exit_code == 0
245+
assert result.output == "\x1b[92mNo typosquats detected\x1b[0m\n"
217246

218-
def test_dependency_file_name_has_to_be_recognized(self):
247+
def test_dependency_file_name_has_to_be_recognized(self) -> None:
219248
runner = CliRunner()
220249
result = runner.invoke(cli.run, ["--dependency-file", "requirements-dev.txt"], catch_exceptions=False)
221250

@@ -241,7 +270,9 @@ def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dep
241270
assert "Test base error message" in caplog.text
242271

243272
@patch("twyn.cli.check_dependencies")
244-
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):
273+
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(
274+
self, mock_check_dependencies: Mock, caplog: pytest.LogCaptureFixture
275+
) -> None:
245276
"""Test that unhandled exceptions are caught and wrapped in CliError."""
246277
runner = CliRunner()
247278

0 commit comments

Comments
 (0)