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.
@@ -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()