repld-tool 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.
Files changed (63) hide show
  1. {repld_tool-0.2.0 → repld_tool-0.2.2}/PKG-INFO +1 -1
  2. {repld_tool-0.2.0 → repld_tool-0.2.2}/pyproject.toml +1 -1
  3. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/__init__.py +3 -1
  4. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/bind.py +44 -1
  5. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/help.py +60 -4
  6. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/kernel.py +57 -11
  7. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/runtime.py +11 -3
  8. {repld_tool-0.2.0 → repld_tool-0.2.2}/LICENSE +0 -0
  9. {repld_tool-0.2.0 → repld_tool-0.2.2}/README.md +0 -0
  10. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/.ruff_cache/.gitignore +0 -0
  11. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
  12. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
  13. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/__main__.py +0 -0
  14. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/bg.py +0 -0
  15. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/bridge.py +0 -0
  16. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/bridge_tools.py +0 -0
  17. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/__init__.py +0 -0
  18. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/browser.py +0 -0
  19. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/capture.py +0 -0
  20. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/cdp.py +0 -0
  21. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/har.py +0 -0
  22. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/observe.py +0 -0
  23. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/pin.py +0 -0
  24. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/png.py +0 -0
  25. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/pool.py +0 -0
  26. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/row.py +0 -0
  27. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/selector.py +0 -0
  28. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/session.py +0 -0
  29. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/tab.py +0 -0
  30. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/tab_query.py +0 -0
  31. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser/target.py +0 -0
  32. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/browser_dispatch.py +0 -0
  33. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/channel.py +0 -0
  34. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/cli.py +0 -0
  35. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/cli_args.py +0 -0
  36. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/core_schemas.py +0 -0
  37. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/dashboard.py +0 -0
  38. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/dashboard_cmd.py +0 -0
  39. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/dashboard_html.py +0 -0
  40. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/display.py +0 -0
  41. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/eventlog.py +0 -0
  42. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/events.py +0 -0
  43. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/exec_cmd.py +0 -0
  44. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gate_cmd.py +0 -0
  45. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gates.py +0 -0
  46. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gist_api.py +0 -0
  47. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gist_cmd.py +0 -0
  48. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gist_deps.py +0 -0
  49. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gist_links.py +0 -0
  50. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gist_lint.py +0 -0
  51. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/gists.py +0 -0
  52. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/ipc.py +0 -0
  53. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/kernel_context.py +0 -0
  54. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/lifecycle_cmd.py +0 -0
  55. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/log_cmd.py +0 -0
  56. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/paths.py +0 -0
  57. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/protocol.py +0 -0
  58. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/relaunch.py +0 -0
  59. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/render.py +0 -0
  60. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/sessions.py +0 -0
  61. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/spawn.py +0 -0
  62. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/state.py +0 -0
  63. {repld_tool-0.2.0 → repld_tool-0.2.2}/src/repld/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repld-tool
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package.
5
5
  Keywords: repl,mcp,agent,browser
6
6
  Author: Fredrik Angelsen
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repld-tool"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -11,7 +11,9 @@ if TYPE_CHECKING:
11
11
  from typing import Any, Callable, Coroutine
12
12
 
13
13
  def notify(content: Any, **meta: Any) -> None: ...
14
- def defer(coro: Coroutine, label: str | None = None) -> str: ...
14
+ def defer(
15
+ coro: Coroutine | Callable[[], Coroutine], label: str | None = None
16
+ ) -> str: ...
15
17
  def every(
16
18
  seconds: float, *, label: str | None = None, delay: float = 0.0
17
19
  ) -> Callable: ...
@@ -161,12 +161,50 @@ def adopt(venv: Path) -> Path | None:
161
161
  return sp
162
162
 
163
163
 
