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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: gemx
3
- Version: 0.2.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gemx"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Drive the Gemini web UI from Python via Playwright"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.1"
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
- attempts = max(0, self._config.max_retries) + 1
320
- for attempt in range(1, attempts + 1):
321
- try:
322
- return await self._ask_raw_once(prompt, expected_format)
323
- except ResponseTimeoutError:
324
- if attempt >= attempts:
325
- raise
326
- logger.warning(
327
- "Gemini attempt %d/%d failed; retrying",
328
- attempt,
329
- attempts,
330
- exc_info=True,
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