Skip to content

Commit cca5a0e

Browse files
authored
feat: allow searching dependencies from the command line (#152)
1 parent 94d1d08 commit cca5a0e

File tree

5 files changed

+136
-40
lines changed

5 files changed

+136
-40
lines changed

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ test-all: venv
4242

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

4747
# Lint all code in the project.
4848
lint: venv

src/twyn/cli.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
from typing import Optional
23

34
import click
45

@@ -28,6 +29,12 @@ def entry_point() -> None:
2829
"for supported files, but this option will override that behavior."
2930
),
3031
)
32+
@click.option(
33+
"--dependency",
34+
type=str,
35+
multiple=True,
36+
help="Dependency to analyze. Cannot be set together with --dependency-file. If provided, it will take precedence over the default dependency file.",
37+
)
3138
@click.option(
3239
"--selector-method",
3340
type=click.Choice(list(SELECTOR_METHOD_MAPPING.keys())),
@@ -51,15 +58,14 @@ def entry_point() -> None:
5158
)
5259
def run(
5360
config: str,
54-
dependency_file: str,
61+
dependency_file: Optional[str],
62+
dependency: tuple[str],
5563
selector_method: str,
5664
v: bool,
5765
vv: bool,
5866
) -> int:
5967
if v and vv:
60-
raise ValueError(
61-
"Only one verbosity level is allowed. Choose either -v or -vv."
62-
)
68+
raise ValueError("Only one verbosity level is allowed. Choose either -v or -vv.")
6369

6470
if v:
6571
verbosity = AvailableLoggingLevels.info
@@ -68,10 +74,14 @@ def run(
6874
else:
6975
verbosity = AvailableLoggingLevels.none
7076

77+
if dependency and dependency_file:
78+
raise ValueError("Only one of --dependency or --dependency-file can be set at a time.")
79+
7180
return int(
7281
check_dependencies(
7382
config_file=config,
7483
dependency_file=dependency_file,
84+
dependencies_cli=set(dependency) or None,
7585
selector_method=selector_method,
7686
verbosity=verbosity,
7787
)

src/twyn/main.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,22 @@
3030

3131
def check_dependencies(
3232
config_file: Optional[str],
33-
dependency_file: str,
33+
dependency_file: Optional[str],
34+
dependencies_cli: Optional[set[str]],
3435
selector_method: str,
3536
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
3637
) -> bool:
3738
"""Check if dependencies could be typosquats."""
3839
config = get_configuration(config_file, dependency_file, selector_method, verbosity)
40+
3941
trusted_packages = TrustedPackages(
4042
names=TopPyPiReference().get_packages(),
4143
algorithm=EditDistance(),
4244
selector=get_candidate_selector(config.selector_method),
4345
threshold_class=SimilarityThreshold,
4446
)
4547
normalized_allowlist_packages = normalize_packages(config.allowlist)
46-
dependencies = get_parsed_dependencies(config.dependency_file)
48+
dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file)
4749
normalized_dependencies = normalize_packages(dependencies)
4850

4951
errors: list[TyposquatCheckResult] = []
@@ -53,9 +55,7 @@ def check_dependencies(
5355
continue
5456

5557
logger.info(f"Analyzing {dependency}")
56-
if dependency not in trusted_packages and (
57-
typosquat_results := trusted_packages.get_typosquat(dependency)
58-
):
58+
if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)):
5959
errors.append(typosquat_results)
6060

