Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,11 @@ twyn run --dependency-file <file path>
The following dependency file formats are supported:

- `requirements.txt`
- `poetry.lock`
- `poetry.lock` (<1.5, >=1.5)
- `uv.lock`
- `package-lock.json` (v1, v2, v3)
- `yarn.lock` (v1, v2)
- `pnpm-lock.yaml` (v9)

### Check dependencies introduced through the CLI

Expand Down
1 change: 1 addition & 0 deletions src/twyn/base/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"poetry.lock": dependency_parser.PoetryLockParser,
"uv.lock": dependency_parser.UvLockParser,
"package-lock.json": dependency_parser.PackageLockJsonParser,
"pnpm-lock.yaml": dependency_parser.PnpmLockParser,
"yarn.lock": dependency_parser.YarnLockParser,
}
"""Mapping of dependency file names to their parser classes."""
Expand Down
3 changes: 2 additions & 1 deletion src/twyn/dependency_managers/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from twyn.dependency_managers.exceptions import NoMatchingDependencyManagerError
from twyn.dependency_parser.parsers.constants import (
PACKAGE_LOCK_JSON,
PNPM_LOCK_YAML,
POETRY_LOCK,
REQUIREMENTS_TXT,
UV_LOCK,
Expand Down Expand Up @@ -48,7 +49,7 @@ def get_alternative_source(self, sources: dict[str, str]) -> str | None:
npm_dependency_manager = DependencyManager(
name="npm",
trusted_packages_source=TopNpmReference,
dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK},
dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK, PNPM_LOCK_YAML},
trusted_packages_manager=TrustedNpmPackageManager,
)
pypi_dependency_manager = DependencyManager(
Expand Down
10 changes: 9 additions & 1 deletion src/twyn/dependency_parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

from twyn.dependency_parser.parsers.lock_parser import PoetryLockParser, UvLockParser
from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser
from twyn.dependency_parser.parsers.pnpm_lock_parser import PnpmLockParser
from twyn.dependency_parser.parsers.requirements_txt_parser import RequirementsTxtParser
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser

__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser", "PackageLockJsonParser", "YarnLockParser"]
__all__ = [
"RequirementsTxtParser",
"PoetryLockParser",
"UvLockParser",
"PackageLockJsonParser",
"YarnLockParser",
"PnpmLockParser",
]
Empty file.
Empty file.
3 changes: 3 additions & 0 deletions src/twyn/dependency_parser/parsers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
PACKAGE_LOCK_JSON = "package-lock.json"
"""Filename for npm package lock files."""

PNPM_LOCK_YAML = "pnpm-lock.yaml"
"""Filename for pnpm lock files."""

POETRY_LOCK = "poetry.lock"
"""Filename for Poetry dependency lock files."""

Expand Down
137 changes: 137 additions & 0 deletions src/twyn/dependency_parser/parsers/pnpm_lock_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import re
from typing import Any

import yaml
from typing_extensions import override

from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.constants import PNPM_LOCK_YAML


class PnpmLockParser(AbstractParser):
"""Parser for pnpm-lock.yaml dependencies."""

_SCOPED_PACKAGE_PATTERN = re.compile(r"^(@[^@]+/[^@]+)@")
_REGULAR_PACKAGE_PATTERN = re.compile(r"^([^@]+)@")
_PACKAGE_NAME_VALIDATION_PATTERN = re.compile(r"^(@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_.-]+$")

def __init__(self, file_path: str = PNPM_LOCK_YAML) -> None:
super().__init__(file_path)

@override
def parse(self) -> set[str]:
"""Parse pnpm-lock.yaml file and extract package names."""
content = self.file_handler.read()
try:
data = yaml.safe_load(content)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in pnpm-lock.yaml: {e}") from e

if not isinstance(data, dict):
return set()

packages: set[str] = set()

# Parse root-level dependencies (if any)
self._extract_from_pnpm_dependencies(data.get("dependencies", {}), packages)
self._extract_from_pnpm_dependencies(data.get("devDependencies", {}), packages)
self._extract_from_pnpm_dependencies(data.get("optionalDependencies", {}), packages)

# Parse packages section (all resolved packages)
self._extract_from_packages(data.get("packages", {}), packages)

# Parse importers section (workspace packages)
importers = data.get("importers", {})
if isinstance(importers, dict):
for importer_data in importers.values():
if isinstance(importer_data, dict):
self._extract_from_pnpm_dependencies(importer_data.get("dependencies", {}), packages)
self._extract_from_pnpm_dependencies(importer_data.get("devDependencies", {}), packages)
self._extract_from_pnpm_dependencies(importer_data.get("optionalDependencies", {}), packages)

return packages

def _extract_from_pnpm_dependencies(self, deps: dict[str, Any], packages: set[str]) -> None:
"""Extract package names from pnpm-lock.yaml dependencies section.

In pnpm-lock.yaml, dependencies have the format:
{
"package-name": {
"specifier": "^1.0.0",
"version": "1.0.0"
}
}
"""
if not isinstance(deps, dict):
return

for dep_name, _dep_info in deps.items():
if isinstance(dep_name, str):
# Handle scoped packages (@scope/package)
normalized_name = self._normalize_package_name(dep_name)
if normalized_name:
packages.add(normalized_name)

def _extract_from_packages(self, packages_section: dict[str, Any], packages: set[str]) -> None:
"""Extract package names from packages section.

In pnpm-lock.yaml v9+, package keys are in the format:
- @scope/[email protected]
- [email protected]
"""
if not isinstance(packages_section, dict):
return

for package_key in packages_section:
if isinstance(package_key, str):
# Package keys are in format: package@version or @scope/package@version
package_name = self._extract_package_name_from_key(package_key)
if package_name:
normalized_name = self._normalize_package_name(package_name)
if normalized_name:
packages.add(normalized_name)

def _extract_package_name_from_key(self, package_key: str) -> str | None:
"""Extract package name from package key.

In pnpm-lock.yaml v9+, package keys are in formats like:
- @scope/[email protected]
- [email protected]
- @scope/[email protected][email protected]

"""
if not package_key:
return None

# Handle scoped packages (@scope/package@version)
if package_key.startswith("@"):
# Pattern: @scope/package@version or @scope/package@version_peer-deps
match = self._SCOPED_PACKAGE_PATTERN.match(package_key)
if match:
return match.group(1)
else:
# Pattern: package@version or package@version_peer-deps
match = self._REGULAR_PACKAGE_PATTERN.match(package_key)
if match:
return match.group(1)

return None

def _normalize_package_name(self, name: str) -> str | None:
"""Normalize package name by removing invalid characters."""
if not name or not isinstance(name, str):
return None

# Remove any trailing whitespace and validate
normalized = name.strip()

# Basic validation for npm package names
if not normalized or len(normalized) > 214:
return None

# Check for valid npm package name characters
# Allow letters, numbers, hyphens, underscores, dots, and scoped packages
if not self._PACKAGE_NAME_VALIDATION_PATTERN.match(normalized):
return None

return normalized
85 changes: 85 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,91 @@ def yarn_lock_file_v2(tmp_path: Path) -> Iterator[Path]:
yield tmp_file


@pytest.fixture
def pnpm_lock_file_v9(tmp_path: Path) -> Iterator[Path]:
"""PNPM lock file v9 with comprehensive structure."""
pnpm_lock_file = tmp_path / "pnpm-lock.yaml"
data = """
lockfileVersion: '9.0'

settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

importers:
.:
dependencies:
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0([email protected])
lodash:
specifier: ^4.17.21
version: 4.17.21
devDependencies:
'@babel/core':
specifier: ^7.20.0
version: 7.20.12
'@types/node':
specifier: ^18.0.0
version: 18.11.18
typescript:
specifier: ^4.9.0
version: 4.9.5

packages/workspace-a:
dependencies:
express:
specifier: ^4.18.2
version: 4.18.2
devDependencies:
debug:
specifier: ^4.3.4
version: 4.3.4

packages:
'[email protected]':
resolution: {integrity: sha512-react-hash}
engines: {node: '>=0.10.0'}

'[email protected]':
resolution: {integrity: sha512-react-dom-hash}
peerDependencies:
react: ^18.2.0

'[email protected]':
resolution: {integrity: sha512-lodash-hash}

'@babel/[email protected]':
resolution: {integrity: sha512-babel-core-hash}
engines: {node: '>=6.9.0'}

'@babel/[email protected]':
resolution: {integrity: sha512-babel-helper-hash}
engines: {node: '>=6.9.0'}

'@types/[email protected]':
resolution: {integrity: sha512-types-node-hash}

'[email protected]':
resolution: {integrity: sha512-typescript-hash}
engines: {node: '>=4.2.0'}
hasBin: true

'[email protected]':
resolution: {integrity: sha512-express-hash}
engines: {node: '>= 0.10.0'}

'[email protected]':
resolution: {integrity: sha512-debug-hash}
engines: {node: '>=6.0'}
"""
with create_tmp_file(pnpm_lock_file, data) as tmp_file:
yield tmp_file


@pytest.fixture
def package_lock_json_file_with_namespace_typo(tmp_path: Path) -> Iterator[Path]:
"""NPM package-lock.json file with both namespace and regular package typos."""
Expand Down
34 changes: 32 additions & 2 deletions tests/dependency_parser/test_dependency_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
from unittest.mock import Mock, patch

import pytest
from twyn.dependency_parser import PackageLockJsonParser, PoetryLockParser, RequirementsTxtParser, UvLockParser
from twyn.dependency_parser import (
PackageLockJsonParser,
PnpmLockParser,
PoetryLockParser,
RequirementsTxtParser,
UvLockParser,
)
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
Expand Down Expand Up @@ -32,7 +38,7 @@ def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None:
)
def test_raise_for_valid_file(
self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock
):
) -> None:
mock_exists.return_value = file_exists
mock_is_file.return_value = is_file

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

assert parser.parse() == {"dependencies", "react-dom", "lodash", "version", "cacheKey", "resolution", "react"}


class TestPnpmLockParser:
def test_parse_pnpm_lock_v9(self, pnpm_lock_file_v9: Path) -> None:
parser = PnpmLockParser(file_path=str(pnpm_lock_file_v9))
result = parser.parse()

# Should find all dependencies from the v9 fixture
expected_packages = {
"react",
"react-dom",
"lodash",
"@babel/core",
"@babel/helper-plugin-utils",
"express",
"debug",
"typescript",
"@types/node",
}
assert expected_packages == result

# Should not include workspace projects
assert "test-project" not in result
assert "my-workspace" not in result
Loading