Skip to content

Commit 379a32c

Browse files
committed
feat: add vNext engine support
1 parent 70c961d commit 379a32c

File tree

2 files changed

+192
-17
lines changed

2 files changed

+192
-17
lines changed

src/lingodotdev/engine.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,20 @@ class EngineConfig(BaseModel):
1818
"""Configuration for the LingoDotDevEngine"""
1919

2020
api_key: str
21+
engine_id: Optional[str] = None
2122
api_url: str = "https://engine.lingo.dev"
2223
batch_size: int = Field(default=25, ge=1, le=250)
2324
ideal_batch_item_size: int = Field(default=250, ge=1, le=2500)
2425

25-
@validator("api_url")
26+
@validator("api_url", pre=True, always=True)
2627
@classmethod
27-
def validate_api_url(cls, v: str) -> str:
28+
def validate_api_url(cls, v: Optional[str], values: Dict[str, Any]) -> str:
29+
if v is None or v == "https://engine.lingo.dev":
30+
engine_id = values.get("engine_id")
31+
if engine_id:
32+
return "https://api.lingo.dev"
33+
if v is None:
34+
return "https://engine.lingo.dev"
2835
if not v.startswith(("http://", "https://")):
2936
raise ValueError("API URL must be a valid HTTP/HTTPS URL")
3037
return v
@@ -55,6 +62,11 @@ def __init__(self, config: Dict[str, Any]):
5562
"""
5663
self.config = EngineConfig(**config)
5764
self._client: Optional[httpx.AsyncClient] = None
65+
self._session_id: str = generate()
66+
67+
@property
68+
def _is_vnext(self) -> bool:
69+
return self.config.engine_id is not None
5870

5971
async def __aenter__(self):
6072
"""Async context manager entry"""
@@ -68,10 +80,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
6880
async def _ensure_client(self):
6981
"""Ensure the httpx client is initialized"""
7082
if self._client is None or self._client.is_closed:
83+
if self._is_vnext:
84+
auth_header = {"X-API-Key": self.config.api_key}
85+
else:
86+
auth_header = {"Authorization": f"Bearer {self.config.api_key}"}
7187
self._client = httpx.AsyncClient(
7288
headers={
7389
"Content-Type": "application/json; charset=utf-8",
74-
"Authorization": f"Bearer {self.config.api_key}",
90+
**auth_header,
7591
},
7692
timeout=60.0,
7793
)
@@ -200,16 +216,27 @@ async def _localize_chunk(
200216
"""
201217
await self._ensure_client()
202218
assert self._client is not None # Type guard for mypy
203-
url = urljoin(self.config.api_url, "/i18n")
204-
205-
request_data = {
206-
"params": {"workflowId": workflow_id, "fast": fast},
207-
"locale": {"source": source_locale, "target": target_locale},
208-
"data": payload["data"],
209-
}
210219

211-
if payload.get("reference"):
212-
request_data["reference"] = payload["reference"]
220+
if self._is_vnext:
221+
url = f"{self.config.api_url}/process/{self.config.engine_id}/localize"
222+
request_data: Dict[str, Any] = {
223+
"params": {"fast": fast},
224+
"sourceLocale": source_locale,
225+
"targetLocale": target_locale,
226+
"data": payload["data"],
227+
"sessionId": self._session_id,
228+
}
229+
if payload.get("reference"):
230+
request_data["reference"] = payload["reference"]
231+
else:
232+
url = urljoin(self.config.api_url, "/i18n")
233+
request_data = {
234+
"params": {"workflowId": workflow_id, "fast": fast},
235+
"locale": {"source": source_locale, "target": target_locale},
236+
"data": payload["data"],
237+
}
238+
if payload.get("reference"):
239+
request_data["reference"] = payload["reference"]
213240

214241
try:
215242
response = await self._client.post(url, json=request_data)
@@ -455,7 +482,11 @@ async def recognize_locale(self, text: str) -> str:
455482

456483
await self._ensure_client()
457484
assert self._client is not None # Type guard for mypy
458-
url = urljoin(self.config.api_url, "/recognize")
485+
486+
if self._is_vnext:
487+
url = f"{self.config.api_url}/process/recognize"
488+
else:
489+
url = urljoin(self.config.api_url, "/recognize")
459490

