Skip to content

Commit 5e66da1

Browse files
committed
feat(version-scanner): support target list inputs and refine python version checks
1 parent 387abe0 commit 5e66da1

3 files changed

Lines changed: 301 additions & 36 deletions

File tree

scripts/version_scanner/regex_config.yaml

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,23 @@ rules:
1717
examples:
1818
- "python_requires = '==3.7'"
1919
- "python_requires = '>=3.7'"
20+
- "python_requires = '>=3.7.0'"
2021
- "python_requires = '<=3.7'"
2122
- "python_requires = '>3.6'"
2223
- "python_requires = '<3.8'"
2324
rules:
2425
- |
25-
python_requires\s*=\s*['"]==3\.{minor}['"]
26+
python_requires\s*=\s*['"]==3\.{minor}(?:\.\d+)?['"]
2627
- |
27-
python_requires\s*=\s*['"]>=3\.{minor}['"]
28+
python_requires\s*=\s*['"]>=3\.{minor}(?:\.\d+)?['"]
2829
- |
29-
python_requires\s*=\s*['"]<=3\.{minor}['"]
30+
python_requires\s*=\s*['"]<=3\.{minor}(?:\.\d+)?['"]
31+
# Matches >3.6 (equivalent to >=3.7)
3032
- |
31-
python_requires\s*=\s*['"]>3\.{minor_minus_one}['"]
33+
python_requires\s*=\s*['"]>3\.{minor_minus_one}(?:\.\d+)?['"]
34+
# Matches <3.8 (equivalent to <=3.7)
3235
- |
33-
python_requires\s*=\s*['"]<3\.{minor_plus_one}['"]
36+
python_requires\s*=\s*['"]<3\.{minor_plus_one}(?:\.\d+)?['"]
3437
3538
- name: sys_version_info
3639
description: Finds sys.version_info checks in code.
@@ -46,15 +49,22 @@ rules:
4649
- "sys.version_info.minor <= 7"
4750
- "sys.version_info.minor > 6"
4851
- "sys.version_info.minor < 8"
52+
- "sys.version_info[1] == 7"
53+
- "sys.version_info[1] >= 7"
54+
- "sys.version_info[1] <= 7"
55+
- "sys.version_info[1] > 6"
56+
- "sys.version_info[1] < 8"
4957
rules:
5058
- |
5159
sys\.version_info\s*==\s*\(3,\s*{minor}\)
5260
- |
5361
sys\.version_info\s*>=\s*\(3,\s*{minor}\)
5462
- |
5563
sys\.version_info\s*<=\s*\(3,\s*{minor}\)
64+
# Matches sys.version_info > (3, 6) (equivalent to >=3.7)
5665
- |
5766
sys\.version_info\s*>\s*\(3,\s*{minor_minus_one}\)
67+
# Matches sys.version_info < (3, 8) (equivalent to <=3.7)
5868
- |
5969
sys\.version_info\s*<\s*\(3,\s*{minor_plus_one}\)
6070
- |
@@ -63,10 +73,24 @@ rules:
6373
sys\.version_info\.minor\s*>=\s*{minor}(?!\d)
6474
- |
6575
sys\.version_info\.minor\s*<=\s*{minor}(?!\d)
76+
# Matches sys.version_info.minor > 6 (equivalent to >=7)
6677
- |
6778
sys\.version_info\.minor\s*>\s*{minor_minus_one}(?!\d)
79+
# Matches sys.version_info.minor < 8 (equivalent to <=7)
6880
- |
6981
sys\.version_info\.minor\s*<\s*{minor_plus_one}(?!\d)
82+
- |
83+
sys\.version_info\[1\]\s*==\s*{minor}(?!\d)
84+
- |
85+
sys\.version_info\[1\]\s*>=\s*{minor}(?!\d)
86+
- |
87+
sys\.version_info\[1\]\s*<=\s*{minor}(?!\d)
88+
# Matches sys.version_info[1] > 6 (equivalent to >=7)
89+
- |
90+
sys\.version_info\[1\]\s*>\s*{minor_minus_one}(?!\d)
91+
# Matches sys.version_info[1] < 8 (equivalent to <=7)
92+
- |
93+
sys\.version_info\[1\]\s*<\s*{minor_plus_one}(?!\d)
7094
7195
- name: python_env_short
7296
description: Finds short python environment names often used in tox or nox.
@@ -99,4 +123,16 @@ rules:
99123
- |
100124
Python{major}{minor}(?!\d)
101125
126+
- name: dependency_requirement
127+
description: Finds standard dependency requirement formats (e.g., protobuf==3.7).
128+
examples:
129+
- "protobuf==3.7"
130+
- "protobuf>=3.7"
131+
- "protobuf<=3.7"
132+
- "protobuf~=3.7"
133+
- "protobuf!=3.7"
134+
rules:
135+
- |
136+
{name}\s*(?:==|>=|<=|~=|!=)\s*{version}
137+
102138

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 127 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,6 @@ def test_main_package_file_permission_error(tmp_path, capsys):
227227
package_file = tmp_path / "packages.txt"
228228
package_file.write_text("packages/pkg_a")
229229

230-
import sys
231230
test_args = ["version_scanner.py", "-d", "python", "-v", "3.7", "--package-file", str(package_file)]
232231

233232
real_open = open
@@ -246,7 +245,6 @@ def side_effect(file, *args, **kwargs):
246245
captured = capsys.readouterr()
247246
assert "Error: Permission denied reading package file" in captured.err
248247
def test_main_package_file_not_found(capsys):
249-
import sys
250248
test_args = ["version_scanner.py", "-d", "python", "-v", "3.7", "--package-file", "non_existent_file.txt"]
251249

252250
with patch("sys.argv", test_args):
@@ -323,7 +321,6 @@ def test_main_loads_ignore_from_script_dir(mock_scan, mock_load_ignore):
323321
mock_load_ignore.return_value = []
324322
mock_scan.return_value = []
325323

326-
import sys
327324
test_args = ["version_scanner.py", "-d", "python", "-v", "3.7"]
328325

329326
with mock.patch('sys.argv', test_args):
@@ -339,7 +336,8 @@ def test_main_loads_ignore_from_script_dir(mock_scan, mock_load_ignore):
339336

340337

341338
try:
342-
import googleapiclient
339+
# Ruff linter F401: Imported solely to detect Google API Client library presence for test skipping
340+
import googleapiclient # noqa: F401
343341
HAS_GOOGLE_API = True
344342
except ImportError:
345343
HAS_GOOGLE_API = False
@@ -407,8 +405,8 @@ def test_regex_examples_from_config():
407405

408406
rules_list = config.get("rules", [])
409407

410-
# Variables for interpolation (simulate Python 3.7)
411-
vars = {
408+
# Base variables for interpolation (simulate target version 3.7)
409+
base_vars = {
412410
"major": "3",
413411
"minor": "7",
414412
"version": "3.7",
@@ -424,6 +422,11 @@ def test_regex_examples_from_config():
424422
if not examples or not templates:
425423
continue
426424

425+
# Resolve target dependency name based on applies_to metadata, falling back to protobuf
426+
applies_to = rule_group.get("applies_to", [])
427+
dep_name = applies_to[0] if applies_to else "protobuf"
428+
vars = {**base_vars, "name": dep_name}
429+
427430
compiled_patterns = []
428431
for template in templates:
429432
try:
@@ -638,39 +641,67 @@ def test_format_for_raw_csv_handles_empty_line_number():
638641

639642
def test_format_for_raw_csv():
640643
match = {
644+
"file_name": "setup.py",
641645
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
642646
"repo_path": "packages/pkg_a/setup.py",
643647
"package_name": "pkg_a",
644648
"rule_name": "python_requires_check",
645649
"line_number": "123",
646650
"matched_string": "3.7",
647-
"context_line": "python_requires = '>=3.7'"
651+
"context_line": "python_requires = '>=3.7'",
652+
"dependency": "python",
653+
"version": "3.7"
648654
}
649655

650656
formatted = format_for_raw_csv(match)
651657

658+
assert formatted["file_name"] == "setup.py"
652659
assert formatted["file_path"] == "google-cloud-python/main/packages/pkg_a/setup.py"
653660
assert formatted["package_name"] == "pkg_a"
654661
assert formatted["rule_name"] == "python_requires_check"
655662
assert formatted["line_number"] == 123 # Int conversion
656663
assert formatted["matched_string"] == "3.7" # No formula wrapping
657664
assert formatted["context_line"] == "python_requires = '>=3.7'"
665+
assert formatted["dependency"] == "python"
666+
assert formatted["version"] == "3.7"
667+
668+
def test_format_for_raw_csv_fallback_filename():
669+
match = {
670+
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
671+
"repo_path": "packages/pkg_a/setup.py",
672+
"package_name": "pkg_a",
673+
"rule_name": "python_requires_check",
674+
"line_number": "123",
675+
"matched_string": "3.7",
676+
"context_line": "python_requires = '>=3.7'",
677+
"dependency": "python",
678+
"version": "3.7"
679+
}
680+
681+
formatted = format_for_raw_csv(match)
682+
assert formatted["file_name"] == "setup.py"
658683

659684
def test_format_for_spreadsheet():
660685
match = {
686+
"file_name": "setup.py",
661687
"file_path": "google-cloud-python/main/packages/pkg_a/setup.py",
662688
"repo_path": "packages/pkg_a/setup.py",
663689
"package_name": "pkg_a",
664690
"rule_name": "python_requires_check",
665691
"line_number": 123,
666692
"matched_string": "3.7",
667-
"context_line": "python_requires = '>=3.7'"
693+
"context_line": "python_requires = '>=3.7'",
694+
"dependency": "python",
695+
"version": "3.7"
668696
}
669697

670698
# Without github_repo
671699
formatted_no_repo = format_for_spreadsheet(match)
700+
assert formatted_no_repo["file_name"] == "setup.py"
672701
assert formatted_no_repo["line_number"] == 123
673702
assert formatted_no_repo["matched_string"] == '="3.7"' # Decimal protection formula
703+
assert formatted_no_repo["dependency"] == "python"
704+
assert formatted_no_repo["version"] == "3.7"
674705

675706
# With github_repo
676707
formatted_repo = format_for_spreadsheet(match, github_repo="https://github.com/user/repo", branch="main")
@@ -695,3 +726,91 @@ def test_format_for_console():
695726
assert "3.7" in log_str
696727
assert "python_requires = " not in log_str # Slim format doesn't print context line
697728

729+
def test_parse_targets_inline_json():
730+
from version_scanner import parse_targets
731+
json_str = '{"python": ["3.7", "3.8"], "protobuf": "4.25.8"}'
732+
targets = parse_targets(json_str)
733+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
734+
735+
def test_parse_targets_inline_yaml():
736+
from version_scanner import parse_targets
737+
yaml_str = """
738+
python:
739+
- "3.7"
740+
- "3.8"
741+
protobuf: "4.25.8"
742+
"""
743+
targets = parse_targets(yaml_str)
744+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
745+
746+
def test_parse_targets_from_file(tmp_path):
747+
from version_scanner import parse_targets
748+
yaml_file = tmp_path / "targets.yaml"
749+
yaml_file.write_text("""
750+
python:
751+
- "3.7"
752+
- "3.8"
753+
protobuf: "4.25.8"
754+
""")
755+
targets = parse_targets(str(yaml_file))
756+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
757+
758+
def test_parse_targets_invalid_syntax():
759+
from version_scanner import parse_targets
760+
with pytest.raises(SystemExit) as excinfo:
761+
parse_targets('{"invalid"')
762+
assert excinfo.value.code == 1
763+
764+
def test_scan_repository_multi_targets(tmp_path):
765+
# Setup files in tmp repository
766+
file1 = tmp_path / "packages" / "pkg1" / "setup.py"
767+
file1.parent.mkdir(parents=True)
768+
file1.write_text("python_requires = '>=3.7'\n")
769+
770+
file2 = tmp_path / "packages" / "pkg2" / "requirements.txt"
771+
file2.parent.mkdir(parents=True)
772+
file2.write_text("protobuf==4.25.8\n")
773+
774+
# Let's mock a config file with rules for both python and protobuf
775+
config_file = tmp_path / "regex_config.yaml"
776+
config_file.write_text("""
777+
rules:
778+
- name: python_requires_check
779+
applies_to:
780+
- python
781+
rules:
782+
- python_requires\\s*=\\s*['\"]>={version}['\"]
783+
- name: protobuf_check
784+
applies_to:
785+
- protobuf
786+
rules:
787+
- protobuf=={version}
788+
""")
789+
790+
from version_scanner import ConfigManager, scan_repository
791+
792+
targets = [("python", "3.7"), ("protobuf", "4.25.8")]
793+
rules = []
794+
for dep, ver in targets:
795+
cm = ConfigManager(str(config_file), dep, ver)
796+
rules.extend(cm.load_config())
797+
798+
results = scan_repository(str(tmp_path), rules, targets=targets)
799+
800+
# We should have 2 matches
801+
assert len(results) == 2
802+
803+
# Match for python
804+
python_match = [r for r in results if r["dependency"] == "python"]
805+
assert len(python_match) == 1
806+
assert python_match[0]["version"] == "3.7"
807+
assert python_match[0]["rule_name"] == "python_requires_check"
808+
assert python_match[0]["file_name"] == "setup.py"
809+
810+
# Match for protobuf
811+
protobuf_match = [r for r in results if r["dependency"] == "protobuf"]
812+
assert len(protobuf_match) == 1
813+
assert protobuf_match[0]["version"] == "4.25.8"
814+
assert protobuf_match[0]["rule_name"] == "protobuf_check"
815+
assert protobuf_match[0]["file_name"] == "requirements.txt"
816+

0 commit comments

Comments
 (0)