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
8 changes: 7 additions & 1 deletion src/twyn/dependency_parser/parsers/lock_parser.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import tomlkit
import tomlkit.exceptions

from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.constants import POETRY_LOCK, UV_LOCK
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError


class TomlLockParser(AbstractParser):
"""Parser for TOML-based lock files."""

def parse(self) -> set[str]:
"""Parse dependencies names and map them to a set."""
data = tomlkit.parse(self.file_handler.read())
try:
data = tomlkit.parse(self.file_handler.read())
except tomlkit.exceptions.ParseError as e:
raise InvalidFileFormatError("Invalid YAML format.") from e

packages = data.get("package", [])
return {pkg["name"] for pkg in packages if isinstance(pkg, dict) and "name" in pkg}

Expand Down
9 changes: 7 additions & 2 deletions src/twyn/dependency_parser/parsers/package_lock_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.constants import PACKAGE_LOCK_JSON
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError


class PackageLockJsonParser(AbstractParser):
Expand All @@ -14,7 +15,11 @@ def parse(self) -> set[str]:

It supports v1, v2 and v3.
"""
data = json.loads(self.file_handler.read())
try:
data = json.loads(self.file_handler.read())
except json.JSONDecodeError as e:
raise InvalidFileFormatError("Invalid JSON format.") from e

result: set[str] = set()

# Handle v1 & v2
Expand All @@ -34,7 +39,7 @@ def parse(self) -> set[str]:

return result

def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]):
def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]) -> None:
"""Recursively collect dependencies from dependency tree."""
for name, info in dep_tree.items():
collected.add(name)
Expand Down
2 changes: 1 addition & 1 deletion src/twyn/dependency_parser/parsers/yarn_lock_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def parse(self) -> set[str]:

if "__metadata:" in line:
return self._parse_v2(fp)
raise InvalidFileFormatError
raise InvalidFileFormatError("Unkown file format.")

def _parse_v1(self, fp: TextIO) -> set[str]:
"""Parse a yarn.lock file (v1) and return all the dependencies in it."""
Expand Down
6 changes: 6 additions & 0 deletions src/twyn/file_handler/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ class PathNotFoundError(TwynError):
"""Exception raised when a specified file path does not exist in the filesystem."""

message = "Specified dependencies file path does not exist"


class EmptyFileError(TwynError):
"""Exception raised when the read file is empty."""

message = "Given file is empty."
7 changes: 5 additions & 2 deletions src/twyn/file_handler/file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from typing import TextIO

from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
from twyn.file_handler.exceptions import EmptyFileError, PathIsNotFileError, PathNotFoundError

logger = logging.getLogger("twyn")

Expand Down Expand Up @@ -42,7 +42,7 @@ def exists(self) -> bool:
"""Check if file exists and is a valid file."""
try:
self._raise_for_file_exists()
except (PathNotFoundError, PathIsNotFileError):
except (PathNotFoundError, PathIsNotFileError, EmptyFileError):
return False
return True

Expand All @@ -54,6 +54,9 @@ def _raise_for_file_exists(self) -> None:
if not self.file_path.is_file():
raise PathIsNotFileError

if self.file_path.stat().st_size == 0:
raise EmptyFileError

def write(self, data: str) -> None:
"""Write data to file."""
self.file_path.write_text(data)
Expand Down
19 changes: 18 additions & 1 deletion src/twyn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
)
from twyn.dependency_parser.dependency_selector import DependencySelector
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError
from twyn.file_handler.exceptions import EmptyFileError
from twyn.file_handler.file_handler import FileHandler
from twyn.similarity.algorithm import EditDistance, SimilarityThreshold
from twyn.trusted_packages.cache_handler import CacheHandler
Expand Down Expand Up @@ -185,8 +187,23 @@ def _analyze_packages_from_source(
)
results: list[TyposquatCheckResultFromSource] = []
for parser in parsers:
try:
parsed_content = parser.parse()
except (InvalidFileFormatError, EmptyFileError) as e:
logger.warning("Could not parse %s. %s", parser.file_path, e)
continue

if not parsed_content:
logger.warning("No packages found in %s. Skipping...", parser.file_path)
continue

analyzed_dependencies = _analyze_dependencies(
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path
top_package_reference,
trusted_packages,
parsed_content,
allowlist,
show_progress_bar,
parser.file_path,
)

if analyzed_dependencies:
Expand Down
12 changes: 4 additions & 8 deletions tests/config/test_config_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,21 +216,17 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml: Mock, mock
assert not mock_write_toml.called

@pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"])
def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path) -> None:
def test_valid_selector_methods_accepted(self, valid_selector: str, pyproject_toml_file: Path) -> None:
"""Test that all valid selector methods are accepted."""
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text("")
config = ConfigHandler(FileHandler(str(pyproject_toml)))
config = ConfigHandler(FileHandler(str(pyproject_toml_file)))

# Should not raise any exception
resolved_config = config.resolve_config(selector_method=valid_selector)
assert resolved_config.selector_method == valid_selector

def test_invalid_selector_method_rejected(self, tmp_path: Path) -> None:
def test_invalid_selector_method_rejected(self, pyproject_toml_file: Path) -> None:
"""Test that invalid selector methods are rejected with appropriate error."""
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text("")
config = ConfigHandler(FileHandler(str(pyproject_toml)))
config = ConfigHandler(FileHandler(str(pyproject_toml_file)))

with pytest.raises(InvalidSelectorMethodError) as exc_info:
config.resolve_config(selector_method="random-selector")
Expand Down
30 changes: 25 additions & 5 deletions tests/dependency_parser/test_dependency_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
)
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


class TestAbstractParser:
Expand All @@ -22,26 +21,47 @@ def parse(self) -> set[str]:
self._read()
return set()

@patch("pathlib.Path.stat")
@patch("pathlib.Path.exists")
@patch("pathlib.Path.is_file")
def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None:
def test_file_exists(self, mock_is_file: Mock, mock_exists: Mock, mock_stat: Mock) -> None:
mock_exists.return_value = True
mock_is_file.return_value = True

mock_stat_result = Mock()
mock_stat_result.st_size = 100
mock_stat.return_value = mock_stat_result

parser = self.TemporaryParser("fake_path.txt")
assert parser.file_exists() is True

@patch("pathlib.Path.stat")
@patch("pathlib.Path.exists")
@patch("pathlib.Path.is_file")
@pytest.mark.parametrize(
("file_exists", "is_file", "exception"),
[(False, False, PathNotFoundError), (True, False, PathIsNotFileError)],
("file_exists", "is_file", "file_size"),
[
(False, False, 100),
(True, False, 100),
(True, True, 0),
],
)
def test_raise_for_valid_file(
self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock
self,
mock_is_file: Mock,
mock_exists: Mock,
mock_stat: Mock,
file_exists: bool,
is_file: bool,
file_size: int,
) -> None:
mock_exists.return_value = file_exists
mock_is_file.return_value = is_file

mock_stat_result = Mock()
mock_stat_result.st_size = file_size
mock_stat.return_value = mock_stat_result

parser = self.TemporaryParser("fake_path.txt")
assert parser.file_exists() is False

Expand Down
20 changes: 20 additions & 0 deletions tests/main/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,23 @@ def test_auto_detect_dependency_file_parser_scans_subdirectories(

assert len(error.results) == 1
assert error.get_results_from_source(str(req_file)) is not None

@patch("twyn.trusted_packages.TopPyPiReference.get_packages")
def test_check_dependencies_continues_execution_with_empty_file_mixed_with_content_file(
self, mock_get_packages: Mock, tmp_path: Path, uv_lock_file_with_typo: Path
) -> None:
"""Test that execution continues when one file is empty and another contains dependencies."""
mock_get_packages.return_value = {"requests"}

empty_file = tmp_path / "requirements.txt"
with create_tmp_file(empty_file, ""):
error = check_dependencies(
dependency_files={str(empty_file), str(uv_lock_file_with_typo)},
use_cache=False,
)

assert len(error.results) == 1
assert error.results[0].source == str(uv_lock_file_with_typo)
assert error.results[0].errors == [TyposquatCheckResultEntry(dependency="reqests", similars=["requests"])]

assert error.get_results_from_source(str(empty_file)) is None
Loading