Skip to content

Commit 35133fd

Browse files
authored
feat: recursively look for lock files (#332)
1 parent 2a4f84d commit 35133fd

12 files changed

Lines changed: 219 additions & 88 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,6 @@ GitHub.sublime-settings
111111
.history
112112
/.ruff_cache/
113113
/.env.local
114+
115+
# Test lock files
116+
yarn.lock

README.md

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [Using `Twyn` as a cli tool](#using-twyn-as-a-cli-tool)
1515
- [Installation](#installation)
1616
- [Docker](#docker)
17+
- [CLI Options Reference](#cli-options-reference)
1718
- [Run](#run)
1819
- [JSON Format](#json-format)
1920
- [Using `Twyn` as a library](#using-twyn-as-a-library)
@@ -61,15 +62,29 @@ docker pull elementsinteractive/twyn:latest
6162
docker run elementsinteractive/twyn --help
6263
```
6364

65+
##### CLI Options Reference
66+
67+
| Option / Argument | Type / Values | Description |
68+
|--------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------|
69+
| `--config` | `str` (path) | Path to configuration file (`twyn.toml` or `pyproject.toml` by default). |
70+
| `--dependency-file` | `str` (path) | Dependency file to analyze. Supported: `requirements.txt`, `poetry.lock`, `uv.lock`, etc. |
71+
| `--dependency` | `str` (multiple allowed) | Dependency to analyze directly. Can be specified multiple times. |
72+
| `--selector-method` | `all`, `first-letter`, `nearby-letter` | Method for selecting possible typosquats. |
73+
| `--package-ecosystem` | `pypi`, `npm` | Package ecosystem for analysis. |
74+
| `-v` | flag | Enable info-level logging. |
75+
| `-vv` | flag | Enable debug-level logging. |
76+
| `--no-cache` | flag | Disable use of trusted packages cache. Always fetch from the source. |
77+
| `--no-track` | flag | Do not show the progress bar while processing packages. |
78+
| `--json` | flag | Display results in JSON format. Implies `--no-track`. |
79+
| `-r`, `--recursive` | flag | Scan directories recursively for dependency files. |
6480
#### Run
6581

66-
To run twyn simply type:
82+
**Usage Example:**
6783

6884
```sh
6985
twyn run <OPTIONS>
7086
```
71-
72-
For a list of all the available options as well as their expected arguments run:
87+
or get help with
7388

7489
```sh
7590
twyn run --help
@@ -90,41 +105,9 @@ If `Twyn` was run by manually giving it dependencies (with `--dependency`), the
90105

91106
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.
92107

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-
```
124108

125109
### Using Twyn as a library
126110

127-
128111
#### Installation
129112
`Twyn` also supports being used as 3rd party library for you project. To install it, run:
130113

@@ -154,6 +137,7 @@ logging.basicConfig(level=logging.INFO)
154137
logging.getLogger("twyn").setLevel(logging.INFO)
155138
```
156139

140+
157141
## Configuration
158142

159143
### Allowlist
@@ -268,5 +252,3 @@ To clear the cache, run:
268252
```python
269253
twyn run cache clear
270254
```
271-
272-

src/twyn/base/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
3636
DEFAULT_TWYN_TOML_FILE = "twyn.toml"
3737
DEFAULT_USE_CACHE = True
38+
DEFAULT_RECURSIVE = False
3839

3940

4041
PackageEcosystems: TypeAlias = Literal["pypi", "npm"]

src/twyn/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ def entry_point() -> None:
103103
default=False,
104104
help="Display the results in json format. It implies --no-track.",
105105
)
106+
@click.option(
107+
"-r",
108+
"--recursive",
109+
is_flag=True,
110+
default=False,
111+
help="Recursively look for files when trying to locate them automatically. Ignored if --dependency-file is given.",
112+
)
106113
def run( # noqa: C901
107114
config: str,
108115
dependency_file: Optional[str],
@@ -114,6 +121,7 @@ def run( # noqa: C901
114121
no_track: bool,
115122
json: bool,
116123
package_ecosystem: Optional[str],
124+
recursive: bool,
117125
) -> int:
118126
if vv:
119127
logger.setLevel(logging.DEBUG)
@@ -138,6 +146,7 @@ def run( # noqa: C901
138146
show_progress_bar=False if json else not no_track,
139147
load_config_from_file=True,
140148
package_ecosystem=package_ecosystem,
149+
recursive=recursive,
141150
)
142151
except TwynError as e:
143152
raise CliError(str(e)) from e

src/twyn/config/config_handler.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from twyn.base.constants import (
1010
DEFAULT_PROJECT_TOML_FILE,
11+
DEFAULT_RECURSIVE,
1112
DEFAULT_SELECTOR_METHOD,
1213
DEFAULT_TWYN_TOML_FILE,
1314
DEFAULT_USE_CACHE,
@@ -37,6 +38,7 @@ class TwynConfiguration:
3738
source: Optional[str]
3839
use_cache: bool
3940
package_ecosystem: Optional[PackageEcosystems]
41+
recursive: Optional[bool]
4042

4143

4244
@dataclass
@@ -49,6 +51,7 @@ class ReadTwynConfiguration:
4951
source: Optional[str] = None
5052
use_cache: Optional[bool] = None
5153
package_ecosystem: Optional[PackageEcosystems] = None
54+
recursive: Optional[bool] = None
5255

5356

5457
class ConfigHandler:
@@ -63,6 +66,7 @@ def resolve_config(
6366
dependency_file: Optional[str] = None,
6467
use_cache: Optional[bool] = None,
6568
package_ecosystem: Optional[PackageEcosystems] = None,
69+
recursive: Optional[bool] = None,
6670
) -> TwynConfiguration:
6771
"""Resolve the configuration for Twyn.
6872
@@ -95,13 +99,21 @@ def resolve_config(
9599
else:
96100
final_use_cache = DEFAULT_USE_CACHE
97101

102+
if recursive is not None:
103+
final_recursive = use_cache
104+
elif read_config.recursive is not None:
105+
final_recursive = read_config.recursive
106+
else:
107+
final_recursive = DEFAULT_RECURSIVE
108+
98109
return TwynConfiguration(
99110
dependency_file=dependency_file or read_config.dependency_file,
100111
selector_method=final_selector_method,
101112
allowlist=read_config.allowlist,
102113
source=read_config.source,
103114
use_cache=final_use_cache,
104115
package_ecosystem=package_ecosystem or read_config.package_ecosystem,
116+
recursive=final_recursive,
105117
)
106118

107119
def add_package_to_allowlist(self, package_name: str) -> None:

src/twyn/dependency_parser/dependency_selector.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from pathlib import Path
23
from typing import Optional
34

45
from twyn.base.constants import DEPENDENCY_FILE_MAPPING
@@ -11,21 +12,28 @@
1112

1213

1314
class DependencySelector:
14-
def __init__(self, dependency_file: Optional[str] = None) -> None:
15+
def __init__(self, dependency_file: Optional[str] = None, root_path: str = ".") -> None:
1516
self.dependency_file = dependency_file or ""
17+
self.root_path = root_path
1618

1719
def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
1820
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)
21+
root = Path(self.root_path)
22+
for path in root.rglob("*"):
23+
if ".git" in path.parts:
24+
continue
25+
if path.is_file():
26+
for known_file, dependency_parser in DEPENDENCY_FILE_MAPPING.items():
27+
if path.name == known_file:
28+
file_parser = dependency_parser(str(path))
29+
if file_parser.file_exists():
30+
parsers.append(file_parser)
31+
logger.debug("Assigned %s parser for local dependencies file at %s.", file_parser, path)
2432

2533
if not parsers:
2634
raise NoMatchingParserError
2735

28-
logger.debug("Dependencies file found")
36+
logger.debug("Dependencies file(s) found: %s", [str(p.file_path) for p in parsers])
2937
return parsers
3038

3139
def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:

src/twyn/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def check_dependencies(
4343
show_progress_bar: bool = False,
4444
load_config_from_file: bool = False,
4545
package_ecosystem: Optional[PackageEcosystems] = None,
46+
recursive: Optional[bool] = None,
4647
) -> TyposquatCheckResults:
4748
"""
4849
Check if the provided dependencies are potential typosquats of trusted packages.
@@ -70,6 +71,7 @@ def check_dependencies(
7071
dependency_file=dependency_file,
7172
use_cache=use_cache,
7273
package_ecosystem=package_ecosystem,
74+
recursive=recursive,
7375
)
7476
maybe_cache_handler = CacheHandler() if config.use_cache else None
7577
selector_method_obj = _get_selector_method(config.selector_method)
@@ -85,9 +87,17 @@ def check_dependencies(
8587
dependencies=dependencies,
8688
)
8789

90+
# The following checks do not result in an error to avoid inconsistencies.
91+
# If the user has set in the config file a setting that would conflict with a cli provided option
92+
# it would always result in an execution error rather than in overriding the behaviour.
8893
if config.package_ecosystem:
8994
logger.warning("`package_ecosystem` is not supported when reading dependencies from files. It will be ignored.")
9095

96+
if config.dependency_file and config.recursive:
97+
logger.warning(
98+
"`--recursive` has been set together with `--dependency-file`. `--dependency-file` will take precedence."
99+
)
100+
91101
return _analyze_packages_from_source(
92102
selector_method=selector_method_obj,
93103
source=config.source,
@@ -155,6 +165,7 @@ def _analyze_packages_from_source(
155165
typos_by_file = TyposquatCheckResults()
156166
for dependency_manager, parsers in dependency_managers.items():
157167
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
168+
158169
packages_from_source = top_package_reference.get_packages()
159170
trusted_packages = TrustedPackages(
160171
names=packages_from_source,
@@ -259,6 +270,7 @@ def _get_config(
259270
dependency_file: Optional[str],
260271
use_cache: Optional[bool],
261272
package_ecosystem: Optional[PackageEcosystems],
273+
recursive: Optional[bool],
262274
) -> TwynConfiguration:
263275
"""Given the arguments passed to the main function and the configuration loaded from the config file (if any), return a config object."""
264276
if load_config_from_file:
@@ -270,4 +282,5 @@ def _get_config(
270282
dependency_file=dependency_file,
271283
use_cache=use_cache,
272284
package_ecosystem=package_ecosystem,
285+
recursive=recursive,
273286
)

tests/config/test_config_handler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None:
4242
source=None,
4343
use_cache=True,
4444
package_ecosystem=None,
45+
recursive=False,
4546
)
4647

4748
def test_config_raises_for_unknown_file(self) -> None:
@@ -103,6 +104,7 @@ def test_write_toml(self, pyproject_toml_file: Path) -> None:
103104
"selector_method": "all",
104105
"allowlist": {},
105106
"use_cache": False,
107+
"recursive": False,
106108
},
107109
}
108110
}

tests/conftest.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import datetime
2-
from collections.abc import Iterator
2+
from collections.abc import Generator, Iterator
33
from contextlib import contextmanager
44
from pathlib import Path
5+
from typing import Any
56
from unittest import mock
67

78
import pytest
@@ -21,10 +22,8 @@ def patch_pypi_packages_download(packages: list[str]) -> Iterator[mock.Mock]:
2122
Replaces call with the output you would get from downloading the top PyPi packages list.
2223
"""
2324
json_response = {"packages": packages, "date": datetime.datetime.now().isoformat()}
24-
2525
with mock.patch("twyn.trusted_packages.TopPyPiReference._download") as mock_download:
2626
mock_download.return_value = json_response
27-
2827
yield mock_download
2928

3029

@@ -447,3 +446,10 @@ def yarn_lock_file_v2(tmp_path: Path) -> Iterator[Path]:
447446
"""
448447
with create_tmp_file(yarn_file, data) as tmp_file:
449448
yield tmp_file
449+
450+
451+
@pytest.fixture(autouse=True)
452+
def fail_on_requests_get(request) -> Generator[None, Any, None]:
453+
with mock.patch("requests.get") as m_get:
454+
m_get.side_effect = RuntimeError("`requests.get()` was called!")
455+
yield

0 commit comments

Comments
 (0)