From 379a32cab9acc10f01b44729386edeaa69a37bcc Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Tue, 3 Mar 2026 23:06:37 +0300 Subject: [PATCH 1/7] feat: add vNext engine support --- src/lingodotdev/engine.py | 78 ++++++++++++++++++----- tests/test_engine.py | 131 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 17 deletions(-) diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index dbdcbd7..eb7f828 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -18,13 +18,20 @@ class EngineConfig(BaseModel): """Configuration for the LingoDotDevEngine""" api_key: str + engine_id: Optional[str] = None api_url: str = "https://engine.lingo.dev" batch_size: int = Field(default=25, ge=1, le=250) ideal_batch_item_size: int = Field(default=250, ge=1, le=2500) - @validator("api_url") + @validator("api_url", pre=True, always=True) @classmethod - def validate_api_url(cls, v: str) -> str: + def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str: + if v is None or v == "https://engine.lingo.dev": + engine_id = values.get("engine_id") + if engine_id: + return "https://api.lingo.dev" + if v is None: + return "https://engine.lingo.dev" if not v.startswith(("http://", "https://")): raise ValueError("API URL must be a valid HTTP/HTTPS URL") return v @@ -55,6 +62,11 @@ def __init__(self, config: Dict[str, Any]): """ self.config = EngineConfig(**config) self._client: Optional[httpx.AsyncClient] = None + self._session_id: str = generate() + + @property + def _is_vnext(self) -> bool: + return self.config.engine_id is not None async def __aenter__(self): """Async context manager entry""" @@ -68,10 +80,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def _ensure_client(self): """Ensure the httpx client is initialized""" if self._client is None or self._client.is_closed: + if self._is_vnext: + auth_header = {"X-API-Key": self.config.api_key} + else: + auth_header = {"Authorization": f"Bearer {self.config.api_key}"} self._client = httpx.AsyncClient( headers={ "Content-Type": "application/json; charset=utf-8", - "Authorization": f"Bearer {self.config.api_key}", + **auth_header, }, timeout=60.0, ) @@ -200,16 +216,27 @@ async def _localize_chunk( """ await self._ensure_client() assert self._client is not None # Type guard for mypy - url = urljoin(self.config.api_url, "/i18n") - - request_data = { - "params": {"workflowId": workflow_id, "fast": fast}, - "locale": {"source": source_locale, "target": target_locale}, - "data": payload["data"], - } - if payload.get("reference"): - request_data["reference"] = payload["reference"] + if self._is_vnext: + url = f"{self.config.api_url}/process/{self.config.engine_id}/localize" + request_data: Dict[str, Any] = { + "params": {"fast": fast}, + "sourceLocale": source_locale, + "targetLocale": target_locale, + "data": payload["data"], + "sessionId": self._session_id, + } + if payload.get("reference"): + request_data["reference"] = payload["reference"] + else: + url = urljoin(self.config.api_url, "/i18n") + request_data = { + "params": {"workflowId": workflow_id, "fast": fast}, + "locale": {"source": source_locale, "target": target_locale}, + "data": payload["data"], + } + if payload.get("reference"): + request_data["reference"] = payload["reference"] try: response = await self._client.post(url, json=request_data) @@ -455,7 +482,11 @@ async def recognize_locale(self, text: str) -> str: await self._ensure_client() assert self._client is not None # Type guard for mypy - url = urljoin(self.config.api_url, "/recognize") + + if self._is_vnext: + url = f"{self.config.api_url}/process/recognize" + else: + url = urljoin(self.config.api_url, "/recognize") try: response = await self._client.post(url, json={"text": text}) @@ -487,10 +518,17 @@ async def whoami(self) -> Optional[Dict[str, str]]: """ await self._ensure_client() assert self._client is not None # Type guard for mypy - url = urljoin(self.config.api_url, "/whoami") + + if self._is_vnext: + url = f"{self.config.api_url}/users/me" + else: + url = urljoin(self.config.api_url, "/whoami") try: - response = await self._client.post(url) + if self._is_vnext: + response = await self._client.get(url) + else: + response = await self._client.post(url) if response.is_success: payload = self._safe_parse_json(response) @@ -541,6 +579,7 @@ async def quick_translate( source_locale: Optional[str] = None, api_url: str = "https://engine.lingo.dev", fast: bool = True, + engine_id: Optional[str] = None, ) -> Any: """ Quick one-off translation without manual context management. @@ -572,10 +611,12 @@ async def quick_translate( "es" ) """ - config = { + config: Dict[str, Any] = { "api_key": api_key, "api_url": api_url, } + if engine_id: + config["engine_id"] = engine_id async with cls(config) as engine: params = { @@ -600,6 +641,7 @@ async def quick_batch_translate( source_locale: Optional[str] = None, api_url: str = "https://engine.lingo.dev", fast: bool = True, + engine_id: Optional[str] = None, ) -> List[Any]: """ Quick batch translation to multiple target locales. @@ -624,10 +666,12 @@ async def quick_batch_translate( ) # Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"] """ - config = { + config: Dict[str, Any] = { "api_key": api_key, "api_url": api_url, } + if engine_id: + config["engine_id"] = engine_id async with cls(config) as engine: if isinstance(content, str): diff --git a/tests/test_engine.py b/tests/test_engine.py index 2fb9317..2fc063b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -641,3 +641,134 @@ async def test_full_localization_workflow(self, mock_post): assert request_data["locale"]["target"] == "es" assert request_data["params"]["fast"] is True assert request_data["data"] == {"greeting": "hello", "farewell": "goodbye"} + + +@pytest.mark.asyncio +class TestVNextEngine: + """Test vNext / Engine ID specific behavior""" + + def setup_method(self): + """Set up test fixtures""" + self.config = {"api_key": "test_api_key", "engine_id": "my-engine-id"} + self.engine = LingoDotDevEngine(self.config) + + def test_engine_id_default_api_url(self): + """Test that engine_id switches default api_url to api.lingo.dev""" + assert self.engine.config.api_url == "https://api.lingo.dev" + assert self.engine.config.engine_id == "my-engine-id" + + def test_engine_id_with_explicit_api_url(self): + """Test that explicit api_url is preserved with engine_id""" + engine = LingoDotDevEngine({ + "api_key": "key", + "engine_id": "eng", + "api_url": "https://custom.api.com", + }) + assert engine.config.api_url == "https://custom.api.com" + + def test_is_vnext_true(self): + """Test _is_vnext is True with engine_id""" + assert self.engine._is_vnext is True + + def test_is_vnext_false_without_engine_id(self): + """Test _is_vnext is False without engine_id""" + engine = LingoDotDevEngine({"api_key": "key", "api_url": "https://api.test.com"}) + assert engine._is_vnext is False + + def test_session_id_generated(self): + """Test that session_id is generated on init""" + assert self.engine._session_id + assert isinstance(self.engine._session_id, str) + + async def test_vnext_ensure_client_uses_x_api_key(self): + """Test that vNext engine uses X-API-Key header""" + await self.engine._ensure_client() + assert self.engine._client is not None + assert self.engine._client.headers.get("x-api-key") == "test_api_key" + assert "authorization" not in self.engine._client.headers + await self.engine.close() + + async def test_classic_ensure_client_uses_bearer(self): + """Test that classic engine uses Bearer auth header""" + engine = LingoDotDevEngine({"api_key": "test_key", "api_url": "https://api.test.com"}) + await engine._ensure_client() + assert engine._client is not None + assert engine._client.headers.get("authorization") == "Bearer test_key" + assert "x-api-key" not in engine._client.headers + await engine.close() + + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_vnext_localize_chunk_url_and_body(self, mock_post): + """Test vNext localize chunk uses correct URL and body format""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"data": {"key": "translated"}} + mock_post.return_value = mock_response + + await self.engine._localize_chunk( + "en", "es", {"data": {"key": "value"}, "reference": {"es": {"key": "ref"}}}, "wf", True + ) + + call_args = mock_post.call_args + url = call_args[0][0] + assert url == "https://api.lingo.dev/process/my-engine-id/localize" + + body = call_args[1]["json"] + assert body["sourceLocale"] == "en" + assert body["targetLocale"] == "es" + assert body["params"] == {"fast": True} + assert body["data"] == {"key": "value"} + assert body["sessionId"] == self.engine._session_id + assert body["reference"] == {"es": {"key": "ref"}} + + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_vnext_recognize_locale_url(self, mock_post): + """Test vNext recognize_locale uses correct URL""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"locale": "es"} + mock_post.return_value = mock_response + + await self.engine.recognize_locale("Hola mundo") + + url = mock_post.call_args[0][0] + assert url == "https://api.lingo.dev/process/recognize" + + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_vnext_whoami(self, mock_get): + """Test vNext whoami calls GET /users/me""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"id": "usr_abc", "email": "user@example.com"} + mock_get.return_value = mock_response + + result = await self.engine.whoami() + + assert result == {"email": "user@example.com", "id": "usr_abc"} + url = mock_get.call_args[0][0] + assert url == "https://api.lingo.dev/users/me" + + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_vnext_full_localization_workflow(self, mock_post): + """Test full vNext localization workflow via localize_object""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"data": {"greeting": "hola"}} + mock_post.return_value = mock_response + + result = await self.engine.localize_object( + {"greeting": "hello"}, + {"source_locale": "en", "target_locale": "es", "fast": True}, + ) + + assert result == {"greeting": "hola"} + + call_args = mock_post.call_args + url = call_args[0][0] + assert url == "https://api.lingo.dev/process/my-engine-id/localize" + + body = call_args[1]["json"] + assert body["sourceLocale"] == "en" + assert body["targetLocale"] == "es" + assert "sessionId" in body + assert "locale" not in body # classic format should NOT be present From d48212e6fa113dd4129b3539a0e8bd3434a9e23b Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Tue, 3 Mar 2026 23:54:46 +0300 Subject: [PATCH 2/7] fix: validate engine_id and normalize api_url for vNext support --- src/lingodotdev/engine.py | 10 ++++++- tests/test_engine.py | 57 +++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index eb7f828..326708d 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -23,6 +23,14 @@ class EngineConfig(BaseModel): batch_size: int = Field(default=25, ge=1, le=250) ideal_batch_item_size: int = Field(default=250, ge=1, le=2500) + @validator("engine_id", pre=True, always=True) + @classmethod + def validate_engine_id(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + return v if v else None + @validator("api_url", pre=True, always=True) @classmethod def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str: @@ -34,7 +42,7 @@ def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str: return "https://engine.lingo.dev" if not v.startswith(("http://", "https://")): raise ValueError("API URL must be a valid HTTP/HTTPS URL") - return v + return v.rstrip("/") class LocalizationParams(BaseModel): diff --git a/tests/test_engine.py b/tests/test_engine.py index 2fc063b..43baef2 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -652,6 +652,37 @@ def setup_method(self): self.config = {"api_key": "test_api_key", "engine_id": "my-engine-id"} self.engine = LingoDotDevEngine(self.config) + def test_engine_id_empty_string_treated_as_none(self): + """Test that empty engine_id is treated as None""" + engine = LingoDotDevEngine({"api_key": "key", "engine_id": ""}) + assert engine.config.engine_id is None + assert engine._is_vnext is False + assert engine.config.api_url == "https://engine.lingo.dev" + + def test_engine_id_whitespace_treated_as_none(self): + """Test that whitespace-only engine_id is treated as None""" + engine = LingoDotDevEngine({"api_key": "key", "engine_id": " "}) + assert engine.config.engine_id is None + assert engine._is_vnext is False + assert engine.config.api_url == "https://engine.lingo.dev" + + def test_engine_id_stripped(self): + """Test that engine_id is stripped of whitespace""" + engine = LingoDotDevEngine({"api_key": "key", "engine_id": " eng_123 "}) + assert engine.config.engine_id == "eng_123" + assert engine._is_vnext is True + + def test_api_url_trailing_slash_stripped(self): + """Test that trailing slash is stripped from api_url""" + engine = LingoDotDevEngine( + { + "api_key": "key", + "engine_id": "eng", + "api_url": "https://custom.api.com/", + } + ) + assert engine.config.api_url == "https://custom.api.com" + def test_engine_id_default_api_url(self): """Test that engine_id switches default api_url to api.lingo.dev""" assert self.engine.config.api_url == "https://api.lingo.dev" @@ -659,11 +690,13 @@ def test_engine_id_default_api_url(self): def test_engine_id_with_explicit_api_url(self): """Test that explicit api_url is preserved with engine_id""" - engine = LingoDotDevEngine({ - "api_key": "key", - "engine_id": "eng", - "api_url": "https://custom.api.com", - }) + engine = LingoDotDevEngine( + { + "api_key": "key", + "engine_id": "eng", + "api_url": "https://custom.api.com", + } + ) assert engine.config.api_url == "https://custom.api.com" def test_is_vnext_true(self): @@ -672,7 +705,9 @@ def test_is_vnext_true(self): def test_is_vnext_false_without_engine_id(self): """Test _is_vnext is False without engine_id""" - engine = LingoDotDevEngine({"api_key": "key", "api_url": "https://api.test.com"}) + engine = LingoDotDevEngine( + {"api_key": "key", "api_url": "https://api.test.com"} + ) assert engine._is_vnext is False def test_session_id_generated(self): @@ -690,7 +725,9 @@ async def test_vnext_ensure_client_uses_x_api_key(self): async def test_classic_ensure_client_uses_bearer(self): """Test that classic engine uses Bearer auth header""" - engine = LingoDotDevEngine({"api_key": "test_key", "api_url": "https://api.test.com"}) + engine = LingoDotDevEngine( + {"api_key": "test_key", "api_url": "https://api.test.com"} + ) await engine._ensure_client() assert engine._client is not None assert engine._client.headers.get("authorization") == "Bearer test_key" @@ -706,7 +743,11 @@ async def test_vnext_localize_chunk_url_and_body(self, mock_post): mock_post.return_value = mock_response await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}, "reference": {"es": {"key": "ref"}}}, "wf", True + "en", + "es", + {"data": {"key": "value"}, "reference": {"es": {"key": "ref"}}}, + "wf", + True, ) call_args = mock_post.call_args From 6c85587f47e567fa36fc99eaa89c9641b499158e Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Wed, 4 Mar 2026 01:14:46 +0300 Subject: [PATCH 3/7] fix: validate engine_id and normalize api_url inputs --- src/lingodotdev/engine.py | 18 ++++++++++-------- tests/test_engine.py | 5 +++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index 326708d..88fe403 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -34,15 +34,15 @@ def validate_engine_id(cls, v: Optional[str]) -> Optional[str]: @validator("api_url", pre=True, always=True) @classmethod def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str: - if v is None or v == "https://engine.lingo.dev": - engine_id = values.get("engine_id") - if engine_id: - return "https://api.lingo.dev" + default_url = "https://engine.lingo.dev" if v is None: - return "https://engine.lingo.dev" + v = default_url if not v.startswith(("http://", "https://")): raise ValueError("API URL must be a valid HTTP/HTTPS URL") - return v.rstrip("/") + v = v.rstrip("/") + if v == default_url and values.get("engine_id"): + return "https://api.lingo.dev" + return v class LocalizationParams(BaseModel): @@ -600,6 +600,7 @@ async def quick_translate( source_locale: Source language code (optional, auto-detected if None) api_url: API endpoint URL fast: Enable fast mode for quicker translations + engine_id: Optional engine ID for vNext API. Returns: Translated content (same type as input) @@ -619,7 +620,7 @@ async def quick_translate( "es" ) """ - config: Dict[str, Any] = { + config = { "api_key": api_key, "api_url": api_url, } @@ -662,6 +663,7 @@ async def quick_batch_translate( source_locale: Source language code (optional, auto-detected if None) api_url: API endpoint URL fast: Enable fast mode for quicker translations + engine_id: Optional engine ID for vNext API. Returns: List of translated content (one for each target locale) @@ -674,7 +676,7 @@ async def quick_batch_translate( ) # Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"] """ - config: Dict[str, Any] = { + config = { "api_key": api_key, "api_url": api_url, } diff --git a/tests/test_engine.py b/tests/test_engine.py index 43baef2..9e9ddc4 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -652,6 +652,11 @@ def setup_method(self): self.config = {"api_key": "test_api_key", "engine_id": "my-engine-id"} self.engine = LingoDotDevEngine(self.config) + def teardown_method(self): + """Clean up engine client""" + if self.engine._client and not self.engine._client.is_closed: + asyncio.get_event_loop().run_until_complete(self.engine.close()) + def test_engine_id_empty_string_treated_as_none(self): """Test that empty engine_id is treated as None""" engine = LingoDotDevEngine({"api_key": "key", "engine_id": ""}) From 13d8efd58e286ba1768162ce36f2a92a0cab9271 Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Thu, 5 Mar 2026 01:27:05 +0300 Subject: [PATCH 4/7] feat: vNext migration --- README.md | 23 ++-- src/lingodotdev/engine.py | 107 ++++++------------ tests/test_engine.py | 222 ++++++++++++++------------------------ tests/test_integration.py | 30 ++++-- 4 files changed, 147 insertions(+), 235 deletions(-) diff --git a/README.md b/README.md index 8883030..a71df1a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ async def main(): result = await LingoDotDevEngine.quick_translate( "Hello, world!", api_key="your-api-key", + engine_id="your-engine-id", target_locale="es" ) print(result) # "¡Hola, mundo!" @@ -55,9 +56,9 @@ from lingodotdev import LingoDotDevEngine async def main(): config = { "api_key": "your-api-key", - "api_url": "https://engine.lingo.dev" # Optional, defaults to this + "engine_id": "your-engine-id", } - + async with LingoDotDevEngine(config) as engine: # Translate text text_result = await engine.localize_text( @@ -89,6 +90,7 @@ async def batch_example(): results = await LingoDotDevEngine.quick_batch_translate( "Welcome to our application", api_key="your-api-key", + engine_id="your-engine-id", target_locales=["es", "fr", "de", "it"] ) # Results: ["Bienvenido...", "Bienvenue...", "Willkommen...", "Benvenuto..."] @@ -103,7 +105,7 @@ async def progress_example(): large_content = {f"item_{i}": f"Content {i}" for i in range(1000)} - async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + async with LingoDotDevEngine({"api_key": "your-api-key", "engine_id": "your-engine-id"}) as engine: result = await engine.localize_object( large_content, {"target_locale": "es"}, @@ -122,7 +124,7 @@ async def chat_example(): {"name": "Charlie", "text": "Great to see you all!"} ] - async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + async with LingoDotDevEngine({"api_key": "your-api-key", "engine_id": "your-engine-id"}) as engine: translated_chat = await engine.localize_chat( chat_messages, {"source_locale": "en", "target_locale": "es"} @@ -140,7 +142,7 @@ async def concurrent_objects_example(): {"success": "Account created", "next": "Continue to dashboard"} ] - async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + async with LingoDotDevEngine({"api_key": "your-api-key", "engine_id": "your-engine-id"}) as engine: results = await engine.batch_localize_objects( objects, {"target_locale": "fr"} @@ -152,7 +154,7 @@ async def concurrent_objects_example(): ```python async def detection_example(): - async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + async with LingoDotDevEngine({"api_key": "your-api-key", "engine_id": "your-engine-id"}) as engine: detected = await engine.recognize_locale("Bonjour le monde") print(detected) # "fr" ``` @@ -162,7 +164,8 @@ async def detection_example(): ```python config = { "api_key": "your-api-key", # Required: Your API key - "api_url": "https://engine.lingo.dev", # Optional: API endpoint + "engine_id": "your-engine-id", # Required: Your engine ID + "api_url": "https://api.lingo.dev", # Optional: API endpoint "batch_size": 25, # Optional: Items per batch (1-250) "ideal_batch_item_size": 250 # Optional: Target words per batch (1-2500) } @@ -186,7 +189,7 @@ config = { ```python async def error_handling_example(): try: - async with LingoDotDevEngine({"api_key": "invalid-key"}) as engine: + async with LingoDotDevEngine({"api_key": "invalid-key", "engine_id": "your-engine-id"}) as engine: result = await engine.localize_text("Hello", {"target_locale": "es"}) except ValueError as e: print(f"Invalid request: {e}") @@ -221,8 +224,8 @@ The async version is a drop-in replacement with these changes: - `whoami()` - Get API account info ### Convenience Methods -- `quick_translate(content, api_key, target_locale, ...)` - One-off translation -- `quick_batch_translate(content, api_key, target_locales, ...)` - Batch translation +- `quick_translate(content, api_key, engine_id, target_locale, ...)` - One-off translation +- `quick_batch_translate(content, api_key, engine_id, target_locales, ...)` - Batch translation ## 📄 License diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index 88fe403..d4268f5 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -2,12 +2,9 @@ LingoDotDevEngine implementation for Python SDK - Async version with httpx """ -# mypy: disable-error-code=unreachable - import asyncio import json from typing import Any, Callable, Dict, List, Optional -from urllib.parse import urljoin import httpx from nanoid import generate @@ -18,31 +15,24 @@ class EngineConfig(BaseModel): """Configuration for the LingoDotDevEngine""" api_key: str - engine_id: Optional[str] = None - api_url: str = "https://engine.lingo.dev" + engine_id: str + api_url: str = "https://api.lingo.dev" batch_size: int = Field(default=25, ge=1, le=250) ideal_batch_item_size: int = Field(default=250, ge=1, le=2500) @validator("engine_id", pre=True, always=True) @classmethod - def validate_engine_id(cls, v: Optional[str]) -> Optional[str]: - if v is None: - return None - v = v.strip() - return v if v else None + def validate_engine_id(cls, v: str) -> str: + if not v.strip(): + raise ValueError("engine_id is required and cannot be empty") + return v.strip() @validator("api_url", pre=True, always=True) @classmethod - def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str: - default_url = "https://engine.lingo.dev" - if v is None: - v = default_url + def validate_api_url(cls, v: str) -> str: if not v.startswith(("http://", "https://")): raise ValueError("API URL must be a valid HTTP/HTTPS URL") - v = v.rstrip("/") - if v == default_url and values.get("engine_id"): - return "https://api.lingo.dev" - return v + return v.rstrip("/") class LocalizationParams(BaseModel): @@ -72,10 +62,6 @@ def __init__(self, config: Dict[str, Any]): self._client: Optional[httpx.AsyncClient] = None self._session_id: str = generate() - @property - def _is_vnext(self) -> bool: - return self.config.engine_id is not None - async def __aenter__(self): """Async context manager entry""" await self._ensure_client() @@ -88,14 +74,10 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def _ensure_client(self): """Ensure the httpx client is initialized""" if self._client is None or self._client.is_closed: - if self._is_vnext: - auth_header = {"X-API-Key": self.config.api_key} - else: - auth_header = {"Authorization": f"Bearer {self.config.api_key}"} self._client = httpx.AsyncClient( headers={ "Content-Type": "application/json; charset=utf-8", - **auth_header, + "X-API-Key": self.config.api_key, }, timeout=60.0, ) @@ -158,7 +140,6 @@ async def _localize_raw( """ await self._ensure_client() chunked_payload = self._extract_payload_chunks(payload) - workflow_id = generate() if concurrent and not progress_callback: # Process chunks concurrently for better performance @@ -168,7 +149,6 @@ async def _localize_raw( params.source_locale, params.target_locale, {"data": chunk, "reference": params.reference}, - workflow_id, params.fast or False, ) tasks.append(task) @@ -184,7 +164,6 @@ async def _localize_raw( params.source_locale, params.target_locale, {"data": chunk, "reference": params.reference}, - workflow_id, params.fast or False, ) @@ -206,7 +185,6 @@ async def _localize_chunk( source_locale: Optional[str], target_locale: str, payload: Dict[str, Any], - workflow_id: str, fast: bool, ) -> Dict[str, str]: """ @@ -216,7 +194,6 @@ async def _localize_chunk( source_locale: Source locale target_locale: Target locale payload: Payload containing the chunk to be localized - workflow_id: Workflow ID for tracking fast: Whether to use fast mode Returns: @@ -225,26 +202,16 @@ async def _localize_chunk( await self._ensure_client() assert self._client is not None # Type guard for mypy - if self._is_vnext: - url = f"{self.config.api_url}/process/{self.config.engine_id}/localize" - request_data: Dict[str, Any] = { - "params": {"fast": fast}, - "sourceLocale": source_locale, - "targetLocale": target_locale, - "data": payload["data"], - "sessionId": self._session_id, - } - if payload.get("reference"): - request_data["reference"] = payload["reference"] - else: - url = urljoin(self.config.api_url, "/i18n") - request_data = { - "params": {"workflowId": workflow_id, "fast": fast}, - "locale": {"source": source_locale, "target": target_locale}, - "data": payload["data"], - } - if payload.get("reference"): - request_data["reference"] = payload["reference"] + url = f"{self.config.api_url}/process/{self.config.engine_id}/localize" + request_data: Dict[str, Any] = { + "params": {"fast": fast}, + "sourceLocale": source_locale, + "targetLocale": target_locale, + "data": payload["data"], + "sessionId": self._session_id, + } + if payload.get("reference"): + request_data["reference"] = payload["reference"] try: response = await self._client.post(url, json=request_data) @@ -491,10 +458,7 @@ async def recognize_locale(self, text: str) -> str: await self._ensure_client() assert self._client is not None # Type guard for mypy - if self._is_vnext: - url = f"{self.config.api_url}/process/recognize" - else: - url = urljoin(self.config.api_url, "/recognize") + url = f"{self.config.api_url}/process/recognize" try: response = await self._client.post(url, json={"text": text}) @@ -527,16 +491,10 @@ async def whoami(self) -> Optional[Dict[str, str]]: await self._ensure_client() assert self._client is not None # Type guard for mypy - if self._is_vnext: - url = f"{self.config.api_url}/users/me" - else: - url = urljoin(self.config.api_url, "/whoami") + url = f"{self.config.api_url}/users/me" try: - if self._is_vnext: - response = await self._client.get(url) - else: - response = await self._client.post(url) + response = await self._client.get(url) if response.is_success: payload = self._safe_parse_json(response) @@ -583,11 +541,11 @@ async def quick_translate( cls, content: Any, api_key: str, + engine_id: str, target_locale: str, source_locale: Optional[str] = None, - api_url: str = "https://engine.lingo.dev", + api_url: str = "https://api.lingo.dev", fast: bool = True, - engine_id: Optional[str] = None, ) -> Any: """ Quick one-off translation without manual context management. @@ -596,11 +554,11 @@ async def quick_translate( Args: content: Text string or dict to translate api_key: Your Lingo.dev API key + engine_id: Engine ID for the API target_locale: Target language code (e.g., 'es', 'fr') source_locale: Source language code (optional, auto-detected if None) api_url: API endpoint URL fast: Enable fast mode for quicker translations - engine_id: Optional engine ID for vNext API. Returns: Translated content (same type as input) @@ -610,6 +568,7 @@ async def quick_translate( result = await LingoDotDevEngine.quick_translate( "Hello world", "your-api-key", + "your-engine-id", "es" ) @@ -617,15 +576,15 @@ async def quick_translate( result = await LingoDotDevEngine.quick_translate( {"greeting": "Hello", "farewell": "Goodbye"}, "your-api-key", + "your-engine-id", "es" ) """ config = { "api_key": api_key, + "engine_id": engine_id, "api_url": api_url, } - if engine_id: - config["engine_id"] = engine_id async with cls(config) as engine: params = { @@ -646,11 +605,11 @@ async def quick_batch_translate( cls, content: Any, api_key: str, + engine_id: str, target_locales: List[str], source_locale: Optional[str] = None, - api_url: str = "https://engine.lingo.dev", + api_url: str = "https://api.lingo.dev", fast: bool = True, - engine_id: Optional[str] = None, ) -> List[Any]: """ Quick batch translation to multiple target locales. @@ -659,11 +618,11 @@ async def quick_batch_translate( Args: content: Text string or dict to translate api_key: Your Lingo.dev API key + engine_id: Engine ID for the API target_locales: List of target language codes (e.g., ['es', 'fr', 'de']) source_locale: Source language code (optional, auto-detected if None) api_url: API endpoint URL fast: Enable fast mode for quicker translations - engine_id: Optional engine ID for vNext API. Returns: List of translated content (one for each target locale) @@ -672,16 +631,16 @@ async def quick_batch_translate( results = await LingoDotDevEngine.quick_batch_translate( "Hello world", "your-api-key", + "your-engine-id", ["es", "fr", "de"] ) # Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"] """ config = { "api_key": api_key, + "engine_id": engine_id, "api_url": api_url, } - if engine_id: - config["engine_id"] = engine_id async with cls(config) as engine: if isinstance(content, str): diff --git a/tests/test_engine.py b/tests/test_engine.py index 9e9ddc4..893cc47 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -19,42 +19,71 @@ def test_valid_config(self): """Test valid configuration""" config = EngineConfig( api_key="test_key", + engine_id="my-engine", api_url="https://api.test.com", batch_size=50, ideal_batch_item_size=500, ) assert config.api_key == "test_key" + assert config.engine_id == "my-engine" assert config.api_url == "https://api.test.com" assert config.batch_size == 50 assert config.ideal_batch_item_size == 500 def test_default_values(self): """Test default configuration values""" - config = EngineConfig(api_key="test_key") - assert config.api_url == "https://engine.lingo.dev" + config = EngineConfig(api_key="test_key", engine_id="my-engine") + assert config.api_url == "https://api.lingo.dev" assert config.batch_size == 25 assert config.ideal_batch_item_size == 250 + def test_engine_id_required(self): + """Test that engine_id is required""" + with pytest.raises(Exception, match="engine_id"): + EngineConfig(api_key="test_key") + + def test_engine_id_empty_string_rejected(self): + """Test that empty engine_id is rejected""" + with pytest.raises(ValueError, match="engine_id is required"): + EngineConfig(api_key="test_key", engine_id="") + + def test_engine_id_whitespace_rejected(self): + """Test that whitespace-only engine_id is rejected""" + with pytest.raises(ValueError, match="engine_id is required"): + EngineConfig(api_key="test_key", engine_id=" ") + + def test_engine_id_stripped(self): + """Test that engine_id is stripped of whitespace""" + config = EngineConfig(api_key="test_key", engine_id=" eng_123 ") + assert config.engine_id == "eng_123" + def test_invalid_api_url(self): """Test invalid API URL validation""" with pytest.raises(ValueError, match="API URL must be a valid HTTP/HTTPS URL"): - EngineConfig(api_key="test_key", api_url="invalid_url") + EngineConfig(api_key="test_key", engine_id="eng", api_url="invalid_url") + + def test_api_url_trailing_slash_stripped(self): + """Test that trailing slash is stripped from api_url""" + config = EngineConfig( + api_key="test_key", engine_id="eng", api_url="https://custom.api.com/" + ) + assert config.api_url == "https://custom.api.com" def test_invalid_batch_size(self): """Test invalid batch size validation""" with pytest.raises(ValueError): - EngineConfig(api_key="test_key", batch_size=0) + EngineConfig(api_key="test_key", engine_id="eng", batch_size=0) with pytest.raises(ValueError): - EngineConfig(api_key="test_key", batch_size=300) + EngineConfig(api_key="test_key", engine_id="eng", batch_size=300) def test_invalid_ideal_batch_item_size(self): """Test invalid ideal batch item size validation""" with pytest.raises(ValueError): - EngineConfig(api_key="test_key", ideal_batch_item_size=0) + EngineConfig(api_key="test_key", engine_id="eng", ideal_batch_item_size=0) with pytest.raises(ValueError): - EngineConfig(api_key="test_key", ideal_batch_item_size=3000) + EngineConfig(api_key="test_key", engine_id="eng", ideal_batch_item_size=3000) class TestErrorHandling: @@ -167,7 +196,11 @@ class TestErrorHandlingIntegration: def setup_method(self): """Set up test fixtures""" - self.config = {"api_key": "test_api_key", "api_url": "https://api.test.com"} + self.config = { + "api_key": "test_api_key", + "engine_id": "test-engine", + "api_url": "https://api.test.com", + } self.engine = LingoDotDevEngine(self.config) @patch("lingodotdev.engine.httpx.AsyncClient.post") @@ -183,7 +216,7 @@ async def test_localize_chunk_502_html_response(self, mock_post): with pytest.raises(RuntimeError) as exc_info: await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) error_msg = str(exc_info.value) @@ -207,7 +240,7 @@ async def test_localize_chunk_success_but_html_response(self, mock_post): with pytest.raises(RuntimeError) as exc_info: await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) assert "Failed to parse API response as JSON" in str(exc_info.value) @@ -228,15 +261,15 @@ async def test_recognize_locale_502_html_response(self, mock_post): error_msg = str(exc_info.value) assert "Server error (502)" in error_msg - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_whoami_502_html_response(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_whoami_502_html_response(self, mock_get): """Test whoami handles 502 HTML gracefully""" mock_response = Mock() mock_response.is_success = False mock_response.status_code = 502 mock_response.reason_phrase = "Bad Gateway" mock_response.text = "502 Bad Gateway" - mock_post.return_value = mock_response + mock_get.return_value = mock_response with pytest.raises(RuntimeError) as exc_info: await self.engine.whoami() @@ -257,7 +290,7 @@ async def test_error_message_truncation_in_api_call(self, mock_post): with pytest.raises(RuntimeError) as exc_info: await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) error_msg = str(exc_info.value) @@ -274,6 +307,7 @@ def setup_method(self): """Set up test fixtures""" self.config = { "api_key": "test_api_key", + "engine_id": "test-engine", "api_url": "https://api.test.com", "batch_size": 10, "ideal_batch_item_size": 100, @@ -347,7 +381,7 @@ async def test_localize_chunk_success(self, mock_post): mock_post.return_value = mock_response result = await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) assert result == {"key": "translated_value"} @@ -365,7 +399,7 @@ async def test_localize_chunk_server_error(self, mock_post): with pytest.raises(RuntimeError, match="Server error"): await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) @patch("lingodotdev.engine.httpx.AsyncClient.post") @@ -380,7 +414,7 @@ async def test_localize_chunk_bad_request(self, mock_post): with pytest.raises(ValueError, match="Invalid request \\(400\\)"): await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) @patch("lingodotdev.engine.httpx.AsyncClient.post") @@ -393,7 +427,7 @@ async def test_localize_chunk_streaming_error(self, mock_post): with pytest.raises(RuntimeError, match="Streaming error occurred"): await self.engine._localize_chunk( - "en", "es", {"data": {"key": "value"}}, "workflow_id", False + "en", "es", {"data": {"key": "value"}}, False ) @patch("lingodotdev.engine.LingoDotDevEngine._localize_raw") @@ -502,8 +536,8 @@ async def test_recognize_locale_server_error(self, mock_post): with pytest.raises(RuntimeError, match="Server error"): await self.engine.recognize_locale("Hello world") - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_whoami_success(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_whoami_success(self, mock_get): """Test successful whoami request""" mock_response = Mock() mock_response.is_success = True @@ -511,46 +545,46 @@ async def test_whoami_success(self, mock_post): "email": "test@example.com", "id": "user_123", } - mock_post.return_value = mock_response + mock_get.return_value = mock_response result = await self.engine.whoami() assert result == {"email": "test@example.com", "id": "user_123"} - mock_post.assert_called_once() + mock_get.assert_called_once() - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_whoami_unauthenticated(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_whoami_unauthenticated(self, mock_get): """Test whoami request when unauthenticated""" mock_response = Mock() mock_response.is_success = False mock_response.status_code = 401 - mock_post.return_value = mock_response + mock_get.return_value = mock_response result = await self.engine.whoami() assert result is None - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_whoami_server_error(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_whoami_server_error(self, mock_get): """Test whoami request with server error""" mock_response = Mock() mock_response.is_success = False mock_response.status_code = 500 mock_response.reason_phrase = "Internal Server Error" mock_response.text = "Server error details" - mock_post.return_value = mock_response + mock_get.return_value = mock_response with pytest.raises(RuntimeError, match="Server error"): await self.engine.whoami() - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_whoami_no_email(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.get") + async def test_whoami_no_email(self, mock_get): """Test whoami request with no email in response""" mock_response = Mock() mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = {} - mock_post.return_value = mock_response + mock_get.return_value = mock_response result = await self.engine.whoami() @@ -609,47 +643,10 @@ class TestIntegration: def setup_method(self): """Set up test fixtures""" - self.config = {"api_key": "test_api_key", "api_url": "https://api.test.com"} - self.engine = LingoDotDevEngine(self.config) - - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_full_localization_workflow(self, mock_post): - """Test full localization workflow""" - # Mock the API response - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = { - "data": {"greeting": "hola", "farewell": "adiós"} + self.config = { + "api_key": "test_api_key", + "engine_id": "my-engine-id", } - mock_post.return_value = mock_response - - # Test object localization - result = await self.engine.localize_object( - {"greeting": "hello", "farewell": "goodbye"}, - {"source_locale": "en", "target_locale": "es", "fast": True}, - ) - - assert result == {"greeting": "hola", "farewell": "adiós"} - - # Verify the API was called with correct parameters - mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args[0][0].endswith("/i18n") - - request_data = call_args[1]["json"] - assert request_data["locale"]["source"] == "en" - assert request_data["locale"]["target"] == "es" - assert request_data["params"]["fast"] is True - assert request_data["data"] == {"greeting": "hello", "farewell": "goodbye"} - - -@pytest.mark.asyncio -class TestVNextEngine: - """Test vNext / Engine ID specific behavior""" - - def setup_method(self): - """Set up test fixtures""" - self.config = {"api_key": "test_api_key", "engine_id": "my-engine-id"} self.engine = LingoDotDevEngine(self.config) def teardown_method(self): @@ -657,44 +654,13 @@ def teardown_method(self): if self.engine._client and not self.engine._client.is_closed: asyncio.get_event_loop().run_until_complete(self.engine.close()) - def test_engine_id_empty_string_treated_as_none(self): - """Test that empty engine_id is treated as None""" - engine = LingoDotDevEngine({"api_key": "key", "engine_id": ""}) - assert engine.config.engine_id is None - assert engine._is_vnext is False - assert engine.config.api_url == "https://engine.lingo.dev" - - def test_engine_id_whitespace_treated_as_none(self): - """Test that whitespace-only engine_id is treated as None""" - engine = LingoDotDevEngine({"api_key": "key", "engine_id": " "}) - assert engine.config.engine_id is None - assert engine._is_vnext is False - assert engine.config.api_url == "https://engine.lingo.dev" - - def test_engine_id_stripped(self): - """Test that engine_id is stripped of whitespace""" - engine = LingoDotDevEngine({"api_key": "key", "engine_id": " eng_123 "}) - assert engine.config.engine_id == "eng_123" - assert engine._is_vnext is True - - def test_api_url_trailing_slash_stripped(self): - """Test that trailing slash is stripped from api_url""" - engine = LingoDotDevEngine( - { - "api_key": "key", - "engine_id": "eng", - "api_url": "https://custom.api.com/", - } - ) - assert engine.config.api_url == "https://custom.api.com" - - def test_engine_id_default_api_url(self): - """Test that engine_id switches default api_url to api.lingo.dev""" + def test_default_api_url(self): + """Test that default api_url is api.lingo.dev""" assert self.engine.config.api_url == "https://api.lingo.dev" assert self.engine.config.engine_id == "my-engine-id" - def test_engine_id_with_explicit_api_url(self): - """Test that explicit api_url is preserved with engine_id""" + def test_explicit_api_url_preserved(self): + """Test that explicit api_url is preserved""" engine = LingoDotDevEngine( { "api_key": "key", @@ -704,44 +670,22 @@ def test_engine_id_with_explicit_api_url(self): ) assert engine.config.api_url == "https://custom.api.com" - def test_is_vnext_true(self): - """Test _is_vnext is True with engine_id""" - assert self.engine._is_vnext is True - - def test_is_vnext_false_without_engine_id(self): - """Test _is_vnext is False without engine_id""" - engine = LingoDotDevEngine( - {"api_key": "key", "api_url": "https://api.test.com"} - ) - assert engine._is_vnext is False - def test_session_id_generated(self): """Test that session_id is generated on init""" assert self.engine._session_id assert isinstance(self.engine._session_id, str) - async def test_vnext_ensure_client_uses_x_api_key(self): - """Test that vNext engine uses X-API-Key header""" + async def test_ensure_client_uses_x_api_key(self): + """Test that engine uses X-API-Key header""" await self.engine._ensure_client() assert self.engine._client is not None assert self.engine._client.headers.get("x-api-key") == "test_api_key" assert "authorization" not in self.engine._client.headers await self.engine.close() - async def test_classic_ensure_client_uses_bearer(self): - """Test that classic engine uses Bearer auth header""" - engine = LingoDotDevEngine( - {"api_key": "test_key", "api_url": "https://api.test.com"} - ) - await engine._ensure_client() - assert engine._client is not None - assert engine._client.headers.get("authorization") == "Bearer test_key" - assert "x-api-key" not in engine._client.headers - await engine.close() - @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_vnext_localize_chunk_url_and_body(self, mock_post): - """Test vNext localize chunk uses correct URL and body format""" + async def test_localize_chunk_url_and_body(self, mock_post): + """Test localize chunk uses correct URL and body format""" mock_response = Mock() mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "translated"}} @@ -751,7 +695,6 @@ async def test_vnext_localize_chunk_url_and_body(self, mock_post): "en", "es", {"data": {"key": "value"}, "reference": {"es": {"key": "ref"}}}, - "wf", True, ) @@ -768,8 +711,8 @@ async def test_vnext_localize_chunk_url_and_body(self, mock_post): assert body["reference"] == {"es": {"key": "ref"}} @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_vnext_recognize_locale_url(self, mock_post): - """Test vNext recognize_locale uses correct URL""" + async def test_recognize_locale_url(self, mock_post): + """Test recognize_locale uses correct URL""" mock_response = Mock() mock_response.is_success = True mock_response.json.return_value = {"locale": "es"} @@ -781,8 +724,8 @@ async def test_vnext_recognize_locale_url(self, mock_post): assert url == "https://api.lingo.dev/process/recognize" @patch("lingodotdev.engine.httpx.AsyncClient.get") - async def test_vnext_whoami(self, mock_get): - """Test vNext whoami calls GET /users/me""" + async def test_whoami(self, mock_get): + """Test whoami calls GET /users/me""" mock_response = Mock() mock_response.is_success = True mock_response.json.return_value = {"id": "usr_abc", "email": "user@example.com"} @@ -795,8 +738,8 @@ async def test_vnext_whoami(self, mock_get): assert url == "https://api.lingo.dev/users/me" @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_vnext_full_localization_workflow(self, mock_post): - """Test full vNext localization workflow via localize_object""" + async def test_full_localization_workflow(self, mock_post): + """Test full localization workflow via localize_object""" mock_response = Mock() mock_response.is_success = True mock_response.json.return_value = {"data": {"greeting": "hola"}} @@ -817,4 +760,3 @@ async def test_vnext_full_localization_workflow(self, mock_post): assert body["sourceLocale"] == "en" assert body["targetLocale"] == "es" assert "sessionId" in body - assert "locale" not in body # classic format should NOT be present diff --git a/tests/test_integration.py b/tests/test_integration.py index 8ecacc7..f377571 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,13 +34,17 @@ class TestRealAPIIntegration: def setup_method(self): """Set up test fixtures""" api_key = os.getenv("LINGODOTDEV_API_KEY") + engine_id = os.getenv("LINGODOTDEV_ENGINE_ID") if not api_key: pytest.skip("No API key provided") + if not engine_id: + pytest.skip("No engine ID provided") self.engine = LingoDotDevEngine( { "api_key": api_key, - "api_url": os.getenv("LINGODOTDEV_API_URL", "https://engine.lingo.dev"), + "engine_id": engine_id, + "api_url": os.getenv("LINGODOTDEV_API_URL", "https://api.lingo.dev"), } ) @@ -270,7 +274,11 @@ class TestMockedIntegration: def setup_method(self): """Set up test fixtures""" self.engine = LingoDotDevEngine( - {"api_key": "test_api_key", "api_url": "https://api.test.com"} + { + "api_key": "test_api_key", + "engine_id": "test-engine", + "api_url": "https://api.test.com", + } ) @patch("lingodotdev.engine.httpx.AsyncClient.post") @@ -318,8 +326,8 @@ async def test_reference_parameter(self, mock_post): assert request_data["reference"] == reference @patch("lingodotdev.engine.httpx.AsyncClient.post") - async def test_workflow_id_consistency(self, mock_post): - """Test that workflow ID is consistent across chunks""" + async def test_session_id_consistency(self, mock_post): + """Test that session ID is consistent across chunks""" mock_response = Mock() mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "value"}} @@ -332,16 +340,16 @@ async def test_workflow_id_consistency(self, mock_post): large_payload, {"source_locale": "en", "target_locale": "es"} ) - # Extract workflow IDs from all calls - workflow_ids = [] + # Extract session IDs from all calls + session_ids = [] for call in mock_post.call_args_list: request_data = call[1]["json"] - workflow_id = request_data["params"]["workflowId"] - workflow_ids.append(workflow_id) + session_id = request_data["sessionId"] + session_ids.append(session_id) - # All workflow IDs should be the same - assert len(set(workflow_ids)) == 1 - assert len(workflow_ids[0]) > 0 # Should be a non-empty string + # All session IDs should be the same + assert len(set(session_ids)) == 1 + assert len(session_ids[0]) > 0 # Should be a non-empty string @patch("lingodotdev.engine.httpx.AsyncClient.post") async def test_concurrent_chunk_processing(self, mock_post): From f1d3f4fa160a0629996ba18acf3ea95226b246ea Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Thu, 5 Mar 2026 01:41:43 +0300 Subject: [PATCH 5/7] fix: formatting fix --- src/lingodotdev/engine.py | 6 ++++-- tests/test_engine.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index d4268f5..07de4dd 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -2,6 +2,8 @@ LingoDotDevEngine implementation for Python SDK - Async version with httpx """ +# mypy: disable-error-code=unreachable + import asyncio import json from typing import Any, Callable, Dict, List, Optional @@ -20,14 +22,14 @@ class EngineConfig(BaseModel): batch_size: int = Field(default=25, ge=1, le=250) ideal_batch_item_size: int = Field(default=250, ge=1, le=2500) - @validator("engine_id", pre=True, always=True) + @validator("engine_id") @classmethod def validate_engine_id(cls, v: str) -> str: if not v.strip(): raise ValueError("engine_id is required and cannot be empty") return v.strip() - @validator("api_url", pre=True, always=True) + @validator("api_url") @classmethod def validate_api_url(cls, v: str) -> str: if not v.startswith(("http://", "https://")): diff --git a/tests/test_engine.py b/tests/test_engine.py index 893cc47..041390d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -83,7 +83,9 @@ def test_invalid_ideal_batch_item_size(self): EngineConfig(api_key="test_key", engine_id="eng", ideal_batch_item_size=0) with pytest.raises(ValueError): - EngineConfig(api_key="test_key", engine_id="eng", ideal_batch_item_size=3000) + EngineConfig( + api_key="test_key", engine_id="eng", ideal_batch_item_size=3000 + ) class TestErrorHandling: From 9f58f69fc78349e8e70af9a05f68b4b9afeada0e Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Thu, 5 Mar 2026 22:57:21 +0300 Subject: [PATCH 6/7] feat: make engine_id optional and use single /process/localize endpoint --- README.md | 6 ++--- src/lingodotdev/engine.py | 38 +++++++++++++++------------ tests/test_engine.py | 54 +++++++++++++++++++++++++++++---------- tests/test_integration.py | 19 +++++++------- 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a71df1a..853247f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ A powerful async-first localization engine that supports various content types i - 🔀 **Concurrent processing** for dramatically faster bulk translations - 🎯 **Multiple content types**: text, objects, chat messages, and more - 🌐 **Auto-detection** of source languages -- ⚡ **Fast mode** for quick translations - 🔧 **Flexible configuration** with progress callbacks - 📦 **Context manager** support for proper resource management @@ -56,7 +55,7 @@ from lingodotdev import LingoDotDevEngine async def main(): config = { "api_key": "your-api-key", - "engine_id": "your-engine-id", + "engine_id": "your-engine-id", # Optional } async with LingoDotDevEngine(config) as engine: @@ -164,7 +163,7 @@ async def detection_example(): ```python config = { "api_key": "your-api-key", # Required: Your API key - "engine_id": "your-engine-id", # Required: Your engine ID + "engine_id": "your-engine-id", # Optional: Your engine ID "api_url": "https://api.lingo.dev", # Optional: API endpoint "batch_size": 25, # Optional: Items per batch (1-250) "ideal_batch_item_size": 250 # Optional: Target words per batch (1-2500) @@ -176,7 +175,6 @@ config = { ### Translation Parameters - **source_locale**: Source language code (auto-detected if None) - **target_locale**: Target language code (required) -- **fast**: Enable fast mode for quicker translations - **reference**: Reference translations for context - **concurrent**: Process chunks concurrently (faster, but no progress callbacks) diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index 07de4dd..fa57f9f 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -17,17 +17,20 @@ class EngineConfig(BaseModel): """Configuration for the LingoDotDevEngine""" api_key: str - engine_id: str + engine_id: Optional[str] = None api_url: str = "https://api.lingo.dev" batch_size: int = Field(default=25, ge=1, le=250) ideal_batch_item_size: int = Field(default=250, ge=1, le=2500) - @validator("engine_id") + @validator("engine_id", pre=True, always=True) @classmethod - def validate_engine_id(cls, v: str) -> str: - if not v.strip(): - raise ValueError("engine_id is required and cannot be empty") - return v.strip() + def validate_engine_id(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + stripped = v.strip() + if not stripped: + return None + return stripped @validator("api_url") @classmethod @@ -204,7 +207,7 @@ async def _localize_chunk( await self._ensure_client() assert self._client is not None # Type guard for mypy - url = f"{self.config.api_url}/process/{self.config.engine_id}/localize" + url = f"{self.config.api_url}/process/localize" request_data: Dict[str, Any] = { "params": {"fast": fast}, "sourceLocale": source_locale, @@ -212,6 +215,8 @@ async def _localize_chunk( "data": payload["data"], "sessionId": self._session_id, } + if self.config.engine_id: + request_data["engineId"] = self.config.engine_id if payload.get("reference"): request_data["reference"] = payload["reference"] @@ -543,9 +548,9 @@ async def quick_translate( cls, content: Any, api_key: str, - engine_id: str, target_locale: str, source_locale: Optional[str] = None, + engine_id: Optional[str] = None, api_url: str = "https://api.lingo.dev", fast: bool = True, ) -> Any: @@ -556,9 +561,9 @@ async def quick_translate( Args: content: Text string or dict to translate api_key: Your Lingo.dev API key - engine_id: Engine ID for the API target_locale: Target language code (e.g., 'es', 'fr') source_locale: Source language code (optional, auto-detected if None) + engine_id: Optional engine ID for the API api_url: API endpoint URL fast: Enable fast mode for quicker translations @@ -582,11 +587,12 @@ async def quick_translate( "es" ) """ - config = { + config: Dict[str, Any] = { "api_key": api_key, - "engine_id": engine_id, "api_url": api_url, } + if engine_id: + config["engine_id"] = engine_id async with cls(config) as engine: params = { @@ -607,9 +613,9 @@ async def quick_batch_translate( cls, content: Any, api_key: str, - engine_id: str, target_locales: List[str], source_locale: Optional[str] = None, + engine_id: Optional[str] = None, api_url: str = "https://api.lingo.dev", fast: bool = True, ) -> List[Any]: @@ -620,9 +626,9 @@ async def quick_batch_translate( Args: content: Text string or dict to translate api_key: Your Lingo.dev API key - engine_id: Engine ID for the API target_locales: List of target language codes (e.g., ['es', 'fr', 'de']) source_locale: Source language code (optional, auto-detected if None) + engine_id: Optional engine ID for the API api_url: API endpoint URL fast: Enable fast mode for quicker translations @@ -633,16 +639,16 @@ async def quick_batch_translate( results = await LingoDotDevEngine.quick_batch_translate( "Hello world", "your-api-key", - "your-engine-id", ["es", "fr", "de"] ) # Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"] """ - config = { + config: Dict[str, Any] = { "api_key": api_key, - "engine_id": engine_id, "api_url": api_url, } + if engine_id: + config["engine_id"] = engine_id async with cls(config) as engine: if isinstance(content, str): diff --git a/tests/test_engine.py b/tests/test_engine.py index 041390d..7f00412 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -37,20 +37,20 @@ def test_default_values(self): assert config.batch_size == 25 assert config.ideal_batch_item_size == 250 - def test_engine_id_required(self): - """Test that engine_id is required""" - with pytest.raises(Exception, match="engine_id"): - EngineConfig(api_key="test_key") + def test_engine_id_optional(self): + """Test that engine_id is optional and defaults to None""" + config = EngineConfig(api_key="test_key") + assert config.engine_id is None - def test_engine_id_empty_string_rejected(self): - """Test that empty engine_id is rejected""" - with pytest.raises(ValueError, match="engine_id is required"): - EngineConfig(api_key="test_key", engine_id="") + def test_engine_id_empty_string_becomes_none(self): + """Test that empty engine_id becomes None""" + config = EngineConfig(api_key="test_key", engine_id="") + assert config.engine_id is None - def test_engine_id_whitespace_rejected(self): - """Test that whitespace-only engine_id is rejected""" - with pytest.raises(ValueError, match="engine_id is required"): - EngineConfig(api_key="test_key", engine_id=" ") + def test_engine_id_whitespace_becomes_none(self): + """Test that whitespace-only engine_id becomes None""" + config = EngineConfig(api_key="test_key", engine_id=" ") + assert config.engine_id is None def test_engine_id_stripped(self): """Test that engine_id is stripped of whitespace""" @@ -702,7 +702,7 @@ async def test_localize_chunk_url_and_body(self, mock_post): call_args = mock_post.call_args url = call_args[0][0] - assert url == "https://api.lingo.dev/process/my-engine-id/localize" + assert url == "https://api.lingo.dev/process/localize" body = call_args[1]["json"] assert body["sourceLocale"] == "en" @@ -710,6 +710,7 @@ async def test_localize_chunk_url_and_body(self, mock_post): assert body["params"] == {"fast": True} assert body["data"] == {"key": "value"} assert body["sessionId"] == self.engine._session_id + assert body["engineId"] == "my-engine-id" assert body["reference"] == {"es": {"key": "ref"}} @patch("lingodotdev.engine.httpx.AsyncClient.post") @@ -756,9 +757,34 @@ async def test_full_localization_workflow(self, mock_post): call_args = mock_post.call_args url = call_args[0][0] - assert url == "https://api.lingo.dev/process/my-engine-id/localize" + assert url == "https://api.lingo.dev/process/localize" body = call_args[1]["json"] assert body["sourceLocale"] == "en" assert body["targetLocale"] == "es" + assert body["engineId"] == "my-engine-id" assert "sessionId" in body + + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_localize_without_engine_id(self, mock_post): + """Test localization without engine_id omits engineId from body""" + engine = LingoDotDevEngine({"api_key": "test_api_key"}) + + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"data": {"greeting": "hola"}} + mock_post.return_value = mock_response + + result = await engine.localize_object( + {"greeting": "hello"}, + {"source_locale": "en", "target_locale": "es", "fast": True}, + ) + + assert result == {"greeting": "hola"} + + call_args = mock_post.call_args + url = call_args[0][0] + assert url == "https://api.lingo.dev/process/localize" + + body = call_args[1]["json"] + assert "engineId" not in body diff --git a/tests/test_integration.py b/tests/test_integration.py index f377571..39d9afb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,19 +34,18 @@ class TestRealAPIIntegration: def setup_method(self): """Set up test fixtures""" api_key = os.getenv("LINGODOTDEV_API_KEY") - engine_id = os.getenv("LINGODOTDEV_ENGINE_ID") if not api_key: pytest.skip("No API key provided") - if not engine_id: - pytest.skip("No engine ID provided") - self.engine = LingoDotDevEngine( - { - "api_key": api_key, - "engine_id": engine_id, - "api_url": os.getenv("LINGODOTDEV_API_URL", "https://api.lingo.dev"), - } - ) + config = { + "api_key": api_key, + "api_url": os.getenv("LINGODOTDEV_API_URL", "https://api.lingo.dev"), + } + engine_id = os.getenv("LINGODOTDEV_ENGINE_ID") + if engine_id: + config["engine_id"] = engine_id + + self.engine = LingoDotDevEngine(config) async def test_localize_text_real_api(self): """Test text localization against real API""" From 5c0a9cf97adc65250385c1ed102dc8a3fec852ec Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Thu, 5 Mar 2026 23:06:09 +0300 Subject: [PATCH 7/7] chore: docs update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 853247f..f11e169 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,8 @@ The async version is a drop-in replacement with these changes: - `whoami()` - Get API account info ### Convenience Methods -- `quick_translate(content, api_key, engine_id, target_locale, ...)` - One-off translation -- `quick_batch_translate(content, api_key, engine_id, target_locales, ...)` - Batch translation +- `quick_translate(content, api_key, target_locale, ...)` - One-off translation +- `quick_batch_translate(content, api_key, target_locales, ...)` - Batch translation ## 📄 License