Skip to content

Commit 3d8d582

Browse files
authored
feat(version-scanner): support target list inputs via --targets (#17478)
This pull request adds support for scanning multiple target dependencies and versions concurrently via a YAML file containing targets, while consolidating file handling and error logging. Key changes: - **YAML Targets File Support:** Adds a `--targets-file` CLI argument to read and resolve multiple target dependency-version tuples from a configuration file. - **Consolidated File Handling:** Centralizes file reading and error logging across the codebase under a single helper (`_safe_read_file`) with uniform stderr printing and exit codes. - **Expanded Test Coverage:** Adds parametrized unit tests validating targets file parsing errors (missing files, bad format, null values) and the core file helper error branches, and updates integration tests to utilize soft-fail flags.
1 parent f119dc2 commit 3d8d582

3 files changed

Lines changed: 329 additions & 122 deletions

File tree

scripts/version_scanner/tests/integration/test_scanner_integration.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ def test_integration_scan(tmp_path):
3232
"-v", "3.7",
3333
"-p", data_dir,
3434
"--config", config_path,
35-
"-o", "scanner_report.csv"
35+
"-o", "scanner_report.csv",
36+
"--soft-fail"
3637
]
3738

3839
result = subprocess.run(cmd, cwd=tmp_path, capture_output=True, text=True, check=True)

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 154 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,60 @@
3232
format_for_console
3333
)
3434

