llamactl 0.2.7a1__py3-none-any.whl → 0.3.0__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.
Files changed (41) hide show
  1. llama_deploy/cli/__init__.py +9 -22
  2. llama_deploy/cli/app.py +69 -0
  3. llama_deploy/cli/auth/client.py +362 -0
  4. llama_deploy/cli/client.py +47 -170
  5. llama_deploy/cli/commands/aliased_group.py +33 -0
  6. llama_deploy/cli/commands/auth.py +696 -0
  7. llama_deploy/cli/commands/deployment.py +300 -0
  8. llama_deploy/cli/commands/env.py +211 -0
  9. llama_deploy/cli/commands/init.py +313 -0
  10. llama_deploy/cli/commands/serve.py +239 -0
  11. llama_deploy/cli/config/_config.py +390 -0
  12. llama_deploy/cli/config/_migrations.py +65 -0
  13. llama_deploy/cli/config/auth_service.py +130 -0
  14. llama_deploy/cli/config/env_service.py +67 -0
  15. llama_deploy/cli/config/migrations/0001_init.sql +35 -0
  16. llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
  17. llama_deploy/cli/config/migrations/__init__.py +7 -0
  18. llama_deploy/cli/config/schema.py +61 -0
  19. llama_deploy/cli/env.py +5 -3
  20. llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
  21. llama_deploy/cli/interactive_prompts/utils.py +6 -72
  22. llama_deploy/cli/options.py +27 -5
  23. llama_deploy/cli/py.typed +0 -0
  24. llama_deploy/cli/styles.py +10 -0
  25. llama_deploy/cli/textual/deployment_form.py +263 -36
  26. llama_deploy/cli/textual/deployment_help.py +53 -0
  27. llama_deploy/cli/textual/deployment_monitor.py +466 -0
  28. llama_deploy/cli/textual/git_validation.py +20 -21
  29. llama_deploy/cli/textual/github_callback_server.py +17 -14
  30. llama_deploy/cli/textual/llama_loader.py +13 -1
  31. llama_deploy/cli/textual/secrets_form.py +28 -8
  32. llama_deploy/cli/textual/styles.tcss +49 -8
  33. llama_deploy/cli/utils/env_inject.py +23 -0
  34. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
  35. llamactl-0.3.0.dist-info/RECORD +38 -0
  36. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
  37. llama_deploy/cli/commands.py +0 -549
  38. llama_deploy/cli/config.py +0 -173
  39. llama_deploy/cli/textual/profile_form.py +0 -171
  40. llamactl-0.2.7a1.dist-info/RECORD +0 -19
  41. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,466 @@
