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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. 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)