Skip to content

Commit b7b64ce

Browse files
committed
feat: load config from twyn.toml if it exists
1 parent 8cbbabd commit b7b64ce

9 files changed

Lines changed: 79 additions & 35 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ twyn run --selector-method <method>
149149

150150
You can save your configurations in a `.toml` file, so you don't need to specify them everytime you run `Twyn` in your terminal.
151151

152-
By default, it will try to find a `pyproject.toml` file in your working directory when it's trying to load your configurations.
152+
By default, it will try to find a `twyn.roml` file in your working directory when it's trying to load your configurations. If it does not find it, it will fallback to `pyproject.toml`.
153153
However, you can specify a config file as follows:
154154

155155
```sh

src/twyn/base/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
DEFAULT_SELECTOR_METHOD = "all"
2929
DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
30+
DEFAULT_TWYN_TOML_FILE = "twyn.toml"
3031
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
3132

3233

src/twyn/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def allowlist() -> None:
155155
@click.option("--config", type=click.STRING)
156156
@click.argument("package_name")
157157
def add(package_name: str, config: str) -> None:
158-
fh = FileHandler(config or DEFAULT_PROJECT_TOML_FILE)
158+
fh = FileHandler(config or ConfigHandler.get_default_config_file_path())
159159
ConfigHandler(fh).add_package_to_allowlist(package_name)
160160

161161

src/twyn/config/config_handler.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import logging
22
from dataclasses import asdict, dataclass
33
from enum import Enum
4-
from typing import Optional
4+
from pathlib import Path
5+
from typing import Any, Optional, Union
56

67
from tomlkit import TOMLDocument, dumps, parse, table
78

89
from twyn.base.constants import (
910
DEFAULT_PROJECT_TOML_FILE,
1011
DEFAULT_SELECTOR_METHOD,
1112
DEFAULT_TOP_PYPI_PACKAGES,
13+
DEFAULT_TWYN_TOML_FILE,
1214
SELECTOR_METHOD_KEYS,
1315
AvailableLoggingLevels,
1416
)
@@ -145,6 +147,16 @@ def _read_toml(self) -> TOMLDocument:
145147
return TOMLDocument()
146148
raise TOMLError(f"Error reading toml from {self.file_handler.file_path}") from None
147149

150+
@staticmethod
151+
def get_default_config_file_path() -> str:
152+
"""Return `twyn.toml` if it exists. If not, it returns the default `pyproject.toml` file.
153+
154+
It does not fail if the latter does not exist, as it is not mandatory to have a config file to run twyn.
155+
"""
156+
if Path(DEFAULT_TWYN_TOML_FILE).exists():
157+
return DEFAULT_TWYN_TOML_FILE
158+
return DEFAULT_PROJECT_TOML_FILE
159+
148160

149161
def _get_logging_level(
150162
cli_verbosity: AvailableLoggingLevels,
@@ -160,8 +172,8 @@ def _get_logging_level(
160172
return cli_verbosity
161173

162174

163-
def _serialize_config(x):
164-
def _value_to_for_config(v):
175+
def _serialize_config(x: Any) -> Union[Any, str, list[Any]]:
176+
def _value_to_for_config(v: Any) -> Union[str, list[Any], Any]:
165177
if isinstance(v, Enum):
166178
return v.name
167179
elif isinstance(v, set):

src/twyn/file_handler/file_handler.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ class FileHandler(BaseFileHandler):
1919
def __init__(self, file_path: str) -> None:
2020
self.file_path = self._get_file_path(file_path)
2121

22-
def _get_file_path(self, file_path: str) -> Path:
23-
return Path(os.path.abspath(os.path.join(os.getcwd(), file_path)))
24-
2522
def is_handler_of_file(self, name: str) -> bool:
2623
return self._get_file_path(name) == self.file_path
2724

@@ -66,3 +63,6 @@ def delete(self, delete_parent_dir: bool = False) -> None:
6663
logger.exception(
6764
"Directory not empty or not enough permissions. Cannot be removed: %s", self.file_path.parent
6865
)
66+
67+
def _get_file_path(self, file_path: str) -> Path:
68+
return Path(os.path.abspath(os.path.join(os.getcwd(), file_path)))

src/twyn/main.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from rich.progress import track
55

66
from twyn.base.constants import (
7-
DEFAULT_PROJECT_TOML_FILE,
87
SELECTOR_METHOD_MAPPING,
98
AvailableLoggingLevels,
109
SelectorMethod,
@@ -35,7 +34,7 @@ def check_dependencies(
3534
use_track: bool = False,
3635
) -> TyposquatCheckResultList:
3736
"""Check if dependencies could be typosquats."""
38-
config_file_handler = FileHandler(config_file or DEFAULT_PROJECT_TOML_FILE)
37+
config_file_handler = FileHandler(config_file or ConfigHandler.get_default_config_file_path())
3938
config = ConfigHandler(config_file_handler, enforce_file=False).resolve_config(
4039
verbosity=verbosity, selector_method=selector_method, dependency_file=dependency_file
4140
)
Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import dataclasses
22
from copy import deepcopy
33
from pathlib import Path
4-
from unittest.mock import patch
4+
from typing import NoReturn
5+
from unittest.mock import Mock, patch
56

67
import pytest
78
from tomlkit import TOMLDocument, dumps, parse
8-
from twyn.base.constants import DEFAULT_PROJECT_TOML_FILE, DEFAULT_TOP_PYPI_PACKAGES, AvailableLoggingLevels
9+
from twyn.base.constants import (
10+
DEFAULT_PROJECT_TOML_FILE,
11+
DEFAULT_TOP_PYPI_PACKAGES,
12+
DEFAULT_TWYN_TOML_FILE,
13+
AvailableLoggingLevels,
14+
)
915
from twyn.config.config_handler import ConfigHandler, ReadTwynConfiguration, TwynConfiguration
1016
from twyn.config.exceptions import (
1117
AllowlistPackageAlreadyExistsError,
@@ -16,19 +22,21 @@
1622
from twyn.file_handler.exceptions import PathNotFoundError
1723
from twyn.file_handler.file_handler import FileHandler
1824

25+
from tests.conftest import create_tmp_file
26+
1927

20-
class TestConfig:
21-
def throw_exception(self):
28+
class TestConfigHandler:
29+
def throw_exception(self) -> NoReturn:
2230
raise PathNotFoundError
2331

2432
@patch("twyn.file_handler.file_handler.FileHandler.read")
25-
def test_enforce_file_error(self, mock_is_file):
33+
def test_enforce_file_error(self, mock_is_file: Mock) -> None:
2634
mock_is_file.side_effect = self.throw_exception
2735
with pytest.raises(TOMLError):
2836
ConfigHandler(FileHandler(DEFAULT_PROJECT_TOML_FILE), enforce_file=True).resolve_config()
2937

3038
@patch("twyn.file_handler.file_handler.FileHandler.read")
31-
def test_no_enforce_file_on_non_existent_file(self, mock_is_file):
39+
def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None:
3240
"""Resolving the config without enforcing the file to be present gives you defaults."""
3341
mock_is_file.side_effect = self.throw_exception
3442
config = ConfigHandler(FileHandler(DEFAULT_PROJECT_TOML_FILE), enforce_file=False).resolve_config()
@@ -41,18 +49,18 @@ def test_no_enforce_file_on_non_existent_file(self, mock_is_file):
4149
pypi_reference=DEFAULT_TOP_PYPI_PACKAGES,
4250
)
4351

44-
def test_config_raises_for_unknown_file(self):
52+
def test_config_raises_for_unknown_file(self) -> None:
4553
with pytest.raises(TOMLError):
4654
ConfigHandler(FileHandler("non-existent-file.toml")).resolve_config()
4755

48-
def test_read_config_values(self, pyproject_toml_file):
56+
def test_read_config_values(self, pyproject_toml_file: Path) -> None:
4957
config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config()
5058
assert config.dependency_file == "my_file.txt"
5159
assert config.selector_method == "all"
5260
assert config.logging_level == AvailableLoggingLevels.debug
5361
assert config.allowlist == {"boto4", "boto2"}
5462

55-
def test_get_twyn_data_from_file(self, pyproject_toml_file):
63+
def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
5664
handler = ConfigHandler(FileHandler(str(pyproject_toml_file)))
5765

5866
toml = handler._read_toml()
@@ -65,7 +73,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file):
6573
pypi_reference=None,
6674
)
6775

68-
def test_write_toml(self, pyproject_toml_file):
76+
def test_write_toml(self, pyproject_toml_file: Path) -> None:
6977
handler = ConfigHandler(FileHandler(pyproject_toml_file))
7078
toml = handler._read_toml()
7179

@@ -105,11 +113,35 @@ def test_write_toml(self, pyproject_toml_file):
105113
}
106114
}
107115

116+
def test_get_default_config_file_path_twyn_file_exists(self, tmp_path: Path, pyproject_toml_file: Path) -> None:
117+
assert pyproject_toml_file.exists()
118+
twyn_path = tmp_path / DEFAULT_TWYN_TOML_FILE
119+
with (
120+
create_tmp_file(twyn_path, ""),
121+
patch("twyn.config.config_handler.DEFAULT_TWYN_TOML_FILE", new=str(twyn_path)),
122+
patch("twyn.config.config_handler.DEFAULT_PROJECT_TOML_FILE", new=str(pyproject_toml_file)),
123+
):
124+
assert twyn_path.exists()
125+
126+
assert ConfigHandler.get_default_config_file_path() == str(twyn_path)
127+
128+
def test_get_default_config_file_path_twyn_file_does_not_exist(
129+
self, tmp_path: Path, pyproject_toml_file: Path
130+
) -> None:
131+
assert pyproject_toml_file.exists()
132+
twyn_path = tmp_path / DEFAULT_TWYN_TOML_FILE
133+
with (
134+
patch("twyn.config.config_handler.DEFAULT_TWYN_TOML_FILE", new=str(twyn_path)),
135+
patch("twyn.config.config_handler.DEFAULT_PROJECT_TOML_FILE", new=str(pyproject_toml_file)),
136+
):
137+
assert not twyn_path.exists()
138+
assert ConfigHandler.get_default_config_file_path() == str(pyproject_toml_file)
139+
108140

109141
class TestAllowlistConfigHandler:
110142
@patch("twyn.file_handler.file_handler.FileHandler.write")
111143
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
112-
def test_allowlist_add(self, mock_toml, mock_write_toml):
144+
def test_allowlist_add(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
113145
mock_toml.return_value = TOMLDocument()
114146

115147
config = ConfigHandler(FileHandler("some-file"))
@@ -123,7 +155,7 @@ def test_allowlist_add(self, mock_toml, mock_write_toml):
123155

124156
@patch("twyn.config.config_handler.ConfigHandler._write_toml")
125157
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
126-
def test_allowlist_add_duplicate_error(self, mock_toml, mock_write_toml):
158+
def test_allowlist_add_duplicate_error(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
127159
mock_toml.return_value = parse(dumps({"tool": {"twyn": {"allowlist": ["mypackage"]}}}))
128160

129161
config = ConfigHandler(FileHandler("some-file"))
@@ -137,7 +169,7 @@ def test_allowlist_add_duplicate_error(self, mock_toml, mock_write_toml):
137169

138170
@patch("twyn.config.config_handler.ConfigHandler._write_toml")
139171
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
140-
def test_allowlist_remove_completely(self, mock_toml, mock_write_toml):
172+
def test_allowlist_remove_completely(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
141173
mock_toml.return_value = parse(dumps({"tool": {"twyn": {"allowlist": ["mypackage"]}}}))
142174

143175
config = ConfigHandler(FileHandler("some-file"))
@@ -147,7 +179,7 @@ def test_allowlist_remove_completely(self, mock_toml, mock_write_toml):
147179

148180
@patch("twyn.config.config_handler.ConfigHandler._write_toml")
149181
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
150-
def test_allowlist_remove(self, mock_toml, mock_write_toml):
182+
def test_allowlist_remove(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
151183
mock_toml.return_value = parse(dumps({"tool": {"twyn": {"allowlist": ["mypackage", "another-package"]}}}))
152184

153185
config = ConfigHandler(FileHandler("some-file"))
@@ -157,7 +189,7 @@ def test_allowlist_remove(self, mock_toml, mock_write_toml):
157189

158190
@patch("twyn.config.config_handler.ConfigHandler._write_toml")
159191
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
160-
def test_allowlist_remove_non_existent_package_error(self, mock_toml, mock_write_toml):
192+
def test_allowlist_remove_non_existent_package_error(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
161193
mock_toml.return_value = parse(dumps({"tool": {"twyn": {"allowlist": ["mypackage"]}}}))
162194

163195
config = ConfigHandler(FileHandler("some-file"))
@@ -170,7 +202,7 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml, mock_write
170202
assert not mock_write_toml.called
171203

172204
@pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"])
173-
def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path):
205+
def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path) -> None:
174206
"""Test that all valid selector methods are accepted."""
175207
pyproject_toml = tmp_path / "pyproject.toml"
176208
pyproject_toml.write_text("")
@@ -180,7 +212,7 @@ def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Pa
180212
resolved_config = config.resolve_config(selector_method=valid_selector)
181213
assert resolved_config.selector_method == valid_selector
182214

183-
def test_invalid_selector_method_rejected(self, tmp_path: Path):
215+
def test_invalid_selector_method_rejected(self, tmp_path: Path) -> None:
184216
"""Test that invalid selector methods are rejected with appropriate error."""
185217
pyproject_toml = tmp_path / "pyproject.toml"
186218
pyproject_toml.write_text("")
@@ -193,7 +225,7 @@ def test_invalid_selector_method_rejected(self, tmp_path: Path):
193225
assert "Invalid selector_method 'random-selector'" in error_message
194226
assert "Must be one of: all, first-letter, nearby-letter" in error_message
195227

196-
def test_invalid_selector_method_from_config_file(self, tmp_path: Path):
228+
def test_invalid_selector_method_from_config_file(self, tmp_path: Path) -> None:
197229
"""Test that invalid selector method from config file is rejected."""
198230
# Create a config file with invalid selector method
199231
pyproject_toml = tmp_path / "pyproject.toml"

tests/conftest.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88

99
@contextmanager
10-
def create_tmp_file(path: Path, data: str) -> Iterator[str]:
10+
def create_tmp_file(path: Path, data: str) -> Iterator[Path]:
1111
path.parent.mkdir(parents=True, exist_ok=True)
1212
path.write_text(data)
13-
yield str(path)
13+
yield path
1414

1515

1616
@contextmanager
@@ -28,7 +28,7 @@ def patch_pypi_packages_download(packages: Iterable[str]) -> Iterator[mock.Mock]
2828

2929

3030
@pytest.fixture
31-
def requirements_txt_file(tmp_path: Path) -> Iterator[str]:
31+
def requirements_txt_file(tmp_path: Path) -> Iterator[Path]:
3232
requirements_txt_file = tmp_path / "requirements.txt"
3333

3434
data = """
@@ -41,7 +41,7 @@ def requirements_txt_file(tmp_path: Path) -> Iterator[str]:
4141

4242

4343
@pytest.fixture
44-
def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[str]:
44+
def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[Path]:
4545
"""Poetry lock version < 1.5."""
4646
poetry_lock_file = tmp_path / "poetry.lock"
4747
data = """
@@ -88,7 +88,7 @@ def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[str]:
8888

8989

9090
@pytest.fixture
91-
def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]:
91+
def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[Path]:
9292
"""Poetry lock version >= 1.5."""
9393
poetry_lock_file = tmp_path / "poetry.lock"
9494
data = """
@@ -132,7 +132,7 @@ def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]:
132132

133133

134134
@pytest.fixture
135-
def uv_lock_file(tmp_path: Path) -> Iterator[str]:
135+
def uv_lock_file(tmp_path: Path) -> Iterator[Path]:
136136
"""Uv lock file."""
137137
uv_lock_file = tmp_path / "uv.lock"
138138
data = """
@@ -177,7 +177,7 @@ def uv_lock_file(tmp_path: Path) -> Iterator[str]:
177177

178178

179179
@pytest.fixture
180-
def pyproject_toml_file(tmp_path: Path) -> Iterator[str]:
180+
def pyproject_toml_file(tmp_path: Path) -> Iterator[Path]:
181181
pyproject_toml = tmp_path / "pyproject.toml"
182182
data = """
183183
[tool.poetry.dependencies]

0 commit comments

Comments
 (0)