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 ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import Client
4
+ from .server import Server
5
+
6
+ __version__ = "2.0.0"
7
+
8
+ __all__ = [
9
+ "Client",
10
+ "Server",
11
+ "__version__",
12
+ ]
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()
@@ -0,0 +1 @@
1
+ """Generated protobuf contract for PKIPC."""
@@ -0,0 +1,11 @@
1
+ syntax = "proto3";
2
+
3
+ package pkipc.runtime.v2;
4
+
5
+ service Runtime {
6
+ rpc Run(stream Frame) returns (stream Frame);
7
+ }
8
+
9
+ message Frame {
10
+ bytes payload = 1;
11
+ }
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pkipc