launchhelm 0.1.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.
- launchhelm/__init__.py +35 -0
- launchhelm/_version.py +5 -0
- launchhelm/client.py +1660 -0
- launchhelm/commands.py +231 -0
- launchhelm/env.py +70 -0
- launchhelm/errors.py +62 -0
- launchhelm/models.py +6546 -0
- launchhelm/py.typed +0 -0
- launchhelm/rich.py +807 -0
- launchhelm/runtime.py +1348 -0
- launchhelm/session.py +2824 -0
- launchhelm/store.py +893 -0
- launchhelm/system.py +213 -0
- launchhelm/torch/__init__.py +41 -0
- launchhelm/torch/_checkpoint.py +181 -0
- launchhelm/torch/_controls.py +306 -0
- launchhelm/torch/_dist.py +210 -0
- launchhelm/torch/_trainer.py +1167 -0
- launchhelm/torch/hf.py +172 -0
- launchhelm/torch/lightning.py +179 -0
- launchhelm/transport.py +197 -0
- launchhelm/values.py +325 -0
- launchhelm-0.1.0.dist-info/METADATA +193 -0
- launchhelm-0.1.0.dist-info/RECORD +26 -0
- launchhelm-0.1.0.dist-info/WHEEL +4 -0
- launchhelm-0.1.0.dist-info/licenses/LICENSE +201 -0
launchhelm/commands.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Turn a typed function into a capability the session can register.
|
|
2
|
+
|
|
3
|
+
Contract: parameter annotations ``int``, ``float``, ``str``, and ``bool``
|
|
4
|
+
become scalar fields. ``list`` of those four becomes a list field. ``Schedule``
|
|
5
|
+
becomes a schedule field of decimal points. A ``TypedDict`` becomes a nested
|
|
6
|
+
record of those same types. ``T | None``
|
|
7
|
+
or a default of ``None`` makes the field optional. A parameter named
|
|
8
|
+
``context`` and annotated as ``CommandContext`` is injected and is not part
|
|
9
|
+
of the schema. ``None`` returns become an empty object.
|
|
10
|
+
|
|
11
|
+
Failure: ``TypeError`` for a missing annotation or a type this helper cannot
|
|
12
|
+
describe.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
import re
|
|
19
|
+
import types
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import (
|
|
22
|
+
Any,
|
|
23
|
+
TypedDict,
|
|
24
|
+
Union,
|
|
25
|
+
get_args,
|
|
26
|
+
get_origin,
|
|
27
|
+
get_type_hints,
|
|
28
|
+
is_typeddict,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .models import Capability, CommandContext, opaque_schema
|
|
32
|
+
from .values import JSONObject, JSONValue
|
|
33
|
+
|
|
34
|
+
CommandFunction = Callable[..., JSONValue | None]
|
|
35
|
+
_NAME = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SchedulePoint(TypedDict):
|
|
39
|
+
"""One decimal ``at`` and ``value`` on a piecewise schedule."""
|
|
40
|
+
|
|
41
|
+
at: str
|
|
42
|
+
value: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Schedule(TypedDict):
|
|
46
|
+
"""A command field of decimal points. ``command`` infers this annotation."""
|
|
47
|
+
|
|
48
|
+
points: list[SchedulePoint]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def command_capability(function: CommandFunction, safe_point: str, *, name: str = "") -> Capability:
|
|
52
|
+
"""Build a capability whose input fields match ``function`` parameters.
|
|
53
|
+
|
|
54
|
+
Contract: the capability name is ``name`` or the function name. The only
|
|
55
|
+
safe point is ``safe_point``.
|
|
56
|
+
|
|
57
|
+
Failure: ``TypeError`` from ``input_schema``. ``ValueError`` for a name
|
|
58
|
+
the protocol rejects.
|
|
59
|
+
"""
|
|
60
|
+
chosen = name or function.__name__
|
|
61
|
+
if _NAME.fullmatch(chosen) is None:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"command name must start with a lowercase letter and contain only "
|
|
64
|
+
"lowercase letters, digits, and _.:-"
|
|
65
|
+
)
|
|
66
|
+
return Capability(
|
|
67
|
+
name=chosen,
|
|
68
|
+
provider="script",
|
|
69
|
+
version="1.0.0",
|
|
70
|
+
input_schema=input_schema(function),
|
|
71
|
+
output_schema=opaque_schema(),
|
|
72
|
+
safe_points=(safe_point,),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def input_schema(function: CommandFunction) -> JSONObject:
|
|
77
|
+
"""Return the semantic record schema for ``function`` parameters.
|
|
78
|
+
|
|
79
|
+
Failure: ``TypeError`` when a parameter has no supported annotation.
|
|
80
|
+
"""
|
|
81
|
+
fields: list[JSONValue] = []
|
|
82
|
+
hints = _hints(function)
|
|
83
|
+
for parameter in _parameters(function):
|
|
84
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
85
|
+
field: JSONObject = {
|
|
86
|
+
"name": parameter.name,
|
|
87
|
+
"type": _type(annotation, parameter.name),
|
|
88
|
+
}
|
|
89
|
+
if _optional(annotation, parameter):
|
|
90
|
+
field["optional"] = True
|
|
91
|
+
fields.append(field)
|
|
92
|
+
return {
|
|
93
|
+
"revision": "1",
|
|
94
|
+
"root": {"kind": "record", "renderer": "form", "fields": fields},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def adapt(
|
|
99
|
+
function: CommandFunction,
|
|
100
|
+
) -> Callable[[JSONValue, CommandContext], JSONValue]:
|
|
101
|
+
"""Return a handler that unpacks a payload into ``function`` parameters.
|
|
102
|
+
|
|
103
|
+
Contract: ``context`` is passed only when the function declares it.
|
|
104
|
+
``None`` becomes ``{}``.
|
|
105
|
+
|
|
106
|
+
Failure: ``TypeError`` when the payload is not an object.
|
|
107
|
+
"""
|
|
108
|
+
parameters = _parameters(function)
|
|
109
|
+
hints = _hints(function)
|
|
110
|
+
wants_context = _wants_context(function)
|
|
111
|
+
|
|
112
|
+
def handler(payload: JSONValue, context: CommandContext) -> JSONValue:
|
|
113
|
+
if not isinstance(payload, dict):
|
|
114
|
+
raise TypeError("command payload must be an object")
|
|
115
|
+
arguments: dict[str, Any] = {}
|
|
116
|
+
for parameter in parameters:
|
|
117
|
+
name = parameter.name
|
|
118
|
+
if name in payload:
|
|
119
|
+
arguments[name] = payload[name]
|
|
120
|
+
continue
|
|
121
|
+
if parameter.default is not inspect.Parameter.empty:
|
|
122
|
+
arguments[name] = parameter.default
|
|
123
|
+
continue
|
|
124
|
+
annotation = hints.get(name, parameter.annotation)
|
|
125
|
+
if _optional(annotation, parameter):
|
|
126
|
+
arguments[name] = None
|
|
127
|
+
continue
|
|
128
|
+
raise TypeError(f"command payload is missing {name}")
|
|
129
|
+
if wants_context:
|
|
130
|
+
arguments["context"] = context
|
|
131
|
+
result = function(**arguments)
|
|
132
|
+
if result is None:
|
|
133
|
+
return {}
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
return handler
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parameters(function: CommandFunction) -> list[inspect.Parameter]:
|
|
140
|
+
signature = inspect.signature(function)
|
|
141
|
+
hints = _hints(function)
|
|
142
|
+
parameters: list[inspect.Parameter] = []
|
|
143
|
+
for parameter in signature.parameters.values():
|
|
144
|
+
if parameter.name == "self":
|
|
145
|
+
continue
|
|
146
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
147
|
+
if parameter.name == "context" and annotation is CommandContext:
|
|
148
|
+
continue
|
|
149
|
+
parameters.append(parameter)
|
|
150
|
+
return parameters
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _wants_context(function: CommandFunction) -> bool:
|
|
154
|
+
hints = _hints(function)
|
|
155
|
+
return hints.get("context") is CommandContext
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hints(function: CommandFunction) -> dict[str, Any]:
|
|
159
|
+
try:
|
|
160
|
+
return get_type_hints(function)
|
|
161
|
+
except (NameError, TypeError, AttributeError):
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _optional(annotation: object, parameter: inspect.Parameter) -> bool:
|
|
166
|
+
if parameter.default is None:
|
|
167
|
+
return True
|
|
168
|
+
origin = get_origin(annotation)
|
|
169
|
+
if origin is Union or origin is types.UnionType:
|
|
170
|
+
return type(None) in get_args(annotation)
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _unwrap(annotation: object) -> object:
|
|
175
|
+
origin = get_origin(annotation)
|
|
176
|
+
if origin is Union or origin is types.UnionType:
|
|
177
|
+
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
178
|
+
if len(args) == 1:
|
|
179
|
+
return args[0]
|
|
180
|
+
return annotation
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _type(annotation: object, name: str, depth: int = 0) -> JSONObject:
|
|
184
|
+
annotation = _unwrap(annotation)
|
|
185
|
+
if annotation is Schedule or annotation == "Schedule":
|
|
186
|
+
return {"kind": "schedule", "renderer": "generic"}
|
|
187
|
+
if is_typeddict(annotation):
|
|
188
|
+
return _record(annotation, name, depth)
|
|
189
|
+
origin = get_origin(annotation)
|
|
190
|
+
if origin is list:
|
|
191
|
+
args = get_args(annotation)
|
|
192
|
+
if len(args) != 1:
|
|
193
|
+
raise TypeError(f"parameter {name} list must name one item type")
|
|
194
|
+
return {
|
|
195
|
+
"kind": "list",
|
|
196
|
+
"renderer": "generic",
|
|
197
|
+
"item": _type(args[0], name, depth + 1),
|
|
198
|
+
}
|
|
199
|
+
return _scalar(annotation, name)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _record(annotation: object, name: str, depth: int) -> JSONObject:
|
|
203
|
+
if depth > 8:
|
|
204
|
+
raise TypeError(f"parameter {name} record is nested too deeply")
|
|
205
|
+
hints = get_type_hints(annotation)
|
|
206
|
+
required = set(getattr(annotation, "__required_keys__", hints))
|
|
207
|
+
fields: list[JSONValue] = []
|
|
208
|
+
for field_name, field_type in hints.items():
|
|
209
|
+
field: JSONObject = {
|
|
210
|
+
"name": field_name,
|
|
211
|
+
"type": _type(field_type, f"{name}.{field_name}", depth + 1),
|
|
212
|
+
}
|
|
213
|
+
if field_name not in required:
|
|
214
|
+
field["optional"] = True
|
|
215
|
+
fields.append(field)
|
|
216
|
+
return {"kind": "record", "renderer": "form", "fields": fields}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _scalar(annotation: object, name: str) -> JSONObject:
|
|
220
|
+
if annotation is int:
|
|
221
|
+
return {"kind": "scalar", "scalar": "integer", "renderer": "numeric"}
|
|
222
|
+
if annotation is float:
|
|
223
|
+
return {"kind": "scalar", "scalar": "number", "renderer": "numeric"}
|
|
224
|
+
if annotation is str:
|
|
225
|
+
return {"kind": "scalar", "scalar": "string", "renderer": "text"}
|
|
226
|
+
if annotation is bool:
|
|
227
|
+
return {"kind": "scalar", "scalar": "boolean", "renderer": "check"}
|
|
228
|
+
raise TypeError(
|
|
229
|
+
f"parameter {name} must be int, float, str, bool, Schedule, "
|
|
230
|
+
"a TypedDict of those, a list of those, or optional"
|
|
231
|
+
)
|
launchhelm/env.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Load ``.env`` files without overriding the process environment.
|
|
2
|
+
|
|
3
|
+
Contract: keys already set in the shell stay as they are. Files are read
|
|
4
|
+
from the start directory up to the filesystem root. A file closer to the
|
|
5
|
+
start wins over a parent file for keys that were unset.
|
|
6
|
+
|
|
7
|
+
Failure: unreadable or malformed lines are skipped. A missing ``.env`` is
|
|
8
|
+
not an error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_env(start: Path | None = None) -> None:
|
|
18
|
+
"""Apply parent ``.env`` files onto unset environment keys.
|
|
19
|
+
|
|
20
|
+
Contract: see the module docstring. ``start`` defaults to the current
|
|
21
|
+
working directory.
|
|
22
|
+
|
|
23
|
+
Failure: none. Individual bad lines are ignored.
|
|
24
|
+
"""
|
|
25
|
+
origin = (start or Path.cwd()).resolve()
|
|
26
|
+
directories: list[Path] = []
|
|
27
|
+
current = origin
|
|
28
|
+
while True:
|
|
29
|
+
directories.append(current)
|
|
30
|
+
if current.parent == current:
|
|
31
|
+
break
|
|
32
|
+
current = current.parent
|
|
33
|
+
preset = set(os.environ)
|
|
34
|
+
found: dict[str, str] = {}
|
|
35
|
+
for directory in reversed(directories):
|
|
36
|
+
path = directory / ".env"
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
for key, value in _parse(path):
|
|
40
|
+
if key not in preset:
|
|
41
|
+
found[key] = value
|
|
42
|
+
os.environ.update(found)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse(path: Path) -> list[tuple[str, str]]:
|
|
46
|
+
try:
|
|
47
|
+
text = path.read_text(encoding="utf-8")
|
|
48
|
+
except OSError:
|
|
49
|
+
return []
|
|
50
|
+
pairs: list[tuple[str, str]] = []
|
|
51
|
+
for raw in text.splitlines():
|
|
52
|
+
line = raw.strip()
|
|
53
|
+
if not line or line.startswith("#"):
|
|
54
|
+
continue
|
|
55
|
+
if line.startswith("export "):
|
|
56
|
+
line = line[len("export ") :].strip()
|
|
57
|
+
if "=" not in line:
|
|
58
|
+
continue
|
|
59
|
+
key, value = line.split("=", 1)
|
|
60
|
+
key = key.strip()
|
|
61
|
+
if not key or not key.replace("_", "").isalnum() or key[0].isdigit():
|
|
62
|
+
continue
|
|
63
|
+
pairs.append((key, _unquote(value.strip())))
|
|
64
|
+
return pairs
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unquote(value: str) -> str:
|
|
68
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
69
|
+
return value[1:-1]
|
|
70
|
+
return value
|
launchhelm/errors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Public error types for the LaunchHelm Python SDK.
|
|
2
|
+
|
|
3
|
+
Contract: every error carries a stable ``code`` suitable for logs and
|
|
4
|
+
reconciliation. ``retryable`` is true only when repeating the same request
|
|
5
|
+
with the same idempotency key is the specified recovery.
|
|
6
|
+
|
|
7
|
+
Failure: these classes are the failure; they are never used as values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LaunchHelmError(Exception):
|
|
14
|
+
"""Protocol, transport, or local runtime failure.
|
|
15
|
+
|
|
16
|
+
Contract: ``code`` is the machine-readable identity. ``status`` is the
|
|
17
|
+
HTTP status when the error came from a response, otherwise ``0``.
|
|
18
|
+
``retryable`` is true only when the outcome is uncertain or the server
|
|
19
|
+
asked for a retry; it is never true for a definitive rejection.
|
|
20
|
+
|
|
21
|
+
Failure: raising this error fails the calling operation. Callers must not
|
|
22
|
+
treat a non-retryable error as success.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, code: str, message: str, *, status: int = 0, retryable: bool = False
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(f"{code}: {message}")
|
|
29
|
+
self.code = code
|
|
30
|
+
self.status = status
|
|
31
|
+
self.retryable = retryable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BufferFull(LaunchHelmError):
|
|
35
|
+
"""A configured memory, spool, or command-journal byte budget is exhausted.
|
|
36
|
+
|
|
37
|
+
Contract: queued telemetry is preserved. The caller must shed load, wait
|
|
38
|
+
for drain, or enlarge the budget. Command-journal exhaustion fails closed
|
|
39
|
+
so tombstones cannot be dropped.
|
|
40
|
+
|
|
41
|
+
Failure: this is the failure. It is not retryable by repeating the same
|
|
42
|
+
``log`` / ``remember`` call without releasing budget.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
super().__init__("buffer_full", "The configured durable buffer limit was reached")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RuntimeFenced(LaunchHelmError):
|
|
50
|
+
"""This process no longer owns the participant execution session.
|
|
51
|
+
|
|
52
|
+
Contract: once fenced, ``log`` and ``flush`` raise this error. In-flight
|
|
53
|
+
points already snapshotted under the previous epoch remain associated
|
|
54
|
+
with that epoch. Unknown command effects are never automatically
|
|
55
|
+
re-executed.
|
|
56
|
+
|
|
57
|
+
Failure: this is terminal for the Runtime instance; construct a new one
|
|
58
|
+
after reconciliation.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
super().__init__("fenced", "The runtime no longer owns its execution session")
|