Skip to content

Commit 2a4f84d

Browse files
authored
feat: support parsing multiple files (#326)
1 parent cde36f7 commit 2a4f84d

13 files changed

Lines changed: 506 additions & 171 deletions

File tree

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ In short, `Twyn` protects you against [typosquatting attacks](https://en.wikiped
3434

3535
It works as follows:
3636

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.
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.
3838
2. If the name of your package name matches with the name of one of the most well known packages, the package is accepted.
3939
3. If the name of your package is similar to the name of one of the most used packages, `Twyn` will prompt an error.
4040
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.
@@ -84,8 +84,43 @@ If you want your output in JSON format, you can run `Twyn` with the following fl
8484
This will output:
8585

8686
```json
87-
{"errors":[{"dependency":"reqests","similars":["requests","grequests"]}]}
87+
{"results":[{"errors":[{"dependency":"my-package","similars":["mypackage"]}],"source":"manual_input"}]}
8888
```
89+
If `Twyn` was run by manually giving it dependencies (with `--dependency`), the source will be `manual_input`.
90+
91+
In any other case (when dependencies are parsed from a file), the source will be the path to the dependencies file. One entry will be created for every source.
92+
93+
### Using Twyn as a library
94+
95+
96+
#### Installation
97+
`Twyn` also supports being used as 3rd party library for you project. To install it, run:
98+
99+
100+
```sh
101+
pip install twyn
102+
```
103+
104+
Example usage in your code:
105+
106+
```python
107+
from twyn import check_dependencies
108+
109+
typos = check_dependencies()
110+
111+
for typo in typos.errors:
112+
print(f"Dependency:{typo.dependency}")
113+
print(f"Did you mean any of [{','.join(typo.similars)}]")
114+
115+
```
116+
117+
#### Logging level
118+
By default, logging is disabled when running as a 3rd party library. To override this behaviour, you can:
119+
120+
```python
121+
logging.basicConfig(level=logging.INFO)
122+
logging.getLogger("twyn").setLevel(logging.INFO)
123+
```
89124

90125
### Using Twyn as a library
91126

dependencies/scripts/download_packages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def download(ecosystem: str) -> None:
6161
):
6262
with attempt, httpx.Client(timeout=30) as client:
6363
logger.info("Attempting to download %s packages. Attempt #%d.", ecosystem, attempt.num)
64-
response = client.get(ECOSYSTEMS[ecosystem]["url"])
64+
response = client.get(str(ECOSYSTEMS[ecosystem]["url"]))
6565
response.raise_for_status()
6666

6767
fpath = Path("dependencies") / f"{ecosystem}.json"

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/cli.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def entry_point() -> None:
103103
default=False,
104104
help="Display the results in json format. It implies --no-track.",
105105
)
106-
def run(
106+
def run( # noqa: C901
107107
config: str,
108108
dependency_file: Optional[str],
109109
dependency: tuple[str],
@@ -146,14 +146,15 @@ def run(
146146

147147
if json:
148148
click.echo(possible_typos.model_dump_json())
149-
sys.exit(int(bool(possible_typos.errors)))
150-
elif possible_typos.errors:
151-
for possible_typosquats in possible_typos.errors:
152-
click.echo(
153-
click.style("Possible typosquat detected: ", fg="red") + f"`{possible_typosquats.dependency}`, "
154-
f"did you mean any of [{', '.join(possible_typosquats.similars)}]?",
155-
color=True,
156-
)
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+
)
157158
sys.exit(1)
158159
else:
159160
click.echo(click.style("No typosquats detected", fg="green"), color=True)

src/twyn/dependency_parser/dependency_selector.py

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from twyn.base.constants import DEPENDENCY_FILE_MAPPING
55
from twyn.dependency_parser.exceptions import (
6-
MultipleParsersError,
76
NoMatchingParserError,
87
)
98
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
@@ -15,46 +14,36 @@ class DependencySelector:
1514
def __init__(self, dependency_file: Optional[str] = None) -> None:
1615
self.dependency_file = dependency_file or ""
1716

18-
@staticmethod
19-
def _raise_for_selected_parsers(parsers: list[type[AbstractParser]]) -> None:
20-
if len(parsers) > 1:
21-
raise MultipleParsersError
17+
def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
18+
parsers: list[AbstractParser] = []
19+
for dependency_parser in DEPENDENCY_FILE_MAPPING.values():
20+
file_parser = dependency_parser()
21+
if file_parser.file_exists():
22+
parsers.append(file_parser)
23+
logger.debug("Assigned %s parser for local dependencies file.", file_parser)
2224

2325
if not parsers:
2426
raise NoMatchingParserError
2527

26-
def auto_detect_dependency_file_parser(self) -> type[AbstractParser]:
27-
parsers: list[AbstractParser] = [
28-
dependency_parser
29-
for dependency_parser in DEPENDENCY_FILE_MAPPING.values()
30-
if dependency_parser().file_exists()
31-
]
32-
self._raise_for_selected_parsers(parsers)
33-
self.dependency_file = parsers[0]().file_path
3428
logger.debug("Dependencies file found")
35-
return parsers[0]
29+
return parsers
3630

37-
def get_dependency_file_parser_from_file_name(
38-
self,
39-
) -> type[AbstractParser]:
31+
def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:
4032
parsers = []
4133
for known_dependency_file_name in DEPENDENCY_FILE_MAPPING:
4234
if self.dependency_file.endswith(known_dependency_file_name):
43-
parsers.append(DEPENDENCY_FILE_MAPPING[known_dependency_file_name])
35+
file_parser = DEPENDENCY_FILE_MAPPING[known_dependency_file_name](self.dependency_file)
36+
parsers.append(file_parser)
4437

45-
self._raise_for_selected_parsers(parsers)
46-
return parsers[0]
38+
if not parsers:
39+
raise NoMatchingParserError
4740

48-
def get_dependency_parser(self) -> AbstractParser:
41+
return parsers
42+
43+
def get_dependency_parsers(self) -> list[AbstractParser]:
4944
if self.dependency_file:
5045
logger.debug("Dependency file provided. Assigning a parser.")
51-
dependency_file_parser = self.get_dependency_file_parser_from_file_name()
52-
file_parser = dependency_file_parser(self.dependency_file)
53-
else:
54-
logger.debug("No dependency file provided. Attempting to locate one.")
55-
dependency_file_parser = self.auto_detect_dependency_file_parser()
56-
file_parser = dependency_file_parser()
57-
58-
logger.debug("Assigned %s parser for local dependencies file.", file_parser)
46+
return self.get_dependency_file_parsers_from_file_name()
5947

60-
return file_parser
48+
logger.debug("No dependency file provided. Attempting to locate one.")
49+
return self.auto_detect_dependency_file_parser()

0 commit comments

Comments
 (0)