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