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.
@@ -1,6 +1,7 @@
1
+ from llama_deploy.cli.commands.auth import auth
1
2
  from llama_deploy.cli.commands.deployment import deployments
3
+ from llama_deploy.cli.commands.env import env_group
2
4
  from llama_deploy.cli.commands.init import init
3
- from llama_deploy.cli.commands.profile import profiles
4
5
  from llama_deploy.cli.commands.serve import serve
5
6
 
6
7
  from .app import app
@@ -11,7 +12,7 @@ def main() -> None:
11
12
  app()
12
13
 
13
14
 
14
- __all__ = ["app", "deployments", "profiles", "serve", "init"]
15
+ __all__ = ["app", "deployments", "auth", "serve", "init", "env_group"]
15
16
 
16
17
 
17
18
  if __name__ == "__main__":
llama_deploy/cli/app.py CHANGED
@@ -2,9 +2,8 @@ from importlib.metadata import PackageNotFoundError
2
2
  from importlib.metadata import version as pkg_version
3
3
 
4
4
  import click
5
- from llama_deploy.cli.client import get_control_plane_client
6
5
  from llama_deploy.cli.commands.aliased_group import AliasedGroup
7
- from llama_deploy.cli.config import config_manager
6
+ from llama_deploy.cli.config.env_service import service
8
7
  from llama_deploy.cli.options import global_options
9
8
  from rich import print as rprint
10
9
  from rich.console import Console
@@ -22,12 +21,11 @@ def print_version(ctx: click.Context, param: click.Option, value: bool) -> None:
22
21
  console.print(Text.assemble("client version: ", (ver, "green")))
23
22
 
24
23
  # If there is an active profile, attempt to query server version
25
- profile = config_manager.get_current_profile()
26
- if profile and profile.api_url:
24
+ auth_service = service.current_auth_service()
25
+ if auth_service:
27
26
  try:
