Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ twyn run --selector-method <method>

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

By default, it will try to find a `pyproject.toml` file in your working directory when it's trying to load your configurations.
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`.
However, you can specify a config file as follows:

```sh
Expand Down
1 change: 1 addition & 0 deletions src/twyn/base/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

DEFAULT_SELECTOR_METHOD = "all"
DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
DEFAULT_TWYN_TOML_FILE = "twyn.toml"
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"


Expand Down
2 changes: 1 addition & 1 deletion src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def allowlist() -> None:
@click.option("--config", type=click.STRING)
@click.argument("package_name")
def add(package_name: str, config: str) -> None:
fh = FileHandler(config or DEFAULT_PROJECT_TOML_FILE)
fh = FileHandler(config or ConfigHandler.get_default_config_file_path())
ConfigHandler(fh).add_package_to_allowlist(package_name)


Expand Down
18 changes: 15 additions & 3 deletions src/twyn/config/config_handler.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import logging
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Optional
from pathlib import Path
from typing import Any, Optional, Union

from tomlkit import TOMLDocument, dumps, parse, table

from twyn.base.constants import (
DEFAULT_PROJECT_TOML_FILE,
DEFAULT_SELECTOR_METHOD,
DEFAULT_TOP_PYPI_PACKAGES,
DEFAULT_TWYN_TOML_FILE,
SELECTOR_METHOD_KEYS,
AvailableLoggingLevels,
)
Expand Down Expand Up @@ -145,6 +147,16 @@ def _read_toml(self) -> TOMLDocument:
return TOMLDocument()
raise TOMLError(f"Error reading toml from {self.file_handler.file_path}") from None

@staticmethod
def get_default_config_file_path() -> str:
"""Return `twyn.toml` if it exists. If not, it returns the default `pyproject.toml` file.

It does not fail if the latter does not exist, as it is not mandatory to have a config file to run twyn.
"""
if Path(DEFAULT_TWYN_TOML_FILE).exists():
return DEFAULT_TWYN_TOML_FILE
return DEFAULT_PROJECT_TOML_FILE


def _get_logging_level(
cli_verbosity: AvailableLoggingLevels,
Expand All @@ -160,8 +172,8 @@ def _get_logging_level(
return cli_verbosity


def _serialize_config(x):
def _value_to_for_config(v):
def _serialize_config(x: Any) -> Union[Any, str, list[Any]]:
def _value_to_for_config(v: Any) -> Union[str, list[Any], Any]:
if isinstance(v, Enum):
return v.name
elif isinstance(v, set):
Expand Down
6 changes: 3 additions & 3 deletions src/twyn/file_handler/file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ class FileHandler(BaseFileHandler):
def __init__(self, file_path: str) -> None:
self.file_path = self._get_file_path(file_path)

def _get_file_path(self, file_path: str) -> Path:
return Path(os.path.abspath(os.path.join(os.getcwd(), file_path)))

def is_handler_of_file(self, name: str) -> bool:
return self._get_file_path(name) == self.file_path

Expand Down Expand Up @@ -66,3 +63,6 @@ def delete(self, delete_parent_dir: bool = False) -> None:
logger.exception(
"Directory not empty or not enough permissions. Cannot be removed: %s", self.file_path.parent
)

def _get_file_path(self, file_path: str) -> Path:
return (Path(os.getcwd()) / Path(file_path)).resolve()
3 changes: 1 addition & 2 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from rich.progress import track

from twyn.base.constants import (
DEFAULT_PROJECT_TOML_FILE,
SELECTOR_METHOD_MAPPING,
AvailableLoggingLevels,
SelectorMethod,
Expand Down Expand Up @@ -35,7 +34,7 @@ def check_dependencies(
use_track: bool = False,
) -> TyposquatCheckResultList:
"""Check if dependencies could be typosquats."""
config_file_handler = FileHandler(config_file or DEFAULT_PROJECT_TOML_FILE)
config_file_handler = FileHandler(config_file or ConfigHandler.get_default_config_file_path())
config = ConfigHandler(config_file_handler, enforce_file=False).resolve_config(
verbosity=verbosity, selector_method=selector_method, dependency_file=dependency_file
)
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import dataclasses
from copy import deepcopy
from pathlib import Path
from unittest.mock import patch
from typing import NoReturn
from unittest.mock import Mock, patch

import pytest
from tomlkit import TOMLDocument, dumps, parse
from twyn.base.constants import DEFAULT_PROJECT_TOML_FILE, DEFAULT_TOP_PYPI_PACKAGES, AvailableLoggingLevels
from twyn.base.constants import (
DEFAULT_PROJECT_TOML_FILE,
DEFAULT_TOP_PYPI_PACKAGES,
DEFAULT_TWYN_TOML_FILE,
AvailableLoggingLevels,
)
from twyn.config.config_handler import ConfigHandler, ReadTwynConfiguration, TwynConfiguration
from twyn.config.exceptions import (
AllowlistPackageAlreadyExistsError,
Expand All @@ -16,19 +22,21 @@
from twyn.file_handler.exceptions import PathNotFoundError
from twyn.file_handler.file_handler import FileHandler

from tests.conftest import create_tmp_file


class TestConfig:
def throw_exception(self):
class TestConfigHandler:
def throw_exception(self) -> NoReturn:
raise PathNotFoundError

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

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

def test_config_raises_for_unknown_file(self):
def test_config_raises_for_unknown_file(self) -> None:
with pytest.raises(TOMLError):
ConfigHandler(FileHandler("non-existent-file.toml")).resolve_config()

def test_read_config_values(self, pyproject_toml_file):
def test_read_config_values(self, pyproject_toml_file: Path) -> None:
config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config()
assert config.dependency_file == "my_file.txt"
assert config.selector_method == "all"
assert config.logging_level == AvailableLoggingLevels.debug
assert config.allowlist == {"boto4", "boto2"}

def test_get_twyn_data_from_file(self, pyproject_toml_file):
def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
handler = ConfigHandler(FileHandler(str(pyproject_toml_file)))

toml = handler._read_toml()
Expand All @@ -65,7 +73,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file):
pypi_reference=None,
)

def test_write_toml(self, pyproject_toml_file):
def test_write_toml(self, pyproject_toml_file: Path) -> None:
handler = ConfigHandler(FileHandler(pyproject_toml_file))
toml = handler._read_toml()

Expand Down Expand Up @@ -105,11 +113,35 @@ def test_write_toml(self, pyproject_toml_file):
}
}

