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.
- llama_deploy/cli/__init__.py +9 -22
- llama_deploy/cli/app.py +69 -0
- llama_deploy/cli/auth/client.py +362 -0
- llama_deploy/cli/client.py +47 -170
- llama_deploy/cli/commands/aliased_group.py +33 -0
- llama_deploy/cli/commands/auth.py +696 -0
- llama_deploy/cli/commands/deployment.py +300 -0
- llama_deploy/cli/commands/env.py +211 -0
- llama_deploy/cli/commands/init.py +313 -0
- llama_deploy/cli/commands/serve.py +239 -0
- llama_deploy/cli/config/_config.py +390 -0
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +130 -0
- llama_deploy/cli/config/env_service.py +67 -0
- llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llama_deploy/cli/config/migrations/__init__.py +7 -0
- llama_deploy/cli/config/schema.py +61 -0
- llama_deploy/cli/env.py +5 -3
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +6 -72
- llama_deploy/cli/options.py +27 -5
- llama_deploy/cli/py.typed +0 -0
- llama_deploy/cli/styles.py +10 -0
- llama_deploy/cli/textual/deployment_form.py +263 -36
- llama_deploy/cli/textual/deployment_help.py +53 -0
- llama_deploy/cli/textual/deployment_monitor.py +466 -0
- llama_deploy/cli/textual/git_validation.py +20 -21
- llama_deploy/cli/textual/github_callback_server.py +17 -14
- llama_deploy/cli/textual/llama_loader.py +13 -1
- llama_deploy/cli/textual/secrets_form.py +28 -8
- llama_deploy/cli/textual/styles.tcss +49 -8
- llama_deploy/cli/utils/env_inject.py +23 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
- llamactl-0.3.0.dist-info/RECORD +38 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/cli/commands.py +0 -549
- llama_deploy/cli/config.py +0 -173
- llama_deploy/cli/textual/profile_form.py +0 -171
- llamactl-0.2.7a1.dist-info/RECORD +0 -19
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,28 +1,53 @@
|
|
|
1
1
|
"""Textual-based deployment forms for CLI interactions"""
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
3
|
import dataclasses
|
|
5
4
|
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
7
9
|
|
|
8
|
-
from llama_deploy.cli.
|
|
10
|
+
from llama_deploy.cli.client import get_project_client as get_client
|
|
11
|
+
from llama_deploy.cli.env import load_env_secrets_from_string
|
|
12
|
+
from llama_deploy.cli.textual.deployment_help import (
|
|
13
|
+
DeploymentHelpBackMessage,
|
|
14
|
+
DeploymentHelpWidget,
|
|
15
|
+
)
|
|
16
|
+
from llama_deploy.cli.textual.deployment_monitor import (
|
|
17
|
+
DeploymentMonitorWidget,
|
|
18
|
+
MonitorCloseMessage,
|
|
19
|
+
)
|
|
9
20
|
from llama_deploy.cli.textual.git_validation import (
|
|
10
21
|
GitValidationWidget,
|
|
11
|
-
ValidationResultMessage,
|
|
12
22
|
ValidationCancelMessage,
|
|
23
|
+
ValidationResultMessage,
|
|
24
|
+
)
|
|
25
|
+
from llama_deploy.cli.textual.secrets_form import SecretsWidget
|
|
26
|
+
from llama_deploy.core.deployment_config import (
|
|
27
|
+
DEFAULT_DEPLOYMENT_NAME,
|
|
28
|
+
read_deployment_config,
|
|
29
|
+
)
|
|
30
|
+
from llama_deploy.core.git.git_util import (
|
|
31
|
+
get_current_branch,
|
|
32
|
+
get_git_root,
|
|
33
|
+
get_unpushed_commits_count,
|
|
34
|
+
is_git_repo,
|
|
35
|
+
list_remotes,
|
|
36
|
+
working_tree_has_changes,
|
|
13
37
|
)
|
|
14
38
|
from llama_deploy.core.schema.deployments import (
|
|
15
39
|
DeploymentCreate,
|
|
16
40
|
DeploymentResponse,
|
|
17
41
|
DeploymentUpdate,
|
|
18
42
|
)
|
|
43
|
+
from textual import events
|
|
19
44
|
from textual.app import App, ComposeResult
|
|
20
45
|
from textual.containers import Container, HorizontalGroup, Widget
|
|
46
|
+
from textual.content import Content
|
|
47
|
+
from textual.message import Message
|
|
48
|
+
from textual.reactive import reactive
|
|
21
49
|
from textual.validation import Length
|
|
22
50
|
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
51
|
|
|
27
52
|
|
|
28
53
|
@dataclass
|
|
@@ -34,7 +59,7 @@ class DeploymentForm:
|
|
|
34
59
|
id: str | None = None
|
|
35
60
|
repo_url: str = ""
|
|
36
61
|
git_ref: str = "main"
|
|
37
|
-
deployment_file_path: str = "
|
|
62
|
+
deployment_file_path: str = ""
|
|
38
63
|
personal_access_token: str = ""
|
|
39
64
|
# indicates if the deployment has a personal access token (value is unknown)
|
|
40
65
|
has_existing_pat: bool = False
|
|
@@ -46,6 +71,10 @@ class DeploymentForm:
|
|
|
46
71
|
removed_secrets: set[str] = field(default_factory=set)
|
|
47
72
|
# if the deployment is being edited
|
|
48
73
|
is_editing: bool = False
|
|
74
|
+
# warnings shown to the user
|
|
75
|
+
warnings: list[str] = field(default_factory=list)
|
|
76
|
+
# env info
|
|
77
|
+
env_info_messages: str | None = None
|
|
49
78
|
|
|
50
79
|
@classmethod
|
|
51
80
|
def from_deployment(cls, deployment: DeploymentResponse) -> "DeploymentForm":
|
|
@@ -56,7 +85,7 @@ class DeploymentForm:
|
|
|
56
85
|
id=deployment.id,
|
|
57
86
|
repo_url=deployment.repo_url,
|
|
58
87
|
git_ref=deployment.git_ref or "main",
|
|
59
|
-
deployment_file_path=deployment.deployment_file_path
|
|
88
|
+
deployment_file_path=deployment.deployment_file_path,
|
|
60
89
|
personal_access_token="", # Always start empty for security
|
|
61
90
|
has_existing_pat=deployment.has_personal_access_token,
|
|
62
91
|
secrets={},
|
|
@@ -74,7 +103,7 @@ class DeploymentForm:
|
|
|
74
103
|
data = DeploymentUpdate(
|
|
75
104
|
repo_url=self.repo_url,
|
|
76
105
|
git_ref=self.git_ref or "main",
|
|
77
|
-
deployment_file_path=self.deployment_file_path or
|
|
106
|
+
deployment_file_path=self.deployment_file_path or None,
|
|
78
107
|
personal_access_token=(
|
|
79
108
|
""
|
|
80
109
|
if self.personal_access_token is None and not self.has_existing_pat
|
|
@@ -91,7 +120,7 @@ class DeploymentForm:
|
|
|
91
120
|
return DeploymentCreate(
|
|
92
121
|
name=self.name,
|
|
93
122
|
repo_url=self.repo_url,
|
|
94
|
-
deployment_file_path=self.deployment_file_path or
|
|
123
|
+
deployment_file_path=self.deployment_file_path or None,
|
|
95
124
|
git_ref=self.git_ref or "main",
|
|
96
125
|
personal_access_token=self.personal_access_token,
|
|
97
126
|
secrets=self.secrets,
|
|
@@ -119,8 +148,11 @@ class DeploymentFormWidget(Widget):
|
|
|
119
148
|
|
|
120
149
|
def compose(self) -> ComposeResult:
|
|
121
150
|
title = "Edit Deployment" if self.form_data.is_editing else "Create Deployment"
|
|
151
|
+
|
|
122
152
|
yield Static(
|
|
123
|
-
|
|
153
|
+
Content.from_markup(
|
|
154
|
+
f"{title} [italic][@click=app.show_help()]More info[/][/italic]"
|
|
155
|
+
),
|
|
124
156
|
classes="primary-message",
|
|
125
157
|
)
|
|
126
158
|
yield Static(
|
|
@@ -128,6 +160,13 @@ class DeploymentFormWidget(Widget):
|
|
|
128
160
|
id="error-message",
|
|
129
161
|
classes="error-message " + ("visible" if self.error_message else "hidden"),
|
|
130
162
|
)
|
|
163
|
+
# Top-of-form warnings banner
|
|
164
|
+
yield Static(
|
|
165
|
+
"Note: " + " ".join(f"{w}" for w in self.form_data.warnings),
|
|
166
|
+
id="warning-list",
|
|
167
|
+
classes="warning-message mb-1 hidden "
|
|
168
|
+
+ ("visible" if self.form_data.warnings else ""),
|
|
169
|
+
)
|
|
131
170
|
|
|
132
171
|
# Main deployment fields
|
|
133
172
|
with Widget(classes="two-column-form-grid"):
|
|
@@ -161,10 +200,10 @@ class DeploymentFormWidget(Widget):
|
|
|
161
200
|
compact=True,
|
|
162
201
|
)
|
|
163
202
|
|
|
164
|
-
yield Label("
|
|
203
|
+
yield Label("Config File:", classes="form-label", shrink=True)
|
|
165
204
|
yield Input(
|
|
166
205
|
value=self.form_data.deployment_file_path,
|
|
167
|
-
placeholder="
|
|
206
|
+
placeholder="Optional path to config dir/file",
|
|
168
207
|
id="deployment_file_path",
|
|
169
208
|
compact=True,
|
|
170
209
|
)
|
|
@@ -191,6 +230,7 @@ class DeploymentFormWidget(Widget):
|
|
|
191
230
|
yield SecretsWidget(
|
|
192
231
|
initial_secrets=self.form_data.secrets,
|
|
193
232
|
prior_secrets=self.form_data.initial_secrets,
|
|
233
|
+
info_message=self.form_data.env_info_messages,
|
|
194
234
|
)
|
|
195
235
|
|
|
196
236
|
with HorizontalGroup(classes="button-row"):
|
|
@@ -201,7 +241,7 @@ class DeploymentFormWidget(Widget):
|
|
|
201
241
|
if event.button.id == "save":
|
|
202
242
|
self._save()
|
|
203
243
|
elif event.button.id == "change_pat":
|
|
204
|
-
updated_form = dataclasses.replace(self.
|
|
244
|
+
updated_form = dataclasses.replace(self.resolve_form_data())
|
|
205
245
|
updated_form.has_existing_pat = False
|
|
206
246
|
updated_form.personal_access_token = ""
|
|
207
247
|
self.form_data = updated_form
|
|
@@ -210,7 +250,7 @@ class DeploymentFormWidget(Widget):
|
|
|
210
250
|
self.post_message(CancelFormMessage())
|
|
211
251
|
|
|
212
252
|
def _save(self) -> None:
|
|
213
|
-
self.form_data = self.
|
|
253
|
+
self.form_data = self.resolve_form_data()
|
|
214
254
|
if self._validate_form():
|
|
215
255
|
# Post message to parent app to start validation
|
|
216
256
|
self.post_message(StartValidationMessage(self.form_data))
|
|
@@ -245,7 +285,7 @@ class DeploymentFormWidget(Widget):
|
|
|
245
285
|
"""Show an error message"""
|
|
246
286
|
self.error_message = message
|
|
247
287
|
|
|
248
|
-
def
|
|
288
|
+
def resolve_form_data(self) -> DeploymentForm:
|
|
249
289
|
"""Extract form data from inputs"""
|
|
250
290
|
name_input = self.query_one("#name", Input)
|
|
251
291
|
repo_url_input = self.query_one("#repo_url", Input)
|
|
@@ -269,8 +309,7 @@ class DeploymentFormWidget(Widget):
|
|
|
269
309
|
id=self.form_data.id,
|
|
270
310
|
repo_url=repo_url_input.value.strip(),
|
|
271
311
|
git_ref=git_ref_input.value.strip() or "main",
|
|
272
|
-
deployment_file_path=deployment_file_input.value.strip()
|
|
273
|
-
or "llama_deploy.yaml",
|
|
312
|
+
deployment_file_path=deployment_file_input.value.strip(),
|
|
274
313
|
personal_access_token=pat_value,
|
|
275
314
|
secrets=updated_secrets,
|
|
276
315
|
initial_secrets=self.original_form_data.initial_secrets,
|
|
@@ -299,15 +338,26 @@ class StartValidationMessage(Message):
|
|
|
299
338
|
self.form_data = form_data
|
|
300
339
|
|
|
301
340
|
|
|
341
|
+
class ShowHelpMessage(Message):
|
|
342
|
+
def __init__(self, form_data: DeploymentForm):
|
|
343
|
+
super().__init__()
|
|
344
|
+
self.form_data = form_data
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class HelpBackMessage(Message):
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
|
|
302
351
|
class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
303
352
|
"""Textual app for editing/creating deployments"""
|
|
304
353
|
|
|
305
354
|
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
306
355
|
|
|
307
|
-
# App states: 'form' or '
|
|
356
|
+
# App states: 'form', 'validation', 'help', or 'monitor'
|
|
308
357
|
current_state: reactive[str] = reactive("form", recompose=True)
|
|
309
358
|
form_data: reactive[DeploymentForm] = reactive(DeploymentForm())
|
|
310
359
|
save_error: reactive[str] = reactive("", recompose=True)
|
|
360
|
+
saved_deployment = reactive[DeploymentResponse | None](None, recompose=True)
|
|
311
361
|
|
|
312
362
|
def __init__(self, initial_data: DeploymentForm):
|
|
313
363
|
super().__init__()
|
|
@@ -317,13 +367,17 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
317
367
|
def on_mount(self) -> None:
|
|
318
368
|
self.theme = "tokyo-night"
|
|
319
369
|
|
|
320
|
-
def on_key(self, event) -> None:
|
|
370
|
+
def on_key(self, event: events.Key) -> None:
|
|
321
371
|
"""Handle key events, including Ctrl+C"""
|
|
322
372
|
if event.key == "ctrl+c":
|
|
323
|
-
self.
|
|
373
|
+
if self.current_state == "monitor" and self.saved_deployment is not None:
|
|
374
|
+
self.exit(self.saved_deployment)
|
|
375
|
+
else:
|
|
376
|
+
self.exit(None)
|
|
324
377
|
|
|
325
378
|
def compose(self) -> ComposeResult:
|
|
326
|
-
|
|
379
|
+
is_slim = self.current_state != "monitor"
|
|
380
|
+
with Container(classes="form-container" if is_slim else ""):
|
|
327
381
|
if self.current_state == "form":
|
|
328
382
|
yield DeploymentFormWidget(self.form_data, self.save_error)
|
|
329
383
|
elif self.current_state == "validation":
|
|
@@ -336,6 +390,28 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
336
390
|
if self.form_data.personal_access_token
|
|
337
391
|
else None,
|
|
338
392
|
)
|
|
393
|
+
elif self.current_state == "help":
|
|
394
|
+
yield DeploymentHelpWidget()
|
|
395
|
+
elif self.current_state == "monitor":
|
|
396
|
+
deployment_id = (
|
|
397
|
+
self.saved_deployment.id if self.saved_deployment else ""
|
|
398
|
+
)
|
|
399
|
+
yield DeploymentMonitorWidget(deployment_id)
|
|
400
|
+
else:
|
|
401
|
+
yield Static("Unknown state: " + self.current_state)
|
|
402
|
+
|
|
403
|
+
def action_show_help(self) -> None:
|
|
404
|
+
widget = self.query("DeploymentFormWidget")
|
|
405
|
+
if widget:
|
|
406
|
+
typed_widget: DeploymentFormWidget = widget[0]
|
|
407
|
+
self.form_data = typed_widget.resolve_form_data()
|
|
408
|
+
|
|
409
|
+
self.current_state = "help"
|
|
410
|
+
|
|
411
|
+
def on_deployment_help_back_message(
|
|
412
|
+
self, message: DeploymentHelpBackMessage
|
|
413
|
+
) -> None:
|
|
414
|
+
self.current_state = "form"
|
|
339
415
|
|
|
340
416
|
def on_start_validation_message(self, message: StartValidationMessage) -> None:
|
|
341
417
|
"""Handle validation start message from form widget"""
|
|
@@ -343,7 +419,9 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
343
419
|
self.save_error = "" # Clear any previous errors
|
|
344
420
|
self.current_state = "validation"
|
|
345
421
|
|
|
346
|
-
def on_validation_result_message(
|
|
422
|
+
async def on_validation_result_message(
|
|
423
|
+
self, message: ValidationResultMessage
|
|
424
|
+
) -> None:
|
|
347
425
|
"""Handle validation success from git validation widget"""
|
|
348
426
|
logging.info("validation result message", message)
|
|
349
427
|
# Update form data with validated PAT if provided
|
|
@@ -355,30 +433,44 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
355
433
|
updated_form.has_existing_pat = False
|
|
356
434
|
self.form_data = updated_form
|
|
357
435
|
|
|
358
|
-
# Proceed with save
|
|
359
|
-
self._perform_save()
|
|
436
|
+
# Proceed with save (async)
|
|
437
|
+
await self._perform_save()
|
|
360
438
|
|
|
361
439
|
def on_validation_cancel_message(self, message: ValidationCancelMessage) -> None:
|
|
362
440
|
"""Handle validation cancellation from git validation widget"""
|
|
363
441
|
# Return to form, clearing any save error
|
|
364
|
-
print("DEBUG: on_validation_cancel_message")
|
|
365
442
|
self.save_error = ""
|
|
366
443
|
self.current_state = "form"
|
|
367
444
|
|
|
368
|
-
def
|
|
445
|
+
def on_show_help_message(self, message: ShowHelpMessage) -> None:
|
|
446
|
+
"""Navigate to help view, preserving current form state."""
|
|
447
|
+
self.form_data = message.form_data
|
|
448
|
+
self.current_state = "help"
|
|
449
|
+
|
|
450
|
+
def on_help_back_message(self, message: HelpBackMessage) -> None:
|
|
451
|
+
"""Return from help to form, keeping form state intact."""
|
|
452
|
+
self.current_state = "form"
|
|
453
|
+
|
|
454
|
+
async def _perform_save(self) -> None:
|
|
369
455
|
"""Actually save the deployment after validation"""
|
|
370
456
|
logging.info("saving form data", self.form_data)
|
|
371
457
|
result = self.form_data
|
|
372
458
|
client = get_client()
|
|
373
459
|
try:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
460
|
+
update_deployment = (
|
|
461
|
+
await client.update_deployment(result.id, result.to_update())
|
|
462
|
+
if result.is_editing
|
|
463
|
+
else await client.create_deployment(result.to_create())
|
|
464
|
+
)
|
|
465
|
+
# Save and navigate to embedded monitor screen
|
|
466
|
+
self.saved_deployment = update_deployment
|
|
467
|
+
# Ensure form_data carries the new ID for any subsequent operations
|
|
468
|
+
if not result.is_editing and update_deployment.id:
|
|
469
|
+
updated_form = dataclasses.replace(self.form_data)
|
|
470
|
+
updated_form.id = update_deployment.id
|
|
471
|
+
updated_form.is_editing = True
|
|
472
|
+
self.form_data = updated_form
|
|
473
|
+
self.current_state = "monitor"
|
|
382
474
|
except Exception as e:
|
|
383
475
|
# Return to form and show error
|
|
384
476
|
self.save_error = f"Error saving deployment: {e}"
|
|
@@ -392,6 +484,10 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
|
|
|
392
484
|
"""Handle cancel message from form widget"""
|
|
393
485
|
self.exit(None)
|
|
394
486
|
|
|
487
|
+
def on_monitor_close_message(self, _: MonitorCloseMessage) -> None:
|
|
488
|
+
"""Handle close from embedded monitor by exiting with saved deployment."""
|
|
489
|
+
self.exit(self.saved_deployment)
|
|
490
|
+
|
|
395
491
|
|
|
396
492
|
def edit_deployment_form(
|
|
397
493
|
deployment: DeploymentResponse,
|
|
@@ -404,6 +500,137 @@ def edit_deployment_form(
|
|
|
404
500
|
|
|
405
501
|
def create_deployment_form() -> DeploymentResponse | None:
|
|
406
502
|
"""Launch deployment creation form and return result"""
|
|
407
|
-
initial_data =
|
|
503
|
+
initial_data = _initialize_deployment_data()
|
|
408
504
|
app = DeploymentEditApp(initial_data)
|
|
409
505
|
return app.run()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _initialize_deployment_data() -> DeploymentForm:
|
|
509
|
+
"""
|
|
510
|
+
initialize the deployment form data from the current git repo and .env file
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
repo_url: str | None = None
|
|
514
|
+
git_ref: str | None = None
|
|
515
|
+
secrets: dict[str, str] = {}
|
|
516
|
+
name: str | None = None
|
|
517
|
+
config_file_path: str | None = None
|
|
518
|
+
warnings: list[str] = []
|
|
519
|
+
has_git = is_git_repo()
|
|
520
|
+
has_no_workflows = False
|
|
521
|
+
try:
|
|
522
|
+
config = read_deployment_config(Path("."), Path("."))
|
|
523
|
+
if config.name != DEFAULT_DEPLOYMENT_NAME:
|
|
524
|
+
name = config.name
|
|
525
|
+
has_no_workflows = config.has_no_workflows()
|
|
526
|
+
except Exception:
|
|
527
|
+
warnings.append("Could not parse local deployment config. It may be invalid.")
|
|
528
|
+
if not has_git and has_no_workflows:
|
|
529
|
+
warnings = [
|
|
530
|
+
"Run from within a git repository to automatically generate a deployment config."
|
|
531
|
+
]
|
|
532
|
+
elif has_no_workflows:
|
|
533
|
+
warnings = [
|
|
534
|
+
"The current project has no workflows configured. It may be invalid."
|
|
535
|
+
]
|
|
536
|
+
elif not has_git:
|
|
537
|
+
warnings.append(
|
|
538
|
+
"Current directory is not a git repository. If you are trying to deploy this directory, you will need to create a git repository and push it before creating a deployment."
|
|
539
|
+
)
|
|
540
|
+
else:
|
|
541
|
+
seen = set[str]()
|
|
542
|
+
remotes = list_remotes()
|
|
543
|
+
candidate_origins = []
|
|
544
|
+
for remote in remotes:
|
|
545
|
+
normalized_url = _normalize_to_http(remote)
|
|
546
|
+
if normalized_url not in seen:
|
|
547
|
+
candidate_origins.append(normalized_url)
|
|
548
|
+
seen.add(normalized_url)
|
|
549
|
+
preferred_origin = sorted(
|
|
550
|
+
candidate_origins, key=lambda x: "github.com" in x, reverse=True
|
|
551
|
+
)
|
|
552
|
+
if preferred_origin:
|
|
553
|
+
repo_url = preferred_origin[0]
|
|
554
|
+
git_ref = get_current_branch()
|
|
555
|
+
root = get_git_root()
|
|
556
|
+
if root != Path.cwd():
|
|
557
|
+
config_file_path = str(Path.cwd().relative_to(root))
|
|
558
|
+
|
|
559
|
+
if not preferred_origin:
|
|
560
|
+
warnings.append(
|
|
561
|
+
"No git remote was found. You will need to push your changes to a remote repository before creating a deployment from this repository."
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
# Working tree changes
|
|
565
|
+
if working_tree_has_changes() and preferred_origin:
|
|
566
|
+
warnings.append(
|
|
567
|
+
"Working tree has uncommitted or untracked changes. You may want to push them before creating a deployment from this branch."
|
|
568
|
+
)
|
|
569
|
+
else:
|
|
570
|
+
# Unpushed commits (ahead of upstream)
|
|
571
|
+
ahead = get_unpushed_commits_count()
|
|
572
|
+
if ahead is None:
|
|
573
|
+
warnings.append(
|
|
574
|
+
"Current branch has no upstream configured. You will need to push them or choose a different branch."
|
|
575
|
+
)
|
|
576
|
+
elif ahead > 0:
|
|
577
|
+
warnings.append(
|
|
578
|
+
f"There are {ahead} local commits not pushed to upstream. They won't be included in the deployment unless you push them first."
|
|
579
|
+
)
|
|
580
|
+
env_info_message = None
|
|
581
|
+
if Path(".env").exists():
|
|
582
|
+
secrets = load_env_secrets_from_string(Path(".env").read_text())
|
|
583
|
+
if len(secrets) > 0:
|
|
584
|
+
env_info_message = "Secrets were automatically seeded from your .env file. Remove or change any that should not be set. They must be manually configured after creation."
|
|
585
|
+
|
|
586
|
+
form = DeploymentForm(
|
|
587
|
+
name=name or "",
|
|
588
|
+
repo_url=repo_url or "",
|
|
589
|
+
git_ref=git_ref or "main",
|
|
590
|
+
secrets=secrets,
|
|
591
|
+
deployment_file_path=config_file_path or "",
|
|
592
|
+
warnings=warnings,
|
|
593
|
+
env_info_messages=env_info_message,
|
|
594
|
+
)
|
|
595
|
+
return form
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _normalize_to_http(url: str) -> str:
|
|
599
|
+
"""
|
|
600
|
+
normalize a git url to a best guess for a corresponding http(s) url
|
|
601
|
+
"""
|
|
602
|
+
candidate = (url or "").strip()
|
|
603
|
+
|
|
604
|
+
# If no scheme, first try scp-like SSH syntax: [user@]host:path
|
|
605
|
+
has_scheme = "://" in candidate
|
|
606
|
+
if not has_scheme:
|
|
607
|
+
scp_match = re.match(
|
|
608
|
+
r"^(?:(?P<user>[^@]+)@)?(?P<host>[^:/\s]+):(?P<path>[^/].+)$",
|
|
609
|
+
candidate,
|
|
610
|
+
)
|
|
611
|
+
if scp_match:
|
|
612
|
+
host = scp_match.group("host")
|
|
613
|
+
path = scp_match.group("path").lstrip("/")
|
|
614
|
+
if path.endswith(".git"):
|
|
615
|
+
path = path[:-4]
|
|
616
|
+
return f"https://{host}/{path}"
|
|
617
|
+
|
|
618
|
+
# If no scheme (and not scp), assume host/path and prepend https
|
|
619
|
+
parsed = urlsplit(candidate if has_scheme else f"https://{candidate}")
|
|
620
|
+
|
|
621
|
+
# Drop credentials from netloc
|
|
622
|
+
netloc = parsed.netloc.split("@", 1)[-1]
|
|
623
|
+
|
|
624
|
+
# Drop explicit port (common for SSH like :7999 which is wrong for https)
|
|
625
|
+
if ":" in netloc:
|
|
626
|
+
netloc = netloc.split(":", 1)[0]
|
|
627
|
+
|
|
628
|
+
# Normalize path and strip .git
|
|
629
|
+
path = parsed.path.lstrip("/")
|
|
630
|
+
if path.endswith(".git"):
|
|
631
|
+
path = path[:-4]
|
|
632
|
+
|
|
633
|
+
if path:
|
|
634
|
+
return f"https://{netloc}/{path}"
|
|
635
|
+
else:
|
|
636
|
+
return f"https://{netloc}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import HorizontalGroup, Widget
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widgets import Button, Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeploymentHelpBackMessage(Message):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeploymentHelpWidget(Widget):
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
DeploymentHelpWidget {
|
|
17
|
+
layout: vertical;
|
|
18
|
+
height: auto;
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def compose(self) -> ComposeResult:
|
|
23
|
+
yield Static(
|
|
24
|
+
"Deploy your app to llama cloud or your own infrastructure:",
|
|
25
|
+
classes="primary-message",
|
|
26
|
+
)
|
|
27
|
+
yield Static(
|
|
28
|
+
Content.from_markup(
|
|
29
|
+
dedent("""
|
|
30
|
+
[b]Deployment Name[/b]
|
|
31
|
+
A unique name to identify this deployment. Controls the URL where your deployment is accessible. Will have a random suffix appended if not unique.
|
|
32
|
+
|
|
33
|
+
[b]Git Repository[/b]
|
|
34
|
+
A git repository URL to pull code from. If not publically accessible, you will be prompted to install the llama deploy github app. If code is on another platform, either provide a Personal Access Token (basic access credentials) instead.
|
|
35
|
+
|
|
36
|
+
[b]Git Ref[/b]
|
|
37
|
+
The git ref to deploy. This can be a branch, tag, or commit hash. If this is a branch, after deploying, run a `[slategrey reverse]llamactl deploy update[/]` to update the deployment to the latest git ref after you make updates.
|
|
38
|
+
|
|
39
|
+
[b]Config File[/b]
|
|
40
|
+
Path to a directory or file containing a `[slategrey reverse]pyproject.toml[/]` or `[slategrey reverse]llama_deploy.yaml[/]` containing the llama deploy configuration. Only necessary if you have the configuration not at the root of the repo, or you have an unconventional configuration file.
|
|
41
|
+
|
|
42
|
+
[b]Secrets[/b]
|
|
43
|
+
Secrets to add as environment variables to the deployment. e.g. to access a database or an API. Supports adding in `[slategrey reverse].env[/]` file format.
|
|
44
|
+
|
|
45
|
+
""").strip()
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
with HorizontalGroup(classes="button-row"):
|
|
49
|
+
yield Button("Back", variant="primary", id="help_back", compact=True)
|
|
50
|
+
|
|
51
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
52
|
+
if event.button.id == "help_back":
|
|
53
|
+
self.post_message(DeploymentHelpBackMessage())
|