460491
try:
461492
response = await self._client.post(url, json={"text": text})
@@ -487,10 +518,17 @@ async def whoami(self) -> Optional[Dict[str, str]]:
487518
"""
488519
await self._ensure_client()
489520
assert self._client is not None # Type guard for mypy
490-
url = urljoin(self.config.api_url, "/whoami")
521+
522+
if self._is_vnext:
523+
url = f"{self.config.api_url}/users/me"
524+
else:
525+
url = urljoin(self.config.api_url, "/whoami")
491526

492527
try:
493-
response = await self._client.post(url)
528+
if self._is_vnext:
529+
response = await self._client.get(url)
530+
else:
531+
response = await self._client.post(url)
494532

495533
if response.is_success:
496534
payload = self._safe_parse_json(response)
@@ -541,6 +579,7 @@ async def quick_translate(
541579
source_locale: Optional[str] = None,
542580
api_url: str = "https://engine.lingo.dev",
543581
fast: bool = True,
582+
engine_id: Optional[str] = None,
544583
) -> Any:
545584
"""
546585
Quick one-off translation without manual context management.
@@ -572,10 +611,12 @@ async def quick_translate(
572611
"es"
573612
)
574613
"""
575-
config = {
614+
config: Dict[str, Any] = {
576615
"api_key": api_key,
577616
"api_url": api_url,
578617
}
618+
if engine_id:
619+
config["engine_id"] = engine_id
579620

580621
async with cls(config) as engine:
581622
params = {
@@ -600,6 +641,7 @@ async def quick_batch_translate(
600641
source_locale: Optional[str] = None,
601642
api_url: str = "https://engine.lingo.dev",
602643
fast: bool = True,
644+
engine_id: Optional[str] = None,
603645
) -> List[Any]:
604646
"""
605647
Quick batch translation to multiple target locales.
@@ -624,10 +666,12 @@ async def quick_batch_translate(
624666
)
625667
# Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"]
626668
"""
627-
config = {
669+
config: Dict[str, Any] = {
628670
"api_key": api_key,
629671
"api_url": api_url,
630672
}
673+
if engine_id:
674+
config["engine_id"] = engine_id
631675

632676
async with cls(config) as engine:
633677
if isinstance(content, str):

tests/test_engine.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,3 +641,134 @@ async def test_full_localization_workflow(self, mock_post):
641641
assert request_data["locale"]["target"] == "es"
642642
assert request_data["params"]["fast"] is True
643643
assert request_data["data"] == {"greeting": "hello", "farewell": "goodbye"}
644+
645+
646+
@pytest.mark.asyncio
647+
class TestVNextEngine:
648+
"""Test vNext / Engine ID specific behavior"""
649+
650+
def setup_method(self):
651+
"""Set up test fixtures"""
652+
self.config = {"api_key": "test_api_key", "engine_id": "my-engine-id"}
653+
self.engine = LingoDotDevEngine(self.config)
654+
655+
def test_engine_id_default_api_url(self):
656+
"""Test that engine_id switches default api_url to api.lingo.dev"""
657+
assert self.engine.config.api_url == "https://api.lingo.dev"
658+
assert self.engine.config.engine_id == "my-engine-id"
659+
660+
def test_engine_id_with_explicit_api_url(self):
661+
"""Test that explicit api_url is preserved with engine_id"""
662+
engine = LingoDotDevEngine({
663+
"api_key": "key",
664+
"engine_id": "eng",
665+
"api_url": "https://custom.api.com",
666+
})
667+
assert engine.config.api_url == "https://custom.api.com"
668+
669+
def test_is_vnext_true(self):
670+
"""Test _is_vnext is True with engine_id"""
671+
assert self.engine._is_vnext is True
672+
673+
def test_is_vnext_false_without_engine_id(self):
674+
"""Test _is_vnext is False without engine_id"""
675+
engine = LingoDotDevEngine({"api_key": "key", "api_url": "https://api.test.com"})
676+
assert engine._is_vnext is False
677+
678+
def test_session_id_generated(self):
679+
"""Test that session_id is generated on init"""
680+
assert self.engine._session_id
681+
assert isinstance(self.engine._session_id, str)
682+
683+
async def test_vnext_ensure_client_uses_x_api_key(self):
684+
"""Test that vNext engine uses X-API-Key header"""
685+
await self.engine._ensure_client()
686+
assert self.engine._client is not None
687+
assert self.engine._client.headers.get("x-api-key") == "test_api_key"
688+
assert "authorization" not in self.engine._client.headers
689+
await self.engine.close()
690+
691+
async def test_classic_ensure_client_uses_bearer(self):
692+
"""Test that classic engine uses Bearer auth header"""
693+
engine = LingoDotDevEngine({"api_key": "test_key", "api_url": "https://api.test.com"})
694+
await engine._ensure_client()
695+
assert engine._client is not None
696+
assert engine._client.headers.get("authorization") == "Bearer test_key"
697+
assert "x-api-key" not in engine._client.headers
698+
await engine.close()
699+
700+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
701+
async def test_vnext_localize_chunk_url_and_body(self, mock_post):
702+
"""Test vNext localize chunk uses correct URL and body format"""
703+
mock_response = Mock()
704+
mock_response.is_success = True
705+
mock_response.json.return_value = {"data": {"key": "translated"}}
706+
mock_post.return_value = mock_response
707+
708+
await self.engine._localize_chunk(
709+
"en", "es", {"data": {"key": "value"}, "reference": {"es": {"key": "ref"}}}, "wf", True
710+
)
711+
712+
call_args = mock_post.call_args
713+
url = call_args[0][0]
714+
assert url == "https://api.lingo.dev/process/my-engine-id/localize"
715+
716+
body = call_args[1]["json"]
717+
assert body["sourceLocale"] == "en"
718+
assert body["targetLocale"] == "es"
719+
assert body["params"] == {"fast": True}
720+
assert body["data"] == {"key": "value"}
721+
assert body["sessionId"] == self.engine._session_id
722+
assert body["reference"] == {"es": {"key": "ref"}}
723+
724+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
725+
async def test_vnext_recognize_locale_url(self, mock_post):
726+
"""Test vNext recognize_locale uses correct URL"""
727+
mock_response = Mock()
728+
mock_response.is_success = True
729+
mock_response.json.return_value = {"locale": "es"}
730+
mock_post.return_value = mock_response
731+
732+
await self.engine.recognize_locale("Hola mundo")
733+
734+
url = mock_post.call_args[0][0]
735+
assert url == "https://api.lingo.dev/process/recognize"
736+
737+
@patch("lingodotdev.engine.httpx.AsyncClient.get")
738+
async def test_vnext_whoami(self, mock_get):
739+
"""Test vNext whoami calls GET /users/me"""
740+
mock_response = Mock()
741+
mock_response.is_success = True
742+
mock_response.json.return_value = {"id": "usr_abc", "email": "user@example.com"}
743+
mock_get.return_value = mock_response
744+
745+
result = await self.engine.whoami()
746+
747+
assert result == {"email": "user@example.com", "id": "usr_abc"}
748+
url = mock_get.call_args[0][0]
749+
assert url == "https://api.lingo.dev/users/me"
750+
751+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
752+
async def test_vnext_full_localization_workflow(self, mock_post):
753+
"""Test full vNext localization workflow via localize_object"""
754+
mock_response = Mock()
755+
mock_response.is_success = True
756+
mock_response.json.return_value = {"data": {"greeting": "hola"}}
757+
mock_post.return_value = mock_response
758+
759+
result = await self.engine.localize_object(
760+
{"greeting": "hello"},
761+
{"source_locale": "en", "target_locale": "es", "fast": True},
762+
)
763+
764+
assert result == {"greeting": "hola"}
765+
766+
call_args = mock_post.call_args
767+
url = call_args[0][0]
768+
assert url == "https://api.lingo.dev/process/my-engine-id/localize"
769+
770+
body = call_args[1]["json"]
771+
assert body["sourceLocale"] == "en"
772+
assert body["targetLocale"] == "es"
773+
assert "sessionId" in body
774+
assert "locale" not in body # classic format should NOT be present

0 commit comments

Comments
 (0)