gemx 0.2.0__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.0 → gemx-0.2.2}/PKG-INFO +2 -2
- {gemx-0.2.0 → gemx-0.2.2}/pyproject.toml +1 -1
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/__init__.py +3 -1
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/client.py +71 -15
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/errors.py +4 -0
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/formats.py +43 -4
- {gemx-0.2.0 → gemx-0.2.2}/.gitignore +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/LICENSE +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/README.md +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/cli.py +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/src/gemx/py.typed +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/__init__.py +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/scripts/TEST_GEMINI.md +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/scripts/gemini_console.js +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/scripts/gemini_console_prompt.js +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/test_cli.py +0 -0
- {gemx-0.2.0 → gemx-0.2.2}/tests/test_client.py +0 -0
- {gemx-0.2.0 → 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."""
|
|
@@ -82,6 +82,42 @@ def _clean_json_text(text: str) -> str:
|
|
|
82
82
|
return text
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def _first_json_object(text: str) -> str | None:
|
|
86
|
+
"""The first complete top-level ``{...}`` in ``text``, or None.
|
|
87
|
+
|
|
88
|
+
``rfind("}")`` walks past the object into whatever Gemini wrote after it,
|
|
89
|
+
and on a reply that is still streaming it lands on a nested brace, so the
|
|
90
|
+
slice it hands back is not the object at all. Depth counting stops at the
|
|
91
|
+
real end; strings and their escapes are skipped so a brace inside a value
|
|
92
|
+
does not move the depth.
|
|
93
|
+
"""
|
|
94
|
+
start = text.find("{")
|
|
95
|
+
if start == -1:
|
|
96
|
+
return None
|
|
97
|
+
depth = 0
|
|
98
|
+
in_string = False
|
|
99
|
+
escaped = False
|
|
100
|
+
for index in range(start, len(text)):
|
|
101
|
+
char = text[index]
|
|
102
|
+
if in_string:
|
|
103
|
+
if escaped:
|
|
104
|
+
escaped = False
|
|
105
|
+
elif char == "\\":
|
|
106
|
+
escaped = True
|
|
107
|
+
elif char == '"':
|
|
108
|
+
in_string = False
|
|
109
|
+
continue
|
|
110
|
+
if char == '"':
|
|
111
|
+
in_string = True
|
|
112
|
+
elif char == "{":
|
|
113
|
+
depth += 1
|
|
114
|
+
elif char == "}":
|
|
115
|
+
depth -= 1
|
|
116
|
+
if depth == 0:
|
|
117
|
+
return text[start : index + 1]
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
85
121
|
def parse_output(text: str, fmt: OutputFormat) -> Any:
|
|
86
122
|
"""Extract a structured value from Gemini's raw reply for ``fmt``.
|
|
87
123
|
|
|
@@ -97,11 +133,14 @@ def parse_output(text: str, fmt: OutputFormat) -> Any:
|
|
|
97
133
|
return cleaned
|
|
98
134
|
|
|
99
135
|
if fmt is OutputFormat.JSON:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
candidate
|
|
136
|
+
repaired = _clean_json_text(cleaned)
|
|
137
|
+
candidate = _first_json_object(repaired)
|
|
138
|
+
if candidate is None:
|
|
139
|
+
start = repaired.find("{")
|
|
140
|
+
end = repaired.rfind("}") + 1
|
|
141
|
+
candidate = repaired[start:end] if start != -1 and end != 0 else repaired
|
|
103
142
|
try:
|
|
104
|
-
return json.loads(
|
|
143
|
+
return json.loads(candidate)
|
|
105
144
|
except json.JSONDecodeError as exc:
|
|
106
145
|
raise ValueError(f"Could not parse JSON from response: {exc}") from exc
|
|
107
146
|
|
|
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
|