synth-ai 0.2.8.dev13__py3-none-any.whl → 0.2.9.dev1__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.
Potentially problematic release.
This version of synth-ai might be problematic. Click here for more details.
- synth_ai/api/train/cli.py +21 -0
- synth_ai/api/train/config_finder.py +54 -6
- synth_ai/api/train/task_app.py +70 -5
- synth_ai/cli/rl_demo.py +16 -4
- synth_ai/cli/root.py +36 -5
- synth_ai/cli/task_apps.py +792 -205
- synth_ai/demo_registry.py +258 -0
- synth_ai/demos/core/cli.py +147 -111
- synth_ai/demos/demo_task_apps/__init__.py +7 -1
- synth_ai/demos/demo_task_apps/math/config.toml +55 -110
- synth_ai/demos/demo_task_apps/math/modal_task_app.py +157 -21
- synth_ai/demos/demo_task_apps/math/task_app_entry.py +39 -0
- synth_ai/task/auth.py +33 -12
- synth_ai/task/client.py +20 -3
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/METADATA +1 -1
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/RECORD +20 -18
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.8.dev13.dist-info → synth_ai-0.2.9.dev1.dist-info}/top_level.txt +0 -0
synth_ai/cli/task_apps.py
CHANGED
|
@@ -1,20 +1,770 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import ast
|
|
4
|
+
import contextlib
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
import inspect
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
3
12
|
import shutil
|
|
4
13
|
import subprocess
|
|
14
|
+
import sys
|
|
5
15
|
import tempfile
|
|
6
|
-
import
|
|
7
|
-
import signal
|
|
16
|
+
from dataclasses import dataclass
|
|
8
17
|
from pathlib import Path
|
|
9
|
-
from typing import Sequence
|
|
18
|
+
from typing import Callable, Iterable, Sequence
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
|
|
22
|
+
from synth_ai.task.server import run_task_app
|
|
10
23
|
|
|
11
24
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
DEFAULT_IGNORE_DIRS = {
|
|
27
|
+
".git",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
"node_modules",
|
|
30
|
+
"venv",
|
|
31
|
+
".venv",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
".mypy_cache",
|
|
35
|
+
".pytest_cache",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
DEFAULT_SEARCH_RELATIVE = (
|
|
39
|
+
Path("."),
|
|
40
|
+
Path("examples"),
|
|
41
|
+
Path("synth_ai"),
|
|
42
|
+
)
|
|
14
43
|
|
|
15
|
-
from synth_ai.task.apps import ModalDeploymentConfig, TaskAppEntry, registry
|
|
16
|
-
from synth_ai.task.server import run_task_app
|
|
17
44
|
|
|
45
|
+
@dataclass
|
|
46
|
+
class AppChoice:
|
|
47
|
+
app_id: str
|
|
48
|
+
label: str
|
|
49
|
+
path: Path
|
|
50
|
+
source: str
|
|
51
|
+
description: str | None = None
|
|
52
|
+
aliases: tuple[str, ...] = ()
|
|
53
|
+
entry: TaskAppEntry | None = None
|
|
54
|
+
entry_loader: Callable[[], TaskAppEntry] | None = None
|
|
55
|
+
modal_script: Path | None = None
|
|
56
|
+
lineno: int | None = None
|
|
57
|
+
|
|
58
|
+
def ensure_entry(self) -> TaskAppEntry:
|
|
59
|
+
if self.entry is not None:
|
|
60
|
+
return self.entry
|
|
61
|
+
if self.entry_loader is None:
|
|
62
|
+
raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
|
|
63
|
+
entry = self.entry_loader()
|
|
64
|
+
self.entry = entry
|
|
65
|
+
return entry
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _should_ignore_path(path: Path) -> bool:
|
|
69
|
+
return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _candidate_search_roots() -> list[Path]:
|
|
73
|
+
roots: list[Path] = []
|
|
74
|
+
env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
|
|
75
|
+
if env_paths:
|
|
76
|
+
for chunk in env_paths.split(os.pathsep):
|
|
77
|
+
if chunk:
|
|
78
|
+
roots.append(Path(chunk).expanduser())
|
|
79
|
+
|
|
80
|
+
cwd = Path.cwd().resolve()
|
|
81
|
+
roots.append(cwd)
|
|
82
|
+
|
|
83
|
+
for rel in DEFAULT_SEARCH_RELATIVE:
|
|
84
|
+
try:
|
|
85
|
+
candidate = (cwd / rel).resolve()
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
roots.append(candidate)
|
|
89
|
+
if REPO_ROOT not in (None, candidate):
|
|
90
|
+
try:
|
|
91
|
+
repo_candidate = (REPO_ROOT / rel).resolve()
|
|
92
|
+
except Exception:
|
|
93
|
+
repo_candidate = None
|
|
94
|
+
if repo_candidate:
|
|
95
|
+
roots.append(repo_candidate)
|
|
96
|
+
|
|
97
|
+
roots.append(REPO_ROOT)
|
|
98
|
+
|
|
99
|
+
seen: set[Path] = set()
|
|
100
|
+
ordered: list[Path] = []
|
|
101
|
+
for root in roots:
|
|
102
|
+
try:
|
|
103
|
+
resolved = root.resolve()
|
|
104
|
+
except Exception:
|
|
105
|
+
continue
|
|
106
|
+
if resolved in seen or not resolved.exists():
|
|
107
|
+
continue
|
|
108
|
+
seen.add(resolved)
|
|
109
|
+
ordered.append(resolved)
|
|
110
|
+
return ordered
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _TaskAppConfigVisitor(ast.NodeVisitor):
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self.matches: list[tuple[str, int]] = []
|
|
116
|
+
|
|
117
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
118
|
+
if _is_task_app_config_call(node):
|
|
119
|
+
app_id = _extract_app_id(node)
|
|
120
|
+
if app_id:
|
|
121
|
+
self.matches.append((app_id, getattr(node, "lineno", 0)))
|
|
122
|
+
self.generic_visit(node)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_task_app_config_call(node: ast.Call) -> bool:
|
|
126
|
+
func = node.func
|
|
127
|
+
if isinstance(func, ast.Name) and func.id == "TaskAppConfig":
|
|
128
|
+
return True
|
|
129
|
+
if isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig":
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _extract_app_id(node: ast.Call) -> str | None:
|
|
135
|
+
for kw in node.keywords:
|
|
136
|
+
if kw.arg == "app_id" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
137
|
+
return kw.value.value
|
|
138
|
+
if node.args:
|
|
139
|
+
first = node.args[0]
|
|
140
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
141
|
+
return first.value
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _ModalAppVisitor(ast.NodeVisitor):
|
|
146
|
+
def __init__(self) -> None:
|
|
147
|
+
self.app_aliases: set[str] = set()
|
|
148
|
+
self.modal_aliases: set[str] = set()
|
|
149
|
+
self.matches: list[tuple[str, int]] = []
|
|
150
|
+
|
|
151
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
|
|
152
|
+
if node.module == "modal":
|
|
153
|
+
for alias in node.names:
|
|
154
|
+
if alias.name == "App":
|
|
155
|
+
self.app_aliases.add(alias.asname or alias.name)
|
|
156
|
+
self.generic_visit(node)
|
|
157
|
+
|
|
158
|
+
def visit_Import(self, node: ast.Import) -> None: # noqa: D401
|
|
159
|
+
for alias in node.names:
|
|
160
|
+
if alias.name == "modal":
|
|
161
|
+
self.modal_aliases.add(alias.asname or alias.name)
|
|
162
|
+
self.generic_visit(node)
|
|
163
|
+
|
|
164
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
165
|
+
func = node.func
|
|
166
|
+
if isinstance(func, ast.Name) and func.id in self.app_aliases:
|
|
167
|
+
name = _extract_modal_app_name(node)
|
|
168
|
+
if name:
|
|
169
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
170
|
+
elif isinstance(func, ast.Attribute):
|
|
171
|
+
if isinstance(func.value, ast.Name) and func.value.id in self.modal_aliases and func.attr == "App":
|
|
172
|
+
name = _extract_modal_app_name(node)
|
|
173
|
+
if name:
|
|
174
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
175
|
+
self.generic_visit(node)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_modal_app_name(node: ast.Call) -> str | None:
|
|
179
|
+
for kw in node.keywords:
|
|
180
|
+
if kw.arg in {"name", "app_name"} and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
181
|
+
return kw.value.value
|
|
182
|
+
if node.args:
|
|
183
|
+
first = node.args[0]
|
|
184
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
185
|
+
return first.value
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@functools.lru_cache(maxsize=1)
|
|
190
|
+
def _collect_task_app_choices() -> list[AppChoice]:
|
|
191
|
+
choices: list[AppChoice] = []
|
|
192
|
+
with contextlib.suppress(Exception):
|
|
193
|
+
import synth_ai.demos.demo_task_apps # noqa: F401
|
|
194
|
+
choices.extend(_collect_registered_choices())
|
|
195
|
+
choices.extend(_collect_scanned_task_configs())
|
|
196
|
+
choices.extend(_collect_modal_scripts())
|
|
197
|
+
|
|
198
|
+
unique: dict[tuple[str, Path], AppChoice] = {}
|
|
199
|
+
ordered: list[AppChoice] = []
|
|
200
|
+
for choice in choices:
|
|
201
|
+
key = (choice.app_id, choice.path.resolve())
|
|
202
|
+
if key in unique:
|
|
203
|
+
existing = unique[key]
|
|
204
|
+
if existing.source == "registered" and choice.source != "registered":
|
|
205
|
+
continue
|
|
206
|
+
if choice.source == "registered" and existing.source != "registered":
|
|
207
|
+
unique[key] = choice
|
|
208
|
+
idx = ordered.index(existing)
|
|
209
|
+
ordered[idx] = choice
|
|
210
|
+
continue
|
|
211
|
+
unique[key] = choice
|
|
212
|
+
ordered.append(choice)
|
|
213
|
+
return ordered
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collect_registered_choices() -> list[AppChoice]:
|
|
217
|
+
result: list[AppChoice] = []
|
|
218
|
+
for entry in registry.list():
|
|
219
|
+
module_name = entry.config_factory.__module__
|
|
220
|
+
module = sys.modules.get(module_name)
|
|
221
|
+
if module is None:
|
|
222
|
+
module = importlib.import_module(module_name)
|
|
223
|
+
module_file = getattr(module, "__file__", None)
|
|
224
|
+
path = Path(module_file).resolve() if module_file else REPO_ROOT
|
|
225
|
+
result.append(
|
|
226
|
+
AppChoice(
|
|
227
|
+
app_id=entry.app_id,
|
|
228
|
+
label=entry.app_id,
|
|
229
|
+
path=path,
|
|
230
|
+
source="registered",
|
|
231
|
+
description=entry.description,
|
|
232
|
+
aliases=tuple(entry.aliases),
|
|
233
|
+
entry=entry,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _collect_scanned_task_configs() -> list[AppChoice]:
|
|
240
|
+
results: list[AppChoice] = []
|
|
241
|
+
seen: set[tuple[str, Path]] = set()
|
|
242
|
+
for root in _candidate_search_roots():
|
|
243
|
+
if not root.exists() or not root.is_dir():
|
|
244
|
+
continue
|
|
245
|
+
for path in root.rglob("*.py"):
|
|
246
|
+
if not path.is_file():
|
|
247
|
+
continue
|
|
248
|
+
if _should_ignore_path(path):
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
source = path.read_text(encoding="utf-8")
|
|
252
|
+
except Exception:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
tree = ast.parse(source, filename=str(path))
|
|
256
|
+
except SyntaxError:
|
|
257
|
+
continue
|
|
258
|
+
visitor = _TaskAppConfigVisitor()
|
|
259
|
+
visitor.visit(tree)
|
|
260
|
+
for app_id, lineno in visitor.matches:
|
|
261
|
+
key = (app_id, path.resolve())
|
|
262
|
+
if key in seen:
|
|
263
|
+
continue
|
|
264
|
+
seen.add(key)
|
|
265
|
+
results.append(
|
|
266
|
+
AppChoice(
|
|
267
|
+
app_id=app_id,
|
|
268
|
+
label=app_id,
|
|
269
|
+
path=path.resolve(),
|
|
270
|
+
source="discovered",
|
|
271
|
+
description=f"TaskAppConfig in {path.name} (line {lineno})",
|
|
272
|
+
entry_loader=lambda p=path.resolve(), a=app_id: _load_entry_from_path(p, a),
|
|
273
|
+
lineno=lineno,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _collect_modal_scripts() -> list[AppChoice]:
|
|
280
|
+
results: list[AppChoice] = []
|
|
281
|
+
seen: set[tuple[str, Path]] = set()
|
|
282
|
+
for root in _candidate_search_roots():
|
|
283
|
+
if not root.exists() or not root.is_dir():
|
|
284
|
+
continue
|
|
285
|
+
for path in root.rglob("*.py"):
|
|
286
|
+
if not path.is_file():
|
|
287
|
+
continue
|
|
288
|
+
if _should_ignore_path(path):
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
source = path.read_text(encoding="utf-8")
|
|
292
|
+
except Exception:
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
tree = ast.parse(source, filename=str(path))
|
|
296
|
+
except SyntaxError:
|
|
297
|
+
continue
|
|
298
|
+
visitor = _ModalAppVisitor()
|
|
299
|
+
visitor.visit(tree)
|
|
300
|
+
for app_name, lineno in visitor.matches:
|
|
301
|
+
key = (app_name, path.resolve())
|
|
302
|
+
if key in seen:
|
|
303
|
+
continue
|
|
304
|
+
seen.add(key)
|
|
305
|
+
results.append(
|
|
306
|
+
AppChoice(
|
|
307
|
+
app_id=app_name,
|
|
308
|
+
label=app_name,
|
|
309
|
+
path=path.resolve(),
|
|
310
|
+
source="modal-script",
|
|
311
|
+
description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
|
|
312
|
+
modal_script=path.resolve(),
|
|
313
|
+
lineno=lineno,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return results
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
|
|
320
|
+
ident = identifier.strip()
|
|
321
|
+
if not ident:
|
|
322
|
+
return False
|
|
323
|
+
if ident == choice.app_id or ident == choice.label:
|
|
324
|
+
return True
|
|
325
|
+
if ident in choice.aliases:
|
|
326
|
+
return True
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _choice_has_modal_support(choice: AppChoice) -> bool:
|
|
331
|
+
if choice.modal_script:
|
|
332
|
+
return True
|
|
333
|
+
try:
|
|
334
|
+
entry = choice.ensure_entry()
|
|
335
|
+
except click.ClickException:
|
|
336
|
+
return False
|
|
337
|
+
return entry.modal is not None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _choice_has_local_support(choice: AppChoice) -> bool:
|
|
341
|
+
if choice.modal_script:
|
|
342
|
+
return False
|
|
343
|
+
try:
|
|
344
|
+
choice.ensure_entry()
|
|
345
|
+
except click.ClickException:
|
|
346
|
+
return False
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _format_choice(choice: AppChoice, index: int | None = None) -> str:
|
|
351
|
+
prefix = f"[{index}] " if index is not None else ""
|
|
352
|
+
rel_path: str
|
|
353
|
+
try:
|
|
354
|
+
rel_path = str(choice.path.relative_to(REPO_ROOT))
|
|
355
|
+
except Exception:
|
|
356
|
+
rel_path = str(choice.path)
|
|
357
|
+
details = choice.description or f"Located at {rel_path}"
|
|
358
|
+
return f"{prefix}{choice.app_id} ({choice.source}) – {details}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
|
|
362
|
+
click.echo("Select a task app:")
|
|
363
|
+
for idx, choice in enumerate(choices, start=1):
|
|
364
|
+
click.echo(_format_choice(choice, idx))
|
|
365
|
+
response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
|
|
366
|
+
if not response.isdigit():
|
|
367
|
+
raise click.ClickException("Selection must be a number")
|
|
368
|
+
index = int(response)
|
|
369
|
+
if not 1 <= index <= len(choices):
|
|
370
|
+
raise click.ClickException("Selection out of range")
|
|
371
|
+
return choices[index - 1]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
|
|
375
|
+
choices = _collect_task_app_choices()
|
|
376
|
+
if purpose == "serve":
|
|
377
|
+
filtered = [c for c in choices if not c.modal_script]
|
|
378
|
+
elif purpose in {"deploy", "modal-serve"}:
|
|
379
|
+
filtered = []
|
|
380
|
+
for choice in choices:
|
|
381
|
+
if choice.modal_script or _choice_has_modal_support(choice):
|
|
382
|
+
filtered.append(choice)
|
|
383
|
+
else:
|
|
384
|
+
filtered = choices
|
|
385
|
+
|
|
386
|
+
if not filtered:
|
|
387
|
+
raise click.ClickException("No task apps discovered for this command.")
|
|
388
|
+
|
|
389
|
+
if app_id:
|
|
390
|
+
matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
|
|
391
|
+
if not matches:
|
|
392
|
+
available = ", ".join(sorted({c.app_id for c in filtered}))
|
|
393
|
+
raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
|
|
394
|
+
if len(matches) == 1:
|
|
395
|
+
return matches[0]
|
|
396
|
+
# Prefer entries with modal support when required
|
|
397
|
+
if purpose in {"deploy", "modal-serve"}:
|
|
398
|
+
modal_matches = [c for c in matches if _choice_has_modal_support(c)]
|
|
399
|
+
if len(modal_matches) == 1:
|
|
400
|
+
return modal_matches[0]
|
|
401
|
+
if modal_matches:
|
|
402
|
+
matches = modal_matches
|
|
403
|
+
return _prompt_user_for_choice(matches)
|
|
404
|
+
|
|
405
|
+
if len(filtered) == 1:
|
|
406
|
+
choice = filtered[0]
|
|
407
|
+
click.echo(_format_choice(choice))
|
|
408
|
+
return choice
|
|
409
|
+
|
|
410
|
+
return _prompt_user_for_choice(filtered)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
|
|
414
|
+
resolved = path.resolve()
|
|
415
|
+
module_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
|
|
416
|
+
spec = importlib.util.spec_from_file_location(module_name, str(resolved))
|
|
417
|
+
if spec is None or spec.loader is None:
|
|
418
|
+
raise click.ClickException(f"Unable to load Python module from {resolved}")
|
|
419
|
+
module = importlib.util.module_from_spec(spec)
|
|
420
|
+
sys.modules[module_name] = module
|
|
421
|
+
try:
|
|
422
|
+
spec.loader.exec_module(module)
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
raise click.ClickException(f"Failed to import {resolved}: {exc}") from exc
|
|
425
|
+
|
|
426
|
+
config_obj: TaskAppConfig | None = None
|
|
427
|
+
factory_callable: Callable[[], TaskAppConfig] | None = None
|
|
428
|
+
|
|
429
|
+
for attr_name in dir(module):
|
|
430
|
+
try:
|
|
431
|
+
attr = getattr(module, attr_name)
|
|
432
|
+
except Exception:
|
|
433
|
+
continue
|
|
434
|
+
if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
|
|
435
|
+
config_obj = attr
|
|
436
|
+
factory_callable = lambda cfg=attr: cfg
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
if factory_callable is None:
|
|
440
|
+
for attr_name in dir(module):
|
|
441
|
+
if attr_name.startswith("_"):
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
attr = getattr(module, attr_name)
|
|
445
|
+
except Exception:
|
|
446
|
+
continue
|
|
447
|
+
if not callable(attr):
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
sig = inspect.signature(attr)
|
|
451
|
+
except (TypeError, ValueError):
|
|
452
|
+
continue
|
|
453
|
+
has_required = False
|
|
454
|
+
for param in sig.parameters.values():
|
|
455
|
+
if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and param.default is inspect._empty:
|
|
456
|
+
has_required = True
|
|
457
|
+
break
|
|
458
|
+
if has_required:
|
|
459
|
+
continue
|
|
460
|
+
try:
|
|
461
|
+
result = attr()
|
|
462
|
+
except Exception:
|
|
463
|
+
continue
|
|
464
|
+
if isinstance(result, TaskAppConfig) and result.app_id == app_id:
|
|
465
|
+
def _factory() -> TaskAppConfig:
|
|
466
|
+
return attr() # type: ignore[call-arg]
|
|
467
|
+
factory_callable = _factory
|
|
468
|
+
config_obj = result
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
if factory_callable is None or config_obj is None:
|
|
472
|
+
raise click.ClickException(
|
|
473
|
+
f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
modal_cfg: ModalDeploymentConfig | None = None
|
|
477
|
+
for attr_name in dir(module):
|
|
478
|
+
try:
|
|
479
|
+
attr = getattr(module, attr_name)
|
|
480
|
+
except Exception:
|
|
481
|
+
continue
|
|
482
|
+
if isinstance(attr, ModalDeploymentConfig):
|
|
483
|
+
modal_cfg = attr
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
|
|
487
|
+
env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
|
|
488
|
+
|
|
489
|
+
entry = TaskAppEntry(
|
|
490
|
+
app_id=app_id,
|
|
491
|
+
description=description,
|
|
492
|
+
config_factory=factory_callable,
|
|
493
|
+
aliases=(),
|
|
494
|
+
env_files=tuple(str(Path(p)) for p in env_files if p),
|
|
495
|
+
modal=modal_cfg,
|
|
496
|
+
)
|
|
497
|
+
return entry
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) -> list[Path]:
|
|
501
|
+
if explicit:
|
|
502
|
+
resolved: list[Path] = []
|
|
503
|
+
for candidate in explicit:
|
|
504
|
+
p = Path(candidate).expanduser()
|
|
505
|
+
if not p.exists():
|
|
506
|
+
raise click.ClickException(f"Env file not found: {p}")
|
|
507
|
+
resolved.append(p)
|
|
508
|
+
return resolved
|
|
509
|
+
|
|
510
|
+
script_dir = script_path.parent.resolve()
|
|
511
|
+
fallback_order = [
|
|
512
|
+
script_dir / ".env",
|
|
513
|
+
REPO_ROOT / "examples" / "rl" / ".env",
|
|
514
|
+
REPO_ROOT / "examples" / "warming_up_to_rl" / ".env",
|
|
515
|
+
REPO_ROOT / ".env",
|
|
516
|
+
]
|
|
517
|
+
resolved = [p for p in fallback_order if p.exists()]
|
|
518
|
+
if resolved:
|
|
519
|
+
return resolved
|
|
520
|
+
created = _interactive_create_env(script_dir)
|
|
521
|
+
if created is None:
|
|
522
|
+
raise click.ClickException("Env file required (--env-file) for this task app")
|
|
523
|
+
return [created]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_modal_script(
|
|
527
|
+
script_path: Path,
|
|
528
|
+
modal_cli: str,
|
|
529
|
+
command: str,
|
|
530
|
+
env_paths: Sequence[Path],
|
|
531
|
+
*,
|
|
532
|
+
modal_name: str | None = None,
|
|
533
|
+
dry_run: bool = False,
|
|
534
|
+
) -> None:
|
|
535
|
+
modal_path = shutil.which(modal_cli)
|
|
536
|
+
if modal_path is None:
|
|
537
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
538
|
+
|
|
539
|
+
env_paths_list = [Path(p).resolve() for p in env_paths]
|
|
540
|
+
path_strings = [str(p) for p in env_paths_list]
|
|
541
|
+
_load_env_files_into_process(path_strings)
|
|
542
|
+
_ensure_env_values(env_paths_list, script_path.parent)
|
|
543
|
+
_load_env_values(env_paths_list)
|
|
544
|
+
|
|
545
|
+
cmd = [modal_path, command, str(script_path)]
|
|
546
|
+
if modal_name:
|
|
547
|
+
cmd.extend(["--name", modal_name])
|
|
548
|
+
if dry_run:
|
|
549
|
+
click.echo("Dry run: " + " ".join(cmd))
|
|
550
|
+
return
|
|
551
|
+
try:
|
|
552
|
+
subprocess.run(cmd, check=True)
|
|
553
|
+
except subprocess.CalledProcessError as exc:
|
|
554
|
+
raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _preflight_env_key() -> None:
|
|
558
|
+
try:
|
|
559
|
+
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
560
|
+
backend_base = raw_backend.rstrip('/')
|
|
561
|
+
if not backend_base.endswith('/api'):
|
|
562
|
+
backend_base = backend_base + '/api'
|
|
563
|
+
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
564
|
+
env_api_key = (
|
|
565
|
+
os.environ.get("ENVIRONMENT_API_KEY")
|
|
566
|
+
or os.environ.get("dev_environment_api_key")
|
|
567
|
+
or os.environ.get("DEV_ENVIRONMENT_API_KEY")
|
|
568
|
+
or ""
|
|
569
|
+
)
|
|
570
|
+
if synth_key and env_api_key:
|
|
571
|
+
import base64
|
|
572
|
+
import httpx
|
|
573
|
+
|
|
574
|
+
click.echo(f"[preflight] backend={backend_base}")
|
|
575
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
576
|
+
click.echo("[preflight] fetching public key…")
|
|
577
|
+
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
578
|
+
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
579
|
+
if pk:
|
|
580
|
+
try:
|
|
581
|
+
from nacl.public import PublicKey, SealedBox
|
|
582
|
+
|
|
583
|
+
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
584
|
+
sb = SealedBox(pub)
|
|
585
|
+
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
|
|
586
|
+
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
587
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
588
|
+
click.echo("[preflight] upserting env key…")
|
|
589
|
+
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
590
|
+
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
591
|
+
click.echo("[preflight] verifying env key presence…")
|
|
592
|
+
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
593
|
+
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
594
|
+
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
595
|
+
else:
|
|
596
|
+
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
597
|
+
except Exception:
|
|
598
|
+
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
599
|
+
except Exception:
|
|
600
|
+
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _run_modal_with_entry(
|
|
604
|
+
entry: TaskAppEntry,
|
|
605
|
+
modal_cfg: ModalDeploymentConfig,
|
|
606
|
+
modal_cli: str,
|
|
607
|
+
modal_name: str | None,
|
|
608
|
+
env_paths: list[Path],
|
|
609
|
+
command: str,
|
|
610
|
+
*,
|
|
611
|
+
dry_run: bool = False,
|
|
612
|
+
) -> None:
|
|
613
|
+
modal_path = shutil.which(modal_cli)
|
|
614
|
+
if modal_path is None:
|
|
615
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
616
|
+
|
|
617
|
+
env_paths_list = [Path(p).resolve() for p in env_paths]
|
|
618
|
+
dotenv_paths = [str(p) for p in env_paths_list]
|
|
619
|
+
_load_env_files_into_process(dotenv_paths)
|
|
620
|
+
fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
|
|
621
|
+
_ensure_env_values(env_paths_list, fallback_dir)
|
|
622
|
+
_load_env_values(env_paths_list)
|
|
623
|
+
_preflight_env_key()
|
|
624
|
+
|
|
625
|
+
script_path = _write_modal_entrypoint(
|
|
626
|
+
entry,
|
|
627
|
+
modal_cfg,
|
|
628
|
+
modal_name,
|
|
629
|
+
dotenv_paths=dotenv_paths,
|
|
630
|
+
)
|
|
631
|
+
cmd = [modal_path, command, str(script_path)]
|
|
632
|
+
|
|
633
|
+
if dry_run:
|
|
634
|
+
click.echo("Dry run: " + " ".join(cmd))
|
|
635
|
+
script_path.unlink(missing_ok=True)
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
subprocess.run(cmd, check=True)
|
|
640
|
+
except subprocess.CalledProcessError as exc:
|
|
641
|
+
raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
|
|
642
|
+
finally:
|
|
643
|
+
script_path.unlink(missing_ok=True)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _load_env_values(paths: list[Path], *, allow_empty: bool = False) -> dict[str, str]:
|
|
649
|
+
values: dict[str, str] = {}
|
|
650
|
+
for p in paths:
|
|
651
|
+
try:
|
|
652
|
+
content = p.read_text(encoding="utf-8")
|
|
653
|
+
except FileNotFoundError:
|
|
654
|
+
continue
|
|
655
|
+
for line in content.splitlines():
|
|
656
|
+
if not line or line.lstrip().startswith('#') or '=' not in line:
|
|
657
|
+
continue
|
|
658
|
+
key, value = line.split('=', 1)
|
|
659
|
+
if key and key not in values:
|
|
660
|
+
values[key.strip()] = value.strip()
|
|
661
|
+
if not allow_empty and not values:
|
|
662
|
+
raise click.ClickException("No environment values found")
|
|
663
|
+
os.environ.update({k: v for k, v in values.items() if k and v})
|
|
664
|
+
return values
|
|
665
|
+
def _interactive_create_env(target_dir: Path) -> Path | None:
|
|
666
|
+
env_path = (target_dir / ".env").resolve()
|
|
667
|
+
if env_path.exists():
|
|
668
|
+
existing = _parse_env_file(env_path)
|
|
669
|
+
env_api = (existing.get("ENVIRONMENT_API_KEY") or "").strip()
|
|
670
|
+
if env_api:
|
|
671
|
+
return env_path
|
|
672
|
+
click.echo(f"Existing {env_path} is missing ENVIRONMENT_API_KEY. Let's update it.")
|
|
673
|
+
return _interactive_fill_env(env_path)
|
|
674
|
+
|
|
675
|
+
click.echo("No .env found for this task app. Let's create one.")
|
|
676
|
+
return _interactive_fill_env(env_path)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
680
|
+
data: dict[str, str] = {}
|
|
681
|
+
try:
|
|
682
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
683
|
+
if not line or line.lstrip().startswith('#') or '=' not in line:
|
|
684
|
+
continue
|
|
685
|
+
key, value = line.split('=', 1)
|
|
686
|
+
data[key.strip()] = value.strip()
|
|
687
|
+
except FileNotFoundError:
|
|
688
|
+
pass
|
|
689
|
+
return data
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _interactive_fill_env(env_path: Path) -> Path | None:
|
|
693
|
+
existing = _parse_env_file(env_path) if env_path.exists() else {}
|
|
694
|
+
|
|
695
|
+
def _prompt(label: str, *, default: str = "", required: bool) -> str | None:
|
|
696
|
+
while True:
|
|
697
|
+
try:
|
|
698
|
+
value = click.prompt(label, default=default, show_default=bool(default) or not required).strip()
|
|
699
|
+
except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
|
|
700
|
+
click.echo("Aborted env creation.")
|
|
701
|
+
return None
|
|
702
|
+
if value or not required:
|
|
703
|
+
return value
|
|
704
|
+
click.echo("This field is required.")
|
|
705
|
+
|
|
706
|
+
env_default = existing.get("ENVIRONMENT_API_KEY", "").strip()
|
|
707
|
+
env_api_key = _prompt("ENVIRONMENT_API_KEY", default=env_default, required=True)
|
|
708
|
+
if env_api_key is None:
|
|
709
|
+
return None
|
|
710
|
+
synth_default = existing.get("SYNTH_API_KEY", "").strip()
|
|
711
|
+
openai_default = existing.get("OPENAI_API_KEY", "").strip()
|
|
712
|
+
synth_key = _prompt("SYNTH_API_KEY (optional)", default=synth_default, required=False) or ""
|
|
713
|
+
openai_key = _prompt("OPENAI_API_KEY (optional)", default=openai_default, required=False) or ""
|
|
714
|
+
|
|
715
|
+
lines = [
|
|
716
|
+
f"ENVIRONMENT_API_KEY={env_api_key}",
|
|
717
|
+
f"SYNTH_API_KEY={synth_key}",
|
|
718
|
+
f"OPENAI_API_KEY={openai_key}",
|
|
719
|
+
]
|
|
720
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
722
|
+
click.echo(f"Wrote credentials to {env_path}")
|
|
723
|
+
return env_path
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
|
|
727
|
+
if (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
|
|
728
|
+
return
|
|
729
|
+
target = env_paths[0] if env_paths else (fallback_dir / ".env").resolve()
|
|
730
|
+
result = _interactive_fill_env(target)
|
|
731
|
+
if result is None:
|
|
732
|
+
raise click.ClickException("ENVIRONMENT_API_KEY required to continue")
|
|
733
|
+
# After generating .env, load it and override any previously-empty values
|
|
734
|
+
_load_env_values([result])
|
|
735
|
+
if not (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
|
|
736
|
+
raise click.ClickException("Failed to load ENVIRONMENT_API_KEY from generated .env")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _deploy_entry(
|
|
740
|
+
entry: TaskAppEntry,
|
|
741
|
+
modal_name: str | None,
|
|
742
|
+
dry_run: bool,
|
|
743
|
+
modal_cli: str,
|
|
744
|
+
env_file: Sequence[str],
|
|
745
|
+
) -> None:
|
|
746
|
+
modal_cfg = entry.modal
|
|
747
|
+
if modal_cfg is None:
|
|
748
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
749
|
+
|
|
750
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
751
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
752
|
+
_run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="deploy", dry_run=dry_run)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _modal_serve_entry(
|
|
756
|
+
entry: TaskAppEntry,
|
|
757
|
+
modal_name: str | None,
|
|
758
|
+
modal_cli: str,
|
|
759
|
+
env_file: Sequence[str],
|
|
760
|
+
) -> None:
|
|
761
|
+
modal_cfg = entry.modal
|
|
762
|
+
if modal_cfg is None:
|
|
763
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
764
|
+
|
|
765
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
766
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
767
|
+
_run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="serve")
|
|
18
768
|
|
|
19
769
|
@click.group(
|
|
20
770
|
name='task-app',
|
|
@@ -47,13 +797,16 @@ def _load_env_files_into_process(paths: Sequence[str]) -> None:
|
|
|
47
797
|
k, v = line.split('=', 1)
|
|
48
798
|
key = k.strip()
|
|
49
799
|
val = v.strip().strip('"').strip("'")
|
|
50
|
-
if
|
|
51
|
-
|
|
800
|
+
# Load into process, but allow overriding if the current value is empty
|
|
801
|
+
if key:
|
|
802
|
+
current = os.environ.get(key)
|
|
803
|
+
if current is None or not str(current).strip():
|
|
804
|
+
os.environ[key] = val
|
|
52
805
|
|
|
53
806
|
|
|
54
807
|
|
|
55
808
|
@click.command('serve')
|
|
56
|
-
@click.argument('app_id', type=str)
|
|
809
|
+
@click.argument('app_id', type=str, required=False)
|
|
57
810
|
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
58
811
|
@click.option('--port', default=8001, show_default=True, type=int)
|
|
59
812
|
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
@@ -62,7 +815,7 @@ def _load_env_files_into_process(paths: Sequence[str]) -> None:
|
|
|
62
815
|
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
63
816
|
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
64
817
|
def serve_command(
|
|
65
|
-
app_id: str,
|
|
818
|
+
app_id: str | None,
|
|
66
819
|
host: str,
|
|
67
820
|
port: int,
|
|
68
821
|
env_file: Sequence[str],
|
|
@@ -71,11 +824,13 @@ def serve_command(
|
|
|
71
824
|
trace_dir: str | None,
|
|
72
825
|
trace_db: str | None,
|
|
73
826
|
) -> None:
|
|
74
|
-
|
|
827
|
+
choice = _select_app_choice(app_id, purpose="serve")
|
|
828
|
+
entry = choice.ensure_entry()
|
|
829
|
+
_serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
75
830
|
|
|
76
831
|
|
|
77
832
|
@task_app_group.command('serve')
|
|
78
|
-
@click.argument('app_id', type=str)
|
|
833
|
+
@click.argument('app_id', type=str, required=False)
|
|
79
834
|
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
80
835
|
@click.option('--port', default=8001, show_default=True, type=int)
|
|
81
836
|
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
@@ -84,7 +839,7 @@ def serve_command(
|
|
|
84
839
|
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
85
840
|
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
86
841
|
def serve_task_group(
|
|
87
|
-
app_id: str,
|
|
842
|
+
app_id: str | None,
|
|
88
843
|
host: str,
|
|
89
844
|
port: int,
|
|
90
845
|
env_file: Sequence[str],
|
|
@@ -93,7 +848,9 @@ def serve_task_group(
|
|
|
93
848
|
trace_dir: str | None,
|
|
94
849
|
trace_db: str | None,
|
|
95
850
|
) -> None:
|
|
96
|
-
|
|
851
|
+
choice = _select_app_choice(app_id, purpose="serve")
|
|
852
|
+
entry = choice.ensure_entry()
|
|
853
|
+
_serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
97
854
|
|
|
98
855
|
def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
|
|
99
856
|
resolved: list[Path] = []
|
|
@@ -167,8 +924,8 @@ def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
|
|
|
167
924
|
if in_use_after:
|
|
168
925
|
raise click.ClickException(f'Port {port} is still in use after attempting to terminate processes.')
|
|
169
926
|
|
|
170
|
-
def
|
|
171
|
-
|
|
927
|
+
def _serve_entry(
|
|
928
|
+
entry: TaskAppEntry,
|
|
172
929
|
host: str,
|
|
173
930
|
port: int,
|
|
174
931
|
env_file: Sequence[str],
|
|
@@ -178,11 +935,6 @@ def _serve(
|
|
|
178
935
|
trace_dir: str | None = None,
|
|
179
936
|
trace_db: str | None = None,
|
|
180
937
|
) -> None:
|
|
181
|
-
try:
|
|
182
|
-
entry = registry.get(app_id)
|
|
183
|
-
except KeyError as exc: # pragma: no cover - CLI input validation
|
|
184
|
-
raise click.ClickException(str(exc)) from exc
|
|
185
|
-
|
|
186
938
|
env_files = list(entry.env_files)
|
|
187
939
|
env_files.extend(env_file)
|
|
188
940
|
|
|
@@ -214,49 +966,7 @@ def _serve(
|
|
|
214
966
|
|
|
215
967
|
_ensure_port_free(port, host, force=force)
|
|
216
968
|
|
|
217
|
-
|
|
218
|
-
try:
|
|
219
|
-
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
220
|
-
backend_base = raw_backend.rstrip("/")
|
|
221
|
-
if not backend_base.endswith("/api"):
|
|
222
|
-
backend_base = backend_base + "/api"
|
|
223
|
-
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
224
|
-
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
225
|
-
if synth_key and env_api_key:
|
|
226
|
-
import base64, httpx
|
|
227
|
-
click.echo(f"[preflight] backend={backend_base}")
|
|
228
|
-
# Fetch sealed-box public key
|
|
229
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
230
|
-
click.echo("[preflight] fetching public key…")
|
|
231
|
-
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
232
|
-
if rpk.status_code == 200:
|
|
233
|
-
pk = (rpk.json() or {}).get("public_key")
|
|
234
|
-
else:
|
|
235
|
-
pk = None
|
|
236
|
-
if pk:
|
|
237
|
-
# Encrypt env_api_key using libsodium sealed box
|
|
238
|
-
try:
|
|
239
|
-
from nacl.public import SealedBox, PublicKey
|
|
240
|
-
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
241
|
-
sb = SealedBox(pub)
|
|
242
|
-
ct = sb.encrypt(env_api_key.encode("utf-8"))
|
|
243
|
-
ct_b64 = base64.b64encode(ct).decode()
|
|
244
|
-
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
245
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
246
|
-
click.echo("[preflight] upserting env key…")
|
|
247
|
-
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
248
|
-
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
249
|
-
# Verify
|
|
250
|
-
click.echo("[preflight] verifying env key presence…")
|
|
251
|
-
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
252
|
-
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
253
|
-
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
254
|
-
else:
|
|
255
|
-
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
256
|
-
except Exception:
|
|
257
|
-
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
258
|
-
except Exception:
|
|
259
|
-
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
969
|
+
_preflight_env_key()
|
|
260
970
|
|
|
261
971
|
run_task_app(
|
|
262
972
|
entry.config_factory,
|
|
@@ -268,81 +978,24 @@ def _serve(
|
|
|
268
978
|
|
|
269
979
|
|
|
270
980
|
@task_app_group.command('deploy')
|
|
271
|
-
@click.argument("app_id", type=str)
|
|
981
|
+
@click.argument("app_id", type=str, required=False)
|
|
272
982
|
@click.option("--name", "modal_name", default=None, help="Override Modal app name")
|
|
273
983
|
@click.option("--dry-run", is_flag=True, help="Print modal deploy command without executing")
|
|
274
984
|
@click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
|
|
275
985
|
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
276
|
-
def deploy_app(app_id: str, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
|
|
986
|
+
def deploy_app(app_id: str | None, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
|
|
277
987
|
"""Deploy a task app to Modal."""
|
|
278
988
|
|
|
279
|
-
|
|
280
|
-
entry = registry.get(app_id)
|
|
281
|
-
except KeyError as exc: # pragma: no cover - CLI input validation
|
|
282
|
-
raise click.ClickException(str(exc)) from exc
|
|
283
|
-
|
|
284
|
-
modal_cfg = entry.modal
|
|
285
|
-
if modal_cfg is None:
|
|
286
|
-
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
989
|
+
choice = _select_app_choice(app_id, purpose="deploy")
|
|
287
990
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if modal_path is None:
|
|
293
|
-
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
294
|
-
|
|
295
|
-
# Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before deploy
|
|
296
|
-
try:
|
|
297
|
-
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
298
|
-
backend_base = raw_backend.rstrip("/")
|
|
299
|
-
if not backend_base.endswith("/api"):
|
|
300
|
-
backend_base = backend_base + "/api"
|
|
301
|
-
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
302
|
-
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
303
|
-
if synth_key and env_api_key:
|
|
304
|
-
import base64, httpx
|
|
305
|
-
click.echo(f"[preflight] backend={backend_base}")
|
|
306
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
307
|
-
click.echo("[preflight] fetching public key…")
|
|
308
|
-
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
309
|
-
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
310
|
-
if pk:
|
|
311
|
-
try:
|
|
312
|
-
from nacl.public import SealedBox, PublicKey
|
|
313
|
-
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
314
|
-
sb = SealedBox(pub)
|
|
315
|
-
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
|
|
316
|
-
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
317
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
318
|
-
click.echo("[preflight] upserting env key…")
|
|
319
|
-
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
320
|
-
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
321
|
-
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
322
|
-
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
323
|
-
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
324
|
-
else:
|
|
325
|
-
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
326
|
-
except Exception:
|
|
327
|
-
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
328
|
-
except Exception:
|
|
329
|
-
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
330
|
-
|
|
331
|
-
script_path = _write_modal_entrypoint(
|
|
332
|
-
entry,
|
|
333
|
-
modal_cfg,
|
|
334
|
-
modal_name,
|
|
335
|
-
dotenv_paths=[str(path) for path in env_paths],
|
|
336
|
-
)
|
|
337
|
-
cmd = [modal_path, "deploy", str(script_path)]
|
|
338
|
-
if dry_run:
|
|
339
|
-
click.echo("Dry run: " + " ".join(cmd))
|
|
340
|
-
script_path.unlink(missing_ok=True)
|
|
991
|
+
if choice.modal_script:
|
|
992
|
+
env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
|
|
993
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
994
|
+
_run_modal_script(choice.modal_script, modal_cli, "deploy", env_paths, modal_name=modal_name, dry_run=dry_run)
|
|
341
995
|
return
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
script_path.unlink(missing_ok=True)
|
|
996
|
+
|
|
997
|
+
entry = choice.ensure_entry()
|
|
998
|
+
_deploy_entry(entry, modal_name, dry_run, modal_cli, env_file)
|
|
346
999
|
|
|
347
1000
|
@task_app_group.command('modal-serve')
|
|
348
1001
|
@click.argument('app_id', type=str, required=False)
|
|
@@ -350,82 +1003,16 @@ def deploy_app(app_id: str, modal_name: str | None, dry_run: bool, modal_cli: st
|
|
|
350
1003
|
@click.option('--name', 'modal_name', default=None, help='Override Modal app name (optional)')
|
|
351
1004
|
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
352
1005
|
def modal_serve_app(app_id: str | None, modal_cli: str, modal_name: str | None, env_file: Sequence[str]) -> None:
|
|
353
|
-
|
|
354
|
-
if app_id is None:
|
|
355
|
-
if len(entries) == 1:
|
|
356
|
-
entry = entries[0]
|
|
357
|
-
else:
|
|
358
|
-
available = ', '.join(e.app_id for e in entries) or 'none'
|
|
359
|
-
raise click.ClickException(f"APP_ID required (available: {available})")
|
|
360
|
-
else:
|
|
361
|
-
try:
|
|
362
|
-
entry = registry.get(app_id)
|
|
363
|
-
except KeyError as exc:
|
|
364
|
-
raise click.ClickException(str(exc)) from exc
|
|
365
|
-
|
|
366
|
-
modal_cfg = entry.modal
|
|
367
|
-
if modal_cfg is None:
|
|
368
|
-
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
369
|
-
|
|
370
|
-
env_paths = _determine_env_files(entry, env_file)
|
|
371
|
-
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
372
|
-
# Make values available for preflight
|
|
373
|
-
_load_env_files_into_process([str(p) for p in env_paths])
|
|
374
|
-
|
|
375
|
-
modal_path = shutil.which(modal_cli)
|
|
376
|
-
if modal_path is None:
|
|
377
|
-
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
1006
|
+
choice = _select_app_choice(app_id, purpose="modal-serve")
|
|
378
1007
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
backend_base = backend_base + '/api'
|
|
385
|
-
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
386
|
-
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
387
|
-
if synth_key and env_api_key:
|
|
388
|
-
import base64, httpx
|
|
389
|
-
click.echo(f"[preflight] backend={backend_base}")
|
|
390
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
391
|
-
click.echo("[preflight] fetching public key…")
|
|
392
|
-
rpk = c.get(f"{backend_base}/v1/crypto/public-key")
|
|
393
|
-
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
394
|
-
if pk:
|
|
395
|
-
try:
|
|
396
|
-
from nacl.public import SealedBox, PublicKey
|
|
397
|
-
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
398
|
-
sb = SealedBox(pub)
|
|
399
|
-
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
|
|
400
|
-
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
401
|
-
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
402
|
-
click.echo("[preflight] upserting env key…")
|
|
403
|
-
up = c.post(f"{backend_base}/v1/env-keys", json=payload)
|
|
404
|
-
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
405
|
-
click.echo("[preflight] verifying env key presence…")
|
|
406
|
-
ver = c.get(f"{backend_base}/v1/env-keys/verify")
|
|
407
|
-
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
408
|
-
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
409
|
-
else:
|
|
410
|
-
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
411
|
-
except Exception:
|
|
412
|
-
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
413
|
-
except Exception:
|
|
414
|
-
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
1008
|
+
if choice.modal_script:
|
|
1009
|
+
env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
|
|
1010
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
1011
|
+
_run_modal_script(choice.modal_script, modal_cli, "serve", env_paths, modal_name=modal_name)
|
|
1012
|
+
return
|
|
415
1013
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
modal_cfg,
|
|
419
|
-
modal_name,
|
|
420
|
-
dotenv_paths=[str(path) for path in env_paths],
|
|
421
|
-
)
|
|
422
|
-
cmd = [modal_path, 'serve', str(script_path)]
|
|
423
|
-
try:
|
|
424
|
-
subprocess.run(cmd, check=True)
|
|
425
|
-
except subprocess.CalledProcessError as exc:
|
|
426
|
-
raise click.ClickException(f"modal serve failed with exit code {exc.returncode}") from exc
|
|
427
|
-
finally:
|
|
428
|
-
script_path.unlink(missing_ok=True)
|
|
1014
|
+
entry = choice.ensure_entry()
|
|
1015
|
+
_modal_serve_entry(entry, modal_name, modal_cli, env_file)
|
|
429
1016
|
|
|
430
1017
|
|
|
431
1018
|
def _write_modal_entrypoint(
|