164
+ def has_browser_extra() -> bool:
165
+ """Whether *this* interpreter can import the browser stack.
166
+
167
+ All three are load-bearing and imported eagerly, so the question is not
168
+ "which one" but "are they all here" — `browser/png.py` pulls in PIL at
169
+ module scope, and `kernel._inject_builtins` reads any one of them missing
170
+ as the whole extra being absent.
171
+
172
+ ``find_spec`` rather than an import: this runs on every bound start, and
173
+ importing duckdb to find out whether we could is most of the cost the extra
174
+ exists to avoid paying.
175
+ """
176
+ from importlib.util import find_spec
177
+
178
+ for mod in ("duckdb", "websockets", "PIL"):
179
+ try:
180
+ if find_spec(mod) is None:
181
+ return False
182
+ except (ImportError, ValueError):
183
+ return False
184
+ return True
185
+
186
+
164
187
  def uv_run_argv(argv: list[str]) -> list[str] | None:
165
188
  """The ``uv run`` command that relaunches ``repld <argv>`` under the
166
189
  project's interpreter, or None if uv isn't on PATH.
167
190
 
168
191
  Reuses ``relaunch._editable_path()`` so a local checkout keeps winning over
169
192
  the published package, exactly as it does for ``repld browser``.
193
+
194
+ **The browser extra is carried across the re-exec, or the rebind silently
195
+ takes it away.** ``uv run --with`` builds a fresh environment from the spec
196
+ it is given; the interpreter we are leaving does not contribute to it. So a
197
+ repld installed *with* the extra — the tool venv carrying duckdb,
198
+ websockets and PIL — re-execs into an environment that has none of them,
199
+ and the kernel comes up with no ``browser`` builtin, no browser MCP tools,
200
+ and nothing anywhere saying why. The observable shape of that bug is the
201
+ tell: browser worked in every project *without* a ``./.venv`` and in none
202
+ of the projects with one, since only the latter rebind at all.
203
+
204
+ Conditional on what we already have rather than unconditional, so a repld
205
+ installed without the extra doesn't start resolving duckdb behind the
206
+ user's back. The rule is that the re-exec must not *lose* a capability, not
207
+ that it should acquire one.
170
208
  """
171
209
  import shutil
172
210
 
@@ -175,8 +213,13 @@ def uv_run_argv(argv: list[str]) -> list[str] | None:
175
213
  uv = shutil.which("uv")
176
214
  if uv is None:
177
215
  return None
216
+ extra = "[browser]" if has_browser_extra() else ""
178
217
  path = _editable_path()
179
- with_arg = ["--with-editable", path] if path else ["--with", "repld-tool"]
218
+ with_arg = (
219
+ ["--with-editable", f"{path}{extra}"]
220
+ if path
221
+ else ["--with", f"repld-tool{extra}"]
222
+ )
180
223
  return [uv, "run", *with_arg, "repld", *argv]
181
224
 
182
225
 
@@ -96,8 +96,6 @@ _PLAYBOOK = (
96
96
  "Read repld://docs/playbook for the full methodology."
97
97
  )
98
98
 
99
- _REFERENCE = "Reference: `repld help <topic>` — topics: exec, browser, gists, gates\nRead repld://docs/guide for exec patterns and gist conventions. Read repld://docs/browser for the full browser API and internals.\nRead repld://docs/production when graduating gists to FastMCP/FastAPI."
100
-
101
99
 
102
100
  # ---------------------------------------------------------------------------
103
101
  # PLAYBOOK (repld://docs/playbook resource — workflow methodology)
@@ -992,7 +990,9 @@ def static_instructions() -> str:
992
990
  depends on kernel state — the browser model, the gist listing, and
993
991
  registered gist tools.
