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.
- llama_deploy/cli/__init__.py +9 -22
- llama_deploy/cli/app.py +69 -0
- llama_deploy/cli/auth/client.py +362 -0
- llama_deploy/cli/client.py +47 -170
- llama_deploy/cli/commands/aliased_group.py +33 -0
- llama_deploy/cli/commands/auth.py +696 -0
- llama_deploy/cli/commands/deployment.py +300 -0
- llama_deploy/cli/commands/env.py +211 -0
- llama_deploy/cli/commands/init.py +313 -0
- llama_deploy/cli/commands/serve.py +239 -0
- llama_deploy/cli/config/_config.py +390 -0
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +130 -0
- llama_deploy/cli/config/env_service.py +67 -0
- llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llama_deploy/cli/config/migrations/__init__.py +7 -0
- llama_deploy/cli/config/schema.py +61 -0
- llama_deploy/cli/env.py +5 -3
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +6 -72
- llama_deploy/cli/options.py +27 -5
- llama_deploy/cli/py.typed +0 -0
- llama_deploy/cli/styles.py +10 -0
- llama_deploy/cli/textual/deployment_form.py +263 -36
- llama_deploy/cli/textual/deployment_help.py +53 -0
- llama_deploy/cli/textual/deployment_monitor.py +466 -0
- llama_deploy/cli/textual/git_validation.py +20 -21
- llama_deploy/cli/textual/github_callback_server.py +17 -14
- llama_deploy/cli/textual/llama_loader.py +13 -1
- llama_deploy/cli/textual/secrets_form.py +28 -8
- llama_deploy/cli/textual/styles.tcss +49 -8
- llama_deploy/cli/utils/env_inject.py +23 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
- llamactl-0.3.0.dist-info/RECORD +38 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/cli/commands.py +0 -549
- llama_deploy/cli/config.py +0 -173
- llama_deploy/cli/textual/profile_form.py +0 -171
- llamactl-0.2.7a1.dist-info/RECORD +0 -19
- {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()
|