File size: 3,326 Bytes
bd5c812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 3.5 audit code-quality — les tests dans
``tests/integration/live/`` doivent porter le marker
``@pytest.mark.live`` sur **chacune** de leurs fonctions de test.

Contexte : ``pyproject.toml`` déclare le marker ``live`` comme
« tests d'intégration contre vraie API/binaire (Tesseract,
Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in via
``pytest -m live`` ».  Le filtre ``addopts = '-m "not live and not
network"'`` les déselectionne au runner par défaut.

Si une fonction dans ``tests/integration/live/`` oublie le marker,
elle s'exécute lors du ``pytest tests/`` standard et :

- échoue sur les runners sans la dep cloud → faux échec CI ;
- consomme du quota API (clé en CI = facture surprise) ;
- introduit une dépendance réseau non documentée.

L'agent d'audit avait flaggé ``test_tesseract_live.py`` comme
« skip top-level inconditionnel ».  Vérification : le skip est en
fait **conditionnel** (``if shutil.which("tesseract") is None``),
ce qui est légitime — un test live qui peut s'exécuter seulement
si le binaire est présent.  Mais le garde-fou ci-dessous évite
qu'une nouvelle fonction de test oublie le marker.
"""

from __future__ import annotations

import ast
from pathlib import Path

import pytest

LIVE_DIR = Path(__file__).resolve().parents[1] / "integration" / "live"


def _test_functions(path: Path) -> list[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]:
    """Liste les fonctions ``test_*`` au top-level d'un fichier."""
    tree = ast.parse(path.read_text(encoding="utf-8"))
    out: list[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]] = []
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
            out.append((node.name, node))
    return out


def _has_live_marker(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
    for deco in fn.decorator_list:
        # ``@pytest.mark.live`` ou ``@pytest.mark.live(reason=...)``
        if isinstance(deco, ast.Attribute) and deco.attr == "live":
            return True
        if isinstance(deco, ast.Call) and isinstance(deco.func, ast.Attribute) and deco.func.attr == "live":
            return True
    return False


def _live_test_files() -> list[Path]:
    if not LIVE_DIR.exists():
        return []
    return [
        p for p in sorted(LIVE_DIR.glob("test_*.py"))
        if p.name != "__init__.py" and p.name != "conftest.py"
    ]


@pytest.mark.parametrize("path", _live_test_files(), ids=lambda p: p.name)
def test_every_function_in_live_dir_has_live_marker(path: Path) -> None:
    """Chaque ``test_*`` dans ``tests/integration/live/`` porte ``@pytest.mark.live``.

    Sinon le test peut s'exécuter en CI standard et casser sur
    l'absence de clé API / binaire externe.
    """
    missing: list[str] = []
    for name, fn in _test_functions(path):
        if not _has_live_marker(fn):
            missing.append(f"  {path.name}:{fn.lineno} :: {name}")

    assert not missing, (
        f"Fonctions dans {LIVE_DIR.name}/ sans ``@pytest.mark.live`` :\n"
        + "\n".join(missing)
        + "\n\nAjouter ``@pytest.mark.live`` au-dessus de chaque test "
        "qui hit une API/un binaire externe — sinon le test "
        "s'exécute sans opt-in et peut faire échouer le CI standard."
    )