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/_batch.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``@bladekit.batched`` — server-side dynamic batching (SPEC: batching.md).
|
|
2
|
+
|
|
3
|
+
Dynamic batching auto-collects individual calls into a *batch* and invokes the
|
|
4
|
+
decorated function ONCE per batch, then fans the per-call results back to each
|
|
5
|
+
caller. This is distinct from ``@app.concurrent`` (which forwards N concurrent
|
|
6
|
+
requests to a long-lived server, each handled independently): a batched function
|
|
7
|
+
is written to take *lists* of arguments and return a *list* of results.
|
|
8
|
+
|
|
9
|
+
@app.function(gpu="RTX-A4500", image="fabric/torch")
|
|
10
|
+
@bladekit.batched(max_batch_size=32, wait_ms=50)
|
|
11
|
+
def embed(prompts: list[str]) -> list[list[float]]:
|
|
12
|
+
# `prompts` is the collected batch; return one result per prompt.
|
|
13
|
+
return model.encode(prompts).tolist()
|
|
14
|
+
|
|
15
|
+
Callers still invoke ONE item at a time and get ONE result back::
|
|
16
|
+
|
|
17
|
+
vec = embed.remote("hello") # batched with concurrent callers
|
|
18
|
+
handle = embed.spawn("world") # non-blocking
|
|
19
|
+
|
|
20
|
+
The batch fills when it reaches ``max_batch_size`` inputs OR ``wait_ms``
|
|
21
|
+
milliseconds elapse since the batch's first input — whichever comes first
|
|
22
|
+
(mirrors ``modal.batched``). The runner owns the accumulation/dispatch loop
|
|
23
|
+
(``forge_runner.batch``); this module is the SDK surface: the decorator, its
|
|
24
|
+
validation, and the wire ``batch_config`` carried on the run/deploy manifest.
|
|
25
|
+
|
|
26
|
+
CONTRACT: the collected argument is passed *positionally*. A batched function
|
|
27
|
+
takes exactly the positional parameters the individual call passes, each widened
|
|
28
|
+
to a list of the per-call values in batch order. Keyword arguments a caller
|
|
29
|
+
passes are batched into a list under the same keyword. Every returned list MUST
|
|
30
|
+
have the same length as the batch, else the runner raises
|
|
31
|
+
``BATCH_RESULT_LENGTH_MISMATCH`` and fails every call in the batch.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any, Callable, Optional
|
|
37
|
+
|
|
38
|
+
from . import exceptions as exc
|
|
39
|
+
from ._constants import BATCH_MAX_SIZE_LIMIT, BATCH_WAIT_MS_LIMIT
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_batch_config(max_batch_size: int, wait_ms: int) -> None:
|
|
43
|
+
"""Validate the batching knobs at decoration time (before any network I/O)."""
|
|
44
|
+
if (
|
|
45
|
+
not isinstance(max_batch_size, int)
|
|
46
|
+
or isinstance(max_batch_size, bool)
|
|
47
|
+
or max_batch_size < 1
|
|
48
|
+
or max_batch_size > BATCH_MAX_SIZE_LIMIT
|
|
49
|
+
):
|
|
50
|
+
raise exc.InvalidArgumentError(
|
|
51
|
+
"BATCH_CONFIG_INVALID",
|
|
52
|
+
f"max_batch_size must be an integer between 1 and "
|
|
53
|
+
f"{BATCH_MAX_SIZE_LIMIT}; got {max_batch_size!r}",
|
|
54
|
+
)
|
|
55
|
+
if (
|
|
56
|
+
not isinstance(wait_ms, int)
|
|
57
|
+
or isinstance(wait_ms, bool)
|
|
58
|
+
or wait_ms < 0
|
|
59
|
+
or wait_ms > BATCH_WAIT_MS_LIMIT
|
|
60
|
+
):
|
|
61
|
+
raise exc.InvalidArgumentError(
|
|
62
|
+
"BATCH_CONFIG_INVALID",
|
|
63
|
+
f"wait_ms must be an integer between 0 and {BATCH_WAIT_MS_LIMIT}; "
|
|
64
|
+
f"got {wait_ms!r}",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BatchedFunction:
|
|
69
|
+
"""Wraps a batch-shaped user function, carrying its batch configuration.
|
|
70
|
+
|
|
71
|
+
``@app.function`` detects this wrapper (via ``__forge_batched__``) and folds
|
|
72
|
+
the config into the ``Function`` so the run/deploy manifest carries it. The
|
|
73
|
+
object is transparently callable — it delegates to the user function — so
|
|
74
|
+
``.local()`` (which passes lists directly) and any introspection keep working
|
|
75
|
+
exactly as if the plain function had been decorated.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, fn: Callable[..., Any], max_batch_size: int, wait_ms: int) -> None:
|
|
79
|
+
_validate_batch_config(max_batch_size, wait_ms)
|
|
80
|
+
self._fn = fn
|
|
81
|
+
self.max_batch_size = max_batch_size
|
|
82
|
+
self.wait_ms = wait_ms
|
|
83
|
+
# The wire config; the shape mirrors the runner's parser and the server's
|
|
84
|
+
# EndpointSpec.batch_config (batching.md §4).
|
|
85
|
+
self.__forge_batched__ = {
|
|
86
|
+
"max_batch_size": max_batch_size,
|
|
87
|
+
"wait_ms": wait_ms,
|
|
88
|
+
}
|
|
89
|
+
# Keep the wrapped function introspectable / importable by the runner:
|
|
90
|
+
# FunctionRef.qualname points at the module attribute, which is THIS
|
|
91
|
+
# object, so it must carry the user function's identity.
|
|
92
|
+
self.__name__ = getattr(fn, "__name__", "fn")
|
|
93
|
+
self.__qualname__ = getattr(fn, "__qualname__", self.__name__)
|
|
94
|
+
self.__module__ = getattr(fn, "__module__", "__main__")
|
|
95
|
+
self.__doc__ = getattr(fn, "__doc__", None)
|
|
96
|
+
|
|
97
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
98
|
+
"""Invoke the underlying batch function directly (lists in, list out).
|
|
99
|
+
|
|
100
|
+
This is the path the runner takes once it has assembled a batch, and the
|
|
101
|
+
path ``.local()`` takes for local testing with already-batched inputs.
|
|
102
|
+
"""
|
|
103
|
+
return self._fn(*args, **kwargs)
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str:
|
|
106
|
+
return (
|
|
107
|
+
f"<bladekit.batched {self.__qualname__} "
|
|
108
|
+
f"max_batch_size={self.max_batch_size} wait_ms={self.wait_ms}>"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def batched(
|
|
113
|
+
_fn: Optional[Callable[..., Any]] = None,
|
|
114
|
+
*,
|
|
115
|
+
max_batch_size: int,
|
|
116
|
+
wait_ms: int,
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""Decorator: auto-collect individual calls into server-side batches.
|
|
119
|
+
|
|
120
|
+
Stack it UNDER ``@app.function`` (Modal order), so the function decorator
|
|
121
|
+
wraps the batched function::
|
|
122
|
+
|
|
123
|
+
@app.function(gpu="RTX-A4500", image="fabric/torch")
|
|
124
|
+
@bladekit.batched(max_batch_size=32, wait_ms=50)
|
|
125
|
+
def embed(prompts: list[str]) -> list[list[float]]:
|
|
126
|
+
return model.encode(prompts).tolist()
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
max_batch_size: the maximum number of individual calls fused into one
|
|
130
|
+
invocation (1..1000). A batch is dispatched as soon as it fills.
|
|
131
|
+
wait_ms: how long the runner waits, after the batch's first input, for
|
|
132
|
+
more inputs to arrive before dispatching a partial batch (0..600000).
|
|
133
|
+
``0`` dispatches as soon as the event loop yields — useful when you
|
|
134
|
+
only want to fuse calls that are already queued.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A :class:`BatchedFunction` wrapping the user function.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def wrap(fn: Callable[..., Any]) -> BatchedFunction:
|
|
141
|
+
if isinstance(fn, BatchedFunction):
|
|
142
|
+
raise exc.InvalidArgumentError(
|
|
143
|
+
"BATCH_CONFIG_INVALID",
|
|
144
|
+
"@bladekit.batched cannot wrap an already-batched function",
|
|
145
|
+
)
|
|
146
|
+
return BatchedFunction(fn, max_batch_size, wait_ms)
|
|
147
|
+
|
|
148
|
+
if _fn is not None:
|
|
149
|
+
return wrap(_fn)
|
|
150
|
+
return wrap
|
bladekit/_bucket.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""``bladekit.CloudBucketMount`` — mount an external object-store bucket as a path.
|
|
2
|
+
|
|
3
|
+
A ``CloudBucketMount`` mounts an S3 / GCS / R2 / MinIO bucket (anything that
|
|
4
|
+
speaks the S3 API) as a POSIX filesystem path inside a function, deployed
|
|
5
|
+
replica, or sandbox, using a FUSE mount helper (``s3fs``/``goofys``/``rclone``)
|
|
6
|
+
that the runner sets up on container start and tears down on teardown.
|
|
7
|
+
|
|
8
|
+
Unlike :class:`bladekit.Volume` — an *AI Fabric-managed* network volume with generations
|
|
9
|
+
and ``commit()`` — a bucket mount is a live view of a bucket **you own** in an
|
|
10
|
+
**external** cloud account. AI Fabric never stores the objects; it only mounts them.
|
|
11
|
+
|
|
12
|
+
Usage — pass it in the same ``volumes=`` map as a ``Volume`` (both are
|
|
13
|
+
"filesystem things mounted at a path"), keyed by mount path::
|
|
14
|
+
|
|
15
|
+
creds = bladekit.Secret.from_name("aws-credentials")
|
|
16
|
+
|
|
17
|
+
@app.function(
|
|
18
|
+
image=img,
|
|
19
|
+
volumes={
|
|
20
|
+
"/mnt/data": bladekit.CloudBucketMount(
|
|
21
|
+
"my-data-bucket",
|
|
22
|
+
secret=creds,
|
|
23
|
+
read_only=True,
|
|
24
|
+
key_prefix="raw/2024/",
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
def process():
|
|
29
|
+
import pathlib
|
|
30
|
+
for p in pathlib.Path("/mnt/data").glob("*.parquet"):
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
Security invariant (audit-security-idor.md, conventions.md §11): the bucket
|
|
34
|
+
credentials come **only** from a :class:`bladekit.Secret`, never inline. The mount
|
|
35
|
+
spec on the wire carries the *secret name*, never a value; the control plane
|
|
36
|
+
resolves the secret and injects its values as container env vars (the same path
|
|
37
|
+
as ``secrets=[...]``), and the runner reads those env vars at mount time. A
|
|
38
|
+
credential value never appears in the SDK object, the run body, or a log line.
|
|
39
|
+
|
|
40
|
+
``read_only=True`` (the default is read-write) mounts the bucket without write
|
|
41
|
+
access — the runner passes the mount helper's read-only flag and the mount is
|
|
42
|
+
refused any object PUT/DELETE.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any, Dict, Optional
|
|
48
|
+
|
|
49
|
+
from . import exceptions as exc
|
|
50
|
+
|
|
51
|
+
# S3 bucket names are 3–63 chars, lowercase letters/digits/dots/hyphens. We keep
|
|
52
|
+
# the check permissive (providers vary: GCS allows underscores, R2 is looser)
|
|
53
|
+
# but reject the obvious footguns — empty, whitespace, a leading scheme — so a
|
|
54
|
+
# typo fails in the SDK instead of as an opaque mount error in the container.
|
|
55
|
+
_MAX_BUCKET_LEN = 255
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CloudBucketMount:
|
|
59
|
+
"""Reference to an external object-store bucket mounted at a path.
|
|
60
|
+
|
|
61
|
+
Construct directly (there is no ``from_name`` — a bucket is not an AI Fabric
|
|
62
|
+
object, it is your external bucket)::
|
|
63
|
+
|
|
64
|
+
bladekit.CloudBucketMount("my-bucket", secret=bladekit.Secret.from_name("aws-credentials"))
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
bucket_name:
|
|
69
|
+
The external bucket to mount (S3/GCS/R2/MinIO).
|
|
70
|
+
secret:
|
|
71
|
+
A :class:`bladekit.Secret` holding the credentials. Required — credentials
|
|
72
|
+
are never passed inline. The secret's env keys are provider-specific
|
|
73
|
+
(e.g. ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` for S3).
|
|
74
|
+
read_only:
|
|
75
|
+
Mount without write access (default ``False`` = read-write).
|
|
76
|
+
key_prefix:
|
|
77
|
+
Mount only the objects under this key prefix (must end with ``/``); the
|
|
78
|
+
prefix is stripped from the visible paths. Omit to mount the whole
|
|
79
|
+
bucket.
|
|
80
|
+
bucket_endpoint_url:
|
|
81
|
+
Override the S3 endpoint for non-AWS stores (R2, MinIO, GCS-S3,
|
|
82
|
+
Scaleway). Omit for AWS S3.
|
|
83
|
+
requester_pays:
|
|
84
|
+
Set for buckets configured with S3 Requester Pays.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = (
|
|
88
|
+
"bucket_name",
|
|
89
|
+
"_secret",
|
|
90
|
+
"_read_only",
|
|
91
|
+
"_key_prefix",
|
|
92
|
+
"_endpoint_url",
|
|
93
|
+
"_requester_pays",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
bucket_name: str,
|
|
99
|
+
*,
|
|
100
|
+
secret: Any,
|
|
101
|
+
read_only: bool = False,
|
|
102
|
+
key_prefix: Optional[str] = None,
|
|
103
|
+
bucket_endpoint_url: Optional[str] = None,
|
|
104
|
+
requester_pays: bool = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
# Import here (not at module top) to avoid any import cycle and keep the
|
|
107
|
+
# isinstance check against the concrete class.
|
|
108
|
+
from ._secret import Secret
|
|
109
|
+
|
|
110
|
+
if not isinstance(bucket_name, str) or not bucket_name.strip():
|
|
111
|
+
raise exc.InvalidArgumentError(
|
|
112
|
+
"INVALID_ARGUMENT", "bucket_name must be a non-empty string"
|
|
113
|
+
)
|
|
114
|
+
if len(bucket_name) > _MAX_BUCKET_LEN:
|
|
115
|
+
raise exc.InvalidArgumentError(
|
|
116
|
+
"INVALID_ARGUMENT",
|
|
117
|
+
f"bucket_name too long ({len(bucket_name)} > {_MAX_BUCKET_LEN})",
|
|
118
|
+
)
|
|
119
|
+
if "://" in bucket_name or "/" in bucket_name:
|
|
120
|
+
raise exc.InvalidArgumentError(
|
|
121
|
+
"INVALID_ARGUMENT",
|
|
122
|
+
f"bucket_name {bucket_name!r} must be a bare bucket name, not a "
|
|
123
|
+
"URL or path (use key_prefix= for a sub-path)",
|
|
124
|
+
)
|
|
125
|
+
# Credentials come ONLY from a Secret, never inline (security invariant).
|
|
126
|
+
if not isinstance(secret, Secret):
|
|
127
|
+
raise exc.InvalidArgumentError(
|
|
128
|
+
"INVALID_ARGUMENT",
|
|
129
|
+
"CloudBucketMount requires secret=bladekit.Secret.from_name(...); "
|
|
130
|
+
"credentials are never passed inline",
|
|
131
|
+
)
|
|
132
|
+
if key_prefix is not None:
|
|
133
|
+
if not isinstance(key_prefix, str) or not key_prefix:
|
|
134
|
+
raise exc.InvalidArgumentError(
|
|
135
|
+
"INVALID_ARGUMENT", "key_prefix must be a non-empty string"
|
|
136
|
+
)
|
|
137
|
+
if key_prefix.startswith("/"):
|
|
138
|
+
raise exc.InvalidArgumentError(
|
|
139
|
+
"INVALID_ARGUMENT",
|
|
140
|
+
"key_prefix must be a bucket-relative key, not start with '/'",
|
|
141
|
+
)
|
|
142
|
+
if not key_prefix.endswith("/"):
|
|
143
|
+
raise exc.InvalidArgumentError(
|
|
144
|
+
"INVALID_ARGUMENT",
|
|
145
|
+
f"key_prefix {key_prefix!r} must end with '/'",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self.bucket_name = bucket_name
|
|
149
|
+
self._secret = secret
|
|
150
|
+
self._read_only = bool(read_only)
|
|
151
|
+
self._key_prefix = key_prefix
|
|
152
|
+
self._endpoint_url = bucket_endpoint_url
|
|
153
|
+
self._requester_pays = bool(requester_pays)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def secret_name(self) -> str:
|
|
157
|
+
"""Name of the Secret that provides this bucket's credentials."""
|
|
158
|
+
return self._secret.name
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def read_only(self) -> bool:
|
|
162
|
+
return self._read_only
|
|
163
|
+
|
|
164
|
+
def _spec(self, mount_path: str) -> Dict[str, Any]:
|
|
165
|
+
"""Serialize to the wire spec (openapi BucketMountSpec).
|
|
166
|
+
|
|
167
|
+
Carries the secret *name* only — never a credential value. Optional
|
|
168
|
+
fields are emitted only when set so a minimal mount body stays minimal.
|
|
169
|
+
"""
|
|
170
|
+
spec: Dict[str, Any] = {
|
|
171
|
+
"bucket_name": self.bucket_name,
|
|
172
|
+
"mount_path": mount_path,
|
|
173
|
+
"secret": self._secret.name,
|
|
174
|
+
}
|
|
175
|
+
if self._read_only:
|
|
176
|
+
spec["read_only"] = True
|
|
177
|
+
if self._key_prefix is not None:
|
|
178
|
+
spec["key_prefix"] = self._key_prefix
|
|
179
|
+
if self._endpoint_url is not None:
|
|
180
|
+
spec["endpoint_url"] = self._endpoint_url
|
|
181
|
+
if self._requester_pays:
|
|
182
|
+
spec["requester_pays"] = True
|
|
183
|
+
return spec
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
ro = ", read_only=True" if self._read_only else ""
|
|
187
|
+
return f"<bladekit.CloudBucketMount bucket={self.bucket_name!r}{ro}>"
|
|
188
|
+
|
|
189
|
+
def __eq__(self, other: object) -> bool:
|
|
190
|
+
if not isinstance(other, CloudBucketMount):
|
|
191
|
+
return NotImplemented
|
|
192
|
+
return (
|
|
193
|
+
self.bucket_name == other.bucket_name
|
|
194
|
+
and self._secret == other._secret
|
|
195
|
+
and self._read_only == other._read_only
|
|
196
|
+
and self._key_prefix == other._key_prefix
|
|
197
|
+
and self._endpoint_url == other._endpoint_url
|
|
198
|
+
and self._requester_pays == other._requester_pays
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def __hash__(self) -> int:
|
|
202
|
+
return hash(
|
|
203
|
+
(
|
|
204
|
+
self.bucket_name,
|
|
205
|
+
self._secret,
|
|
206
|
+
self._read_only,
|
|
207
|
+
self._key_prefix,
|
|
208
|
+
self._endpoint_url,
|
|
209
|
+
self._requester_pays,
|
|
210
|
+
)
|
|
211
|
+
)
|