Skip to content

Commit 01ebfc6

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

File tree

10 files changed

+131
-80
lines changed

10 files changed

+131
-80
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_files=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: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
class TwynConfiguration:
3333
"""Fully resolved configuration for Twyn."""
3434

35-
dependency_file: Optional[str]
35+
dependency_files: set[str]
3636
selector_method: str
3737
allowlist: set[str]
3838
source: Optional[str]
@@ -45,7 +45,7 @@ class TwynConfiguration:
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_files: 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_files=dependency_files or read_config.dependency_files or set(),
111111
selector_method=final_selector_method,
112112
allowlist=read_config.allowlist,
113113
source=read_config.source,
@@ -141,12 +141,27 @@ def remove_package_from_allowlist(self, package_name: str) -> None:
141141
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", {})
144+
145+
dependency_file = twyn_config_data.get("dependency_file", set())
146+
if isinstance(dependency_file, str):
147+
dependency_file = {dependency_file}
148+
elif isinstance(dependency_file, list):
149+
dependency_file = set(dependency_file)
150+
151+
allowlist = twyn_config_data.get("allowlist", set())
152+
if isinstance(allowlist, str):
153+
allowlist = {allowlist}
154+
elif isinstance(allowlist, list):
155+
allowlist = set(allowlist)
156+
144157
return ReadTwynConfiguration(
145-
dependency_file=twyn_config_data.get("dependency_file"),
158+
dependency_files=dependency_file,
146159
selector_method=twyn_config_data.get("selector_method"),
147-
allowlist=set(twyn_config_data.get("allowlist", set())),
160+
allowlist=allowlist,
148161
source=twyn_config_data.get("source"),
149162
use_cache=twyn_config_data.get("use_cache"),
163+
package_ecosystem=twyn_config_data.get("package_ecosystem"),
164+
recursive=twyn_config_data.get("recursive"),
150165
)
151166

