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 `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`.
By default, it will try to find a `twyn.toml` 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 @@ -29,6 +29,7 @@
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"
DEFAULT_USE_CACHE = True


class AvailableLoggingLevels(Enum):
Expand Down
6 changes: 3 additions & 3 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def entry_point() -> None:
@click.option(
"--no-cache",
is_flag=True,
default=False,
default=None,
help="Disable use of the trusted packages cache. Always fetch from the source.",
)
@click.option(
Expand All @@ -96,7 +96,7 @@ def run(
selector_method: str,
v: bool,
vv: bool,
no_cache: bool,
no_cache: Optional[bool],
no_track: bool,
json: bool,
) -> int:
Expand All @@ -122,7 +122,7 @@ def run(
config_file=config,
dependency_file=dependency_file,
verbosity=verbosity,
use_cache=not no_cache,
use_cache=not no_cache if no_cache is not None else no_cache,
use_track=False if json else not no_track,
)
except TwynError as e:
Expand Down
13 changes: 13 additions & 0 deletions src/twyn/config/config_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DEFAULT_SELECTOR_METHOD,
DEFAULT_TOP_PYPI_PACKAGES,
DEFAULT_TWYN_TOML_FILE,
DEFAULT_USE_CACHE,
SELECTOR_METHOD_KEYS,
AvailableLoggingLevels,
)
Expand All @@ -35,6 +36,7 @@ class TwynConfiguration:
logging_level: AvailableLoggingLevels
allowlist: set[str]
pypi_reference: str
use_cache: bool


@dataclass
Expand All @@ -46,6 +48,7 @@ class ReadTwynConfiguration:
logging_level: Optional[AvailableLoggingLevels]
allowlist: set[str]
pypi_reference: Optional[str]
use_cache: Optional[bool]


class ConfigHandler:
Expand All @@ -60,6 +63,7 @@ def resolve_config(
selector_method: Optional[str] = None,
dependency_file: Optional[str] = None,
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
use_cache: Optional[bool] = None,
) -> TwynConfiguration:
"""Resolve the configuration for Twyn.

Expand All @@ -81,12 +85,20 @@ def resolve_config(
f"Invalid selector_method '{final_selector_method}'. Must be one of: {valid_methods}"
)

if use_cache is not None:
final_use_cache = use_cache
elif read_config.use_cache is not None:
final_use_cache = read_config.use_cache
else:
final_use_cache = DEFAULT_USE_CACHE

return TwynConfiguration(
dependency_file=dependency_file or read_config.dependency_file,
selector_method=final_selector_method,
logging_level=_get_logging_level(verbosity, read_config.logging_level),
allowlist=read_config.allowlist,
pypi_reference=read_config.pypi_reference or DEFAULT_TOP_PYPI_PACKAGES,
use_cache=final_use_cache,
)

def add_package_to_allowlist(self, package_name: str) -> None:
Expand Down Expand Up @@ -120,6 +132,7 @@ def _get_read_config(self, toml: TOMLDocument) -> ReadTwynConfiguration:
logging_level=twyn_config_data.get("logging_level"),
allowlist=set(twyn_config_data.get("allowlist", set())),
pypi_reference=twyn_config_data.get("pypi_reference"),
use_cache=twyn_config_data.get("use_cache"),
)

def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/twyn/file_handler/file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ def delete(self, delete_parent_dir: bool = False) -> None:
)

def _get_file_path(self, file_path: str) -> Path:
return (Path(os.getcwd()) / Path(file_path)).resolve()
return Path(os.path.abspath(os.path.join(os.getcwd(), file_path)))
9 changes: 5 additions & 4 deletions src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,20 @@ def check_dependencies(
dependency_file: Optional[str] = None,
dependencies: Optional[set[str]] = None,
verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none,
use_cache: bool = True,
use_cache: Optional[bool] = True,
use_track: bool = False,
) -> TyposquatCheckResultList:
"""Check if dependencies could be typosquats."""
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
verbosity=verbosity, selector_method=selector_method, dependency_file=dependency_file, use_cache=use_cache
)
_set_logging_level(config.logging_level)

cache_handler = CacheHandler()
cache_handler = CacheHandler() if config.use_cache else None

trusted_packages = TrustedPackages(
names=TopPyPiReference(source=config.pypi_reference, cache_handler=cache_handler).get_packages(use_cache),
names=TopPyPiReference(source=config.pypi_reference, cache_handler=cache_handler).get_packages(),
algorithm=EditDistance(),
selector=get_candidate_selector(config.selector_method),
threshold_class=SimilarityThreshold,
Expand Down
26 changes: 15 additions & 11 deletions src/twyn/trusted_packages/references.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from typing import Any, Union

import requests

Expand All @@ -19,43 +19,47 @@
class AbstractPackageReference(ABC):
"""Represents a reference from where to retrieve trusted packages."""

def __init__(self, source: str, cache_handler: CacheHandler) -> None:
def __init__(self, source: str, cache_handler: Union[CacheHandler, None] = None) -> None:
self.source = source
self.cache_handler = cache_handler

@abstractmethod
def get_packages(self, use_cache: bool = True) -> set[str]:
def get_packages(self) -> set[str]:
"""Return the names of the trusted packages available in the reference."""


class TopPyPiReference(AbstractPackageReference):
"""Top PyPi packages retrieved from an online source."""

def get_packages(self, use_cache: bool = True) -> set[str]:
def get_packages(self) -> set[str]:
"""Download and parse online source of top Python Package Index packages."""
packages_to_use = set()
if use_cache:
packages_to_use = self._get_packages_from_cache()
# we don't save the cache here, we keep it as it is so the date remains the original one.
packages_to_use = self._get_packages_from_cache_if_enabled()
# we don't save the cache here, we keep it as it is so the date remains the original one.

if not packages_to_use:
# no cache usage, no cache hit (non-existent or outdated) or cache was empty.
logger.info("Fetching trusted packages from PyPI reference...")
packages_to_use = self._parse(self._download())
if use_cache:
self._save_trusted_packages_to_cache(packages_to_use)

# New packages were downloaded, we create a new entry updating all values.
self._save_trusted_packages_to_cache_if_enabled(packages_to_use)

normalized_packages = normalize_packages(packages_to_use)
return normalized_packages

def _save_trusted_packages_to_cache(self, packages: set[str]) -> None:
def _save_trusted_packages_to_cache_if_enabled(self, packages: set[str]) -> None:
"""Save trusted packages using CacheHandler."""
if not self.cache_handler:
return
cache_entry = CacheEntry(saved_date=datetime.now().date().isoformat(), packages=packages)
self.cache_handler.write_entry(self.source, cache_entry)
logger.debug("Saved %d trusted packages for source %s", len(packages), self.source)

def _get_packages_from_cache(self) -> set[str]:
def _get_packages_from_cache_if_enabled(self) -> set[str]:
"""Get packages from cache if it's present and up to date."""
if not self.cache_handler:
return set()
cache_entry = self.cache_handler.get_cache_entry(self.source)
if not cache_entry:
logger.debug("No cache entry found for source: %s", self.source)
Expand Down
4 changes: 4 additions & 0 deletions tests/config/test_config_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None:
logging_level=AvailableLoggingLevels.warning,
allowlist=set(),
pypi_reference=DEFAULT_TOP_PYPI_PACKAGES,
use_cache=True,
)

