PKIPC 2.0.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.
- pkipc/__init__.py +12 -0
- pkipc/_grpc_common.py +12 -0
- pkipc/_grpc_host.py +153 -0
- pkipc/_runtime.py +314 -0
- pkipc/_stream.py +53 -0
- pkipc/client.py +253 -0
- pkipc/proto/__init__.py +1 -0
- pkipc/proto/pkipc.proto +11 -0
- pkipc/proto/pkipc_pb2.py +38 -0
- pkipc/proto/pkipc_pb2.pyi +11 -0
- pkipc/proto/pkipc_pb2_grpc.py +97 -0
- pkipc/server.py +151 -0
- pkipc-2.0.0.dist-info/METADATA +184 -0
- pkipc-2.0.0.dist-info/RECORD +16 -0
- pkipc-2.0.0.dist-info/WHEEL +5 -0
- pkipc-2.0.0.dist-info/top_level.txt +1 -0
pkipc/__init__.py
ADDED
pkipc/_grpc_common.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
ENDPOINT_ENV = "PKIPC_ENDPOINT"
|
|
4
|
+
TOKEN_ENV = "PKIPC_TOKEN"
|
|
5
|
+
TOKEN_METADATA_KEY = "x-pkipc-token"
|
|
6
|
+
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
7
|
+
OUTGOING_QUEUE_SIZE = 256
|
|
8
|
+
|
|
9
|
+
GRPC_OPTIONS: tuple[tuple[str, int], ...] = (
|
|
10
|
+
("grpc.max_receive_message_length", MAX_MESSAGE_BYTES),
|
|
11
|
+
("grpc.max_send_message_length", MAX_MESSAGE_BYTES),
|
|
12
|
+
)
|
pkipc/_grpc_host.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import grpc
|
|
11
|
+
|
|
12
|
+
from ._grpc_common import GRPC_OPTIONS, TOKEN_METADATA_KEY
|
|
13
|
+
from ._runtime import CONFIG_EVENT, HandlerResolver, Runtime
|
|
14
|
+
from .proto import pkipc_pb2, pkipc_pb2_grpc
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger(__name__)
|
|
17
|
+
CloseCallback = Callable[[], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _HostServicer(pkipc_pb2_grpc.RuntimeServicer):
|
|
21
|
+
def __init__(self, host: GrpcHost) -> None:
|
|
22
|
+
self._host = host
|
|
23
|
+
|
|
24
|
+
def Run(
|
|
25
|
+
self,
|
|
26
|
+
request_iterator: Iterator[pkipc_pb2.Frame],
|
|
27
|
+
context: grpc.ServicerContext,
|
|
28
|
+
) -> Iterator[pkipc_pb2.Frame]:
|
|
29
|
+
supplied_token = ""
|
|
30
|
+
for key, value in context.invocation_metadata():
|
|
31
|
+
if key == TOKEN_METADATA_KEY:
|
|
32
|
+
supplied_token = value
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
if not hmac.compare_digest(supplied_token, self._host._token):
|
|
36
|
+
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid PKIPC token")
|
|
37
|
+
|
|
38
|
+
if not self._host._attach(context):
|
|
39
|
+
context.abort(grpc.StatusCode.ALREADY_EXISTS, "Worker is already attached")
|
|
40
|
+
|
|
41
|
+
reader = threading.Thread(
|
|
42
|
+
target=self._host._read_worker_events,
|
|
43
|
+
args=(request_iterator, context),
|
|
44
|
+
name="pkipc-host-reader",
|
|
45
|
+
daemon=True,
|
|
46
|
+
)
|
|
47
|
+
reader.start()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
yield from self._host._runtime
|
|
51
|
+
finally:
|
|
52
|
+
self._host._mark_stream_closed()
|
|
53
|
+
reader.join(timeout=1.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GrpcHost:
|
|
57
|
+
"""Parent-side gRPC endpoint for exactly one worker process."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
token: str,
|
|
63
|
+
config: dict[str, Any],
|
|
64
|
+
resolve_handler: HandlerResolver,
|
|
65
|
+
on_close: CloseCallback,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._token = token
|
|
68
|
+
self._on_close = on_close
|
|
69
|
+
self._runtime = Runtime(resolve_handler=resolve_handler)
|
|
70
|
+
self._runtime.send(CONFIG_EVENT, config)
|
|
71
|
+
self._server: grpc.Server | None = None
|
|
72
|
+
self._attached = threading.Event()
|
|
73
|
+
self._stream_closed = threading.Event()
|
|
74
|
+
self._attach_lock = threading.Lock()
|
|
75
|
+
self._close_lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
def start(self) -> str:
|
|
78
|
+
if self._server is not None:
|
|
79
|
+
raise RuntimeError("gRPC host is already started")
|
|
80
|
+
|
|
81
|
+
server = grpc.server(ThreadPoolExecutor(max_workers=2), options=GRPC_OPTIONS)
|
|
82
|
+
pkipc_pb2_grpc.add_RuntimeServicer_to_server(_HostServicer(self), server)
|
|
83
|
+
port = server.add_insecure_port("127.0.0.1:0")
|
|
84
|
+
if port == 0:
|
|
85
|
+
raise OSError("Failed to bind the PKIPC gRPC host")
|
|
86
|
+
server.start()
|
|
87
|
+
self._server = server
|
|
88
|
+
return f"127.0.0.1:{port}"
|
|
89
|
+
|
|
90
|
+
def wait_for_attach(self, timeout: float) -> bool:
|
|
91
|
+
return self._attached.wait(timeout=max(0.0, timeout))
|
|
92
|
+
|
|
93
|
+
def wait_for_close(self, timeout: float) -> bool:
|
|
94
|
+
return self._stream_closed.wait(timeout=max(0.0, timeout))
|
|
95
|
+
|
|
96
|
+
def send(self, event: str, data: Any) -> None:
|
|
97
|
+
if not self.is_connected():
|
|
98
|
+
raise ConnectionError("Worker is not connected")
|
|
99
|
+
self._runtime.send(event, data)
|
|
100
|
+
|
|
101
|
+
def request(self, event: str, data: Any, timeout: float) -> Any:
|
|
102
|
+
if not self.is_connected():
|
|
103
|
+
raise ConnectionError("Worker is not connected")
|
|
104
|
+
return self._runtime.request(event, data, timeout)
|
|
105
|
+
|
|
106
|
+
def close_stream(self) -> None:
|
|
107
|
+
self._runtime.close()
|
|
108
|
+
|
|
109
|
+
def stop(self, grace: float = 0.5) -> None:
|
|
110
|
+
self.close_stream()
|
|
111
|
+
server = self._server
|
|
112
|
+
self._server = None
|
|
113
|
+
if server is not None:
|
|
114
|
+
server.stop(max(0.0, grace)).wait(timeout=max(1.0, grace + 0.5))
|
|
115
|
+
|
|
116
|
+
def is_connected(self) -> bool:
|
|
117
|
+
return self._attached.is_set() and not self._stream_closed.is_set()
|
|
118
|
+
|
|
119
|
+
def _attach(self, context: grpc.ServicerContext) -> bool:
|
|
120
|
+
with self._attach_lock:
|
|
121
|
+
if self._attached.is_set() or self._runtime.is_closed():
|
|
122
|
+
return False
|
|
123
|
+
context.add_callback(self._mark_stream_closed)
|
|
124
|
+
self._attached.set()
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def _read_worker_events(
|
|
128
|
+
self,
|
|
129
|
+
request_iterator: Iterator[pkipc_pb2.Frame],
|
|
130
|
+
context: grpc.ServicerContext,
|
|
131
|
+
) -> None:
|
|
132
|
+
try:
|
|
133
|
+
for frame in request_iterator:
|
|
134
|
+
self._runtime.dispatch(frame)
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
|
|
137
|
+
context.set_details(str(exc))
|
|
138
|
+
except grpc.RpcError:
|
|
139
|
+
pass
|
|
140
|
+
finally:
|
|
141
|
+
self._runtime.close()
|
|
142
|
+
self._mark_stream_closed()
|
|
143
|
+
|
|
144
|
+
def _mark_stream_closed(self) -> None:
|
|
145
|
+
with self._close_lock:
|
|
146
|
+
if self._stream_closed.is_set():
|
|
147
|
+
return
|
|
148
|
+
self._stream_closed.set()
|
|
149
|
+
self._runtime.close()
|
|
150
|
+
try:
|
|
151
|
+
self._on_close()
|
|
152
|
+
except Exception:
|
|
153
|
+
LOGGER.exception("Close callback raised")
|
pkipc/_runtime.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from concurrent.futures import Future
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._stream import OutgoingStream
|
|
15
|
+
from .proto import pkipc_pb2
|
|
16
|
+
|
|
17
|
+
CONFIG_EVENT = "pkipc.config"
|
|
18
|
+
CONTROL_EVENT = "pkipc.control"
|
|
19
|
+
LOG_EVENT = "pkipc.log"
|
|
20
|
+
RESPONSE_EVENT = "pkipc.response"
|
|
21
|
+
|
|
22
|
+
LOGGER = logging.getLogger(__name__)
|
|
23
|
+
EventHandler = Callable[[Any], Any]
|
|
24
|
+
HandlerResolver = Callable[[str], EventHandler | None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Envelope:
|
|
29
|
+
event: str
|
|
30
|
+
data: Any
|
|
31
|
+
request_id: str | None = None
|
|
32
|
+
reply_to: str | None = None
|
|
33
|
+
error: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode_frame(
|
|
37
|
+
event: str,
|
|
38
|
+
data: Any,
|
|
39
|
+
*,
|
|
40
|
+
request_id: str | None = None,
|
|
41
|
+
reply_to: str | None = None,
|
|
42
|
+
error: str | None = None,
|
|
43
|
+
) -> pkipc_pb2.Frame:
|
|
44
|
+
"""Encode one application envelope into a transport frame."""
|
|
45
|
+
envelope = Envelope(event, data, request_id, reply_to, error)
|
|
46
|
+
_validate_envelope(envelope)
|
|
47
|
+
value: dict[str, Any] = {"event": event, "data": data}
|
|
48
|
+
if request_id is not None:
|
|
49
|
+
value["id"] = request_id
|
|
50
|
+
if reply_to is not None:
|
|
51
|
+
value["reply_to"] = reply_to
|
|
52
|
+
if error is not None:
|
|
53
|
+
value["error"] = error
|
|
54
|
+
try:
|
|
55
|
+
payload = json.dumps(
|
|
56
|
+
value,
|
|
57
|
+
ensure_ascii=False,
|
|
58
|
+
separators=(",", ":"),
|
|
59
|
+
allow_nan=False,
|
|
60
|
+
).encode("utf-8")
|
|
61
|
+
except (TypeError, ValueError) as exc:
|
|
62
|
+
raise ValueError(f"Event data is not valid JSON: {exc}") from exc
|
|
63
|
+
return pkipc_pb2.Frame(payload=payload)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_frame(frame: pkipc_pb2.Frame) -> Envelope:
|
|
67
|
+
"""Decode and validate one transport frame."""
|
|
68
|
+
try:
|
|
69
|
+
value = json.loads(
|
|
70
|
+
frame.payload.decode("utf-8"),
|
|
71
|
+
parse_constant=_raise_invalid_constant,
|
|
72
|
+
)
|
|
73
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
74
|
+
raise ValueError(f"Frame payload is not valid UTF-8 JSON: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
if not isinstance(value, dict):
|
|
77
|
+
raise ValueError("Frame payload must be a JSON object")
|
|
78
|
+
if "event" not in value or "data" not in value:
|
|
79
|
+
raise ValueError("Frame payload requires event and data fields")
|
|
80
|
+
|
|
81
|
+
envelope = Envelope(
|
|
82
|
+
event=value["event"],
|
|
83
|
+
data=value["data"],
|
|
84
|
+
request_id=value.get("id"),
|
|
85
|
+
reply_to=value.get("reply_to"),
|
|
86
|
+
error=value.get("error"),
|
|
87
|
+
)
|
|
88
|
+
_validate_envelope(envelope)
|
|
89
|
+
return envelope
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Runtime:
|
|
93
|
+
"""Encode, route, correlate, and queue PKIPC application events."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
first_event: str | None = None,
|
|
99
|
+
resolve_handler: HandlerResolver | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
if first_event is not None:
|
|
102
|
+
validate_event_name(first_event)
|
|
103
|
+
self._first_event = first_event
|
|
104
|
+
self._resolve_handler = resolve_handler
|
|
105
|
+
self._received_events = 0
|
|
106
|
+
self._handlers: dict[str, EventHandler] = {}
|
|
107
|
+
self._handlers_lock = threading.RLock()
|
|
108
|
+
self._pending: dict[str, Future[Any]] = {}
|
|
109
|
+
self._pending_lock = threading.Lock()
|
|
110
|
+
self._dispatch_state = threading.local()
|
|
111
|
+
self._outgoing: OutgoingStream[pkipc_pb2.Frame] = OutgoingStream()
|
|
112
|
+
|
|
113
|
+
def on(self, event: str, handler: EventHandler) -> EventHandler:
|
|
114
|
+
"""Register a handler for an event and return it for decorator use."""
|
|
115
|
+
validate_event_name(event)
|
|
116
|
+
if event == RESPONSE_EVENT:
|
|
117
|
+
raise ValueError(f"{RESPONSE_EVENT} is managed by the runtime")
|
|
118
|
+
with self._handlers_lock:
|
|
119
|
+
self._handlers[event] = handler
|
|
120
|
+
return handler
|
|
121
|
+
|
|
122
|
+
def send(self, event: str, data: Any) -> None:
|
|
123
|
+
"""Queue one event without waiting for a response."""
|
|
124
|
+
self._outgoing.send(encode_frame(event, data))
|
|
125
|
+
|
|
126
|
+
def request(self, event: str, data: Any, timeout: float = 10.0) -> Any:
|
|
127
|
+
"""Send a request and block until its correlated response arrives."""
|
|
128
|
+
if getattr(self._dispatch_state, "active", False):
|
|
129
|
+
raise RuntimeError("request() cannot be called from an event handler")
|
|
130
|
+
if timeout <= 0 or not math.isfinite(timeout):
|
|
131
|
+
raise ValueError("timeout must be a finite positive number")
|
|
132
|
+
|
|
133
|
+
deadline = time.monotonic() + timeout
|
|
134
|
+
future: Future[Any] = Future()
|
|
135
|
+
with self._pending_lock:
|
|
136
|
+
request_id = uuid.uuid4().hex
|
|
137
|
+
while request_id in self._pending:
|
|
138
|
+
request_id = uuid.uuid4().hex
|
|
139
|
+
self._pending[request_id] = future
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
self._outgoing.send(
|
|
143
|
+
encode_frame(event, data, request_id=request_id),
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
)
|
|
146
|
+
except BaseException:
|
|
147
|
+
with self._pending_lock:
|
|
148
|
+
self._pending.pop(request_id, None)
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
remaining = deadline - time.monotonic()
|
|
152
|
+
if remaining <= 0:
|
|
153
|
+
with self._pending_lock:
|
|
154
|
+
self._pending.pop(request_id, None)
|
|
155
|
+
raise TimeoutError(f"Request {event!r} timed out after {timeout}s")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
return future.result(timeout=remaining)
|
|
159
|
+
except TimeoutError as exc:
|
|
160
|
+
with self._pending_lock:
|
|
161
|
+
self._pending.pop(request_id, None)
|
|
162
|
+
raise TimeoutError(f"Request {event!r} timed out after {timeout}s") from exc
|
|
163
|
+
|
|
164
|
+
def dispatch(self, frame: pkipc_pb2.Frame) -> None:
|
|
165
|
+
"""Validate a frame, resolve responses, or invoke its event handler."""
|
|
166
|
+
envelope = decode_frame(frame)
|
|
167
|
+
with self._handlers_lock:
|
|
168
|
+
if self._received_events == 0 and self._first_event not in (None, envelope.event):
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"First event must be {self._first_event!r}, got {envelope.event!r}"
|
|
171
|
+
)
|
|
172
|
+
if self._received_events > 0 and envelope.event == self._first_event:
|
|
173
|
+
raise ValueError(f"Event {envelope.event!r} may only be received once")
|
|
174
|
+
self._received_events += 1
|
|
175
|
+
handler = self._handlers.get(envelope.event)
|
|
176
|
+
resolver = self._resolve_handler
|
|
177
|
+
|
|
178
|
+
if envelope.reply_to is not None:
|
|
179
|
+
self._resolve_response(envelope)
|
|
180
|
+
return
|
|
181
|
+
if handler is None and resolver is not None:
|
|
182
|
+
handler = resolver(envelope.event)
|
|
183
|
+
if envelope.request_id is not None:
|
|
184
|
+
self._handle_request(envelope, handler)
|
|
185
|
+
elif handler is not None:
|
|
186
|
+
self._handle_event(envelope, handler)
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._outgoing.close()
|
|
190
|
+
with self._pending_lock:
|
|
191
|
+
pending = list(self._pending.values())
|
|
192
|
+
self._pending.clear()
|
|
193
|
+
for future in pending:
|
|
194
|
+
future.set_exception(ConnectionError("The PKIPC stream is closed"))
|
|
195
|
+
|
|
196
|
+
def is_closed(self) -> bool:
|
|
197
|
+
return self._outgoing.is_closed()
|
|
198
|
+
|
|
199
|
+
def __iter__(self) -> Iterator[pkipc_pb2.Frame]:
|
|
200
|
+
return iter(self._outgoing)
|
|
201
|
+
|
|
202
|
+
def _handle_event(self, envelope: Envelope, handler: EventHandler) -> None:
|
|
203
|
+
try:
|
|
204
|
+
self._invoke_handler(handler, envelope.data)
|
|
205
|
+
except Exception:
|
|
206
|
+
LOGGER.exception("Handler for event %r raised", envelope.event)
|
|
207
|
+
|
|
208
|
+
def _handle_request(
|
|
209
|
+
self,
|
|
210
|
+
envelope: Envelope,
|
|
211
|
+
handler: EventHandler | None,
|
|
212
|
+
) -> None:
|
|
213
|
+
assert envelope.request_id is not None
|
|
214
|
+
try:
|
|
215
|
+
if handler is None:
|
|
216
|
+
raise LookupError(f"No handler registered for event {envelope.event!r}")
|
|
217
|
+
result = self._invoke_handler(handler, envelope.data)
|
|
218
|
+
response = encode_frame(
|
|
219
|
+
RESPONSE_EVENT,
|
|
220
|
+
result,
|
|
221
|
+
reply_to=envelope.request_id,
|
|
222
|
+
)
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
response = encode_frame(
|
|
225
|
+
RESPONSE_EVENT,
|
|
226
|
+
None,
|
|
227
|
+
reply_to=envelope.request_id,
|
|
228
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
self._outgoing.send(response)
|
|
232
|
+
except ConnectionError:
|
|
233
|
+
LOGGER.debug("Could not send response for event %r", envelope.event)
|
|
234
|
+
|
|
235
|
+
def _resolve_response(self, envelope: Envelope) -> None:
|
|
236
|
+
assert envelope.reply_to is not None
|
|
237
|
+
with self._pending_lock:
|
|
238
|
+
future = self._pending.pop(envelope.reply_to, None)
|
|
239
|
+
if future is None:
|
|
240
|
+
LOGGER.debug("Ignoring response for unknown request %s", envelope.reply_to)
|
|
241
|
+
return
|
|
242
|
+
if envelope.error is not None:
|
|
243
|
+
future.set_exception(RuntimeError(f"Remote handler failed: {envelope.error}"))
|
|
244
|
+
else:
|
|
245
|
+
future.set_result(envelope.data)
|
|
246
|
+
|
|
247
|
+
def _invoke_handler(self, handler: EventHandler, data: Any) -> Any:
|
|
248
|
+
previous = getattr(self._dispatch_state, "active", False)
|
|
249
|
+
self._dispatch_state.active = True
|
|
250
|
+
try:
|
|
251
|
+
return handler(data)
|
|
252
|
+
finally:
|
|
253
|
+
self._dispatch_state.active = previous
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def validate_event_name(event: object) -> None:
|
|
257
|
+
if not isinstance(event, str) or not event.strip():
|
|
258
|
+
raise ValueError("event must be a non-empty string")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_application_event(event: object) -> None:
|
|
262
|
+
validate_event_name(event)
|
|
263
|
+
if event in (CONFIG_EVENT, RESPONSE_EVENT):
|
|
264
|
+
raise ValueError(f"{event} is managed by the runtime")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _validate_id(name: str, value: str | None) -> None:
|
|
268
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
269
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validate_envelope(envelope: Envelope) -> None:
|
|
273
|
+
validate_event_name(envelope.event)
|
|
274
|
+
_validate_id("id", envelope.request_id)
|
|
275
|
+
_validate_id("reply_to", envelope.reply_to)
|
|
276
|
+
if envelope.request_id is not None and envelope.reply_to is not None:
|
|
277
|
+
raise ValueError("Frame cannot contain both id and reply_to")
|
|
278
|
+
if envelope.error is not None and (not isinstance(envelope.error, str) or not envelope.error):
|
|
279
|
+
raise ValueError("error must be a non-empty string")
|
|
280
|
+
|
|
281
|
+
if envelope.event == RESPONSE_EVENT:
|
|
282
|
+
if envelope.reply_to is None:
|
|
283
|
+
raise ValueError(f"{RESPONSE_EVENT} requires reply_to")
|
|
284
|
+
elif envelope.reply_to is not None or envelope.error is not None:
|
|
285
|
+
raise ValueError(f"Only {RESPONSE_EVENT} may contain reply_to or error")
|
|
286
|
+
|
|
287
|
+
if envelope.event == CONFIG_EVENT and envelope.request_id is not None:
|
|
288
|
+
raise ValueError(f"{CONFIG_EVENT} cannot be sent as a request")
|
|
289
|
+
_validate_reserved_event(envelope.event, envelope.data)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _validate_action(action: object) -> None:
|
|
293
|
+
if not isinstance(action, str) or not action.strip():
|
|
294
|
+
raise ValueError("control action must be a non-empty string")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _validate_reserved_event(event: str, data: Any) -> None:
|
|
298
|
+
if event == CONFIG_EVENT and not isinstance(data, dict):
|
|
299
|
+
raise ValueError("pkipc.config data must be a JSON object")
|
|
300
|
+
if event == CONTROL_EVENT:
|
|
301
|
+
if not isinstance(data, dict):
|
|
302
|
+
raise ValueError("pkipc.control data must be a JSON object")
|
|
303
|
+
_validate_action(data.get("action"))
|
|
304
|
+
if event == LOG_EVENT:
|
|
305
|
+
if not isinstance(data, dict):
|
|
306
|
+
raise ValueError("pkipc.log data must be a JSON object")
|
|
307
|
+
if not isinstance(data.get("level"), str) or not data["level"]:
|
|
308
|
+
raise ValueError("pkipc.log level must be a non-empty string")
|
|
309
|
+
if not isinstance(data.get("message"), str):
|
|
310
|
+
raise ValueError("pkipc.log message must be a string")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _raise_invalid_constant(value: str) -> Any:
|
|
314
|
+
raise ValueError(f"Invalid JSON number constant: {value}")
|
pkipc/_stream.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from ._grpc_common import OUTGOING_QUEUE_SIZE
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OutgoingStream(Generic[T]):
|
|
15
|
+
"""Thread-safe bounded iterator used as a gRPC request stream."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._items: deque[T] = deque()
|
|
19
|
+
self._closed = False
|
|
20
|
+
self._changed = threading.Condition()
|
|
21
|
+
|
|
22
|
+
def send(self, item: T, timeout: float = 5.0) -> None:
|
|
23
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
24
|
+
with self._changed:
|
|
25
|
+
while len(self._items) >= OUTGOING_QUEUE_SIZE and not self._closed:
|
|
26
|
+
remaining = deadline - time.monotonic()
|
|
27
|
+
if remaining <= 0:
|
|
28
|
+
raise ConnectionError("The gRPC stream send queue is full")
|
|
29
|
+
self._changed.wait(remaining)
|
|
30
|
+
if self._closed:
|
|
31
|
+
raise ConnectionError("The gRPC stream is closed")
|
|
32
|
+
self._items.append(item)
|
|
33
|
+
self._changed.notify()
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
with self._changed:
|
|
37
|
+
self._closed = True
|
|
38
|
+
self._changed.notify_all()
|
|
39
|
+
|
|
40
|
+
def __iter__(self) -> Iterator[T]:
|
|
41
|
+
while True:
|
|
42
|
+
with self._changed:
|
|
43
|
+
while not self._items and not self._closed:
|
|
44
|
+
self._changed.wait()
|
|
45
|
+
if not self._items:
|
|
46
|
+
return
|
|
47
|
+
item = self._items.popleft()
|
|
48
|
+
self._changed.notify()
|
|
49
|
+
yield item
|
|
50
|
+
|
|
51
|
+
def is_closed(self) -> bool:
|
|
52
|
+
with self._changed:
|
|
53
|
+
return self._closed
|
pkipc/client.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from typing import Any, NoReturn
|
|
11
|
+
|
|
12
|
+
from ._grpc_common import ENDPOINT_ENV, TOKEN_ENV
|
|
13
|
+
from ._grpc_host import GrpcHost
|
|
14
|
+
from ._runtime import validate_application_event
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger(__name__)
|
|
17
|
+
EventCallback = Callable[[Any], Any]
|
|
18
|
+
CloseCallback = Callable[[], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
"""Start one worker process and own its private gRPC endpoint."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
executable: str,
|
|
27
|
+
*,
|
|
28
|
+
config: Mapping[str, Any] | None = None,
|
|
29
|
+
args: Sequence[str] = (),
|
|
30
|
+
working_directory: str | None = None,
|
|
31
|
+
environment: Mapping[str, str] | None = None,
|
|
32
|
+
start_timeout: float = 5.0,
|
|
33
|
+
stop_timeout: float = 2.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
if not executable:
|
|
36
|
+
raise ValueError("executable must not be empty")
|
|
37
|
+
self._executable = executable
|
|
38
|
+
self._config = dict(config or {})
|
|
39
|
+
self._args = tuple(args)
|
|
40
|
+
self._working_directory = working_directory
|
|
41
|
+
self._environment = dict(environment or {})
|
|
42
|
+
self._start_timeout = max(0.1, start_timeout)
|
|
43
|
+
self._stop_timeout = max(0.1, stop_timeout)
|
|
44
|
+
self._callbacks: dict[str, EventCallback] = {}
|
|
45
|
+
self._close_callback: CloseCallback | None = None
|
|
46
|
+
self._host: GrpcHost | None = None
|
|
47
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
48
|
+
self._lock = threading.RLock()
|
|
49
|
+
self._started = False
|
|
50
|
+
self._start_attempted = False
|
|
51
|
+
self._closing = False
|
|
52
|
+
|
|
53
|
+
def on(self, event: str) -> Callable[[EventCallback], EventCallback]:
|
|
54
|
+
"""Register the handler for one named application event."""
|
|
55
|
+
validate_application_event(event)
|
|
56
|
+
|
|
57
|
+
def decorator(callback: EventCallback) -> EventCallback:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._callbacks[event] = callback
|
|
60
|
+
return callback
|
|
61
|
+
|
|
62
|
+
return decorator
|
|
63
|
+
|
|
64
|
+
def on_close(self, callback: CloseCallback) -> CloseCallback:
|
|
65
|
+
"""Register the callback for an unexpected stream close."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._close_callback = callback
|
|
68
|
+
return callback
|
|
69
|
+
|
|
70
|
+
def start(self) -> Client:
|
|
71
|
+
"""Start the process and wait until it opens the gRPC stream."""
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._start_attempted or self._closing:
|
|
74
|
+
raise RuntimeError("Client instances cannot be started more than once")
|
|
75
|
+
self._start_attempted = True
|
|
76
|
+
|
|
77
|
+
token = secrets.token_urlsafe(32)
|
|
78
|
+
host = GrpcHost(
|
|
79
|
+
token=token,
|
|
80
|
+
config=self._config,
|
|
81
|
+
resolve_handler=self._resolve_handler,
|
|
82
|
+
on_close=self._handle_close,
|
|
83
|
+
)
|
|
84
|
+
endpoint = host.start()
|
|
85
|
+
child_environment = os.environ.copy()
|
|
86
|
+
child_environment.update(self._environment)
|
|
87
|
+
child_environment[ENDPOINT_ENV] = endpoint
|
|
88
|
+
child_environment[TOKEN_ENV] = token
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
process = self._spawn_worker(child_environment)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
host.stop()
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._closing = True
|
|
96
|
+
raise OSError(f"Failed to start worker process: {self._executable}") from exc
|
|
97
|
+
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._host = host
|
|
100
|
+
self._process = process
|
|
101
|
+
|
|
102
|
+
deadline = time.monotonic() + self._start_timeout
|
|
103
|
+
while True:
|
|
104
|
+
remaining = deadline - time.monotonic()
|
|
105
|
+
if remaining <= 0:
|
|
106
|
+
self._fail_start(
|
|
107
|
+
TimeoutError(f"Worker did not attach within {self._start_timeout:.1f}s")
|
|
108
|
+
)
|
|
109
|
+
if host.wait_for_attach(min(0.05, remaining)):
|
|
110
|
+
break
|
|
111
|
+
exit_code = process.poll()
|
|
112
|
+
if exit_code is not None:
|
|
113
|
+
self._fail_start(
|
|
114
|
+
ChildProcessError(f"Worker exited before attaching, exit code={exit_code}")
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._started = True
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def send(self, event: str, data: Any = None) -> None:
|
|
122
|
+
"""Send one named application event to the worker."""
|
|
123
|
+
validate_application_event(event)
|
|
124
|
+
with self._lock:
|
|
125
|
+
host = self._host
|
|
126
|
+
if not self._started or self._closing or host is None:
|
|
127
|
+
raise ConnectionError("Client is not connected")
|
|
128
|
+
host.send(event, data)
|
|
129
|
+
|
|
130
|
+
def request(self, event: str, data: Any = None, timeout: float = 10.0) -> Any:
|
|
131
|
+
"""Send a request and block until the worker returns a response."""
|
|
132
|
+
validate_application_event(event)
|
|
133
|
+
with self._lock:
|
|
134
|
+
host = self._host
|
|
135
|
+
if not self._started or self._closing or host is None:
|
|
136
|
+
raise ConnectionError("Client is not connected")
|
|
137
|
+
return host.request(event, data, timeout)
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
"""Close the stream, then terminate a worker that ignores it."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
if self._closing:
|
|
143
|
+
return
|
|
144
|
+
self._closing = True
|
|
145
|
+
host = self._host
|
|
146
|
+
process = self._process
|
|
147
|
+
|
|
148
|
+
if host is not None:
|
|
149
|
+
host.close_stream()
|
|
150
|
+
if process is not None and not self._wait_for_process(process, self._stop_timeout):
|
|
151
|
+
self._terminate_process(process)
|
|
152
|
+
if host is not None:
|
|
153
|
+
host.wait_for_close(0.5)
|
|
154
|
+
host.stop()
|
|
155
|
+
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._started = False
|
|
158
|
+
|
|
159
|
+
def wait(self, timeout: float | None = None) -> int | None:
|
|
160
|
+
"""Wait for the worker and return its exit code, or None on timeout."""
|
|
161
|
+
process = self._process
|
|
162
|
+
if process is None:
|
|
163
|
+
raise RuntimeError("Client has not been started")
|
|
164
|
+
try:
|
|
165
|
+
exit_code = process.wait(timeout=timeout)
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
return None
|
|
168
|
+
if self._host is not None:
|
|
169
|
+
self._host.wait_for_close(1.0)
|
|
170
|
+
self._host.stop()
|
|
171
|
+
return exit_code
|
|
172
|
+
|
|
173
|
+
def is_connected(self) -> bool:
|
|
174
|
+
"""Return whether the worker stream is open."""
|
|
175
|
+
host = self._host
|
|
176
|
+
return self._started and not self._closing and host is not None and host.is_connected()
|
|
177
|
+
|
|
178
|
+
def is_running(self) -> bool:
|
|
179
|
+
"""Return whether the worker process is alive."""
|
|
180
|
+
return self._process is not None and self._process.poll() is None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def pid(self) -> int:
|
|
184
|
+
"""Return the worker PID, or zero before start."""
|
|
185
|
+
return self._process.pid if self._process is not None else 0
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def exit_code(self) -> int:
|
|
189
|
+
"""Return the worker exit code, or -1 while it is running."""
|
|
190
|
+
if self._process is None:
|
|
191
|
+
return -1
|
|
192
|
+
exit_code = self._process.poll()
|
|
193
|
+
return -1 if exit_code is None else exit_code
|
|
194
|
+
|
|
195
|
+
def _spawn_worker(self, environment: Mapping[str, str]) -> subprocess.Popen[bytes]:
|
|
196
|
+
creationflags = 0
|
|
197
|
+
if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW"):
|
|
198
|
+
creationflags = subprocess.CREATE_NO_WINDOW
|
|
199
|
+
process = subprocess.Popen(
|
|
200
|
+
[self._executable, *self._args],
|
|
201
|
+
stdin=subprocess.DEVNULL,
|
|
202
|
+
cwd=self._working_directory or None,
|
|
203
|
+
env=environment,
|
|
204
|
+
creationflags=creationflags,
|
|
205
|
+
)
|
|
206
|
+
LOGGER.info("Started worker process %s", process.pid)
|
|
207
|
+
return process
|
|
208
|
+
|
|
209
|
+
def _fail_start(self, error: Exception) -> NoReturn:
|
|
210
|
+
host = self._host
|
|
211
|
+
process = self._process
|
|
212
|
+
if host is not None:
|
|
213
|
+
host.close_stream()
|
|
214
|
+
if process is not None:
|
|
215
|
+
self._terminate_process(process)
|
|
216
|
+
if host is not None:
|
|
217
|
+
host.stop()
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._closing = True
|
|
220
|
+
raise error
|
|
221
|
+
|
|
222
|
+
def _terminate_process(self, process: subprocess.Popen[bytes]) -> None:
|
|
223
|
+
if process.poll() is not None:
|
|
224
|
+
return
|
|
225
|
+
LOGGER.warning("Terminating worker process %s", process.pid)
|
|
226
|
+
process.terminate()
|
|
227
|
+
if self._wait_for_process(process, self._stop_timeout):
|
|
228
|
+
return
|
|
229
|
+
process.kill()
|
|
230
|
+
process.wait(timeout=2.0)
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _wait_for_process(
|
|
234
|
+
process: subprocess.Popen[bytes],
|
|
235
|
+
timeout: float,
|
|
236
|
+
) -> bool:
|
|
237
|
+
try:
|
|
238
|
+
process.wait(timeout=max(0.1, timeout))
|
|
239
|
+
except subprocess.TimeoutExpired:
|
|
240
|
+
return False
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
def _resolve_handler(self, event: str) -> EventCallback | None:
|
|
244
|
+
with self._lock:
|
|
245
|
+
return self._callbacks.get(event)
|
|
246
|
+
|
|
247
|
+
def _handle_close(self) -> None:
|
|
248
|
+
with self._lock:
|
|
249
|
+
if self._closing:
|
|
250
|
+
return
|
|
251
|
+
callback = self._close_callback
|
|
252
|
+
if callback is not None:
|
|
253
|
+
callback()
|
pkipc/proto/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated protobuf contract for PKIPC."""
|
pkipc/proto/pkipc.proto
ADDED
pkipc/proto/pkipc_pb2.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: pkipc/proto/pkipc.proto
|
|
5
|
+
# Protobuf Python Version: 6.33.5
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
6,
|
|
15
|
+
33,
|
|
16
|
+
5,
|
|
17
|
+
'',
|
|
18
|
+
'pkipc/proto/pkipc.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17pkipc/proto/pkipc.proto\x12\x10pkipc.runtime.v2\"\x18\n\x05\x46rame\x12\x0f\n\x07payload\x18\x01 \x01(\x0c\x32\x46\n\x07Runtime\x12;\n\x03Run\x12\x17.pkipc.runtime.v2.Frame\x1a\x17.pkipc.runtime.v2.Frame(\x01\x30\x01\x62\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pkipc.proto.pkipc_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_FRAME']._serialized_start=45
|
|
35
|
+
_globals['_FRAME']._serialized_end=69
|
|
36
|
+
_globals['_RUNTIME']._serialized_start=71
|
|
37
|
+
_globals['_RUNTIME']._serialized_end=141
|
|
38
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from google.protobuf import descriptor as _descriptor
|
|
2
|
+
from google.protobuf import message as _message
|
|
3
|
+
from typing import ClassVar as _ClassVar, Optional as _Optional
|
|
4
|
+
|
|
5
|
+
DESCRIPTOR: _descriptor.FileDescriptor
|
|
6
|
+
|
|
7
|
+
class Frame(_message.Message):
|
|
8
|
+
__slots__ = ("payload",)
|
|
9
|
+
PAYLOAD_FIELD_NUMBER: _ClassVar[int]
|
|
10
|
+
payload: bytes
|
|
11
|
+
def __init__(self, payload: _Optional[bytes] = ...) -> None: ...
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from pkipc.proto import pkipc_pb2 as pkipc_dot_proto_dot_pkipc__pb2
|
|
7
|
+
|
|
8
|
+
GRPC_GENERATED_VERSION = '1.81.1'
|
|
9
|
+
GRPC_VERSION = grpc.__version__
|
|
10
|
+
_version_not_supported = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from grpc._utilities import first_version_is_lower
|
|
14
|
+
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
|
15
|
+
except ImportError:
|
|
16
|
+
_version_not_supported = True
|
|
17
|
+
|
|
18
|
+
if _version_not_supported:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f'The grpc package installed is at version {GRPC_VERSION},'
|
|
21
|
+
+ ' but the generated code in pkipc/proto/pkipc_pb2_grpc.py depends on'
|
|
22
|
+
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
|
23
|
+
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
|
24
|
+
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RuntimeStub:
|
|
29
|
+
"""Missing associated documentation comment in .proto file."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, channel):
|
|
32
|
+
"""Constructor.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
channel: A grpc.Channel.
|
|
36
|
+
"""
|
|
37
|
+
self.Run = channel.stream_stream(
|
|
38
|
+
'/pkipc.runtime.v2.Runtime/Run',
|
|
39
|
+
request_serializer=pkipc_dot_proto_dot_pkipc__pb2.Frame.SerializeToString,
|
|
40
|
+
response_deserializer=pkipc_dot_proto_dot_pkipc__pb2.Frame.FromString,
|
|
41
|
+
_registered_method=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RuntimeServicer:
|
|
45
|
+
"""Missing associated documentation comment in .proto file."""
|
|
46
|
+
|
|
47
|
+
def Run(self, request_iterator, context):
|
|
48
|
+
"""Missing associated documentation comment in .proto file."""
|
|
49
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
50
|
+
context.set_details('Method not implemented!')
|
|
51
|
+
raise NotImplementedError('Method not implemented!')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_RuntimeServicer_to_server(servicer, server):
|
|
55
|
+
rpc_method_handlers = {
|
|
56
|
+
'Run': grpc.stream_stream_rpc_method_handler(
|
|
57
|
+
servicer.Run,
|
|
58
|
+
request_deserializer=pkipc_dot_proto_dot_pkipc__pb2.Frame.FromString,
|
|
59
|
+
response_serializer=pkipc_dot_proto_dot_pkipc__pb2.Frame.SerializeToString,
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
63
|
+
'pkipc.runtime.v2.Runtime', rpc_method_handlers)
|
|
64
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
65
|
+
server.add_registered_method_handlers('pkipc.runtime.v2.Runtime', rpc_method_handlers)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# This class is part of an EXPERIMENTAL API.
|
|
69
|
+
class Runtime:
|
|
70
|
+
"""Missing associated documentation comment in .proto file."""
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def Run(request_iterator,
|
|
74
|
+
target,
|
|
75
|
+
options=(),
|
|
76
|
+
channel_credentials=None,
|
|
77
|
+
call_credentials=None,
|
|
78
|
+
insecure=False,
|
|
79
|
+
compression=None,
|
|
80
|
+
wait_for_ready=None,
|
|
81
|
+
timeout=None,
|
|
82
|
+
metadata=None):
|
|
83
|
+
return grpc.experimental.stream_stream(
|
|
84
|
+
request_iterator,
|
|
85
|
+
target,
|
|
86
|
+
'/pkipc.runtime.v2.Runtime/Run',
|
|
87
|
+
pkipc_dot_proto_dot_pkipc__pb2.Frame.SerializeToString,
|
|
88
|
+
pkipc_dot_proto_dot_pkipc__pb2.Frame.FromString,
|
|
89
|
+
options,
|
|
90
|
+
channel_credentials,
|
|
91
|
+
insecure,
|
|
92
|
+
call_credentials,
|
|
93
|
+
compression,
|
|
94
|
+
wait_for_ready,
|
|
95
|
+
timeout,
|
|
96
|
+
metadata,
|
|
97
|
+
_registered_method=True)
|
pkipc/server.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
from ._grpc_common import (
|
|
12
|
+
ENDPOINT_ENV,
|
|
13
|
+
GRPC_OPTIONS,
|
|
14
|
+
TOKEN_ENV,
|
|
15
|
+
TOKEN_METADATA_KEY,
|
|
16
|
+
)
|
|
17
|
+
from ._runtime import CONFIG_EVENT, Runtime, validate_application_event
|
|
18
|
+
from .proto import pkipc_pb2_grpc
|
|
19
|
+
|
|
20
|
+
LOGGER = logging.getLogger(__name__)
|
|
21
|
+
EventCallback = Callable[[Any], Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Server:
|
|
25
|
+
"""Worker-side API backed by one long-lived gRPC stream."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._runtime = Runtime(first_event=CONFIG_EVENT)
|
|
29
|
+
self._runtime.on(CONFIG_EVENT, self._receive_config)
|
|
30
|
+
self._channel: grpc.Channel | None = None
|
|
31
|
+
self._call: grpc.Call | None = None
|
|
32
|
+
self._receiver: threading.Thread | None = None
|
|
33
|
+
self._config_ready = threading.Event()
|
|
34
|
+
self._stop_requested = threading.Event()
|
|
35
|
+
self._connected = threading.Event()
|
|
36
|
+
self._config: dict[str, Any] | None = None
|
|
37
|
+
self._start_error: Exception | None = None
|
|
38
|
+
|
|
39
|
+
def on(self, event: str) -> Callable[[EventCallback], EventCallback]:
|
|
40
|
+
"""Register the handler for one named application event."""
|
|
41
|
+
validate_application_event(event)
|
|
42
|
+
|
|
43
|
+
def decorator(callback: EventCallback) -> EventCallback:
|
|
44
|
+
return self._runtime.on(event, callback)
|
|
45
|
+
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
def start(self, timeout: float = 10.0) -> dict[str, Any]:
|
|
49
|
+
"""Connect to the parent and return its startup configuration."""
|
|
50
|
+
if self._channel is not None:
|
|
51
|
+
raise RuntimeError("Server instances cannot be started more than once")
|
|
52
|
+
|
|
53
|
+
endpoint = os.environ.get(ENDPOINT_ENV, "")
|
|
54
|
+
token = os.environ.get(TOKEN_ENV, "")
|
|
55
|
+
if not endpoint or not token:
|
|
56
|
+
raise RuntimeError(f"{ENDPOINT_ENV} and {TOKEN_ENV} must be provided by PKIPC Client")
|
|
57
|
+
|
|
58
|
+
channel = grpc.insecure_channel(endpoint, options=GRPC_OPTIONS)
|
|
59
|
+
self._channel = channel
|
|
60
|
+
self._call = pkipc_pb2_grpc.RuntimeStub(channel).Run(
|
|
61
|
+
iter(self._runtime),
|
|
62
|
+
metadata=((TOKEN_METADATA_KEY, token),),
|
|
63
|
+
)
|
|
64
|
+
self._receiver = threading.Thread(
|
|
65
|
+
target=self._receive_frames,
|
|
66
|
+
name="pkipc-worker-reader",
|
|
67
|
+
daemon=True,
|
|
68
|
+
)
|
|
69
|
+
self._receiver.start()
|
|
70
|
+
|
|
71
|
+
if not self._config_ready.wait(timeout=max(0.0, timeout)):
|
|
72
|
+
self.close()
|
|
73
|
+
raise TimeoutError(f"Timed out waiting for startup configuration after {timeout}s")
|
|
74
|
+
if self._start_error is not None:
|
|
75
|
+
error = self._start_error
|
|
76
|
+
self.close()
|
|
77
|
+
raise error
|
|
78
|
+
if self._config is None:
|
|
79
|
+
self.close()
|
|
80
|
+
raise ConnectionError("Host closed before sending startup configuration")
|
|
81
|
+
return dict(self._config)
|
|
82
|
+
|
|
83
|
+
def send(self, event: str, data: Any = None) -> None:
|
|
84
|
+
"""Send one named application event to the parent."""
|
|
85
|
+
validate_application_event(event)
|
|
86
|
+
self._require_connected()
|
|
87
|
+
self._runtime.send(event, data)
|
|
88
|
+
|
|
89
|
+
def request(self, event: str, data: Any = None, timeout: float = 10.0) -> Any:
|
|
90
|
+
"""Send a request and block until the parent returns a response."""
|
|
91
|
+
validate_application_event(event)
|
|
92
|
+
self._require_connected()
|
|
93
|
+
return self._runtime.request(event, data, timeout)
|
|
94
|
+
|
|
95
|
+
def close(self, grace: float = 2.0) -> None:
|
|
96
|
+
"""Finish pending sends and release the gRPC channel."""
|
|
97
|
+
self._runtime.close()
|
|
98
|
+
receiver = self._receiver
|
|
99
|
+
if receiver is not None and receiver is not threading.current_thread():
|
|
100
|
+
receiver.join(timeout=max(0.0, grace))
|
|
101
|
+
if receiver.is_alive() and self._call is not None:
|
|
102
|
+
self._call.cancel()
|
|
103
|
+
receiver.join(timeout=1.0)
|
|
104
|
+
if self._channel is not None:
|
|
105
|
+
self._channel.close()
|
|
106
|
+
self._connected.clear()
|
|
107
|
+
self._stop_requested.set()
|
|
108
|
+
|
|
109
|
+
def stop_requested(self) -> bool:
|
|
110
|
+
"""Return whether the parent closed the stream."""
|
|
111
|
+
return self._stop_requested.is_set()
|
|
112
|
+
|
|
113
|
+
def is_connected(self) -> bool:
|
|
114
|
+
"""Return whether the gRPC stream is accepting messages."""
|
|
115
|
+
return self._connected.is_set() and not self._stop_requested.is_set()
|
|
116
|
+
|
|
117
|
+
def _receive_frames(self) -> None:
|
|
118
|
+
try:
|
|
119
|
+
assert self._call is not None
|
|
120
|
+
for frame in self._call:
|
|
121
|
+
self._runtime.dispatch(frame)
|
|
122
|
+
except grpc.RpcError as exc:
|
|
123
|
+
if self._config is None:
|
|
124
|
+
self._start_error = ConnectionError(
|
|
125
|
+
f"gRPC connection failed: {exc.code().name}: {exc.details()}"
|
|
126
|
+
)
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
if self._config is None:
|
|
129
|
+
self._start_error = exc
|
|
130
|
+
else:
|
|
131
|
+
LOGGER.error("Closing invalid PKIPC stream: %s", exc)
|
|
132
|
+
if self._call is not None:
|
|
133
|
+
self._call.cancel()
|
|
134
|
+
finally:
|
|
135
|
+
if self._config is None and self._start_error is None:
|
|
136
|
+
self._start_error = ConnectionError(
|
|
137
|
+
"Host closed before sending startup configuration"
|
|
138
|
+
)
|
|
139
|
+
self._config_ready.set()
|
|
140
|
+
self._connected.clear()
|
|
141
|
+
self._stop_requested.set()
|
|
142
|
+
self._runtime.close()
|
|
143
|
+
|
|
144
|
+
def _receive_config(self, config: Any) -> None:
|
|
145
|
+
self._config = dict(config)
|
|
146
|
+
self._connected.set()
|
|
147
|
+
self._config_ready.set()
|
|
148
|
+
|
|
149
|
+
def _require_connected(self) -> None:
|
|
150
|
+
if not self.is_connected():
|
|
151
|
+
raise ConnectionError("Server is not connected")
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: PKIPC
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Event runtime over gRPC for parent-child process communication
|
|
5
|
+
Author: PKIPC
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: grpcio<2,>=1.81.1
|
|
9
|
+
Requires-Dist: protobuf<7,>=6.33.5
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.2; extra == "test"
|
|
12
|
+
Requires-Dist: ruff>=0.12; extra == "test"
|
|
13
|
+
Provides-Extra: build
|
|
14
|
+
Requires-Dist: grpcio-tools<2,>=1.81.1; extra == "build"
|
|
15
|
+
Requires-Dist: pyinstaller>=6.0; extra == "build"
|
|
16
|
+
|
|
17
|
+
# pyPKIPC
|
|
18
|
+
|
|
19
|
+
`pyPKIPC 2` 是用于父子进程通信的轻量事件运行时。父进程负责启动子进程,双方通过一条长期 gRPC 双向流收发任意 JSON 事件。
|
|
20
|
+
|
|
21
|
+
运行时不使用 `stdin/stdout` 传输,不定义应用层握手,也没有旧协议兼容代码。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
python -m pip install PKIPC
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
本地开发和打包依赖:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
python -m pip install -e .[test,build]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 子进程:Server
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import threading
|
|
39
|
+
|
|
40
|
+
from pkipc import Server
|
|
41
|
+
|
|
42
|
+
server = Server()
|
|
43
|
+
stopped = threading.Event()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@server.on("solver.add")
|
|
47
|
+
def add(data):
|
|
48
|
+
return {"result": data["a"] + data["b"]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@server.on("worker.stop")
|
|
52
|
+
def stop(_data):
|
|
53
|
+
stopped.set()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
config = server.start()
|
|
57
|
+
server.send(
|
|
58
|
+
"pkipc.log",
|
|
59
|
+
{
|
|
60
|
+
"level": "INFO",
|
|
61
|
+
"message": f"worker started with {config}",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
while not stopped.wait(0.1) and not server.stop_requested():
|
|
65
|
+
pass
|
|
66
|
+
server.close()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`start()` 建立 gRPC 流并返回父进程下发的配置。事件回调在单一接收线程中按顺序执行。
|
|
70
|
+
|
|
71
|
+
## 父进程:Client
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import sys
|
|
75
|
+
|
|
76
|
+
from pkipc import Client
|
|
77
|
+
|
|
78
|
+
client = Client(
|
|
79
|
+
sys.executable,
|
|
80
|
+
args=("worker.py",),
|
|
81
|
+
config={"value": 42},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@client.on("pkipc.log")
|
|
86
|
+
def log_received(data):
|
|
87
|
+
print(data["level"], data["message"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
client.start()
|
|
91
|
+
result = client.request(
|
|
92
|
+
"solver.add",
|
|
93
|
+
{
|
|
94
|
+
"a": 10,
|
|
95
|
+
"b": 20,
|
|
96
|
+
},
|
|
97
|
+
timeout=5.0,
|
|
98
|
+
)
|
|
99
|
+
print(result)
|
|
100
|
+
client.send("worker.stop")
|
|
101
|
+
client.wait()
|
|
102
|
+
client.close()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
父子两端使用完全相同的 `send(event, data)`、`request(event, data, timeout)` 和 `on(event)`。`send()` 是不等待结果的单向事件;`request()` 阻塞等待对应 handler 的返回值,请求 ID 和响应匹配完全由 Runtime 管理。框架不提供 `send_data`、`send_log`、`on_data` 或 `on_log`;业务层可以按需用普通函数做薄封装。
|
|
106
|
+
|
|
107
|
+
`request()` 不能在 `@on` handler 内调用,因为 handler 运行在单一接收线程中;Runtime 会直接抛出 `RuntimeError`,避免嵌套同步请求死锁。请求超时抛出 `TimeoutError`,断连时所有等待中的请求抛出 `ConnectionError`,远端 handler 异常则在调用端表现为 `RuntimeError`。
|
|
108
|
+
|
|
109
|
+
框架不定义自有异常类型:无效事件抛出 `ValueError`,断连抛出 `ConnectionError`,启动等待超时抛出 `TimeoutError`,子进程提前退出抛出 `ChildProcessError`,重复启动等生命周期错误抛出 `RuntimeError`。
|
|
110
|
+
|
|
111
|
+
## 运行模型
|
|
112
|
+
|
|
113
|
+
1. 父进程在 `127.0.0.1:0` 启动专属 gRPC Server。
|
|
114
|
+
2. 父进程生成随机 token,通过环境变量把 endpoint 和 token 传给子进程。
|
|
115
|
+
3. 子进程建立 `Runtime.Run` 双向流。
|
|
116
|
+
4. 父进程发送的第一帧固定为 `pkipc.config`,由 `Server.start()` 消费。
|
|
117
|
+
5. 配置完成后,父子进程通过同样的 Frame 双向收发任意命名事件。
|
|
118
|
+
6. 流结束即表示停止,错误由 gRPC status 表达。
|
|
119
|
+
|
|
120
|
+
传输层只有一个无类型消息和一个 RPC:
|
|
121
|
+
|
|
122
|
+
```protobuf
|
|
123
|
+
service Runtime {
|
|
124
|
+
rpc Run(stream Frame) returns (stream Frame);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
message Frame {
|
|
128
|
+
bytes payload = 1;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`Frame.payload` 是 UTF-8 JSON,统一由 Runtime 层编码、校验和分发:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{"event":"pkipc.config","data":{"threads":8}}
|
|
136
|
+
{"event":"pkipc.control","data":{"action":"PAUSE"}}
|
|
137
|
+
{"event":"pkipc.data","data":{"progress":0.5}}
|
|
138
|
+
{"event":"pkipc.log","data":{"level":"INFO","message":"solver started"}}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
同步请求增加 `id`,响应使用内部事件 `pkipc.response` 和 `reply_to`:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{"event":"solver.add","data":{"a":10,"b":20},"id":"request-id"}
|
|
145
|
+
{"event":"pkipc.response","data":{"result":30},"reply_to":"request-id"}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`event` 必须是非空字符串,`data` 必须存在且可以是任意合法 JSON。`id`、`reply_to` 和错误响应由 Runtime 独占管理。`pkipc.config` 由启动流程管理;`pkipc.control`、`pkipc.data` 和 `pkipc.log` 是经过校验的标准事件约定,但不绑定专用 Python API。自定义事件名会原样传输,不需要修改或重新生成 Protobuf。
|
|
149
|
+
|
|
150
|
+
协议定义在:
|
|
151
|
+
|
|
152
|
+
- `pkipc/proto/pkipc.proto`
|
|
153
|
+
|
|
154
|
+
Python 生成代码和 `.proto` 一起发布。C++ 实现只需要从同一份 `.proto` 生成传输 stub,并在 Runtime dispatcher 中实现相同的 JSON 信封约定。
|
|
155
|
+
|
|
156
|
+
## 完整示例
|
|
157
|
+
|
|
158
|
+
- `examples/01_request_response/`:同步请求响应,实现计算器
|
|
159
|
+
- `examples/02_commands/`:单向命令,实现暂停、恢复和停止
|
|
160
|
+
- `examples/03_progress/`:长任务持续推送进度,最后返回结果
|
|
161
|
+
- `examples/04_reverse_request/`:Server 反向请求 Client
|
|
162
|
+
- `examples/05_errors/`:请求超时、连接断开和远端 handler 异常
|
|
163
|
+
|
|
164
|
+
每个目录包含一组 `client.py` 和 `server.py`。只运行 `client.py`,Client 会负责启动
|
|
165
|
+
Server 子进程:
|
|
166
|
+
|
|
167
|
+
```powershell
|
|
168
|
+
python examples/01_request_response/client.py
|
|
169
|
+
python examples/02_commands/client.py
|
|
170
|
+
python examples/03_progress/client.py
|
|
171
|
+
python examples/04_reverse_request/client.py
|
|
172
|
+
python examples/05_errors/client.py
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
完整说明见 `examples/README.md`。
|
|
176
|
+
|
|
177
|
+
## 测试
|
|
178
|
+
|
|
179
|
+
```powershell
|
|
180
|
+
python -m pytest -q
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
测试覆盖单一 Frame 协议、信封校验、双向通用事件分发、五组完整示例、启动失败、
|
|
184
|
+
stdout 独立性、主动关闭和子进程异常退出。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pkipc/__init__.py,sha256=wQjxYPHEEt3v1XaHeX5kAF0OXMtY4J979jnEPZdijW0,175
|
|
2
|
+
pkipc/_grpc_common.py,sha256=JYc4MRWUX-wExgqIqdhUD3cNR4iHFb9jS37H7Qjqmt8,360
|
|
3
|
+
pkipc/_grpc_host.py,sha256=OqUrESpHcnXGqZw7zAatJVP98yOLV30NdVj7WGJ5EYw,5049
|
|
4
|
+
pkipc/_runtime.py,sha256=m-c7Z_ODaEnYIvWyEU07WyYOmgr7A2xulaeCRz13INU,11668
|
|
5
|
+
pkipc/_stream.py,sha256=MiuQbVwfGN-VvVvNO_J7qmk9865EQ3rihUMJpo9BoSE,1682
|
|
6
|
+
pkipc/client.py,sha256=bljEPFYa--Mf1cU9Bop6t-fNM0nTF1I03WahWrFJLIg,8862
|
|
7
|
+
pkipc/server.py,sha256=yiti62w3CTE_9_o2VEOnF0PUm6r3BMMeNEcWWIMIvhM,5627
|
|
8
|
+
pkipc/proto/__init__.py,sha256=h9xWjvc7Er9ibz4C_Y-4_jH6FJHQAsP55JnoG3yKotY,45
|
|
9
|
+
pkipc/proto/pkipc.proto,sha256=rqEkiUZ9IRIqa2ClPHJLrt6X6h7QTeGgLVXPdLOsk40,155
|
|
10
|
+
pkipc/proto/pkipc_pb2.py,sha256=5KLWJWmzwjFWbpPLALDp8vPvtuNgNeC84-S2186yB4g,1505
|
|
11
|
+
pkipc/proto/pkipc_pb2.pyi,sha256=RMdUUuzXGqYbgC468lMN8bweDK7A3FdOym6PjpZ-cuc,396
|
|
12
|
+
pkipc/proto/pkipc_pb2_grpc.py,sha256=n7I0Ro_whSumoqbEQYyG-_DeVMBMv5e48Tygaea2g5c,3469
|
|
13
|
+
pkipc-2.0.0.dist-info/METADATA,sha256=73DsjSR32imvJukUnzPLSYrNA9JFgT1yKUQSOWEeazI,5945
|
|
14
|
+
pkipc-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
pkipc-2.0.0.dist-info/top_level.txt,sha256=nqx0Nalxl2qZh7N1s3uK5-LC6LK-qxQB9nys7UOMms8,6
|
|
16
|
+
pkipc-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pkipc
|