microcosm-emitter-interface 0.5.0__tar.gz

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 (38) hide show
  1. microcosm_emitter_interface-0.5.0/.gitignore +9 -0
  2. microcosm_emitter_interface-0.5.0/LICENSE +21 -0
  3. microcosm_emitter_interface-0.5.0/PKG-INFO +45 -0
  4. microcosm_emitter_interface-0.5.0/README.md +35 -0
  5. microcosm_emitter_interface-0.5.0/pyproject.toml +18 -0
  6. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/__init__.py +28 -0
  7. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/client.py +355 -0
  8. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-close.json +10 -0
  9. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-hello.json +14 -0
  10. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-launch.json +22 -0
  11. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-message-envelope.json +13 -0
  12. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-ping.json +9 -0
  13. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-query-envelope.json +14 -0
  14. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-configuration.json +10 -0
  15. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-enqueue.json +35 -0
  16. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-inventory.json +22 -0
  17. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-receipt.json +13 -0
  18. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-retry.json +13 -0
  19. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-calibration-progress.json +15 -0
  20. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-complete.json +9 -0
  21. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-configuration.json +22 -0
  22. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-emit.json +16 -0
  23. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-fail-without-code.json +13 -0
  24. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-fail.json +14 -0
  25. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-progress.json +16 -0
  26. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-refusal.json +17 -0
  27. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-stage.json +15 -0
  28. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-transition-calibration-progress.json +15 -0
  29. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-transition-stage.json +13 -0
  30. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/launcher.py +273 -0
  31. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/orrery.py +156 -0
  32. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/protocol.py +32 -0
  33. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/protocol_fixtures.py +140 -0
  34. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/py.typed +0 -0
  35. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/serialization.py +28 -0
  36. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/spool.py +11 -0
  37. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/telemetry.py +351 -0
  38. microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/transport.py +139 -0
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .coverage
8
+ .collector/
9
+ .test-artifacts/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PolicyEngine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: microcosm-emitter-interface
3
+ Version: 0.5.0
4
+ Summary: Typed hand-over calls from Microcosm builds to the separately installed microcosm-emitter
5
+ Project-URL: Source, https://github.com/PolicyEngine/microcosm-emitter
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Microcosm emitter interface
12
+
13
+ `microcosm-emitter-interface` is the small, dependency-free package a Microcosm build imports to hand work to the separately installed `microcosm-emitter`. Microcosm decides what to hand over; the emitter decides where it goes.
14
+
15
+ Install the emitter on its own, outside the build's environment:
16
+
17
+ ```bash
18
+ uv tool install microcosm-emitter
19
+ ```
20
+
21
+ The build then depends on this package with a version range and starts the emitter through it:
22
+
23
+ ```python
24
+ from microcosm_emitter_interface import EmitterClient, TelemetryRun, TelemetrySettings
25
+
26
+ client = EmitterClient.start(
27
+ telemetry=TelemetrySettings(run=TelemetryRun("run-id", "UK", "uk-build")),
28
+ )
29
+ client.telemetry.transition_stage("calibrating")
30
+ client.telemetry.complete()
31
+ client.close()
32
+ ```
33
+
34
+ `EmitterClient.start` runs the `microcosm-emitter` command as the build's own child process. The command comes from the `command` argument, then the `MICROCOSM_EMITTER_COMMAND` environment variable, then `PATH`. The child inherits the build's environment variables and user, so it finds the same Hugging Face credential: `HF_TOKEN`, then `HUGGINGFACE_TOKEN`, then the token saved by `hf auth login`.
35
+
36
+ No call raises into the build: not because the emitter is missing, slow or refusing a request, and not because an argument cannot be converted to JSON (for example a multi-element NumPy array or a self-referencing mapping). Each call converts its arguments inside its error handling and reports whether the emitter accepted it; after a refusal, `client.last_failure` says why (`emitter_unavailable`, `invalid_arguments` or `not_accepted`). Startup settings that cannot be converted start an unavailable client (`invalid_configuration`). The build continues in every case.
37
+
38
+ ## Compatibility
39
+
40
+ Both packages are released together with one version number, and `microcosm-emitter==X` requires `microcosm-emitter-interface==X`. A build, though, may run with a different emitter from the interface it depends on: it keeps the emitter it started with, and the next build uses whichever emitter is installed then. So the socket protocol carries its own version:
41
+
42
+ - `PROTOCOL_VERSION` names the shape of every message this package sends. The emitter lists the versions it accepts in its `hello` answer, and the client refuses an emitter that does not list its version (`unsupported_protocol_version`); the build continues without it.
43
+ - **Every emitter release must accept every protocol version that interface releases still in use send.** Adding a module or a message kind keeps the version; changing the shape of an existing message needs a new version, which emitters then accept alongside the old one.
44
+ - `fixtures/protocol-v<N>/` holds one JSON file per message of protocol version `N`, including how the command is started (`host-launch.json`: its arguments and the configuration object on standard input) and each socket request's whole envelope (`host-ping.json`, `host-message-envelope.json`, `host-query-envelope.json`, `host-close.json`, and `host-hello.json`). This package's tests check that each typed call, the launcher and the socket transport send exactly their fixture, and the emitter's contract tests check that it accepts every fixture of every version it supports, so a change to the command's arguments or the envelope cannot pass by changing both sides at once. An emitter's `hello` answer must contain the recorded keys and may add more. A released fixture is never edited or removed: the emitter's tests pin each one's SHA-256.
45
+ - An emitter that lacks a module this package asks for starts without it (`--skip-unavailable-modules`); calls for that module report that they were not accepted.
@@ -0,0 +1,35 @@
1
+ # Microcosm emitter interface
2
+
3
+ `microcosm-emitter-interface` is the small, dependency-free package a Microcosm build imports to hand work to the separately installed `microcosm-emitter`. Microcosm decides what to hand over; the emitter decides where it goes.
4
+
5
+ Install the emitter on its own, outside the build's environment:
6
+
7
+ ```bash
8
+ uv tool install microcosm-emitter
9
+ ```
10
+
11
+ The build then depends on this package with a version range and starts the emitter through it:
12
+
13
+ ```python
14
+ from microcosm_emitter_interface import EmitterClient, TelemetryRun, TelemetrySettings
15
+
16
+ client = EmitterClient.start(
17
+ telemetry=TelemetrySettings(run=TelemetryRun("run-id", "UK", "uk-build")),
18
+ )
19
+ client.telemetry.transition_stage("calibrating")
20
+ client.telemetry.complete()
21
+ client.close()
22
+ ```
23
+
24
+ `EmitterClient.start` runs the `microcosm-emitter` command as the build's own child process. The command comes from the `command` argument, then the `MICROCOSM_EMITTER_COMMAND` environment variable, then `PATH`. The child inherits the build's environment variables and user, so it finds the same Hugging Face credential: `HF_TOKEN`, then `HUGGINGFACE_TOKEN`, then the token saved by `hf auth login`.
25
+
26
+ No call raises into the build: not because the emitter is missing, slow or refusing a request, and not because an argument cannot be converted to JSON (for example a multi-element NumPy array or a self-referencing mapping). Each call converts its arguments inside its error handling and reports whether the emitter accepted it; after a refusal, `client.last_failure` says why (`emitter_unavailable`, `invalid_arguments` or `not_accepted`). Startup settings that cannot be converted start an unavailable client (`invalid_configuration`). The build continues in every case.
27
+
28
+ ## Compatibility
29
+
30
+ Both packages are released together with one version number, and `microcosm-emitter==X` requires `microcosm-emitter-interface==X`. A build, though, may run with a different emitter from the interface it depends on: it keeps the emitter it started with, and the next build uses whichever emitter is installed then. So the socket protocol carries its own version:
31
+
32
+ - `PROTOCOL_VERSION` names the shape of every message this package sends. The emitter lists the versions it accepts in its `hello` answer, and the client refuses an emitter that does not list its version (`unsupported_protocol_version`); the build continues without it.
33
+ - **Every emitter release must accept every protocol version that interface releases still in use send.** Adding a module or a message kind keeps the version; changing the shape of an existing message needs a new version, which emitters then accept alongside the old one.
34
+ - `fixtures/protocol-v<N>/` holds one JSON file per message of protocol version `N`, including how the command is started (`host-launch.json`: its arguments and the configuration object on standard input) and each socket request's whole envelope (`host-ping.json`, `host-message-envelope.json`, `host-query-envelope.json`, `host-close.json`, and `host-hello.json`). This package's tests check that each typed call, the launcher and the socket transport send exactly their fixture, and the emitter's contract tests check that it accepts every fixture of every version it supports, so a change to the command's arguments or the envelope cannot pass by changing both sides at once. An emitter's `hello` answer must contain the recorded keys and may add more. A released fixture is never edited or removed: the emitter's tests pin each one's SHA-256.
35
+ - An emitter that lacks a module this package asks for starts without it (`--skip-unavailable-modules`); calls for that module report that they were not accepted.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "microcosm-emitter-interface"
3
+ version = "0.5.0"
4
+ description = "Typed hand-over calls from Microcosm builds to the separately installed microcosm-emitter"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ dependencies = []
9
+
10
+ [project.urls]
11
+ Source = "https://github.com/PolicyEngine/microcosm-emitter"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/microcosm_emitter_interface"]
@@ -0,0 +1,28 @@
1
+ """Typed hand-overs from Microcosm builds to the separately installed emitter.
2
+
3
+ Importing this package starts no process and opens no socket or file.
4
+ """
5
+
6
+ from microcosm_emitter_interface.client import EmitterClient, RequestFailure
7
+ from microcosm_emitter_interface.launcher import (
8
+ COMMAND_ENVIRONMENT_VARIABLE,
9
+ DEFAULT_COMMAND,
10
+ EmitterStartupError,
11
+ )
12
+ from microcosm_emitter_interface.orrery import OrrerySettings
13
+ from microcosm_emitter_interface.protocol import PROTOCOL_VERSION
14
+ from microcosm_emitter_interface.spool import default_spool_path
15
+ from microcosm_emitter_interface.telemetry import TelemetryRun, TelemetrySettings
16
+
17
+ __all__ = [
18
+ "COMMAND_ENVIRONMENT_VARIABLE",
19
+ "DEFAULT_COMMAND",
20
+ "PROTOCOL_VERSION",
21
+ "EmitterClient",
22
+ "EmitterStartupError",
23
+ "OrrerySettings",
24
+ "RequestFailure",
25
+ "TelemetryRun",
26
+ "TelemetrySettings",
27
+ "default_spool_path",
28
+ ]
@@ -0,0 +1,355 @@
1
+ """One typed call per kind of hand-over from a build to its local emitter.
2
+
3
+ Starting the client starts the installed ``microcosm-emitter`` command as this
4
+ process's child. No call raises into the build: not when the emitter is missing,
5
+ slow or refusing, and not when an argument cannot be converted to JSON (for
6
+ example a multi-element NumPy array or a self-referencing mapping). Each call
7
+ converts its arguments inside its error handling, reports that it was not
8
+ accepted, records why in ``EmitterClient.last_failure``, and the build continues.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import threading
16
+ import time
17
+ from collections.abc import Callable, Mapping
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ from microcosm_emitter_interface.launcher import (
23
+ EmitterProcess,
24
+ EmitterStartupError,
25
+ start_emitter,
26
+ )
27
+ from microcosm_emitter_interface.orrery import (
28
+ OrrerySettings,
29
+ PublicationFileRole,
30
+ PublicationInventory,
31
+ PublicationReceipt,
32
+ answered_inventory,
33
+ answered_receipt,
34
+ enqueue_message,
35
+ inventory_message,
36
+ publication_id_of,
37
+ receipt_message,
38
+ retry_message,
39
+ unavailable_receipt,
40
+ )
41
+ from microcosm_emitter_interface.protocol import (
42
+ ACKNOWLEDGEMENT_TIMEOUT_SECONDS,
43
+ FILE_REQUEST_TIMEOUT_SECONDS,
44
+ ORRERY_MODULE,
45
+ STARTUP_TIMEOUT_SECONDS,
46
+ TELEMETRY_MODULE,
47
+ )
48
+ from microcosm_emitter_interface.spool import default_spool_path
49
+ from microcosm_emitter_interface.telemetry import (
50
+ TelemetryCommand,
51
+ TelemetryRecorder,
52
+ TelemetrySettings,
53
+ telemetry_message,
54
+ )
55
+
56
+ STARTUP_WARNING = (
57
+ "Local Microcosm emitter failed to start ({reason}); the build will continue."
58
+ )
59
+ REQUEST_WARNING = (
60
+ "The local Microcosm emitter did not accept a {kind} request "
61
+ "({reason}, {error_type}); the build will continue."
62
+ )
63
+
64
+ type RequestFailureReason = Literal[
65
+ # No emitter is running, or it did not load the call's module.
66
+ "emitter_unavailable",
67
+ # The call's arguments could not be converted to a protocol message.
68
+ "invalid_arguments",
69
+ # The emitter refused the request, or did not answer in time.
70
+ "not_accepted",
71
+ ]
72
+
73
+ type MessageBuilder = Callable[[], Mapping[str, Any]]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class RequestFailure:
78
+ """Why a call reported that the emitter did not accept it.
79
+
80
+ ``error_type`` is only the exception's type name, never its message.
81
+ """
82
+
83
+ kind: str
84
+ reason: RequestFailureReason
85
+ error_type: str | None = None
86
+
87
+
88
+ class EmitterClient:
89
+ """Typed hand-overs to one emitter process owned by this build."""
90
+
91
+ def __init__(
92
+ self,
93
+ process: EmitterProcess | None = None,
94
+ *,
95
+ startup_error: EmitterStartupError | None = None,
96
+ ) -> None:
97
+ self._process = process
98
+ self.startup_error = startup_error
99
+ self._closed = False
100
+ self._warned: set[str] = set()
101
+ self._warning_lock = threading.Lock()
102
+ #: The most recent call that reported failure, and why.
103
+ self.last_failure: RequestFailure | None = None
104
+ self.telemetry = TelemetryRecorder(self)
105
+
106
+ @classmethod
107
+ def start(
108
+ cls,
109
+ *,
110
+ telemetry: TelemetrySettings | None = None,
111
+ orrery: OrrerySettings | None = None,
112
+ spool_path: Path | str | None = None,
113
+ command: str | os.PathLike[str] | None = None,
114
+ startup_timeout: float = STARTUP_TIMEOUT_SECONDS,
115
+ ) -> EmitterClient:
116
+ """Start the selected modules; return an unavailable client on failure."""
117
+ try:
118
+ path = Path(spool_path) if spool_path is not None else default_spool_path()
119
+ modules: dict[str, dict[str, Any]] = {}
120
+ if telemetry is not None:
121
+ modules[TELEMETRY_MODULE] = telemetry.configuration(path)
122
+ if orrery is not None:
123
+ modules[ORRERY_MODULE] = orrery.configuration(path)
124
+ except Exception:
125
+ failure = EmitterStartupError("invalid_configuration")
126
+ print(STARTUP_WARNING.format(reason=failure.reason), file=sys.stderr)
127
+ return cls(startup_error=failure)
128
+ try:
129
+ process = start_emitter(
130
+ modules, command=command, startup_timeout=startup_timeout
131
+ )
132
+ except EmitterStartupError as error:
133
+ print(STARTUP_WARNING.format(reason=error.reason), file=sys.stderr)
134
+ return cls(startup_error=error)
135
+ except Exception:
136
+ failure = EmitterStartupError("startup_failure")
137
+ print(STARTUP_WARNING.format(reason=failure.reason), file=sys.stderr)
138
+ return cls(startup_error=failure)
139
+ return cls(process)
140
+
141
+ @property
142
+ def available(self) -> bool:
143
+ return self._process is not None and not self._closed
144
+
145
+ @property
146
+ def modules(self) -> frozenset[str]:
147
+ """The modules the running emitter loaded."""
148
+ if not self.available:
149
+ return frozenset()
150
+ assert self._process is not None
151
+ return frozenset(self._process.description.get("modules", ()))
152
+
153
+ @property
154
+ def emitter_version(self) -> str | None:
155
+ if self._process is None:
156
+ return None
157
+ version = self._process.description.get("emitter_version")
158
+ return version if isinstance(version, str) else None
159
+
160
+ def _fail(
161
+ self,
162
+ kind: str,
163
+ reason: RequestFailureReason,
164
+ error: BaseException | None = None,
165
+ ) -> None:
166
+ """Record why a call failed; warn once per kind of call.
167
+
168
+ A missing emitter was already reported when it failed to start, so
169
+ only refused requests and unconvertible arguments print a warning.
170
+ """
171
+ error_type = None if error is None else type(error).__name__
172
+ self.last_failure = RequestFailure(kind, reason, error_type)
173
+ if reason == "emitter_unavailable":
174
+ return
175
+ with self._warning_lock:
176
+ if kind in self._warned:
177
+ return
178
+ self._warned.add(kind)
179
+ print(
180
+ REQUEST_WARNING.format(kind=kind, reason=reason, error_type=error_type),
181
+ file=sys.stderr,
182
+ )
183
+
184
+ def _message(
185
+ self, build_message: MessageBuilder, *, kind: str
186
+ ) -> dict[str, Any] | None:
187
+ """The converted message, or None after recording why it failed."""
188
+ try:
189
+ return dict(build_message())
190
+ except Exception as error:
191
+ self._fail(kind, "invalid_arguments", error)
192
+ return None
193
+
194
+ def _send(self, module: str, build_message: MessageBuilder, *, kind: str) -> bool:
195
+ if module not in self.modules:
196
+ self._fail(kind, "emitter_unavailable")
197
+ return False
198
+ message = self._message(build_message, kind=kind)
199
+ if message is None:
200
+ return False
201
+ assert self._process is not None
202
+ try:
203
+ self._process.transport.send(
204
+ module, message, timeout=ACKNOWLEDGEMENT_TIMEOUT_SECONDS
205
+ )
206
+ except Exception as error:
207
+ self._fail(kind, "not_accepted", error)
208
+ return False
209
+ return True
210
+
211
+ def _request(
212
+ self,
213
+ module: str,
214
+ build_message: MessageBuilder,
215
+ *,
216
+ kind: str,
217
+ timeout: float = FILE_REQUEST_TIMEOUT_SECONDS,
218
+ ) -> tuple[dict[str, Any] | None, RequestFailureReason | None]:
219
+ """The module's answer, or None and why this call failed.
220
+
221
+ The reason belongs to this call only. ``last_failure`` is shared by
222
+ every call and thread, so a receipt's error code never reads it.
223
+ """
224
+ if module not in self.modules:
225
+ self._fail(kind, "emitter_unavailable")
226
+ return None, "emitter_unavailable"
227
+ message = self._message(build_message, kind=kind)
228
+ if message is None:
229
+ return None, "invalid_arguments"
230
+ assert self._process is not None
231
+ try:
232
+ return self._process.transport.query(module, message, timeout=timeout), None
233
+ except Exception as error:
234
+ self._fail(kind, "not_accepted", error)
235
+ return None, "not_accepted"
236
+
237
+ def _query(
238
+ self,
239
+ module: str,
240
+ build_message: MessageBuilder,
241
+ *,
242
+ kind: str,
243
+ timeout: float = FILE_REQUEST_TIMEOUT_SECONDS,
244
+ ) -> dict[str, Any] | None:
245
+ answer, _ = self._request(module, build_message, kind=kind, timeout=timeout)
246
+ return answer
247
+
248
+ def emit_telemetry(self, command: TelemetryCommand) -> bool:
249
+ """Hand over one telemetry event; True once the emitter committed it."""
250
+ return self._send(
251
+ TELEMETRY_MODULE, lambda: telemetry_message(command), kind="telemetry"
252
+ )
253
+
254
+ def orrery_inventory(
255
+ self,
256
+ directory: Path,
257
+ roles: Mapping[str, PublicationFileRole],
258
+ *,
259
+ publication_id: str | None = None,
260
+ ) -> PublicationInventory | None:
261
+ """Ask the emitter to measure the selected preserved Orrery input files."""
262
+ response = self._query(
263
+ ORRERY_MODULE,
264
+ lambda: inventory_message(directory, roles, publication_id),
265
+ kind="Orrery",
266
+ )
267
+ return answered_inventory(response)
268
+
269
+ def hand_over_orrery_inputs(
270
+ self,
271
+ directory: Path,
272
+ inventory: PublicationInventory,
273
+ *,
274
+ wait_seconds: float = 30.0,
275
+ ) -> PublicationReceipt:
276
+ """Queue the inventoried files; wait only after the durable enqueue."""
277
+ publication_id = publication_id_of(inventory)
278
+ if publication_id is None:
279
+ self._fail("Orrery", "invalid_arguments")
280
+ return unavailable_receipt(None, error_code="invalid_arguments")
281
+ receipt = self._orrery_receipt_request(
282
+ lambda: enqueue_message(directory, inventory), publication_id
283
+ )
284
+ deadline = time.monotonic() + _wait_seconds(wait_seconds)
285
+ while (
286
+ self.available
287
+ and receipt["status"] == "pending"
288
+ and not receipt.get("attempts", 0)
289
+ and time.monotonic() < deadline
290
+ ):
291
+ time.sleep(0.1)
292
+ receipt = self.orrery_receipt(publication_id)
293
+ return receipt
294
+
295
+ def orrery_receipt(self, publication_id: str) -> PublicationReceipt:
296
+ return self._orrery_receipt_request(
297
+ lambda: receipt_message(publication_id), publication_id
298
+ )
299
+
300
+ def retry_orrery_inputs(
301
+ self, publication_id: str, *, wait_seconds: float = 0
302
+ ) -> PublicationReceipt:
303
+ receipt = self._orrery_receipt_request(
304
+ lambda: retry_message(publication_id), publication_id
305
+ )
306
+ previous_attempts = receipt.get("attempts", 0)
307
+ deadline = time.monotonic() + _wait_seconds(wait_seconds)
308
+ while (
309
+ self.available
310
+ and receipt["status"] == "pending"
311
+ and receipt.get("attempts", 0) == previous_attempts
312
+ and time.monotonic() < deadline
313
+ ):
314
+ time.sleep(0.1)
315
+ receipt = self.orrery_receipt(publication_id)
316
+ return receipt
317
+
318
+ def _orrery_receipt_request(
319
+ self, build_message: MessageBuilder, publication_id: str | None
320
+ ) -> PublicationReceipt:
321
+ answer, failure = self._request(ORRERY_MODULE, build_message, kind="Orrery")
322
+ return answered_receipt(
323
+ answer, publication_id, error_code=_receipt_error_code(failure)
324
+ )
325
+
326
+ def close(self) -> None:
327
+ """Ask the emitter to drain and stop; repeated calls do nothing."""
328
+ if self._closed:
329
+ return
330
+ self._closed = True
331
+ if self._process is not None:
332
+ try:
333
+ self._process.close()
334
+ except Exception as error:
335
+ self._fail("close", "not_accepted", error)
336
+
337
+
338
+ def _receipt_error_code(failure: RequestFailureReason | None) -> str:
339
+ """The error code of an unavailable receipt, from its own call's failure.
340
+
341
+ An answer without a receipt, or no answer, is ``local_service_unavailable``,
342
+ as in the emitter's in-process client.
343
+ """
344
+ if failure == "invalid_arguments":
345
+ return "invalid_arguments"
346
+ return "local_service_unavailable"
347
+
348
+
349
+ def _wait_seconds(value: Any) -> float:
350
+ """A usable waiting time; an unusable one means not waiting."""
351
+ try:
352
+ seconds = float(value)
353
+ except Exception:
354
+ return 0.0
355
+ return seconds if seconds > 0 else 0.0
@@ -0,0 +1,10 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "envelope",
5
+ "sequence": 1,
6
+ "body": {
7
+ "action": "close"
8
+ },
9
+ "response": "acknowledgement"
10
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "hello",
5
+ "body": {
6
+ "action": "hello"
7
+ },
8
+ "response_keys": [
9
+ "emitter_version",
10
+ "modules",
11
+ "protocol_versions",
12
+ "skipped_modules"
13
+ ]
14
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "launch",
5
+ "body": {
6
+ "arguments": [
7
+ "host",
8
+ "--socket",
9
+ "{socket_path}",
10
+ "--parent-pid",
11
+ "{parent_pid}",
12
+ "--skip-unavailable-modules"
13
+ ],
14
+ "standard_input": {
15
+ "orrery": {
16
+ "spool_path": "{spool_path}",
17
+ "development_collector_url": "http://127.0.0.1:1",
18
+ "publication_origin": "http://127.0.0.1:1"
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "envelope",
5
+ "body": {
6
+ "action": "message",
7
+ "module": "telemetry",
8
+ "message": {
9
+ "command": "complete"
10
+ }
11
+ },
12
+ "response": "acknowledgement"
13
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "envelope",
5
+ "body": {
6
+ "action": "ping"
7
+ },
8
+ "response": "acknowledgement"
9
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "host",
4
+ "kind": "envelope",
5
+ "body": {
6
+ "action": "query",
7
+ "module": "orrery",
8
+ "message": {
9
+ "command": "receipt",
10
+ "publication_id": "fixture-publication"
11
+ }
12
+ },
13
+ "response": "answer"
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "orrery",
4
+ "kind": "configuration",
5
+ "body": {
6
+ "spool_path": "{spool_path}",
7
+ "development_collector_url": "http://127.0.0.1:1",
8
+ "publication_origin": "http://127.0.0.1:1"
9
+ }
10
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "protocol_version": 1,
3
+ "module": "orrery",
4
+ "kind": "query",
5
+ "sequence": 2,
6
+ "files": {
7
+ "graph.orrery.json": "{\"nodes\":[]}",
8
+ "graph.schema.json": "{\"schema\":true}"
9
+ },
10
+ "body": {
11
+ "command": "enqueue",
12
+ "directory": "{directory}",
13
+ "inventory": {
14
+ "version": 1,
15
+ "publication_id": "fixture-publication",
16
+ "files": [
17
+ {
18
+ "name": "graph.orrery.json",
19
+ "role": "graph",
20
+ "bytes": 12,
21
+ "sha256": "acf2fa576acb702442f9d0101673354c398db67315c066ca48be8db8e0d2c75b"
22
+ },
23
+ {
24
+ "name": "graph.schema.json",
25
+ "role": "schema",
26
+ "bytes": 15,
27
+ "sha256": "e296bc1725afc9c1e1585549e7f3c3b0cf58acba82fc60188d8eda40b9e17a13"
28
+ }
29
+ ]
30
+ }
31
+ },
32
+ "response_keys": [
33
+ "receipt"
34
+ ]
35
+ }