Merged
ci: 自动发现增量测试并移除算子映射表 #48
wangliweiAdd on 19 days ago
ci: 自动发现增量测试并移除算子映射表 #48
Merged
Merged fromwangliwei12/ops-tilelangmasterintocann/ops-tilelangmaster
Total: 6 files changed+812-261
| @@ -1,8 +1,10 @@ | |||
| 1 | # Files whose changes require running all L0 functional tests. | 1 | # Files whose changes require running all L0 functional tests. |
| 2 | # | 2 | # |
| 3 | -# Format: one repository-relative Bash glob per line. Blank lines and | 3 | +# Format: one repository-relative glob per line (Python fnmatch). Blank lines and |
| 4 | # full-line comments are ignored. Matching is based on pr_filelist.txt, so | 4 | # full-line comments are ignored. Matching is based on pr_filelist.txt, so |
| 5 | # added, modified, and deleted paths can all trigger the full test scope. | 5 | # added, modified, and deleted paths can all trigger the full test scope. |
| 6 | +# .gitcode/ is excluded by default so CI-only adjustments can skip operator tests. | ||
| 7 | +# Add an explicit rule here if a CI path should require the full test scope. | ||
| 6 | 8 | ||
| 7 | src/cann_ops_tilelang/config.py | 9 | src/cann_ops_tilelang/config.py |
| 8 | src/cann_ops_tilelang/utils.py | 10 | src/cann_ops_tilelang/utils.py |
| @@ -10,3 +12,7 @@ src/cann_ops_tilelang/utils.py | |||
| 10 | tests/conftest.py | 12 | tests/conftest.py |
| 11 | tests/pytest_*_plugin.py | 13 | tests/pytest_*_plugin.py |
| 12 | tests/testing/* | 14 | tests/testing/* |
| 15 | + | ||
| 16 | +pyproject.toml | ||
| 17 | +requirements*.txt | ||
| 18 | +scripts/*.sh | ||
| @@ -1,82 +0,0 @@ | |||
| 1 | -# Operator-level incremental pytest mapping. | ||
| 2 | -# Format: <source-pattern> <test-path> [<test-path> ...], separated by whitespace. | ||
| 3 | -# All paths are relative to the repository root; paths cannot contain spaces. | ||
| 4 | -# Source patterns use Bash globs (*, ?, [...]); test paths are explicit files. | ||
| 5 | -# Blank lines and full-line comments are ignored. The first matching row wins. | ||
| 6 | -# Put specific patterns before broader ones. Use separate rows for source aliases. | ||
| 7 | -# Missing test files are ignored by ut.sh (e.g. tests awaiting migration). | ||
| 8 | -# Only operator sources are mapped; modeling integration paths are excluded. | ||
| 9 | - | ||
| 10 | -# Engram | ||
| 11 | -src/cann_ops_tilelang/engram/engram_fused_weight*.py tests/engram/test_engram_fused_weight.py | ||
| 12 | -src/cann_ops_tilelang/engram/engram_gate*.py tests/engram/test_engram_gate_fwd.py tests/engram/test_engram_gate_bwd.py | ||
| 13 | -src/cann_ops_tilelang/engram/engram_grad_w_reduce*.py tests/engram/test_engram_grad_w_reduce.py | ||
| 14 | -src/cann_ops_tilelang/engram/engram_hash*.py tests/engram/test_engram_hash.py | ||
| 15 | -src/cann_ops_tilelang/engram/engram_sinkhorn*.py tests/engram/test_engram_sinkhorn.py | ||
| 16 | - | ||
| 17 | -# MHC | ||
| 18 | -src/cann_ops_tilelang/mhc/expand*.py tests/mhc/test_expand.py | ||
| 19 | -src/cann_ops_tilelang/mhc/head_compute_mix*.py tests/mhc/test_head_compute_mix.py | ||
| 20 | -src/cann_ops_tilelang/mhc/multilayer_recompute*.py tests/mhc/test_multilayer_recompute.py | ||
| 21 | -src/cann_ops_tilelang/mhc/norm_fn*.py tests/mhc/test_norm_fn.py | ||
| 22 | -src/cann_ops_tilelang/mhc/post*.py tests/mhc/test_post.py | ||
| 23 | -src/cann_ops_tilelang/mhc/pre_apply_mix*.py tests/mhc/test_pre_apply_mix.py | ||
| 24 | -src/cann_ops_tilelang/mhc/pre_big_fuse*.py tests/mhc/test_pre_big_fuse.py | ||
| 25 | -src/cann_ops_tilelang/mhc/pre_split_mixes*.py tests/mhc/test_pre_split_mixes.py | ||
| 26 | -src/cann_ops_tilelang/mhc/sinkhorn*.py tests/mhc/test_sinkhorn.py | ||
| 27 | - | ||
| 28 | -# MoE | ||
| 29 | -src/cann_ops_tilelang/moe/moe_topk_gate*.py tests/moe/test_moe_topk_gate.py | ||
| 30 | -src/cann_ops_tilelang/moe/normalize_weight*.py tests/moe/test_normalize_weight.py | ||
| 31 | -src/cann_ops_tilelang/moe/topk_gate*.py tests/moe/test_topk_gate.py | ||
| 32 | - | ||
| 33 | -# Attention | ||
| 34 | -src/cann_ops_tilelang/attention/_simd.py tests/attention/test_cache.py | ||
| 35 | -src/cann_ops_tilelang/attention/cache*.py tests/attention/test_cache.py | ||
| 36 | -src/cann_ops_tilelang/attention/flash_mla_utils*.py tests/attention/test_flash_mla_utils.py | ||
| 37 | -src/cann_ops_tilelang/attention/get_kv_index*.py tests/attention/test_get_kv_index.py | ||
| 38 | -src/cann_ops_tilelang/attention/kv_index_cat*.py tests/attention/test_kv_index_cat.py | ||
| 39 | -src/cann_ops_tilelang/attention/prepare_cp_mirror*.py tests/attention/test_prepare_cp_mirror.py | ||
| 40 | -src/cann_ops_tilelang/attention/prepare_llama_decode_metadata*.py tests/attention/test_prepare_llama_decode_metadata.py | ||
| 41 | -src/cann_ops_tilelang/attention/prepare_v33_decode_metadata*.py tests/attention/test_prepare_v33_decode_metadata.py | ||
| 42 | -src/cann_ops_tilelang/attention/rope*.py tests/attention/test_rope.py | ||
| 43 | -src/cann_ops_tilelang/attention/state_cache*.py tests/attention/test_state_cache.py | ||
| 44 | - | ||
| 45 | -# Multimodal | ||
| 46 | -src/cann_ops_tilelang/multimodal/dsocr*.py tests/multimodal/test_dsocr.py | ||
| 47 | -src/cann_ops_tilelang/multimodal/image_embed_ops*.py tests/multimodal/test_image_embed_ops.py | ||
| 48 | - | ||
| 49 | -# Normalization | ||
| 50 | -src/cann_ops_tilelang/normalization/norm*.py tests/normalization/test_norm.py | ||
| 51 | - | ||
| 52 | -# Quant: specific names must precede overlapping generic names. | ||
| 53 | -src/cann_ops_tilelang/quant/per_block_cast_lossless*.py tests/quant/test_per_block_cast_lossless.py | ||
| 54 | -src/cann_ops_tilelang/quant/per_block_cast*.py tests/quant/test_per_block_cast.py | ||
| 55 | -src/cann_ops_tilelang/quant/per_channel_cast_with_psum*.py tests/quant/test_per_channel_cast_with_psum.py | ||
| 56 | -src/cann_ops_tilelang/quant/per_channel_cast*.py tests/quant/test_per_channel_cast.py | ||
| 57 | -src/cann_ops_tilelang/quant/per_token_cast_and_cast_back*.py tests/quant/test_per_token_cast_and_cast_back.py | ||
| 58 | -src/cann_ops_tilelang/quant/per_token_cast*.py tests/quant/test_per_token_cast.py tests/attention/test_cache.py | ||
| 59 | -src/cann_ops_tilelang/quant/cast_back*.py tests/quant/test_cast_back.py | ||
| 60 | -src/cann_ops_tilelang/quant/swiglu_backward*.py tests/quant/test_swiglu_backward.py | ||
| 61 | -src/cann_ops_tilelang/quant/swiglu_forward*.py tests/quant/test_swiglu_forward.py | ||
| 62 | - | ||
| 63 | -# Sampling | ||
| 64 | -src/cann_ops_tilelang/sampling/fused_sample*.py tests/sampling/test_fused_sample.py | ||
| 65 | -src/cann_ops_tilelang/sampling/prompt_logprobs*.py tests/sampling/test_prompt_logprobs.py | ||
| 66 | -src/cann_ops_tilelang/sampling/random_sampling*.py tests/sampling/test_random_sampling.py tests/sampling/test_fused_sample.py | ||
| 67 | -src/cann_ops_tilelang/sampling/sampler*.py tests/sampling/test_sampler.py | ||
| 68 | -src/cann_ops_tilelang/sampling/schedule_mtp_varlen*.py tests/sampling/test_schedule_mtp_varlen.py | ||
| 69 | -src/cann_ops_tilelang/sampling/softmax*.py tests/sampling/test_softmax.py tests/sampling/test_fused_sample.py | ||
| 70 | -src/cann_ops_tilelang/sampling/structure_output*.py tests/sampling/test_structure_output.py | ||
| 71 | -src/cann_ops_tilelang/sampling/sum_smaller_probs*.py tests/sampling/test_sum_smaller_probs.py | ||
| 72 | - | ||
| 73 | -# Tensor: specific gather variants must precede the generic gather pattern. | ||
| 74 | -src/cann_ops_tilelang/tensor/compute_seqlens_k_zos*.py tests/tensor/test_compute_seqlens_k_zos.py | ||
| 75 | -src/cann_ops_tilelang/tensor/gather_engram_tokens_zos*.py tests/tensor/test_gather_engram_tokens_zos.py | ||
| 76 | -src/cann_ops_tilelang/tensor/gather_input_ids_zos*.py tests/tensor/test_gather_input_ids_zos.py | ||
| 77 | -src/cann_ops_tilelang/tensor/gather*.py tests/tensor/test_gather.py | ||
| 78 | -src/cann_ops_tilelang/tensor/unbind*.py tests/tensor/test_unbind.py | ||
| 79 | - | ||
| 80 | -# Transpose | ||
| 81 | -src/cann_ops_tilelang/transpose/batched_transpose*.py tests/transpose/test_transpose.py | ||
| 82 | -src/cann_ops_tilelang/transpose/transpose*.py tests/transpose/test_transpose.py | ||
| @@ -0,0 +1,380 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# MIT License | ||
| 3 | +# | ||
| 4 | +# Copyright (c) 2026 ops-tilelang contributors | ||
| 5 | +# | ||
| 6 | +# Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 7 | +# of this software and associated documentation files (the "Software"), to deal | ||
| 8 | +# in the Software without restriction, including without limitation the rights | ||
| 9 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 10 | +# copies of the Software, and to permit persons to whom the Software is | ||
| 11 | +# furnished to do so, subject to the following conditions: | ||
| 12 | +# | ||
| 13 | +# The above copyright notice and this permission notice shall be included in all | ||
| 14 | +# copies or substantial portions of the Software. | ||
| 15 | +# | ||
| 16 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 17 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 18 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 19 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 20 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 21 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 22 | +# SOFTWARE. | ||
| 23 | + | ||
| 24 | +"""Select affected pytest files without importing the operator package.""" | ||
| 25 | + | ||
| 26 | +from __future__ import annotations | ||
| 27 | + | ||
| 28 | +import argparse | ||
| 29 | +import ast | ||
| 30 | +import logging | ||
| 31 | +import sys | ||
| 32 | +from collections import defaultdict | ||
| 33 | +from dataclasses import dataclass | ||
| 34 | +from fnmatch import fnmatchcase | ||
| 35 | +from pathlib import Path, PurePosixPath | ||
| 36 | + | ||
| 37 | +LOGGER = logging.getLogger(__name__) | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +@dataclass(frozen=True) | ||
| 41 | +class Selection: | ||
| 42 | + mode: str | ||
| 43 | + targets: tuple[str, ...] | ||
| 44 | + reasons: tuple[str, ...] | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +@dataclass(frozen=True) | ||
| 48 | +class Reference: | ||
| 49 | + module: str | ||
| 50 | + names: tuple[str, ...] = () | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def _internal(module: str) -> bool: | ||
| 54 | + return any(module == root or module.startswith(root + '.') for root in ('cann_ops_tilelang', 'tests')) | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def _test_file(path: str) -> bool: | ||
| 58 | + name = PurePosixPath(path).name | ||
| 59 | + return path.startswith('tests/') and (fnmatchcase(name, 'test_*.py') or fnmatchcase(name, '*_test.py')) | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def _document(path: str) -> bool: | ||
| 63 | + return PurePosixPath(path).suffix.lower() in {'.md', '.rst'} or PurePosixPath(path).name in { | ||
| 64 | + 'LICENSE', | ||
| 65 | + 'NOTICE', | ||
| 66 | + 'COPYING', | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def _normalize(path: str) -> str: | ||
| 71 | + path = path.strip().replace('\\', '/') | ||
| 72 | + parsed = PurePosixPath(path) | ||
| 73 | + has_invalid_component = '..' in parsed.parts or ':' in path | ||
| 74 | + if not path or parsed.is_absolute() or has_invalid_component: | ||
| 75 | + raise ValueError(f'invalid repository-relative changed path: {path!r}') | ||
| 76 | + return str(parsed) | ||
| 77 | + | ||
| 78 | + | ||
| 79 | +class DependencyGraph: | ||
| 80 | + """Track file dependencies, resolving package exports to their definitions. | ||
| 81 | + | ||
| 82 | + Package initializers are not expanded for every import: importing ops.foo | ||
| 83 | + depends on foo's definition, not every symbol re-exported by ops. Changes | ||
| 84 | + to initializers themselves always require the full suite. | ||
| 85 | + """ | ||
| 86 | + | ||
| 87 | + def __init__(self, root: Path): | ||
| 88 | + self.modules: dict[str, str] = {} | ||
| 89 | + self.packages: set[str] = set() | ||
| 90 | + self.trees: dict[str, ast.Module] = {} | ||
| 91 | + self.exports: dict[str, dict[str, Reference]] = defaultdict(dict) | ||
| 92 | + self.edges: dict[str, set[str]] = defaultdict(set) | ||
| 93 | + self.problems: list[str] = [] | ||
| 94 | + for directory in ('src/cann_ops_tilelang', 'tests'): | ||
| 95 | + for file in sorted((root / directory).rglob('*.py')): | ||
| 96 | + path = file.relative_to(root).as_posix() | ||
| 97 | + parts = list(PurePosixPath(path).with_suffix('').parts) | ||
| 98 | + if parts[0] == 'src': | ||
| 99 | + parts.pop(0) | ||
| 100 | + is_package = parts[-1] == '__init__' | ||
| 101 | + if is_package: | ||
| 102 | + parts.pop() | ||
| 103 | + module = '.'.join(parts) | ||
| 104 | + if module in self.modules: | ||
| 105 | + self.problems.append(f'ambiguous module {module}') | ||
| 106 | + self.modules[module] = path | ||
| 107 | + for length in range(1, len(parts) + int(is_package)): | ||
| 108 | + self.packages.add('.'.join(parts[:length])) | ||
| 109 | + try: | ||
| 110 | + self.trees[module] = ast.parse(file.read_text(encoding='utf-8-sig'), filename=path) | ||
| 111 | + except (OSError, UnicodeError, SyntaxError) as error: | ||
| 112 | + self.problems.append(f'cannot parse {path}: {error}') | ||
| 113 | + # Build all export tables before resolving any import chain. | ||
| 114 | + for module, tree in self.trees.items(): | ||
| 115 | + if module in self.packages: | ||
| 116 | + for node in tree.body: | ||
| 117 | + for name, reference in self._imports(module, node): | ||
| 118 | + self.exports[module][name] = reference | ||
| 119 | + for module, tree in self.trees.items(): | ||
| 120 | + self._scan(module, tree) | ||
| 121 | + | ||
| 122 | + def affected_files(self, changed: str) -> set[str]: | ||
| 123 | + reverse: dict[str, set[str]] = defaultdict(set) | ||
| 124 | + for consumer, dependencies in self.edges.items(): | ||
| 125 | + for dependency in dependencies: | ||
| 126 | + reverse[dependency].add(consumer) | ||
| 127 | + visited, pending = set(), [changed] | ||
| 128 | + while pending: | ||
| 129 | + path = pending.pop() | ||
| 130 | + if path not in visited: | ||
| 131 | + visited.add(path) | ||
| 132 | + pending.extend(reverse[path] - visited) | ||
| 133 | + return visited | ||
| 134 | + | ||
| 135 | + def _base(self, module: str, node: ast.ImportFrom) -> str: | ||
| 136 | + if not node.level: | ||
| 137 | + return node.module or '' | ||
| 138 | + package = module if module in self.packages else module.rpartition('.')[0] | ||
| 139 | + parts = package.split('.') | ||
| 140 | + if node.level > len(parts): | ||
| 141 | + self.problems.append(f'invalid relative import in {self.modules[module]}:{node.lineno}') | ||
| 142 | + return '' | ||
| 143 | + prefix = parts[: len(parts) - node.level + 1] | ||
| 144 | + return '.'.join(prefix + ([node.module] if node.module else [])) | ||
| 145 | + | ||
| 146 | + def _imports(self, module: str, node: ast.AST) -> list[tuple[str, Reference]]: | ||
| 147 | + if isinstance(node, ast.Import): | ||
| 148 | + return [ | ||
| 149 | + ( | ||
| 150 | + item.asname or item.name.split('.')[0], | ||
| 151 | + Reference(item.name if item.asname else item.name.split('.')[0]), | ||
| 152 | + ) | ||
| 153 | + for item in node.names | ||
| 154 | + if _internal(item.name) | ||
| 155 | + ] | ||
| 156 | + if isinstance(node, ast.ImportFrom): | ||
| 157 | + base = self._base(module, node) | ||
| 158 | + if _internal(base): | ||
| 159 | + return [(item.asname or item.name, Reference(base, (item.name,))) for item in node.names] | ||
| 160 | + return [] | ||
| 161 | + | ||
| 162 | + def _resolve(self, reference: Reference, seen: frozenset[Reference] = frozenset()) -> str | None: | ||
| 163 | + module, names = reference.module, reference.names | ||
| 164 | + if reference in seen: | ||
| 165 | + raise ValueError(f'cyclic package export: {module}.{".".join(names)}') | ||
| 166 | + if module not in self.packages: | ||
| 167 | + if module in self.modules: | ||
| 168 | + return self.modules[module] | ||
| 169 | + raise ValueError(f'unresolved internal module: {module}') | ||
| 170 | + if not names: | ||
| 171 | + return None # A namespace, not a dependency on every exported symbol. | ||
| 172 | + if names[0] == '*': | ||
| 173 | + raise ValueError(f'star import from internal package: {module}') | ||
| 174 | + exported = self.exports[module].get(names[0]) | ||
| 175 | + child = module + '.' + names[0] | ||
| 176 | + if exported and exported != Reference(module, (names[0],)): | ||
| 177 | + return self._resolve(Reference(exported.module, exported.names + names[1:]), seen | {reference}) | ||
| 178 | + if child in self.modules or child in self.packages: | ||
| 179 | + return self._resolve(Reference(child, names[1:]), seen | {reference}) | ||
| 180 | + raise ValueError(f'unresolved internal export: {child}') | ||
| 181 | + | ||
| 182 | + def _scan(self, module: str, tree: ast.Module) -> None: | ||
| 183 | + path = self.modules[module] | ||
| 184 | + bindings: dict[str, set[Reference]] = defaultdict(set) | ||
| 185 | + loader_names = {'__import__'} | ||
| 186 | + importlib_names = {'importlib'} | ||
| 187 | + nodes = list(ast.walk(tree)) | ||
| 188 | + parents = {child: parent for parent in nodes for child in ast.iter_child_nodes(parent)} | ||
| 189 | + | ||
| 190 | + def depend(reference: Reference, node: ast.AST) -> str | None: | ||
| 191 | + try: | ||
| 192 | + dependency = self._resolve(reference) | ||
| 193 | + if dependency: | ||
| 194 | + self.edges[path].add(dependency) | ||
| 195 | + return dependency | ||
| 196 | + except ValueError as error: | ||
| 197 | + self.problems.append(f'{path}:{getattr(node, "lineno", 0)}: {error}') | ||
| 198 | + return None | ||
| 199 | + | ||
| 200 | + for node in nodes: | ||
| 201 | + for name, reference in self._imports(module, node): | ||
| 202 | + bindings[name].add(reference) | ||
| 203 | + depend(reference, node) | ||
| 204 | + if isinstance(node, (ast.Assign, ast.AnnAssign)): | ||
| 205 | + targets = node.targets if isinstance(node, ast.Assign) else [node.target] | ||
| 206 | + if any(isinstance(target, ast.Name) and target.id == 'pytest_plugins' for target in targets): | ||
| 207 | + try: | ||
| 208 | + plugins = ast.literal_eval(node.value) | ||
| 209 | + plugins = [plugins] if isinstance(plugins, str) else plugins | ||
| 210 | + if not isinstance(plugins, (list, tuple)) or not all(isinstance(item, str) for item in plugins): | ||
| 211 | + raise ValueError('expected constant plugin names') | ||
| 212 | + for plugin in plugins: | ||
| 213 | + if _internal(plugin) and depend(Reference(plugin), node) is None: | ||
| 214 | + if plugin in self.modules: | ||
| 215 | + self.edges[path].add(self.modules[plugin]) | ||
| 216 | + else: | ||
| 217 | + self.problems.append(f'{path}:{node.lineno}: unresolved pytest plugin {plugin}') | ||
| 218 | + except (ValueError, TypeError): | ||
| 219 | + self.problems.append(f'{path}:{node.lineno}: dynamic pytest_plugins declaration') | ||
| 220 | + if isinstance(node, ast.Import): | ||
| 221 | + for item in node.names: | ||
| 222 | + if _internal(item.name): | ||
| 223 | + depend(Reference(item.name), node) | ||
| 224 | + if item.name == 'importlib': | ||
| 225 | + importlib_names.add(item.asname or item.name) | ||
| 226 | + elif isinstance(node, ast.ImportFrom) and node.module == 'importlib': | ||
| 227 | + loader_names.update(item.asname or item.name for item in node.names if item.name == 'import_module') | ||
| 228 | + | ||
| 229 | + def references(node: ast.AST) -> list[Reference]: | ||
| 230 | + attributes = [] | ||
| 231 | + while isinstance(node, ast.Attribute): | ||
| 232 | + attributes.append(node.attr) | ||
| 233 | + node = node.value | ||
| 234 | + if not isinstance(node, ast.Name): | ||
| 235 | + return [] | ||
| 236 | + return [Reference(ref.module, ref.names + tuple(reversed(attributes))) for ref in bindings.get(node.id, ())] | ||
| 237 | + | ||
| 238 | + for node in nodes: | ||
| 239 | + parent = parents.get(node) | ||
| 240 | + is_direct_call = isinstance(parent, ast.Call) and parent.func is node | ||
| 241 | + is_loader_name = isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in loader_names | ||
| 242 | + if is_loader_name and not is_direct_call: | ||
| 243 | + self.problems.append(f'{path}:{node.lineno}: dynamic import function used as a value') | ||
| 244 | + if isinstance(node, ast.Attribute): | ||
| 245 | + is_import_module = ( | ||
| 246 | + node.attr == 'import_module' | ||
| 247 | + and isinstance(node.value, ast.Name) | ||
| 248 | + and node.value.id in importlib_names | ||
| 249 | + ) | ||
| 250 | + if is_import_module and not is_direct_call: | ||
| 251 | + self.problems.append(f'{path}:{node.lineno}: dynamic import function used as a value') | ||
| 252 | + if not isinstance(parent, ast.Attribute) or parent.value is not node: | ||
| 253 | + for reference in references(node): | ||
| 254 | + if depend(reference, node) is None: | ||
| 255 | + self.problems.append(f'{path}:{node.lineno}: internal package used dynamically') | ||
| 256 | + elif isinstance(node, ast.Call): | ||
| 257 | + function = node.func | ||
| 258 | + loader = isinstance(function, ast.Name) and function.id in loader_names | ||
| 259 | + loader = loader or ( | ||
| 260 | + isinstance(function, ast.Attribute) | ||
| 261 | + and function.attr == 'import_module' | ||
| 262 | + and isinstance(function.value, ast.Name) | ||
| 263 | + and function.value.id in importlib_names | ||
| 264 | + ) | ||
| 265 | + if loader: | ||
| 266 | + name = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None | ||
| 267 | + if not isinstance(name, str) or _internal(name) or name.startswith('.'): | ||
| 268 | + self.problems.append(f'{path}:{node.lineno}: dynamic internal import') | ||
| 269 | + if ( | ||
| 270 | + isinstance(function, ast.Name) | ||
| 271 | + and function.id in {'getattr', 'hasattr', 'setattr', 'delattr'} | ||
| 272 | + and node.args | ||
| 273 | + ): | ||
| 274 | + refs = references(node.args[0]) | ||
| 275 | + attribute = ( | ||
| 276 | + node.args[1].value if len(node.args) > 1 and isinstance(node.args[1], ast.Constant) else None | ||
| 277 | + ) | ||
| 278 | + for reference in refs: | ||
| 279 | + if isinstance(attribute, str) and function.id in {'getattr', 'hasattr'}: | ||
| 280 | + if depend(Reference(reference.module, reference.names + (attribute,)), node) is None: | ||
| 281 | + self.problems.append(f'{path}:{node.lineno}: internal package used dynamically') | ||
| 282 | + else: | ||
| 283 | + self.problems.append(f'{path}:{node.lineno}: dynamic internal attribute access') | ||
| 284 | + if isinstance(function, ast.Name) and function.id in {'eval', 'exec', 'globals', 'locals'}: | ||
| 285 | + self.problems.append(f'{path}:{node.lineno}: dynamic namespace access') | ||
| 286 | + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in bindings: | ||
| 287 | + parent = parents.get(node) | ||
| 288 | + if isinstance(parent, ast.Attribute) and parent.value is node: | ||
| 289 | + continue | ||
| 290 | + is_attribute_access = ( | ||
| 291 | + isinstance(parent, ast.Call) | ||
| 292 | + and isinstance(parent.func, ast.Name) | ||
| 293 | + and parent.func.id in {'getattr', 'hasattr', 'setattr', 'delattr'} | ||
| 294 | + ) | ||
| 295 | + if is_attribute_access and parent.args and parent.args[0] is node: | ||
| 296 | + continue | ||
| 297 | + for reference in bindings[node.id]: | ||
| 298 | + if depend(reference, node) is None: | ||
| 299 | + self.problems.append(f'{path}:{node.lineno}: internal package used dynamically') | ||
| 300 | + | ||
| 301 | + | ||
| 302 | +def select_tests(root: Path, changed_paths: list[str], full_patterns: list[str]) -> Selection: | ||
| 303 | + root = Path(root).resolve() | ||
| 304 | + if not root.is_dir(): | ||
| 305 | + raise ValueError(f'repository root does not exist: {root}') | ||
| 306 | + if not changed_paths: | ||
| 307 | + raise ValueError('changed-file list is empty') | ||
| 308 | + paths = sorted({_normalize(path) for path in changed_paths}) | ||
| 309 | + | ||
| 310 | + def full(reason: str) -> Selection: | ||
| 311 | + return Selection('full', ('tests',), (reason,)) | ||
| 312 | + | ||
| 313 | + code = [] | ||
| 314 | + for path in paths: | ||
| 315 | + for pattern in full_patterns: | ||
| 316 | + if fnmatchcase(path, pattern): | ||
| 317 | + return full(f'{path}: full-test rule {pattern}') | ||
| 318 | + # Keep CI-only adjustments available without forcing operator tests. | ||
| 319 | + # Explicit full-test rules above still take precedence. | ||
| 320 | + if _document(path) or path.startswith('.gitcode/'): | ||
| 321 | + continue | ||
| 322 | + if not path.endswith('.py') or not path.startswith(('src/cann_ops_tilelang/', 'tests/')): | ||
| 323 | + return full(f'{path}: unclassified non-document change') | ||
| 324 | + if not (root / path).is_file(): | ||
| 325 | + return full(f'{path}: deleted or unavailable Python file') | ||
| 326 | + if PurePosixPath(path).name == '__init__.py': | ||
| 327 | + return full(f'{path}: package initializer changed') | ||
| 328 | + code.append(path) | ||
| 329 | + if not code: | ||
| 330 | + return Selection('skip', (), ('only documentation or CI files changed',)) | ||
| 331 | + | ||
| 332 | + graph = DependencyGraph(root) | ||
| 333 | + if graph.problems: | ||
| 334 | + return full('dependency analysis is uncertain: ' + graph.problems[0]) | ||
| 335 | + targets, reasons = set(), [] | ||
| 336 | + for path in code: | ||
| 337 | + affected_files = graph.affected_files(path) | ||
| 338 | + if any(PurePosixPath(consumer).name == 'conftest.py' for consumer in affected_files): | ||
| 339 | + return full(f'{path}: affects implicitly loaded pytest fixtures') | ||
| 340 | + affected = {consumer for consumer in affected_files if _test_file(consumer)} | ||
| 341 | + if not affected: | ||
| 342 | + return full(f'{path}: no affected tests could be established') | ||
| 343 | + targets.update(affected) | ||
| 344 | + reasons.append(f'{path} -> {", ".join(sorted(affected))}') | ||
| 345 | + return Selection('incremental', tuple(sorted(targets)), tuple(reasons)) | ||
| 346 | + | ||
| 347 | + | ||
| 348 | +def main() -> int: | ||
| 349 | + logging.basicConfig(level=logging.INFO, format='%(message)s', stream=sys.stderr) | ||
| 350 | + parser = argparse.ArgumentParser(description=__doc__) | ||
| 351 | + parser.add_argument('--root', type=Path, required=True) | ||
| 352 | + parser.add_argument('--changed-files', type=Path, required=True) | ||
| 353 | + parser.add_argument('--full-test-triggers', type=Path, required=True) | ||
| 354 | + arguments = parser.parse_args() | ||
| 355 | + try: | ||
| 356 | + changed = [ | ||
| 357 | + line.strip() | ||
| 358 | + for line in arguments.changed_files.read_text(encoding='utf-8-sig').splitlines() | ||
| 359 | + if line.strip() | ||
| 360 | + ] | ||
| 361 | + patterns = [ | ||
| 362 | + line.strip() | ||
| 363 | + for line in arguments.full_test_triggers.read_text(encoding='utf-8-sig').splitlines() | ||
| 364 | + if line.strip() and not line.lstrip().startswith('#') | ||
| 365 | + ] | ||
| 366 | + if not patterns or any(len(pattern.split()) != 1 for pattern in patterns): | ||
| 367 | + raise ValueError('full-test trigger config must contain one nonempty pattern per rule') | ||
| 368 | + result = select_tests(arguments.root, changed, patterns) | ||
| 369 | + except (OSError, ValueError) as error: | ||
| 370 | + LOGGER.error('test selection failed: %s', error) | ||
| 371 | + return 2 | ||
| 372 | + for reason in result.reasons: | ||
| 373 | + LOGGER.info('%s', reason) | ||
| 374 | + # stdout is the machine-readable protocol consumed by ut.sh. | ||
| 375 | + sys.stdout.write('\n'.join((result.mode, *result.targets)) + '\n') | ||
| 376 | + return 0 | ||
| 377 | + | ||
| 378 | + | ||
| 379 | +if __name__ == '__main__': | ||
| 380 | + raise SystemExit(main()) | ||
| @@ -0,0 +1,383 @@ | |||
| 1 | +# MIT License | ||
| 2 | +# | ||
| 3 | +# Copyright (c) 2026 ops-tilelang contributors | ||
| 4 | +# | ||
| 5 | +# Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 6 | +# of this software and associated documentation files (the "Software"), to deal | ||
| 7 | +# in the Software without restriction, including without limitation the rights | ||
| 8 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 9 | +# copies of the Software, and to permit persons to whom the Software is | ||
| 10 | +# furnished to do so, subject to the following conditions: | ||
| 11 | +# | ||
| 12 | +# The above copyright notice and this permission notice shall be included in all | ||
| 13 | +# copies or substantial portions of the Software. | ||
| 14 | +# | ||
| 15 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 16 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 17 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 18 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 19 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 20 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 21 | +# SOFTWARE. | ||
| 22 | + | ||
| 23 | +"""CPU-only black-box regression tests for incremental test selection. | ||
| 24 | + | ||
| 25 | +Each case builds a tiny source checkout and invokes the selector CLI. No | ||
| 26 | +operator module, pytest plugin, accelerator runtime, or kernel is imported. | ||
| 27 | +""" | ||
| 28 | + | ||
| 29 | +from __future__ import annotations | ||
| 30 | + | ||
| 31 | +import subprocess | ||
| 32 | +import sys | ||
| 33 | +import tempfile | ||
| 34 | +import unittest | ||
| 35 | +from pathlib import Path | ||
| 36 | + | ||
| 37 | +SELECTOR = Path(__file__).resolve().parents[1] / 'select_tests.py' | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +class SelectTestsTests(unittest.TestCase): | ||
| 41 | + def setUp(self): | ||
| 42 | + self.temp = tempfile.TemporaryDirectory(prefix='ops-tilelang-selector-') | ||
| 43 | + self.addCleanup(self.temp.cleanup) | ||
| 44 | + self.root = Path(self.temp.name) | ||
| 45 | + self.changed_file = self.root / 'changed.txt' | ||
| 46 | + self.trigger_file = self.root / 'full_test_triggers.conf' | ||
| 47 | + self.trigger_file.write_text('pyproject.toml\ntests/conftest.py\n', encoding='utf-8') | ||
| 48 | + self.write('src/cann_ops_tilelang/__init__.py', 'from .ops import bar, foo\n') | ||
| 49 | + self.write('src/cann_ops_tilelang/ops/__init__.py', 'from .bar import bar\nfrom .foo import foo\n') | ||
| 50 | + self.write( | ||
| 51 | + 'src/cann_ops_tilelang/ops/foo.py', | ||
| 52 | + 'from .foo_kernel import kernel\n\ndef foo():\n return kernel()\n', | ||
| 53 | + ) | ||
| 54 | + self.write('src/cann_ops_tilelang/ops/foo_kernel.py', 'def kernel():\n return 1\n') | ||
| 55 | + self.write('src/cann_ops_tilelang/ops/bar.py', 'def bar():\n return 2\n') | ||
| 56 | + self.write('tests/__init__.py', '') | ||
| 57 | + self.write( | ||
| 58 | + 'tests/test_foo.py', | ||
| 59 | + 'from cann_ops_tilelang.ops.foo import foo\n\ndef test_foo():\n assert foo() == 1\n', | ||
| 60 | + ) | ||
| 61 | + self.write( | ||
| 62 | + 'tests/test_bar.py', | ||
| 63 | + 'from cann_ops_tilelang.ops.bar import bar\n\ndef test_bar():\n assert bar() == 2\n', | ||
| 64 | + ) | ||
| 65 | + | ||
| 66 | + def write(self, relative, content): | ||
| 67 | + path = self.root / relative | ||
| 68 | + path.parent.mkdir(parents=True, exist_ok=True) | ||
| 69 | + path.write_text(content, encoding='utf-8') | ||
| 70 | + return path | ||
| 71 | + | ||
| 72 | + def invoke(self, changed, *, extra_args=()): | ||
| 73 | + if changed is not None: | ||
| 74 | + self.changed_file.write_text('\n'.join(changed) + '\n', encoding='utf-8') | ||
| 75 | + return subprocess.run( | ||
| 76 | + [ | ||
| 77 | + sys.executable, | ||
| 78 | + str(SELECTOR), | ||
| 79 | + '--root', | ||
| 80 | + str(self.root), | ||
| 81 | + '--changed-files', | ||
| 82 | + str(self.changed_file), | ||
| 83 | + '--full-test-triggers', | ||
| 84 | + str(self.trigger_file), | ||
| 85 | + *extra_args, | ||
| 86 | + ], | ||
| 87 | + capture_output=True, | ||
| 88 | + text=True, | ||
| 89 | + timeout=20, | ||
| 90 | + check=False, | ||
| 91 | + ) | ||
| 92 | + | ||
| 93 | + def assert_selection(self, changed, mode, targets=()): | ||
| 94 | + result = self.invoke(changed) | ||
| 95 | + diagnostic = f'stdout:\n{result.stdout}\nstderr:\n{result.stderr}' | ||
| 96 | + self.assertEqual(result.returncode, 0, diagnostic) | ||
| 97 | + lines = result.stdout.splitlines() | ||
| 98 | + self.assertTrue(lines, diagnostic) | ||
| 99 | + self.assertEqual(lines[0], mode, diagnostic) | ||
| 100 | + self.assertEqual(set(lines[1:]), set(targets), diagnostic) | ||
| 101 | + self.assertEqual(len(lines[1:]), len(targets), 'Targets must be deduplicated.\n' + diagnostic) | ||
| 102 | + return result | ||
| 103 | + | ||
| 104 | + def test_absolute_import_selects_only_affected_test(self): | ||
| 105 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'incremental', ['tests/test_foo.py']) | ||
| 106 | + | ||
| 107 | + def test_relative_kernel_import_is_transitive(self): | ||
| 108 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'incremental', ['tests/test_foo.py']) | ||
| 109 | + | ||
| 110 | + def test_import_alias_and_module_attribute(self): | ||
| 111 | + self.write( | ||
| 112 | + 'tests/test_alias.py', | ||
| 113 | + 'import cann_ops_tilelang.ops.foo as foo_module\n' | ||
| 114 | + 'from cann_ops_tilelang.ops.foo import foo as aliased\n' | ||
| 115 | + '\ndef test_alias():\n assert foo_module.foo() == aliased()\n', | ||
| 116 | + ) | ||
| 117 | + self.assert_selection( | ||
| 118 | + ['src/cann_ops_tilelang/ops/foo_kernel.py'], | ||
| 119 | + 'incremental', | ||
| 120 | + ['tests/test_foo.py', 'tests/test_alias.py'], | ||
| 121 | + ) | ||
| 122 | + | ||
| 123 | + def test_root_reexport_resolves_symbol_without_expanding_whole_package(self): | ||
| 124 | + self.write('tests/test_root_foo.py', 'from cann_ops_tilelang import foo\n') | ||
| 125 | + self.write('tests/test_root_bar.py', 'from cann_ops_tilelang import bar\n') | ||
| 126 | + self.assert_selection( | ||
| 127 | + ['src/cann_ops_tilelang/ops/foo_kernel.py'], | ||
| 128 | + 'incremental', | ||
| 129 | + ['tests/test_foo.py', 'tests/test_root_foo.py'], | ||
| 130 | + ) | ||
| 131 | + | ||
| 132 | + def test_root_package_alias_attribute_resolves_specific_export(self): | ||
| 133 | + self.write('tests/test_ops_foo.py', 'import cann_ops_tilelang as ops\n\ndef test_foo():\n ops.foo()\n') | ||
| 134 | + self.write('tests/test_ops_bar.py', 'import cann_ops_tilelang as ops\n\ndef test_bar():\n ops.bar()\n') | ||
| 135 | + self.assert_selection( | ||
| 136 | + ['src/cann_ops_tilelang/ops/foo_kernel.py'], | ||
| 137 | + 'incremental', | ||
| 138 | + ['tests/test_foo.py', 'tests/test_ops_foo.py'], | ||
| 139 | + ) | ||
| 140 | + | ||
| 141 | + def test_multiple_operators_and_tests_follow_transitive_dependency(self): | ||
| 142 | + self.write('src/cann_ops_tilelang/shared.py', 'def helper():\n return 1\n') | ||
| 143 | + self.write( | ||
| 144 | + 'src/cann_ops_tilelang/ops/foo_kernel.py', | ||
| 145 | + 'from ..shared import helper\n\ndef kernel():\n return helper()\n', | ||
| 146 | + ) | ||
| 147 | + self.write( | ||
| 148 | + 'src/cann_ops_tilelang/ops/bar.py', | ||
| 149 | + 'from ..shared import helper\n\ndef bar():\n return helper()\n', | ||
| 150 | + ) | ||
| 151 | + self.write( | ||
| 152 | + 'tests/test_both.py', | ||
| 153 | + 'from cann_ops_tilelang.ops.foo import foo\nfrom cann_ops_tilelang.ops.bar import bar\n', | ||
| 154 | + ) | ||
| 155 | + self.assert_selection( | ||
| 156 | + ['src/cann_ops_tilelang/shared.py'], | ||
| 157 | + 'incremental', | ||
| 158 | + ['tests/test_foo.py', 'tests/test_bar.py', 'tests/test_both.py'], | ||
| 159 | + ) | ||
| 160 | + | ||
| 161 | + def test_cross_domain_quant_dependency_reaches_attention_and_normalization(self): | ||
| 162 | + self.write('src/cann_ops_tilelang/quant/__init__.py', 'from .per_token_cast import per_token_cast\n') | ||
| 163 | + self.write( | ||
| 164 | + 'src/cann_ops_tilelang/quant/per_token_cast.py', | ||
| 165 | + 'from .per_token_cast_kernel import kernel\n\ndef per_token_cast():\n return kernel()\n', | ||
| 166 | + ) | ||
| 167 | + self.write('src/cann_ops_tilelang/quant/per_token_cast_kernel.py', 'def kernel():\n return 1\n') | ||
| 168 | + self.write( | ||
| 169 | + 'tests/quant/test_per_token_cast.py', | ||
| 170 | + 'from cann_ops_tilelang.quant.per_token_cast import per_token_cast\n', | ||
| 171 | + ) | ||
| 172 | + self.write('tests/attention/test_cache.py', 'from cann_ops_tilelang.quant import per_token_cast as cast\n') | ||
| 173 | + self.write( | ||
| 174 | + 'tests/normalization/test_norm.py', | ||
| 175 | + 'from cann_ops_tilelang.quant.per_token_cast import per_token_cast\n', | ||
| 176 | + ) | ||
| 177 | + self.assert_selection( | ||
| 178 | + ['src/cann_ops_tilelang/quant/per_token_cast_kernel.py'], | ||
| 179 | + 'incremental', | ||
| 180 | + ['tests/quant/test_per_token_cast.py', 'tests/attention/test_cache.py', 'tests/normalization/test_norm.py'], | ||
| 181 | + ) | ||
| 182 | + | ||
| 183 | + def test_relative_test_helper_import_is_followed(self): | ||
| 184 | + self.write('tests/reference.py', 'from cann_ops_tilelang.ops.foo import foo\n') | ||
| 185 | + self.write('tests/test_reference.py', 'from .reference import foo\n') | ||
| 186 | + self.assert_selection(['tests/reference.py'], 'incremental', ['tests/test_reference.py']) | ||
| 187 | + self.assert_selection( | ||
| 188 | + ['src/cann_ops_tilelang/ops/foo_kernel.py'], | ||
| 189 | + 'incremental', | ||
| 190 | + ['tests/test_foo.py', 'tests/test_reference.py'], | ||
| 191 | + ) | ||
| 192 | + | ||
| 193 | + def test_new_operator_and_nonmatching_test_name_need_no_mapping(self): | ||
| 194 | + self.write('src/cann_ops_tilelang/new_family/__init__.py', '') | ||
| 195 | + self.write('src/cann_ops_tilelang/new_family/brand_new.py', 'def operation():\n return 3\n') | ||
| 196 | + self.write( | ||
| 197 | + 'tests/new_family/test_unrelated_name.py', | ||
| 198 | + 'from cann_ops_tilelang.new_family.brand_new import operation\n', | ||
| 199 | + ) | ||
| 200 | + self.assert_selection( | ||
| 201 | + ['src/cann_ops_tilelang/new_family/brand_new.py'], | ||
| 202 | + 'incremental', | ||
| 203 | + ['tests/new_family/test_unrelated_name.py'], | ||
| 204 | + ) | ||
| 205 | + | ||
| 206 | + def test_direct_test_change_runs_itself(self): | ||
| 207 | + self.assert_selection(['tests/test_foo.py'], 'incremental', ['tests/test_foo.py']) | ||
| 208 | + | ||
| 209 | + def test_duplicate_paths_and_dependency_routes_are_deduplicated(self): | ||
| 210 | + self.assert_selection( | ||
| 211 | + [ | ||
| 212 | + 'tests/test_foo.py', | ||
| 213 | + 'src/cann_ops_tilelang/ops/foo.py', | ||
| 214 | + 'src/cann_ops_tilelang/ops/foo_kernel.py', | ||
| 215 | + 'tests/test_foo.py', | ||
| 216 | + ], | ||
| 217 | + 'incremental', | ||
| 218 | + ['tests/test_foo.py'], | ||
| 219 | + ) | ||
| 220 | + | ||
| 221 | + def test_unknown_source_runs_full_scope(self): | ||
| 222 | + self.write('src/cann_ops_tilelang/ops/unreferenced.py', 'VALUE = 1\n') | ||
| 223 | + self.assert_selection(['src/cann_ops_tilelang/ops/unreferenced.py'], 'full', ['tests']) | ||
| 224 | + | ||
| 225 | + def test_dynamic_local_import_runs_full_scope(self): | ||
| 226 | + self.write( | ||
| 227 | + 'tests/test_dynamic.py', | ||
| 228 | + 'import importlib\n\ndef test_dynamic():\n' | ||
| 229 | + ' module_name = "cann_ops_tilelang.ops.foo"\n' | ||
| 230 | + ' module = importlib.import_module(module_name)\n' | ||
| 231 | + ' module.foo()\n', | ||
| 232 | + ) | ||
| 233 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'full', ['tests']) | ||
| 234 | + | ||
| 235 | + def test_dynamic_import_loader_aliases_cannot_hide_affected_tests(self): | ||
| 236 | + loaders = ( | ||
| 237 | + 'import importlib\nload = importlib.import_module\n', | ||
| 238 | + 'load = __import__\n', | ||
| 239 | + 'from importlib import import_module as module_loader\nload = module_loader\n', | ||
| 240 | + ) | ||
| 241 | + for loader in loaders: | ||
| 242 | + with self.subTest(loader=loader): | ||
| 243 | + self.write( | ||
| 244 | + 'tests/test_dynamic_alias.py', | ||
| 245 | + loader + '\ndef test_dynamic():\n load("cann_ops_tilelang.ops.foo")\n', | ||
| 246 | + ) | ||
| 247 | + # The existing direct consumer must not mask this dynamic one. | ||
| 248 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 249 | + | ||
| 250 | + def test_package_namespace_assignment_runs_full_scope(self): | ||
| 251 | + self.write( | ||
| 252 | + 'tests/test_namespace.py', | ||
| 253 | + 'import cann_ops_tilelang as ops\nnamespace = ops.ops\n\ndef test_namespace():\n namespace.foo()\n', | ||
| 254 | + ) | ||
| 255 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 256 | + | ||
| 257 | + def test_eval_import_runs_full_scope(self): | ||
| 258 | + self.write( | ||
| 259 | + 'tests/test_eval.py', | ||
| 260 | + 'def test_eval():\n eval("__import__(\'cann_ops_tilelang.ops.foo\')")\n', | ||
| 261 | + ) | ||
| 262 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 263 | + | ||
| 264 | + def test_nested_conftest_change_runs_full_scope(self): | ||
| 265 | + self.write('tests/group/conftest.py', 'VALUE = 1\n') | ||
| 266 | + self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n') | ||
| 267 | + self.assert_selection(['tests/group/conftest.py'], 'full', ['tests']) | ||
| 268 | + | ||
| 269 | + def test_helper_used_by_conftest_cannot_select_only_direct_consumers(self): | ||
| 270 | + self.write('tests/reference.py', 'VALUE = 1\n') | ||
| 271 | + self.write('tests/test_direct_helper.py', 'from tests.reference import VALUE\n') | ||
| 272 | + self.write('tests/group/conftest.py', 'from tests.reference import VALUE\n') | ||
| 273 | + self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n') | ||
| 274 | + self.assert_selection(['tests/reference.py'], 'full', ['tests']) | ||
| 275 | + | ||
| 276 | + def test_plugin_helper_reaches_conftest_through_pytest_plugins(self): | ||
| 277 | + self.write('tests/reference.py', 'VALUE = 1\n') | ||
| 278 | + self.write('tests/test_direct_helper.py', 'from tests.reference import VALUE\n') | ||
| 279 | + self.write('tests/fixture_plugin.py', 'from tests.reference import VALUE\n') | ||
| 280 | + self.write('tests/group/conftest.py', 'pytest_plugins = ["tests.fixture_plugin"]\n') | ||
| 281 | + self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n') | ||
| 282 | + self.assert_selection(['tests/reference.py'], 'full', ['tests']) | ||
| 283 | + | ||
| 284 | + def test_dynamic_pytest_plugins_runs_full_scope(self): | ||
| 285 | + self.write('tests/group/conftest.py', 'pytest_plugins = discover_plugins()\n') | ||
| 286 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 287 | + | ||
| 288 | + def test_package_star_import_runs_full_scope(self): | ||
| 289 | + self.write('tests/test_star.py', 'from cann_ops_tilelang import *\n') | ||
| 290 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'full', ['tests']) | ||
| 291 | + | ||
| 292 | + def test_syntax_error_runs_full_scope(self): | ||
| 293 | + self.write('src/cann_ops_tilelang/ops/foo.py', 'def foo(:\n') | ||
| 294 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 295 | + | ||
| 296 | + def test_deleted_source_runs_full_scope(self): | ||
| 297 | + (self.root / 'src/cann_ops_tilelang/ops/foo.py').unlink() | ||
| 298 | + self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests']) | ||
| 299 | + | ||
| 300 | + def test_deleted_test_runs_full_scope(self): | ||
| 301 | + (self.root / 'tests/test_foo.py').unlink() | ||
| 302 | + self.assert_selection(['tests/test_foo.py'], 'full', ['tests']) | ||
| 303 | + | ||
| 304 | + def test_package_initializers_run_full_scope(self): | ||
| 305 | + for changed in ('src/cann_ops_tilelang/__init__.py', 'src/cann_ops_tilelang/ops/__init__.py'): | ||
| 306 | + with self.subTest(changed=changed): | ||
| 307 | + self.assert_selection([changed], 'full', ['tests']) | ||
| 308 | + | ||
| 309 | + def test_global_trigger_configuration_is_read(self): | ||
| 310 | + self.trigger_file.write_text('# policy comment\n\nrequirements*.txt\n', encoding='utf-8') | ||
| 311 | + self.write('requirements-dev.txt', 'pytest\n') | ||
| 312 | + self.assert_selection(['requirements-dev.txt'], 'full', ['tests']) | ||
| 313 | + | ||
| 314 | + def test_global_trigger_dominates_incremental_selection(self): | ||
| 315 | + self.write('pyproject.toml', '[project]\nname = "test"\n') | ||
| 316 | + self.assert_selection(['tests/test_foo.py', 'pyproject.toml'], 'full', ['tests']) | ||
| 317 | + | ||
| 318 | + def test_docs_only_skip(self): | ||
| 319 | + self.write('README.md', 'Documentation only.\n') | ||
| 320 | + self.write('docs/new_operator.md', 'Documentation only.\n') | ||
| 321 | + self.assert_selection(['README.md', 'docs/new_operator.md'], 'skip') | ||
| 322 | + | ||
| 323 | + def test_ci_only_changes_skip_operator_tests(self): | ||
| 324 | + paths = ( | ||
| 325 | + '.gitcode/config/full_test_triggers.conf', | ||
| 326 | + '.gitcode/pipeline.yml', | ||
| 327 | + '.gitcode/scripts/ut.sh', | ||
| 328 | + '.gitcode/scripts/select_tests.py', | ||
| 329 | + '.gitcode/scripts/tests/test_select_tests.py', | ||
| 330 | + ) | ||
| 331 | + for path in paths: | ||
| 332 | + with self.subTest(path=path): | ||
| 333 | + self.write(path, '# CI adjustment\n') | ||
| 334 | + self.assert_selection([path], 'skip') | ||
| 335 | + (self.root / path).unlink() | ||
| 336 | + self.assert_selection([path], 'skip') | ||
| 337 | + self.assert_selection([*paths, 'README.md'], 'skip') | ||
| 338 | + | ||
| 339 | + def test_ci_changes_preserve_source_selection(self): | ||
| 340 | + self.assert_selection( | ||
| 341 | + ['.gitcode/scripts/ut.sh', 'src/cann_ops_tilelang/ops/foo_kernel.py'], | ||
| 342 | + 'incremental', | ||
| 343 | + ['tests/test_foo.py'], | ||
| 344 | + ) | ||
| 345 | + self.assert_selection(['.gitcode/scripts/ut.sh', 'pyproject.toml'], 'full', ['tests']) | ||
| 346 | + | ||
| 347 | + def test_explicit_ci_full_test_rule_takes_precedence(self): | ||
| 348 | + self.trigger_file.write_text('pyproject.toml\n.gitcode/*\n', encoding='utf-8') | ||
| 349 | + self.assert_selection(['.gitcode/scripts/ut.sh'], 'full', ['tests']) | ||
| 350 | + | ||
| 351 | + def test_unknown_changes_outside_ci_still_run_full_scope(self): | ||
| 352 | + self.assert_selection(['.gitcode-other/pipeline.yml'], 'full', ['tests']) | ||
| 353 | + | ||
| 354 | + def test_missing_changed_file_is_error(self): | ||
| 355 | + result = self.invoke(None) | ||
| 356 | + self.assertNotEqual(result.returncode, 0, result.stdout) | ||
| 357 | + self.assertTrue(result.stderr) | ||
| 358 | + | ||
| 359 | + def test_empty_changed_file_is_error(self): | ||
| 360 | + result = self.invoke([]) | ||
| 361 | + self.assertNotEqual(result.returncode, 0, result.stdout) | ||
| 362 | + self.assertTrue(result.stderr) | ||
| 363 | + | ||
| 364 | + def test_missing_trigger_config_is_error(self): | ||
| 365 | + self.trigger_file.unlink() | ||
| 366 | + result = self.invoke(['tests/test_foo.py']) | ||
| 367 | + self.assertNotEqual(result.returncode, 0, result.stdout) | ||
| 368 | + self.assertTrue(result.stderr) | ||
| 369 | + | ||
| 370 | + def test_invalid_changed_path_is_error(self): | ||
| 371 | + result = self.invoke(['../outside.py']) | ||
| 372 | + self.assertNotEqual(result.returncode, 0, result.stdout) | ||
| 373 | + self.assertTrue(result.stderr) | ||
| 374 | + | ||
| 375 | + def test_invalid_trigger_row_is_error(self): | ||
| 376 | + self.trigger_file.write_text('pyproject.toml unexpected-second-field\n', encoding='utf-8') | ||
| 377 | + result = self.invoke(['tests/test_foo.py']) | ||
| 378 | + self.assertNotEqual(result.returncode, 0, result.stdout) | ||
| 379 | + self.assertTrue(result.stderr) | ||
| 380 | + | ||
| 381 | + | ||
| 382 | +if __name__ == '__main__': | ||
| 383 | + unittest.main() | ||
| @@ -6,14 +6,11 @@ | |||
| 6 | # Override the backend list with UT_TARGETS, e.g. UT_TARGETS="auto" or | 6 | # Override the backend list with UT_TARGETS, e.g. UT_TARGETS="auto" or |
| 7 | # UT_TARGETS="pto". | 7 | # UT_TARGETS="pto". |
| 8 | # | 8 | # |
| 9 | -# Incremental mode (default when pr_filelist.txt exists): run changed | 9 | +# Incremental mode (default when pr_filelist.txt exists): statically discover |
| 10 | -# pytest files directly and map changed operator sources to their pytest files; | 10 | +# tests affected by changed Python modules, including transitive imports. |
| 11 | -# changes to configured shared files instead run the complete L0 test scope; | 11 | +# Shared-file changes or uncertain dependencies run the complete L0 scope. |
| 12 | -# exit 0 if no changed file qualifies. Override with PR_FILELIST env var. | 12 | +# No operator-to-test mapping is required. Override the changed-file list with |
| 13 | -# Operator mapping rules live in .gitcode/config/operator_tests.conf; override | 13 | +# PR_FILELIST and global full-test rules with FULL_TEST_TRIGGER_CONFIG. |
| 14 | -# with OPERATOR_TEST_MAP. Full-test trigger rules live in | ||
| 15 | -# .gitcode/config/full_test_triggers.conf; override with | ||
| 16 | -# FULL_TEST_TRIGGER_CONFIG. Rules are loaded once in incremental mode. | ||
| 17 | # | 14 | # |
| 18 | # Optional overrides: | 15 | # Optional overrides: |
| 19 | # DEVICE_ID=1,3 PYTEST_WORKERS=6 bash scripts/ut.sh | 16 | # DEVICE_ID=1,3 PYTEST_WORKERS=6 bash scripts/ut.sh |
| @@ -33,7 +30,6 @@ CONDA_ENV="${CONDA_ENV:-tl_tilelang0922ci}" | |||
| 33 | DEVICE_ID="${DEVICE_ID:-${ASCEND_RT_VISIBLE_DEVICES:-2,3}}" | 30 | DEVICE_ID="${DEVICE_ID:-${ASCEND_RT_VISIBLE_DEVICES:-2,3}}" |
| 34 | PYTEST_WORKERS="${PYTEST_WORKERS:-6}" | 31 | PYTEST_WORKERS="${PYTEST_WORKERS:-6}" |
| 35 | PR_FILELIST="${PR_FILELIST:-${ROOT_DIR}/pr_filelist.txt}" | 32 | PR_FILELIST="${PR_FILELIST:-${ROOT_DIR}/pr_filelist.txt}" |
| 36 | -OPERATOR_TEST_MAP="${OPERATOR_TEST_MAP:-${ROOT_DIR}/.gitcode/config/operator_tests.conf}" | ||
| 37 | FULL_TEST_TRIGGER_CONFIG="${FULL_TEST_TRIGGER_CONFIG:-${ROOT_DIR}/.gitcode/config/full_test_triggers.conf}" | 33 | FULL_TEST_TRIGGER_CONFIG="${FULL_TEST_TRIGGER_CONFIG:-${ROOT_DIR}/.gitcode/config/full_test_triggers.conf}" |
| 38 | 34 | ||
| 39 | if [[ ! "${PYTEST_WORKERS}" =~ ^[1-9][0-9]*$ ]]; then | 35 | if [[ ! "${PYTEST_WORKERS}" =~ ^[1-9][0-9]*$ ]]; then |
| @@ -60,177 +56,37 @@ echo "Running regular pytest cases on NPU ${ASCEND_RT_VISIBLE_DEVICES} with ${PY | |||
| 60 | if (( $# > 0 )); then | 56 | if (( $# > 0 )); then |
| 61 | pytest_targets=("$@") | 57 | pytest_targets=("$@") |
| 62 | elif [[ -f "${PR_FILELIST}" ]]; then | 58 | elif [[ -f "${PR_FILELIST}" ]]; then |
| 63 | - # Incremental mode: pick changed pytest files and tests mapped from changed | 59 | + python -m unittest discover -s "${ROOT_DIR}/.gitcode/scripts/tests" |
| 64 | - # operator sources in pr_filelist.txt. | 60 | + # Capture the command first so selector failures propagate under set -e. |
| 65 | - pytest_targets=() | 61 | + selection_output="$(python "${ROOT_DIR}/.gitcode/scripts/select_tests.py" \ |
| 66 | - | 62 | + --root "${ROOT_DIR}" \ |
| 67 | - add_pytest_target() { | 63 | + --changed-files "${PR_FILELIST}" \ |
| 68 | - local test_path="$1" | 64 | + --full-test-triggers "${FULL_TEST_TRIGGER_CONFIG}")" |
| 69 | - local existing | 65 | + mapfile -t selection <<< "${selection_output}" |
| 70 | - | 66 | + case "${selection[0]}" in |
| 71 | - # Deleted or not-yet-migrated tests cannot be passed to pytest. | 67 | + full) |
| 72 | - if [[ ! -f "${ROOT_DIR}/${test_path}" ]]; then | 68 | + pytest_targets=(tests) |
| 73 | - return | 69 | + export OPS_TILELANG_TEST_LEVEL=0 |
| 74 | - fi | 70 | + echo "Full L0 UT selected by changed-file analysis." |
| 75 | - for existing in "${pytest_targets[@]}"; do | 71 | + ;; |
| 76 | - if [[ "${existing}" == "${test_path}" ]]; then | 72 | + incremental) |
| 77 | - return | 73 | + pytest_targets=("${selection[@]:1}") |
| 74 | + if (( ${#pytest_targets[@]} == 0 )); then | ||
| 75 | + echo "Test selector returned an empty incremental scope." >&2 | ||
| 76 | + exit 2 | ||
| 78 | fi | 77 | fi |
| 79 | - done | 78 | + echo "Incremental UT on ${#pytest_targets[@]} affected pytest file(s):" |
| 80 | - pytest_targets+=("${test_path}") | 79 | + printf ' %s\n' "${pytest_targets[@]}" |
| 81 | - } | 80 | + ;; |
| 82 | - | 81 | + skip) |
| 83 | - load_full_test_triggers() { | 82 | + echo "No affected executable files; skip UT." |
| 84 | - local config_file="$1" | ||
| 85 | - local line line_number=0 | ||
| 86 | - local -a fields=() | ||
| 87 | - full_test_source_patterns=() | ||
| 88 | - | ||
| 89 | - if [[ ! -f "${config_file}" || ! -r "${config_file}" ]]; then | ||
| 90 | - echo "Full-test trigger config is missing or unreadable: ${config_file}" >&2 | ||
| 91 | - return 2 | ||
| 92 | - fi | ||
| 93 | - | ||
| 94 | - while IFS= read -r line || [[ -n "${line}" ]]; do | ||
| 95 | - ((line_number += 1)) | ||
| 96 | - line="${line%$'\r'}" | ||
| 97 | - read -r -a fields <<< "${line}" | ||
| 98 | - if (( ${#fields[@]} == 0 )) || [[ "${fields[0]}" == \#* ]]; then | ||
| 99 | - continue | ||
| 100 | - fi | ||
| 101 | - if (( ${#fields[@]} != 1 )); then | ||
| 102 | - echo "${config_file}:${line_number}: expected exactly one source pattern" >&2 | ||
| 103 | - return 2 | ||
| 104 | - fi | ||
| 105 | - full_test_source_patterns+=("${fields[0]}") | ||
| 106 | - done < "${config_file}" | ||
| 107 | - | ||
| 108 | - if (( ${#full_test_source_patterns[@]} == 0 )); then | ||
| 109 | - echo "Full-test trigger config contains no patterns: ${config_file}" >&2 | ||
| 110 | - return 2 | ||
| 111 | - fi | ||
| 112 | - } | ||
| 113 | - | ||
| 114 | - match_full_test_trigger() { | ||
| 115 | - local file_path="$1" | ||
| 116 | - local index | ||
| 117 | - | ||
| 118 | - for ((index = 0; index < ${#full_test_source_patterns[@]}; index++)); do | ||
| 119 | - # Leave the right-hand side unquoted to apply the configured Bash glob. | ||
| 120 | - if [[ "${file_path}" == ${full_test_source_patterns[index]} ]]; then | ||
| 121 | - full_test_changed_file="${file_path}" | ||
| 122 | - full_test_matched_pattern="${full_test_source_patterns[index]}" | ||
| 123 | - return 0 | ||
| 124 | - fi | ||
| 125 | - done | ||
| 126 | - return 1 | ||
| 127 | - } | ||
| 128 | - | ||
| 129 | - load_operator_test_map() { | ||
| 130 | - local map_file="$1" | ||
| 131 | - local line line_number=0 test_path | ||
| 132 | - local -a fields=() | ||
| 133 | - operator_source_patterns=() | ||
| 134 | - operator_test_targets=() | ||
| 135 | - | ||
| 136 | - if [[ ! -f "${map_file}" || ! -r "${map_file}" ]]; then | ||
| 137 | - echo "Operator test map is missing or unreadable: ${map_file}" >&2 | ||
| 138 | - return 2 | ||
| 139 | - fi | ||
| 140 | - | ||
| 141 | - while IFS= read -r line || [[ -n "${line}" ]]; do | ||
| 142 | - ((line_number += 1)) | ||
| 143 | - line="${line%$'\r'}" | ||
| 144 | - read -r -a fields <<< "${line}" | ||
| 145 | - if (( ${#fields[@]} == 0 )) || [[ "${fields[0]}" == \#* ]]; then | ||
| 146 | - continue | ||
| 147 | - fi | ||
| 148 | - if (( ${#fields[@]} < 2 )); then | ||
| 149 | - echo "${map_file}:${line_number}: expected a source pattern and test paths" >&2 | ||
| 150 | - return 2 | ||
| 151 | - fi | ||
| 152 | - for test_path in "${fields[@]:1}"; do | ||
| 153 | - if [[ "${test_path}" != tests/*.py || "${test_path}" == */../* ]]; then | ||
| 154 | - echo "${map_file}:${line_number}: invalid test path: ${test_path}" >&2 | ||
| 155 | - return 2 | ||
| 156 | - fi | ||
| 157 | - done | ||
| 158 | - operator_source_patterns+=("${fields[0]}") | ||
| 159 | - operator_test_targets+=("${fields[*]:1}") | ||
| 160 | - done < "${map_file}" | ||
| 161 | - } | ||
| 162 | - | ||
| 163 | - map_operator_tests() { | ||
| 164 | - local file_path="$1" | ||
| 165 | - local index test_path | ||
| 166 | - local -a targets=() | ||
| 167 | - | ||
| 168 | - for ((index = 0; index < ${#operator_source_patterns[@]}; index++)); do | ||
| 169 | - # Leave the right-hand side unquoted to apply the configured Bash glob. | ||
| 170 | - if [[ "${file_path}" == ${operator_source_patterns[index]} ]]; then | ||
| 171 | - read -r -a targets <<< "${operator_test_targets[index]}" | ||
| 172 | - for test_path in "${targets[@]}"; do | ||
| 173 | - add_pytest_target "${test_path}" | ||
| 174 | - done | ||
| 175 | - # Match the original case statement: the first matching rule wins. | ||
| 176 | - return 0 | ||
| 177 | - fi | ||
| 178 | - done | ||
| 179 | - return 0 | ||
| 180 | - } | ||
| 181 | - | ||
| 182 | - load_full_test_triggers "${FULL_TEST_TRIGGER_CONFIG}" | ||
| 183 | - load_operator_test_map "${OPERATOR_TEST_MAP}" | ||
| 184 | - | ||
| 185 | - mapfile -t changed_lines < "${PR_FILELIST}" | ||
| 186 | - full_test_triggered=0 | ||
| 187 | - for file_path in "${changed_lines[@]}" | ||
| 188 | - do | ||
| 189 | - file_path="$(echo "${file_path}" | xargs)" | ||
| 190 | - if [[ -z "${file_path}" ]]; then | ||
| 191 | - continue | ||
| 192 | - fi | ||
| 193 | - if match_full_test_trigger "${file_path}"; then | ||
| 194 | - full_test_triggered=1 | ||
| 195 | - break | ||
| 196 | - fi | ||
| 197 | - done | ||
| 198 | - | ||
| 199 | - if (( full_test_triggered )); then | ||
| 200 | - pytest_targets=(tests) | ||
| 201 | - export OPS_TILELANG_TEST_LEVEL=0 | ||
| 202 | - echo "Full L0 UT triggered by shared-file change:" | ||
| 203 | - echo " changed file: ${full_test_changed_file}" | ||
| 204 | - echo " matched rule: ${full_test_matched_pattern}" | ||
| 205 | - echo " pytest target: tests" | ||
| 206 | - echo " OPS_TILELANG_TEST_LEVEL=${OPS_TILELANG_TEST_LEVEL}" | ||
| 207 | - else | ||
| 208 | - for file_path in "${changed_lines[@]}" | ||
| 209 | - do | ||
| 210 | - # Keep the committed name-only file-list parsing behavior. | ||
| 211 | - file_path="$(echo "${file_path}" | xargs)" | ||
| 212 | - if [[ -z "${file_path}" ]]; then | ||
| 213 | - continue | ||
| 214 | - fi | ||
| 215 | - | ||
| 216 | - # Keep the existing behavior for directly changed pytest files. | ||
| 217 | - if [[ "${file_path}" == tests/* ]]; then | ||
| 218 | - base_name="$(basename -- "${file_path}")" | ||
| 219 | - if [[ "${base_name}" == test_*.py || "${base_name}" == *_test.py ]]; then | ||
| 220 | - add_pytest_target "${file_path}" | ||
| 221 | - continue | ||
| 222 | - fi | ||
| 223 | - fi | ||
| 224 | - | ||
| 225 | - map_operator_tests "${file_path}" | ||
| 226 | - done | ||
| 227 | - if (( ${#pytest_targets[@]} == 0 )); then | ||
| 228 | - echo "No changed pytest file, mapped operator test, or full-test trigger found; skip UT." | ||
| 229 | exit 0 | 83 | exit 0 |
| 230 | - fi | 84 | + ;; |
| 231 | - echo "Incremental UT on ${#pytest_targets[@]} changed or mapped pytest file(s):" | 85 | + *) |
| 232 | - printf ' %s\n' "${pytest_targets[@]}" | 86 | + echo "Invalid test selector result: ${selection[0]}" >&2 |
| 233 | - fi | 87 | + exit 2 |
| 88 | + ;; | ||
| 89 | + esac | ||
| 234 | else | 90 | else |
| 235 | pytest_targets=(tests) | 91 | pytest_targets=(tests) |
| 236 | fi | 92 | fi |
| @@ -9,6 +9,14 @@ | |||
| 9 | - `pytest_xdist_failfast_plugin.py`:并行 `-x` 时停止全部 worker; | 9 | - `pytest_xdist_failfast_plugin.py`:并行 `-x` 时停止全部 worker; |
| 10 | - `pytest_tilelang_warning_plugin.py`:在终端汇总 TileLang 编译警告。 | 10 | - `pytest_tilelang_warning_plugin.py`:在终端汇总 TileLang 编译警告。 |
| 11 | 11 | ||
| 12 | +## CI 增量测试 | ||
| 13 | + | ||
| 14 | +CI 根据 `pr_filelist.txt` 中的变更,通过 `.gitcode/scripts/select_tests.py` 静态分析源码和测试的导入关系,自动选择直接或间接受影响的测试。新增算子及测试无需登记映射;测试应通过常规 Python 导入引用被测实现和参考实现。 | ||
| 15 | + | ||
| 16 | +共享配置、包入口、删除文件或无法确定依赖关系的代码变更保守执行全仓 L0。全局触发策略保留在 `.gitcode/config/full_test_triggers.conf`,其中不维护算子与测试的对应关系。选择器的 CPU 回归测试可用 `python -m unittest discover -s .gitcode/scripts/tests` 运行。 | ||
| 17 | + | ||
| 18 | +`.gitcode/` 下的 CI 文件默认不触发算子测试,便于管理员在机器故障等场景调整执行逻辑。仅修改 CI 文件或文档时跳过算子用例;同时修改源码或测试时,仍正常选择受影响的测试。显式配置的全量触发规则优先,管理员可按需添加 CI 路径规则;选择器的 CPU 回归测试仍会执行。 | ||
| 19 | + | ||
| 12 | ## 测试分级 | 20 | ## 测试分级 |
| 13 | 21 | ||
| 14 | `OPS_TILELANG_TEST_LEVEL` 取值为 `0`、`1`、`2`,分别表示核心、默认和完整参数集合。参数生成器位于 `tests/testing/generator.py`。 | 22 | `OPS_TILELANG_TEST_LEVEL` 取值为 `0`、`1`、`2`,分别表示核心、默认和完整参数集合。参数生成器位于 `tests/testing/generator.py`。 |