Skip to content

Commit 6d68284

Browse files
committed
feat: add support for package-lock.json files
BREAKING CHANGE
1 parent d2708fc commit 6d68284

27 files changed

Lines changed: 840 additions & 222 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The following dependency file formats are supported:
113113
- `requirements.txt`
114114
- `poetry.lock`
115115
- `uv.lock`
116+
- `package-lock.json` (v1, v2, v3)
116117

117118
### Check dependencies introduced through the CLI
118119

@@ -166,7 +167,7 @@ dependency_file="/my/path/requirements.txt"
166167
selector_method="first_letter"
167168
logging_level="debug"
168169
allowlist=["my_package"]
169-
pypi_reference="https://mirror-with-trusted-dependencies.com/file.json"
170+
source="https://mirror-with-trusted-dependencies.com/file.json"
170171
```
171172

172173
> [!WARNING]

src/twyn/base/constants.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from typing import TYPE_CHECKING, Literal
44

5+
from typing_extensions import TypeAlias
6+
57
from twyn import dependency_parser
68
from twyn.trusted_packages import selectors
79

810
if TYPE_CHECKING:
9-
from twyn.dependency_parser.abstract_parser import AbstractParser
11+
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
1012

1113

1214
SELECTOR_METHOD_MAPPING: dict[str, type[selectors.AbstractSelector]] = {
@@ -19,13 +21,17 @@
1921
SelectorMethod = Literal["first-letter", "nearby-letter", "all"]
2022

2123
DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = {
22-
"requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser,
23-
"poetry.lock": dependency_parser.lock_parser.PoetryLockParser,
24-
"uv.lock": dependency_parser.lock_parser.UvLockParser,
24+
"requirements.txt": dependency_parser.RequirementsTxtParser,
25+
"poetry.lock": dependency_parser.PoetryLockParser,
26+
"uv.lock": dependency_parser.UvLockParser,
27+
"package-lock.json": dependency_parser.PackageLockJsonParser,
2528
}
2629

30+
2731
DEFAULT_SELECTOR_METHOD = "all"
2832
DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
2933
DEFAULT_TWYN_TOML_FILE = "twyn.toml"
30-
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
3134
DEFAULT_USE_CACHE = True
35+
36+
37+
PackageManagers: TypeAlias = Literal["pypi", "npm"]

src/twyn/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ def entry_point() -> None:
6060
"against all of those in the reference."
6161
),
6262
)
63+
@click.option(
64+
"--language",
65+
type=click.Choice(["pypi", "npm"]),
66+
default=None,
67+
help="Programming language for dependency analysis (pypi or npm). Overrides auto-detection.",
68+
)
6369
@click.option(
6470
"-v",
6571
default=False,
@@ -98,6 +104,7 @@ def run(
98104
no_cache: Optional[bool],
99105
no_track: bool,
100106
json: bool,
107+
language: Optional[str],
101108
) -> int:
102109
if vv:
103110
logger.setLevel(logging.DEBUG)
@@ -121,6 +128,7 @@ def run(
121128
use_cache=not no_cache if no_cache is not None else no_cache,
122129
show_progress_bar=False if json else not no_track,
123130
load_config_from_file=True,
131+
package_manager=language,
124132
)
125133
except TwynError as e:
126134
raise CliError(str(e)) from e

src/twyn/config/config_handler.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
from twyn.base.constants import (
1010
DEFAULT_PROJECT_TOML_FILE,
1111
DEFAULT_SELECTOR_METHOD,
12-
DEFAULT_TOP_PYPI_PACKAGES,
1312
DEFAULT_TWYN_TOML_FILE,
1413
DEFAULT_USE_CACHE,
1514
SELECTOR_METHOD_KEYS,
15+
PackageManagers,
1616
)
1717
from twyn.config.exceptions import (
1818
AllowlistPackageAlreadyExistsError,
@@ -34,8 +34,9 @@ class TwynConfiguration:
3434
dependency_file: Optional[str]
3535
selector_method: str
3636
allowlist: set[str]
37-
pypi_reference: str
37+
source: Optional[str]
3838
use_cache: bool
39+
package_manager: Optional[PackageManagers]
3940

4041

4142
@dataclass
@@ -45,8 +46,9 @@ class ReadTwynConfiguration:
4546
dependency_file: Optional[str] = None
4647
selector_method: Optional[str] = None
4748
allowlist: set[str] = field(default_factory=set)
48-
pypi_reference: Optional[str] = None
49+
source: Optional[str] = None
4950
use_cache: Optional[bool] = None
51+
package_manager: Optional[PackageManagers] = None
5052

5153

5254
class ConfigHandler:
@@ -60,6 +62,7 @@ def resolve_config(
6062
selector_method: Optional[str] = None,
6163
dependency_file: Optional[str] = None,
6264
use_cache: Optional[bool] = None,
65+
package_manager: Optional[PackageManagers] = None,
6366
) -> TwynConfiguration:
6467
"""Resolve the configuration for Twyn.
6568
@@ -96,8 +99,9 @@ def resolve_config(
9699
dependency_file=dependency_file or read_config.dependency_file,
97100
selector_method=final_selector_method,
98101
allowlist=read_config.allowlist,
99-
pypi_reference=read_config.pypi_reference or DEFAULT_TOP_PYPI_PACKAGES,
102+
source=read_config.source,
100103
use_cache=final_use_cache,
104+
package_manager=package_manager or read_config.package_manager,
101105
)
102106

103107
def add_package_to_allowlist(self, package_name: str) -> None:
@@ -129,7 +133,7 @@ def _get_read_config(self, toml: TOMLDocument) -> ReadTwynConfiguration:
129133
dependency_file=twyn_config_data.get("dependency_file"),
130134
selector_method=twyn_config_data.get("selector_method"),
131135
allowlist=set(twyn_config_data.get("allowlist", set())),
132-
pypi_reference=twyn_config_data.get("pypi_reference"),
136+
source=twyn_config_data.get("source"),
133137
use_cache=twyn_config_data.get("use_cache"),
134138
)
135139

src/twyn/dependency_managers/__init__.py

Whitespace-only changes.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
4+
from dparse import filetypes
5+
6+
from twyn.dependency_managers.exceptions import NoMatchingDependencyManagerError
7+
from twyn.dependency_parser.parsers.constants import PACKAGE_LOCK_JSON, UV_LOCK
8+
from twyn.trusted_packages.references import AbstractPackageReference, TopNpmReference, TopPyPiReference
9+
10+
11+
@dataclass
12+
class BaseDependencyManager:
13+
"""Base class for all `DependencyManagers`.
14+
15+
It acts as a repository, linking programming languages with trusted packages sources and dependency file names.
16+
"""
17+
18+
name: str
19+
trusted_packages_source: type[AbstractPackageReference]
20+
dependency_files: set[str]
21+
22+
@classmethod
23+
def matches_dependency_file(cls, dependency_file: str) -> bool:
24+
return Path(dependency_file).name in cls.dependency_files
25+
26+
@classmethod
27+
def matches_language_name(cls, name: str) -> bool:
28+
return cls.name == Path(name).name.lower()
29+
30+
31+
@dataclass
32+
class PypiDependencyManager(BaseDependencyManager):
33+
name = "pypi"
34+
trusted_packages_source = TopPyPiReference
35+
dependency_files = {UV_LOCK, filetypes.poetry_lock, filetypes.requirements_txt}
36+
37+
38+
@dataclass
39+
class NpmDependencyManager(BaseDependencyManager):
40+
name = "npm"
41+
trusted_packages_source = TopNpmReference
42+
dependency_files = {PACKAGE_LOCK_JSON}
43+
44+
45+
DEPENDENCY_MANAGERS: list[type[BaseDependencyManager]] = [PypiDependencyManager, NpmDependencyManager]
46+
PACKAGE_MANAGERS = {x.name for x in DEPENDENCY_MANAGERS}
47+
48+
49+
def get_dependency_manager_from_file(dependency_file: str) -> type[BaseDependencyManager]:
50+
for manager in DEPENDENCY_MANAGERS:
51+
if manager.matches_dependency_file(dependency_file):
52+
return manager
53+
raise NoMatchingDependencyManagerError
54+
55+
56+
def get_dependency_manager_from_name(name: str) -> type[BaseDependencyManager]:
57+
for manager in DEPENDENCY_MANAGERS:
58+
if manager.matches_language_name(name):
59+
return manager
60+
raise NoMatchingDependencyManagerError
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from twyn.base.exceptions import TwynError
2+
3+
4+
class NoMatchingDependencyManagerError(TwynError):
5+
"""Error for when a DependencyManger cannot be retrieved based on the provided arguments."""
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Dependency parsers."""
22

3-
from twyn.dependency_parser.lock_parser import PoetryLockParser, UvLockParser
4-
from twyn.dependency_parser.requirements_txt_parser import RequirementsTxtParser
3+
from twyn.dependency_parser.parsers.lock_parser import PoetryLockParser, UvLockParser
4+
from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser
5+
from twyn.dependency_parser.parsers.requirements_txt_parser import RequirementsTxtParser
56

6-
__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser"]
7+
__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser", "PackageLockJsonParser"]

src/twyn/dependency_parser/constants.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/twyn/dependency_parser/dependency_selector.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
from typing import Optional
33

44
from twyn.base.constants import DEPENDENCY_FILE_MAPPING
5-
from twyn.dependency_parser.abstract_parser import AbstractParser
65
from twyn.dependency_parser.exceptions import (
76
MultipleParsersError,
87
NoMatchingParserError,
98
)
9+
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
1010

1111
logger = logging.getLogger("twyn")
1212

@@ -24,12 +24,13 @@ def _raise_for_selected_parsers(parsers: list[type[AbstractParser]]) -> None:
2424
raise NoMatchingParserError
2525

2626
def auto_detect_dependency_file_parser(self) -> type[AbstractParser]:
27-
parsers = [
27+
parsers: list[AbstractParser] = [
2828
dependency_parser
2929
for dependency_parser in DEPENDENCY_FILE_MAPPING.values()
3030
if dependency_parser().file_exists()
3131
]
3232
self._raise_for_selected_parsers(parsers)
33+
self.dependency_file = parsers[0]().file_path
3334
logger.debug("Dependencies file found")
3435
return parsers[0]
3536

@@ -45,8 +46,6 @@ def get_dependency_file_parser_from_file_name(
4546
return parsers[0]
4647

4748
def get_dependency_parser(self) -> AbstractParser:
48-
logger.debug("Dependency file: %s", self.dependency_file)
49-
5049
if self.dependency_file:
5150
logger.debug("Dependency file provided. Assigning a parser.")
5251
dependency_file_parser = self.get_dependency_file_parser_from_file_name()

0 commit comments

Comments
 (0)