gemx 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gemx-0.2.1 → gemx-0.2.2}/PKG-INFO +2 -2
- {gemx-0.2.1 → gemx-0.2.2}/pyproject.toml +1 -1
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/__init__.py +3 -1
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/client.py +71 -15
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/errors.py +4 -0
- {gemx-0.2.1 → gemx-0.2.2}/.gitignore +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/LICENSE +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/README.md +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/cli.py +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/formats.py +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/src/gemx/py.typed +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/__init__.py +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/scripts/TEST_GEMINI.md +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/scripts/gemini_console.js +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/scripts/gemini_console_prompt.js +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/test_cli.py +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/test_client.py +0 -0
- {gemx-0.2.1 → gemx-0.2.2}/tests/test_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: gemx
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Drive the Gemini web UI from Python via Playwright
|
|
5
5
|
Project-URL: Homepage, https://github.com/ra0x3/gemx
|
|
6
6
|
Project-URL: Repository, https://github.com/ra0x3/gemx
|
|
@@ -9,6 +9,7 @@ from .client import Gemx, GemxConfig
|
|
|
9
9
|
from .errors import (
|
|
10
10
|
GemxError,
|
|
11
11
|
InputError,
|
|
12
|
+
ProfileBusyError,
|
|
12
13
|
ResponseParseError,
|
|
13
14
|
ResponseTimeoutError,
|
|
14
15
|
)
|
|
@@ -20,10 +21,11 @@ __all__ = [
|
|
|
20
21
|
"GemxError",
|
|
21
22
|
"InputError",
|
|
22
23
|
"OutputFormat",
|
|
24
|
+
"ProfileBusyError",
|
|
23
25
|
"ResponseParseError",
|
|
24
26
|
"ResponseTimeoutError",
|
|
25
27
|
"format_instruction",
|
|
26
28
|
"parse_output",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
|
-
__version__ = "0.2.
|
|
31
|
+
__version__ = "0.2.2"
|
|
@@ -16,15 +16,18 @@ if it were an API. The non-obvious parts (and why this exists):
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import fcntl
|
|
19
20
|
import logging
|
|
21
|
+
import time
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
20
23
|
from contextvars import ContextVar
|
|
21
24
|
from dataclasses import dataclass, field
|
|
22
25
|
from pathlib import Path
|
|
23
|
-
from typing import Any
|
|
26
|
+
from typing import Any, AsyncIterator
|
|
24
27
|
|
|
25
28
|
from playwright.async_api import Page, async_playwright
|
|
26
29
|
|
|
27
|
-
from .errors import InputError, ResponseTimeoutError
|
|
30
|
+
from .errors import InputError, ProfileBusyError, ResponseTimeoutError
|
|
28
31
|
from .formats import OutputFormat, format_instruction, parse_output
|
|
29
32
|
|
|
30
33
|
logger = logging.getLogger("gemx")
|
|
@@ -88,6 +91,17 @@ RESPONSE_SELECTORS = (
|
|
|
88
91
|
".model-response-text",
|
|
89
92
|
".markdown",
|
|
90
93
|
)
|
|
94
|
+
CANNED_REPLIES = (
|
|
95
|
+
"i'm having a hard time fulfilling your request",
|
|
96
|
+
"i seem to be encountering an error",
|
|
97
|
+
"i encountered an error doing what you asked",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_canned_reply(text: str) -> bool:
|
|
102
|
+
"""True when ``text`` is one of Gemini's stock refusal or error replies."""
|
|
103
|
+
lowered = text.lower().replace("’", "'")
|
|
104
|
+
return len(lowered) < 300 and any(phrase in lowered for phrase in CANNED_REPLIES)
|
|
91
105
|
|
|
92
106
|
_INSERT_TEXT_JS = """(text) => {
|
|
93
107
|
const editor = document.querySelector('.ql-editor[contenteditable="true"]');
|
|
@@ -273,6 +287,8 @@ class GemxConfig: # pylint: disable=too-many-instance-attributes
|
|
|
273
287
|
# How often (seconds) to emit page diagnostics while waiting for the response
|
|
274
288
|
# node to appear.
|
|
275
289
|
diagnostics_interval_s: int = 10
|
|
290
|
+
# How long to wait for another session to release the browser profile.
|
|
291
|
+
lock_timeout_s: int = 900
|
|
276
292
|
|
|
277
293
|
|
|
278
294
|
class Gemx:
|
|
@@ -316,24 +332,56 @@ class Gemx:
|
|
|
316
332
|
"""
|
|
317
333
|
token = _ACTIVE_LOG_LEVEL.set(self._log_level)
|
|
318
334
|
try:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
335
|
+
async with self._profile_lock():
|
|
336
|
+
attempts = max(0, self._config.max_retries) + 1
|
|
337
|
+
for attempt in range(1, attempts + 1):
|
|
338
|
+
try:
|
|
339
|
+
return await self._ask_raw_once(prompt, expected_format)
|
|
340
|
+
except ResponseTimeoutError:
|
|
341
|
+
if attempt >= attempts:
|
|
342
|
+
raise
|
|
343
|
+
logger.warning(
|
|
344
|
+
"Gemini attempt %d/%d failed; retrying",
|
|
345
|
+
attempt,
|
|
346
|
+
attempts,
|
|
347
|
+
exc_info=True,
|
|
348
|
+
)
|
|
332
349
|
finally:
|
|
333
350
|
_ACTIVE_LOG_LEVEL.reset(token)
|
|
334
351
|
|
|
335
352
|
raise ResponseTimeoutError("Gemini retry loop exited without a result")
|
|
336
353
|
|
|
354
|
+
@asynccontextmanager
|
|
355
|
+
async def _profile_lock(self) -> AsyncIterator[None]:
|
|
356
|
+
"""Hold the browser profile for one whole request, retries included.
|
|
357
|
+
|
|
358
|
+
Chromium refuses a persistent profile another process has open, so
|
|
359
|
+
every job sharing a profile takes turns through a lockfile beside it.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
ProfileBusyError: If the profile stayed held past ``lock_timeout_s``.
|
|
363
|
+
"""
|
|
364
|
+
profile = self._config.profile_dir.expanduser()
|
|
365
|
+
path = profile.with_name(f"{profile.name}.gemx.lock")
|
|
366
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
deadline = time.monotonic() + self._config.lock_timeout_s
|
|
368
|
+
with path.open("w") as handle:
|
|
369
|
+
while True:
|
|
370
|
+
try:
|
|
371
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
372
|
+
break
|
|
373
|
+
except BlockingIOError:
|
|
374
|
+
if time.monotonic() >= deadline:
|
|
375
|
+
raise ProfileBusyError(
|
|
376
|
+
f"profile {profile} still in use after "
|
|
377
|
+
f"{self._config.lock_timeout_s}s"
|
|
378
|
+
) from None
|
|
379
|
+
await asyncio.sleep(2)
|
|
380
|
+
try:
|
|
381
|
+
yield
|
|
382
|
+
finally:
|
|
383
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
384
|
+
|
|
337
385
|
async def _ask_raw_once(
|
|
338
386
|
self, prompt: str, expected_format: OutputFormat | None = None
|
|
339
387
|
) -> str:
|
|
@@ -525,6 +573,14 @@ class Gemx:
|
|
|
525
573
|
current_length,
|
|
526
574
|
len(best),
|
|
527
575
|
)
|
|
576
|
+
if (
|
|
577
|
+
stable >= cfg.stable_ticks
|
|
578
|
+
and expected_format in (OutputFormat.JSON, OutputFormat.XML)
|
|
579
|
+
and is_canned_reply(best)
|
|
580
|
+
):
|
|
581
|
+
raise ResponseTimeoutError(
|
|
582
|
+
f"Gemini returned a canned reply: {best[:200]!r}"
|
|
583
|
+
)
|
|
528
584
|
if stable >= cfg.stable_ticks and len(best) > cfg.min_response_chars:
|
|
529
585
|
if expected_format in (OutputFormat.JSON, OutputFormat.XML):
|
|
530
586
|
try:
|
|
@@ -17,3 +17,7 @@ class ResponseTimeoutError(GemxError):
|
|
|
17
17
|
|
|
18
18
|
class ResponseParseError(GemxError):
|
|
19
19
|
"""Gemini responded, but the payload could not be parsed for the format."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProfileBusyError(GemxError):
|
|
23
|
+
"""Another Gemx session held the browser profile past the lock timeout."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|