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