Skip to content

Commit 95d9349

Browse files
committed
fix: do not error on empty json files
1 parent 91e1553 commit 95d9349

File tree

9 files changed

+95
-20
lines changed

9 files changed

+95
-20
lines changed

src/twyn/dependency_parser/parsers/lock_parser.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import tomlkit
2+
import tomlkit.exceptions
23

34
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
45
from twyn.dependency_parser.parsers.constants import POETRY_LOCK, UV_LOCK
6+
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError
57

68

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

1012
def parse(self) -> set[str]:
1113
"""Parse dependencies names and map them to a set."""
12-
data = tomlkit.parse(self.file_handler.read())
14+
try:
15+
data = tomlkit.parse(self.file_handler.read())
16+
except tomlkit.exceptions.ParseError as e:
17+
raise InvalidFileFormatError("Invalid YAML format.") from e
18+
1319
packages = data.get("package", [])
1420
return {pkg["name"] for pkg in packages if isinstance(pkg, dict) and "name" in pkg}
1521

src/twyn/dependency_parser/parsers/package_lock_json.py

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

44
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
55
from twyn.dependency_parser.parsers.constants import PACKAGE_LOCK_JSON
6+
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError
67

78

89
class PackageLockJsonParser(AbstractParser):
@@ -14,7 +15,11 @@ def parse(self) -> set[str]:
1415
1516
It supports v1, v2 and v3.
1617
"""
17-
data = json.loads(self.file_handler.read())
18+
try:
19+
data = json.loads(self.file_handler.read())
20+
except json.JSONDecodeError as e:
21+
raise InvalidFileFormatError("Invalid JSON format.") from e
22+
1823
result: set[str] = set()
1924

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

3540
return result
3641

37-
def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]):
42+
def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]) -> None:
3843
"""Recursively collect dependencies from dependency tree."""
3944
for name, info in dep_tree.items():
4045
collected.add(name)

src/twyn/dependency_parser/parsers/yarn_lock_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def parse(self) -> set[str]:
2727

2828
if "__metadata:" in line:
2929
return self._parse_v2(fp)
30-
raise InvalidFileFormatError
30+
raise InvalidFileFormatError("Unkown file format.")
3131

3232
def _parse_v1(self, fp: TextIO) -> set[str]:
3333
"""Parse a yarn.lock file (v1) and return all the dependencies in it."""

src/twyn/file_handler/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ class PathNotFoundError(TwynError):
1111
"""Exception raised when a specified file path does not exist in the filesystem."""
1212

1313
message = "Specified dependencies file path does not exist"
14+
15+
16+
class EmptyFileError(TwynError):
17+
"""Exception raised when the read file is empty."""
18+
19+
message = "Given file is empty."

src/twyn/file_handler/file_handler.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from typing import TextIO
77

8-
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
8+
from twyn.file_handler.exceptions import EmptyFileError, PathIsNotFileError, PathNotFoundError
99

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

@@ -42,7 +42,7 @@ def exists(self) -> bool:
4242
"""Check if file exists and is a valid file."""
4343
try:
4444
self._raise_for_file_exists()
45-
except (PathNotFoundError, PathIsNotFileError):
45+
except (PathNotFoundError, PathIsNotFileError, EmptyFileError):
4646
return False
4747
return True
4848

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

57+
if self.file_path.stat().st_size == 0:
58+
raise EmptyFileError
59+
5760
def write(self, data: str) -> None:
5861
"""Write data to file."""
5962
self.file_path.write_text(data)

src/twyn/main.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
)
1717
from twyn.dependency_parser.dependency_selector import DependencySelector
1818
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
19+
from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError
20+
from twyn.file_handler.exceptions import EmptyFileError
1921
from twyn.file_handler.file_handler import FileHandler
2022
from twyn.similarity.algorithm import EditDistance, SimilarityThreshold
2123
from twyn.trusted_packages.cache_handler import CacheHandler
@@ -185,8 +187,23 @@ def _analyze_packages_from_source(
185187
)
186188
results: list[TyposquatCheckResultFromSource] = []
187189
for parser in parsers:
190+
try:
191+
parsed_content = parser.parse()
192+
except (InvalidFileFormatError, EmptyFileError) as e:
193+
logger.warning("Could not parse %s. %s", parser.file_path, e)
194+
continue
195+
196+
if not parsed_content:
197+
logger.warning("No packages found in file %s. Skipping...", parser.file_path)
198+
continue
199+
188200
analyzed_dependencies = _analyze_dependencies(
189-
top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path
201+
top_package_reference,
202+
trusted_packages,
203+
parsed_content,
204+
allowlist,
205+
show_progress_bar,
206+
parser.file_path,
190207
)
191208

192209
if analyzed_dependencies:

tests/config/test_config_handler.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,17 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml: Mock, mock
216216
assert not mock_write_toml.called
217217

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

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

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

235231
with pytest.raises(InvalidSelectorMethodError) as exc_info:
236232
config.resolve_config(selector_method="random-selector")

tests/dependency_parser/test_dependency_parser.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
)
1212
from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
1313
from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser
14-
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
1514

1615

1716
class TestAbstractParser:
@@ -22,26 +21,49 @@ def parse(self) -> set[str]:
2221
self._read()
2322
return set()
2423

24+
@patch("pathlib.Path.stat")
2525
@patch("pathlib.Path.exists")
2626
@patch("pathlib.Path.is_file")
27-
def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None:
27+
def test_file_exists(self, mock_is_file: Mock, mock_exists: Mock, mock_stat: Mock) -> None:
2828
mock_exists.return_value = True
2929
mock_is_file.return_value = True
30+
31+
# Mock stat to return a mock object with st_size > 0 (non-empty file)
32+
mock_stat_result = Mock()
33+
mock_stat_result.st_size = 100
34+
mock_stat.return_value = mock_stat_result
35+
3036
parser = self.TemporaryParser("fake_path.txt")
3137
assert parser.file_exists() is True
3238

39+
@patch("pathlib.Path.stat")
3340
@patch("pathlib.Path.exists")
3441
@patch("pathlib.Path.is_file")
3542
@pytest.mark.parametrize(
36-
("file_exists", "is_file", "exception"),
37-
[(False, False, PathNotFoundError), (True, False, PathIsNotFileError)],
43+
("file_exists", "is_file", "file_size"),
44+
[
45+
(False, False, 100),
46+
(True, False, 100),
47+
(True, True, 0),
48+
],
3849
)
3950
def test_raise_for_valid_file(
40-
self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock
51+
self,
52+
mock_is_file: Mock,
53+
mock_exists: Mock,
54+
mock_stat: Mock,
55+
file_exists: bool,
56+
is_file: bool,
57+
file_size: int,
4158
) -> None:
4259
mock_exists.return_value = file_exists
4360
mock_is_file.return_value = is_file
4461

62+
# Mock stat to return a mock object with st_size attribute
63+
mock_stat_result = Mock()
64+
mock_stat_result.st_size = file_size
65+
mock_stat.return_value = mock_stat_result
66+
4567
parser = self.TemporaryParser("fake_path.txt")
4668
assert parser.file_exists() is False
4769

tests/main/test_main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,3 +655,23 @@ def test_auto_detect_dependency_file_parser_scans_subdirectories(
655655

656656
assert len(error.results) == 1
657657
assert error.get_results_from_source(str(req_file)) is not None
658+
659+
@patch("twyn.trusted_packages.TopPyPiReference.get_packages")
660+
def test_check_dependencies_continues_execution_with_empty_file_mixed_with_content_file(
661+
self, mock_get_packages: Mock, tmp_path: Path, uv_lock_file_with_typo: Path
662+
) -> None:
663+
"""Test that execution continues when one file is empty and another contains dependencies."""
664+
mock_get_packages.return_value = {"requests"}
665+
666+
empty_file = tmp_path / "requirements.txt"
667+
with create_tmp_file(empty_file, ""):
668+
error = check_dependencies(
669+
dependency_files={str(empty_file), str(uv_lock_file_with_typo)},
670+
use_cache=False,
671+
)
672+
673+
assert len(error.results) == 1
674+
assert error.results[0].source == str(uv_lock_file_with_typo)
675+
assert error.results[0].errors == [TyposquatCheckResultEntry(dependency="reqests", similars=["requests"])]
676+
677+
assert error.get_results_from_source(str(empty_file)) is None

0 commit comments

Comments
 (0)