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/_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