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