gemx 0.2.1__tar.gz → 0.2.3__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.3}/PKG-INFO +2 -2
- {gemx-0.2.1 → gemx-0.2.3}/pyproject.toml +1 -1
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/__init__.py +3 -1
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/client.py +92 -15
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/errors.py +4 -0
- {gemx-0.2.1 → gemx-0.2.3}/.gitignore +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/LICENSE +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/README.md +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/cli.py +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/formats.py +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/src/gemx/py.typed +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/__init__.py +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/scripts/TEST_GEMINI.md +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/scripts/gemini_console.js +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/scripts/gemini_console_prompt.js +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/test_cli.py +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/tests/test_client.py +0 -0
- {gemx-0.2.1 → gemx-0.2.3}/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.3
|
|
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.3"
|
|
@@ -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,18 @@ 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
|
+
"something went wrong. please try your request again",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_canned_reply(text: str) -> bool:
|
|
103
|
+
"""True when ``text`` is one of Gemini's stock refusal or error replies."""
|
|
104
|
+
lowered = text.lower().replace("’", "'")
|
|
105
|
+
return len(lowered) < 300 and any(phrase in lowered for phrase in CANNED_REPLIES)
|
|
91
106
|
|
|
92
107
|
_INSERT_TEXT_JS = """(text) => {
|
|
93
108
|
const editor = document.querySelector('.ql-editor[contenteditable="true"]');
|
|
@@ -166,6 +181,20 @@ _LONGEST_RESPONSE_JS = """(args) => {
|
|
|
166
181
|
return { text: best, selector, length: best.length };
|
|
167
182
|
}"""
|
|
168
183
|
|
|
184
|
+
_LIVE_RESPONSES_JS = """(args) => {
|
|
185
|
+
const [selectors, initialCounts] = args;
|
|
186
|
+
const texts = [];
|
|
187
|
+
selectors.forEach(sel => {
|
|
188
|
+
const els = Array.from(document.querySelectorAll(sel));
|
|
189
|
+
const initial = initialCounts[sel] || 0;
|
|
190
|
+
els.slice(initial).forEach(el => {
|
|
191
|
+
const t = el.innerText || el.textContent || '';
|
|
192
|
+
if (t) texts.push(t);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return texts;
|
|
196
|
+
}"""
|
|
197
|
+
|
|
169
198
|
_PAGE_ELEMENTS_JS = """() => {
|
|
170
199
|
return {
|
|
171
200
|
hasTextarea: document.querySelector('textarea') !== null,
|
|
@@ -273,6 +302,8 @@ class GemxConfig: # pylint: disable=too-many-instance-attributes
|
|
|
273
302
|
# How often (seconds) to emit page diagnostics while waiting for the response
|
|
274
303
|
# node to appear.
|
|
275
304
|
diagnostics_interval_s: int = 10
|
|
305
|
+
# How long to wait for another session to release the browser profile.
|
|
306
|
+
lock_timeout_s: int = 900
|
|
276
307
|
|
|
277
308
|
|
|
278
309
|
class Gemx:
|
|
@@ -316,24 +347,56 @@ class Gemx:
|
|
|
316
347
|
"""
|
|
317
348
|
token = _ACTIVE_LOG_LEVEL.set(self._log_level)
|
|
318
349
|
try:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
350
|
+
async with self._profile_lock():
|
|
351
|
+
attempts = max(0, self._config.max_retries) + 1
|
|
352
|
+
for attempt in range(1, attempts + 1):
|
|
353
|
+
try:
|
|
354
|
+
return await self._ask_raw_once(prompt, expected_format)
|
|
355
|
+
except ResponseTimeoutError:
|
|
356
|
+
if attempt >= attempts:
|
|
357
|
+
raise
|
|
358
|
+
logger.warning(
|
|
359
|
+
"Gemini attempt %d/%d failed; retrying",
|
|
360
|
+
attempt,
|
|
361
|
+
attempts,
|
|
362
|
+
exc_info=True,
|
|
363
|
+
)
|
|
332
364
|
finally:
|
|
333
365
|
_ACTIVE_LOG_LEVEL.reset(token)
|
|
334
366
|
|
|
335
367
|
raise ResponseTimeoutError("Gemini retry loop exited without a result")
|
|
336
368
|
|
|
369
|
+
@asynccontextmanager
|
|
370
|
+
async def _profile_lock(self) -> AsyncIterator[None]:
|
|
371
|
+
"""Hold the browser profile for one whole request, retries included.
|
|
372
|
+
|
|
373
|
+
Chromium refuses a persistent profile another process has open, so
|
|
374
|
+
every job sharing a profile takes turns through a lockfile beside it.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
ProfileBusyError: If the profile stayed held past ``lock_timeout_s``.
|
|
378
|
+
"""
|
|
379
|
+
profile = self._config.profile_dir.expanduser()
|
|
380
|
+
path = profile.with_name(f"{profile.name}.gemx.lock")
|
|
381
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
deadline = time.monotonic() + self._config.lock_timeout_s
|
|
383
|
+
with path.open("w") as handle:
|
|
384
|
+
while True:
|
|
385
|
+
try:
|
|
386
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
387
|
+
break
|
|
388
|
+
except BlockingIOError:
|
|
389
|
+
if time.monotonic() >= deadline:
|
|
390
|
+
raise ProfileBusyError(
|
|
391
|
+
f"profile {profile} still in use after "
|
|
392
|
+
f"{self._config.lock_timeout_s}s"
|
|
393
|
+
) from None
|
|
394
|
+
await asyncio.sleep(2)
|
|
395
|
+
try:
|
|
396
|
+
yield
|
|
397
|
+
finally:
|
|
398
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
399
|
+
|
|
337
400
|
async def _ask_raw_once(
|
|
338
401
|
self, prompt: str, expected_format: OutputFormat | None = None
|
|
339
402
|
) -> str:
|
|
@@ -525,6 +588,20 @@ class Gemx:
|
|
|
525
588
|
current_length,
|
|
526
589
|
len(best),
|
|
527
590
|
)
|
|
591
|
+
if stable >= cfg.stable_ticks and expected_format in (
|
|
592
|
+
OutputFormat.JSON,
|
|
593
|
+
OutputFormat.XML,
|
|
594
|
+
):
|
|
595
|
+
live = await page.evaluate(
|
|
596
|
+
_LIVE_RESPONSES_JS, [list(RESPONSE_SELECTORS), initial_counts]
|
|
597
|
+
)
|
|
598
|
+
canned = next(
|
|
599
|
+
(t for t in [best, *live] if is_canned_reply(t)), None
|
|
600
|
+
)
|
|
601
|
+
if canned is not None:
|
|
602
|
+
raise ResponseTimeoutError(
|
|
603
|
+
f"Gemini returned a canned reply: {canned[:200]!r}"
|
|
604
|
+
)
|
|
528
605
|
if stable >= cfg.stable_ticks and len(best) > cfg.min_response_chars:
|
|
529
606
|
if expected_format in (OutputFormat.JSON, OutputFormat.XML):
|
|
530
607
|
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
|