urun-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {urun_cli-0.3.0 → urun_cli-0.4.0}/PKG-INFO +1 -1
- {urun_cli-0.3.0 → urun_cli-0.4.0}/pyproject.toml +1 -1
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/api.py +36 -3
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/cli.py +41 -8
- urun_cli-0.4.0/src/urun/deps.py +399 -0
- urun_cli-0.3.0/src/urun/deps.py +0 -13
- {urun_cli-0.3.0 → urun_cli-0.4.0}/.gitignore +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/CHANGELOG.md +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/LICENSE +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/README.md +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/SECURITY.md +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/__init__.py +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/config.py +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/discovery.py +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/errors.py +0 -0
- {urun_cli-0.3.0 → urun_cli-0.4.0}/src/urun/manifest.py +0 -0
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
import urllib.error
|
|
6
6
|
import urllib.parse
|
|
7
7
|
import urllib.request
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -171,13 +172,36 @@ def require_safe_url(raw_url: str, label: str) -> None:
|
|
|
171
172
|
raise UrunError(f"{label} must use HTTPS except for localhost development")
|
|
172
173
|
|
|
173
174
|
|
|
175
|
+
# Terminal build states reported by the control plane's deployment-status document.
|
|
176
|
+
# The build worker transitions a deployment queued -> building -> ready|failed and
|
|
177
|
+
# records the actual build stderr/reason in the `error` field on failure.
|
|
178
|
+
TERMINAL_STATES = {"ready", "failed"}
|
|
179
|
+
|
|
180
|
+
# Human-readable progress lines for non-terminal states, so the CLI streams what the
|
|
181
|
+
# builder is doing instead of blocking silently until ready/failed.
|
|
182
|
+
_STATE_PROGRESS = {
|
|
183
|
+
"queued": "queued (waiting for a build slot)",
|
|
184
|
+
"building": "building (installing deps + materializing app)",
|
|
185
|
+
"not_found": "waiting for the control plane to register the build",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
174
189
|
def poll_until_done(
|
|
175
190
|
client: ApiClient,
|
|
176
191
|
manifest_hash: str,
|
|
177
192
|
interval: float = 2.0,
|
|
178
193
|
timeout: float = 1800.0,
|
|
179
194
|
not_found_grace: float = 30.0,
|
|
195
|
+
on_state: Callable[[str], None] | None = None,
|
|
180
196
|
) -> dict[str, Any]:
|
|
197
|
+
"""Poll the control plane for the real build outcome of a deployment.
|
|
198
|
+
|
|
199
|
+
Streams each new build state via ``on_state`` (so callers can show
|
|
200
|
+
queued -> building -> ... progress) and returns the terminal status
|
|
201
|
+
document (status ``ready`` or ``failed``). The ``failed`` document carries
|
|
202
|
+
the actual build error under ``error`` for the caller to surface. Raises
|
|
203
|
+
``UrunError`` on timeout.
|
|
204
|
+
"""
|
|
181
205
|
deadline = time.time() + timeout
|
|
182
206
|
not_found_deadline = time.time() + min(not_found_grace, timeout)
|
|
183
207
|
last_state = "unknown"
|
|
@@ -186,16 +210,25 @@ def poll_until_done(
|
|
|
186
210
|
status = client.deployment_status(manifest_hash)
|
|
187
211
|
except ApiError as exc:
|
|
188
212
|
if exc.status == 404 and time.time() < not_found_deadline:
|
|
213
|
+
if last_state != "not_found" and on_state is not None:
|
|
214
|
+
on_state("not_found")
|
|
189
215
|
last_state = "not_found"
|
|
190
216
|
time.sleep(interval)
|
|
191
217
|
continue
|
|
192
218
|
raise
|
|
193
|
-
state = status.get("status")
|
|
194
|
-
last_state
|
|
195
|
-
|
|
219
|
+
state = str(status.get("status"))
|
|
220
|
+
if state != last_state and on_state is not None:
|
|
221
|
+
on_state(state)
|
|
222
|
+
last_state = state
|
|
223
|
+
if state in TERMINAL_STATES:
|
|
196
224
|
return status
|
|
197
225
|
if time.time() >= deadline:
|
|
198
226
|
raise UrunError(
|
|
199
227
|
f"timed out waiting for deployment {manifest_hash} (last status: {last_state})"
|
|
200
228
|
)
|
|
201
229
|
time.sleep(interval)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def progress_line(state: str) -> str:
|
|
233
|
+
"""Render a human-readable progress line for a build state."""
|
|
234
|
+
return _STATE_PROGRESS.get(state, state)
|
|
@@ -11,7 +11,13 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from . import __version__
|
|
14
|
-
from .api import
|
|
14
|
+
from .api import (
|
|
15
|
+
DEFAULT_API_URL,
|
|
16
|
+
ApiClient,
|
|
17
|
+
poll_until_done,
|
|
18
|
+
progress_line,
|
|
19
|
+
upload_missing_blobs,
|
|
20
|
+
)
|
|
15
21
|
from .config import load_credentials, save_credentials
|
|
16
22
|
from .deps import resolve_deps
|
|
17
23
|
from .discovery import derive_app_name, discover_main_files
|
|
@@ -289,7 +295,8 @@ def deploy(args: argparse.Namespace) -> int:
|
|
|
289
295
|
|
|
290
296
|
project_root = Path.cwd()
|
|
291
297
|
entrypoint, files = discover_main_files(args.entrypoint, project_root)
|
|
292
|
-
|
|
298
|
+
entrypoint_path = (project_root / args.entrypoint).resolve()
|
|
299
|
+
deps, deps_blob, python_version = resolve_deps(project_root, entrypoint_path)
|
|
293
300
|
|
|
294
301
|
app_name = args.name or derive_app_name(args.entrypoint)
|
|
295
302
|
all_blobs = files + ([deps_blob] if deps_blob is not None else [])
|
|
@@ -325,17 +332,43 @@ def deploy(args: argparse.Namespace) -> int:
|
|
|
325
332
|
print(f"Status: {status_url}")
|
|
326
333
|
return 0
|
|
327
334
|
|
|
328
|
-
|
|
335
|
+
print("Waiting for build to complete (--no-wait to skip)")
|
|
336
|
+
status = poll_until_done(
|
|
337
|
+
client,
|
|
338
|
+
mh,
|
|
339
|
+
args.poll_interval,
|
|
340
|
+
args.timeout,
|
|
341
|
+
on_state=lambda state: print(f" build: {progress_line(state)}"),
|
|
342
|
+
)
|
|
329
343
|
if status.get("status") == "failed":
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
message = err.get("message")
|
|
333
|
-
detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
|
|
334
|
-
raise UrunError(f"deployment failed: {detail}")
|
|
344
|
+
print_build_failure(status)
|
|
345
|
+
return 1
|
|
335
346
|
print_ready(status)
|
|
336
347
|
return 0
|
|
337
348
|
|
|
338
349
|
|
|
350
|
+
def print_build_failure(status: dict[str, Any]) -> None:
|
|
351
|
+
"""Surface the REAL build failure (e.g. the uv/git/torch error) to stderr.
|
|
352
|
+
|
|
353
|
+
The control plane records the build worker's terminal error under
|
|
354
|
+
``error`` as ``{code, message}`` where ``message`` is the actual build
|
|
355
|
+
stderr/reason. We print the full message verbatim so callers no longer have
|
|
356
|
+
to kubectl into the cluster to learn why a build failed.
|
|
357
|
+
"""
|
|
358
|
+
err = status.get("error") or {}
|
|
359
|
+
code = string_value(err.get("code"))
|
|
360
|
+
message = string_value(err.get("message"))
|
|
361
|
+
print("Deployment failed: build did not succeed", file=sys.stderr)
|
|
362
|
+
if code:
|
|
363
|
+
print(f" reason: {code}", file=sys.stderr)
|
|
364
|
+
if message:
|
|
365
|
+
print(" build error:", file=sys.stderr)
|
|
366
|
+
for line in message.rstrip().splitlines() or [message]:
|
|
367
|
+
print(f" {line}", file=sys.stderr)
|
|
368
|
+
if not code and not message:
|
|
369
|
+
print(" (no error detail recorded by the build worker)", file=sys.stderr)
|
|
370
|
+
|
|
371
|
+
|
|
339
372
|
def resolve_api_credentials(args: argparse.Namespace) -> tuple[str, str]:
|
|
340
373
|
api_key = args.api_key or os.getenv("URUN_API_KEY")
|
|
341
374
|
api_url = args.api_url or os.getenv("URUN_API_URL")
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .manifest import FileBlob, file_blob
|
|
9
|
+
|
|
10
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
|
11
|
+
|
|
12
|
+
# Sentinel returned when the static evaluator cannot determine a value (e.g. an
|
|
13
|
+
# attribute access into config objects that are not statically resolvable). It
|
|
14
|
+
# behaves as an empty-ish value so unioning deps simply skips it.
|
|
15
|
+
_UNKNOWN = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _DepsEvaluator:
|
|
19
|
+
"""A tiny, side-effect-free interpreter over a Python *module* AST.
|
|
20
|
+
|
|
21
|
+
It evaluates only the small subset of Python needed to recover the
|
|
22
|
+
``Dependencies(python=[...], apt=[...], post_install=[...])`` literal that
|
|
23
|
+
apps declare on ``@app.function(deps=...)`` — INCLUDING the common
|
|
24
|
+
indirections used by helios/lingbot:
|
|
25
|
+
|
|
26
|
+
* module-level ``CONFIG = dict(..., deps=Dependencies(...))`` then
|
|
27
|
+
``@app.function(**CONFIG)`` / ``@app.on(name, **CONFIG)``
|
|
28
|
+
* ``python=_runtime_python_deps()`` — a no-arg local function that builds a
|
|
29
|
+
list with literals, ``.extend([...])`` and ``for``/``if``/``continue``
|
|
30
|
+
filtering using ``str.startswith``.
|
|
31
|
+
|
|
32
|
+
It never imports or executes the module; unknown constructs degrade to an
|
|
33
|
+
``_UNKNOWN`` sentinel rather than raising, so deps extraction is best-effort
|
|
34
|
+
and robust.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, tree: ast.Module) -> None:
|
|
38
|
+
self.tree = tree
|
|
39
|
+
# Module-level symbol table: name -> AST value node (lazily evaluated).
|
|
40
|
+
self.assignments: dict[str, ast.expr] = {}
|
|
41
|
+
self.functions: dict[str, ast.FunctionDef] = {}
|
|
42
|
+
for node in tree.body:
|
|
43
|
+
if isinstance(node, ast.Assign):
|
|
44
|
+
for target in node.targets:
|
|
45
|
+
if isinstance(target, ast.Name):
|
|
46
|
+
self.assignments[target.id] = node.value
|
|
47
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
48
|
+
if node.value is not None:
|
|
49
|
+
self.assignments[node.target.id] = node.value
|
|
50
|
+
elif isinstance(node, ast.FunctionDef):
|
|
51
|
+
self.functions[node.name] = node
|
|
52
|
+
|
|
53
|
+
# -- public API --------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def collect_dependencies(self) -> dict[str, list[str]]:
|
|
56
|
+
"""Union ``deps=Dependencies(...)`` across all app function decorators."""
|
|
57
|
+
out: dict[str, list[str]] = {"python": [], "apt": [], "post_install": []}
|
|
58
|
+
seen: set[tuple[str, str]] = set()
|
|
59
|
+
for deps_call in self._find_deps_calls():
|
|
60
|
+
spec = self._eval_dependencies_call(deps_call)
|
|
61
|
+
if not isinstance(spec, dict):
|
|
62
|
+
continue
|
|
63
|
+
for key in out:
|
|
64
|
+
for item in spec.get(key, []) or []:
|
|
65
|
+
if isinstance(item, str) and (key, item) not in seen:
|
|
66
|
+
seen.add((key, item))
|
|
67
|
+
out[key].append(item)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
# -- decorator discovery ----------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _find_deps_calls(self) -> list[ast.Call]:
|
|
73
|
+
calls: list[ast.Call] = []
|
|
74
|
+
for node in ast.walk(self.tree):
|
|
75
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
76
|
+
continue
|
|
77
|
+
for dec in node.decorator_list:
|
|
78
|
+
if isinstance(dec, ast.Call) and self._is_app_decorator(dec.func):
|
|
79
|
+
deps = self._extract_deps_from_call(dec)
|
|
80
|
+
if deps is not None:
|
|
81
|
+
calls.append(deps)
|
|
82
|
+
return calls
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _is_app_decorator(func: ast.expr) -> bool:
|
|
86
|
+
# Match `app.function(...)`, `app.on(...)`, `something.function(...)`.
|
|
87
|
+
if isinstance(func, ast.Attribute):
|
|
88
|
+
return func.attr in {"function", "on"}
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def _extract_deps_from_call(self, call: ast.Call) -> ast.Call | None:
|
|
92
|
+
# Direct keyword: deps=Dependencies(...)
|
|
93
|
+
for kw in call.keywords:
|
|
94
|
+
if kw.arg == "deps":
|
|
95
|
+
return self._as_dependencies_call(kw.value)
|
|
96
|
+
# Indirection via **CONFIG spread.
|
|
97
|
+
for kw in call.keywords:
|
|
98
|
+
if kw.arg is None: # **spread
|
|
99
|
+
config = self._resolve(kw.value)
|
|
100
|
+
if isinstance(config, dict):
|
|
101
|
+
deps_val = config.get("deps")
|
|
102
|
+
if isinstance(deps_val, ast.AST):
|
|
103
|
+
return self._as_dependencies_call(deps_val)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _as_dependencies_call(self, node: ast.AST) -> ast.Call | None:
|
|
107
|
+
# node may be a Name pointing at a module-level Dependencies(...) call.
|
|
108
|
+
node = self._deref(node)
|
|
109
|
+
if isinstance(node, ast.Call) and self._call_name(node.func) == "Dependencies":
|
|
110
|
+
return node
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
# -- Dependencies(...) evaluation -------------------------------------
|
|
114
|
+
|
|
115
|
+
def _eval_dependencies_call(self, call: ast.Call) -> dict[str, list[str]]:
|
|
116
|
+
result: dict[str, list[str]] = {"python": [], "apt": [], "post_install": []}
|
|
117
|
+
for kw in call.keywords:
|
|
118
|
+
if kw.arg in result:
|
|
119
|
+
value = self._eval(kw.value, {})
|
|
120
|
+
if isinstance(value, list):
|
|
121
|
+
result[kw.arg] = [v for v in value if isinstance(v, str)]
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
# -- name resolution helpers ------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _deref(self, node: ast.AST) -> ast.AST:
|
|
127
|
+
"""Follow a module-level Name to its assigned value node."""
|
|
128
|
+
while isinstance(node, ast.Name) and node.id in self.assignments:
|
|
129
|
+
node = self.assignments[node.id]
|
|
130
|
+
return node
|
|
131
|
+
|
|
132
|
+
def _resolve(self, node: ast.AST) -> Any:
|
|
133
|
+
"""Resolve a possibly-Name node to an evaluated value (module scope)."""
|
|
134
|
+
return self._eval(node, {})
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _call_name(func: ast.expr) -> str | None:
|
|
138
|
+
if isinstance(func, ast.Name):
|
|
139
|
+
return func.id
|
|
140
|
+
if isinstance(func, ast.Attribute):
|
|
141
|
+
return func.attr
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# -- the constrained evaluator ----------------------------------------
|
|
145
|
+
|
|
146
|
+
def _eval(self, node: ast.AST, scope: dict[str, Any]) -> Any:
|
|
147
|
+
if isinstance(node, ast.Constant):
|
|
148
|
+
return node.value
|
|
149
|
+
if isinstance(node, ast.Name):
|
|
150
|
+
if node.id in scope:
|
|
151
|
+
return scope[node.id]
|
|
152
|
+
if node.id in self.assignments:
|
|
153
|
+
return self._eval(self.assignments[node.id], {})
|
|
154
|
+
return _UNKNOWN
|
|
155
|
+
if isinstance(node, ast.List | ast.Tuple | ast.Set):
|
|
156
|
+
out: list[Any] = []
|
|
157
|
+
for elt in node.elts:
|
|
158
|
+
if isinstance(elt, ast.Starred):
|
|
159
|
+
inner = self._eval(elt.value, scope)
|
|
160
|
+
if isinstance(inner, list | tuple | set):
|
|
161
|
+
out.extend(inner)
|
|
162
|
+
else:
|
|
163
|
+
out.append(self._eval(elt, scope))
|
|
164
|
+
return out
|
|
165
|
+
if isinstance(node, ast.Dict):
|
|
166
|
+
result: dict[Any, Any] = {}
|
|
167
|
+
for k, v in zip(node.keys, node.values, strict=True):
|
|
168
|
+
if k is None: # **spread
|
|
169
|
+
inner = self._eval(v, scope)
|
|
170
|
+
if isinstance(inner, dict):
|
|
171
|
+
result.update(inner)
|
|
172
|
+
continue
|
|
173
|
+
result[self._eval(k, scope)] = v # keep value node lazy for dict()
|
|
174
|
+
return result
|
|
175
|
+
if isinstance(node, ast.Call):
|
|
176
|
+
return self._eval_call(node, scope)
|
|
177
|
+
if isinstance(node, ast.BoolOp):
|
|
178
|
+
values = [self._eval(v, scope) for v in node.values]
|
|
179
|
+
if isinstance(node.op, ast.Or):
|
|
180
|
+
result_bool = False
|
|
181
|
+
for v in values:
|
|
182
|
+
if v is True:
|
|
183
|
+
return True
|
|
184
|
+
if v is _UNKNOWN:
|
|
185
|
+
result_bool = _UNKNOWN # type: ignore[assignment]
|
|
186
|
+
return result_bool
|
|
187
|
+
if isinstance(node.op, ast.And):
|
|
188
|
+
for v in values:
|
|
189
|
+
if v is False:
|
|
190
|
+
return False
|
|
191
|
+
if v is _UNKNOWN:
|
|
192
|
+
return _UNKNOWN
|
|
193
|
+
return True
|
|
194
|
+
return _UNKNOWN
|
|
195
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
|
196
|
+
inner = self._eval(node.operand, scope)
|
|
197
|
+
if inner is True:
|
|
198
|
+
return False
|
|
199
|
+
if inner is False:
|
|
200
|
+
return True
|
|
201
|
+
return _UNKNOWN
|
|
202
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
203
|
+
left = self._eval(node.left, scope)
|
|
204
|
+
right = self._eval(node.right, scope)
|
|
205
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
206
|
+
return left + right
|
|
207
|
+
return _UNKNOWN
|
|
208
|
+
if isinstance(node, ast.Attribute):
|
|
209
|
+
return _UNKNOWN
|
|
210
|
+
return _UNKNOWN
|
|
211
|
+
|
|
212
|
+
def _eval_call(self, node: ast.Call, scope: dict[str, Any]) -> Any:
|
|
213
|
+
name = self._call_name(node.func)
|
|
214
|
+
# dict(...) -> mapping with value nodes kept lazy (so deps stays an AST).
|
|
215
|
+
if name == "dict" and isinstance(node.func, ast.Name):
|
|
216
|
+
mapping: dict[str, Any] = {}
|
|
217
|
+
for kw in node.keywords:
|
|
218
|
+
if kw.arg is None:
|
|
219
|
+
inner = self._eval(kw.value, scope)
|
|
220
|
+
if isinstance(inner, dict):
|
|
221
|
+
mapping.update(inner)
|
|
222
|
+
elif kw.arg == "deps":
|
|
223
|
+
mapping["deps"] = kw.value # keep AST for Dependencies match
|
|
224
|
+
else:
|
|
225
|
+
mapping[kw.arg] = kw.value
|
|
226
|
+
return mapping
|
|
227
|
+
# list(x) / list([...])
|
|
228
|
+
if name == "list" and isinstance(node.func, ast.Name):
|
|
229
|
+
if not node.args:
|
|
230
|
+
return []
|
|
231
|
+
inner = self._eval(node.args[0], scope)
|
|
232
|
+
if isinstance(inner, list | tuple | set):
|
|
233
|
+
return list(inner)
|
|
234
|
+
return _UNKNOWN
|
|
235
|
+
# str.startswith(prefix) for filtering loops.
|
|
236
|
+
if isinstance(node.func, ast.Attribute):
|
|
237
|
+
recv = self._eval(node.func.value, scope)
|
|
238
|
+
attr = node.func.attr
|
|
239
|
+
if isinstance(recv, str) and attr == "startswith":
|
|
240
|
+
args = [self._eval(a, scope) for a in node.args]
|
|
241
|
+
if args and isinstance(args[0], str):
|
|
242
|
+
return recv.startswith(args[0])
|
|
243
|
+
return _UNKNOWN
|
|
244
|
+
# No-arg local function call: interpret its body.
|
|
245
|
+
if isinstance(node.func, ast.Name) and node.func.id in self.functions and not node.args:
|
|
246
|
+
return self._eval_function(self.functions[node.func.id])
|
|
247
|
+
return _UNKNOWN
|
|
248
|
+
|
|
249
|
+
def _eval_function(self, fn: ast.FunctionDef) -> Any:
|
|
250
|
+
"""Interpret a no-arg function body that builds and returns a list."""
|
|
251
|
+
scope: dict[str, Any] = {}
|
|
252
|
+
return self._exec_body(fn.body, scope)
|
|
253
|
+
|
|
254
|
+
def _exec_body(self, body: list[ast.stmt], scope: dict[str, Any]) -> Any:
|
|
255
|
+
for stmt in body:
|
|
256
|
+
result = self._exec_stmt(stmt, scope)
|
|
257
|
+
if result is not _NO_RETURN:
|
|
258
|
+
return result
|
|
259
|
+
return _UNKNOWN
|
|
260
|
+
|
|
261
|
+
def _exec_stmt(self, stmt: ast.stmt, scope: dict[str, Any]) -> Any:
|
|
262
|
+
if isinstance(stmt, ast.Return):
|
|
263
|
+
return self._eval(stmt.value, scope) if stmt.value else None
|
|
264
|
+
if isinstance(stmt, ast.Assign):
|
|
265
|
+
value = self._eval(stmt.value, scope)
|
|
266
|
+
for target in stmt.targets:
|
|
267
|
+
if isinstance(target, ast.Name):
|
|
268
|
+
scope[target.id] = value
|
|
269
|
+
return _NO_RETURN
|
|
270
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
271
|
+
if stmt.value is not None:
|
|
272
|
+
scope[stmt.target.id] = self._eval(stmt.value, scope)
|
|
273
|
+
return _NO_RETURN
|
|
274
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
275
|
+
self._exec_method_call(stmt.value, scope)
|
|
276
|
+
return _NO_RETURN
|
|
277
|
+
if isinstance(stmt, ast.For):
|
|
278
|
+
return self._exec_for(stmt, scope)
|
|
279
|
+
if isinstance(stmt, ast.If):
|
|
280
|
+
test = self._eval(stmt.test, scope)
|
|
281
|
+
branch = stmt.body if test is True else stmt.orelse if test is False else None
|
|
282
|
+
if branch is not None:
|
|
283
|
+
return self._exec_body_or_continue(branch, scope)
|
|
284
|
+
return _NO_RETURN
|
|
285
|
+
return _NO_RETURN
|
|
286
|
+
|
|
287
|
+
def _exec_for(self, stmt: ast.For, scope: dict[str, Any]) -> Any:
|
|
288
|
+
iterable = self._eval(stmt.iter, scope)
|
|
289
|
+
if not isinstance(iterable, list | tuple | set):
|
|
290
|
+
return _NO_RETURN
|
|
291
|
+
if not isinstance(stmt.target, ast.Name):
|
|
292
|
+
return _NO_RETURN
|
|
293
|
+
var = stmt.target.id
|
|
294
|
+
for item in iterable:
|
|
295
|
+
scope[var] = item
|
|
296
|
+
signal = self._exec_loop_body(stmt.body, scope)
|
|
297
|
+
if signal is _CONTINUE:
|
|
298
|
+
continue
|
|
299
|
+
if signal is not _NO_RETURN and signal is not None:
|
|
300
|
+
return signal
|
|
301
|
+
return _NO_RETURN
|
|
302
|
+
|
|
303
|
+
def _exec_loop_body(self, body: list[ast.stmt], scope: dict[str, Any]) -> Any:
|
|
304
|
+
for stmt in body:
|
|
305
|
+
if isinstance(stmt, ast.Continue):
|
|
306
|
+
return _CONTINUE
|
|
307
|
+
if isinstance(stmt, ast.If):
|
|
308
|
+
test = self._eval(stmt.test, scope)
|
|
309
|
+
branch = stmt.body if test is True else stmt.orelse if test is False else []
|
|
310
|
+
signal = self._exec_loop_body(branch, scope)
|
|
311
|
+
if signal is not _NO_RETURN:
|
|
312
|
+
return signal
|
|
313
|
+
continue
|
|
314
|
+
if isinstance(stmt, ast.Return):
|
|
315
|
+
return self._eval(stmt.value, scope) if stmt.value else None
|
|
316
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
317
|
+
self._exec_method_call(stmt.value, scope)
|
|
318
|
+
continue
|
|
319
|
+
if isinstance(stmt, ast.Assign):
|
|
320
|
+
self._exec_stmt(stmt, scope)
|
|
321
|
+
return _NO_RETURN
|
|
322
|
+
|
|
323
|
+
def _exec_body_or_continue(self, body: list[ast.stmt], scope: dict[str, Any]) -> Any:
|
|
324
|
+
return self._exec_body(body, scope)
|
|
325
|
+
|
|
326
|
+
def _exec_method_call(self, call: ast.Call, scope: dict[str, Any]) -> None:
|
|
327
|
+
# Handle list.append(x) / list.extend([...]) mutating a scope variable.
|
|
328
|
+
if not isinstance(call.func, ast.Attribute):
|
|
329
|
+
return
|
|
330
|
+
target = call.func.value
|
|
331
|
+
attr = call.func.attr
|
|
332
|
+
if not isinstance(target, ast.Name) or target.id not in scope:
|
|
333
|
+
return
|
|
334
|
+
container = scope[target.id]
|
|
335
|
+
if not isinstance(container, list):
|
|
336
|
+
return
|
|
337
|
+
if attr == "append" and call.args:
|
|
338
|
+
container.append(self._eval(call.args[0], scope))
|
|
339
|
+
elif attr == "extend" and call.args:
|
|
340
|
+
extra = self._eval(call.args[0], scope)
|
|
341
|
+
if isinstance(extra, list | tuple | set):
|
|
342
|
+
container.extend(extra)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_NO_RETURN = object()
|
|
346
|
+
_CONTINUE = object()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def extract_app_python_deps(entrypoint: Path) -> dict[str, list[str]]:
|
|
350
|
+
"""Statically extract declared app deps from the entrypoint source.
|
|
351
|
+
|
|
352
|
+
Returns a mapping with ``python``/``apt``/``post_install`` string lists.
|
|
353
|
+
Never imports or executes the app; pure ``ast``.
|
|
354
|
+
"""
|
|
355
|
+
source = entrypoint.read_text(encoding="utf-8")
|
|
356
|
+
tree = ast.parse(source, filename=str(entrypoint))
|
|
357
|
+
return _DepsEvaluator(tree).collect_dependencies()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def render_requirements_txt(python_deps: list[str]) -> str:
|
|
361
|
+
"""Render requirements.txt content matching Dependencies.to_requirements_txt()."""
|
|
362
|
+
return "\n".join(python_deps)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def resolve_deps(
|
|
366
|
+
project_root: Path, entrypoint: Path | None = None
|
|
367
|
+
) -> tuple[dict[str, Any], FileBlob | None, str]:
|
|
368
|
+
"""Resolve app dependencies for the deploy manifest.
|
|
369
|
+
|
|
370
|
+
Dependencies are declared by the urun app via ``@app.function(deps=...)``.
|
|
371
|
+
We extract the python list STATICALLY (no import, no subprocess), render it
|
|
372
|
+
as a requirements.txt, and emit a ``requirements_txt`` deps manifest backed
|
|
373
|
+
by an uploadable FileBlob. If no python deps are declared, fall back to the
|
|
374
|
+
``app`` kind (no blob) so simple apps still deploy.
|
|
375
|
+
"""
|
|
376
|
+
python_version = DEFAULT_PYTHON_VERSION
|
|
377
|
+
if entrypoint is None:
|
|
378
|
+
return {"kind": "app", "python_version": python_version}, None, python_version
|
|
379
|
+
|
|
380
|
+
extracted = extract_app_python_deps(entrypoint)
|
|
381
|
+
python_deps = extracted.get("python", [])
|
|
382
|
+
if not python_deps:
|
|
383
|
+
return {"kind": "app", "python_version": python_version}, None, python_version
|
|
384
|
+
|
|
385
|
+
text = render_requirements_txt(python_deps)
|
|
386
|
+
# Materialize to a real file so the existing blob upload path (which reads
|
|
387
|
+
# blob.source from disk and re-hashes) can ship it.
|
|
388
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="urun-deps-"))
|
|
389
|
+
req_path = tmp_dir / "requirements.txt"
|
|
390
|
+
req_path.write_text(text, encoding="utf-8")
|
|
391
|
+
blob = file_blob(req_path, tmp_dir, manifest_path="requirements.txt")
|
|
392
|
+
|
|
393
|
+
deps = {
|
|
394
|
+
"kind": "requirements_txt",
|
|
395
|
+
"blob_sha256": blob.sha256,
|
|
396
|
+
"size": blob.size,
|
|
397
|
+
"python_version": python_version,
|
|
398
|
+
}
|
|
399
|
+
return deps, blob, python_version
|
urun_cli-0.3.0/src/urun/deps.py
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
DEFAULT_PYTHON_VERSION = "3.12"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def resolve_deps(_project_root: Path) -> tuple[dict[str, Any], None, str]:
|
|
10
|
-
# Dependencies are declared by the urun app in app.py, not by project-level
|
|
11
|
-
# pyproject.toml or requirements.txt files.
|
|
12
|
-
python_version = DEFAULT_PYTHON_VERSION
|
|
13
|
-
return {"kind": "app", "python_version": python_version}, None, python_version
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|