已合并
[fix]: 支持按版本装饰器过滤测试用例 #45273
thickhair创建于 16 天前
[fix]: 支持按版本装饰器过滤测试用例 #45273
已合并
thickhair创建于 16 天前
3 个文件变更+53-11
@@ -1,12 +1,16 @@
1"""AST-based version filter for test files.1"""AST-based version filter for test files.
2 2 
3-Scans test files for @runIfVersion decorators and filters out files3+Scans test files for @runIfVersion decorators and filters files based on
4-whose decorated cases all fall outside the CI's target version range.4+whether --between_version is specified.
5 5 
6-Filtering rule per file:6+When --between_version IS specified (versioned job):
7-- No @runIfVersion decorators found -> keep (runs on all versions)7+- No @runIfVersion decorators found -> drop (only version-decorated cases run)
8- Any @runIfVersion intersects with CI range -> keep8- Any @runIfVersion intersects with CI range -> keep
9- All @runIfVersion decorators outside CI range -> drop9- All @runIfVersion decorators outside CI range -> drop
10+ 
11+When --between_version is NOT specified (non-versioned job):
12+- No @runIfVersion decorators found -> keep (universal cases run)
13+- Any @runIfVersion decorators found -> drop (versioned cases skip)
10"""14"""
11import ast15import ast
12from pathlib import Path16from pathlib import Path
@@ -70,6 +74,27 @@ def _ranges_intersect(
70 return True74 return True
71 75 
72 76 
77+def _file_has_version_decorators(file_path: str) -> bool:
78+ """Return True if the file has any @runIfVersion decorators."""
79+ try:
80+ with open(file_path, "r", encoding="utf-8") as f:
81+ source = f.read()
82+ tree = ast.parse(source, filename=file_path)
83+ except (SyntaxError, OSError, UnicodeDecodeError):
84+ return False
85+ 
86+ for node in ast.walk(tree):
87+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
88+ continue
89+ for dec in node.decorator_list:
90+ call = dec if isinstance(dec, ast.Call) else None
91+ if call is None and isinstance(dec, ast.Attribute) and isinstance(dec.value, ast.Call):
92+ call = dec.value
93+ if call is not None and _decorator_name(call) == "runIfVersion":
94+ return True
95+ return False
96+ 
97+ 
73def _file_should_run(98def _file_should_run(
74 file_path: str,99 file_path: str,
75 ci_min: Optional[VersionTuple],100 ci_min: Optional[VersionTuple],
@@ -107,8 +132,8 @@ def _file_should_run(
107 if _ranges_intersect(min_ver, max_ver, ci_min, ci_max):132 if _ranges_intersect(min_ver, max_ver, ci_min, ci_max):
108 return True133 return True
109 134 
110- # No decorators -> keep; decorators but none intersected -> drop135+ # No decorators -> drop; decorators but none intersected -> drop
111- return not found_any136+ return False
112 137 
113 138 
114def filter_test_files(139def filter_test_files(
@@ -118,10 +143,21 @@ def filter_test_files(
118) -> dict:143) -> dict:
119 """Filter a {ut_type: [file_paths]} dict by the CI version range.144 """Filter a {ut_type: [file_paths]} dict by the CI version range.
120 145 
121- Returns a new dict with only the files that should run.146+ When ci_min/ci_max are None (no --between_version):
147+ - Keep files WITHOUT @runIfVersion decorators
148+ - Drop files WITH @runIfVersion decorators
149+ 
150+ When ci_min/ci_max are set (--between_version specified):
151+ - Keep files with @runIfVersion that intersects the CI range
152+ - Drop files without @runIfVersion or with non-intersecting ranges
122 """153 """
123 if ci_min is None and ci_max is None:154 if ci_min is None and ci_max is None:
124- return test_files155+ # No version filter: keep only files without version decorators
156+ filtered = {}
157+ for ut_type, files in test_files.items():
158+ kept = [f for f in files if not _file_has_version_decorators(str(Path(f)))]
159+ filtered[ut_type] = kept
160+ return filtered
125 161 
126 filtered = {}162 filtered = {}
127 for ut_type, files in test_files.items():163 for ut_type, files in test_files.items():
@@ -19,7 +19,7 @@ from access_control import (
19from split_by_time import load_and_validate_time_data, split_by_time, get_test_key19from split_by_time import load_and_validate_time_data, split_by_time, get_test_key
20 20 
21sys.path.insert(0, str(TEST_DIR))21sys.path.insert(0, str(TEST_DIR))
22-from utils.version_mark import set_ci_version_range22+from version_mark import set_ci_version_range
23 23 
24 24 
25def fetch_acl_headers():25def fetch_acl_headers():
@@ -335,6 +335,12 @@ if __name__ == "__main__":
335 os.environ["CI_VERSION_MIN"] = ".".join(map(str, ci_min))335 os.environ["CI_VERSION_MIN"] = ".".join(map(str, ci_min))
336 if ci_max is not None:336 if ci_max is not None:
337 os.environ["CI_VERSION_MAX"] = ".".join(map(str, ci_max))337 os.environ["CI_VERSION_MAX"] = ".".join(map(str, ci_max))
338+ else:
339+ # No --between_version: drop files with @runIfVersion decorators,
340+ # keep only universal test cases (those without version decorators).
341+ print("No --between_version specified: filtering out version-decorated test cases")
342+ test_mgr.filter_by_version(None, None)
343+ cur_test_files = test_mgr.get_test_files()
338 344 
339 test_mgr.print_modify_files()345 test_mgr.print_modify_files()
340 test_mgr.print_ut_files()346 test_mgr.print_ut_files()
Rtest/utils/version_mark.pytest/version_mark.py+2-2
@@ -12,7 +12,7 @@ The decorator serves two purposes:
12 12 
13Usage13Usage
14-----14-----
15- from test.utils.version_mark import runIfVersion15+ from version_mark import runIfVersion
16 16 
17 @runIfVersion(min="2.10")17 @runIfVersion(min="2.10")
18 def test_new_api(self): ... # runs on >= 2.1018 def test_new_api(self): ... # runs on >= 2.10
@@ -132,4 +132,4 @@ def runIfVersion(
132 return unittest.skip(reason)(target)132 return unittest.skip(reason)(target)
133 return target133 return target
134 134 
135- return decorator135+ return decorator