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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 = str(state)
195
- if state in {"ready", "failed"}:
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 DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
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
- deps, deps_blob, python_version = resolve_deps(project_root)
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
- status = poll_until_done(client, mh, args.poll_interval, args.timeout)
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
- err = status.get("error") or {}
331
- code = err.get("code")
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
@@ -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