bladekit 4.5.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.
- bladekit/__init__.py +219 -0
- bladekit/_app.py +3241 -0
- bladekit/_banner.py +640 -0
- bladekit/_batch.py +150 -0
- bladekit/_bucket.py +211 -0
- bladekit/_build.py +337 -0
- bladekit/_bundle.py +459 -0
- bladekit/_client.py +1152 -0
- bladekit/_cls.py +574 -0
- bladekit/_constants.py +121 -0
- bladekit/_cron.py +88 -0
- bladekit/_dict.py +174 -0
- bladekit/_env.py +98 -0
- bladekit/_image.py +577 -0
- bladekit/_loader.py +79 -0
- bladekit/_map.py +555 -0
- bladekit/_preempt.py +94 -0
- bladekit/_queue.py +137 -0
- bladekit/_render.py +683 -0
- bladekit/_runner_api.py +148 -0
- bladekit/_sandbox.py +1051 -0
- bladekit/_secret.py +52 -0
- bladekit/_serialize.py +88 -0
- bladekit/_serve.py +188 -0
- bladekit/_volume.py +232 -0
- bladekit/_webhook.py +90 -0
- bladekit/cli.py +2282 -0
- bladekit/config.py +181 -0
- bladekit/exceptions.py +544 -0
- bladekit-4.5.0.dist-info/METADATA +373 -0
- bladekit-4.5.0.dist-info/RECORD +33 -0
- bladekit-4.5.0.dist-info/WHEEL +4 -0
- bladekit-4.5.0.dist-info/entry_points.txt +3 -0
bladekit/_cls.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""``@app.cls()`` classes and their container-lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
The standard inference pattern: a class loads its model once per container, then
|
|
4
|
+
each method call reuses it::
|
|
5
|
+
|
|
6
|
+
@app.cls(gpu="RTX-A4500", image=img)
|
|
7
|
+
class Embedder:
|
|
8
|
+
@bladekit.enter()
|
|
9
|
+
def load(self):
|
|
10
|
+
self.model = load_model()
|
|
11
|
+
|
|
12
|
+
@app.method()
|
|
13
|
+
def embed(self, texts):
|
|
14
|
+
return self.model.encode(texts)
|
|
15
|
+
|
|
16
|
+
Embedder().embed.remote([...])
|
|
17
|
+
|
|
18
|
+
Contract (runner-protocol.md §10): the runner instantiates the class with NO
|
|
19
|
+
args, runs every method marked ``__forge_enter__`` (in ``__forge_enter_seq__``
|
|
20
|
+
order — set by ``@bladekit.enter()``), once per container, then calls the target
|
|
21
|
+
method. ``FunctionRef.class_name`` carries the class qualname; ``qualname`` is
|
|
22
|
+
the full method qualname (``Embedder.embed``). Those two attributes are THE
|
|
23
|
+
SDK↔runner contract — see also ``forge-runner``.
|
|
24
|
+
|
|
25
|
+
Three lifecycle hooks mirror one another (all discovered by a marker attribute
|
|
26
|
+
the runner scans for, exactly like ``__forge_enter__``):
|
|
27
|
+
|
|
28
|
+
- ``@bladekit.build`` — runs ONCE at image-build time to bake state into the image
|
|
29
|
+
(weights, caches). Until the control-plane build path executes it, the runner
|
|
30
|
+
falls back to running it once per container, before the enter hooks — see
|
|
31
|
+
``comingSoon`` in the tutorial and the runner's ``instantiate_with_enter``.
|
|
32
|
+
- ``@bladekit.enter`` — runs once per container before the first call (model load).
|
|
33
|
+
- ``@bladekit.exit`` — runs once per container at graceful teardown, after the last
|
|
34
|
+
call drains and before the process exits (flush caches, close handles).
|
|
35
|
+
|
|
36
|
+
``bladekit.parameter()`` declares a typed parameter so ONE class definition deploys
|
|
37
|
+
as many parametrized instances, each addressed by its parameter values (the
|
|
38
|
+
``modal.parameter`` equivalent) — the runner instantiates one container per
|
|
39
|
+
distinct parameter tuple and routes each call to the matching instance.
|
|
40
|
+
|
|
41
|
+
A run targets ONE method (one run = one callable + a FIFO call queue). Two
|
|
42
|
+
methods = two runs.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import itertools
|
|
48
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
from . import exceptions as exc
|
|
51
|
+
|
|
52
|
+
# Global, monotonically-increasing sequences = definition order of each hook
|
|
53
|
+
# kind across the whole process. The runner orders hooks by these values (§10).
|
|
54
|
+
# Each kind has its own counter so build/enter/exit never interleave by accident.
|
|
55
|
+
_enter_seq = itertools.count()
|
|
56
|
+
_build_seq = itertools.count()
|
|
57
|
+
_exit_seq = itertools.count()
|
|
58
|
+
|
|
59
|
+
# Sentinel for "no default" so ``None`` stays a valid parameter default.
|
|
60
|
+
_MISSING = object()
|
|
61
|
+
|
|
62
|
+
# The value types a ``bladekit.parameter()`` may carry. Kept to the small set that
|
|
63
|
+
# is safely serializable AND hashable (so a parameter tuple can key a container
|
|
64
|
+
# pool), matching modal.parameter's supported types.
|
|
65
|
+
_PARAM_TYPES: Tuple[type, ...] = (str, int, float, bool, bytes)
|
|
66
|
+
|
|
67
|
+
# Resolve a stringized annotation back to its concrete type. Under
|
|
68
|
+
# ``from __future__ import annotations`` (PEP 563 — common in user modules) a
|
|
69
|
+
# class annotation is a *string* ("str", "int", …), so the annotation read off
|
|
70
|
+
# ``__annotations__`` never equals the ``str``/``int`` type object and type
|
|
71
|
+
# validation would silently no-op. We resolve the small allowed set by name.
|
|
72
|
+
_PARAM_TYPES_BY_NAME: Dict[str, type] = {t.__name__: t for t in _PARAM_TYPES}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_param_type(annotation: Any) -> Optional[type]:
|
|
76
|
+
"""Normalise a parameter annotation to a concrete type (or None).
|
|
77
|
+
|
|
78
|
+
Accepts a type object as-is and resolves a stringized annotation (PEP 563)
|
|
79
|
+
by name against the allowed scalar types; anything else resolves to None
|
|
80
|
+
(untyped — no validation beyond the scalar allow-list at bind time).
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(annotation, type):
|
|
83
|
+
return annotation
|
|
84
|
+
if isinstance(annotation, str):
|
|
85
|
+
return _PARAM_TYPES_BY_NAME.get(annotation)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _reject_async(fn: Callable[..., Any], what: str) -> None:
|
|
90
|
+
"""Reject ``async def`` at decoration time (see _app._reject_coroutine_function).
|
|
91
|
+
|
|
92
|
+
Imported lazily: ``_app`` imports ``_cls`` (inside ``App.cls``), so a
|
|
93
|
+
module-level import here would close the cycle.
|
|
94
|
+
"""
|
|
95
|
+
from ._app import _reject_coroutine_function # noqa: PLC0415 - avoids an import cycle
|
|
96
|
+
|
|
97
|
+
_reject_coroutine_function(fn, what)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def enter() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
101
|
+
"""Mark a method to run once per container before the first call (model load).
|
|
102
|
+
|
|
103
|
+
Usable as ``@bladekit.enter()``. Re-run after a hot reload (``blade serve``):
|
|
104
|
+
the instance is dropped with its module, so the hook reloads the model on
|
|
105
|
+
the new code (runner-protocol.md §9/§10).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
109
|
+
_reject_async(fn, "@bladekit.enter")
|
|
110
|
+
fn.__forge_enter__ = True # type: ignore[attr-defined]
|
|
111
|
+
fn.__forge_enter_seq__ = next(_enter_seq) # type: ignore[attr-defined]
|
|
112
|
+
return fn
|
|
113
|
+
|
|
114
|
+
return deco
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
118
|
+
"""Mark a method to run ONCE at image-build time to bake state into the image.
|
|
119
|
+
|
|
120
|
+
Usable as ``@bladekit.build()``. The canonical use is to persist expensive,
|
|
121
|
+
deterministic setup — download model weights, warm a compile cache — into the
|
|
122
|
+
image layer so every container starts with it already present, paying the cost
|
|
123
|
+
once at build time rather than on every cold start.
|
|
124
|
+
|
|
125
|
+
Build-time execution is driven by the control-plane image-build path. Until
|
|
126
|
+
that path is deployed, the runner falls back to running ``@bladekit.build``
|
|
127
|
+
methods once per container, before the enter hooks (see
|
|
128
|
+
``forge_runner.execute.instantiate_with_enter``). The fallback is semantically
|
|
129
|
+
weaker (per-container, not baked into the image) but keeps user code correct;
|
|
130
|
+
the tutorial documents the limitation honestly.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
134
|
+
_reject_async(fn, "@bladekit.build")
|
|
135
|
+
fn.__forge_build__ = True # type: ignore[attr-defined]
|
|
136
|
+
fn.__forge_build_seq__ = next(_build_seq) # type: ignore[attr-defined]
|
|
137
|
+
return fn
|
|
138
|
+
|
|
139
|
+
return deco
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def exit() -> Callable[[Callable[..., Any]], Callable[..., Any]]: # noqa: A001 - public API mirrors modal.exit
|
|
143
|
+
"""Mark a method to run once per container at graceful shutdown/teardown.
|
|
144
|
+
|
|
145
|
+
Usable as ``@bladekit.exit()``. The runner runs every ``@bladekit.exit`` method,
|
|
146
|
+
in ``__forge_exit_seq__`` order, after the container's last call has drained
|
|
147
|
+
and BEFORE the process exits — on a normal drain and on a teardown alike
|
|
148
|
+
(runner-protocol.md §10). Use it to flush a cache, commit a Volume, close a
|
|
149
|
+
network handle, or write a final metric. Exit hooks are best-effort: an
|
|
150
|
+
exception in one is logged and the remaining hooks still run, so a failing
|
|
151
|
+
teardown never wedges container shutdown.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
155
|
+
_reject_async(fn, "@bladekit.exit")
|
|
156
|
+
fn.__forge_exit__ = True # type: ignore[attr-defined]
|
|
157
|
+
fn.__forge_exit_seq__ = next(_exit_seq) # type: ignore[attr-defined]
|
|
158
|
+
return fn
|
|
159
|
+
|
|
160
|
+
return deco
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Parameter:
|
|
164
|
+
"""A declared typed parameter of an ``@app.cls`` class (see ``parameter``).
|
|
165
|
+
|
|
166
|
+
Placed as a class attribute (``model_name: str = bladekit.parameter()``) it turns
|
|
167
|
+
the class into a *parametrized* class: instead of a single instance, one
|
|
168
|
+
definition deploys as many instances, one per distinct set of parameter
|
|
169
|
+
values, each addressed by those values. ``name``/``type`` are filled in by
|
|
170
|
+
``Cls`` when it scans the user class (``type`` from the annotation unless given
|
|
171
|
+
explicitly).
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
__slots__ = ("default", "type", "name")
|
|
175
|
+
|
|
176
|
+
def __init__(self, default: Any = _MISSING, *, type: Optional[type] = None) -> None: # noqa: A002 - mirrors modal.parameter
|
|
177
|
+
self.default = default
|
|
178
|
+
self.type = type
|
|
179
|
+
self.name: Optional[str] = None
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def required(self) -> bool:
|
|
183
|
+
return self.default is _MISSING
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
d = "<required>" if self.required else repr(self.default)
|
|
187
|
+
return f"<bladekit.parameter name={self.name!r} type={self.type} default={d}>"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parameter(default: Any = _MISSING, *, type: Optional[type] = None) -> Any: # noqa: A002 - mirrors modal.parameter
|
|
191
|
+
"""Declare a typed parameter on an ``@app.cls`` class (``modal.parameter``).
|
|
192
|
+
|
|
193
|
+
Usage::
|
|
194
|
+
|
|
195
|
+
@app.cls(gpu="RTX-A4500", image=img)
|
|
196
|
+
class Model:
|
|
197
|
+
model_name: str = bladekit.parameter(default="Qwen/Qwen2.5-1.5B-Instruct")
|
|
198
|
+
|
|
199
|
+
@bladekit.enter()
|
|
200
|
+
def load(self):
|
|
201
|
+
self.pipe = load(self.model_name)
|
|
202
|
+
|
|
203
|
+
@app.method()
|
|
204
|
+
def generate(self, prompt): ...
|
|
205
|
+
|
|
206
|
+
# one definition, two parametrized instances → two container pools
|
|
207
|
+
Model(model_name="Qwen/Qwen2.5-1.5B-Instruct").generate.remote("hi")
|
|
208
|
+
Model(model_name="meta-llama/Llama-3.1-8B-Instruct").generate.remote("hi")
|
|
209
|
+
|
|
210
|
+
The value type is taken from the annotation (or the explicit ``type=``) and
|
|
211
|
+
must be one of ``str``, ``int``, ``float``, ``bool``, ``bytes``. A parameter
|
|
212
|
+
without a ``default`` is required at call time. The bound values travel with
|
|
213
|
+
the call so the runner instantiates the class with them and routes the call to
|
|
214
|
+
the matching parametrized container.
|
|
215
|
+
"""
|
|
216
|
+
return _Parameter(default=default, type=type)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _validate_param_value(name: str, value: Any, declared: Optional[type]) -> None:
|
|
220
|
+
"""Validate a bound parameter value against the allowed types / declaration."""
|
|
221
|
+
# bool is a subclass of int; check the concrete type against the allow-list so
|
|
222
|
+
# e.g. a list or a numpy scalar is rejected with a clear message.
|
|
223
|
+
if type(value) not in _PARAM_TYPES:
|
|
224
|
+
raise exc.InvalidArgumentError(
|
|
225
|
+
"INVALID_ARGUMENT",
|
|
226
|
+
f"parameter {name!r} must be one of str/int/float/bool/bytes, "
|
|
227
|
+
f"got {type(value).__name__}",
|
|
228
|
+
)
|
|
229
|
+
if declared in _PARAM_TYPES:
|
|
230
|
+
# Accept an int where a float is declared (the usual numeric widening);
|
|
231
|
+
# every other mismatch is an error.
|
|
232
|
+
if declared is float and isinstance(value, int) and not isinstance(value, bool):
|
|
233
|
+
return
|
|
234
|
+
if not isinstance(value, declared):
|
|
235
|
+
raise exc.InvalidArgumentError(
|
|
236
|
+
"INVALID_ARGUMENT",
|
|
237
|
+
f"parameter {name!r} expects {declared.__name__}, "
|
|
238
|
+
f"got {type(value).__name__}",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Method:
|
|
243
|
+
"""A method of an ``@app.cls`` class, callable remotely.
|
|
244
|
+
|
|
245
|
+
Bound onto an instance via the descriptor protocol so
|
|
246
|
+
``Embedder().embed.remote(...)`` works. The handle creates a run whose
|
|
247
|
+
``FunctionRef`` names the class and the full method qualname.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, fn: Callable[..., Any]) -> None:
|
|
251
|
+
_reject_async(fn, "@app.method")
|
|
252
|
+
self._fn = fn
|
|
253
|
+
self.__name__ = getattr(fn, "__name__", "method")
|
|
254
|
+
self.__qualname__ = getattr(fn, "__qualname__", self.__name__)
|
|
255
|
+
self.__doc__ = getattr(fn, "__doc__", None)
|
|
256
|
+
self._cls: Optional["Cls"] = None # set by Cls._bind_methods
|
|
257
|
+
|
|
258
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
259
|
+
self.__name__ = name
|
|
260
|
+
|
|
261
|
+
def __get__(self, instance: Any, owner: type) -> Any:
|
|
262
|
+
# Accessed on the class object (owner) before instantiation, or on an
|
|
263
|
+
# instance. Either way we return a bound remote handle. The user's
|
|
264
|
+
# local code path goes through __call__ on the underlying function.
|
|
265
|
+
if instance is None:
|
|
266
|
+
return self
|
|
267
|
+
return _BoundMethod(self, instance)
|
|
268
|
+
|
|
269
|
+
def __call__(self, instance: Any, *args: Any, **kwargs: Any) -> Any:
|
|
270
|
+
"""Direct local call (also the path forge-runner uses after getattr)."""
|
|
271
|
+
return self._fn(instance, *args, **kwargs)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _BoundMethod:
|
|
275
|
+
"""``instance.method`` — exposes ``.remote()`` / ``.spawn()`` / ``.local()``."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, method: Method, instance: Any) -> None:
|
|
278
|
+
self._method = method
|
|
279
|
+
self._instance = instance
|
|
280
|
+
# Parametrized classes stamp the bound parameter values on the instance so
|
|
281
|
+
# the remote handle can carry them to the runner (routing by param value).
|
|
282
|
+
self._params: Optional[Dict[str, Any]] = getattr(
|
|
283
|
+
instance, "__forge_bound_params__", None
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def local(self, *args: Any, **kwargs: Any) -> Any:
|
|
287
|
+
return self._method._fn(self._instance, *args, **kwargs)
|
|
288
|
+
|
|
289
|
+
__call__ = local
|
|
290
|
+
|
|
291
|
+
def _function(self):
|
|
292
|
+
cls = self._method._cls
|
|
293
|
+
if cls is None:
|
|
294
|
+
raise exc.FabricError(
|
|
295
|
+
f"method {self._method.__name__!r} is not registered on an "
|
|
296
|
+
"@app.cls class"
|
|
297
|
+
)
|
|
298
|
+
return cls._function_for(self._method, params=self._params)
|
|
299
|
+
|
|
300
|
+
def remote(self, *args: Any, **kwargs: Any) -> Any:
|
|
301
|
+
return self._function().remote(*args, **kwargs)
|
|
302
|
+
|
|
303
|
+
def spawn(self, *args: Any, **kwargs: Any):
|
|
304
|
+
return self._function().spawn(*args, **kwargs)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Cls:
|
|
308
|
+
"""An ``@app.cls()``-decorated class: a namespace of remote methods.
|
|
309
|
+
|
|
310
|
+
Calling it (``Embedder()``) returns a normal instance for local use; remote
|
|
311
|
+
calls go through ``instance.method.remote(...)``.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
app: Any,
|
|
317
|
+
user_cls: type,
|
|
318
|
+
*,
|
|
319
|
+
gpu: Optional[str] = None,
|
|
320
|
+
cpu: Optional[float] = None,
|
|
321
|
+
memory: Optional[int] = None,
|
|
322
|
+
timeout: Optional[int] = None,
|
|
323
|
+
image: Any = None,
|
|
324
|
+
idle_timeout: Optional[int] = None,
|
|
325
|
+
volumes: Optional[Dict[str, Any]] = None,
|
|
326
|
+
secrets: Optional[List[Any]] = None,
|
|
327
|
+
gpu_class: str = "spot",
|
|
328
|
+
best_effort: bool = False,
|
|
329
|
+
max_interruptions: Optional[int] = None,
|
|
330
|
+
) -> None:
|
|
331
|
+
self._app = app
|
|
332
|
+
self._user_cls = user_cls
|
|
333
|
+
self._gpu = gpu
|
|
334
|
+
self._cpu = cpu
|
|
335
|
+
self._memory = memory
|
|
336
|
+
self._timeout = timeout
|
|
337
|
+
self._image = image
|
|
338
|
+
self._idle_timeout = idle_timeout
|
|
339
|
+
self._volumes = volumes
|
|
340
|
+
self._secrets = secrets
|
|
341
|
+
# Capacity class + preemption budget, forwarded to the per-method
|
|
342
|
+
# Function (m8-spot.md §13).
|
|
343
|
+
self._gpu_class = gpu_class
|
|
344
|
+
self._best_effort = best_effort
|
|
345
|
+
self._max_interruptions = max_interruptions
|
|
346
|
+
self._serve = False
|
|
347
|
+
# method name -> Function (lazily built, cached per method)
|
|
348
|
+
self._functions: Dict[Tuple[str, Optional[tuple]], Any] = {}
|
|
349
|
+
self._methods: Dict[str, Method] = {}
|
|
350
|
+
self.__name__ = user_cls.__name__
|
|
351
|
+
self.__qualname__ = user_cls.__qualname__
|
|
352
|
+
self.__doc__ = user_cls.__doc__
|
|
353
|
+
# SDK↔runner contract (runner-protocol §10): @app.cls replaces the class
|
|
354
|
+
# in the module namespace with this wrapper, so the runner's
|
|
355
|
+
# getattr(module, class_name) resolves to a Cls, not a `type`. Expose the
|
|
356
|
+
# underlying class under a stable dunder so the runner can unwrap it and
|
|
357
|
+
# instantiate it / find its @bladekit.enter hooks. Additive, documented in
|
|
358
|
+
# contracts/CHANGELOG.md.
|
|
359
|
+
self.__forge_user_class__ = user_cls
|
|
360
|
+
self._bind_methods()
|
|
361
|
+
# Declared bladekit.parameter() attributes (name -> _Parameter). Non-empty
|
|
362
|
+
# ⇒ this is a parametrized class: __call__ binds values instead of running
|
|
363
|
+
# the user __init__ (mirrors modal.parameter, which synthesizes __init__).
|
|
364
|
+
self._parameters: Dict[str, _Parameter] = self._collect_parameters()
|
|
365
|
+
|
|
366
|
+
def _bind_methods(self) -> None:
|
|
367
|
+
for name, attr in vars(self._user_cls).items():
|
|
368
|
+
if isinstance(attr, Method):
|
|
369
|
+
attr._cls = self
|
|
370
|
+
self._methods[name] = attr
|
|
371
|
+
|
|
372
|
+
def _collect_parameters(self) -> Dict[str, _Parameter]:
|
|
373
|
+
"""Scan the class (MRO) for ``bladekit.parameter()`` declarations.
|
|
374
|
+
|
|
375
|
+
The value type is taken from the matching annotation unless the parameter
|
|
376
|
+
gave an explicit ``type=``. Own-class declarations win over inherited
|
|
377
|
+
ones (a subclass may re-declare a parameter).
|
|
378
|
+
"""
|
|
379
|
+
annotations: Dict[str, Any] = {}
|
|
380
|
+
params: Dict[str, _Parameter] = {}
|
|
381
|
+
# Walk base→derived so a subclass override replaces the parent's entry.
|
|
382
|
+
for klass in reversed(self._user_cls.__mro__):
|
|
383
|
+
annotations.update(getattr(klass, "__annotations__", {}) or {})
|
|
384
|
+
for name, attr in vars(klass).items():
|
|
385
|
+
if isinstance(attr, _Parameter):
|
|
386
|
+
attr.name = name
|
|
387
|
+
if attr.type is None:
|
|
388
|
+
attr.type = _resolve_param_type(annotations.get(name))
|
|
389
|
+
else:
|
|
390
|
+
# An explicit type= may still arrive stringized.
|
|
391
|
+
attr.type = _resolve_param_type(attr.type)
|
|
392
|
+
params[name] = attr
|
|
393
|
+
return params
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def parameters(self) -> Dict[str, _Parameter]:
|
|
397
|
+
return dict(self._parameters)
|
|
398
|
+
|
|
399
|
+
def _bind_parameters(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
400
|
+
"""Resolve call-time kwargs against the declared parameters (validated)."""
|
|
401
|
+
bound: Dict[str, Any] = {}
|
|
402
|
+
for name, param in self._parameters.items():
|
|
403
|
+
if name in kwargs:
|
|
404
|
+
value = kwargs[name]
|
|
405
|
+
elif not param.required:
|
|
406
|
+
value = param.default
|
|
407
|
+
else:
|
|
408
|
+
raise exc.InvalidArgumentError(
|
|
409
|
+
"INVALID_ARGUMENT",
|
|
410
|
+
f"missing required parameter {name!r} for @app.cls "
|
|
411
|
+
f"{self.__name__!r}",
|
|
412
|
+
)
|
|
413
|
+
_validate_param_value(name, value, param.type)
|
|
414
|
+
bound[name] = value
|
|
415
|
+
extra = sorted(set(kwargs) - set(self._parameters))
|
|
416
|
+
if extra:
|
|
417
|
+
raise exc.InvalidArgumentError(
|
|
418
|
+
"INVALID_ARGUMENT",
|
|
419
|
+
f"unknown parameter(s) {extra} for @app.cls {self.__name__!r}; "
|
|
420
|
+
f"declared: {sorted(self._parameters)}",
|
|
421
|
+
)
|
|
422
|
+
return bound
|
|
423
|
+
|
|
424
|
+
def _construct_parametrized(self, bound: Dict[str, Any]) -> Any:
|
|
425
|
+
"""Build an instance for a parametrized class WITHOUT the user __init__.
|
|
426
|
+
|
|
427
|
+
Like modal.parameter, a parametrized class does not define its own
|
|
428
|
+
``__init__``; the parameter values are set as attributes directly. The
|
|
429
|
+
enter/build hooks (which read ``self.<param>``) run afterwards.
|
|
430
|
+
"""
|
|
431
|
+
# The ignore below is a mypy limitation, not a defect: mypy cannot model
|
|
432
|
+
# `type.__new__` on a dynamic class object -- it offers only the 1-arg
|
|
433
|
+
# `type(o)` and 3-arg `type(name, bases, ns)` overloads, and this is
|
|
434
|
+
# neither. Keeping
|
|
435
|
+
# `cls.__new__(cls)` is deliberate -- it is the stdlib idiom for building
|
|
436
|
+
# an instance without running `__init__` (what copy and pickle use) and
|
|
437
|
+
# it still honours a user-defined `__new__`. `object.__new__(cls)` does
|
|
438
|
+
# type-check, and is WRONG here: it would bypass that `__new__`.
|
|
439
|
+
instance = self._user_cls.__new__(self._user_cls) # type: ignore[call-overload]
|
|
440
|
+
for name, value in bound.items():
|
|
441
|
+
setattr(instance, name, value)
|
|
442
|
+
return instance
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _params_cache_key(params: Optional[Dict[str, Any]]) -> Optional[tuple]:
|
|
446
|
+
if not params:
|
|
447
|
+
return None
|
|
448
|
+
return tuple(sorted(params.items()))
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def methods(self) -> Dict[str, Method]:
|
|
452
|
+
return dict(self._methods)
|
|
453
|
+
|
|
454
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
455
|
+
"""Instantiate the underlying class (local use / runner instantiation).
|
|
456
|
+
|
|
457
|
+
For a parametrized class (``bladekit.parameter`` declared) the call binds the
|
|
458
|
+
keyword parameter values, validates them, and returns an instance carrying
|
|
459
|
+
``__forge_bound_params__`` so the remote handle routes to the matching
|
|
460
|
+
parametrized container. A non-parametrized class instantiates normally.
|
|
461
|
+
"""
|
|
462
|
+
if self._parameters:
|
|
463
|
+
if args:
|
|
464
|
+
raise exc.InvalidArgumentError(
|
|
465
|
+
"INVALID_ARGUMENT",
|
|
466
|
+
f"parametrized @app.cls {self.__name__!r} takes keyword "
|
|
467
|
+
"parameters only, not positional arguments",
|
|
468
|
+
)
|
|
469
|
+
bound = self._bind_parameters(kwargs)
|
|
470
|
+
instance = self._construct_parametrized(bound)
|
|
471
|
+
instance.__forge_bound_params__ = bound
|
|
472
|
+
return instance
|
|
473
|
+
return self._user_cls(*args, **kwargs)
|
|
474
|
+
|
|
475
|
+
def __repr__(self) -> str:
|
|
476
|
+
return (
|
|
477
|
+
f"<bladekit.Cls {self._app.name}.{self.__name__} "
|
|
478
|
+
f"methods={list(self._methods)}>"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _function_for(
|
|
482
|
+
self, method: Method, params: Optional[Dict[str, Any]] = None
|
|
483
|
+
) -> Any:
|
|
484
|
+
"""Build (and cache) the ``Function`` backing one method's remote calls.
|
|
485
|
+
|
|
486
|
+
``params`` (when the method is called on a parametrized instance) is
|
|
487
|
+
folded into the cache key AND the FunctionRef, so each distinct parameter
|
|
488
|
+
tuple gets its own Function — hence its own run and its own container pool,
|
|
489
|
+
routed by parameter value.
|
|
490
|
+
"""
|
|
491
|
+
name = method.__name__
|
|
492
|
+
cache_key = (name, self._params_cache_key(params))
|
|
493
|
+
cached = self._functions.get(cache_key)
|
|
494
|
+
if cached is not None:
|
|
495
|
+
return cached
|
|
496
|
+
|
|
497
|
+
from ._app import Function
|
|
498
|
+
|
|
499
|
+
# The Function wraps the method so the runner imports the module,
|
|
500
|
+
# instantiates the class, runs @bladekit.build/@bladekit.enter hooks, then calls
|
|
501
|
+
# the method (runner-protocol.md §10). Locally, calling the Function runs
|
|
502
|
+
# the user method on a fresh instance — matching the remote semantics.
|
|
503
|
+
user_cls = self._user_cls
|
|
504
|
+
cls_obj = self
|
|
505
|
+
|
|
506
|
+
def _local_call(*args: Any, **kwargs: Any) -> Any:
|
|
507
|
+
if params:
|
|
508
|
+
instance = cls_obj._construct_parametrized(params)
|
|
509
|
+
else:
|
|
510
|
+
instance = user_cls()
|
|
511
|
+
_run_build_hooks(instance)
|
|
512
|
+
_run_enter_hooks(instance)
|
|
513
|
+
return method._fn(instance, *args, **kwargs)
|
|
514
|
+
|
|
515
|
+
_local_call.__name__ = name
|
|
516
|
+
# The runner resolves the method by "ClassName.method_name". We use the
|
|
517
|
+
# short class __name__ (not __qualname__) so that the qualname is always
|
|
518
|
+
# in the form "Embedder.embed" — resolvable by getattr from the module —
|
|
519
|
+
# regardless of where the class was defined (e.g. inside a test helper).
|
|
520
|
+
_local_call.__qualname__ = f"{self.__name__}.{name}"
|
|
521
|
+
_local_call.__module__ = getattr(user_cls, "__module__", "__main__")
|
|
522
|
+
|
|
523
|
+
fn = Function(
|
|
524
|
+
self._app,
|
|
525
|
+
_local_call,
|
|
526
|
+
gpu=self._gpu,
|
|
527
|
+
cpu=self._cpu,
|
|
528
|
+
memory=self._memory,
|
|
529
|
+
timeout=self._timeout,
|
|
530
|
+
image=self._image,
|
|
531
|
+
idle_timeout=self._idle_timeout,
|
|
532
|
+
volumes=self._volumes,
|
|
533
|
+
secrets=self._secrets,
|
|
534
|
+
class_name=self.__name__,
|
|
535
|
+
serve=self._serve,
|
|
536
|
+
gpu_class=self._gpu_class,
|
|
537
|
+
best_effort=self._best_effort,
|
|
538
|
+
max_interruptions=self._max_interruptions,
|
|
539
|
+
parameters=params,
|
|
540
|
+
)
|
|
541
|
+
self._functions[cache_key] = fn
|
|
542
|
+
return fn
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _run_marked_hooks(instance: Any, marker: str, seq_marker: str) -> None:
|
|
546
|
+
"""Run every method carrying ``marker`` in ``seq_marker`` order.
|
|
547
|
+
|
|
548
|
+
Shared by the build/enter/exit local runners; the ordering key is the global
|
|
549
|
+
per-kind sequence the decorator assigned at definition time (runner-protocol
|
|
550
|
+
§10). Mirrors the runner's ``execute._marked_hooks``.
|
|
551
|
+
"""
|
|
552
|
+
hooks = []
|
|
553
|
+
for attr_name in dir(type(instance)):
|
|
554
|
+
attr = getattr(type(instance), attr_name, None)
|
|
555
|
+
if callable(attr) and getattr(attr, marker, False):
|
|
556
|
+
hooks.append((getattr(attr, seq_marker, 0), attr_name))
|
|
557
|
+
hooks.sort()
|
|
558
|
+
for _seq, attr_name in hooks:
|
|
559
|
+
getattr(instance, attr_name)()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _run_build_hooks(instance: Any) -> None:
|
|
563
|
+
"""Run @bladekit.build hooks in ``__forge_build_seq__`` order (runtime fallback)."""
|
|
564
|
+
_run_marked_hooks(instance, "__forge_build__", "__forge_build_seq__")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _run_enter_hooks(instance: Any) -> None:
|
|
568
|
+
"""Run @bladekit.enter hooks in ``__forge_enter_seq__`` order (runner-protocol §10)."""
|
|
569
|
+
_run_marked_hooks(instance, "__forge_enter__", "__forge_enter_seq__")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _run_exit_hooks(instance: Any) -> None:
|
|
573
|
+
"""Run @bladekit.exit hooks in ``__forge_exit_seq__`` order (runner-protocol §10)."""
|
|
574
|
+
_run_marked_hooks(instance, "__forge_exit__", "__forge_exit_seq__")
|
bladekit/_constants.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Contract-derived constants.
|
|
2
|
+
|
|
3
|
+
Single source of the numbers and strings pinned by contracts/conventions.md,
|
|
4
|
+
contracts/openapi.yaml and contracts/runner-protocol.md. Keeping them in one
|
|
5
|
+
module makes it obvious when an implementation value is normative rather than
|
|
6
|
+
arbitrary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# --- Limits (conventions.md §6) ---------------------------------------------
|
|
12
|
+
|
|
13
|
+
# Raw binary (pre-base64) inline payload ceiling: 1 MiB. Above this, the object
|
|
14
|
+
# store flow (pl_ payload) is mandatory.
|
|
15
|
+
INLINE_PAYLOAD_MAX_BYTES = 1_048_576
|
|
16
|
+
|
|
17
|
+
# Compressed bundle (tar.gz) ceiling: 256 MiB.
|
|
18
|
+
BUNDLE_MAX_BYTES = 256 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
# Pagination.
|
|
21
|
+
DEFAULT_PAGE_LIMIT = 50
|
|
22
|
+
MAX_PAGE_LIMIT = 200
|
|
23
|
+
|
|
24
|
+
# --- Defaults for ResourceSpec (openapi.yaml ResourceSpec) ------------------
|
|
25
|
+
|
|
26
|
+
DEFAULT_CPU_MILLICORES = 1000
|
|
27
|
+
# 16 GiB: a sane floor for ML workloads. Raised from 4096 (4 GiB) after a
|
|
28
|
+
# real-GPU validation where loading a 7B model was OOM-killed by the memory
|
|
29
|
+
# cgroup under the old default. Kept in lockstep with the control-plane
|
|
30
|
+
# default (state.DefaultMemoryMB). Set memory= explicitly to override.
|
|
31
|
+
DEFAULT_MEMORY_MB = 16384
|
|
32
|
+
DEFAULT_TIMEOUT_SECONDS = 3600
|
|
33
|
+
DEFAULT_IDLE_TIMEOUT_SECONDS = 60
|
|
34
|
+
|
|
35
|
+
# --- Serialization format (conventions.md §8) -------------------------------
|
|
36
|
+
|
|
37
|
+
PAYLOAD_FORMAT = "cloudpickle"
|
|
38
|
+
|
|
39
|
+
# --- Terminal states (conventions.md §7) ------------------------------------
|
|
40
|
+
|
|
41
|
+
RUN_TERMINAL_STATES = frozenset({"succeeded", "failed", "cancelled", "lost"})
|
|
42
|
+
CALL_TERMINAL_STATES = frozenset({"succeeded", "failed", "cancelled", "lost"})
|
|
43
|
+
|
|
44
|
+
# Ordered run lifecycle, for progress rendering.
|
|
45
|
+
RUN_PROGRESS_ORDER = (
|
|
46
|
+
"pending",
|
|
47
|
+
"scheduled",
|
|
48
|
+
"starting",
|
|
49
|
+
"running",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# --- Default network endpoints ----------------------------------------------
|
|
53
|
+
|
|
54
|
+
# The public control plane: what `pip install forge-shadow` should be able to
|
|
55
|
+
# talk to with nothing configured. This was `http://localhost:8080` — an
|
|
56
|
+
# orchestration dev convention that is nobody's plane once the package is
|
|
57
|
+
# installed, so `blade setup` with no --api-url died on a refused connection.
|
|
58
|
+
#
|
|
59
|
+
# Everything that runs against another plane already says so and does not reach
|
|
60
|
+
# this default: the runner injects FORGE_API_URL into the container, the e2e
|
|
61
|
+
# drivers and the CI jobs require it, and a developer running the control plane
|
|
62
|
+
# locally exports it (or `blade token set --api-url http://localhost:8080`).
|
|
63
|
+
# Env and config file both win over this value — see config.load_config.
|
|
64
|
+
DEFAULT_API_URL = "https://cp.run.blade.sh"
|
|
65
|
+
|
|
66
|
+
# --- Polling for .remote()/.get() (openapi GET /v1/calls/{id} guidance) -----
|
|
67
|
+
|
|
68
|
+
POLL_INITIAL_INTERVAL = 0.2
|
|
69
|
+
POLL_MAX_INTERVAL = 2.0
|
|
70
|
+
|
|
71
|
+
# --- map/queue/dict limits (conventions.md §6) -------------------------------
|
|
72
|
+
|
|
73
|
+
# map() inputs per POST /v1/maps/{id}/inputs (≤ 1000 per request).
|
|
74
|
+
MAP_INPUT_BATCH_SIZE = 1000
|
|
75
|
+
|
|
76
|
+
# Maximum inputs per map invocation (1 M).
|
|
77
|
+
MAP_MAX_INPUTS = 1_000_000
|
|
78
|
+
|
|
79
|
+
# Results page limit for GET /v1/maps/{id}/results (≤ 200, conventions §6).
|
|
80
|
+
MAP_RESULTS_PAGE_LIMIT = 200
|
|
81
|
+
|
|
82
|
+
# Polling intervals for map results (seconds).
|
|
83
|
+
MAP_POLL_INTERVAL = 0.3
|
|
84
|
+
MAP_POLL_MAX_INTERVAL = 3.0
|
|
85
|
+
|
|
86
|
+
# --- dynamic batching (@bladekit.batched) ---------------------------------------
|
|
87
|
+
|
|
88
|
+
# A batched function collects individual calls into one invocation that runs the
|
|
89
|
+
# user function once over the whole batch. A batch is dispatched as soon as it
|
|
90
|
+
# reaches max_batch_size inputs OR the per-batch wait window elapses.
|
|
91
|
+
#
|
|
92
|
+
# BATCH_MAX_SIZE_LIMIT is the platform serving ceiling (max_concurrent_inputs
|
|
93
|
+
# BETWEEN 1 AND 100 in the schema), NOT an arbitrary cap: a replica fills a batch
|
|
94
|
+
# by pulling calls concurrently, one runner worker per pending call, so the batch
|
|
95
|
+
# can never grow past the replica's serving concurrency. Accepting a larger
|
|
96
|
+
# max_batch_size here would silently cap the real batch at 100 and force every
|
|
97
|
+
# batch to wait the full window (the size trigger would be unreachable), so the
|
|
98
|
+
# decorator rejects anything above the ceiling instead. Mirrored by the control
|
|
99
|
+
# plane (runs.go batchServingCeiling) and the runner (config.max_concurrent_inputs).
|
|
100
|
+
BATCH_MAX_SIZE_LIMIT = 100 # ceiling for max_batch_size (== serving concurrency)
|
|
101
|
+
BATCH_WAIT_MS_LIMIT = 600_000 # ceiling for wait_ms (10 minutes)
|
|
102
|
+
|
|
103
|
+
# priority range (conventions §6 — 0–9, higher = more urgent).
|
|
104
|
+
PRIORITY_MIN = 0
|
|
105
|
+
PRIORITY_MAX = 9
|
|
106
|
+
PRIORITY_DEFAULT = 5
|
|
107
|
+
|
|
108
|
+
# retries (conventions §6).
|
|
109
|
+
RETRIES_MAX = 10
|
|
110
|
+
RETRIES_BACKOFF_INITIAL_MAX = 300
|
|
111
|
+
RETRIES_BACKOFF_MAX_MAX = 3600
|
|
112
|
+
RETRIES_BACKOFF_FACTOR_MIN = 1.0
|
|
113
|
+
RETRIES_BACKOFF_FACTOR_MAX = 10.0
|
|
114
|
+
|
|
115
|
+
# gpu_count (conventions §6 — borne relevée 1→8, additif).
|
|
116
|
+
GPU_COUNT_MIN = 0
|
|
117
|
+
GPU_COUNT_MAX = 8
|
|
118
|
+
|
|
119
|
+
# Queue/Dict limits.
|
|
120
|
+
QUEUE_GET_TIMEOUT_MAX = 600
|
|
121
|
+
DICT_KEY_MAX_BYTES = 256
|