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,501 @@
1
+ from __future__ import annotations
2
+
3
+ import collections.abc
4
+ import contextlib
5
+ import datetime as dt
6
+ import importlib
7
+ import inspect
8
+ import json
9
+ import keyword
10
+ import re
11
+ import sys
12
+ import types
13
+ import typing
14
+ import uuid
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from cyclopts import App
20
+
21
+ codegen_app = App(name="pynect-codegen", help="Generate Pynect TypeScript bindings from a JSON request.")
22
+
23
+
24
+ class BridgeSettings(typing.NamedTuple):
25
+ auto_close: bool | None
26
+ call_timeout_ms: int | None
27
+ project: str | typing.Literal[False] | None
28
+ startup_timeout_ms: int | None
29
+ uv_args: tuple[str, ...]
30
+ validate_pydantic: bool | None
31
+ with_editable: tuple[str, ...]
32
+ with_requirements: tuple[str, ...]
33
+ with_values: tuple[str, ...]
34
+
35
+
36
+ class GeneratorSettings(typing.NamedTuple):
37
+ bridge: BridgeSettings
38
+ class_name: str
39
+ modules: dict[str, str]
40
+ out: Path
41
+
42
+
43
+ def annotation_to_typescript(annotation: Any) -> str:
44
+ if annotation in {
45
+ inspect.Signature.empty,
46
+ inspect.Parameter.empty,
47
+ Any,
48
+ typing.Any,
49
+ }:
50
+ return "unknown"
51
+ if annotation in {None, type(None)}:
52
+ return "null"
53
+ primitives = {
54
+ str: "string",
55
+ int: "number",
56
+ float: "number",
57
+ complex: "{ readonly real: number; readonly imag: number }",
58
+ bool: "boolean",
59
+ bytes: "Uint8Array",
60
+ bytearray: "Uint8Array",
61
+ dt.datetime: "Date",
62
+ dt.date: "Date",
63
+ dt.time: "string",
64
+ Decimal: "string",
65
+ uuid.UUID: "string",
66
+ }
67
+ if annotation in primitives:
68
+ return primitives[annotation]
69
+
70
+ origin = typing.get_origin(annotation)
71
+ arguments = typing.get_args(annotation)
72
+ if origin in {typing.Union, types.UnionType}:
73
+ return " | ".join(dict.fromkeys(annotation_to_typescript(item) for item in arguments))
74
+ if origin is typing.Literal:
75
+ return " | ".join(json.dumps(item) for item in arguments)
76
+ if origin in {list, set, frozenset, collections.abc.Sequence, collections.abc.Set}:
77
+ item = annotation_to_typescript(arguments[0]) if arguments else "unknown"
78
+ return f"ReadonlyArray<{item}>"
79
+ if origin is tuple:
80
+ if len(arguments) == 2 and arguments[1] is Ellipsis:
81
+ return f"ReadonlyArray<{annotation_to_typescript(arguments[0])}>"
82
+ return f"readonly [{', '.join(annotation_to_typescript(item) for item in arguments)}]"
83
+ if origin in {dict, collections.abc.Mapping}:
84
+ key = annotation_to_typescript(arguments[0]) if arguments else "string"
85
+ value = annotation_to_typescript(arguments[1]) if len(arguments) > 1 else "unknown"
86
+ return f"Readonly<Record<string, {value}>>" if key == "string" else f"ReadonlyMap<{key}, {value}>"
87
+ if origin in {collections.abc.Iterable, collections.abc.Iterator}:
88
+ item = annotation_to_typescript(arguments[0]) if arguments else "unknown"
89
+ return f"AsyncIterable<{item}>"
90
+ if origin in {collections.abc.AsyncIterable, collections.abc.AsyncIterator}:
91
+ item = annotation_to_typescript(arguments[0]) if arguments else "unknown"
92
+ return f"AsyncIterable<{item}>"
93
+ if origin in {collections.abc.Callable, typing.Callable}:
94
+ if len(arguments) != 2:
95
+ return "((...args: unknown[]) => unknown | Promise<unknown>)"
96
+ parameter_annotations, return_annotation = arguments
97
+ result = annotation_to_typescript(return_annotation)
98
+ if parameter_annotations is Ellipsis:
99
+ parameters = "...args: unknown[]"
100
+ elif isinstance(parameter_annotations, (list, tuple)):
101
+ parameters = ", ".join(
102
+ f"arg{index}: {annotation_to_typescript(item)}" for index, item in enumerate(parameter_annotations)
103
+ )
104
+ else:
105
+ parameters = "...args: unknown[]"
106
+ return f"(({parameters}) => {result} | Promise<{result}>)"
107
+ if isinstance(annotation, typing.TypeVar):
108
+ return "unknown"
109
+ if inspect.isclass(annotation):
110
+ return typescript_identifier(annotation.__name__)
111
+ forward = getattr(annotation, "__forward_arg__", None)
112
+ if isinstance(forward, str):
113
+ return typescript_identifier(forward.rsplit(".", 1)[-1])
114
+ return "unknown"
115
+
116
+
117
+ def callable_signature(function: Any, *, skip_receiver: bool = False) -> str:
118
+ try:
119
+ signature = inspect.signature(function)
120
+ hints = typing.get_type_hints(function, include_extras=True)
121
+ except (NameError, TypeError, ValueError):
122
+ return "(...args: unknown[]): Promise<unknown>"
123
+
124
+ parameters: list[str] = []
125
+ keyword_fields: list[str] = []
126
+ has_varargs = False
127
+ for index, parameter in enumerate(signature.parameters.values()):
128
+ if skip_receiver and index == 0 and parameter.name in {"self", "cls"}:
129
+ continue
130
+ annotation = hints.get(parameter.name, parameter.annotation)
131
+ value_type = annotation_to_typescript(annotation)
132
+ name = typescript_identifier(parameter.name)
133
+ optional = parameter.default is not inspect.Parameter.empty
134
+ if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
135
+ parameters.append(f"...{name}: {value_type}[]")
136
+ has_varargs = True
137
+ elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
138
+ keyword_fields.append(f"readonly [name: string]: {value_type}")
139
+ elif parameter.kind is inspect.Parameter.KEYWORD_ONLY:
140
+ keyword_fields.append(f"readonly {property_name(parameter.name)}{'?' if optional else ''}: {value_type}")
141
+ else:
142
+ parameters.append(f"{name}{'?' if optional else ''}: {value_type}")
143
+
144
+ if keyword_fields and not has_varargs:
145
+ keyword_shape = "; ".join(keyword_fields)
146
+ parameters.append(f"keywordArguments?: PythonKeywordArguments<{{ {keyword_shape} }}>")
147
+ result = annotation_to_typescript(hints.get("return", signature.return_annotation))
148
+ return f"({', '.join(parameters)}) => Promise<{result}>"
149
+
150
+
151
+ def inspect_module(alias: str, import_name: str) -> tuple[str, list[str]]:
152
+ module = importlib.import_module(import_name)
153
+ module_interface = f"{pascal_case(alias)}Module"
154
+ declarations: list[str] = []
155
+ members: list[str] = []
156
+
157
+ for name, value in sorted(vars(module).items()):
158
+ if name.startswith("_"):
159
+ continue
160
+ if inspect.isfunction(value) and getattr(value, "__module__", None) == module.__name__:
161
+ members.append(f" readonly {property_name(name)}: {callable_signature(value)};")
162
+ elif inspect.isclass(value) and getattr(value, "__module__", None) == module.__name__:
163
+ class_name = typescript_identifier(value.__name__)
164
+ if typing.is_typeddict(value):
165
+ declarations.append(render_typed_dict_interface(class_name, value))
166
+ else:
167
+ declarations.append(render_class_interface(class_name, value))
168
+ constructor = callable_signature(value.__init__, skip_receiver=True)
169
+ members.append(f" readonly {property_name(name)}: {replace_promise_result(constructor, class_name)};")
170
+
171
+ if not members:
172
+ members.append(" readonly [name: string]: unknown;")
173
+ declarations.append(f"export interface {module_interface} {{\n" + "\n".join(members) + "\n}")
174
+ return module_interface, declarations
175
+
176
+
177
+ def render_typed_dict_interface(class_name: str, value: type[Any]) -> str:
178
+ try:
179
+ annotations = typing.get_type_hints(value, include_extras=True)
180
+ except (NameError, TypeError):
181
+ annotations = getattr(value, "__annotations__", {})
182
+ required_keys = getattr(value, "__required_keys__", frozenset(annotations))
183
+ members = [
184
+ f" readonly {property_name(name)}{'?' if name not in required_keys else ''}: "
185
+ f"{annotation_to_typescript(annotation)};"
186
+ for name, annotation in sorted(annotations.items())
187
+ ]
188
+ return f"export interface {class_name} {{\n" + "\n".join(members) + "\n}"
189
+
190
+
191
+ def render_class_interface(class_name: str, value: type[Any]) -> str:
192
+ members: list[str] = []
193
+ annotations = getattr(value, "__annotations__", {})
194
+ with contextlib.suppress(NameError, TypeError):
195
+ annotations = typing.get_type_hints(value, include_extras=True)
196
+ for name, annotation in sorted(annotations.items()):
197
+ if not name.startswith("_"):
198
+ members.append(f" readonly {property_name(name)}: Promise<{annotation_to_typescript(annotation)}>;")
199
+ for name, function in sorted(inspect.getmembers(value, predicate=inspect.isfunction)):
200
+ if not name.startswith("_"):
201
+ members.append(f" readonly {property_name(name)}: {callable_signature(function, skip_receiver=True)};")
202
+ members.extend(
203
+ [
204
+ " $release(): Promise<void>;",
205
+ " $repr(): Promise<string>;",
206
+ " $value(): Promise<unknown>;",
207
+ ]
208
+ )
209
+ return f"export interface {class_name} {{\n" + "\n".join(members) + "\n}"
210
+
211
+
212
+ def render(settings: GeneratorSettings) -> str:
213
+ declarations: list[str] = []
214
+ module_types: dict[str, str] = {}
215
+ for alias, import_name in settings.modules.items():
216
+ module_type, module_declarations = inspect_module(alias, import_name)
217
+ module_types[alias] = module_type
218
+ declarations.extend(module_declarations)
219
+
220
+ defaults = bridge_defaults(settings)
221
+
222
+ module_map = "\n".join(
223
+ f" readonly {property_name(alias)}: {module_type};" for alias, module_type in module_types.items()
224
+ )
225
+ getters = "\n\n".join(
226
+ f" get {typescript_identifier(alias)}(): Promise<{module_type}> {{\n"
227
+ f" return this.venv.import({json.dumps(alias)});\n"
228
+ " }"
229
+ for alias, module_type in module_types.items()
230
+ if is_typescript_identifier(alias)
231
+ )
232
+ serialized_defaults = json.dumps(defaults, indent=2).replace("\n", "\n ")
233
+
234
+ sections = [
235
+ "// Generated by pynect-worker codegen. Do not edit by hand.",
236
+ "import { createVenv, type CreateVenvOptions, type PythonKeywordArguments, type PythonVenv } from 'pynect';",
237
+ *declarations,
238
+ f"export interface {settings.class_name}Modules {{\n{module_map}\n}}",
239
+ f"const defaults = {serialized_defaults} as const satisfies CreateVenvOptions;",
240
+ (
241
+ f"export class {settings.class_name} implements AsyncDisposable {{\n"
242
+ f" readonly venv: PythonVenv<{settings.class_name}Modules>;\n\n"
243
+ " constructor(options: CreateVenvOptions = {}) {\n"
244
+ f" this.venv = createVenv<{settings.class_name}Modules>({{\n"
245
+ " ...defaults,\n"
246
+ " ...options,\n"
247
+ " modules: { ...defaults.modules, ...options.modules },\n"
248
+ " });\n"
249
+ " }\n\n"
250
+ f"{getters}\n\n"
251
+ " close(): Promise<void> {\n"
252
+ " return this.venv.close();\n"
253
+ " }\n\n"
254
+ " async [Symbol.asyncDispose](): Promise<void> {\n"
255
+ " await this.close();\n"
256
+ " }\n"
257
+ "}"
258
+ ),
259
+ ]
260
+ return "\n\n".join(section for section in sections if section) + "\n"
261
+
262
+
263
+ def bridge_defaults(settings: GeneratorSettings) -> dict[str, Any]:
264
+ bridge = settings.bridge
265
+ defaults: dict[str, Any] = {"modules": settings.modules}
266
+ optional_values = {
267
+ "autoClose": bridge.auto_close,
268
+ "callTimeoutMs": bridge.call_timeout_ms,
269
+ "project": bridge.project,
270
+ "startupTimeoutMs": bridge.startup_timeout_ms,
271
+ "validatePydantic": bridge.validate_pydantic,
272
+ }
273
+ defaults.update((name, value) for name, value in optional_values.items() if value is not None)
274
+ sequence_values = {
275
+ "uvArgs": bridge.uv_args,
276
+ "with": bridge.with_values,
277
+ "withEditable": bridge.with_editable,
278
+ "withRequirements": bridge.with_requirements,
279
+ }
280
+ defaults.update((name, list(value)) for name, value in sequence_values.items() if value)
281
+ return defaults
282
+
283
+
284
+ def replace_promise_result(signature: str, result: str) -> str:
285
+ return re.sub(r"Promise<.*>$", f"Promise<{result}>", signature)
286
+
287
+
288
+ def property_name(value: str) -> str:
289
+ return value if is_typescript_identifier(value) else json.dumps(value)
290
+
291
+
292
+ def is_typescript_identifier(value: str) -> bool:
293
+ return bool(re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", value)) and value not in {
294
+ "break",
295
+ "case",
296
+ "catch",
297
+ "class",
298
+ "const",
299
+ "continue",
300
+ "debugger",
301
+ "default",
302
+ "delete",
303
+ "do",
304
+ "else",
305
+ "export",
306
+ "extends",
307
+ "finally",
308
+ "for",
309
+ "function",
310
+ "if",
311
+ "import",
312
+ "in",
313
+ "instanceof",
314
+ "let",
315
+ "new",
316
+ "return",
317
+ "super",
318
+ "switch",
319
+ "this",
320
+ "throw",
321
+ "try",
322
+ "typeof",
323
+ "var",
324
+ "void",
325
+ "while",
326
+ "with",
327
+ "yield",
328
+ }
329
+
330
+
331
+ def typescript_identifier(value: str) -> str:
332
+ normalized = re.sub(r"[^A-Za-z0-9_$]", "_", value)
333
+ if not normalized or normalized[0].isdigit() or keyword.iskeyword(normalized):
334
+ normalized = f"_{normalized}"
335
+ return normalized
336
+
337
+
338
+ def pascal_case(value: str) -> str:
339
+ parts = re.findall(r"[A-Za-z0-9]+", value)
340
+ result = "".join(part[:1].upper() + part[1:] for part in parts) or "Python"
341
+ return f"_{result}" if result[0].isdigit() else result
342
+
343
+
344
+ def string_sequence(value: Any, name: str) -> tuple[str, ...]:
345
+ if value is None:
346
+ return ()
347
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
348
+ raise TypeError(f"bridge.{name} must be an array of non-empty strings")
349
+ return tuple(value)
350
+
351
+
352
+ def project_value(value: Any) -> str | typing.Literal[False] | None:
353
+ if value is None or value is False:
354
+ return value
355
+ if not isinstance(value, str) or not value:
356
+ raise TypeError("bridge.project must be a non-empty string, false, or null")
357
+ return value
358
+
359
+
360
+ def optional_bool(value: Any, name: str) -> bool | None:
361
+ if value is None:
362
+ return None
363
+ if not isinstance(value, bool):
364
+ raise TypeError(f"bridge.{name} must be true, false, or null")
365
+ return value
366
+
367
+
368
+ def timeout_value(value: Any, name: str, *, allow_zero: bool) -> int | None:
369
+ if value is None:
370
+ return None
371
+ minimum = 0 if allow_zero else 1
372
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
373
+ qualifier = "zero or a positive integer" if allow_zero else "a positive integer"
374
+ raise ValueError(f"bridge.{name} must be {qualifier} or null")
375
+ return value
376
+
377
+
378
+ def parse_settings(argument: str) -> tuple[Path, GeneratorSettings]:
379
+ try:
380
+ value = json.loads(argument)
381
+ except json.JSONDecodeError as error:
382
+ raise ValueError("argument must be valid JSON") from error
383
+ if not isinstance(value, dict):
384
+ raise TypeError("argument must decode to an object")
385
+
386
+ expected = {"root", "modules", "className", "out", "bridge"}
387
+ missing = expected - value.keys()
388
+ unknown = value.keys() - expected
389
+ if missing:
390
+ raise ValueError(f"missing required field(s): {', '.join(sorted(missing))}")
391
+ if unknown:
392
+ raise ValueError(f"unknown field(s): {', '.join(sorted(unknown))}")
393
+
394
+ root_value = value["root"]
395
+ if not isinstance(root_value, str) or not root_value:
396
+ raise TypeError("root must be a non-empty absolute path string")
397
+ root = Path(root_value)
398
+ if not root.is_absolute():
399
+ raise ValueError("root must be an absolute path")
400
+ root = root.resolve()
401
+
402
+ modules_value = value["modules"]
403
+ if not isinstance(modules_value, dict) or not modules_value:
404
+ raise TypeError("modules must be a non-empty object mapping aliases to imports")
405
+ if not all(
406
+ isinstance(alias, str) and alias and isinstance(import_name, str) and import_name
407
+ for alias, import_name in modules_value.items()
408
+ ):
409
+ raise ValueError("module aliases and import names must be non-empty strings")
410
+ modules = dict(modules_value)
411
+
412
+ class_name = value["className"]
413
+ if not isinstance(class_name, str) or not class_name:
414
+ raise TypeError("className must be a non-empty string")
415
+ class_name = typescript_identifier(class_name)
416
+
417
+ out_value = value["out"]
418
+ if not isinstance(out_value, str) or not out_value:
419
+ raise TypeError("out must be a non-empty path string")
420
+
421
+ bridge = value["bridge"]
422
+ if not isinstance(bridge, dict):
423
+ raise TypeError("bridge must be an object")
424
+ bridge_keys = {
425
+ "autoClose",
426
+ "callTimeoutMs",
427
+ "project",
428
+ "startupTimeoutMs",
429
+ "uvArgs",
430
+ "validatePydantic",
431
+ "with",
432
+ "withEditable",
433
+ "withRequirements",
434
+ }
435
+ unknown_bridge = bridge.keys() - bridge_keys
436
+ if unknown_bridge:
437
+ raise ValueError(f"unknown bridge field(s): {', '.join(sorted(unknown_bridge))}")
438
+
439
+ settings = GeneratorSettings(
440
+ bridge=BridgeSettings(
441
+ auto_close=optional_bool(bridge.get("autoClose"), "autoClose"),
442
+ call_timeout_ms=timeout_value(bridge.get("callTimeoutMs"), "callTimeoutMs", allow_zero=True),
443
+ project=project_value(bridge.get("project")),
444
+ startup_timeout_ms=timeout_value(
445
+ bridge.get("startupTimeoutMs"),
446
+ "startupTimeoutMs",
447
+ allow_zero=False,
448
+ ),
449
+ uv_args=string_sequence(bridge.get("uvArgs"), "uvArgs"),
450
+ validate_pydantic=optional_bool(bridge.get("validatePydantic"), "validatePydantic"),
451
+ with_editable=string_sequence(bridge.get("withEditable"), "withEditable"),
452
+ with_requirements=string_sequence(bridge.get("withRequirements"), "withRequirements"),
453
+ with_values=string_sequence(bridge.get("with"), "with"),
454
+ ),
455
+ class_name=class_name,
456
+ modules=modules,
457
+ out=Path(out_value),
458
+ )
459
+ return root, settings
460
+
461
+
462
+ def generate(root: Path, settings: GeneratorSettings) -> Path:
463
+ add_import_path(root)
464
+ if isinstance(settings.bridge.project, str):
465
+ project_path = Path(settings.bridge.project)
466
+ if not project_path.is_absolute():
467
+ project_path = root / project_path
468
+ add_import_path(project_path.resolve())
469
+
470
+ output_path = settings.out
471
+ if not output_path.is_absolute():
472
+ output_path = root / output_path
473
+ output_path = output_path.resolve()
474
+ generated = render(settings)
475
+ if output_path.is_file() and output_path.read_text(encoding="utf-8") == generated:
476
+ return output_path
477
+ output_path.parent.mkdir(parents=True, exist_ok=True)
478
+ output_path.write_text(generated, encoding="utf-8", newline="\n")
479
+ return output_path
480
+
481
+
482
+ def add_import_path(path: Path) -> None:
483
+ value = str(path)
484
+ if value not in sys.path:
485
+ sys.path.insert(0, value)
486
+
487
+
488
+ @codegen_app.default
489
+ def run(request: str) -> None:
490
+ """Generate bindings from one JSON request."""
491
+
492
+ root, settings = parse_settings(request)
493
+ print(generate(root, settings))
494
+
495
+
496
+ def main() -> None:
497
+ codegen_app()
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()
@@ -0,0 +1,147 @@
1
+ """Transport-neutral RPC connection contracts and local IPC implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import threading
8
+ import time
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any
11
+
12
+ from .framing import decode_frame, encode_frame
13
+ from .models import MAX_LOCAL_FRAME_BYTES
14
+
15
+
16
+ class RpcConnection(ABC):
17
+ """A message-oriented connection consumed by the Python bridge."""
18
+
19
+ @abstractmethod
20
+ def read(self) -> dict[str, Any] | None:
21
+ """Read one message, or return ``None`` after a clean disconnect."""
22
+
23
+ @abstractmethod
24
+ def write(self, message: dict[str, Any]) -> None:
25
+ """Write one message atomically."""
26
+
27
+ @abstractmethod
28
+ def close(self) -> None:
29
+ """Release the underlying transport."""
30
+
31
+
32
+ class FramedConnection(RpcConnection):
33
+ """Length-prefixed Unix-socket or Windows named-pipe connection."""
34
+
35
+ def __init__(self, endpoint: str) -> None:
36
+ self._write_lock = threading.Lock()
37
+ self._socket: socket.socket | None = None
38
+ self._pipe_handle: int | None = None
39
+ if os.name == "nt":
40
+ self._pipe_handle = self._open_windows_pipe(endpoint)
41
+ else:
42
+ connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
43
+ connection.connect(endpoint)
44
+ self._socket = connection
45
+
46
+ @staticmethod
47
+ def _open_windows_pipe(endpoint: str) -> int:
48
+ import _winapi
49
+
50
+ deadline = time.monotonic() + 10
51
+ while True:
52
+ try:
53
+ _winapi.WaitNamedPipe(endpoint, 1000)
54
+ return _winapi.CreateFile(
55
+ endpoint,
56
+ _winapi.GENERIC_READ | _winapi.GENERIC_WRITE,
57
+ 0,
58
+ _winapi.NULL,
59
+ _winapi.OPEN_EXISTING,
60
+ _winapi.FILE_FLAG_OVERLAPPED,
61
+ _winapi.NULL,
62
+ )
63
+ except OSError as error:
64
+ if time.monotonic() >= deadline:
65
+ raise
66
+ if error.winerror not in {
67
+ _winapi.ERROR_PIPE_BUSY,
68
+ _winapi.ERROR_SEM_TIMEOUT,
69
+ }:
70
+ raise
71
+ time.sleep(0.025)
72
+
73
+ def read(self) -> dict[str, Any] | None:
74
+ header = self._read_exact(4)
75
+ if header is None:
76
+ return None
77
+ length = int.from_bytes(header, "big")
78
+ if length > MAX_LOCAL_FRAME_BYTES:
79
+ raise ValueError(f"RPC frame exceeds {MAX_LOCAL_FRAME_BYTES} bytes")
80
+ payload = self._read_exact(length)
81
+ if payload is None:
82
+ raise EOFError("IPC connection closed inside a frame")
83
+ return decode_frame(header + payload, MAX_LOCAL_FRAME_BYTES)
84
+
85
+ def write(self, message: dict[str, Any]) -> None:
86
+ frame = encode_frame(message, MAX_LOCAL_FRAME_BYTES)
87
+ with self._write_lock:
88
+ if self._pipe_handle is not None:
89
+ self._write_windows(frame)
90
+ return
91
+ if self._socket is not None:
92
+ self._socket.sendall(frame)
93
+ return
94
+ raise EOFError("IPC connection is closed")
95
+
96
+ def _read_exact(self, length: int) -> bytes | None:
97
+ chunks: list[bytes] = []
98
+ remaining = length
99
+ while remaining:
100
+ chunk = self._socket.recv(remaining) if self._socket is not None else self._read_windows(remaining)
101
+ if not chunk:
102
+ if not chunks:
103
+ return None
104
+ raise EOFError("IPC connection closed during read")
105
+ chunks.append(bytes(chunk))
106
+ remaining -= len(chunk)
107
+ return b"".join(chunks)
108
+
109
+ def _read_windows(self, length: int) -> bytes:
110
+ import _winapi
111
+
112
+ try:
113
+ overlapped, _ = _winapi.ReadFile(
114
+ self._pipe_handle,
115
+ length,
116
+ overlapped=True,
117
+ )
118
+ overlapped.GetOverlappedResult(True)
119
+ return bytes(overlapped.getbuffer())
120
+ except OSError as error:
121
+ if error.winerror == _winapi.ERROR_BROKEN_PIPE:
122
+ return b""
123
+ raise
124
+
125
+ def _write_windows(self, value: bytes) -> None:
126
+ import _winapi
127
+
128
+ overlapped, _ = _winapi.WriteFile(
129
+ self._pipe_handle,
130
+ value,
131
+ overlapped=True,
132
+ )
133
+ written, error = overlapped.GetOverlappedResult(True)
134
+ if error:
135
+ raise OSError(error, "Windows named pipe write failed")
136
+ if written != len(value):
137
+ raise EOFError("Windows named pipe closed during write")
138
+
139
+ def close(self) -> None:
140
+ if self._socket is not None:
141
+ self._socket.close()
142
+ self._socket = None
143
+ if self._pipe_handle is not None:
144
+ import _winapi
145
+
146
+ _winapi.CloseHandle(self._pipe_handle)
147
+ self._pipe_handle = None
@@ -0,0 +1,32 @@
1
+ """Shared MessagePack framing for IPC and WebSocket transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import msgpack
8
+
9
+
10
+ def encode_frame(message: dict[str, Any], max_frame_bytes: int) -> bytes:
11
+ """Encode one length-prefixed RPC message."""
12
+
13
+ payload = msgpack.packb(message, use_bin_type=True)
14
+ if len(payload) > max_frame_bytes:
15
+ raise ValueError(f"RPC frame exceeds {max_frame_bytes} bytes")
16
+ return len(payload).to_bytes(4, "big") + payload
17
+
18
+
19
+ def decode_frame(frame: bytes, max_frame_bytes: int) -> dict[str, Any]:
20
+ """Decode exactly one length-prefixed RPC message."""
21
+
22
+ if len(frame) < 4:
23
+ raise ValueError("RPC frame is missing its length prefix")
24
+ length = int.from_bytes(frame[:4], "big")
25
+ if length > max_frame_bytes:
26
+ raise ValueError(f"RPC frame exceeds {max_frame_bytes} bytes")
27
+ if length != len(frame) - 4:
28
+ raise ValueError("RPC frame length is invalid")
29
+ value = msgpack.unpackb(frame[4:], raw=False, strict_map_key=False)
30
+ if not isinstance(value, dict):
31
+ raise TypeError("RPC message must be a map")
32
+ return value