File size: 5,103 Bytes
da31b89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""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("<package> 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<pkg>[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("<dep> 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"