llamactl 0.3.0a11__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.
@@ -0,0 +1,563 @@
1
+ """Textual-based forms for CLI interactions"""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from llama_deploy.core.client.manage_client import ClientError, ControlPlaneClient
11
+ from llama_deploy.core.schema.projects import ProjectSummary
12
+ from textual import events, work
13
+ from textual.app import App, ComposeResult
14
+ from textual.containers import Container, HorizontalGroup, Widget
15
+ from textual.content import Content
16
+ from textual.reactive import reactive
17
+ from textual.validation import Length
18
+ from textual.widgets import Button, Input, Label, Select, Static
19
+
20
+ from ..config import Profile, config_manager
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ValidationState(Enum):
26
+ """States for API validation"""
27
+
28
+ IDLE = "idle"
29
+ VALIDATING = "validating"
30
+ VALID = "valid"
31
+ NETWORK_ERROR = "network_error"
32
+ AUTH_REQUIRED = "auth_required"
33
+ AUTH_INVALID = "auth_invalid"
34
+ ERROR = "error"
35
+
36
+
37
+ @dataclass
38
+ class APIKeyProfileForm:
39
+ """Form data for profile editing/creation"""
40
+
41
+ name: str = ""
42
+ api_url: str = ""
43
+ project_id: str = ""
44
+ api_key_auth_token: str = ""
45
+ existing_name: str | None = None
46
+
47
+ @classmethod
48
+ def from_profile(cls, profile: Profile) -> "APIKeyProfileForm":
49
+ """Create form from existing profile"""
50
+ return cls(
51
+ name=profile.name,
52
+ api_url=profile.api_url,
53
+ project_id=profile.project_id,
54
+ api_key_auth_token=profile.api_key_auth_token or "",
55
+ )
56
+
57
+ def to_profile(self) -> Profile:
58
+ """Create profile from form data"""
59
+ return Profile(
60
+ name=self.name,
61
+ api_url=self.api_url,
62
+ project_id=self.project_id,
63
+ api_key_auth_token=self.api_key_auth_token,
64
+ )
65
+
66
+
67
+ def validate_api_connection(
68
+ api_url: str, api_key: str | None = None
69
+ ) -> tuple[ValidationState, List[ProjectSummary], str]:
70
+ """Validate API connection and return projects if successful"""
71
+ try:
72
+ # Create ControlPlaneClient with optional auth
73
+ client = ControlPlaneClient(api_url, api_key)
74
+
75
+ # Try to list projects (async client)
76
+ projects = asyncio.run(client.list_projects())
77
+
78
+ return ValidationState.VALID, projects, "Connected successfully"
79
+
80
+ except ClientError as e:
81
+ if e.status_code == 401:
82
+ return ValidationState.AUTH_INVALID, [], "API key is not valid"
83
+ elif e.status_code == 403:
84
+ return ValidationState.AUTH_REQUIRED, [], "API requires an API key"
85
+ elif e.status_code and 400 <= e.status_code < 500:
86
+ return ValidationState.NETWORK_ERROR, [], "Invalid API URL"
87
+ else:
88
+ return ValidationState.ERROR, [], f"Server error: {str(e)}"
89
+ except Exception as e:
90
+ error_msg = str(e).lower()
91
+ if (
92
+ "connection" in error_msg
93
+ or "timeout" in error_msg
94
+ or "resolve" in error_msg
95
+ ):
96
+ return ValidationState.NETWORK_ERROR, [], "Cannot connect to API URL"
97
+ else:
98
+ return ValidationState.ERROR, [], f"Connection error: {str(e)}"
99
+
100
+
101
+ class APIKeyProfileEditApp(App[APIKeyProfileForm | None]):
102
+ """Textual app for editing profiles"""
103
+
104
+ CSS_PATH = Path(__file__).parent / "styles.tcss"
105
+
106
+ name: reactive[str] = reactive("")
107
+ api_url: reactive[str] = reactive("")
108
+ project_id: reactive[str] = reactive("")
109
+ api_key_auth_token: reactive[str] = reactive("")
110
+
111
+ validation_state: reactive[ValidationState] = reactive(ValidationState.IDLE)
112
+ validation_message: reactive[str] = reactive("")
113
+
114
+ # Structural toggles → recompose when these change
115
+ available_projects: reactive[List[ProjectSummary]] = reactive([], recompose=True)
116
+ manual_project_mode: reactive[bool] = reactive(False, recompose=True)
117
+ api_key_required: reactive[bool] = reactive(False, recompose=True)
118
+
119
+ # Error banner content
120
+ form_error_message: reactive[str] = reactive("", recompose=True)
121
+
122
+ def __init__(
123
+ self, initial_data: APIKeyProfileForm, prompt_message: str | None = None
124
+ ):
125
+ super().__init__()
126
+ self.form_data = initial_data
127
+ self.prompt_message = prompt_message
128
+ # Track last validated values to prevent redundant validation
129
+ self._last_validated_api_url = ""
130
+ self._last_validated_api_key = ""
131
+ # Initialize fields from provided data
132
+ self.name = self.form_data.name
133
+ self.api_url = self.form_data.api_url
134
+ self.project_id = self.form_data.project_id
135
+ self.api_key_auth_token = self.form_data.api_key_auth_token or ""
136
+
137
+ def on_mount(self) -> None:
138
+ self.theme = "tokyo-night"
139
+ # Initialize tracked values from form data to prevent unnecessary initial validation
140
+ # Force initial validation by resetting last validated sentinels
141
+ self._last_validated_api_url = ""
142
+ self._last_validated_api_key = ""
143
+
144
+ # Trigger initial validation if we have API URL
145
+ if self.api_url:
146
+ self._trigger_validation()
147
+
148
+ # ----- Reactive watchers (targeted updates to avoid focus loss) -----
149
+ def watch_validation_message(self, old: str, new: str) -> None:
150
+ try:
151
+ message_widget = self.query_one("#validation-message", Static)
152
+ message_widget.update(new)
153
+ # Toggle visibility
154
+ message_widget.display = bool(new)
155
+ # Update style based on state
156
+ css_class = (
157
+ "success-message"
158
+ if self.validation_state == ValidationState.VALID
159
+ else "warning-message"
160
+ )
161
+ message_widget.set_classes(f"{css_class} full-width")
162
+ except Exception:
163
+ pass
164
+
165
+ def watch_validation_state(
166
+ self, old: ValidationState, new: ValidationState
167
+ ) -> None:
168
+ # Re-use same updater for message to refresh styling
169
+ self.watch_validation_message(self.validation_message, self.validation_message)
170
+
171
+ def on_key(self, event: events.Key) -> None:
172
+ """Handle key events, including Ctrl+C"""
173
+ if event.key == "ctrl+c":
174
+ self.exit(None)
175
+
176
+ @work(exclusive=True, thread=True)
177
+ async def _validate_api_worker(self) -> None:
178
+ """Worker to validate API connection"""
179
+ # Use the latest state rather than querying widgets (avoids race conditions)
180
+ api_url = self.api_url.strip()
181
+ api_key_value = self.api_key_auth_token.strip()
182
+ api_key = api_key_value or None
183
+
184
+ # Check if values have actually changed since last validation
185
+ if (
186
+ api_url == self._last_validated_api_url
187
+ and api_key == self._last_validated_api_key
188
+ ):
189
+ logger.debug(
190
+ f"Skipping validation - no changes detected (URL: {api_url}, Key: {'***' if api_key else 'None'})"
191
+ )
192
+ return
193
+
194
+ if not api_url:
195
+ self.validation_state = ValidationState.IDLE
196
+ self.validation_message = ""
197
+ self.available_projects = []
198
+ self.api_key_required = False
199
+ # Reset tracked values when empty
200
+ self._last_validated_api_url = ""
201
+ self._last_validated_api_key = ""
202
+ return
203
+
204
+ # Update tracked values before validation
205
+ self._last_validated_api_url = api_url
206
+ self._last_validated_api_key = api_key
207
+
208
+ self.validation_state = ValidationState.VALIDATING
209
+ self.validation_message = "Validating connection..."
210
+ logger.debug(
211
+ f"Validating connection to {api_url} with API key {'***' if api_key else 'None'}"
212
+ )
213
+ state, projects, message = validate_api_connection(api_url, api_key)
214
+ logger.debug(
215
+ f"Validation result: {state}, {len(projects)} projects, '{message}'"
216
+ )
217
+
218
+ # Commit validation results
219
+ self.validation_state = state
220
+ self.validation_message = message
221
+ self.available_projects = projects
222
+ self.api_key_required = state == ValidationState.AUTH_REQUIRED
223
+
224
+ # If we got projects but user is in manual mode and project is empty, suggest switching
225
+ if state == ValidationState.VALID and projects and self.manual_project_mode:
226
+ try:
227
+ project_input = self.query_one("#project_id", Input)
228
+ if not project_input.value.strip():
229
+ self.validation_message = f"Connected successfully. Found {len(projects)} projects - consider using project selector."
230
+ except Exception:
231
+ # Project input might not exist in selector mode
232
+ pass
233
+
234
+ def _trigger_validation(self) -> None:
235
+ """Trigger validation worker"""
236
+ self._validate_api_worker()
237
+
238
+ def on_input_submitted(self, event: Input.Submitted) -> None:
239
+ """Handle input enter/tab to trigger validation"""
240
+ if event.input.id == "api_url":
241
+ self.api_url = event.input.value.strip()
242
+ self._trigger_validation()
243
+ elif event.input.id == "api_key_auth_token":
244
+ self.api_key_auth_token = event.input.value.strip()
245
+ self._trigger_validation()
246
+
247
+ def on_input_changed(self, event: Input.Changed) -> None:
248
+ """Handle input changes with debouncing to avoid excessive validation"""
249
+ if event.input.id in ("api_url", "api_key_auth_token", "name", "project_id"):
250
+ # Cancel any existing validation timer
251
+ if hasattr(self, "_validation_timer") and self._validation_timer:
252
+ self._validation_timer.stop()
253
+
254
+ # Update state from input immediately
255
+ if event.input.id == "api_url":
256
+ self.api_url = event.value.strip()
257
+ elif event.input.id == "api_key_auth_token":
258
+ self.api_key_auth_token = event.value.strip()
259
+ elif event.input.id == "name":
260
+ self.name = event.value.strip()
261
+ elif event.input.id == "project_id":
262
+ self.project_id = event.value.strip()
263
+
264
+ # Set a new timer to validate after 1 second of no typing (only for api fields)
265
+ if event.input.id in ("api_url", "api_key_auth_token"):
266
+ self._validation_timer = self.set_timer(1.0, self._trigger_validation)
267
+
268
+ def compose(self) -> ComposeResult:
269
+ with Container(classes="form-container"):
270
+ title = (
271
+ "Edit Profile"
272
+ if self.form_data.existing_name
273
+ else "Create API Key Profile"
274
+ )
275
+ yield Static(title, classes="primary-message")
276
+ yield Static(
277
+ Content.from_markup(
278
+ "Configure a new API key profile to authenticate with the LlamaCloud control plane. This is stored locally in your OS's config directory."
279
+ ),
280
+ classes="info-message mb-1",
281
+ )
282
+ yield Static(
283
+ self.form_error_message,
284
+ id="error-message",
285
+ classes=f"error-message {'hidden visible' if self.form_error_message else 'hidden'}",
286
+ )
287
+
288
+ # Validation status message (always present but hidden when empty)
289
+ css_class = (
290
+ "success-message"
291
+ if self.validation_state == ValidationState.VALID
292
+ else "warning-message"
293
+ )
294
+ yield Static(
295
+ self.validation_message,
296
+ id="validation-message",
297
+ classes=f"{css_class} full-width {'hidden' if not self.validation_message else ''}",
298
+ )
299
+
300
+ with Static(classes="two-column-form-grid mb-1"):
301
+ yield Label(
302
+ Content.from_markup("Profile Name[red]*[/]"),
303
+ classes="required form-label",
304
+ shrink=True,
305
+ )
306
+ yield Input(
307
+ value=self.name,
308
+ placeholder="A memorable name for this API key",
309
+ validators=[Length(minimum=1)],
310
+ id="name",
311
+ compact=True,
312
+ )
313
+ yield Label(
314
+ Content.from_markup("API URL[red]*[/]"),
315
+ classes="required form-label",
316
+ shrink=True,
317
+ )
318
+ yield Input(
319
+ value=self.api_url,
320
+ placeholder="https://api.cloud.llamaindex.ai",
321
+ validators=[Length(minimum=1)],
322
+ id="api_url",
323
+ compact=True,
324
+ )
325
+
326
+ # API Key field - make required if auth is required
327
+ api_key_label = (
328
+ "API Key[red]*[/]" if self.api_key_required else "API Key"
329
+ )
330
+ yield Label(
331
+ Content.from_markup(api_key_label),
332
+ id="api-key-label",
333
+ classes="form-label",
334
+ shrink=True,
335
+ )
336
+ yield Input(
337
+ value=self.api_key_auth_token,
338
+ placeholder="API key auth token. Only required if control plane is authenticated",
339
+ id="api_key_auth_token",
340
+ validators=[Length(minimum=1)] if self.api_key_required else [],
341
+ compact=True,
342
+ )
343
+ # Project selection area
344
+ yield Label(
345
+ Content.from_markup("Project ID[red]*[/]"),
346
+ classes="required form-label",
347
+ shrink=True,
348
+ )
349
+
350
+ # Project input area with toggle
351
+ with Widget(id="project-input-area"):
352
+ if not self.manual_project_mode and self.available_projects:
353
+ # Show project selector
354
+ options = [
355
+ (
356
+ f"{p.project_name} ({p.deployment_count} deployments)",
357
+ p.project_id,
358
+ )
359
+ for p in self.available_projects
360
+ ]
361
+ # Find a valid initial value
362
+ project_ids = [p.project_id for p in self.available_projects]
363
+ initial_value = (
364
+ self.project_id
365
+ if self.project_id in project_ids
366
+ else project_ids[0]
367
+ if project_ids
368
+ else None
369
+ )
370
+
371
+ if initial_value is not None:
372
+ yield Select(
373
+ options=options,
374
+ value=initial_value,
375
+ id="project_select",
376
+ allow_blank=False,
377
+ compact=True,
378
+ )
379
+ else:
380
+ # Fallback to manual input if no projects available
381
+ yield Input(
382
+ value=self.project_id,
383
+ placeholder="Enter project ID manually",
384
+ validators=[Length(minimum=1)],
385
+ id="project_id",
386
+ compact=True,
387
+ )
388
+ else:
389
+ # Show manual input
390
+ yield Input(
391
+ value=self.project_id,
392
+ placeholder="Enter project ID manually",
393
+ validators=[Length(minimum=1)],
394
+ id="project_id",
395
+ compact=True,
396
+ )
397
+
398
+ yield Static()
399
+ # Mode toggle button
400
+ if self.available_projects:
401
+ toggle_text = (
402
+ "Enter Project ID Manually"
403
+ if not self.manual_project_mode
404
+ else "Select Existing Project"
405
+ )
406
+ yield Button(
407
+ toggle_text,
408
+ id="toggle_project_mode",
409
+ classes="align-right",
410
+ variant="default",
411
+ compact=True,
412
+ )
413
+ else:
414
+ yield Static()
415
+
416
+ with HorizontalGroup(classes="button-row"):
417
+ yield Button("Save", variant="primary", id="save", compact=True)
418
+ yield Button("Cancel", variant="default", id="cancel", compact=True)
419
+
420
+ def on_button_pressed(self, event: Button.Pressed) -> None:
421
+ if event.button.id == "save":
422
+ if self._validate_form():
423
+ result = self._get_form_data()
424
+ try:
425
+ if result.existing_name:
426
+ config_manager.delete_profile(result.existing_name)
427
+ profile = config_manager.create_profile(
428
+ result.name,
429
+ result.api_url,
430
+ result.project_id,
431
+ result.api_key_auth_token,
432
+ )
433
+ self.exit(profile)
434
+ except Exception as e:
435
+ self._handle_error(e)
436
+
437
+ elif event.button.id == "cancel":
438
+ self.exit(None)
439
+
440
+ elif event.button.id == "toggle_project_mode":
441
+ # Keep current project ID when switching modes
442
+ current_project_id = self._get_current_project_id()
443
+ self.manual_project_mode = not self.manual_project_mode
444
+ self.project_id = current_project_id
445
+
446
+ def on_select_changed(self, event: Select.Changed) -> None:
447
+ """Handle project selection changes"""
448
+ if event.select.id == "project_select" and event.value:
449
+ # Update state when project is selected
450
+ self.project_id = event.value
451
+ logger.debug(f"Project selected: {event.value}")
452
+
453
+ def _handle_error(self, error: Exception) -> None:
454
+ self.form_error_message = f"Error creating profile: {error}"
455
+
456
+ def _validate_form(self) -> bool:
457
+ """Validate required fields"""
458
+ name_input = self.query_one("#name", Input)
459
+ api_url_input = self.query_one("#api_url", Input)
460
+ api_key_input = self.query_one("#api_key_auth_token", Input)
461
+ errors = []
462
+
463
+ # Clear previous error state
464
+ name_input.remove_class("error")
465
+ api_url_input.remove_class("error")
466
+ api_key_input.remove_class("error")
467
+
468
+ if not name_input.value.strip():
469
+ name_input.add_class("error")
470
+ errors.append("Profile name is required")
471
+
472
+ if not api_url_input.value.strip():
473
+ api_url_input.add_class("error")
474
+ errors.append("API URL is required")
475
+
476
+ # Validate API key if required
477
+ if self.api_key_required and not api_key_input.value.strip():
478
+ api_key_input.add_class("error")
479
+ errors.append("API key is required")
480
+
481
+ # Validate project ID
482
+ project_id = self._get_current_project_id()
483
+ if not project_id:
484
+ # Add error class to appropriate element
485
+ if self.manual_project_mode or not self.available_projects:
486
+ try:
487
+ project_input = self.query_one("#project_id", Input)
488
+ project_input.add_class("error")
489
+ except Exception:
490
+ pass
491
+ else:
492
+ try:
493
+ project_select = self.query_one("#project_select", Select)
494
+ project_select.add_class("error")
495
+ except Exception:
496
+ pass
497
+ errors.append("Project ID is required")
498
+
499
+ if errors:
500
+ self.form_error_message = "; ".join(errors)
501
+ return False
502
+ else:
503
+ self.form_error_message = ""
504
+ return True
505
+
506
+ def _get_current_project_id(self) -> str:
507
+ """Get the current project ID from either selector or input"""
508
+ if self.manual_project_mode or not self.available_projects:
509
+ try:
510
+ project_input = self.query_one("#project_id", Input)
511
+ return project_input.value.strip()
512
+ except Exception:
513
+ return ""
514
+ else:
515
+ try:
516
+ project_select = self.query_one("#project_select", Select)
517
+ return project_select.value or ""
518
+ except Exception:
519
+ return ""
520
+
521
+ def _get_form_data(self) -> APIKeyProfileForm:
522
+ """Extract form data from inputs"""
523
+ name_input = self.query_one("#name", Input)
524
+ api_url_input = self.query_one("#api_url", Input)
525
+ api_key_input = self.query_one("#api_key_auth_token", Input)
526
+
527
+ return APIKeyProfileForm(
528
+ name=name_input.value.strip(),
529
+ api_url=api_url_input.value.strip(),
530
+ project_id=self._get_current_project_id(),
531
+ api_key_auth_token=api_key_input.value.strip(),
532
+ existing_name=self.form_data.existing_name,
533
+ )
534
+
535
+
536
+ def edit_api_key_profile_form(
537
+ profile: Profile,
538
+ prompt_message: str | None = None,
539
+ ) -> APIKeyProfileForm | None:
540
+ """Launch profile edit form and return result"""
541
+ initial_data = APIKeyProfileForm.from_profile(profile)
542
+ initial_data.existing_name = profile.name or None
543
+ app = APIKeyProfileEditApp(initial_data, prompt_message)
544
+ return app.run()
545
+
546
+
547
+ def create_api_key_profile_form(
548
+ api_url: str = "https://api.cloud.llamaindex.ai",
549
+ name: str | None = None,
550
+ project_id: str | None = None,
551
+ api_key_auth_token: str | None = None,
552
+ prompt_message: str | None = None,
553
+ ) -> APIKeyProfileForm | None:
554
+ """Launch profile creation form and return result"""
555
+ return edit_api_key_profile_form(
556
+ Profile(
557
+ name=name or "",
558
+ api_url=api_url,
559
+ project_id=project_id or "",
560
+ api_key_auth_token=api_key_auth_token or "",
561
+ ),
562
+ prompt_message,
563
+ )
@@ -405,7 +405,9 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
405
405
  self.save_error = "" # Clear any previous errors
