Skip to content

Commit 6b966cf

Browse files
committed
feat!: check_dependencies returns the list of dependencies (#252)
BREAKING CHANGE: check_dependencies now returns the list of dependencies
1 parent c1d9e3e commit 6b966cf

File tree

11 files changed

+205
-142
lines changed

11 files changed

+205
-142
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Install uv
2424
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # v6.5.0
2525

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

2929
- name: Run tests

src/twyn/base/constants.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

3-
import enum
4-
from typing import TYPE_CHECKING
3+
from enum import Enum
4+
from typing import TYPE_CHECKING, Literal
55

66
from twyn import dependency_parser
77
from twyn.trusted_packages import selectors
@@ -16,6 +16,9 @@
1616
"all": selectors.AllSimilar,
1717
}
1818

19+
SELECTOR_METHOD_KEYS = set(SELECTOR_METHOD_MAPPING.keys())
20+
SelectorMethod = Literal["first-letter", "nearby-letter", "all"]
21+
1922
DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = {
2023
"requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser,
2124
"poetry.lock": dependency_parser.lock_parser.PoetryLockParser,
@@ -27,7 +30,7 @@
2730
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
2831

2932

30-
class AvailableLoggingLevels(enum.Enum):
33+
class AvailableLoggingLevels(Enum):
3134
none = "NONE"
3235
debug = "DEBUG"
3336
info = "INFO"

src/twyn/cli.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,27 @@ def run(
8686
if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
8787
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())
8888

89-
sys.exit(
90-
int(
91-
check_dependencies(
92-
config_file=config,
93-
dependency_file=dependency_file,
94-
dependencies_cli=set(dependency) or None,
95-
selector_method=selector_method,
96-
verbosity=verbosity,
97-
)
98-
)
89+
errors = check_dependencies(
90+
dependencies=set(dependency) or None,
91+
config_file=config,
92+
dependency_file=dependency_file,
93+
selector_method=selector_method,
94+
verbosity=verbosity,
9995
)
10096

97+
if errors:
98+
for possible_typosquats in errors:
99+
click.echo(
100+
click.style("Possible typosquat detected: ", fg="red")
101+
+ f"`{possible_typosquats.candidate_dependency}`, "
102+
f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?",
103+
color=True,
104+
)
105+
sys.exit(1)
106+
else:
107+
click.echo(click.style("No typosquats detected", fg="green"), color=True)
108+
sys.exit(0)
109+
101110

102111
@entry_point.group()
103112
def allowlist() -> None:

src/twyn/config/config_handler.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
DEFAULT_PROJECT_TOML_FILE,
1010
DEFAULT_SELECTOR_METHOD,
1111
DEFAULT_TOP_PYPI_PACKAGES,
12+
SELECTOR_METHOD_KEYS,
1213
AvailableLoggingLevels,
1314
)
1415
from twyn.config.exceptions import (
1516
AllowlistPackageAlreadyExistsError,
1617
AllowlistPackageDoesNotExistError,
18+
InvalidSelectorMethodError,
1719
TOMLError,
1820
)
1921
from twyn.file_handler.exceptions import PathNotFoundError
@@ -67,9 +69,19 @@ def resolve_config(
6769
toml = self._read_toml()
6870
read_config = self._get_read_config(toml)
6971

72+
# Determine final selector method from CLI, config file, or default
73+
final_selector_method = selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD
74+
75+
# Validate selector_method
76+
if final_selector_method not in SELECTOR_METHOD_KEYS:
77+
valid_methods = ", ".join(sorted(SELECTOR_METHOD_KEYS))
78+
raise InvalidSelectorMethodError(
79+
f"Invalid selector_method '{final_selector_method}'. Must be one of: {valid_methods}"
80+
)
81+
7082
return TwynConfiguration(
7183
dependency_file=dependency_file or read_config.dependency_file,
72-
selector_method=selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD,
84+
selector_method=final_selector_method,
7385
logging_level=_get_logging_level(verbosity, read_config.logging_level),
7486
allowlist=read_config.allowlist,
7587
pypi_reference=read_config.pypi_reference or DEFAULT_TOP_PYPI_PACKAGES,

src/twyn/config/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ class AllowlistPackageAlreadyExistsError(AllowlistError):
1818

1919
class AllowlistPackageDoesNotExistError(AllowlistError):
2020
message = "Package '{}' is not present in the allowlist. Skipping."
21+
22+
23+
class InvalidSelectorMethodError(TwynError):
24+
"""Exception for when an invalid selector method has been specified."""

src/twyn/main.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
import re
33
from typing import Optional
44

5-
import click
65
from rich.logging import RichHandler
76
from rich.progress import track
87

98
from twyn.base.constants import (
109
DEFAULT_PROJECT_TOML_FILE,
1110
SELECTOR_METHOD_MAPPING,
1211
AvailableLoggingLevels,
12+
SelectorMethod,
1313
)
1414
from twyn.config.config_handler import ConfigHandler
1515
from twyn.dependency_parser.dependency_selector import DependencySelector
@@ -31,12 +31,12 @@
3131

3232

3333
def check_dependencies(
34-
config_file: Optional[str],
35-
dependency_file: Optional[str],
36-
dependencies_cli: Optional[set[str]],
37-
selector_method: str,
34+
selector_method: SelectorMethod,
35+
config_file: Optional[str] = None,
36+
dependency_file: Optional[str] = None,
37+
dependencies: Optional[set[str]] = None,
3838
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
39-
) -> bool:
39+
) -> list[TyposquatCheckResult]:
4040
"""Check if dependencies could be typosquats."""
4141
config_file_handler = FileHandler(config_file or DEFAULT_PROJECT_TOML_FILE)
4242
config = ConfigHandler(config_file_handler, enforce_file=False).resolve_config(
@@ -51,7 +51,7 @@ def check_dependencies(
5151
threshold_class=SimilarityThreshold,
5252
)
5353
normalized_allowlist_packages = _normalize_packages(config.allowlist)
54-
dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file)
54+
dependencies = dependencies if dependencies else get_parsed_dependencies_from_file(config.dependency_file)
5555
normalized_dependencies = _normalize_packages(dependencies)
5656

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

67-
for possible_typosquats in errors:
68-
click.echo(
69-
click.style("Possible typosquat detected: ", fg="red") + f"`{possible_typosquats.candidate_dependency}`, "
70-
f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?",
71-
color=True,
72-
)
73-
74-
if not errors:
75-
click.echo(click.style("No typosquats detected", fg="green"), color=True)
76-
77-
return bool(errors)
67+
return errors
7868

7969

8070
def _set_logging_level(logging_level: AvailableLoggingLevels) -> None:

tests/conftest.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
from collections.abc import Iterator
2+
from collections.abc import Iterable, Iterator
33
from contextlib import contextmanager
44
from pathlib import Path
55
from unittest import mock
@@ -20,6 +20,20 @@ def create_tmp_file(path: Path, data: str) -> Iterator[str]:
2020
os.remove(path)
2121

2222

23+
@contextmanager
24+
def patch_pypi_requests_get(packages: Iterable[str]) -> Iterator[mock.Mock]:
25+
"""Patcher of `requests.get` for Top PyPi list.
26+
27+
Replaces call with the output you would get from downloading the top PyPi packages list.
28+
"""
29+
json_response = {"rows": [{"project": name} for name in packages]}
30+
31+
with mock.patch("requests.get") as mock_get:
32+
mock_get.return_value.json.return_value = json_response
33+
34+
yield mock_get
35+
36+
2337
@pytest.fixture
2438
def requirements_txt_file(tmp_path: Path) -> Iterator[str]:
2539
requirements_txt_file = tmp_path / "requirements.txt"
@@ -187,7 +201,7 @@ def pyproject_toml_file(tmp_path: Path) -> Iterator[str]:
187201
188202
[tool.twyn]
189203
dependency_file="my_file.txt"
190-
selector_method="my_selector"
204+
selector_method="all"
191205
logging_level="debug"
192206
allowlist=["boto4", "boto2"]
193207

tests/core/test_config_handler.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dataclasses
22
from copy import deepcopy
3+
from pathlib import Path
34
from unittest.mock import patch
45

56
import pytest
@@ -9,6 +10,7 @@
910
from twyn.config.exceptions import (
1011
AllowlistPackageAlreadyExistsError,
1112
AllowlistPackageDoesNotExistError,
13+
InvalidSelectorMethodError,
1214
TOMLError,
1315
)
1416
from twyn.file_handler.exceptions import PathNotFoundError
@@ -46,7 +48,7 @@ def test_config_raises_for_unknown_file(self):
4648
def test_read_config_values(self, pyproject_toml_file):
4749
config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config()
4850
assert config.dependency_file == "my_file.txt"
49-
assert config.selector_method == "my_selector"
51+
assert config.selector_method == "all"
5052
assert config.logging_level == AvailableLoggingLevels.debug
5153
assert config.allowlist == {"boto4", "boto2"}
5254

@@ -57,7 +59,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file):
5759
twyn_data = ConfigHandler(FileHandler(pyproject_toml_file))._get_read_config(toml)
5860
assert twyn_data == ReadTwynConfiguration(
5961
dependency_file="my_file.txt",
60-
selector_method="my_selector",
62+
selector_method="all",
6163
logging_level="debug",
6264
allowlist={"boto4", "boto2"},
6365
pypi_reference=None,
@@ -95,7 +97,7 @@ def test_write_toml(self, pyproject_toml_file):
9597
},
9698
"twyn": {
9799
"dependency_file": "my_file.txt",
98-
"selector_method": "my_selector",
100+
"selector_method": "all",
99101
"logging_level": "debug",
100102
"allowlist": {},
101103
"pypi_reference": DEFAULT_TOP_PYPI_PACKAGES,
@@ -166,3 +168,46 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml, mock_write
166168
config.remove_package_from_allowlist("mypackage2")
167169

168170
assert not mock_write_toml.called
171+
172+
@pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"])
173+
def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path):
174+
"""Test that all valid selector methods are accepted."""
175+
pyproject_toml = tmp_path / "pyproject.toml"
176+
pyproject_toml.write_text("")
177+
config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False)
178+
179+
# Should not raise any exception
180+
resolved_config = config.resolve_config(selector_method=valid_selector)
181+
assert resolved_config.selector_method == valid_selector
182+
183+
def test_invalid_selector_method_rejected(self, tmp_path: Path):
184+
"""Test that invalid selector methods are rejected with appropriate error."""
185+
pyproject_toml = tmp_path / "pyproject.toml"
186+
pyproject_toml.write_text("")
187+
config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False)
188+
189+
with pytest.raises(InvalidSelectorMethodError) as exc_info:
190+
config.resolve_config(selector_method="random-selector")
191+
192+
error_message = str(exc_info.value)
193+
assert "Invalid selector_method 'random-selector'" in error_message
194+
assert "Must be one of: all, first-letter, nearby-letter" in error_message
195+
196+
def test_invalid_selector_method_from_config_file(self, tmp_path: Path):
197+
"""Test that invalid selector method from config file is rejected."""
198+
# Create a config file with invalid selector method
199+
pyproject_toml = tmp_path / "pyproject.toml"
200+
data = """
201+
[tool.twyn]
202+
selector_method="invalid-selector"
203+
"""
204+
pyproject_toml.write_text(data)
205+
206+
config = ConfigHandler(FileHandler(str(pyproject_toml)))
207+
208+
with pytest.raises(InvalidSelectorMethodError) as exc_info:
209+
config.resolve_config()
210+
211+
error_message = str(exc_info.value)
212+
assert "Invalid selector_method 'invalid-selector'" in error_message
213+
assert "Must be one of: all, first-letter, nearby-letter" in error_message