1
+ """Textual component to monitor a deployment and stream its logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import logging
8
+ import threading
9
+ import webbrowser
10
+ from collections.abc import AsyncGenerator
11
+ from pathlib import Path
12
+
13
+ from llama_deploy.cli.client import (
14
+ project_client_context,
15
+ )
16
+ from llama_deploy.core.iter_utils import merge_generators
17
+ from llama_deploy.core.schema import LogEvent
18
+ from llama_deploy.core.schema.deployments import DeploymentResponse
19
+ from rich.text import Text
20
+ from textual import events
21
+ from textual.app import App, ComposeResult
22
+ from textual.containers import Container, HorizontalGroup, Widget
23
+ from textual.content import Content
24
+ from textual.css.query import NoMatches
25
+ from textual.message import Message
26
+ from textual.reactive import reactive
27
+ from textual.widgets import Button, RichLog, Static
28
+ from typing_extensions import Literal
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class DeploymentMonitorWidget(Widget):
34
+ """Widget that fetches deployment details once and streams logs.
35
+
36
+ Notes:
37
+ - Status is polled periodically
38
+ - Log stream is started with init container logs included on first connect
39
+ - If the stream ends or hangs, we reconnect with duration-aware backoff
40
+ """
41
+
42
+ DEFAULT_CSS = """
43
+ DeploymentMonitorWidget {
44
+ layout: vertical;
45
+ width: 1fr;
46
+ height: 1fr;
47
+ }
48
+
49
+ .monitor-container {
50
+ width: 1fr;
51
+ height: 1fr;
52
+ padding: 0;
53
+ margin: 0;
54
+ }
55
+
56
+ .details-grid {
57
+ layout: grid;
58
+ grid-size: 2;
59
+ grid-columns: auto 1fr;
60
+ grid-gutter: 0 1;
61
+ grid-rows: auto;
62
+ height: auto;
63
+ width: 1fr;
64
+ }
65
+
66
+ .log-header {
67
+ margin-top: 1;
68
+ }
69
+
70
+ .status-line .status-main {
71
+ width: auto;
72
+ }
73
+
74
+ .status-line .status-right {
75
+ width: 1fr;
76
+ text-align: right;
77
+ min-width: 12;
78
+ }
79
+
80
+ .deployment-link-label {
81
+ width: auto;
82
+ }
83
+
84
+ .deployment-link {
85
+ width: 1fr;
86
+ min-width: 16;
87
+ height: auto;
88
+ align: left middle;
89
+ text-align: left;
90
+ content-align: left middle;
91
+ }
92
+
93
+ .log-view-container {
94
+ width: 1fr;
95
+ height: 1fr;
96
+ padding: 0;
97
+ margin: 0;
98
+ }
99
+ """
100
+
101
+ deployment_id: str
102
+ deployment = reactive[DeploymentResponse | None](None, recompose=False)
103
+ error_message = reactive("", recompose=False)
104
+ wrap_enabled = reactive(False, recompose=False)
105
+ autoscroll_enabled = reactive(True, recompose=False)
106
+
107
+ def __init__(self, deployment_id: str) -> None:
108
+ super().__init__()
109
+ self.deployment_id = deployment_id
110
+ self._stop_stream = threading.Event()
111
+ # Persist content written to the RichLog across recomposes
112
+ self._log_buffer: list[Text] = []
113
+ self._log_stream_started = False
114
+
115
+ async def on_mount(self) -> None:
116
+ # Kick off initial fetch and start logs stream in background
117
+ self.run_worker(self._fetch_deployment())
118
+ # Force an initial layout, then start the log stream after that layout completes
119
+ self.refresh(layout=True)
120
+ self.call_after_refresh(lambda: self.run_worker(self._stream_logs()))
121
+ # Start periodic polling of deployment status
122
+ self.run_worker(self._poll_deployment_status())
123
+
124
+ def compose(self) -> ComposeResult:
125
+ yield Static("Deployment Status", classes="primary-message")
126
+
127
+ with HorizontalGroup(classes=""):
128
+ yield Static(" URL: ", classes="deployment-link-label")
129
+ yield Button(
130
+ "",
131
+ id="deployment_link_button",
132
+ classes="deployment-link",
133
+ compact=True,
134
+ variant="default",
135
+ )
136
+ yield Static("", classes="error-message", id="error_message")
137
+
138
+ # Single-line status bar with colored icon and deployment ID
139
+ with HorizontalGroup(classes="status-line"):
140
+ yield Static(
141
+ self._render_status_line(), classes="status-main", id="status_line"
142
+ )
143
+ yield Static("", classes="status-right", id="last_event_status")
144
+ yield Static("", classes="last-event", id="last_event_details")
145
+ yield Static("Logs", classes="secondary-message log-header")
146
+ yield HorizontalGroup(classes="log-view-container", id="log_view_container")
147
+
148
+ with HorizontalGroup(classes="button-row"):
149
+ wrap_label = "Wrap: On" if self.wrap_enabled else "Wrap: Off"
150
+ auto_label = "Scroll: Auto" if self.autoscroll_enabled else "Scroll: Off"
151
+ yield Button(wrap_label, id="toggle_wrap", variant="default", compact=True)
152
+ yield Button(
153
+ auto_label, id="toggle_autoscroll", variant="default", compact=True
154
+ )
155
+ yield Button("Copy", id="copy_log", variant="default", compact=True)
156
+ yield Button("Close", id="close", variant="default", compact=True)
157
+
158
+ def on_button_pressed(self, event: Button.Pressed) -> None:
159
+ if event.button.id == "close":
160
+ # Signal parent app to close
161
+ self.post_message(MonitorCloseMessage())
162
+ elif event.button.id == "toggle_wrap":
163
+ self.wrap_enabled = not self.wrap_enabled
164
+ elif event.button.id == "toggle_autoscroll":
165
+ self.autoscroll_enabled = not self.autoscroll_enabled
166
+ elif event.button.id == "copy_log":
167
+ txt = "\n".join([str(x) for x in self._log_buffer])
168
+ self.app.copy_to_clipboard(txt)
169
+ elif event.button.id == "deployment_link_button":
170
+ self.action_open_url()
171
+
172
+ async def _fetch_deployment(self) -> None:
173
+ try:
174
+ async with project_client_context() as client:
175
+ self.deployment = await client.get_deployment(
176
+ self.deployment_id, include_events=True
177
+ )
178
+ # Clear any previous error on success
179
+ self.error_message = ""
180
+ except Exception as e: # pragma: no cover - network errors
181
+ self.error_message = f"Failed to fetch deployment: {e}"
182
+
183
+ async def _stream_logs(self) -> None:
184
+ """Consume the async log iterator, batch updates, and reconnect with backoff."""
185
+
186
+ async def _flush_signal(
187
+ frequency_seconds: float,
188
+ ) -> AsyncGenerator[Literal["__FLUSH__"], None]:
189
+ while not self._stop_stream.is_set():
190
+ await asyncio.sleep(frequency_seconds)
191
+ yield "__FLUSH__"
192
+
193
+ failures = 0
194
+ while not self._stop_stream.is_set():
195
+ async with project_client_context() as client:
196
+ await asyncio.sleep(min(failures, 10))
197
+ batch: list[LogEvent] = []
198
+ try:
199
+ logger.info(f"Streaming logs for deployment {self.deployment_id}")
200
+ async for event in merge_generators(
201
+ client.stream_deployment_logs(
202
+ self.deployment_id,
203
+ include_init_containers=True,
204
+ tail_lines=10000,
205
+ ),
206
+ _flush_signal(0.2),
207
+ ):
208
+ if event == "__FLUSH__" and batch:
209
+ self._handle_log_events(batch)
210
+ batch = []
211
+ elif isinstance(event, LogEvent):
212
+ batch.append(event)
213
+ if len(batch) >= 1000:
214
+ self._handle_log_events(batch)
215
+ batch = []
216
+ except Exception as e:
217
+ if not self._stop_stream.is_set():
218
+ self._set_error_message(
219
+ f"Log stream failed: {e}. Reconnecting..."
220
+ )
221
+ failures += 1
222
+ finally:
223
+ if batch:
224
+ self._handle_log_events(batch)
225
+
226
+ def _reset_log_view_for_reconnect(self) -> None:
227
+ """Clear UI and buffers so new stream replaces previous content."""
228
+ try:
229
+ log_widget = self.query_one("#log_view", RichLog)
230
+ except Exception:
231
+ log_widget = None
232
+ if log_widget is not None:
233
+ log_widget.clear()
234
+
235
+ def _set_error_message(self, message: str) -> None:
236
+ self.error_message = message
237
+
238
+ def _handle_log_events(self, events: list[LogEvent]) -> None:
239
+ def to_text(event: LogEvent) -> Text:
240
+ txt = Text()
241
+ txt.append(
242
+ f"[{event.container}] ", style=self._container_style(event.container)
243
+ )
244
+ txt.append(event.text)
245
+ return txt
246
+
247
+ texts = [to_text(event) for event in events]
248
+ if not texts:
249
+ return
250
+
251
+ try:
252
+ # due to bugs in the the RichLog widget, defer mounting, otherwise it won't get a "ResizeEvent" (on_resize), and be waiting indefinitely
253
+ # before it renders (unless you manually resize the terminal window)
254
+ log_widget = self.query_one("#log_view", RichLog)
255
+ except NoMatches:
256
+ log_container = self.query_one("#log_view_container", HorizontalGroup)
257
+ log_widget = RichLog(
258
+ id="log_view",
259
+ classes="log-view mb-1",
260
+ auto_scroll=self.autoscroll_enabled,
261
+ wrap=self.wrap_enabled,
262
+ highlight=True,
263
+ )
264
+ log_container.mount(log_widget)
265
+ for text in texts:
266
+ log_widget.write(text)
267
+ self._log_buffer.append(text)
268
+ log_widget.refresh()
269
+
270
+ # One-time kick to ensure initial draw
271
+ # Clear any previous error once we successfully receive logs
272
+ if self.error_message:
273
+ self.error_message = ""
274
+
275
+ def _container_style(self, container_name: str) -> str:
276
+ palette = [
277
+ "bold magenta",
278
+ "bold cyan",
279
+ "bold blue",
280
+ "bold green",
281
+ "bold red",
282
+ "bold bright_blue",
283
+ ]
284
+ # Stable hash to pick a color per container name
285
+ h = int(hashlib.sha256(container_name.encode()).hexdigest(), 16)
286
+ return palette[h % len(palette)]
287
+
288
+ def _status_icon_and_style(self, phase: str) -> tuple[str, str]:
289
+ # Map deployment phase to a colored icon
290
+ phase = phase or "-"
291
+ green = "bold green"
292
+ yellow = "bold yellow"
293
+ red = "bold red"
294
+ gray = "grey50"
295
+ if phase in {"Running", "Succeeded"}:
296
+ return "●", green
297
+ if phase in {"Pending", "Syncing", "RollingOut"}:
298
+ return "●", yellow
299
+ if phase in {"Failed", "RolloutFailed"}:
300
+ return "●", red
301
+ return "●", gray
302
+
303
+ def action_open_url(self) -> None:
304
+ if not self.deployment or not self.deployment.apiserver_url:
305
+ return
306
+ logger.debug(f"Opening URL: {self.deployment.apiserver_url}")
307
+ webbrowser.open(str(self.deployment.apiserver_url))
308
+
309
+ def _render_status_line(self) -> Text:
310
+ phase = self.deployment.status if self.deployment else "Unknown"
311
+ icon, style = self._status_icon_and_style(phase)
312
+ line = Text()
313
+ line.append(icon, style=style)
314
+ line.append(" ")
315
+ line.append(f"Status: {phase} — Deployment ID: {self.deployment_id or '-'}")
316
+ return line
317
+
318
+ def _render_last_event_details(self) -> Content:
319
+ if not self.deployment or not self.deployment.events:
320
+ return Content()
321
+ latest = self.deployment.events[-1]
322
+ txt = Text(f" {latest.message}", style="dim")
323
+ return Content.from_rich_text(txt)
324
+
325
+ def _render_last_event_status(self) -> Content:
326
+ if not self.deployment or not self.deployment.events:
327
+ return Content()
328
+ txt = Text()
329
+ # Pick the most recent by last_timestamp
330
+ latest = self.deployment.events[-1]
331
+ ts = None
332
+ ts = (latest.last_timestamp or latest.first_timestamp).strftime(
333
+ "%Y-%m-%d %H:%M:%S"
334
+ )
335
+ parts: list[str] = []
336
+ if latest.type:
337
+ parts.append(latest.type)
338
+ if latest.reason:
339
+ parts.append(latest.reason)
340
+ kind = "/".join(parts) if parts else None
341
+ if kind:
342
+ txt.append(f"{kind} ", style="medium_purple3")
343
+ txt.append(f"{ts}", style="dim")
344
+ return Content.from_rich_text(txt)
345
+
346
+ def on_unmount(self) -> None:
347
+ # Attempt to stop the streaming loop
348
+ self._stop_stream.set()
349
+
350
+ # Reactive watchers to update widgets in place instead of recomposing
351
+ def watch_error_message(self, message: str) -> None:
352
+ try:
353
+ widget = self.query_one("#error_message", Static)
354
+ except Exception:
355
+ return
356
+ widget.update(message)
357
+ widget.display = bool(message)
358
+
359
+ def watch_deployment(self, deployment: DeploymentResponse | None) -> None:
360
+ if deployment is None:
361
+ return
362
+
363
+ widget = self.query_one("#status_line", Static)
364
+ ev_widget = self.query_one("#last_event_status", Static)
365
+ ev_details_widget = self.query_one("#last_event_details", Static)
366
+ deployment_link_button = self.query_one("#deployment_link_button", Button)
367
+ widget.update(self._render_status_line())
368
+ deployment_link_button.label = f"{str(self.deployment.apiserver_url or '')}"
369
+ # Update last event line
370
+ ev_widget.update(self._render_last_event_status())
371
+ ev_details_widget.update(self._render_last_event_details())
372
+ ev_details_widget.display = bool(self.deployment and self.deployment.events)
373
+
374
+ def watch_wrap_enabled(self, enabled: bool) -> None:
375
+ try:
376
+ log_widget = self.query_one("#log_view", RichLog)
377
+ log_widget.wrap = enabled
378
+ # Clear existing lines; new wrap mode will apply to subsequent events
379
+ log_widget.clear()
380
+ for text in self._log_buffer:
381
+ log_widget.write(text)
382
+ except Exception:
383
+ pass
384
+ try:
385
+ btn = self.query_one("#toggle_wrap", Button)
386
+ btn.label = "Wrap: On" if enabled else "Wrap: Off"
387
+ except Exception:
388
+ pass
389
+
390
+ def watch_autoscroll_enabled(self, enabled: bool) -> None:
391
+ try:
392
+ log_widget = self.query_one("#log_view", RichLog)
393
+ log_widget.auto_scroll = enabled
394
+ except Exception:
395
+ pass
396
+ try:
397
+ btn = self.query_one("#toggle_autoscroll", Button)
398
+ btn.label = "Scroll: Auto" if enabled else "Scroll: Off"
399
+ except Exception:
400
+ pass
401
+
402
+ async def _poll_deployment_status(self) -> None:
403
+ """Periodically refresh deployment status to reflect updates in the UI."""
404
+ while not self._stop_stream.is_set():
405
+ try:
406
+ async with project_client_context() as client:
407
+ self.deployment = await client.get_deployment(
408
+ self.deployment_id, include_events=True
409
+ )
410
+ # Clear any previous error on success
411
+ if self.error_message:
412
+ self.error_message = ""
413
+ except Exception as e: # pragma: no cover - network errors
414
+ # Non-fatal; will try again on next interval
415
+ self.error_message = f"Failed to refresh status: {e}"
416
+ await asyncio.sleep(5)
417
+
418
+
419
+ class MonitorCloseMessage(Message):
420
+ pass
421
+
422
+
423
+ class LogBatchMessage(Message):
424
+ def __init__(self, events: list[LogEvent]) -> None:
425
+ super().__init__()
426
+ self.events = events
427
+
428
+
429
+ class ErrorTextMessage(Message):
430
+ def __init__(self, text: str) -> None:
431
+ super().__init__()
432
+ self.text = text
433
+
434
+
435
+ class DeploymentMonitorApp(App[None]):
436
+ """Standalone app wrapper around the monitor widget.
437
+
438
+ This allows easy reuse in other flows by embedding the widget.
439
+ """
440
+
441
+ CSS_PATH = Path(__file__).parent / "styles.tcss"
442
+
443
+ def __init__(self, deployment_id: str) -> None:
444
+ super().__init__()
445
+ self.deployment_id = deployment_id
446
+
447
+ def on_mount(self) -> None:
448
+ self.theme = "tokyo-night"
449
+
450
+ def compose(self) -> ComposeResult:
451
+ with Container():
452
+ yield DeploymentMonitorWidget(self.deployment_id)
453
+
454
+ def on_monitor_close_message(self, _: MonitorCloseMessage) -> None:
455
+ self.exit(None)
456
+
457
+ def on_key(self, event: events.Key) -> None:
458
+ # Support Ctrl+C to exit, consistent with other screens and terminals
459
+ if event.key == "ctrl+c":
460
+ self.exit(None)
461
+
462
+
463
+ def monitor_deployment_screen(deployment_id: str) -> None:
464
+ """Launch the standalone deployment monitor screen."""
465
+ app = DeploymentMonitorApp(deployment_id)
466
+ app.run()
@@ -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_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
 
@@ -206,7 +205,6 @@ class GitValidationWidget(Widget):
206
205
  yield Button(
207
206
  "Continue", id="continue_success", variant="primary", compact=True
208
207
  )
209
- print("DEBUG: render cancel button")
210
208
  # Always show cancel button
211
209
  yield Button("Back to Edit", id="cancel", variant="default", compact=True)
212
210
 
@@ -226,7 +224,7 @@ class GitValidationWidget(Widget):
226
224
  self.current_state = "options"
227
225
  self.error_message = ""
228
226
  elif event.button.id == "cancel_github_auth":
229
- self._cancel_github_auth()
227
+ self.run_worker(self._cancel_github_auth())
230
228
  elif event.button.id == "recheck_github":
231
229
  self.run_worker(self._recheck_github_auth())
232
230
  elif event.button.id == "continue_success":
@@ -238,7 +236,6 @@ class GitValidationWidget(Widget):
238
236
  )
239
237
  self.post_message(ValidationResultMessage(self.repo_url, pat_to_send))
240
238
  elif event.button.id == "cancel":
241
- print("DEBUG: cancel button pressed")
242
239
  self.post_message(ValidationCancelMessage())
243
240
 
244
241
  def _start_github_auth(self) -> None:
@@ -261,10 +258,10 @@ class GitValidationWidget(Widget):
261
258
  self.github_callback_server = GitHubCallbackServer()
262
259
  self.run_worker(self._wait_for_callback())
263
260
 
264
- def _cancel_github_auth(self) -> None:
261
+ async def _cancel_github_auth(self) -> None:
265
262
  """Cancel GitHub authentication and return to options"""
266
263
  if self.github_callback_server:
267
- self.github_callback_server.stop()
264
+ await self.github_callback_server.stop()
268
265
  self.github_callback_server = None
269
266
  self.current_state = "options"
270
267
 
@@ -285,14 +282,14 @@ class GitValidationWidget(Widget):
285
282
  self.error_message = ""
286
283
  try:
287
284
  client = get_client()
288
- response = client.validate_repository(
285
+ self.validation_response = await client.validate_repository(
289
286
  repo_url=self.repo_url, deployment_id=self.deployment_id, pat=pat
290
287
  )
291
- self.validation_response = response
292
288
 
293
- if response.accessible:
289
+ resp = self.validation_response
290
+ if resp and resp.accessible:
294
291
  # Success - post result message with appropriate messaging
295
- if response.pat_is_obsolete:
292
+ if resp.pat_is_obsolete:
296
293
  # Show success message about PAT obsolescence before proceeding
297
294
  self.current_state = "success"
298
295
  self.error_message = "Repository accessible via GitHub App. Your Personal Access Token is now obsolete and will be removed."
@@ -311,22 +308,24 @@ class GitValidationWidget(Widget):
311
308
  self.error_message = ""
312
309
  try:
313
310
  client = get_client()
314
- response = client.validate_repository(
311
+ self.validation_response = await client.validate_repository(
315
312
  repo_url=self.repo_url, deployment_id=self.deployment_id
316
313
  )
317
- self.validation_response = response
318
314
 
319
- if response.accessible:
315
+ resp = self.validation_response
316
+ if resp and resp.accessible:
320
317
  # Success - post result message with appropriate messaging
321
318
  self.current_state = "success"
322
319
  self.post_message(
323
320
  ValidationResultMessage(
324
- self.repo_url, "" if response.pat_is_obsolete else None
321
+ self.repo_url, "" if resp.pat_is_obsolete else None
325
322
  )
326
323
  )
327
324
  else:
328
325
  # Failed - stay in github_auth and show error
329
- self.error_message = f"Still not accessible: {response.message}"
326
+ self.error_message = (
327
+ f"Still not accessible: {resp.message if resp else ''}"
328
+ )
330
329
 
331
330
  except Exception as e:
332
331
  # Failed - stay in github_auth and show error
@@ -4,9 +4,12 @@ import asyncio
4
4
  import logging
5
5
  import webbrowser
6
6
  from textwrap import dedent
7
- from typing import Dict, Any, cast
7
+ from typing import Any, Dict, cast
8
8
 
9
- from aiohttp import web
9
+ from aiohttp.web_app import Application
10
+ from aiohttp.web_request import Request
11
+ from aiohttp.web_response import Response
12
+ from aiohttp.web_runner import AppRunner, TCPSite
10
13
 
11
14
  logger = logging.getLogger(__name__)
12
15
 
@@ -18,9 +21,9 @@ class GitHubCallbackServer:
18
21
  self.port = port
19
22
  self.callback_data: Dict[str, Any] = {}
20
23
  self.callback_received = asyncio.Event()
21
- self.app: web.Application | None = None
22
- self.runner: web.AppRunner | None = None
23
- self.site: web.TCPSite | None = None
24
+ self.app: Application | None = None
25
+ self.runner: AppRunner | None = None
26
+ self.site: TCPSite | None = None
24
27
 
25
28
  async def start_and_wait(self, timeout: float = 300) -> Dict[str, Any]:
26
29
  """Start the server and wait for a callback with timeout"""
@@ -38,19 +41,19 @@ class GitHubCallbackServer:
38
41
 
39
42
  async def _start_server(self) -> None:
40
43
  """Start the aiohttp server"""
41
- self.app = web.Application()
44
+ self.app = Application()
42
45
  self.app.router.add_get("/", self._handle_callback)
43
46
  self.app.router.add_get("/{path:.*}", self._handle_callback)
44
47
 
45
- self.runner = web.AppRunner(self.app, logger=None) # Suppress server logs
48
+ self.runner = AppRunner(self.app, logger=None) # Suppress server logs
46
49
  await self.runner.setup()
47
50
 
48
- self.site = web.TCPSite(self.runner, "localhost", self.port)
51
+ self.site = TCPSite(self.runner, "localhost", self.port)
49
52
  await self.site.start()
50
53
 
51
54
  logger.debug(f"GitHub callback server started on port {self.port}")
52
55
 
53
- async def _handle_callback(self, request: web.Request) -> web.Response:
56
+ async def _handle_callback(self, request: Request) -> Response:
54
57
  """Handle the GitHub callback"""
55
58
  # Capture query parameters
56
59
  query_params: dict[str, str] = dict(cast(Any, request.query))
@@ -62,7 +65,7 @@ class GitHubCallbackServer:
62
65
 
63
66
  # Return success page
64
67
  html_response = self._get_success_html()
65
- return web.Response(text=html_response, content_type="text/html")
68
+ return Response(text=html_response, content_type="text/html")
66
69
 
67
70
  async def stop(self) -> None:
68
71
  """Stop the server and cleanup"""
@@ -188,17 +191,17 @@ async def main():
188
191
  server = GitHubCallbackServer(port=41010)
189
192
 
190
193
  # Start server and open browser
191
- print(f"Starting GitHub callback server on http://localhost:{server.port}")
192
- print("Opening browser to show success page...")
194
+ logger.debug(f"Starting GitHub callback server on http://localhost:{server.port}")
195
+ logger.debug("Opening browser to show success page...")
193
196
 
194
197
  # Open browser to success page to see the styling
195
198
  webbrowser.open(f"http://localhost:{server.port}")
196
199
 
197
200
  try:
198
201
  # Wait for callback (or just keep server running)
199
- print("Server running... Press Ctrl+C to stop")
202
+ logger.debug("Server running... Press Ctrl+C to stop")
200
203
  callback_data = await server.start_and_wait(timeout=3600) # 1 hour timeout
201
- print(f"Received callback data: {callback_data}")
204
+ logger.debug(f"Received callback data: {callback_data}")
202
205
  finally:
203
206
  await server.stop()
204
207
 
@@ -1,11 +1,23 @@
1
1
  import re
2
+ from typing import TypedDict
3
+
2
4
  from textual.widgets import Static
3
5
 
4
6
 
7
+ class StaticKwargs(TypedDict, total=False):
8
+ expand: bool
9
+ shrink: bool
10
+ markup: bool
11
+ name: str | None
12
+ id: str | None
13
+ classes: str | None
14
+ disabled: bool
15
+
16
+
5
17
  class PixelLlamaLoader(Static):
6
18
  """Pixelated llama loading animation using block characters"""
7
19
 
8
- def __init__(self, **kwargs):
20
+ def __init__(self, **kwargs: StaticKwargs):
9
21
  self.frame = 0
10
22
  # Pixelated llama frames using Unicode block characters
11
23
  self.frames = [