406
406
  self.current_state = "validation"
407
407
 
408
- def on_validation_result_message(self, message: ValidationResultMessage) -> None:
408
+ async def on_validation_result_message(
409
+ self, message: ValidationResultMessage
410
+ ) -> None:
409
411
  """Handle validation success from git validation widget"""
410
412
  logging.info("validation result message", message)
411
413
  # Update form data with validated PAT if provided
@@ -417,8 +419,8 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
417
419
  updated_form.has_existing_pat = False
418
420
  self.form_data = updated_form
419
421
 
420
- # Proceed with save
421
- self._perform_save()
422
+ # Proceed with save (async)
423
+ await self._perform_save()
422
424
 
423
425
  def on_validation_cancel_message(self, message: ValidationCancelMessage) -> None:
424
426
  """Handle validation cancellation from git validation widget"""
@@ -435,18 +437,17 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
435
437
  """Return from help to form, keeping form state intact."""
436
438
  self.current_state = "form"
437
439
 
438
- def _perform_save(self) -> None:
440
+ async def _perform_save(self) -> None:
439
441
  """Actually save the deployment after validation"""
440
442
  logging.info("saving form data", self.form_data)
441
443
  result = self.form_data
442
444
  client = get_client()
443
445
  try:
444
- if result.is_editing:
445
- update_deployment = client.update_deployment(
446
- result.id, result.to_update()
447
- )
448
- else:
449
- update_deployment = client.create_deployment(result.to_create())
446
+ update_deployment = (
447
+ await client.update_deployment(result.id, result.to_update())
448
+ if result.is_editing
449
+ else await client.create_deployment(result.to_create())
450
+ )
450
451
  # Save and navigate to embedded monitor screen
451
452
  self.saved_deployment = update_deployment
452
453
  # Ensure form_data carries the new ID for any subsequent operations