velophex 0.2.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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
r"""Authenticated framing of the executor protocol (``docs/EXECUTOR-PROTOCOL.md`` section 2).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
frame = length (u32 BE, 1..limit) || tag (32 bytes) || payload (UTF-8 JSON)
|
|
6
|
+
tag = HMAC-SHA256(key, "VPXEX/1\0" || direction (u8) || sequence (u64 BE)
|
|
7
|
+
|| length (u32 BE) || payload)
|
|
8
|
+
|
|
9
|
+
Binding the direction and the per-direction sequence number into the tag is what makes a
|
|
10
|
+
frame impossible to forge, reorder, replay or reflect back at its sender. A reader that has
|
|
11
|
+
rejected one frame stays faulted: after a violation nothing on the channel can be trusted, so
|
|
12
|
+
the session ends and the parent kills the process.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hmac
|
|
18
|
+
import os
|
|
19
|
+
import struct
|
|
20
|
+
import threading
|
|
21
|
+
from base64 import b64decode
|
|
22
|
+
from hashlib import sha256
|
|
23
|
+
from typing import BinaryIO
|
|
24
|
+
|
|
25
|
+
from velophex.contracts.executor import (
|
|
26
|
+
ABSOLUTE_MAXIMUM_FRAME_BYTES,
|
|
27
|
+
DEFAULT_MAXIMUM_FRAME_BYTES,
|
|
28
|
+
FRAME_HEADER_BYTES,
|
|
29
|
+
FRAME_LENGTH_BYTES,
|
|
30
|
+
FRAME_TAG_BYTES,
|
|
31
|
+
SESSION_KEY_BYTES,
|
|
32
|
+
SESSION_KEY_ENVIRONMENT_VARIABLE,
|
|
33
|
+
TAG_LABEL,
|
|
34
|
+
ExecutorMessage,
|
|
35
|
+
ExecutorProtocolError,
|
|
36
|
+
ExecutorProtocolException,
|
|
37
|
+
decode_message,
|
|
38
|
+
encode_message,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = ["FrameReader", "FrameWriter", "compute_tag", "take_session_key_from_environment"]
|
|
42
|
+
|
|
43
|
+
_HEADER = struct.Struct(">I")
|
|
44
|
+
_BINDING = struct.Struct(">BQI")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compute_tag(session_key: bytes, direction: int, sequence: int, payload: bytes) -> bytes:
|
|
48
|
+
"""The 32-byte tag of one frame."""
|
|
49
|
+
if len(session_key) != SESSION_KEY_BYTES:
|
|
50
|
+
raise ValueError(f"The session key must be {SESSION_KEY_BYTES} bytes.")
|
|
51
|
+
|
|
52
|
+
mac = hmac.new(session_key, digestmod=sha256)
|
|
53
|
+
mac.update(TAG_LABEL)
|
|
54
|
+
mac.update(_BINDING.pack(direction, sequence, len(payload)))
|
|
55
|
+
mac.update(payload)
|
|
56
|
+
return mac.digest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def take_session_key_from_environment() -> bytes | None:
|
|
60
|
+
"""Read the session key from the environment and **remove it**.
|
|
61
|
+
|
|
62
|
+
Automation code runs in this process, so the key must not be readable once the handler
|
|
63
|
+
starts: it is the only thing that separates the protocol stream from anything else that
|
|
64
|
+
could write to stdout.
|
|
65
|
+
"""
|
|
66
|
+
encoded = os.environ.pop(SESSION_KEY_ENVIRONMENT_VARIABLE, None)
|
|
67
|
+
|
|
68
|
+
if not encoded or not encoded.strip():
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
key = b64decode(encoded.strip(), validate=True)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return key if len(key) == SESSION_KEY_BYTES else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FrameWriter:
|
|
80
|
+
"""Writes authenticated frames.
|
|
81
|
+
|
|
82
|
+
Thread-safe, because logs, progress and the terminal message are written from different
|
|
83
|
+
threads while the handler runs.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
stream: BinaryIO,
|
|
89
|
+
session_key: bytes,
|
|
90
|
+
direction: int,
|
|
91
|
+
maximum_payload_bytes: int = DEFAULT_MAXIMUM_FRAME_BYTES,
|
|
92
|
+
) -> None:
|
|
93
|
+
if not 1 <= maximum_payload_bytes <= ABSOLUTE_MAXIMUM_FRAME_BYTES:
|
|
94
|
+
raise ValueError(f"The frame limit must be between 1 and {ABSOLUTE_MAXIMUM_FRAME_BYTES} bytes.")
|
|
95
|
+
|
|
96
|
+
self._stream = stream
|
|
97
|
+
self._key = session_key
|
|
98
|
+
self._direction = direction
|
|
99
|
+
self._maximum = maximum_payload_bytes
|
|
100
|
+
self._lock = threading.Lock()
|
|
101
|
+
self._sequence = 0
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def frames_written(self) -> int:
|
|
105
|
+
"""How many frames this writer has produced."""
|
|
106
|
+
return self._sequence
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def maximum_payload_bytes(self) -> int:
|
|
110
|
+
"""The largest payload this writer will frame."""
|
|
111
|
+
return self._maximum
|
|
112
|
+
|
|
113
|
+
def build_frame(self, message: ExecutorMessage) -> bytes:
|
|
114
|
+
"""Build the next frame without writing it. Used by the conformance vectors."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
return self._build(self.encode(message))
|
|
117
|
+
|
|
118
|
+
def write(self, message: ExecutorMessage) -> None:
|
|
119
|
+
"""Encode, authenticate and flush *message*."""
|
|
120
|
+
self.write_payload(self.encode(message))
|
|
121
|
+
|
|
122
|
+
def write_payload(self, payload: bytes) -> None:
|
|
123
|
+
"""Authenticate and flush a payload :meth:`encode` produced.
|
|
124
|
+
|
|
125
|
+
Splitting the two lets a caller find out that a message cannot be sent - a value that
|
|
126
|
+
is not JSON, or a payload over the limit - before anything reaches the stream, and
|
|
127
|
+
send something else in its place.
|
|
128
|
+
"""
|
|
129
|
+
if not 1 <= len(payload) <= self._maximum:
|
|
130
|
+
raise ValueError(f"A payload must be between 1 and {self._maximum} bytes.")
|
|
131
|
+
|
|
132
|
+
with self._lock:
|
|
133
|
+
frame = self._build(payload)
|
|
134
|
+
self._stream.write(frame)
|
|
135
|
+
self._stream.flush()
|
|
136
|
+
|
|
137
|
+
def encode(self, message: ExecutorMessage) -> bytes:
|
|
138
|
+
"""The payload of *message*.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
TypeError: The message holds a value that has no JSON form (a ``datetime``, a set).
|
|
142
|
+
ValueError: The message holds a number JSON cannot carry (NaN, infinity) or is
|
|
143
|
+
nested too deeply.
|
|
144
|
+
ExecutorProtocolException: The payload is over the frame limit (``PayloadTooLarge``).
|
|
145
|
+
"""
|
|
146
|
+
payload = encode_message(message)
|
|
147
|
+
|
|
148
|
+
if len(payload) > self._maximum:
|
|
149
|
+
raise ExecutorProtocolException(
|
|
150
|
+
ExecutorProtocolError.PAYLOAD_TOO_LARGE,
|
|
151
|
+
f"The '{message.type_name}' message is {len(payload)} bytes; the limit is {self._maximum} bytes.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return payload
|
|
155
|
+
|
|
156
|
+
def _build(self, payload: bytes) -> bytes:
|
|
157
|
+
tag = compute_tag(self._key, self._direction, self._sequence, payload)
|
|
158
|
+
self._sequence += 1
|
|
159
|
+
return _HEADER.pack(len(payload)) + tag + payload
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class FrameReader:
|
|
163
|
+
"""Reads and verifies frames. Not thread-safe: one reader per direction."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
stream: BinaryIO,
|
|
168
|
+
session_key: bytes,
|
|
169
|
+
direction: int,
|
|
170
|
+
maximum_payload_bytes: int = DEFAULT_MAXIMUM_FRAME_BYTES,
|
|
171
|
+
) -> None:
|
|
172
|
+
if not 1 <= maximum_payload_bytes <= ABSOLUTE_MAXIMUM_FRAME_BYTES:
|
|
173
|
+
raise ValueError(f"The frame limit must be between 1 and {ABSOLUTE_MAXIMUM_FRAME_BYTES} bytes.")
|
|
174
|
+
|
|
175
|
+
self._stream = stream
|
|
176
|
+
self._key = session_key
|
|
177
|
+
self._direction = direction
|
|
178
|
+
self._maximum = maximum_payload_bytes
|
|
179
|
+
self._sequence = 0
|
|
180
|
+
self._fault: ExecutorProtocolException | None = None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def frames_read(self) -> int:
|
|
184
|
+
"""How many frames this reader has accepted."""
|
|
185
|
+
return self._sequence
|
|
186
|
+
|
|
187
|
+
def read(self) -> ExecutorMessage | None:
|
|
188
|
+
"""The next message, or ``None`` when the stream ends cleanly between frames."""
|
|
189
|
+
if self._fault is not None:
|
|
190
|
+
raise ExecutorProtocolException(
|
|
191
|
+
self._fault.error, "The channel is closed after an earlier protocol violation."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
header = self._read_exactly(FRAME_HEADER_BYTES, allow_empty=True)
|
|
195
|
+
|
|
196
|
+
if header is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
length = _HEADER.unpack_from(header)[0]
|
|
200
|
+
|
|
201
|
+
if length == 0:
|
|
202
|
+
raise self._faulted(ExecutorProtocolError.INVALID_LENGTH, "A frame declared an empty payload.")
|
|
203
|
+
|
|
204
|
+
if length > self._maximum:
|
|
205
|
+
raise self._faulted(
|
|
206
|
+
ExecutorProtocolError.PAYLOAD_TOO_LARGE,
|
|
207
|
+
f"A frame declared a {length}-byte payload; the limit is {self._maximum} bytes.",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
payload = self._read_exactly(length, allow_empty=False)
|
|
211
|
+
assert payload is not None
|
|
212
|
+
|
|
213
|
+
expected = compute_tag(self._key, self._direction, self._sequence, payload)
|
|
214
|
+
|
|
215
|
+
if not hmac.compare_digest(expected, header[FRAME_LENGTH_BYTES : FRAME_LENGTH_BYTES + FRAME_TAG_BYTES]):
|
|
216
|
+
raise self._faulted(ExecutorProtocolError.AUTHENTICATION_FAILED, "A frame failed authentication.")
|
|
217
|
+
|
|
218
|
+
self._sequence += 1
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
return decode_message(payload)
|
|
222
|
+
except ExecutorProtocolException as exception:
|
|
223
|
+
self._fault = exception
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
def _read_exactly(self, count: int, *, allow_empty: bool) -> bytes | None:
|
|
227
|
+
buffer = bytearray()
|
|
228
|
+
|
|
229
|
+
while len(buffer) < count:
|
|
230
|
+
chunk = self._stream.read(count - len(buffer))
|
|
231
|
+
|
|
232
|
+
if not chunk:
|
|
233
|
+
if not buffer and allow_empty:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
where = "header" if allow_empty else "payload"
|
|
237
|
+
raise self._faulted(
|
|
238
|
+
ExecutorProtocolError.TRUNCATED_FRAME,
|
|
239
|
+
f"The stream ended inside a frame {where}.",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
buffer.extend(chunk)
|
|
243
|
+
|
|
244
|
+
return bytes(buffer)
|
|
245
|
+
|
|
246
|
+
def _faulted(self, error: str, message: str) -> ExecutorProtocolException:
|
|
247
|
+
self._fault = ExecutorProtocolException(error, message)
|
|
248
|
+
return self._fault
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Finding the handler a ``start`` message names.
|
|
2
|
+
|
|
3
|
+
``start`` carries a package root and an entry point id; ``project.json`` at the root of that
|
|
4
|
+
package turns the id into a ``module:function`` handler (OD-11), and this module imports it.
|
|
5
|
+
|
|
6
|
+
The package's own sources are added to ``sys.path`` so ``invoices.main:run`` resolves whether
|
|
7
|
+
the project uses a flat layout or ``src/``. Nothing is executed until the module is imported,
|
|
8
|
+
which is the first moment automation code runs in the process - after the session key has
|
|
9
|
+
already been taken out of the environment.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from velophex.contracts.diagnostics import has_errors
|
|
20
|
+
from velophex.contracts.project import EntryPoint, ProjectFiles, ProjectManifest, read_project_manifest
|
|
21
|
+
from velophex.entrypoints import EntryPointDefinition, entrypoint_definition, is_entrypoint
|
|
22
|
+
|
|
23
|
+
__all__ = ["LoadedEntryPoint", "PackageLoadError", "load_entry_point", "read_project"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PackageLoadError(Exception):
|
|
27
|
+
"""The package, its ``project.json`` or the handler module could not be loaded."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class LoadedEntryPoint:
|
|
32
|
+
"""A handler resolved from the package, with the manifest it came from."""
|
|
33
|
+
|
|
34
|
+
project: ProjectManifest
|
|
35
|
+
entry_point: EntryPoint
|
|
36
|
+
definition: EntryPointDefinition
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_project(package_root: str | Path) -> ProjectManifest:
|
|
40
|
+
"""Read and validate ``project.json`` at *package_root*."""
|
|
41
|
+
path = Path(package_root) / ProjectFiles.MANIFEST_FILE_NAME
|
|
42
|
+
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
raise PackageLoadError(f"'{package_root}' has no {ProjectFiles.MANIFEST_FILE_NAME}.")
|
|
45
|
+
|
|
46
|
+
manifest, diagnostics = read_project_manifest(path.read_bytes())
|
|
47
|
+
|
|
48
|
+
if manifest is None or has_errors(diagnostics):
|
|
49
|
+
reasons = "; ".join(str(diagnostic) for diagnostic in diagnostics if diagnostic.severity.value == "Error")
|
|
50
|
+
raise PackageLoadError(f"{path} is not a valid project manifest: {reasons}")
|
|
51
|
+
|
|
52
|
+
return manifest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_entry_point(package_root: str | Path, entry_point_id: str | None) -> LoadedEntryPoint:
|
|
56
|
+
"""Resolve *entry_point_id* to an imported, decorated handler."""
|
|
57
|
+
root = Path(package_root)
|
|
58
|
+
project = read_project(root)
|
|
59
|
+
entry = project.find_entry_point(entry_point_id)
|
|
60
|
+
|
|
61
|
+
if entry is None:
|
|
62
|
+
known = ", ".join(item.id for item in project.entry_points) or "none"
|
|
63
|
+
raise PackageLoadError(f"'{entry_point_id}' is not an entry point of '{project.name}' (it has: {known}).")
|
|
64
|
+
|
|
65
|
+
if not entry.handler:
|
|
66
|
+
raise PackageLoadError(
|
|
67
|
+
f"Entry point '{entry.id}' has no handler; a Python entry point needs 'module:function'."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_extend_path(root, entry.path)
|
|
71
|
+
|
|
72
|
+
module_name, _, function_name = entry.handler.partition(":")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
module = importlib.import_module(module_name)
|
|
76
|
+
except ImportError as error:
|
|
77
|
+
raise PackageLoadError(f"The handler module '{module_name}' could not be imported: {error}") from error
|
|
78
|
+
|
|
79
|
+
function = getattr(module, function_name, None)
|
|
80
|
+
|
|
81
|
+
if function is None or not callable(function):
|
|
82
|
+
raise PackageLoadError(f"'{module_name}' has no callable '{function_name}'.")
|
|
83
|
+
|
|
84
|
+
if not is_entrypoint(function):
|
|
85
|
+
raise PackageLoadError(
|
|
86
|
+
f"'{entry.handler}' is not decorated with @velophex.entrypoint, so it has no input or output schema."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return LoadedEntryPoint(project=project, entry_point=entry, definition=entrypoint_definition(function))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extend_path(root: Path, entry_path: str) -> None:
|
|
93
|
+
"""Put the package's import roots first on ``sys.path``.
|
|
94
|
+
|
|
95
|
+
The project root covers a flat layout; ``src/`` covers the layout ``velophex init``
|
|
96
|
+
creates. The directory holding the entry point file is added last so a single-file
|
|
97
|
+
automation also imports.
|
|
98
|
+
"""
|
|
99
|
+
candidates = [root, root / "src", (root / entry_path).parent]
|
|
100
|
+
|
|
101
|
+
for candidate in candidates:
|
|
102
|
+
resolved = str(candidate.resolve())
|
|
103
|
+
|
|
104
|
+
if candidate.is_dir() and resolved not in sys.path:
|
|
105
|
+
sys.path.insert(0, resolved)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Everything the worker sends to its parent, and the bridge from :mod:`logging`.
|
|
2
|
+
|
|
3
|
+
Sending never raises: a parent that has gone away must not fail the automation, and the
|
|
4
|
+
process ends when its stdin closes. The log sequence is monotonic per process, which is what
|
|
5
|
+
the Orchestrator de-duplicates a retried log batch on.
|
|
6
|
+
|
|
7
|
+
Automation code is expected to use ``logging``; the handler installed here turns each record
|
|
8
|
+
into a ``log`` frame so ``logging.info("...")`` lands on the job's log without the author
|
|
9
|
+
learning a VeloPhex API. ``print`` goes to stderr instead, because stdout is the protocol.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import itertools
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from velophex.contracts.executor import ExecutorMessage, ExecutorProtocolException, LogMessage, ProgressMessage
|
|
20
|
+
from velophex.contracts.runtimes import ExecutionPhases, LogLevels
|
|
21
|
+
from velophex.worker.framing import FrameWriter
|
|
22
|
+
|
|
23
|
+
__all__ = ["ExecutorOutbox", "WorkerLogHandler", "level_name"]
|
|
24
|
+
|
|
25
|
+
_LEVELS: tuple[tuple[int, str], ...] = (
|
|
26
|
+
(logging.CRITICAL, LogLevels.CRITICAL),
|
|
27
|
+
(logging.ERROR, LogLevels.ERROR),
|
|
28
|
+
(logging.WARNING, LogLevels.WARNING),
|
|
29
|
+
(logging.INFO, LogLevels.INFORMATION),
|
|
30
|
+
(logging.DEBUG, LogLevels.DEBUG),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def level_name(level: int) -> str:
|
|
35
|
+
"""Map a :mod:`logging` level onto the platform's log levels."""
|
|
36
|
+
for threshold, name in _LEVELS:
|
|
37
|
+
if level >= threshold:
|
|
38
|
+
return name
|
|
39
|
+
|
|
40
|
+
return LogLevels.TRACE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExecutorOutbox:
|
|
44
|
+
"""Writes messages to the parent, numbering log lines as it goes."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, writer: FrameWriter) -> None:
|
|
47
|
+
self._writer = writer
|
|
48
|
+
self._sequence = itertools.count(1)
|
|
49
|
+
self.disconnected = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def maximum_payload_bytes(self) -> int:
|
|
53
|
+
"""The frame limit of the underlying writer."""
|
|
54
|
+
return self._writer.maximum_payload_bytes
|
|
55
|
+
|
|
56
|
+
def encode(self, message: ExecutorMessage) -> bytes:
|
|
57
|
+
"""The payload of *message*; raises like :meth:`FrameWriter.encode`."""
|
|
58
|
+
return self._writer.encode(message)
|
|
59
|
+
|
|
60
|
+
def send(self, message: ExecutorMessage) -> None:
|
|
61
|
+
"""Send *message*, ignoring a parent that has already gone.
|
|
62
|
+
|
|
63
|
+
A message that cannot be encoded (a value with no JSON form, or one over the frame
|
|
64
|
+
limit) is dropped rather than raised: that is a property of the message, not of the
|
|
65
|
+
channel, so it must not mark the parent as gone and swallow everything after it. The
|
|
66
|
+
terminal message is checked before it gets here (see ``WorkerSession``), so what can
|
|
67
|
+
be dropped is telemetry.
|
|
68
|
+
"""
|
|
69
|
+
if self.disconnected:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
payload = self._writer.encode(message)
|
|
74
|
+
except (TypeError, ValueError, ExecutorProtocolException):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
self.send_payload(payload)
|
|
78
|
+
|
|
79
|
+
def send_payload(self, payload: bytes) -> None:
|
|
80
|
+
"""Send a payload :meth:`encode` produced, ignoring a parent that has already gone."""
|
|
81
|
+
if self.disconnected:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
self._writer.write_payload(payload)
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
# ValueError is what a closed file raises on write.
|
|
88
|
+
self.disconnected = True
|
|
89
|
+
|
|
90
|
+
def log(
|
|
91
|
+
self,
|
|
92
|
+
level: str,
|
|
93
|
+
message: str,
|
|
94
|
+
phase: str | None = ExecutionPhases.EXECUTION,
|
|
95
|
+
sub_phase: str | None = None,
|
|
96
|
+
details: Any | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Send one log line."""
|
|
99
|
+
self.send(
|
|
100
|
+
LogMessage(
|
|
101
|
+
sequence=next(self._sequence),
|
|
102
|
+
timestamp=datetime.now(UTC),
|
|
103
|
+
level=level,
|
|
104
|
+
message=message,
|
|
105
|
+
phase=phase,
|
|
106
|
+
sub_phase=sub_phase,
|
|
107
|
+
details=details,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def progress(self, node_id: str, state: str, display_name: str | None = None) -> None:
|
|
112
|
+
"""Report that a step started, completed, faulted or was canceled."""
|
|
113
|
+
self.send(
|
|
114
|
+
ProgressMessage(
|
|
115
|
+
node_id=node_id,
|
|
116
|
+
state=state,
|
|
117
|
+
timestamp=datetime.now(UTC),
|
|
118
|
+
display_name=display_name,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class WorkerLogHandler(logging.Handler):
|
|
124
|
+
"""A :mod:`logging` handler that forwards records to the parent as ``log`` messages."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, outbox: ExecutorOutbox, phase: str = ExecutionPhases.EXECUTION) -> None:
|
|
127
|
+
super().__init__(level=logging.NOTSET)
|
|
128
|
+
self._outbox = outbox
|
|
129
|
+
self._phase = phase
|
|
130
|
+
|
|
131
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
132
|
+
"""Forward one record. A failure here must never escape into automation code."""
|
|
133
|
+
try:
|
|
134
|
+
details: dict[str, Any] | None = None
|
|
135
|
+
|
|
136
|
+
if record.exc_info:
|
|
137
|
+
details = {"exception": self.format(record)}
|
|
138
|
+
|
|
139
|
+
self._outbox.log(
|
|
140
|
+
level_name(record.levelno),
|
|
141
|
+
record.getMessage(),
|
|
142
|
+
phase=self._phase,
|
|
143
|
+
sub_phase=record.name if record.name != "root" else None,
|
|
144
|
+
details=details,
|
|
145
|
+
)
|
|
146
|
+
except Exception:
|
|
147
|
+
self.handleError(record)
|