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
pynect_worker/codegen.py
ADDED
|
@@ -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
|
pynect_worker/framing.py
ADDED
|
@@ -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
|