Skip to content

Commit 9949b1e

Browse files
committed
feat: add --table option
1 parent 7aff2b3 commit 9949b1e

File tree

3 files changed

+150
-30
lines changed

3 files changed

+150
-30
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
![Build Status](https://github.com/elementsinteractive/twyn/actions/workflows/test.yml/badge.svg)
44
[![PyPI version](https://img.shields.io/pypi/v/twyn)](https://pypi.org/project/twyn/)
55
[![Docker version](https://img.shields.io/docker/v/elementsinteractive/twyn?label=DockerHub&logo=docker&logoColor=f5f5f5)](https://hub.docker.com/r/elementsinteractive/twyn)
6-
[![Python Version](https://img.shields.io/badge/python-%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C3.14-blue?logo=python&logoColor=yellow)](https://pypi.org/project/twyn/)
6+
[![Python Version](https://img.shields.io/badge/python-%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue?logo=python&logoColor=yellow)](https://pypi.org/project/twyn/)
77
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
88
[![License](https://img.shields.io/github/license/elementsinteractive/twyn)](LICENSE)
99

src/twyn/cli.py

Lines changed: 104 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import sys
3+
from typing import NoReturn
34

45
from twyn.__version__ import __version__
56
from twyn.base.constants import (
@@ -13,11 +14,13 @@
1314
from twyn.main import check_dependencies
1415
from twyn.trusted_packages.cache_handler import CacheHandler
1516
from twyn.trusted_packages.constants import CACHE_DIR
17+
from twyn.trusted_packages.models import TyposquatCheckResults
1618

1719
try:
1820
import click
1921
from rich.console import Console
2022
from rich.logging import RichHandler
23+
from rich.table import Table
2124

2225
from twyn.base.exceptions import CliError
2326
except ImportError:
@@ -104,6 +107,12 @@ def entry_point() -> None:
104107
default=False,
105108
help="Display the results in json format. It implies --no-track.",
106109
)
110+
@click.option(
111+
"--table",
112+
is_flag=True,
113+
default=False,
114+
help="Display the results in a table format. It implies --no-track.",
115+
)
107116
@click.option(
108117
"-r",
109118
"--recursive",
@@ -131,33 +140,94 @@ def run( # noqa: C901
131140
no_cache: bool | None,
132141
no_track: bool,
133142
json: bool,
143+
table: bool,
134144
package_ecosystem: str | None,
135145
recursive: bool,
136146
pypi_source: str | None,
137147
npm_source: str | None,
138-
) -> int:
139-
if vv:
140-
logger.setLevel(logging.DEBUG)
141-
elif v:
142-
logger.setLevel(logging.INFO)
148+
) -> None:
149+
set_logging_level(v=v, vv=vv)
150+
check_args(
151+
dependency_file=dependency_file,
152+
dependency=dependency,
153+
json=json,
154+
table=table,
155+
)
143156

144-
if dependency and dependency_file:
145-
raise click.UsageError(
146-
"Only one of --dependency or --dependency-file can be set at a time.", ctx=click.get_current_context()
147-
)
157+
possible_typos = get_typos(
158+
config=config,
159+
dependency_file=dependency_file,
160+
dependency=dependency,
161+
selector_method=selector_method,
162+
no_cache=no_cache,
163+
no_track=no_track,
164+
json=json,
165+
table=table,
166+
package_ecosystem=package_ecosystem,
167+
recursive=recursive,
168+
pypi_source=pypi_source,
169+
npm_source=npm_source,
170+
)
171+
display_output_and_exit(json=json, table=table, possible_typos=possible_typos)
148172

149-
for dep_file in dependency_file:
150-
if dep_file and not any(dep_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
151-
raise click.UsageError(f"Dependency file name {dep_file} not supported.", ctx=click.get_current_context())
152173

174+
def display_output_and_exit(json: bool, table: bool, possible_typos: TyposquatCheckResults) -> NoReturn:
175+
if json:
176+
click.echo(possible_typos.model_dump_json())
177+
sys.exit(int(bool(possible_typos)))
178+
elif table:
179+
if not possible_typos:
180+
click.echo("✅ No typosquats detected")
181+
sys.exit(0)
182+
183+
console = Console()
184+
table_obj = Table(title="❌ Twyn Detection Results")
185+
table_obj.add_column("Source")
186+
table_obj.add_column("Dependency")
187+
table_obj.add_column("Similar trusted packages")
188+
189+
for possible_typosquats in possible_typos.results:
190+
for error in possible_typosquats.errors:
191+
table_obj.add_row(str(possible_typosquats.source), error.dependency, ", ".join(error.similars))
192+
193+
console.print(table_obj)
194+
sys.exit(1)
195+
elif possible_typos:
196+
for possible_typosquats in possible_typos.results:
197+
for error in possible_typosquats.errors:
198+
click.echo(
199+
click.style("Possible typosquat detected: ", fg="red") + f"`{error.dependency}`, "
200+
f"did you mean any of [{', '.join(error.similars)}]?",
201+
color=True,
202+
)
203+
sys.exit(1)
204+
else:
205+
click.echo(click.style("No typosquats detected", fg="green"), color=True)
206+
sys.exit(0)
207+
208+
209+
def get_typos(
210+
config: str,
211+
dependency_file: tuple[str],
212+
dependency: tuple[str],
213+
selector_method: str,
214+
no_cache: bool | None,
215+
no_track: bool,
216+
json: bool,
217+
table: bool,
218+
package_ecosystem: str | None,
219+
recursive: bool,
220+
pypi_source: str | None,
221+
npm_source: str | None,
222+
) -> TyposquatCheckResults:
153223
try:
154-
possible_typos = check_dependencies(
224+
return check_dependencies(
155225
selector_method=selector_method,
156226
dependencies=set(dependency) or None,
157227
config_file=config,
158228
dependency_files=set(dependency_file) or None,
159229
use_cache=not no_cache if no_cache is not None else no_cache,
160-
show_progress_bar=False if json else not no_track,
230+
show_progress_bar=False if json or table else not no_track,
161231
load_config_from_file=True,
162232
package_ecosystem=package_ecosystem,
163233
recursive=recursive,
@@ -169,21 +239,26 @@ def run( # noqa: C901
169239
except Exception as e:
170240
raise CliError("Unhandled exception occured.") from e
171241

172-
if json:
173-
click.echo(possible_typos.model_dump_json())
174-
sys.exit(int(bool(possible_typos)))
175-
elif possible_typos:
176-
for possible_typosquats in possible_typos.results:
177-
for error in possible_typosquats.errors:
178-
click.echo(
179-
click.style("Possible typosquat detected: ", fg="red") + f"`{error.dependency}`, "
180-
f"did you mean any of [{', '.join(error.similars)}]?",
181-
color=True,
182-
)
183-
sys.exit(1)
184-
else:
185-
click.echo(click.style("No typosquats detected", fg="green"), color=True)
186-
sys.exit(0)
242+
243+
def set_logging_level(v: bool, vv: bool) -> None:
244+
if vv:
245+
logger.setLevel(logging.DEBUG)
246+
elif v:
247+
logger.setLevel(logging.INFO)
248+
249+
250+
def check_args(dependency_file: tuple[str], dependency: tuple[str], json: bool, table: bool) -> None:
251+
if dependency and dependency_file:
252+
raise click.UsageError(
253+
"Only one of --dependency or --dependency-file can be set at a time.", ctx=click.get_current_context()
254+
)
255+
256+
if json and table:
257+
raise click.UsageError("`--json` and `--table` are mutually exclusive. Select only one.")
258+
259+
for dep_file in dependency_file:
260+
if dep_file and not any(dep_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
261+
raise click.UsageError(f"Dependency file name {dep_file} not supported.", ctx=click.get_current_context())
187262

188263

189264
@entry_point.group()

tests/main/test_cli.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,18 @@ def test_return_code_1(self, mock_check_dependencies: Mock) -> None:
288288
assert result.exit_code == 1
289289
assert "did you mean any of [mypackage]" in result.output
290290

291+
def test_table_and_json_mutually_exclusive(self) -> None:
292+
runner = CliRunner()
293+
result = runner.invoke(
294+
cli.run,
295+
[
296+
"--json",
297+
"--table",
298+
],
299+
)
300+
assert result.exit_code != 0
301+
assert "`--json` and `--table` are mutually exclusive. Select only one." in result.output
302+
291303
@patch("twyn.cli.check_dependencies")
292304
def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None:
293305
mock_check_dependencies.return_value = TyposquatCheckResults(
@@ -312,6 +324,39 @@ def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None:
312324
== '{"results":[{"errors":[{"dependency":"my-package","similars":["mypackage"]}],"source":"manual_input"}]}\n'
313325
)
314326

327+
@patch("twyn.cli.check_dependencies")
328+
def test_table_typo_detected(self, mock_check_dependencies: Mock) -> None:
329+
mock_check_dependencies.return_value = TyposquatCheckResults(
330+
results=[
331+
TyposquatCheckResultFromSource(
332+
errors=[TyposquatCheckResultEntry(dependency="my-package", similars=["mypackage"])],
333+
source="manual_input",
334+
)
335+
]
336+
)
337+
runner = CliRunner()
338+
result = runner.invoke(
339+
cli.run,
340+
[
341+
"--table",
342+
],
343+
)
344+
345+
assert result.exit_code == 1
346+
347+
@patch("twyn.cli.check_dependencies")
348+
def test_table_no_typo_detected(self, mock_check_dependencies: Mock) -> None:
349+
mock_check_dependencies.return_value = TyposquatCheckResults(results=[])
350+
runner = CliRunner()
351+
result = runner.invoke(
352+
cli.run,
353+
[
354+
"--table",
355+
],
356+
)
357+
358+
assert result.exit_code == 0
359+
315360
@patch("twyn.cli.check_dependencies")
316361
def test_json_no_typo(self, mock_check_dependencies: Mock) -> None:
317362
mock_check_dependencies.return_value = TyposquatCheckResults()

0 commit comments

Comments
 (0)