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.
- microcosm_emitter_interface-0.5.0/.gitignore +9 -0
- microcosm_emitter_interface-0.5.0/LICENSE +21 -0
- microcosm_emitter_interface-0.5.0/PKG-INFO +45 -0
- microcosm_emitter_interface-0.5.0/README.md +35 -0
- microcosm_emitter_interface-0.5.0/pyproject.toml +18 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/__init__.py +28 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/client.py +355 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-close.json +10 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-hello.json +14 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-launch.json +22 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-message-envelope.json +13 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-ping.json +9 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/host-query-envelope.json +14 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-configuration.json +10 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-enqueue.json +35 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-inventory.json +22 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-receipt.json +13 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/orrery-retry.json +13 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-calibration-progress.json +15 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-complete.json +9 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-configuration.json +22 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-emit.json +16 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-fail-without-code.json +13 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-fail.json +14 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-progress.json +16 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-refusal.json +17 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-stage.json +15 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-transition-calibration-progress.json +15 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/fixtures/protocol-v1/telemetry-transition-stage.json +13 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/launcher.py +273 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/orrery.py +156 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/protocol.py +32 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/protocol_fixtures.py +140 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/py.typed +0 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/serialization.py +28 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/spool.py +11 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/telemetry.py +351 -0
- microcosm_emitter_interface-0.5.0/src/microcosm_emitter_interface/transport.py +139 -0
|
@@ -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,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,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
|
+
}
|