llamactl 0.2.7a1__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.
@@ -0,0 +1,549 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+ from llama_deploy.appserver.client import Client as ApiserverClient
8
+ from llama_deploy.core.config import DEFAULT_DEPLOYMENT_FILE_PATH
9
+ from llama_deploy.core.schema.deployments import DeploymentUpdate
10
+ from rich import print as rprint
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from tenacity import RetryError, Retrying, stop_after_attempt, wait_fixed
14
+
15
+ from .client import get_client
16
+ from .config import config_manager
17
+ from .interactive_prompts.utils import (
18
+ confirm_action,
19
+ select_deployment,
20
+ select_profile,
21
+ )
22
+ from .options import global_options
23
+ from .textual.deployment_form import create_deployment_form, edit_deployment_form
24
+ from .textual.profile_form import create_profile_form, edit_profile_form
25
+
26
+ RETRY_WAIT_SECONDS = 1
27
+ console = Console()
28
+
29
+
30
+ # Create sub-applications for organizing commands
31
+ @click.group(help="Manage profiles", no_args_is_help=True)
32
+ @global_options
33
+ def profile() -> None:
34
+ """Manage profiles"""
35
+ pass
36
+
37
+
38
+ @click.group(help="Manage projects", no_args_is_help=True)
39
+ @global_options
40
+ def projects() -> None:
41
+ """Manage projects"""
42
+ pass
43
+
44
+
45
+ @click.group(help="Manage deployments", no_args_is_help=True)
46
+ @global_options
47
+ def deployments() -> None:
48
+ """Manage deployments"""
49
+ pass
50
+
51
+
52
+ # Profile commands
53
+ @profile.command("create")
54
+ @global_options
55
+ @click.option("--name", help="Profile name")
56
+ @click.option("--api-url", help="API server URL")
57
+ @click.option("--project-id", help="Default project ID")
58
+ def create_profile(
59
+ name: Optional[str], api_url: Optional[str], project_id: Optional[str]
60
+ ) -> None:
61
+ """Create a new profile"""
62
+ try:
63
+ # If all required args are provided via CLI, skip interactive mode
64
+ if name and api_url:
65
+ # Use CLI args directly
66
+ profile = config_manager.create_profile(name, api_url, project_id)
67
+ rprint(f"[green]Created profile '{profile.name}'[/green]")
68
+
69
+ # Automatically switch to the new profile
70
+ config_manager.set_current_profile(name)
71
+ rprint(f"[green]Switched to profile '{name}'[/green]")
72
+ return
73
+
74
+ # Use interactive creation
75
+ profile = create_profile_form()
76
+ if profile is None:
77
+ rprint("[yellow]Cancelled[/yellow]")
78
+ return
79
+
80
+ try:
81
+ rprint(f"[green]Created profile '{profile.name}'[/green]")
82
+
83
+ # Automatically switch to the new profile
84
+ config_manager.set_current_profile(profile.name)
85
+ rprint(f"[green]Switched to profile '{profile.name}'[/green]")
86
+ except Exception as e:
87
+ rprint(f"[red]Error creating profile: {e}[/red]")
88
+ raise click.Abort()
89
+
90
+ except ValueError as e:
91
+ rprint(f"[red]Error: {e}[/red]")
92
+ raise click.Abort()
93
+ except Exception as e:
94
+ rprint(f"[red]Error: {e}[/red]")
95
+ raise click.Abort()
96
+
97
+
98
+ @profile.command("list")
99
+ @global_options
100
+ def list_profiles() -> None:
101
+ """List all profiles"""
102
+ try:
103
+ profiles = config_manager.list_profiles()
104
+ current_name = config_manager.get_current_profile_name()
105
+
106
+ if not profiles:
107
+ rprint("[yellow]No profiles found[/yellow]")
108
+ rprint("Create one with: [cyan]llamactl profile create[/cyan]")
109
+ return
110
+
111
+ table = Table(title="Profiles")
112
+ table.add_column("Name", style="cyan")
113
+ table.add_column("API URL", style="green")
114
+ table.add_column("Active Project", style="yellow")
115
+ table.add_column("Current", style="magenta")
116
+
117
+ for profile in profiles:
118
+ is_current = "✓" if profile.name == current_name else ""
119
+ active_project = profile.active_project_id or "-"
120
+ table.add_row(profile.name, profile.api_url, active_project, is_current)
121
+
122
+ console.print(table)
123
+
124
+ except Exception as e:
125
+ rprint(f"[red]Error: {e}[/red]")
126
+ raise click.Abort()
127
+
128
+
129
+ @profile.command("switch")
130
+ @global_options
131
+ @click.argument("name", required=False)
132
+ def switch_profile(name: Optional[str]) -> None:
133
+ """Switch to a different profile"""
134
+ try:
135
+ name = select_profile(name)
136
+ if not name:
137
+ rprint("[yellow]No profile selected[/yellow]")
138
+ return
139
+
140
+ profile = config_manager.get_profile(name)
141
+ if not profile:
142
+ rprint(f"[red]Profile '{name}' not found[/red]")
143
+ raise click.Abort()
144
+
145
+ config_manager.set_current_profile(name)
146
+ rprint(f"[green]Switched to profile '{name}'[/green]")
147
+
148
+ except Exception as e:
149
+ rprint(f"[red]Error: {e}[/red]")
150
+ raise click.Abort()
151
+
152
+
153
+ @profile.command("delete")
154
+ @global_options
155
+ @click.argument("name", required=False)
156
+ def delete_profile(name: Optional[str]) -> None:
157
+ """Delete a profile"""
158
+ try:
159
+ name = select_profile(name)
160
+ if not name:
161
+ rprint("[yellow]No profile selected[/yellow]")
162
+ return
163
+
164
+ profile = config_manager.get_profile(name)
165
+ if not profile:
166
+ rprint(f"[red]Profile '{name}' not found[/red]")
167
+ raise click.Abort()
168
+
169
+ if config_manager.delete_profile(name):
170
+ rprint(f"[green]Deleted profile '{name}'[/green]")
171
+ else:
172
+ rprint(f"[red]Profile '{name}' not found[/red]")
173
+
174
+ except Exception as e:
175
+ rprint(f"[red]Error: {e}[/red]")
176
+ raise click.Abort()
177
+
178
+
179
+ @profile.command("edit")
180
+ @global_options
181
+ @click.argument("name", required=False)
182
+ def edit_profile(name: Optional[str]) -> None:
183
+ """Edit a profile"""
184
+ try:
185
+ name = select_profile(name)
186
+ if not name:
187
+ rprint("[yellow]No profile selected[/yellow]")
188
+ return
189
+
190
+ # Get current profile
191
+ maybe_profile = config_manager.get_profile(name)
192
+ if not maybe_profile:
193
+ rprint(f"[red]Profile '{name}' not found[/red]")
194
+ raise click.Abort()
195
+ profile = maybe_profile
196
+
197
+ # Use the interactive edit menu
198
+ updated = edit_profile_form(profile)
199
+ if updated is None:
200
+ rprint("[yellow]Cancelled[/yellow]")
201
+ return
202
+
203
+ try:
204
+ current_profile = config_manager.get_current_profile()
205
+ if not current_profile or current_profile.name != updated.name:
206
+ config_manager.set_current_profile(updated.name)
207
+ rprint(f"[green]Updated profile '{profile.name}'[/green]")
208
+ except Exception as e:
209
+ rprint(f"[red]Error updating profile: {e}[/red]")
210
+ raise click.Abort()
211
+
212
+ except Exception as e:
213
+ rprint(f"[red]Error: {e}[/red]")
214
+ raise click.Abort()
215
+
216
+
217
+ # Projects commands
218
+ @projects.command("list")
219
+ @global_options
220
+ def list_projects() -> None:
221
+ """List all projects with deployment counts"""
222
+ try:
223
+ client = get_client()
224
+ projects = client.list_projects()
225
+
226
+ if not projects:
227
+ rprint("[yellow]No projects found[/yellow]")
228
+ return
229
+
230
+ table = Table(title="Projects")
231
+ table.add_column("Project ID", style="cyan")
232
+ table.add_column("Deployments", style="green")
233
+
234
+ for project in projects:
235
+ project_id = project.project_id
236
+ deployment_count = project.deployment_count
237
+ table.add_row(project_id, str(deployment_count))
238
+
239
+ console.print(table)
240
+
241
+ except Exception as e:
242
+ rprint(f"[red]Error: {e}[/red]")
243
+ raise click.Abort()
244
+
245
+
246
+ # Health check command (at root level)
247
+ @click.command()
248
+ @global_options
249
+ def health_check() -> None:
250
+ """Check if the API server is healthy"""
251
+ try:
252
+ client = get_client()
253
+ health = client.health_check()
254
+
255
+ status = health.get("status", "unknown")
256
+ if status == "ok":
257
+ rprint("[green]API server is healthy[/green]")
258
+ else:
259
+ rprint(f"[yellow]API server status: {status}[/yellow]")
260
+
261
+ except Exception as e:
262
+ rprint(f"[red]Error: {e}[/red]")
263
+ raise click.Abort()
264
+
265
+
266
+ # Deployments commands
267
+ @deployments.command("list")
268
+ @global_options
269
+ def list_deployments() -> None:
270
+ """List deployments for the configured project"""
271
+ try:
272
+ client = get_client()
273
+ deployments = client.list_deployments()
274
+
275
+ if not deployments:
276
+ rprint(
277
+ f"[yellow]No deployments found for project {client.project_id}[/yellow]"
278
+ )
279
+ return
280
+
281
+ table = Table(title=f"Deployments for project {client.project_id}")
282
+ table.add_column("Name", style="cyan")
283
+ table.add_column("ID", style="yellow")
284
+ table.add_column("Status", style="green")
285
+ table.add_column("Repository", style="blue")
286
+ table.add_column("Deployment File", style="magenta")
287
+ table.add_column("Git Ref", style="white")
288
+ table.add_column("PAT", style="red")
289
+ table.add_column("Secrets", style="bright_green")
290
+
291
+ for deployment in deployments:
292
+ name = deployment.name
293
+ deployment_id = deployment.id
294
+ status = deployment.status
295
+ repo_url = deployment.repo_url
296
+ deployment_file_path = deployment.deployment_file_path
297
+ git_ref = deployment.git_ref
298
+ has_pat = "✓" if deployment.has_personal_access_token else "-"
299
+ secret_names = deployment.secret_names
300
+ secrets_display = str(len(secret_names)) if secret_names else "-"
301
+
302
+ table.add_row(
303
+ name,
304
+ deployment_id,
305
+ status,
306
+ repo_url,
307
+ deployment_file_path,
308
+ git_ref,
309
+ has_pat,
310
+ secrets_display,
311
+ )
312
+
313
+ console.print(table)
314
+
315
+ except Exception as e:
316
+ rprint(f"[red]Error: {e}[/red]")
317
+ raise click.Abort()
318
+
319
+
320
+ @deployments.command("get")
321
+ @global_options
322
+ @click.argument("deployment_id", required=False)
323
+ def get_deployment(deployment_id: Optional[str]) -> None:
324
+ """Get details of a specific deployment"""
325
+ try:
326
+ client = get_client()
327
+
328
+ deployment_id = select_deployment(deployment_id)
329
+ if not deployment_id:
330
+ rprint("[yellow]No deployment selected[/yellow]")
331
+ return
332
+
333
+ deployment = client.get_deployment(deployment_id)
334
+
335
+ table = Table(title=f"Deployment: {deployment.name}")
336
+ table.add_column("Property", style="cyan")
337
+ table.add_column("Value", style="green")
338
+
339
+ table.add_row("ID", deployment.id)
340
+ table.add_row("Project ID", deployment.project_id)
341
+ table.add_row("Status", deployment.status)
342
+ table.add_row("Repository", deployment.repo_url)
343
+ table.add_row("Deployment File", deployment.deployment_file_path)
344
+ table.add_row("Git Ref", deployment.git_ref)
345
+ table.add_row("Has PAT", str(deployment.has_personal_access_token))
346
+
347
+ apiserver_url = deployment.apiserver_url
348
+ if apiserver_url:
349
+ table.add_row("API Server URL", str(apiserver_url))
350
+
351
+ secret_names = deployment.secret_names
352
+ if secret_names:
353
+ table.add_row("Secrets", ", ".join(secret_names))
354
+
355
+ console.print(table)
356
+
357
+ except Exception as e:
358
+ rprint(f"[red]Error: {e}[/red]")
359
+ raise click.Abort()
360
+
361
+
362
+ @deployments.command("create")
363
+ @global_options
364
+ @click.option("--repo-url", help="HTTP(S) Git Repository URL")
365
+ @click.option("--name", help="Deployment name")
366
+ @click.option("--deployment-file-path", help="Path to deployment file")
367
+ @click.option("--git-ref", help="Git reference (branch, tag, or commit)")
368
+ @click.option(
369
+ "--personal-access-token", help="Git Personal Access Token (HTTP Basic Auth)"
370
+ )
371
+ def create_deployment(
372
+ repo_url: Optional[str],
373
+ name: Optional[str],
374
+ deployment_file_path: Optional[str],
375
+ git_ref: Optional[str],
376
+ personal_access_token: Optional[str],
377
+ ) -> None:
378
+ """Create a new deployment"""
379
+
380
+ # Use interactive creation
381
+ deployment_form = create_deployment_form()
382
+ if deployment_form is None:
383
+ rprint("[yellow]Cancelled[/yellow]")
384
+ return
385
+
386
+ rprint(
387
+ f"[green]Created deployment: {deployment_form.name} (id: {deployment_form.id})[/green]"
388
+ )
389
+
390
+
391
+ @deployments.command("delete")
392
+ @global_options
393
+ @click.argument("deployment_id", required=False)
394
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
395
+ def delete_deployment(deployment_id: Optional[str], confirm: bool) -> None:
396
+ """Delete a deployment"""
397
+ try:
398
+ client = get_client()
399
+
400
+ deployment_id = select_deployment(deployment_id)
401
+ if not deployment_id:
402
+ rprint("[yellow]No deployment selected[/yellow]")
403
+ return
404
+
405
+ if not confirm:
406
+ if not confirm_action(f"Delete deployment '{deployment_id}'?"):
407
+ rprint("[yellow]Cancelled[/yellow]")
408
+ return
409
+
410
+ client.delete_deployment(deployment_id)
411
+ rprint(f"[green]Deleted deployment: {deployment_id}[/green]")
412
+
413
+ except Exception as e:
414
+ rprint(f"[red]Error: {e}[/red]")
415
+ raise click.Abort()
416
+
417
+
418
+ @deployments.command("edit")
419
+ @global_options
420
+ @click.argument("deployment_id", required=False)
421
+ def edit_deployment(deployment_id: Optional[str]) -> None:
422
+ """Interactively edit a deployment"""
423
+ try:
424
+ client = get_client()
425
+
426
+ deployment_id = select_deployment(deployment_id)
427
+ if not deployment_id:
428
+ rprint("[yellow]No deployment selected[/yellow]")
429
+ return
430
+
431
+ # Get current deployment details
432
+ current_deployment = client.get_deployment(deployment_id)
433
+
434
+ # Use the interactive edit form
435
+ updated_deployment = edit_deployment_form(current_deployment)
436
+ if updated_deployment is None:
437
+ rprint("[yellow]Cancelled[/yellow]")
438
+ return
439
+
440
+ rprint(
441
+ f"[green]Successfully updated deployment: {updated_deployment.name}[/green]"
442
+ )
443
+
444
+ except Exception as e:
445
+ rprint(f"[red]Error: {e}[/red]")
446
+ raise click.Abort()
447
+
448
+
449
+ @deployments.command("refresh")
450
+ @global_options
451
+ @click.argument("deployment_id", required=False)
452
+ def refresh_deployment(deployment_id: Optional[str]) -> None:
453
+ """Refresh a deployment with the latest code from its git reference"""
454
+ try:
455
+ client = get_client()
456
+
457
+ deployment_id = select_deployment(deployment_id)
458
+ if not deployment_id:
459
+ rprint("[yellow]No deployment selected[/yellow]")
460
+ return
461
+
462
+ # Get current deployment details to show what we're refreshing
463
+ current_deployment = client.get_deployment(deployment_id)
464
+ deployment_name = current_deployment.name
465
+ old_git_sha = current_deployment.git_sha or ""
466
+
467
+ # Create an empty update to force git SHA refresh with spinner
468
+ with console.status(f"Refreshing {deployment_name}..."):
469
+ deployment_update = DeploymentUpdate()
470
+ updated_deployment = client.update_deployment(
471
+ deployment_id, deployment_update, force_git_sha_update=True
472
+ )
473
+
474
+ # Show the git SHA change with short SHAs
475
+ new_git_sha = updated_deployment.git_sha or ""
476
+ old_short = old_git_sha[:7] if old_git_sha else "none"
477
+ new_short = new_git_sha[:7] if new_git_sha else "none"
478
+
479
+ if old_git_sha == new_git_sha:
480
+ rprint(f"No changes: already at {new_short}")
481
+ else:
482
+ rprint(f"Updated: {old_short} → {new_short}")
483
+
484
+ except Exception as e:
485
+ rprint(f"[red]Error: {e}[/red]")
486
+ raise click.Abort()
487
+
488
+
489
+ @click.command("serve")
490
+ @click.argument(
491
+ "deployment_file",
492
+ required=False,
493
+ default=DEFAULT_DEPLOYMENT_FILE_PATH,
494
+ type=click.Path(dir_okay=False, resolve_path=True, path_type=Path), # type: ignore
495
+ )
496
+ @global_options
497
+ def serve(deployment_file: Path) -> None:
498
+ """Run llama_deploy API Server in the foreground. If no deployment_file is provided, will look for a llama_deploy.yaml in the current directory."""
499
+ if not deployment_file.exists():
500
+ rprint(f"[red]Deployment file '{deployment_file}' not found[/red]")
501
+ raise click.Abort()
502
+
503
+ try:
504
+ env = os.environ.copy()
505
+ env["LLAMA_DEPLOY_APISERVER_DEPLOYMENTS_PATH"] = str(deployment_file.parent)
506
+
507
+ client = ApiserverClient()
508
+
509
+ uvicorn_p = subprocess.Popen(
510
+ [
511
+ "uvicorn",
512
+ "llama_deploy.appserver.app:app",
513
+ "--host",
514
+ "localhost",
515
+ "--port",
516
+ "4501",
517
+ ],
518
+ env=env,
519
+ )
520
+
521
+ retrying = Retrying(
522
+ stop=stop_after_attempt(5), wait=wait_fixed(RETRY_WAIT_SECONDS)
523
+ )
524
+ try:
525
+ for attempt in retrying:
526
+ with attempt:
527
+ client.sync.apiserver.deployments.create(
528
+ deployment_file.open("rb"),
529
+ base_path=deployment_file.parent,
530
+ local=True,
531
+ )
532
+ except RetryError as e:
533
+ uvicorn_p.terminate()
534
+ last: Optional[BaseException] = e.last_attempt.exception(0)
535
+ last_msg = ""
536
+ if last is not None:
537
+ last_msg = ": " + (
538
+ str(last.message) if hasattr(last, "message") else str(last)
539
+ )
540
+ raise click.ClickException(f"Failed to create deployment{last_msg}")
541
+
542
+ uvicorn_p.wait()
543
+
544
+ except KeyboardInterrupt:
545
+ print("Shutting down...")
546
+
547
+ except Exception as e:
548
+ rprint(f"[red]Error: {e}[/red]")
549
+ raise click.Abort()