Skip to content

Commit 5f2efaf

Browse files
authored
feat: Accept a source for every dependency manager in the config (#340)
1 parent fb634a6 commit 5f2efaf

File tree

11 files changed

+90
-67
lines changed

11 files changed

+90
-67
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ dependency_file="/my/path/requirements.txt" # it can be either a string or a lis
222222
selector_method="first_letter"
223223
logging_level="debug"
224224
allowlist=["my_package"]
225-
source="https://mirror-with-trusted-dependencies.com/file.json"
225+
pypi_source="https://mirror-with-trusted-dependencies.com/file-pypi.json"
226+
npm_source="https://mirror-with-trusted-dependencies.com/file-npm.json"
226227
```
227228

228229
The file format for each reference is as follows:

src/twyn/config/config_handler.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ class TwynConfiguration:
3535
dependency_files: set[str]
3636
selector_method: str
3737
allowlist: set[str]
38-
source: Optional[str]
38+
pypi_source: Optional[str]
39+
npm_source: Optional[str]
3940
use_cache: bool
4041
package_ecosystem: Optional[PackageEcosystems]
4142
recursive: Optional[bool]
@@ -48,7 +49,8 @@ class ReadTwynConfiguration:
4849
dependency_files: Optional[set[str]] = field(default_factory=set)
4950
selector_method: Optional[str] = None
5051
allowlist: set[str] = field(default_factory=set)
51-
source: Optional[str] = None
52+
pypi_source: Optional[str] = None
53+
npm_source: Optional[str] = None
5254
use_cache: Optional[bool] = None
5355
package_ecosystem: Optional[PackageEcosystems] = None
5456
recursive: Optional[bool] = None
@@ -91,7 +93,6 @@ def resolve_config(
9193
raise InvalidSelectorMethodError(
9294
f"Invalid selector_method '{final_selector_method}'. Must be one of: {valid_methods}"
9395
)
94-
9596
if use_cache is not None:
9697
final_use_cache = use_cache
9798
elif read_config.use_cache is not None:
@@ -110,7 +111,8 @@ def resolve_config(
110111
dependency_files=dependency_files or read_config.dependency_files or set(),
111112
selector_method=final_selector_method,
112113
allowlist=read_config.allowlist,
113-
source=read_config.source,
114+
pypi_source=read_config.pypi_source,
115+
npm_source=read_config.npm_source,
114116
use_cache=final_use_cache,
115117
package_ecosystem=package_ecosystem or read_config.package_ecosystem,
116118
recursive=final_recursive,
@@ -158,7 +160,8 @@ def _get_read_config(self, toml: TOMLDocument) -> ReadTwynConfiguration:
158160
dependency_files=dependency_file,
159161
selector_method=twyn_config_data.get("selector_method"),
160162
allowlist=allowlist,
161-
source=twyn_config_data.get("source"),
163+
pypi_source=twyn_config_data.get("pypi_source"),
164+
npm_source=twyn_config_data.get("npm_source"),
162165
use_cache=twyn_config_data.get("use_cache"),
163166
package_ecosystem=twyn_config_data.get("package_ecosystem"),
164167
recursive=twyn_config_data.get("recursive"),
Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,7 @@
1-
from dataclasses import dataclass
2-
from pathlib import Path
3-
41
from twyn.dependency_managers.exceptions import NoMatchingDependencyManagerError
5-
from twyn.dependency_parser.parsers.constants import PACKAGE_LOCK_JSON, POETRY_LOCK, REQUIREMENTS_TXT, UV_LOCK
6-
from twyn.trusted_packages.references import AbstractPackageReference, TopNpmReference, TopPyPiReference
7-
8-
9-
@dataclass
10-
class BaseDependencyManager:
11-
"""Base class for all `DependencyManagers`.
12-
13-
It acts as a repository, linking programming languages with trusted packages sources and dependency file names.
14-
"""
15-
16-
name: str
17-
trusted_packages_source: type[AbstractPackageReference]
18-
dependency_files: set[str]
19-
20-
@classmethod
21-
def matches_dependency_file(cls, dependency_file: str) -> bool:
22-
return Path(dependency_file).name in cls.dependency_files
23-
24-
@classmethod
25-
def matches_language_name(cls, name: str) -> bool:
26-
return cls.name == Path(name).name.lower()
27-
28-
29-
@dataclass
30-
class PypiDependencyManager(BaseDependencyManager):
31-
name = "pypi"
32-
trusted_packages_source = TopPyPiReference
33-
dependency_files = {UV_LOCK, POETRY_LOCK, REQUIREMENTS_TXT}
34-
35-
36-
@dataclass
37-
class NpmDependencyManager(BaseDependencyManager):
38-
name = "npm"
39-
trusted_packages_source = TopNpmReference
40-
dependency_files = {PACKAGE_LOCK_JSON}
41-
2+
from twyn.dependency_managers.managers.base import BaseDependencyManager
3+
from twyn.dependency_managers.managers.npm_dependency_manager import NpmDependencyManager
4+
from twyn.dependency_managers.managers.pypi_dependency_manager import PypiDependencyManager
425

436
DEPENDENCY_MANAGERS: list[type[BaseDependencyManager]] = [PypiDependencyManager, NpmDependencyManager]
447
PACKAGE_ECOSYSTEMS = {x.name for x in DEPENDENCY_MANAGERS}
@@ -53,6 +16,6 @@ def get_dependency_manager_from_file(dependency_file: str) -> type[BaseDependenc
5316

5417
def get_dependency_manager_from_name(name: str) -> type[BaseDependencyManager]:
5518
for manager in DEPENDENCY_MANAGERS:
56-
if manager.matches_language_name(name):
19+
if manager.matches_ecosystem_name(name):
5720
return manager
5821
raise NoMatchingDependencyManagerError

src/twyn/dependency_managers/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33

44
class NoMatchingDependencyManagerError(TwynError):
55
"""Error for when a DependencyManger cannot be retrieved based on the provided arguments."""
6+
7+
8+
class MultipleSourcesError(TwynError):
9+
"""Error for when more than one alternative source matches the configuration."""

src/twyn/dependency_managers/managers/base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import dataclass
22
from pathlib import Path
3+
from typing import Optional
34

45
from twyn.trusted_packages.references.base import AbstractPackageReference
56

@@ -20,5 +21,11 @@ def matches_dependency_file(cls, dependency_file: str) -> bool:
2021
return Path(dependency_file).name in cls.dependency_files
2122

2223
@classmethod
23-
def matches_language_name(cls, name: str) -> bool:
24+
def matches_ecosystem_name(cls, name: str) -> bool:
2425
return cls.name == Path(name).name.lower()
26+
27+
@classmethod
28+
def get_alternative_source(cls, sources: dict[str, str]) -> Optional[str]:
29+
match = [x for x in sources if x == cls.name]
30+
31+
return sources[match[0]] if match else None

src/twyn/dependency_managers/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def get_dependency_manager_from_file(dependency_file: str) -> type[BaseDependenc
1313

1414
def get_dependency_manager_from_name(name: str) -> type[BaseDependencyManager]:
1515
for manager in DEPENDENCY_MANAGERS:
16-
if manager.matches_language_name(name):
16+
if manager.matches_ecosystem_name(name):
1717
return manager
1818
raise NoMatchingDependencyManagerError
1919

src/twyn/main.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ def check_dependencies(
7979
if dependencies: # Dependencies where input manually, will not read dependency files.
8080
return _analyze_dependencies_from_input(
8181
selector_method=selector_method_obj,
82-
source=config.source,
82+
pypi_source=config.pypi_source,
83+
npm_source=config.npm_source,
8384
maybe_cache_handler=maybe_cache_handler,
8485
allowlist=config.allowlist,
8586
show_progress_bar=show_progress_bar,
@@ -100,7 +101,8 @@ def check_dependencies(
100101

101102
return _analyze_packages_from_source(
102103
selector_method=selector_method_obj,
103-
source=config.source,
104+
pypi_source=config.pypi_source,
105+
npm_source=config.npm_source,
104106
maybe_cache_handler=maybe_cache_handler,
105107
allowlist=config.allowlist,
106108
show_progress_bar=show_progress_bar,
@@ -111,7 +113,8 @@ def check_dependencies(
111113
def _analyze_dependencies_from_input(
112114
package_ecosystem: Optional[PackageEcosystems],
113115
selector_method: SelectorMethod,
114-
source: Optional[str],
116+
pypi_source: Optional[str],
117+
npm_source: Optional[str],
115118
maybe_cache_handler: Optional[CacheHandler],
116119
dependencies: set[str],
117120
allowlist: set[str],
@@ -127,6 +130,7 @@ def _analyze_dependencies_from_input(
127130
raise InvalidArgumentsError("Not a valid `package_ecosystem`.")
128131

129132
dependency_manager = get_dependency_manager_from_name(package_ecosystem)
133+
source = dependency_manager.get_alternative_source({"pypi": pypi_source, "npm": npm_source})
130134
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
131135
trusted_packages = TrustedPackages(
132136
names=top_package_reference.get_packages(),
@@ -154,7 +158,8 @@ def _analyze_packages_from_source(
154158
selector_method: SelectorMethod,
155159
show_progress_bar: bool,
156160
dependency_files: Optional[set[str]],
157-
source: Optional[str],
161+
pypi_source: Optional[str],
162+
npm_source: Optional[str],
158163
maybe_cache_handler: Optional[CacheHandler],
159164
) -> TyposquatCheckResults:
160165
"""Analyze dependencies from a dependencies file.
@@ -165,6 +170,7 @@ def _analyze_packages_from_source(
165170

166171
dependency_managers = _get_dependency_managers_and_parsers_mapping(dependency_files)
167172
for dependency_manager, parsers in dependency_managers.items():
173+
source = dependency_manager.get_alternative_source({"pypi": pypi_source, "npm": npm_source})
168174
top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler)
169175

170176
packages_from_source = top_package_reference.get_packages()

tests/config/test_config_handler.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None:
3535
dependency_files=set(),
3636
selector_method="all",
3737
allowlist=set(),
38-
source=None,
38+
pypi_source=None,
39+
npm_source=None,
3940
use_cache=True,
4041
package_ecosystem=None,
4142
recursive=False,
@@ -61,7 +62,8 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file: Path) -> None:
6162
dependency_files={"my_file.txt", "my_other_file.txt"},
6263
selector_method="all",
6364
allowlist={"boto4", "boto2"},
64-
source=None,
65+
pypi_source=None,
66+
npm_source=None,
6567
use_cache=False,
6668
)
6769

@@ -138,7 +140,8 @@ def test_no_load_config_from_cache(self, pyproject_toml_file: Path) -> None:
138140
assert config.dependency_files == set()
139141
assert config.use_cache is True
140142
assert config.selector_method == DEFAULT_SELECTOR_METHOD
141-
assert config.source is None
143+
assert config.pypi_source is None
144+
assert config.npm_source is None
142145

143146
def test_cannot_write_if_file_not_configured(self) -> None:
144147
with pytest.raises(ConfigFileNotConfiguredError, match="write operation"):

tests/dependency_managers/__init__.py

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from unittest.mock import Mock
2+
3+
from twyn.dependency_managers.managers.base import BaseDependencyManager
4+
5+
6+
class DummyManager(BaseDependencyManager):
7+
name = "pypi"
8+
trusted_packages_source = Mock()
9+
dependency_files = {"requirements.txt", "poetry.lock"}
10+
11+
12+
class TestDependencyManager:
13+
def test_matches_dependency_file(self) -> None:
14+
assert DummyManager.matches_dependency_file("requirements.txt")
15+
assert DummyManager.matches_dependency_file("/some/path/poetry.lock")
16+
assert not DummyManager.matches_dependency_file("setup.py")
17+
18+
def test_matches_language_name(self) -> None:
19+
assert DummyManager.matches_ecosystem_name("pypi")
20+
assert not DummyManager.matches_ecosystem_name("npm")
21+
22+
def test_get_alternative_source_none(self) -> None:
23+
sources = {"npm": "npmjs.com"}
24+
assert DummyManager.get_alternative_source(sources) is None

0 commit comments

Comments
 (0)