Skip to content

Commit 02c500e

Browse files
committed
feat: recursively look for lock files
1 parent 626c643 commit 02c500e

11 files changed

Lines changed: 121 additions & 55 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: 20 additions & 17 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,21 +62,35 @@ 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
7691
```
7792

78-
#### JSON format
93+
##### JSON format
7994
If you want your output in JSON format, you can run `Twyn` with the following flag:
8095

8196
```python
@@ -90,8 +105,8 @@ 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
94108

109+
### Using Twyn as a library
95110

96111
#### Installation
97112
`Twyn` also supports being used as 3rd party library for you project. To install it, run:
@@ -133,16 +148,6 @@ logging.getLogger("twyn").setLevel(logging.INFO)
133148
pip install twyn
134149
```
135150

136-
Example usage in your code:
137-
138-
```python
139-
from twyn import check_dependencies
140-
141-
typos = check_dependencies()
142-
143-
for typo in typos.errors:
144-
print(f"Dependency:{typo.dependency}")
145-
print(f"Did you mean any of [{','.join(typo.similars)}]")
146151

147152
```
148153
@@ -268,5 +273,3 @@ To clear the cache, run:
268273
```python
269274
twyn run cache clear
270275
```
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="Display the results in json format. It implies --no-track.",
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: 16 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,29 @@
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 path.is_dir() and path.name == ".git":
24+
# Skip .git directory and its contents
25+
continue
26+
if path.is_file():
27+
for known_file, dependency_parser in DEPENDENCY_FILE_MAPPING.items():
28+
if path.name == known_file:
29+
file_parser = dependency_parser(str(path))
30+
if file_parser.file_exists():
31+
parsers.append(file_parser)
32+
logger.debug("Assigned %s parser for local dependencies file at %s.", file_parser, path)
2433

2534
if not parsers:
2635
raise NoMatchingParserError
2736

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

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

src/twyn/main.py

Lines changed: 4 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)
@@ -259,6 +261,7 @@ def _get_config(
259261
dependency_file: Optional[str],
260262
use_cache: Optional[bool],
261263
package_ecosystem: Optional[PackageEcosystems],
264+
recursive: Optional[bool],
262265
) -> TwynConfiguration:
263266
"""Given the arguments passed to the main function and the configuration loaded from the config file (if any), return a config object."""
264267
if load_config_from_file:
@@ -270,4 +273,5 @@ def _get_config(
270273
dependency_file=dependency_file,
271274
use_cache=use_cache,
272275
package_ecosystem=package_ecosystem,
276+
recursive=recursive,
273277
)

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
}
Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from pathlib import Path
12
from unittest.mock import Mock, patch
23

34
import pytest
@@ -7,6 +8,8 @@
78
NoMatchingParserError,
89
)
910
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
11+
from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser
12+
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser
1013

1114

1215
class TestDependencySelector:
@@ -22,6 +25,8 @@ class TestDependencySelector:
2225
("/some/path/poetry.lock", PoetryLockParser),
2326
("/some/path/uv.lock", UvLockParser),
2427
("/some/path/requirements.txt", RequirementsTxtParser),
28+
("/some/path/yarn.lock", YarnLockParser),
29+
("/some/path/package-lock.json", PackageLockJsonParser),
2530
],
2631
)
2732
def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]):
@@ -31,44 +36,19 @@ def test_get_dependency_parser(self, file_name: str, parser_class: type[Abstract
3136
assert isinstance(parser[0], parser_class)
3237
assert str(parser[0].file_handler.file_path).endswith(file_name)
3338

34-
@patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists")
35-
@patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists")
36-
@patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists")
37-
def test_get_dependency_parser_auto_detect_requirements_file(
38-
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
39-
):
40-
poetry_file_exists.return_value = False
41-
req_file_exists.return_value = True
42-
uv_file_exists.return_value = False
43-
44-
parser = DependencySelector("").get_dependency_parsers()
39+
def test_get_dependency_parser_auto_detect_requirements_file(self, requirements_txt_file: Path, tmp_path: Path):
40+
parser = DependencySelector("", root_path=str(tmp_path)).get_dependency_parsers()
4541
assert isinstance(parser[0], RequirementsTxtParser)
4642

47-
@patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists")
48-
@patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists")
49-
@patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists")
5043
def test_get_dependency_parser_auto_detect_poetry_lock_file(
51-
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
44+
self, poetry_lock_file_ge_1_5: Path, tmp_path: Path
5245
) -> None:
53-
poetry_file_exists.return_value = True
54-
req_file_exists.return_value = False
55-
uv_file_exists.return_value = False
56-
57-
selector = DependencySelector("")
46+
selector = DependencySelector("", root_path=str(tmp_path))
5847
parser = selector.get_dependency_parsers()
5948
assert isinstance(parser[0], PoetryLockParser)
6049

61-
@patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists")
62-
@patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists")
63-
@patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists")
64-
def test_get_dependency_parser_auto_detect_uv_lock_file(
65-
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
66-
) -> None:
67-
poetry_file_exists.return_value = False
68-
req_file_exists.return_value = False
69-
uv_file_exists.return_value = True
70-
71-
parser = DependencySelector("").get_dependency_parsers()
50+
def test_get_dependency_parser_auto_detect_uv_lock_file(self, uv_lock_file: Path, tmp_path: Path) -> None:
51+
parser = DependencySelector("", root_path=str(tmp_path)).get_dependency_parsers()
7252
assert isinstance(parser[0], UvLockParser)
7353

7454
@patch("twyn.dependency_parser.parsers.abstract_parser.AbstractParser.file_exists")
@@ -81,3 +61,29 @@ def test_auto_detect_dependency_file_parser_exceptions(self, file_exists: Mock)
8161
def test_get_dependency_file_parser_unknown_file_type(self, file_name: str) -> None:
8262
with pytest.raises(NoMatchingParserError):
8363
DependencySelector(file_name).get_dependency_file_parsers_from_file_name()
64+
65+
def test_auto_detect_dependency_file_parser_scans_subdirectories(self, tmp_path):
66+
# Create nested directories and dependency files
67+
subdir = tmp_path / "subdir"
68+
subdir.mkdir()
69+
70+
req_file = subdir / "requirements.txt"
71+
req_file.write_text("flask\n")
72+
poetry_file = tmp_path / "poetry.lock"
73+
poetry_file.write_text("[package]\nname = 'pytest'\n")
74+
75+
# Should not scan .git
76+
git_dir = tmp_path / ".git"
77+
git_dir.mkdir()
78+
git_file = git_dir / "requirements.txt"
79+
git_file.write_text("should not be found\n")
80+
81+
# Should find both files
82+
selector = DependencySelector(root_path=str(tmp_path))
83+
with patch(
84+
"twyn.base.constants.DEPENDENCY_FILE_MAPPING",
85+
{"requirements.txt": RequirementsTxtParser, "poetry.lock": PoetryLockParser},
86+
):
87+
parsers = selector.auto_detect_dependency_file_parser()
88+
found_files = {str(p.file_path.name) for p in parsers}
89+
assert found_files == {"requirements.txt", "poetry.lock"}

0 commit comments

Comments
 (0)