urun-cli 0.3.1__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,6 +5,7 @@ import time
5
5
  import urllib.error
6
6
  import urllib.parse
7
7
  import urllib.request
8
+ from collections.abc import Callable
8
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from typing import Any
10
11
 
@@ -171,13 +172,36 @@ def require_safe_url(raw_url: str, label: str) -> None:
171
172
  raise UrunError(f"{label} must use HTTPS except for localhost development")
172
173
 
173
174
 
175
+ # Terminal build states reported by the control plane's deployment-status document.
176
+ # The build worker transitions a deployment queued -> building -> ready|failed and
177
+ # records the actual build stderr/reason in the `error` field on failure.
178
+ TERMINAL_STATES = {"ready", "failed"}
179
+
180
+ # Human-readable progress lines for non-terminal states, so the CLI streams what the
181
+ # builder is doing instead of blocking silently until ready/failed.
182
+ _STATE_PROGRESS = {
183
+ "queued": "queued (waiting for a build slot)",
184
+ "building": "building (installing deps + materializing app)",
185
+ "not_found": "waiting for the control plane to register the build",
186
+ }
187
+
188
+
174
189
  def poll_until_done(
175
190
  client: ApiClient,
176
191
  manifest_hash: str,
177
192
  interval: float = 2.0,
178
193
  timeout: float = 1800.0,
179
194
  not_found_grace: float = 30.0,
195
+ on_state: Callable[[str], None] | None = None,
180
196
  ) -> dict[str, Any]:
197
+ """Poll the control plane for the real build outcome of a deployment.
198
+
199
+ Streams each new build state via ``on_state`` (so callers can show
200
+ queued -> building -> ... progress) and returns the terminal status
201
+ document (status ``ready`` or ``failed``). The ``failed`` document carries
202
+ the actual build error under ``error`` for the caller to surface. Raises
203
+ ``UrunError`` on timeout.
204
+ """
181
205
  deadline = time.time() + timeout
182
206
  not_found_deadline = time.time() + min(not_found_grace, timeout)
183
207
  last_state = "unknown"
@@ -186,16 +210,25 @@ def poll_until_done(
186
210
  status = client.deployment_status(manifest_hash)
187
211
  except ApiError as exc:
188
212
  if exc.status == 404 and time.time() < not_found_deadline:
213
+ if last_state != "not_found" and on_state is not None:
214
+ on_state("not_found")
189
215
  last_state = "not_found"
190
216
  time.sleep(interval)
191
217
  continue
192
218
  raise
193
- state = status.get("status")
194
- last_state = str(state)
195
- if state in {"ready", "failed"}:
219
+ state = str(status.get("status"))
220
+ if state != last_state and on_state is not None:
221
+ on_state(state)
222
+ last_state = state
223
+ if state in TERMINAL_STATES:
196
224
  return status
197
225
  if time.time() >= deadline:
198
226
  raise UrunError(
199
227
  f"timed out waiting for deployment {manifest_hash} (last status: {last_state})"
200
228
  )
201
229
  time.sleep(interval)
230
+
231
+
232
+ def progress_line(state: str) -> str:
233
+ """Render a human-readable progress line for a build state."""
234
+ return _STATE_PROGRESS.get(state, state)
@@ -11,7 +11,13 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from . import __version__
14
- from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
14
+ from .api import (
15
+ DEFAULT_API_URL,
16
+ ApiClient,
17
+ poll_until_done,
18
+ progress_line,
19
+ upload_missing_blobs,
20
+ )
15
21
  from .config import load_credentials, save_credentials
16
22
  from .deps import resolve_deps
17
23
  from .discovery import derive_app_name, discover_main_files
@@ -326,17 +332,43 @@ def deploy(args: argparse.Namespace) -> int:
326
332
  print(f"Status: {status_url}")
327
333
  return 0
328
334
 
329
- status = poll_until_done(client, mh, args.poll_interval, args.timeout)
335
+ print("Waiting for build to complete (--no-wait to skip)")
336
+ status = poll_until_done(
337
+ client,
338
+ mh,
339
+ args.poll_interval,
340
+ args.timeout,
341
+ on_state=lambda state: print(f" build: {progress_line(state)}"),
342
+ )
330
343
  if status.get("status") == "failed":
331
- err = status.get("error") or {}
332
- code = err.get("code")
333
- message = err.get("message")
334
- detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
335
- raise UrunError(f"deployment failed: {detail}")
344
+ print_build_failure(status)
345
+ return 1
336
346
  print_ready(status)
337
347
  return 0
338
348
 
339
349
 
350
+ def print_build_failure(status: dict[str, Any]) -> None:
351
+ """Surface the REAL build failure (e.g. the uv/git/torch error) to stderr.
352
+
353
+ The control plane records the build worker's terminal error under
354
+ ``error`` as ``{code, message}`` where ``message`` is the actual build
355
+ stderr/reason. We print the full message verbatim so callers no longer have
356
+ to kubectl into the cluster to learn why a build failed.
357
+ """
358
+ err = status.get("error") or {}
359
+ code = string_value(err.get("code"))
360
+ message = string_value(err.get("message"))
361
+ print("Deployment failed: build did not succeed", file=sys.stderr)
362
+ if code:
363
+ print(f" reason: {code}", file=sys.stderr)
364
+ if message:
365
+ print(" build error:", file=sys.stderr)
366
+ for line in message.rstrip().splitlines() or [message]:
367
+ print(f" {line}", file=sys.stderr)
368
+ if not code and not message:
369
+ print(" (no error detail recorded by the build worker)", file=sys.stderr)
370
+
371
+
340
372
  def resolve_api_credentials(args: argparse.Namespace) -> tuple[str, str]:
341
373
  api_key = args.api_key or os.getenv("URUN_API_KEY")
342
374
  api_url = args.api_url or os.getenv("URUN_API_URL")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes