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/_cron.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""bladekit.Cron — cron schedule declaration for @app.function.
|
|
2
|
+
|
|
3
|
+
A ``Cron`` object declares a cron schedule that the control plane's cron
|
|
4
|
+
scheduler will use to trigger the decorated function automatically.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
@app.function(schedule=bladekit.Cron("0 3 * * *"))
|
|
9
|
+
def nightly_job():
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
The cron expression must be a standard 5-field UTC cron string
|
|
13
|
+
(``minute hour day-of-month month day-of-week``). Validation is done both
|
|
14
|
+
locally (basic 5-field check) and by the server (CRON_EXPRESSION_INVALID).
|
|
15
|
+
|
|
16
|
+
The schedule is part of the deployment manifest (deploy path). For a
|
|
17
|
+
``blade run`` path, the schedule is stored in the function's metadata
|
|
18
|
+
but not automatically triggered — the control plane only triggers registered
|
|
19
|
+
crons in a deployed app.
|
|
20
|
+
|
|
21
|
+
Limits (conventions.md §6):
|
|
22
|
+
- Up to 64 crons per deployment.
|
|
23
|
+
- Cron history retained for 90 days or 1000 executions per schedule.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Very lightweight validation: 5 space-separated fields of printable non-space
|
|
32
|
+
# chars. The server performs authoritative validation (CRON_EXPRESSION_INVALID).
|
|
33
|
+
_CRON_RE = re.compile(
|
|
34
|
+
r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Cron:
|
|
39
|
+
"""A cron schedule for use with ``@app.function(schedule=bladekit.Cron(...))``.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
expression: A 5-field UTC cron expression, e.g. ``"0 3 * * *"`` (every
|
|
43
|
+
day at 03:00 UTC), ``"*/5 * * * *"`` (every 5 minutes).
|
|
44
|
+
overlap_policy: What to do if a previous execution is still running when
|
|
45
|
+
the next one is due. ``"skip"`` (default): skip the new trigger;
|
|
46
|
+
``"allow"``: start the new trigger alongside the existing one.
|
|
47
|
+
|
|
48
|
+
The expression is validated locally (structure only) and by the server on
|
|
49
|
+
deployment (``CRON_EXPRESSION_INVALID`` with ``details.field`` on error).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
expression: str,
|
|
55
|
+
*,
|
|
56
|
+
overlap_policy: str = "skip",
|
|
57
|
+
) -> None:
|
|
58
|
+
if not isinstance(expression, str):
|
|
59
|
+
raise TypeError(f"Cron expression must be a str, got {type(expression).__name__}")
|
|
60
|
+
expression = expression.strip()
|
|
61
|
+
if not _CRON_RE.match(expression):
|
|
62
|
+
from . import exceptions as exc
|
|
63
|
+
|
|
64
|
+
raise exc.InvalidArgumentError(
|
|
65
|
+
"CRON_EXPRESSION_INVALID",
|
|
66
|
+
f"cron expression {expression!r} must have exactly 5 space-separated "
|
|
67
|
+
"fields (minute hour day-of-month month day-of-week)",
|
|
68
|
+
)
|
|
69
|
+
valid_policies = ("skip", "allow")
|
|
70
|
+
if overlap_policy not in valid_policies:
|
|
71
|
+
from . import exceptions as exc
|
|
72
|
+
|
|
73
|
+
raise exc.InvalidArgumentError(
|
|
74
|
+
"INVALID_ARGUMENT",
|
|
75
|
+
f"overlap_policy must be one of {valid_policies}, got {overlap_policy!r}",
|
|
76
|
+
)
|
|
77
|
+
self.expression = expression
|
|
78
|
+
self.overlap_policy = overlap_policy
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"bladekit.Cron({self.expression!r})"
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
"""Serialize for inclusion in a deployment manifest or run create body."""
|
|
85
|
+
return {
|
|
86
|
+
"expression": self.expression,
|
|
87
|
+
"overlap_policy": self.overlap_policy,
|
|
88
|
+
}
|
bladekit/_dict.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""bladekit.Dict — distributed key-value store.
|
|
2
|
+
|
|
3
|
+
Persistent, backed by Postgres on the control plane. Used for sharing state
|
|
4
|
+
between remote workers (e.g. config broadcast, progress tracking, dedup sets).
|
|
5
|
+
|
|
6
|
+
Protocol (conventions.md §14.5):
|
|
7
|
+
- get(key): returns the value or None if absent (never raises NotFoundError).
|
|
8
|
+
- put(key, value): upsert — last-write-wins per key.
|
|
9
|
+
- contains(key): check existence without fetching the value.
|
|
10
|
+
- delete(key): remove a key (idempotent — no error if absent).
|
|
11
|
+
- [] accessor: d["key"] → get; d["key"] = v → put.
|
|
12
|
+
- from_name(name): get-or-create by name.
|
|
13
|
+
|
|
14
|
+
Limits (conventions.md §6):
|
|
15
|
+
- Up to 100,000 entries per Dict.
|
|
16
|
+
- Key: UTF-8 string, ≤ 256 bytes (DICT_KEY_TOO_LARGE).
|
|
17
|
+
- Value: ≤ 1 MiB inline; object store beyond (DICT_VALUE_TOO_LARGE).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Dict:
|
|
26
|
+
"""A distributed key-value store persistent in the AI Fabric control plane.
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
d = bladekit.Dict.from_name("checkpoints")
|
|
31
|
+
d["step"] = 42
|
|
32
|
+
step = d["step"] # 42
|
|
33
|
+
"step" in d # True
|
|
34
|
+
|
|
35
|
+
Keys are UTF-8 strings (≤ 256 bytes). Values are serialized with cloudpickle.
|
|
36
|
+
The dict is identified by name; ``from_name`` is idempotent (get-or-create).
|
|
37
|
+
``put`` is an upsert: last-write-wins per key (no transactions across keys).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, name: str, client: Any) -> None:
|
|
41
|
+
self._name = name
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
return self._name
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"bladekit.Dict(name={self._name!r})"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_name(cls, name: str, *, create_if_missing: bool = False) -> "Dict":
|
|
53
|
+
"""Reference a Dict by name (get-or-create on first use).
|
|
54
|
+
|
|
55
|
+
No round-trip here: the server creates the Dict lazily on the first
|
|
56
|
+
put/get (openapi.yaml: "get-or-create by name at usage time"). Two
|
|
57
|
+
references with the same name address the same store. ``create_if_missing``
|
|
58
|
+
is accepted for parity with ``Volume.from_name`` but is a no-op.
|
|
59
|
+
"""
|
|
60
|
+
_ = create_if_missing
|
|
61
|
+
from ._client import Client
|
|
62
|
+
from .config import load_config
|
|
63
|
+
from . import exceptions as exc_mod
|
|
64
|
+
|
|
65
|
+
cfg = load_config()
|
|
66
|
+
if not cfg.token:
|
|
67
|
+
raise exc_mod.AuthenticationError(
|
|
68
|
+
"UNAUTHENTICATED",
|
|
69
|
+
"no token configured: run `blade token set <token>` or set FABRIC_TOKEN",
|
|
70
|
+
)
|
|
71
|
+
return cls(name, Client(cfg.api_url, cfg.token))
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _from_client(cls, name: str, client: Any) -> "Dict":
|
|
75
|
+
"""Create a Dict object backed by an existing client (for tests)."""
|
|
76
|
+
return cls(name, client)
|
|
77
|
+
|
|
78
|
+
# -- key validation ---------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _validate_key(key: str) -> None:
|
|
82
|
+
from . import exceptions as exc_mod
|
|
83
|
+
|
|
84
|
+
if not isinstance(key, str):
|
|
85
|
+
raise exc_mod.InvalidArgumentError(
|
|
86
|
+
"INVALID_ARGUMENT", f"Dict key must be a str, got {type(key).__name__}"
|
|
87
|
+
)
|
|
88
|
+
encoded = key.encode("utf-8")
|
|
89
|
+
if len(encoded) > 256:
|
|
90
|
+
raise exc_mod.InvalidArgumentError(
|
|
91
|
+
"DICT_KEY_TOO_LARGE",
|
|
92
|
+
f"Dict key is {len(encoded)} bytes (UTF-8), exceeds the 256-byte limit",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# -- operations -------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
98
|
+
"""Return the value for ``key``, or ``default`` if absent."""
|
|
99
|
+
self._validate_key(key)
|
|
100
|
+
from ._serialize import decode_payload_out, loads
|
|
101
|
+
|
|
102
|
+
# DictGetResponse: {found, value}. found=false ⇒ absent (200, not 404).
|
|
103
|
+
resp = self._client.dict_get(self._name, key)
|
|
104
|
+
if not resp.get("found"):
|
|
105
|
+
return default
|
|
106
|
+
payload = resp.get("value")
|
|
107
|
+
if payload is None:
|
|
108
|
+
return default
|
|
109
|
+
raw = decode_payload_out(payload, fetch=self._client.download_presigned)
|
|
110
|
+
# put() serialized (value,) as args; unwrap.
|
|
111
|
+
result = loads(raw)
|
|
112
|
+
if isinstance(result, tuple) and len(result) == 2:
|
|
113
|
+
args, kwargs = result
|
|
114
|
+
if isinstance(args, tuple) and len(args) == 1 and not kwargs:
|
|
115
|
+
return args[0]
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def put(self, key: str, value: Any) -> None:
|
|
119
|
+
"""Upsert ``key`` with ``value`` (last-write-wins)."""
|
|
120
|
+
self._validate_key(key)
|
|
121
|
+
from ._serialize import dumps_args, make_payload_in
|
|
122
|
+
|
|
123
|
+
raw = dumps_args((value,), {})
|
|
124
|
+
payload_in, object_bytes = make_payload_in(raw)
|
|
125
|
+
if object_bytes is not None:
|
|
126
|
+
decl = self._client.create_payload(
|
|
127
|
+
payload_in["size_bytes"], payload_in["sha256"]
|
|
128
|
+
)
|
|
129
|
+
self._client.upload_to_presigned(
|
|
130
|
+
decl["put_url"], object_bytes, "application/octet-stream"
|
|
131
|
+
)
|
|
132
|
+
payload_in["payload_id"] = decl["payload_id"]
|
|
133
|
+
self._client.dict_put(self._name, key, payload_in)
|
|
134
|
+
|
|
135
|
+
def contains(self, key: str) -> bool:
|
|
136
|
+
"""Return True if ``key`` exists in the dict."""
|
|
137
|
+
self._validate_key(key)
|
|
138
|
+
resp = self._client.dict_get(self._name, key)
|
|
139
|
+
return bool(resp.get("found", False))
|
|
140
|
+
|
|
141
|
+
def delete(self, key: str) -> None:
|
|
142
|
+
"""Remove ``key`` from the dict (idempotent — no error if absent)."""
|
|
143
|
+
self._validate_key(key)
|
|
144
|
+
from . import exceptions as exc_mod
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
self._client.dict_delete(self._name, key)
|
|
148
|
+
except exc_mod.NotFoundError:
|
|
149
|
+
pass # idempotent — missing key is not an error
|
|
150
|
+
|
|
151
|
+
# -- Pythonic accessors ------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def __getitem__(self, key: str) -> Any:
|
|
154
|
+
result = self.get(key)
|
|
155
|
+
if result is None and not self.contains(key):
|
|
156
|
+
raise KeyError(key)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
160
|
+
self.put(key, value)
|
|
161
|
+
|
|
162
|
+
def __delitem__(self, key: str) -> None:
|
|
163
|
+
if not self.contains(key):
|
|
164
|
+
raise KeyError(key)
|
|
165
|
+
self.delete(key)
|
|
166
|
+
|
|
167
|
+
def __contains__(self, key: object) -> bool:
|
|
168
|
+
if not isinstance(key, str):
|
|
169
|
+
return False
|
|
170
|
+
return self.contains(key)
|
|
171
|
+
|
|
172
|
+
def delete_store(self) -> None:
|
|
173
|
+
"""Delete the entire Dict store."""
|
|
174
|
+
self._client.delete_dict(self._name)
|
bladekit/_env.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Environment variable lookup for the SDK and the in-container runtime.
|
|
2
|
+
|
|
3
|
+
The SDK reads ``FABRIC_<NAME>`` first and falls back to the legacy
|
|
4
|
+
``FORGE_<NAME>`` (PAAS-771). The fallback is load-bearing, not cosmetic: the
|
|
5
|
+
control plane still injects ``FORGE_*`` into every container (FORGE_API_URL,
|
|
6
|
+
FORGE_RUNNER_TOKEN, FORGE_VOLUMES, FORGE_SERVER_PORT, ...), and existing users
|
|
7
|
+
have ``FORGE_TOKEN`` / ``FORGE_API_URL`` in their shells and CI. Drop the
|
|
8
|
+
fallback only once the server injects ``FABRIC_*`` and the old names have been
|
|
9
|
+
deprecated for a release cycle.
|
|
10
|
+
|
|
11
|
+
An empty value counts as unset, matching how the SDK has always treated these
|
|
12
|
+
variables (``if os.environ.get(...)``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import warnings
|
|
19
|
+
from typing import Optional, Set, Tuple
|
|
20
|
+
|
|
21
|
+
PREFIX = "FABRIC_"
|
|
22
|
+
LEGACY_PREFIX = "FORGE_"
|
|
23
|
+
# Every prefix the runtime owns. User-defined image env layers may not use them
|
|
24
|
+
# (see Image.env), or a user value could shadow what the platform injects.
|
|
25
|
+
RESERVED_PREFIXES = (PREFIX, LEGACY_PREFIX)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Names already warned about (FABRIC_X and FORGE_X both set, different values),
|
|
29
|
+
# so a variable read on every request warns once per process.
|
|
30
|
+
_conflicts_warned: Set[str] = set()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lookup(name: str) -> Tuple[Optional[str], Optional[str]]:
|
|
34
|
+
"""Return ``(value, variable)`` for ``FABRIC_<name>``, else ``FORGE_<name>``.
|
|
35
|
+
|
|
36
|
+
``variable`` is the name actually read (for "where did this come from"
|
|
37
|
+
messages such as ``blade token show``); both are None when neither is set.
|
|
38
|
+
When both are set to different values FABRIC_* wins and a ``UserWarning``
|
|
39
|
+
says so (once per name per process).
|
|
40
|
+
"""
|
|
41
|
+
value = os.environ.get(PREFIX + name)
|
|
42
|
+
legacy = os.environ.get(LEGACY_PREFIX + name)
|
|
43
|
+
if value:
|
|
44
|
+
if legacy and legacy != value and name not in _conflicts_warned:
|
|
45
|
+
_conflicts_warned.add(name)
|
|
46
|
+
warnings.warn(
|
|
47
|
+
f"both {PREFIX}{name} and {LEGACY_PREFIX}{name} are set, to different "
|
|
48
|
+
f"values; using {PREFIX}{name} ({LEGACY_PREFIX}{name} is the deprecated "
|
|
49
|
+
"spelling).",
|
|
50
|
+
UserWarning,
|
|
51
|
+
stacklevel=3,
|
|
52
|
+
)
|
|
53
|
+
return value, PREFIX + name
|
|
54
|
+
if legacy:
|
|
55
|
+
return legacy, LEGACY_PREFIX + name
|
|
56
|
+
return None, None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get(name: str, default: Optional[str] = None) -> Optional[str]:
|
|
60
|
+
"""Value of ``FABRIC_<name>``, else ``FORGE_<name>``, else ``default``."""
|
|
61
|
+
value, _ = lookup(name)
|
|
62
|
+
return value if value is not None else default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flag(name: str) -> bool:
|
|
66
|
+
"""Boolean variable: set and not one of "", 0, false, no, off."""
|
|
67
|
+
value = get(name)
|
|
68
|
+
return value is not None and value.strip().lower() not in ("", "0", "false", "no", "off")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Variables the platform injects into a container for USER code to read
|
|
72
|
+
# (documented as FABRIC_*). The server still injects them as FORGE_* (PAAS-771),
|
|
73
|
+
# so importing the SDK copies each one to its FABRIC_* name when that is unset —
|
|
74
|
+
# `os.environ["FABRIC_SERVER_PORT"]` then works in a function body and in the
|
|
75
|
+
# subprocesses it spawns (e.g. `vllm serve --port $FABRIC_SERVER_PORT`).
|
|
76
|
+
# Credentials are deliberately absent: FORGE_RUNNER_TOKEN is scrubbed by the
|
|
77
|
+
# runner and must not be resurrected under another name.
|
|
78
|
+
MIRRORED_RUNTIME_VARS = (
|
|
79
|
+
"SERVER_PORT",
|
|
80
|
+
"VOLUMES",
|
|
81
|
+
"PREEMPT_GRACE_SECONDS",
|
|
82
|
+
"MAX_SESSION_SECONDS",
|
|
83
|
+
"TURN_URL",
|
|
84
|
+
"TURNS_URL",
|
|
85
|
+
"TURN_SECRET",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mirror_legacy_runtime_env() -> None:
|
|
90
|
+
"""Set ``FABRIC_<NAME>`` from ``FORGE_<NAME>`` for :data:`MIRRORED_RUNTIME_VARS`.
|
|
91
|
+
|
|
92
|
+
Never overwrites a FABRIC_* value already present; a no-op outside a
|
|
93
|
+
container, where none of these is set.
|
|
94
|
+
"""
|
|
95
|
+
for name in MIRRORED_RUNTIME_VARS:
|
|
96
|
+
legacy = os.environ.get(LEGACY_PREFIX + name)
|
|
97
|
+
if legacy and not os.environ.get(PREFIX + name):
|
|
98
|
+
os.environ[PREFIX + name] = legacy
|