alab-cli 0.1.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.
alab/cli.py ADDED
@@ -0,0 +1,638 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import traceback
6
+ from dataclasses import dataclass
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from .auth import read_token, token_permission_warning, verify_raw_credential
12
+ from .configs import load_global_config, project_config_json_obj
13
+ from .context import detect_context
14
+ from .db import connect_initialized, one
15
+ from .errors import AlabError, error_exit_code
16
+ from .home import resolve_home
17
+ from .registry import COMMANDS, CommandSpec, match_command
18
+ from .rendering import ResultBlock, error_block, render_text
19
+ from .services import GlobalOptions, Request
20
+
21
+
22
+ @dataclass
23
+ class ParsedGlobals:
24
+ argv: list[str]
25
+ home: str | None = None
26
+ output: str = "text"
27
+ key: str | None = None
28
+ key_source: str | None = None
29
+
30
+
31
+ PathTuple = tuple[str, ...]
32
+
33
+ app = typer.Typer(
34
+ add_completion=False,
35
+ add_help_option=False,
36
+ invoke_without_command=True,
37
+ no_args_is_help=False,
38
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True, "help_option_names": []},
39
+ )
40
+
41
+
42
+ GLOBAL_PUBLIC: set[PathTuple] = {
43
+ ("help",),
44
+ ("auth", "init"),
45
+ ("config", "show"),
46
+ ("config", "set"),
47
+ ("config", "reset"),
48
+ ("config", "validate"),
49
+ ("context", "show"),
50
+ ("context", "repair"),
51
+ }
52
+
53
+ GLOBAL_CONFIG_REPAIR: set[PathTuple] = {
54
+ ("auth", "init"),
55
+ ("config", "show"),
56
+ ("config", "set"),
57
+ ("config", "reset"),
58
+ ("config", "validate"),
59
+ }
60
+
61
+ PUBLIC_PROJECT: set[PathTuple] = {
62
+ ("status",),
63
+ }
64
+
65
+ PUBLIC_PROJECT_WHEN_ENABLED: set[PathTuple] = {
66
+ ("exp", "create"),
67
+ }
68
+
69
+ EXPERIMENT_TOKEN: set[PathTuple] = {
70
+ ("status",),
71
+ ("run",),
72
+ ("submit",),
73
+ ("exp", "checkout"),
74
+ ("exp", "tag", "add"),
75
+ ("exp", "tag", "remove"),
76
+ ("exp", "tag", "list"),
77
+ ("annotate", "add"),
78
+ ("annotate", "edit"),
79
+ ("annotate", "archive"),
80
+ ("annotate", "unarchive"),
81
+ ("annotate", "remove"),
82
+ }
83
+
84
+ OBSERVE_READ: set[PathTuple] = {
85
+ ("exp", "list"),
86
+ ("exp", "search"),
87
+ ("exp", "show"),
88
+ ("exp", "best"),
89
+ ("observe", "experiments", "list"),
90
+ ("observe", "experiments", "search"),
91
+ ("observe", "experiments", "show"),
92
+ ("observe", "experiments", "best"),
93
+ ("observe", "runs", "list"),
94
+ ("observe", "runs", "show"),
95
+ ("observe", "artifacts", "list"),
96
+ ("observe", "artifacts", "show"),
97
+ ("observe", "artifacts", "export"),
98
+ ("observe", "logs", "list"),
99
+ ("observe", "logs", "show"),
100
+ ("observe", "logs", "export"),
101
+ ("observe", "annotations", "list"),
102
+ ("observe", "annotations", "show"),
103
+ ("runs", "list"),
104
+ ("runs", "show"),
105
+ ("artifacts", "list"),
106
+ ("artifacts", "show"),
107
+ ("artifacts", "export"),
108
+ ("logs", "list"),
109
+ ("logs", "show"),
110
+ ("logs", "export"),
111
+ ("annotations", "list"),
112
+ ("annotations", "show"),
113
+ }
114
+
115
+ OBSERVE_TOKEN_LIFECYCLE: set[PathTuple] = {
116
+ ("observe", "runs", "archive"),
117
+ ("observe", "runs", "unarchive"),
118
+ ("observe", "artifacts", "archive"),
119
+ ("observe", "artifacts", "unarchive"),
120
+ ("observe", "logs", "archive"),
121
+ ("observe", "logs", "unarchive"),
122
+ ("runs", "archive"),
123
+ ("runs", "unarchive"),
124
+ ("artifacts", "archive"),
125
+ ("artifacts", "unarchive"),
126
+ ("logs", "archive"),
127
+ ("logs", "unarchive"),
128
+ }
129
+
130
+ INSPECTION_TOKEN: set[PathTuple] = {
131
+ ("status",),
132
+ ("exp", "checkout", "remove"),
133
+ }
134
+
135
+
136
+ HELP_OPTIONS = {"--all", "--explain"}
137
+
138
+
139
+ def pre_scan(argv: list[str]) -> ParsedGlobals:
140
+ cleaned: list[str] = []
141
+ parsed = ParsedGlobals(argv=cleaned)
142
+ i = 0
143
+ stop = False
144
+ seen: set[str] = set()
145
+ while i < len(argv):
146
+ item = argv[i]
147
+ if item == "--":
148
+ stop = True
149
+ cleaned.extend(argv[i:])
150
+ break
151
+ if not stop and item in {"--home", "--output", "--key"}:
152
+ if item in seen:
153
+ raise AlabError("CONFIG_INVALID", f"duplicate global option {item}")
154
+ if item == "--key" and "--key-stdin" in seen:
155
+ raise AlabError("CONFIG_INVALID", "--key conflicts with --key-stdin")
156
+ seen.add(item)
157
+ if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
158
+ raise AlabError("CONFIG_INVALID", f"{item} requires a value")
159
+ value = argv[i + 1]
160
+ if value == "":
161
+ raise AlabError("CONFIG_INVALID", f"{item} requires a non-empty value")
162
+ if item == "--home":
163
+ parsed.home = value
164
+ elif item == "--output":
165
+ if value not in {"text", "rich"}:
166
+ raise AlabError("CONFIG_INVALID", "--output must be text or rich")
167
+ parsed.output = value
168
+ elif item == "--key":
169
+ parsed.key = value
170
+ parsed.key_source = "explicit"
171
+ i += 2
172
+ continue
173
+ if not stop and item == "--key-stdin":
174
+ if "--key-stdin" in seen or "--key" in seen:
175
+ raise AlabError("CONFIG_INVALID", "--key conflicts with --key-stdin")
176
+ seen.add(item)
177
+ raw = sys.stdin.read()
178
+ if raw.endswith("\n"):
179
+ raw = raw[:-1]
180
+ if not raw or "\n" in raw or "\0" in raw:
181
+ raise AlabError("CONFIG_INVALID", "--key-stdin requires a non-empty single-line value")
182
+ parsed.key = raw
183
+ parsed.key_source = "explicit"
184
+ i += 1
185
+ continue
186
+ cleaned.append(item)
187
+ i += 1
188
+ return parsed
189
+
190
+
191
+ def _option_value(args: list[str], name: str) -> str | None:
192
+ for idx, item in enumerate(args):
193
+ if item == name:
194
+ if idx + 1 >= len(args) or args[idx + 1].startswith("--"):
195
+ raise AlabError("CONFIG_INVALID", f"{name} requires a value")
196
+ if args[idx + 1] == "":
197
+ raise AlabError("CONFIG_INVALID", f"{name} requires a non-empty value")
198
+ return args[idx + 1]
199
+ return None
200
+
201
+
202
+ def _safe_context(home) -> object | None:
203
+ try:
204
+ if not home.db_path.exists():
205
+ return None
206
+ return detect_context(home)
207
+ except AlabError:
208
+ raise
209
+ except Exception:
210
+ return None
211
+
212
+
213
+ def _requested_project_id(req: Request, args: list[str] | None = None) -> str | None:
214
+ return _option_value(args or [], "--project") or (req.context.project_id if req.context else None)
215
+
216
+
217
+ def _has_context_token(req: Request, token_mode: str) -> bool:
218
+ if not req.context:
219
+ return False
220
+ if token_mode == "worktree" and req.context.context_type != "experiment":
221
+ return False
222
+ if token_mode == "inspection" and req.context.context_type != "inspection":
223
+ return False
224
+ try:
225
+ conn = connect_initialized(req.globals.home)
226
+ try:
227
+ token = read_token(req.context.path)
228
+ verify_raw_credential(
229
+ conn,
230
+ token,
231
+ required="token",
232
+ project_id=req.context.project_id,
233
+ exp_id=req.context.exp_id,
234
+ token_mode=token_mode,
235
+ path_hash=req.context.path_hash,
236
+ )
237
+ return True
238
+ finally:
239
+ conn.close()
240
+ except AlabError:
241
+ return False
242
+
243
+
244
+ def _public_exp_create_enabled(req: Request, args: list[str] | None = None) -> bool:
245
+ project_id = _requested_project_id(req, args)
246
+ if project_id is None:
247
+ return False
248
+ try:
249
+ conn = connect_initialized(req.globals.home)
250
+ try:
251
+ project = one(conn, "SELECT * FROM projects WHERE project_id = ?", (project_id,))
252
+ if project is None or project["status"] != "valid":
253
+ return False
254
+ version = project["active_valid_config_version"]
255
+ if version is None:
256
+ return False
257
+ row = one(
258
+ conn,
259
+ "SELECT canonical_config_json FROM project_config_versions WHERE project_id = ? AND version = ?",
260
+ (project_id, version),
261
+ )
262
+ if row is None:
263
+ return False
264
+ config = project_config_json_obj(row["canonical_config_json"])
265
+ return bool(config.get("project", {}).get("allow_public_exp_create", True))
266
+ finally:
267
+ conn.close()
268
+ except AlabError:
269
+ return False
270
+
271
+
272
+ def _admin_actor_scope(req: Request) -> str | None:
273
+ if not req.globals.key or req.actor is None:
274
+ return None
275
+ if req.actor.actor_type == "root":
276
+ return "root"
277
+ if req.actor.actor_type == "admin":
278
+ return "admin"
279
+ return None
280
+
281
+
282
+ def _admin_project_mismatch(req: Request, args: list[str] | None) -> bool:
283
+ if req.actor is None or req.actor.actor_type != "admin" or not req.actor.project_id:
284
+ return False
285
+ requested = _requested_project_id(req, args)
286
+ return bool(requested and requested != req.actor.project_id)
287
+
288
+
289
+ def _context_project_conflict(req: Request, args: list[str] | None) -> bool:
290
+ requested = _option_value(args or [], "--project")
291
+ return bool(requested and req.context and req.context.project_id and requested != req.context.project_id)
292
+
293
+
294
+ def _availability(spec: CommandSpec, req: Request, args: list[str] | None = None) -> tuple[bool, str | None, str | None]:
295
+ if _context_project_conflict(req, args):
296
+ return False, "explicit project conflicts with current context", "leave the context or use the matching project id"
297
+ path = spec.path
298
+ admin_scope = _admin_actor_scope(req)
299
+ if admin_scope == "root":
300
+ if spec.credential == "token":
301
+ if req.context and req.context.context_type == "experiment" and _has_context_token(req, "worktree"):
302
+ return True, None, "worktree-token"
303
+ return (
304
+ False,
305
+ "experiment worktree token context required",
306
+ "run from an experiment worktree",
307
+ )
308
+ return True, None, "root"
309
+ if admin_scope == "admin":
310
+ if spec.credential == "root":
311
+ return False, "root credential required", "use a root key"
312
+ if spec.credential == "token":
313
+ if req.context and req.context.context_type == "experiment" and _has_context_token(req, "worktree"):
314
+ return True, None, "worktree-token"
315
+ return (
316
+ False,
317
+ "experiment worktree token context required",
318
+ "run from an experiment worktree",
319
+ )
320
+ if _admin_project_mismatch(req, args):
321
+ if path in PUBLIC_PROJECT and _requested_project_id(req, args):
322
+ return True, None, "public-project"
323
+ if path in PUBLIC_PROJECT_WHEN_ENABLED and _public_exp_create_enabled(req, args):
324
+ return True, None, "public-project"
325
+ if spec.credential in {"admin", "public_or_admin", "token_or_admin"}:
326
+ return False, "project admin credential does not match requested project", "use a matching project admin key or root key"
327
+ return True, None, "project-admin"
328
+ if path in GLOBAL_PUBLIC:
329
+ return True, None, "global"
330
+ if req.context is None:
331
+ if path in PUBLIC_PROJECT and _requested_project_id(req, args):
332
+ return True, None, "public-project"
333
+ if path in PUBLIC_PROJECT_WHEN_ENABLED and _public_exp_create_enabled(req, args):
334
+ return True, None, "public-project"
335
+ return False, "project, experiment, inspection, or explicit credential required", "use alab help --all or pass an explicit key"
336
+ if req.context.context_type == "project":
337
+ if path in PUBLIC_PROJECT:
338
+ return True, None, "public-project"
339
+ if path in PUBLIC_PROJECT_WHEN_ENABLED and _public_exp_create_enabled(req, args):
340
+ return True, None, "public-project"
341
+ return False, "project admin or root credential required", "pass --key or --key-stdin"
342
+ if req.context.context_type == "experiment":
343
+ if not _has_context_token(req, "worktree"):
344
+ return False, "valid experiment token required", "repair context or restore token"
345
+ if path in EXPERIMENT_TOKEN or path in OBSERVE_READ or path in OBSERVE_TOKEN_LIFECYCLE:
346
+ return True, None, "worktree-token"
347
+ if path in PUBLIC_PROJECT_WHEN_ENABLED and _public_exp_create_enabled(req, args):
348
+ return True, None, "public-project"
349
+ return False, "command is not exposed to experiment tokens", "pass an explicit project admin/root key when appropriate"
350
+ if req.context.context_type == "inspection":
351
+ if not _has_context_token(req, "inspection"):
352
+ return False, "valid inspection token required", "repair context or recreate the inspection checkout"
353
+ if path in INSPECTION_TOKEN or path in OBSERVE_READ:
354
+ return True, None, "inspection-token"
355
+ return False, "command is not exposed to inspection tokens", "pass an explicit project admin/root key when appropriate"
356
+ return False, "credential or context required", "use an explicit key or matching context"
357
+
358
+
359
+ def _credential_source(req: Request) -> str:
360
+ if req.globals.key and req.actor:
361
+ return "explicit-root" if req.actor.actor_type == "root" else "explicit-admin" if req.actor.actor_type == "admin" else "explicit-token"
362
+ if req.context and req.context.context_type == "project":
363
+ return "public"
364
+ if req.context and req.context.context_type in {"experiment", "inspection"}:
365
+ return "context-token"
366
+ return "none"
367
+
368
+
369
+ def _credential_scope(req: Request) -> str:
370
+ if req.actor:
371
+ if req.actor.actor_type == "token" and req.actor.token_mode:
372
+ return f"token:{req.actor.token_mode}"
373
+ return req.actor.actor_type
374
+ if req.context and req.context.context_type == "experiment":
375
+ return "token:worktree"
376
+ if req.context and req.context.context_type == "inspection":
377
+ return "token:inspection"
378
+ return "none"
379
+
380
+
381
+ def _parse_help_options(options: list[str]) -> tuple[bool, bool]:
382
+ seen: set[str] = set()
383
+ for item in options:
384
+ if item not in HELP_OPTIONS:
385
+ raise AlabError("CONFIG_INVALID", f"invalid help option {item}")
386
+ if item in seen:
387
+ raise AlabError("CONFIG_INVALID", f"duplicate help option {item}")
388
+ seen.add(item)
389
+ return "--all" in seen, "--explain" in seen
390
+
391
+
392
+ def _help_request(argv: list[str]) -> tuple[bool, bool, list[tuple[CommandSpec, list[str] | None]] | None, bool] | None:
393
+ if not argv:
394
+ return False, False, None, False
395
+ if argv[0] == "help":
396
+ all_commands, explain = _parse_help_options(argv[1:])
397
+ return all_commands, explain, None, False
398
+ if argv[0] == "--help":
399
+ all_commands, explain = _parse_help_options(argv[1:])
400
+ return all_commands, explain, None, False
401
+
402
+ stop_at = argv.index("--") if "--" in argv else len(argv)
403
+ prefix = argv[:stop_at]
404
+ suffix = argv[stop_at:]
405
+ if "--help" not in prefix:
406
+ return None
407
+ if prefix.count("--help") > 1:
408
+ raise AlabError("CONFIG_INVALID", "duplicate help option --help")
409
+ for option in HELP_OPTIONS:
410
+ if prefix.count(option) > 1:
411
+ raise AlabError("CONFIG_INVALID", f"duplicate help option {option}")
412
+ selector = [item for item in prefix if item not in HELP_OPTIONS and item != "--help"]
413
+ all_commands = "--all" in prefix
414
+ explain = "--explain" in prefix
415
+ if not selector:
416
+ return all_commands, explain, None, False
417
+ spec, rest = match_command(selector + suffix)
418
+ if spec is None:
419
+ raise AlabError("CONFIG_INVALID", "invalid help selector")
420
+ return all_commands, explain, [(spec, rest)], True
421
+
422
+
423
+ def _is_help_request(argv: list[str]) -> bool:
424
+ if not argv or argv[0] in {"help", "--help"}:
425
+ return True
426
+ stop_at = argv.index("--") if "--" in argv else len(argv)
427
+ return "--help" in argv[:stop_at]
428
+
429
+
430
+ def help_blocks(
431
+ req: Request,
432
+ *,
433
+ all_commands: bool = False,
434
+ explain: bool = False,
435
+ commands: list[tuple[CommandSpec, list[str] | None]] | None = None,
436
+ include_locked_selected: bool = False,
437
+ ) -> list[ResultBlock]:
438
+ context_type = req.context.context_type if req.context else "none"
439
+ credential_source = _credential_source(req)
440
+ blocks = [
441
+ ResultBlock(
442
+ "help",
443
+ [
444
+ ("context type", context_type),
445
+ ("credential source", credential_source),
446
+ ("credential scope", _credential_scope(req)),
447
+ ("project id", req.context.project_id if req.context else None),
448
+ ("exp id", req.context.exp_id if req.context else None),
449
+ ("mode", "all" if all_commands else "available"),
450
+ ("next", ["alab auth init"] if context_type == "none" else ["alab status"]),
451
+ ],
452
+ )
453
+ ]
454
+ selected = commands or [(spec, None) for spec in COMMANDS]
455
+ command_rows: list[tuple[bool, ResultBlock]] = []
456
+ for spec, command_args in selected:
457
+ available, locked_reason, hint_or_source = _availability(spec, req, command_args)
458
+ if not available and not all_commands and not include_locked_selected:
459
+ continue
460
+ command_rows.append(
461
+ (
462
+ available,
463
+ ResultBlock(
464
+ "help_command",
465
+ [
466
+ ("command", " ".join(spec.path)),
467
+ ("available", available),
468
+ ("locked reason", None if available else locked_reason),
469
+ ("unlock hint", None if available else hint_or_source),
470
+ ("capability source", hint_or_source if explain else None),
471
+ ("summary", spec.summary),
472
+ ],
473
+ ),
474
+ )
475
+ )
476
+ if all_commands and commands is None:
477
+ command_rows.sort(key=lambda item: not item[0])
478
+ blocks.extend(block for _available, block in command_rows)
479
+ return blocks
480
+
481
+
482
+ def _context_token_warning_blocks(req: Request) -> list[ResultBlock]:
483
+ if req.context is None or req.context.context_type not in {"experiment", "inspection"}:
484
+ return []
485
+ warning = token_permission_warning(req.context.path)
486
+ if warning is None:
487
+ return []
488
+ return [
489
+ ResultBlock(
490
+ "warning",
491
+ [
492
+ ("warning code", warning),
493
+ ("warning reason", "token file permissions are broader than 0600"),
494
+ ],
495
+ )
496
+ ]
497
+
498
+
499
+ def _with_context_token_warnings(req: Request, blocks: list[ResultBlock]) -> list[ResultBlock]:
500
+ return [*blocks, *_context_token_warning_blocks(req)]
501
+
502
+
503
+ def build_base_request(parsed: ParsedGlobals) -> Request:
504
+ home = resolve_home(parsed.home)
505
+ globals_ = GlobalOptions(home=home, output=parsed.output, key=parsed.key, key_source=parsed.key_source)
506
+ return Request(globals=globals_, context=None, actor=None)
507
+
508
+
509
+ def hydrate_request(req: Request) -> Request:
510
+ context = _safe_context(req.globals.home)
511
+ actor = None
512
+ if req.globals.key and req.globals.home.db_path.exists():
513
+ conn = connect_initialized(req.globals.home)
514
+ try:
515
+ actor = verify_raw_credential(conn, req.globals.key)
516
+ finally:
517
+ conn.close()
518
+ return Request(globals=req.globals, context=context, actor=actor)
519
+
520
+
521
+ def build_request(parsed: ParsedGlobals) -> Request:
522
+ return hydrate_request(build_base_request(parsed))
523
+
524
+
525
+ def preflight(spec: CommandSpec, req: Request, args: list[str] | None = None) -> None:
526
+ if _context_project_conflict(req, args):
527
+ raise AlabError("CONTEXT_CONFLICT", "explicit --project conflicts with current ALab context")
528
+ available, _reason, _hint = _availability(spec, req, args)
529
+ if available:
530
+ return
531
+ raise AlabError("COMMAND_UNAVAILABLE", "command is not available in the current context")
532
+
533
+
534
+ def enforce_global_config_valid(spec_path: PathTuple, req: Request) -> None:
535
+ if spec_path in GLOBAL_CONFIG_REPAIR:
536
+ return
537
+ load_global_config(req.globals.home.config_path)
538
+
539
+
540
+ def infer_result_exit_code(blocks: list[ResultBlock]) -> int:
541
+ for block in blocks:
542
+ fields = dict(block.fields)
543
+ saved_failure = "error code" in fields
544
+ if block.object_type == "run" and saved_failure and fields.get("run status") not in {None, "passed"}:
545
+ return 1
546
+ if block.object_type == "validation" and saved_failure and fields.get("validation status") not in {None, "passed"}:
547
+ return 1
548
+ if block.object_type in {"project_config", "project_env", "project_secret"} and saved_failure and fields.get("validation status") not in {None, "passed", "skipped", "inherited", "dry-run"}:
549
+ return 1
550
+ if block.object_type == "project" and saved_failure and fields.get("validation status") not in {None, "passed", "skipped"}:
551
+ return 1
552
+ if block.object_type == "submission" and fields.get("submit accepted") is False:
553
+ return 1
554
+ return 0
555
+
556
+
557
+ def run(argv: list[str]) -> int:
558
+ try:
559
+ parsed = pre_scan(argv)
560
+ base_req = build_base_request(parsed)
561
+ if _is_help_request(parsed.argv):
562
+ enforce_global_config_valid(("help",), base_req)
563
+ req = build_request(parsed)
564
+ help_request = _help_request(parsed.argv)
565
+ if help_request is None:
566
+ raise AlabError("CONFIG_INVALID", "invalid help selector")
567
+ all_commands, explain, commands, include_locked_selected = help_request
568
+ sys.stdout.write(
569
+ render_text(
570
+ _with_context_token_warnings(
571
+ req,
572
+ help_blocks(
573
+ req,
574
+ all_commands=all_commands,
575
+ explain=explain,
576
+ commands=commands,
577
+ include_locked_selected=include_locked_selected,
578
+ ),
579
+ )
580
+ )
581
+ )
582
+ return 0
583
+ spec, rest = match_command(parsed.argv)
584
+ if spec is None:
585
+ raise AlabError("COMMAND_UNAVAILABLE", "unknown or unavailable command")
586
+ enforce_global_config_valid(spec.path, base_req)
587
+ req = build_request(parsed)
588
+ preflight(spec, req, rest)
589
+ blocks = spec.handler(rest, req)
590
+ blocks = _with_context_token_warnings(req, blocks)
591
+ sys.stdout.write(render_text(blocks))
592
+ return infer_result_exit_code(blocks)
593
+ except AlabError as exc:
594
+ sys.stderr.write(
595
+ render_text(
596
+ [
597
+ error_block(
598
+ message=exc.message,
599
+ code=exc.code,
600
+ exit_code=exc.exit_code or error_exit_code(exc.code),
601
+ reason=exc.reason,
602
+ next_action=exc.next_action,
603
+ )
604
+ ]
605
+ )
606
+ )
607
+ if os.environ.get("ALAB_DEBUG") == "1" and (exc.exit_code or 5) == 5:
608
+ traceback.print_exc(file=sys.stderr)
609
+ return exc.exit_code or error_exit_code(exc.code)
610
+ except Exception as exc:
611
+ sys.stderr.write(
612
+ render_text(
613
+ [
614
+ error_block(
615
+ message="Command failed.",
616
+ code="STORAGE_ERROR",
617
+ exit_code=5,
618
+ reason=str(exc),
619
+ next_action=None,
620
+ )
621
+ ]
622
+ )
623
+ )
624
+ if os.environ.get("ALAB_DEBUG") == "1":
625
+ traceback.print_exc(file=sys.stderr)
626
+ return 5
627
+
628
+
629
+ @app.callback(invoke_without_command=True)
630
+ def _typer_entry(ctx: typer.Context, args: Annotated[list[str] | None, typer.Argument()] = None) -> None:
631
+ raise typer.Exit(run([*(args or []), *ctx.args]))
632
+
633
+
634
+ def main(argv: list[str] | None = None) -> None:
635
+ if argv is None:
636
+ app(prog_name="alab")
637
+ return
638
+ raise SystemExit(run(list(argv)))