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.
- tetra_rp/__init__.py +109 -19
- tetra_rp/cli/commands/__init__.py +1 -0
- tetra_rp/cli/commands/apps.py +143 -0
- tetra_rp/cli/commands/build.py +1082 -0
- tetra_rp/cli/commands/build_utils/__init__.py +1 -0
- tetra_rp/cli/commands/build_utils/handler_generator.py +176 -0
- tetra_rp/cli/commands/build_utils/lb_handler_generator.py +309 -0
- tetra_rp/cli/commands/build_utils/manifest.py +430 -0
- tetra_rp/cli/commands/build_utils/mothership_handler_generator.py +75 -0
- tetra_rp/cli/commands/build_utils/scanner.py +596 -0
- tetra_rp/cli/commands/deploy.py +580 -0
- tetra_rp/cli/commands/init.py +123 -0
- tetra_rp/cli/commands/resource.py +108 -0
- tetra_rp/cli/commands/run.py +296 -0
- tetra_rp/cli/commands/test_mothership.py +458 -0
- tetra_rp/cli/commands/undeploy.py +533 -0
- tetra_rp/cli/main.py +97 -0
- tetra_rp/cli/utils/__init__.py +1 -0
- tetra_rp/cli/utils/app.py +15 -0
- tetra_rp/cli/utils/conda.py +127 -0
- tetra_rp/cli/utils/deployment.py +530 -0
- tetra_rp/cli/utils/ignore.py +143 -0
- tetra_rp/cli/utils/skeleton.py +184 -0
- tetra_rp/cli/utils/skeleton_template/.env.example +4 -0
- tetra_rp/cli/utils/skeleton_template/.flashignore +40 -0
- tetra_rp/cli/utils/skeleton_template/.gitignore +44 -0
- tetra_rp/cli/utils/skeleton_template/README.md +263 -0
- tetra_rp/cli/utils/skeleton_template/main.py +44 -0
- tetra_rp/cli/utils/skeleton_template/mothership.py +55 -0
- tetra_rp/cli/utils/skeleton_template/pyproject.toml +58 -0
- tetra_rp/cli/utils/skeleton_template/requirements.txt +1 -0
- tetra_rp/cli/utils/skeleton_template/workers/__init__.py +0 -0
- tetra_rp/cli/utils/skeleton_template/workers/cpu/__init__.py +19 -0
- tetra_rp/cli/utils/skeleton_template/workers/cpu/endpoint.py +36 -0
- tetra_rp/cli/utils/skeleton_template/workers/gpu/__init__.py +19 -0
- tetra_rp/cli/utils/skeleton_template/workers/gpu/endpoint.py +61 -0
- tetra_rp/client.py +136 -33
- tetra_rp/config.py +29 -0
- tetra_rp/core/api/runpod.py +591 -39
- tetra_rp/core/deployment.py +232 -0
- tetra_rp/core/discovery.py +425 -0
- tetra_rp/core/exceptions.py +50 -0
- tetra_rp/core/resources/__init__.py +27 -9
- tetra_rp/core/resources/app.py +738 -0
- tetra_rp/core/resources/base.py +139 -4
- tetra_rp/core/resources/constants.py +21 -0
- tetra_rp/core/resources/cpu.py +115 -13
- tetra_rp/core/resources/gpu.py +182 -16
- tetra_rp/core/resources/live_serverless.py +153 -16
- tetra_rp/core/resources/load_balancer_sls_resource.py +440 -0
- tetra_rp/core/resources/network_volume.py +126 -31
- tetra_rp/core/resources/resource_manager.py +436 -35
- tetra_rp/core/resources/serverless.py +537 -120
- tetra_rp/core/resources/serverless_cpu.py +201 -0
- tetra_rp/core/resources/template.py +1 -59
- tetra_rp/core/utils/constants.py +10 -0
- tetra_rp/core/utils/file_lock.py +260 -0
- tetra_rp/core/utils/http.py +67 -0
- tetra_rp/core/utils/lru_cache.py +75 -0
- tetra_rp/core/utils/singleton.py +36 -1
- tetra_rp/core/validation.py +44 -0
- tetra_rp/execute_class.py +301 -0
- tetra_rp/protos/remote_execution.py +98 -9
- tetra_rp/runtime/__init__.py +1 -0
- tetra_rp/runtime/circuit_breaker.py +274 -0
- tetra_rp/runtime/config.py +12 -0
- tetra_rp/runtime/exceptions.py +49 -0
- tetra_rp/runtime/generic_handler.py +206 -0
- tetra_rp/runtime/lb_handler.py +189 -0
- tetra_rp/runtime/load_balancer.py +160 -0
- tetra_rp/runtime/manifest_fetcher.py +192 -0
- tetra_rp/runtime/metrics.py +325 -0
- tetra_rp/runtime/models.py +73 -0
- tetra_rp/runtime/mothership_provisioner.py +512 -0
- tetra_rp/runtime/production_wrapper.py +266 -0
- tetra_rp/runtime/reliability_config.py +149 -0
- tetra_rp/runtime/retry_manager.py +118 -0
- tetra_rp/runtime/serialization.py +124 -0
- tetra_rp/runtime/service_registry.py +346 -0
- tetra_rp/runtime/state_manager_client.py +248 -0
- tetra_rp/stubs/live_serverless.py +35 -17
- tetra_rp/stubs/load_balancer_sls.py +357 -0
- tetra_rp/stubs/registry.py +145 -19
- {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/METADATA +398 -60
- tetra_rp-0.24.0.dist-info/RECORD +99 -0
- {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/WHEEL +1 -1
- tetra_rp-0.24.0.dist-info/entry_points.txt +2 -0
- tetra_rp/core/pool/cluster_manager.py +0 -177
- tetra_rp/core/pool/dataclass.py +0 -18
- tetra_rp/core/pool/ex.py +0 -38
- tetra_rp/core/pool/job.py +0 -22
- tetra_rp/core/pool/worker.py +0 -19
- tetra_rp/core/resources/utils.py +0 -50
- tetra_rp/core/utils/json.py +0 -33
- tetra_rp-0.6.0.dist-info/RECORD +0 -39
- /tetra_rp/{core/pool → cli}/__init__.py +0 -0
- {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
|