35+
@pytest.fixture
36+
def sample_match():
37+
return {
38+
"file_name": "setup.py",
39+
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
40+
"repo_path": "packages/pkg_a/setup.py",
41+
"package_name": "pkg_a",
42+
"rule_name": "python_requires_check",
43+
"line_number": "123",
44+
"matched_string": "3.7",
45+
"context_line": "python_requires = '>=3.7'",
46+
"dependency": "python",
47+
"version": "3.7"
48+
}
49+
50+
51+
@pytest.mark.parametrize(
52+
"exception_to_raise, required, silent_missing, expected_exit, expected_output, expected_return",
53+
[
54+
(None, True, False, False, None, "file content"), # Success
55+
(FileNotFoundError(), True, True, False, None, None), # Silent missing FileNotFoundError
56+
(FileNotFoundError(), True, False, True, "Error: Test_desc not found", None), # Required FileNotFoundError
57+
(FileNotFoundError(), False, False, False, "Warning: Test_desc not found", None), # Optional FileNotFoundError
58+
(PermissionError(), True, False, True, "Error: Permission denied reading test_desc", None), # Required PermissionError
59+
(PermissionError(), False, False, False, "Warning: Permission denied reading test_desc", None), # Optional PermissionError
60+
(IOError("disk full"), True, False, True, "Error reading test_desc", None), # Required IOError
61+
(IOError("disk full"), False, False, False, "Warning: Error reading test_desc", None), # Optional IOError
62+
]
63+
)
64+
def test_safe_read_file_scenarios(
65+
capsys, exception_to_raise, required, silent_missing, expected_exit, expected_output, expected_return
66+
):
67+
from version_scanner import _safe_read_file
68+
69+
if exception_to_raise:
70+
mock_open = mock.mock_open()
71+
mock_open.side_effect = exception_to_raise
72+
else:
73+
mock_open = mock.mock_open(read_data="file content")
74+
75+
with patch("builtins.open", mock_open):
76+
if expected_exit:
77+
with pytest.raises(SystemExit) as excinfo:
78+
_safe_read_file("dummy.txt", required=required, description="test_desc", silent_missing=silent_missing)
79+
assert excinfo.value.code == 1
80+
else:
81+
res = _safe_read_file("dummy.txt", required=required, description="test_desc", silent_missing=silent_missing)
82+
assert res == expected_return
83+
84+
if expected_output:
85+
captured = capsys.readouterr()
86+
assert expected_output in captured.err
87+
88+
3589
# Test ConfigManager
3690
@pytest.mark.parametrize("dependency, version, expected", [
3791
(
@@ -682,34 +736,13 @@ def test_safe_int():
682736
assert _safe_int(None) == 0
683737
assert _safe_int("abc") == 0
684738

685-
def test_format_for_raw_csv_handles_empty_line_number():
686-
match = {
687-
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
688-
"repo_path": "packages/pkg_a/setup.py",
689-
"package_name": "pkg_a",
690-
"rule_name": "python_requires_check",
691-
"line_number": "",
692-
"matched_string": "3.7",
693-
"context_line": "python_requires = '>=3.7'"
694-
}
695-
formatted = format_for_raw_csv(match)
739+
def test_format_for_raw_csv_handles_empty_line_number(sample_match):
740+
sample_match["line_number"] = ""
741+
formatted = format_for_raw_csv(sample_match)
696742
assert formatted["line_number"] == 0
697743

698-
def test_format_for_raw_csv():
699-
match = {
700-
"file_name": "setup.py",
701-
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
702-
"repo_path": "packages/pkg_a/setup.py",
703-
"package_name": "pkg_a",
704-
"rule_name": "python_requires_check",
705-
"line_number": "123",
706-
"matched_string": "3.7",
707-
"context_line": "python_requires = '>=3.7'",
708-
"dependency": "python",
709-
"version": "3.7"
710-
}
711-
712-
formatted = format_for_raw_csv(match)
744+
def test_format_for_raw_csv(sample_match):
745+
formatted = format_for_raw_csv(sample_match)
713746

714747
assert formatted["file_name"] == "setup.py"
715748
assert formatted["file_path"] == "google-cloud-python/main/packages/pkg_a/setup.py"
@@ -721,64 +754,117 @@ def test_format_for_raw_csv():
721754
assert formatted["dependency"] == "python"
722755
assert formatted["version"] == "3.7"
723756

724-
def test_format_for_raw_csv_fallback_filename():
725-
match = {
726-
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
727-
"repo_path": "packages/pkg_a/setup.py",
728-
"package_name": "pkg_a",
729-
"rule_name": "python_requires_check",
730-
"line_number": "123",
731-
"matched_string": "3.7",
732-
"context_line": "python_requires = '>=3.7'",
733-
"dependency": "python",
734-
"version": "3.7"
735-
}
736-
737-
formatted = format_for_raw_csv(match)
757+
def test_format_for_raw_csv_fallback_filename(sample_match):
758+
del sample_match["file_name"]
759+
formatted = format_for_raw_csv(sample_match)
738760
assert formatted["file_name"] == "setup.py"
739761

740-
def test_format_for_spreadsheet():
741-
match = {
742-
"file_name": "setup.py",
743-
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
744-
"repo_path": "packages/pkg_a/setup.py",
745-
"package_name": "pkg_a",
746-
"rule_name": "python_requires_check",
747-
"line_number": 123,
748-
"matched_string": "3.7",
749-
"context_line": "python_requires = '>=3.7'",
750-
"dependency": "python",
751-
"version": "3.7"
752-
}
753-
762+
def test_format_for_spreadsheet(sample_match):
754763
# Without github_repo
755-
formatted_no_repo = format_for_spreadsheet(match)
764+
formatted_no_repo = format_for_spreadsheet(sample_match)
756765
assert formatted_no_repo["file_name"] == "setup.py"
757766
assert formatted_no_repo["line_number"] == 123
758767
assert formatted_no_repo["matched_string"] == '="3.7"' # Decimal protection formula
759768
assert formatted_no_repo["dependency"] == "python"
760769
assert formatted_no_repo["version"] == "3.7"
761770

762771
# With github_repo
763-
formatted_repo = format_for_spreadsheet(match, github_repo="https://github.com/user/repo", branch="main")
772+
formatted_repo = format_for_spreadsheet(sample_match, github_repo="https://github.com/user/repo", branch="main")
764773
expected_url = "https://github.com/user/repo/blob/main/packages/pkg_a/setup.py#L123"
765774
assert formatted_repo["line_number"] == f'=HYPERLINK("{expected_url}", "123")'
766775
assert formatted_repo["matched_string"] == '="3.7"'
767776

768-
def test_format_for_console():
769-
match = {
770-
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
771-
"repo_path": "packages/pkg_a/setup.py",
772-
"package_name": "pkg_a",
773-
"rule_name": "python_requires_check",
774-
"line_number": 123,
775-
"matched_string": "3.7",
776-
"context_line": "python_requires = '>=3.7'"
777-
}
778-
779-
log_str = format_for_console(match)
777+
def test_format_for_console(sample_match):
778+
log_str = format_for_console(sample_match)
780779
assert "google-cloud-python/main/packages/pkg_a/setup.py:123" in log_str
781780
assert "[python_requires_check]" in log_str
782781
assert "3.7" in log_str
783782
assert "python_requires = " not in log_str # Slim format doesn't print context line
784783

784+
785+
def test_parse_targets_file(tmp_path):
786+
from version_scanner import parse_targets_file
787+
yaml_file = tmp_path / "targets.yaml"
788+
yaml_file.write_text("""
789+
python:
790+
- "3.7"
791+
- "3.8"
792+
protobuf: "4.25.8"
793+
""")
794+
targets = parse_targets_file(str(yaml_file))
795+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
796+
797+
@pytest.mark.parametrize(
798+
"file_content, file_exists",
799+
[
800+
(None, False), # File not found
801+
("invalid: {", True), # Invalid YAML
802+
("- not_a_mapping", True), # Invalid structure (list instead of map)
803+
("python:\n - null", True), # Invalid version type (null/None value)
804+
]
805+
)
806+
def test_parse_targets_file_failures(tmp_path, file_content, file_exists):
807+
from version_scanner import parse_targets_file
808+
809+
if file_exists:
810+
yaml_file = tmp_path / "targets_failures.yaml"
811+
yaml_file.write_text(file_content)
812+
path = str(yaml_file)
813+
else:
814+
path = "nonexistent_file.yaml"
815+
816+
with pytest.raises(SystemExit) as excinfo:
817+
parse_targets_file(path)
818+
assert excinfo.value.code == 1
819+
820+
def test_scan_repository_multi_targets(tmp_path):
821+
# Setup files in tmp repository
822+
file1 = tmp_path / "packages" / "pkg1" / "setup.py"
823+
file1.parent.mkdir(parents=True)
824+
file1.write_text("python_requires = '>=3.7'\n")
825+
826+
file2 = tmp_path / "packages" / "pkg2" / "requirements.txt"
827+
file2.parent.mkdir(parents=True)
828+
file2.write_text("protobuf==4.25.8\n")
829+
830+
# Let's mock a config file with rules for both python and protobuf
831+
config_file = tmp_path / "regex_config.yaml"
832+
config_file.write_text("""
833+
rules:
834+
- name: python_requires_check
835+
applies_to:
836+
- python
837+
rules:
838+
- python_requires\\s*=\\s*['\"]>={version}['\"]
839+
- name: protobuf_check
840+
applies_to:
841+
- protobuf
842+
rules:
843+
- protobuf=={version}
844+
""")
845+
846+
from version_scanner import ConfigManager, scan_repository
847+
848+
targets = [("python", "3.7"), ("protobuf", "4.25.8")]
849+
rules = []
850+
for dep, ver in targets:
851+
cm = ConfigManager(str(config_file), dep, ver)
852+
rules.extend(cm.load_config())
853+
854+
results = scan_repository(str(tmp_path), rules, targets=targets)
855+
856+
# We should have 2 matches
857+
assert len(results) == 2
858+
859+
# Match for python
860+
python_match = [r for r in results if r["dependency"] == "python"]
861+
assert len(python_match) == 1
862+
assert python_match[0]["version"] == "3.7"
863+
assert python_match[0]["rule_name"] == "python_requires_check"
864+
865+
# Match for protobuf
866+
protobuf_match = [r for r in results if r["dependency"] == "protobuf"]
867+
assert len(protobuf_match) == 1
868+
assert protobuf_match[0]["version"] == "4.25.8"
869+
assert protobuf_match[0]["rule_name"] == "protobuf_check"
870+

0 commit comments

Comments
 (0)