llamactl 0.3.0a12__py3-none-any.whl → 0.3.0a13__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 +2 -2
- llama_deploy/cli/app.py +3 -2
- llama_deploy/cli/client.py +26 -5
- llama_deploy/cli/commands/auth.py +382 -0
- llama_deploy/cli/commands/deployment.py +89 -38
- llama_deploy/cli/config.py +91 -23
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +12 -37
- llama_deploy/cli/options.py +24 -1
- llama_deploy/cli/platform_client.py +52 -0
- llama_deploy/cli/textual/api_key_profile_form.py +563 -0
- llama_deploy/cli/textual/deployment_form.py +11 -10
- llama_deploy/cli/textual/deployment_monitor.py +98 -105
- llama_deploy/cli/textual/git_validation.py +11 -9
- llama_deploy/cli/textual/styles.tcss +21 -5
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a13.dist-info}/METADATA +3 -3
- llamactl-0.3.0a13.dist-info/RECORD +29 -0
- llama_deploy/cli/commands/profile.py +0 -217
- llama_deploy/cli/textual/profile_form.py +0 -170
- llamactl-0.3.0a12.dist-info/RECORD +0 -27
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a13.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a13.dist-info}/entry_points.txt +0 -0
|
@@ -6,18 +6,24 @@ 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
|
|
13
17
|
|
|
14
18
|
from ..app import app, console
|
|
15
19
|
from ..client import get_project_client
|
|
20
|
+
from ..interactive_prompts.session_utils import (
|
|
21
|
+
is_interactive_session,
|
|
22
|
+
)
|
|
16
23
|
from ..interactive_prompts.utils import (
|
|
17
24
|
confirm_action,
|
|
18
|
-
select_deployment,
|
|
19
25
|
)
|
|
20
|
-
from ..options import global_options
|
|
26
|
+
from ..options import global_options, interactive_option
|
|
21
27
|
from ..textual.deployment_form import create_deployment_form, edit_deployment_form
|
|
22
28
|
from ..textual.deployment_monitor import monitor_deployment_screen
|
|
23
29
|
|
|
@@ -35,11 +41,13 @@ def deployments() -> None:
|
|
|
35
41
|
# Deployments commands
|
|
36
42
|
@deployments.command("list")
|
|
37
43
|
@global_options
|
|
38
|
-
|
|
44
|
+
@interactive_option
|
|
45
|
+
def list_deployments(interactive: bool) -> None:
|
|
39
46
|
"""List deployments for the configured project."""
|
|
47
|
+
validate_authenticated_profile(interactive)
|
|
40
48
|
try:
|
|
41
49
|
client = get_project_client()
|
|
42
|
-
deployments = client.list_deployments()
|
|
50
|
+
deployments = asyncio.run(client.list_deployments())
|
|
43
51
|
|
|
44
52
|
if not deployments:
|
|
45
53
|
rprint(
|
|
@@ -89,13 +97,10 @@ def list_deployments() -> None:
|
|
|
89
97
|
@deployments.command("get")
|
|
90
98
|
@global_options
|
|
91
99
|
@click.argument("deployment_id", required=False)
|
|
92
|
-
@
|
|
93
|
-
|
|
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:
|
|
100
|
+
@interactive_option
|
|
101
|
+
def get_deployment(deployment_id: str | None, interactive: bool) -> None:
|
|
98
102
|
"""Get details of a specific deployment"""
|
|
103
|
+
validate_authenticated_profile(interactive)
|
|
99
104
|
try:
|
|
100
105
|
client = get_project_client()
|
|
101
106
|
|
|
@@ -103,12 +108,11 @@ def get_deployment(deployment_id: str | None, non_interactive: bool) -> None:
|
|
|
103
108
|
if not deployment_id:
|
|
104
109
|
rprint("[yellow]No deployment selected[/yellow]")
|
|
105
110
|
return
|
|
106
|
-
|
|
107
|
-
if not non_interactive:
|
|
111
|
+
if interactive:
|
|
108
112
|
monitor_deployment_screen(deployment_id)
|
|
109
113
|
return
|
|
110
114
|
|
|
111
|
-
deployment = client.get_deployment(deployment_id)
|
|
115
|
+
deployment = asyncio.run(client.get_deployment(deployment_id))
|
|
112
116
|
|
|
113
117
|
table = Table(title=f"Deployment: {deployment.name}")
|
|
114
118
|
table.add_column("Property", style="cyan")
|
|
@@ -139,22 +143,18 @@ def get_deployment(deployment_id: str | None, non_interactive: bool) -> None:
|
|
|
139
143
|
|
|
140
144
|
@deployments.command("create")
|
|
141
145
|
@global_options
|
|
142
|
-
@
|
|
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
|
-
)
|
|
146
|
+
@interactive_option
|
|
149
147
|
def create_deployment(
|
|
150
|
-
|
|
151
|
-
name: str | None,
|
|
152
|
-
deployment_file_path: str | None,
|
|
153
|
-
git_ref: str | None,
|
|
154
|
-
personal_access_token: str | None,
|
|
148
|
+
interactive: bool,
|
|
155
149
|
) -> None:
|
|
156
150
|
"""Interactively create a new deployment"""
|
|
157
151
|
|
|
152
|
+
if not interactive:
|
|
153
|
+
raise click.ClickException(
|
|
154
|
+
"This command requires an interactive session. Run in a terminal or provide required arguments explicitly."
|
|
155
|
+
)
|
|
156
|
+
validate_authenticated_profile(interactive)
|
|
157
|
+
|
|
158
158
|
# Use interactive creation
|
|
159
159
|
deployment_form = create_deployment_form()
|
|
160
160
|
if deployment_form is None:
|
|
@@ -169,23 +169,24 @@ def create_deployment(
|
|
|
169
169
|
@deployments.command("delete")
|
|
170
170
|
@global_options
|
|
171
171
|
@click.argument("deployment_id", required=False)
|
|
172
|
-
@
|
|
173
|
-
def delete_deployment(deployment_id: str | None,
|
|
172
|
+
@interactive_option
|
|
173
|
+
def delete_deployment(deployment_id: str | None, interactive: bool) -> None:
|
|
174
174
|
"""Delete a deployment"""
|
|
175
|
+
validate_authenticated_profile(interactive)
|
|
175
176
|
try:
|
|
176
177
|
client = get_project_client()
|
|
177
178
|
|
|
178
|
-
deployment_id = select_deployment(deployment_id)
|
|
179
|
+
deployment_id = select_deployment(deployment_id, interactive=interactive)
|
|
179
180
|
if not deployment_id:
|
|
180
181
|
rprint("[yellow]No deployment selected[/yellow]")
|
|
181
182
|
return
|
|
182
183
|
|
|
183
|
-
if
|
|
184
|
+
if interactive:
|
|
184
185
|
if not confirm_action(f"Delete deployment '{deployment_id}'?"):
|
|
185
186
|
rprint("[yellow]Cancelled[/yellow]")
|
|
186
187
|
return
|
|
187
188
|
|
|
188
|
-
client.delete_deployment(deployment_id)
|
|
189
|
+
asyncio.run(client.delete_deployment(deployment_id))
|
|
189
190
|
rprint(f"[green]Deleted deployment: {deployment_id}[/green]")
|
|
190
191
|
|
|
191
192
|
except Exception as e:
|
|
@@ -196,18 +197,20 @@ def delete_deployment(deployment_id: str | None, confirm: bool) -> None:
|
|
|
196
197
|
@deployments.command("edit")
|
|
197
198
|
@global_options
|
|
198
199
|
@click.argument("deployment_id", required=False)
|
|
199
|
-
|
|
200
|
+
@interactive_option
|
|
201
|
+
def edit_deployment(deployment_id: str | None, interactive: bool) -> None:
|
|
200
202
|
"""Interactively edit a deployment"""
|
|
203
|
+
validate_authenticated_profile(interactive)
|
|
201
204
|
try:
|
|
202
205
|
client = get_project_client()
|
|
203
206
|
|
|
204
|
-
deployment_id = select_deployment(deployment_id)
|
|
207
|
+
deployment_id = select_deployment(deployment_id, interactive=interactive)
|
|
205
208
|
if not deployment_id:
|
|
206
209
|
rprint("[yellow]No deployment selected[/yellow]")
|
|
207
210
|
return
|
|
208
211
|
|
|
209
212
|
# Get current deployment details
|
|
210
|
-
current_deployment = client.get_deployment(deployment_id)
|
|
213
|
+
current_deployment = asyncio.run(client.get_deployment(deployment_id))
|
|
211
214
|
|
|
212
215
|
# Use the interactive edit form
|
|
213
216
|
updated_deployment = edit_deployment_form(current_deployment)
|
|
@@ -227,8 +230,10 @@ def edit_deployment(deployment_id: str | None) -> None:
|
|
|
227
230
|
@deployments.command("update")
|
|
228
231
|
@global_options
|
|
229
232
|
@click.argument("deployment_id", required=False)
|
|
230
|
-
|
|
233
|
+
@interactive_option
|
|
234
|
+
def refresh_deployment(deployment_id: str | None, interactive: bool) -> None:
|
|
231
235
|
"""Update the deployment, pulling the latest code from it's branch"""
|
|
236
|
+
validate_authenticated_profile(interactive)
|
|
232
237
|
try:
|
|
233
238
|
client = get_project_client()
|
|
234
239
|
|
|
@@ -238,16 +243,18 @@ def refresh_deployment(deployment_id: str | None) -> None:
|
|
|
238
243
|
return
|
|
239
244
|
|
|
240
245
|
# Get current deployment details to show what we're refreshing
|
|
241
|
-
current_deployment = client.get_deployment(deployment_id)
|
|
246
|
+
current_deployment = asyncio.run(client.get_deployment(deployment_id))
|
|
242
247
|
deployment_name = current_deployment.name
|
|
243
248
|
old_git_sha = current_deployment.git_sha or ""
|
|
244
249
|
|
|
245
250
|
# Create an empty update to force git SHA refresh with spinner
|
|
246
251
|
with console.status(f"Refreshing {deployment_name}..."):
|
|
247
252
|
deployment_update = DeploymentUpdate()
|
|
248
|
-
updated_deployment =
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
updated_deployment = asyncio.run(
|
|
254
|
+
client.update_deployment(
|
|
255
|
+
deployment_id,
|
|
256
|
+
deployment_update,
|
|
257
|
+
)
|
|
251
258
|
)
|
|
252
259
|
|
|
253
260
|
# Show the git SHA change with short SHAs
|
|
@@ -263,3 +270,47 @@ def refresh_deployment(deployment_id: str | None) -> None:
|
|
|
263
270
|
except Exception as e:
|
|
264
271
|
rprint(f"[red]Error: {e}[/red]")
|
|
265
272
|
raise click.Abort()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def select_deployment(
|
|
276
|
+
deployment_id: str | None = None, interactive: bool = is_interactive_session()
|
|
277
|
+
) -> str | None:
|
|
278
|
+
"""
|
|
279
|
+
Select a deployment interactively if ID not provided.
|
|
280
|
+
Returns the selected deployment ID or None if cancelled.
|
|
281
|
+
|
|
282
|
+
In non-interactive sessions, returns None if deployment_id is not provided.
|
|
283
|
+
"""
|
|
284
|
+
if deployment_id:
|
|
285
|
+
return deployment_id
|
|
286
|
+
|
|
287
|
+
# Don't attempt interactive selection in non-interactive sessions
|
|
288
|
+
if not interactive:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
client = get_project_client()
|
|
293
|
+
deployments = asyncio.run(client.list_deployments())
|
|
294
|
+
|
|
295
|
+
if not deployments:
|
|
296
|
+
rprint(
|
|
297
|
+
f"[yellow]No deployments found for project {client.project_id}[/yellow]"
|
|
298
|
+
)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
choices = []
|
|
302
|
+
for deployment in deployments:
|
|
303
|
+
name = deployment.name
|
|
304
|
+
deployment_id = deployment.id
|
|
305
|
+
status = deployment.status
|
|
306
|
+
choices.append(
|
|
307
|
+
questionary.Choice(
|
|
308
|
+
title=f"{name} ({deployment_id}) - {status}", value=deployment_id
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return questionary.select("Select deployment:", choices=choices).ask()
|
|
313
|
+
|
|
314
|
+
except Exception as e:
|
|
315
|
+
rprint(f"[red]Error loading deployments: {e}[/red]")
|
|
316
|
+
return None
|
llama_deploy/cli/config.py
CHANGED
|
@@ -13,7 +13,8 @@ class Profile:
|
|
|
13
13
|
|
|
14
14
|
name: str
|
|
15
15
|
api_url: str
|
|
16
|
-
|
|
16
|
+
project_id: str
|
|
17
|
+
api_key_auth_token: str | None = None
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class ConfigManager:
|
|
@@ -24,9 +25,16 @@ class ConfigManager:
|
|
|
24
25
|
self.db_path = self.config_dir / "profiles.db"
|
|
25
26
|
self._ensure_config_dir()
|
|
26
27
|
self._init_database()
|
|
28
|
+
self.default_control_plane_url = "https://api.llamacloud.com"
|
|
27
29
|
|
|
28
30
|
def _get_config_dir(self) -> Path:
|
|
29
|
-
"""Get the configuration directory path based on OS
|
|
31
|
+
"""Get the configuration directory path based on OS.
|
|
32
|
+
|
|
33
|
+
Honors LLAMACTL_CONFIG_DIR when set. This helps tests isolate state.
|
|
34
|
+
"""
|
|
35
|
+
override = os.environ.get("LLAMACTL_CONFIG_DIR")
|
|
36
|
+
if override:
|
|
37
|
+
return Path(override).expanduser()
|
|
30
38
|
if os.name == "nt": # Windows
|
|
31
39
|
config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
|
|
32
40
|
else: # Unix-like (Linux, macOS)
|
|
@@ -44,18 +52,35 @@ class ConfigManager:
|
|
|
44
52
|
cursor = conn.execute("PRAGMA table_info(profiles)")
|
|
45
53
|
columns = [row[1] for row in cursor.fetchall()]
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
# Migration: handle old active_project_id -> project_id and make it required
|
|
56
|
+
if "active_project_id" in columns and "project_id" not in columns:
|
|
57
|
+
# Delete any profiles that have no active_project_id since project_id is now required
|
|
58
|
+
conn.execute(
|
|
59
|
+
"DELETE FROM profiles WHERE active_project_id IS NULL OR active_project_id = ''"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Rename active_project_id to project_id
|
|
63
|
+
# Note: SQLite doesn't allow changing column constraints easily, but we enforce
|
|
64
|
+
# the NOT NULL constraint in our application code and new table creation
|
|
65
|
+
conn.execute(
|
|
66
|
+
"ALTER TABLE profiles RENAME COLUMN active_project_id TO project_id"
|
|
67
|
+
)
|
|
52
68
|
|
|
53
|
-
|
|
69
|
+
# Add api_key_auth_token column if not already present
|
|
70
|
+
mig_cursor = conn.execute("PRAGMA table_info(profiles)")
|
|
71
|
+
mig_columns = [row[1] for row in mig_cursor.fetchall()]
|
|
72
|
+
if "api_key_auth_token" not in mig_columns:
|
|
73
|
+
conn.execute(
|
|
74
|
+
"ALTER TABLE profiles ADD COLUMN api_key_auth_token TEXT"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Create tables with new schema (this will only create if they don't exist)
|
|
54
78
|
conn.execute("""
|
|
55
79
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
56
80
|
name TEXT PRIMARY KEY,
|
|
57
81
|
api_url TEXT NOT NULL,
|
|
58
|
-
|
|
82
|
+
project_id TEXT NOT NULL,
|
|
83
|
+
api_key_auth_token TEXT
|
|
59
84
|
)
|
|
60
85
|
""")
|
|
61
86
|
|
|
@@ -69,18 +94,32 @@ class ConfigManager:
|
|
|
69
94
|
conn.commit()
|
|
70
95
|
|
|
71
96
|
def create_profile(
|
|
72
|
-
self,
|
|
97
|
+
self,
|
|
98
|
+
name: str,
|
|
99
|
+
api_url: str,
|
|
100
|
+
project_id: str,
|
|
101
|
+
api_key_auth_token: str | None = None,
|
|
73
102
|
) -> Profile:
|
|
74
103
|
"""Create a new profile"""
|
|
104
|
+
if not project_id.strip():
|
|
105
|
+
raise ValueError("Project ID is required")
|
|
75
106
|
profile = Profile(
|
|
76
|
-
name=name,
|
|
107
|
+
name=name,
|
|
108
|
+
api_url=api_url,
|
|
109
|
+
project_id=project_id,
|
|
110
|
+
api_key_auth_token=api_key_auth_token,
|
|
77
111
|
)
|
|
78
112
|
|
|
79
113
|
with sqlite3.connect(self.db_path) as conn:
|
|
80
114
|
try:
|
|
81
115
|
conn.execute(
|
|
82
|
-
"INSERT INTO profiles (name, api_url,
|
|
83
|
-
(
|
|
116
|
+
"INSERT INTO profiles (name, api_url, project_id, api_key_auth_token) VALUES (?, ?, ?, ?)",
|
|
117
|
+
(
|
|
118
|
+
profile.name,
|
|
119
|
+
profile.api_url,
|
|
120
|
+
profile.project_id,
|
|
121
|
+
profile.api_key_auth_token,
|
|
122
|
+
),
|
|
84
123
|
)
|
|
85
124
|
conn.commit()
|
|
86
125
|
except sqlite3.IntegrityError:
|
|
@@ -92,12 +131,17 @@ class ConfigManager:
|
|
|
92
131
|
"""Get a profile by name"""
|
|
93
132
|
with sqlite3.connect(self.db_path) as conn:
|
|
94
133
|
cursor = conn.execute(
|
|
95
|
-
"SELECT name, api_url,
|
|
134
|
+
"SELECT name, api_url, project_id, api_key_auth_token FROM profiles WHERE name = ?",
|
|
96
135
|
(name,),
|
|
97
136
|
)
|
|
98
137
|
row = cursor.fetchone()
|
|
99
138
|
if row:
|
|
100
|
-
return Profile(
|
|
139
|
+
return Profile(
|
|
140
|
+
name=row[0],
|
|
141
|
+
api_url=row[1],
|
|
142
|
+
project_id=row[2],
|
|
143
|
+
api_key_auth_token=row[3],
|
|
144
|
+
)
|
|
101
145
|
return None
|
|
102
146
|
|
|
103
147
|
def list_profiles(self) -> List[Profile]:
|
|
@@ -105,11 +149,16 @@ class ConfigManager:
|
|
|
105
149
|
profiles = []
|
|
106
150
|
with sqlite3.connect(self.db_path) as conn:
|
|
107
151
|
cursor = conn.execute(
|
|
108
|
-
"SELECT name, api_url,
|
|
152
|
+
"SELECT name, api_url, project_id, api_key_auth_token FROM profiles ORDER BY name"
|
|
109
153
|
)
|
|
110
154
|
for row in cursor.fetchall():
|
|
111
155
|
profiles.append(
|
|
112
|
-
Profile(
|
|
156
|
+
Profile(
|
|
157
|
+
name=row[0],
|
|
158
|
+
api_url=row[1],
|
|
159
|
+
project_id=row[2],
|
|
160
|
+
api_key_auth_token=row[3],
|
|
161
|
+
)
|
|
113
162
|
)
|
|
114
163
|
return profiles
|
|
115
164
|
|
|
@@ -151,22 +200,41 @@ class ConfigManager:
|
|
|
151
200
|
current_name = self.get_current_profile_name()
|
|
152
201
|
if current_name:
|
|
153
202
|
return self.get_profile(current_name)
|
|
203
|
+
profiles = self.list_profiles()
|
|
204
|
+
if len(profiles) == 1:
|
|
205
|
+
return profiles[0]
|
|
154
206
|
return None
|
|
155
207
|
|
|
156
|
-
def
|
|
157
|
-
"""Set the
|
|
208
|
+
def set_project(self, profile_name: str, project_id: str) -> bool:
|
|
209
|
+
"""Set the project for a profile. Returns True if profile exists."""
|
|
158
210
|
with sqlite3.connect(self.db_path) as conn:
|
|
159
211
|
cursor = conn.execute(
|
|
160
|
-
"UPDATE profiles SET
|
|
212
|
+
"UPDATE profiles SET project_id = ? WHERE name = ?",
|
|
161
213
|
(project_id, profile_name),
|
|
162
214
|
)
|
|
163
215
|
conn.commit()
|
|
164
216
|
return cursor.rowcount > 0
|
|
165
217
|
|
|
166
|
-
def
|
|
167
|
-
"""
|
|
218
|
+
def set_default_control_plane_url(self, url: str) -> None:
|
|
219
|
+
"""Set the default control plane URL for the current session"""
|
|
220
|
+
self.default_control_plane_url = url
|
|
221
|
+
|
|
222
|
+
def get_project(self, profile_name: str) -> str | None:
|
|
223
|
+
"""Get the project for a profile"""
|
|
168
224
|
profile = self.get_profile(profile_name)
|
|
169
|
-
return profile.
|
|
225
|
+
return profile.project_id if profile else None
|
|
226
|
+
|
|
227
|
+
def set_api_key_auth_token(
|
|
228
|
+
self, profile_name: str, api_key_auth_token: str | None
|
|
229
|
+
) -> bool:
|
|
230
|
+
"""Set the API key auth token for a profile. Returns True if profile exists."""
|
|
231
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
232
|
+
cursor = conn.execute(
|
|
233
|
+
"UPDATE profiles SET api_key_auth_token = ? WHERE name = ?",
|
|
234
|
+
(api_key_auth_token, profile_name),
|
|
235
|
+
)
|
|
236
|
+
conn.commit()
|
|
237
|
+
return cursor.rowcount > 0
|
|
170
238
|
|
|
171
239
|
|
|
172
240
|
# Global config manager instance
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Utilities for detecting and handling interactive CLI sessions."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@functools.cache
|
|
9
|
+
def is_interactive_session() -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Detect if the current CLI session is interactive.
|
|
12
|
+
|
|
13
|
+
Returns True if the session is interactive (user can be prompted),
|
|
14
|
+
False if it's non-interactive (e.g., CI/CD, scripted environment).
|
|
15
|
+
|
|
16
|
+
This function checks multiple indicators:
|
|
17
|
+
- Whether stdin/stdout are connected to a TTY
|
|
18
|
+
- Explicit non-interactive environment variables
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
>>> if is_interactive_session():
|
|
22
|
+
... user_input = questionary.text("Enter value:").ask()
|
|
23
|
+
... else:
|
|
24
|
+
... raise click.ClickException("Value required in non-interactive mode")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Check if stdin and stdout are TTYs
|
|
28
|
+
# This is the most reliable indicator for interactive sessions
|
|
29
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
# Additional check for TERM environment variable
|
|
33
|
+
# Some environments set TERM=dumb for non-interactive sessions
|
|
34
|
+
if os.environ.get("TERM") == "dumb":
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
return True
|
|
@@ -4,56 +4,26 @@ import questionary
|
|
|
4
4
|
from rich import print as rprint
|
|
5
5
|
from rich.console import Console
|
|
6
6
|
|
|
7
|
-
from ..client import get_project_client as get_client
|
|
8
7
|
from ..config import config_manager
|
|
8
|
+
from .session_utils import is_interactive_session
|
|
9
9
|
|
|
10
10
|
console = Console()
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def select_deployment(deployment_id: str | None = None) -> str | None:
|
|
14
|
-
"""
|
|
15
|
-
Select a deployment interactively if ID not provided.
|
|
16
|
-
Returns the selected deployment ID or None if cancelled.
|
|
17
|
-
"""
|
|
18
|
-
if deployment_id:
|
|
19
|
-
return deployment_id
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
client = get_client()
|
|
23
|
-
deployments = client.list_deployments()
|
|
24
|
-
|
|
25
|
-
if not deployments:
|
|
26
|
-
rprint(
|
|
27
|
-
f"[yellow]No deployments found for project {client.project_id}[/yellow]"
|
|
28
|
-
)
|
|
29
|
-
return None
|
|
30
|
-
|
|
31
|
-
choices = []
|
|
32
|
-
for deployment in deployments:
|
|
33
|
-
name = deployment.name
|
|
34
|
-
deployment_id = deployment.id
|
|
35
|
-
status = deployment.status
|
|
36
|
-
choices.append(
|
|
37
|
-
questionary.Choice(
|
|
38
|
-
title=f"{name} ({deployment_id}) - {status}", value=deployment_id
|
|
39
|
-
)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
return questionary.select("Select deployment:", choices=choices).ask()
|
|
43
|
-
|
|
44
|
-
except Exception as e:
|
|
45
|
-
rprint(f"[red]Error loading deployments: {e}[/red]")
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
|
|
49
13
|
def select_profile(profile_name: str | None = None) -> str | None:
|
|
50
14
|
"""
|
|
51
15
|
Select a profile interactively if name not provided.
|
|
52
16
|
Returns the selected profile name or None if cancelled.
|
|
17
|
+
|
|
18
|
+
In non-interactive sessions, returns None if profile_name is not provided.
|
|
53
19
|
"""
|
|
54
20
|
if profile_name:
|
|
55
21
|
return profile_name
|
|
56
22
|
|
|
23
|
+
# Don't attempt interactive selection in non-interactive sessions
|
|
24
|
+
if not is_interactive_session():
|
|
25
|
+
return None
|
|
26
|
+
|
|
57
27
|
try:
|
|
58
28
|
profiles = config_manager.list_profiles()
|
|
59
29
|
|
|
@@ -80,5 +50,10 @@ def select_profile(profile_name: str | None = None) -> str | None:
|
|
|
80
50
|
def confirm_action(message: str, default: bool = False) -> bool:
|
|
81
51
|
"""
|
|
82
52
|
Ask for confirmation with a consistent interface.
|
|
53
|
+
|
|
54
|
+
In non-interactive sessions, returns the default value without prompting.
|
|
83
55
|
"""
|
|
56
|
+
if not is_interactive_session():
|
|
57
|
+
return default
|
|
58
|
+
|
|
84
59
|
return questionary.confirm(message, default=default).ask() or False
|
llama_deploy/cli/options.py
CHANGED
|
@@ -2,6 +2,10 @@ import logging
|
|
|
2
2
|
from typing import Callable, ParamSpec, TypeVar
|
|
3
3
|
|
|
4
4
|
import click
|
|
5
|
+
from llama_deploy.cli.config import config_manager
|
|
6
|
+
from llama_deploy.cli.interactive_prompts.session_utils import is_interactive_session
|
|
7
|
+
|
|
8
|
+
from .debug import setup_file_logging
|
|
5
9
|
|
|
6
10
|
P = ParamSpec("P")
|
|
7
11
|
R = TypeVar("R")
|
|
@@ -9,7 +13,6 @@ R = TypeVar("R")
|
|
|
9
13
|
|
|
10
14
|
def global_options(f: Callable[P, R]) -> Callable[P, R]:
|
|
11
15
|
"""Common decorator to add global options to command groups"""
|
|
12
|
-
from .debug import setup_file_logging
|
|
13
16
|
|
|
14
17
|
def debug_callback(ctx: click.Context, param: click.Parameter, value: str) -> str:
|
|
15
18
|
if value:
|
|
@@ -27,3 +30,23 @@ def global_options(f: Callable[P, R]) -> Callable[P, R]:
|
|
|
27
30
|
is_eager=True,
|
|
28
31
|
hidden=True,
|
|
29
32
|
)(f)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def control_plane_url_callback(
|
|
36
|
+
ctx: click.Context, param: click.Parameter, value: str
|
|
37
|
+
) -> str:
|
|
38
|
+
if value:
|
|
39
|
+
config_manager.set_default_control_plane_url(value)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def interactive_option(f: Callable[P, R]) -> Callable[P, R]:
|
|
44
|
+
"""Add an interactive option to the command"""
|
|
45
|
+
|
|
46
|
+
default = is_interactive_session()
|
|
47
|
+
return click.option(
|
|
48
|
+
"--interactive/--no-interactive",
|
|
49
|
+
help="Run in interactive mode. If not provided, will default to the current session's interactive state.",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
default=default,
|
|
52
|
+
)(f)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel, TypeAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PlatformClient:
|
|
10
|
+
def __init__(self, auth_token: str, platform_url: str):
|
|
11
|
+
self.auth_token = auth_token
|
|
12
|
+
self.platform_url = platform_url
|
|
13
|
+
self.client = httpx.AsyncClient(base_url=platform_url)
|
|
14
|
+
|
|
15
|
+
async def list_projects(self) -> list[Project]:
|
|
16
|
+
response = await self.client.get("/api/v1/projects")
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
return ProjectList.validate_python(response.json())
|
|
19
|
+
|
|
20
|
+
async def list_organizations(self) -> list[Organization]:
|
|
21
|
+
response = await self.client.get("/api/v1/organizations")
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
return OrganizationList.validate_python(response.json())
|
|
24
|
+
|
|
25
|
+
async def validate_auth_token(self) -> bool:
|
|
26
|
+
response = await self.client.get("/api/v1/organizations/default")
|
|
27
|
+
try:
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
return True
|
|
30
|
+
except httpx.HTTPStatusError:
|
|
31
|
+
if response.status_code == 401:
|
|
32
|
+
return False
|
|
33
|
+
raise
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Organization(BaseModel):
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
created_at: datetime
|
|
40
|
+
updated_at: datetime
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Project(BaseModel):
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
organization_id: str
|
|
47
|
+
created_at: datetime
|
|
48
|
+
updated_at: datetime
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
OrganizationList = TypeAdapter(list[Organization])
|
|
52
|
+
ProjectList = TypeAdapter(list[Project])
|