"""Phase 4.4 audit code-quality — interdit les ``pytest.skip("dep non installée")`` sur des dépendances déclarées **obligatoires** dans ``pyproject.toml``. Pattern zombie typique : .. code-block:: python try: import click except ImportError: pytest.skip("click non installé") Si ``click`` est dans ``[project.dependencies]`` (pas dans ``[project.optional-dependencies]``), cet ``ImportError`` ne peut jamais se déclencher → le skip est vacuement vrai et le test n'est jamais exécuté. L'audit code-quality (2026-05) en a trouvé **7 occurrences** dans ``tests/integration/test_chantier{4,5}.py``, toutes sur ``click``. Ce test scanne ``tests/`` à la recherche de skips qui mentionnent une dep obligatoire et échoue avec un message clair indiquant quel test transformer en exécution franche. """ from __future__ import annotations import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] TESTS_DIR = REPO_ROOT / "tests" #: Liste de noms de packages déclarés en dep obligatoire #: ``[project.dependencies]``. Source de vérité : #: ``pyproject.toml``. À synchroniser si la liste évolue (rare #: — les deps obligatoires sont stables par construction). MANDATORY_DEPS: frozenset[str] = frozenset({ "click", "pydantic", "fastapi", "uvicorn", "lxml", "defusedxml", "rapidfuzz", "jiwer", "numpy", "pyyaml", "annotated_types", "typing_extensions", }) #: ``pytest.skip(" non installé")`` ou variantes. Capture #: le nom du package à l'intérieur de la chaîne pour le rapporter. _SKIP_RE = re.compile( r"pytest\.skip\s*\(\s*[fr]?[\"']([^\"']*?)\b" r"(?P[a-zA-Z_][\w\-]*)\b[^\"']*?non installé", re.IGNORECASE, ) def _scan_zombie_skips() -> list[tuple[Path, int, str]]: """Scan AST plutôt que regex pour ignorer commentaires et docstrings.""" import ast findings: list[tuple[Path, int, str]] = [] for path in sorted(TESTS_DIR.rglob("test_*.py")): # On ignore ce test lui-même (sinon il se signale). if path == Path(__file__): continue try: tree = ast.parse(path.read_text(encoding="utf-8")) except SyntaxError: continue for node in ast.walk(tree): # Cherche les appels ``pytest.skip("...")``. if not isinstance(node, ast.Call): continue func = node.func is_pytest_skip = ( isinstance(func, ast.Attribute) and func.attr == "skip" and isinstance(func.value, ast.Name) and func.value.id == "pytest" ) if not is_pytest_skip or not node.args: continue first = node.args[0] if not isinstance(first, ast.Constant) or not isinstance(first.value, str): continue msg = first.value m = _SKIP_RE.search(f'pytest.skip("{msg}")') if not m: continue pkg = m.group("pkg").lower() if pkg in MANDATORY_DEPS: findings.append((path, node.lineno, pkg)) return findings def test_no_skip_on_mandatory_dependency() -> None: """Aucun ``pytest.skip(" non installé")`` ne doit cibler une dep obligatoire. Si une dep apparaît dans le scan, deux options : 1. **Recommandée** — la dep est vraiment obligatoire : retirer le ``try/except ImportError`` et faire un ``import`` direct. Le test plantera franchement si l'environnement est cassé, ce qui est le comportement correct (signal opérationnel). 2. **Exceptionnelle** — la dep est en fait optionnelle (a déménagé vers ``[project.optional-dependencies]``) : retirer le nom de :data:`MANDATORY_DEPS` ci-dessus. """ zombies = _scan_zombie_skips() if zombies: lines = "\n".join( f" {p.relative_to(REPO_ROOT)}:{ln} → skip '{pkg} non installé'" for p, ln, pkg in zombies ) raise AssertionError( "Skips zombies détectés (dep obligatoire = ImportError " "impossible) :\n" + lines + "\n\nRemplacer le ``try/except ImportError → pytest.skip`` " "par un import direct, ou retirer la dep de MANDATORY_DEPS " "si elle est devenue optionnelle." ) def test_scanner_catches_obvious_zombie_pattern(tmp_path: Path) -> None: """Méta-test : le scanner détecte effectivement le pattern. Garde-fou contre un regex trop laxiste qui passerait à côté. """ sample = tmp_path / "test_sample.py" sample.write_text( "import pytest\n" "\n" "def test_x():\n" " try:\n" " import click\n" " except ImportError:\n" " pytest.skip('click non installé')\n", encoding="utf-8", ) matches = list(_SKIP_RE.finditer(sample.read_text(encoding="utf-8"))) assert len(matches) == 1 assert matches[0].group("pkg").lower() == "click"