6161
for possible_typosquats in errors:
@@ -69,8 +69,8 @@ def check_dependencies(
6969

7070
def get_configuration(
7171
config_file: Optional[str],
72-
dependency_file: str,
73-
selector_method: str,
72+
dependency_file: Optional[str],
73+
selector_method: Optional[str],
7474
verbosity: AvailableLoggingLevels,
7575
) -> ConfigHandler:
7676
"""Read configuration and return configuration object.
@@ -80,18 +80,14 @@ def get_configuration(
8080
# Read config from file
8181
config = ConfigHandler(file_path=config_file, enforce_file=False)
8282

83-
# Set logging level according to priority order
84-
logging_level: AvailableLoggingLevels = get_logging_level(
83+
# Set logging level
84+
config.logging_level = get_logging_level(
8585
logging_level=verbosity,
8686
config_logging_level=config.logging_level,
8787
)
88-
set_logging_level(logging_level)
89-
config.logging_level = logging_level.value
90-
88+
set_logging_level(config.logging_level)
9189
# Set selector method according to priority order
92-
config.selector_method = (
93-
selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD
94-
)
90+
config.selector_method = selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD
9591

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

128124

129-
def get_parsed_dependencies(dependency_file: Optional[str] = None) -> set[str]:
125+
def get_parsed_dependencies_from_file(dependency_file: Optional[str] = None) -> set[str]:
130126
dependency_parser = DependencySelector(dependency_file).get_dependency_parser()
131127
dependencies = dependency_parser.parse()
132128
logger.debug("Successfully parsed local dependencies file.")

tests/main/test_cli.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_allowlist_remove(self, mock_allowlist_add):
2828
assert mock_allowlist_add.call_args == call("requests")
2929

3030
@patch("twyn.cli.check_dependencies")
31-
def test_click_arguments(self, mock_check_dependencies):
31+
def test_click_arguments_dependency_file(self, mock_check_dependencies):
3232
runner = CliRunner()
3333
runner.invoke(
3434
cli.run,
@@ -47,11 +47,65 @@ def test_click_arguments(self, mock_check_dependencies):
4747
call(
4848
config_file="my-config",
4949
dependency_file="requirements.txt",
50+
dependencies_cli=None,
5051
selector_method="first-letter",
5152
verbosity=AvailableLoggingLevels.debug,
5253
)
5354
]
5455

56+
@patch("twyn.cli.check_dependencies")
57+
def test_click_arguments_single_dependency_cli(self, mock_check_dependencies):
58+
runner = CliRunner()
59+
runner.invoke(
60+
cli.run,
61+
[
62+
"--dependency",
63+
"reqests",
64+
],
65+
)
66+
assert mock_check_dependencies.call_args_list == [
67+
call(
68+
config_file=None,
69+
dependency_file=None,
70+
dependencies_cli={"reqests"},
71+
selector_method=None,
72+
verbosity=AvailableLoggingLevels.none,
73+
)
74+
]
75+
76+
def test_click_raises_error_dependency_and_dependency_file_set(self):
77+
runner = CliRunner()
78+
result = runner.invoke(
79+
cli.run,
80+
["--dependency", "requests", "--dependency-file", "requirements.txt"],
81+
)
82+
with pytest.raises(ValueError, match="Only one of --dependency or --dependency-file can be set at a time."):
83+
raise result.exception
84+
assert result.exit_code == 1
85+
86+
@patch("twyn.cli.check_dependencies")
87+
def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies):
88+
runner = CliRunner()
89+
runner.invoke(
90+
cli.run,
91+
[
92+
"--dependency",
93+
"reqests",
94+
"--dependency",
95+
"reqeusts",
96+
],
97+
)
98+
99+
assert mock_check_dependencies.call_args_list == [
100+
call(
101+
config_file=None,
102+
dependency_file=None,
103+
dependencies_cli={"reqests", "reqeusts"},
104+
selector_method=None,
105+
verbosity=AvailableLoggingLevels.none,
106+
)
107+
]
108+
55109
@patch("twyn.cli.check_dependencies")
56110
def test_click_arguments_default(self, mock_check_dependencies):
57111
runner = CliRunner()
@@ -62,6 +116,7 @@ def test_click_arguments_default(self, mock_check_dependencies):
62116
config_file=None,
63117
dependency_file=None,
64118
selector_method=None,
119+
dependencies_cli=None,
65120
verbosity=AvailableLoggingLevels.none,
66121
)
67122
]

tests/main/test_main.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
check_dependencies,
88
get_configuration,
99
get_logging_level,
10-
get_parsed_dependencies,
10+
get_parsed_dependencies_from_file,
1111
)
1212

1313

@@ -129,16 +129,17 @@ def test_logging_level(self, passed_logging_level, config, logging_level):
129129
),
130130
)
131131
@patch("twyn.main.TopPyPiReference")
132-
@patch("twyn.main.get_parsed_dependencies")
132+
@patch("twyn.main.get_parsed_dependencies_from_file")
133133
def test_check_dependencies_detects_typosquats(
134-
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
134+
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
135135
):
136136
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}
137-
mock_get_parsed_dependencies.return_value = {package_name}
137+
mock_get_parsed_dependencies_from_file.return_value = {package_name}
138138

139139
error = check_dependencies(
140140
config_file=None,
141141
dependency_file=None,
142+
dependencies_cli=None,
142143
selector_method="first-letter",
143144
)
144145

@@ -154,52 +155,86 @@ def test_check_dependencies_detects_typosquats(
154155
),
155156
)
156157
@patch("twyn.main.TopPyPiReference")
157-
@patch("twyn.main.get_parsed_dependencies")
158+
def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_top_pypi_reference, package_name):
159+
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}
160+
161+
error = check_dependencies(
162+
config_file=None,
163+
dependency_file=None,
164+
dependencies_cli={package_name},
165+
selector_method="first-letter",
166+
)
167+
168+
assert error is True
169+
170+
@patch("twyn.main.TopPyPiReference")
171+
def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(self, mock_top_pypi_reference):
172+
mock_top_pypi_reference.return_value.get_packages.return_value = {"requests", "mypackage"}
173+
174+
error = check_dependencies(
175+
config_file=None,
176+
dependency_file=None,
177+
dependencies_cli={"my-package", "requests"},
178+
selector_method="first-letter",
179+
)
180+
181+
assert error is True
182+
183+
@pytest.mark.parametrize(
184+
"package_name",
185+
(
186+
"my.package",
187+
"my-package",
188+
"my_package",
189+
"My.Package",
190+
),
191+
)
192+
@patch("twyn.main.TopPyPiReference")
193+
@patch("twyn.main.get_parsed_dependencies_from_file")
158194
def test_check_dependencies_ignores_package_in_allowlist(
159-
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
195+
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
160196
):
161197
mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"}
162-
mock_get_parsed_dependencies.return_value = {package_name}
198+
mock_get_parsed_dependencies_from_file.return_value = {package_name}
163199

164200
m_config = Mock(
165201
allowlist={package_name},
166202
dependency_file=None,
203+
dependencies_cli=None,
167204
selector_method="first-letter",
168205
)
169206

170207
with patch("twyn.main.get_configuration", return_value=m_config):
171208
error = check_dependencies(
172209
config_file=None,
173210
dependency_file=None,
211+
dependencies_cli=None,
174212
selector_method="first-letter",
175213
)
176214

177215
assert error is False
178216

179-
@pytest.mark.parametrize(
180-
"package_name", ("my.package", "my-package", "my_package", "My.Package")
181-
)
217+
@pytest.mark.parametrize("package_name", ("my.package", "my-package", "my_package", "My.Package"))
182218
@patch("twyn.main.TopPyPiReference")
183-
@patch("twyn.main.get_parsed_dependencies")
219+
@patch("twyn.main.get_parsed_dependencies_from_file")
184220
def test_check_dependencies_does_not_error_on_same_package(
185-
self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name
221+
self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name
186222
):
187223
mock_top_pypi_reference.return_value.get_packages.return_value = {"my-package"}
188-
mock_get_parsed_dependencies.return_value = {package_name}
224+
mock_get_parsed_dependencies_from_file.return_value = {package_name}
189225

190226
error = check_dependencies(
191227
config_file=None,
192228
dependency_file=None,
229+
dependencies_cli=None,
193230
selector_method="first-letter",
194231
)
195232

196233
assert error is False
197234

198-
@patch(
199-
"twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser"
200-
)
235+
@patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser")
201236
@patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.parse")
202-
def test_get_parsed_dependencies(self, mock_parse, mock_get_dependency_parser):
237+
def test_get_parsed_dependencies_from_file(self, mock_parse, mock_get_dependency_parser):
203238
mock_get_dependency_parser.return_value = RequirementsTxtParser()
204239
mock_parse.return_value = {"boto3"}
205-
assert get_parsed_dependencies() == {"boto3"}
240+
assert get_parsed_dependencies_from_file() == {"boto3"}

0 commit comments

Comments
 (0)