llamactl 0.2.7a1__py3-none-any.whl → 0.3.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.
Files changed (41) hide show
  1. llama_deploy/cli/__init__.py +9 -22
  2. llama_deploy/cli/app.py +69 -0
  3. llama_deploy/cli/auth/client.py +362 -0
  4. llama_deploy/cli/client.py +47 -170
  5. llama_deploy/cli/commands/aliased_group.py +33 -0
  6. llama_deploy/cli/commands/auth.py +696 -0
  7. llama_deploy/cli/commands/deployment.py +300 -0
  8. llama_deploy/cli/commands/env.py +211 -0
  9. llama_deploy/cli/commands/init.py +313 -0
  10. llama_deploy/cli/commands/serve.py +239 -0
  11. llama_deploy/cli/config/_config.py +390 -0
  12. llama_deploy/cli/config/_migrations.py +65 -0
  13. llama_deploy/cli/config/auth_service.py +130 -0
  14. llama_deploy/cli/config/env_service.py +67 -0
  15. llama_deploy/cli/config/migrations/0001_init.sql +35 -0
  16. llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
  17. llama_deploy/cli/config/migrations/__init__.py +7 -0
  18. llama_deploy/cli/config/schema.py +61 -0
  19. llama_deploy/cli/env.py +5 -3
  20. llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
  21. llama_deploy/cli/interactive_prompts/utils.py +6 -72
  22. llama_deploy/cli/options.py +27 -5
  23. llama_deploy/cli/py.typed +0 -0
  24. llama_deploy/cli/styles.py +10 -0
  25. llama_deploy/cli/textual/deployment_form.py +263 -36
  26. llama_deploy/cli/textual/deployment_help.py +53 -0
  27. llama_deploy/cli/textual/deployment_monitor.py +466 -0
  28. llama_deploy/cli/textual/git_validation.py +20 -21
  29. llama_deploy/cli/textual/github_callback_server.py +17 -14
  30. llama_deploy/cli/textual/llama_loader.py +13 -1
  31. llama_deploy/cli/textual/secrets_form.py +28 -8
  32. llama_deploy/cli/textual/styles.tcss +49 -8
  33. llama_deploy/cli/utils/env_inject.py +23 -0
  34. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
  35. llamactl-0.3.0.dist-info/RECORD +38 -0
  36. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
  37. llama_deploy/cli/commands.py +0 -549
  38. llama_deploy/cli/config.py +0 -173
  39. llama_deploy/cli/textual/profile_form.py +0 -171
  40. llamactl-0.2.7a1.dist-info/RECORD +0 -19
  41. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,300 @@
