bladekit 4.5.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 (33) hide show
  1. bladekit-4.5.0/.gitignore +71 -0
  2. bladekit-4.5.0/PKG-INFO +373 -0
  3. bladekit-4.5.0/README.md +353 -0
  4. bladekit-4.5.0/pyproject.toml +75 -0
  5. bladekit-4.5.0/src/bladekit/__init__.py +219 -0
  6. bladekit-4.5.0/src/bladekit/_app.py +3241 -0
  7. bladekit-4.5.0/src/bladekit/_banner.py +640 -0
  8. bladekit-4.5.0/src/bladekit/_batch.py +150 -0
  9. bladekit-4.5.0/src/bladekit/_bucket.py +211 -0
  10. bladekit-4.5.0/src/bladekit/_build.py +337 -0
  11. bladekit-4.5.0/src/bladekit/_bundle.py +459 -0
  12. bladekit-4.5.0/src/bladekit/_client.py +1152 -0
  13. bladekit-4.5.0/src/bladekit/_cls.py +574 -0
  14. bladekit-4.5.0/src/bladekit/_constants.py +121 -0
  15. bladekit-4.5.0/src/bladekit/_cron.py +88 -0
  16. bladekit-4.5.0/src/bladekit/_dict.py +174 -0
  17. bladekit-4.5.0/src/bladekit/_env.py +98 -0
  18. bladekit-4.5.0/src/bladekit/_image.py +577 -0
  19. bladekit-4.5.0/src/bladekit/_loader.py +79 -0
  20. bladekit-4.5.0/src/bladekit/_map.py +555 -0
  21. bladekit-4.5.0/src/bladekit/_preempt.py +94 -0
  22. bladekit-4.5.0/src/bladekit/_queue.py +137 -0
  23. bladekit-4.5.0/src/bladekit/_render.py +683 -0
  24. bladekit-4.5.0/src/bladekit/_runner_api.py +148 -0
  25. bladekit-4.5.0/src/bladekit/_sandbox.py +1051 -0
  26. bladekit-4.5.0/src/bladekit/_secret.py +52 -0
  27. bladekit-4.5.0/src/bladekit/_serialize.py +88 -0
  28. bladekit-4.5.0/src/bladekit/_serve.py +188 -0
  29. bladekit-4.5.0/src/bladekit/_volume.py +232 -0
  30. bladekit-4.5.0/src/bladekit/_webhook.py +90 -0
  31. bladekit-4.5.0/src/bladekit/cli.py +2282 -0
  32. bladekit-4.5.0/src/bladekit/config.py +181 -0
  33. bladekit-4.5.0/src/bladekit/exceptions.py +544 -0
