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
37 changes: 21 additions & 16 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Optional

import click
from rich.console import Console
from rich.logging import RichHandler

from twyn.__version__ import __version__
Expand All @@ -22,7 +23,7 @@
logging.basicConfig(
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))],
)
logger = logging.getLogger("twyn")

Expand Down Expand Up @@ -82,6 +83,12 @@ def entry_point() -> None:
default=False,
help="Do not show the progress bar while processing packages.",
)
@click.option(
"--json",
is_flag=True,
default=False,
help="Display the results in json format. It implies --no-track.",
)
def run(
config: str,
dependency_file: Optional[str],
Expand All @@ -91,16 +98,12 @@ def run(
vv: bool,
no_cache: bool,
no_track: bool,
json: bool,
) -> int:
if v and vv:
raise click.UsageError(
"Only one verbosity level is allowed. Choose either -v or -vv.", ctx=click.get_current_context()
)

if v:
verbosity = AvailableLoggingLevels.info
elif vv:
if vv:
verbosity = AvailableLoggingLevels.debug
elif v:
verbosity = AvailableLoggingLevels.info
else:
verbosity = AvailableLoggingLevels.none

Expand All @@ -113,26 +116,28 @@ def run(
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())

try:
errors = check_dependencies(
possible_typos = check_dependencies(
dependencies=set(dependency) or None,
config_file=config,
dependency_file=dependency_file,
selector_method=selector_method,
verbosity=verbosity,
use_cache=not no_cache,
use_track=not no_track,
use_track=False if json else not no_track,
)
except TwynError as e:
raise CliError(e.message) from e
except Exception as e:
raise CliError("Unhandled exception occured.") from e

if errors:
for possible_typosquats in errors:
if json:
click.echo(possible_typos.model_dump_json())
sys.exit(int(bool(possible_typos.errors)))
elif possible_typos.errors:
for possible_typosquats in possible_typos.errors:
click.echo(
click.style("Possible typosquat detected: ", fg="red")
+ f"`{possible_typosquats.candidate_dependency}`, "
f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?",
click.style("Possible typosquat detected: ", fg="red") + f"`{possible_typosquats.dependency}`, "
f"did you mean any of [{', '.join(possible_typosquats.similars)}]?",
color=True,
)
sys.exit(1)
Expand Down
10 changes: 5 additions & 5 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from twyn.trusted_packages.selectors import AbstractSelector
from twyn.trusted_packages.trusted_packages import (
TrustedPackages,
TyposquatCheckResult,
TyposquatCheckResultList,
)

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

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

logger.info("Analyzing %s", dependency)
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
errors.append(typosquat_results)
typos_list.errors.append(typosquat_results)

return errors
return typos_list


def _set_logging_level(logging_level: AvailableLoggingLevels) -> None:
Expand Down
24 changes: 16 additions & 8 deletions src/twyn/trusted_packages/trusted_packages.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any

from pydantic import BaseModel

from twyn.similarity.algorithm import (
AbstractSimilarityAlgorithm,
SimilarityThreshold,
Expand All @@ -11,19 +12,26 @@
_PackageNames = defaultdict[str, set[str]]


@dataclass
class TyposquatCheckResult:
class TyposquatCheckResult(BaseModel):
"""Represents the result of analyzing a dependency for a possible typosquat."""

candidate_dependency: str
similar_dependencies: list[str] = field(default_factory=list)
dependency: str
similars: list[str] = []

def __bool__(self) -> bool:
return bool(self.similar_dependencies)
return bool(self.similars)

def add(self, similar_name: str) -> None:
"""Add a similar dependency to this typosquat check result."""
self.similar_dependencies.append(similar_name)
self.similars.append(similar_name)


class TyposquatCheckResultList(BaseModel):
Comment thread
scastlara marked this conversation as resolved.
errors: list[TyposquatCheckResult] = []

def get_typosquats(self) -> set[str]:
"""Return a set containing all the detected packages with a typo."""
return {typo.dependency for typo in self.errors}


class TrustedPackages:
Expand Down Expand Up @@ -65,7 +73,7 @@ def get_typosquat(
are used to determine if the package name can be considered similar.
"""
threshold = self.threshold_class.from_name(package_name)
typosquat_result = TyposquatCheckResult(package_name)
typosquat_result = TyposquatCheckResult(dependency=package_name)
for trusted_package_name in self.selector.select_similar_names(names=self.names, name=package_name):
distance = self.algorithm.get_distance(package_name, trusted_package_name)
if threshold.is_inside_threshold(distance):
Expand Down
6 changes: 0 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@
import pytest


@pytest.fixture(autouse=True)
def disable_click_echo():
with mock.patch("click.echo"):
yield


@contextmanager
def create_tmp_file(path: Path, data: str) -> Iterator[str]:
path.write_text(data)
Expand Down
76 changes: 52 additions & 24 deletions tests/main/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from pathlib import Path
from unittest.mock import call, patch
from unittest.mock import Mock, call, patch

import pytest
from click.testing import CliRunner
from twyn import cli
from twyn.base.constants import AvailableLoggingLevels
from twyn.base.exceptions import TwynError
from twyn.trusted_packages.cache_handler import CacheEntry, CacheHandler
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult, TyposquatCheckResultList


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

@patch("twyn.cli.check_dependencies")
def test_no_cache_option_disables_cache(self, mock_check_dependencies):
def test_no_cache_option_disables_cache(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.run,
Expand All @@ -46,7 +47,7 @@ def test_no_cache_option_disables_cache(self, mock_check_dependencies):
assert mock_check_dependencies.call_args[1]["dependencies"] == {"requests"}

@patch("twyn.config.config_handler.ConfigHandler.add_package_to_allowlist")
def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add):
def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.add,
Expand All @@ -56,7 +57,7 @@ def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add):
assert mock_allowlist_add.call_args == call("requests")

@patch("twyn.config.config_handler.ConfigHandler.remove_package_from_allowlist")
def test_allowlist_remove(self, mock_allowlist_add):
def test_allowlist_remove(self, mock_allowlist_add: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.remove,
Expand All @@ -66,7 +67,7 @@ def test_allowlist_remove(self, mock_allowlist_add):
assert mock_allowlist_add.call_args == call("requests")

@patch("twyn.cli.check_dependencies")
def test_click_arguments_dependency_file(self, mock_check_dependencies):
def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.run,
Expand Down Expand Up @@ -94,7 +95,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies):
]

@patch("twyn.cli.check_dependencies")
def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies):
def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.run,
Expand All @@ -117,7 +118,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
]

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

@patch("twyn.cli.check_dependencies")
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
runner.invoke(
cli.run,
Expand All @@ -173,7 +174,7 @@ def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
]

@patch("twyn.cli.check_dependencies")
def test_click_arguments_default(self, mock_check_dependencies):
def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
runner.invoke(cli.run)

Expand All @@ -190,32 +191,57 @@ def test_click_arguments_default(self, mock_check_dependencies):
]

@patch("twyn.cli.check_dependencies")
def test_return_code_1(self, mock_check_dependencies):
def test_return_code_1(self, mock_check_dependencies: Mock) -> None:
runner = CliRunner()
mock_check_dependencies.return_value = [
TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])
]
mock_check_dependencies.return_value = TyposquatCheckResultList(
errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])]
)

result = runner.invoke(cli.run)
assert result.exit_code == 1
assert "did you mean any of [mypackage]" in result.output

@patch("twyn.cli.check_dependencies")
def test_return_code_0(self, mock_check_dependencies):
def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None:
mock_check_dependencies.return_value = TyposquatCheckResultList(
errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])]
)
runner = CliRunner()
mock_check_dependencies.return_value = []
result = runner.invoke(
cli.run,
[
"--json",
],
)

assert result.exit_code == 1
assert result.output == '{"errors":[{"dependency":"my-package","similars":["mypackage"]}]}\n'

@patch("twyn.cli.check_dependencies")
def test_json_no_typo(self, mock_check_dependencies: Mock) -> None:
mock_check_dependencies.return_value = TyposquatCheckResultList(errors=[])
runner = CliRunner()
result = runner.invoke(
cli.run,
[
"--json",
],
)

result = runner.invoke(cli.run)
assert result.exit_code == 0
assert result.output == '{"errors":[]}\n'

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

assert isinstance(result.exception, SystemExit)
assert result.exit_code == 2
assert "Only one verbosity level is allowed. Choose either -v or -vv." in result.output
result = runner.invoke(cli.run)

assert result.exit_code == 0
assert "No typosquats detected" in result.output

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

Expand All @@ -241,7 +267,9 @@ def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dep
assert "Test base error message" in caplog.text

@patch("twyn.cli.check_dependencies")
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(
self, mock_check_dependencies: Mock, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that unhandled exceptions are caught and wrapped in CliError."""
runner = CliRunner()

Expand Down
Loading