109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
"""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
|