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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: gemx
3
- Version: 0.2.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gemx"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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.0"
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
- 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
- )
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
- start = cleaned.find("{")
101
- end = cleaned.rfind("}") + 1
102
- candidate = cleaned[start:end] if start != -1 and end != 0 else cleaned
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(_clean_json_text(candidate))
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