994
992
  """
995
- return "\n\n".join([_EXEC_MODEL, _GISTS_MODEL, _deps_hint(), _PLAYBOOK, _REFERENCE])
993
+ return "\n\n".join(
994
+ [_EXEC_MODEL, _GISTS_MODEL, _deps_hint(), _PLAYBOOK, _reference()]
995
+ )
996
996
 
997
997
 
998
998
  def build_instructions() -> str:
@@ -1029,7 +1029,7 @@ def build_instructions() -> str:
1029
1029
 
1030
1030
  parts.append(_deps_hint())
1031
1031
  parts.append(_PLAYBOOK)
1032
- parts.append(_REFERENCE)
1032
+ parts.append(_reference())
1033
1033
  return "\n\n".join(parts)
1034
1034
 
1035
1035
 
@@ -1113,6 +1113,9 @@ no_display(value) → value
1113
1113
 
1114
1114
  defer(coro, label=None) → task_id
1115
1115
  Fire-and-forget. Channel push on done. Visible to get_task/cancel.
1116
+ coro can also be a zero-arg callable that builds the awaitable —
1117
+ defer(lambda: asyncio.gather(a(), b())) — so gather()/create_task()
1118
+ construct on the kernel's loop instead of a sync cell's own thread.
1116
1119
 
1117
1120
  every(seconds, label=, delay=0)(fn) → fn periodic ticker; fn.cancel() stops
1118
1121
  delay= defers the first tick (default: tick now)
@@ -1366,10 +1369,55 @@ Emits awaiting_human channel while blocked.
1366
1369
 
1367
1370
  notify(content, **meta)
1368
1371
  One-shot channel push to all MCP sessions.
1372
+ """,
1373
+ "migration": """\
1374
+ Why a repld project has no state files (0.1.x → 0.2).
1375
+
1376
+ repld 0.2 writes nothing into a project directory. Files an older setup
1377
+ created are absent on purpose — none of these are missing, they are gone:
1378
+
1379
+ .pyrepl.lock/.sock/.dashboard runtime state; now
1380
+ $XDG_RUNTIME_DIR/repld/projects/<slug>/ as
1381
+ kernel.lock/.sock/.dashboard. `repld status`
1382
+ prints the live paths.
1383
+ .mcp.json never written now. Register the server with
1384
+ `claude mcp add repld -- repld bridge`.
1385
+ CLAUDE.md repld:start block gone; that content is the MCP `initialize`
1386
+ instructions, composed fresh each session.
1387
+ repl.py + --init now ./repld_init.py, auto-detected and run by
1388
+ every kernel, whoever started it.
1389
+ __repld_tools__ removed in 0.2 and ignored, so a file still
1390
+ declaring one has silently lost its tools.
1391
+ `repld gist lint` is what reports it.
1392
+
1393
+ Still in the project and yours to commit: ./gists/, ./gists/.links, ./.env,
1394
+ ./repld_init.py.
1395
+
1396
+ If you FIND a .pyrepl.* file, it is 0.1.x leftover, not something this version
1397
+ made. .pyrepl.lock names a pid that may still be running — check it with
1398
+ `ps -p <pid> -o pid,command` before signalling, since a stale lockfile can name
1399
+ a reused pid. .pyrepl.dashboard holds a dead dashboard's API token; it was only
1400
+ gitignored where `repld init` ran, so check `git ls-files | grep pyrepl`.
1401
+ Cleanup: https://angelsen.github.io/repld/docs/guides/upgrading/
1369
1402
  """,
1370
1403
  }
1371
1404
 
1372
1405
 
1406
+ def _reference() -> str:
1407
+ """The always-loaded pointer block. Topic list derives from _TOPICS.
1408
+
1409
+ It was a literal listing four topics by hand, which is a second copy of
1410
+ `sorted(_TOPICS)` in the one string every session loads — so adding a topic
1411
+ silently published a list that omitted it.
1412
+ """
1413
+ return (
1414
+ f"Reference: `repld help <topic>` — topics: {', '.join(sorted(_TOPICS))}\n"
1415
+ "Read repld://docs/guide for exec patterns and gist conventions. "
1416
+ "Read repld://docs/browser for the full browser API and internals.\n"
1417
+ "Read repld://docs/production when graduating gists to FastMCP/FastAPI."
1418
+ )
1419
+
1420
+
1373
1421
  # `== Builtins ==` below recaps _EXEC_MODEL's builtins — keep in sync.
