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,357 @@
1
+ """Git repository validation widget for Textual CLI"""
2
+
3
+ import logging
4
+ import webbrowser
5
+ from typing import Literal, cast
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import HorizontalGroup, Widget
9
+ from textual.widgets import Button, Input, Label, Static
10
+ from textual.message import Message
11
+ from textual.content import Content
12
+ 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
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ValidationResultMessage(Message):
23
+ """Message sent when validation completes successfully"""
24
+
25
+ def __init__(self, repo_url: str, pat: str | None = None):
26
+ super().__init__()
27
+ self.repo_url = repo_url
28
+ self.pat = pat
29
+
30
+
31
+ class ValidationCancelMessage(Message):
32
+ """Message sent when validation is cancelled"""
33
+
34
+ pass
35
+
36
+
37
+ States = Literal["validating", "options", "pat_input", "github_auth", "success"]
38
+
39
+
40
+ class GitValidationWidget(Widget):
41
+ """Widget for handling repository validation and GitHub App authentication"""
42
+
43
+ DEFAULT_CSS = """
44
+ GitValidationWidget {
45
+ layout: vertical;
46
+ height: auto;
47
+ }
48
+
49
+
50
+ .validation-options {
51
+ layout: vertical;
52
+ margin-top: 1;
53
+ align: center middle;
54
+ height: auto;
55
+ }
56
+
57
+ .validation-options Button {
58
+ max-width: 40;
59
+ }
60
+
61
+ .option-button {
62
+ margin-bottom: 1;
63
+ width: 100%;
64
+ }
65
+
66
+ .pat-input-section {
67
+ layout: vertical;
68
+ margin: 2 0;
69
+ border: solid $primary-muted;
70
+ padding: 1;
71
+ }
72
+
73
+ .url-link {
74
+ text-style: underline;
75
+ color: $accent;
76
+ }
77
+
78
+ """
79
+
80
+ validation_response: reactive[RepositoryValidationResponse | None] = reactive(
81
+ cast(RepositoryValidationResponse | None, None), recompose=True
82
+ )
83
+ current_state: reactive[States] = reactive(
84
+ cast(States, "validating"), recompose=True
85
+ )
86
+ error_message: reactive[str] = reactive("", recompose=True)
87
+ repo_url: str = ""
88
+ deployment_id: str | None = None
89
+ github_callback_server: GitHubCallbackServer | None = None
90
+
91
+ def __init__(
92
+ self,
93
+ repo_url: str,
94
+ deployment_id: str | None = None,
95
+ pat: str | None = None,
96
+ ):
97
+ super().__init__()
98
+ self.repo_url = repo_url
99
+ self.deployment_id = deployment_id
100
+ self.initial_pat = pat
101
+
102
+ def on_mount(self) -> None:
103
+ """Start validation when widget mounts"""
104
+ self.run_worker(self._validate_repository(self.initial_pat))
105
+
106
+ def compose(self) -> ComposeResult:
107
+ yield Static("Repository Validation", classes="primary-message")
108
+
109
+ if self.current_state == "validating":
110
+ yield Static("Validating repository access...")
111
+ yield PixelLlamaLoader(classes="mb-1")
112
+
113
+ elif self.current_state == "options":
114
+ if not self.validation_response:
115
+ yield Static(self.error_message, classes="error-message")
116
+ else:
117
+ yield Static(self.validation_response.message, classes="error-message")
118
+
119
+ with Widget(classes="validation-options"):
120
+ if self.validation_response is not None:
121
+ if self.validation_response.github_app_installation_url:
122
+ # GitHub repository with app available
123
+ yield Button(
124
+ "Install GitHub App (Recommended)",
125
+ id="install_github_app",
126
+ classes="option-button",
127
+ variant="primary",
128
+ compact=True,
129
+ )
130
+ yield Button(
131
+ "Use Personal Access Token (PAT)",
132
+ id="use_pat",
133
+ classes="option-button",
134
+ variant="primary",
135
+ compact=True,
136
+ )
137
+ else:
138
+ # Non-GitHub or GitHub without app
139
+ yield Button(
140
+ "Retry with Personal Access Token (PAT)",
141
+ id="use_pat",
142
+ classes="option-button",
143
+ variant="primary",
144
+ compact=True,
145
+ )
146
+ yield Button(
147
+ "Save Anyway",
148
+ id="save_anyway",
149
+ classes="option-button",
150
+ variant="warning",
151
+ compact=True,
152
+ )
153
+
154
+ elif self.current_state == "pat_input":
155
+ if self.error_message:
156
+ yield Static(self.error_message, classes="text-error mb-1")
157
+
158
+ with Widget(classes="two-column-form-grid mb-1"):
159
+ yield Label("Personal Access Token:", classes="form-label")
160
+ yield Input(
161
+ placeholder="Enter your PAT",
162
+ password=True,
163
+ id="pat_input",
164
+ compact=True,
165
+ )
166
+
167
+ elif self.current_state == "github_auth":
168
+ yield Static("Waiting for GitHub App installation...")
169
+ if self.error_message:
170
+ yield Static(self.error_message, classes="error-message mt-1")
171
+ yield PixelLlamaLoader(classes="mb-1")
172
+
173
+ if (
174
+ self.validation_response
175
+ and self.validation_response.github_app_installation_url
176
+ ):
177
+ yield Static(
178
+ Content.from_markup(
179
+ f'Open this URL to Install: [link="{self.validation_response.github_app_installation_url}"]{self.validation_response.github_app_installation_url}[/link]'
180
+ ),
181
+ classes="mb-1",
182
+ )
183
+
184
+ elif self.current_state == "success":
185
+ yield Static("Validation Successful", classes="text-success mb-2")
186
+ if self.error_message:
187
+ yield Static(self.error_message, classes="text-warning mb-2")
188
+
189
+ # Single button row for all states
190
+ with HorizontalGroup(classes="button-row"):
191
+ if self.current_state == "pat_input":
192
+ yield Button(
193
+ "Validate", id="validate_pat", variant="primary", compact=True
194
+ )
195
+ yield Button(
196
+ "Back", id="back_to_options", variant="default", compact=True
197
+ )
198
+ elif self.current_state == "github_auth":
199
+ yield Button(
200
+ "Recheck", id="recheck_github", variant="primary", compact=True
201
+ )
202
+ yield Button(
203
+ "Cancel", id="cancel_github_auth", variant="default", compact=True
204
+ )
205
+ elif self.current_state == "success":
206
+ yield Button(
207
+ "Continue", id="continue_success", variant="primary", compact=True
208
+ )
209
+ print("DEBUG: render cancel button")
210
+ # Always show cancel button
211
+ yield Button("Back to Edit", id="cancel", variant="default", compact=True)
212
+
213
+ def on_button_pressed(self, event: Button.Pressed) -> None:
214
+ """Handle button presses"""
215
+ if event.button.id == "install_github_app":
216
+ self._start_github_auth()
217
+ elif event.button.id == "use_pat":
218
+ self.current_state = "pat_input"
219
+ self.error_message = ""
220
+ elif event.button.id == "save_anyway":
221
+ logging.info("saving anyway")
222
+ self.post_message(ValidationResultMessage(self.repo_url))
223
+ elif event.button.id == "validate_pat":
224
+ self._validate_with_pat()
225
+ elif event.button.id == "back_to_options":
226
+ self.current_state = "options"
227
+ self.error_message = ""
228
+ elif event.button.id == "cancel_github_auth":
229
+ self._cancel_github_auth()
230
+ elif event.button.id == "recheck_github":
231
+ self.run_worker(self._recheck_github_auth())
232
+ elif event.button.id == "continue_success":
233
+ # For PAT obsolescence case, send empty PAT to clear it
234
+ pat_to_send = (
235
+ ""
236
+ if self.validation_response and self.validation_response.pat_is_obsolete
237
+ else None
238
+ )
239
+ self.post_message(ValidationResultMessage(self.repo_url, pat_to_send))
240
+ elif event.button.id == "cancel":
241
+ print("DEBUG: cancel button pressed")
242
+ self.post_message(ValidationCancelMessage())
243
+
244
+ def _start_github_auth(self) -> None:
245
+ """Start GitHub App authentication flow"""
246
+ if (
247
+ not self.validation_response
248
+ or not self.validation_response.github_app_installation_url
249
+ ):
250
+ return
251
+
252
+ self.current_state = "github_auth"
253
+
254
+ # Open browser to GitHub App installation URL
255
+ try:
256
+ webbrowser.open(self.validation_response.github_app_installation_url)
257
+ except Exception as e:
258
+ logger.warning(f"Failed to open browser: {e}")
259
+
260
+ # # Start callback server
261
+ self.github_callback_server = GitHubCallbackServer()
262
+ self.run_worker(self._wait_for_callback())
263
+
264
+ def _cancel_github_auth(self) -> None:
265
+ """Cancel GitHub authentication and return to options"""
266
+ if self.github_callback_server:
267
+ self.github_callback_server.stop()
268
+ self.github_callback_server = None
269
+ self.current_state = "options"
270
+
271
+ def _validate_with_pat(self) -> None:
272
+ """Validate repository with PAT from input"""
273
+ pat_input = self.query_one("#pat_input", Input)
274
+ pat = pat_input.value.strip()
275
+
276
+ if not pat:
277
+ self.error_message = "Please enter a Personal Access Token"
278
+ return
279
+
280
+ self.run_worker(self._validate_repository(pat))
281
+
282
+ async def _validate_repository(self, pat: str | None = None) -> None:
283
+ """Perform repository validation"""
284
+ self.current_state = "validating"
285
+ self.error_message = ""
286
+ try:
287
+ client = get_client()
288
+ response = client.validate_repository(
289
+ repo_url=self.repo_url, deployment_id=self.deployment_id, pat=pat
290
+ )
291
+ self.validation_response = response
292
+
293
+ if response.accessible:
294
+ # Success - post result message with appropriate messaging
295
+ if response.pat_is_obsolete:
296
+ # Show success message about PAT obsolescence before proceeding
297
+ self.current_state = "success"
298
+ self.error_message = "Repository accessible via GitHub App. Your Personal Access Token is now obsolete and will be removed."
299
+ else:
300
+ self.post_message(ValidationResultMessage(self.repo_url, pat))
301
+ else:
302
+ # Failed - show options
303
+ self.current_state = "options"
304
+
305
+ except Exception as e:
306
+ self.error_message = f"Validation failed: {e}"
307
+ self.current_state = "options"
308
+
309
+ async def _recheck_github_auth(self) -> None:
310
+ """Re-validate repository while staying in github_auth state"""
311
+ self.error_message = ""
312
+ try:
313
+ client = get_client()
314
+ response = client.validate_repository(
315
+ repo_url=self.repo_url, deployment_id=self.deployment_id
316
+ )
317
+ self.validation_response = response
318
+
319
+ if response.accessible:
320
+ # Success - post result message with appropriate messaging
321
+ self.current_state = "success"
322
+ self.post_message(
323
+ ValidationResultMessage(
324
+ self.repo_url, "" if response.pat_is_obsolete else None
325
+ )
326
+ )
327
+ else:
328
+ # Failed - stay in github_auth and show error
329
+ self.error_message = f"Still not accessible: {response.message}"
330
+
331
+ except Exception as e:
332
+ # Failed - stay in github_auth and show error
333
+ self.error_message = f"Recheck failed: {e}"
334
+
335
+ async def _wait_for_callback(self) -> None:
336
+ """Wait for GitHub callback and re-validate"""
337
+ if not self.github_callback_server:
338
+ return
339
+
340
+ try:
341
+ # Wait for callback with timeout
342
+ await self.github_callback_server.start_and_wait(timeout=300)
343
+ # Process callback - re-validate without PAT since GitHub App should now be available
344
+ await self._validate_repository()
345
+
346
+ except TimeoutError:
347
+ logger.info("callback timed out")
348
+ self.error_message = "Authentication timed out"
349
+ self.current_state = "options"
350
+ except Exception as e:
351
+ logger.error("callback failed", exc_info=True)
352
+ self.error_message = f"Callback failed: {e}"
353
+ self.current_state = "options"
354
+ finally:
355
+ if self.github_callback_server:
356
+ self.github_callback_server.stop()
357
+ self.github_callback_server = None
@@ -0,0 +1,207 @@
1
+ """GitHub App callback server for handling OAuth flows"""
2
+
3
+ import asyncio
4
+ import logging
5
+ import webbrowser
6
+ from textwrap import dedent
7
+ from typing import Dict, Any, cast
8
+
9
+ from aiohttp import web
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GitHubCallbackServer:
15
+ """Local HTTP server to handle GitHub App installation callbacks"""
16
+
17
+ def __init__(self, port: int = 41010):
18
+ self.port = port
19
+ self.callback_data: Dict[str, Any] = {}
20
+ 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
+
25
+ async def start_and_wait(self, timeout: float = 300) -> Dict[str, Any]:
26
+ """Start the server and wait for a callback with timeout"""
27
+ await self._start_server()
28
+
29
+ try:
30
+ # Wait for callback with timeout
31
+ await asyncio.wait_for(self.callback_received.wait(), timeout=timeout)
32
+ logger.debug(f"processing callback: {self.callback_data}")
33
+ return self.callback_data
34
+ except asyncio.TimeoutError:
35
+ raise TimeoutError(f"GitHub callback timed out after {timeout} seconds")
36
+ finally:
37
+ await self.stop()
38
+
39
+ async def _start_server(self) -> None:
40
+ """Start the aiohttp server"""
41
+ self.app = web.Application()
42
+ self.app.router.add_get("/", self._handle_callback)
43
+ self.app.router.add_get("/{path:.*}", self._handle_callback)
44
+
45
+ self.runner = web.AppRunner(self.app, logger=None) # Suppress server logs
46
+ await self.runner.setup()
47
+
48
+ self.site = web.TCPSite(self.runner, "localhost", self.port)
49
+ await self.site.start()
50
+
51
+ logger.debug(f"GitHub callback server started on port {self.port}")
52
+
53
+ async def _handle_callback(self, request: web.Request) -> web.Response:
54
+ """Handle the GitHub callback"""
55
+ # Capture query parameters
56
+ query_params: dict[str, str] = dict(cast(Any, request.query))
57
+ self.callback_data.update(query_params)
58
+
59
+ # Signal that callback was received
60
+ logger.debug(f"GitHub callback received: {query_params}")
61
+ self.callback_received.set()
62
+
63
+ # Return success page
64
+ html_response = self._get_success_html()
65
+ return web.Response(text=html_response, content_type="text/html")
66
+
67
+ async def stop(self) -> None:
68
+ """Stop the server and cleanup"""
69
+ if self.site:
70
+ await self.site.stop()
71
+ self.site = None
72
+ if self.runner:
73
+ await self.runner.cleanup()
74
+ self.runner = None
75
+ self.app = None
76
+ self.callback_received.clear()
77
+ logger.debug("GitHub callback server stopped")
78
+
79
+ def _get_success_html(self) -> str:
80
+ """Get the HTML for the success page"""
81
+ return dedent("""
82
+ <!DOCTYPE html>
83
+ <html>
84
+ <meta charset="UTF-8">
85
+ <head>
86
+ <title>llamactl - Authentication Complete</title>
87
+ <style>
88
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
89
+
90
+ * {
91
+ margin: 0;
92
+ padding: 0;
93
+ box-sizing: border-box;
94
+ }
95
+
96
+ body {
97
+ font-family: 'JetBrains Mono', 'Courier New', monospace;
98
+ background: #1a0d26;
99
+ color: #e4e4e7;
100
+ min-height: 100vh;
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ line-height: 1.6;
105
+ }
106
+
107
+ .terminal {
108
+ background: #0f0a17;
109
+ border: 2px solid #7c3aed;
110
+ border-radius: 0;
111
+ max-width: 600px;
112
+ width: 90vw;
113
+ padding: 0;
114
+ box-shadow: 0 0 20px rgba(124, 58, 237, 0.3);
115
+ }
116
+
117
+ .terminal-header {
118
+ background: #7c3aed;
119
+ color: #ffffff;
120
+ padding: 12px 20px;
121
+ font-weight: bold;
122
+ font-size: 14px;
123
+ border-bottom: 2px solid #6d28d9;
124
+ }
125
+
126
+ .terminal-body {
127
+ padding: 30px;
128
+ }
129
+
130
+ .prompt {
131
+ color: #10b981;
132
+ font-weight: bold;
133
+ }
134
+
135
+ .success-icon {
136
+ color: #10b981;
137
+ font-size: 24px;
138
+ margin-right: 8px;
139
+ }
140
+
141
+ .highlight {
142
+ color: #a78bfa;
143
+ font-weight: bold;
144
+ }
145
+
146
+ .instruction {
147
+ background: #2d1b69;
148
+ border: 1px solid #7c3aed;
149
+ padding: 16px;
150
+ margin: 20px 0;
151
+ border-radius: 4px;
152
+ }
153
+
154
+ .blink {
155
+ animation: blink 1s infinite;
156
+ }
157
+
158
+ @keyframes blink {
159
+ 0%, 50% { opacity: 1; }
160
+ 51%, 100% { opacity: 0; }
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <div class="terminal">
166
+ <div class="terminal-header">
167
+ llamactl@github-auth-server
168
+ </div>
169
+ <div class="terminal-body">
170
+ <div><span class="prompt">$</span> GitHub App installation successful</div>
171
+ <div class="instruction">
172
+ <div><strong>Next Steps:</strong></div>
173
+ <div>1. Close this browser</div>
174
+ <div>2. Return to your terminal</div>
175
+ <div>3. Continue with llamactl<span class="blink">_</span></div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </body>
180
+ </html>
181
+ """).strip()
182
+
183
+
184
+ async def main():
185
+ """Main function to demo the callback server"""
186
+ logging.basicConfig(level=logging.INFO)
187
+
188
+ server = GitHubCallbackServer(port=41010)
189
+
190
+ # 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...")
193
+
194
+ # Open browser to success page to see the styling
195
+ webbrowser.open(f"http://localhost:{server.port}")
196
+
197
+ try:
198
+ # Wait for callback (or just keep server running)
199
+ print("Server running... Press Ctrl+C to stop")
200
+ callback_data = await server.start_and_wait(timeout=3600) # 1 hour timeout
201
+ print(f"Received callback data: {callback_data}")
202
+ finally:
203
+ await server.stop()
204
+
205
+
206
+ if __name__ == "__main__":
207
+ asyncio.run(main())
@@ -0,0 +1,52 @@
1
+ import re
2
+ from textual.widgets import Static
3
+
4
+
5
+ class PixelLlamaLoader(Static):
6
+ """Pixelated llama loading animation using block characters"""
7
+
8
+ def __init__(self, **kwargs):
9
+ self.frame = 0
10
+ # Pixelated llama frames using Unicode block characters
11
+ self.frames = [
12
+ # ── Frame 1 – all legs down (starting position) ─
13
+ """
14
+ ,
15
+ ~)
16
+ (_---;
17
+ |~|
18
+ | |""",
19
+ # ── Frame 2 – lift right front leg ─
20
+ """
21
+ ,
22
+ ~)
23
+ (_---;
24
+ /|~|
25
+ / | |""",
26
+ """
27
+ ,
28
+ ~)
29
+ (_---;
30
+ /|~|
31
+ || |\\""",
32
+ # ── Frame 3 – right front forward, lift left back ─
33
+ """
34
+ ,
35
+ ~)
36
+ (_---;
37
+ |~|\\
38
+ |\\| \\""",
39
+ ]
40
+ self.frames = [re.sub(r"^\n", "", x) for x in self.frames]
41
+
42
+ super().__init__(self._get_display_text(), **kwargs)
43
+
44
+ def _get_display_text(self) -> str:
45
+ return f"{self.frames[self.frame]}"
46
+
47
+ def on_mount(self) -> None:
48
+ self.timer = self.set_interval(0.6, self.animate)
49
+
50
+ def animate(self) -> None:
51
+ self.frame = (self.frame + 1) % len(self.frames)
52
+ self.update(self._get_display_text())