28
- cp_client = get_control_plane_client()
29
- data = cp_client.server_version()
30
- server_ver = data.get("version")
27
+ data = auth_service.fetch_server_version()
28
+ server_ver = data.version
31
29
  console.print(
32
30
  Text.assemble(
33
31
  "server version: ",
@@ -1,34 +1,41 @@
1
- from llama_deploy.cli.config import config_manager
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncGenerator
3
+
4
+ from llama_deploy.cli.config.env_service import service
2
5
  from llama_deploy.core.client.manage_client import ControlPlaneClient, ProjectClient
3
6
  from rich import print as rprint
4
7
 
5
8
 
6
- def get_control_plane_client(base_url: str | None = None) -> ControlPlaneClient:
7
- profile = config_manager.get_current_profile()
8
- if not profile and not base_url:
9
- rprint("\n[bold red]No profile configured![/bold red]")
10
- rprint("\nTo get started, create a profile with:")
11
- rprint("[cyan]llamactl profile create[/cyan]")
12
- raise SystemExit(1)
13
- resolved_base_url = (base_url or (profile.api_url if profile else "")).rstrip("/")
14
- if not resolved_base_url:
15
- raise ValueError("API URL is required")
16
- return ControlPlaneClient(resolved_base_url)
9
+ def get_control_plane_client() -> ControlPlaneClient:
10
+ profile = service.current_auth_service().get_current_profile()
11
+ if profile:
12
+ resolved_base_url = profile.api_url.rstrip("/")
13
+ resolved_api_key = profile.api_key
14
+ return ControlPlaneClient(resolved_base_url, resolved_api_key)
17
15
 
16
+ # Fallback: allow env-scoped client construction for env operations
17
+ env = service.get_current_environment()
18
+ resolved_base_url = env.api_url.rstrip("/")
19
+ return ControlPlaneClient(resolved_base_url, None)
18
20
 
19
- def get_project_client(
20
- base_url: str | None = None, project_id: str | None = None
21
- ) -> ProjectClient:
22
- profile = config_manager.get_current_profile()
21
+
22
+ def get_project_client() -> ProjectClient:
23
+ profile = service.current_auth_service().get_current_profile()
23
24
  if not profile:
24
25
  rprint("\n[bold red]No profile configured![/bold red]")
25
26
  rprint("\nTo get started, create a profile with:")
26
- rprint("[cyan]llamactl profile create[/cyan]")
27
+ rprint("[cyan]llamactl auth token[/cyan]")
27
28
  raise SystemExit(1)
28
- resolved_base_url = (base_url or profile.api_url or "").rstrip("/")
29
- if not resolved_base_url:
30
- raise ValueError("API URL is required")
31
- resolved_project_id = project_id or profile.active_project_id
32
- if not resolved_project_id:
33
- raise ValueError("Project ID is required")
34
- return ProjectClient(resolved_base_url, resolved_project_id)
29
+ return ProjectClient(profile.api_url, profile.project_id, profile.api_key)
30
+
31
+
32
+ @asynccontextmanager
33
+ async def project_client_context() -> AsyncGenerator[ProjectClient, None]:
34
+ client = get_project_client()
35
+ try:
36
+ yield client
37
+ finally:
38
+ try:
39
+ await client.aclose()
40
+ except Exception:
41
+ pass
@@ -0,0 +1,377 @@
1
+ import asyncio
2
+
3
+ import click
4
+ import questionary
5
+ from llama_deploy.cli.client import get_control_plane_client
6
+ from llama_deploy.cli.config.auth_service import AuthService
7
+ from llama_deploy.cli.config.env_service import service
8
+ from llama_deploy.core.client.manage_client import ClientError, ControlPlaneClient
9
+ from llama_deploy.core.schema.projects import ProjectSummary
10
+ from rich import print as rprint
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from ..app import app, console
15
+ from ..config.schema import Auth
16
+ from ..options import global_options, interactive_option
17
+
18
+
19
+ # Create sub-applications for organizing commands
20
+ @app.group(
21
+ help="Login to llama cloud control plane to manage deployments",
22
+ no_args_is_help=True,
23
+ )
24
+ @global_options
25
+ def auth() -> None:
26
+ """Login to llama cloud control plane"""
27
+ pass
28
+
29
+
30
+ @auth.command("token")
31
+ @global_options
32
+ @click.option(
33
+ "--project-id",
34
+ help="Project ID to use for the login when creating non-interactively",
35
+ )
36
+ @click.option(
37
+ "--api-key",
38
+ help="API key to use for the login when creating non-interactively",
39
+ )
40
+ @interactive_option
41
+ def create_api_key_profile(
42
+ project_id: str | None,
43
+ api_key: str | None,
44
+ interactive: bool,
45
+ ) -> None:
46
+ """Authenticate with an API key and create a profile in the current environment."""
47
+ try:
48
+ auth_svc = service.current_auth_service()
49
+
50
+ # Non-interactive mode: require both api-key and project-id
51
+ if not interactive:
52
+ if not api_key or not project_id:
53
+ raise click.ClickException(
54
+ "--api-key and --project-id are required in non-interactive mode"
55
+ )
56
+ created = auth_svc.create_profile_from_token(project_id, api_key)
57
+ rprint(
58
+ f"[green]Created API key profile '{created.name}' and set as current[/green]"
59
+ )
60
+ return
61
+
62
+ # Interactive mode: prompt for token (masked) and validate
63
+ token_value = api_key or _prompt_for_api_key()
64
+ projects = _validate_token_and_list_projects(auth_svc, token_value)
65
+
66
+ # Select or enter project ID
67
+ selected_project_id = project_id or _select_or_enter_project(
68
+ projects, auth_svc.env.requires_auth
69
+ )
70
+ if not selected_project_id:
71
+ rprint("[yellow]No project selected[/yellow]")
72
+ return
73
+
74
+ # Create and set profile
75
+ created = auth_svc.create_profile_from_token(selected_project_id, token_value)
76
+ rprint(
77
+ f"[green]Created API key profile '{created.name}' and set as current[/green]"
78
+ )
79
+ except Exception as e:
80
+ rprint(f"[red]Error: {e}[/red]")
81
+ raise click.Abort()
82
+
83
+
84
+ @auth.command("list")
85
+ @global_options
86
+ def list_profiles() -> None:
87
+ """List all logged in profiles"""
88
+ try:
89
+ auth_svc = service.current_auth_service()
90
+ profiles = auth_svc.list_profiles()
91
+ current = auth_svc.get_current_profile()
92
+
93
+ if not profiles:
94
+ rprint("[yellow]No profiles found[/yellow]")
95
+ rprint("Create one with: [cyan]llamactl auth token[/cyan]")
96
+ return
97
+
98
+ table = Table(show_edge=False, box=None, header_style="bold cornflower_blue")
99
+ table.add_column(" Name")
100
+ table.add_column("Active Project", style="grey46")
101
+
102
+ for profile in profiles:
103
+ text = Text()
104
+ if profile == current:
105
+ text.append("* ", style="magenta")
106
+ else:
107
+ text.append(" ")
108
+ text.append(profile.name)
109
+ active_project = profile.project_id or "-"
110
+ table.add_row(
111
+ text,
112
+ active_project,
113
+ )
114
+
115
+ console.print(table)
116
+
117
+ except Exception as e:
118
+ rprint(f"[red]Error: {e}[/red]")
119
+ raise click.Abort()
120
+
121
+
122
+ @auth.command("switch")
123
+ @global_options
124
+ @click.argument("name", required=False)
125
+ @interactive_option
126
+ def switch_profile(name: str | None, interactive: bool) -> None:
127
+ """Switch to a different profile"""
128
+ auth_svc = service.current_auth_service()
129
+ try:
130
+ selected_auth = _select_profile(auth_svc, name, interactive)
131
+ if not selected_auth:
132
+ rprint("[yellow]No profile selected[/yellow]")
133
+ return
134
+
135
+ auth_svc.set_current_profile(selected_auth.name)
136
+ rprint(f"[green]Switched to profile '{selected_auth.name}'[/green]")
137
+
138
+ except Exception as e:
139
+ rprint(f"[red]Error: {e}[/red]")
140
+ raise click.Abort()
141
+
142
+
143
+ @auth.command("logout")
144
+ @global_options
145
+ @click.argument("name", required=False)
146
+ @interactive_option
147
+ def delete_profile(name: str | None, interactive: bool) -> None:
148
+ """Logout from a profile and wipe all associated data"""
149
+ try:
150
+ auth_svc = service.current_auth_service()
151
+ auth = _select_profile(auth_svc, name, interactive)
152
+ if not auth:
153
+ rprint("[yellow]No profile selected[/yellow]")
154
+ return
155
+
156
+ if auth_svc.delete_profile(auth.name):
157
+ rprint(f"[green]Logged out from '{auth.name}'[/green]")
158
+ else:
159
+ rprint(f"[red]Profile '{auth.name}' not found[/red]")
160
+
161
+ except Exception as e:
162
+ rprint(f"[red]Error: {e}[/red]")
163
+ raise click.Abort()
164
+
165
+
166
+ # Projects commands
167
+ @auth.command("project")
168
+ @click.argument("project_id", required=False)
169
+ @interactive_option
170
+ @global_options
171
+ def change_project(project_id: str | None, interactive: bool) -> None:
172
+ """Change the active project for the current profile"""
173
+ profile = validate_authenticated_profile(interactive)
174
+ if project_id and profile.project_id == project_id:
175
+ return
176
+ auth_svc = service.current_auth_service()
177
+ if project_id:
178
+ auth_svc.set_project(profile.name, project_id)
179
+ rprint(f"[green]Set active project to '{project_id}'[/green]")
180
+ return
181
+ if not interactive:
182
+ raise click.ClickException(
183
+ "No --project-id provided. Run `llamactl auth project --help` for more information."
184
+ )
185
+ try:
186
+ client = get_control_plane_client()
187
+ projects = asyncio.run(client.list_projects())
188
+
189
+ if not projects:
190
+ rprint("[yellow]No projects found[/yellow]")
191
+ return
192
+ result = questionary.select(
193
+ "Select a project",
194
+ choices=[
195
+ questionary.Choice(
196
+ title=f"{project.project_name} ({project.deployment_count} deployments)",
197
+ value=project.project_id,
198
+ )
199
+ for project in projects
200
+ ]
201
+ + (
202
+ [questionary.Choice(title="Create new project", value="__CREATE__")]
203
+ if not auth_svc.env.requires_auth
204
+ else []
205
+ ),
206
+ ).ask()
207
+ if result == "__CREATE__":
208
+ project_id = questionary.text("Enter project ID").ask()
209
+ result = project_id
210
+ if result:
211
+ auth_svc.set_project(profile.name, result)
212
+ rprint(f"[green]Set active project to '{result}'[/green]")
213
+ else:
214
+ rprint("[yellow]No project selected[/yellow]")
215
+ except Exception as e:
216
+ rprint(f"[red]Error: {e}[/red]")
217
+ raise click.Abort()
218
+
219
+
220
+ def validate_authenticated_profile(interactive: bool) -> Auth:
221
+ """Validate that the user is authenticated within the current environment.
222
+
223
+ - If there is a current profile, return it.
224
+ - If multiple profiles exist in the current environment, prompt to select in interactive mode.
225
+ - If none exist:
226
+ - If environment requires_auth: run token flow inline.
227
+ - Else: create profile without token after selecting a project.
228
+ """
229
+
230
+ auth_svc = service.current_auth_service()
231
+ existing = auth_svc.get_current_profile()
232
+ if existing:
233
+ return existing
234
+
235
+ if not interactive:
236
+ raise click.ClickException(
237
+ "No profile configured. Run `llamactl auth token` to create a profile."
238
+ )
239
+
240
+ # Filter profiles by current environment
241
+ env_profiles = auth_svc.list_profiles()
242
+ current_env = auth_svc.env
243
+
244
+ if len(env_profiles) > 1:
245
+ # Prompt to select
246
+ choice: Auth | None = questionary.select(
247
+ "Select profile",
248
+ choices=[questionary.Choice(title=p.name, value=p) for p in env_profiles],
249
+ ).ask()
250
+ if not choice:
251
+ raise click.ClickException("No profile selected")
252
+ auth_svc.set_current_profile(choice.name)
253
+ return choice
254
+ if len(env_profiles) == 1:
255
+ only = env_profiles[0]
256
+ auth_svc.set_current_profile(only.name)
257
+ return only
258
+
259
+ # No profiles exist for this env
260
+ if current_env.requires_auth:
261
+ # Inline token flow
262
+ created = _token_flow_for_env(auth_svc)
263
+ return created
264
+ else:
265
+ # No auth required: select project and create a default profile without token
266
+ project_id: str | None = questionary.text("Enter project ID").ask()
267
+ if not project_id:
268
+ raise click.ClickException("No project ID provided")
269
+ created = auth_svc.create_profile_from_token(project_id, None)
270
+ return created
271
+
272
+
273
+ # -----------------------------
274
+ # Helpers for token/profile flow
275
+ # -----------------------------
276
+
277
+
278
+ def _prompt_for_api_key() -> str:
279
+ while True:
280
+ entered = questionary.password("Enter API key token").ask()
281
+ if entered:
282
+ return entered.strip()
283
+ rprint("[yellow]API key is required[/yellow]")
284
+
285
+
286
+ def _validate_token_and_list_projects(
287
+ auth_svc: AuthService, api_key: str
288
+ ) -> list[ProjectSummary]:
289
+ async def _run():
290
+ async with ControlPlaneClient.ctx(auth_svc.env.api_url, api_key) as client:
291
+ return await client.list_projects()
292
+
293
+ try:
294
+ return asyncio.run(_run())
295
+ except ClientError as e:
296
+ if getattr(e, "status_code", None) == 401:
297
+ rprint("[red]Invalid API key. Please try again.[/red]")
298
+ return _validate_token_and_list_projects(auth_svc, _prompt_for_api_key())
299
+ if getattr(e, "status_code", None) == 403:
300
+ rprint("[red]This environment requires a valid API key.[/red]")
301
+ return _validate_token_and_list_projects(auth_svc, _prompt_for_api_key())
302
+ raise
303
+ except Exception as e:
304
+ raise click.ClickException(f"Failed to validate API key: {e}")
305
+
306
+
307
+ def _select_or_enter_project(
308
+ projects: list[ProjectSummary], requires_auth: bool
309
+ ) -> str | None:
310
+ if not projects:
311
+ return None
312
+ # select the only authorized project if there is only one
313
+ elif len(projects) == 1 and requires_auth:
314
+ return projects[0].project_id
315
+ else:
316
+ choice = questionary.select(
317
+ "Select a project",
318
+ choices=[
319
+ questionary.Choice(
320
+ title=f"{p.project_name} ({p.deployment_count} deployments)",
321
+ value=p.project_id,
322
+ )
323
+ for p in projects
324
+ ],
325
+ ).ask()
326
+ return choice
327
+
328
+
329
+ def _token_flow_for_env(auth_service: AuthService) -> Auth:
330
+ token_value = _prompt_for_api_key()
331
+ projects = _validate_token_and_list_projects(auth_service, token_value)
332
+ project_id = _select_or_enter_project(projects, auth_service.env.requires_auth)
333
+ if not project_id:
334
+ raise click.ClickException("No project selected")
335
+ created = auth_service.create_profile_from_token(project_id, token_value)
336
+ return created
337
+
338
+
339
+ def _select_profile(
340
+ auth_svc: AuthService, profile_name: str | None, is_interactive: bool
341
+ ) -> Auth | None:
342
+ """
343
+ Select a profile interactively if name not provided.
344
+ Returns the selected profile name or None if cancelled.
345
+
346
+ In non-interactive sessions, returns None if profile_name is not provided.
347
+ """
348
+ if profile_name:
349
+ profile = auth_svc.get_profile(profile_name)
350
+ if profile:
351
+ return profile
352
+
353
+ # Don't attempt interactive selection in non-interactive sessions
354
+ if not is_interactive:
355
+ return None
356
+
357
+ try:
358
+ profiles = auth_svc.list_profiles()
359
+
360
+ if not profiles:
361
+ rprint("[yellow]No profiles found[/yellow]")
362
+ return None
363
+
364
+ choices = []
365
+ current = auth_svc.get_current_profile()
366
+
367
+ for profile in profiles:
368
+ title = f"{profile.name} ({profile.api_url})"
369
+ if profile == current:
370
+ title += " [current]"
371
+ choices.append(questionary.Choice(title=title, value=profile))
372
+
373
+ return questionary.select("Select profile:", choices=choices).ask()
374
+
375
+ except Exception as e:
376
+ rprint(f"[red]Error loading profiles: {e}[/red]")
377
+ return None