Skip to content

Commit 20edd1f

Browse files
committed
feat(version-scanner): support target list inputs via --targets
1 parent 387abe0 commit 20edd1f

2 files changed

Lines changed: 212 additions & 28 deletions

File tree

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 89 additions & 4 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
@@ -695,3 +693,90 @@ def test_format_for_console():
695693
assert "3.7" in log_str
696694
assert "python_requires = " not in log_str # Slim format doesn't print context line
697695

696+
697+
def test_parse_targets_inline_json():
698+
from version_scanner import parse_targets
699+
json_str = '{"python": ["3.7", "3.8"], "protobuf": "4.25.8"}'
700+
targets = parse_targets(json_str)
701+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
702+
703+
def test_parse_targets_inline_yaml():
704+
from version_scanner import parse_targets
705+
yaml_str = """
706+
python:
707+
- "3.7"
708+
- "3.8"
709+
protobuf: "4.25.8"
710+
"""
711+
targets = parse_targets(yaml_str)
712+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
713+
714+
def test_parse_targets_from_file(tmp_path):
715+
from version_scanner import parse_targets
716+
yaml_file = tmp_path / "targets.yaml"
717+
yaml_file.write_text("""
718+
python:
719+
- "3.7"
720+
- "3.8"
721+
protobuf: "4.25.8"
722+
""")
723+
targets = parse_targets(str(yaml_file))
724+
assert targets == [("python", "3.7"), ("python", "3.8"), ("protobuf", "4.25.8")]
725+
726+
def test_parse_targets_invalid_syntax():
727+
from version_scanner import parse_targets
728+
with pytest.raises(SystemExit) as excinfo:
729+
parse_targets('{"invalid"')
730+
assert excinfo.value.code == 1
731+
732+
def test_scan_repository_multi_targets(tmp_path):
733+
# Setup files in tmp repository
734+
file1 = tmp_path / "packages" / "pkg1" / "setup.py"
735+
file1.parent.mkdir(parents=True)
736+
file1.write_text("python_requires = '>=3.7'\n")
737+
738+
file2 = tmp_path / "packages" / "pkg2" / "requirements.txt"
739+
file2.parent.mkdir(parents=True)
740+
file2.write_text("protobuf==4.25.8\n")
741+
742+
# Let's mock a config file with rules for both python and protobuf
743+
config_file = tmp_path / "regex_config.yaml"
744+
config_file.write_text("""
745+
rules:
746+
- name: python_requires_check
747+
applies_to:
748+
- python
749+
rules:
750+
- python_requires\\s*=\\s*['\"]>={version}['\"]
751+
- name: protobuf_check
752+
applies_to:
753+
- protobuf
754+
rules:
755+
- protobuf=={version}
756+
""")
757+
758+
from version_scanner import ConfigManager, scan_repository
759+
760+
targets = [("python", "3.7"), ("protobuf", "4.25.8")]
761+
rules = []
762+
for dep, ver in targets:
763+
cm = ConfigManager(str(config_file), dep, ver)
764+
rules.extend(cm.load_config())
765+
766+
results = scan_repository(str(tmp_path), rules, targets=targets)
767+
768+
# We should have 2 matches
769+
assert len(results) == 2
770+
771+
# Match for python
772+
python_match = [r for r in results if r["dependency"] == "python"]
773+
assert len(python_match) == 1
774+
assert python_match[0]["version"] == "3.7"
775+
assert python_match[0]["rule_name"] == "python_requires_check"
776+
777+
# Match for protobuf
778+
protobuf_match = [r for r in results if r["dependency"] == "protobuf"]
779+
assert len(protobuf_match) == 1
780+
assert protobuf_match[0]["version"] == "4.25.8"
781+
assert protobuf_match[0]["rule_name"] == "protobuf_check"
782+

scripts/version_scanner/version_scanner.py

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ def load_config(self) -> List[Dict[str, str]]:
137137
resolved_pattern = template.strip().format(**self.variables)
138138
resolved_rules.append({
139139
"name": name,
140-
"pattern": resolved_pattern
140+
"pattern": resolved_pattern,
141+
"dependency": self.dependency,
142+
"version": self.version
141143
})
142144
except KeyError as e:
143145
print(f"Warning: Missing variable for interpolation in rule {name}: {e}", file=sys.stderr)
@@ -178,7 +180,9 @@ def scan_file(file_path: str, compiled_rules: List[Dict[str, re.Pattern]]) -> Li
178180
"rule_name": rule["name"],
179181
"line_number": line_num,
180182
"matched_string": match.group(0).strip(),
181-
"context_line": line.strip()
183+
"context_line": line.strip(),
184+
"dependency": rule.get("dependency", ""),
185+
"version": rule.get("version", "")
182186
})
183187
except IOError as e:
184188
print(f"Warning: Could not read file {file_path}: {e}", file=sys.stderr)
@@ -456,11 +460,12 @@ def read_package_file(file_path: str) -> List[str]:
456460