1
+ """CLI commands for managing LlamaDeploy deployments.
2
+
3
+ This command group lets you list, create, edit, refresh, and delete deployments.
4
+ A deployment points the control plane at your Git repository and deployment file
5
+ (e.g., `llama_deploy.yaml`). The control plane pulls your code at the selected
6
+ git ref, reads the config, and runs your app.
7
+ """
8
+
9
+ import asyncio
10
+
11
+ import click
12
+ import questionary
13
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
14
+ from llama_deploy.cli.styles import HEADER_COLOR, MUTED_COL, PRIMARY_COL, WARNING
15
+ from llama_deploy.core.schema.deployments import DeploymentUpdate
16
+ from rich import print as rprint
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from ..app import app, console
21
+ from ..client import get_project_client
22
+ from ..interactive_prompts.session_utils import (
23
+ is_interactive_session,
24
+ )
25
+ from ..interactive_prompts.utils import (
26
+ confirm_action,
27
+ )
28
+ from ..options import global_options, interactive_option
29
+ from ..textual.deployment_form import create_deployment_form, edit_deployment_form
30
+ from ..textual.deployment_monitor import monitor_deployment_screen
31
+
32
+
33
+ @app.group(
34
+ help="Deploy your app to the cloud.",
35
+ no_args_is_help=True,
36
+ )
37
+ @global_options
38
+ def deployments() -> None:
39
+ """Manage deployments"""
40
+ pass
41
+
42
+
43
+ # Deployments commands
44
+ @deployments.command("list")
45
+ @global_options
46
+ @interactive_option
47
+ def list_deployments(interactive: bool) -> None:
48
+ """List deployments for the configured project."""
49
+ validate_authenticated_profile(interactive)
50
+ try:
51
+ client = get_project_client()
52
+ deployments = asyncio.run(client.list_deployments())
53
+
54
+ if not deployments:
55
+ rprint(
56
+ f"[{WARNING}]No deployments found for project {client.project_id}[/]"
57
+ )
58
+ return
59
+
60
+ table = Table(show_edge=False, box=None, header_style=f"bold {HEADER_COLOR}")
61
+ table.add_column("Name", style=PRIMARY_COL)
62
+ table.add_column("Status", style=MUTED_COL)
63
+ table.add_column("URL", style=MUTED_COL)
64
+ table.add_column("Repository", style=MUTED_COL)
65
+
66
+ for deployment in deployments:
67
+ name = deployment.id
68
+ status = deployment.status
69
+ repo_url = deployment.repo_url
70
+ gh = "https://github.com/"
71
+ if repo_url.startswith(gh):
72
+ repo_url = "gh:" + repo_url.removeprefix(gh)
73
+
74
+ table.add_row(
75
+ name,
76
+ status,
77
+ str(deployment.apiserver_url or ""),
78
+ repo_url,
79
+ )
80
+
81
+ console.print(table)
82
+
83
+ except Exception as e:
84
+ rprint(f"[red]Error: {e}[/red]")
85
+ raise click.Abort()
86
+
87
+
88
+ @deployments.command("get")
89
+ @global_options
90
+ @click.argument("deployment_id", required=False)
91
+ @interactive_option
92
+ def get_deployment(deployment_id: str | None, interactive: bool) -> None:
93
+ """Get details of a specific deployment"""
94
+ validate_authenticated_profile(interactive)
95
+ try:
96
+ client = get_project_client()
97
+
98
+ deployment_id = select_deployment(deployment_id)
99
+ if not deployment_id:
100
+ rprint(f"[{WARNING}]No deployment selected[/]")
101
+ return
102
+ if interactive:
103
+ monitor_deployment_screen(deployment_id)
104
+ return
105
+
106
+ deployment = asyncio.run(client.get_deployment(deployment_id))
107
+
108
+ table = Table(show_edge=False, box=None, header_style=f"bold {HEADER_COLOR}")
109
+ table.add_column("Property", style=MUTED_COL, justify="right")
110
+ table.add_column("Value", style=PRIMARY_COL)
111
+
112
+ table.add_row("ID", Text(deployment.id))
113
+ table.add_row("Project ID", Text(deployment.project_id))
114
+ table.add_row("Status", Text(deployment.status))
115
+ table.add_row("Repository", Text(deployment.repo_url))
116
+ table.add_row("Deployment File", Text(deployment.deployment_file_path))
117
+ table.add_row("Git Ref", Text(deployment.git_ref or "-"))
118
+
119
+ apiserver_url = deployment.apiserver_url
120
+ table.add_row(
121
+ "API Server URL",
122
+ Text(str(apiserver_url) if apiserver_url else "-"),
123
+ )
124
+
125
+ secret_names = deployment.secret_names or []
126
+ table.add_row("Secrets", Text("\n".join(secret_names), style="italic"))
127
+
128
+ console.print(table)
129
+
130
+ except Exception as e:
131
+ rprint(f"[red]Error: {e}[/red]")
132
+ raise click.Abort()
133
+
134
+
135
+ @deployments.command("create")
136
+ @global_options
137
+ @interactive_option
138
+ def create_deployment(
139
+ interactive: bool,
140
+ ) -> None:
141
+ """Interactively create a new deployment"""
142
+
143
+ if not interactive:
144
+ raise click.ClickException(
145
+ "This command requires an interactive session. Run in a terminal or provide required arguments explicitly."
146
+ )
147
+ validate_authenticated_profile(interactive)
148
+
149
+ # Use interactive creation
150
+ deployment_form = create_deployment_form()
151
+ if deployment_form is None:
152
+ rprint(f"[{WARNING}]Cancelled[/]")
153
+ return
154
+
155
+ rprint(
156
+ f"[green]Created deployment: {deployment_form.name} (id: {deployment_form.id})[/green]"
157
+ )
158
+
159
+
160
+ @deployments.command("delete")
161
+ @global_options
162
+ @click.argument("deployment_id", required=False)
163
+ @interactive_option
164
+ def delete_deployment(deployment_id: str | None, interactive: bool) -> None:
165
+ """Delete a deployment"""
166
+ validate_authenticated_profile(interactive)
167
+ try:
168
+ client = get_project_client()
169
+
170
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
171
+ if not deployment_id:
172
+ rprint(f"[{WARNING}]No deployment selected[/]")
173
+ return
174
+
175
+ if interactive:
176
+ if not confirm_action(f"Delete deployment '{deployment_id}'?"):
177
+ rprint(f"[{WARNING}]Cancelled[/]")
178
+ return
179
+
180
+ asyncio.run(client.delete_deployment(deployment_id))
181
+ rprint(f"[green]Deleted deployment: {deployment_id}[/green]")
182
+
183
+ except Exception as e:
184
+ rprint(f"[red]Error: {e}[/red]")
185
+ raise click.Abort()
186
+
187
+
188
+ @deployments.command("edit")
189
+ @global_options
190
+ @click.argument("deployment_id", required=False)
191
+ @interactive_option
192
+ def edit_deployment(deployment_id: str | None, interactive: bool) -> None:
193
+ """Interactively edit a deployment"""
194
+ validate_authenticated_profile(interactive)
195
+ try:
196
+ client = get_project_client()
197
+
198
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
199
+ if not deployment_id:
200
+ rprint(f"[{WARNING}]No deployment selected[/]")
201
+ return
202
+
203
+ # Get current deployment details
204
+ current_deployment = asyncio.run(client.get_deployment(deployment_id))
205
+
206
+ # Use the interactive edit form
207
+ updated_deployment = edit_deployment_form(current_deployment)
208
+ if updated_deployment is None:
209
+ rprint(f"[{WARNING}]Cancelled[/]")
210
+ return
211
+
212
+ rprint(
213
+ f"[green]Successfully updated deployment: {updated_deployment.name}[/green]"
214
+ )
215
+
216
+ except Exception as e:
217
+ rprint(f"[red]Error: {e}[/red]")
218
+ raise click.Abort()
219
+
220
+
221
+ @deployments.command("update")
222
+ @global_options
223
+ @click.argument("deployment_id", required=False)
224
+ @interactive_option
225
+ def refresh_deployment(deployment_id: str | None, interactive: bool) -> None:
226
+ """Update the deployment, pulling the latest code from it's branch"""
227
+ validate_authenticated_profile(interactive)
228
+ try:
229
+ deployment_id = select_deployment(deployment_id)
230
+ if not deployment_id:
231
+ rprint(f"[{WARNING}]No deployment selected[/]")
232
+ return
233
+
234
+ # Get current deployment details to show what we're refreshing
235
+ current_deployment = asyncio.run(
236
+ get_project_client().get_deployment(deployment_id)
237
+ )
238
+ deployment_name = current_deployment.name
239
+ old_git_sha = current_deployment.git_sha or ""
240
+
241
+ # Create an empty update to force git SHA refresh with spinner
242
+ with console.status(f"Refreshing {deployment_name}..."):
243
+ deployment_update = DeploymentUpdate()
244
+ updated_deployment = asyncio.run(
245
+ get_project_client().update_deployment(
246
+ deployment_id,
247
+ deployment_update,
248
+ )
249
+ )
250
+
251
+ # Show the git SHA change with short SHAs
252
+ new_git_sha = updated_deployment.git_sha or ""
253
+ old_short = old_git_sha[:7] if old_git_sha else "none"
254
+ new_short = new_git_sha[:7] if new_git_sha else "none"
255
+
256
+ if old_git_sha == new_git_sha:
257
+ rprint(f"No changes: already at {new_short}")
258
+ else:
259
+ rprint(f"Updated: {old_short} → {new_short}")
260
+
261
+ except Exception as e:
262
+ rprint(f"[red]Error: {e}[/red]")
263
+ raise click.Abort()
264
+
265
+
266
+ def select_deployment(
267
+ deployment_id: str | None = None, interactive: bool = is_interactive_session()
268
+ ) -> str | None:
269
+ """
270
+ Select a deployment interactively if ID not provided.
271
+ Returns the selected deployment ID or None if cancelled.
272
+
273
+ In non-interactive sessions, returns None if deployment_id is not provided.
274
+ """
275
+ if deployment_id:
276
+ return deployment_id
277
+
278
+ # Don't attempt interactive selection in non-interactive sessions
279
+ if not interactive:
280
+ return None
281
+
282
+ client = get_project_client()
283
+ deployments = asyncio.run(client.list_deployments())
284
+
285
+ if not deployments:
286
+ rprint(f"[{WARNING}]No deployments found for project {client.project_id}[/]")
287
+ return None
288
+
289
+ choices = []
290
+ for deployment in deployments:
291
+ name = deployment.name
292
+ deployment_id = deployment.id
293
+ status = deployment.status
294
+ choices.append(
295
+ questionary.Choice(
296
+ title=f"{name} ({deployment_id}) - {status}", value=deployment_id
297
+ )
298
+ )
299
+
300
+ return questionary.select("Select deployment:", choices=choices).ask()
@@ -0,0 +1,211 @@
1
+ from importlib import metadata as importlib_metadata
2
+
3
+ import click
4
+ import questionary
5
+ from llama_deploy.cli.config.schema import Environment
6
+ from llama_deploy.cli.styles import (
7
+ ACTIVE_INDICATOR,
8
+ HEADER_COLOR,
9
+ MUTED_COL,
10
+ PRIMARY_COL,
11
+ WARNING,
12
+ )
13
+ from packaging import version as packaging_version
14
+ from rich import print as rprint
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from ..app import console
19
+ from ..config.env_service import service
20
+ from ..options import global_options, interactive_option
21
+ from .auth import auth
22
+
23
+
24
+ @auth.group(
25
+ name="env",
26
+ help="Manage environments (control plane API URLs)",
27
+ no_args_is_help=True,
28
+ )
29
+ @global_options
30
+ def env_group() -> None:
31
+ pass
32
+
33
+
34
+ @env_group.command("list")
35
+ @global_options
36
+ def list_environments_cmd() -> None:
37
+ try:
38
+ envs = service.list_environments()
39
+ current_env = service.get_current_environment()
40
+
41
+ if not envs:
42
+ rprint(f"[{WARNING}]No environments found[/]")
43
+ return
44
+
45
+ table = Table(show_edge=False, box=None, header_style=f"bold {HEADER_COLOR}")
46
+ table.add_column(" API URL", style=PRIMARY_COL)
47
+ table.add_column("Requires Auth", style=MUTED_COL)
48
+
49
+ for env in envs:
50
+ text = Text()
51
+ if env == current_env:
52
+ text.append("* ", style=ACTIVE_INDICATOR)
53
+ else:
54
+ text.append(" ")
55
+ text.append(env.api_url)
56
+ table.add_row(text, Text("true" if env.requires_auth else "false"))
57
+
58
+ console.print(table)
59
+ except Exception as e:
60
+ rprint(f"[red]Error: {e}[/red]")
61
+ raise click.Abort()
62
+
63
+
64
+ @env_group.command("add")
65
+ @click.argument("api_url", required=False)
66
+ @interactive_option
67
+ @global_options
68
+ def add_environment_cmd(api_url: str | None, interactive: bool) -> None:
69
+ try:
70
+ if not api_url:
71
+ if not interactive:
72
+ raise click.ClickException("API URL is required when not interactive")
73
+ current_env = service.get_current_environment()
74
+ entered = questionary.text(
75
+ "Enter control plane API URL", default=current_env.api_url
76
+ ).ask()
77
+ if not entered:
78
+ rprint(f"[{WARNING}]No environment entered[/]")
79
+ return
80
+ api_url = entered.strip()
81
+
82
+ api_url = api_url.rstrip("/")
83
+ env = service.probe_environment(api_url)
84
+ service.create_or_update_environment(env)
85
+ rprint(
86
+ f"[green]Added environment[/green] {env.api_url} (requires_auth={env.requires_auth}, min_llamactl_version={env.min_llamactl_version or '-'})."
87
+ )
88
+ _maybe_warn_min_version(env.min_llamactl_version)
89
+ except Exception as e:
90
+ rprint(f"[red]Failed to add environment: {e}[/red]")
91
+ raise click.Abort()
92
+
93
+
94
+ @env_group.command("delete")
95
+ @click.argument("api_url", required=False)
96
+ @interactive_option
97
+ @global_options
98
+ def delete_environment_cmd(api_url: str | None, interactive: bool) -> None:
99
+ try:
100
+ if not api_url:
101
+ if not interactive:
102
+ raise click.ClickException("API URL is required when not interactive")
103
+ result = _select_environment(
104
+ service.list_environments(),
105
+ service.get_current_environment(),
106
+ "Select environment to delete",
107
+ )
108
+
109
+ if not result:
110
+ rprint(f"[{WARNING}]No environment selected[/]")
111
+ return
112
+ api_url = result.api_url
113
+
114
+ api_url = api_url.rstrip("/")
115
+ deleted = service.delete_environment(api_url)
116
+ if not deleted:
117
+ raise click.ClickException(f"Environment '{api_url}' not found")
118
+ rprint(
119
+ f"[green]Deleted environment[/green] {api_url} and all associated profiles"
120
+ )
121
+ except Exception as e:
122
+ rprint(f"[red]Failed to delete environment: {e}[/red]")
123
+ raise click.Abort()
124
+
125
+
126
+ @env_group.command("switch")
127
+ @click.argument("api_url", required=False)
128
+ @interactive_option
129
+ @global_options
130
+ def switch_environment_cmd(api_url: str | None, interactive: bool) -> None:
131
+ try:
132
+ selected_url = api_url
133
+
134
+ if not selected_url and interactive:
135
+ result = _select_environment(
136
+ service.list_environments(),
137
+ service.get_current_environment(),
138
+ "Select environment",
139
+ )
140
+ if not result:
141
+ rprint(f"[{WARNING}]No environment selected[/]")
142
+ return
143
+ selected_url = result.api_url
144
+
145
+ if not selected_url:
146
+ if interactive:
147
+ rprint(f"[{WARNING}]No environment selected[/]")
148
+ return
149
+ raise click.ClickException("API URL is required when not interactive")
150
+
151
+ selected_url = selected_url.rstrip("/")
152
+
153
+ # Ensure environment exists and switch
154
+ env = service.switch_environment(selected_url)
155
+ try:
156
+ env = service.auto_update_env(env)
157
+ except Exception as e:
158
+ rprint(f"[{WARNING}]Failed to resolve environment: {e}[/]")
159
+ return
160
+ service.current_auth_service().select_any_profile()
161
+ rprint(f"[green]Switched to environment[/green] {env.api_url}")
162
+ _maybe_warn_min_version(env.min_llamactl_version)
163
+ except Exception as e:
164
+ rprint(f"[red]Failed to switch environment: {e}[/red]")
165
+ raise click.Abort()
166
+
167
+
168
+ def _get_cli_version() -> str | None:
169
+ try:
170
+ return importlib_metadata.version("llamactl")
171
+ except Exception:
172
+ return None
173
+
174
+
175
+ def _maybe_warn_min_version(min_required: str | None) -> None:
176
+ if not min_required:
177
+ return
178
+ current = _get_cli_version()
179
+ if not current:
180
+ return
181
+ try:
182
+ if packaging_version.parse(current) < packaging_version.parse(min_required):
183
+ rprint(
184
+ f"[{WARNING}]Warning:[/] This environment requires llamactl >= [bold]{min_required}[/bold], you have [bold]{current}[/bold]."
185
+ )
186
+ except Exception:
187
+ # If packaging is not available or parsing fails, skip strict comparison
188
+ pass
189
+
190
+
191
+ def _select_environment(
192
+ envs: list[Environment],
193
+ current_env: Environment,
194
+ message: str = "Select environment",
195
+ ) -> Environment | None:
196
+ envs = service.list_environments()
197
+ current_env = service.get_current_environment()
198
+ if not envs:
199
+ raise click.ClickException(
200
+ "No environments found. This is a bug and shouldn't happen."
201
+ )
202
+ return questionary.select(
203
+ message,
204
+ choices=[
205
+ questionary.Choice(
206
+ title=f"{env.api_url} {'(current)' if env.api_url == current_env.api_url else ''}",
207
+ value=env,
208
+ )
209
+ for env in envs
210
+ ],
211
+ ).ask()