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.
Files changed (46) hide show
  1. beamflow/__init__.py +0 -0
  2. beamflow/commands/__init__.py +0 -0
  3. beamflow/commands/auth.py +111 -0
  4. beamflow/commands/build.py +127 -0
  5. beamflow/commands/deploy.py +104 -0
  6. beamflow/commands/project.py +451 -0
  7. beamflow/commands/run.py +65 -0
  8. beamflow/core/__init__.py +0 -0
  9. beamflow/core/api_client.py +62 -0
  10. beamflow/core/auth_server.py +36 -0
  11. beamflow/core/builder.py +40 -0
  12. beamflow/core/config.py +81 -0
  13. beamflow/core/docker_utils.py +33 -0
  14. beamflow/main.py +227 -0
  15. beamflow/templates/_.beamflow +4 -0
  16. beamflow/templates/_.dockerignore +9 -0
  17. beamflow/templates/_README.md +28 -0
  18. beamflow/templates/_api_main.py +19 -0
  19. beamflow/templates/_config/[env]/(backend-asyncio)/backend.yaml +4 -0
  20. beamflow/templates/_config/[env]/(backend-dramatiq)/backend.yaml +8 -0
  21. beamflow/templates/_config/[env]/(backend-managed)/backend.yaml +6 -0
  22. beamflow/templates/_config/[env]/_backend.yaml +4 -0
  23. beamflow/templates/_config/shared/backend.yaml +4 -0
  24. beamflow/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +34 -0
  25. beamflow/templates/_deployment/[env]/.env +3 -0
  26. beamflow/templates/_deployment/shared/.env +5 -0
  27. beamflow/templates/_deployment/shared/api.Dockerfile +10 -0
  28. beamflow/templates/_deployment/shared/clients/demoClient.yaml +39 -0
  29. beamflow/templates/_deployment/shared/worker.Dockerfile +9 -0
  30. beamflow/templates/_pyproject.toml +40 -0
  31. beamflow/templates/_src/_api/__init__.py +1 -0
  32. beamflow/templates/_src/_api/_routes/webhooks.py +25 -0
  33. beamflow/templates/_src/_shared/__init__.py +1 -0
  34. beamflow/templates/_src/_shared/clients/client.py +18 -0
  35. beamflow/templates/_src/_shared/models/models.py +1 -0
  36. beamflow/templates/_src/_shared/tasks/sharedTasks.py +10 -0
  37. beamflow/templates/_src/_worker/__init__.py +1 -0
  38. beamflow/templates/_src/_worker/tasks/tasks.py +15 -0
  39. beamflow/templates/_worker_main.py +25 -0
  40. beamflow/templates/tests/_test_config_loading.py +10 -0
  41. beamflow/templates/tests/_test_integration.py +20 -0
  42. beamflow/ui/__init__.py +0 -0
  43. beamflow_cli-0.3.0.dist-info/METADATA +22 -0
  44. beamflow_cli-0.3.0.dist-info/RECORD +46 -0
  45. beamflow_cli-0.3.0.dist-info/WHEEL +4 -0
  46. 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]")
@@ -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 {}
@@ -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