Skip to content

Commit a2fee23

Browse files
committed
feat: Add pnpm-lock.yaml parser
1 parent d7ac402 commit a2fee23

File tree

10 files changed

+271
-4
lines changed

10 files changed

+271
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ The following dependency file formats are supported:
169169
- `uv.lock`
170170
- `package-lock.json` (v1, v2, v3)
171171
- `yarn.lock` (v1, v2)
172+
- `pnpm-lock.yaml` (v9)
172173

173174
### Check dependencies introduced through the CLI
174175

src/twyn/base/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"poetry.lock": dependency_parser.PoetryLockParser,
3131
"uv.lock": dependency_parser.UvLockParser,
3232
"package-lock.json": dependency_parser.PackageLockJsonParser,
33+
"pnpm-lock.yaml": dependency_parser.PnpmLockParser,
3334
"yarn.lock": dependency_parser.YarnLockParser,
3435
}
3536
"""Mapping of dependency file names to their parser classes."""

src/twyn/dependency_managers/managers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from twyn.dependency_managers.exceptions import NoMatchingDependencyManagerError
55
from twyn.dependency_parser.parsers.constants import (
66
PACKAGE_LOCK_JSON,
7+
PNPM_LOCK_YAML,
78
POETRY_LOCK,
89
REQUIREMENTS_TXT,
910
UV_LOCK,
@@ -48,7 +49,7 @@ def get_alternative_source(self, sources: dict[str, str]) -> str | None:
4849
npm_dependency_manager = DependencyManager(
4950
name="npm",
5051
trusted_packages_source=TopNpmReference,
51-
dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK},
52+
dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK, PNPM_LOCK_YAML},
5253
trusted_packages_manager=TrustedNpmPackageManager,
5354
)
5455
pypi_dependency_manager = DependencyManager(

src/twyn/dependency_parser/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
from twyn.dependency_parser.parsers.lock_parser import PoetryLockParser, UvLockParser
44
from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser
5+
from twyn.dependency_parser.parsers.pnpm_lock_parser import PnpmLockParser
56
from twyn.dependency_parser.parsers.requirements_txt_parser import RequirementsTxtParser
67
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser
78

8-
__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser", "PackageLockJsonParser", "YarnLockParser"]
9+
__all__ = [
10+
"RequirementsTxtParser",
11+
"PoetryLockParser",
12+
"UvLockParser",
13+
"PackageLockJsonParser",
14+
"YarnLockParser",
15+
"PnpmLockParser",
16+
]

src/twyn/dependency_parser/lock_parser.py

Whitespace-only changes.

src/twyn/dependency_parser/package_lock_json.py

Whitespace-only changes.

src/twyn/dependency_parser/parsers/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
PACKAGE_LOCK_JSON = "package-lock.json"
55
"""Filename for npm package lock files."""
66

7+
PNPM_LOCK_YAML = "pnpm-lock.yaml"
8+
"""Filename for pnpm lock files."""
9+
710
POETRY_LOCK = "poetry.lock"
811
"""Filename for Poetry dependency lock files."""
912

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import re
2+
from typing import Any
3+
4+
from typing_extensions import override
5+
6+
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
7+
from twyn.dependency_parser.parsers.constants import PNPM_LOCK_YAML
8+
9+
10+
class PnpmLockParser(AbstractParser):
11+
"""Parser for pnpm-lock.yaml dependencies."""
12+
13+
_SCOPED_PACKAGE_PATTERN = re.compile(r"^(@[^@]+/[^@]+)@")
14+
_REGULAR_PACKAGE_PATTERN = re.compile(r"^([^@]+)@")
15+
_PACKAGE_NAME_VALIDATION_PATTERN = re.compile(r"^(@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_.-]+$")
16+
17+
def __init__(self, file_path: str = PNPM_LOCK_YAML) -> None:
18+
super().__init__(file_path)
19+
20+
@override
21+
def parse(self) -> set[str]:
22+
"""Parse pnpm-lock.yaml file and extract package names."""
23+
import yaml # noqa: PLC0415
24+
25+
content = self.file_handler.read()
26+
try:
27+
data = yaml.safe_load(content)
28+
except yaml.YAMLError as e:
29+
raise ValueError(f"Invalid YAML in pnpm-lock.yaml: {e}") from e
30+
31+
if not isinstance(data, dict):
32+
return set()
33+
34+
packages: set[str] = set()
35+
36+
# Parse root-level dependencies (if any)
37+
self._extract_from_pnpm_dependencies(data.get("dependencies", {}), packages)
38+
self._extract_from_pnpm_dependencies(data.get("devDependencies", {}), packages)
39+
self._extract_from_pnpm_dependencies(data.get("optionalDependencies", {}), packages)
40+
41+
# Parse packages section (all resolved packages)
42+
self._extract_from_packages(data.get("packages", {}), packages)
43+
44+
# Parse importers section (workspace packages)
45+
importers = data.get("importers", {})
46+
if isinstance(importers, dict):
47+
for importer_data in importers.values():
48+
if isinstance(importer_data, dict):
49+
self._extract_from_pnpm_dependencies(importer_data.get("dependencies", {}), packages)
50+
self._extract_from_pnpm_dependencies(importer_data.get("devDependencies", {}), packages)
51+
self._extract_from_pnpm_dependencies(importer_data.get("optionalDependencies", {}), packages)
52+
53+
return packages
54+
55+
def _extract_from_pnpm_dependencies(self, deps: dict[str, Any], packages: set[str]) -> None:
56+
"""Extract package names from pnpm-lock.yaml dependencies section.
57+
58+
In pnpm-lock.yaml, dependencies have the format:
59+
{
60+
"package-name": {
61+
"specifier": "^1.0.0",
62+
"version": "1.0.0"
63+
}
64+
}
65+
"""
66+
if not isinstance(deps, dict):
67+
return
68+
69+
for dep_name, _dep_info in deps.items():
70+
if isinstance(dep_name, str):
71+
# Handle scoped packages (@scope/package)
72+
normalized_name = self._normalize_package_name(dep_name)
73+
if normalized_name:
74+
packages.add(normalized_name)
75+
76+
def _extract_from_packages(self, packages_section: dict[str, Any], packages: set[str]) -> None:
77+
"""Extract package names from packages section.
78+
79+
In pnpm-lock.yaml v9+, package keys are in the format:
80+
81+
82+
"""
83+
if not isinstance(packages_section, dict):
84+
return
85+
86+
for package_key in packages_section:
87+
if isinstance(package_key, str):
88+
# Package keys are in format: package@version or @scope/package@version
89+
package_name = self._extract_package_name_from_key(package_key)
90+
if package_name:
91+
normalized_name = self._normalize_package_name(package_name)
92+
if normalized_name:
93+
packages.add(normalized_name)
94+
95+
def _extract_package_name_from_key(self, package_key: str) -> str | None:
96+
"""Extract package name from package key.
97+
98+
In pnpm-lock.yaml v9+, package keys are in formats like:
99+
100+
101+
102+
103+
"""
104+
if not package_key:
105+
return None
106+
107+
# Handle scoped packages (@scope/package@version)
108+
if package_key.startswith("@"):
109+
# Pattern: @scope/package@version or @scope/package@version_peer-deps
110+
match = self._SCOPED_PACKAGE_PATTERN.match(package_key)
111+
if match:
112+
return match.group(1)
113+
else:
114+
# Pattern: package@version or package@version_peer-deps
115+
match = self._REGULAR_PACKAGE_PATTERN.match(package_key)
116+
if match:
117+
return match.group(1)
118+
119+
return None
120+
121+
def _normalize_package_name(self, name: str) -> str | None:
122+
"""Normalize package name by removing invalid characters."""
123+
if not name or not isinstance(name, str):
124+
return None
125+
126+
# Remove any trailing whitespace and validate
127+
normalized = name.strip()
128+
129+
# Basic validation for npm package names
130+
if not normalized or len(normalized) > 214:
131+
return None
132+
133+
# Check for valid npm package name characters
134+
# Allow letters, numbers, hyphens, underscores, dots, and scoped packages
135+
if not self._PACKAGE_NAME_VALIDATION_PATTERN.match(normalized):
136+
return None
137+
138+
return normalized

tests/conftest.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,91 @@ def yarn_lock_file_v2(tmp_path: Path) -> Iterator[Path]:
452452
yield tmp_file
453453

454454

455+
@pytest.fixture
456+
def pnpm_lock_file_v9(tmp_path: Path) -> Iterator[Path]:
457+
"""PNPM lock file v9 with comprehensive structure."""
458+
pnpm_lock_file = tmp_path / "pnpm-lock.yaml"
459+
data = """
460+
lockfileVersion: '9.0'
461+
462+
settings:
463+
autoInstallPeers: true
464+
excludeLinksFromLockfile: false
465+
466+
importers:
467+
.:
468+
dependencies:
469+
react:
470+
specifier: ^18.2.0
471+
version: 18.2.0
472+
react-dom:
473+
specifier: ^18.2.0
474+
version: 18.2.0([email protected])
475+
lodash:
476+
specifier: ^4.17.21
477+
version: 4.17.21
478+
devDependencies:
479+
'@babel/core':
480+
specifier: ^7.20.0
481+
version: 7.20.12
482+
'@types/node':
483+
specifier: ^18.0.0
484+
version: 18.11.18
485+
typescript:
486+
specifier: ^4.9.0
487+
version: 4.9.5
488+
489+
packages/workspace-a:
490+
dependencies:
491+
express:
492+
specifier: ^4.18.2
493+
version: 4.18.2
494+
devDependencies:
495+
debug:
496+
specifier: ^4.3.4
497+
version: 4.3.4
498+
499+
packages:
500+
501+
resolution: {integrity: sha512-react-hash}
502+
engines: {node: '>=0.10.0'}
503+
504+
505+
resolution: {integrity: sha512-react-dom-hash}
506+
peerDependencies:
507+
react: ^18.2.0
508+
509+
510+
resolution: {integrity: sha512-lodash-hash}
511+
512+
513+
resolution: {integrity: sha512-babel-core-hash}
514+
engines: {node: '>=6.9.0'}
515+
516+
517+
resolution: {integrity: sha512-babel-helper-hash}
518+
engines: {node: '>=6.9.0'}
519+
520+
521+
resolution: {integrity: sha512-types-node-hash}
522+
523+
524+
resolution: {integrity: sha512-typescript-hash}
525+
engines: {node: '>=4.2.0'}
526+
hasBin: true
527+
528+
529+
resolution: {integrity: sha512-express-hash}
530+
engines: {node: '>= 0.10.0'}
531+
532+
533+
resolution: {integrity: sha512-debug-hash}
534+
engines: {node: '>=6.0'}
535+
"""
536+
with create_tmp_file(pnpm_lock_file, data) as tmp_file:
537+
yield tmp_file
538+
539+
455540
@pytest.fixture
456541
def package_lock_json_file_with_namespace_typo(tmp_path: Path) -> Iterator[Path]:
457542
"""NPM package-lock.json file with both namespace and regular package typos."""

tests/dependency_parser/test_dependency_parser.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
from unittest.mock import Mock, patch
33

44
import pytest
5-
from twyn.dependency_parser import PackageLockJsonParser, PoetryLockParser, RequirementsTxtParser, UvLockParser
5+
from twyn.dependency_parser import (
6+
PackageLockJsonParser,
7+
PnpmLockParser,
8+
PoetryLockParser,
9+
RequirementsTxtParser,
10+
UvLockParser,
11+
)
612
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
713
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser
814
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
@@ -32,7 +38,7 @@ def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None:
3238
)
3339
def test_raise_for_valid_file(
3440
self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock
35-
):
41+
) -> None:
3642
mock_exists.return_value = file_exists
3743
mock_is_file.return_value = is_file
3844

@@ -102,3 +108,27 @@ def test_parse_yarn_lock_v2(self, yarn_lock_file_v2: Path) -> None:
102108
parser = YarnLockParser(file_path=str(yarn_lock_file_v2))
103109

104110
assert parser.parse() == {"dependencies", "react-dom", "lodash", "version", "cacheKey", "resolution", "react"}
111+
112+
113+
class TestPnpmLockParser:
114+
def test_parse_pnpm_lock_v9(self, pnpm_lock_file_v9: Path) -> None:
115+
parser = PnpmLockParser(file_path=str(pnpm_lock_file_v9))
116+
result = parser.parse()
117+
118+
# Should find all dependencies from the v9 fixture
119+
expected_packages = {
120+
"react",
121+
"react-dom",
122+
"lodash",
123+
"@babel/core",
124+
"@babel/helper-plugin-utils",
125+
"express",
126+
"debug",
127+
"typescript",
128+
"@types/node",
129+
}
130+
assert expected_packages == result
131+
132+
# Should not include workspace projects
133+
assert "test-project" not in result
134+
assert "my-workspace" not in result

0 commit comments

Comments
 (0)