@@ -0,0 +1,71 @@
1
+ # Go
2
+ *.test
3
+ /control-plane/bin/
4
+ /forge-node/bin/
5
+ # Local `go build` output landing next to the sources instead of bin/. CI builds
6
+ # these from ./cmd/... and publishes them as generic packages — a host binary
7
+ # committed here is always an accident.
8
+ /forge-node/forge-node
9
+ /forge-node/forge-relay
10
+ /forge-node/forge-sandboxd
11
+ /control-plane/control-plane
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.egg-info/
16
+ .venv/
17
+ dist/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+
21
+ # Node
22
+ node_modules/
23
+
24
+ # Env / local
25
+ .env
26
+ *.local
27
+ .DS_Store
28
+
29
+ # e2e generated artifacts (logs, screenshots, tokens)
30
+ /e2e/artifacts/
31
+ /e2e/tutorials/artifacts/
32
+ /e2e/artifacts-*
33
+
34
+ # Inference outputs from running the tutorials by hand — the tutorial code
35
+ # writes these to the working directory, so they land at the repo root. The
36
+ # copies that actually matter live in motion/assets/. .dockerignore already
37
+ # excluded sd-out.png and structure.pdb; keep the two lists in step.
38
+ /flux-out.png
39
+ /sdxl-out/
40
+ /sd-out.png
41
+ /animation.mp4
42
+ /ltx-out.mp4
43
+ /structure.pdb
44
+ .claude/worktrees/
45
+
46
+ # provider secrets (OpenStack OS_* creds) — never commit
47
+ .env.provider
48
+ infra/dev/.env.provider
49
+
50
+ # OVH S3 credentials (never commit)
51
+ user-*.txt
52
+
53
+ # Registry v2 token-signing keypair (registry-v2.md §5.1) — never commit
54
+ infra/dev/registry/
55
+
56
+ infra/dev/headscale/lib/
57
+ infra/dev/headscale/authkey.txt
58
+ infra/dev/headscale/preauth.key
59
+
60
+ # forge-node fetches contracts/ here (make contracts) — never a vendored copy.
61
+ /forge-node/.contracts
62
+ /forge-node/.contracts.tmp
63
+
64
+ # iam's published persona -> capability contract, fetched by
65
+ # contracts/fetch-iam-personas.sh and asserted by
66
+ # control-plane/internal/api/adminauthz_test.go. Same rule as the line above:
67
+ # never a vendored copy. A copy in this repo would keep passing after iam's map
68
+ # moved, which is the one thing the cross-check exists to catch.
69
+ /contracts/.iam-personas.json
70
+ /contracts/.iam-personas.json.tmp.*
71
+
@@ -0,0 +1,373 @@
1
+ Metadata-Version: 2.4
2
+ Name: bladekit
3
+ Version: 4.5.0
4
+ Summary: Blade AI Fabric SDK + CLI — run Python functions on Blade's GPU fleet.
5
+ Project-URL: Homepage, https://blade.sh
6
+ Project-URL: Documentation, https://console.blade.sh
7
+ Author: Blade AI Fabric team
8
+ License: Proprietary
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: cloudpickle<4,>=3.0
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: rich>=13.7
13
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
14
+ Requires-Dist: typer>=0.12
15
+ Requires-Dist: watchfiles>=0.21
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # bladekit — Blade AI Fabric SDK + CLI
22
+
23
+ Run Python functions on the GPU fleet, from your laptop.
24
+
25
+ ## Install
26
+
27
+ Distribution `bladekit`, import `bladekit`, command `blade` (`bladekit` is installed too, as an alias of the same command). Python ≥ 3.10.
28
+
29
+ ```bash
30
+ pip install bladekit
31
+ ```
32
+
33
+ Also served, with the `forge-shadow` compatibility package, by the branded index
34
+ `https://console.blade.sh/sdk/` (`docs/INSTALL.md` in the source repository).
35
+
36
+ ```python
37
+ import bladekit
38
+
39
+ app = bladekit.app("demo")
40
+
41
+ @app.function(gpu="A100", image="fabric/torch-2.x")
42
+ def embed(texts):
43
+ from sentence_transformers import SentenceTransformer
44
+ return SentenceTransformer("all-MiniLM-L6-v2").encode(texts).tolist()
45
+
46
+ @app.local_entrypoint()
47
+ def main():
48
+ print(embed.remote(["hello", "world"]))
49
+ ```
50
+
51
+ ```console
52
+ $ blade setup # browser flow: mints + saves a token (defaults to https://cp.run.blade.sh)
53
+ $ blade setup --api-url https://cp.run.stg.blade.sh # ... or against another plane
54
+ $ echo "$FABRIC_TOKEN" | blade token set # headless alternative; or: --from-env FABRIC_TOKEN
55
+ $ blade run script.py # streams logs live, returns the result
56
+ $ blade logs <run_id> -f # follow logs with resume
57
+ $ blade status <run_id> # state + queue position
58
+ $ blade runs # list runs
59
+ $ blade cancel <run_id>
60
+ $ blade nodes # fleet: GPUs free/busy
61
+ $ blade --version # installed bladekit version
62
+ ```
63
+
64
+ ## Object API
65
+
66
+ - `bladekit.app(name, image=...)` (or the class, `bladekit.App`) — namespace of remote functions.
67
+ - `@app.function(gpu=, cpu=, memory=, timeout=, image=, idle_timeout=)` — register
68
+ a function. `image` is a catalogue alias (`fabric/base`) or a raw registry ref
69
+ with an explicit tag/digest. Note: when the platform enforces a registry
70
+ allowlist (`FORGE_IMAGE_REGISTRY_ALLOWLIST`), a raw ref (or an Image-DSL
71
+ `Image.from_registry(...)` base) outside the allowed registries is rejected
72
+ with a 400 `IMAGE_REGISTRY_FORBIDDEN` — catalogue aliases are always allowed.
73
+ - `@app.local_entrypoint()` — the function `blade run` invokes locally.
74
+ - `fn.remote(*args, **kwargs)` — blocking; returns the result or re-raises the
75
+ remote exception (with the remote traceback).
76
+ - `fn.spawn(*args, **kwargs)` — non-blocking; returns a handle with
77
+ `.get(timeout=)` / `.cancel()`.
78
+ - `fn.local(...)` — run locally without the fleet.
79
+
80
+ Successive `.remote()` calls of the same function in one process reuse the same
81
+ run (hot container, FIFO calls). At the end of `blade run` the SDK POSTs
82
+ `/complete` to drain the container immediately.
83
+
84
+ ## Container lifecycle hooks
85
+
86
+ An `@app.cls()` class is the stateful counterpart of `@app.function`: it is
87
+ instantiated once per container and its methods (marked with `@app.method()`)
88
+ reuse that instance across calls. Three decorators run code at defined moments in
89
+ the container's life:
90
+
91
+ - `@bladekit.build()` — runs **once at image-build time** to bake expensive,
92
+ deterministic setup into the image layer (download weights, warm a compile
93
+ cache), so every container starts with it already on disk. Until the
94
+ control-plane image-build path executes it, the runner falls back to running it
95
+ **once per container, before the enter hooks** — semantically weaker
96
+ (per-container, not baked in) but correct.
97
+ - `@bladekit.enter()` — runs **once per container** before the first call (the usual
98
+ place to load a model onto the GPU). Re-runs on a fresh instance after a
99
+ `blade serve` hot reload.
100
+ - `@bladekit.exit()` — runs **once per container at graceful teardown**, after the
101
+ last call drains and before the process exits (flush a cache, `volume.commit()`,
102
+ close a handle, emit a final metric). Exit hooks are **best-effort**: an
103
+ exception in one is logged and the remaining hooks still run, so a failing
104
+ teardown never wedges shutdown.
105
+
106
+ Within each kind, hooks run in **definition order**; across kinds the order is
107
+ always build → enter → (calls) → exit.
108
+
109
+ ```python
110
+ @app.cls(gpu="RTX-A4500", image="fabric/torch")
111
+ class Embedder:
112
+ MODEL = "BAAI/bge-small-en-v1.5"
113
+
114
+ @bladekit.build()
115
+ def fetch_weights(self): # once at build time (fallback: per container)
116
+ from huggingface_hub import snapshot_download
117
+ snapshot_download(self.MODEL)
118
+
119
+ @bladekit.enter()
120
+ def load(self): # once per container
121
+ from sentence_transformers import SentenceTransformer
122
+ self.model = SentenceTransformer(self.MODEL, device="cuda")
123
+
124
+ @app.method()
125
+ def embed(self, texts):
126
+ return self.model.encode(texts).tolist()
127
+
128
+ @bladekit.exit()
129
+ def flush(self): # once at teardown, best-effort
130
+ print("shutting down")
131
+
132
+ Embedder().embed.remote(["hello", "world"])
133
+ ```
134
+
135
+ ## Parametrized classes
136
+
137
+ `bladekit.parameter()` turns one `@app.cls` definition into **many independent
138
+ instances**, one per distinct set of parameter values — the equivalent of
139
+ `modal.parameter`. Declare parameters as typed class attributes; a parametrized
140
+ class defines no `__init__` (the values are set for you). Each distinct value set
141
+ gets its own run and its own container pool, and the bound values travel with
142
+ every call so AI Fabric routes it to the matching container.
143
+
144
+ ```python
145
+ @app.cls(gpu="RTX-A4500", image="fabric/torch")
146
+ class LLM:
147
+ model_name: str = bladekit.parameter(default="Qwen/Qwen2.5-1.5B-Instruct")
148
+ temperature: float = bladekit.parameter(default=0.7)
149
+
150
+ @bladekit.enter()
151
+ def load(self):
152
+ # each instance reads its own bound parameters
153
+ self.pipe = load_pipeline(self.model_name)
154
+
155
+ @app.method()
156
+ def generate(self, prompt):
157
+ return self.pipe(prompt, temperature=self.temperature)
158
+
159
+ # one definition, two parametrized instances → two container pools
160
+ LLM(model_name="Qwen/Qwen2.5-1.5B-Instruct").generate.remote("hi")
161
+ LLM(model_name="meta-llama/Llama-3.1-8B-Instruct").generate.remote("hi")
162
+ ```
163
+
164
+ - The value type comes from the annotation (or an explicit `type=`) and must be
165
+ one of `str`, `int`, `float`, `bool`, `bytes`. A parameter without a `default`
166
+ is **required** at call time.
167
+ - Instantiate with **keyword arguments only**; unknown or wrongly-typed values
168
+ are rejected with `bladekit.InvalidArgumentError`.
169
+
170
+ ## Fan-out: map, spawn_map
171
+
172
+ - `fn.map(iterable, ...)` — blocking fan-out of `f(x)` over the GPU fleet; yields
173
+ results in input order (`order="index"`, default) or arrival order.
174
+ - `fn.starmap(iterable, ...)` — like `map`, but each element is a tuple splatted
175
+ as positional args: `(a, b)` → `f(a, b)`.
176
+ - `fn.spawn_map(iterable, ...)` — **`spawn` + `map`**: fire the whole fan-out and
177
+ return a `MapHandle` *immediately*, without blocking on results. The batch is
178
+ created, all inputs are pushed, and it is finalized server-side; you then poll
179
+ it at your leisure. `fn.spawn_starmap(iterable, ...)` is the tuple-splatting
180
+ variant.
181
+
182
+ ```python
183
+ handle = classify.spawn_map(urls) # returns at once — nothing awaited
184
+ ... # do other work; the fleet churns
185
+ for label in handle.results(): # collect later (order/return_exceptions here)
186
+ ...
187
+ print(handle.status()) # inspect progress
188
+ handle.cancel() # stop the batch
189
+ ```
190
+
191
+ The batch is durable server-side, so a handle survives a client disconnect —
192
+ re-attach from another process with `blade map resume <map_id>`. `map()` blocks
193
+ and streams; `spawn_map()` hands you the handle and gets out of the way.
194
+
195
+ ## Dynamic batching: `@bladekit.batched`
196
+
197
+ `@bladekit.batched` auto-collects individual calls into a **batch** and invokes the
198
+ decorated function *once* per batch, then fans the per-call results back to each
199
+ caller. The function is written to take *lists* and return a list; callers still
200
+ invoke one item at a time. Stack it under `@app.function` (Modal order):
201
+
202
+ ```python
203
+ @app.function(gpu="RTX-A4500", image="fabric/torch")
204
+ @bladekit.batched(max_batch_size=32, wait_ms=50)
205
+ def embed(prompts: list[str]) -> list[list[float]]:
206
+ # `prompts` is the whole batch; return one vector per prompt.
207
+ return model.encode(prompts).tolist()
208
+
209
+ vec = embed.remote("hello") # one call in, one vector out — batched under the hood
210
+ ```
211
+
212
+ A batch is dispatched as soon as **either** trigger fires: it reaches
213
+ `max_batch_size` inputs, **or** `wait_ms` milliseconds elapse after the batch's
214
+ first input. This is distinct from `@app.concurrent`, which forwards N concurrent
215
+ requests to a long-lived server, each handled independently — `@bladekit.batched`
216
+ *fuses* calls into one invocation so you amortize a fixed per-call cost (a model
217
+ forward pass, a tokenizer round-trip) across the whole batch.
218
+
219
+ Contract: positional args are transposed by position and keyword args by name, so
220
+ every call in a batch must share the same call shape; the function must return a
221
+ list of exactly `len(batch)` results (else the whole batch fails with
222
+ `BATCH_RESULT_LENGTH_MISMATCH`). If the function raises, every call in the batch
223
+ receives that exception.
224
+
225
+ ## Cloud bucket mounts
226
+
227
+ `bladekit.CloudBucketMount` mounts an **external** object-store bucket you own
228
+ (S3 / GCS / R2 / MinIO / Scaleway — anything speaking the S3 API) as a
229
+ filesystem path inside a function or deployed replica. Unlike `bladekit.Volume`
230
+ (an AI Fabric-managed network volume with generations and `commit()`), a bucket
231
+ mount is a live view of *your* bucket — AI Fabric only mounts it, it never stores
232
+ the objects. The runner FUSE-mounts each bucket at container start and unmounts
233
+ it on teardown.
234
+
235
+ Pass it in the same `volumes=` map as a `Volume`, keyed by mount path:
236
+
237
+ ```python
238
+ import bladekit, pathlib
239
+
240
+ app = bladekit.App("bucket-demo")
241
+
242
+ creds = bladekit.Secret.from_name("aws-credentials") # holds the bucket keys
243
+
244
+ @app.function(
245
+ image="fabric/base",
246
+ volumes={
247
+ "/mnt/data": bladekit.CloudBucketMount(
248
+ "my-data-bucket",
249
+ secret=creds,
250
+ read_only=True, # mount without write access
251
+ key_prefix="raw/2024/", # mount only this sub-path
252
+ ),
253
+ },
254
+ )
255
+ def count_parquet():
256
+ return sum(1 for _ in pathlib.Path("/mnt/data").glob("*.parquet"))
257
+
258
+ @app.local_entrypoint()
259
+ def main():
260
+ print(count_parquet.remote())
261
+ ```
262
+
263
+ `bladekit.CloudBucketMount(bucket_name, *, secret, read_only=False, key_prefix=None,
264
+ bucket_endpoint_url=None, requester_pays=False)`:
265
+
266
+ - `secret` — **required**. Credentials come **only** from a `bladekit.Secret`,
267
+ never inline. The wire spec carries the secret *name*, never a value; the
268
+ control plane resolves it and injects its keys (`AWS_ACCESS_KEY_ID` /
269
+ `AWS_SECRET_ACCESS_KEY`, plus optional `AWS_SESSION_TOKEN`) as container env
270
+ vars, and the runner reads those at mount time. A credential never appears in
271
+ the run body, argv, or a log line.
272
+ - `read_only=True` — mounted without write access.
273
+ - `key_prefix="raw/2024/"` — mount only objects under this bucket-relative key
274
+ prefix (must end with `/`).
275
+ - `bucket_endpoint_url=` — S3 endpoint override for non-AWS stores (R2, MinIO,
276
+ GCS-S3, Scaleway); omit for AWS S3.
277
+ - `requester_pays=True` — for buckets with S3 Requester Pays.
278
+
279
+ Store the credentials first with `blade secret create aws-credentials
280
+ AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…`. At most 8 bucket mounts per
281
+ function; mount paths share the volume namespace (absolute, unique, outside
282
+ reserved paths).
283
+
284
+ ## Errors
285
+
286
+ - `bladekit.APIError` (and subclasses) carry the API envelope `code` + human
287
+ message.
288
+ - `bladekit.RunError` (and subclasses `NodeLostError`, `ContainerFailedError`,
289
+ `VersionMismatchError`, …) describe *platform* failures with actionable hints.
290
+ - A remote *user* exception is re-raised as the original exception when possible,
291
+ otherwise wrapped in `bladekit.RemoteFunctionError` — never confused with a
292
+ platform error.
293
+
294
+ ## Config
295
+
296
+ `blade setup` (browser token flow) and `blade token set` both write
297
+ `~/.blade/config.toml`. `FABRIC_API_URL` / `FABRIC_TOKEN` environment variables
298
+ take precedence; `FABRIC_CONFIG` points the CLI at another file. `blade token show`
299
+ says where each value came from.
300
+
301
+ `blade token set` **never** accepts the token as a positional argument — that would
302
+ leak it into `ps aux`, shell history and process monitors. Pass it on stdin
303
+ (`echo "$TOK" | blade token set`) or name the variable (`--from-env FABRIC_TOKEN`).
304
+
305
+ Inside a container, read the platform's variables under their `FABRIC_*` names
306
+ (`FABRIC_SERVER_PORT`, `FABRIC_VOLUMES`, `FABRIC_PREEMPT_GRACE_SECONDS`,
307
+ `FABRIC_MAX_SESSION_SECONDS`, `FABRIC_TURN_URL` / `FABRIC_TURNS_URL` /
308
+ `FABRIC_TURN_SECRET`). `FABRIC_*` and `FORGE_*` are reserved: `Image.env()` rejects
309
+ both prefixes.
310
+
311
+ ## Compatibility and migration (from `forge` / `forge-shadow`)
312
+
313
+ The SDK used to be `forge-shadow` (import `forge`, command `forge`). Nothing an
314
+ existing user does breaks on upgrade; the old names are deprecated and keep
315
+ working for a transition period (see "Removal" below).
316
+
317
+ | Before | Now | The old one… |
318
+ | --- | --- | --- |
319
+ | `pip install forge-shadow` | `pip install bladekit` | installs bladekit + the shims below |
320
+ | `import forge` | `import bladekit` | works, `DeprecationWarning` once |
321
+ | `forge.<x>`, `forge.<module>` | `bladekit.<x>`, `bladekit.<module>` | same objects, same modules |
322
+ | `forge` command | `blade` command | same subcommands and options, notice on stderr |
323
+ | `ForgeError` | `FabricError` | the same class, `DeprecationWarning` on use |
324
+ | `FORGE_TOKEN`, `FORGE_API_URL`, `FORGE_CONFIG`, `FORGE_VERBOSE`, … | `FABRIC_*` | read when the `FABRIC_*` one is unset |
325
+ | `~/.forge/config.toml` | `~/.blade/config.toml` | copied to the new path on first use, left in place |
326
+ | image `forge/torch` | image `fabric/torch` | still accepted, same image |
327
+
328
+ Details:
329
+
330
+ - **`forge-shadow`** is now a thin compatibility distribution (source in
331
+ `sdk/compat/forge-shadow/`), published at exactly the same
332
+ version as `bladekit`, which it requires (`bladekit==<version>`). It
333
+ ships the `forge` import shim and the `forge` command. `bladekit` alone does
334
+ not ship a `forge` package: two distributions owning one `forge/` directory is
335
+ what pip deletes during an upgrade, and `forge` is another project's name on
336
+ public PyPI. `pip install -U forge-shadow` therefore upgrades an existing install
337
+ in place; new installs should use `bladekit`.
338
+ - **The `forge` import shim** resolves every `forge.<module>` to the already
339
+ imported `bladekit.<module>` object, so `isinstance`/`except` checks and
340
+ pickles that name `forge.*` modules (written by an older client) keep working.
341
+ New pickles name `bladekit.*`, which needs bladekit on the other side —
342
+ every image the control plane builds has it, because the image's
343
+ `forge-shadow==<version>` pulls it in.
344
+ - **Environment**: `FABRIC_<NAME>` wins; `FORGE_<NAME>` is the fallback. When both
345
+ are set to different values the SDK uses `FABRIC_<NAME>` and warns once.
346
+ The control plane still injects `FORGE_*` into containers, so apps deployed with
347
+ the old SDK keep running unchanged, and importing `bladekit` in a container
348
+ also exposes the user-facing ones under their `FABRIC_*` names.
349
+ - **Catalog aliases**: the SDK sends `fabric/<name>` to the control plane as
350
+ `forge/<name>` (the only spelling the server knows for now); `blade image list`
351
+ shows `fabric/<name>`.
352
+ - **Unchanged on purpose** (server side, renamed separately): HTTP headers
353
+ (`X-Forge-*`, `Forge-Signature`), hosts, the `forge-runner` / `forge-sandboxd`
354
+ processes and their `[forge-runner]` log prefix, the `fg_` token prefix, the
355
+ `__forge_*__` attributes the runner reads, and `.forgeignore`.
356
+
357
+ **Removal.** The deprecated names (`forge` import and command, `forge-shadow`,
358
+ `ForgeError`, `FORGE_*` fallback, `~/.forge` migration, `forge/` alias rewrite) are
359
+ kept for a transition period whose end is not decided yet; they are removed in a
360
+ future major release, announced in the changelog.
361
+
362
+ ## .forgeignore
363
+
364
+ Bundling tars the script's directory. Add a `.forgeignore` to exclude paths; it
365
+ uses exactly the `.gitignore` syntax and matching rules (`*` never crosses `/`,
366
+ a leading or middle `/` anchors at the bundle root, a trailing `/` matches
367
+ directories only, `**` spans directories, `!` re-includes). `.git`, `.venv`,
368
+ `__pycache__` are always excluded.
369
+
370
+ `Image.from_dockerfile(context_dir=...)` filters its build context with a
371
+ `.dockerignore` at the context root instead, matched exactly like `docker build`
372
+ (patterns are relative to the context root, so `*.py` only matches top-level
373
+ files; use `**/*.py` for any depth). `!` is not supported there yet.