tetra-rp 0.13.0__py3-none-any.whl → 0.14.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.

Potentially problematic release.


This version of tetra-rp might be problematic. Click here for more details.

@@ -0,0 +1,501 @@
1
+ """Flash build command - Package Flash applications for deployment."""
2
+
3
+ import ast
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tarfile
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn
14
+ from rich.table import Table
15
+
16
+ from ..utils.ignore import get_file_tree, load_ignore_patterns
17
+
18
+ console = Console()
19
+
20
+ # Constants
21
+ PIP_INSTALL_TIMEOUT_SECONDS = 600 # 10 minute timeout for pip install
22
+
23
+
24
+ def build_command(
25
+ no_deps: bool = typer.Option(
26
+ False, "--no-deps", help="Skip transitive dependencies during pip install"
27
+ ),
28
+ keep_build: bool = typer.Option(
29
+ False, "--keep-build", help="Keep .build directory after creating archive"
30
+ ),
31
+ output_name: str | None = typer.Option(
32
+ None, "--output", "-o", help="Custom archive name (default: archive.tar.gz)"
33
+ ),
34
+ ):
35
+ """
36
+ Build Flash application for deployment.
37
+
38
+ Packages the application code and dependencies into a self-contained tarball,
39
+ similar to AWS Lambda packaging. All pip packages are installed as local modules.
40
+
41
+ Examples:
42
+ flash build # Build with all dependencies
43
+ flash build --no-deps # Skip transitive dependencies
44
+ flash build --keep-build # Keep temporary build directory
45
+ flash build -o my-app.tar.gz # Custom archive name
46
+ """
47
+ try:
48
+ # Validate project structure
49
+ project_dir, app_name = discover_flash_project()
50
+
51
+ if not validate_project_structure(project_dir):
52
+ console.print("[red]Error:[/red] Not a valid Flash project")
53
+ console.print("Run [bold]flash init[/bold] to create a Flash project")
54
+ raise typer.Exit(1)
55
+
56
+ # Display configuration
57
+ _display_build_config(project_dir, app_name, no_deps, keep_build, output_name)
58
+
59
+ # Execute build
60
+ with Progress(
61
+ SpinnerColumn(),
62
+ TextColumn("[progress.description]{task.description}"),
63
+ console=console,
64
+ ) as progress:
65
+ # Load ignore patterns
66
+ ignore_task = progress.add_task("Loading ignore patterns...")
67
+ spec = load_ignore_patterns(project_dir)
68
+ progress.update(ignore_task, description="[green]✓ Loaded ignore patterns")
69
+ progress.stop_task(ignore_task)
70
+
71
+ # Collect files
72
+ collect_task = progress.add_task("Collecting project files...")
73
+ files = get_file_tree(project_dir, spec)
74
+ progress.update(
75
+ collect_task,
76
+ description=f"[green]✓ Found {len(files)} files to package",
77
+ )
78
+ progress.stop_task(collect_task)
79
+
80
+ # Create build directory
81
+ build_task = progress.add_task("Creating build directory...")
82
+ build_dir = create_build_directory(project_dir, app_name)
83
+ progress.update(
84
+ build_task,
85
+ description=f"[green]✓ Created .tetra/.build/{app_name}/",
86
+ )
87
+ progress.stop_task(build_task)
88
+
89
+ # Copy files
90
+ copy_task = progress.add_task("Copying project files...")
91
+ copy_project_files(files, project_dir, build_dir)
92
+ progress.update(
93
+ copy_task, description=f"[green]✓ Copied {len(files)} files"
94
+ )
95
+ progress.stop_task(copy_task)
96
+
97
+ # Install dependencies
98
+ deps_task = progress.add_task("Installing dependencies...")
99
+ requirements = collect_requirements(project_dir, build_dir)
100
+
101
+ if not requirements:
102
+ progress.update(
103
+ deps_task,
104
+ description="[yellow]⚠ No dependencies found",
105
+ )
106
+ else:
107
+ progress.update(
108
+ deps_task,
109
+ description=f"Installing {len(requirements)} packages...",
110
+ )
111
+
112
+ success = install_dependencies(build_dir, requirements, no_deps)
113
+
114
+ if not success:
115
+ progress.stop_task(deps_task)
116
+ console.print("[red]Error:[/red] Failed to install dependencies")
117
+ raise typer.Exit(1)
118
+
119
+ progress.update(
120
+ deps_task,
121
+ description=f"[green]✓ Installed {len(requirements)} packages",
122
+ )
123
+
124
+ progress.stop_task(deps_task)
125
+
126
+ # Create archive
127
+ archive_task = progress.add_task("Creating archive...")
128
+ archive_name = output_name or "archive.tar.gz"
129
+ archive_path = project_dir / ".tetra" / archive_name
130
+
131
+ create_tarball(build_dir, archive_path, app_name)
132
+
133
+ # Get archive size
134
+ size_mb = archive_path.stat().st_size / (1024 * 1024)
135
+
136
+ progress.update(
137
+ archive_task,
138
+ description=f"[green]✓ Created {archive_name} ({size_mb:.1f} MB)",
139
+ )
140
+ progress.stop_task(archive_task)
141
+
142
+ # Cleanup
143
+ if not keep_build:
144
+ cleanup_task = progress.add_task("Cleaning up...")
145
+ cleanup_build_directory(build_dir.parent)
146
+ progress.update(
147
+ cleanup_task, description="[green]✓ Removed .build directory"
148
+ )
149
+ progress.stop_task(cleanup_task)
150
+
151
+ # Success summary
152
+ _display_build_summary(archive_path, app_name, len(files), len(requirements))
153
+
154
+ except KeyboardInterrupt:
155
+ console.print("\n[yellow]Build cancelled by user[/yellow]")
156
+ raise typer.Exit(1)
157
+ except Exception as e:
158
+ console.print(f"\n[red]Build failed:[/red] {e}")
159
+ import traceback
160
+
161
+ console.print(traceback.format_exc())
162
+ raise typer.Exit(1)
163
+
164
+
165
+ def discover_flash_project() -> tuple[Path, str]:
166
+ """
167
+ Discover Flash project directory and app name.
168
+
169
+ Returns:
170
+ Tuple of (project_dir, app_name)
171
+
172
+ Raises:
173
+ typer.Exit: If not in a Flash project directory
174
+ """
175
+ project_dir = Path.cwd()
176
+ app_name = project_dir.name
177
+
178
+ return project_dir, app_name
179
+
180
+
181
+ def validate_project_structure(project_dir: Path) -> bool:
182
+ """
183
+ Validate that directory is a Flash project.
184
+
185
+ Args:
186
+ project_dir: Directory to validate
187
+
188
+ Returns:
189
+ True if valid Flash project
190
+ """
191
+ main_py = project_dir / "main.py"
192
+
193
+ if not main_py.exists():
194
+ console.print(f"[red]Error:[/red] main.py not found in {project_dir}")
195
+ return False
196
+
197
+ # Check if main.py has FastAPI app
198
+ try:
199
+ content = main_py.read_text(encoding="utf-8")
200
+ if "FastAPI" not in content:
201
+ console.print(
202
+ "[yellow]Warning:[/yellow] main.py does not appear to have a FastAPI app"
203
+ )
204
+ except Exception:
205
+ pass
206
+
207
+ return True
208
+
209
+
210
+ def create_build_directory(project_dir: Path, app_name: str) -> Path:
211
+ """
212
+ Create .tetra/.build/{app_name}/ directory.
213
+
214
+ Args:
215
+ project_dir: Flash project directory
216
+ app_name: Application name
217
+
218
+ Returns:
219
+ Path to build directory
220
+ """
221
+ tetra_dir = project_dir / ".tetra"
222
+ tetra_dir.mkdir(exist_ok=True)
223
+
224
+ build_base = tetra_dir / ".build"
225
+ build_dir = build_base / app_name
226
+
227
+ # Remove existing build directory
228
+ if build_dir.exists():
229
+ shutil.rmtree(build_dir)
230
+
231
+ build_dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ return build_dir
234
+
235
+
236
+ def copy_project_files(files: list[Path], source_dir: Path, dest_dir: Path) -> None:
237
+ """
238
+ Copy project files to build directory.
239
+
240
+ Args:
241
+ files: List of files to copy
242
+ source_dir: Source directory
243
+ dest_dir: Destination directory
244
+ """
245
+ for file_path in files:
246
+ # Get relative path
247
+ rel_path = file_path.relative_to(source_dir)
248
+
249
+ # Create destination path
250
+ dest_path = dest_dir / rel_path
251
+
252
+ # Create parent directories
253
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
254
+
255
+ # Copy file
256
+ shutil.copy2(file_path, dest_path)
257
+
258
+
259
+ def collect_requirements(project_dir: Path, build_dir: Path) -> list[str]:
260
+ """
261
+ Collect all requirements from requirements.txt and @remote decorators.
262
+
263
+ Args:
264
+ project_dir: Flash project directory
265
+ build_dir: Build directory (to check for workers)
266
+
267
+ Returns:
268
+ List of requirement strings
269
+ """
270
+ requirements = []
271
+
272
+ # Load requirements.txt
273
+ req_file = project_dir / "requirements.txt"
274
+ if req_file.exists():
275
+ try:
276
+ content = req_file.read_text(encoding="utf-8")
277
+ for line in content.splitlines():
278
+ line = line.strip()
279
+ # Skip empty lines and comments
280
+ if line and not line.startswith("#"):
281
+ requirements.append(line)
282
+ except Exception as e:
283
+ console.print(
284
+ f"[yellow]Warning:[/yellow] Failed to read requirements.txt: {e}"
285
+ )
286
+
287
+ # Extract dependencies from @remote decorators
288
+ workers_dir = build_dir / "workers"
289
+ if workers_dir.exists():
290
+ remote_deps = extract_remote_dependencies(workers_dir)
291
+ requirements.extend(remote_deps)
292
+
293
+ # Remove duplicates while preserving order
294
+ seen = set()
295
+ unique_requirements = []
296
+ for req in requirements:
297
+ if req not in seen:
298
+ seen.add(req)
299
+ unique_requirements.append(req)
300
+
301
+ return unique_requirements
302
+
303
+
304
+ def extract_remote_dependencies(workers_dir: Path) -> list[str]:
305
+ """
306
+ Extract dependencies from @remote decorators in worker files.
307
+
308
+ Args:
309
+ workers_dir: Path to workers directory
310
+
311
+ Returns:
312
+ List of dependency strings
313
+ """
314
+ dependencies = []
315
+
316
+ for py_file in workers_dir.glob("*.py"):
317
+ if py_file.name == "__init__.py":
318
+ continue
319
+
320
+ try:
321
+ tree = ast.parse(py_file.read_text(encoding="utf-8"))
322
+
323
+ for node in ast.walk(tree):
324
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef)):
325
+ for decorator in node.decorator_list:
326
+ if isinstance(decorator, ast.Call):
327
+ func_name = None
328
+ if isinstance(decorator.func, ast.Name):
329
+ func_name = decorator.func.id
330
+ elif isinstance(decorator.func, ast.Attribute):
331
+ func_name = decorator.func.attr
332
+
333
+ if func_name == "remote":
334
+ # Extract dependencies keyword argument
335
+ for keyword in decorator.keywords:
336
+ if keyword.arg == "dependencies":
337
+ if isinstance(keyword.value, ast.List):
338
+ for elt in keyword.value.elts:
339
+ if isinstance(elt, ast.Constant):
340
+ dependencies.append(elt.value)
341
+
342
+ except Exception as e:
343
+ console.print(
344
+ f"[yellow]Warning:[/yellow] Failed to parse {py_file.name}: {e}"
345
+ )
346
+
347
+ return dependencies
348
+
349
+
350
+ def install_dependencies(
351
+ build_dir: Path, requirements: list[str], no_deps: bool
352
+ ) -> bool:
353
+ """
354
+ Install dependencies to build directory using pip.
355
+
356
+ Args:
357
+ build_dir: Build directory (pip --target)
358
+ requirements: List of requirements to install
359
+ no_deps: If True, skip transitive dependencies
360
+
361
+ Returns:
362
+ True if successful
363
+ """
364
+ if not requirements:
365
+ return True
366
+
367
+ # Use only sys.executable -m pip to ensure correct environment
368
+ pip_cmd = [sys.executable, "-m", "pip"]
369
+
370
+ # Check if pip is available
371
+ try:
372
+ result = subprocess.run(
373
+ pip_cmd + ["--version"],
374
+ capture_output=True,
375
+ timeout=5,
376
+ )
377
+ if result.returncode != 0:
378
+ console.print(
379
+ "[red]Error:[/red] pip not found in current Python environment"
380
+ )
381
+ return False
382
+ except (subprocess.SubprocessError, FileNotFoundError):
383
+ console.print("[red]Error:[/red] pip not found in current Python environment")
384
+ return False
385
+
386
+ cmd = pip_cmd + [
387
+ "install",
388
+ "--target",
389
+ str(build_dir),
390
+ "--upgrade",
391
+ ]
392
+
393
+ if no_deps:
394
+ cmd.append("--no-deps")
395
+
396
+ cmd.extend(requirements)
397
+
398
+ try:
399
+ result = subprocess.run(
400
+ cmd,
401
+ capture_output=True,
402
+ text=True,
403
+ timeout=PIP_INSTALL_TIMEOUT_SECONDS,
404
+ )
405
+
406
+ if result.returncode != 0:
407
+ console.print(f"[red]pip install failed:[/red]\n{result.stderr}")
408
+ return False
409
+
410
+ return True
411
+
412
+ except subprocess.TimeoutExpired:
413
+ console.print(
414
+ f"[red]pip install timed out ({PIP_INSTALL_TIMEOUT_SECONDS} seconds)[/red]"
415
+ )
416
+ return False
417
+ except Exception as e:
418
+ console.print(f"[red]pip install error:[/red] {e}")
419
+ return False
420
+
421
+
422
+ def create_tarball(build_dir: Path, output_path: Path, app_name: str) -> None:
423
+ """
424
+ Create gzipped tarball of build directory.
425
+
426
+ Args:
427
+ build_dir: Build directory to archive
428
+ output_path: Output archive path
429
+ app_name: Application name (used as archive root)
430
+ """
431
+ # Remove existing archive
432
+ if output_path.exists():
433
+ output_path.unlink()
434
+
435
+ # Create tarball with app_name as root directory
436
+ with tarfile.open(output_path, "w:gz") as tar:
437
+ tar.add(build_dir, arcname=app_name)
438
+
439
+
440
+ def cleanup_build_directory(build_base: Path) -> None:
441
+ """
442
+ Remove build directory.
443
+
444
+ Args:
445
+ build_base: .build directory to remove
446
+ """
447
+ if build_base.exists():
448
+ shutil.rmtree(build_base)
449
+
450
+
451
+ def _display_build_config(
452
+ project_dir: Path,
453
+ app_name: str,
454
+ no_deps: bool,
455
+ keep_build: bool,
456
+ output_name: str | None,
457
+ ):
458
+ """Display build configuration."""
459
+ archive_name = output_name or "archive.tar.gz"
460
+
461
+ console.print(
462
+ Panel(
463
+ f"[bold]Project:[/bold] {app_name}\n"
464
+ f"[bold]Directory:[/bold] {project_dir}\n"
465
+ f"[bold]Archive:[/bold] .tetra/{archive_name}\n"
466
+ f"[bold]Skip transitive deps:[/bold] {no_deps}\n"
467
+ f"[bold]Keep build dir:[/bold] {keep_build}",
468
+ title="Flash Build Configuration",
469
+ expand=False,
470
+ )
471
+ )
472
+
473
+
474
+ def _display_build_summary(
475
+ archive_path: Path, app_name: str, file_count: int, dep_count: int
476
+ ):
477
+ """Display build summary."""
478
+ size_mb = archive_path.stat().st_size / (1024 * 1024)
479
+
480
+ summary = Table(show_header=False, box=None)
481
+ summary.add_column("Item", style="bold")
482
+ summary.add_column("Value", style="cyan")
483
+
484
+ summary.add_row("Application", app_name)
485
+ summary.add_row("Files packaged", str(file_count))
486
+ summary.add_row("Dependencies", str(dep_count))
487
+ summary.add_row("Archive", str(archive_path.relative_to(Path.cwd())))
488
+ summary.add_row("Size", f"{size_mb:.1f} MB")
489
+
490
+ console.print("\n")
491
+ console.print(summary)
492
+
493
+ console.print(
494
+ Panel(
495
+ f"[bold]{app_name}[/bold] built successfully!\n\n"
496
+ f"Archive ready for deployment.",
497
+ title="✓ Build Complete",
498
+ expand=False,
499
+ border_style="green",
500
+ )
501
+ )
@@ -1,77 +1,119 @@
1
1
  """Project initialization command."""
