Spaces:
Sleeping
fix(audit-2): 5 correctifs supplémentaires d'un 2e tour d'audit
Browse files3 agents Explore indépendants ont audité le commit 1e8b84c
(corrections du 1er audit). Ils ont trouvé que mon propre
correctif contenait des défauts. Cinq nouveaux fixes :
1. **CRITIQUE — _imports_target_relative bombe à retardement**
La fonction n'acceptait les imports relatifs QUE depuis
``MEASUREMENTS_DIR`` lui-même (check ``source_dir != …``).
Un sous-package ``measurements/narrative/foo.py`` qui ferait
``from .. import builtin_metrics`` n'était pas détecté → le
module classé à tort test-only. Pas activé aujourd'hui (aucun
import de cette forme), mais devient un bug silencieux dès
qu'un sous-package en utilise un.
Refactor : remonter ``node.level - 1`` parents depuis
``source_dir`` pour résoudre le package cible, puis comparer
à ``MEASUREMENTS_DIR``. Couvre désormais level=1, 2, 3, …
2. **MAJEURE — _test_only_modules sans mémoïsation**
Les 2 tests du fichier appellent la même fonction qui parse
l'AST de tous les fichiers prod (~12 s). Sans cache, on
parsait 2× pour rien (23 s par run).
``@functools.cache`` sur ``_test_only_modules``. Mesure :
23.1 s → 11.2 s par run (÷2.06 comme prévu).
3. **MINEURE — tests NaN/Inf placebos avec titres mensongers**
``test_nan_falls_back_to_red`` n'asserait que "ne crash pas
et hex valide" alors que le titre prétendait tester le rouge.
En réalité, ``min(1.0, NaN) = 1.0`` (Python propage NaN à
travers ``min``/``max``), donc NaN → vert max, pas rouge.
Renommé ``test_nan_propagates_to_green`` avec assertion
exacte sur ``GRADIENT_GREEN_RGB`` + cas inversé low_is_good.
Idem ``test_inverted_scale_returns_yellow_neutral`` qui
vérifie maintenant l'égalité exacte avec
``GRADIENT_YELLOW_RGB``. Plus de tests "non-crash".
4. **MINEURE — doc contradictoire sur les conventions**
La docstring affirmait "rouge/jaune/vert (échelle universelle
de qualité)" puis admettait dans la même section que ces
couleurs sont précisément non-universelles en daltonisme.
Reformulation : "convention historique pour vision trichromate
normale, **compromis d'accessibilité accepté**" + référence
à la palette Okabe-Ito de ``picarones.report.colors`` comme
dette de migration.
5. **MINEURE — logger trompeur si Pillow indisponible**
``encode_image_b64`` attrapait toutes les exceptions sous le
même message ``"échec d'encodage base64 de l'image …"``.
Si Pillow n'était pas installé, le log faisait croire à un
problème de fichier image alors que c'était l'environnement.
Split en deux ``try`` : le premier capture
``ImportError`` avec un message explicite "Pillow indisponible"
+ suggestion d'install ; le second garde le message générique
pour les vrais problèmes par image.
Suite : 3834 passed, 2 skipped (parité). 1 échec pré-existant.
ruff : All checks passed!
|
@@ -49,13 +49,25 @@ def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
|
|
| 49 |
échoue (Pillow indisponible, format non géré, fichier corrompu).
|
| 50 |
Logue un avertissement dans ce dernier cas — le rapport reste
|
| 51 |
fonctionnel mais l'image manquera dans la galerie.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
"""
|
| 53 |
p = Path(image_path)
|
| 54 |
if not p.exists():
|
| 55 |
return ""
|
| 56 |
try:
|
| 57 |
from PIL import Image
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
with Image.open(p) as img:
|
| 60 |
if img.width > max_width:
|
| 61 |
ratio = max_width / img.width
|
|
|
|
| 49 |
échoue (Pillow indisponible, format non géré, fichier corrompu).
|
| 50 |
Logue un avertissement dans ce dernier cas — le rapport reste
|
| 51 |
fonctionnel mais l'image manquera dans la galerie.
|
| 52 |
+
|
| 53 |
+
Distingue ``ImportError`` (Pillow non installée — problème
|
| 54 |
+
d'environnement) du reste (problème par image) pour aider au
|
| 55 |
+
diagnostic en logs de production.
|
| 56 |
"""
|
| 57 |
p = Path(image_path)
|
| 58 |
if not p.exists():
|
| 59 |
return ""
|
| 60 |
try:
|
| 61 |
from PIL import Image
|
| 62 |
+
except ImportError as exc:
|
| 63 |
+
logger.warning(
|
| 64 |
+
"[report] Pillow indisponible : %s — toutes les images "
|
| 65 |
+
"du rapport seront omises. Installer ``pip install Pillow`` "
|
| 66 |
+
"ou ``pip install picarones[report]``.",
|
| 67 |
+
exc,
|
| 68 |
+
)
|
| 69 |
+
return ""
|
| 70 |
+
try:
|
| 71 |
with Image.open(p) as img:
|
| 72 |
if img.width > max_width:
|
| 73 |
ratio = max_width / img.width
|
|
@@ -37,12 +37,19 @@ maladresse) :
|
|
| 37 |
(deltas signés) sont symétriques autour de 0 — la borne est la
|
| 38 |
même des deux côtés.
|
| 39 |
|
| 40 |
-
Le choix des couleurs reflète la sémantique métier :
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
Palette
|
| 48 |
-------
|
|
|
|
| 37 |
(deltas signés) sont symétriques autour de 0 — la borne est la
|
| 38 |
même des deux côtés.
|
| 39 |
|
| 40 |
+
Le choix des couleurs reflète la sémantique métier :
|
| 41 |
+
|
| 42 |
+
- **Traffic-light** rouge/jaune/vert : convention historique
|
| 43 |
+
largement comprise pour vision trichromate normale. **Compromis
|
| 44 |
+
d'accessibilité accepté** : la confusion rouge/vert affecte ~8 %
|
| 45 |
+
des hommes (deutéranopie/protanopie). Une migration vers la
|
| 46 |
+
palette Okabe-Ito de :mod:`picarones.report.colors` est tracée
|
| 47 |
+
comme dette dans un sprint dédié.
|
| 48 |
+
- **Diverging** bleu/vert/orange par défaut : vert au centre =
|
| 49 |
+
neutre, extrémités opposées sémantiquement, et ces 3 teintes
|
| 50 |
+
restent distinguables en daltonisme deutéranope. Choix retenu
|
| 51 |
+
parce que les cellules diverging sont moins nombreuses et
|
| 52 |
+
qu'on a pu repartir de zéro en les écrivant.
|
| 53 |
|
| 54 |
Palette
|
| 55 |
-------
|
|
@@ -38,6 +38,7 @@ Test ratchet :
|
|
| 38 |
from __future__ import annotations
|
| 39 |
|
| 40 |
import ast
|
|
|
|
| 41 |
from pathlib import Path
|
| 42 |
|
| 43 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
@@ -107,24 +108,38 @@ def _imports_target_relative(
|
|
| 107 |
node: ast.AST, module_name: str, source_dir: Path,
|
| 108 |
) -> bool:
|
| 109 |
"""True si ce nœud AST importe ``module_name`` via un import relatif
|
| 110 |
-
|
| 111 |
|
| 112 |
-
Couvre
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
- ``from . import X`` (depuis ``measurements/
|
| 115 |
-
|
| 116 |
-
- ``from .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
"""
|
| 118 |
if not isinstance(node, ast.ImportFrom):
|
| 119 |
return False
|
| 120 |
if node.level < 1:
|
| 121 |
return False
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return False
|
| 124 |
-
# ``from .X import …``
|
| 125 |
if node.module == module_name:
|
| 126 |
return True
|
| 127 |
-
# ``from . import X``
|
| 128 |
if node.module is None:
|
| 129 |
for alias in node.names:
|
| 130 |
if alias.name == module_name:
|
|
@@ -163,7 +178,15 @@ def _has_production_consumer(module_name: str) -> bool:
|
|
| 163 |
return False
|
| 164 |
|
| 165 |
|
|
|
|
| 166 |
def _test_only_modules() -> frozenset[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
return frozenset(
|
| 168 |
m for m in _measurements_modules()
|
| 169 |
if not _has_production_consumer(m)
|
|
|
|
| 38 |
from __future__ import annotations
|
| 39 |
|
| 40 |
import ast
|
| 41 |
+
import functools
|
| 42 |
from pathlib import Path
|
| 43 |
|
| 44 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
| 108 |
node: ast.AST, module_name: str, source_dir: Path,
|
| 109 |
) -> bool:
|
| 110 |
"""True si ce nœud AST importe ``module_name`` via un import relatif
|
| 111 |
+
qui pointe vers ``picarones/measurements/<module_name>``.
|
| 112 |
|
| 113 |
+
Couvre les imports relatifs depuis n'importe quel sous-dossier du
|
| 114 |
+
package ``measurements`` (y compris ``measurements/narrative/`` et
|
| 115 |
+
``measurements/narrative/detectors/``) :
|
| 116 |
|
| 117 |
+
- ``from . import X`` (level=1) depuis ``measurements/foo.py``.
|
| 118 |
+
- ``from .X import Y`` (level=1, module=X) depuis le même.
|
| 119 |
+
- ``from .. import X`` (level=2) depuis ``measurements/sub/foo.py``.
|
| 120 |
+
- ``from ..X import Y`` (level=2, module=X) depuis le même.
|
| 121 |
+
- Idem pour level=3 et au-delà depuis sous-sous-packages.
|
| 122 |
+
|
| 123 |
+
L'ancien check ``source_dir == MEASUREMENTS_DIR`` ratait tous les
|
| 124 |
+
imports relatifs depuis les sous-packages — bombe à retardement
|
| 125 |
+
qui devient critique dès qu'un sous-package importe un voisin.
|
| 126 |
"""
|
| 127 |
if not isinstance(node, ast.ImportFrom):
|
| 128 |
return False
|
| 129 |
if node.level < 1:
|
| 130 |
return False
|
| 131 |
+
# Remonter ``node.level - 1`` niveaux pour résoudre le package cible.
|
| 132 |
+
# Pour ``from . import X`` (level=1) on reste dans ``source_dir`` ;
|
| 133 |
+
# pour ``from ..X import Y`` (level=2) on remonte d'un niveau ; etc.
|
| 134 |
+
target_dir = source_dir
|
| 135 |
+
for _ in range(node.level - 1):
|
| 136 |
+
target_dir = target_dir.parent
|
| 137 |
+
if target_dir != MEASUREMENTS_DIR:
|
| 138 |
return False
|
| 139 |
+
# ``from .X import …`` ou ``from ..X import …``
|
| 140 |
if node.module == module_name:
|
| 141 |
return True
|
| 142 |
+
# ``from . import X`` ou ``from .. import X``
|
| 143 |
if node.module is None:
|
| 144 |
for alias in node.names:
|
| 145 |
if alias.name == module_name:
|
|
|
|
| 178 |
return False
|
| 179 |
|
| 180 |
|
| 181 |
+
@functools.cache
|
| 182 |
def _test_only_modules() -> frozenset[str]:
|
| 183 |
+
"""Retourne les modules de ``measurements/`` sans consommateur prod.
|
| 184 |
+
|
| 185 |
+
Mémoïsée par ``functools.cache`` : les deux tests de ce fichier
|
| 186 |
+
appellent cette fonction (≈ 12 s par appel sur ~200 fichiers
|
| 187 |
+
Python), donc sans cache on parsait l'AST de tout le projet
|
| 188 |
+
deux fois pour rien.
|
| 189 |
+
"""
|
| 190 |
return frozenset(
|
| 191 |
m for m in _measurements_modules()
|
| 192 |
if not _has_production_consumer(m)
|
|
@@ -75,14 +75,17 @@ class TestColorTrafficLight:
|
|
| 75 |
assert len(c) == 7
|
| 76 |
assert c == c.lower()
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
#
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
-
#
|
| 83 |
-
|
| 84 |
-
assert
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
def test_inf_clamped_to_max(self) -> None:
|
| 88 |
# +inf > scale_max → clamp à scale_max → vert (high_is_good)
|
|
@@ -90,12 +93,15 @@ class TestColorTrafficLight:
|
|
| 90 |
# -inf < 0 → clamp à 0 → rouge
|
| 91 |
assert _hex_to_rgb(color_traffic_light(float("-inf"))) == GRADIENT_RED_RGB
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
# scale_min > scale_max → span
|
| 95 |
-
#
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
# ──────────────────────────────────────────────────────────────────
|
|
|
|
| 75 |
assert len(c) == 7
|
| 76 |
assert c == c.lower()
|
| 77 |
|
| 78 |
+
def test_nan_propagates_to_green(self) -> None:
|
| 79 |
+
# Vérification IEEE 754 : ``min(1.0, NaN)`` retourne ``1.0`` en
|
| 80 |
+
# Python (la comparaison avec NaN est False, donc Python retient
|
| 81 |
+
# le second argument). ``max(0.0, 1.0) = 1.0``. Donc f=1.0,
|
| 82 |
+
# branche ``f > 0.5`` → vert max. Comportement déterministe et
|
| 83 |
+
# testable (pas un test placebo "ne crash pas").
|
| 84 |
+
assert _hex_to_rgb(color_traffic_light(float("nan"))) == GRADIENT_GREEN_RGB
|
| 85 |
+
# Avec low_is_good=True, l'inversion donne f=0 → rouge.
|
| 86 |
+
assert _hex_to_rgb(
|
| 87 |
+
color_traffic_light(float("nan"), low_is_good=True)
|
| 88 |
+
) == GRADIENT_RED_RGB
|
| 89 |
|
| 90 |
def test_inf_clamped_to_max(self) -> None:
|
| 91 |
# +inf > scale_max → clamp à scale_max → vert (high_is_good)
|
|
|
|
| 93 |
# -inf < 0 → clamp à 0 → rouge
|
| 94 |
assert _hex_to_rgb(color_traffic_light(float("-inf"))) == GRADIENT_RED_RGB
|
| 95 |
|
| 96 |
+
def test_inverted_scale_returns_yellow_neutral(self) -> None:
|
| 97 |
+
# scale_min > scale_max → span <= 0 → branche "zero span" → f=0.5
|
| 98 |
+
# → frontière exacte rouge/jaune/vert → jaune neutre.
|
| 99 |
+
assert _hex_to_rgb(
|
| 100 |
+
color_traffic_light(5.0, scale_min=10, scale_max=5)
|
| 101 |
+
) == GRADIENT_YELLOW_RGB
|
| 102 |
+
# Idem pour scale_min == scale_max (déjà couvert par
|
| 103 |
+
# test_zero_span_returns_yellow plus haut, mais la cohérence
|
| 104 |
+
# de comportement est explicite ici).
|
| 105 |
|
| 106 |
|
| 107 |
# ──────────────────────────────────────────────────────────────────
|