Skip to content

Commit 3e2e84e

Browse files
committed
feat(version-scanner): support target list inputs via --targets
1 parent 0d2d40d commit 3e2e84e

2 files changed

Lines changed: 208 additions & 21 deletions

File tree

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,3 +782,90 @@ def test_format_for_console():
782782
assert "3.7" in log_str
783783
assert "python_requires = " not in log_str # Slim format doesn't print context line
784784

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

scripts/version_scanner/version_scanner.py

Lines changed: 121 additions & 21 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)
@@ -465,10 +469,11 @@ def read_package_file(file_path: str) -> List[str]:
465469

466470
def scan_repository(
467471
root_path: str,
468-
rules: List[Dict[str, str]],
472+
rules: List[Dict[str, Any]],
469473
target_packages: List[str] = None,
470474
ignore_dirs: List[str] = None,
471-
version_string: str = None
475+
version_string: str = None,
476+
targets: List[Tuple[str, str]] = None
472477
) -> List[Dict[str, Any]]:
473478
"""
474479
Scans the repository directory tree applying resolved regex patterns to files.
@@ -487,21 +492,30 @@ def scan_repository(
487492
performs a full recursive scan of the repository.
488493
ignore_dirs: Optional list of directory names or glob-like files to ignore (case-insensitive).
489494
version_string: Optional target version string (e.g. "3.7") to scan for in filenames.
495+
targets: Optional list of (dependency, version) tuples.
490496
491497
Returns:
492-
A list of dictionaries detailing each match: 'file_path', 'repo_path',
493-
'package_name', 'rule_name', 'line_number', 'matched_string', 'context_line'.
498+
A list of dictionaries detailing each match.
494499
"""
495500
ignore_lower = {i.lower() for i in ignore_dirs} if ignore_dirs else set()
496501
results = []
497502

503+
filename_targets = []
504+
if targets:
505+
filename_targets = targets
506+
elif version_string:
507+
dep = rules[0].get("dependency") if rules else None
508+
filename_targets = [(dep, version_string)]
509+
498510
# Compile patterns once here
499511
compiled_rules = []
500512
for rule in rules:
501513
try:
502514
compiled_rules.append({
503515
"name": rule["name"],
504-
"pattern": re.compile(rule["pattern"], re.IGNORECASE)
516+
"pattern": re.compile(rule["pattern"], re.IGNORECASE),
517+
"dependency": rule.get("dependency", ""),
518+
"version": rule.get("version", "")
505519
})
506520
except re.error as e:
507521
print(f"Error compiling regex for rule {rule['name']}: {e}", file=sys.stderr)
@@ -541,13 +555,16 @@ def scan_repository(
541555
matches = scan_file(file_path, compiled_rules)
542556

543557
# Add filename match if applicable
544-
if version_string and version_string in file:
545-
matches.append({
546-
"rule_name": "filename_match",
547-
"line_number": 0,
548-
"matched_string": version_string,
549-
"context_line": f"Filename contains {version_string}"
550-
})
558+
for dep, ver in filename_targets:
559+
if ver and ver in file:
560+
matches.append({
561+
"rule_name": "filename_match",
562+
"line_number": 0,
563+
"matched_string": ver,
564+
"context_line": f"Filename contains {ver}",
565+
"dependency": dep or "",
566+
"version": ver
567+
})
551568

552569
# Compute display path and package name
553570
rel_file_path = os.path.relpath(file_path, root_path)
@@ -576,6 +593,46 @@ def scan_repository(
576593
return results
577594

578595

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

587644
parser.add_argument(
588645
"-d", "--dependency",
589-
required=True,
590646
help="Name of the dependency (e.g., python, protobuf)"
591647
)
592648

593649
parser.add_argument(
594650
"-v", "--version",
595-
required=True,
596651
help="Specific version to search for (e.g., 3.7, 4.25.8)"
597652
)
598653

654+
parser.add_argument(
655+
"--targets",
656+
help="Path to a YAML/JSON targets file, or an inline YAML/JSON string (e.g. 'python: [3.8, 3.9]')"
657+
)
658+
599659
parser.add_argument(
600660
"-p", "--path",
601661
default=".",
@@ -659,6 +719,25 @@ def main():
659719

660720
args = parser.parse_args()
661721

722+
# Validation of required inputs
723+
has_single_target = bool(args.dependency and args.version)
724+
has_targets_list = bool(args.targets)
725+
726+
if not (has_single_target or has_targets_list):
727+
parser.error("Must specify either (-d/--dependency AND -v/--version) OR (--targets)")
728+
if has_single_target and has_targets_list:
729+
parser.error("Cannot specify both single target (-d/-v) and targets list (--targets)")
730+
731+
targets = []
732+
if has_targets_list:
733+
targets = parse_targets(args.targets)
734+
else:
735+
targets = [(args.dependency, args.version)]
736+
737+
if not targets:
738+
print("Error: No targets resolved to scan.", file=sys.stderr)
739+
sys.exit(1)
740+
662741
# Resolve target packages if filtering is requested
663742
target_packages = []
664743
if args.package:
@@ -670,7 +749,12 @@ def main():
670749
elif args.package_file:
671750
target_packages = read_package_file(args.package_file)
672751

673-
print(f"Starting scan for dependency: {args.dependency} version: {args.version}")
752+
if has_targets_list:
753+
print("Starting scan for multiple targets:")
754+
for dep, ver in targets:
755+
print(f" - {dep}: {ver}")
756+
else:
757+
print(f"Starting scan for dependency: {args.dependency} version: {args.version}")
674758
print(f"Root path: {args.path}")
675759
print("Targets to scan:")
676760
if target_packages:
@@ -681,8 +765,10 @@ def main():
681765
print(f"Using config: {args.config}")
682766

683767
# Load and resolve rules
684-
config_manager = ConfigManager(args.config, args.dependency, args.version)
685-
rules = config_manager.load_config()
768+
rules = []
769+
for dep, ver in targets:
770+
config_manager = ConfigManager(args.config, dep, ver)
771+
rules.extend(config_manager.load_config())
686772

687773

688774

@@ -695,7 +781,14 @@ def main():
695781
print(f"Loaded {len(ignore_dirs)} ignore patterns from {ignore_file_path}")
696782

697783
# Scan repository
698-
all_matches = scan_repository(args.path, rules, target_packages, ignore_dirs, version_string=args.version)
784+
all_matches = scan_repository(
785+
args.path,
786+
rules,
787+
target_packages,
788+
ignore_dirs,
789+
version_string=(None if has_targets_list else args.version),
790+
targets=targets
791+
)
699792

700793
print(f"\nFound {len(all_matches)} matches.")
701794
display_matches = all_matches if args.stdout else all_matches[:10]
@@ -717,7 +810,14 @@ def main():
717810
script_dir = os.path.dirname(os.path.abspath(__file__))
718811
results_dir = os.path.join(script_dir, "results")
719812
os.makedirs(results_dir, exist_ok=True)
720-
output_path = os.path.join(results_dir, f"{args.dependency}-{args.version}-{timestamp}.csv")
813+
if has_targets_list:
814+
if os.path.exists(args.targets):
815+
base_name = os.path.splitext(os.path.basename(args.targets))[0]
816+
else:
817+
base_name = "targets"
818+
output_path = os.path.join(results_dir, f"{base_name}-{timestamp}.csv")
819+
else:
820+
output_path = os.path.join(results_dir, f"{args.dependency}-{args.version}-{timestamp}.csv")
721821

722822
write_csv_report(output_path, all_matches)
723823

0 commit comments

Comments
 (0)