loom-batch 0.1.0__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 (35) hide show
  1. loom_batch-0.1.0/LICENSE +21 -0
  2. loom_batch-0.1.0/PKG-INFO +377 -0
  3. loom_batch-0.1.0/README.md +339 -0
  4. loom_batch-0.1.0/loom/__init__.py +3 -0
  5. loom_batch-0.1.0/loom/core/__init__.py +0 -0
  6. loom_batch-0.1.0/loom/core/models.py +39 -0
  7. loom_batch-0.1.0/loom/core/orchestrator.py +357 -0
  8. loom_batch-0.1.0/loom/main.py +425 -0
  9. loom_batch-0.1.0/loom/providers/__init__.py +38 -0
  10. loom_batch-0.1.0/loom/providers/anthropic.py +63 -0
  11. loom_batch-0.1.0/loom/providers/anthropic_sync.py +36 -0
  12. loom_batch-0.1.0/loom/providers/base.py +26 -0
  13. loom_batch-0.1.0/loom/providers/google.py +96 -0
  14. loom_batch-0.1.0/loom/providers/google_sync.py +36 -0
  15. loom_batch-0.1.0/loom/providers/openai.py +92 -0
  16. loom_batch-0.1.0/loom/providers/openai_sync.py +24 -0
  17. loom_batch-0.1.0/loom/providers/openrouter_sync.py +32 -0
  18. loom_batch-0.1.0/loom/providers/sync_base.py +26 -0
  19. loom_batch-0.1.0/loom/utils/__init__.py +0 -0
  20. loom_batch-0.1.0/loom/utils/cache.py +77 -0
  21. loom_batch-0.1.0/loom/utils/converters.py +199 -0
  22. loom_batch-0.1.0/loom/utils/keys.py +37 -0
  23. loom_batch-0.1.0/loom/utils/storage.py +59 -0
  24. loom_batch-0.1.0/loom_batch.egg-info/PKG-INFO +377 -0
  25. loom_batch-0.1.0/loom_batch.egg-info/SOURCES.txt +33 -0
  26. loom_batch-0.1.0/loom_batch.egg-info/dependency_links.txt +1 -0
  27. loom_batch-0.1.0/loom_batch.egg-info/entry_points.txt +2 -0
  28. loom_batch-0.1.0/loom_batch.egg-info/requires.txt +13 -0
  29. loom_batch-0.1.0/loom_batch.egg-info/top_level.txt +1 -0
  30. loom_batch-0.1.0/pyproject.toml +58 -0
  31. loom_batch-0.1.0/setup.cfg +4 -0
  32. loom_batch-0.1.0/tests/test_cli_help.py +30 -0
  33. loom_batch-0.1.0/tests/test_converters.py +201 -0
  34. loom_batch-0.1.0/tests/test_keys.py +23 -0
  35. loom_batch-0.1.0/tests/test_storage.py +50 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Nehring
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,377 @@
1
+ Metadata-Version: 2.4
2
+ Name: loom-batch
3
+ Version: 0.1.0
4
+ Summary: Weave batch LLM jobs across OpenAI, Anthropic, and Google.
5
+ Author: Jan Nehring
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jannehring/loom
8
+ Project-URL: Repository, https://github.com/jannehring/loom
9
+ Project-URL: Issues, https://github.com/jannehring/loom/issues
10
+ Keywords: llm,batch,openai,anthropic,gemini,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: typer>=0.12
26
+ Requires-Dist: pydantic>=2
27
+ Requires-Dist: pandas>=2
28
+ Requires-Dist: python-dotenv>=1
29
+ Requires-Dist: openai>=1.40
30
+ Requires-Dist: anthropic>=0.34
31
+ Requires-Dist: google-genai>=0.3
32
+ Requires-Dist: rich>=13
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4; extra == "dev"
36
+ Requires-Dist: build>=1; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # Loom: LLM Batch Processing Made Easy
40
+
41
+ > Weave LLM jobs across OpenAI, Anthropic, Google, and OpenRouter — in batch or live.
42
+
43
+ ## 1. Introduction
44
+
45
+ Loom is a small Python CLI for running a dataset of prompts (JSON or CSV) through an LLM and merging the responses back into the original file. It speaks two modes:
46
+
47
+ - **Batch** (`loom run`, default): submits the dataset to the provider's batch API, persists the batch id locally, and later you call `loom fetch` to download and merge results. Cheap (50% off on OpenAI / Anthropic) but asynchronous — can take up to 24 hours.
48
+ - **Sequential** (`loom run --sync`): calls the chat-completion endpoint per prompt with a concurrent worker pool, writes the output file immediately, and uses an on-disk response cache.
49
+
50
+ It also ships a `loom tokens` command that uses each provider's token-counting API where available.
51
+
52
+ ### Supported providers
53
+
54
+ | Provider | Batch (`loom run`) | Sequential (`loom run --sync`) | Token counter (`loom tokens`) |
55
+ | --- | --- | --- | --- |
56
+ | OpenAI | ✓ | ✓ | ✗ — no remote API |
57
+ | Anthropic | ✓ | ✓ | ✓ |
58
+ | Google (Gemini) | ✓ | ✓ | ✓ |
59
+ | OpenRouter | ✗ | ✓ | ✗ — no remote API |
60
+
61
+ ### Table of contents
62
+
63
+ - [1. Introduction](#1-introduction)
64
+ - [2. Getting started](#2-getting-started)
65
+ - [Installation](#installation)
66
+ - [Preparing the data](#preparing-the-data)
67
+ - [Submit a batch request](#submit-a-batch-request)
68
+ - [3. Usage](#3-usage)
69
+ - [Command-line reference](#command-line-reference)
70
+ - [Batch vs sequential](#batch-vs-sequential)
71
+ - [Storing API keys](#storing-api-keys)
72
+ - [Caching](#caching)
73
+ - [Token counter](#token-counter)
74
+ - [Where Loom stores state](#where-loom-stores-state)
75
+ - [Troubleshooting](#troubleshooting)
76
+ - [4. Developer instructions](#4-developer-instructions)
77
+ - [5. License](#5-license)
78
+
79
+ ## 2. Getting started
80
+
81
+ ### Installation
82
+
83
+ ```bash
84
+ pip install loom-batch
85
+ ```
86
+
87
+ The PyPI package is `loom-batch` (the name `loom` was taken); the CLI command is `loom`.
88
+
89
+ From source, for hacking or running tests:
90
+
91
+ ```bash
92
+ git clone https://github.com/jannehring/loom
93
+ cd loom
94
+ python -m venv .venv && source .venv/bin/activate
95
+ pip install -e ".[dev]"
96
+ ```
97
+
98
+ > **Tip:** if you create the venv with `uv venv`, `pip` is not installed inside it. Use `uv pip install -e ".[dev]"` instead, or recreate the venv with stdlib `python -m venv` (see [Troubleshooting](#troubleshooting)).
99
+
100
+ ### Preparing the data
101
+
102
+ Loom accepts two input formats — plain or **gzip-compressed** (`.json.gz`, `.csv.gz`). Compressed inputs are decompressed transparently; outputs are always written uncompressed (`.json` / `.csv`).
103
+
104
+ **JSON** — a list of `{id, prompt}` objects. The `id` is reused as the row key in the merged output.
105
+
106
+ ```json
107
+ [
108
+ {"id": "task-001", "prompt": "Summarize the plot of Hamlet in one sentence."},
109
+ {"id": "task-002", "prompt": "Translate 'Good morning' to French."}
110
+ ]
111
+ ```
112
+
113
+ **CSV** — any schema; you tell Loom which column holds the prompt with `--col`. All original columns are preserved; a new `llm_response` column is appended.
114
+
115
+ ```csv
116
+ id,text,priority
117
+ 1,"Explain quantum physics in one paragraph",low
118
+ 2,"Write a haiku about rust",high
119
+ ```
120
+
121
+ ### Submit a batch request
122
+
123
+ Minimal end-to-end run, passing the API key inline (see [Storing API keys](#storing-api-keys) for cleaner options):
124
+
125
+ ```bash
126
+ loom run --file prompts.json \
127
+ --provider openai \
128
+ --model gpt-4o-mini \
129
+ --api-key sk-...
130
+ # -> Batch submitted. id=batch_abc123 provider=openai
131
+
132
+ # ...minutes or hours later...
133
+ loom fetch # --all is the default; fetches every pending batch
134
+ # -> Fabric complete. id=batch_abc123 -> prompts_results_openai_gpt-4o-mini.json
135
+ ```
136
+
137
+ The output is written next to the input as `<name>_results_<provider>_<model>.<ext>`. Forward slashes and other unsafe characters in the model id are replaced with underscores (e.g. `openai/gpt-4o-mini` → `openai_gpt-4o-mini`). For gzipped inputs the `.gz` is dropped — `data.csv.gz` → `data_results_<provider>_<model>.csv`. Override the path entirely with `--output`.
138
+
139
+ ## 3. Usage
140
+
141
+ ### Command-line reference
142
+
143
+ #### `loom run`
144
+
145
+ Submit a dataset as a batch job (default) or run it synchronously with `--sync`.
146
+
147
+ | Flag | Default | Description |
148
+ | --- | --- | --- |
149
+ | `--file`, `-f` | _required_ | Input `.json`, `.csv`, `.json.gz`, or `.csv.gz`. |
150
+ | `--provider`, `-p` | _required_ | `openai`, `anthropic`, `google`, or `openrouter`. |
151
+ | `--model`, `-m` | _required_ | Provider-specific model id (e.g. `gpt-4o-mini`, `claude-3-5-sonnet-latest`, `gemini-2.0-flash`, `openai/gpt-4o-mini`). |
152
+ | `--col`, `-c` | — | Prompt column name (required for CSV). |
153
+ | `--api-key` | env / `.env` | Override the resolved API key for this run. |
154
+ | `--output`, `-o` | `<input>_results_<provider>_<model>.<ext>` | Custom output file path. |
155
+ | `--sync` / `--batch` | `--batch` | `--sync` calls the provider per prompt and writes the output immediately. `--batch` uses the provider's batch API. |
156
+ | `--workers`, `-w` | `8` | Concurrent workers in `--sync` mode. |
157
+ | `--no-cache` | off | Disable the on-disk response cache (`--sync` only). |
158
+ | `--force` | off | Overwrite an existing output file without prompting (`--sync` only). |
159
+ | `--with-meta` | off | Add `llm_provider` and `llm_model` columns (CSV) or fields (JSON) to the output, alongside `llm_response`. |
160
+
161
+ OpenRouter has no batch API; using `--provider openrouter` without `--sync` exits with a helpful error.
162
+
163
+ #### `loom fetch`
164
+
165
+ Poll the provider, download results, merge into the output file.
166
+
167
+ | Flag | Default | Description |
168
+ | --- | --- | --- |
169
+ | `--id`, `-i` | — | Fetch a single batch by id. If set, implies `--no-all`. |
170
+ | `--all` / `--no-all`, `-a` | `--all` | Process every pending batch under `~/.loom/batches/`. This is the default — `loom fetch` with no args walks all batches. |
171
+ | `--api-key` | env / `.env` | Override the resolved API key. |
172
+ | `--keep`, `-k` | off | Keep the metadata file in `~/.loom/batches/` after a successful fetch (default: delete it). |
173
+ | `--force` | off | Overwrite existing output files without prompting. |
174
+
175
+ For pending batches, `loom fetch` prints the current status and a one-sentence explanation. The full set of possible statuses:
176
+
177
+ | Status | Meaning |
178
+ | --- | --- |
179
+ | `validating` | Provider has accepted the batch and is queueing/preparing it; no work has started yet. |
180
+ | `in_progress` | Provider is actively running the prompts; check back later. |
181
+ | `completed` | All prompts finished and results were downloaded — the merged output file has been written. |
182
+ | `failed` | Provider reported the batch as failed; results are not available. |
183
+ | `expired` | Batch exceeded the provider's time limit (typically 24h) before completing. |
184
+ | `cancelled` | Batch was cancelled — either by you on the provider's dashboard, or by the provider itself. |
185
+ | `unknown` | The last fetch attempt raised an error (invalid id, auth failure, network glitch, or an API response Loom doesn't recognise). Re-run `loom fetch` to retry; if it persists, inspect the metadata file under `~/.loom/batches/`. |
186
+
187
+ `validating` and `in_progress` are the only non-terminal states — `loom fetch` will pick the batch up again on the next run. The other states are terminal: `completed` means the output file is on disk, and `failed` / `expired` / `cancelled` mean no merge happened.
188
+
189
+ #### `loom list`
190
+
191
+ List every batch known to Loom, with last-seen status, model, and source file. No flags.
192
+
193
+ #### `loom tokens`
194
+
195
+ Count input tokens for every prompt using the provider's token-counting API. See [Token counter](#token-counter).
196
+
197
+ | Flag | Default | Description |
198
+ | --- | --- | --- |
199
+ | `--file`, `-f` | _required_ | Input `.json`, `.csv`, `.json.gz`, or `.csv.gz`. |
200
+ | `--provider`, `-p` | _required_ | `openai`, `anthropic`, `google`, or `openrouter`. |
201
+ | `--model`, `-m` | _required_ | Provider-specific model id. |
202
+ | `--col`, `-c` | — | Prompt column name (required for CSV). |
203
+ | `--api-key` | env / `.env` | Override the resolved API key. |
204
+ | `--workers`, `-w` | `8` | Concurrent workers. |
205
+
206
+ #### `loom cache clear`
207
+
208
+ Delete every cached response under `~/.loom/cache/`. See [Caching](#caching).
209
+
210
+ | Flag | Default | Description |
211
+ | --- | --- | --- |
212
+ | `--yes`, `-y` | off | Skip the confirmation prompt. |
213
+
214
+ ### Batch vs sequential
215
+
216
+ | | `loom run` (batch, default) | `loom run --sync` (sequential) |
217
+ | --- | --- | --- |
218
+ | Latency | Up to 24h | Real-time |
219
+ | Pricing (OpenAI, Anthropic) | 50% off | Standard |
220
+ | Steps | `run` → wait → `fetch` | Single command |
221
+ | Cache | n/a | On-disk, on by default |
222
+ | OpenRouter | ✗ | ✓ (only mode) |
223
+ | State on disk | `~/.loom/batches/` | None (cache only) |
224
+
225
+ Pick **batch** when you have a large dataset and don't care about wall-clock time. Pick **sync** when you want results now, or when the provider has no batch API (OpenRouter).
226
+
227
+ ### Storing API keys
228
+
229
+ Loom resolves keys in this order: **`--api-key` flag → environment variable → `.env` file** in the current working directory (loaded via `python-dotenv`, does not overwrite existing env vars).
230
+
231
+ Recognised environment variables:
232
+
233
+ ```ini
234
+ OPENAI_API_KEY=sk-...
235
+ ANTHROPIC_API_KEY=sk-ant-...
236
+ GOOGLE_API_KEY=...
237
+ OPENROUTER_API_KEY=sk-or-...
238
+ ```
239
+
240
+ A `.env` in the working directory is the friction-free option for daily use; `--api-key` is handy for one-offs or shared workstations.
241
+
242
+ ### Caching
243
+
244
+ In `--sync` mode, Loom caches every response under `~/.loom/cache/`. The cache key is `sha256("<provider>|<model>|<prompt>")`, so changing any of those misses the cache. There is no TTL or eviction — the cache grows monotonically until you clear it.
245
+
246
+ ```bash
247
+ loom run --sync -p openai -m gpt-4o-mini -f data.csv -c text # first run: API calls
248
+ loom run --sync -p openai -m gpt-4o-mini -f data.csv -c text # second run: 100% cache hits
249
+ loom run --sync -p openai -m gpt-4o-mini -f data.csv -c text --no-cache # bypass
250
+ loom cache clear # wipe ~/.loom/cache/
251
+ ```
252
+
253
+ `loom run --sync` reports cache hits live in its progress bar.
254
+
255
+ ### Token counter
256
+
257
+ ```bash
258
+ loom tokens --file prompts.json --provider anthropic --model claude-3-5-sonnet-latest
259
+ # Counting tokens ████████░░░░ 340/1000 est_total≈36,210 errors=0 0:01:12 eta 0:02:35
260
+ # -> Total input tokens: 12,345 across 100 prompts (provider=anthropic, model=claude-3-5-sonnet-latest, errors=0)
261
+ ```
262
+
263
+ `loom tokens` calls each provider's official count-tokens endpoint, one prompt at a time, with a concurrent worker pool. The live progress bar shows:
264
+
265
+ - `done/total` prompts processed,
266
+ - `est_total` — running estimate of the final input-token count, computed as the mean tokens-per-prompt-so-far multiplied by `total` (refines as more prompts complete),
267
+ - `errors`,
268
+ - elapsed time and `eta` (estimated time remaining, based on the current rate).
269
+
270
+ | Provider | Endpoint | Available |
271
+ | --- | --- | --- |
272
+ | Anthropic | `client.messages.count_tokens(...)` → `input_tokens` | ✓ |
273
+ | Google | `client.models.count_tokens(...)` → `total_tokens` | ✓ |
274
+ | OpenAI | — | ✗ (no remote API; use `tiktoken` locally) |
275
+ | OpenRouter | — | ✗ |
276
+
277
+ For unsupported providers, `loom tokens` prints _"Token counting not available: ..."_ and exits with code 2.
278
+
279
+ ### Where Loom stores state
280
+
281
+ ```
282
+ ~/.loom/
283
+ ├── batches/ # one <provider>_<batch_id>.json per pending or kept batch
284
+ └── cache/ # one <sha256>.json per cached --sync response
285
+ ```
286
+
287
+ - `~/.loom/batches/<provider>_<safe_id>.json` is created by `loom run` (batch mode) and contains `batch_id`, `provider`, `model`, `original_file_path`, `file_type`, the prompt column, an `id_map` mapping internal `custom_id` → original row id, `created_at`, and the last-seen `status`. `loom fetch` updates `status`, downloads results, and (unless `--keep` is passed) deletes the file on success.
288
+ - `~/.loom/cache/<sha256>.json` is the response cache used by `--sync`. Each file holds `{provider, model, response, created_at}`.
289
+
290
+ Both directories are safe to delete by hand: cache will rebuild itself; deleting `batches/` orphans any in-flight batch jobs (they still complete on the provider's side, you just lose Loom's view of them).
291
+
292
+ ### Troubleshooting
293
+
294
+ **`ModuleNotFoundError: No module named 'pandas'` right after `pip install -e ".[dev]"`**
295
+ Your `.venv` was probably created with `uv venv`, which doesn't install `pip` inside. Your `pip install` ran against the system / conda `pip` and dropped the packages elsewhere. Fix with `uv pip install -e ".[dev]"`, or recreate the venv with `python -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"`.
296
+
297
+ **`which loom` shows `/opt/miniconda3/bin/loom` even after `source .venv/bin/activate`**
298
+ Conda's path is being prepended after the venv. Either reorder your shell init, or just call the venv binary directly: `./.venv/bin/loom <cmd>`.
299
+
300
+ **Google batch `Invalid batch job name: jqpem7...`**
301
+ The stored `batch_id` is missing the required `batches/` prefix (an older Loom version stripped it). Edit `~/.loom/batches/google_<id>.json`, change `"batch_id": "<id>"` to `"batch_id": "batches/<id>"`, and rename the file to `google_batches_<id>.json` so the on-disk filename and the in-file id stay consistent.
302
+
303
+ **`status=unknown` in `loom fetch`**
304
+ The previous fetch attempt raised an exception (bad id, network blip, expired key, or an API response Loom doesn't recognise). Re-running `loom fetch` retries; if it persists, run with the provider's SDK directly to surface the underlying error.
305
+
306
+ **`Error: OpenRouter has no batch API`**
307
+ OpenRouter doesn't offer batch processing. Re-run with `--sync`.
308
+
309
+ ## 4. Developer instructions
310
+
311
+ ### Repository layout
312
+
313
+ ```
314
+ loom/
315
+ main.py # CLI entry point (Typer commands)
316
+ core/
317
+ orchestrator.py # run_batch, fetch_batch, generate_sync, count_tokens
318
+ models.py # Pydantic models, ProviderName, BatchStatus
319
+ providers/
320
+ base.py # Batch provider ABC (submit/check_status/download)
321
+ sync_base.py # Sync provider ABC (generate/count_tokens)
322
+ openai.py, anthropic.py,
323
+ google.py # Batch implementations
324
+ openai_sync.py, anthropic_sync.py,
325
+ google_sync.py, openrouter_sync.py # Sync implementations
326
+ utils/
327
+ converters.py # Load / merge JSON & CSV
328
+ storage.py # ~/.loom/batches/ persistence
329
+ cache.py # ~/.loom/cache/ response cache
330
+ keys.py # API-key resolution
331
+ tests/ # pytest suite
332
+ .github/workflows/ # CI: test.yml, publish.yml
333
+ pyproject.toml # Dependencies and package metadata
334
+ ```
335
+
336
+ ### Running unit tests
337
+
338
+ ```bash
339
+ pip install -e ".[dev]"
340
+ pytest # quiet
341
+ pytest -v # verbose
342
+ pytest tests/test_converters.py # one file
343
+ pytest tests/test_storage.py::test_save_and_load_roundtrip # one test
344
+ ```
345
+
346
+ ### GitHub Actions
347
+
348
+ - [`.github/workflows/test.yml`](.github/workflows/test.yml) — runs on every push and PR, with a matrix over Python 3.10 / 3.11 / 3.12. Installs the project with `pip install -e ".[dev]"` and runs `pytest -v`.
349
+ - [`.github/workflows/publish.yml`](.github/workflows/publish.yml) — fires when you push a `v*` tag or publish a GitHub Release. It runs tests, builds an sdist + wheel, and uploads to PyPI via **OIDC Trusted Publishing** — no PyPI token is stored in repo secrets.
350
+
351
+ ### Releasing
352
+
353
+ **One-time PyPI setup:**
354
+
355
+ 1. Create the project on PyPI: https://pypi.org/manage/account/publishing/
356
+ 2. Add a **trusted publisher** with:
357
+ - Owner: `jannehring`
358
+ - Repository: `loom`
359
+ - Workflow: `publish.yml`
360
+ - Environment: `pypi`
361
+ 3. In GitHub: **Settings → Environments → New environment → `pypi`**. Enable manual approval here if you want a human in the loop for every release.
362
+
363
+ **Cutting a release:**
364
+
365
+ ```bash
366
+ # 1. Bump version in pyproject.toml
367
+ # 2. Commit and tag
368
+ git commit -am "Release v0.1.1"
369
+ git tag v0.1.1
370
+ git push origin main --tags
371
+ ```
372
+
373
+ The `publish` workflow runs on the tag, executes the test suite, builds, and uploads. You can also trigger it manually from the Actions tab (`workflow_dispatch`).
374
+
375
+ ## 5. License
376
+
377
+ MIT — see [LICENSE](LICENSE).