batchgrid 0.2.0b1__tar.gz → 0.2.0b3__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
1
  Metadata-Version: 2.5
2
2
  Name: batchgrid
3
- Version: 0.2.0b1
3
+ Version: 0.2.0b3
4
4
  Summary: Run a prompt, or a whole pipeline, over every row of a DataFrame or spreadsheet - with cost estimates, retries and resumable runs.
5
5
  Project-URL: Homepage, https://github.com/mertguvencli/batchgrid
6
6
  Project-URL: Source, https://github.com/mertguvencli/batchgrid/tree/main/python
@@ -83,7 +83,8 @@ The file is the same one `batchgrid --save-plan` writes and `batchgrid --plan` r
83
83
 
84
84
  ## In the browser
85
85
 
86
- `batchgrid.ui()` opens the batchgrid workspace in your browser with your data in the sheet. You can
86
+ `batchgrid.ui()` opens the batchgrid workspace with your data in the sheet. In a Jupyter notebook it
87
+ shows in the cell's output; elsewhere, or with `inline=False`, it opens in your browser. You can
87
88
  chat, check the plan and run it there, while the log prints in the notebook cell or terminal as it
88
89
  happens:
89
90
 
@@ -105,14 +106,16 @@ batchgrid: 100%|██████████| 1000/1000 [00:42<00:00, failed=0
105
106
  Finished in the browser.
106
107
  ```
107
108
 
108
- To come back, press **Finish** in the tab, close the tab, or interrupt the cell. `ui()` returns the
109
+ To come back, press **Finish** in the workspace, close its tab, or interrupt the cell. Once it stops,
110
+ a note takes the workspace's place in the cell. `ui()` returns the
109
111
  sheet as the tab last showed it. For a DataFrame, that is a copy of yours with the new columns and
110
112
  any cells you edited; untouched columns keep their dtypes. `ui()` with no data starts on an empty
111
113
  sheet.
112
114
 
113
- The workspace runs on your machine and uses the keys from `batchgrid config` or your environment. Your
114
- browser has to be able to reach the machine running Python, so this does not work for a notebook on
115
- a remote server.
115
+ The workspace runs on the machine running Python and uses the keys from `batchgrid config` or the
116
+ environment there. Your browser has to be able to reach that machine. On Google Colab it reaches it
117
+ through Colab's own proxy, so `ui()` works there too; a notebook on another remote server is out of
118
+ reach.
116
119
 
117
120
  From a terminal, the `batchgrid` command this package installs does the same:
118
121
 
@@ -163,9 +166,10 @@ except KeyboardInterrupt:
163
166
  result = batchgrid.resume(df)
164
167
  ```
165
168
 
166
- ### `batchgrid.ui(data=None, *, open_browser=True, port=None, log=True) -> DataFrame | None`
169
+ ### `batchgrid.ui(data=None, *, inline=None, height=720, open_browser=True, port=None, log=True) -> DataFrame | None`
167
170
 
168
- See [In the browser](#in-the-browser). `on_event` receives the same events as in `run()`, plus `ui`,
171
+ See [In the browser](#in-the-browser). `inline` shows the workspace in the cell, `height` pixels tall;
172
+ by default it does in a Jupyter notebook and opens a browser tab anywhere else. `on_event` receives the same events as in `run()`, plus `ui`,
169
173
  `sheet`, `browser_error` and `closed`.
170
174
 
171
175
  ### Errors
@@ -191,7 +195,7 @@ for the CLI in this order:
191
195
  1. the `cli=[...]` argument
192
196
  2. the `BATCHGRID_CLI` environment variable, e.g. `node /path/to/cli/dist/index.js`
193
197
  3. the npm `batchgrid` on the PATH (never this package's own command)
194
- 4. `npx "batchgrid@>=0.3.0 <1"`
198
+ 4. `npx "batchgrid@>=0.3.1 <1"`
195
199
 
196
200
  ## Development
197
201
 
@@ -57,7 +57,8 @@ The file is the same one `batchgrid --save-plan` writes and `batchgrid --plan` r
57
57
 
58
58
  ## In the browser
59
59
 
60
- `batchgrid.ui()` opens the batchgrid workspace in your browser with your data in the sheet. You can
60
+ `batchgrid.ui()` opens the batchgrid workspace with your data in the sheet. In a Jupyter notebook it
61
+ shows in the cell's output; elsewhere, or with `inline=False`, it opens in your browser. You can
61
62
  chat, check the plan and run it there, while the log prints in the notebook cell or terminal as it
62
63
  happens:
63
64
 
@@ -79,14 +80,16 @@ batchgrid: 100%|██████████| 1000/1000 [00:42<00:00, failed=0
79
80
  Finished in the browser.
80
81
  ```
81
82
 
82
- To come back, press **Finish** in the tab, close the tab, or interrupt the cell. `ui()` returns the
83
+ To come back, press **Finish** in the workspace, close its tab, or interrupt the cell. Once it stops,
84
+ a note takes the workspace's place in the cell. `ui()` returns the
83
85
  sheet as the tab last showed it. For a DataFrame, that is a copy of yours with the new columns and
84
86
  any cells you edited; untouched columns keep their dtypes. `ui()` with no data starts on an empty
85
87
  sheet.
86
88
 
87
- The workspace runs on your machine and uses the keys from `batchgrid config` or your environment. Your
88
- browser has to be able to reach the machine running Python, so this does not work for a notebook on
89
- a remote server.
89
+ The workspace runs on the machine running Python and uses the keys from `batchgrid config` or the
90
+ environment there. Your browser has to be able to reach that machine. On Google Colab it reaches it
91
+ through Colab's own proxy, so `ui()` works there too; a notebook on another remote server is out of
92
+ reach.
90
93
 
91
94
  From a terminal, the `batchgrid` command this package installs does the same:
92
95
 
@@ -137,9 +140,10 @@ except KeyboardInterrupt:
137
140
  result = batchgrid.resume(df)
138
141
  ```
139
142
 
140
- ### `batchgrid.ui(data=None, *, open_browser=True, port=None, log=True) -> DataFrame | None`
143
+ ### `batchgrid.ui(data=None, *, inline=None, height=720, open_browser=True, port=None, log=True) -> DataFrame | None`
141
144
 
142
- See [In the browser](#in-the-browser). `on_event` receives the same events as in `run()`, plus `ui`,
145
+ See [In the browser](#in-the-browser). `inline` shows the workspace in the cell, `height` pixels tall;
146
+ by default it does in a Jupyter notebook and opens a browser tab anywhere else. `on_event` receives the same events as in `run()`, plus `ui`,
143
147
  `sheet`, `browser_error` and `closed`.
144
148
 
145
149
  ### Errors
@@ -165,7 +169,7 @@ for the CLI in this order:
165
169
  1. the `cli=[...]` argument
166
170
  2. the `BATCHGRID_CLI` environment variable, e.g. `node /path/to/cli/dist/index.js`
167
171
  3. the npm `batchgrid` on the PATH (never this package's own command)
168
- 4. `npx "batchgrid@>=0.3.0 <1"`
172
+ 4. `npx "batchgrid@>=0.3.1 <1"`
169
173
 
170
174
  ## Development
171
175
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "batchgrid"
7
- version = "0.2.0b1"
7
+ version = "0.2.0b3"
8
8
  description = "Run a prompt, or a whole pipeline, over every row of a DataFrame or spreadsheet - with cost estimates, retries and resumable runs."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,7 +16,7 @@ from ._ui import ui
16
16
  from ._cli import CLI_SPEC, find_cli
17
17
  from .errors import BatchgridError, CostLimitError, MissingSecretsError, NoPlanError
18
18
 
19
- __version__ = "0.2.0b1"
19
+ __version__ = "0.2.0b3"
20
20
 
21
21
  __all__ = [
22
22
  "BatchgridError",
@@ -19,8 +19,8 @@ import threading
19
19
  from collections import deque
20
20
  from typing import Callable, Deque, Dict, Iterator, List, Mapping, Optional, Sequence
21
21
 
22
- #: The CLI releases this package speaks to: 0.3.0 was the first with ``ui``, and its events only gain fields
23
- CLI_SPEC = "batchgrid@>=0.3.0 <1"
22
+ #: The CLI releases this package speaks to: 0.3.0 was the first with ``ui``, 0.3.1 the first behind Colab's proxy, and its events only gain fields
23
+ CLI_SPEC = "batchgrid@>=0.3.1 <1"
24
24
 
25
25
  Event = Dict[str, object]
26
26
 
@@ -1,11 +1,13 @@
1
- """ui() - the web workspace in the browser, with its log here and its sheet coming back."""
1
+ """ui() - the web workspace in the notebook or the browser, with its log here and its sheet coming back."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import socket
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING, Any, Callable, List, Mapping, Optional, Sequence
10
+ from urllib.parse import urlsplit, urlunsplit
9
11
 
10
12
  from ._api import Data, EventHandler, _input_file, _is_dataframe, _merge_output, _TempDir
11
13
  from ._cli import Event, find_cli, stream_events
@@ -18,6 +20,8 @@ if TYPE_CHECKING:
18
20
  def ui(
19
21
  data: Optional[Data] = None,
20
22
  *,
23
+ inline: Optional[bool] = None,
24
+ height: int = 720,
21
25
  open_browser: bool = True,
22
26
  port: Optional[int] = None,
23
27
  log: bool = True,
@@ -25,28 +29,41 @@ def ui(
25
29
  cli: Optional[Sequence[str]] = None,
26
30
  env: Optional[Mapping[str, str]] = None,
27
31
  ) -> Optional["pd.DataFrame"]:
28
- """Open the batchgrid workspace in the browser and wait until you are done there.
32
+ """Open the batchgrid workspace and wait until you are done there.
29
33
 
30
- ``data`` - a file path or a pandas DataFrame - opens in the sheet. Chat,
31
- plan and run in the browser; what happens there is printed here as it
32
- happens, with a progress bar while rows run.
34
+ ``data`` - a file path or a pandas DataFrame - opens in the sheet. In a
35
+ Jupyter notebook the workspace shows in the cell's output, ``height``
36
+ pixels tall; ``inline=False`` opens it in a browser tab instead, which is
37
+ what happens outside a notebook. Chat, plan and run there; what happens is
38
+ printed here as it happens, with a progress bar while rows run.
33
39
 
34
- Press Finish in the tab, close it, or interrupt the cell (Ctrl+C) to come
35
- back. The sheet as the tab last showed it is returned as a DataFrame: for
40
+ Press Finish in the workspace, close its tab, or interrupt the cell
41
+ (Ctrl+C) to come back. The sheet as the tab last showed it is returned as a DataFrame: for
36
42
  a DataFrame you passed in, a copy with the new columns added and your
37
43
  edits in it. None when nothing was opened, or pandas is not installed.
38
44
 
39
- It runs on this machine, so it needs a browser that can reach it - not a
40
- notebook running on a remote server.
45
+ It runs where Python runs, so the browser has to reach that machine. On
46
+ Colab it does through Colab's own proxy; a notebook on another remote
47
+ server is out of reach.
41
48
  """
42
- printer = _Printer() if log else None
49
+ embed = _in_notebook() if inline is None else inline
50
+ colab = _in_colab()
51
+ frame_view = _Frame(height) if embed else None
52
+ printer = _Printer(link=not embed) if log else None
43
53
  with _input_file(data) as (path, frame):
44
54
  out = _TempDir()
45
55
  try:
46
56
  sheet = os.path.join(out.get(), "sheet.csv")
47
57
  args: List[str] = ["ui", *([path] if path else []), "--output", sheet, "--json"]
48
- if not open_browser:
58
+ # On Colab the browser that would open is on Google's machine, not yours
59
+ if embed or colab or not open_browser:
49
60
  args.append("--no-open")
61
+ proxy: Optional[str] = None
62
+ if colab:
63
+ # The proxy's address names the port, so the port is settled before the server starts
64
+ port = port if port is not None else _free_port()
65
+ proxy = _colab_proxy(port)
66
+ args += ["--allow-host", str(urlsplit(proxy).hostname)]
50
67
  if port is not None:
51
68
  args += ["--port", str(port)]
52
69
 
@@ -59,6 +76,10 @@ def ui(
59
76
  failure = event
60
77
  elif event.get("type") == "ui":
61
78
  started = True
79
+ if proxy is not None:
80
+ event = {**event, "url": _through(proxy, str(event.get("url")))}
81
+ if frame_view is not None:
82
+ frame_view.show(str(event.get("url")))
62
83
  elif event.get("type") == "exit":
63
84
  ended = event
64
85
  if printer is not None:
@@ -71,6 +92,8 @@ def ui(
71
92
  finally:
72
93
  if printer is not None:
73
94
  printer.close()
95
+ if frame_view is not None:
96
+ frame_view.close()
74
97
  if failure is not None:
75
98
  raise BatchgridError(str(failure.get("code")), str(failure.get("message")))
76
99
  if not started and ended is not None and ended.get("code"):
@@ -110,11 +133,81 @@ def _read_sheet(frame: Any, sheet: Path, sent: Optional[Path]) -> Optional["pd.D
110
133
  return merged
111
134
 
112
135
 
136
+ def _in_colab() -> bool:
137
+ """Whether this is a Colab kernel, on a machine the browser reaches only through Colab's proxy"""
138
+ return "google.colab" in sys.modules
139
+
140
+
141
+ def _colab_proxy(port: int) -> str:
142
+ """The address Colab's proxy gives a port on the kernel's machine"""
143
+ from google.colab.output import eval_js # type: ignore[import-not-found]
144
+
145
+ return str(eval_js(f"google.colab.kernel.proxyPort({port}, {{cache: false}})"))
146
+
147
+
148
+ def _through(proxy: str, url: str) -> str:
149
+ """The server's link, with the proxy's address in place of 127.0.0.1"""
150
+ base, own = urlsplit(proxy), urlsplit(url)
151
+ query = "&".join(q for q in (base.query, own.query) if q)
152
+ return urlunsplit((base.scheme, base.netloc, own.path or "/", query, ""))
153
+
154
+
155
+ def _free_port() -> int:
156
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
157
+ probe.bind(("127.0.0.1", 0))
158
+ return int(probe.getsockname()[1])
159
+
160
+
161
+ def _in_notebook() -> bool:
162
+ """Whether this runs in a Jupyter kernel, whose front end can show the workspace in a cell"""
163
+ try:
164
+ from IPython import get_ipython
165
+
166
+ shell = get_ipython()
167
+ return shell is not None and "IPKernelApp" in shell.config
168
+ except Exception: # noqa: BLE001 - no IPython means a plain console
169
+ return False
170
+
171
+
172
+ class _Frame:
173
+ """The workspace in the cell's output; once it stops, a note takes its place"""
174
+
175
+ def __init__(self, height: int) -> None:
176
+ self.height = height
177
+ self.handle: Any = None
178
+
179
+ def show(self, url: str) -> None:
180
+ from IPython.display import IFrame, display
181
+
182
+ self.handle = display(
183
+ IFrame(
184
+ url,
185
+ width="100%",
186
+ height=self.height,
187
+ extras=[
188
+ 'style="border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 6px"',
189
+ 'allow="clipboard-read; clipboard-write"',
190
+ ],
191
+ ),
192
+ display_id=True,
193
+ )
194
+
195
+ def close(self) -> None:
196
+ if self.handle is None:
197
+ return
198
+ from IPython.display import HTML
199
+
200
+ # The server behind the frame is gone, so a saved notebook keeps no dead workspace
201
+ self.handle.update(HTML("<em>batchgrid closed. Run the cell again to reopen it.</em>"))
202
+ self.handle = None
203
+
204
+
113
205
  class _Printer:
114
206
  """The terminal's log, in a notebook cell or a Python console"""
115
207
 
116
- def __init__(self) -> None:
208
+ def __init__(self, link: bool = True) -> None:
117
209
  self.bar: Any = None
210
+ self.link = link
118
211
  try:
119
212
  from tqdm.auto import tqdm
120
213
 
@@ -130,7 +223,7 @@ class _Printer:
130
223
  # Anything else ends the run's bar, so its lines are not drawn over it
131
224
  if kind in ("result", "closed", "user", "plan"):
132
225
  self.close()
133
- if kind == "ui":
226
+ if kind == "ui" and self.link:
134
227
  self._link(str(event.get("url")))
135
228
  elif kind == "user":
136
229
  self._line(f"\n❯ {event.get('text')}")
@@ -155,7 +248,7 @@ class _Printer:
155
248
  elif kind == "error":
156
249
  self._line(f"✖ {event.get('message')}")
157
250
  elif kind == "closed":
158
- why = {"tab_closed": "The tab was closed", "finished": "Finished in the browser"}.get(
251
+ why = {"tab_closed": "The tab was closed", "finished": "Finished in the browser" if self.link else "Finished"}.get(
159
252
  str(event.get("reason")), "Stopped"
160
253
  )
161
254
  self._line(f"\n{why}.")
@@ -173,15 +266,11 @@ class _Printer:
173
266
  self.bar.refresh()
174
267
 
175
268
  def _link(self, url: str) -> None:
176
- try:
177
- from IPython import get_ipython
269
+ if _in_notebook():
178
270
  from IPython.display import HTML, display
179
271
 
180
- if get_ipython() is not None and "IPKernelApp" in get_ipython().config:
181
- display(HTML(f'batchgrid is open at <a href="{url}" target="_blank">{url.split("?")[0]}</a>'))
182
- return
183
- except Exception: # noqa: BLE001 - no IPython means a plain console
184
- pass
272
+ display(HTML(f'batchgrid is open at <a href="{url}" target="_blank">{url.split("?")[0]}</a>'))
273
+ return
185
274
  self._line(f"batchgrid is open at {url}")
186
275
 
187
276
  def _line(self, text: str) -> None:
@@ -14,6 +14,7 @@ import stat
14
14
  import sys
15
15
  import threading
16
16
  import time
17
+ import types
17
18
  import urllib.request
18
19
  from pathlib import Path
19
20
  from typing import Any, Dict, List
@@ -129,6 +130,83 @@ def test_interrupting_the_cell_returns_instead_of_raising() -> None:
129
130
  assert result.loc[0, "sentiment"] == "positive"
130
131
 
131
132
 
133
+ def test_in_a_notebook_the_workspace_shows_in_the_cell(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
134
+ shown: List[Any] = []
135
+
136
+ class Handle:
137
+ def update(self, obj: Any) -> None:
138
+ shown.append(obj)
139
+
140
+ def display(obj: Any, display_id: bool = False) -> Handle:
141
+ shown.append(obj)
142
+ return Handle()
143
+
144
+ # A stand-in for the IPython a Jupyter kernel runs
145
+ ipython = types.ModuleType("IPython")
146
+ ipython.get_ipython = lambda: types.SimpleNamespace(config={"IPKernelApp": {}}) # type: ignore[attr-defined]
147
+ ipython_display = types.ModuleType("IPython.display")
148
+ ipython_display.HTML = lambda html: html # type: ignore[attr-defined]
149
+ ipython_display.IFrame = lambda src, **options: {"src": src, **options} # type: ignore[attr-defined]
150
+ ipython_display.display = display # type: ignore[attr-defined]
151
+ monkeypatch.setitem(sys.modules, "IPython", ipython)
152
+ monkeypatch.setitem(sys.modules, "IPython.display", ipython_display)
153
+
154
+ urls: List[str] = []
155
+
156
+ def act(tab: FakeTab) -> None:
157
+ urls.append(tab.url)
158
+ tab.call("finish", {})
159
+
160
+ # No open_browser=False: showing it in the cell never opens a tab
161
+ batchgrid.ui(on_event=open_tab(act))
162
+
163
+ assert len(shown) == 2
164
+ assert shown[0]["src"] == urls[0] and shown[0]["height"] == 720
165
+ # The server behind it stopped, so a note takes the workspace's place
166
+ assert "batchgrid closed" in shown[1]
167
+ printed = capsys.readouterr().out
168
+ assert "is open at" not in printed
169
+ assert "Finished." in printed
170
+
171
+
172
+ def test_on_colab_the_workspace_goes_through_its_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
173
+ asked: List[str] = []
174
+
175
+ def eval_js(script: str) -> str:
176
+ asked.append(script)
177
+ port = script.split("(")[1].split(",")[0]
178
+ return f"https://{port}-abc-colab.googleusercontent.com/?authuser=0"
179
+
180
+ # A stand-in for the module a Colab kernel has loaded
181
+ colab = types.ModuleType("google.colab")
182
+ colab_output = types.ModuleType("google.colab.output")
183
+ colab_output.eval_js = eval_js # type: ignore[attr-defined]
184
+ monkeypatch.setitem(sys.modules, "google.colab", colab)
185
+ monkeypatch.setitem(sys.modules, "google.colab.output", colab_output)
186
+
187
+ seen: Dict[str, Any] = {}
188
+
189
+ def on_event(event: Dict[str, Any]) -> None:
190
+ if event["type"] != "ui":
191
+ return
192
+ seen["url"] = event["url"]
193
+ port = asked[0].split("(")[1].split(",")[0]
194
+ token = event["url"].split("token=")[1]
195
+ # What Colab's proxy sends on: the request addressed to its own host name
196
+ page = urllib.request.Request(
197
+ f"http://127.0.0.1:{port}/?token={token}", headers={"Host": f"{port}-abc-colab.googleusercontent.com"}
198
+ )
199
+ with urllib.request.urlopen(page, timeout=10) as response:
200
+ seen["status"] = response.status
201
+ tab = FakeTab(f"http://127.0.0.1:{port}/?token={token}")
202
+ threading.Thread(target=tab.call, args=("finish", {}), daemon=True).start()
203
+
204
+ batchgrid.ui(inline=False, log=False, on_event=on_event)
205
+
206
+ assert seen["url"].startswith("https://") and "-abc-colab.googleusercontent.com/?authuser=0&token=" in seen["url"]
207
+ assert seen["status"] == 200
208
+
209
+
132
210
  def test_nothing_synced_returns_none() -> None:
133
211
  result = batchgrid.ui(open_browser=False, log=False, on_event=open_tab(lambda tab: tab.call("finish", {})))
134
212
  assert result is None
File without changes