llamactl 0.3.0a4__py3-none-any.whl → 0.3.0a5__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 +7 -25
- llama_deploy/cli/app.py +71 -0
- llama_deploy/cli/client.py +6 -11
- llama_deploy/cli/commands/aliased_group.py +31 -0
- llama_deploy/cli/commands/deployment.py +255 -0
- llama_deploy/cli/commands/profile.py +217 -0
- llama_deploy/cli/commands/serve.py +68 -0
- llama_deploy/cli/config.py +11 -11
- llama_deploy/cli/env.py +3 -2
- llama_deploy/cli/interactive_prompts/utils.py +2 -2
- llama_deploy/cli/options.py +2 -0
- llama_deploy/cli/textual/deployment_form.py +147 -12
- llama_deploy/cli/textual/deployment_help.py +53 -0
- llama_deploy/cli/textual/git_validation.py +6 -7
- llama_deploy/cli/textual/github_callback_server.py +1 -1
- llama_deploy/cli/textual/llama_loader.py +1 -0
- llama_deploy/cli/textual/profile_form.py +6 -7
- llama_deploy/cli/textual/secrets_form.py +24 -8
- llama_deploy/cli/textual/styles.tcss +23 -0
- {llamactl-0.3.0a4.dist-info → llamactl-0.3.0a5.dist-info}/METADATA +3 -3
- llamactl-0.3.0a5.dist-info/RECORD +24 -0
- llama_deploy/cli/commands.py +0 -577
- llamactl-0.3.0a4.dist-info/RECORD +0 -19
- {llamactl-0.3.0a4.dist-info → llamactl-0.3.0a5.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a4.dist-info → llamactl-0.3.0a5.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from llama_deploy.appserver.app import (
|
|
5
|
+
prepare_server,
|
|
6
|
+
start_server_in_target_venv,
|
|
7
|
+
)
|
|
8
|
+
from llama_deploy.core.config import DEFAULT_DEPLOYMENT_FILE_PATH
|
|
9
|
+
from rich import print as rprint
|
|
10
|
+
|
|
11
|
+
from ..app import app
|
|
12
|
+
from ..options import global_options
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command(
|
|
16
|
+
"serve",
|
|
17
|
+
help="Serve a LlamaDeploy app locally for development and testing",
|
|
18
|
+
)
|
|
19
|
+
@click.argument(
|
|
20
|
+
"deployment_file",
|
|
21
|
+
required=False,
|
|
22
|
+
default=DEFAULT_DEPLOYMENT_FILE_PATH,
|
|
23
|
+
type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--no-install", is_flag=True, help="Skip installing python and js dependencies"
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--no-reload", is_flag=True, help="Skip reloading the API server on code changes"
|
|
30
|
+
)
|
|
31
|
+
@click.option("--no-open-browser", is_flag=True, help="Skip opening the browser")
|
|
32
|
+
@click.option(
|
|
33
|
+
"--preview",
|
|
34
|
+
is_flag=True,
|
|
35
|
+
help="Preview mode pre-builds the UI to static files, like a production build",
|
|
36
|
+
)
|
|
37
|
+
@global_options
|
|
38
|
+
def serve(
|
|
39
|
+
deployment_file: Path,
|
|
40
|
+
no_install: bool,
|
|
41
|
+
no_reload: bool,
|
|
42
|
+
no_open_browser: bool,
|
|
43
|
+
preview: bool,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Run llama_deploy API Server in the foreground. If no deployment_file is provided, will look for a llama_deploy.yaml in the current directory."""
|
|
46
|
+
if not deployment_file.exists():
|
|
47
|
+
rprint(f"[red]Deployment file '{deployment_file}' not found[/red]")
|
|
48
|
+
raise click.Abort()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
prepare_server(
|
|
52
|
+
install=not no_install,
|
|
53
|
+
build=preview,
|
|
54
|
+
)
|
|
55
|
+
start_server_in_target_venv(
|
|
56
|
+
cwd=Path.cwd(),
|
|
57
|
+
deployment_file=deployment_file,
|
|
58
|
+
proxy_ui=not preview,
|
|
59
|
+
reload=not no_reload,
|
|
60
|
+
open_browser=not no_open_browser,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
print("Shutting down...")
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
68
|
+
raise click.Abort()
|
llama_deploy/cli/config.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Configuration and profile management for llamactl"""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sqlite3
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Optional, List
|
|
6
5
|
from dataclasses import dataclass
|
|
7
|
-
import
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@dataclass
|
|
@@ -13,7 +13,7 @@ class Profile:
|
|
|
13
13
|
|
|
14
14
|
name: str
|
|
15
15
|
api_url: str
|
|
16
|
-
active_project_id:
|
|
16
|
+
active_project_id: str | None = None
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class ConfigManager:
|
|
@@ -69,7 +69,7 @@ class ConfigManager:
|
|
|
69
69
|
conn.commit()
|
|
70
70
|
|
|
71
71
|
def create_profile(
|
|
72
|
-
self, name: str, api_url: str, active_project_id:
|
|
72
|
+
self, name: str, api_url: str, active_project_id: str | None = None
|
|
73
73
|
) -> Profile:
|
|
74
74
|
"""Create a new profile"""
|
|
75
75
|
profile = Profile(
|
|
@@ -88,7 +88,7 @@ class ConfigManager:
|
|
|
88
88
|
|
|
89
89
|
return profile
|
|
90
90
|
|
|
91
|
-
def get_profile(self, name: str) ->
|
|
91
|
+
def get_profile(self, name: str) -> Profile | None:
|
|
92
92
|
"""Get a profile by name"""
|
|
93
93
|
with sqlite3.connect(self.db_path) as conn:
|
|
94
94
|
cursor = conn.execute(
|
|
@@ -125,7 +125,7 @@ class ConfigManager:
|
|
|
125
125
|
|
|
126
126
|
return cursor.rowcount > 0
|
|
127
127
|
|
|
128
|
-
def set_current_profile(self, name:
|
|
128
|
+
def set_current_profile(self, name: str | None):
|
|
129
129
|
"""Set the current active profile"""
|
|
130
130
|
with sqlite3.connect(self.db_path) as conn:
|
|
131
131
|
if name is None:
|
|
@@ -137,7 +137,7 @@ class ConfigManager:
|
|
|
137
137
|
)
|
|
138
138
|
conn.commit()
|
|
139
139
|
|
|
140
|
-
def get_current_profile_name(self) ->
|
|
140
|
+
def get_current_profile_name(self) -> str | None:
|
|
141
141
|
"""Get the name of the current active profile"""
|
|
142
142
|
with sqlite3.connect(self.db_path) as conn:
|
|
143
143
|
cursor = conn.execute(
|
|
@@ -146,14 +146,14 @@ class ConfigManager:
|
|
|
146
146
|
row = cursor.fetchone()
|
|
147
147
|
return row[0] if row else None
|
|
148
148
|
|
|
149
|
-
def get_current_profile(self) ->
|
|
149
|
+
def get_current_profile(self) -> Profile | None:
|
|
150
150
|
"""Get the current active profile"""
|
|
151
151
|
current_name = self.get_current_profile_name()
|
|
152
152
|
if current_name:
|
|
153
153
|
return self.get_profile(current_name)
|
|
154
154
|
return None
|
|
155
155
|
|
|
156
|
-
def set_active_project(self, profile_name: str, project_id:
|
|
156
|
+
def set_active_project(self, profile_name: str, project_id: str | None) -> bool:
|
|
157
157
|
"""Set the active project for a profile. Returns True if profile exists."""
|
|
158
158
|
with sqlite3.connect(self.db_path) as conn:
|
|
159
159
|
cursor = conn.execute(
|
|
@@ -163,7 +163,7 @@ class ConfigManager:
|
|
|
163
163
|
conn.commit()
|
|
164
164
|
return cursor.rowcount > 0
|
|
165
165
|
|
|
166
|
-
def get_active_project(self, profile_name: str) ->
|
|
166
|
+
def get_active_project(self, profile_name: str) -> str | None:
|
|
167
167
|
"""Get the active project for a profile"""
|
|
168
168
|
profile = self.get_profile(profile_name)
|
|
169
169
|
return profile.active_project_id if profile else None
|
llama_deploy/cli/env.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Environment variable handling utilities for llamactl"""
|
|
2
2
|
|
|
3
|
-
from typing import Dict
|
|
4
3
|
from io import StringIO
|
|
5
|
-
from
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
6
|
from dotenv import dotenv_values
|
|
7
|
+
from rich import print as rprint
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def load_env_secrets_from_string(env_content: str) -> Dict[str, str]:
|
|
@@ -12,7 +12,7 @@ from ..config import config_manager
|
|
|
12
12
|
console = Console()
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def select_deployment(deployment_id:
|
|
15
|
+
def select_deployment(deployment_id: str | None = None) -> str | None:
|
|
16
16
|
"""
|
|
17
17
|
Select a deployment interactively if ID not provided.
|
|
18
18
|
Returns the selected deployment ID or None if cancelled.
|
|
@@ -48,7 +48,7 @@ def select_deployment(deployment_id: Optional[str] = None) -> Optional[str]:
|
|
|
48
48
|
return None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def select_profile(profile_name:
|
|
51
|
+
def select_profile(profile_name: str | None = None) -> str | None:
|
|
52
52
|
"""
|
|
53
53
|
Select a profile interactively if name not provided.
|
|
54
54
|
Returns the selected profile name or None if cancelled.
|
llama_deploy/cli/options.py
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
"""Textual-based deployment forms for CLI interactions"""
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
3
|
import dataclasses
|
|
5
4
|
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
6
8
|
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
7
10
|
|
|
8
|
-
from llama_deploy.cli.
|
|
11
|
+
from llama_deploy.cli.client import get_project_client as get_client
|
|
12
|
+
from llama_deploy.cli.env import load_env_secrets_from_string
|
|
13
|
+
from llama_deploy.cli.textual.deployment_help import (
|
|
14
|
+
DeploymentHelpBackMessage,
|
|
15
|
+
DeploymentHelpWidget,
|
|
16
|
+
)
|
|
9
17
|
from llama_deploy.cli.textual.git_validation import (
|
|
10
18
|
GitValidationWidget,
|
|
11
|
-
ValidationResultMessage,
|
|
12
19
|
ValidationCancelMessage,
|
|
20
|
+
ValidationResultMessage,
|
|
13
21
|
)
|
|
22
|
+
from llama_deploy.cli.textual.secrets_form import SecretsWidget
|
|
23
|
+
from llama_deploy.core.deployment_config import DeploymentConfig
|
|
24
|
+
from llama_deploy.core.git.git_util import get_current_branch, is_git_repo, list_remotes
|
|
14
25
|
from llama_deploy.core.schema.deployments import (
|
|
15
26
|
DeploymentCreate,
|
|
16
27
|
DeploymentResponse,
|
|
@@ -18,11 +29,11 @@ from llama_deploy.core.schema.deployments import (
|
|
|
18
29
|
)
|
|
19
30
|
from textual.app import App, ComposeResult
|
|
20
31
|
from textual.containers import Container, HorizontalGroup, Widget
|
|
32
|
+
from textual.content import Content
|
|
33
|
+
from textual.message import Message
|
|
34
|
+
from textual.reactive import reactive
|
|
21
35
|
from textual.validation import Length
|
|
22
36
|
from textual.widgets import Button, Input, Label, Static
|
|
23
|
-
from textual.reactive import reactive
|
|
24
|
-
from llama_deploy.cli.client import get_project_client as get_client
|
|
25
|
-
from textual.message import Message
|
|
26
37
|
|
|
27
38
|
|
|
28
39
|
@dataclass
|
|
@@ -119,8 +130,11 @@ class DeploymentFormWidget(Widget):
|
|
|
119
130
|
|
|
120
131
|
def compose(self) -> ComposeResult:
|
|
121
132
|
title = "Edit Deployment" if self.form_data.is_editing else "Create Deployment"
|
|
133
|
+
|
|
122
134
|
yield Static(
|
|
123
|
-
|
|
135
|
+
Content.from_markup(
|
|
136
|
+
f"{title} [italic][@click=app.show_help()]More info[/][/italic]"
|
|
137
|
+
),
|
|
124
138
|
classes="primary-message",
|
|
125
139
|
)
|
|
126
140
|
yield Static(
|
|
@@ -201,7 +215,7 @@ class DeploymentFormWidget(Widget):
|
|
|
201
215
|
if event.button.id == "save":
|
|
202
216
|
self._save()
|
|
203
217
|
elif event.button.id == "change_pat":
|
|
204
|
-
updated_form = dataclasses.replace(self.
|
|
218
|
+
updated_form = dataclasses.replace(self.resolve_form_data())
|
|
205
219
|
updated_form.has_existing_pat = False
|
|
206
220
|
updated_form.personal_access_token = ""
|
|
207
221
|
self.form_data = updated_form
|
|
@@ -210,7 +224,7 @@ class DeploymentFormWidget(Widget):
|
|
|
210
224
|
self.post_message(CancelFormMessage())
|
|
211
225
|
|
|
212
226
|
def _save(self) -> None:
|
|
213
|
-
self.form_data = self.
|
|
227
|
+
self.form_data = self.resolve_form_data()
|
|
214
228
|
if self._validate_form():
|
|
215
229
|
# Post message to parent app to start validation
|
|
216
230
|
self.post_message(StartValidationMessage(self.form_data))
|
|
@@ -245,7 +259,7 @@ class DeploymentFormWidget(Widget):
|
|
|
245
259
|
"""Show an error message"""
|
|
246
260
|
self.error_message = message
|
|
247
261
|
|
|
248
|
-
def
|
|
262
|
+
def resolve_form_data(self) -> DeploymentForm:
|
|
249
263
|
"""Extract form data from inputs"""
|
|
250
264
|
name_input = self.query_one("#name", Input)
|
|
251
265
|
repo_url_input = self.query_one("#repo_url", Input)
|
|
@@ -299,12 +313,22 @@ class StartValidationMessage(Message):
|
|
|
299
313
|
self.form_data = form_data
|
|
300
314
|
|
|
301
315
|
|
|
316
|
+
class ShowHelpMessage(Message):
|
|
317
|
+
def __init__(self, form_data: DeploymentForm):
|
|
318
|
+
super().__init__()
|
|
319
|
+
self.form_data = form_data
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class HelpBackMessage(Message):
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
|
|
302
326
|
class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
303
327
|
"""Textual app for editing/creating deployments"""
|
|
304
328
|
|
|
305
329
|
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
306
330
|
|
|
307
|
-
# App states: 'form' or '
|
|
331
|
+
# App states: 'form', 'validation', or 'help'
|
|
308
332
|
current_state: reactive[str] = reactive("form", recompose=True)
|
|
309
333
|
form_data: reactive[DeploymentForm] = reactive(DeploymentForm())
|
|
310
334
|
save_error: reactive[str] = reactive("", recompose=True)
|
|
@@ -336,6 +360,23 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
336
360
|
if self.form_data.personal_access_token
|
|
337
361
|
else None,
|
|
338
362
|
)
|
|
363
|
+
elif self.current_state == "help":
|
|
364
|
+
yield DeploymentHelpWidget()
|
|
365
|
+
else:
|
|
366
|
+
yield Static("Unknown state: " + self.current_state)
|
|
367
|
+
|
|
368
|
+
def action_show_help(self) -> None:
|
|
369
|
+
widget = self.query("DeploymentFormWidget")
|
|
370
|
+
if widget:
|
|
371
|
+
typed_widget: DeploymentFormWidget = widget[0]
|
|
372
|
+
self.form_data = typed_widget.resolve_form_data()
|
|
373
|
+
|
|
374
|
+
self.current_state = "help"
|
|
375
|
+
|
|
376
|
+
def on_deployment_help_back_message(
|
|
377
|
+
self, message: DeploymentHelpBackMessage
|
|
378
|
+
) -> None:
|
|
379
|
+
self.current_state = "form"
|
|
339
380
|
|
|
340
381
|
def on_start_validation_message(self, message: StartValidationMessage) -> None:
|
|
341
382
|
"""Handle validation start message from form widget"""
|
|
@@ -364,6 +405,15 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
364
405
|
self.save_error = ""
|
|
365
406
|
self.current_state = "form"
|
|
366
407
|
|
|
408
|
+
def on_show_help_message(self, message: ShowHelpMessage) -> None:
|
|
409
|
+
"""Navigate to help view, preserving current form state."""
|
|
410
|
+
self.form_data = message.form_data
|
|
411
|
+
self.current_state = "help"
|
|
412
|
+
|
|
413
|
+
def on_help_back_message(self, message: HelpBackMessage) -> None:
|
|
414
|
+
"""Return from help to form, keeping form state intact."""
|
|
415
|
+
self.current_state = "form"
|
|
416
|
+
|
|
367
417
|
def _perform_save(self) -> None:
|
|
368
418
|
"""Actually save the deployment after validation"""
|
|
369
419
|
logging.info("saving form data", self.form_data)
|
|
@@ -403,6 +453,91 @@ def edit_deployment_form(
|
|
|
403
453
|
|
|
404
454
|
def create_deployment_form() -> DeploymentResponse | None:
|
|
405
455
|
"""Launch deployment creation form and return result"""
|
|
406
|
-
initial_data =
|
|
456
|
+
initial_data = _initialize_deployment_data()
|
|
407
457
|
app = DeploymentEditApp(initial_data)
|
|
408
458
|
return app.run()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _initialize_deployment_data() -> DeploymentForm:
|
|
462
|
+
"""
|
|
463
|
+
initialize the deployment form data from the current git repo and .env file
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
repo_url: str | None = None
|
|
467
|
+
git_ref: str | None = None
|
|
468
|
+
secrets: dict[str, str] = {}
|
|
469
|
+
name: str | None = None
|
|
470
|
+
if is_git_repo():
|
|
471
|
+
seen = set[str]()
|
|
472
|
+
remotes = list_remotes()
|
|
473
|
+
candidate_origins = []
|
|
474
|
+
for remote in remotes:
|
|
475
|
+
normalized_url = _normalize_to_http(remote)
|
|
476
|
+
if normalized_url not in seen:
|
|
477
|
+
candidate_origins.append(normalized_url)
|
|
478
|
+
seen.add(normalized_url)
|
|
479
|
+
preferred_origin = sorted(
|
|
480
|
+
candidate_origins, key=lambda x: "github.com" in x, reverse=True
|
|
481
|
+
)
|
|
482
|
+
if preferred_origin:
|
|
483
|
+
repo_url = preferred_origin[0]
|
|
484
|
+
git_ref = get_current_branch()
|
|
485
|
+
if Path(".env").exists():
|
|
486
|
+
secrets = load_env_secrets_from_string(Path(".env").read_text())
|
|
487
|
+
for f in os.listdir("."):
|
|
488
|
+
if f.endswith(".yml") or f.endswith(".yaml"):
|
|
489
|
+
try:
|
|
490
|
+
config = DeploymentConfig.from_yaml(Path(f))
|
|
491
|
+
name = config.name
|
|
492
|
+
break
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
form = DeploymentForm(
|
|
497
|
+
name=name or "",
|
|
498
|
+
repo_url=repo_url or "",
|
|
499
|
+
git_ref=git_ref or "main",
|
|
500
|
+
secrets=secrets,
|
|
501
|
+
)
|
|
502
|
+
return form
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _normalize_to_http(url: str) -> str:
|
|
506
|
+
"""
|
|
507
|
+
normalize a git url to a best guess for a corresponding http(s) url
|
|
508
|
+
"""
|
|
509
|
+
candidate = (url or "").strip()
|
|
510
|
+
|
|
511
|
+
# If no scheme, first try scp-like SSH syntax: [user@]host:path
|
|
512
|
+
has_scheme = "://" in candidate
|
|
513
|
+
if not has_scheme:
|
|
514
|
+
scp_match = re.match(
|
|
515
|
+
r"^(?:(?P<user>[^@]+)@)?(?P<host>[^:/\s]+):(?P<path>[^/].+)$",
|
|
516
|
+
candidate,
|
|
517
|
+
)
|
|
518
|
+
if scp_match:
|
|
519
|
+
host = scp_match.group("host")
|
|
520
|
+
path = scp_match.group("path").lstrip("/")
|
|
521
|
+
if path.endswith(".git"):
|
|
522
|
+
path = path[:-4]
|
|
523
|
+
return f"https://{host}/{path}"
|
|
524
|
+
|
|
525
|
+
# If no scheme (and not scp), assume host/path and prepend https
|
|
526
|
+
parsed = urlsplit(candidate if has_scheme else f"https://{candidate}")
|
|
527
|
+
|
|
528
|
+
# Drop credentials from netloc
|
|
529
|
+
netloc = parsed.netloc.split("@", 1)[-1]
|
|
530
|
+
|
|
531
|
+
# Drop explicit port (common for SSH like :7999 which is wrong for https)
|
|
532
|
+
if ":" in netloc:
|
|
533
|
+
netloc = netloc.split(":", 1)[0]
|
|
534
|
+
|
|
535
|
+
# Normalize path and strip .git
|
|
536
|
+
path = parsed.path.lstrip("/")
|
|
537
|
+
if path.endswith(".git"):
|
|
538
|
+
path = path[:-4]
|
|
539
|
+
|
|
540
|
+
if path:
|
|
541
|
+
return f"https://{netloc}/{path}"
|
|
542
|
+
else:
|
|
543
|
+
return f"https://{netloc}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import HorizontalGroup, Widget
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widgets import Button, Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeploymentHelpBackMessage(Message):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeploymentHelpWidget(Widget):
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
DeploymentHelpWidget {
|
|
17
|
+
layout: vertical;
|
|
18
|
+
height: auto;
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def compose(self) -> ComposeResult:
|
|
23
|
+
yield Static(
|
|
24
|
+
"Deploy your app to llama cloud or your own infrastructure:",
|
|
25
|
+
classes="primary-message",
|
|
26
|
+
)
|
|
27
|
+
yield Static(
|
|
28
|
+
Content.from_markup(
|
|
29
|
+
dedent("""
|
|
30
|
+
[b]Deployment Name[/b]
|
|
31
|
+
A unique name to identify this deployment. Controls the URL where your deployment is accessible. Will have a random suffix appended if not unique.
|
|
32
|
+
|
|
33
|
+
[b]Git Repository[/b]
|
|
34
|
+
A git repository URL to pull code from. If not publically accessible, you will be prompted to install the llama deploy github app. If code is on another platform, either provide a Personal Access Token (basic access credentials) instead.
|
|
35
|
+
|
|
36
|
+
[b]Git Ref[/b]
|
|
37
|
+
The git ref to deploy. This can be a branch, tag, or commit hash. If this is a branch, after deploying, run a `[slategrey reverse]llamactl deploy refresh[/]` to update the deployment to the latest git ref after you make updates.
|
|
38
|
+
|
|
39
|
+
[b]Deployment File[/b]
|
|
40
|
+
The `[slategrey reverse]llama_deploy.yaml[/]` file to use for the deployment. This is a yaml file that contains the configuration for how to serve the deployment.
|
|
41
|
+
|
|
42
|
+
[b]Secrets[/b]
|
|
43
|
+
Secrets to add as environment variables to the deployment. e.g. to access a database or an API. Supports adding in `[slategrey reverse].env[/]` file format.
|
|
44
|
+
|
|
45
|
+
""").strip()
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
with HorizontalGroup(classes="button-row"):
|
|
49
|
+
yield Button("Back", variant="primary", id="help_back", compact=True)
|
|
50
|
+
|
|
51
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
52
|
+
if event.button.id == "help_back":
|
|
53
|
+
self.post_message(DeploymentHelpBackMessage())
|
|
@@ -4,17 +4,16 @@ import logging
|
|
|
4
4
|
import webbrowser
|
|
5
5
|
from typing import Literal, cast
|
|
6
6
|
|
|
7
|
+
from llama_deploy.cli.client import get_project_client as get_client
|
|
8
|
+
from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
|
|
9
|
+
from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
|
|
10
|
+
from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
|
|
7
11
|
from textual.app import ComposeResult
|
|
8
12
|
from textual.containers import HorizontalGroup, Widget
|
|
9
|
-
from textual.widgets import Button, Input, Label, Static
|
|
10
|
-
from textual.message import Message
|
|
11
13
|
from textual.content import Content
|
|
14
|
+
from textual.message import Message
|
|
12
15
|
from textual.reactive import reactive
|
|
13
|
-
|
|
14
|
-
from llama_deploy.cli.client import get_project_client as get_client
|
|
15
|
-
from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
|
|
16
|
-
from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
|
|
17
|
-
from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
|
|
16
|
+
from textual.widgets import Button, Input, Label, Static
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger(__name__)
|
|
20
19
|
|
|
@@ -12,8 +12,7 @@ from textual.containers import (
|
|
|
12
12
|
from textual.validation import Length
|
|
13
13
|
from textual.widgets import Button, Input, Label, Static
|
|
14
14
|
|
|
15
|
-
from ..config import Profile
|
|
16
|
-
from ..config import config_manager
|
|
15
|
+
from ..config import Profile, config_manager
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
@dataclass
|
|
@@ -23,7 +22,7 @@ class ProfileForm:
|
|
|
23
22
|
name: str = ""
|
|
24
23
|
api_url: str = ""
|
|
25
24
|
active_project_id: str = ""
|
|
26
|
-
existing_name:
|
|
25
|
+
existing_name: str | None = None
|
|
27
26
|
|
|
28
27
|
@classmethod
|
|
29
28
|
def from_profile(cls, profile: Profile) -> "ProfileForm":
|
|
@@ -35,7 +34,7 @@ class ProfileForm:
|
|
|
35
34
|
)
|
|
36
35
|
|
|
37
36
|
|
|
38
|
-
class ProfileEditApp(App[
|
|
37
|
+
class ProfileEditApp(App[ProfileForm | None]):
|
|
39
38
|
"""Textual app for editing profiles"""
|
|
40
39
|
|
|
41
40
|
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
@@ -71,7 +70,7 @@ class ProfileEditApp(App[Optional[ProfileForm]]):
|
|
|
71
70
|
yield Label("API URL: *", classes="required form-label", shrink=True)
|
|
72
71
|
yield Input(
|
|
73
72
|
value=self.form_data.api_url,
|
|
74
|
-
placeholder="
|
|
73
|
+
placeholder="http://prod-cloud-llama-deploy",
|
|
75
74
|
validators=[Length(minimum=1)],
|
|
76
75
|
id="api_url",
|
|
77
76
|
compact=True,
|
|
@@ -162,10 +161,10 @@ def edit_profile_form(profile: Profile) -> ProfileForm | None:
|
|
|
162
161
|
return app.run()
|
|
163
162
|
|
|
164
163
|
|
|
165
|
-
def create_profile_form() ->
|
|
164
|
+
def create_profile_form() -> ProfileForm | None:
|
|
166
165
|
"""Launch profile creation form and return result"""
|
|
167
166
|
return edit_profile_form(
|
|
168
167
|
Profile(
|
|
169
|
-
name="", api_url="
|
|
168
|
+
name="", api_url="http://prod-cloud-llama-deploy", active_project_id=None
|
|
170
169
|
)
|
|
171
170
|
)
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
"""Textual-based deployment forms for CLI interactions"""
|
|
2
2
|
|
|
3
3
|
from llama_deploy.cli.env import load_env_secrets_from_string
|
|
4
|
-
|
|
5
4
|
from textual.app import ComposeResult
|
|
6
5
|
from textual.containers import HorizontalGroup, Widget
|
|
7
|
-
from textual.widgets import Button, Input, Label, Static, TextArea
|
|
8
6
|
from textual.reactive import reactive
|
|
7
|
+
from textual.widgets import Button, Input, Label, Static, TextArea
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
class SecretsWidget(Widget):
|
|
@@ -62,9 +61,7 @@ class SecretsWidget(Widget):
|
|
|
62
61
|
"""
|
|
63
62
|
secrets = reactive({}, recompose=True) # Auto-recompose when secrets change
|
|
64
63
|
prior_secrets = reactive(set(), recompose=True)
|
|
65
|
-
visible_secrets = reactive(
|
|
66
|
-
set(), recompose=True
|
|
67
|
-
) # Auto-recompose when visible secrets change
|
|
64
|
+
visible_secrets = reactive(set(), recompose=True)
|
|
68
65
|
|
|
69
66
|
def __init__(
|
|
70
67
|
self,
|
|
@@ -75,11 +72,18 @@ class SecretsWidget(Widget):
|
|
|
75
72
|
self.secrets = initial_secrets or {}
|
|
76
73
|
self.prior_secrets = prior_secrets or set()
|
|
77
74
|
self.visible_secrets = set()
|
|
75
|
+
# Persist textarea content across recomposes triggered by other actions
|
|
76
|
+
self._new_secrets_text = ""
|
|
78
77
|
|
|
79
78
|
def compose(self) -> ComposeResult:
|
|
80
79
|
"""Compose the secrets section - called automatically when secrets change"""
|
|
81
|
-
yield Static("
|
|
82
|
-
|
|
80
|
+
yield Static("Secrets", classes="secrets-header")
|
|
81
|
+
# Preserve deterministic order: known secrets in insertion order, then prior-only sorted
|
|
82
|
+
known_secret_names = list(self.secrets.keys())
|
|
83
|
+
prior_only_secret_names = sorted(
|
|
84
|
+
[name for name in self.prior_secrets if name not in self.secrets]
|
|
85
|
+
)
|
|
86
|
+
secret_names = known_secret_names + prior_only_secret_names
|
|
83
87
|
hidden = len(secret_names) == 0
|
|
84
88
|
with Static(
|
|
85
89
|
classes="secrets-grid" + (" hidden" if hidden else ""),
|
|
@@ -108,9 +112,15 @@ class SecretsWidget(Widget):
|
|
|
108
112
|
compact=True,
|
|
109
113
|
)
|
|
110
114
|
|
|
115
|
+
# Short help text for textarea format
|
|
116
|
+
yield Static(
|
|
117
|
+
"Format: one per line, KEY=VALUE (e.g., API_KEY=abc).",
|
|
118
|
+
classes="secret-label",
|
|
119
|
+
)
|
|
120
|
+
|
|
111
121
|
with HorizontalGroup(classes="text-container"):
|
|
112
122
|
yield TextArea(
|
|
113
|
-
|
|
123
|
+
self._new_secrets_text,
|
|
114
124
|
id="new_secrets",
|
|
115
125
|
show_line_numbers=True,
|
|
116
126
|
highlight_cursor_line=True,
|
|
@@ -154,6 +164,7 @@ class SecretsWidget(Widget):
|
|
|
154
164
|
|
|
155
165
|
# Clear textarea
|
|
156
166
|
textarea.text = ""
|
|
167
|
+
self._new_secrets_text = ""
|
|
157
168
|
|
|
158
169
|
def _toggle_secret_visibility(self, secret_name: str) -> None:
|
|
159
170
|
"""Toggle the visibility of a secret"""
|
|
@@ -175,6 +186,11 @@ class SecretsWidget(Widget):
|
|
|
175
186
|
updated_prior_secrets.remove(secret_name)
|
|
176
187
|
self.prior_secrets = updated_prior_secrets
|
|
177
188
|
|
|
189
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
190
|
+
"""Track textarea edits so content persists across recomposes."""
|
|
191
|
+
text = event.text_area.text
|
|
192
|
+
self._new_secrets_text = text
|
|
193
|
+
|
|
178
194
|
def get_updated_secrets(self) -> dict[str, str]:
|
|
179
195
|
"""Get current secrets with values from input fields"""
|
|
180
196
|
self._add_secrets_from_textarea()
|