feat(p1): persist workflows and semantic learning artifacts
This commit is contained in:
108
tests/unit/test_clip_embedder_device_fix.py
Normal file
108
tests/unit/test_clip_embedder_device_fix.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests de non-régression pour le fix UnboundLocalError sur 'torch'.
|
||||
|
||||
Cas couvert : appel `CLIPEmbedder(device="cpu")` explicite — le if `device is
|
||||
None` n'était pas pris, donc l'import local `torch` n'était pas exécuté, mais
|
||||
Python avait quand même noté `torch` comme local au scope `__init__`, faisant
|
||||
planter `with torch.no_grad():` plus bas en UnboundLocalError.
|
||||
|
||||
Référence : inbox_codex/2026-05-25_1235_..._enquete-feedbackbus-5004.md
|
||||
Fix : core/embedding/clip_embedder.py l. 60-77 (import local supprimé).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_clip_embedder_init_no_local_torch_shadow():
|
||||
"""Le source de CLIPEmbedder.__init__ ne contient plus 'import torch' à
|
||||
l'intérieur du `if device is None:` (qui shadowait le torch module-level)."""
|
||||
import inspect
|
||||
from core.embedding import clip_embedder
|
||||
|
||||
src = inspect.getsource(clip_embedder.CLIPEmbedder.__init__)
|
||||
# Tolérance : on accepte qu'un commentaire mentionne `import torch`,
|
||||
# mais pas une vraie ligne d'instruction.
|
||||
code_lines = [
|
||||
line for line in src.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
code_only = "\n".join(code_lines)
|
||||
# On ne doit plus avoir un import torch indenté au-delà du module-level.
|
||||
# (l'import existe au top du fichier l. 8, pas dans __init__).
|
||||
assert " import torch" not in code_only, (
|
||||
"import torch local trouvé dans __init__ — il faut utiliser le torch "
|
||||
"du scope module (l. 8 du fichier) pour éviter UnboundLocalError "
|
||||
"quand l'appelant passe device='cpu'."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_clip_embedder_module_imports_torch():
|
||||
"""Le module clip_embedder doit avoir `import torch` au scope module
|
||||
pour que les autres méthodes (embed_image, embed_text) puissent l'utiliser."""
|
||||
import core.embedding.clip_embedder as ce
|
||||
assert hasattr(ce, "torch"), (
|
||||
"Le module clip_embedder doit exposer `torch` au scope module."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_clip_embedder_handles_device_cpu_without_unbound_local(monkeypatch):
|
||||
"""Reproduit le cas qui plantait : on appelle l'init avec device='cpu'.
|
||||
|
||||
Avant fix : UnboundLocalError sur `torch` au moment de `torch.no_grad()`.
|
||||
Après fix : l'init doit échouer proprement sur l'absence éventuelle de
|
||||
open_clip ou de poids, mais PAS sur UnboundLocalError.
|
||||
|
||||
On mocke open_clip et torch.no_grad pour ne pas charger un vrai modèle.
|
||||
"""
|
||||
import types
|
||||
from core.embedding import clip_embedder
|
||||
|
||||
# Mock open_clip pour éviter le download
|
||||
fake_open_clip = types.SimpleNamespace(
|
||||
create_model_and_transforms=lambda *a, **kw: (
|
||||
types.SimpleNamespace(
|
||||
eval=lambda: None,
|
||||
encode_image=lambda x: type("T", (), {"shape": (1, 512)})(),
|
||||
),
|
||||
None,
|
||||
lambda img: img,
|
||||
),
|
||||
get_tokenizer=lambda name: lambda t: None,
|
||||
)
|
||||
monkeypatch.setattr(clip_embedder, "open_clip", fake_open_clip)
|
||||
|
||||
# Mock torch.no_grad et torch.zeros pour court-circuiter le dummy embed
|
||||
class _FakeCtx:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
fake_zeros = lambda *args, **kwargs: type("Z", (), {"to": lambda self, d: self})()
|
||||
monkeypatch.setattr(clip_embedder.torch, "no_grad", lambda: _FakeCtx())
|
||||
monkeypatch.setattr(clip_embedder.torch, "zeros", fake_zeros)
|
||||
|
||||
# Appel direct avec device="cpu" — ne doit PAS lever UnboundLocalError.
|
||||
# Peut échouer pour autre raison (ex. encode_image), on isole uniquement
|
||||
# le bug torch unbound.
|
||||
try:
|
||||
embedder = clip_embedder.CLIPEmbedder(device="cpu")
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
assert "cannot access local variable 'torch'" not in msg, (
|
||||
f"UnboundLocalError torch toujours présent : {msg}"
|
||||
)
|
||||
# Autre erreur acceptée (mock incomplet)
|
||||
pytest.skip(f"Mock incomplet, mais bug torch absent : {msg}")
|
||||
except UnboundLocalError as e:
|
||||
pytest.fail(f"Bug torch toujours présent : {e}")
|
||||
# Si on arrive ici, init a réussi sans bug torch
|
||||
Reference in New Issue
Block a user