Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
135 changes: 105 additions & 30 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import sys
from typing import NoReturn

from twyn.__version__ import __version__
from twyn.base.constants import (
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -226,4 +301,4 @@ def clear() -> None:


if __name__ == "__main__":
sys.exit(entry_point())
entry_point()
48 changes: 48 additions & 0 deletions tests/main/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down
Loading