1374
1422
  GUIDE = """\
1375
1423
  repld — working guide
@@ -1421,6 +1469,14 @@ For intentionally long work, use defer():
1421
1469
 
1422
1470
  defer(download_all_invoices(), label="invoice sync")
1423
1471
 
1472
+ Fanning out with asyncio.gather()/create_task() inside defer() needs one
1473
+ more step: those reach for a running loop when *built*, not just when
1474
+ awaited, and a sync cell (no top-level await) runs in a worker thread with
1475
+ no loop of its own. Pass defer() a zero-arg callable instead of a
1476
+ pre-built awaitable, so the gather() call happens on the kernel's loop:
1477
+
1478
+ defer(lambda: asyncio.gather(one("a"), one("b"), return_exceptions=True))
1479
+
1424
1480
  This returns the task_id immediately. The channel notification arrives
1425
1481
  when the coroutine completes (or fails).
1426
1482
 
@@ -487,14 +487,29 @@ async def _run_cell(task_id: str, src: str, n: int, *, wait_ready: bool = True)
487
487
  task["exception"] = type(exc).__name__
488
488
 
489
489
 
490
- async def _run_deferred(task_id: str, coro) -> None:
491
- """Await a user-supplied coroutine within the task lifecycle.
492
-
493
- Like _run_cell but skips compile/eval — just awaits the coroutine directly.
490
+ async def _run_deferred(task_id: str, thunk) -> None:
491
+ """Build and await a user-supplied awaitable within the task lifecycle.
492
+
493
+ `thunk` is always a zero-arg callable — defer() wraps a coroutine object
494
+ passed directly as ``lambda: coro``, so this only has one code path.
495
+ Calling it here, inside a coroutine already scheduled on the kernel's
496
+ loop (`asyncio.run_coroutine_threadsafe`), is what lets a factory like
497
+ ``lambda: asyncio.gather(...)`` build its awaitable with a loop actually
498
+ running: `asyncio.gather()`/`create_task()`/`ensure_future()` all reach
499
+ for the *current thread's* loop at construction time, not just to await,
500
+ and a sync cell's body runs off-loop (`asyncio.to_thread`) to keep the
501
+ kernel responsive — so building one directly in the cell raises "no
502
+ current event loop" before defer() ever gets a look at it.
494
503
  """
495
504
  async with _task_scope(task_id) as task:
496
505
  try:
497
- await coro
506
+ awaitable = thunk()
507
+ if not inspect.isawaitable(awaitable):
508
+ raise TypeError(
509
+ "defer() factory must return an awaitable, got "
510
+ f"{type(awaitable).__name__}"
511
+ )
512
+ await awaitable
498
513
  except asyncio.CancelledError:
499
514
  task["exception"] = "CancelledError"
500
515
  except BaseException as exc:
@@ -508,13 +523,32 @@ def _make_defer(loop: asyncio.AbstractEventLoop):
508
523
  def defer(coro, label: str | None = None) -> str:
509
524
  """Schedule a coroutine as a tracked task. Returns task_id immediately.
510
525
 
526
+ `coro` is normally a coroutine object (`defer(my_async_fn())`). It
527
+ may also be a zero-arg callable that *builds* one —
528
+ `defer(lambda: asyncio.gather(one(), two()))` — for constructs that
529
+ need a running loop to construct, not just to await. A sync cell's
530
+ body runs in a worker thread with no event loop set, so calling
531
+ `asyncio.gather(...)` there eagerly (as a `defer()` argument) fails
532
+ before `defer()` is even entered; wrapping it in a zero-arg callable
533
+ defers the call itself onto the kernel's loop, where it belongs.
534
+
511
535
  The task is visible to get_task and cancel. On completion, a task_done
512
536
  channel notification is pushed.
513
537
  """