2
2
 
3
+ from pathlib import Path
4
+
3
5
  import typer
4
- from typing import Optional
5
6
  from rich.console import Console
6
7
  from rich.panel import Panel
7
8
  from rich.table import Table
8
- import questionary
9
9
 
10
- from tetra_rp.config import get_paths
11
- from ..utils.skeleton import create_project_skeleton, get_available_templates
10
+ from ..utils.skeleton import create_project_skeleton
11
+ from ..utils.conda import (
12
+ check_conda_available,
13
+ create_conda_environment,
14
+ install_packages_in_env,
15
+ environment_exists,
16
+ get_activation_command,
17
+ )
12
18
 
13
19
  console = Console()
14
20
 
21
+ # Required packages for flash run to work smoothly
22
+ REQUIRED_PACKAGES = [
23
+ "fastapi>=0.104.0",
24
+ "uvicorn[standard]>=0.24.0",
25
+ "python-dotenv>=1.0.0",
26
+ "pydantic>=2.0.0",
27
+ "aiohttp>=3.9.0",
28
+ ]
29
+
15
30
 
16
31
  def init_command(
17
- template: Optional[str] = typer.Option(
18
- None, "--template", "-t", help="Project template to use"
32
+ project_name: str = typer.Argument(..., help="Project name"),
33
+ no_env: bool = typer.Option(
34
+ False, "--no-env", help="Skip conda environment creation"
35
+ ),
36
+ force: bool = typer.Option(
37
+ False, "--force", "-f", help="Overwrite existing directory"
19
38
  ),
20
- force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
21
39
  ):
22
- """Create skeleton application with starter files."""
40
+ """Create new Flash project with Flash Server and GPU workers."""
23
41
 
24
- # Check if we're already in a Tetra project
25
- paths = get_paths()
26
- if paths.tetra_dir.exists() and not force:
27
- console.print("Already in a Tetra project directory")
28
- console.print("Use --force to overwrite existing configuration")
29
- raise typer.Exit(1)
42
+ # Create project directory
43
+ project_dir = Path(project_name)
30
44
 
31
- # Get available templates
32
- available_templates = get_available_templates()
33
-
34
- # Interactive template selection if not provided
35
- if not template:
36
- template_choices = []
37
- for name, info in available_templates.items():
38
- template_choices.append(f"{name} - {info['description']}")
39
-
40
- try:
41
- selected = questionary.select(
42
- "Choose a project template:", choices=template_choices
43
- ).ask()
44
-
45
- if not selected:
46
- console.print("Template selection cancelled")
47
- raise typer.Exit(1)
48
-
49
- template = selected.split(" - ")[0]
50
- except KeyboardInterrupt:
51
- console.print("\nTemplate selection cancelled")
52
- raise typer.Exit(1)
53
-
54
- # Validate template choice
55
- if template not in available_templates:
56
- console.print(f"Unknown template: {template}")
57
- console.print("Available templates:")
58
- for name, info in available_templates.items():
59
- console.print(f" • {name} - {info['description']}")
45
+ if project_dir.exists() and not force:
46
+ console.print(f"Directory '{project_name}' already exists")
47
+ console.print("Use --force to overwrite")
60
48
  raise typer.Exit(1)
61
49
 
62
- # Create project skeleton
63
- template_info = available_templates[template]
64
-
65
- with console.status(f"Creating project with {template} template..."):
66
- created_files = create_project_skeleton(template, template_info, force)
50
+ # Create project directory
51
+ project_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ with console.status(f"Creating Flash project '{project_name}'..."):
54
+ create_project_skeleton(project_dir, force)
55
+
56
+ # Create conda environment if requested
57
+ env_created = False
58
+ if not no_env:
59
+ if not check_conda_available():
60
+ console.print(
61
+ "[yellow]Warning: conda not found. Skipping environment creation.[/yellow]"
62
+ )
63
+ console.print(
64
+ "Install Miniconda or Anaconda, or use --no-env flag to skip this step."
65
+ )
66
+ else:
67
+ # Check if environment already exists
68
+ if environment_exists(project_name):
69
+ console.print(
70
+ f"[yellow]Conda environment '{project_name}' already exists. Skipping creation.[/yellow]"
71
+ )
72
+ env_created = True
73
+ else:
74
+ # Create conda environment
75
+ with console.status(f"Creating conda environment '{project_name}'..."):
76
+ success, message = create_conda_environment(project_name)
77
+
78
+ if not success:
79
+ console.print(f"[yellow]Warning: {message}[/yellow]")
80
+ console.print(
81
+ "You can manually create the environment and install dependencies."
82
+ )
83
+ else:
84
+ env_created = True
85
+
86
+ # Install required packages
87
+ with console.status("Installing dependencies..."):
88
+ success, message = install_packages_in_env(
89
+ project_name, REQUIRED_PACKAGES, use_pip=True
90
+ )
91
+
92
+ if not success:
93
+ console.print(f"[yellow]Warning: {message}[/yellow]")
94
+ console.print(
95
+ "You can manually install dependencies: pip install -r requirements.txt"
96
+ )
67
97
 
68
98
  # Success output
69
- panel_content = f"Project initialized with [bold]{template}[/bold] template\n\n"
70
- panel_content += "Created files:\n"
71
- for file_path in created_files:
72
- panel_content += f" {file_path}\n"
73
-
74
- console.print(Panel(panel_content, title="Project Initialized", expand=False))
99
+ panel_content = (
100
+ f"Flash project '[bold]{project_name}[/bold]' created successfully!\n\n"
101
+ )
102
+ panel_content += "Project structure:\n"
103
+ panel_content += f" {project_name}/\n"
104
+ panel_content += " ├── main.py # Flash Server (FastAPI)\n"
105
+ panel_content += " ├── workers/ # GPU workers\n"
106
+ panel_content += " │ └── example_worker.py\n"
107
+ panel_content += " ├── .env.example\n"
108
+ panel_content += " ├── requirements.txt\n"
109
+ panel_content += " └── README.md\n"
110
+
111
+ if env_created:
112
+ panel_content += (
113
+ f"\nConda environment '[bold]{project_name}[/bold]' created and configured"
114
+ )
115
+
116
+ console.print(Panel(panel_content, title="Project Created", expand=False))
75
117
 
76
118
  # Next steps
77
119
  console.print("\n[bold]Next steps:[/bold]")
@@ -79,8 +121,15 @@ def init_command(
79
121
  steps_table.add_column("Step", style="bold cyan")
80
122
  steps_table.add_column("Description")
81
123
 
82
- steps_table.add_row("1.", "Edit .env with your RunPod API key")
83
- steps_table.add_row("2.", "Install dependencies: pip install -r requirements.txt")
84
- steps_table.add_row("3.", "Run your project: flash run")
124
+ steps_table.add_row("1.", f"cd {project_name}")
125
+
126
+ if env_created:
127
+ steps_table.add_row("2.", f"{get_activation_command(project_name)}")
128
+ steps_table.add_row("3.", "cp .env.example .env # Add your RUNPOD_API_KEY")
129
+ steps_table.add_row("4.", "flash run")
130
+ else:
131
+ steps_table.add_row("2.", "pip install -r requirements.txt")
132
+ steps_table.add_row("3.", "cp .env.example .env # Add your RUNPOD_API_KEY")
133
+ steps_table.add_row("4.", "flash run")
85
134
 
86
135
  console.print(steps_table)