Skip to content

Commit 8bb09b0

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

File tree

9 files changed

+156
-79
lines changed

9 files changed

+156
-79
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: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from enum import Enum
44
from pathlib import Path
55
from typing import Any, Optional, Union
6+
from warnings import warn
67

78
from tomlkit import TOMLDocument, dumps, load, table
89

@@ -32,7 +33,7 @@
3233
class TwynConfiguration:
3334
"""Fully resolved configuration for Twyn."""
3435

35-
dependency_file: Optional[str]
36+
dependency_files: set[str]
3637
selector_method: str
3738
allowlist: set[str]
3839
source: Optional[str]
@@ -45,7 +46,7 @@ class TwynConfiguration:
4546
class ReadTwynConfiguration:
4647
"""Configuration for twyn as set by the user. It may have None values."""
4748

48-
dependency_file: Optional[str] = None
49+
dependency_files: Optional[set[str]] = field(default_factory=set)
4950
selector_method: Optional[str] = None
5051
allowlist: set[str] = field(default_factory=set)
5152
source: Optional[str] = None
@@ -63,7 +64,7 @@ def __init__(self, file_handler: Optional[FileHandler] = None) -> None:
6364
def resolve_config(
6465
self,
6566
selector_method: Optional[str] = None,
66-
dependency_file: Optional[str] = None,
67+
dependency_files: Optional[set[str]] = None,
6768
use_cache: Optional[bool] = None,
6869
package_ecosystem: Optional[PackageEcosystems] = None,
6970
recursive: Optional[bool] = None,
@@ -107,7 +108,7 @@ def resolve_config(
107108
final_recursive = DEFAULT_RECURSIVE
108109

109110
return TwynConfiguration(
110-
dependency_file=dependency_file or read_config.dependency_file,
111+
dependency_files=dependency_files or read_config.dependency_files or set(),
111112
selector_method=final_selector_method,
112113
allowlist=read_config.allowlist,
113114
source=read_config.source,
@@ -141,12 +142,39 @@ def remove_package_from_allowlist(self, package_name: str) -> None:
141142
def _get_read_config(self, toml: TOMLDocument) -> ReadTwynConfiguration:
142143
"""Read the twyn configuration from a provided toml document."""
143144
twyn_config_data = toml.get("tool", {}).get("twyn", {})
145+
dependency_file = twyn_config_data.get("dependency_file", set())
146+
dependency_files = twyn_config_data.get("dependency_files", set())
147+
148+
if dependency_file:
149+
if dependency_files:
150+
raise TOMLError(
151+
"Found both `dependency_file` and `dependency_files` in your config file. Please, use only `dependency_files`."
152+
)
153+
154+
warn(
155+
"Using `dependency_file` in your twyn configuration is deprecated, this field will be removed in an upcoming release. Use `dependency_files` instead.",
156+
DeprecationWarning,
157+
stacklevel=2,
158+
)
159+
160+
dependency_files = {dependency_file}
161+
elif dependency_files:
162+
dependency_files = set(dependency_files)
163+
164+
allowlist = twyn_config_data.get("allowlist", set())
165+
if isinstance(allowlist, str):
166+
allowlist = {allowlist}
167+
elif isinstance(allowlist, list):
168+
allowlist = set(allowlist)
169+
144170
return ReadTwynConfiguration(
145-
dependency_file=twyn_config_data.get("dependency_file"),
171+
dependency_files=dependency_files,
146172
selector_method=twyn_config_data.get("selector_method"),
147-
allowlist=set(twyn_config_data.get("allowlist", set())),
173+
allowlist=allowlist,
148174
source=twyn_config_data.get("source"),
149175
use_cache=twyn_config_data.get("use_cache"),
176+
package_ecosystem=twyn_config_data.get("package_ecosystem"),
177+
recursive=twyn_config_data.get("recursive"),
150178
)
151179

152180
def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> None:
@@ -155,12 +183,12 @@ def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> No
155183
All null values are simply omitted from the toml file.
156184
"""
157185
twyn_toml_data = asdict(config, dict_factory=lambda x: _serialize_config(x))
186+
158187
if "tool" not in toml:
159188
toml.add("tool", table())
160189
if "twyn" not in toml["tool"]: # type: ignore[operator]
161190
toml["tool"]["twyn"] = {} # type: ignore[index]
162191
toml["tool"]["twyn"] = twyn_toml_data # type: ignore[index]
163-
164192
self._write_toml(toml)
165193

166194
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: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@
3232

3333
logger = logging.getLogger("twyn")
3434
logger.addHandler(logging.NullHandler())
35+
logging.captureWarnings(True)
3536

3637

3738
def check_dependencies(
3839
selector_method: Union[SelectorMethod, None] = None,
3940
config_file: Optional[str] = None,
40-
dependency_file: Optional[str] = None,
41+
dependency_files: Optional[set[str]] = None,
4142
dependencies: Optional[set[str]] = None,
4243
use_cache: Optional[bool] = True,
4344
show_progress_bar: bool = False,
@@ -68,7 +69,7 @@ def check_dependencies(
6869
load_config_from_file=load_config_from_file,
6970
config_file=config_file,
7071
selector_method=selector_method,
71-
dependency_file=dependency_file,
72+
dependency_files=dependency_files,
7273
use_cache=use_cache,
7374
package_ecosystem=package_ecosystem,
7475
recursive=recursive,
@@ -93,7 +94,7 @@ def check_dependencies(
9394
if config.package_ecosystem:
9495
logger.warning("`package_ecosystem` is not supported when reading dependencies from files. It will be ignored.")
9596

96-
if config.dependency_file and config.recursive:
97+
if config.dependency_files and config.recursive:
9798
logger.warning(
9899
"`--recursive` has been set together with `--dependency-file`. `--dependency-file` will take precedence."
99100
)
@@ -104,7 +105,7 @@ def check_dependencies(
104105
maybe_cache_handler=maybe_cache_handler,
105106
allowlist=config.allowlist,
106107
show_progress_bar=show_progress_bar,
107-
dependency_file=config.dependency_file,
108+
dependency_files=config.dependency_files,
108109
)
109110

110111

@@ -153,16 +154,17 @@ def _analyze_packages_from_source(
153154
allowlist: set[str],
154155
selector_method: SelectorMethod,
155156
show_progress_bar: bool,
156-
dependency_file: Optional[str],
157+
dependency_files: Optional[set[str]],
157158
source: Optional[str],
158159
maybe_cache_handler: Optional[CacheHandler],
159160
) -> TyposquatCheckResults:
160161
"""Analyze dependencies from a dependencies file.
161162
162163
It will return a list of the possible typos grouped by source, each source being a dependency file.
163164
"""
164-
dependency_managers = _get_dependency_managers_and_parsers_mapping(dependency_file)
165165
typos_by_file = TyposquatCheckResults()
166+
167+
dependency_managers = _get_dependency_managers_and_parsers_mapping(dependency_files)
166168
for dependency_manager, parsers in dependency_managers.items():
167169
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
168170

@@ -174,7 +176,6 @@ def _analyze_packages_from_source(
174176
threshold_class=SimilarityThreshold,
175177
)
176178
results: list[TyposquatCheckResultFromSource] = []
177-
178179
for parser in parsers:
179180
analyzed_dependencies = _analyze_dependencies(
180181
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path
@@ -252,13 +253,13 @@ def _get_selector_method(selector_method: str) -> SelectorMethod:
252253

253254

254255
def _get_dependency_managers_and_parsers_mapping(
255-
dependency_file: Optional[str],
256+
dependency_files: Optional[set[str]],
256257
) -> dict[type[BaseDependencyManager], list[AbstractParser]]:
257258
"""Return a dictionary, grouping all files to parse by their DependencyManager."""
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
261-
dependency_selector = DependencySelector(dependency_file)
262+
dependency_selector = DependencySelector(dependency_files)
262263
dependency_parsers = dependency_selector.get_dependency_parsers()
263264

264265
for parser in dependency_parsers:
@@ -274,7 +275,7 @@ def _get_config(
274275
load_config_from_file: bool,
275276
config_file: Optional[str],
276277
selector_method: Union[SelectorMethod, None],
277-
dependency_file: Optional[str],
278+
dependency_files: Optional[set[str]],
278279
use_cache: Optional[bool],
279280
package_ecosystem: Optional[PackageEcosystems],
280281
recursive: Optional[bool],
@@ -286,7 +287,7 @@ def _get_config(
286287
config_file_handler = None
287288
return ConfigHandler(config_file_handler).resolve_config(
288289
selector_method=selector_method,
289-
dependency_file=dependency_file,
290+
dependency_files=dependency_files,
290291
use_cache=use_cache,
291292
package_ecosystem=package_ecosystem,
292293
recursive=recursive,

tests/config/test_config_handler.py

Lines changed: 30 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,27 @@ 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"}
269+
270+
def test_both_dependency_files_args_in_config_raise_error(self, tmp_path: Path) -> None:
271+
pyproject_toml = tmp_path / "pyproject.toml"
272+
data = """
273+
[tool.twyn]
274+
dependency_file="requirements.txt"
275+
dependency_files=["requirements.txt"]
276+
"""
277+
pyproject_toml.write_text(data)
278+
279+
with pytest.raises(TOMLError):
280+
ConfigHandler(FileHandler(str(pyproject_toml))).resolve_config()

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_files=["my_file.txt", "my_other_file.txt"]
241241
selector_method="all"
242242
logging_level="debug"
243243
allowlist=["boto4", "boto2"]

0 commit comments

Comments
 (0)