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.
@@ -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,5 @@
1
+ """Run the Pynect worker CLI with ``python -m pynect_worker``."""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1 @@
1
+ """Generated package metadata."""
@@ -0,0 +1,3 @@
1
+ """Package identity synthesized from the shared Projen configuration."""
2
+ PACKAGE_NAME = "pynect-worker"
3
+ PACKAGE_VERSION = "0.8.5"
@@ -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()