beamflow-cli 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.
- beamflow/__init__.py +0 -0
- beamflow/commands/__init__.py +0 -0
- beamflow/commands/auth.py +111 -0
- beamflow/commands/build.py +127 -0
- beamflow/commands/deploy.py +104 -0
- beamflow/commands/project.py +451 -0
- beamflow/commands/run.py +65 -0
- beamflow/core/__init__.py +0 -0
- beamflow/core/api_client.py +62 -0
- beamflow/core/auth_server.py +36 -0
- beamflow/core/builder.py +40 -0
- beamflow/core/config.py +81 -0
- beamflow/core/docker_utils.py +33 -0
- beamflow/main.py +227 -0
- beamflow/templates/_.beamflow +4 -0
- beamflow/templates/_.dockerignore +9 -0
- beamflow/templates/_README.md +28 -0
- beamflow/templates/_api_main.py +19 -0
- beamflow/templates/_config/[env]/(backend-asyncio)/backend.yaml +4 -0
- beamflow/templates/_config/[env]/(backend-dramatiq)/backend.yaml +8 -0
- beamflow/templates/_config/[env]/(backend-managed)/backend.yaml +6 -0
- beamflow/templates/_config/[env]/_backend.yaml +4 -0
- beamflow/templates/_config/shared/backend.yaml +4 -0
- beamflow/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +34 -0
- beamflow/templates/_deployment/[env]/.env +3 -0
- beamflow/templates/_deployment/shared/.env +5 -0
- beamflow/templates/_deployment/shared/api.Dockerfile +10 -0
- beamflow/templates/_deployment/shared/clients/demoClient.yaml +39 -0
- beamflow/templates/_deployment/shared/worker.Dockerfile +9 -0
- beamflow/templates/_pyproject.toml +40 -0
- beamflow/templates/_src/_api/__init__.py +1 -0
- beamflow/templates/_src/_api/_routes/webhooks.py +25 -0
- beamflow/templates/_src/_shared/__init__.py +1 -0
- beamflow/templates/_src/_shared/clients/client.py +18 -0
- beamflow/templates/_src/_shared/models/models.py +1 -0
- beamflow/templates/_src/_shared/tasks/sharedTasks.py +10 -0
- beamflow/templates/_src/_worker/__init__.py +1 -0
- beamflow/templates/_src/_worker/tasks/tasks.py +15 -0
- beamflow/templates/_worker_main.py +25 -0
- beamflow/templates/tests/_test_config_loading.py +10 -0
- beamflow/templates/tests/_test_integration.py +20 -0
- beamflow/ui/__init__.py +0 -0
- beamflow_cli-0.3.0.dist-info/METADATA +22 -0
- beamflow_cli-0.3.0.dist-info/RECORD +46 -0
- beamflow_cli-0.3.0.dist-info/WHEEL +4 -0
- beamflow_cli-0.3.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from typing import Optional, List, Dict
|
|
2
|
+
import typer
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
import asyncio
|
|
7
|
+
import questionary
|
|
8
|
+
|
|
9
|
+
from ..core.api_client import APIClient
|
|
10
|
+
from ..core.config import (
|
|
11
|
+
get_access_token,
|
|
12
|
+
load_project_config,
|
|
13
|
+
save_project_config,
|
|
14
|
+
ProjectConfig,
|
|
15
|
+
EnvironmentMode
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app = typer.Typer()
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
|
|
23
|
+
def find_project_root(start_path: Path = Path.cwd()) -> Optional[Path]:
|
|
24
|
+
"""Find the nearest project root marked by .beamflow or pyproject.toml."""
|
|
25
|
+
for parent in [start_path] + list(start_path.parents):
|
|
26
|
+
if (parent / ".beamflow").exists() or (parent / "pyproject.toml").exists():
|
|
27
|
+
return parent
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def has_env_descendant(path: Path) -> bool:
|
|
31
|
+
"""Check if a directory contains any [env] folder in its descendants."""
|
|
32
|
+
if not path.is_dir():
|
|
33
|
+
return False
|
|
34
|
+
if path.name == "[env]":
|
|
35
|
+
return True
|
|
36
|
+
for child in path.iterdir():
|
|
37
|
+
if child.name == "[env]":
|
|
38
|
+
return True
|
|
39
|
+
if child.is_dir() and has_env_descendant(child):
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def process_node(
|
|
44
|
+
src: Path,
|
|
45
|
+
dest_parent: Path,
|
|
46
|
+
env_mode: Optional[EnvironmentMode],
|
|
47
|
+
is_fix: bool,
|
|
48
|
+
is_check: bool,
|
|
49
|
+
force: bool,
|
|
50
|
+
project_name: str,
|
|
51
|
+
root_path: Path,
|
|
52
|
+
errors: list,
|
|
53
|
+
target_env: Optional[str] = None
|
|
54
|
+
):
|
|
55
|
+
name = src.name
|
|
56
|
+
|
|
57
|
+
# If target_env is set and we are NOT in an environment-specific context yet
|
|
58
|
+
if target_env and not env_mode:
|
|
59
|
+
# If this IS the [env] folder, we only care about target_env
|
|
60
|
+
if name == "[env]":
|
|
61
|
+
pass # Continue to [env] handling below
|
|
62
|
+
elif src.is_dir():
|
|
63
|
+
# If this is a directory, only proceed if it contains an [env] somewhere
|
|
64
|
+
if not has_env_descendant(src):
|
|
65
|
+
return
|
|
66
|
+
else:
|
|
67
|
+
# If this is a file at the root or outside [env], skip it for env-add
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# 1. Condition check (...)
|
|
71
|
+
if name.startswith("(") and name.endswith(")"):
|
|
72
|
+
condition = name[1:-1]
|
|
73
|
+
if "-" in condition:
|
|
74
|
+
q, a = condition.split("-", 1)
|
|
75
|
+
if not env_mode:
|
|
76
|
+
return
|
|
77
|
+
if env_mode.options.get(q) != a:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Condition MET. Process its children and place them in dest_parent
|
|
81
|
+
if src.is_dir():
|
|
82
|
+
for child in src.iterdir():
|
|
83
|
+
process_node(child, dest_parent, env_mode, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# 2. Iterate environments [env]
|
|
87
|
+
if name == "[env]":
|
|
88
|
+
if src.is_dir():
|
|
89
|
+
project_config = load_project_config()
|
|
90
|
+
envs = project_config.environments if project_config else []
|
|
91
|
+
|
|
92
|
+
# If target_env is specified, only process that one
|
|
93
|
+
if target_env:
|
|
94
|
+
envs = [e for e in envs if e.name == target_env]
|
|
95
|
+
|
|
96
|
+
for em in envs:
|
|
97
|
+
new_dest = dest_parent / em.name
|
|
98
|
+
for child in src.iterdir():
|
|
99
|
+
process_node(child, new_dest, em, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
# 3. Handle `_` prefix
|
|
103
|
+
is_underscored = False
|
|
104
|
+
target_name = name
|
|
105
|
+
if target_name.startswith("_") and not target_name.startswith("__"):
|
|
106
|
+
is_underscored = True
|
|
107
|
+
target_name = target_name[1:]
|
|
108
|
+
|
|
109
|
+
target_path = dest_parent / target_name
|
|
110
|
+
|
|
111
|
+
# 4. Handle Directory vs File
|
|
112
|
+
if src.is_dir():
|
|
113
|
+
if not is_check:
|
|
114
|
+
should_create = True
|
|
115
|
+
if not target_path.exists():
|
|
116
|
+
if is_underscored and is_fix and not force:
|
|
117
|
+
should_create = False
|
|
118
|
+
|
|
119
|
+
if should_create:
|
|
120
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
if is_underscored and not target_path.exists():
|
|
124
|
+
errors.append(f"Missing mandatory folder: [bold]{target_path.relative_to(root_path)}[/bold]")
|
|
125
|
+
|
|
126
|
+
# Recurse children
|
|
127
|
+
for child in src.iterdir():
|
|
128
|
+
process_node(child, target_path, env_mode, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# File
|
|
132
|
+
if not is_check:
|
|
133
|
+
should_create = True
|
|
134
|
+
if target_path.exists() and not force:
|
|
135
|
+
should_create = False
|
|
136
|
+
|
|
137
|
+
if not target_path.exists():
|
|
138
|
+
if is_underscored and is_fix and not force:
|
|
139
|
+
should_create = False
|
|
140
|
+
|
|
141
|
+
if should_create:
|
|
142
|
+
try:
|
|
143
|
+
content = src.read_text()
|
|
144
|
+
content = content.replace("{project_name}", project_name)
|
|
145
|
+
if env_mode:
|
|
146
|
+
content = content.replace("{env}", env_mode.name)
|
|
147
|
+
content = content.replace("[env]", env_mode.name)
|
|
148
|
+
|
|
149
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
target_path.write_text(content)
|
|
151
|
+
status = "Updated" if target_path.exists() and force else "Created"
|
|
152
|
+
|
|
153
|
+
# Compute relative path or just use absolute if not under root
|
|
154
|
+
try:
|
|
155
|
+
rel_path = target_path.relative_to(root_path)
|
|
156
|
+
except ValueError:
|
|
157
|
+
rel_path = target_path
|
|
158
|
+
console.print(f"{status}: [bold]{rel_path}[/bold]")
|
|
159
|
+
except Exception as e:
|
|
160
|
+
console.print(f"[red]Failed to process {src.name}: {e}[/red]")
|
|
161
|
+
else:
|
|
162
|
+
if is_underscored and not target_path.exists():
|
|
163
|
+
try:
|
|
164
|
+
rel_path = target_path.relative_to(root_path)
|
|
165
|
+
except ValueError:
|
|
166
|
+
rel_path = target_path
|
|
167
|
+
errors.append(f"Missing mandatory file: [bold]{rel_path}[/bold]")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def check():
|
|
173
|
+
"""Inspect the current project against the spec and produce a report."""
|
|
174
|
+
root = find_project_root()
|
|
175
|
+
if not root:
|
|
176
|
+
console.print("[red]Not in a Beamflow project (no .beamflow or pyproject.toml found).[/red]")
|
|
177
|
+
raise typer.Exit(code=1)
|
|
178
|
+
|
|
179
|
+
console.print(f"Project root: [bold]{root}[/bold]")
|
|
180
|
+
|
|
181
|
+
project_config = load_project_config()
|
|
182
|
+
errors = []
|
|
183
|
+
|
|
184
|
+
if (root / ".beamflow").exists():
|
|
185
|
+
if project_config is None:
|
|
186
|
+
errors.append("[bold].beamflow[/bold] exists but is invalid YAML or has a wrong schema.")
|
|
187
|
+
else:
|
|
188
|
+
if not project_config.project_name:
|
|
189
|
+
errors.append("[bold].beamflow[/bold] is missing [bold]project_name[/bold].")
|
|
190
|
+
|
|
191
|
+
project_name = project_config.project_name if project_config else root.name
|
|
192
|
+
|
|
193
|
+
templates_dir = Path(__file__).parent.parent / "templates"
|
|
194
|
+
for child in templates_dir.iterdir():
|
|
195
|
+
process_node(child, root, None, is_fix=False, is_check=True, force=False, project_name=project_name, root_path=root, errors=errors)
|
|
196
|
+
|
|
197
|
+
if not errors:
|
|
198
|
+
console.print("[green]Project structure is valid![/green]")
|
|
199
|
+
else:
|
|
200
|
+
console.print("[red]Structure issues found:[/red]")
|
|
201
|
+
for err in errors:
|
|
202
|
+
console.print(f" - {err}")
|
|
203
|
+
console.print("\n[yellow]Run 'beamflow init --fix' to attempt repair.[/yellow]")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def init_env(env_name: str) -> EnvironmentMode:
|
|
207
|
+
answers = {}
|
|
208
|
+
|
|
209
|
+
# Backend
|
|
210
|
+
backend_choices = [
|
|
211
|
+
questionary.Choice("Asyncio - only for local development... not recomended for production", "asyncio"),
|
|
212
|
+
questionary.Choice("Dramatiq task queue - ideal for dev envirment / self hosting", "dramatiq")
|
|
213
|
+
]
|
|
214
|
+
if env_name != "local":
|
|
215
|
+
backend_choices.append(questionary.Choice("Cloud managed backend, scales from 0->inf+ ... ideal for hasle free", "managed"))
|
|
216
|
+
|
|
217
|
+
backend = questionary.select(
|
|
218
|
+
"Backend type:\nPick one of these backend types:",
|
|
219
|
+
choices=backend_choices
|
|
220
|
+
).ask()
|
|
221
|
+
if backend:
|
|
222
|
+
answers["backend"] = backend
|
|
223
|
+
|
|
224
|
+
# Observability
|
|
225
|
+
obs_choices = [
|
|
226
|
+
questionary.Choice("None or custom observability", "unset"),
|
|
227
|
+
questionary.Choice("Log events to log file", "logfile"),
|
|
228
|
+
#questionary.Choice("OpenTelemetry", "otel"),
|
|
229
|
+
questionary.Choice("Use BeamFlow managed monitoring", "managed")
|
|
230
|
+
]
|
|
231
|
+
obs = questionary.select(
|
|
232
|
+
"Observability:",
|
|
233
|
+
choices=obs_choices
|
|
234
|
+
).ask()
|
|
235
|
+
if obs:
|
|
236
|
+
answers["observability"] = obs
|
|
237
|
+
|
|
238
|
+
is_managed = (answers.get("backend") == "managed") or (answers.get("observability") == "managed")
|
|
239
|
+
return EnvironmentMode(name=env_name, managed=is_managed, options=answers)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def add_environment(project_config: ProjectConfig, env_name: Optional[str] = None):
|
|
243
|
+
if not env_name:
|
|
244
|
+
existing_envs = {e.name for e in project_config.environments}
|
|
245
|
+
env_choices = [
|
|
246
|
+
questionary.Choice("Local (local)", "local"),
|
|
247
|
+
questionary.Choice("Development (dev)", "dev"),
|
|
248
|
+
questionary.Choice("Production (prod)", "prod"),
|
|
249
|
+
questionary.Choice("Custom", "custom")
|
|
250
|
+
]
|
|
251
|
+
# Filter out existing (except custom)
|
|
252
|
+
env_choices = [c for c in env_choices if c.value == "custom" or c.value not in existing_envs]
|
|
253
|
+
|
|
254
|
+
env_sel = questionary.select(
|
|
255
|
+
"Environment name:",
|
|
256
|
+
choices=env_choices
|
|
257
|
+
).ask()
|
|
258
|
+
|
|
259
|
+
if not env_sel:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
if env_sel == "custom":
|
|
263
|
+
env_name = Prompt.ask("Enter custom environment name")
|
|
264
|
+
else:
|
|
265
|
+
env_name = env_sel
|
|
266
|
+
|
|
267
|
+
if not env_name or any(e.name == env_name for e in project_config.environments):
|
|
268
|
+
console.print("[red]Environment already exists or invalid name![/red]")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
mode = init_env(env_name)
|
|
272
|
+
project_config.environments.append(mode)
|
|
273
|
+
save_project_config(project_config)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command("add")
|
|
277
|
+
def env_add(
|
|
278
|
+
env_name: Optional[str] = typer.Argument(None, help="Environment name to add"),
|
|
279
|
+
force: bool = typer.Option(False, "--force", help="Force overwrite existing files")
|
|
280
|
+
):
|
|
281
|
+
"""Add a new environment to the project."""
|
|
282
|
+
root = find_project_root()
|
|
283
|
+
if not root:
|
|
284
|
+
console.print("[red]Not in a Beamflow project. Run 'beamflow init' first.[/red]")
|
|
285
|
+
raise typer.Exit(code=1)
|
|
286
|
+
|
|
287
|
+
project_config = load_project_config()
|
|
288
|
+
if not project_config:
|
|
289
|
+
console.print("[red]Invalid project configuration.[/red]")
|
|
290
|
+
raise typer.Exit(code=1)
|
|
291
|
+
|
|
292
|
+
add_environment(project_config, env_name=env_name)
|
|
293
|
+
|
|
294
|
+
# Reload config to get the newly added environment
|
|
295
|
+
project_config = load_project_config()
|
|
296
|
+
|
|
297
|
+
# Check if we should ask for linkage
|
|
298
|
+
is_managed = any(e.managed for e in project_config.environments)
|
|
299
|
+
if not project_config.project_id and is_managed:
|
|
300
|
+
if Prompt.ask("Link to managed project?", choices=["y", "n"], default="y") == "y":
|
|
301
|
+
_link_project(project_config)
|
|
302
|
+
|
|
303
|
+
# Process templates for the new environment
|
|
304
|
+
templates_dir = Path(__file__).parent.parent / "templates"
|
|
305
|
+
errors = []
|
|
306
|
+
project_name = project_config.project_name or root.name
|
|
307
|
+
|
|
308
|
+
for child in templates_dir.iterdir():
|
|
309
|
+
process_node(child, root, None, is_fix=True, is_check=False, force=force, project_name=project_name, root_path=root, errors=errors, target_env=env_name)
|
|
310
|
+
|
|
311
|
+
console.print(f"[green]Environment configuration updated![/green]")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("delete")
|
|
315
|
+
def env_delete(
|
|
316
|
+
env_name: str = typer.Argument(..., help="Name of the environment to delete")
|
|
317
|
+
):
|
|
318
|
+
"""
|
|
319
|
+
[bold red]Delete an environment[/bold red] from your project.
|
|
320
|
+
|
|
321
|
+
This will remove the environment from .beamflow and optionally delete its configuration folder.
|
|
322
|
+
"""
|
|
323
|
+
root = find_project_root()
|
|
324
|
+
if not root:
|
|
325
|
+
console.print("[red]Not in a Beamflow project. Run 'beamflow init' first.[/red]")
|
|
326
|
+
raise typer.Exit(code=1)
|
|
327
|
+
|
|
328
|
+
project_config = load_project_config()
|
|
329
|
+
if not project_config:
|
|
330
|
+
console.print("[red]Invalid project configuration.[/red]")
|
|
331
|
+
raise typer.Exit(code=1)
|
|
332
|
+
|
|
333
|
+
# Find the environment
|
|
334
|
+
env_to_del = next((e for e in project_config.environments if e.name == env_name), None)
|
|
335
|
+
if not env_to_del:
|
|
336
|
+
console.print(f"[red]Environment '{env_name}' not found.[/red]")
|
|
337
|
+
console.print(f"[yellow]Available environments: {', '.join(e.name for e in project_config.environments)}[/yellow]")
|
|
338
|
+
raise typer.Exit(code=1)
|
|
339
|
+
|
|
340
|
+
from rich.prompt import Confirm
|
|
341
|
+
if not Confirm.ask(f"Are you sure you want to delete environment [bold red]{env_name}[/bold red]?"):
|
|
342
|
+
console.print("Cancelled.")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# Remove from config
|
|
346
|
+
project_config.environments = [e for e in project_config.environments if e.name != env_name]
|
|
347
|
+
save_project_config(project_config)
|
|
348
|
+
|
|
349
|
+
# Optionally delete the folder
|
|
350
|
+
env_folder = root / env_name
|
|
351
|
+
if env_folder.exists() and env_folder.is_dir():
|
|
352
|
+
if Confirm.ask(f"Do you also want to delete the configuration folder [bold]{env_name}/[/bold]?"):
|
|
353
|
+
import shutil
|
|
354
|
+
shutil.rmtree(env_folder)
|
|
355
|
+
console.print(f"Deleted folder: [bold]{env_name}/[/bold]")
|
|
356
|
+
|
|
357
|
+
console.print(f"[green]Environment '{env_name}' deleted successfully![/green]")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.command()
|
|
361
|
+
def init(
|
|
362
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Project name"),
|
|
363
|
+
fix: bool = typer.Option(False, "--fix", help="Repair missing components"),
|
|
364
|
+
force: bool = typer.Option(False, "--force", help="Force overwrite existing files")
|
|
365
|
+
):
|
|
366
|
+
"""Initialize or repair a Beamflow project."""
|
|
367
|
+
root = Path.cwd()
|
|
368
|
+
project_config = load_project_config()
|
|
369
|
+
|
|
370
|
+
if project_config and not fix:
|
|
371
|
+
if not Prompt.ask("Project already initialized. Re-initialize?", choices=["y", "n"], default="n") == "y":
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if not name:
|
|
375
|
+
name = project_config.project_name if project_config else root.name
|
|
376
|
+
name = Prompt.ask("Project name", default=name)
|
|
377
|
+
|
|
378
|
+
if not project_config:
|
|
379
|
+
project_config = ProjectConfig(project_name=name)
|
|
380
|
+
save_project_config(project_config)
|
|
381
|
+
console.print(f"[green]Created .beamflow with project_name: {name}[/green]")
|
|
382
|
+
else:
|
|
383
|
+
project_config.project_name = name
|
|
384
|
+
save_project_config(project_config)
|
|
385
|
+
|
|
386
|
+
# Environments Setup
|
|
387
|
+
if not project_config.environments:
|
|
388
|
+
# Ask to configure at least the first environment
|
|
389
|
+
setup_first = Prompt.ask("Setup first eniroment? y/n?", choices=["y", "n"], default="y")
|
|
390
|
+
if setup_first == "y":
|
|
391
|
+
add_environment(project_config)
|
|
392
|
+
else:
|
|
393
|
+
if not fix:
|
|
394
|
+
while Prompt.ask("Add another enviroment? y/n?", choices=["y", "n"], default="n") == "y":
|
|
395
|
+
add_environment(project_config)
|
|
396
|
+
|
|
397
|
+
templates_dir = Path(__file__).parent.parent / "templates"
|
|
398
|
+
errors = []
|
|
399
|
+
|
|
400
|
+
for child in templates_dir.iterdir():
|
|
401
|
+
process_node(child, root, None, is_fix=fix, is_check=False, force=force, project_name=name, root_path=root, errors=errors)
|
|
402
|
+
|
|
403
|
+
# Prompt for project linkage if not linked AND handled by managed services
|
|
404
|
+
is_managed = any(e.managed for e in project_config.environments)
|
|
405
|
+
if not project_config.project_id and is_managed:
|
|
406
|
+
if Prompt.ask("Link to managed project?", choices=["y", "n"], default="y") == "y":
|
|
407
|
+
_link_project(project_config)
|
|
408
|
+
|
|
409
|
+
console.print("[green]Initialization/Repair complete![/green]")
|
|
410
|
+
|
|
411
|
+
def _link_project(config: ProjectConfig):
|
|
412
|
+
token = get_access_token()
|
|
413
|
+
if not token:
|
|
414
|
+
console.print("[yellow]Not logged in. Use 'beamflow login' to link to a managed project.[/yellow]")
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
api = APIClient()
|
|
418
|
+
try:
|
|
419
|
+
projects_data = asyncio.run(api.get("/v1/projects"))
|
|
420
|
+
projects = projects_data.get("projects", [])
|
|
421
|
+
except Exception as e:
|
|
422
|
+
console.print(f"[red]Failed to fetch projects: {e}[/red]")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
choices = ["Create new project"] + [f"{p['name']} ({p['project_id']})" for p in projects]
|
|
426
|
+
choice = questionary.select("Select a managed project", choices=choices).ask()
|
|
427
|
+
|
|
428
|
+
if not choice: return
|
|
429
|
+
|
|
430
|
+
if choice == "Create new project":
|
|
431
|
+
name = Prompt.ask("Enter name for new managed project", default=config.project_name)
|
|
432
|
+
try:
|
|
433
|
+
project = asyncio.run(api.post("/v1/projects", json={"name": name}))
|
|
434
|
+
project_id = project["project_id"]
|
|
435
|
+
except Exception as e:
|
|
436
|
+
console.print(f"[red]Failed to create project: {e}[/red]")
|
|
437
|
+
return
|
|
438
|
+
else:
|
|
439
|
+
project_id = choice.split("(")[-1].rstrip(")")
|
|
440
|
+
|
|
441
|
+
# Fetch tenant
|
|
442
|
+
try:
|
|
443
|
+
user_info = asyncio.run(api.get("/v1/auth/me"))
|
|
444
|
+
config.tenant_id = user_info["tenant_id"]
|
|
445
|
+
config.user_email = user_info.get("email")
|
|
446
|
+
except:
|
|
447
|
+
config.tenant_id = "default"
|
|
448
|
+
|
|
449
|
+
config.project_id = project_id
|
|
450
|
+
save_project_config(config)
|
|
451
|
+
console.print(f"[green]Linked to project_id: {project_id}[/green]")
|
beamflow/commands/run.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
import subprocess
|
|
5
|
+
import os
|
|
6
|
+
from .project import find_project_root
|
|
7
|
+
from ..core.docker_utils import check_docker_binary, check_docker_compose_binary, check_docker_daemon, get_docker_compose_cmd
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def run(
|
|
14
|
+
env: str = typer.Argument(..., help="Environment to run"),
|
|
15
|
+
build: bool = typer.Option(False, "--build", help="Build images before starting"),
|
|
16
|
+
detach: bool = typer.Option(False, "--detach", "-d", help="Run in background"),
|
|
17
|
+
logs: bool = typer.Option(False, "--logs", help="Follow logs (useful with --detach)")
|
|
18
|
+
):
|
|
19
|
+
"""Run the project locally via Docker Compose."""
|
|
20
|
+
root = find_project_root()
|
|
21
|
+
if not root:
|
|
22
|
+
console.print("[red]Not in a Beamflow project.[/red]")
|
|
23
|
+
raise typer.Exit(code=1)
|
|
24
|
+
|
|
25
|
+
# Docker prerequisites
|
|
26
|
+
if not check_docker_binary():
|
|
27
|
+
console.print("[red]Docker binary not found. Please install Docker.[/red]")
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
if not check_docker_compose_binary():
|
|
31
|
+
console.print("[red]Docker Compose not found. Please install Docker Compose.[/red]")
|
|
32
|
+
raise typer.Exit(code=1)
|
|
33
|
+
|
|
34
|
+
daemon_running, daemon_err = check_docker_daemon()
|
|
35
|
+
if not daemon_running:
|
|
36
|
+
console.print(f"[red]Docker daemon not reachable: {daemon_err}[/red]")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
compose_file = root / "deployment" / env / "docker-compose.yaml"
|
|
40
|
+
if not compose_file.exists():
|
|
41
|
+
console.print(f"[red]No docker-compose.yaml found for env '{env}' at {compose_file}[/red]")
|
|
42
|
+
console.print("[yellow]Try running 'beamflow init --fix' to create it.[/yellow]")
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
cmd = get_docker_compose_cmd() + ["-f", str(compose_file), "up"]
|
|
46
|
+
if build:
|
|
47
|
+
cmd.append("--build")
|
|
48
|
+
if detach:
|
|
49
|
+
cmd.append("-d")
|
|
50
|
+
|
|
51
|
+
console.print(f"Running Beamflow in [bold]{env}[/bold] mode...")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# We want to stream output directly to terminal
|
|
55
|
+
subprocess.run(cmd, check=True, cwd=root)
|
|
56
|
+
|
|
57
|
+
if detach and logs:
|
|
58
|
+
log_cmd = get_docker_compose_cmd() + ["-f", str(compose_file), "logs", "-f"]
|
|
59
|
+
subprocess.run(log_cmd, check=True, cwd=root)
|
|
60
|
+
|
|
61
|
+
except subprocess.CalledProcessError:
|
|
62
|
+
console.print("[red]Docker Compose command failed.[/red]")
|
|
63
|
+
raise typer.Exit(code=1)
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
console.print("\n[yellow]Stopping...[/yellow]")
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional, Dict, Any
|
|
3
|
+
from .config import load_global_config, get_access_token
|
|
4
|
+
|
|
5
|
+
class APIClient:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.config = load_global_config()
|
|
8
|
+
self.base_url = self.config.api_url.rstrip("/")
|
|
9
|
+
|
|
10
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
11
|
+
headers = {}
|
|
12
|
+
token = get_access_token()
|
|
13
|
+
if token:
|
|
14
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
15
|
+
return headers
|
|
16
|
+
|
|
17
|
+
async def get(self, path: str, params: Optional[Dict[str, Any]] = None):
|
|
18
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
19
|
+
response = await client.get(
|
|
20
|
+
f"{self.base_url}{path}",
|
|
21
|
+
params=params,
|
|
22
|
+
headers=self._get_headers()
|
|
23
|
+
)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return response.json()
|
|
26
|
+
|
|
27
|
+
async def post(self, path: str, json: Optional[Dict[str, Any]] = None):
|
|
28
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
29
|
+
response = await client.post(
|
|
30
|
+
f"{self.base_url}{path}",
|
|
31
|
+
json=json,
|
|
32
|
+
headers=self._get_headers()
|
|
33
|
+
)
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
return response.json()
|
|
36
|
+
|
|
37
|
+
async def put_binary(self, url: str, data: bytes, headers: Optional[Dict[str, str]] = None):
|
|
38
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
39
|
+
response = await client.put(
|
|
40
|
+
url,
|
|
41
|
+
content=data,
|
|
42
|
+
headers=headers or {}
|
|
43
|
+
)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
# Generic request method if needed
|
|
48
|
+
async def request(self, method: str, path: str, **kwargs):
|
|
49
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
50
|
+
url = f"{self.base_url}{path}"
|
|
51
|
+
headers = self._get_headers()
|
|
52
|
+
if "headers" in kwargs:
|
|
53
|
+
headers.update(kwargs.pop("headers"))
|
|
54
|
+
|
|
55
|
+
response = await client.request(
|
|
56
|
+
method,
|
|
57
|
+
url,
|
|
58
|
+
headers=headers,
|
|
59
|
+
**kwargs
|
|
60
|
+
)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
return response.json()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
from fastapi import FastAPI, Request
|
|
3
|
+
from fastapi.responses import HTMLResponse
|
|
4
|
+
import uvicorn
|
|
5
|
+
import threading
|
|
6
|
+
import queue
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
result_queue = queue.Queue()
|
|
11
|
+
|
|
12
|
+
@app.get("/callback")
|
|
13
|
+
async def callback(request: Request):
|
|
14
|
+
params = dict(request.query_params)
|
|
15
|
+
result_queue.put(params)
|
|
16
|
+
return HTMLResponse(content="""
|
|
17
|
+
<html>
|
|
18
|
+
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
|
19
|
+
<h1>Authentication Successful</h1>
|
|
20
|
+
<p>You can now close this window and return to the CLI.</p>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
def run_server(port: int):
|
|
26
|
+
uvicorn.run(app, host="127.0.0.1", port=port, log_level="error")
|
|
27
|
+
|
|
28
|
+
def start_callback_server(port: int = 8888) -> Dict[str, str]:
|
|
29
|
+
thread = threading.Thread(target=run_server, args=(port,), daemon=True)
|
|
30
|
+
thread.start()
|
|
31
|
+
# Wait for result
|
|
32
|
+
try:
|
|
33
|
+
result = result_queue.get(timeout=300) # 5 minutes timeout
|
|
34
|
+
return result
|
|
35
|
+
except queue.Empty:
|
|
36
|
+
return {}
|
beamflow/core/builder.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tarfile
|
|
3
|
+
import tempfile
|
|
4
|
+
import gzip
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
def bundle_source(directory: Path, ignore_patterns: Optional[List[str]] = None) -> Path:
|
|
9
|
+
"""
|
|
10
|
+
Bundles the source code in directory into a .tar.gz file.
|
|
11
|
+
Follows .gitignore if present.
|
|
12
|
+
"""
|
|
13
|
+
temp_dir = Path(tempfile.gettempdir())
|
|
14
|
+
tar_path = temp_dir / f"source_{os.urandom(4).hex()}.tar.gz"
|
|
15
|
+
|
|
16
|
+
# Basic ignore logic (should be improved to handle .gitignore properly)
|
|
17
|
+
default_ignore = {".git", ".venv", "__pycache__", ".pytest_cache", ".beamflow"}
|
|
18
|
+
if ignore_patterns:
|
|
19
|
+
default_ignore.update(ignore_patterns)
|
|
20
|
+
|
|
21
|
+
def is_ignored(path: Path) -> bool:
|
|
22
|
+
for part in path.parts:
|
|
23
|
+
if part in default_ignore:
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
with tarfile.open(tar_path, "w:gz") as tar:
|
|
28
|
+
for root, dirs, files in os.walk(directory):
|
|
29
|
+
root_path = Path(root)
|
|
30
|
+
if is_ignored(root_path.relative_to(directory)):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
for file in files:
|
|
34
|
+
file_path = root_path / file
|
|
35
|
+
if is_ignored(file_path.relative_to(directory)):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
tar.add(file_path, arcname=str(file_path.relative_to(directory)))
|
|
39
|
+
|
|
40
|
+
return tar_path
|