""" Track C1.1 — Provider interface adapters. Two adapter classes that bridge the legacy `TranslationProvider` interface (translate(text, target, source) -> str) and the new TranslationProvider interface (translate_text(request) -> Response). Why? The codebase has TWO provider interfaces: 1. New: services/providers/base.py - translate_text(TranslationRequest) -> TranslationResponse - translate_batch(List[TranslationRequest]) -> List[TranslationResponse] 2. Legacy: services/translation_service.py - translate(text, target, source) -> str - translate_batch(texts, target, source) -> List[str] Both are used in production. Translators sometimes receive the new interface, sometimes the legacy one. Previously the translators duck-typed the provider to figure out which interface it supported (see e.g. pptx_translator._translate_with_provider). That was hacky. These adapters give a single, explicit way to bridge the two interfaces, so translators can always treat the provider as whichever interface they prefer. Usage: # Wrap a new-style provider as legacy legacy = NewProviderAsLegacyAdapter(new_provider) legacy.translate("Hello", "fr", "auto") # returns str # Wrap a legacy-style provider as new new = LegacyProviderAsNewAdapter(legacy_provider) new.translate_text(TranslationRequest(...)) # returns TranslationResponse """ from __future__ import annotations from typing import List, Optional from core.logging import get_logger from .base import TranslationProvider as NewTranslationProvider from .schemas import TranslationRequest, TranslationResponse, ProviderHealthStatus logger = get_logger(__name__) # ============================================================================= # New-style → Legacy # ============================================================================= class NewProviderAsLegacyAdapter: """Adapt a new-style provider (translate_text) to the legacy interface. The legacy interface is what most translators and the legacy `translation_service` expect: translate(text, target_language, source_language="auto") -> str translate_batch(texts, target, source) -> List[str] get_name() -> str is_available() -> bool This adapter is for INTERNAL use — it adapts at the boundary so that a single provider instance can be passed to any code path. """ def __init__(self, new_provider: NewTranslationProvider): self._new = new_provider # ---- Core legacy methods ---- def translate( self, text: str, target_language: str, source_language: str = "auto" ) -> str: """Translate a single text — legacy signature.""" # Lazy import to avoid circular import try: from .schemas import TranslationRequest request = TranslationRequest( text=text, target_language=target_language, source_language=source_language, ) response = self._new.translate_text(request) if response.error: raise Exception( f"[{response.error_code or 'UNKNOWN'}] {response.error}" ) return response.translated_text except Exception as e: # If the new provider raised, re-raise so the caller # can decide how to handle it (legacy code expects exceptions). logger.debug( "new_provider_as_legacy_translate_error", error=str(e)[:200], ) raise def translate_batch( self, texts: List[str], target_language: str, source_language: str = "auto", ) -> List[str]: """Translate multiple texts — legacy signature.""" if not texts: return [] try: from .schemas import TranslationRequest requests = [ TranslationRequest( text=t, target_language=target_language, source_language=source_language, ) for t in texts ] responses = self._new.translate_batch(requests) return [r.translated_text for r in responses] except Exception as e: logger.debug( "new_provider_as_legacy_batch_error", error=str(e)[:200], count=len(texts), ) raise # ---- Identity methods (pass-through) ---- def get_name(self) -> str: return self._new.get_name() def is_available(self) -> bool: try: return bool(self._new.is_available()) except Exception: return False def health_check(self) -> dict: """Best-effort health check, legacy-shaped (plain dict).""" try: status = self._new.health_check() return { "name": status.name, "available": status.available, "latency_ms": status.latency_ms, "error": status.error, } except Exception as e: return { "name": self.get_name(), "available": False, "error": str(e)[:200], } # ============================================================================= # Legacy-style → New # ============================================================================= class LegacyProviderAsNewAdapter(NewTranslationProvider): """Adapt a legacy-style provider (translate(text, ...)) to the new interface. Most new code expects the new interface: translate_text(TranslationRequest) -> TranslationResponse translate_batch(List[TranslationRequest]) -> List[TranslationResponse] A "legacy" provider is anything that has: translate(text, target, source) -> str translate_batch(texts, target, source) -> List[str] get_name() -> str is_available() -> bool """ def __init__(self, legacy_provider, provider_name: str = None): self._legacy = legacy_provider # If a custom name is provided, use it; otherwise inherit from the # legacy provider. if provider_name is not None: self._name = provider_name else: try: self._name = legacy_provider.get_name() except Exception: self._name = "legacy" # ---- Implement the abstract methods of NewTranslationProvider ---- def translate_text(self, request: TranslationRequest) -> TranslationResponse: """Translate a single text — new interface.""" start = time.time() try: translated = self._legacy.translate( text=request.text, target_language=request.target_language, source_language=request.source_language or "auto", ) elapsed_ms = round((time.time() - start) * 1000, 2) return TranslationResponse( translated_text=translated or "", provider_name=self._name, source_language=request.source_language or "auto", target_language=request.target_language, elapsed_ms=elapsed_ms, ) except Exception as e: elapsed_ms = round((time.time() - start) * 1000, 2) return TranslationResponse( translated_text="", provider_name=self._name, source_language=request.source_language or "auto", target_language=request.target_language, elapsed_ms=elapsed_ms, error=str(e)[:200], error_code="LEGACY_PROVIDER_ERROR", ) def translate_batch( self, requests: List[TranslationRequest] ) -> List[TranslationResponse]: """Translate a batch of texts — new interface.""" if not requests: return [] # Group by language pair so we can call the legacy # translate_batch efficiently (legacy signature is per-language-pair). from collections import defaultdict groups: dict = defaultdict(list) for i, r in enumerate(requests): key = (r.target_language, r.source_language or "auto") groups[key].append((i, r)) results: List[TranslationResponse] = [None] * len(requests) # type: ignore for (target_lang, source_lang), items in groups.items(): texts = [r.text for _, r in items] try: translated = self._legacy.translate_batch( texts=texts, target_language=target_lang, source_language=source_lang, ) except Exception as e: # If legacy batch fails, fall back to per-text logger.debug( "legacy_batch_fallback_to_individual", error=str(e)[:200], ) translated = [] for t in texts: try: translated.append( self._legacy.translate(t, target_lang, source_lang) ) except Exception as individual_err: translated.append("") # legacy code expects str for (orig_idx, r), t in zip(items, translated): results[orig_idx] = TranslationResponse( translated_text=t or "", provider_name=self._name, source_language=source_lang, target_language=target_lang, ) return results def get_name(self) -> str: # Always prefer the explicit name set at construction. Only fall # back to the legacy provider's get_name if no name was set. if hasattr(self, "_name") and self._name: return self._name try: return self._legacy.get_name() except Exception: return "legacy" def is_available(self) -> bool: try: return bool(self._legacy.is_available()) except Exception: return False # ============================================================================= # Auto-detect helper # ============================================================================= import time # noqa: E402 def coerce_to_legacy(provider) -> NewProviderAsLegacyAdapter: """Coerce any provider to the legacy interface. If the provider is already a legacy provider, return it as-is. If it's a new-style provider, wrap it in NewProviderAsLegacyAdapter. """ if isinstance(provider, NewProviderAsLegacyAdapter): return provider if isinstance(provider, NewTranslationProvider): return NewProviderAsLegacyAdapter(provider) # Already legacy-shaped — pass through return provider def coerce_to_new(provider, name: str = None) -> NewTranslationProvider: """Coerce any provider to the new interface. If the provider is already a new-style provider, return it as-is. If it's a legacy provider, wrap it in LegacyProviderAsNewAdapter. """ if isinstance(provider, NewTranslationProvider): return provider return LegacyProviderAsNewAdapter(provider, provider_name=name)