tetra-rp 0.6.0__py3-none-any.whl → 0.24.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 (97) hide show
  1. tetra_rp/__init__.py +109 -19
  2. tetra_rp/cli/commands/__init__.py +1 -0
  3. tetra_rp/cli/commands/apps.py +143 -0
  4. tetra_rp/cli/commands/build.py +1082 -0
  5. tetra_rp/cli/commands/build_utils/__init__.py +1 -0
  6. tetra_rp/cli/commands/build_utils/handler_generator.py +176 -0
  7. tetra_rp/cli/commands/build_utils/lb_handler_generator.py +309 -0
  8. tetra_rp/cli/commands/build_utils/manifest.py +430 -0
  9. tetra_rp/cli/commands/build_utils/mothership_handler_generator.py +75 -0
  10. tetra_rp/cli/commands/build_utils/scanner.py +596 -0
  11. tetra_rp/cli/commands/deploy.py +580 -0
  12. tetra_rp/cli/commands/init.py +123 -0
  13. tetra_rp/cli/commands/resource.py +108 -0
  14. tetra_rp/cli/commands/run.py +296 -0
  15. tetra_rp/cli/commands/test_mothership.py +458 -0
  16. tetra_rp/cli/commands/undeploy.py +533 -0
  17. tetra_rp/cli/main.py +97 -0
  18. tetra_rp/cli/utils/__init__.py +1 -0
  19. tetra_rp/cli/utils/app.py +15 -0
  20. tetra_rp/cli/utils/conda.py +127 -0
  21. tetra_rp/cli/utils/deployment.py +530 -0
  22. tetra_rp/cli/utils/ignore.py +143 -0
  23. tetra_rp/cli/utils/skeleton.py +184 -0
  24. tetra_rp/cli/utils/skeleton_template/.env.example +4 -0
  25. tetra_rp/cli/utils/skeleton_template/.flashignore +40 -0
  26. tetra_rp/cli/utils/skeleton_template/.gitignore +44 -0
  27. tetra_rp/cli/utils/skeleton_template/README.md +263 -0
  28. tetra_rp/cli/utils/skeleton_template/main.py +44 -0
  29. tetra_rp/cli/utils/skeleton_template/mothership.py +55 -0
  30. tetra_rp/cli/utils/skeleton_template/pyproject.toml +58 -0
  31. tetra_rp/cli/utils/skeleton_template/requirements.txt +1 -0
  32. tetra_rp/cli/utils/skeleton_template/workers/__init__.py +0 -0
  33. tetra_rp/cli/utils/skeleton_template/workers/cpu/__init__.py +19 -0
  34. tetra_rp/cli/utils/skeleton_template/workers/cpu/endpoint.py +36 -0
  35. tetra_rp/cli/utils/skeleton_template/workers/gpu/__init__.py +19 -0
  36. tetra_rp/cli/utils/skeleton_template/workers/gpu/endpoint.py +61 -0
  37. tetra_rp/client.py +136 -33
  38. tetra_rp/config.py +29 -0
  39. tetra_rp/core/api/runpod.py +591 -39
  40. tetra_rp/core/deployment.py +232 -0
  41. tetra_rp/core/discovery.py +425 -0
  42. tetra_rp/core/exceptions.py +50 -0
  43. tetra_rp/core/resources/__init__.py +27 -9
  44. tetra_rp/core/resources/app.py +738 -0
  45. tetra_rp/core/resources/base.py +139 -4
  46. tetra_rp/core/resources/constants.py +21 -0
  47. tetra_rp/core/resources/cpu.py +115 -13
  48. tetra_rp/core/resources/gpu.py +182 -16
  49. tetra_rp/core/resources/live_serverless.py +153 -16
  50. tetra_rp/core/resources/load_balancer_sls_resource.py +440 -0
  51. tetra_rp/core/resources/network_volume.py +126 -31
  52. tetra_rp/core/resources/resource_manager.py +436 -35
  53. tetra_rp/core/resources/serverless.py +537 -120
  54. tetra_rp/core/resources/serverless_cpu.py +201 -0
  55. tetra_rp/core/resources/template.py +1 -59
  56. tetra_rp/core/utils/constants.py +10 -0
  57. tetra_rp/core/utils/file_lock.py +260 -0
  58. tetra_rp/core/utils/http.py +67 -0
  59. tetra_rp/core/utils/lru_cache.py +75 -0
  60. tetra_rp/core/utils/singleton.py +36 -1
  61. tetra_rp/core/validation.py +44 -0
  62. tetra_rp/execute_class.py +301 -0
  63. tetra_rp/protos/remote_execution.py +98 -9
  64. tetra_rp/runtime/__init__.py +1 -0
  65. tetra_rp/runtime/circuit_breaker.py +274 -0
  66. tetra_rp/runtime/config.py +12 -0
  67. tetra_rp/runtime/exceptions.py +49 -0
  68. tetra_rp/runtime/generic_handler.py +206 -0
  69. tetra_rp/runtime/lb_handler.py +189 -0
  70. tetra_rp/runtime/load_balancer.py +160 -0
  71. tetra_rp/runtime/manifest_fetcher.py +192 -0
  72. tetra_rp/runtime/metrics.py +325 -0
  73. tetra_rp/runtime/models.py +73 -0
  74. tetra_rp/runtime/mothership_provisioner.py +512 -0
  75. tetra_rp/runtime/production_wrapper.py +266 -0
  76. tetra_rp/runtime/reliability_config.py +149 -0
  77. tetra_rp/runtime/retry_manager.py +118 -0
  78. tetra_rp/runtime/serialization.py +124 -0
  79. tetra_rp/runtime/service_registry.py +346 -0
  80. tetra_rp/runtime/state_manager_client.py +248 -0
  81. tetra_rp/stubs/live_serverless.py +35 -17
  82. tetra_rp/stubs/load_balancer_sls.py +357 -0
  83. tetra_rp/stubs/registry.py +145 -19
  84. {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/METADATA +398 -60
  85. tetra_rp-0.24.0.dist-info/RECORD +99 -0
  86. {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/WHEEL +1 -1
  87. tetra_rp-0.24.0.dist-info/entry_points.txt +2 -0
  88. tetra_rp/core/pool/cluster_manager.py +0 -177
  89. tetra_rp/core/pool/dataclass.py +0 -18
  90. tetra_rp/core/pool/ex.py +0 -38
  91. tetra_rp/core/pool/job.py +0 -22
  92. tetra_rp/core/pool/worker.py +0 -19
  93. tetra_rp/core/resources/utils.py +0 -50
  94. tetra_rp/core/utils/json.py +0 -33
  95. tetra_rp-0.6.0.dist-info/RECORD +0 -39
  96. /tetra_rp/{core/pool → cli}/__init__.py +0 -0
  97. {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,596 @@
1
+ """AST scanner for discovering @remote decorated functions and classes."""
2
+
3
+ import ast
4
+ import importlib
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class RemoteFunctionMetadata:
16
+ """Metadata about a @remote decorated function or class."""
17
+
18
+ function_name: str
19
+ module_path: str
20
+ resource_config_name: str
21
+ resource_type: str
22
+ is_async: bool
23
+ is_class: bool
24
+ file_path: Path
25
+ http_method: Optional[str] = None # HTTP method for LB endpoints: GET, POST, etc.
26
+ http_path: Optional[str] = None # HTTP path for LB endpoints: /api/process
27
+ is_load_balanced: bool = False # LoadBalancerSlsResource or LiveLoadBalancer
28
+ is_live_resource: bool = (
29
+ False # LiveLoadBalancer (vs deployed LoadBalancerSlsResource)
30
+ )
31
+ config_variable: Optional[str] = None # Variable name like "gpu_config"
32
+
33
+
34
+ class RemoteDecoratorScanner:
35
+ """Scans Python files for @remote decorators and extracts metadata."""
36
+
37
+ def __init__(self, project_dir: Path):
38
+ self.project_dir = project_dir
39
+ self.py_files: List[Path] = []
40
+ self.resource_configs: Dict[str, str] = {} # name -> name
41
+ self.resource_types: Dict[str, str] = {} # name -> type
42
+ self.resource_flags: Dict[str, Dict[str, bool]] = {} # name -> {flag: bool}
43
+ self.resource_variables: Dict[str, str] = {} # name -> variable_name
44
+
45
+ def discover_remote_functions(self) -> List[RemoteFunctionMetadata]:
46
+ """Discover all @remote decorated functions and classes."""
47
+ functions = []
48
+
49
+ # Find all Python files, excluding root-level directories that shouldn't be scanned
50
+ all_py_files = self.project_dir.rglob("*.py")
51
+ # Only exclude these directories if they're direct children of project_dir
52
+ excluded_root_dirs = {".venv", ".flash", ".runpod"}
53
+ self.py_files = []
54
+ for f in all_py_files:
55
+ try:
56
+ rel_path = f.relative_to(self.project_dir)
57
+ # Check if first part of path is in excluded_root_dirs
58
+ if rel_path.parts and rel_path.parts[0] not in excluded_root_dirs:
59
+ self.py_files.append(f)
60
+ except (ValueError, IndexError):
61
+ # Include files that can't be made relative
62
+ self.py_files.append(f)
63
+
64
+ # First pass: extract all resource configs from all files
65
+ for py_file in self.py_files:
66
+ try:
67
+ content = py_file.read_text(encoding="utf-8")
68
+ tree = ast.parse(content)
69
+ self._extract_resource_configs(tree, py_file)
70
+ except UnicodeDecodeError:
71
+ logger.debug(f"Skipping non-UTF-8 file: {py_file}")
72
+ except SyntaxError as e:
73
+ logger.warning(f"Syntax error in {py_file}: {e}")
74
+ except Exception as e:
75
+ logger.debug(f"Failed to parse {py_file}: {e}")
76
+
77
+ # Second pass: extract @remote decorated functions
78
+ for py_file in self.py_files:
79
+ try:
80
+ content = py_file.read_text(encoding="utf-8")
81
+ tree = ast.parse(content)
82
+ functions.extend(self._extract_remote_functions(tree, py_file))
83
+ except UnicodeDecodeError:
84
+ logger.debug(f"Skipping non-UTF-8 file: {py_file}")
85
+ except SyntaxError as e:
86
+ logger.warning(f"Syntax error in {py_file}: {e}")
87
+ except Exception as e:
88
+ logger.debug(f"Failed to parse {py_file}: {e}")
89
+
90
+ return functions
91
+
92
+ def _extract_resource_configs(self, tree: ast.AST, py_file: Path) -> None:
93
+ """Extract resource config variable assignments and determine type flags.
94
+
95
+ This method extracts resource configurations and determines is_load_balanced
96
+ and is_live_resource flags using string-based type matching.
97
+ """
98
+ module_path = self._get_module_path(py_file)
99
+
100
+ for node in ast.walk(tree):
101
+ if isinstance(node, ast.Assign):
102
+ # Look for assignments like: gpu_config = LiveServerless(...) or api = LiveLoadBalancer(...)
103
+ for target in node.targets:
104
+ if isinstance(target, ast.Name):
105
+ variable_name = target.id
106
+ config_type = self._get_call_type(node.value)
107
+
108
+ # Accept any class that looks like a resource config (DeployableResource)
109
+ if config_type and self._is_resource_config_type(config_type):
110
+ # Extract the resource's name parameter (the actual identifier)
111
+ # If extraction fails, fall back to variable name
112
+ resource_name = self._extract_resource_name(node.value)
113
+ if not resource_name:
114
+ resource_name = variable_name
115
+
116
+ # Store mapping using the resource's name (or variable name as fallback)
117
+ self.resource_configs[resource_name] = resource_name
118
+ self.resource_types[resource_name] = config_type
119
+
120
+ # Store variable name for test-mothership config discovery
121
+ self.resource_variables[resource_name] = variable_name
122
+
123
+ # Also store variable name mapping for local lookups in same module
124
+ var_key = f"{module_path}:{variable_name}"
125
+ self.resource_configs[var_key] = resource_name
126
+ self.resource_types[var_key] = config_type
127
+ self.resource_variables[var_key] = variable_name
128
+
129
+ # Determine boolean flags using string-based type checking
130
+ # This is determined by isinstance() at scan time in production,
131
+ # but we use string matching for reliability
132
+ is_load_balanced = config_type in [
133
+ "LoadBalancerSlsResource",
134
+ "LiveLoadBalancer",
135
+ "CpuLiveLoadBalancer",
136
+ ]
137
+ is_live_resource = config_type in [
138
+ "LiveLoadBalancer",
139
+ "CpuLiveLoadBalancer",
140
+ ]
141
+
142
+ # Store flags for this resource
143
+ self.resource_flags[resource_name] = {
144
+ "is_load_balanced": is_load_balanced,
145
+ "is_live_resource": is_live_resource,
146
+ }
147
+ # Also store for variable key
148
+ self.resource_flags[var_key] = {
149
+ "is_load_balanced": is_load_balanced,
150
+ "is_live_resource": is_live_resource,
151
+ }
152
+
153
+ def _extract_remote_functions(
154
+ self, tree: ast.AST, py_file: Path
155
+ ) -> List[RemoteFunctionMetadata]:
156
+ """Extract @remote decorated functions and classes."""
157
+ module_path = self._get_module_path(py_file)
158
+ functions = []
159
+
160
+ for node in ast.walk(tree):
161
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
162
+ # Check if this node has @remote decorator
163
+ remote_decorator = self._find_remote_decorator(node.decorator_list)
164
+
165
+ if remote_decorator:
166
+ # Extract resource config name from decorator
167
+ resource_config_name = self._extract_resource_config_name(
168
+ remote_decorator, module_path
169
+ )
170
+
171
+ if resource_config_name:
172
+ is_async = isinstance(node, ast.AsyncFunctionDef)
173
+ is_class = isinstance(node, ast.ClassDef)
174
+
175
+ # Get resource type for this config
176
+ resource_type = self._get_resource_type(resource_config_name)
177
+
178
+ # Extract HTTP routing metadata (for LB endpoints)
179
+ http_method, http_path = self._extract_http_routing(
180
+ remote_decorator
181
+ )
182
+
183
+ # Get flags for this resource
184
+ flags = self.resource_flags.get(
185
+ resource_config_name,
186
+ {"is_load_balanced": False, "is_live_resource": False},
187
+ )
188
+
189
+ metadata = RemoteFunctionMetadata(
190
+ function_name=node.name,
191
+ module_path=module_path,
192
+ resource_config_name=resource_config_name,
193
+ resource_type=resource_type,
194
+ is_async=is_async,
195
+ is_class=is_class,
196
+ file_path=py_file,
197
+ http_method=http_method,
198
+ http_path=http_path,
199
+ is_load_balanced=flags["is_load_balanced"],
200
+ is_live_resource=flags["is_live_resource"],
201
+ config_variable=self.resource_variables.get(
202
+ resource_config_name
203
+ ),
204
+ )
205
+ functions.append(metadata)
206
+
207
+ return functions
208
+
209
+ def _find_remote_decorator(self, decorators: List[ast.expr]) -> Optional[ast.expr]:
210
+ """Find @remote decorator in a list of decorators."""
211
+ for decorator in decorators:
212
+ # Handle @remote or @remote(...)
213
+ if isinstance(decorator, ast.Name):
214
+ if decorator.id == "remote":
215
+ return decorator
216
+ elif isinstance(decorator, ast.Call):
217
+ if isinstance(decorator.func, ast.Name):
218
+ if decorator.func.id == "remote":
219
+ return decorator
220
+ elif isinstance(decorator.func, ast.Attribute):
221
+ if decorator.func.attr == "remote":
222
+ return decorator
223
+
224
+ return None
225
+
226
+ def _extract_resource_config_name(
227
+ self, decorator: ast.expr, module_path: str
228
+ ) -> Optional[str]:
229
+ """Extract resource_config name from @remote decorator."""
230
+ if isinstance(decorator, ast.Name):
231
+ # @remote without arguments
232
+ return None
233
+
234
+ if isinstance(decorator, ast.Call):
235
+ # @remote(...) with arguments
236
+ # Look for resource_config= or first positional arg
237
+ for keyword in decorator.keywords:
238
+ if keyword.arg == "resource_config":
239
+ return self._extract_name_from_expr(keyword.value, module_path)
240
+
241
+ # Try first positional argument
242
+ if decorator.args:
243
+ return self._extract_name_from_expr(decorator.args[0], module_path)
244
+
245
+ return None
246
+
247
+ def _extract_name_from_expr(
248
+ self, expr: ast.expr, module_path: str
249
+ ) -> Optional[str]:
250
+ """Extract config name from an expression (Name or Call).
251
+
252
+ Returns the resource's name (from the name= parameter), not the variable name.
253
+ """
254
+ if isinstance(expr, ast.Name):
255
+ # Variable reference: @remote(gpu_config)
256
+ variable_name = expr.id
257
+
258
+ # Try module-scoped lookup first (current module)
259
+ var_key = f"{module_path}:{variable_name}"
260
+ if var_key in self.resource_configs:
261
+ # Return the actual resource name (mapped from variable)
262
+ return self.resource_configs[var_key]
263
+
264
+ # Try simple name lookup
265
+ if variable_name in self.resource_configs:
266
+ return self.resource_configs[variable_name]
267
+
268
+ # Fall back to the variable name itself (unresolved reference)
269
+ return variable_name
270
+
271
+ elif isinstance(expr, ast.Call):
272
+ # Direct instantiation: @remote(LiveServerless(name="gpu_config"))
273
+ # Extract the name= parameter
274
+ resource_name = self._extract_resource_name(expr)
275
+ if resource_name:
276
+ return resource_name
277
+
278
+ return None
279
+
280
+ def _is_resource_config_type(self, type_name: str) -> bool:
281
+ """Check if a type represents a ServerlessResource subclass.
282
+
283
+ Returns True only if the class can be imported and is a ServerlessResource.
284
+ """
285
+ from tetra_rp.core.resources.serverless import ServerlessResource
286
+
287
+ try:
288
+ module = importlib.import_module("tetra_rp")
289
+ if hasattr(module, type_name):
290
+ cls = getattr(module, type_name)
291
+ return isinstance(cls, type) and issubclass(cls, ServerlessResource)
292
+ except (ImportError, AttributeError, TypeError):
293
+ pass
294
+
295
+ return False
296
+
297
+ def _get_call_type(self, expr: ast.expr) -> Optional[str]:
298
+ """Get the type name of a call expression."""
299
+ if isinstance(expr, ast.Call):
300
+ if isinstance(expr.func, ast.Name):
301
+ return expr.func.id
302
+ elif isinstance(expr.func, ast.Attribute):
303
+ return expr.func.attr
304
+
305
+ return None
306
+
307
+ def _extract_resource_name(self, expr: ast.expr) -> Optional[str]:
308
+ """Extract the 'name' parameter from a resource config instantiation.
309
+
310
+ For example, from LiveServerless(name="01_01_gpu_worker", ...)
311
+ returns "01_01_gpu_worker".
312
+ """
313
+ if isinstance(expr, ast.Call):
314
+ for keyword in expr.keywords:
315
+ if keyword.arg == "name":
316
+ if isinstance(keyword.value, ast.Constant):
317
+ return keyword.value.value
318
+ return None
319
+
320
+ def _get_resource_type(self, resource_config_name: str) -> str:
321
+ """Get the resource type for a given config name."""
322
+ if resource_config_name in self.resource_types:
323
+ return self.resource_types[resource_config_name]
324
+ # Default to LiveServerless if type not found
325
+ return "LiveServerless"
326
+
327
+ def _sanitize_resource_name(self, name: str) -> str:
328
+ """Sanitize resource config name for use in filenames.
329
+
330
+ Replaces invalid filename characters with underscores and ensures
331
+ the name starts with a letter or underscore (valid for Python identifiers).
332
+
333
+ Args:
334
+ name: Raw resource config name
335
+
336
+ Returns:
337
+ Sanitized name safe for use in filenames and as Python identifiers
338
+ """
339
+ # Replace invalid characters with underscores
340
+ sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", name)
341
+
342
+ # Ensure it starts with a letter or underscore
343
+ if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"):
344
+ sanitized = f"_{sanitized}"
345
+
346
+ return sanitized or "_"
347
+
348
+ def _get_module_path(self, py_file: Path) -> str:
349
+ """Convert file path to module path."""
350
+ try:
351
+ # Get relative path from project directory
352
+ rel_path = py_file.relative_to(self.project_dir)
353
+
354
+ # Remove .py extension and convert / to .
355
+ module = str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
356
+
357
+ return module
358
+ except ValueError:
359
+ # If relative_to fails, just use filename
360
+ return py_file.stem
361
+
362
+ def _extract_http_routing(
363
+ self, decorator: ast.expr
364
+ ) -> tuple[Optional[str], Optional[str]]:
365
+ """Extract HTTP method and path from @remote decorator.
366
+
367
+ Returns:
368
+ Tuple of (method, path) or (None, None) if not found.
369
+ method: GET, POST, PUT, DELETE, PATCH
370
+ path: /api/endpoint routes
371
+
372
+ Raises:
373
+ ValueError: If method is not a valid HTTP verb
374
+ """
375
+ if not isinstance(decorator, ast.Call):
376
+ return None, None
377
+
378
+ http_method = None
379
+ http_path = None
380
+
381
+ # Extract keyword arguments: method="POST", path="/api/process"
382
+ for keyword in decorator.keywords:
383
+ if keyword.arg == "method":
384
+ if isinstance(keyword.value, ast.Constant):
385
+ http_method = keyword.value.value
386
+ elif keyword.arg == "path":
387
+ if isinstance(keyword.value, ast.Constant):
388
+ http_path = keyword.value.value
389
+
390
+ # Validate HTTP method if provided
391
+ valid_methods = {"GET", "POST", "PUT", "DELETE", "PATCH"}
392
+ if http_method is not None and http_method.upper() not in valid_methods:
393
+ raise ValueError(
394
+ f"Invalid HTTP method '{http_method}'. Must be one of: {', '.join(valid_methods)}"
395
+ )
396
+
397
+ return http_method, http_path
398
+
399
+
400
+ def detect_main_app(
401
+ project_root: Path, explicit_mothership_exists: bool = False
402
+ ) -> Optional[dict]:
403
+ """Detect main.py FastAPI app and return mothership config.
404
+
405
+ Searches for main.py/app.py/server.py and parses AST to find FastAPI app.
406
+ Only returns config if app has custom routes (not just @remote calls).
407
+
408
+ Args:
409
+ project_root: Root directory of Flash project
410
+ explicit_mothership_exists: If True, skip auto-detection (explicit config takes precedence)
411
+
412
+ Returns:
413
+ Dict with app metadata: {
414
+ 'file_path': Path,
415
+ 'app_variable': str,
416
+ 'has_routes': bool,
417
+ }
418
+ Returns None if no FastAPI app found with custom routes or explicit_mothership_exists is True.
419
+ """
420
+ if explicit_mothership_exists:
421
+ # Explicit mothership config exists, skip auto-detection
422
+ return None
423
+ for filename in ["main.py", "app.py", "server.py"]:
424
+ main_path = project_root / filename
425
+ if not main_path.exists():
426
+ continue
427
+
428
+ try:
429
+ content = main_path.read_text(encoding="utf-8")
430
+ tree = ast.parse(content)
431
+
432
+ # Find FastAPI app instantiation
433
+ for node in ast.walk(tree):
434
+ if isinstance(node, ast.Assign):
435
+ if isinstance(node.value, ast.Call):
436
+ call_type = None
437
+ if isinstance(node.value.func, ast.Name):
438
+ call_type = node.value.func.id
439
+ elif isinstance(node.value.func, ast.Attribute):
440
+ call_type = node.value.func.attr
441
+
442
+ if call_type == "FastAPI":
443
+ app_variable = None
444
+ for target in node.targets:
445
+ if isinstance(target, ast.Name):
446
+ app_variable = target.id
447
+ break
448
+
449
+ if app_variable:
450
+ # Check for custom routes (not just @remote)
451
+ has_routes = _has_custom_routes(tree, app_variable)
452
+
453
+ return {
454
+ "file_path": main_path,
455
+ "app_variable": app_variable,
456
+ "has_routes": has_routes,
457
+ }
458
+ except UnicodeDecodeError:
459
+ logger.debug(f"Skipping non-UTF-8 file: {main_path}")
460
+ except SyntaxError as e:
461
+ logger.debug(f"Syntax error in {main_path}: {e}")
462
+ except Exception as e:
463
+ logger.debug(f"Failed to parse {main_path}: {e}")
464
+
465
+ return None
466
+
467
+
468
+ def _has_custom_routes(tree: ast.AST, app_variable: str) -> bool:
469
+ """Check if FastAPI app has custom routes (beyond @remote).
470
+
471
+ Args:
472
+ tree: AST tree of the file
473
+ app_variable: Name of the FastAPI app variable
474
+
475
+ Returns:
476
+ True if app has route decorators (app.get, app.post, etc.)
477
+ """
478
+ for node in ast.walk(tree):
479
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
480
+ for decorator in node.decorator_list:
481
+ # Look for app.get(), app.post(), app.put(), etc.
482
+ if isinstance(decorator, ast.Call):
483
+ if isinstance(decorator.func, ast.Attribute):
484
+ if (
485
+ isinstance(decorator.func.value, ast.Name)
486
+ and decorator.func.value.id == app_variable
487
+ and decorator.func.attr
488
+ in ["get", "post", "put", "delete", "patch"]
489
+ ):
490
+ return True
491
+ # Also check for @app.get without parentheses (decorator without Call)
492
+ elif isinstance(decorator, ast.Attribute):
493
+ if (
494
+ isinstance(decorator.value, ast.Name)
495
+ and decorator.value.id == app_variable
496
+ and decorator.attr in ["get", "post", "put", "delete", "patch"]
497
+ ):
498
+ return True
499
+
500
+ return False
501
+
502
+
503
+ def detect_explicit_mothership(project_root: Path) -> Optional[Dict]:
504
+ """Detect explicitly configured mothership resource in mothership.py.
505
+
506
+ Parses mothership.py to extract resource configuration.
507
+
508
+ Args:
509
+ project_root: Root directory of Flash project
510
+
511
+ Returns:
512
+ Dict with mothership config if found:
513
+ {
514
+ 'resource_type': str (e.g., 'CpuLiveLoadBalancer'),
515
+ 'name': str,
516
+ 'workersMin': int,
517
+ 'workersMax': int,
518
+ 'is_explicit': bool,
519
+ }
520
+ Returns None if mothership.py doesn't exist or can't be parsed.
521
+ """
522
+ mothership_file = project_root / "mothership.py"
523
+
524
+ if not mothership_file.exists():
525
+ return None
526
+
527
+ try:
528
+ content = mothership_file.read_text(encoding="utf-8")
529
+ tree = ast.parse(content)
530
+
531
+ # Look for variable assignment: mothership = SomeResource(...)
532
+ for node in ast.walk(tree):
533
+ if isinstance(node, ast.Assign):
534
+ for target in node.targets:
535
+ if isinstance(target, ast.Name) and target.id == "mothership":
536
+ # Found mothership variable assignment
537
+ if isinstance(node.value, ast.Call):
538
+ resource_type = _extract_resource_type(node.value)
539
+ kwargs = _extract_call_kwargs(node.value)
540
+
541
+ return {
542
+ "resource_type": resource_type,
543
+ "name": kwargs.get("name", "mothership"),
544
+ "workersMin": kwargs.get("workersMin", 1),
545
+ "workersMax": kwargs.get("workersMax", 3),
546
+ "is_explicit": True,
547
+ }
548
+
549
+ return None
550
+
551
+ except UnicodeDecodeError:
552
+ logger.debug(f"Skipping non-UTF-8 file: {mothership_file}")
553
+ return None
554
+ except SyntaxError as e:
555
+ logger.debug(f"Syntax error in mothership.py: {e}")
556
+ return None
557
+ except Exception as e:
558
+ logger.debug(f"Failed to parse mothership.py: {e}")
559
+ return None
560
+
561
+
562
+ def _extract_resource_type(call_node: ast.Call) -> str:
563
+ """Extract resource type from Call node.
564
+
565
+ Args:
566
+ call_node: AST Call node representing resource instantiation
567
+
568
+ Returns:
569
+ Resource type name (e.g., 'CpuLiveLoadBalancer'), or default if not found
570
+ """
571
+ if isinstance(call_node.func, ast.Name):
572
+ return call_node.func.id
573
+ elif isinstance(call_node.func, ast.Attribute):
574
+ return call_node.func.attr
575
+ return "CpuLiveLoadBalancer" # Default
576
+
577
+
578
+ def _extract_call_kwargs(call_node: ast.Call) -> Dict:
579
+ """Extract keyword arguments from Call node.
580
+
581
+ Args:
582
+ call_node: AST Call node
583
+
584
+ Returns:
585
+ Dict of keyword arguments with evaluated values (numbers, strings)
586
+ """
587
+ kwargs = {}
588
+ for keyword in call_node.keywords:
589
+ if keyword.arg:
590
+ try:
591
+ # Try to evaluate simple literal values
592
+ kwargs[keyword.arg] = ast.literal_eval(keyword.value)
593
+ except (ValueError, SyntaxError, TypeError):
594
+ # Skip non-literal arguments
595
+ pass
596
+ return kwargs