152167
def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> None:
@@ -155,12 +170,12 @@ def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> No
155170
All null values are simply omitted from the toml file.
156171
"""
157172
twyn_toml_data = asdict(config, dict_factory=lambda x: _serialize_config(x))
173+
158174
if "tool" not in toml:
159175
toml.add("tool", table())
160176
if "twyn" not in toml["tool"]: # type: ignore[operator]
161177
toml["tool"]["twyn"] = {} # type: ignore[index]
162178
toml["tool"]["twyn"] = twyn_toml_data # type: ignore[index]
163-
164179
self._write_toml(toml)
165180

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

src/twyn/dependency_parser/dependency_selector.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313

1414
class DependencySelector:
15-
def __init__(self, dependency_file: Optional[str] = None, root_path: str = ".") -> None:
16-
self.dependency_file = dependency_file or ""
15+
def __init__(self, dependency_files: Optional[set[str]] = None, root_path: str = ".") -> None:
16+
self.dependency_files = dependency_files or set()
1717
self.root_path = root_path
1818

1919
def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
@@ -38,18 +38,18 @@ def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
3838

3939
def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:
4040
parsers = []
41-
for known_dependency_file_name in DEPENDENCY_FILE_MAPPING:
42-
if self.dependency_file.endswith(known_dependency_file_name):
43-
file_parser = DEPENDENCY_FILE_MAPPING[known_dependency_file_name](self.dependency_file)
44-
parsers.append(file_parser)
45-
41+
for dependency_file in self.dependency_files:
42+
for known_dependency_file_name in DEPENDENCY_FILE_MAPPING:
43+
if dependency_file.endswith(known_dependency_file_name):
44+
file_parser = DEPENDENCY_FILE_MAPPING[known_dependency_file_name](dependency_file)
45+
parsers.append(file_parser)
4646
if not parsers:
4747
raise NoMatchingParserError
4848

4949
return parsers
5050

5151
def get_dependency_parsers(self) -> list[AbstractParser]:
52-
if self.dependency_file:
52+
if self.dependency_files:
5353
logger.debug("Dependency file provided. Assigning a parser.")
5454
return self.get_dependency_file_parsers_from_file_name()
5555

src/twyn/main.py

Lines changed: 11 additions & 11 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_files: 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_files,
7272
use_cache=use_cache,
7373
package_ecosystem=package_ecosystem,
7474
recursive=recursive,
@@ -93,7 +93,7 @@ def check_dependencies(
9393
if config.package_ecosystem:
9494
logger.warning("`package_ecosystem` is not supported when reading dependencies from files. It will be ignored.")
9595

96-
if config.dependency_file and config.recursive:
96+
if config.dependency_files and config.recursive:
9797
logger.warning(
9898
"`--recursive` has been set together with `--dependency-file`. `--dependency-file` will take precedence."
9999
)
@@ -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_files,
108108
)
109109

110110

@@ -153,16 +153,17 @@ 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)
165164
typos_by_file = TyposquatCheckResults()
165+
166+
dependency_managers = _get_dependency_managers_and_parsers_mapping(dependency_files)
166167
for dependency_manager, parsers in dependency_managers.items():
167168
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
168169

@@ -174,7 +175,6 @@ def _analyze_packages_from_source(
174175
threshold_class=SimilarityThreshold,
175176
)
176177
results: list[TyposquatCheckResultFromSource] = []
177-
178178
for parser in parsers:
179179
analyzed_dependencies = _analyze_dependencies(
180180
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path
@@ -252,13 +252,13 @@ def _get_selector_method(selector_method: str) -> SelectorMethod:
252252

253253

254254
def _get_dependency_managers_and_parsers_mapping(
255-
dependency_file: Optional[str],
255+
dependency_files: Optional[set[str]],
256256
) -> dict[type[BaseDependencyManager], list[AbstractParser]]:
257257
"""Return a dictionary, grouping all files to parse by their DependencyManager."""
258258
dependency_managers: dict[type[BaseDependencyManager], list[AbstractParser]] = {}
259259

260260
# No dependencies introduced via the CLI, so the dependecy file was either given or will be auto-detected
261-
dependency_selector = DependencySelector(dependency_file)
261+
dependency_selector = DependencySelector(dependency_files)
262262
dependency_parsers = dependency_selector.get_dependency_parsers()
263263

264264
for parser in dependency_parsers:
@@ -274,7 +274,7 @@ def _get_config(
274274
load_config_from_file: bool,
275275
config_file: Optional[str],
276276
selector_method: Union[SelectorMethod, None],
277-
dependency_file: Optional[str],
277+
dependency_files: Optional[set[str]],
278278
use_cache: Optional[bool],
279279
package_ecosystem: Optional[PackageEcosystems],
280280
recursive: Optional[bool],
@@ -286,7 +286,7 @@ def _get_config(
286286
config_file_handler = None
287287
return ConfigHandler(config_file_handler).resolve_config(
288288
selector_method=selector_method,
289-
dependency_file=dependency_file,
289+
dependency_files=dependency_files,
290290
use_cache=use_cache,
291291
package_ecosystem=package_ecosystem,
292292
recursive=recursive,

tests/config/test_config_handler.py

Lines changed: 18 additions & 12 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_files=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_files == {"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_files={"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=set(), dependency_files=set())
7975

8076
handler._write_config(toml, to_write)
8177

@@ -100,9 +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",
10499
"selector_method": "all",
105-
"allowlist": {},
106100
"use_cache": False,
107101
"recursive": False,
108102
},
@@ -141,7 +135,7 @@ def test_no_load_config_from_cache(self, pyproject_toml_file: Path) -> None:
141135
config = ConfigHandler().resolve_config()
142136

143137
assert config.allowlist == set()
144-
assert config.dependency_file is None
138+
assert config.dependency_files == set()
145139
assert config.use_cache is True
146140
assert config.selector_method == DEFAULT_SELECTOR_METHOD
147141
assert config.source is None
@@ -260,3 +254,15 @@ def test_invalid_selector_method_from_config_file(self, tmp_path: Path) -> None:
260254
error_message = str(exc_info.value)
261255
assert "Invalid selector_method 'invalid-selector'" in error_message
262256
assert "Must be one of: all, first-letter, nearby-letter" in error_message
257+
258+
def test_load_single_dependency_file(self, tmp_path: Path) -> None:
259+
pyproject_toml = tmp_path / "pyproject.toml"
260+
data = """
261+
[tool.twyn]
262+
dependency_file="requirements.txt"
263+
"""
264+
pyproject_toml.write_text(data)
265+
266+
config = ConfigHandler(FileHandler(str(pyproject_toml))).resolve_config()
267+
268+
assert config.dependency_files == {"requirements.txt"}

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/dependency_parser/test_dependency_selector.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ class TestDependencySelector:
2828
("/some/path/package-lock.json", PackageLockJsonParser),
2929
],
3030
)
31-
def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]):
32-
parser = DependencySelector(file_name).get_dependency_parsers()
31+
def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]) -> None:
32+
parser = DependencySelector({file_name}).get_dependency_parsers()
3333
assert len(parser) == 1
3434

3535
assert isinstance(parser[0], parser_class)
3636
assert str(parser[0].file_handler.file_path).endswith(file_name)
3737

38-
def test_get_dependency_parser_auto_detect_requirements_file(self, requirements_txt_file: Path, tmp_path: Path):
38+
def test_get_dependency_parser_auto_detect_requirements_file(
39+
self, requirements_txt_file: Path, tmp_path: Path
40+
) -> None:
3941
parser = DependencySelector("", root_path=str(tmp_path)).get_dependency_parsers()
4042
assert isinstance(parser[0], RequirementsTxtParser)
4143

tests/main/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
@pytest.fixture(scope="module")
99
def disable_track() -> Generator[None, Any, None]:
1010
"""Disables the track UI for running tests."""
11-
with patch("rich.progress.track") as m_track:
11+
with patch("rich.progress.track") as m_track, patch("click.echo"):
1212
m_track.side_effect = lambda iterable, description: iterable
1313
yield

0 commit comments

Comments
 (0)