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/_build.py ADDED
@@ -0,0 +1,337 @@
1
+ """Image-build resolution: create-or-find a build, stream build logs, wait.
2
+
3
+ Used when ``@app.function(image=bladekit.Image(...))`` is first invoked. The build
4
+ is idempotent by definition hash (image-dsl.md §7), so an unchanged image
5
+ triggers zero rebuild. While a build is ``queued``/``building`` the SDK streams
6
+ the build's SSE logs to the terminal (same mechanism as run logs), then reports
7
+ success or raises an actionable :class:`bladekit.ImageBuildError`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Optional
14
+
15
+ from . import exceptions as exc
16
+
17
+ _TERMINAL = frozenset({"succeeded", "failed"})
18
+
19
+
20
+ def ensure_context_bundle(client: Any, data: bytes) -> str:
21
+ """Upload a from_dockerfile build context as a bundle; return its id.
22
+
23
+ Same flow as code bundles (create → presigned PUT → complete), so an
24
+ unchanged context deduplicates server-side by sha256 and uploads nothing.
25
+ The complete response is authoritative for the id: a concurrent identical
26
+ upload may adopt the winning ready bundle.
27
+ """
28
+ import hashlib
29
+
30
+ from ._constants import BUNDLE_MAX_BYTES
31
+
32
+ if len(data) > BUNDLE_MAX_BYTES:
33
+ raise exc.PayloadTooLargeError(
34
+ "BUNDLE_TOO_LARGE",
35
+ f"build context is {len(data) / 1e6:.1f} MB compressed, over the "
36
+ f"{BUNDLE_MAX_BYTES // (1024 * 1024)} MiB limit — add a .dockerignore "
37
+ "(large data belongs in a bladekit.Volume, not the image)",
38
+ )
39
+ sha = hashlib.sha256(data).hexdigest()
40
+ resp = client.create_bundle(len(data), sha)
41
+ bundle_id = resp["bundle"]["bundle_id"]
42
+ if resp.get("deduplicated"):
43
+ return bundle_id
44
+ client.upload_to_presigned(resp["upload_url"], data, "application/gzip")
45
+ completed = client.complete_bundle(bundle_id)
46
+ return completed.get("bundle_id", bundle_id)
47
+
48
+
49
+ def resolve_image_build(
50
+ client: Any,
51
+ definition: dict,
52
+ definition_hash: str,
53
+ *,
54
+ context_data: Optional[bytes] = None,
55
+ ) -> str:
56
+ """Return the build_id of a ``succeeded`` build for this definition.
57
+
58
+ Creates the build if needed (idempotent), streams logs while it runs, and
59
+ raises ImageBuildError on failure.
60
+
61
+ Clean mode (default) collapses the whole build into a single ``building``
62
+ phase line — a spinner while it runs, then ``✓ building <short-ref> (dur)``
63
+ — and SUPPRESSES the raw build-step log chunks (that noise is only useful
64
+ when debugging). ``--verbose`` keeps the old behaviour: a start line, the
65
+ streamed build steps, per-state transitions, and an ``image built`` line.
66
+ """
67
+ from ._render import console, is_verbose, phase
68
+
69
+ context_bundle_id = (
70
+ ensure_context_bundle(client, context_data) if context_data else None
71
+ )
72
+ resp = client.create_image_build(
73
+ definition, definition_hash, context_bundle_id=context_bundle_id
74
+ )
75
+ build = resp["build"]
76
+ build_id = build["build_id"]
77
+ state = build.get("state")
78
+
79
+ if state == "succeeded":
80
+ # Already built (deduplicated): nothing to wait for, common fast path.
81
+ return build_id
82
+
83
+ if state == "failed":
84
+ # Per image-dsl.md §7, a POST whose last build is `failed` yields a NEW
85
+ # queued build; so seeing `failed` here would be a freshly-adopted
86
+ # terminal failure — surface it.
87
+ _raise_build_failed(build, build_id, definition_hash)
88
+
89
+ # queued / building (or an unknown non-terminal state): stream logs + wait.
90
+ if is_verbose():
91
+ console.print(
92
+ f"[cyan]building image[/cyan] [dim]{definition_hash[:12]}…[/dim] "
93
+ f"(build {build_id})"
94
+ )
95
+ final = _stream_build_logs(client, build_id)
96
+ final_state = final.get("state")
97
+ if final_state == "succeeded":
98
+ console.print(
99
+ f"[green]image built[/green] [dim]{definition_hash[:12]}…[/dim]"
100
+ )
101
+ return build_id
102
+ if final_state == "failed":
103
+ _raise_build_failed(final, build_id, definition_hash)
104
+ return _confirm_terminal(client, build_id, definition_hash)
105
+
106
+ # Clean mode: one collapsed phase line, build-step chunks suppressed.
107
+ with phase("building", f"{definition_hash[:8]}…") as ph:
108
+ final = _stream_build_logs(client, build_id, quiet=True)
109
+ final_state = final.get("state")
110
+ if final_state == "failed":
111
+ _raise_build_failed(final, build_id, definition_hash)
112
+ # Fetch the finished build once for the real short ref + timestamps
113
+ # (the SSE eof path drops both), so the resolved line shows the built
114
+ # ref and the true build duration rather than a wall-clock estimate.
115
+ if final_state != "succeeded":
116
+ build_id = _confirm_terminal(client, build_id, definition_hash)
117
+ fb = _safe_get_build(client, build_id)
118
+ if fb is not None:
119
+ from ._render import duration_str
120
+
121
+ ph.update(
122
+ meta=_short_image_ref(fb.get("image_ref"), definition_hash),
123
+ duration=duration_str(
124
+ fb.get("started_at") or fb.get("created_at"),
125
+ fb.get("finished_at"),
126
+ ),
127
+ )
128
+ else:
129
+ ph.update(meta=_short_image_ref(None, definition_hash))
130
+ return build_id
131
+
132
+
133
+ def run_standalone_build(
134
+ client: Any,
135
+ definition: dict,
136
+ definition_hash: str,
137
+ *,
138
+ force: bool = False,
139
+ force_compression: bool = False,
140
+ context_data: Optional[bytes] = None,
141
+ ) -> dict:
142
+ """Drive a definition through ``POST /v1/images/builds`` to a terminal
143
+ state, returning the FULL final build record (``state``, ``image_ref``, …).
144
+
145
+ Used by the standalone ``blade image build`` CLI command
146
+ (docs/specs/phase2-dogfood-catalog-build.md §4b). Unlike
147
+ :func:`resolve_image_build` — the IMPLICIT path triggered by ``run``/
148
+ ``deploy``, which only ever returns a ``build_id`` — this always surfaces
149
+ the full record so the caller can print the digest-pinned ``image_ref`` or
150
+ emit ``--json``, and it threads through the explicit ``--force``/
151
+ ``--force-compression`` flags that the implicit path never sets.
152
+
153
+ Always streams quietly (build-step chunks suppressed), regardless of
154
+ ``--verbose``: build-log chunks are unconditionally tagged ``stdout``
155
+ server-side (``imagebuilder.go:492``), so surfacing them raw would leak
156
+ onto stdout and break ``blade image build --json``'s stdout-purity
157
+ contract (only the final JSON object may land on stdout). Use
158
+ ``blade image logs -f <build_id>`` for the raw step-by-step stream. The
159
+ collapsed ``building`` phase line (and any raised error) is rendered on
160
+ ``err_console`` instead of the default stdout ``console``, for the same
161
+ reason.
162
+
163
+ Raises :class:`bladekit.ImageBuildError` on a failed or unexpectedly
164
+ terminated build; the caller decides the process exit code.
165
+ """
166
+ from ._render import err_console, is_verbose, phase, duration_str
167
+
168
+ context_bundle_id = (
169
+ ensure_context_bundle(client, context_data) if context_data else None
170
+ )
171
+ resp = client.create_image_build(
172
+ definition,
173
+ definition_hash,
174
+ force=force,
175
+ force_compression=force_compression,
176
+ context_bundle_id=context_bundle_id,
177
+ )
178
+ build = resp["build"]
179
+ build_id = build["build_id"]
180
+ state = build.get("state")
181
+
182
+ if state == "succeeded":
183
+ # Already built (deduplicated): nothing to wait for, common fast path.
184
+ return build
185
+
186
+ if state == "failed":
187
+ # Per image-dsl.md §7, a POST whose last build is `failed` yields a NEW
188
+ # queued build; so seeing `failed` here would be a freshly-adopted
189
+ # terminal failure — surface it.
190
+ _raise_build_failed(build, build_id, definition_hash)
191
+
192
+ if is_verbose():
193
+ err_console.print(
194
+ f"[cyan]building image[/cyan] [dim]{definition_hash[:12]}…[/dim] "
195
+ f"(build {build_id})"
196
+ )
197
+
198
+ with phase("building", f"{definition_hash[:8]}…", stream=err_console) as ph:
199
+ final = _stream_build_logs(client, build_id, quiet=True)
200
+ if final.get("state") == "failed":
201
+ _raise_build_failed(final, build_id, definition_hash)
202
+ fb = _confirm_terminal_build(client, build_id, definition_hash)
203
+ ph.update(
204
+ meta=_short_image_ref(fb.get("image_ref"), definition_hash),
205
+ duration=duration_str(
206
+ fb.get("started_at") or fb.get("created_at"), fb.get("finished_at")
207
+ ),
208
+ )
209
+ return fb
210
+
211
+
212
+ def _confirm_terminal_build(client: Any, build_id: str, definition_hash: str) -> dict:
213
+ """Final GET after the log stream ends: return the FULL build record on
214
+ success, raise ImageBuildError on ``failed``/any other non-terminal state.
215
+
216
+ Distinct from :func:`_confirm_terminal` (which only returns the
217
+ ``build_id``) because :func:`run_standalone_build` needs the full record
218
+ (``image_ref``, timestamps) for its own output.
219
+ """
220
+ build = client.get_image_build(build_id)
221
+ if build.get("state") == "succeeded":
222
+ return build
223
+ if build.get("state") == "failed":
224
+ _raise_build_failed(build, build_id, definition_hash)
225
+ raise exc.ImageBuildError(
226
+ build_id,
227
+ "INTERNAL",
228
+ f"build ended in unexpected state {build.get('state')!r}",
229
+ definition_hash=definition_hash,
230
+ )
231
+
232
+
233
+ def _confirm_terminal(client: Any, build_id: str, definition_hash: str) -> str:
234
+ """Unknown terminal/aborted stream: a final GET decides success/failure."""
235
+ build = client.get_image_build(build_id)
236
+ if build.get("state") == "succeeded":
237
+ return build_id
238
+ if build.get("state") == "failed":
239
+ _raise_build_failed(build, build_id, definition_hash)
240
+ raise exc.ImageBuildError(
241
+ build_id,
242
+ "INTERNAL",
243
+ f"build ended in unexpected state {build.get('state')!r}",
244
+ definition_hash=definition_hash,
245
+ )
246
+
247
+
248
+ def _safe_get_build(client: Any, build_id: str) -> Optional[dict]:
249
+ try:
250
+ return client.get_image_build(build_id)
251
+ except Exception: # noqa: BLE001 — best-effort metadata for the phase line
252
+ return None
253
+
254
+
255
+ def _short_image_ref(image_ref: Optional[str], definition_hash: str) -> str:
256
+ """Short human ref for a built image — ``built:7d448073…`` — never the full
257
+ sha256. Falls back to the definition hash when no image_ref is available."""
258
+ h: Optional[str] = None
259
+ if image_ref and "built:" in image_ref:
260
+ h = image_ref.rsplit("built:", 1)[-1]
261
+ elif image_ref and ":" in image_ref:
262
+ h = image_ref.rsplit(":", 1)[-1]
263
+ elif image_ref:
264
+ h = image_ref
265
+ h = h or definition_hash
266
+ return f"built:{h[:8]}…" if len(h) > 8 else f"built:{h}"
267
+
268
+
269
+ def _raise_build_failed(build: dict, build_id: str, definition_hash: str) -> None:
270
+ err = build.get("error") or {}
271
+ raise exc.ImageBuildError(
272
+ build.get("build_id", build_id),
273
+ str(err.get("code", "IMAGE_BUILD_FAILED")),
274
+ str(err.get("message", "image build failed")),
275
+ definition_hash=definition_hash,
276
+ )
277
+
278
+
279
+ def _stream_build_logs(client: Any, build_id: str, quiet: bool = False) -> dict:
280
+ """Stream a build's SSE logs to the terminal until eof; return final state.
281
+
282
+ Mirrors the run-log streaming loop: resilient to a dropped connection
283
+ (resume from the last seq), tolerant of unparseable events.
284
+
285
+ ``quiet=True`` consumes the stream WITHOUT printing anything (build-step
286
+ chunks and state transitions are suppressed) — used by the clean-mode
287
+ ``building`` phase, which shows a single collapsed line instead.
288
+ """
289
+ from ._render import print_log_chunk, print_state_transition, err_console
290
+
291
+ cursor: Optional[int] = None
292
+ final_state: Optional[str] = None
293
+ while True:
294
+ try:
295
+ for event, data, event_id in client.stream_build_logs(
296
+ build_id, cursor=cursor
297
+ ):
298
+ if event == "log":
299
+ try:
300
+ chunk = json.loads(data)
301
+ except ValueError:
302
+ continue
303
+ if not quiet:
304
+ print_log_chunk(chunk)
305
+ if isinstance(chunk, dict) and "seq" in chunk:
306
+ cursor = chunk["seq"]
307
+ elif event_id is not None:
308
+ try:
309
+ cursor = int(event_id)
310
+ except ValueError:
311
+ pass
312
+ elif event == "state":
313
+ try:
314
+ payload = json.loads(data)
315
+ except ValueError:
316
+ continue
317
+ final_state = payload.get("state")
318
+ if not quiet:
319
+ print_state_transition(final_state, payload.get("ts"))
320
+ elif event == "eof":
321
+ return {"state": final_state}
322
+ elif event == "ping":
323
+ continue
324
+ # Generator ended without eof: check whether the build is terminal.
325
+ build = client.get_image_build(build_id)
326
+ if build.get("state") in _TERMINAL:
327
+ return build
328
+ return {"state": final_state}
329
+ except exc.ConnectionFailed:
330
+ build = client.get_image_build(build_id)
331
+ if build.get("state") in _TERMINAL:
332
+ return build
333
+ err_console.print("[dim]build log stream dropped, reconnecting…[/dim]")
334
+ import time
335
+
336
+ time.sleep(0.5)
337
+ continue