pynect-worker 0.8.5__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.
- pynect_worker/__init__.py +11 -0
- pynect_worker/__main__.py +5 -0
- pynect_worker/_generated/__init__.py +1 -0
- pynect_worker/_generated/metadata.py +3 -0
- pynect_worker/bridge.py +594 -0
- pynect_worker/cli.py +64 -0
- pynect_worker/codegen.py +501 -0
- pynect_worker/connection.py +147 -0
- pynect_worker/framing.py +32 -0
- pynect_worker/models.py +108 -0
- pynect_worker/py.typed +1 -0
- pynect_worker/schema.py +47 -0
- pynect_worker/server.py +221 -0
- pynect_worker-0.8.5.dist-info/METADATA +386 -0
- pynect_worker-0.8.5.dist-info/RECORD +17 -0
- pynect_worker-0.8.5.dist-info/WHEEL +4 -0
- pynect_worker-0.8.5.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Python worker and proxy-fronted WebSocket server for Pynect."""
|
|
2
|
+
|
|
3
|
+
from ._generated.metadata import PACKAGE_NAME, PACKAGE_VERSION
|
|
4
|
+
from .models import ServerOptions, WorkerOptions
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PACKAGE_NAME",
|
|
8
|
+
"PACKAGE_VERSION",
|
|
9
|
+
"ServerOptions",
|
|
10
|
+
"WorkerOptions",
|
|
11
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated package metadata."""
|
pynect_worker/bridge.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import contextlib
|
|
6
|
+
import dataclasses
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import enum
|
|
9
|
+
import importlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import inspect
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import traceback
|
|
16
|
+
import weakref
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, get_type_hints
|
|
20
|
+
|
|
21
|
+
from .connection import FramedConnection, RpcConnection
|
|
22
|
+
from .models import PROTOCOL_VERSION, MessageKind, Operation, WireValueType
|
|
23
|
+
|
|
24
|
+
WIRE_TYPE = "__pynect_wire_type__"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JavaScriptError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ObjectRegistry:
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._next_id = 1
|
|
35
|
+
self._objects: dict[int, Any] = {}
|
|
36
|
+
|
|
37
|
+
def add(self, value: Any) -> int:
|
|
38
|
+
with self._lock:
|
|
39
|
+
object_id = self._next_id
|
|
40
|
+
self._next_id += 1
|
|
41
|
+
self._objects[object_id] = value
|
|
42
|
+
return object_id
|
|
43
|
+
|
|
44
|
+
def get(self, object_id: int) -> Any:
|
|
45
|
+
with self._lock:
|
|
46
|
+
try:
|
|
47
|
+
return self._objects[object_id]
|
|
48
|
+
except KeyError as error:
|
|
49
|
+
raise ReferenceError(f"Python object #{object_id} has been released") from error
|
|
50
|
+
|
|
51
|
+
def release(self, object_id: int) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._objects.pop(object_id, None)
|
|
54
|
+
|
|
55
|
+
def clear(self) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._objects.clear()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncRuntime:
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
64
|
+
self._thread: threading.Thread | None = None
|
|
65
|
+
self._closed = False
|
|
66
|
+
|
|
67
|
+
def _run(self) -> None:
|
|
68
|
+
loop = self._loop
|
|
69
|
+
if loop is None:
|
|
70
|
+
return
|
|
71
|
+
asyncio.set_event_loop(loop)
|
|
72
|
+
loop.run_forever()
|
|
73
|
+
|
|
74
|
+
def _ensure_started(self) -> asyncio.AbstractEventLoop:
|
|
75
|
+
with self._lock:
|
|
76
|
+
if self._closed:
|
|
77
|
+
raise RuntimeError("The Pynect asyncio runtime is closed")
|
|
78
|
+
if self._loop is not None:
|
|
79
|
+
return self._loop
|
|
80
|
+
loop = asyncio.new_event_loop()
|
|
81
|
+
self._loop = loop
|
|
82
|
+
thread = threading.Thread(
|
|
83
|
+
target=self._run,
|
|
84
|
+
name="pynect-asyncio",
|
|
85
|
+
daemon=True,
|
|
86
|
+
)
|
|
87
|
+
self._thread = thread
|
|
88
|
+
thread.start()
|
|
89
|
+
return loop
|
|
90
|
+
|
|
91
|
+
def resolve(self, value: Any) -> Any:
|
|
92
|
+
if not inspect.isawaitable(value):
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
async def await_value() -> Any:
|
|
96
|
+
return await value
|
|
97
|
+
|
|
98
|
+
return asyncio.run_coroutine_threadsafe(
|
|
99
|
+
await_value(),
|
|
100
|
+
self._ensure_started(),
|
|
101
|
+
).result()
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._closed:
|
|
106
|
+
return
|
|
107
|
+
self._closed = True
|
|
108
|
+
loop = self._loop
|
|
109
|
+
thread = self._thread
|
|
110
|
+
self._loop = None
|
|
111
|
+
self._thread = None
|
|
112
|
+
if loop is None:
|
|
113
|
+
return
|
|
114
|
+
with contextlib.suppress(Exception):
|
|
115
|
+
asyncio.run_coroutine_threadsafe(self._cancel_pending(), loop).result(timeout=2)
|
|
116
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
117
|
+
if thread is not None:
|
|
118
|
+
thread.join(timeout=2)
|
|
119
|
+
if not loop.is_closed():
|
|
120
|
+
loop.close()
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
async def _cancel_pending() -> None:
|
|
124
|
+
current = asyncio.current_task()
|
|
125
|
+
tasks = [task for task in asyncio.all_tasks() if task is not current]
|
|
126
|
+
for task in tasks:
|
|
127
|
+
task.cancel()
|
|
128
|
+
if tasks:
|
|
129
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
130
|
+
await asyncio.get_running_loop().shutdown_asyncgens()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class PydanticValidator:
|
|
134
|
+
def __init__(self, enabled: bool) -> None:
|
|
135
|
+
self.enabled = enabled
|
|
136
|
+
self._pydantic: Any | None = None
|
|
137
|
+
if enabled:
|
|
138
|
+
try:
|
|
139
|
+
import pydantic
|
|
140
|
+
|
|
141
|
+
self._pydantic = pydantic
|
|
142
|
+
except ImportError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
def arguments(
|
|
146
|
+
self,
|
|
147
|
+
function: Callable[..., Any],
|
|
148
|
+
args: list[Any],
|
|
149
|
+
kwargs: dict[str, Any],
|
|
150
|
+
) -> tuple[list[Any], dict[str, Any]]:
|
|
151
|
+
if self._pydantic is None:
|
|
152
|
+
return args, kwargs
|
|
153
|
+
try:
|
|
154
|
+
signature = inspect.signature(function)
|
|
155
|
+
hints = get_type_hints(function, include_extras=True)
|
|
156
|
+
except (NameError, TypeError, ValueError):
|
|
157
|
+
return args, kwargs
|
|
158
|
+
|
|
159
|
+
bound = signature.bind(*args, **kwargs)
|
|
160
|
+
bound.apply_defaults()
|
|
161
|
+
for name, value in list(bound.arguments.items()):
|
|
162
|
+
parameter = signature.parameters[name]
|
|
163
|
+
annotation = hints.get(name, parameter.annotation)
|
|
164
|
+
if annotation is inspect.Parameter.empty:
|
|
165
|
+
continue
|
|
166
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
167
|
+
bound.arguments[name] = tuple(self._validate(annotation, item) for item in value)
|
|
168
|
+
elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
169
|
+
bound.arguments[name] = {key: self._validate(annotation, item) for key, item in value.items()}
|
|
170
|
+
else:
|
|
171
|
+
bound.arguments[name] = self._validate(annotation, value)
|
|
172
|
+
return list(bound.args), dict(bound.kwargs)
|
|
173
|
+
|
|
174
|
+
def _validate(self, annotation: Any, value: Any) -> Any:
|
|
175
|
+
type_adapter = getattr(self._pydantic, "TypeAdapter", None)
|
|
176
|
+
if type_adapter is not None:
|
|
177
|
+
return type_adapter(annotation).validate_python(value)
|
|
178
|
+
parse_obj_as = getattr(self._pydantic, "parse_obj_as", None)
|
|
179
|
+
return parse_obj_as(annotation, value) if parse_obj_as else value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclasses.dataclass
|
|
183
|
+
class PendingCallback:
|
|
184
|
+
event: threading.Event = dataclasses.field(default_factory=threading.Event)
|
|
185
|
+
message: dict[str, Any] | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class JavaScriptCallback:
|
|
189
|
+
def __init__(self, bridge: Bridge, callback_id: int) -> None:
|
|
190
|
+
self._bridge = bridge
|
|
191
|
+
self._callback_id = callback_id
|
|
192
|
+
self._release = weakref.finalize(
|
|
193
|
+
self,
|
|
194
|
+
bridge.release_callback,
|
|
195
|
+
callback_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
199
|
+
return self._bridge.invoke_callback(self._callback_id, list(args), kwargs)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Bridge:
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
connection: RpcConnection,
|
|
206
|
+
token: str | None,
|
|
207
|
+
validate_pydantic: bool,
|
|
208
|
+
modules: Mapping[str, str] | None = None,
|
|
209
|
+
executor: concurrent.futures.Executor | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self.connection = connection
|
|
212
|
+
self.token = token
|
|
213
|
+
self.modules = None if modules is None else dict(modules)
|
|
214
|
+
self.registry = ObjectRegistry()
|
|
215
|
+
self.async_runtime = AsyncRuntime()
|
|
216
|
+
self.validator = PydanticValidator(validate_pydantic)
|
|
217
|
+
self._stopped = threading.Event()
|
|
218
|
+
self._callback_lock = threading.Lock()
|
|
219
|
+
self._next_callback_id = 1
|
|
220
|
+
self._pending_callbacks: dict[int, PendingCallback] = {}
|
|
221
|
+
self._owns_executor = executor is None
|
|
222
|
+
worker_count = min(8, (os.cpu_count() or 1) + 4)
|
|
223
|
+
self._executor = executor or concurrent.futures.ThreadPoolExecutor(
|
|
224
|
+
max_workers=worker_count, thread_name_prefix="pynect-call"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def run(self) -> None:
|
|
228
|
+
hello: dict[str, Any] = {
|
|
229
|
+
"kind": MessageKind.HELLO.value,
|
|
230
|
+
"pid": os.getpid(),
|
|
231
|
+
"protocol": PROTOCOL_VERSION,
|
|
232
|
+
}
|
|
233
|
+
if self.token is not None:
|
|
234
|
+
hello["token"] = self.token
|
|
235
|
+
self.connection.write(hello)
|
|
236
|
+
try:
|
|
237
|
+
while not self._stopped.is_set():
|
|
238
|
+
message = self.connection.read()
|
|
239
|
+
if message is None:
|
|
240
|
+
break
|
|
241
|
+
self._dispatch_message(message)
|
|
242
|
+
finally:
|
|
243
|
+
self._stopped.set()
|
|
244
|
+
with self._callback_lock:
|
|
245
|
+
pending = list(self._pending_callbacks.values())
|
|
246
|
+
self._pending_callbacks.clear()
|
|
247
|
+
for callback in pending:
|
|
248
|
+
callback.message = {
|
|
249
|
+
"ok": False,
|
|
250
|
+
"error": {
|
|
251
|
+
"message": "JavaScript host disconnected",
|
|
252
|
+
"type": "ConnectionError",
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
callback.event.set()
|
|
256
|
+
if self._owns_executor:
|
|
257
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
258
|
+
self.async_runtime.close()
|
|
259
|
+
self.registry.clear()
|
|
260
|
+
self.connection.close()
|
|
261
|
+
|
|
262
|
+
def _dispatch_message(self, message: dict[str, Any]) -> None:
|
|
263
|
+
kind = parse_message_kind(message.get("kind"))
|
|
264
|
+
if kind is MessageKind.CALLBACK_RESULT:
|
|
265
|
+
self._resolve_callback(message)
|
|
266
|
+
return
|
|
267
|
+
if kind not in {MessageKind.REQUEST, MessageKind.NOTIFY}:
|
|
268
|
+
raise TypeError(f"Unknown RPC message kind: {kind}")
|
|
269
|
+
operation = parse_operation(message.get("op"))
|
|
270
|
+
if operation is Operation.SHUTDOWN and kind is MessageKind.REQUEST:
|
|
271
|
+
self.connection.write(
|
|
272
|
+
{
|
|
273
|
+
"id": message.get("id"),
|
|
274
|
+
"kind": MessageKind.RESPONSE.value,
|
|
275
|
+
"ok": True,
|
|
276
|
+
"value": None,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
self._stopped.set()
|
|
280
|
+
return
|
|
281
|
+
self._executor.submit(self._handle_request, message)
|
|
282
|
+
|
|
283
|
+
def _handle_request(self, message: dict[str, Any]) -> None:
|
|
284
|
+
request_id = message.get("id")
|
|
285
|
+
expects_response = parse_message_kind(message.get("kind")) is MessageKind.REQUEST
|
|
286
|
+
try:
|
|
287
|
+
payload = decode_wire(message.get("payload", {}), self)
|
|
288
|
+
if not isinstance(payload, dict):
|
|
289
|
+
raise TypeError("RPC request payload must be a map")
|
|
290
|
+
result = self._operate(parse_operation(message.get("op")), payload)
|
|
291
|
+
if expects_response:
|
|
292
|
+
self.connection.write(
|
|
293
|
+
{
|
|
294
|
+
"id": request_id,
|
|
295
|
+
"kind": MessageKind.RESPONSE.value,
|
|
296
|
+
"ok": True,
|
|
297
|
+
"value": encode_wire(result, self),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
# Even SystemExit from user code must become a response instead of stranding the host.
|
|
301
|
+
except BaseException as error: # noqa: BLE001
|
|
302
|
+
if expects_response:
|
|
303
|
+
self.connection.write(
|
|
304
|
+
{
|
|
305
|
+
"error": {
|
|
306
|
+
"message": str(error),
|
|
307
|
+
"traceback": traceback.format_exc(),
|
|
308
|
+
"type": type(error).__name__,
|
|
309
|
+
},
|
|
310
|
+
"id": request_id,
|
|
311
|
+
"kind": MessageKind.RESPONSE.value,
|
|
312
|
+
"ok": False,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _operate(self, operation: Operation, payload: dict[str, Any]) -> Any:
|
|
317
|
+
if operation is Operation.IMPORT:
|
|
318
|
+
public_name = str(payload["name"])
|
|
319
|
+
if self.modules is None:
|
|
320
|
+
module_name = public_name
|
|
321
|
+
else:
|
|
322
|
+
try:
|
|
323
|
+
module_name = self.modules[public_name]
|
|
324
|
+
except KeyError as error:
|
|
325
|
+
raise PermissionError(
|
|
326
|
+
f'Python module "{public_name}" is not exposed by this Pynect server.'
|
|
327
|
+
) from error
|
|
328
|
+
return import_python_module(module_name)
|
|
329
|
+
target = self.registry.get(int(payload["target"]))
|
|
330
|
+
path = [str(part) for part in payload.get("path", [])]
|
|
331
|
+
value = resolve_path(target, path)
|
|
332
|
+
if operation is Operation.GET:
|
|
333
|
+
return value
|
|
334
|
+
if operation is Operation.SET:
|
|
335
|
+
if not path:
|
|
336
|
+
raise TypeError("Setting a root Python reference is not supported")
|
|
337
|
+
parent = resolve_path(target, path[:-1])
|
|
338
|
+
setattr(parent, path[-1], payload.get("value"))
|
|
339
|
+
return None
|
|
340
|
+
if operation is Operation.CALL:
|
|
341
|
+
if not callable(value):
|
|
342
|
+
raise TypeError(f"{type(value).__name__} object is not callable")
|
|
343
|
+
args = list(payload.get("args", []))
|
|
344
|
+
kwargs = dict(payload.get("kwargs", {}))
|
|
345
|
+
args, kwargs = self.validator.arguments(value, args, kwargs)
|
|
346
|
+
return self.async_runtime.resolve(value(*args, **kwargs))
|
|
347
|
+
if operation is Operation.ITER:
|
|
348
|
+
iterator = value.__aiter__() if hasattr(value, "__aiter__") else iter(value)
|
|
349
|
+
return iterator
|
|
350
|
+
if operation is Operation.NEXT:
|
|
351
|
+
try:
|
|
352
|
+
item = self.async_runtime.resolve(value.__anext__()) if hasattr(value, "__anext__") else next(value)
|
|
353
|
+
return {"done": False, "value": item}
|
|
354
|
+
except (StopIteration, StopAsyncIteration):
|
|
355
|
+
return {"done": True, "value": None}
|
|
356
|
+
if operation is Operation.REPR:
|
|
357
|
+
return repr(value)
|
|
358
|
+
if operation is Operation.DIR:
|
|
359
|
+
return sorted(name for name in dir(value) if not name.startswith("_"))
|
|
360
|
+
if operation is Operation.VALUE:
|
|
361
|
+
return force_value(value)
|
|
362
|
+
if operation is Operation.RELEASE:
|
|
363
|
+
self.registry.release(int(payload["target"]))
|
|
364
|
+
return None
|
|
365
|
+
raise ValueError(f"Unknown RPC operation: {operation}")
|
|
366
|
+
|
|
367
|
+
def callback(self, callback_id: int) -> JavaScriptCallback:
|
|
368
|
+
return JavaScriptCallback(self, callback_id)
|
|
369
|
+
|
|
370
|
+
def release_callback(self, callback_id: int) -> None:
|
|
371
|
+
if self._stopped.is_set():
|
|
372
|
+
return
|
|
373
|
+
with contextlib.suppress(EOFError, OSError):
|
|
374
|
+
self.connection.write(
|
|
375
|
+
{
|
|
376
|
+
"callback": callback_id,
|
|
377
|
+
"kind": MessageKind.CALLBACK_RELEASE.value,
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def invoke_callback(
|
|
382
|
+
self,
|
|
383
|
+
callback_id: int,
|
|
384
|
+
args: list[Any],
|
|
385
|
+
kwargs: dict[str, Any],
|
|
386
|
+
) -> Any:
|
|
387
|
+
with self._callback_lock:
|
|
388
|
+
request_id = self._next_callback_id
|
|
389
|
+
self._next_callback_id += 1
|
|
390
|
+
pending = PendingCallback()
|
|
391
|
+
self._pending_callbacks[request_id] = pending
|
|
392
|
+
self.connection.write(
|
|
393
|
+
{
|
|
394
|
+
"args": encode_wire(args, self),
|
|
395
|
+
"callback": callback_id,
|
|
396
|
+
"id": request_id,
|
|
397
|
+
"kind": MessageKind.CALLBACK.value,
|
|
398
|
+
"kwargs": encode_wire(kwargs, self),
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
pending.event.wait()
|
|
402
|
+
message = pending.message or {}
|
|
403
|
+
if message.get("ok") is True:
|
|
404
|
+
return decode_wire(message.get("value"), self)
|
|
405
|
+
error = message.get("error")
|
|
406
|
+
detail = error.get("message") if isinstance(error, dict) else str(error)
|
|
407
|
+
raise JavaScriptError(detail or "JavaScript callback failed")
|
|
408
|
+
|
|
409
|
+
def _resolve_callback(self, message: dict[str, Any]) -> None:
|
|
410
|
+
request_id = int(message.get("id", 0))
|
|
411
|
+
with self._callback_lock:
|
|
412
|
+
pending = self._pending_callbacks.pop(request_id, None)
|
|
413
|
+
if pending is not None:
|
|
414
|
+
pending.message = message
|
|
415
|
+
pending.event.set()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def resolve_path(value: Any, path: list[str]) -> Any:
|
|
419
|
+
for name in path:
|
|
420
|
+
value = getattr(value, name)
|
|
421
|
+
return value
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def parse_message_kind(value: Any) -> MessageKind:
|
|
425
|
+
try:
|
|
426
|
+
return MessageKind(value)
|
|
427
|
+
except (TypeError, ValueError) as error:
|
|
428
|
+
raise TypeError(f"Unknown RPC message kind: {value}") from error
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def parse_operation(value: Any) -> Operation:
|
|
432
|
+
try:
|
|
433
|
+
return Operation(value)
|
|
434
|
+
except (TypeError, ValueError) as error:
|
|
435
|
+
raise TypeError(f"Unknown RPC operation: {value}") from error
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def import_python_module(name: str) -> Any:
|
|
439
|
+
candidate = Path(name)
|
|
440
|
+
if candidate.suffix == ".py" or candidate.is_absolute() or any(separator in name for separator in ("/", "\\")):
|
|
441
|
+
source = candidate if candidate.is_absolute() else Path.cwd() / candidate
|
|
442
|
+
source = source.resolve()
|
|
443
|
+
module_name = f"pynect_file_{abs(hash(source))}"
|
|
444
|
+
spec = importlib.util.spec_from_file_location(module_name, source)
|
|
445
|
+
if spec is None or spec.loader is None:
|
|
446
|
+
raise ImportError(f"Cannot import Python file: {source}")
|
|
447
|
+
module = importlib.util.module_from_spec(spec)
|
|
448
|
+
sys.modules[module_name] = module
|
|
449
|
+
spec.loader.exec_module(module)
|
|
450
|
+
return module
|
|
451
|
+
return importlib.import_module(name)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def encode_wire(value: Any, bridge: Bridge, seen: set[int] | None = None) -> Any:
|
|
455
|
+
if value is None or isinstance(value, (bool, int, float, str, bytes)):
|
|
456
|
+
return value
|
|
457
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
458
|
+
return bytes(value)
|
|
459
|
+
if isinstance(value, enum.Enum):
|
|
460
|
+
return encode_wire(value.value, bridge, seen)
|
|
461
|
+
if isinstance(value, dt.datetime):
|
|
462
|
+
return {WIRE_TYPE: WireValueType.DATETIME.value, "value": value.isoformat()}
|
|
463
|
+
if isinstance(value, dt.date):
|
|
464
|
+
return {WIRE_TYPE: WireValueType.DATE.value, "value": value.isoformat()}
|
|
465
|
+
if isinstance(value, dt.time):
|
|
466
|
+
return {WIRE_TYPE: WireValueType.TIME.value, "value": value.isoformat()}
|
|
467
|
+
|
|
468
|
+
model_dump = getattr(value, "model_dump", None)
|
|
469
|
+
if callable(model_dump):
|
|
470
|
+
return encode_wire(model_dump(mode="json"), bridge, seen)
|
|
471
|
+
legacy_dict = getattr(value, "dict", None)
|
|
472
|
+
if callable(legacy_dict) and type(value).__module__.startswith("pydantic"):
|
|
473
|
+
return encode_wire(legacy_dict(), bridge, seen)
|
|
474
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
475
|
+
return encode_wire(dataclasses.asdict(value), bridge, seen)
|
|
476
|
+
|
|
477
|
+
seen = seen or set()
|
|
478
|
+
identity = id(value)
|
|
479
|
+
if identity in seen:
|
|
480
|
+
return python_reference(value, bridge)
|
|
481
|
+
seen.add(identity)
|
|
482
|
+
try:
|
|
483
|
+
if isinstance(value, (list, tuple)):
|
|
484
|
+
return [encode_wire(item, bridge, seen) for item in value]
|
|
485
|
+
if isinstance(value, (set, frozenset)):
|
|
486
|
+
return {
|
|
487
|
+
WIRE_TYPE: WireValueType.SET.value,
|
|
488
|
+
"value": [encode_wire(item, bridge, seen) for item in value],
|
|
489
|
+
}
|
|
490
|
+
if isinstance(value, Mapping):
|
|
491
|
+
if all(isinstance(key, str) for key in value) and WIRE_TYPE not in value:
|
|
492
|
+
return {key: encode_wire(item, bridge, seen) for key, item in value.items()}
|
|
493
|
+
return {
|
|
494
|
+
WIRE_TYPE: WireValueType.MAP.value,
|
|
495
|
+
"value": [
|
|
496
|
+
[encode_wire(key, bridge, seen), encode_wire(item, bridge, seen)] for key, item in value.items()
|
|
497
|
+
],
|
|
498
|
+
}
|
|
499
|
+
return python_reference(value, bridge)
|
|
500
|
+
finally:
|
|
501
|
+
seen.remove(identity)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def python_reference(value: Any, bridge: Bridge) -> dict[str, Any]:
|
|
505
|
+
return {
|
|
506
|
+
WIRE_TYPE: WireValueType.REFERENCE.value,
|
|
507
|
+
"id": bridge.registry.add(value),
|
|
508
|
+
"pythonType": f"{type(value).__module__}.{type(value).__qualname__}",
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def decode_wire(value: Any, bridge: Bridge) -> Any:
|
|
513
|
+
if isinstance(value, list):
|
|
514
|
+
return [decode_wire(item, bridge) for item in value]
|
|
515
|
+
if not isinstance(value, dict):
|
|
516
|
+
return value
|
|
517
|
+
if WIRE_TYPE not in value:
|
|
518
|
+
return {key: decode_wire(item, bridge) for key, item in value.items()}
|
|
519
|
+
try:
|
|
520
|
+
wire_type = WireValueType(value[WIRE_TYPE])
|
|
521
|
+
except (TypeError, ValueError) as error:
|
|
522
|
+
raise TypeError(f"Unknown wire value type: {value[WIRE_TYPE]}") from error
|
|
523
|
+
if wire_type is WireValueType.REFERENCE:
|
|
524
|
+
return bridge.registry.get(int(value["id"]))
|
|
525
|
+
if wire_type is WireValueType.CALLBACK:
|
|
526
|
+
return bridge.callback(int(value["id"]))
|
|
527
|
+
if wire_type is WireValueType.UNDEFINED:
|
|
528
|
+
return None
|
|
529
|
+
if wire_type is WireValueType.DATETIME:
|
|
530
|
+
# Python 3.10 needs the UTC designator normalized before fromisoformat.
|
|
531
|
+
return dt.datetime.fromisoformat(str(value["value"]).replace("Z", "+00:00"))
|
|
532
|
+
if wire_type is WireValueType.DATE:
|
|
533
|
+
return dt.date.fromisoformat(str(value["value"]))
|
|
534
|
+
if wire_type is WireValueType.TIME:
|
|
535
|
+
return dt.time.fromisoformat(str(value["value"]))
|
|
536
|
+
if wire_type is WireValueType.SET:
|
|
537
|
+
return {decode_wire(item, bridge) for item in value.get("value", [])}
|
|
538
|
+
if wire_type is WireValueType.MAP:
|
|
539
|
+
return {decode_wire(entry[0], bridge): decode_wire(entry[1], bridge) for entry in value.get("value", [])}
|
|
540
|
+
raise AssertionError(f"Unhandled wire value type: {wire_type}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def force_value(value: Any, seen: set[int] | None = None) -> Any:
|
|
544
|
+
if value is None or isinstance(value, (bool, int, float, str, bytes)):
|
|
545
|
+
return value
|
|
546
|
+
if isinstance(value, (dt.datetime, dt.date, dt.time, enum.Enum)):
|
|
547
|
+
return value
|
|
548
|
+
model_dump = getattr(value, "model_dump", None)
|
|
549
|
+
if callable(model_dump):
|
|
550
|
+
return model_dump(mode="json")
|
|
551
|
+
legacy_dict = getattr(value, "dict", None)
|
|
552
|
+
if callable(legacy_dict) and type(value).__module__.startswith("pydantic"):
|
|
553
|
+
return legacy_dict()
|
|
554
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
555
|
+
return dataclasses.asdict(value)
|
|
556
|
+
|
|
557
|
+
seen = seen or set()
|
|
558
|
+
identity = id(value)
|
|
559
|
+
if identity in seen:
|
|
560
|
+
raise ValueError("Cannot copy a circular Python value")
|
|
561
|
+
seen.add(identity)
|
|
562
|
+
try:
|
|
563
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
564
|
+
return [force_value(item, seen) for item in value]
|
|
565
|
+
if isinstance(value, Mapping):
|
|
566
|
+
return {key: force_value(item, seen) for key, item in value.items()}
|
|
567
|
+
if hasattr(value, "__dict__"):
|
|
568
|
+
return {key: force_value(item, seen) for key, item in vars(value).items() if not key.startswith("_")}
|
|
569
|
+
finally:
|
|
570
|
+
seen.remove(identity)
|
|
571
|
+
raise TypeError(f"{type(value).__name__} cannot be copied by value")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def main() -> None:
|
|
575
|
+
endpoint = os.environ.get("PYNECT_ENDPOINT")
|
|
576
|
+
token = os.environ.get("PYNECT_TOKEN")
|
|
577
|
+
if not endpoint or not token:
|
|
578
|
+
raise RuntimeError("PYNECT_ENDPOINT and PYNECT_TOKEN are required")
|
|
579
|
+
expected_parent = os.environ.get("PYNECT_PARENT_PID")
|
|
580
|
+
if expected_parent and int(expected_parent) <= 0:
|
|
581
|
+
raise RuntimeError("PYNECT_PARENT_PID is invalid")
|
|
582
|
+
cwd = os.environ.get("PYNECT_CWD")
|
|
583
|
+
if cwd:
|
|
584
|
+
os.chdir(cwd)
|
|
585
|
+
if cwd not in sys.path:
|
|
586
|
+
sys.path.insert(0, cwd)
|
|
587
|
+
connection = FramedConnection(endpoint)
|
|
588
|
+
validate = os.environ.get("PYNECT_VALIDATE_PYDANTIC", "1") != "0"
|
|
589
|
+
bridge = Bridge(connection, token, validate)
|
|
590
|
+
bridge.run()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
main()
|
pynect_worker/cli.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Cyclopts command-line interfaces for the worker and WebSocket server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import App, Parameter
|
|
8
|
+
|
|
9
|
+
from ._generated.metadata import PACKAGE_VERSION
|
|
10
|
+
from .models import ServerOptions
|
|
11
|
+
|
|
12
|
+
app = App(
|
|
13
|
+
name="pynect-worker",
|
|
14
|
+
help="Run Pynect's local worker or proxy-fronted WebSocket server.",
|
|
15
|
+
version=PACKAGE_VERSION,
|
|
16
|
+
)
|
|
17
|
+
server_app = App(
|
|
18
|
+
name="pynect-server",
|
|
19
|
+
help="Run Pynect's proxy-fronted WebSocket server.",
|
|
20
|
+
version=PACKAGE_VERSION,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
FlatServerOptions = Annotated[ServerOptions | None, Parameter(name="*")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _serve(options: ServerOptions) -> None:
|
|
27
|
+
from .server import run_server
|
|
28
|
+
|
|
29
|
+
run_server(options)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command
|
|
33
|
+
def ipc() -> None:
|
|
34
|
+
"""Run the private local IPC worker from its environment configuration."""
|
|
35
|
+
|
|
36
|
+
from .bridge import main as run_ipc_worker
|
|
37
|
+
|
|
38
|
+
run_ipc_worker()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command
|
|
42
|
+
def serve(options: FlatServerOptions = None) -> None:
|
|
43
|
+
"""Serve isolated Pynect sessions over binary WebSockets."""
|
|
44
|
+
|
|
45
|
+
_serve(options or ServerOptions())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@server_app.default
|
|
49
|
+
def serve_default(options: FlatServerOptions = None) -> None:
|
|
50
|
+
"""Serve isolated Pynect sessions over binary WebSockets."""
|
|
51
|
+
|
|
52
|
+
_serve(options or ServerOptions())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
"""Run the multi-command worker CLI."""
|
|
57
|
+
|
|
58
|
+
app()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def server_main() -> None:
|
|
62
|
+
"""Run the server-only CLI."""
|
|
63
|
+
|
|
64
|
+
server_app()
|