Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
1638 lines
52 KiB
Python
1638 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Check built-in channel contract coverage without importing QwenPaw.
|
|
|
|
Usage:
|
|
python scripts/check_channel_contracts.py
|
|
python scripts/check_channel_contracts.py --list-specs
|
|
|
|
The built-in registry is the source of truth. Source implementations and
|
|
contract tests are inspected with the standard-library AST module so the
|
|
check does not require optional channel dependencies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
REGISTRY_PATH = Path("src/qwenpaw/app/channels/registry.py")
|
|
CHANNELS_PATH = Path("src/qwenpaw/app/channels")
|
|
CONTRACT_TESTS_PATH = Path("tests/contract/channels")
|
|
CHANNEL_PACKAGE = "qwenpaw.app.channels"
|
|
BASE_CHANNEL = f"{CHANNEL_PACKAGE}.base.BaseChannel"
|
|
CONTRACT_BASE = "tests.contract.channels.ChannelContractTest"
|
|
DISABLED_PYTEST_CALLS = {
|
|
"pytest.importorskip",
|
|
"pytest.skip",
|
|
"pytest.xfail",
|
|
}
|
|
|
|
|
|
class CoverageCheckError(RuntimeError):
|
|
"""Raised when the static coverage model cannot be evaluated safely."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChannelSpec:
|
|
"""A statically declared built-in channel."""
|
|
|
|
key: str
|
|
module: str
|
|
class_name: str
|
|
|
|
@property
|
|
def suggested_test_path(self) -> str:
|
|
"""Return the canonical contract-test path for this registry key."""
|
|
return f"tests/contract/channels/test_{self.key}_contract.py"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceClass:
|
|
"""A module-qualified class declaration."""
|
|
|
|
qualified_name: str
|
|
bases: tuple[str, ...]
|
|
path: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModuleInfo:
|
|
"""Static exports for one Python module."""
|
|
|
|
name: str
|
|
path: Path
|
|
exports: dict[str, str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceIndex:
|
|
"""Static channel modules and their class declarations."""
|
|
|
|
classes: dict[str, SourceClass]
|
|
modules: dict[str, ModuleInfo]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CoverageReport:
|
|
"""Static coverage result for the current repository."""
|
|
|
|
specs: tuple[ChannelSpec, ...]
|
|
tested_classes: frozenset[str]
|
|
errors: tuple[str, ...]
|
|
|
|
@property
|
|
def all_classes(self) -> frozenset[str]:
|
|
"""Return all registered built-in class names."""
|
|
return frozenset(spec.class_name for spec in self.specs)
|
|
|
|
@property
|
|
def missing_specs(self) -> tuple[ChannelSpec, ...]:
|
|
"""Return registered channels without a recognized contract test."""
|
|
return tuple(
|
|
spec
|
|
for spec in self.specs
|
|
if spec.class_name not in self.tested_classes
|
|
)
|
|
|
|
|
|
def _read_ast(path: Path) -> ast.Module:
|
|
"""Read a UTF-8 Python file and return its parsed syntax tree."""
|
|
try:
|
|
source = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise CoverageCheckError(f"cannot read {path}: {exc}") from exc
|
|
|
|
try:
|
|
return ast.parse(source, filename=str(path))
|
|
except SyntaxError as exc:
|
|
raise CoverageCheckError(f"cannot parse {path}: {exc}") from exc
|
|
|
|
|
|
def _allowed_registry_read(
|
|
node: ast.Name,
|
|
parents: dict[ast.AST, ast.AST],
|
|
) -> bool:
|
|
"""Return whether a registry load is a known read-only method call."""
|
|
parent = parents.get(node)
|
|
grandparent = parents.get(parent) if parent is not None else None
|
|
return bool(
|
|
isinstance(parent, ast.Attribute)
|
|
and parent.value is node
|
|
and parent.attr in {"items", "keys"}
|
|
and isinstance(grandparent, ast.Call)
|
|
and grandparent.func is parent,
|
|
)
|
|
|
|
|
|
def _validate_registry_statement(node: ast.stmt, name: str) -> None:
|
|
"""Reject dynamic mutation, aliasing, or passing of the registry."""
|
|
parents = {
|
|
child: parent
|
|
for parent in ast.walk(node)
|
|
for child in ast.iter_child_nodes(parent)
|
|
}
|
|
mutating_methods = {
|
|
"clear",
|
|
"pop",
|
|
"popitem",
|
|
"setdefault",
|
|
"update",
|
|
}
|
|
for child in ast.walk(node):
|
|
if (
|
|
isinstance(child, ast.Name)
|
|
and child.id == name
|
|
and isinstance(child.ctx, (ast.Store, ast.Del))
|
|
):
|
|
raise CoverageCheckError(
|
|
f"{name} cannot be modified dynamically",
|
|
)
|
|
if (
|
|
isinstance(child, ast.Subscript)
|
|
and isinstance(child.ctx, (ast.Store, ast.Del))
|
|
and isinstance(child.value, ast.Name)
|
|
and child.value.id == name
|
|
):
|
|
raise CoverageCheckError(
|
|
f"{name} cannot be modified dynamically",
|
|
)
|
|
if (
|
|
isinstance(child, ast.Call)
|
|
and isinstance(child.func, ast.Attribute)
|
|
and isinstance(child.func.value, ast.Name)
|
|
and child.func.value.id == name
|
|
and child.func.attr in mutating_methods
|
|
):
|
|
raise CoverageCheckError(
|
|
f"{name} cannot be modified dynamically",
|
|
)
|
|
if (
|
|
isinstance(child, ast.Name)
|
|
and child.id == name
|
|
and isinstance(child.ctx, ast.Load)
|
|
and not _allowed_registry_read(child, parents)
|
|
):
|
|
raise CoverageCheckError(
|
|
f"{name} cannot be aliased or passed dynamically",
|
|
)
|
|
|
|
|
|
def _assigned_value(tree: ast.Module, name: str) -> ast.expr:
|
|
"""Return one simple top-level assignment and reject later mutation."""
|
|
assignments: list[tuple[ast.stmt, ast.expr]] = []
|
|
for node in tree.body:
|
|
if (
|
|
isinstance(node, ast.AnnAssign)
|
|
and isinstance(node.target, ast.Name)
|
|
and node.target.id == name
|
|
and node.value is not None
|
|
):
|
|
assignments.append((node, node.value))
|
|
elif isinstance(node, ast.Assign) and any(
|
|
isinstance(target, ast.Name) and target.id == name
|
|
for target in node.targets
|
|
):
|
|
if not (
|
|
len(node.targets) == 1
|
|
and isinstance(node.targets[0], ast.Name)
|
|
and node.targets[0].id == name
|
|
):
|
|
raise CoverageCheckError(
|
|
f"{name} must use a single-name assignment",
|
|
)
|
|
assignments.append((node, node.value))
|
|
|
|
if not assignments:
|
|
raise CoverageCheckError(f"{name} is not declared in the registry")
|
|
if len(assignments) != 1:
|
|
raise CoverageCheckError(
|
|
f"{name} must have exactly one top-level assignment",
|
|
)
|
|
|
|
assignment, value = assignments[0]
|
|
for node in tree.body:
|
|
if node is not assignment:
|
|
_validate_registry_statement(node, name)
|
|
return value
|
|
|
|
|
|
def _string_literal(node: ast.expr, description: str) -> str:
|
|
"""Return a string literal or fail with an actionable message."""
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
return node.value
|
|
raise CoverageCheckError(f"{description} must be a string literal")
|
|
|
|
|
|
def load_builtin_specs(
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> tuple[ChannelSpec, ...]:
|
|
"""Load the static _BUILTIN_SPECS registry declaration."""
|
|
value = _assigned_value(
|
|
_read_ast(repo_root / REGISTRY_PATH),
|
|
"_BUILTIN_SPECS",
|
|
)
|
|
if not isinstance(value, ast.Dict):
|
|
raise CoverageCheckError(
|
|
"_BUILTIN_SPECS must be a dictionary literal",
|
|
)
|
|
|
|
specs: list[ChannelSpec] = []
|
|
seen_keys: set[str] = set()
|
|
seen_classes: set[str] = set()
|
|
for key_node, value_node in zip(value.keys, value.values):
|
|
if key_node is None:
|
|
raise CoverageCheckError(
|
|
"_BUILTIN_SPECS cannot contain dictionary unpacking",
|
|
)
|
|
key = _string_literal(key_node, "channel registry key")
|
|
if (
|
|
not key
|
|
or key[0] not in "abcdefghijklmnopqrstuvwxyz"
|
|
or any(
|
|
character not in "abcdefghijklmnopqrstuvwxyz0123456789_"
|
|
for character in key
|
|
)
|
|
):
|
|
raise CoverageCheckError(
|
|
f"invalid channel registry key: {key!r}",
|
|
)
|
|
if not (
|
|
isinstance(value_node, (ast.Tuple, ast.List))
|
|
and len(value_node.elts) == 2
|
|
):
|
|
raise CoverageCheckError(
|
|
f"registry entry {key!r} must be a (module, class) pair",
|
|
)
|
|
module = _string_literal(
|
|
value_node.elts[0],
|
|
f"module for {key!r}",
|
|
)
|
|
class_name = _string_literal(
|
|
value_node.elts[1],
|
|
f"class for {key!r}",
|
|
)
|
|
if not module.startswith(".") or not module.lstrip("."):
|
|
raise CoverageCheckError(
|
|
f"module for {key!r} must be package-relative",
|
|
)
|
|
if key in seen_keys:
|
|
raise CoverageCheckError(f"duplicate registry key: {key}")
|
|
if class_name in seen_classes:
|
|
raise CoverageCheckError(
|
|
f"duplicate registered class: {class_name}",
|
|
)
|
|
seen_keys.add(key)
|
|
seen_classes.add(class_name)
|
|
specs.append(ChannelSpec(key, module, class_name))
|
|
|
|
if not specs:
|
|
raise CoverageCheckError("_BUILTIN_SPECS is empty")
|
|
return tuple(specs)
|
|
|
|
|
|
def _module_name(path: Path, base: Path) -> tuple[str, bool]:
|
|
"""Return a module name and whether path is a package initializer."""
|
|
relative = path.relative_to(base).with_suffix("")
|
|
parts = list(relative.parts)
|
|
is_package = parts[-1] == "__init__"
|
|
if is_package:
|
|
parts.pop()
|
|
return ".".join(parts), is_package
|
|
|
|
|
|
def _import_target(
|
|
node: ast.ImportFrom,
|
|
module_name: str,
|
|
is_package: bool = False,
|
|
) -> str:
|
|
"""Resolve an absolute or relative from-import module."""
|
|
if not node.level:
|
|
return node.module or ""
|
|
|
|
package = module_name if is_package else module_name.rpartition(".")[0]
|
|
relative = "." * node.level + (node.module or "")
|
|
try:
|
|
return importlib.util.resolve_name(relative, package)
|
|
except (ImportError, ValueError) as exc:
|
|
raise CoverageCheckError(
|
|
f"cannot resolve import {relative!r} in {module_name}",
|
|
) from exc
|
|
|
|
|
|
def _direct_imports(
|
|
statements: Iterable[ast.stmt],
|
|
module_name: str,
|
|
is_package: bool = False,
|
|
) -> dict[str, str]:
|
|
"""Resolve imports declared directly in one lexical scope."""
|
|
aliases: dict[str, str] = {}
|
|
for node in statements:
|
|
if isinstance(node, ast.ImportFrom):
|
|
target_module = _import_target(
|
|
node,
|
|
module_name,
|
|
is_package,
|
|
)
|
|
for imported in node.names:
|
|
if imported.name == "*":
|
|
continue
|
|
local_name = imported.asname or imported.name
|
|
aliases[local_name] = f"{target_module}.{imported.name}"
|
|
elif isinstance(node, ast.Import):
|
|
for imported in node.names:
|
|
local_name = imported.asname or imported.name.split(".")[0]
|
|
aliases[local_name] = (
|
|
imported.name
|
|
if imported.asname
|
|
else imported.name.split(".")[0]
|
|
)
|
|
return aliases
|
|
|
|
|
|
class _BoundNameCollector(ast.NodeVisitor):
|
|
"""Collect names rebound without entering nested lexical scopes."""
|
|
|
|
def __init__(self, include_imports: bool = False) -> None:
|
|
self.names: set[str] = set()
|
|
self.include_imports = include_imports
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
self.names.add(node.id)
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self.names.add(node.name)
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
self.names.add(node.name)
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self.names.add(node.name)
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
if self.include_imports:
|
|
self.names.update(
|
|
alias.asname or alias.name.split(".")[0]
|
|
for alias in node.names
|
|
)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
if self.include_imports:
|
|
self.names.update(
|
|
alias.asname or alias.name
|
|
for alias in node.names
|
|
if alias.name != "*"
|
|
)
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
del node
|
|
|
|
|
|
def _rebound_names(
|
|
statements: Iterable[ast.stmt],
|
|
include_imports: bool = False,
|
|
) -> frozenset[str]:
|
|
"""Return assignment targets in one lexical scope."""
|
|
collector = _BoundNameCollector(include_imports)
|
|
for statement in statements:
|
|
collector.visit(statement)
|
|
return frozenset(collector.names)
|
|
|
|
|
|
def _stable_imports(
|
|
statements: Iterable[ast.stmt],
|
|
module_name: str,
|
|
is_package: bool = False,
|
|
) -> dict[str, str]:
|
|
"""Return direct imports that are not rebound in the same scope."""
|
|
statement_list = tuple(statements)
|
|
imports = _direct_imports(
|
|
statement_list,
|
|
module_name,
|
|
is_package,
|
|
)
|
|
non_imports = (
|
|
statement
|
|
for statement in statement_list
|
|
if not isinstance(statement, (ast.Import, ast.ImportFrom))
|
|
)
|
|
for name in _rebound_names(non_imports, include_imports=True):
|
|
imports.pop(name, None)
|
|
return imports
|
|
|
|
|
|
def _dotted_name(node: ast.expr) -> str | None:
|
|
"""Return the dotted identifier represented by an expression."""
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
prefix = _dotted_name(node.value)
|
|
return f"{prefix}.{node.attr}" if prefix else node.attr
|
|
if isinstance(node, ast.Subscript):
|
|
return _dotted_name(node.value)
|
|
return None
|
|
|
|
|
|
def _resolve_expr(
|
|
node: ast.expr,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Resolve a name expression to a module-qualified identifier."""
|
|
dotted = _dotted_name(node)
|
|
if not dotted:
|
|
return None
|
|
head, separator, tail = dotted.partition(".")
|
|
if head in aliases:
|
|
resolved = aliases[head]
|
|
return f"{resolved}.{tail}" if separator else resolved
|
|
if not separator:
|
|
return f"{module_name}.{head}"
|
|
return None
|
|
|
|
|
|
def _build_source_index(repo_root: Path) -> SourceIndex:
|
|
"""Index module-qualified channel source classes and exports."""
|
|
source_root = repo_root / "src"
|
|
channels_dir = repo_root / CHANNELS_PATH
|
|
classes: dict[str, SourceClass] = {}
|
|
modules: dict[str, ModuleInfo] = {}
|
|
|
|
for source_path in sorted(channels_dir.rglob("*.py")):
|
|
tree = _read_ast(source_path)
|
|
module_name, is_package = _module_name(
|
|
source_path,
|
|
source_root,
|
|
)
|
|
exports: dict[str, str] = {}
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
exports.update(
|
|
_direct_imports(
|
|
(node,),
|
|
module_name,
|
|
is_package,
|
|
),
|
|
)
|
|
continue
|
|
if not isinstance(node, ast.ClassDef):
|
|
for name in _rebound_names((node,), include_imports=True):
|
|
exports.pop(name, None)
|
|
continue
|
|
qualified_name = f"{module_name}.{node.name}"
|
|
bases = tuple(
|
|
resolved
|
|
for base in node.bases
|
|
if (
|
|
resolved := _resolve_expr(
|
|
base,
|
|
exports,
|
|
module_name,
|
|
)
|
|
)
|
|
)
|
|
if qualified_name in classes:
|
|
raise CoverageCheckError(
|
|
f"duplicate source class: {qualified_name}",
|
|
)
|
|
classes[qualified_name] = SourceClass(
|
|
qualified_name=qualified_name,
|
|
bases=bases,
|
|
path=source_path,
|
|
)
|
|
exports[node.name] = qualified_name
|
|
modules[module_name] = ModuleInfo(
|
|
name=module_name,
|
|
path=source_path,
|
|
exports=exports,
|
|
)
|
|
return SourceIndex(classes=classes, modules=modules)
|
|
|
|
|
|
def _inherits_base_channel(
|
|
qualified_name: str,
|
|
classes: dict[str, SourceClass],
|
|
active: frozenset[str] = frozenset(),
|
|
) -> bool:
|
|
"""Return whether a specific source class derives from BaseChannel."""
|
|
if qualified_name in active:
|
|
return False
|
|
declaration = classes.get(qualified_name)
|
|
if declaration is None:
|
|
return False
|
|
if BASE_CHANNEL in declaration.bases:
|
|
return True
|
|
next_active = active | {qualified_name}
|
|
return any(
|
|
_inherits_base_channel(base, classes, next_active)
|
|
for base in declaration.bases
|
|
)
|
|
|
|
|
|
def resolve_registered_classes(
|
|
specs: Iterable[ChannelSpec],
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
"""Resolve registry exports to concrete BaseChannel implementations."""
|
|
source_index = _build_source_index(repo_root)
|
|
accepted_imports: dict[str, str] = {}
|
|
errors: list[str] = []
|
|
|
|
for spec in specs:
|
|
module_name = f"{CHANNEL_PACKAGE}{spec.module}"
|
|
public_name = f"{module_name}.{spec.class_name}"
|
|
module = source_index.modules.get(module_name)
|
|
if module is None:
|
|
errors.append(
|
|
f"{spec.key}: registry module {module_name} is missing",
|
|
)
|
|
continue
|
|
target = module.exports.get(spec.class_name)
|
|
if target is None:
|
|
errors.append(
|
|
f"{spec.key}: {module_name} does not export "
|
|
f"{spec.class_name}",
|
|
)
|
|
continue
|
|
if target not in source_index.classes:
|
|
errors.append(
|
|
f"{spec.key}: {public_name} does not resolve to a "
|
|
"channel source class",
|
|
)
|
|
continue
|
|
|
|
if not _inherits_base_channel(target, source_index.classes):
|
|
errors.append(
|
|
f"{spec.key}: {target} does not inherit the canonical "
|
|
"BaseChannel",
|
|
)
|
|
continue
|
|
accepted_imports[public_name] = spec.class_name
|
|
accepted_imports[target] = spec.class_name
|
|
return accepted_imports, tuple(errors)
|
|
|
|
|
|
def _test_class_bases(
|
|
tree: ast.Module,
|
|
module_name: str,
|
|
aliases: dict[str, str],
|
|
) -> tuple[dict[str, ast.ClassDef], dict[str, tuple[str, ...]]]:
|
|
"""Return test declarations and their module-qualified bases."""
|
|
declarations: dict[str, ast.ClassDef] = {}
|
|
bases: dict[str, tuple[str, ...]] = {}
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.ClassDef):
|
|
continue
|
|
qualified_name = f"{module_name}.{node.name}"
|
|
declarations[qualified_name] = node
|
|
bases[qualified_name] = tuple(
|
|
resolved
|
|
for base in node.bases
|
|
if (
|
|
resolved := _resolve_expr(
|
|
base,
|
|
aliases,
|
|
module_name,
|
|
)
|
|
)
|
|
)
|
|
return declarations, bases
|
|
|
|
|
|
def _inherits_contract(
|
|
qualified_name: str,
|
|
bases: dict[str, tuple[str, ...]],
|
|
active: frozenset[str] = frozenset(),
|
|
) -> bool:
|
|
"""Return whether a test class derives from ChannelContractTest."""
|
|
if qualified_name in active:
|
|
return False
|
|
class_bases = bases.get(qualified_name, ())
|
|
if CONTRACT_BASE in class_bases:
|
|
return True
|
|
next_active = active | {qualified_name}
|
|
return any(
|
|
_inherits_contract(base, bases, next_active) for base in class_bases
|
|
)
|
|
|
|
|
|
def _decorator_name(
|
|
decorator: ast.expr,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Resolve a decorator name, including called decorators."""
|
|
expression = (
|
|
decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
)
|
|
return _resolve_expr(expression, aliases, module_name)
|
|
|
|
|
|
def _method_is_abstract(
|
|
method: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> bool:
|
|
"""Return whether a method has an abstractmethod decorator."""
|
|
return any(
|
|
name in {"abc.abstractmethod", "abstractmethod"}
|
|
or (name is not None and name.endswith(".abstractmethod"))
|
|
for decorator in method.decorator_list
|
|
if (
|
|
name := _decorator_name(
|
|
decorator,
|
|
aliases,
|
|
module_name,
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def _abstract_methods(
|
|
qualified_name: str,
|
|
declarations: dict[str, ast.ClassDef],
|
|
bases: dict[str, tuple[str, ...]],
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
active: frozenset[str] = frozenset(),
|
|
) -> frozenset[str]:
|
|
"""Compute abstract methods inherited by a local contract class."""
|
|
if qualified_name in active:
|
|
return frozenset()
|
|
declaration = declarations.get(qualified_name)
|
|
if declaration is None:
|
|
return frozenset()
|
|
|
|
methods: set[str] = set()
|
|
next_active = active | {qualified_name}
|
|
for base in bases.get(qualified_name, ()):
|
|
if base == CONTRACT_BASE:
|
|
methods.add("create_instance")
|
|
elif base in declarations:
|
|
methods.update(
|
|
_abstract_methods(
|
|
base,
|
|
declarations,
|
|
bases,
|
|
aliases,
|
|
module_name,
|
|
next_active,
|
|
),
|
|
)
|
|
|
|
for node in declaration.body:
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
if _method_is_abstract(node, aliases, module_name):
|
|
methods.add(node.name)
|
|
else:
|
|
methods.discard(node.name)
|
|
return frozenset(methods)
|
|
|
|
|
|
class _ModuleGateVisitor(ast.NodeVisitor):
|
|
"""Reject module-level constructs that can disable all contracts."""
|
|
|
|
def __init__(
|
|
self,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> None:
|
|
self.aliases = dict(aliases)
|
|
self.module_name = module_name
|
|
self.class_depth = 0
|
|
self.error: str | None = None
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
self.aliases.update(_direct_imports((node,), self.module_name))
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
self.aliases.update(_direct_imports((node,), self.module_name))
|
|
|
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
if self.class_depth == 0 and any(
|
|
isinstance(target, ast.Name) and target.id == "pytestmark"
|
|
for target in node.targets
|
|
):
|
|
self.error = "uses module-level pytestmark"
|
|
return
|
|
self.generic_visit(node)
|
|
|
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
if (
|
|
self.class_depth == 0
|
|
and isinstance(node.target, ast.Name)
|
|
and node.target.id == "pytestmark"
|
|
):
|
|
self.error = "uses module-level pytestmark"
|
|
return
|
|
self.generic_visit(node)
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if self.class_depth == 0 and node.id == "pytestmark":
|
|
self.error = "uses module-level pytestmark"
|
|
resolved = _resolve_expr(node, self.aliases, self.module_name)
|
|
if resolved in DISABLED_PYTEST_CALLS:
|
|
self.error = f"references {resolved} at module scope"
|
|
|
|
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
resolved = _resolve_expr(node, self.aliases, self.module_name)
|
|
if resolved in DISABLED_PYTEST_CALLS:
|
|
self.error = f"references {resolved} at module scope"
|
|
self.generic_visit(node)
|
|
|
|
def _visit_function_header(
|
|
self,
|
|
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
) -> None:
|
|
for decorator in node.decorator_list:
|
|
self.visit(decorator)
|
|
self.visit(node.args)
|
|
if node.returns is not None:
|
|
self.visit(node.returns)
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
if self.class_depth == 0 and node.name == "pytest_generate_tests":
|
|
self.error = "defines module-level pytest_generate_tests"
|
|
return
|
|
self._visit_function_header(node)
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
if self.class_depth == 0 and node.name == "pytest_generate_tests":
|
|
self.error = "defines module-level pytest_generate_tests"
|
|
return
|
|
self._visit_function_header(node)
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
self.visit(node.args)
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
for decorator in node.decorator_list:
|
|
self.visit(decorator)
|
|
for base in node.bases:
|
|
self.visit(base)
|
|
for keyword in node.keywords:
|
|
self.visit(keyword.value)
|
|
self.class_depth += 1
|
|
try:
|
|
for statement in node.body:
|
|
self.visit(statement)
|
|
finally:
|
|
self.class_depth -= 1
|
|
|
|
|
|
def _module_gate_error(
|
|
tree: ast.Module,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Return a module-level pytest gate that can hide all contracts."""
|
|
visitor = _ModuleGateVisitor(aliases, module_name)
|
|
visitor.visit(tree)
|
|
return visitor.error
|
|
|
|
|
|
class _ClassGateVisitor(ast.NodeVisitor):
|
|
"""Reject collection overrides in one contract class body."""
|
|
|
|
def __init__(self) -> None:
|
|
self.error: str | None = None
|
|
|
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
if any(
|
|
isinstance(target, ast.Name) and target.id == "pytestmark"
|
|
for target in node.targets
|
|
):
|
|
self.error = "uses class-level pytestmark"
|
|
return
|
|
self.generic_visit(node)
|
|
|
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
if (
|
|
isinstance(node.target, ast.Name)
|
|
and node.target.id == "pytestmark"
|
|
):
|
|
self.error = "uses class-level pytestmark"
|
|
return
|
|
self.generic_visit(node)
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if node.id == "__test__":
|
|
self.error = "uses class-level __test__"
|
|
elif node.id == "pytestmark":
|
|
self.error = "uses class-level pytestmark"
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
del node
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
del node
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
del node
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
del node
|
|
|
|
|
|
def _class_gate_error( # pylint: disable=too-many-return-statements,too-many-branches
|
|
qualified_name: str,
|
|
declarations: dict[str, ast.ClassDef],
|
|
bases: dict[str, tuple[str, ...]],
|
|
aliases: dict[str, str],
|
|
active: frozenset[str] = frozenset(),
|
|
) -> str | None:
|
|
"""Return a collection override on a contract class or local base."""
|
|
if qualified_name in active:
|
|
return None
|
|
declaration = declarations.get(qualified_name)
|
|
if declaration is None:
|
|
return None
|
|
module_name = qualified_name.rpartition(".")[0]
|
|
|
|
if declaration.keywords:
|
|
return "uses class keywords or a custom metaclass"
|
|
|
|
if declaration.decorator_list:
|
|
decorator = declaration.decorator_list[0]
|
|
name = _decorator_name(decorator, aliases, module_name)
|
|
return f"uses class decorator {name or '<dynamic>'}"
|
|
|
|
for statement in declaration.body:
|
|
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
if statement.name == "pytest_generate_tests":
|
|
return "defines class-level pytest_generate_tests"
|
|
if statement.name in {"__init__", "__new__"}:
|
|
return (
|
|
f"defines {statement.name}, which prevents "
|
|
"pytest collection"
|
|
)
|
|
if statement.name == "instance":
|
|
return "overrides the contract instance fixture"
|
|
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
targets = (
|
|
statement.targets
|
|
if isinstance(statement, ast.Assign)
|
|
else (statement.target,)
|
|
)
|
|
if any(
|
|
isinstance(target, ast.Name) and target.id == "instance"
|
|
for target in targets
|
|
):
|
|
return "overrides the contract instance fixture"
|
|
|
|
visitor = _ClassGateVisitor()
|
|
for statement in declaration.body:
|
|
visitor.visit(statement)
|
|
if visitor.error:
|
|
return visitor.error
|
|
|
|
next_active = active | {qualified_name}
|
|
for base in bases.get(qualified_name, ()):
|
|
inherited = _class_gate_error(
|
|
base,
|
|
declarations,
|
|
bases,
|
|
aliases,
|
|
next_active,
|
|
)
|
|
if inherited:
|
|
return f"inherits disabled contract base that {inherited}"
|
|
return None
|
|
|
|
|
|
class _ModuleClassMutationVisitor(ast.NodeVisitor):
|
|
"""Reject module-level mutation of recognized contract classes."""
|
|
|
|
def __init__(
|
|
self,
|
|
class_names: frozenset[str],
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> None:
|
|
self.class_names = class_names
|
|
self.aliases = aliases
|
|
self.module_name = module_name
|
|
self.declared: set[str] = set()
|
|
self.error: str | None = None
|
|
|
|
def _check_target(self, target: ast.expr) -> None:
|
|
if isinstance(target, (ast.Tuple, ast.List)):
|
|
for element in target.elts:
|
|
self._check_target(element)
|
|
return
|
|
if isinstance(target, ast.Name):
|
|
qualified_name = f"{self.module_name}.{target.id}"
|
|
if qualified_name in self.class_names:
|
|
self.error = (
|
|
f"rebinds contract class {target.id} after declaration"
|
|
)
|
|
return
|
|
if not isinstance(target, ast.Attribute):
|
|
return
|
|
owner = _resolve_expr(target.value, self.aliases, self.module_name)
|
|
if owner in self.class_names:
|
|
self.error = (
|
|
f"mutates contract class {owner.rsplit('.', 1)[-1]} "
|
|
f"after declaration"
|
|
)
|
|
|
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
for target in node.targets:
|
|
self._check_target(target)
|
|
value = _resolve_expr(node.value, self.aliases, self.module_name)
|
|
if value in self.class_names:
|
|
self.error = (
|
|
f"aliases contract class {value.rsplit('.', 1)[-1]} "
|
|
"after declaration"
|
|
)
|
|
self.visit(node.value)
|
|
|
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
self._check_target(node.target)
|
|
if node.value is not None:
|
|
value = _resolve_expr(node.value, self.aliases, self.module_name)
|
|
if value in self.class_names:
|
|
self.error = (
|
|
f"aliases contract class {value.rsplit('.', 1)[-1]} "
|
|
"after declaration"
|
|
)
|
|
self.visit(node.value)
|
|
|
|
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
self._check_target(node.target)
|
|
self.visit(node.value)
|
|
|
|
def visit_Delete(self, node: ast.Delete) -> None:
|
|
for target in node.targets:
|
|
self._check_target(target)
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
if (
|
|
isinstance(node.func, ast.Name)
|
|
and node.func.id in {"setattr", "delattr"}
|
|
and node.args
|
|
):
|
|
owner = _resolve_expr(
|
|
node.args[0],
|
|
self.aliases,
|
|
self.module_name,
|
|
)
|
|
if owner in self.class_names:
|
|
self.error = (
|
|
f"mutates contract class {owner.rsplit('.', 1)[-1]} "
|
|
"after declaration"
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._check_target(ast.Name(id=node.name, ctx=ast.Store()))
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
self._check_target(ast.Name(id=node.name, ctx=ast.Store()))
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
qualified_name = f"{self.module_name}.{node.name}"
|
|
if qualified_name not in self.class_names:
|
|
return
|
|
if qualified_name in self.declared:
|
|
self.error = f"redefines contract class {node.name}"
|
|
return
|
|
self.declared.add(qualified_name)
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
for imported in node.names:
|
|
name = imported.asname or imported.name.split(".")[0]
|
|
self._check_target(ast.Name(id=name, ctx=ast.Store()))
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
for imported in node.names:
|
|
if imported.name != "*":
|
|
self._check_target(
|
|
ast.Name(
|
|
id=imported.asname or imported.name,
|
|
ctx=ast.Store(),
|
|
),
|
|
)
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
del node
|
|
|
|
|
|
def _module_class_mutation_error(
|
|
tree: ast.Module,
|
|
class_names: Iterable[str],
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Return an out-of-class mutation that invalidates static analysis."""
|
|
visitor = _ModuleClassMutationVisitor(
|
|
frozenset(class_names),
|
|
aliases,
|
|
module_name,
|
|
)
|
|
for statement in tree.body:
|
|
visitor.visit(statement)
|
|
return visitor.error
|
|
|
|
|
|
class _ReturnCollector(ast.NodeVisitor):
|
|
"""Collect returns without entering nested functions or classes."""
|
|
|
|
def __init__(self) -> None:
|
|
self.values: list[ast.expr | None] = []
|
|
|
|
def visit_Return(self, node: ast.Return) -> None:
|
|
self.values.append(node.value)
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
del node
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
del node
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
del node
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
del node
|
|
|
|
|
|
def _factory_returns(
|
|
factory: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
) -> tuple[ast.expr | None, ...]:
|
|
"""Return expressions belonging directly to a factory body."""
|
|
collector = _ReturnCollector()
|
|
for statement in factory.body:
|
|
collector.visit(statement)
|
|
return tuple(collector.values)
|
|
|
|
|
|
class _BindingCounter(ast.NodeVisitor):
|
|
"""Count bindings for one name without entering nested scopes."""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
self.count = 0
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if node.id == self.name and isinstance(
|
|
node.ctx,
|
|
(ast.Store, ast.Del),
|
|
):
|
|
self.count += 1
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
if node.name == self.name:
|
|
self.count += 1
|
|
|
|
def visit_AsyncFunctionDef(
|
|
self,
|
|
node: ast.AsyncFunctionDef,
|
|
) -> None:
|
|
if node.name == self.name:
|
|
self.count += 1
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
if node.name == self.name:
|
|
self.count += 1
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
del node
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
self.count += sum(
|
|
(alias.asname or alias.name.split(".")[0]) == self.name
|
|
for alias in node.names
|
|
)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
self.count += sum(
|
|
(alias.asname or alias.name) == self.name for alias in node.names
|
|
)
|
|
|
|
|
|
def _binding_count(statements: Iterable[ast.stmt], name: str) -> int:
|
|
"""Return how many times name is bound in one lexical scope."""
|
|
counter = _BindingCounter(name)
|
|
for statement in statements:
|
|
counter.visit(statement)
|
|
return counter.count
|
|
|
|
|
|
def _factory_signature_error(factory: ast.FunctionDef) -> str | None:
|
|
"""Require the canonical synchronous ``create_instance(self)`` shape."""
|
|
if factory.decorator_list:
|
|
return "must not use decorators"
|
|
args = factory.args
|
|
positional = args.posonlyargs + args.args
|
|
has_extra_arguments = any(
|
|
(
|
|
args.vararg is not None,
|
|
args.kwarg is not None,
|
|
bool(args.kwonlyargs),
|
|
bool(args.defaults),
|
|
bool(args.kw_defaults),
|
|
),
|
|
)
|
|
if [argument.arg for argument in positional] != [
|
|
"self",
|
|
] or has_extra_arguments:
|
|
return "must have signature create_instance(self)"
|
|
return None
|
|
|
|
|
|
def _pytest_gate_reference(
|
|
statements: Iterable[ast.stmt],
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Return a direct pytest call/reference that can skip execution."""
|
|
local_aliases = dict(aliases)
|
|
statement_list = tuple(statements)
|
|
local_aliases.update(_direct_imports(statement_list, module_name))
|
|
for statement in statement_list:
|
|
for node in ast.walk(statement):
|
|
if isinstance(node, ast.ImportFrom):
|
|
imported = _direct_imports((node,), module_name)
|
|
disabled = DISABLED_PYTEST_CALLS.intersection(
|
|
imported.values(),
|
|
)
|
|
if disabled:
|
|
return sorted(disabled)[0]
|
|
if isinstance(node, (ast.Name, ast.Attribute)):
|
|
resolved = _resolve_expr(node, local_aliases, module_name)
|
|
if resolved in DISABLED_PYTEST_CALLS:
|
|
return resolved
|
|
return None
|
|
|
|
|
|
def _factory_gate_error(
|
|
factory: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> str | None:
|
|
"""Require a straight-line factory with no runtime pytest gates."""
|
|
unsupported = (
|
|
ast.AsyncFor,
|
|
ast.AsyncWith,
|
|
ast.Break,
|
|
ast.ClassDef,
|
|
ast.Continue,
|
|
ast.For,
|
|
ast.FunctionDef,
|
|
ast.If,
|
|
ast.Lambda,
|
|
ast.Match,
|
|
ast.Raise,
|
|
ast.Try,
|
|
ast.TryStar,
|
|
ast.While,
|
|
ast.With,
|
|
)
|
|
for statement in factory.body:
|
|
for node in ast.walk(statement):
|
|
if isinstance(node, unsupported):
|
|
return (
|
|
"must use straight-line code; "
|
|
f"found {type(node).__name__}"
|
|
)
|
|
|
|
disabled = _pytest_gate_reference(
|
|
factory.body,
|
|
aliases,
|
|
module_name,
|
|
)
|
|
if disabled:
|
|
return f"references {disabled}"
|
|
return None
|
|
|
|
|
|
def _autouse_fixture_state(
|
|
method: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
) -> bool | None:
|
|
"""Return False/True for static autouse, or None when dynamic."""
|
|
for decorator in method.decorator_list:
|
|
if not isinstance(decorator, ast.Call):
|
|
continue
|
|
name = _decorator_name(decorator, aliases, module_name)
|
|
if name != "pytest.fixture":
|
|
continue
|
|
autouse_values = [
|
|
keyword.value
|
|
for keyword in decorator.keywords
|
|
if keyword.arg == "autouse"
|
|
]
|
|
if not autouse_values:
|
|
return False
|
|
if len(autouse_values) != 1:
|
|
return None
|
|
value = autouse_values[0]
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, bool):
|
|
return value.value
|
|
return None
|
|
return False
|
|
|
|
|
|
def _fixture_or_setup_gate_error(
|
|
methods: Iterable[ast.stmt],
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
setup_names: frozenset[str],
|
|
) -> str | None:
|
|
"""Reject skip calls in autouse fixtures and xunit setup hooks."""
|
|
for node in methods:
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
is_autouse = _autouse_fixture_state(node, aliases, module_name)
|
|
if is_autouse is None:
|
|
return f"fixture {node.name} uses dynamic autouse"
|
|
if not is_autouse and node.name not in setup_names:
|
|
continue
|
|
disabled = _pytest_gate_reference(
|
|
node.body,
|
|
aliases,
|
|
module_name,
|
|
)
|
|
if disabled:
|
|
kind = "autouse fixture" if is_autouse else "setup hook"
|
|
return f"{kind} {node.name} references {disabled}"
|
|
return None
|
|
|
|
|
|
def _class_runtime_gate_error(
|
|
qualified_name: str,
|
|
declarations: dict[str, ast.ClassDef],
|
|
bases: dict[str, tuple[str, ...]],
|
|
aliases: dict[str, str],
|
|
active: frozenset[str] = frozenset(),
|
|
) -> str | None:
|
|
"""Return a runtime gate in a contract class or local base."""
|
|
if qualified_name in active:
|
|
return None
|
|
declaration = declarations.get(qualified_name)
|
|
if declaration is None:
|
|
return None
|
|
module_name = qualified_name.rpartition(".")[0]
|
|
error = _fixture_or_setup_gate_error(
|
|
declaration.body,
|
|
aliases,
|
|
module_name,
|
|
frozenset({"setup_class", "setup_method"}),
|
|
)
|
|
if error:
|
|
return error
|
|
|
|
next_active = active | {qualified_name}
|
|
for base in bases.get(qualified_name, ()):
|
|
inherited = _class_runtime_gate_error(
|
|
base,
|
|
declarations,
|
|
bases,
|
|
aliases,
|
|
next_active,
|
|
)
|
|
if inherited:
|
|
return f"inherits contract base with {inherited}"
|
|
return None
|
|
|
|
|
|
def _returned_channel(
|
|
value: ast.expr | None,
|
|
aliases: dict[str, str],
|
|
module_name: str,
|
|
accepted_imports: dict[str, str],
|
|
) -> str | None:
|
|
"""Resolve a direct channel constructor returned by a factory."""
|
|
if not isinstance(value, ast.Call):
|
|
return None
|
|
|
|
called = _resolve_expr(value.func, aliases, module_name)
|
|
if called in accepted_imports:
|
|
return accepted_imports[called]
|
|
|
|
if isinstance(value.func, ast.Attribute) and value.func.attr in {
|
|
"from_config",
|
|
"from_env",
|
|
}:
|
|
owner = _resolve_expr(
|
|
value.func.value,
|
|
aliases,
|
|
module_name,
|
|
)
|
|
if owner in accepted_imports:
|
|
return accepted_imports[owner]
|
|
return None
|
|
|
|
|
|
def scan_contract_tests( # pylint: disable=too-many-branches,too-many-statements
|
|
specs: Iterable[ChannelSpec],
|
|
accepted_imports: dict[str, str],
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> tuple[frozenset[str], tuple[str, ...]]:
|
|
"""Find channels returned by concrete, collectable contract factories."""
|
|
specs = tuple(specs)
|
|
contract_dir = repo_root / CONTRACT_TESTS_PATH
|
|
if not contract_dir.exists():
|
|
return frozenset(), (f"missing contract directory: {contract_dir}",)
|
|
|
|
expected_paths = {
|
|
spec.class_name: Path(spec.suggested_test_path) for spec in specs
|
|
}
|
|
tested_by: dict[str, str] = {}
|
|
duplicate_classes: set[str] = set()
|
|
errors: list[str] = []
|
|
for test_path in sorted(contract_dir.glob("test_*_contract.py")):
|
|
tree = _read_ast(test_path)
|
|
module_name, _ = _module_name(test_path, repo_root)
|
|
module_aliases = _stable_imports(tree.body, module_name)
|
|
module_error = _module_gate_error(
|
|
tree,
|
|
module_aliases,
|
|
module_name,
|
|
)
|
|
if module_error:
|
|
errors.append(f"{test_path.name} {module_error}")
|
|
continue
|
|
|
|
module_runtime_error = _fixture_or_setup_gate_error(
|
|
tree.body,
|
|
module_aliases,
|
|
module_name,
|
|
frozenset({"setup_module"}),
|
|
)
|
|
if module_runtime_error:
|
|
errors.append(f"{test_path.name} {module_runtime_error}")
|
|
continue
|
|
|
|
declarations, bases = _test_class_bases(
|
|
tree,
|
|
module_name,
|
|
module_aliases,
|
|
)
|
|
contract_names = frozenset(
|
|
name for name in declarations if _inherits_contract(name, bases)
|
|
)
|
|
audited_names = set(contract_names)
|
|
pending_names = list(contract_names)
|
|
while pending_names:
|
|
name = pending_names.pop()
|
|
for base in bases.get(name, ()):
|
|
if base in declarations and base not in audited_names:
|
|
audited_names.add(base)
|
|
pending_names.append(base)
|
|
invalid_base = next(
|
|
(
|
|
(name, base)
|
|
for name in audited_names
|
|
for base in bases.get(name, ())
|
|
if base != CONTRACT_BASE and base not in declarations
|
|
),
|
|
None,
|
|
)
|
|
if invalid_base is not None:
|
|
class_name, base = invalid_base
|
|
errors.append(
|
|
f"{test_path.name}:{class_name.rsplit('.', 1)[-1]} "
|
|
f"uses external contract base {base}",
|
|
)
|
|
continue
|
|
|
|
mutation_error = _module_class_mutation_error(
|
|
tree,
|
|
audited_names,
|
|
module_aliases,
|
|
module_name,
|
|
)
|
|
if mutation_error:
|
|
errors.append(f"{test_path.name} {mutation_error}")
|
|
continue
|
|
|
|
for qualified_name, declaration in declarations.items():
|
|
if not _inherits_contract(qualified_name, bases):
|
|
continue
|
|
if _abstract_methods(
|
|
qualified_name,
|
|
declarations,
|
|
bases,
|
|
module_aliases,
|
|
module_name,
|
|
):
|
|
continue
|
|
|
|
class_label = f"{test_path.name}:{declaration.name}"
|
|
if not declaration.name.startswith("Test"):
|
|
errors.append(
|
|
f"{class_label} name must start with Test "
|
|
"for pytest collection",
|
|
)
|
|
continue
|
|
collection_error = _class_gate_error(
|
|
qualified_name,
|
|
declarations,
|
|
bases,
|
|
module_aliases,
|
|
)
|
|
if collection_error:
|
|
errors.append(f"{class_label} {collection_error}")
|
|
continue
|
|
runtime_error = _class_runtime_gate_error(
|
|
qualified_name,
|
|
declarations,
|
|
bases,
|
|
module_aliases,
|
|
)
|
|
if runtime_error:
|
|
errors.append(f"{class_label} {runtime_error}")
|
|
continue
|
|
|
|
factories = tuple(
|
|
node
|
|
for node in declaration.body
|
|
if isinstance(
|
|
node,
|
|
(ast.FunctionDef, ast.AsyncFunctionDef),
|
|
)
|
|
and node.name == "create_instance"
|
|
)
|
|
label = f"{class_label}.create_instance"
|
|
if _binding_count(declaration.body, "create_instance") != 1:
|
|
errors.append(
|
|
f"{label} must have exactly one class-level binding",
|
|
)
|
|
continue
|
|
if len(factories) != 1:
|
|
errors.append(
|
|
f"{label} must have exactly one direct definition",
|
|
)
|
|
continue
|
|
factory = factories[0]
|
|
if isinstance(factory, ast.AsyncFunctionDef):
|
|
errors.append(f"{label} must be synchronous")
|
|
continue
|
|
signature_error = _factory_signature_error(factory)
|
|
if signature_error:
|
|
errors.append(f"{label} {signature_error}")
|
|
continue
|
|
gate_error = _factory_gate_error(
|
|
factory,
|
|
module_aliases,
|
|
module_name,
|
|
)
|
|
if gate_error:
|
|
errors.append(f"{label} {gate_error}")
|
|
continue
|
|
|
|
returned_values = _factory_returns(factory)
|
|
if len(returned_values) != 1:
|
|
errors.append(
|
|
f"{label} must contain exactly one return statement",
|
|
)
|
|
continue
|
|
aliases = dict(module_aliases)
|
|
for name in _rebound_names(factory.body):
|
|
aliases.pop(name, None)
|
|
aliases.update(_stable_imports(factory.body, module_name))
|
|
tested_class = _returned_channel(
|
|
returned_values[0],
|
|
aliases,
|
|
module_name,
|
|
accepted_imports,
|
|
)
|
|
if tested_class is None:
|
|
errors.append(
|
|
f"{label} must directly return a registered channel",
|
|
)
|
|
continue
|
|
|
|
actual_path = test_path.relative_to(repo_root)
|
|
expected_path = expected_paths[tested_class]
|
|
if actual_path != expected_path:
|
|
errors.append(
|
|
f"{label} must be declared in "
|
|
f"{expected_path.as_posix()}",
|
|
)
|
|
continue
|
|
if tested_class in duplicate_classes:
|
|
errors.append(
|
|
f"{tested_class} has another duplicate contract "
|
|
f"factory: {label}",
|
|
)
|
|
continue
|
|
if tested_class in tested_by:
|
|
errors.append(
|
|
f"{tested_class} has duplicate contract factories: "
|
|
f"{tested_by[tested_class]} and {label}",
|
|
)
|
|
tested_by.pop(tested_class)
|
|
duplicate_classes.add(tested_class)
|
|
continue
|
|
tested_by[tested_class] = label
|
|
|
|
return frozenset(tested_by), tuple(errors)
|
|
|
|
|
|
def analyze_repository(repo_root: Path = REPO_ROOT) -> CoverageReport:
|
|
"""Build the complete static channel-contract coverage report."""
|
|
specs = load_builtin_specs(repo_root)
|
|
accepted_imports, source_errors = resolve_registered_classes(
|
|
specs,
|
|
repo_root,
|
|
)
|
|
tested, test_errors = scan_contract_tests(
|
|
specs,
|
|
accepted_imports,
|
|
repo_root,
|
|
)
|
|
return CoverageReport(
|
|
specs=specs,
|
|
tested_classes=tested,
|
|
errors=source_errors + test_errors,
|
|
)
|
|
|
|
|
|
def main(argv: Iterable[str] | None = None) -> int:
|
|
"""Print coverage and return a CI-friendly status code."""
|
|
args = tuple(sys.argv[1:] if argv is None else argv)
|
|
if args not in {(), ("--list-specs",)}:
|
|
print(
|
|
"Usage: check_channel_contracts.py [--list-specs]",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
try:
|
|
if args == ("--list-specs",):
|
|
for spec in load_builtin_specs():
|
|
source_dir = spec.module.lstrip(".").split(".", 1)[0]
|
|
print(
|
|
f"{spec.key}\t{source_dir}\t"
|
|
f"{spec.suggested_test_path}",
|
|
)
|
|
return 0
|
|
report = analyze_repository()
|
|
except CoverageCheckError as exc:
|
|
print(f"Channel contract check failed: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
print()
|
|
print("Channel Contract Coverage")
|
|
print(f" Total channels: {len(report.specs)}")
|
|
print(f" With tests: {len(report.tested_classes)}")
|
|
print(f" Missing: {len(report.missing_specs)}")
|
|
|
|
if report.tested_classes:
|
|
tested = ", ".join(sorted(report.tested_classes))
|
|
print(f"\n[OK] Tested: {tested}")
|
|
|
|
if report.errors:
|
|
print("\n[ERROR] Invalid coverage declarations:")
|
|
for error in report.errors:
|
|
print(f" - {error}")
|
|
|
|
if report.missing_specs:
|
|
print("\n[MISSING] Contract tests:")
|
|
for spec in report.missing_specs:
|
|
print(f" - {spec.class_name}")
|
|
print(f" Add {spec.suggested_test_path}")
|
|
print(
|
|
"\nCopy an existing contract test and implement "
|
|
"create_instance().",
|
|
)
|
|
|
|
if report.errors:
|
|
return 2
|
|
if report.missing_specs:
|
|
return 1
|
|
|
|
print("\n[OK] All built-in channels have contract tests.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|