tests/main/test_cli.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from click.testing import CliRunner
44
from twyn import cli
55
from twyn.base.constants import AvailableLoggingLevels
6+
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult
67

78

89
class TestCli:
@@ -46,7 +47,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies):
4647
call(
4748
config_file="my-config",
4849
dependency_file="requirements.txt",
49-
dependencies_cli=None,
50+
dependencies=None,
5051
selector_method="first-letter",
5152
verbosity=AvailableLoggingLevels.debug,
5253
)
@@ -67,7 +68,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
6768
call(
6869
config_file=None,
6970
dependency_file="/path/requirements.txt",
70-
dependencies_cli=None,
71+
dependencies=None,
7172
selector_method=None,
7273
verbosity=AvailableLoggingLevels.none,
7374
)
@@ -87,7 +88,7 @@ def test_click_arguments_single_dependency_cli(self, mock_check_dependencies):
8788
call(
8889
config_file=None,
8990
dependency_file=None,
90-
dependencies_cli={"reqests"},
91+
dependencies={"reqests"},
9192
selector_method=None,
9293
verbosity=AvailableLoggingLevels.none,
9394
)
@@ -103,7 +104,7 @@ def test_click_raises_error_dependency_and_dependency_file_set(self):
103104
assert "Only one of --dependency or --dependency-file can be set at a time." in result.output
104105

