Spaces:
Paused
Paused
| """SPEC-4 §4: Playwright import + async/sync 分离静态守卫。 | |
| 通过 AST 扫描 src/autoteam/**/*.py,断言: | |
| - 不出现 from playwright.async_api ... | |
| - async def 函数体内不引用任何 playwright 符号 | |
| - playwright.sync_api 导入仅限白名单 | |
| - 已豁免的文件必须显式登记在 expected_exempt 集合 | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| from pathlib import Path | |
| from autoteam._playwright_guard import ( | |
| ALLOWED_SYNC_NAMES, | |
| EXEMPTION_MARKER, | |
| FORBIDDEN_MODULES, | |
| ) | |
| SRC_ROOT = Path(__file__).resolve().parents[2] / "src" / "autoteam" | |
| PLAYWRIGHT_NAMESPACE = ALLOWED_SYNC_NAMES | {"async_playwright"} | |
| def _is_exempt(path: Path) -> bool: | |
| """文件首 5 行内出现 EXEMPTION_MARKER 即视为豁免。""" | |
| try: | |
| head = "\n".join(path.read_text(encoding="utf-8").splitlines()[:5]) | |
| except OSError: | |
| return False | |
| return EXEMPTION_MARKER in head | |
| def _iter_py_files(): | |
| for path in SRC_ROOT.rglob("*.py"): | |
| if not _is_exempt(path): | |
| yield path | |
| def _parse(path: Path) -> ast.AST: | |
| return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) | |
| def test_no_forbidden_module_import(): | |
| """A1+A2: 禁止 from playwright.async_api / import playwright.async_api""" | |
| bad: list[str] = [] | |
| for path in _iter_py_files(): | |
| tree = _parse(path) | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.ImportFrom) and node.module in FORBIDDEN_MODULES: | |
| bad.append(f"{path}:{node.lineno} from {node.module}") | |
| elif isinstance(node, ast.Import): | |
| for alias in node.names: | |
| if alias.name in FORBIDDEN_MODULES: | |
| bad.append(f"{path}:{node.lineno} import {alias.name}") | |
| assert not bad, "禁止使用 playwright.async_api:\n" + "\n".join(bad) | |
| def test_no_playwright_in_async_def(): | |
| """A3: async def 函数体内不允许 playwright 符号""" | |
| bad: list[str] = [] | |
| for path in _iter_py_files(): | |
| tree = _parse(path) | |
| for fn in ast.walk(tree): | |
| if not isinstance(fn, ast.AsyncFunctionDef): | |
| continue | |
| for child in ast.walk(fn): | |
| name = getattr(child, "id", None) or getattr(child, "attr", None) | |
| if name in PLAYWRIGHT_NAMESPACE: | |
| bad.append( | |
| f"{path}:{getattr(child, 'lineno', fn.lineno)} " | |
| f"async def {fn.name} 内引用了 {name}" | |
| ) | |
| assert not bad, "async def 函数体内禁止 playwright:\n" + "\n".join(bad) | |
| def test_sync_import_whitelist(): | |
| """A4: playwright.sync_api 导入仅限 ALLOWED_SYNC_NAMES""" | |
| bad: list[str] = [] | |
| for path in _iter_py_files(): | |
| tree = _parse(path) | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.ImportFrom) and node.module == "playwright.sync_api": | |
| for alias in node.names: | |
| if alias.name not in ALLOWED_SYNC_NAMES: | |
| bad.append(f"{path}:{node.lineno} 非白名单符号 {alias.name}") | |
| assert not bad, "sync_api 导入不在白名单:\n" + "\n".join(bad) | |
| def test_exemption_list_intentional(): | |
| """SPEC-4 §5: 豁免文件需登记理由,避免静默扩散""" | |
| exempt = [p for p in SRC_ROOT.rglob("*.py") if _is_exempt(p)] | |
| expected_exempt: set[str] = set() | |
| actual = {p.relative_to(SRC_ROOT).as_posix() for p in exempt} | |
| unexpected = actual - expected_exempt | |
| assert not unexpected, ( | |
| f"未登记的豁免文件:{unexpected}。" | |
| f"如需新增,先更新 SPEC-4 §5 + 本测试 expected_exempt 集合" | |
| ) | |