diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1cf14542..bc8f351c 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -34,9 +34,15 @@ jobs: WHEEL_FILE=$(ls dist/*.whl | head -1) echo "Installing wheel: $WHEEL_FILE" uv pip install "$WHEEL_FILE" + + - name: Test twyn as a library + run: uv run python -c "import twyn; twyn.check_dependencies" - - name: Test --version flag + - name: Test twyn as a cli tool run: | + WHEEL_FILE=$(ls dist/*.whl | head -1) + echo "Installing wheel: `$WHEEL_FILE` with `cli` extra." + uv pip install "${WHEEL_FILE}[cli]" # Test that the CLI is available and --version works uv run twyn --version diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 196ed400..5b0d7a49 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,7 +39,7 @@ jobs: uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1 - name: Install the project - run: uv sync --locked + run: uv sync --locked --extra cli - name: Run Twyn against our dependencies run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a9a57272..12219225 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1 - name: Install the dependencies - run: uv sync --locked --group dev --python ${{ matrix.python-version }} + run: uv sync --locked --group dev --all-extras --python ${{ matrix.python-version }} - name: Run tests run: uv run pytest tests diff --git a/README.md b/README.md index f150fb36..61ef72a6 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ - [Overview](#overview) - [Quickstart](#quickstart) - - [Installation](#installation) - - [Docker](#docker) - - [Run](#run) - - [JSON Format](#json-format) + - [Using `Twyn` as a cli tool](#using-twyn-as-a-cli-tool) + - [Installation](#installation) + - [Docker](#docker) + - [Run](#run) + - [JSON Format](#json-format) + - [Using `Twyn` as a library](#using-twyn-as-a-library) + - [Logging level](#logging-level) - [Configuration](#configuration) - [Allowlist](#allowlist) - [Dependency files](#dependency-files) @@ -22,8 +25,7 @@ - [Selector method](#selector-method) - [Configuration file](#configuration-file) - [Cache](#cache) -- [Using `Twyn` as a library](#using-twyn-as-a-library) - - [Logging level](#logging-level) + ## Overview `Twyn` is a security tool that compares the name of your dependencies against a set of the most popular ones, @@ -39,15 +41,16 @@ It works as follows: ## Quickstart -### Installation +### Using twyn as a CLI tool +#### Installation `Twyn` is available on PyPi repository, you can install it by running ```sh -pip install twyn +pip install twyn[cli] ``` -### Docker +#### Docker `Twyn` provides a Docker image, which can be found [here](https://hub.docker.com/r/elementsinteractive/twyn). @@ -58,7 +61,7 @@ docker pull elementsinteractive/twyn:latest docker run elementsinteractive/twyn --help ``` -### Run +#### Run To run twyn simply type: @@ -72,7 +75,7 @@ For a list of all the available options as well as their expected arguments run: twyn run --help ``` -### JSON format +#### JSON format If you want your output in JSON format, you can run `Twyn` with the following flag: ```python @@ -84,12 +87,43 @@ This will output: {"errors":[{"dependency":"reqests","similars":["requests","grequests"]}]} ``` +### Using Twyn as a library + + +#### Installation +`Twyn` also supports being used as 3rd party library for you project. To install it, run: + + +```sh +pip install twyn +``` + +Example usage in your code: + +```python +from twyn import check_dependencies + +typos = check_dependencies() + +for typo in typos.errors: + print(f"Dependency:{typo.dependency}") + print(f"Did you mean any of [{','.join(typo.similars)}]") + +``` + +#### Logging level +By default, logging is disabled when running as a 3rd party library. To override this behaviour, you can: + +```python +logging.basicConfig(level=logging.INFO) +logging.getLogger("twyn").setLevel(logging.INFO) +``` + ## Configuration ### Allowlist -It can happen that a legitimate package known by the user raises an error because is too similar to one of the most trusted ones. -You can then add this packages to the `allowlist`, so it will be skipped: +It can happen that a legitimate package known by the user raises an error because it is too similar to one of the most trusted ones. Imagine that you are using internally a package that you developed called `reqests`. You can then add this packages to the `allowlist`, so it will not be reported as a typo: ```sh twyn allowlist add @@ -201,24 +235,3 @@ To clear the cache, run: ``` - ### Using Twyn as a library - -`Twyn` also supports being used as 3rd party library for you project. - -```python -from twyn import check_dependencies - -typos = check_dependencies() - -for typo in typos.errors: - print(f"Dependency:{typo.dependency}") - print(f"Did you mean any of [{','.join(typo.similars)}]") - -``` -### Logging level -To override the logging level when using `Twyn` as a 3rd party library, simply override it like: - -```python -logging.basicConfig(level=logging.DEBUG) -logging.getLogger("twyn").setLevel(logging.DEBUG) -``` \ No newline at end of file diff --git a/dependencies/scripts/download_packages.py b/dependencies/scripts/download_packages.py index ccd02ce6..cbcef492 100644 --- a/dependencies/scripts/download_packages.py +++ b/dependencies/scripts/download_packages.py @@ -61,7 +61,7 @@ def download(ecosystem: str) -> None: ): with attempt, httpx.Client(timeout=30) as client: logger.info("Attempting to download %s packages. Attempt #%d.", ecosystem, attempt.num) - response = client.get(ECOSYSTEMS[ecosystem]["url"]) # type: ignore[arg-type] + response = client.get(ECOSYSTEMS[ecosystem]["url"]) response.raise_for_status() fpath = Path("dependencies") / f"{ecosystem}.json" diff --git a/justfile b/justfile index b294f659..b9b27676 100644 --- a/justfile +++ b/justfile @@ -16,7 +16,7 @@ help: venv: @if ! {{ venv-exists }}; \ then \ - uv sync --frozen --all-groups; \ + uv sync --frozen --all-extras --all-groups; \ fi # Cleans all artifacts generated while running this project, including the virtualenv. diff --git a/pyproject.toml b/pyproject.toml index e278bd1d..5ce772fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,6 @@ maintainers = [ requires-python = "<4,>=3.9" dependencies = [ "requests<3.0.0,>=2.32.4", - "click<9.0.0,>=8.1.8", - "rich<15.0.0,>=14.0.0", "rapidfuzz<4.0.0,>=2.13.7", "pyparsing<4.0.0,>=3.2.3", "tomlkit<0.14.0,>=0.11.6", @@ -26,6 +24,13 @@ description = "Security tool against dependency typosquatting attacks" readme = "README.md" dynamic = ["version"] +[project.optional-dependencies] +cli = [ + "click<9.0.0,>=8.1.8", + "rich<15.0.0,>=14.0.0", +] + + [tool.hatch.version] path = "VERSION" pattern = "v(?P[^\\s]+)" diff --git a/src/twyn/base/exceptions.py b/src/twyn/base/exceptions.py index 8545ff71..f8af9cda 100644 --- a/src/twyn/base/exceptions.py +++ b/src/twyn/base/exceptions.py @@ -1,8 +1,6 @@ import logging from typing import IO, Any, Optional -import click - logger = logging.getLogger("twyn") @@ -19,14 +17,20 @@ def __init__(self, message: str = "") -> None: super().__init__(message or self.message) -class CliError(click.ClickException): - """Error that will populate application errors to stdout. It does not inherit from `TwynError`.""" +try: + import click - message = "CLI error" + class CliError(click.ClickException): + """Error that will populate application errors to stdout. It does not inherit from `TwynError`.""" - def __init__(self, message: str = "") -> None: - super().__init__(message) + message = "CLI error" + + def __init__(self, message: str = "") -> None: + super().__init__(message) + + def show(self, file: Optional[IO[Any]] = None) -> None: + logger.debug(self.format_message(), exc_info=True) + logger.error(self.format_message(), exc_info=False) - def show(self, file: Optional[IO[Any]] = None) -> None: - logger.debug(self.format_message(), exc_info=True) - logger.error(self.format_message(), exc_info=False) +except ModuleNotFoundError: + pass diff --git a/src/twyn/cli.py b/src/twyn/cli.py index f93b1897..4981400c 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -2,29 +2,38 @@ import sys from typing import Optional -import click -from rich.console import Console -from rich.logging import RichHandler - from twyn.__version__ import __version__ from twyn.base.constants import ( DEFAULT_PROJECT_TOML_FILE, DEPENDENCY_FILE_MAPPING, SELECTOR_METHOD_MAPPING, ) -from twyn.base.exceptions import CliError, TwynError +from twyn.base.exceptions import TwynError from twyn.config.config_handler import ConfigHandler from twyn.file_handler.file_handler import FileHandler from twyn.main import check_dependencies from twyn.trusted_packages.cache_handler import CacheHandler from twyn.trusted_packages.constants import CACHE_DIR +try: + import click + from rich.console import Console + from rich.logging import RichHandler + + from twyn.base.exceptions import CliError +except ImportError: + print("Could not run twyn as a cli tool, some dependencies are missing! Run `pip install twyn[cli]`.") + import sys + + sys.exit(1) + +logger = logging.getLogger("twyn") + logging.basicConfig( format="%(message)s", datefmt="[%X]", handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))], ) -logger = logging.getLogger("twyn") @click.group() diff --git a/src/twyn/main.py b/src/twyn/main.py index 7fa2fce0..2c34798e 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -1,8 +1,7 @@ import logging +from collections.abc import Iterable from typing import Optional, Union -from rich.progress import track - from twyn.base.constants import ( SELECTOR_METHOD_MAPPING, PackageEcosystems, @@ -88,10 +87,8 @@ def check_dependencies( normalized_dependencies = top_package_reference.normalize_packages(dependencies_to_check) typos_list = TyposquatCheckResultList() - dependencies_list = ( - track(normalized_dependencies, description="Processing...") if show_progress_bar else normalized_dependencies - ) - for dependency in dependencies_list: + + for dependency in _get_dependencies_list(normalized_dependencies, show_progress_bar): if dependency in normalized_allowlist_packages: logger.info("Dependency %s is in the allowlist", dependency) continue @@ -99,10 +96,22 @@ def check_dependencies( logger.info("Analyzing %s", dependency) if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)): typos_list.errors.append(typosquat_results) - return typos_list +def _get_dependencies_list(normalized_dependencies: set[str], show_progress_bar: bool) -> Iterable[str]: + try: + from rich.progress import track # noqa: PLC0415 + + return ( + track(normalized_dependencies, description="Processing...") + if show_progress_bar + else normalized_dependencies + ) + except ImportError: + return normalized_dependencies + + def _get_selector_method(selector_method: str) -> SelectorMethod: if selector_method not in SELECTOR_METHOD_MAPPING: InvalidSelectorMethodError("Invalid selector method") diff --git a/tests/main/conftest.py b/tests/main/conftest.py index cf08aaa5..234df713 100644 --- a/tests/main/conftest.py +++ b/tests/main/conftest.py @@ -8,6 +8,6 @@ @pytest.fixture(scope="module") def disable_track() -> Generator[None, Any, None]: """Disables the track UI for running tests.""" - with patch("twyn.main.track") as m_track: + with patch("rich.progress.track") as m_track: m_track.side_effect = lambda iterable, description: iterable yield diff --git a/tests/main/test_main.py b/tests/main/test_main.py index e56eef80..561a81eb 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -312,7 +312,7 @@ def test_track_is_disabled_by_default_when_used_as_package( package_ecosystem=None, ) mock_get_packages.return_value = {"requests"} - with patch("twyn.main.track") as m_track: + with patch("rich.progress.track") as m_track: check_dependencies() assert m_track.call_count == 0 @@ -328,7 +328,7 @@ def test_track_is_shown_when_enabled(self, mock_config: Mock, mock_get_packages: package_ecosystem=None, ) mock_get_packages.return_value = {"requests"} - with patch("twyn.main.track") as m_track: + with patch("rich.progress.track") as m_track: check_dependencies(show_progress_bar=True) assert m_track.call_count == 1 diff --git a/uv.lock b/uv.lock index c83de5cc..aa83689e 100644 --- a/uv.lock +++ b/uv.lock @@ -333,7 +333,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1487,19 +1487,23 @@ wheels = [ name = "twyn" source = { editable = "." } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pydantic" }, { name = "pyparsing" }, { name = "pyyaml" }, { name = "rapidfuzz", version = "3.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "rapidfuzz", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "requests" }, - { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.13'" }, { name = "tomlkit" }, ] +[package.optional-dependencies] +cli = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich" }, +] + [package.dev-dependencies] dev = [ { name = "freezegun" }, @@ -1525,16 +1529,17 @@ local = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">=8.1.8,<9.0.0" }, + { name = "click", marker = "extra == 'cli'", specifier = ">=8.1.8,<9.0.0" }, { name = "pydantic", specifier = ">=2.11.7,<3.0.0" }, { name = "pyparsing", specifier = ">=3.2.3,<4.0.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "rapidfuzz", specifier = ">=2.13.7,<4.0.0" }, { name = "requests", specifier = ">=2.32.4,<3.0.0" }, - { name = "rich", specifier = ">=14.0.0,<15.0.0" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=14.0.0,<15.0.0" }, { name = "tomli", marker = "python_full_version < '3.13'", specifier = ">=2.2.1,<3.0.0" }, { name = "tomlkit", specifier = ">=0.11.6,<0.14.0" }, ] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [