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/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")