From 329969f9aa2b0e60c4c4c1454bce5494f8d49890 Mon Sep 17 00:00:00 2001 From: Daniel Sanz <13658011+sdn4z@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:09:52 +0100 Subject: [PATCH] feat: add --table option --- README.md | 2 +- src/twyn/cli.py | 135 ++++++++++++++++++++++++++++++++--------- tests/main/test_cli.py | 48 +++++++++++++++ 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5f34ed9..e7e1a3c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Build Status](https://github.com/elementsinteractive/twyn/actions/workflows/test.yml/badge.svg) [![PyPI version](https://img.shields.io/pypi/v/twyn)](https://pypi.org/project/twyn/) [![Docker version](https://img.shields.io/docker/v/elementsinteractive/twyn?label=DockerHub&logo=docker&logoColor=f5f5f5)](https://hub.docker.com/r/elementsinteractive/twyn) -[![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/) +[![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/) [![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) [![License](https://img.shields.io/github/license/elementsinteractive/twyn)](LICENSE) diff --git a/src/twyn/cli.py b/src/twyn/cli.py index a9abdea..d91bf70 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -1,5 +1,6 @@ import logging import sys +from typing import NoReturn from twyn.__version__ import __version__ from twyn.base.constants import ( @@ -13,11 +14,13 @@ from twyn.main import check_dependencies from twyn.trusted_packages.cache_handler import CacheHandler from twyn.trusted_packages.constants import CACHE_DIR +from twyn.trusted_packages.models import TyposquatCheckResults try: import click from rich.console import Console from rich.logging import RichHandler + from rich.table import Table from twyn.base.exceptions import CliError except ImportError: @@ -104,6 +107,12 @@ def entry_point() -> None: default=False, help="Display the results in json format. It implies --no-track.", ) +@click.option( + "--table", + is_flag=True, + default=False, + help="Display the results in a table format. It implies --no-track.", +) @click.option( "-r", "--recursive", @@ -131,33 +140,94 @@ def run( # noqa: C901 no_cache: bool | None, no_track: bool, json: bool, + table: bool, package_ecosystem: str | None, recursive: bool, pypi_source: str | None, npm_source: str | None, -) -> int: - if vv: - logger.setLevel(logging.DEBUG) - elif v: - logger.setLevel(logging.INFO) +) -> NoReturn: + set_logging_level(v=v, vv=vv) + check_args( + dependency_file=dependency_file, + dependency=dependency, + json=json, + table=table, + ) - if dependency and dependency_file: - raise click.UsageError( - "Only one of --dependency or --dependency-file can be set at a time.", ctx=click.get_current_context() - ) + possible_typos = get_typos( + config=config, + dependency_file=dependency_file, + dependency=dependency, + selector_method=selector_method, + no_cache=no_cache, + no_track=no_track, + json=json, + table=table, + package_ecosystem=package_ecosystem, + recursive=recursive, + pypi_source=pypi_source, + npm_source=npm_source, + ) + display_output_and_exit(json=json, table=table, possible_typos=possible_typos) - for dep_file in dependency_file: - if dep_file and not any(dep_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING): - raise click.UsageError(f"Dependency file name {dep_file} not supported.", ctx=click.get_current_context()) +def display_output_and_exit(json: bool, table: bool, possible_typos: TyposquatCheckResults) -> NoReturn: + if json: + click.echo(possible_typos.model_dump_json()) + sys.exit(int(bool(possible_typos))) + elif table: + if not possible_typos: + click.echo("✅ No typosquats detected") + sys.exit(0) + + console = Console() + table_obj = Table(title="❌ Twyn Detection Results") + table_obj.add_column("Source") + table_obj.add_column("Dependency") + table_obj.add_column("Similar trusted packages") + + for possible_typosquats in possible_typos.results: + for error in possible_typosquats.errors: + table_obj.add_row(str(possible_typosquats.source), error.dependency, ", ".join(error.similars)) + + console.print(table_obj) + sys.exit(1) + elif possible_typos: + for possible_typosquats in possible_typos.results: + for error in possible_typosquats.errors: + click.echo( + click.style("Possible typosquat detected: ", fg="red") + f"`{error.dependency}`, " + f"did you mean any of [{', '.join(error.similars)}]?", + color=True, + ) + sys.exit(1) + else: + click.echo(click.style("No typosquats detected", fg="green"), color=True) + sys.exit(0) + + +def get_typos( + config: str, + dependency_file: tuple[str], + dependency: tuple[str], + selector_method: str, + no_cache: bool | None, + no_track: bool, + json: bool, + table: bool, + package_ecosystem: str | None, + recursive: bool, + pypi_source: str | None, + npm_source: str | None, +) -> TyposquatCheckResults: try: - possible_typos = check_dependencies( + return check_dependencies( selector_method=selector_method, dependencies=set(dependency) or None, config_file=config, dependency_files=set(dependency_file) or None, use_cache=not no_cache if no_cache is not None else no_cache, - show_progress_bar=False if json else not no_track, + show_progress_bar=False if json or table else not no_track, load_config_from_file=True, package_ecosystem=package_ecosystem, recursive=recursive, @@ -169,21 +239,26 @@ def run( # noqa: C901 except Exception as e: raise CliError("Unhandled exception occured.") from e - if json: - click.echo(possible_typos.model_dump_json()) - sys.exit(int(bool(possible_typos))) - elif possible_typos: - for possible_typosquats in possible_typos.results: - for error in possible_typosquats.errors: - click.echo( - click.style("Possible typosquat detected: ", fg="red") + f"`{error.dependency}`, " - f"did you mean any of [{', '.join(error.similars)}]?", - color=True, - ) - sys.exit(1) - else: - click.echo(click.style("No typosquats detected", fg="green"), color=True) - sys.exit(0) + +def set_logging_level(v: bool, vv: bool) -> None: + if vv: + logger.setLevel(logging.DEBUG) + elif v: + logger.setLevel(logging.INFO) + + +def check_args(dependency_file: tuple[str], dependency: tuple[str], json: bool, table: bool) -> None: + if dependency and dependency_file: + raise click.UsageError( + "Only one of --dependency or --dependency-file can be set at a time.", ctx=click.get_current_context() + ) + + if json and table: + raise click.UsageError("`--json` and `--table` are mutually exclusive. Select only one.") + + for dep_file in dependency_file: + if dep_file and not any(dep_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING): + raise click.UsageError(f"Dependency file name {dep_file} not supported.", ctx=click.get_current_context()) @entry_point.group() @@ -226,4 +301,4 @@ def clear() -> None: if __name__ == "__main__": - sys.exit(entry_point()) + entry_point() diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index ab72341..8b5278e 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -288,6 +288,18 @@ def test_return_code_1(self, mock_check_dependencies: Mock) -> None: assert result.exit_code == 1 assert "did you mean any of [mypackage]" in result.output + def test_table_and_json_mutually_exclusive(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli.run, + [ + "--json", + "--table", + ], + ) + assert result.exit_code != 0 + assert "`--json` and `--table` are mutually exclusive. Select only one." in result.output + @patch("twyn.cli.check_dependencies") def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None: mock_check_dependencies.return_value = TyposquatCheckResults( @@ -312,6 +324,42 @@ def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None: == '{"results":[{"errors":[{"dependency":"my-package","similars":["mypackage"]}],"source":"manual_input"}]}\n' ) + @patch("twyn.cli.check_dependencies") + def test_table_typo_detected(self, mock_check_dependencies: Mock) -> None: + mock_check_dependencies.return_value = TyposquatCheckResults( + results=[ + TyposquatCheckResultFromSource( + errors=[TyposquatCheckResultEntry(dependency="my-package", similars=["mypackage"])], + source="manual_input", + ) + ] + ) + runner = CliRunner() + result = runner.invoke( + cli.run, + [ + "--table", + ], + ) + + assert result.exit_code == 1 + assert "my-package" in result.output + assert "mypackage" in result.output + + @patch("twyn.cli.check_dependencies") + def test_table_no_typo_detected(self, mock_check_dependencies: Mock) -> None: + mock_check_dependencies.return_value = TyposquatCheckResults(results=[]) + runner = CliRunner() + result = runner.invoke( + cli.run, + [ + "--table", + ], + ) + + assert result.exit_code == 0 + assert "✅ No typosquats detected" in result.output + @patch("twyn.cli.check_dependencies") def test_json_no_typo(self, mock_check_dependencies: Mock) -> None: mock_check_dependencies.return_value = TyposquatCheckResults()