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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # v6.5.0

- name: Install the project
- name: Install the dependencies
run: uv sync --locked --group dev --python ${{ matrix.python-version }}

- name: Run tests
Expand Down
9 changes: 6 additions & 3 deletions src/twyn/base/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import enum
from typing import TYPE_CHECKING
from enum import Enum
from typing import TYPE_CHECKING, Literal

from twyn import dependency_parser
from twyn.trusted_packages import selectors
Expand All @@ -16,6 +16,9 @@
"all": selectors.AllSimilar,
}

SELECTOR_METHOD_KEYS = set(SELECTOR_METHOD_MAPPING.keys())
SelectorMethod = Literal["first-letter", "nearby-letter", "all"]

DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = {
"requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser,
"poetry.lock": dependency_parser.lock_parser.PoetryLockParser,
Expand All @@ -27,7 +30,7 @@
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"


class AvailableLoggingLevels(enum.Enum):
class AvailableLoggingLevels(Enum):
none = "NONE"
debug = "DEBUG"
info = "INFO"
Expand Down
29 changes: 19 additions & 10 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,27 @@ def run(
if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())

sys.exit(
int(
check_dependencies(
config_file=config,
dependency_file=dependency_file,
dependencies_cli=set(dependency) or None,
selector_method=selector_method,
verbosity=verbosity,
)
)
errors = check_dependencies(
dependencies=set(dependency) or None,
config_file=config,
dependency_file=dependency_file,
selector_method=selector_method,
verbosity=verbosity,
)

if errors:
for possible_typosquats in 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)}]?",
color=True,
)
sys.exit(1)
else:
click.echo(click.style("No typosquats detected", fg="green"), color=True)
sys.exit(0)


