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 justfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test-all: venv

# Format all code in the project.
format: venv
poetry run ruff {{ target_dirs }} --fix
poetry run ruff check {{ target_dirs }} --fix

# Lint all code in the project.
lint: venv
Expand Down
18 changes: 14 additions & 4 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import sys
from typing import Optional

import click

Expand Down Expand Up @@ -28,6 +29,12 @@ def entry_point() -> None:
"for supported files, but this option will override that behavior."
),
)
@click.option(
"--dependency",
type=str,
multiple=True,
help="Dependency to analyze. Cannot be set together with --dependency-file. If provided, it will take precedence over the default dependency file.",
)
@click.option(
"--selector-method",
type=click.Choice(list(SELECTOR_METHOD_MAPPING.keys())),
Expand All @@ -51,15 +58,14 @@ def entry_point() -> None:
)
def run(
config: str,
dependency_file: str,
dependency_file: Optional[str],
dependency: tuple[str],
selector_method: str,
v: bool,
vv: bool,
) -> int:
if v and vv:
raise ValueError(
"Only one verbosity level is allowed. Choose either -v or -vv."
)
raise ValueError("Only one verbosity level is allowed. Choose either -v or -vv.")

if v:
verbosity = AvailableLoggingLevels.info
Expand All @@ -68,10 +74,14 @@ def run(
else:
verbosity = AvailableLoggingLevels.none

if dependency and dependency_file:
raise ValueError("Only one of --dependency or --dependency-file can be set at a time.")

return int(
check_dependencies(
config_file=config,
dependency_file=dependency_file,
dependencies_cli=set(dependency) or None,
selector_method=selector_method,
verbosity=verbosity,
)
Expand Down
28 changes: 12 additions & 16 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,22 @@

def check_dependencies(
config_file: Optional[str],
dependency_file: str,
dependency_file: Optional[str],
dependencies_cli: Optional[set[str]],
selector_method: str,
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
) -> bool:
"""Check if dependencies could be typosquats."""
config = get_configuration(config_file, dependency_file, selector_method, verbosity)

trusted_packages = TrustedPackages(
names=TopPyPiReference().get_packages(),
algorithm=EditDistance(),
selector=get_candidate_selector(config.selector_method),
threshold_class=SimilarityThreshold,
)
normalized_allowlist_packages = normalize_packages(config.allowlist)
dependencies = get_parsed_dependencies(config.dependency_file)
dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file)
normalized_dependencies = normalize_packages(dependencies)

errors: list[TyposquatCheckResult] = []
Expand All @@ -53,9 +55,7 @@ def check_dependencies(
continue

logger.info(f"Analyzing {dependency}")
if dependency not in trusted_packages and (
typosquat_results := trusted_packages.get_typosquat(dependency)
):
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
errors.append(typosquat_results)

for possible_typosquats in errors:
Expand All @@ -69,8 +69,8 @@ def check_dependencies(

def get_configuration(
config_file: Optional[str],
dependency_file: str,
selector_method: str,
dependency_file: Optional[str],
selector_method: Optional[str],
verbosity: AvailableLoggingLevels,
) -> ConfigHandler:
"""Read configuration and return configuration object.
Expand All @@ -80,18 +80,14 @@ def get_configuration(
# Read config from file
config = ConfigHandler(file_path=config_file, enforce_file=False)

# Set logging level according to priority order
logging_level: AvailableLoggingLevels = get_logging_level(
# Set logging level
config.logging_level = get_logging_level(
logging_level=verbosity,
config_logging_level=config.logging_level,
)
set_logging_level(logging_level)
config.logging_level = logging_level.value

set_logging_level(config.logging_level)
# Set selector method according to priority order
config.selector_method = (
selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD
)
config.selector_method = selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD

# Set dependency file according to priority order
config.dependency_file = dependency_file or config.dependency_file or None
Expand Down Expand Up @@ -126,7 +122,7 @@ def get_candidate_selector(selector_method_name: Optional[str]) -> AbstractSelec
return selector_method


def get_parsed_dependencies(dependency_file: Optional[str] = None) -> set[str]:
def get_parsed_dependencies_from_file(dependency_file: Optional[str] = None) -> set[str]:
dependency_parser = DependencySelector(dependency_file).get_dependency_parser()
dependencies = dependency_parser.parse()
logger.debug("Successfully parsed local dependencies file.")
Expand Down
57 changes: 56 additions & 1 deletion tests/main/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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(self, mock_check_dependencies):
def test_click_arguments_dependency_file(self, mock_check_dependencies):
runner = CliRunner()
runner.invoke(
cli.run,
Expand All @@ -47,11 +47,65 @@ def test_click_arguments(self, mock_check_dependencies):
call(
config_file="my-config",
dependency_file="requirements.txt",
dependencies_cli=None,
selector_method="first-letter",
verbosity=AvailableLoggingLevels.debug,
)
]

@patch("twyn.cli.check_dependencies")
def test_click_arguments_single_dependency_cli(self, mock_check_dependencies):
runner = CliRunner()
runner.invoke(
cli.run,
[
"--dependency",
"reqests",
],
)
assert mock_check_dependencies.call_args_list == [
call(
config_file=None,
dependency_file=None,
dependencies_cli={"reqests"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
)
]

def test_click_raises_error_dependency_and_dependency_file_set(self):
runner = CliRunner()
result = runner.invoke(
cli.run,
["--dependency", "requests", "--dependency-file", "requirements.txt"],
)
with pytest.raises(ValueError, match="Only one of --dependency or --dependency-file can be set at a time."):
raise result.exception
assert result.exit_code == 1

@patch("twyn.cli.check_dependencies")
def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies):
runner = CliRunner()
runner.invoke(
cli.run,
[
"--dependency",
"reqests",
"--dependency",
"reqeusts",
],
)

assert mock_check_dependencies.call_args_list == [
call(
config_file=None,
dependency_file=None,
dependencies_cli={"reqests", "reqeusts"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
)
]

@patch("twyn.cli.check_dependencies")
def test_click_arguments_default(self, mock_check_dependencies):
runner = CliRunner()
Expand All @@ -62,6 +116,7 @@ def test_click_arguments_default(self, mock_check_dependencies):
config_file=None,
dependency_file=None,
selector_method=None,
dependencies_cli=None,
verbosity=AvailableLoggingLevels.none,
)
]
Expand Down
71 changes: 53 additions & 18 deletions tests/main/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
check_dependencies,
get_configuration,
get_logging_level,
get_parsed_dependencies,
get_parsed_dependencies_from_file,
)


Expand Down Expand Up @@ -129,16 +129,17 @@ def test_logging_level(self, passed_logging_level, config, logging_level):
),
)
@patch("twyn.main.TopPyPiReference")
@patch("twyn.main.get_parsed_dependencies")
@patch("twyn.main.get_parsed_dependencies_from_file")
def test_check_dependencies_detects_typosquats(
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
):
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}
mock_get_parsed_dependencies.return_value = {package_name}
mock_get_parsed_dependencies_from_file.return_value = {package_name}

error = check_dependencies(
config_file=None,
dependency_file=None,
dependencies_cli=None,
selector_method="first-letter",
)

Expand All @@ -154,52 +155,86 @@ def test_check_dependencies_detects_typosquats(
),
)
@patch("twyn.main.TopPyPiReference")
@patch("twyn.main.get_parsed_dependencies")
def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_top_pypi_reference, package_name):
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}

error = check_dependencies(
config_file=None,
dependency_file=None,
dependencies_cli={package_name},
selector_method="first-letter",
)

assert error is True

@patch("twyn.main.TopPyPiReference")
def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(self, mock_top_pypi_reference):
mock_top_pypi_reference.return_value.get_packages.return_value = {"requests", "mypackage"}

error = check_dependencies(
config_file=None,
dependency_file=None,
dependencies_cli={"my-package", "requests"},
selector_method="first-letter",
)

assert error is True

@pytest.mark.parametrize(
"package_name",
(
"my.package",
"my-package",
"my_package",
"My.Package",
),
)
@patch("twyn.main.TopPyPiReference")
@patch("twyn.main.get_parsed_dependencies_from_file")
def test_check_dependencies_ignores_package_in_allowlist(
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
):
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}
mock_get_parsed_dependencies.return_value = {package_name}
mock_get_parsed_dependencies_from_file.return_value = {package_name}

m_config = Mock(
allowlist={package_name},
dependency_file=None,
dependencies_cli=None,
selector_method="first-letter",
)

with patch("twyn.main.get_configuration", return_value=m_config):
error = check_dependencies(
config_file=None,
dependency_file=None,
dependencies_cli=None,
selector_method="first-letter",
)

assert error is False

@pytest.mark.parametrize(
"package_name", ("my.package", "my-package", "my_package", "My.Package")
)
@pytest.mark.parametrize("package_name", ("my.package", "my-package", "my_package", "My.Package"))
@patch("twyn.main.TopPyPiReference")
@patch("twyn.main.get_parsed_dependencies")
@patch("twyn.main.get_parsed_dependencies_from_file")
def test_check_dependencies_does_not_error_on_same_package(
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
):
mock_top_pypi_reference.return_value.get_packages.return_value = {"my-package"}
mock_get_parsed_dependencies.return_value = {package_name}
mock_get_parsed_dependencies_from_file.return_value = {package_name}

error = check_dependencies(
config_file=None,
dependency_file=None,
dependencies_cli=None,
selector_method="first-letter",
)

assert error is False

@patch(
"twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser"
)
@patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser")
@patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.parse")
def test_get_parsed_dependencies(self, mock_parse, mock_get_dependency_parser):
def test_get_parsed_dependencies_from_file(self, mock_parse, mock_get_dependency_parser):
mock_get_dependency_parser.return_value = RequirementsTxtParser()
mock_parse.return_value = {"boto3"}
assert get_parsed_dependencies() == {"boto3"}
assert get_parsed_dependencies_from_file() == {"boto3"}