Skip to content

Commit 2d0d3cf

Browse files
committed
feat: support multiple dependency files in config and cli
1 parent 809b7f1 commit 2d0d3cf

File tree

8 files changed

+77
-76
lines changed

8 files changed

+77
-76
lines changed

src/twyn/cli.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def entry_point() -> None:
4747
@click.option(
4848
"--dependency-file",
4949
type=str,
50+
multiple=True,
5051
help=(
5152
"Dependency file to analyze. By default, twyn will search in the current directory "
5253
"for supported files, but this option will override that behavior."
@@ -112,7 +113,7 @@ def entry_point() -> None:
112113
)
113114
def run( # noqa: C901
114115
config: str,
115-
dependency_file: Optional[str],
116+
dependency_file: tuple[str],
116117
dependency: tuple[str],
117118
selector_method: str,
118119
v: bool,
@@ -133,15 +134,16 @@ def run( # noqa: C901
133134
"Only one of --dependency or --dependency-file can be set at a time.", ctx=click.get_current_context()
134135
)
135136

136-
if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
137-
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())
137+
for dep_file in dependency_file:
138+
if dep_file and not any(dep_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
139+
raise click.UsageError(f"Dependency file name {dep_file} not supported.", ctx=click.get_current_context())
138140

139141
try:
140142
possible_typos = check_dependencies(
141143
selector_method=selector_method,
142144
dependencies=set(dependency) or None,
143145
config_file=config,
144-
dependency_file=dependency_file,
146+
dependency_file=set(dependency_file) or None,
145147
use_cache=not no_cache if no_cache is not None else no_cache,
146148
show_progress_bar=False if json else not no_track,
147149
load_config_from_file=True,

src/twyn/config/config_handler.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,20 @@
3232
class TwynConfiguration:
3333
"""Fully resolved configuration for Twyn."""
3434

35-
dependency_file: Optional[str]
3635
selector_method: str
3736
allowlist: set[str]
3837
source: Optional[str]
3938
use_cache: bool
4039
package_ecosystem: Optional[PackageEcosystems]
4140
recursive: Optional[bool]
41+
dependency_file: set[str]
4242

4343

4444
@dataclass
4545
class ReadTwynConfiguration:
4646
"""Configuration for twyn as set by the user. It may have None values."""
4747

48-
dependency_file: Optional[str] = None
48+
dependency_file: Optional[set[str]] = field(default_factory=set)
4949
selector_method: Optional[str] = None
5050
allowlist: set[str] = field(default_factory=set)
5151
source: Optional[str] = None
@@ -63,7 +63,7 @@ def __init__(self, file_handler: Optional[FileHandler] = None) -> None:
6363
def resolve_config(
6464
self,
6565
selector_method: Optional[str] = None,
66-
dependency_file: Optional[str] = None,
66+
dependency_files: Optional[set[str]] = None,
6767
use_cache: Optional[bool] = None,
6868
package_ecosystem: Optional[PackageEcosystems] = None,
6969
recursive: Optional[bool] = None,
@@ -107,7 +107,7 @@ def resolve_config(
107107
final_recursive = DEFAULT_RECURSIVE
108108

109109
return TwynConfiguration(
110-
dependency_file=dependency_file or read_config.dependency_file,
110+
dependency_file=dependency_files or read_config.dependency_file or set(),
111111
selector_method=final_selector_method,
112112
allowlist=read_config.allowlist,
113113
source=read_config.source,
@@ -142,11 +142,13 @@ def _get_read_config(self, toml: TOMLDocument) -> ReadTwynConfiguration:
142142
"""Read the twyn configuration from a provided toml document."""
143143
twyn_config_data = toml.get("tool", {}).get("twyn", {})
144144
return ReadTwynConfiguration(
145-
dependency_file=twyn_config_data.get("dependency_file"),
145+
dependency_file=set(twyn_config_data.get("dependency_file", set())),
146146
selector_method=twyn_config_data.get("selector_method"),
147147
allowlist=set(twyn_config_data.get("allowlist", set())),
148148
source=twyn_config_data.get("source"),
149149
use_cache=twyn_config_data.get("use_cache"),
150+
package_ecosystem=twyn_config_data.get("package_ecosystem"),
151+
recursive=twyn_config_data.get("recursive"),
150152
)
151153

152154
def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> None:
@@ -155,12 +157,12 @@ def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> No
155157
All null values are simply omitted from the toml file.
156158
"""
157159
twyn_toml_data = asdict(config, dict_factory=lambda x: _serialize_config(x))
160+
158161
if "tool" not in toml:
159162
toml.add("tool", table())
160163
if "twyn" not in toml["tool"]: # type: ignore[operator]
161164
toml["tool"]["twyn"] = {} # type: ignore[index]
162165
toml["tool"]["twyn"] = twyn_toml_data # type: ignore[index]
163-
164166
self._write_toml(toml)
165167

166168
def _write_toml(self, toml: TOMLDocument) -> None:

src/twyn/dependency_parser/dependency_selector.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:
4242
if self.dependency_file.endswith(known_dependency_file_name):
4343
file_parser = DEPENDENCY_FILE_MAPPING[known_dependency_file_name](self.dependency_file)
4444
parsers.append(file_parser)
45-
4645
if not parsers:
4746
raise NoMatchingParserError
4847

src/twyn/main.py

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
def check_dependencies(
3838
selector_method: Union[SelectorMethod, None] = None,
3939
config_file: Optional[str] = None,
40-
dependency_file: Optional[str] = None,
40+
dependency_file: Optional[set[str]] = None,
4141
dependencies: Optional[set[str]] = None,
4242
use_cache: Optional[bool] = True,
4343
show_progress_bar: bool = False,
@@ -68,7 +68,7 @@ def check_dependencies(
6868
load_config_from_file=load_config_from_file,
6969
config_file=config_file,
7070
selector_method=selector_method,
71-
dependency_file=dependency_file,
71+
dependency_files=dependency_file,
7272
use_cache=use_cache,
7373
package_ecosystem=package_ecosystem,
7474
recursive=recursive,
@@ -104,7 +104,7 @@ def check_dependencies(
104104
maybe_cache_handler=maybe_cache_handler,
105105
allowlist=config.allowlist,
106106
show_progress_bar=show_progress_bar,
107-
dependency_file=config.dependency_file,
107+
dependency_files=config.dependency_file,
108108
)
109109

110110

@@ -153,38 +153,39 @@ def _analyze_packages_from_source(
153153
allowlist: set[str],
154154
selector_method: SelectorMethod,
155155
show_progress_bar: bool,
156-
dependency_file: Optional[str],
156+
dependency_files: Optional[set[str]],
157157
source: Optional[str],
158158
maybe_cache_handler: Optional[CacheHandler],
159159
) -> TyposquatCheckResults:
160160
"""Analyze dependencies from a dependencies file.
161161
162162
It will return a list of the possible typos grouped by source, each source being a dependency file.
163163
"""
164-
dependency_managers = _get_dependency_managers_and_parsers_mapping(dependency_file)
165-
typos_by_file = TyposquatCheckResults()
166-
for dependency_manager, parsers in dependency_managers.items():
167-
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
168-
169-
packages_from_source = top_package_reference.get_packages()
170-
trusted_packages = TrustedPackages(
171-
names=packages_from_source,
172-
algorithm=EditDistance(),
173-
selector=selector_method,
174-
threshold_class=SimilarityThreshold,
175-
)
176-
results: list[TyposquatCheckResultFromSource] = []
177-
178-
for parser in parsers:
179-
analyzed_dependencies = _analyze_dependencies(
180-
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path
164+
dependency_files = dependency_files or {""}
165+
for dep_file in dependency_files:
166+
dependency_managers = _get_dependency_managers_and_parsers_mapping(dep_file)
167+
typos_by_file = TyposquatCheckResults()
168+
for dependency_manager, parsers in dependency_managers.items():
169+
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
170+
171+
packages_from_source = top_package_reference.get_packages()
172+
trusted_packages = TrustedPackages(
173+
names=packages_from_source,
174+
algorithm=EditDistance(),
175+
selector=selector_method,
176+
threshold_class=SimilarityThreshold,
181177
)
182-
183-
if analyzed_dependencies:
184-
results.append(
185-
TyposquatCheckResultFromSource(source=str(parser.file_path), errors=analyzed_dependencies)
178+
results: list[TyposquatCheckResultFromSource] = []
179+
for parser in parsers:
180+
analyzed_dependencies = _analyze_dependencies(
181+
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar
186182
)
187-
typos_by_file.results += results
183+
184+
if analyzed_dependencies:
185+
results.append(
186+
TyposquatCheckResultFromSource(source=str(parser.file_path), errors=analyzed_dependencies)
187+
)
188+
typos_by_file.results += results
188189

189190
return typos_by_file
190191

@@ -258,6 +259,7 @@ def _get_dependency_managers_and_parsers_mapping(
258259
dependency_managers: dict[type[BaseDependencyManager], list[AbstractParser]] = {}
259260

260261
# No dependencies introduced via the CLI, so the dependecy file was either given or will be auto-detected
262+
261263
dependency_selector = DependencySelector(dependency_file)
262264
dependency_parsers = dependency_selector.get_dependency_parsers()
263265

@@ -274,7 +276,7 @@ def _get_config(
274276
load_config_from_file: bool,
275277
config_file: Optional[str],
276278
selector_method: Union[SelectorMethod, None],
277-
dependency_file: Optional[str],
279+
dependency_files: Optional[set[str]],
278280
use_cache: Optional[bool],
279281
package_ecosystem: Optional[PackageEcosystems],
280282
recursive: Optional[bool],
@@ -286,7 +288,7 @@ def _get_config(
286288
config_file_handler = None
287289
return ConfigHandler(config_file_handler).resolve_config(
288290
selector_method=selector_method,
289-
dependency_file=dependency_file,
291+
dependency_files=dependency_files,
290292
use_cache=use_cache,
291293
package_ecosystem=package_ecosystem,
292294
recursive=recursive,

tests/config/test_config_handler.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import dataclasses
22
from copy import deepcopy
33
from pathlib import Path
4-
from typing import NoReturn
54
from unittest.mock import Mock, patch
65

76
import pytest
@@ -26,17 +25,14 @@
2625

2726

2827
class TestConfigHandler:
29-
def throw_exception(self) -> NoReturn:
30-
raise PathNotFoundError
31-
3228
@patch("twyn.file_handler.file_handler.FileHandler.read")
3329
def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None:
3430
"""Resolving the config without enforcing the file to be present gives you defaults."""
35-
mock_is_file.side_effect = self.throw_exception
31+
mock_is_file.side_effect = PathNotFoundError()
3632
config = ConfigHandler(FileHandler(DEFAULT_PROJECT_TOML_FILE)).resolve_config()
3733

3834
assert config == TwynConfiguration(
39-
dependency_file=None,
35+
dependency_file=set(),
4036
selector_method="all",
4137
allowlist=set(),
4238
source=None,
@@ -51,7 +47,7 @@ def test_config_raises_for_unknown_file(self) -> None:
5147

5248
def test_read_config_values(self, pyproject_toml_file: Path) -> None:
5349
config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config()
54-
assert config.dependency_file == "my_file.txt"
50+
assert config.dependency_file == {"my_file.txt", "my_other_file.txt"}
5551
assert config.selector_method == "all"
5652
assert config.allowlist == {"boto4", "boto2"}
5753
assert config.use_cache is False
@@ -62,7 +58,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
6258
toml = handler._read_toml()
6359
twyn_data = ConfigHandler(FileHandler(pyproject_toml_file))._get_read_config(toml)
6460
assert twyn_data == ReadTwynConfiguration(
65-
dependency_file="my_file.txt",
61+
dependency_file={"my_file.txt", "my_other_file.txt"},
6662
selector_method="all",
6763
allowlist={"boto4", "boto2"},
6864
source=None,
@@ -75,7 +71,7 @@ def test_write_toml(self, pyproject_toml_file: Path) -> None:
7571

7672
initial_config = handler.resolve_config()
7773
to_write = deepcopy(initial_config)
78-
to_write = dataclasses.replace(to_write, allowlist={})
74+
to_write = dataclasses.replace(to_write, allowlist={}, dependency_file={})
7975

8076
handler._write_config(toml, to_write)
8177

@@ -100,7 +96,7 @@ def test_write_toml(self, pyproject_toml_file: Path) -> None:
10096
"scripts": {"twyn": "twyn.cli:entry_point"},
10197
},
10298
"twyn": {
103-
"dependency_file": "my_file.txt",
99+
"dependency_file": {},
104100
"selector_method": "all",
105101
"allowlist": {},
106102
"use_cache": False,
@@ -141,7 +137,7 @@ def test_no_load_config_from_cache(self, pyproject_toml_file: Path) -> None:
141137
config = ConfigHandler().resolve_config()
142138

143139
assert config.allowlist == set()
144-
assert config.dependency_file is None
140+
assert config.dependency_file == set()
145141
assert config.use_cache is True
146142
assert config.selector_method == DEFAULT_SELECTOR_METHOD
147143
assert config.source is None

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def pyproject_toml_file(tmp_path: Path) -> Iterator[Path]:
237237
twyn = "twyn.cli:entry_point"
238238
239239
[tool.twyn]
240-
dependency_file="my_file.txt"
240+
dependency_file=["my_file.txt", "my_other_file.txt"]
241241
selector_method="all"
242242
logging_level="debug"
243243
allowlist=["boto4", "boto2"]

tests/main/test_cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) ->
8888
assert mock_check_dependencies.call_args_list == [
8989
call(
9090
config_file="my-config",
91-
dependency_file="requirements.txt",
91+
dependency_file={"requirements.txt"},
9292
dependencies=None,
9393
selector_method="first-letter",
9494
use_cache=None,
@@ -113,7 +113,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
113113
assert mock_check_dependencies.call_args_list == [
114114
call(
115115
config_file=None,
116-
dependency_file="/path/requirements.txt",
116+
dependency_file={"/path/requirements.txt"},
117117
dependencies=None,
118118
selector_method=None,
119119
use_cache=None,
@@ -328,7 +328,7 @@ def test_dependency_file_name_has_to_be_recognized(self) -> None:
328328

329329
assert isinstance(result.exception, SystemExit)
330330
assert result.exit_code == 2
331-
assert "Dependency file name not supported." in result.output
331+
assert "Dependency file name requirements-dev.txt not supported." in result.output
332332

333333
@patch("twyn.cli.check_dependencies")
334334
def test_custom_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):

0 commit comments

Comments
 (0)