def test_get_default_config_file_path_twyn_file_exists(self, tmp_path: Path, pyproject_toml_file: Path) -> None:
assert pyproject_toml_file.exists()
twyn_path = tmp_path / DEFAULT_TWYN_TOML_FILE
with (
create_tmp_file(twyn_path, ""),
patch("twyn.config.config_handler.DEFAULT_TWYN_TOML_FILE", new=str(twyn_path)),
patch("twyn.config.config_handler.DEFAULT_PROJECT_TOML_FILE", new=str(pyproject_toml_file)),
):
assert twyn_path.exists()

assert ConfigHandler.get_default_config_file_path() == str(twyn_path)

def test_get_default_config_file_path_twyn_file_does_not_exist(
self, tmp_path: Path, pyproject_toml_file: Path
) -> None:
assert pyproject_toml_file.exists()
twyn_path = tmp_path / DEFAULT_TWYN_TOML_FILE
with (
patch("twyn.config.config_handler.DEFAULT_TWYN_TOML_FILE", new=str(twyn_path)),
patch("twyn.config.config_handler.DEFAULT_PROJECT_TOML_FILE", new=str(pyproject_toml_file)),
):
assert not twyn_path.exists()
assert ConfigHandler.get_default_config_file_path() == str(pyproject_toml_file)


class TestAllowlistConfigHandler:
@patch("twyn.file_handler.file_handler.FileHandler.write")
@patch("twyn.config.config_handler.ConfigHandler._read_toml")
def test_allowlist_add(self, mock_toml, mock_write_toml):
def test_allowlist_add(self, mock_toml: Mock, mock_write_toml: Mock) -> None:
mock_toml.return_value = TOMLDocument()

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

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

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

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

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

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

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

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

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

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

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

def test_invalid_selector_method_from_config_file(self, tmp_path: Path):
def test_invalid_selector_method_from_config_file(self, tmp_path: Path) -> None:
"""Test that invalid selector method from config file is rejected."""
# Create a config file with invalid selector method
pyproject_toml = tmp_path / "pyproject.toml"
Expand Down
14 changes: 7 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@


@contextmanager
def create_tmp_file(path: Path, data: str) -> Iterator[str]:
def create_tmp_file(path: Path, data: str) -> Iterator[Path]:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data)
yield str(path)
yield path


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


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

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


@pytest.fixture
def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[str]:
def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[Path]:
"""Poetry lock version < 1.5."""
poetry_lock_file = tmp_path / "poetry.lock"
data = """
Expand Down Expand Up @@ -88,7 +88,7 @@ def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[str]:


@pytest.fixture
def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]:
def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[Path]:
"""Poetry lock version >= 1.5."""
poetry_lock_file = tmp_path / "poetry.lock"
data = """
Expand Down Expand Up @@ -132,7 +132,7 @@ def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]:


@pytest.fixture
def uv_lock_file(tmp_path: Path) -> Iterator[str]:
def uv_lock_file(tmp_path: Path) -> Iterator[Path]:
"""Uv lock file."""
uv_lock_file = tmp_path / "uv.lock"
data = """
Expand Down Expand Up @@ -177,7 +177,7 @@ def uv_lock_file(tmp_path: Path) -> Iterator[str]:


@pytest.fixture
def pyproject_toml_file(tmp_path: Path) -> Iterator[str]:
def pyproject_toml_file(tmp_path: Path) -> Iterator[Path]:
pyproject_toml = tmp_path / "pyproject.toml"
data = """
[tool.poetry.dependencies]
Expand Down