Skip to content

Commit e16ea60

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

5 files changed

Lines changed: 123 additions & 66 deletions

File tree

src/twyn/cli.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ def entry_point() -> None:
8282
default=False,
8383
help="Do not show the progress bar while processing packages.",
8484
)
85+
@click.option(
86+
"--json",
87+
is_flag=True,
88+
default=False,
89+
help="Display the results in json format. It implies --no-track.",
90+
)
8591
def run(
8692
config: str,
8793
dependency_file: Optional[str],
@@ -91,16 +97,12 @@ def run(
9197
vv: bool,
9298
no_cache: bool,
9399
no_track: bool,
100+
json: bool,
94101
) -> 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:
102+
if vv:
103103
verbosity = AvailableLoggingLevels.debug
104+
elif v:
105+
verbosity = AvailableLoggingLevels.info
104106
else:
105107
verbosity = AvailableLoggingLevels.none
106108

@@ -113,31 +115,33 @@ def run(
113115
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())
114116

115117
try:
116-
errors = check_dependencies(
118+
possible_typos = check_dependencies(
117119
dependencies=set(dependency) or None,
118120
config_file=config,
119121
dependency_file=dependency_file,
120122
selector_method=selector_method,
121123
verbosity=verbosity,
122124
use_cache=not no_cache,
123-
use_track=not no_track,
125+
use_track=False if json else not no_track,
124126
)
125127
except TwynError as e:
126128
raise CliError(e.message) from e
127129
except Exception as e:
128130
raise CliError("Unhandled exception occured.") from e
129131

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,
132+
if json:
133+
print(possible_typos.model_dump_json())
134+
sys.exit(int(bool(possible_typos.errors)))
135+
elif possible_typos.errors:
136+
for possible_typosquats in possible_typos.errors:
137+
print(
138+
f"\033[91mPossible typosquat detected: \033[0m"
139+
f"`{possible_typosquats.candidate_dependency}`, "
140+
f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?"
137141
)
138142
sys.exit(1)
139143
else:
140-
click.echo(click.style("No typosquats detected", fg="green"), color=True)
144+
print("\033[92mNo typosquats detected\033[0m")
141145
sys.exit(0)
142146

143147

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: 13 additions & 5 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,12 +12,11 @@
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

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

2121
def __bool__(self) -> bool:
2222
return bool(self.similar_dependencies)
@@ -26,6 +26,14 @@ def add(self, similar_name: str) -> None:
2626
self.similar_dependencies.append(similar_name)
2727

2828

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.candidate_dependency for typo in self.errors}
35+
36+
2937
class TrustedPackages:
3038
"""Representation of packages that can be trusted."""
3139

@@ -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(candidate_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: 57 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,62 @@ 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(candidate_dependency="my-package", similar_dependencies=["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(candidate_dependency="my-package", similar_dependencies=["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 (
222+
result.output == '{"errors":[{"candidate_dependency":"my-package","similar_dependencies":["mypackage"]}]}\n'
223+
)
224+
225+
@patch("twyn.cli.check_dependencies")
226+
def test_json_no_typo(self, mock_check_dependencies: Mock) -> None:
227+
mock_check_dependencies.return_value = TyposquatCheckResultList(errors=[])
228+
runner = CliRunner()
229+
result = runner.invoke(
230+
cli.run,
231+
[
232+
"--json",
233+
],
234+
)
206235

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

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

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
244+
result = runner.invoke(cli.run)
245+
246+
assert result.exit_code == 0
247+
assert result.output == "\x1b[92mNo typosquats detected\x1b[0m\n"
217248

218-
def test_dependency_file_name_has_to_be_recognized(self):
249+
def test_dependency_file_name_has_to_be_recognized(self) -> None:
219250
runner = CliRunner()
220251
result = runner.invoke(cli.run, ["--dependency-file", "requirements-dev.txt"], catch_exceptions=False)
221252

@@ -241,7 +272,9 @@ def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dep
241272
assert "Test base error message" in caplog.text
242273

243274
@patch("twyn.cli.check_dependencies")
244-
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):
275+
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(
276+
self, mock_check_dependencies: Mock, caplog: pytest.LogCaptureFixture
277+
) -> None:
245278
"""Test that unhandled exceptions are caught and wrapped in CliError."""
246279
runner = CliRunner()
247280

0 commit comments

Comments
 (0)