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/__init__.py +5 -0
- alab/__main__.py +4 -0
- alab/auth.py +242 -0
- alab/cli.py +638 -0
- alab/configs.py +614 -0
- alab/context.py +188 -0
- alab/db.py +283 -0
- alab/docker_platform.py +22 -0
- alab/errors.py +66 -0
- alab/home.py +82 -0
- alab/ids.py +43 -0
- alab/migrations/1_initial.sql +512 -0
- alab/proc.py +38 -0
- alab/registry.py +253 -0
- alab/rendering.py +88 -0
- alab/runner.py +2316 -0
- alab/services.py +10555 -0
- alab/source_import.py +306 -0
- alab/timeutil.py +33 -0
- alab_cli-0.1.0.dist-info/METADATA +333 -0
- alab_cli-0.1.0.dist-info/RECORD +25 -0
- alab_cli-0.1.0.dist-info/WHEEL +5 -0
- alab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- alab_cli-0.1.0.dist-info/licenses/LICENSE +9 -0
- alab_cli-0.1.0.dist-info/top_level.txt +1 -0
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)))
|