457461
def scan_repository(
458462
root_path: str,
459-
rules: List[Dict[str, str]],
463+
rules: List[Dict[str, Any]],
460464
target_packages: List[str] = None,
461465
ignore_dirs: List[str] = None,
462-
version_string: str = None
463-
) -> List[Dict[str, str]]:
466+
version_string: str = None,
467+
targets: List[Tuple[str, str]] = None
468+
) -> List[Dict[str, Any]]:
464469
"""
465470
Scans the repository directory tree applying resolved regex patterns to files.
466471
@@ -478,21 +483,30 @@ def scan_repository(
478483
performs a full recursive scan of the repository.
479484
ignore_dirs: Optional list of directory names or glob-like files to ignore (case-insensitive).
480485
version_string: Optional target version string (e.g. "3.7") to scan for in filenames.
486+
targets: Optional list of (dependency, version) tuples.
481487
482488
Returns:
483-
A list of dictionaries detailing each match: 'file_path', 'repo_path',
484-
'package_name', 'rule_name', 'line_number', 'matched_string', 'context_line'.
489+
A list of dictionaries detailing each match.
485490
"""
486491
ignore_lower = {i.lower() for i in ignore_dirs} if ignore_dirs else set()
487492
results = []
488493

494+
filename_targets = []
495+
if targets:
496+
filename_targets = targets
497+
elif version_string:
498+
dep = rules[0].get("dependency") if rules else None
499+
filename_targets = [(dep, version_string)]
500+
489501
# Compile patterns once here
490502
compiled_rules = []
491503
for rule in rules:
492504
try:
493505
compiled_rules.append({
494506
"name": rule["name"],
495-
"pattern": re.compile(rule["pattern"], re.IGNORECASE)
507+
"pattern": re.compile(rule["pattern"], re.IGNORECASE),
508+
"dependency": rule.get("dependency", ""),
509+
"version": rule.get("version", "")
496510
})
497511
except re.error as e:
498512
print(f"Error compiling regex for rule {rule['name']}: {e}", file=sys.stderr)
@@ -510,7 +524,6 @@ def scan_repository(
510524
files = [f for f in files if f.lower() not in ignore_lower]
511525

512526
rel_root = os.path.relpath(root, root_path)
513-
parts = rel_root.split(os.sep)
514527

515528
# Layout-agnostic generic subdirectory filtering
516529
if target_packages:
@@ -533,13 +546,16 @@ def scan_repository(
533546
matches = scan_file(file_path, compiled_rules)
534547

535548
# Add filename match if applicable
536-
if version_string and version_string in file:
537-
matches.append({
538-
"rule_name": "filename_match",
539-
"line_number": 0,
540-
"matched_string": version_string,
541-
"context_line": f"Filename contains {version_string}"
542-
})
549+
for dep, ver in filename_targets:
550+
if ver and ver in file:
551+
matches.append({
552+
"rule_name": "filename_match",
553+
"line_number": 0,
554+
"matched_string": ver,
555+
"context_line": f"Filename contains {ver}",
556+
"dependency": dep or "",
557+
"version": ver
558+
})
543559

544560
# Compute display path and package name
545561
rel_file_path = os.path.relpath(file_path, root_path)
@@ -567,6 +583,46 @@ def scan_repository(
567583
return results
568584

569585

586+
def parse_targets(targets_input: str) -> List[Tuple[str, str]]:
587+
"""
588+
Parses a targets input (file path or inline YAML/JSON string) into a list of (dependency, version) tuples.
589+
"""
590+
raw_targets = {}
591+
content = targets_input
592+
593+
# Check if the input is a file path
594+
if os.path.exists(targets_input):
595+
try:
596+
with open(targets_input, 'r', encoding='utf-8') as f:
597+
content = f.read()
598+
except PermissionError:
599+
print(f"Error: Permission denied reading targets file: {targets_input}", file=sys.stderr)
600+
sys.exit(1)
601+
except Exception as e:
602+
print(f"Error reading targets file {targets_input}: {e}", file=sys.stderr)
603+
sys.exit(1)
604+
605+
try:
606+
raw_targets = yaml.safe_load(content)
607+
except Exception as e:
608+
print(f"Error parsing targets YAML/JSON content: {e}", file=sys.stderr)
609+
sys.exit(1)
610+
611+
if not isinstance(raw_targets, dict):
612+
print("Error: Targets input must resolve to a JSON object or YAML mapping", file=sys.stderr)
613+
sys.exit(1)
614+
615+
targets = []
616+
for dep, versions in raw_targets.items():
617+
if isinstance(versions, list):
618+
for v in versions:
619+
targets.append((str(dep), str(v)))
620+
else:
621+
targets.append((str(dep), str(versions)))
622+
623+
return targets
624+
625+
570626
def main():
571627
script_dir = os.path.dirname(os.path.abspath(__file__))
572628
default_config = os.path.join(script_dir, "regex_config.yaml")
@@ -577,16 +633,19 @@ def main():
577633

578634
parser.add_argument(
579635
"-d", "--dependency",
580-
required=True,
581636
help="Name of the dependency (e.g., python, protobuf)"
582637
)
583638

584639
parser.add_argument(
585640
"-v", "--version",
586-
required=True,
587641
help="Specific version to search for (e.g., 3.7, 4.25.8)"
588642
)
589643

644+
parser.add_argument(
645+
"--targets",
646+
help="Path to a YAML/JSON targets file, or an inline YAML/JSON string (e.g. 'python: [3.8, 3.9]')"
647+
)
648+
590649
parser.add_argument(
591650
"-p", "--path",
592651
default=".",
@@ -650,6 +709,25 @@ def main():
650709

651710
args = parser.parse_args()
652711

712+
# Validation of required inputs
713+
has_single_target = bool(args.dependency and args.version)
714+
has_targets_list = bool(args.targets)
715+
716+
if not (has_single_target or has_targets_list):
717+
parser.error("Must specify either (-d/--dependency AND -v/--version) OR (--targets)")
718+
if has_single_target and has_targets_list:
719+
parser.error("Cannot specify both single target (-d/-v) and targets list (--targets)")
720+
721+
targets = []
722+
if has_targets_list:
723+
targets = parse_targets(args.targets)
724+
else:
725+
targets = [(args.dependency, args.version)]
726+
727+
if not targets:
728+
print("Error: No targets resolved to scan.", file=sys.stderr)
729+
sys.exit(1)
730+
653731
# Resolve target packages if filtering is requested
654732
target_packages = []
655733
if args.package:
@@ -661,9 +739,14 @@ def main():
661739
elif args.package_file:
662740
target_packages = read_package_file(args.package_file)
663741

664-
print(f"Starting scan for dependency: {args.dependency} version: {args.version}")
742+
if has_targets_list:
743+
print("Starting scan for multiple targets:")
744+
for dep, ver in targets:
745+
print(f" - {dep}: {ver}")
746+
else:
747+
print(f"Starting scan for dependency: {args.dependency} version: {args.version}")
665748
print(f"Root path: {args.path}")
666-
print(f"Targets to scan:")
749+
print("Targets to scan:")
667750
if target_packages:
668751
for pkg in target_packages:
669752
print(f" - {os.path.join(args.path, pkg)}")
@@ -672,8 +755,10 @@ def main():
672755
print(f"Using config: {args.config}")
673756

674757
# Load and resolve rules
675-
config_manager = ConfigManager(args.config, args.dependency, args.version)
676-
rules = config_manager.load_config()
758+
rules = []
759+
for dep, ver in targets:
760+
config_manager = ConfigManager(args.config, dep, ver)
761+
rules.extend(config_manager.load_config())
677762

678763

679764

@@ -686,7 +771,14 @@ def main():
686771
print(f"Loaded {len(ignore_dirs)} ignore patterns from {ignore_file_path}")
687772

688773
# Scan repository
689-
all_matches = scan_repository(args.path, rules, target_packages, ignore_dirs, version_string=args.version)
774+
all_matches = scan_repository(
775+
args.path,
776+
rules,
777+
target_packages,
778+
ignore_dirs,
779+
version_string=(None if has_targets_list else args.version),
780+
targets=targets
781+
)
690782

691783
print(f"\nFound {len(all_matches)} matches.")
692784
display_matches = all_matches if args.stdout else all_matches[:10]
@@ -708,7 +800,14 @@ def main():
708800
script_dir = os.path.dirname(os.path.abspath(__file__))
709801
results_dir = os.path.join(script_dir, "results")
710802
os.makedirs(results_dir, exist_ok=True)
711-
output_path = os.path.join(results_dir, f"{args.dependency}-{args.version}-{timestamp}.csv")
803+
if has_targets_list:
804+
if os.path.exists(args.targets):
805+
base_name = os.path.splitext(os.path.basename(args.targets))[0]
806+
else:
807+
base_name = "targets"
808+
output_path = os.path.join(results_dir, f"{base_name}-{timestamp}.csv")
809+
else:
810+
output_path = os.path.join(results_dir, f"{args.dependency}-{args.version}-{timestamp}.csv")
712811

713812
write_csv_report(output_path, all_matches)
714813

0 commit comments

Comments
 (0)