105106
@patch("twyn.cli.check_dependencies")
106-
def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies):
107+
def test_click_arguments_multiple_dependencies(self, mock_check_dependencies):
107108
runner = CliRunner()
108109
runner.invoke(
109110
cli.run,
@@ -119,7 +120,7 @@ def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies
119120
call(
120121
config_file=None,
121122
dependency_file=None,
122-
dependencies_cli={"reqests", "reqeusts"},
123+
dependencies={"reqests", "reqeusts"},
123124
selector_method=None,
124125
verbosity=AvailableLoggingLevels.none,
125126
)
@@ -135,23 +136,25 @@ def test_click_arguments_default(self, mock_check_dependencies):
135136
config_file=None,
136137
dependency_file=None,
137138
selector_method=None,
138-
dependencies_cli=None,
139+
dependencies=None,
139140
verbosity=AvailableLoggingLevels.none,
140141
)
141142
]
142143

143144
@patch("twyn.cli.check_dependencies")
144145
def test_return_code_1(self, mock_check_dependencies):
145146
runner = CliRunner()
146-
mock_check_dependencies.return_value = True
147+
mock_check_dependencies.return_value = [
148+
TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])
149+
]
147150

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

151154
@patch("twyn.cli.check_dependencies")
152155
def test_return_code_0(self, mock_check_dependencies):
153156
runner = CliRunner()
154-
mock_check_dependencies.return_value = False
157+
mock_check_dependencies.return_value = []
155158

156159
result = runner.invoke(cli.run)
157160
assert result.exit_code == 0

0 commit comments

Comments
 (0)