llamactl 0.2.7a1__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 +32 -0
- llama_deploy/cli/client.py +173 -0
- llama_deploy/cli/commands.py +549 -0
- llama_deploy/cli/config.py +173 -0
- llama_deploy/cli/debug.py +16 -0
- llama_deploy/cli/env.py +30 -0
- llama_deploy/cli/interactive_prompts/utils.py +86 -0
- llama_deploy/cli/options.py +21 -0
- llama_deploy/cli/textual/deployment_form.py +409 -0
- llama_deploy/cli/textual/git_validation.py +357 -0
- llama_deploy/cli/textual/github_callback_server.py +207 -0
- llama_deploy/cli/textual/llama_loader.py +52 -0
- llama_deploy/cli/textual/profile_form.py +171 -0
- llama_deploy/cli/textual/secrets_form.py +186 -0
- llama_deploy/cli/textual/styles.tcss +162 -0
- llamactl-0.2.7a1.dist-info/METADATA +122 -0
- llamactl-0.2.7a1.dist-info/RECORD +19 -0
- llamactl-0.2.7a1.dist-info/WHEEL +4 -0
- llamactl-0.2.7a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Textual-based deployment forms for CLI interactions"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from llama_deploy.cli.textual.secrets_form import SecretsWidget
|
|
9
|
+
from llama_deploy.cli.textual.git_validation import (
|
|
10
|
+
GitValidationWidget,
|
|
11
|
+
ValidationResultMessage,
|
|
12
|
+
ValidationCancelMessage,
|
|
13
|
+
)
|
|
14
|
+
from llama_deploy.core.schema.deployments import (
|
|
15
|
+
DeploymentCreate,
|
|
16
|
+
DeploymentResponse,
|
|
17
|
+
DeploymentUpdate,
|
|
18
|
+
)
|
|
19
|
+
from textual.app import App, ComposeResult
|
|
20
|
+
from textual.containers import Container, HorizontalGroup, Widget
|
|
21
|
+
from textual.validation import Length
|
|
22
|
+
from textual.widgets import Button, Input, Label, Static
|
|
23
|
+
from textual.reactive import reactive
|
|
24
|
+
from llama_deploy.cli.client import get_client
|
|
25
|
+
from textual.message import Message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DeploymentForm:
|
|
30
|
+
"""Form data for deployment editing/creation"""
|
|
31
|
+
|
|
32
|
+
name: str = ""
|
|
33
|
+
# unique id, generated from the name
|
|
34
|
+
id: str | None = None
|
|
35
|
+
repo_url: str = ""
|
|
36
|
+
git_ref: str = "main"
|
|
37
|
+
deployment_file_path: str = "llama_deploy.yaml"
|
|
38
|
+
personal_access_token: str = ""
|
|
39
|
+
# indicates if the deployment has a personal access token (value is unknown)
|
|
40
|
+
has_existing_pat: bool = False
|
|
41
|
+
# secrets that have been added
|
|
42
|
+
secrets: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
# initial secrets, values unknown
|
|
44
|
+
initial_secrets: set[str] = field(default_factory=set)
|
|
45
|
+
# initial secrets that have been removed
|
|
46
|
+
removed_secrets: set[str] = field(default_factory=set)
|
|
47
|
+
# if the deployment is being edited
|
|
48
|
+
is_editing: bool = False
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_deployment(cls, deployment: DeploymentResponse) -> "DeploymentForm":
|
|
52
|
+
secret_names = deployment.secret_names or []
|
|
53
|
+
|
|
54
|
+
return DeploymentForm(
|
|
55
|
+
name=deployment.name,
|
|
56
|
+
id=deployment.id,
|
|
57
|
+
repo_url=deployment.repo_url,
|
|
58
|
+
git_ref=deployment.git_ref or "main",
|
|
59
|
+
deployment_file_path=deployment.deployment_file_path or "llama_deploy.yaml",
|
|
60
|
+
personal_access_token="", # Always start empty for security
|
|
61
|
+
has_existing_pat=deployment.has_personal_access_token,
|
|
62
|
+
secrets={},
|
|
63
|
+
initial_secrets=set(secret_names),
|
|
64
|
+
is_editing=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_update(self) -> DeploymentUpdate:
|
|
68
|
+
"""Convert form data to API format"""
|
|
69
|
+
|
|
70
|
+
secrets: dict[str, str | None] = self.secrets.copy()
|
|
71
|
+
for secret in self.removed_secrets:
|
|
72
|
+
secrets[secret] = None
|
|
73
|
+
|
|
74
|
+
data = DeploymentUpdate(
|
|
75
|
+
repo_url=self.repo_url,
|
|
76
|
+
git_ref=self.git_ref or "main",
|
|
77
|
+
deployment_file_path=self.deployment_file_path or "llama_deploy.yaml",
|
|
78
|
+
personal_access_token=(
|
|
79
|
+
""
|
|
80
|
+
if self.personal_access_token is None and not self.has_existing_pat
|
|
81
|
+
else self.personal_access_token
|
|
82
|
+
),
|
|
83
|
+
secrets=secrets,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
def to_create(self) -> DeploymentCreate:
|
|
89
|
+
"""Convert form data to API format"""
|
|
90
|
+
|
|
91
|
+
return DeploymentCreate(
|
|
92
|
+
name=self.name,
|
|
93
|
+
repo_url=self.repo_url,
|
|
94
|
+
deployment_file_path=self.deployment_file_path or "llama_deploy.yaml",
|
|
95
|
+
git_ref=self.git_ref or "main",
|
|
96
|
+
personal_access_token=self.personal_access_token,
|
|
97
|
+
secrets=self.secrets,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DeploymentFormWidget(Widget):
|
|
102
|
+
"""Widget containing all deployment form logic and reactive state"""
|
|
103
|
+
|
|
104
|
+
DEFAULT_CSS = """
|
|
105
|
+
DeploymentFormWidget {
|
|
106
|
+
layout: vertical;
|
|
107
|
+
height: auto;
|
|
108
|
+
}
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
form_data: reactive[DeploymentForm] = reactive(DeploymentForm(), recompose=True)
|
|
112
|
+
error_message: reactive[str] = reactive("", recompose=True)
|
|
113
|
+
|
|
114
|
+
def __init__(self, initial_data: DeploymentForm, save_error: str | None = None):
|
|
115
|
+
super().__init__()
|
|
116
|
+
self.form_data = initial_data
|
|
117
|
+
self.original_form_data = initial_data
|
|
118
|
+
self.error_message = save_error or ""
|
|
119
|
+
|
|
120
|
+
def compose(self) -> ComposeResult:
|
|
121
|
+
title = "Edit Deployment" if self.form_data.is_editing else "Create Deployment"
|
|
122
|
+
yield Static(
|
|
123
|
+
title,
|
|
124
|
+
classes="primary-message",
|
|
125
|
+
)
|
|
126
|
+
yield Static(
|
|
127
|
+
self.error_message,
|
|
128
|
+
id="error-message",
|
|
129
|
+
classes="error-message " + ("visible" if self.error_message else "hidden"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Main deployment fields
|
|
133
|
+
with Widget(classes="two-column-form-grid"):
|
|
134
|
+
yield Label(
|
|
135
|
+
"Deployment Name: *", classes="required form-label", shrink=True
|
|
136
|
+
)
|
|
137
|
+
yield Input(
|
|
138
|
+
value=self.form_data.name,
|
|
139
|
+
placeholder="Enter deployment name",
|
|
140
|
+
validators=[Length(minimum=1)],
|
|
141
|
+
id="name",
|
|
142
|
+
disabled=self.form_data.is_editing,
|
|
143
|
+
classes="disabled" if self.form_data.is_editing else "",
|
|
144
|
+
compact=True,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
yield Label("Repository URL: *", classes="required form-label", shrink=True)
|
|
148
|
+
yield Input(
|
|
149
|
+
value=self.form_data.repo_url,
|
|
150
|
+
placeholder="https://github.com/user/repo",
|
|
151
|
+
validators=[Length(minimum=1)],
|
|
152
|
+
id="repo_url",
|
|
153
|
+
compact=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
yield Label("Git Reference:", classes="form-label", shrink=True)
|
|
157
|
+
yield Input(
|
|
158
|
+
value=self.form_data.git_ref,
|
|
159
|
+
placeholder="main, develop, v1.0.0, etc.",
|
|
160
|
+
id="git_ref",
|
|
161
|
+
compact=True,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
yield Label("Deployment File:", classes="form-label", shrink=True)
|
|
165
|
+
yield Input(
|
|
166
|
+
value=self.form_data.deployment_file_path,
|
|
167
|
+
placeholder="llama_deploy.yaml",
|
|
168
|
+
id="deployment_file_path",
|
|
169
|
+
compact=True,
|
|
170
|
+
)
|
|
171
|
+
yield Label("Personal Access Token:", classes="form-label", shrink=True)
|
|
172
|
+
if self.form_data.has_existing_pat:
|
|
173
|
+
yield Button(
|
|
174
|
+
"Change / Delete",
|
|
175
|
+
variant="default",
|
|
176
|
+
id="change_pat",
|
|
177
|
+
compact=True,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
yield Input(
|
|
181
|
+
value=self.form_data.personal_access_token,
|
|
182
|
+
placeholder="Leave blank to clear"
|
|
183
|
+
if self.form_data.has_existing_pat
|
|
184
|
+
else "Optional",
|
|
185
|
+
password=True,
|
|
186
|
+
id="personal_access_token",
|
|
187
|
+
compact=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Secrets section
|
|
191
|
+
yield SecretsWidget(
|
|
192
|
+
initial_secrets=self.form_data.secrets,
|
|
193
|
+
prior_secrets=self.form_data.initial_secrets,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with HorizontalGroup(classes="button-row"):
|
|
197
|
+
yield Button("Save", variant="primary", id="save", compact=True)
|
|
198
|
+
yield Button("Cancel", variant="default", id="cancel", compact=True)
|
|
199
|
+
|
|
200
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
201
|
+
if event.button.id == "save":
|
|
202
|
+
self._save()
|
|
203
|
+
elif event.button.id == "change_pat":
|
|
204
|
+
updated_form = dataclasses.replace(self._get_form_data())
|
|
205
|
+
updated_form.has_existing_pat = False
|
|
206
|
+
updated_form.personal_access_token = ""
|
|
207
|
+
self.form_data = updated_form
|
|
208
|
+
elif event.button.id == "cancel":
|
|
209
|
+
# Post message to parent app to handle cancel
|
|
210
|
+
self.post_message(CancelFormMessage())
|
|
211
|
+
|
|
212
|
+
def _save(self) -> None:
|
|
213
|
+
self.form_data = self._get_form_data()
|
|
214
|
+
if self._validate_form():
|
|
215
|
+
# Post message to parent app to start validation
|
|
216
|
+
self.post_message(StartValidationMessage(self.form_data))
|
|
217
|
+
|
|
218
|
+
def _validate_form(self) -> bool:
|
|
219
|
+
"""Validate required fields"""
|
|
220
|
+
name_input = self.query_one("#name", Input)
|
|
221
|
+
repo_url_input = self.query_one("#repo_url", Input)
|
|
222
|
+
|
|
223
|
+
errors = []
|
|
224
|
+
|
|
225
|
+
# Clear previous error state
|
|
226
|
+
name_input.remove_class("error")
|
|
227
|
+
repo_url_input.remove_class("error")
|
|
228
|
+
|
|
229
|
+
if not name_input.value.strip():
|
|
230
|
+
name_input.add_class("error")
|
|
231
|
+
errors.append("Deployment name is required")
|
|
232
|
+
|
|
233
|
+
if not repo_url_input.value.strip():
|
|
234
|
+
repo_url_input.add_class("error")
|
|
235
|
+
errors.append("Repository URL is required")
|
|
236
|
+
|
|
237
|
+
if errors:
|
|
238
|
+
self._show_error("; ".join(errors))
|
|
239
|
+
return False
|
|
240
|
+
else:
|
|
241
|
+
self._show_error("")
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
def _show_error(self, message: str) -> None:
|
|
245
|
+
"""Show an error message"""
|
|
246
|
+
self.error_message = message
|
|
247
|
+
|
|
248
|
+
def _get_form_data(self) -> DeploymentForm:
|
|
249
|
+
"""Extract form data from inputs"""
|
|
250
|
+
name_input = self.query_one("#name", Input)
|
|
251
|
+
repo_url_input = self.query_one("#repo_url", Input)
|
|
252
|
+
git_ref_input = self.query_one("#git_ref", Input)
|
|
253
|
+
deployment_file_input = self.query_one("#deployment_file_path", Input)
|
|
254
|
+
|
|
255
|
+
# PAT input might not exist if there's an existing PAT
|
|
256
|
+
try:
|
|
257
|
+
pat_input = self.query_one("#personal_access_token", Input)
|
|
258
|
+
pat_value = pat_input.value.strip()
|
|
259
|
+
except Exception:
|
|
260
|
+
pat_value = self.form_data.personal_access_token or ""
|
|
261
|
+
|
|
262
|
+
# Get updated secrets from the secrets widget
|
|
263
|
+
secrets_widget = self.query_one(SecretsWidget)
|
|
264
|
+
updated_secrets = secrets_widget.get_updated_secrets()
|
|
265
|
+
updated_prior_secrets = secrets_widget.get_updated_prior_secrets()
|
|
266
|
+
|
|
267
|
+
return DeploymentForm(
|
|
268
|
+
name=name_input.value.strip(),
|
|
269
|
+
id=self.form_data.id,
|
|
270
|
+
repo_url=repo_url_input.value.strip(),
|
|
271
|
+
git_ref=git_ref_input.value.strip() or "main",
|
|
272
|
+
deployment_file_path=deployment_file_input.value.strip()
|
|
273
|
+
or "llama_deploy.yaml",
|
|
274
|
+
personal_access_token=pat_value,
|
|
275
|
+
secrets=updated_secrets,
|
|
276
|
+
initial_secrets=self.original_form_data.initial_secrets,
|
|
277
|
+
is_editing=self.original_form_data.is_editing,
|
|
278
|
+
has_existing_pat=self.form_data.has_existing_pat,
|
|
279
|
+
removed_secrets=self.original_form_data.initial_secrets.difference(
|
|
280
|
+
updated_prior_secrets
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Messages for communication between form widget and app
|
|
286
|
+
class SaveFormMessage(Message):
|
|
287
|
+
def __init__(self, deployment: DeploymentResponse):
|
|
288
|
+
super().__init__()
|
|
289
|
+
self.deployment = deployment
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class CancelFormMessage(Message):
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class StartValidationMessage(Message):
|
|
297
|
+
def __init__(self, form_data: DeploymentForm):
|
|
298
|
+
super().__init__()
|
|
299
|
+
self.form_data = form_data
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
303
|
+
"""Textual app for editing/creating deployments"""
|
|
304
|
+
|
|
305
|
+
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
306
|
+
|
|
307
|
+
# App states: 'form' or 'validation'
|
|
308
|
+
current_state: reactive[str] = reactive("form", recompose=True)
|
|
309
|
+
form_data: reactive[DeploymentForm] = reactive(DeploymentForm())
|
|
310
|
+
save_error: reactive[str] = reactive("", recompose=True)
|
|
311
|
+
|
|
312
|
+
def __init__(self, initial_data: DeploymentForm):
|
|
313
|
+
super().__init__()
|
|
314
|
+
self.initial_data = initial_data
|
|
315
|
+
self.form_data = initial_data
|
|
316
|
+
|
|
317
|
+
def on_mount(self) -> None:
|
|
318
|
+
self.theme = "tokyo-night"
|
|
319
|
+
|
|
320
|
+
def on_key(self, event) -> None:
|
|
321
|
+
"""Handle key events, including Ctrl+C"""
|
|
322
|
+
if event.key == "ctrl+c":
|
|
323
|
+
self.exit(None)
|
|
324
|
+
|
|
325
|
+
def compose(self) -> ComposeResult:
|
|
326
|
+
with Container(classes="form-container"):
|
|
327
|
+
if self.current_state == "form":
|
|
328
|
+
yield DeploymentFormWidget(self.form_data, self.save_error)
|
|
329
|
+
elif self.current_state == "validation":
|
|
330
|
+
yield GitValidationWidget(
|
|
331
|
+
repo_url=self.form_data.repo_url,
|
|
332
|
+
deployment_id=self.form_data.id
|
|
333
|
+
if self.form_data.is_editing
|
|
334
|
+
else None,
|
|
335
|
+
pat=self.form_data.personal_access_token
|
|
336
|
+
if self.form_data.personal_access_token
|
|
337
|
+
else None,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def on_start_validation_message(self, message: StartValidationMessage) -> None:
|
|
341
|
+
"""Handle validation start message from form widget"""
|
|
342
|
+
self.form_data = message.form_data
|
|
343
|
+
self.save_error = "" # Clear any previous errors
|
|
344
|
+
self.current_state = "validation"
|
|
345
|
+
|
|
346
|
+
def on_validation_result_message(self, message: ValidationResultMessage) -> None:
|
|
347
|
+
"""Handle validation success from git validation widget"""
|
|
348
|
+
logging.info("validation result message", message)
|
|
349
|
+
# Update form data with validated PAT if provided
|
|
350
|
+
if message.pat is not None:
|
|
351
|
+
updated_form = dataclasses.replace(self.form_data)
|
|
352
|
+
updated_form.personal_access_token = message.pat
|
|
353
|
+
# If PAT is being cleared (empty string), also clear the has_existing_pat flag
|
|
354
|
+
if message.pat == "":
|
|
355
|
+
updated_form.has_existing_pat = False
|
|
356
|
+
self.form_data = updated_form
|
|
357
|
+
|
|
358
|
+
# Proceed with save
|
|
359
|
+
self._perform_save()
|
|
360
|
+
|
|
361
|
+
def on_validation_cancel_message(self, message: ValidationCancelMessage) -> None:
|
|
362
|
+
"""Handle validation cancellation from git validation widget"""
|
|
363
|
+
# Return to form, clearing any save error
|
|
364
|
+
print("DEBUG: on_validation_cancel_message")
|
|
365
|
+
self.save_error = ""
|
|
366
|
+
self.current_state = "form"
|
|
367
|
+
|
|
368
|
+
def _perform_save(self) -> None:
|
|
369
|
+
"""Actually save the deployment after validation"""
|
|
370
|
+
logging.info("saving form data", self.form_data)
|
|
371
|
+
result = self.form_data
|
|
372
|
+
client = get_client()
|
|
373
|
+
try:
|
|
374
|
+
if result.is_editing:
|
|
375
|
+
update_deployment = client.update_deployment(
|
|
376
|
+
result.id, result.to_update()
|
|
377
|
+
)
|
|
378
|
+
else:
|
|
379
|
+
update_deployment = client.create_deployment(result.to_create())
|
|
380
|
+
# Exit with result
|
|
381
|
+
self.exit(update_deployment)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
# Return to form and show error
|
|
384
|
+
self.save_error = f"Error saving deployment: {e}"
|
|
385
|
+
self.current_state = "form"
|
|
386
|
+
|
|
387
|
+
def on_save_form_message(self, message: SaveFormMessage) -> None:
|
|
388
|
+
"""Handle save message from form widget (shouldn't happen with new flow)"""
|
|
389
|
+
self.exit(message.deployment)
|
|
390
|
+
|
|
391
|
+
def on_cancel_form_message(self, message: CancelFormMessage) -> None:
|
|
392
|
+
"""Handle cancel message from form widget"""
|
|
393
|
+
self.exit(None)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def edit_deployment_form(
|
|
397
|
+
deployment: DeploymentResponse,
|
|
398
|
+
) -> DeploymentResponse | None:
|
|
399
|
+
"""Launch deployment edit form and return result"""
|
|
400
|
+
initial_data = DeploymentForm.from_deployment(deployment)
|
|
401
|
+
app = DeploymentEditApp(initial_data)
|
|
402
|
+
return app.run()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def create_deployment_form() -> DeploymentResponse | None:
|
|
406
|
+
"""Launch deployment creation form and return result"""
|
|
407
|
+
initial_data = DeploymentForm()
|
|
408
|
+
app = DeploymentEditApp(initial_data)
|
|
409
|
+
return app.run()
|