File size: 6,865 Bytes
53f68d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
53f68d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
"""Sprint A14-S3 — interdire les imports par effet de bord dans les nouveaux packages.

Anti-pattern à proscrire : ``picarones/__init__.py`` importe
``picarones.measurements`` au top-level **uniquement** pour
déclencher l'enregistrement des métriques décorées par
``@register_metric``.  Conséquence : tout import du package
charge ~50 sous-modules, exige toutes leurs deps optionnelles, et
fait crasher l'installation minimale (cf. l'épisode ``defusedxml``
au S1).

Ce test garantit que les **nouveaux packages** (créés au S3) ne
reproduisent pas ce pattern.  Pour chaque nouvelle couche, on
mesure le set de modules chargés à l'import du sous-package.  Si
ce set contient des modules externes lourds (numpy, scipy,
fastapi, jinja2, jiwer, ...) **alors que le sous-package est
encore vide**, c'est qu'un ``__init__.py`` fait quelque chose de
suspect.

Note : ce test est volontairement permissif tant que les couches
sont vides — il vérifie surtout l'absence d'import par effet de
bord.  Un test plus strict viendra aux Sprints S5-S6 quand les
premiers contrats du domain seront en place.
"""

from __future__ import annotations

import importlib
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]


#: Couches du rewrite ciblé (cf. ``test_layer_dependencies.py``).
NEW_LAYERS: tuple[str, ...] = (
    "domain",
    "evaluation",
    "pipeline",
    "formats",
    "adapters",
    "app",
    "interfaces",
    "reports",
)


#: Modules dont l'import est trahi par un side-effect "magique".
#: Si l'un de ces modules est chargé alors qu'on importe juste
#: ``picarones.<layer>`` (qui devrait être un namespace quasi-vide
#: au S3), c'est qu'on a un problème.
SUSPECTED_SIDE_EFFECT_LOADS: frozenset[str] = frozenset({
    "numpy",
    "scipy",
    "jinja2",
    "fastapi",
    "starlette",
    "click",
    "uvicorn",
    "jiwer",
    "rapidfuzz",
    "lxml",
    "yaml",
    "PIL",
})


def _import_in_isolation(module_dotted: str) -> set[str]:
    """Importe ``module_dotted`` et retourne le set des modules
    externes (top-level) chargés **propres au sous-package** au
    passage.

    Subtilité : ``import picarones.<layer>`` déclenche d'abord
    ``import picarones`` (le parent), qui aujourd'hui charge
    ``picarones.measurements`` par effet de bord (cf.
    ``BACKLOG_POST_LIVRAISON.md`` §2.4 — sera supprimé au S20).
    Si on ne pré-charge pas ``picarones``, on impute au sous-package
    tout ce que le parent charge — faux positif.

    Stratégie : pré-charger ``picarones`` une fois pour stabiliser
    ``sys.modules``, puis purger uniquement le sous-package cible
    et mesurer le vrai delta.
    """
    # Pré-charger picarones pour stabiliser le baseline.
    importlib.import_module("picarones")

    # Purger uniquement le sous-package cible (et ses descendants).
    # Ne PAS purger picarones lui-même (impact sur d'autres tests).
    to_purge = [
        m for m in list(sys.modules)
        if m == module_dotted or m.startswith(module_dotted + ".")
    ]
    for m in to_purge:
        del sys.modules[m]

    before = set(sys.modules)
    importlib.import_module(module_dotted)
    after = set(sys.modules)

    # Top-level externes seulement (pas picarones.*, pas stdlib).
    stdlib_names = set(getattr(sys, "stdlib_module_names", ()))
    delta_top = {
        m.split(".")[0] for m in (after - before)
        if "." not in m
    }
    delta_top -= {m for m in delta_top if m.startswith("_")}
    delta_top -= stdlib_names
    delta_top -= {"picarones"}
    return delta_top


@pytest.mark.parametrize(
    "layer",
    NEW_LAYERS,
    ids=lambda x: f"layer-{x}",
)
def test_layer_import_is_side_effect_free(layer: str) -> None:
    """L'import du sous-package d'une nouvelle couche ne doit pas
    charger de lib externe lourde tant que la couche est vide.

    Ce test sera ré-évalué à chaque sprint qui ajoute du code dans
    une couche : à ce moment-là, on mettra à jour les attentes par
    couche (cf. ``EXTERNAL_ALLOWED`` dans
    ``test_layer_dependencies.py``).  Pour S3, toutes les couches
    sont vides → toutes leurs dépendances externes attendues sont
    vides aussi.
    """
    layer_dir = REPO_ROOT / "picarones" / layer
    if not layer_dir.exists():
        pytest.skip(f"Couche {layer} pas encore créée — skip.")

    # Compter les .py non-__init__ dans le sous-package (récursif).
    code_files = [
        p for p in layer_dir.rglob("*.py")
        if p.name != "__init__.py" and "__pycache__" not in p.parts
    ]
    if code_files:
        # Si la couche contient déjà du code, le test est moins
        # strict : on vérifie juste que ``__init__.py`` n'importe
        # rien d'extra par effet de bord.  Une vraie vérif viendra
        # avec des règles dédiées par couche aux Sprints S5+.
        pytest.skip(
            f"Couche {layer} contient déjà du code "
            f"({len(code_files)} fichiers) — règle stricte décalée."
        )

    loaded_externals = _import_in_isolation(f"picarones.{layer}")
    suspect = loaded_externals & SUSPECTED_SIDE_EFFECT_LOADS
    assert not suspect, (
        f"\nL'import de ``picarones.{layer}`` charge des modules externes "
        f"par effet de bord alors que la couche est encore vide :\n"
        f"  {sorted(suspect)}\n\n"
        "C'est l'anti-pattern qu'on cherche à éviter — un ``__init__.py`` "
        "qui fait des imports magiques pour 'amorcer' un registre.\n"
        "Solution : construire le registre explicitement dans un service "
        "(cf. ``picarones/app/services/registry_service.py`` au Sprint S20)."
    )


def test_no_dynamic_registry_trigger_in_new_layers() -> None:
    """Méta-test : aucun ``__init__.py`` du nouveau code ne contient
    le pattern ``import picarones.X as _trigger_...`` qu'on essaie
    de bannir."""
    bad_patterns = (
        "_trigger_metric",
        "_trigger_registration",
        "as _bootstrap",
    )
    offenders: list[str] = []
    for layer in NEW_LAYERS:
        layer_dir = REPO_ROOT / "picarones" / layer
        if not layer_dir.exists():
            continue
        for init_path in layer_dir.rglob("__init__.py"):
            text = init_path.read_text(encoding="utf-8")
            for pattern in bad_patterns:
                if pattern in text:
                    offenders.append(
                        f"{init_path.relative_to(REPO_ROOT)} contient "
                        f"le pattern interdit '{pattern}'"
                    )
    assert not offenders, (
        "\nPattern d'import par effet de bord détecté dans un nouveau "
        "``__init__.py`` :\n"
        + "\n".join(f"  - {o}" for o in offenders)
        + "\n\nLes registres se construisent explicitement dans un "
        "service (cf. ``picarones/evaluation/registry/__init__.py``)."
    )