@entry_point.group()
def allowlist() -> None:
Expand Down
14 changes: 13 additions & 1 deletion src/twyn/config/config_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
DEFAULT_PROJECT_TOML_FILE,
DEFAULT_SELECTOR_METHOD,
DEFAULT_TOP_PYPI_PACKAGES,
SELECTOR_METHOD_KEYS,
AvailableLoggingLevels,
)
from twyn.config.exceptions import (
AllowlistPackageAlreadyExistsError,
AllowlistPackageDoesNotExistError,
InvalidSelectorMethodError,
TOMLError,
)
from twyn.file_handler.exceptions import PathNotFoundError
Expand Down Expand Up @@ -67,9 +69,19 @@ def resolve_config(
toml = self._read_toml()
read_config = self._get_read_config(toml)

# Determine final selector method from CLI, config file, or default
final_selector_method = selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD

# Validate selector_method
if final_selector_method not in SELECTOR_METHOD_KEYS:
valid_methods = ", ".join(sorted(SELECTOR_METHOD_KEYS))
raise InvalidSelectorMethodError(
f"Invalid selector_method '{final_selector_method}'. Must be one of: {valid_methods}"
)

return TwynConfiguration(
dependency_file=dependency_file or read_config.dependency_file,
selector_method=selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD,
selector_method=final_selector_method,
logging_level=_get_logging_level(verbosity, read_config.logging_level),
allowlist=read_config.allowlist,
pypi_reference=read_config.pypi_reference or DEFAULT_TOP_PYPI_PACKAGES,
Expand Down
4 changes: 4 additions & 0 deletions src/twyn/config/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ class AllowlistPackageAlreadyExistsError(AllowlistError):

class AllowlistPackageDoesNotExistError(AllowlistError):
message = "Package '{}' is not present in the allowlist. Skipping."


class InvalidSelectorMethodError(TwynError):
"""Exception for when an invalid selector method has been specified."""
26 changes: 8 additions & 18 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
import re
from typing import Optional

import click
from rich.logging import RichHandler
from rich.progress import track

from twyn.base.constants import (
DEFAULT_PROJECT_TOML_FILE,
SELECTOR_METHOD_MAPPING,
AvailableLoggingLevels,
SelectorMethod,
)
from twyn.config.config_handler import ConfigHandler
from twyn.dependency_parser.dependency_selector import DependencySelector
Expand All @@ -31,12 +31,12 @@


def check_dependencies(
config_file: Optional[str],
dependency_file: Optional[str],
dependencies_cli: Optional[set[str]],
selector_method: str,
selector_method: SelectorMethod,
config_file: Optional[str] = None,
dependency_file: Optional[str] = None,
dependencies: Optional[set[str]] = None,
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
) -> bool:
) -> list[TyposquatCheckResult]:
"""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 @@ -51,7 +51,7 @@ def check_dependencies(
threshold_class=SimilarityThreshold,
)
normalized_allowlist_packages = _normalize_packages(config.allowlist)
dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file)
dependencies = dependencies if dependencies else get_parsed_dependencies_from_file(config.dependency_file)
normalized_dependencies = _normalize_packages(dependencies)

errors: list[TyposquatCheckResult] = []
Expand All @@ -64,17 +64,7 @@ def check_dependencies(
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
errors.append(typosquat_results)

for possible_typosquats in 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)}]?",
color=True,
)

if not errors:
click.echo(click.style("No typosquats detected", fg="green"), color=True)

return bool(errors)
return errors


def _set_logging_level(logging_level: AvailableLoggingLevels) -> None:
Expand Down
18 changes: 16 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
Expand All @@ -20,6 +20,20 @@ def create_tmp_file(path: Path, data: str) -> Iterator[str]:
os.remove(path)


@contextmanager
def patch_pypi_requests_get(packages: Iterable[str]) -> Iterator[mock.Mock]:
"""Patcher of `requests.get` for Top PyPi list.

Replaces call with the output you would get from downloading the top PyPi packages list.
"""
json_response = {"rows": [{"project": name} for name in packages]}

with mock.patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = json_response

yield mock_get


@pytest.fixture
def requirements_txt_file(tmp_path: Path) -> Iterator[str]:
requirements_txt_file = tmp_path / "requirements.txt"
Expand Down Expand Up @@ -187,7 +201,7 @@ def pyproject_toml_file(tmp_path: Path) -> Iterator[str]:

[tool.twyn]
dependency_file="my_file.txt"
selector_method="my_selector"
selector_method="all"
logging_level="debug"
allowlist=["boto4", "boto2"]

Expand Down
51 changes: 48 additions & 3 deletions tests/core/test_config_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
from copy import deepcopy
from pathlib import Path
from unittest.mock import patch

import pytest
Expand All @@ -9,6 +10,7 @@
from twyn.config.exceptions import (
AllowlistPackageAlreadyExistsError,
AllowlistPackageDoesNotExistError,
InvalidSelectorMethodError,
TOMLError,
)
from twyn.file_handler.exceptions import PathNotFoundError
Expand Down Expand Up @@ -46,7 +48,7 @@ def test_config_raises_for_unknown_file(self):
def test_read_config_values(self, pyproject_toml_file):
config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config()
assert config.dependency_file == "my_file.txt"
assert config.selector_method == "my_selector"
assert config.selector_method == "all"
assert config.logging_level == AvailableLoggingLevels.debug
assert config.allowlist == {"boto4", "boto2"}

Expand All @@ -57,7 +59,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file):
twyn_data = ConfigHandler(FileHandler(pyproject_toml_file))._get_read_config(toml)
assert twyn_data == ReadTwynConfiguration(
dependency_file="my_file.txt",
selector_method="my_selector",
selector_method="all",
logging_level="debug",
allowlist={"boto4", "boto2"},
pypi_reference=None,
Expand Down Expand Up @@ -95,7 +97,7 @@ def test_write_toml(self, pyproject_toml_file):
},
"twyn": {
"dependency_file": "my_file.txt",
"selector_method": "my_selector",
"selector_method": "all",
"logging_level": "debug",
"allowlist": {},
"pypi_reference": DEFAULT_TOP_PYPI_PACKAGES,
Expand Down Expand Up @@ -166,3 +168,46 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml, mock_write
config.remove_package_from_allowlist("mypackage2")

assert not mock_write_toml.called

@pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"])
def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path):
"""Test that all valid selector methods are accepted."""
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text("")
config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False)

# Should not raise any exception
resolved_config = config.resolve_config(selector_method=valid_selector)
assert resolved_config.selector_method == valid_selector

def test_invalid_selector_method_rejected(self, tmp_path: Path):
"""Test that invalid selector methods are rejected with appropriate error."""
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text("")
config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False)

with pytest.raises(InvalidSelectorMethodError) as exc_info:
config.resolve_config(selector_method="random-selector")

error_message = str(exc_info.value)
assert "Invalid selector_method 'random-selector'" in error_message
assert "Must be one of: all, first-letter, nearby-letter" in error_message

def test_invalid_selector_method_from_config_file(self, tmp_path: Path):
"""Test that invalid selector method from config file is rejected."""
# Create a config file with invalid selector method
pyproject_toml = tmp_path / "pyproject.toml"
data = """
[tool.twyn]
selector_method="invalid-selector"
"""
pyproject_toml.write_text(data)

config = ConfigHandler(FileHandler(str(pyproject_toml)))

with pytest.raises(InvalidSelectorMethodError) as exc_info:
config.resolve_config()

error_message = str(exc_info.value)
assert "Invalid selector_method 'invalid-selector'" in error_message
assert "Must be one of: all, first-letter, nearby-letter" in error_message
19 changes: 11 additions & 8 deletions tests/main/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from click.testing import CliRunner
from twyn import cli
from twyn.base.constants import AvailableLoggingLevels
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult


class TestCli:
Expand Down Expand Up @@ -46,7 +47,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies):
call(
config_file="my-config",
dependency_file="requirements.txt",
dependencies_cli=None,
dependencies=None,
selector_method="first-letter",
verbosity=AvailableLoggingLevels.debug,
)
Expand All @@ -67,7 +68,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
call(
config_file=None,
dependency_file="/path/requirements.txt",
dependencies_cli=None,
dependencies=None,
selector_method=None,
verbosity=AvailableLoggingLevels.none,
)
Expand All @@ -87,7 +88,7 @@ def test_click_arguments_single_dependency_cli(self, mock_check_dependencies):
call(
config_file=None,
dependency_file=None,
dependencies_cli={"reqests"},
dependencies={"reqests"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
)
Expand All @@ -103,7 +104,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_cli(self, mock_check_dependencies):
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
runner = CliRunner()
runner.invoke(
cli.run,
Expand All @@ -119,7 +120,7 @@ def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies
call(
config_file=None,
dependency_file=None,
dependencies_cli={"reqests", "reqeusts"},
dependencies={"reqests", "reqeusts"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
)
Expand All @@ -135,23 +136,25 @@ def test_click_arguments_default(self, mock_check_dependencies):
config_file=None,
dependency_file=None,
selector_method=None,
dependencies_cli=None,
dependencies=None,
verbosity=AvailableLoggingLevels.none,
)
]

@patch("twyn.cli.check_dependencies")
def test_return_code_1(self, mock_check_dependencies):
runner = CliRunner()
mock_check_dependencies.return_value = True
mock_check_dependencies.return_value = [
TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])
]

result = runner.invoke(cli.run)
assert result.exit_code == 1

@patch("twyn.cli.check_dependencies")
def test_return_code_0(self, mock_check_dependencies):
runner = CliRunner()
mock_check_dependencies.return_value = False
mock_check_dependencies.return_value = []

result = runner.invoke(cli.run)
assert result.exit_code == 0
Expand Down
Loading
Loading