batchgrid 0.1.0b1__py3-none-any.whl

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.
batchgrid/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """batchgrid - run a prompt, or a whole pipeline, over every row of a DataFrame or spreadsheet.
2
+
3
+ import batchgrid
4
+
5
+ result = batchgrid.run(df, "classify the sentiment of each review", max_cost=5)
6
+ result.data # df with the new columns
7
+
8
+ The work is done by the batchgrid CLI (Node.js 22+), which this package starts
9
+ and follows; see find_cli() for how it is located.
10
+ """
11
+
12
+ from ._api import Plan, Result, plan, resume, run
13
+ from ._cli import CLI_SPEC, find_cli
14
+ from .errors import BatchgridError, CostLimitError, MissingSecretsError, NoPlanError
15
+
16
+ __version__ = "0.1.0b1"
17
+
18
+ __all__ = [
19
+ "BatchgridError",
20
+ "CLI_SPEC",
21
+ "CostLimitError",
22
+ "MissingSecretsError",
23
+ "NoPlanError",
24
+ "Plan",
25
+ "Result",
26
+ "find_cli",
27
+ "plan",
28
+ "resume",
29
+ "run",
30
+ ]
batchgrid/_api.py ADDED
@@ -0,0 +1,457 @@
1
+ """plan(), run() and resume() - the Python face of the batchgrid CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union
11
+
12
+ from ._cli import Event, find_cli, stream_events
13
+ from .errors import BatchgridError, CostLimitError, MissingSecretsError, NoPlanError
14
+
15
+ if TYPE_CHECKING:
16
+ import pandas as pd
17
+
18
+ PathLike = Union[str, "os.PathLike[str]"]
19
+ #: A file path, or a pandas DataFrame
20
+ Data = Union[PathLike, "pd.DataFrame"]
21
+ EventHandler = Callable[[Event], None]
22
+
23
+
24
+ @dataclass
25
+ class Plan:
26
+ """A workflow the planner wrote, with what it will cost.
27
+
28
+ Save it and pass it to ``run(plan=...)`` to repeat the exact same steps
29
+ later - running a saved plan does not ask the planner again.
30
+ """
31
+
32
+ steps: Dict[str, Any]
33
+ """The plan itself, as the engine reads it."""
34
+ summary: str = ""
35
+ """One readable paragraph: the steps and the columns they write."""
36
+ cost_usd: Optional[float] = None
37
+ """Estimated cost of the run in US dollars, or None when the model has no price list."""
38
+ cost_formatted: str = ""
39
+ rows: int = 0
40
+ """How many rows the run will process."""
41
+ missing_secrets: List[str] = field(default_factory=list)
42
+ """Service keys the plan needs that are not saved yet."""
43
+ cost: Dict[str, Any] = field(default_factory=dict)
44
+ """The full estimate: input and output tokens, model, pricing availability."""
45
+
46
+ @property
47
+ def title(self) -> str:
48
+ return str(self.steps.get("title") or self.steps.get("intent") or "")
49
+
50
+ def save(self, path: PathLike) -> Path:
51
+ """Write the plan as JSON - the same file ``batchgrid --plan`` reads."""
52
+ target = Path(path)
53
+ target.write_text(json.dumps(self.steps, indent=2) + "\n", encoding="utf-8")
54
+ return target
55
+
56
+ @classmethod
57
+ def load(cls, path: PathLike) -> "Plan":
58
+ """Read a plan saved with ``save()`` or ``batchgrid --save-plan``."""
59
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
60
+ # A saved workflow exported from the app wraps the plan: { name, plan }
61
+ if isinstance(data, dict) and "steps" not in data and isinstance(data.get("plan"), dict):
62
+ data = data["plan"]
63
+ return cls(steps=data)
64
+
65
+ @classmethod
66
+ def _from_event(cls, event: Event) -> "Plan":
67
+ cost = event.get("cost") or {}
68
+ assert isinstance(cost, dict)
69
+ priced = bool(cost.get("pricingAvailable"))
70
+ return cls(
71
+ steps=event["plan"], # type: ignore[arg-type]
72
+ summary=str(event.get("summary") or ""),
73
+ cost_usd=float(cost["estimatedCostUsd"]) if priced and "estimatedCostUsd" in cost else None,
74
+ cost_formatted=str(event.get("costFormatted") or ""),
75
+ rows=int(event.get("rows") or 0), # type: ignore[arg-type]
76
+ missing_secrets=list(event.get("missingSecrets") or []), # type: ignore[arg-type]
77
+ cost=cost,
78
+ )
79
+
80
+ def __str__(self) -> str:
81
+ parts = [self.summary or self.title]
82
+ if self.cost_formatted:
83
+ parts.append(self.cost_formatted)
84
+ return "\n\n".join(parts)
85
+
86
+ def _repr_markdown_(self) -> str:
87
+ cost = self.cost_formatted or "no estimate"
88
+ return f"**{self.title}** · {self.rows:,} rows · {cost}\n\n```\n{self.summary}\n```"
89
+
90
+
91
+ @dataclass
92
+ class Result:
93
+ """How a run ended."""
94
+
95
+ status: str
96
+ """``done``, or ``stopped`` when it was interrupted - resume() picks it up."""
97
+ total: int
98
+ success: int
99
+ failed: int
100
+ """Rows that failed after their retries; ``error_log_path`` has the reasons."""
101
+ input_tokens: int
102
+ output_tokens: int
103
+ duration_ms: int
104
+ output_path: Optional[Path]
105
+ """The file the run wrote: the input with the new columns, or a zip when it produced files."""
106
+ error_log_path: Optional[Path]
107
+ data: Optional["pd.DataFrame"] = None
108
+ """The result as a DataFrame, when the input was one."""
109
+
110
+ @classmethod
111
+ def _from_event(cls, event: Event) -> "Result":
112
+ tokens = event.get("tokens") or {}
113
+ assert isinstance(tokens, dict)
114
+ output = event.get("outputPath")
115
+ errors = event.get("errorLogPath")
116
+ return cls(
117
+ status=str(event.get("status")),
118
+ total=int(event.get("total") or 0), # type: ignore[arg-type]
119
+ success=int(event.get("success") or 0), # type: ignore[arg-type]
120
+ failed=int(event.get("failed") or 0), # type: ignore[arg-type]
121
+ input_tokens=int(tokens.get("input") or 0),
122
+ output_tokens=int(tokens.get("output") or 0),
123
+ duration_ms=int(event.get("durationMs") or 0), # type: ignore[arg-type]
124
+ output_path=Path(str(output)) if output else None,
125
+ error_log_path=Path(str(errors)) if errors else None,
126
+ )
127
+
128
+
129
+ def plan(
130
+ data: Optional[Data],
131
+ prompt: str,
132
+ *,
133
+ model: Optional[str] = None,
134
+ rows: Optional[int] = None,
135
+ on_event: Optional[EventHandler] = None,
136
+ cli: Optional[Sequence[str]] = None,
137
+ env: Optional[Mapping[str, str]] = None,
138
+ ) -> Plan:
139
+ """Ask the planner for a workflow and price it, without running anything.
140
+
141
+ ``data`` is a CSV/TSV/Excel path or a pandas DataFrame, or None when the
142
+ request brings its own rows ("find 50 AI startups in Berlin").
143
+ ``model`` is a model name or ``provider:model``, e.g.
144
+ ``"anthropic:claude-sonnet-5"``; the default is the one saved in
145
+ ``batchgrid config``.
146
+ """
147
+ with _input_file(data) as (path, _):
148
+ events = _run_cli(
149
+ [*_input_args(path), "--prompt", prompt, *_common_args(model, rows, None)],
150
+ on_event,
151
+ cli,
152
+ env,
153
+ )
154
+ return _expect_plan(events)
155
+
156
+
157
+ def run(
158
+ data: Optional[Data],
159
+ prompt: Optional[str] = None,
160
+ *,
161
+ plan: Union[Plan, Mapping[str, Any], PathLike, None] = None,
162
+ model: Optional[str] = None,
163
+ rows: Optional[int] = None,
164
+ concurrency: Optional[int] = None,
165
+ output: Optional[PathLike] = None,
166
+ max_cost: Optional[float] = None,
167
+ progress: bool = True,
168
+ on_event: Optional[EventHandler] = None,
169
+ cli: Optional[Sequence[str]] = None,
170
+ env: Optional[Mapping[str, str]] = None,
171
+ ) -> Result:
172
+ """Plan a request and run it over every row - or run a plan you already have.
173
+
174
+ Pass ``prompt`` to have the planner write the workflow, or ``plan`` (a
175
+ ``Plan``, its dict, or a saved JSON file) to repeat one exactly. With
176
+ ``max_cost``, the plan is priced first and nothing runs when the estimate
177
+ is higher - or when the model has no price list to estimate with.
178
+
179
+ For a DataFrame, the result's ``data`` is that DataFrame with the new
180
+ columns added. For a file, the output is written next to it (or to
181
+ ``output``) and ``output_path`` says where.
182
+
183
+ Ctrl+C stops the run and keeps the rows that finished; ``resume()`` with
184
+ the same data carries on from there.
185
+ """
186
+ if (prompt is None) == (plan is None):
187
+ raise ValueError("Pass either a prompt or a plan.")
188
+
189
+ with _input_file(data) as (path, frame):
190
+ if max_cost is not None:
191
+ # Price it on this data first, without running; the run then repeats this exact plan
192
+ with _plan_file(plan) as plan_path:
193
+ request = ["--plan", plan_path] if plan_path else ["--prompt", prompt or ""]
194
+ priced = _expect_plan(
195
+ _run_cli([*_input_args(path), *request, *_common_args(model, rows, None)], on_event, cli, env)
196
+ )
197
+ _check_cost(priced, max_cost)
198
+ plan = priced
199
+
200
+ with _plan_file(plan) as plan_path:
201
+ request = ["--plan", plan_path] if plan_path else ["--prompt", prompt or ""]
202
+ args = [*_input_args(path), *request, "--yes", *_common_args(model, rows, concurrency)]
203
+ out = output if output is not None else (_frame_output(path) if frame is not None else None)
204
+ if out is not None:
205
+ args += ["--output", os.fspath(out)]
206
+ result = _expect_result(_run_cli(args, on_event, cli, env, progress=progress))
207
+ if frame is not None:
208
+ result.data = _merge_output(frame, result.output_path)
209
+ return result
210
+
211
+
212
+ def resume(
213
+ data: Data,
214
+ *,
215
+ output: Optional[PathLike] = None,
216
+ progress: bool = True,
217
+ on_event: Optional[EventHandler] = None,
218
+ cli: Optional[Sequence[str]] = None,
219
+ env: Optional[Mapping[str, str]] = None,
220
+ ) -> Result:
221
+ """Finish a run that was stopped, on the same file or DataFrame.
222
+
223
+ Only the rows that had not finished are sent to the model.
224
+ """
225
+ with _input_file(data) as (path, frame):
226
+ out = output if output is not None else (_frame_output(path) if frame is not None else None)
227
+ args = [*_input_args(path), "--resume"]
228
+ if out is not None:
229
+ args += ["--output", os.fspath(out)]
230
+ result = _expect_result(_run_cli(args, on_event, cli, env, progress=progress))
231
+ if frame is not None:
232
+ result.data = _merge_output(frame, result.output_path)
233
+ return result
234
+
235
+
236
+ # ── Running the CLI ────────────────────────────────────────────────────
237
+
238
+
239
+ def _run_cli(
240
+ args: List[str],
241
+ on_event: Optional[EventHandler],
242
+ cli: Optional[Sequence[str]],
243
+ env: Optional[Mapping[str, str]],
244
+ progress: bool = False,
245
+ ) -> List[Event]:
246
+ bar = _ProgressBar() if progress else None
247
+ events: List[Event] = []
248
+ try:
249
+ for event in stream_events(find_cli(cli), [*args, "--json"], env):
250
+ events.append(event)
251
+ if bar is not None and event.get("type") == "progress":
252
+ bar.update(event)
253
+ if on_event is not None and event.get("type") != "exit":
254
+ on_event(event)
255
+ finally:
256
+ if bar is not None:
257
+ bar.close()
258
+ return events
259
+
260
+
261
+ def _check_cost(p: Plan, max_cost: float) -> None:
262
+ if p.cost_usd is None:
263
+ # A plan without model calls costs nothing; one without a price list cannot be held to a limit
264
+ if p.cost and not p.cost.get("pricingAvailable", True):
265
+ raise CostLimitError(
266
+ f"There is no price list for {p.cost.get('model')}, so the run cannot be held to max_cost={max_cost}.",
267
+ p,
268
+ )
269
+ return
270
+ if p.cost_usd > max_cost:
271
+ raise CostLimitError(
272
+ f"The run is estimated at ${p.cost_usd:.4f}, above max_cost=${max_cost:.4f}. Nothing was run.",
273
+ p,
274
+ )
275
+
276
+
277
+ def _failure(events: List[Event]) -> BatchgridError:
278
+ """The error the CLI reported - or, without one, what it printed on stderr"""
279
+ exit_event = events[-1] if events and events[-1].get("type") == "exit" else {}
280
+ stderr = str(exit_event.get("stderr") or "")
281
+ error = next((e for e in reversed(events) if e.get("type") == "error"), None)
282
+ if error is None:
283
+ hint = " The installed batchgrid CLI is too old - update it." if "unknown option" in stderr else ""
284
+ return BatchgridError("cli_failed", f"The batchgrid CLI stopped unexpectedly.{hint}\n{stderr}".rstrip(), stderr)
285
+
286
+ code, message = str(error.get("code")), str(error.get("message"))
287
+ if code == "no_plan":
288
+ reply = next((str(e["text"]) for e in reversed(events) if e.get("type") == "message"), None)
289
+ return NoPlanError(message, reply)
290
+ if code == "missing_secrets":
291
+ planned = next((e for e in events if e.get("type") == "plan"), {})
292
+ return MissingSecretsError(message, list(planned.get("missingSecrets") or [])) # type: ignore[arg-type]
293
+ return BatchgridError(code, message, stderr)
294
+
295
+
296
+ def _expect_plan(events: List[Event]) -> Plan:
297
+ planned = next((e for e in events if e.get("type") == "plan"), None)
298
+ if planned is None or any(e.get("type") == "error" for e in events):
299
+ raise _failure(events)
300
+ return Plan._from_event(planned)
301
+
302
+
303
+ def _expect_result(events: List[Event]) -> Result:
304
+ result = next((e for e in reversed(events) if e.get("type") == "result"), None)
305
+ if result is None or result.get("status") == "error":
306
+ raise _failure(events)
307
+ parsed = Result._from_event(result)
308
+ if any(e.get("type") == "error" for e in events) and parsed.status == "done":
309
+ # The rows ran, but something after them failed - writing the output
310
+ raise _failure(events)
311
+ return parsed
312
+
313
+
314
+ def _input_args(path: Optional[str]) -> List[str]:
315
+ return [path] if path else []
316
+
317
+
318
+ def _common_args(model: Optional[str], rows: Optional[int], concurrency: Optional[int]) -> List[str]:
319
+ args: List[str] = []
320
+ if model:
321
+ args += ["--model", model]
322
+ if rows is not None:
323
+ args += ["--rows", str(rows)]
324
+ if concurrency is not None:
325
+ args += ["--concurrency", str(concurrency)]
326
+ return args
327
+
328
+
329
+ # ── Files in and out ───────────────────────────────────────────────────
330
+
331
+
332
+ class _TempDir:
333
+ """One scratch directory per call, removed on exit"""
334
+
335
+ def __init__(self) -> None:
336
+ self.path: Optional[str] = None
337
+
338
+ def get(self) -> str:
339
+ if self.path is None:
340
+ self.path = tempfile.mkdtemp(prefix="batchgrid-")
341
+ return self.path
342
+
343
+ def cleanup(self) -> None:
344
+ if self.path is not None:
345
+ import shutil
346
+
347
+ shutil.rmtree(self.path, ignore_errors=True)
348
+
349
+
350
+ class _input_file:
351
+ """The data as a path the CLI can open: a DataFrame is written to a CSV first"""
352
+
353
+ def __init__(self, data: Optional[Data]) -> None:
354
+ self.data = data
355
+ self.tmp = _TempDir()
356
+
357
+ def __enter__(self) -> Tuple[Optional[str], Optional["pd.DataFrame"]]:
358
+ if self.data is None:
359
+ return None, None
360
+ if _is_dataframe(self.data):
361
+ frame = self.data
362
+ path = os.path.join(self.tmp.get(), "data.csv")
363
+ # The same DataFrame writes the same bytes, so its checkpoint is found again on resume()
364
+ frame.rename(columns=str).to_csv(path, index=False) # type: ignore[union-attr]
365
+ return path, frame # type: ignore[return-value]
366
+ path = os.fspath(self.data) # type: ignore[arg-type]
367
+ if not os.path.exists(path):
368
+ raise FileNotFoundError(path)
369
+ return os.path.abspath(path), None
370
+
371
+ def __exit__(self, *exc: object) -> None:
372
+ self.tmp.cleanup()
373
+
374
+
375
+ class _plan_file:
376
+ """A plan as a JSON file for --plan, or None when the planner writes one"""
377
+
378
+ def __init__(self, value: Union[Plan, Mapping[str, Any], PathLike, None]) -> None:
379
+ self.value = value
380
+ self.tmp = _TempDir()
381
+
382
+ def __enter__(self) -> Optional[str]:
383
+ if self.value is None:
384
+ return None
385
+ if isinstance(self.value, Plan):
386
+ steps: Mapping[str, Any] = self.value.steps
387
+ elif isinstance(self.value, Mapping):
388
+ steps = self.value
389
+ else:
390
+ return os.path.abspath(os.fspath(self.value))
391
+ path = os.path.join(self.tmp.get(), "plan.json")
392
+ with open(path, "w", encoding="utf-8") as fh:
393
+ json.dump(steps, fh)
394
+ return path
395
+
396
+ def __exit__(self, *exc: object) -> None:
397
+ self.tmp.cleanup()
398
+
399
+
400
+ def _frame_output(input_path: Optional[str]) -> str:
401
+ # Next to the temp input, so it is removed with it once read back
402
+ return os.path.join(os.path.dirname(input_path or ""), "output.csv")
403
+
404
+
405
+ def _is_dataframe(value: object) -> bool:
406
+ return type(value).__module__.startswith("pandas") and type(value).__name__ == "DataFrame"
407
+
408
+
409
+ def _merge_output(frame: "pd.DataFrame", output_path: Optional[Path]) -> Optional["pd.DataFrame"]:
410
+ """The input DataFrame with the run's new columns added.
411
+
412
+ The original columns keep their dtypes and the index is kept. When the
413
+ run reshaped the sheet - filtered rows, dropped columns - the output is
414
+ returned as read instead.
415
+ """
416
+ if output_path is None or output_path.suffix.lower() != ".csv" or not output_path.exists():
417
+ return None
418
+ import pandas as pd
419
+
420
+ out = pd.read_csv(output_path, dtype=str, keep_default_na=False)
421
+ names = [str(c) for c in frame.columns]
422
+ if len(out) != len(frame) or not set(names).issubset(out.columns):
423
+ return out
424
+ merged = frame.copy()
425
+ for column in out.columns:
426
+ if column not in names:
427
+ merged[column] = out[column].to_numpy()
428
+ return merged
429
+
430
+
431
+ class _ProgressBar:
432
+ """A tqdm bar when tqdm is installed, otherwise nothing"""
433
+
434
+ def __init__(self) -> None:
435
+ self.bar: Any = None
436
+ try:
437
+ from tqdm.auto import tqdm
438
+
439
+ self._tqdm: Any = tqdm
440
+ except ImportError:
441
+ self._tqdm = None
442
+
443
+ def update(self, event: Event) -> None:
444
+ if self._tqdm is None:
445
+ return
446
+ total = int(event.get("total") or 0) # type: ignore[arg-type]
447
+ if self.bar is None:
448
+ self.bar = self._tqdm(total=total, unit="row", desc="batchgrid")
449
+ if total and self.bar.total != total:
450
+ self.bar.total = total
451
+ self.bar.n = int(event.get("completed") or 0) # type: ignore[arg-type]
452
+ self.bar.set_postfix(failed=int(event.get("failed") or 0), refresh=False) # type: ignore[arg-type]
453
+ self.bar.refresh()
454
+
455
+ def close(self) -> None:
456
+ if self.bar is not None:
457
+ self.bar.close()
batchgrid/_cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """Running the batchgrid CLI and reading the JSON events it prints.
2
+
3
+ The engine is the Node CLI; this module only starts it with ``--json`` and
4
+ turns its stdout into Python dicts. The event format is documented in the
5
+ CLI README under "JSON output".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shlex
13
+ import shutil
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from collections import deque
19
+ from typing import Callable, Deque, Dict, Iterator, List, Mapping, Optional, Sequence
20
+
21
+ #: The CLI releases this package speaks to: 0.2.0 was the first with ``--json``, and its events only gain fields
22
+ CLI_SPEC = "batchgrid@>=0.2.0 <1"
23
+
24
+ Event = Dict[str, object]
25
+
26
+
27
+ def find_cli(cli: Optional[Sequence[str]] = None) -> List[str]:
28
+ """The command that starts the CLI.
29
+
30
+ In order: the ``cli`` argument, the ``BATCHGRID_CLI`` environment variable
31
+ (a command line, e.g. ``node /path/to/dist/index.js``), a ``batchgrid`` on
32
+ the PATH, then ``npx`` fetching the matching release.
33
+ """
34
+ if cli:
35
+ return list(cli)
36
+ from_env = os.environ.get("BATCHGRID_CLI", "").strip()
37
+ if from_env:
38
+ return shlex.split(from_env, posix=os.name != "nt")
39
+ installed = shutil.which("batchgrid")
40
+ if installed:
41
+ return [installed]
42
+ npx = shutil.which("npx")
43
+ if npx:
44
+ return [npx, "--yes", CLI_SPEC]
45
+ from .errors import BatchgridError
46
+
47
+ raise BatchgridError(
48
+ "cli_not_found",
49
+ "The batchgrid CLI was not found. Install Node.js 22 or newer "
50
+ "(https://nodejs.org), or point BATCHGRID_CLI at a batchgrid command.",
51
+ )
52
+
53
+
54
+ def stream_events(
55
+ command: Sequence[str],
56
+ args: Sequence[str],
57
+ env: Optional[Mapping[str, str]] = None,
58
+ ) -> Iterator[Event]:
59
+ """Run the CLI and yield each JSON event as it is printed.
60
+
61
+ The last event is always a synthetic ``{"type": "exit", "code": ...,
62
+ "stderr": ...}``, so the caller sees how the process ended.
63
+
64
+ Ctrl+C (KeyboardInterrupt, also Jupyter's interrupt) is passed on to the
65
+ CLI as one SIGINT: the run stops, keeps its finished rows in the
66
+ checkpoint, and reports a ``stopped`` result before the interrupt is
67
+ raised again here.
68
+ """
69
+ full_env = {**os.environ, **(env or {}), "FORCE_COLOR": "0", "NO_COLOR": "1"}
70
+ popen_kwargs: Dict[str, object] = {}
71
+ if os.name == "nt":
72
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
73
+ else:
74
+ # Its own session, so a terminal's Ctrl+C reaches the CLI only through
75
+ # us - two SIGINTs would make it quit without saving
76
+ popen_kwargs["start_new_session"] = True
77
+
78
+ proc = subprocess.Popen(
79
+ [*command, *args],
80
+ stdin=subprocess.DEVNULL,
81
+ stdout=subprocess.PIPE,
82
+ stderr=subprocess.PIPE,
83
+ env=full_env,
84
+ text=True,
85
+ encoding="utf-8",
86
+ bufsize=1,
87
+ **popen_kwargs, # type: ignore[arg-type]
88
+ )
89
+
90
+ # stderr is drained on its own thread so a chatty run cannot fill the pipe
91
+ stderr_tail: Deque[str] = deque(maxlen=40)
92
+ drain = threading.Thread(target=_drain, args=(proc.stderr, stderr_tail.append), daemon=True)
93
+ drain.start()
94
+
95
+ interrupted = False
96
+ try:
97
+ assert proc.stdout is not None
98
+ while True:
99
+ try:
100
+ line = proc.stdout.readline()
101
+ except KeyboardInterrupt:
102
+ if interrupted:
103
+ proc.kill()
104
+ raise
105
+ interrupted = True
106
+ _interrupt(proc)
107
+ continue
108
+ if not line:
109
+ break
110
+ line = line.strip()
111
+ if not line:
112
+ continue
113
+ try:
114
+ event = json.loads(line)
115
+ except ValueError:
116
+ # Not an event - an old CLI without --json, or stray output
117
+ stderr_tail.append(line)
118
+ continue
119
+ if isinstance(event, dict):
120
+ yield event
121
+ code = proc.wait()
122
+ drain.join(timeout=2)
123
+ yield {"type": "exit", "code": code, "stderr": "\n".join(stderr_tail)}
124
+ finally:
125
+ if proc.poll() is None:
126
+ proc.kill()
127
+ proc.wait()
128
+
129
+ if interrupted:
130
+ raise KeyboardInterrupt
131
+
132
+
133
+ def _drain(pipe: Optional[object], sink: Callable[[str], None]) -> None:
134
+ if pipe is None:
135
+ return
136
+ for line in pipe: # type: ignore[attr-defined]
137
+ sink(line.rstrip("\n"))
138
+
139
+
140
+ def _interrupt(proc: "subprocess.Popen[str]") -> None:
141
+ if os.name == "nt":
142
+ # Windows has no SIGINT for another process group; CTRL_BREAK stops it at once
143
+ proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
144
+ else:
145
+ proc.send_signal(signal.SIGINT)
146
+ print("batchgrid: stopping the run and keeping finished rows…", file=sys.stderr)
batchgrid/errors.py ADDED
@@ -0,0 +1,55 @@
1
+ """Exceptions raised by batchgrid. Every one carries the CLI's error code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+
8
+ class BatchgridError(Exception):
9
+ """A plan or a run could not go ahead.
10
+
11
+ ``code`` is the error code the CLI reported (``no_api_key``,
12
+ ``invalid_plan``, ``run_failed``...), or ``cli_failed`` when the CLI
13
+ stopped without saying why - ``stderr`` then holds its last output.
14
+ """
15
+
16
+ def __init__(self, code: str, message: str, stderr: str = "") -> None:
17
+ super().__init__(message)
18
+ self.code = code
19
+ self.message = message
20
+ self.stderr = stderr
21
+
22
+
23
+ class NoPlanError(BatchgridError):
24
+ """The planner answered with a question instead of a plan.
25
+
26
+ ``reply`` is what it said - usually what it needs to know to make one.
27
+ """
28
+
29
+ def __init__(self, message: str, reply: Optional[str]) -> None:
30
+ super().__init__("no_plan", f"{message} The planner said: {reply}" if reply else message)
31
+ self.reply = reply
32
+
33
+
34
+ class MissingSecretsError(BatchgridError):
35
+ """The plan calls a service whose key is not saved.
36
+
37
+ Save each one with ``batchgrid config --set-secret <name>:<value>``, or
38
+ set it in the environment, and run again.
39
+ """
40
+
41
+ def __init__(self, message: str, names: List[str]) -> None:
42
+ super().__init__("missing_secrets", message)
43
+ self.names = names
44
+
45
+
46
+ class CostLimitError(BatchgridError):
47
+ """The estimated cost is above ``max_cost``, so nothing was run.
48
+
49
+ ``plan`` is the plan that was priced - inspect it, then run it with a
50
+ higher limit or on fewer rows.
51
+ """
52
+
53
+ def __init__(self, message: str, plan: object) -> None:
54
+ super().__init__("cost_limit", message)
55
+ self.plan = plan
batchgrid/py.typed ADDED
File without changes
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.5
2
+ Name: batchgrid
3
+ Version: 0.1.0b1
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
+ Project-URL: Homepage, https://github.com/mertguvencli/batchgrid
6
+ Project-URL: Source, https://github.com/mertguvencli/batchgrid/tree/main/python
7
+ Project-URL: Issues, https://github.com/mertguvencli/batchgrid/issues
8
+ Author: Mert Guvencli
9
+ License-Expression: MIT
10
+ Keywords: anthropic,batch,csv,dataframe,gemini,llm,openai,pandas
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: pandas>=1.5; extra == 'dev'
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Provides-Extra: pandas
22
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
23
+ Provides-Extra: progress
24
+ Requires-Dist: tqdm>=4.60; extra == 'progress'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # batchgrid for Python
28
+
29
+ Run a prompt, or a whole pipeline, over every row of a DataFrame or spreadsheet. You get a cost
30
+ estimate before anything runs, parallel calls with retries and rate-limit backoff, and runs that
31
+ resume after an interruption.
32
+
33
+ ```python
34
+ import pandas as pd
35
+ import batchgrid
36
+
37
+ df = pd.read_csv("reviews.csv")
38
+ result = batchgrid.run(df, "classify the sentiment and extract keywords", max_cost=5)
39
+ result.data # df with the new columns
40
+ ```
41
+
42
+ > **Beta.** This package drives the [batchgrid CLI](https://www.npmjs.com/package/batchgrid), so it
43
+ > needs **Node.js 22 or newer**. It uses a `batchgrid` on your PATH, or fetches the CLI with `npx`
44
+ > on first use.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install "batchgrid[pandas,progress]"
50
+ ```
51
+
52
+ `pandas` is needed for DataFrame input and `progress` adds a tqdm progress bar. File paths work
53
+ without either.
54
+
55
+ Set the key of the provider you use, either as an environment variable (`OPENAI_API_KEY`,
56
+ `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `XAI_API_KEY`) or once with
57
+ `npx batchgrid config --set-key openai:sk-…`.
58
+
59
+ ## Plan, check, run
60
+
61
+ Planning asks a model, so the same request can produce a slightly different plan each time. Look at
62
+ the plan and its cost first, then run it:
63
+
64
+ ```python
65
+ plan = batchgrid.plan(df, "classify the sentiment of each review")
66
+ print(plan) # the steps, the columns they write, the estimated cost
67
+ plan.cost_usd # 0.08
68
+
69
+ result = batchgrid.run(df, plan=plan)
70
+ ```
71
+
72
+ A pipeline that must behave the same on every run should save the plan once and run that file.
73
+ Running a saved plan does not ask the planner again:
74
+
75
+ ```python
76
+ plan.save("sentiment.json")
77
+
78
+ # later, in the pipeline
79
+ result = batchgrid.run("next_week.csv", plan="sentiment.json", max_cost=10)
80
+ ```
81
+
82
+ The file is the same one `batchgrid --save-plan` writes and `batchgrid --plan` reads.
83
+
84
+ ## Reference
85
+
86
+ ### `batchgrid.run(data, prompt=None, *, plan=None, ...) -> Result`
87
+
88
+ | Argument | Meaning |
89
+ |---|---|
90
+ | `data` | A CSV, TSV or Excel path, a pandas DataFrame, or `None` when the request brings its own rows |
91
+ | `prompt` | What to do, in plain language. Pass either this or `plan` |
92
+ | `plan` | A `Plan`, its dict, or a saved JSON file |
93
+ | `model` | `"gpt-5.6"` or `"provider:model"`, e.g. `"anthropic:claude-sonnet-5"`. The default is the saved choice |
94
+ | `rows` | Run only the first N rows, a cheap way to try a plan |
95
+ | `concurrency` | The most requests running at once |
96
+ | `output` | Where to write the result. The extension picks the format |
97
+ | `max_cost` | In US dollars. The plan is priced first and nothing runs above it, or when the model has no price list |
98
+ | `progress` | Show a progress bar when tqdm is installed. Defaults to on |
99
+ | `on_event` | Called with each event the CLI reports: plan, progress, result… |
100
+
101
+ `Result` has `status` (`done` or `stopped`), `total`, `success`, `failed`, `input_tokens`,
102
+ `output_tokens`, `duration_ms`, `output_path`, `error_log_path`, and `data`. For DataFrame input,
103
+ `data` is a copy of your DataFrame with the new columns added, with its index and dtypes kept. If a
104
+ plan filters rows or drops columns, `data` holds the output as it was written.
105
+
106
+ Rows that still fail after their retries do not raise an error. Check `result.failed`, and look in
107
+ `result.error_log_path` for the reasons.
108
+
109
+ ### `batchgrid.plan(data, prompt, *, model=None, rows=None) -> Plan`
110
+
111
+ Plans the request and prices it without running anything. `Plan` has `title`, `summary`,
112
+ `cost_usd`, `cost_formatted`, `rows`, `missing_secrets`, the raw `steps`, and `save(path)` /
113
+ `Plan.load(path)`.
114
+
115
+ ### `batchgrid.resume(data) -> Result`
116
+
117
+ Ctrl+C (or a Jupyter interrupt) stops a run and keeps the rows that finished. Call `resume` with the
118
+ same file or DataFrame to finish it. Only the unfinished rows are sent to the model.
119
+
120
+ ```python
121
+ try:
122
+ result = batchgrid.run(df, plan="sentiment.json")
123
+ except KeyboardInterrupt:
124
+ result = batchgrid.resume(df)
125
+ ```
126
+
127
+ ### Errors
128
+
129
+ Everything raises `batchgrid.BatchgridError`, and its `code` says what went wrong:
130
+
131
+ - `no_api_key`: no provider key is set.
132
+ - `invalid_plan`: the plan does not fit the data.
133
+ - `no_plan`: the planner asked a question instead of returning a plan. This raises `NoPlanError`,
134
+ and its `reply` holds the question.
135
+ - `missing_secrets`: the plan needs keys that are not saved. This raises `MissingSecretsError`,
136
+ and its `names` lists them.
137
+ - `cost_limit`: the run would go over `max_cost`. This raises `CostLimitError`, and its `plan`
138
+ holds the priced plan.
139
+ - `run_failed`: the run itself failed.
140
+
141
+ ### Choosing the CLI
142
+
143
+ The package looks for the CLI in this order:
144
+
145
+ 1. the `cli=[...]` argument
146
+ 2. the `BATCHGRID_CLI` environment variable, e.g. `node /path/to/cli/dist/index.js`
147
+ 3. a `batchgrid` on the PATH
148
+ 4. `npx "batchgrid@>=0.2.0 <1"`
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ pnpm --filter batchgrid build # the tests drive the real CLI
154
+ cd python
155
+ uv venv && uv pip install -e ".[dev,progress]"
156
+ .venv/bin/python -m pytest
157
+ ```
158
+
159
+ The tests replace only the model: a local server stands in for the OpenAI API.
@@ -0,0 +1,8 @@
1
+ batchgrid/__init__.py,sha256=R_5Ckff_R3mp2iy4x0u6NxW-I6ZHN6Qyx8xK_LLb8b4,779
2
+ batchgrid/_api.py,sha256=Q56_C66-WHrezUSwSp9Lc25DTy4ygco30Aq8u2Yo-tA,17558
3
+ batchgrid/_cli.py,sha256=Db7IcrfNEB3uVuYjWaQr328gqa4k7WZYtx1nUnrHdbY,4864
4
+ batchgrid/errors.py,sha256=36zxxjPc9yt5kKJ105oEgZETgeSEqv8uLrbhC9Pw5Cs,1757
5
+ batchgrid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ batchgrid-0.1.0b1.dist-info/METADATA,sha256=wysHtSm2ppcZIvq2r1G3qJXhx_nY7srCFr4sPH6anno,6180
7
+ batchgrid-0.1.0b1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ batchgrid-0.1.0b1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any