bladekit 4.5.0__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.
bladekit/_image.py ADDED
@@ -0,0 +1,577 @@
1
+ """``bladekit.Image`` — chained, immutable image-definition DSL.
2
+
3
+ Each builder method returns a NEW ``Image`` with one extra layer appended; an
4
+ ``Image`` is never mutated in place, so the same base can branch safely::
5
+
6
+ base = bladekit.Image.debian_slim(python_version="3.12").apt_install("git")
7
+ a = base.pip_install("torch")
8
+ b = base.pip_install("jax") # `base` unchanged
9
+
10
+ The canonical JSON serialization and the ``definition_hash`` are STRICTLY the
11
+ ones specified in contracts/image-dsl.md §2/§3:
12
+
13
+ - canonical bytes = ``json.dumps(d, sort_keys=True, separators=(",", ":"),
14
+ ensure_ascii=False)`` encoded UTF-8;
15
+ - absent/empty optional fields are dropped before hashing (one representation
16
+ per definition);
17
+ - array order is preserved (layers, packages) — never sorted;
18
+ - ``definition_hash`` = lowercase-hex sha256 of those bytes.
19
+
20
+ The hash is computed locally only for display / dedup hints; the server
21
+ recomputes it and is authoritative (image-dsl.md §3). ``hashlib`` / ``json`` are
22
+ stdlib, so importing this module stays cheap (no heavy top-level import) — it is
23
+ imported lazily from ``bladekit.__init__`` only when first referenced is not
24
+ required, but it is light enough to import eagerly.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import ipaddress
31
+ import json
32
+ import re
33
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
34
+ from urllib.parse import urlsplit
35
+
36
+ from . import exceptions as exc
37
+
38
+ # Limits — contracts/conventions.md §6 and image-dsl.md §2.
39
+ _DEFINITION_VERSION = 1
40
+ _MAX_LAYERS = 64
41
+ _MAX_PACKAGES_PER_LAYER = 256
42
+ _MAX_COMMANDS_PER_LAYER = 64
43
+ _MAX_ENV_VARS_PER_LAYER = 64
44
+ _CANONICAL_MAX_BYTES = 64 * 1024 # 64 KiB
45
+ # from_dockerfile limits (docs/specs/image-from-dockerfile.md §3).
46
+ _MAX_DOCKERFILE_BYTES = 32 * 1024
47
+ _MAX_BUILD_ARGS = 32
48
+ _MAX_BUILD_ARGS_BYTES = 4 * 1024
49
+
50
+ _PYTHON_VERSION_RE = re.compile(r"^3\.[0-9]+$")
51
+ # env keys: ^[A-Z_][A-Z0-9_]*$, never FABRIC_/FORGE_-prefixed (reserved for runtime).
52
+ _ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
53
+ # catalog alias (from_catalog): a catalogue name like "fabric/torch" — not a
54
+ # registry URL. No tag/digest: the server resolves the alias to the real ref.
55
+ _CATALOG_ALIAS_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$")
56
+ # Env prefixes the runtime owns (bladekit._env.RESERVED_PREFIXES, inlined so
57
+ # this module keeps importing nothing but exceptions).
58
+ RESERVED_ENV_PREFIXES = ("FABRIC_", "FORGE_")
59
+
60
+ # Catalog aliases are written ``fabric/<name>`` (PAAS-771). The control plane only
61
+ # knows them as ``forge/<name>`` today, so the SDK rewrites the prefix on the wire
62
+ # and the server never sees ``fabric/``. Remove the rewrite once the server
63
+ # accepts both spellings (PAAS-771, server half). ``forge/<name>`` keeps working
64
+ # unchanged.
65
+ CATALOG_PREFIX = "fabric/"
66
+ WIRE_CATALOG_PREFIX = "forge/"
67
+
68
+
69
+ def to_wire_image(ref: Any) -> Any:
70
+ """Map a ``fabric/<name>`` catalog alias to the ``forge/<name>`` the server knows.
71
+
72
+ Anything else — ``forge/<name>``, a registry ref with a tag or digest, a
73
+ non-string — is returned unchanged.
74
+ """
75
+ if (
76
+ isinstance(ref, str)
77
+ and ref.startswith(CATALOG_PREFIX)
78
+ and _CATALOG_ALIAS_RE.match(ref)
79
+ ):
80
+ return WIRE_CATALOG_PREFIX + ref[len(CATALOG_PREFIX):]
81
+ return ref
82
+
83
+
84
+ def to_display_image(ref: Any) -> Any:
85
+ """Inverse of :func:`to_wire_image`, for showing a server alias to the user."""
86
+ if (
87
+ isinstance(ref, str)
88
+ and ref.startswith(WIRE_CATALOG_PREFIX)
89
+ and _CATALOG_ALIAS_RE.match(ref)
90
+ ):
91
+ return CATALOG_PREFIX + ref[len(WIRE_CATALOG_PREFIX):]
92
+ return ref
93
+
94
+
95
+ # Dockerfile ARG names (same charset docker accepts).
96
+ _BUILD_ARG_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
97
+
98
+
99
+ # pip index URL policy (PAAS-265) — client-side mirror of the server's
100
+ # authoritative check (control-plane/internal/imagedsl checkIndexURL). The
101
+ # server validates too, but client-side rejection gives an immediate, actionable
102
+ # error rather than a build failure minutes later.
103
+ #
104
+ # * https:// is the only scheme accepted for a public host: a plaintext index
105
+ # lets a network attacker substitute packages during the BuildKit build.
106
+ # * http:// is tolerated ONLY for a loopback / RFC 1918 / link-local host or a
107
+ # bare "localhost" (a dev mirror reachable from the builder alone). Names
108
+ # other than "localhost" are never resolved and count as public.
109
+ # * Everything else (file:, ftp://, ssh://, git+, scheme-less, ...) is
110
+ # rejected — a positive allowlist, not a blocklist.
111
+
112
+
113
+ def _is_private_index_host(host: str) -> bool:
114
+ if host.lower() == "localhost":
115
+ return True
116
+ try:
117
+ ip = ipaddress.ip_address(host.strip("[]"))
118
+ except ValueError:
119
+ return False
120
+ return ip.is_loopback or ip.is_private or ip.is_link_local
121
+
122
+
123
+ def _check_index_url(op: str, param: str, url: str) -> None:
124
+ """Raise unless *url* is https://, or http:// to a loopback/private host."""
125
+ parsed = urlsplit(url)
126
+ scheme = parsed.scheme.lower()
127
+ host = parsed.hostname or ""
128
+ if scheme not in ("https", "http") or not host:
129
+ raise _invalid(
130
+ f"{op}: {param} uses a disallowed scheme; only https:// is "
131
+ f"accepted (got {url!r})"
132
+ )
133
+ if scheme == "http" and not _is_private_index_host(host):
134
+ raise _invalid(
135
+ f"{op}: {param} uses plaintext http://; only https:// is allowed "
136
+ f"for non-local hosts — a plaintext index lets a network attacker "
137
+ f"substitute packages during the build (got {url!r})"
138
+ )
139
+
140
+
141
+ def _dockerfile_reads_context(text: str) -> bool:
142
+ """Heuristic: does the Dockerfile COPY/ADD from the local build context?
143
+
144
+ Client-side mirror of the server's authoritative check (which uses the
145
+ real BuildKit parser): merges backslash line continuations, skips
146
+ comments, then looks for a ``COPY`` without ``--from=`` or an ``ADD``
147
+ whose source is not a remote URL. A false positive is harmless (pass
148
+ context_dir); the server re-validates either way.
149
+ """
150
+ logical: List[str] = []
151
+ pending = ""
152
+ for raw in text.splitlines():
153
+ line = raw.rstrip()
154
+ stripped = line.strip()
155
+ if not pending and (not stripped or stripped.startswith("#")):
156
+ continue
157
+ if line.endswith("\\") and not line.endswith("\\\\"):
158
+ pending += line[:-1] + " "
159
+ continue
160
+ logical.append((pending + line).strip())
161
+ pending = ""
162
+ if pending.strip():
163
+ logical.append(pending.strip())
164
+ for inst in logical:
165
+ parts = inst.split()
166
+ if not parts:
167
+ continue
168
+ op = parts[0].upper()
169
+ if op == "COPY":
170
+ if not any(a.lower().startswith("--from=") for a in parts[1:]):
171
+ return True
172
+ elif op == "ADD":
173
+ args = [a for a in parts[1:] if not a.startswith("--")]
174
+ for a in args[:-1]: # last arg is the destination
175
+ low = a.lower()
176
+ if not low.startswith(("http://", "https://", "git@")):
177
+ return True
178
+ return False
179
+
180
+
181
+ def _invalid(message: str) -> exc.InvalidArgumentError:
182
+ """Local validation error — mirrors the server's 400 IMAGE_DEFINITION_INVALID.
183
+
184
+ Raised eagerly at DSL-construction time so the user gets a clear, immediate
185
+ message at the offending ``.pip_install(...)`` call instead of a late 400.
186
+ """
187
+ return exc.InvalidArgumentError("IMAGE_DEFINITION_INVALID", message)
188
+
189
+
190
+ class Image:
191
+ """An immutable image definition: a base plus an ordered list of layers.
192
+
193
+ Construct with one of the base factories
194
+ (:meth:`debian_slim` / :meth:`from_registry`) then chain layer methods.
195
+ """
196
+
197
+ __slots__ = ("_base", "_layers", "_context_data")
198
+
199
+ def __init__(
200
+ self,
201
+ base: Dict[str, Any],
202
+ layers: Sequence[Dict[str, Any]],
203
+ context_data: Optional[bytes] = None,
204
+ ) -> None:
205
+ # Private: use the factories. ``layers`` is copied to keep immutability.
206
+ self._base = base
207
+ self._layers: List[Dict[str, Any]] = list(layers)
208
+ # from_dockerfile build context (tar.gz bytes) — uploaded as a bundle
209
+ # when the build is created; its sha256 is in the base and thus in the
210
+ # hash, the bytes themselves never are (content-addressed identity).
211
+ self._context_data = context_data
212
+
213
+ # -- base factories -----------------------------------------------------
214
+
215
+ @classmethod
216
+ def debian_slim(cls, python_version: str = "3.12") -> "Image":
217
+ """Platform-maintained Debian slim base with the given Python.
218
+
219
+ ``python_version`` is ``major.minor`` (e.g. ``"3.12"``). The exact base
220
+ image digest is a server config, NOT part of the hash (image-dsl.md §5).
221
+ """
222
+ if not isinstance(python_version, str) or not _PYTHON_VERSION_RE.match(
223
+ python_version
224
+ ):
225
+ raise _invalid(
226
+ f"python_version must match '3.<minor>' (e.g. '3.12'), got "
227
+ f"{python_version!r}"
228
+ )
229
+ return cls({"kind": "debian_slim", "python_version": python_version}, [])
230
+
231
+ @classmethod
232
+ def mac_native(cls, python_version: str = "3.13") -> "Image":
233
+ """Native macOS (Apple Silicon) environment base — mac-nodes.md §4.2.
234
+
235
+ The "image" is a relocatable Python venv assembled with ``uv`` ON a
236
+ darwin/arm64 Mac node (no Docker involved) and distributed through the
237
+ registry as a darwin/arm64 artifact. Functions using it schedule on
238
+ Mac nodes only (e.g. ``gpu="M4"``).
239
+
240
+ Only :meth:`pip_install` layers can follow: there is no ``apt`` on
241
+ macOS (common system packages — ffmpeg, git, cmake — are preinstalled
242
+ on Mac nodes), no shell layer, and no baked env vars in v1.
243
+ """
244
+ if not isinstance(python_version, str) or not _PYTHON_VERSION_RE.match(
245
+ python_version
246
+ ):
247
+ raise _invalid(
248
+ f"python_version must match '3.<minor>' (e.g. '3.13'), got "
249
+ f"{python_version!r}"
250
+ )
251
+ return cls({"kind": "mac_native", "python_version": python_version}, [])
252
+
253
+ @classmethod
254
+ def from_registry(cls, ref: str) -> "Image":
255
+ """Base from an existing registry image (must carry an explicit tag/digest).
256
+
257
+ Same rule as run refs: no implicit ``latest``. The image must be
258
+ Linux and ship a Python if pip layers follow (else the build fails — a
259
+ user error, image-dsl.md §2).
260
+ """
261
+ if not isinstance(ref, str) or not ref:
262
+ raise _invalid("from_registry(ref): ref must be a non-empty string")
263
+ if ":" not in ref and "@" not in ref:
264
+ raise _invalid(
265
+ f"from_registry({ref!r}): ref needs an explicit tag (':') or "
266
+ "digest ('@') — no implicit 'latest'."
267
+ )
268
+ return cls({"kind": "registry", "ref": ref}, [])
269
+
270
+ @classmethod
271
+ def from_catalog(cls, alias: str) -> "Image":
272
+ """Base from an AI Fabric catalog image, by its alias (e.g. ``"fabric/torch"``).
273
+
274
+ Unlike :meth:`from_registry`, you pass the *catalog alias* — the same
275
+ string you'd use in ``image="fabric/torch"`` — NOT a registry URL. The
276
+ control plane resolves the alias to the real registry ref at build time,
277
+ so the underlying registry (and its host) never leaks into your code and
278
+ can move (e.g. to an external registry) without changing tutorials.
279
+
280
+ The alias is resolved server-side against the enabled catalogue; an
281
+ unknown or disabled alias fails the build with a clear error.
282
+ """
283
+ if not isinstance(alias, str) or not alias:
284
+ raise _invalid("from_catalog(alias): alias must be a non-empty string")
285
+ if not _CATALOG_ALIAS_RE.match(alias):
286
+ raise _invalid(
287
+ f"from_catalog({alias!r}): alias must look like a catalog name "
288
+ "(e.g. 'fabric/torch') — letters, digits, '.', '_', '-', '/'. "
289
+ "Pass a registry URL to from_registry() instead."
290
+ )
291
+ # Stored in its wire form, so fabric/x and forge/x hash (and cache) alike.
292
+ return cls({"kind": "catalog", "ref": to_wire_image(alias)}, [])
293
+
294
+ @classmethod
295
+ def from_dockerfile(
296
+ cls,
297
+ path: Any,
298
+ *,
299
+ context_dir: Any = None,
300
+ build_args: Optional[Mapping[str, str]] = None,
301
+ ) -> "Image":
302
+ """Base built from a local Dockerfile (docs/specs/image-from-dockerfile.md).
303
+
304
+ The Dockerfile is read EAGERLY (the returned ``Image`` stays immutable
305
+ and hashable; later edits to the file do not affect it). Layer methods
306
+ (:meth:`pip_install`, :meth:`env`, …) chain after it and are applied on
307
+ top of the Dockerfile's final stage.
308
+
309
+ ``context_dir`` is required iff the Dockerfile ``COPY``/``ADD``s local
310
+ files: the directory is packed (honoring a ``.dockerignore`` at its
311
+ root) and uploaded when the build is created. ``build_args`` are the
312
+ Dockerfile ``ARG`` values; they are part of the image identity.
313
+
314
+ Platform notes (same rules as :meth:`from_registry`): linux/amd64
315
+ only; the final stage must ship Python + pip (AI Fabric appends its
316
+ runtime layer there); your ``ENTRYPOINT``/``CMD`` is overridden by the
317
+ AI Fabric runtime entrypoint; the container runs as root.
318
+
319
+ Never put a secret in the Dockerfile or ``build_args`` — the
320
+ definition is stored in clear and hashed. Use :class:`bladekit.Secret`.
321
+ """
322
+ from pathlib import Path
323
+
324
+ p = Path(path)
325
+ try:
326
+ text = p.read_text(encoding="utf-8")
327
+ except OSError as e:
328
+ raise _invalid(f"from_dockerfile({str(path)!r}): cannot read Dockerfile: {e}")
329
+ if not text.strip():
330
+ raise _invalid(f"from_dockerfile({str(path)!r}): the Dockerfile is empty")
331
+ if len(text.encode("utf-8")) > _MAX_DOCKERFILE_BYTES:
332
+ raise _invalid(
333
+ f"from_dockerfile({str(path)!r}): Dockerfile exceeds the "
334
+ f"{_MAX_DOCKERFILE_BYTES}-byte (32 KiB) limit"
335
+ )
336
+
337
+ base: Dict[str, Any] = {"kind": "dockerfile", "dockerfile": text}
338
+
339
+ if build_args:
340
+ if not isinstance(build_args, Mapping):
341
+ raise _invalid("from_dockerfile: build_args must be a mapping of strings")
342
+ if len(build_args) > _MAX_BUILD_ARGS:
343
+ raise _invalid(
344
+ f"from_dockerfile: too many build_args ({len(build_args)} > {_MAX_BUILD_ARGS})"
345
+ )
346
+ clean: Dict[str, str] = {}
347
+ total = 0
348
+ for k, v in build_args.items():
349
+ if not isinstance(k, str) or not _BUILD_ARG_KEY_RE.match(k):
350
+ raise _invalid(
351
+ f"from_dockerfile: invalid build arg name {k!r}; must match "
352
+ "'^[A-Za-z_][A-Za-z0-9_]*$'"
353
+ )
354
+ if not isinstance(v, str):
355
+ raise _invalid(f"from_dockerfile: build arg {k!r} value must be a string")
356
+ total += len(k.encode("utf-8")) + len(v.encode("utf-8"))
357
+ clean[k] = v
358
+ if total > _MAX_BUILD_ARGS_BYTES:
359
+ raise _invalid(
360
+ f"from_dockerfile: build_args exceed {_MAX_BUILD_ARGS_BYTES} total bytes"
361
+ )
362
+ base["build_args"] = clean
363
+
364
+ context_data: Optional[bytes] = None
365
+ if context_dir is not None:
366
+ ctx = Path(context_dir)
367
+ if not ctx.is_dir():
368
+ raise _invalid(
369
+ f"from_dockerfile: context_dir {str(context_dir)!r} is not a directory"
370
+ )
371
+ from ._bundle import build_context_bundle
372
+
373
+ context_data, context_sha, _ = build_context_bundle(ctx)
374
+ base["context_sha256"] = context_sha
375
+ elif _dockerfile_reads_context(text):
376
+ # Client-side mirror of the server's dockerfile_needs_context 400:
377
+ # an immediate, actionable error at the constructor call site.
378
+ raise _invalid(
379
+ f"from_dockerfile({str(path)!r}): the Dockerfile COPY/ADDs local "
380
+ "files but no build context was given — pass "
381
+ "context_dir=<directory containing those files>"
382
+ )
383
+
384
+ return cls(base, [], context_data)
385
+
386
+ # -- layer builders (return a new Image) --------------------------------
387
+
388
+ def _with_layer(self, layer: Dict[str, Any]) -> "Image":
389
+ if len(self._layers) >= _MAX_LAYERS:
390
+ raise _invalid(
391
+ f"too many layers (max {_MAX_LAYERS}); collapse steps or use "
392
+ "run_commands."
393
+ )
394
+ return Image(self._base, [*self._layers, layer], self._context_data)
395
+
396
+ def _reject_on_mac_native(self, op: str, hint: str) -> None:
397
+ """Mirror the server-side mac_native restriction (mac-nodes.md §4.2).
398
+
399
+ A native macOS env build understands pip layers only; rejecting the
400
+ other verbs at the offending call gives an immediate, actionable error
401
+ instead of a 400 at build submission.
402
+ """
403
+ if self._base.get("kind") == "mac_native":
404
+ raise _invalid(f"{op}: not supported on a mac_native base — {hint}")
405
+
406
+ @staticmethod
407
+ def _check_packages(op: str, packages: Sequence[str]) -> List[str]:
408
+ pkgs = list(packages)
409
+ if not pkgs:
410
+ raise _invalid(f"{op}: at least one package is required")
411
+ if len(pkgs) > _MAX_PACKAGES_PER_LAYER:
412
+ raise _invalid(
413
+ f"{op}: too many packages ({len(pkgs)} > {_MAX_PACKAGES_PER_LAYER})"
414
+ )
415
+ for p in pkgs:
416
+ if not isinstance(p, str) or not p:
417
+ raise _invalid(f"{op}: package names must be non-empty strings")
418
+ return pkgs
419
+
420
+ def apt_install(self, *packages: str) -> "Image":
421
+ """Append an ``apt_install`` layer (Debian package names, no shell)."""
422
+ self._reject_on_mac_native(
423
+ "apt_install",
424
+ "there is no apt on macOS; common system packages (ffmpeg, git, "
425
+ "cmake) are preinstalled on Mac nodes",
426
+ )
427
+ pkgs = self._check_packages("apt_install", packages)
428
+ return self._with_layer({"op": "apt_install", "packages": pkgs})
429
+
430
+ def pip_install(
431
+ self,
432
+ *packages: str,
433
+ index_url: Optional[str] = None,
434
+ extra_index_urls: Optional[Sequence[str]] = None,
435
+ ) -> "Image":
436
+ """Append a ``pip_install`` layer (PEP 508 specifiers).
437
+
438
+ Pinning versions is recommended: two builds of the same hash can resolve
439
+ differently otherwise (assumed, image-dsl.md §5).
440
+ """
441
+ pkgs = self._check_packages("pip_install", packages)
442
+ layer: Dict[str, Any] = {"op": "pip_install", "packages": pkgs}
443
+ if index_url is not None:
444
+ if not isinstance(index_url, str) or not index_url:
445
+ raise _invalid("pip_install: index_url must be a non-empty string")
446
+ _check_index_url("pip_install", "index_url", index_url)
447
+ layer["index_url"] = index_url
448
+ if extra_index_urls:
449
+ urls = list(extra_index_urls)
450
+ for u in urls:
451
+ if not isinstance(u, str) or not u:
452
+ raise _invalid(
453
+ "pip_install: extra_index_urls must be non-empty strings"
454
+ )
455
+ _check_index_url("pip_install", "extra_index_urls", u)
456
+ layer["extra_index_urls"] = urls
457
+ return self._with_layer(layer)
458
+
459
+ def env(self, vars: Mapping[str, str]) -> "Image":
460
+ """Append an ``env`` layer. Keys ``^[A-Z_][A-Z0-9_]*$``, never ``FABRIC_*``/``FORGE_*``.
461
+
462
+ Never put a secret here: the definition is stored in clear and hashed —
463
+ use :class:`bladekit.Secret` (image-dsl.md §2).
464
+ """
465
+ self._reject_on_mac_native(
466
+ "env",
467
+ "baked env vars are not supported in v1; set environment "
468
+ "variables via bladekit.Secret or the function definition instead",
469
+ )
470
+ if not isinstance(vars, Mapping) or not vars:
471
+ raise _invalid("env(vars): a non-empty mapping is required")
472
+ if len(vars) > _MAX_ENV_VARS_PER_LAYER:
473
+ raise _invalid(
474
+ f"env: too many vars ({len(vars)} > {_MAX_ENV_VARS_PER_LAYER})"
475
+ )
476
+ clean: Dict[str, str] = {}
477
+ for k, v in vars.items():
478
+ if not isinstance(k, str) or not _ENV_KEY_RE.match(k):
479
+ raise _invalid(
480
+ f"env: invalid key {k!r}; must match '^[A-Z_][A-Z0-9_]*$'"
481
+ )
482
+ # FABRIC_ too, client-side: the SDK reads FABRIC_* before FORGE_*, so a
483
+ # baked FABRIC_API_URL would shadow what the control plane injects.
484
+ reserved = next((p for p in RESERVED_ENV_PREFIXES if k.startswith(p)), None)
485
+ if reserved:
486
+ raise _invalid(
487
+ f"env: key {k!r} is reserved ({reserved} prefix is runtime-only)"
488
+ )
489
+ if not isinstance(v, str):
490
+ raise _invalid(f"env: value for {k!r} must be a string")
491
+ clean[k] = v
492
+ return self._with_layer({"op": "env", "vars": clean})
493
+
494
+ def run_commands(self, *commands: str) -> "Image":
495
+ """Append a ``run_commands`` layer (each command is one ``RUN`` / cache step)."""
496
+ self._reject_on_mac_native(
497
+ "run_commands",
498
+ "a mac env build runs uv commands only, not arbitrary shell",
499
+ )
500
+ cmds = list(commands)
501
+ if not cmds:
502
+ raise _invalid("run_commands: at least one command is required")
503
+ if len(cmds) > _MAX_COMMANDS_PER_LAYER:
504
+ raise _invalid(
505
+ f"run_commands: too many commands ({len(cmds)} > "
506
+ f"{_MAX_COMMANDS_PER_LAYER})"
507
+ )
508
+ for c in cmds:
509
+ if not isinstance(c, str) or not c:
510
+ raise _invalid("run_commands: commands must be non-empty strings")
511
+ if "\n" in c or "\r" in c:
512
+ raise _invalid(
513
+ "run_commands: commands must not contain newline characters "
514
+ "(\\n or \\r); each command is a single shell line"
515
+ )
516
+ return self._with_layer({"op": "run_commands", "commands": cmds})
517
+
518
+ # -- serialization & hash (NORMATIVE — image-dsl.md §2/§3) --------------
519
+
520
+ def to_definition(self, *, sandbox: bool = False) -> Dict[str, Any]:
521
+ """Return the definition object (version, base, layers).
522
+
523
+ Optional/empty fields are already absent (the builders never add empties),
524
+ so this is the pre-hash representation per §3 rule 5.
525
+
526
+ ``sandbox=True`` marks the build as a SANDBOX image: the server injects
527
+ forge-sandboxd as PID 1 / entrypoint instead of forge-runner
528
+ (sandbox-protocol.md §3). It is part of the canonical JSON (and thus the
529
+ hash), so a sandbox build never collides with the function build of the
530
+ same base/layers.
531
+ """
532
+ if sandbox and self._base.get("kind") == "mac_native":
533
+ # Mirrors the server rule: sandboxes are Linux containers
534
+ # (runc/runsc) — a native mac env can never back one.
535
+ raise _invalid(
536
+ "sandbox images are not supported on a mac_native base "
537
+ "(sandboxes are Linux containers)"
538
+ )
539
+ d: Dict[str, Any] = {
540
+ "version": _DEFINITION_VERSION,
541
+ "base": dict(self._base),
542
+ "layers": [dict(layer) for layer in self._layers],
543
+ }
544
+ if sandbox:
545
+ d["sandbox"] = True
546
+ return d
547
+
548
+ def canonical_json(self, *, sandbox: bool = False) -> str:
549
+ """Canonical JSON string (image-dsl.md §3): sorted keys, no spaces, UTF-8."""
550
+ return json.dumps(
551
+ self.to_definition(sandbox=sandbox),
552
+ sort_keys=True,
553
+ separators=(",", ":"),
554
+ ensure_ascii=False,
555
+ )
556
+
557
+ def definition_hash(self, *, sandbox: bool = False) -> str:
558
+ """Lowercase-hex sha256 of the canonical JSON bytes (image-dsl.md §3)."""
559
+ data = self.canonical_json(sandbox=sandbox).encode("utf-8")
560
+ if len(data) > _CANONICAL_MAX_BYTES:
561
+ raise _invalid(
562
+ f"image definition is {len(data)} bytes, over the "
563
+ f"{_CANONICAL_MAX_BYTES}-byte (64 KiB) limit (conventions.md §6)."
564
+ )
565
+ return hashlib.sha256(data).hexdigest()
566
+
567
+ def __repr__(self) -> str:
568
+ kind = self._base.get("kind")
569
+ return f"<bladekit.Image base={kind!r} layers={len(self._layers)} hash={self.definition_hash()[:12]}…>"
570
+
571
+ def __eq__(self, other: object) -> bool:
572
+ if not isinstance(other, Image):
573
+ return NotImplemented
574
+ return self.canonical_json() == other.canonical_json()
575
+
576
+ def __hash__(self) -> int:
577
+ return hash(self.canonical_json())
bladekit/_loader.py ADDED
@@ -0,0 +1,79 @@
1
+ """Load a user script as a module so its ``bladekit.App`` and decorators register.
2
+
3
+ ``blade run script.py`` must:
4
+ - set the bundle root to the script's directory,
5
+ - import the script under its file-stem module name (so the remote runner can
6
+ import the same module from the bundle root — see App._module_for),
7
+ - discover the App and its @app.local_entrypoint.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import sys
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+ from typing import List, Tuple
17
+
18
+ from . import exceptions as exc
19
+ from ._app import App
20
+
21
+
22
+ def load_script(script_path: Path) -> Tuple[ModuleType, str, Path]:
23
+ """Import ``script_path`` and return ``(module, module_name, bundle_root)``.
24
+
25
+ ``module_name`` is the file stem (the name the bundle root will expose), and
26
+ ``bundle_root`` is the script's parent directory.
27
+ """
28
+ script_path = script_path.resolve()
29
+ if not script_path.exists():
30
+ raise exc.FabricError(f"script not found: {script_path}")
31
+
32
+ bundle_root = script_path.parent
33
+ module_name = script_path.stem
34
+
35
+ # Make sibling modules importable both locally and consistently with the
36
+ # remote runner (which inserts the bundle root on sys.path).
37
+ if str(bundle_root) not in sys.path:
38
+ sys.path.insert(0, str(bundle_root))
39
+
40
+ # Remove any stale cached module under this name (e.g. from a prior
41
+ # load_script() call in the same process — common in tests). Leaving the
42
+ # old module in sys.modules would cause importlib to skip re-execution and
43
+ # return the stale object instead of the freshly-loaded one.
44
+ sys.modules.pop(module_name, None)
45
+
46
+ spec = importlib.util.spec_from_file_location(module_name, script_path)
47
+ if spec is None or spec.loader is None:
48
+ raise exc.FabricError(f"cannot import {script_path}")
49
+ module = importlib.util.module_from_spec(spec)
50
+ sys.modules[module_name] = module
51
+ spec.loader.exec_module(module)
52
+ return module, module_name, bundle_root
53
+
54
+
55
+ def find_apps(module: ModuleType) -> List[App]:
56
+ return [v for v in vars(module).values() if isinstance(v, App)]
57
+
58
+
59
+ def select_app_and_entrypoint(module: ModuleType):
60
+ """Return ``(app, entrypoint)`` from a loaded module.
61
+
62
+ Requires exactly one App with at least one @app.local_entrypoint for
63
+ ``blade run``.
64
+ """
65
+ apps = find_apps(module)
66
+ if not apps:
67
+ raise exc.FabricError("no bladekit.App found in script")
68
+ if len(apps) > 1:
69
+ raise exc.FabricError(
70
+ f"multiple bladekit.App objects found ({len(apps)}); only one is supported per script"
71
+ )
72
+ app = apps[0]
73
+ if not app.local_entrypoints:
74
+ raise exc.FabricError(
75
+ "no @app.local_entrypoint found; `blade run` needs one to invoke"
76
+ )
77
+ if len(app.local_entrypoints) > 1:
78
+ raise exc.FabricError("multiple @app.local_entrypoint found; expected one")
79
+ return app, app.local_entrypoints[0]