Skip to content

Commit 8dd969f

Browse files
committed
feat: support parsing multiple files
BREAKING CHANGE
1 parent d0b5857 commit 8dd969f

20 files changed

Lines changed: 477 additions & 244 deletions

File tree

.github/workflows/build-test.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ jobs:
3434
WHEEL_FILE=$(ls dist/*.whl | head -1)
3535
echo "Installing wheel: $WHEEL_FILE"
3636
uv pip install "$WHEEL_FILE"
37+
38+
- name: Test twyn as a library
39+
run: uv run python -c "import twyn; twyn.check_dependencies"
3740

38-
- name: Test --version flag
41+
- name: Test twyn as a cli tool
3942
run: |
43+
WHEEL_FILE=$(ls dist/*.whl | head -1)
44+
echo "Installing wheel: `$WHEEL_FILE` with `cli` extra."
45+
uv pip install "${WHEEL_FILE}[cli]"
4046
# Test that the CLI is available and --version works
4147
uv run twyn --version
4248

.github/workflows/security.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1
4040

4141
- name: Install the project
42-
run: uv sync --locked
42+
run: uv sync --locked --extra cli
4343

4444
- name: Run Twyn against our dependencies
4545
run: |

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.6.1
2525

2626
- name: Install the dependencies
27-
run: uv sync --locked --group dev --python ${{ matrix.python-version }}
27+
run: uv sync --locked --group dev --all-extras --python ${{ matrix.python-version }}
2828

2929
- name: Run tests
3030
run: uv run pytest tests

README.md

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@
1111

1212
- [Overview](#overview)
1313
- [Quickstart](#quickstart)
14-
- [Installation](#installation)
15-
- [Docker](#docker)
16-
- [Run](#run)
17-
- [JSON Format](#json-format)
14+
- [Using `Twyn` as a cli tool](#using-twyn-as-a-cli-tool)
15+
- [Installation](#installation)
16+
- [Docker](#docker)
17+
- [Run](#run)
18+
- [JSON Format](#json-format)
19+
- [Using `Twyn` as a library](#using-twyn-as-a-library)
20+
- [Logging level](#logging-level)
1821
- [Configuration](#configuration)
1922
- [Allowlist](#allowlist)
2023
- [Dependency files](#dependency-files)
2124
- [Check dependencies introduced through the CLI](#check-dependencies-introduced-through-the-cli)
2225
- [Selector method](#selector-method)
2326
- [Configuration file](#configuration-file)
2427
- [Cache](#cache)
25-
- [Using `Twyn` as a library](#using-twyn-as-a-library)
26-
- [Logging level](#logging-level)
28+
2729

2830
## Overview
2931
`Twyn` is a security tool that compares the name of your dependencies against a set of the most popular ones,
@@ -32,22 +34,23 @@ In short, `Twyn` protects you against [typosquatting attacks](https://en.wikiped
3234

3335
It works as follows:
3436

35-
1. Either choose to scan the dependencies in a dependencies file you specify (`--dependency-file`) or some dependencies introduced through the CLI (`--dependency`). If no option was provided, it will try to find a dependencies file in your working path.
37+
1. Either choose to scan the dependencies in a dependencies file you specify (`--dependency-file`) or some dependencies introduced through the CLI (`--dependency`). If no option was provided, it will try to find a dependencies file in your working path. It will try to parse all the supported dependency files that it finds. To know which files are supported head to the [Dependency files](#dependency-files) section.
3638
2. If the name of your package name matches with the name of one of the most well known packages, the package is accepted.
3739
3. If the name of your package is similar to the name of one of the most used packages, `Twyn` will prompt an error.
3840
4. If your package name is not in the list of the most known ones and is not similar enough to any of those to be considered misspelled, the package is accepted. `Twyn` assumes that you're using either a not so popular package (therefore it can't verify its legitimacy) or a package created by yourself, therefore unknown for the rest.
3941

4042
## Quickstart
4143

42-
### Installation
44+
### Using twyn as a CLI tool
45+
#### Installation
4346

4447
`Twyn` is available on PyPi repository, you can install it by running
4548

4649
```sh
47-
pip install twyn
50+
pip install twyn[cli]
4851
```
4952

50-
### Docker
53+
#### Docker
5154

5255
`Twyn` provides a Docker image, which can be found [here](https://hub.docker.com/r/elementsinteractive/twyn).
5356

@@ -58,7 +61,7 @@ docker pull elementsinteractive/twyn:latest
5861
docker run elementsinteractive/twyn --help
5962
```
6063

61-
### Run
64+
#### Run
6265

6366
To run twyn simply type:
6467

@@ -72,7 +75,7 @@ For a list of all the available options as well as their expected arguments run:
7275
twyn run --help
7376
```
7477

75-
### JSON format
78+
#### JSON format
7679
If you want your output in JSON format, you can run `Twyn` with the following flag:
7780

7881
```python
@@ -84,12 +87,43 @@ This will output:
8487
{"errors":[{"dependency":"reqests","similars":["requests","grequests"]}]}
8588
```
8689

90+
### Using Twyn as a library
91+
92+
93+
#### Installation
94+
`Twyn` also supports being used as 3rd party library for you project. To install it, run:
95+
96+
97+
```sh
98+
pip install twyn
99+
```
100+
101+
Example usage in your code:
102+
103+
```python
104+
from twyn import check_dependencies
105+
106+
typos = check_dependencies()
107+
108+
for typo in typos.errors:
109+
print(f"Dependency:{typo.dependency}")
110+
print(f"Did you mean any of [{','.join(typo.similars)}]")
111+
112+
```
113+
114+
#### Logging level
115+
By default, logging is disabled when running as a 3rd party library. To override this behaviour, you can:
116+
117+
```python
118+
logging.basicConfig(level=logging.INFO)
119+
logging.getLogger("twyn").setLevel(logging.INFO)
120+
```
121+
87122
## Configuration
88123

89124
### Allowlist
90125

91-
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.
92-
You can then add this packages to the `allowlist`, so it will be skipped:
126+
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:
93127

94128
```sh
95129
twyn allowlist add <package>
@@ -201,24 +235,3 @@ To clear the cache, run:
201235
```
202236

203237

204-
### Using Twyn as a library
205-
206-
`Twyn` also supports being used as 3rd party library for you project.
207-
208-
```python
209-
from twyn import check_dependencies
210-
211-
typos = check_dependencies()
212-
213-
for typo in typos.errors:
214-
print(f"Dependency:{typo.dependency}")
215-
print(f"Did you mean any of [{','.join(typo.similars)}]")
216-
217-
```
218-
### Logging level
219-
To override the logging level when using `Twyn` as a 3rd party library, simply override it like:
220-
221-
```python
222-
logging.basicConfig(level=logging.DEBUG)
223-
logging.getLogger("twyn").setLevel(logging.DEBUG)
224-
```

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ help:
1616
venv:
1717
@if ! {{ venv-exists }}; \
1818
then \
19-
uv sync --frozen --all-groups; \
19+
uv sync --frozen --all-extras --all-groups; \
2020
fi
2121

2222
# Cleans all artifacts generated while running this project, including the virtualenv.

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ maintainers = [
1212
requires-python = "<4,>=3.9"
1313
dependencies = [
1414
"requests<3.0.0,>=2.32.4",
15-
"click<9.0.0,>=8.1.8",
16-
"rich<15.0.0,>=14.0.0",
1715
"rapidfuzz<4.0.0,>=2.13.7",
1816
"pyparsing<4.0.0,>=3.2.3",
1917
"tomlkit<0.14.0,>=0.11.6",
@@ -26,6 +24,13 @@ description = "Security tool against dependency typosquatting attacks"
2624
readme = "README.md"
2725
dynamic = ["version"]
2826

27+
[project.optional-dependencies]
28+
cli = [
29+
"click<9.0.0,>=8.1.8",
30+
"rich<15.0.0,>=14.0.0",
31+
]
32+
33+
2934
[tool.hatch.version]
3035
path = "VERSION"
3136
pattern = "v(?P<version>[^\\s]+)"

src/twyn/base/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
1212

1313

14+
MANUAL_INPUT_SOURCE = "manual_input"
15+
1416
SELECTOR_METHOD_MAPPING: dict[str, type[selectors.AbstractSelector]] = {
1517
"first-letter": selectors.FirstLetterExact,
1618
"nearby-letter": selectors.FirstLetterNearbyInKeyboard,

src/twyn/base/exceptions.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import logging
22
from typing import IO, Any, Optional
33

4-
import click
5-
64
logger = logging.getLogger("twyn")
75

86

@@ -19,14 +17,20 @@ def __init__(self, message: str = "") -> None:
1917
super().__init__(message or self.message)
2018

2119

22-
class CliError(click.ClickException):
23-
"""Error that will populate application errors to stdout. It does not inherit from `TwynError`."""
20+
try:
21+
import click
2422

25-
message = "CLI error"
23+
class CliError(click.ClickException):
24+
"""Error that will populate application errors to stdout. It does not inherit from `TwynError`."""
2625

27-
def __init__(self, message: str = "") -> None:
28-
super().__init__(message)
26+
message = "CLI error"
27+
28+
def __init__(self, message: str = "") -> None:
29+
super().__init__(message)
30+
31+
def show(self, file: Optional[IO[Any]] = None) -> None:
32+
logger.debug(self.format_message(), exc_info=True)
33+
logger.error(self.format_message(), exc_info=False)
2934

30-
def show(self, file: Optional[IO[Any]] = None) -> None:
31-
logger.debug(self.format_message(), exc_info=True)
32-
logger.error(self.format_message(), exc_info=False)
35+
except ModuleNotFoundError:
36+
pass

src/twyn/cli.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,38 @@
22
import sys
33
from typing import Optional
44

5-
import click
6-
from rich.console import Console
7-
from rich.logging import RichHandler
8-
95
from twyn.__version__ import __version__
106
from twyn.base.constants import (
117
DEFAULT_PROJECT_TOML_FILE,
128
DEPENDENCY_FILE_MAPPING,
139
SELECTOR_METHOD_MAPPING,
1410
)
15-
from twyn.base.exceptions import CliError, TwynError
11+
from twyn.base.exceptions import TwynError
1612
from twyn.config.config_handler import ConfigHandler
1713
from twyn.file_handler.file_handler import FileHandler
1814
from twyn.main import check_dependencies
1915
from twyn.trusted_packages.cache_handler import CacheHandler
2016
from twyn.trusted_packages.constants import CACHE_DIR
2117

18+
try:
19+
import click
20+
from rich.console import Console
21+
from rich.logging import RichHandler
22+
23+
from twyn.base.exceptions import CliError
24+
except ImportError:
25+
print("Could not run twyn as a cli tool, some dependencies are missing! Run `pip install twyn[cli]`.")
26+
import sys
27+
28+
sys.exit(1)
29+
30+
logger = logging.getLogger("twyn")
31+
2232
logging.basicConfig(
2333
format="%(message)s",
2434
datefmt="[%X]",
2535
handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))],
2636
)
27-
logger = logging.getLogger("twyn")
2837

2938

3039
@click.group()
@@ -94,7 +103,7 @@ def entry_point() -> None:
94103
default=False,
95104
help="Display the results in json format. It implies --no-track.",
96105
)
97-
def run(
106+
def run( # noqa: C901
98107
config: str,
99108
dependency_file: Optional[str],
100109
dependency: tuple[str],
@@ -136,15 +145,16 @@ def run(
136145
raise CliError("Unhandled exception occured.") from e
137146

138147
if json:
139-
click.echo(possible_typos.model_dump_json())
140-
sys.exit(int(bool(possible_typos.errors)))
141-
elif possible_typos.errors:
142-
for possible_typosquats in possible_typos.errors:
143-
click.echo(
144-
click.style("Possible typosquat detected: ", fg="red") + f"`{possible_typosquats.dependency}`, "
145-
f"did you mean any of [{', '.join(possible_typosquats.similars)}]?",
146-
color=True,
147-
)
148+
click.echo(possible_typos.model_dump_json() or "{}")
149+
sys.exit(int(bool(possible_typos)))
150+
elif possible_typos:
151+
for possible_typosquats in possible_typos.results:
152+
for error in possible_typosquats.errors:
153+
click.echo(
154+
click.style("Possible typosquat detected: ", fg="red") + f"`{error.dependency}`, "
155+
f"did you mean any of [{', '.join(error.similars)}]?",
156+
color=True,
157+
)
148158
sys.exit(1)
149159
else:
150160
click.echo(click.style("No typosquats detected", fg="green"), color=True)

0 commit comments

Comments
 (0)