def test_config_raises_for_unknown_file(self) -> None:
Expand All @@ -59,6 +60,7 @@ def test_read_config_values(self, pyproject_toml_file: Path) -> None:
assert config.selector_method == "all"
assert config.logging_level == AvailableLoggingLevels.debug
assert config.allowlist == {"boto4", "boto2"}
assert config.use_cache is False

def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
handler = ConfigHandler(FileHandler(str(pyproject_toml_file)))
Expand All @@ -71,6 +73,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
logging_level="debug",
allowlist={"boto4", "boto2"},
pypi_reference=None,
use_cache=False,
)

def test_write_toml(self, pyproject_toml_file: Path) -> None:
Expand Down Expand Up @@ -109,6 +112,7 @@ def test_write_toml(self, pyproject_toml_file: Path) -> None:
"logging_level": "debug",
"allowlist": {},
"pypi_reference": DEFAULT_TOP_PYPI_PACKAGES,
"use_cache": False,
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def pyproject_toml_file(tmp_path: Path) -> Iterator[Path]:
selector_method="all"
logging_level="debug"
allowlist=["boto4", "boto2"]

use_cache=false
"""
with create_tmp_file(pyproject_toml, data) as tmp_file:
yield tmp_file
17 changes: 10 additions & 7 deletions tests/file_handler/test_file_handler.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from unittest.mock import patch
from pathlib import Path
from unittest.mock import Mock, patch

import pytest
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
from twyn.file_handler.exceptions import PathIsNotFileError
from twyn.file_handler.file_handler import FileHandler


class TestFileHandler:
def test_file_exists(self, pyproject_toml_file: str):
def test_file_exists(self, pyproject_toml_file: Path) -> None:
parser = FileHandler(pyproject_toml_file)
assert parser.exists() is True

def test_read_file_success(self, pyproject_toml_file: str):
def test_read_file_success(self, pyproject_toml_file: Path) -> None:
parser = FileHandler(pyproject_toml_file)
read = parser.read()
assert len(read) > 1
Expand All @@ -26,10 +27,12 @@ def test_read_file_does_not_exist(
@patch("pathlib.Path.exists")
@patch("pathlib.Path.is_file")
@pytest.mark.parametrize(
("file_exists", "is_file", "exception"),
[(False, False, PathNotFoundError), (True, False, PathIsNotFileError)],
("file_exists", "is_file"),
[(False, False), (True, False)],
)
def test_raise_for_valid_file(self, mock_is_file, mock_exists, file_exists, is_file, exception):
def test_raise_for_valid_file(
self, mock_is_file: Mock, mock_exists: Mock, file_exists: bool, is_file: bool
) -> None:
mock_exists.return_value = file_exists
mock_is_file.return_value = is_file

Expand Down
10 changes: 5 additions & 5 deletions tests/main/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) ->
dependencies=None,
selector_method="first-letter",
verbosity=AvailableLoggingLevels.debug,
use_cache=True,
use_cache=None,
use_track=True,
)
]
Expand All @@ -112,7 +112,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe
dependencies=None,
selector_method=None,
verbosity=AvailableLoggingLevels.none,
use_cache=True,
use_cache=None,
use_track=True,
)
]
Expand All @@ -134,7 +134,7 @@ def test_click_arguments_single_dependency_cli(self, mock_check_dependencies: Mo
dependencies={"reqests"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
use_cache=True,
use_cache=None,
use_track=True,
)
]
Expand Down Expand Up @@ -168,7 +168,7 @@ def test_click_arguments_multiple_dependencies(self, mock_check_dependencies: Mo
dependencies={"reqests", "reqeusts"},
selector_method=None,
verbosity=AvailableLoggingLevels.none,
use_cache=True,
use_cache=None,
use_track=True,
)
]
Expand All @@ -185,7 +185,7 @@ def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None:
selector_method=None,
dependencies=None,
verbosity=AvailableLoggingLevels.none,
use_cache=True,
use_cache=None,
use_track=True,
)
]
Expand Down
Loading