Skip to content

Commit 0d2d40d

Browse files
authored
feat(version-scanner): refine python version checks and document boundary logic (#17477)
This pull request refines the regex rules configuration used by the dependency version scanner. It improves Python runtime version boundary checking and documents the intent behind boundary offsets. Key changes: - Refines `python_requires` checks to support optional patch versions (e.g., matching `>=3.7.0`). - Adds subscript-based minor version checks (e.g., `sys.version_info[1] >= 7`). - Adds inline YAML comments to document the `+1` and `-1` offset logic for external reviewers and auditors.
1 parent 734302a commit 0d2d40d

2 files changed

Lines changed: 99 additions & 7 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}(?:\.0)?['"]
2829
- |
29-
python_requires\s*=\s*['"]<=3\.{minor}['"]
30+
python_requires\s*=\s*['"]<=3\.{minor}(?:\.0)?['"]
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}(?:\.0)?['"]
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}(?:\.0)?['"]
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\[\s*1\s*\]\s*==\s*{minor}(?!\d)
84+
- |
85+
sys\.version_info\[\s*1\s*\]\s*>=\s*{minor}(?!\d)
86+
- |
87+
sys\.version_info\[\s*1\s*\]\s*<=\s*{minor}(?!\d)
88+
# Matches sys.version_info[1] > 6 (equivalent to >=7)
89+
- |
90+
sys\.version_info\[\s*1\s*\]\s*>\s*{minor_minus_one}(?!\d)
91+
# Matches sys.version_info[1] < 8 (equivalent to <=7)
92+
- |
93+
sys\.version_info\[\s*1\s*\]\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}(?!\d)
137+
102138

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,8 +410,8 @@ def test_regex_examples_from_config():
410410

411411
rules_list = config.get("rules", [])
412412

413-
# Variables for interpolation (simulate Python 3.7)
414-
vars = {
413+
# Base variables for interpolation (simulate target version 3.7)
414+
base_vars = {
415415
"major": "3",
416416
"minor": "7",
417417
"version": "3.7",
@@ -427,6 +427,11 @@ def test_regex_examples_from_config():
427427
if not examples or not templates:
428428
continue
429429

430+
# Resolve target dependency name based on applies_to metadata, falling back to protobuf
431+
applies_to = rule_group.get("applies_to", [])
432+
dep_name = applies_to[0] if applies_to else "protobuf"
433+
vars = {**base_vars, "name": dep_name}
434+
430435
compiled_patterns = []
431436
for template in templates:
432437
try:
@@ -443,6 +448,57 @@ def test_regex_examples_from_config():
443448
break
444449
assert matched, f"Example '{example}' in group '{name}' did not match any pattern."
445450

451+
452+
def test_regex_negative_cases():
453+
"""Verify regex patterns prevent false positives (lookaheads, patch bounds) and support whitespace."""
454+
config_path = "regex_config.yaml"
455+
with open(config_path, 'r') as f:
456+
config = yaml.safe_load(f)
457+
458+
rules_list = config.get("rules", [])
459+
460+
# Target version 3.7
461+
vars = {
462+
"name": "protobuf",
463+
"major": "3",
464+
"minor": "7",
465+
"version": "3.7",
466+
"minor_plus_one": "8",
467+
"minor_minus_one": "6"
468+
}
469+
470+
# Find specific rule groups
471+
dep_req_group = next(r for r in rules_list if r["name"] == "dependency_requirement")
472+
python_cmd_group = next(r for r in rules_list if r["name"] == "explicit_python_command")
473+
python_req_group = next(r for r in rules_list if r["name"] == "python_requires")
474+
sys_info_group = next(r for r in rules_list if r["name"] == "sys_version_info")
475+
476+
# 1. Verify dependency_requirement looks ahead correctly (no partial match)
477+
dep_pattern = re.compile(dep_req_group["rules"][0].strip().format(**vars), re.IGNORECASE)
478+
assert dep_pattern.search("protobuf==3.7")
479+
assert not dep_pattern.search("protobuf==3.72")
480+
481+
# 2. Verify explicit_python_command negative lookahead
482+
cmd_pattern = re.compile(python_cmd_group["rules"][0].strip().format(**vars), re.IGNORECASE)
483+
assert cmd_pattern.search("python3.7")
484+
assert not cmd_pattern.search("python3.72")
485+
486+
# 3. Verify python_requires optional patch limits boundary rules to .0
487+
# Boundary rule 1: >=3.7 (python_requires = '>=3.7.0' is OK, but >=3.7.1 is not equivalent and should be skipped)
488+
req_ge_pattern = re.compile(python_req_group["rules"][1].strip().format(**vars), re.IGNORECASE)
489+
assert req_ge_pattern.search("python_requires = '>=3.7'")
490+
assert req_ge_pattern.search("python_requires = '>=3.7.0'")
491+
assert not req_ge_pattern.search("python_requires = '>=3.7.1'")
492+
493+
# 4. Verify sys_version_info[1] allows optional whitespace
494+
# Matches sys.version_info[ 1 ]
495+
sys_sub_pattern = re.compile(sys_info_group["rules"][10].strip().format(**vars), re.IGNORECASE) # sys.version_info[1] == 7
496+
assert sys_sub_pattern.search("sys.version_info[1] == 7")
497+
assert sys_sub_pattern.search("sys.version_info[ 1 ] == 7")
498+
assert sys_sub_pattern.search("sys.version_info[1 ] == 7")
499+
assert sys_sub_pattern.search("sys.version_info[ 1] == 7")
500+
501+
446502
def test_main_exit_code_1():
447503
"""Test that main() calls sys.exit(1) when matches are found."""
448504
# We can mock scan_repository to return a dummy match

0 commit comments

Comments
 (0)