已合并
[fix]: 支持按版本装饰器过滤测试用例 #45273
thickhair创建于 16 天前
[fix]: 支持按版本装饰器过滤测试用例 #45273
已合并
共 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 files | 3 | +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 -> keep | 8 | - Any @runIfVersion intersects with CI range -> keep |
| 9 | - All @runIfVersion decorators outside CI range -> drop | 9 | - 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 | """ |
| 11 | import ast | 15 | import ast |
| 12 | from pathlib import Path | 16 | from pathlib import Path |
| @@ -70,6 +74,27 @@ def _ranges_intersect( | |||
| 70 | return True | 74 | 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 | + | ||
| 73 | def _file_should_run( | 98 | def _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 True | 133 | return True |
| 109 | 134 | ||
| 110 | - # No decorators -> keep; decorators but none intersected -> drop | 135 | + # No decorators -> drop; decorators but none intersected -> drop |
| 111 | - return not found_any | 136 | + return False |
| 112 | 137 | ||
| 113 | 138 | ||
| 114 | def filter_test_files( | 139 | def 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_files | 155 | + # 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 ( | |||
| 19 | from split_by_time import load_and_validate_time_data, split_by_time, get_test_key | 19 | from split_by_time import load_and_validate_time_data, split_by_time, get_test_key |
| 20 | 20 | ||
| 21 | sys.path.insert(0, str(TEST_DIR)) | 21 | sys.path.insert(0, str(TEST_DIR)) |
| 22 | -from utils.version_mark import set_ci_version_range | 22 | +from version_mark import set_ci_version_range |
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | def fetch_acl_headers(): | 25 | def 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() |
| @@ -12,7 +12,7 @@ The decorator serves two purposes: | |||
| 12 | 12 | ||
| 13 | Usage | 13 | Usage |
| 14 | ----- | 14 | ----- |
| 15 | - from test.utils.version_mark import runIfVersion | 15 | + from version_mark import runIfVersion |
| 16 | 16 | ||
| 17 | 17 | ||
| 18 | def test_new_api(self): ... # runs on >= 2.10 | 18 | 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 target | 133 | return target |
| 134 | 134 | ||
| 135 | - return decorator | 135 | + return decorator |