"""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