514
- if not inspect.iscoroutine(coro):
538
+ if inspect.iscoroutine(coro):
539
+ thunk = lambda c=coro: c # noqa: E731
540
+ elif inspect.iscoroutinefunction(coro):
515
541
  raise TypeError(
516
- f"defer() expects a coroutine object, got {type(coro).__name__}. "
517
- "Call it as: defer(my_async_fn())"
542
+ f"defer() expects a coroutine object, got the function "
543
+ f"{coro.__name__!r} itself. Call it as: defer({coro.__name__}())"
544
+ )
545
+ elif callable(coro):
546
+ thunk = coro
547
+ else:
548
+ raise TypeError(
549
+ "defer() expects a coroutine object (or a zero-arg callable "
550
+ f"that builds one), got {type(coro).__name__}. Call it as: "
551
+ "defer(my_async_fn()) or defer(lambda: asyncio.gather(...))"
518
552
  )
519
553
  # Inherit the calling cell's originating session so a background task
520
554
  # reports back to whoever asked for it. defer() from an @every body or
@@ -527,7 +561,7 @@ def _make_defer(loop: asyncio.AbstractEventLoop):
527
561
  task["label"] = label
528
562
  src_label = label or "..."
529
563
  events.emit(CellStart(task_id, f"defer({src_label})", time.time()))
530
- asyncio.run_coroutine_threadsafe(_run_deferred(task_id, coro), loop)
564
+ asyncio.run_coroutine_threadsafe(_run_deferred(task_id, thunk), loop)
531
565
  return task_id
532
566
 
533
567
  return defer
@@ -1017,8 +1051,20 @@ def _inject_builtins(loop: asyncio.AbstractEventLoop) -> None:
1017
1051
  # themselves up anyway — the pill's staleness check removes it, and the
1018
1052
  # beforeunload guard with it, once Python stops heartbeating, which is
1019
1053
  # exactly what a stopped loop looks like from the page.
1020
- except ImportError:
1021
- pass # repld[browser] not installed — no browser builtin
1054
+ except ImportError as e:
1055
+ # Not an error — most projects never install the extra. But swallowing
1056
+ # it silently means the failure surfaces much later and somewhere else:
1057
+ # `NameError: name 'browser' is not defined` in a cell, or 21 tools
1058
+ # simply absent from tools/list. Both read as "this kernel has no
1059
+ # browser" and neither says *why*, which sends the reader looking at
1060
+ # the browser stack instead of at the environment. This is the only
1061
+ # place that knows which module was missing, so it says so.
1062
+ print(
1063
+ f"repld: browser disabled — cannot import {e.name or e}. "
1064
+ "Install the `browser` extra, or run `repld browser` instead of "
1065
+ "`repld`.",
1066
+ file=sys.stderr,
1067
+ )
1022
1068
 
1023
1069
 
1024
1070
  def _start_services(
@@ -221,8 +221,16 @@ def _format_user_traceback(exc: BaseException) -> str:
221
221
  )
222
222
  elif "no current event loop" in msg or "no running event loop" in msg:
223
223
  formatted += (
224
- "\nHint: this cell ran in a background thread "
225
- "(no 'await' detected). Use defer() to schedule async work:\n"
226
- " defer(some_coroutine())\n"
224
+ "\nHint: this cell ran in a background thread (no top-level "
225
+ "'await' in the cell), so asyncio.gather()/create_task()/"
226
+ "ensure_future() had no loop to attach to — they reach for "
227
+ "one at construction, not just to await it. Build it inside "
228
+ "an async def instead:\n"
229
+ " async def _run():\n"
230
+ " return await asyncio.gather(a(), b())\n"
231
+ " await _run()\n"
232
+ "or, if deferring it, pass defer() a zero-arg callable so it "
233
+ "builds on the kernel's loop rather than in this thread:\n"
234
+ " defer(lambda: asyncio.gather(a(), b()))\n"
227
235
  )
228
236
  return formatted
File without changes
File without changes
File without changes
File without changes
File without changes