crosswind-workers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
workers/asgi.py ADDED
@@ -0,0 +1,523 @@
1
+ """Serve an ASGI application, such as FastAPI, from a Worker.
2
+
3
+ Ported from the stock SDK at cb2db3c. Background work after a streamed
4
+ response or a WebSocket upgrade goes through `ctx.waitUntil` when the caller
5
+ passes `ctx`, because the module-level waitUntil doesn't keep a Python Worker
6
+ alive past the response.
7
+ """
8
+
9
+ import logging
10
+ from asyncio import Event, Future, Queue, create_task, ensure_future
11
+ from collections.abc import Awaitable
12
+ from contextlib import contextmanager, suppress
13
+ from typing import Any
14
+ from urllib.parse import unquote
15
+
16
+ import js
17
+
18
+ from ._adapter import NULL_BODY_STATUSES, body_encoding, request_headers
19
+ from ._js import to_js_headers as _to_js_headers
20
+ from .context import ExecutionContext, js_promise
21
+ from .entrypoints import WorkerEntrypoint
22
+ from .request import Request
23
+ from .types import Context
24
+
25
+ ASGI = {"spec_version": "2.0", "version": "3.0"}
26
+ logger = logging.getLogger("asgi")
27
+ background_tasks = set()
28
+
29
+
30
+ def run_in_background(coro: Awaitable[Any]) -> Future:
31
+ fut = ensure_future(coro)
32
+ background_tasks.add(fut)
33
+
34
+ def _on_done(f):
35
+ background_tasks.discard(f)
36
+ exc = f.exception() if not f.cancelled() else None
37
+ if exc is not None:
38
+ logger.error("Unhandled exception in background task", exc_info=exc)
39
+
40
+ fut.add_done_callback(_on_done)
41
+ return fut
42
+
43
+
44
+ async def close_stream_quietly(writer):
45
+ try:
46
+ await writer.close()
47
+ except Exception:
48
+ logger.debug("Closing the response stream failed", exc_info=True)
49
+
50
+
51
+ @contextmanager
52
+ def acquire_js_buffer(pybuffer):
53
+ from pyodide.ffi import create_proxy
54
+
55
+ px = create_proxy(pybuffer)
56
+ buf = px.getBuffer()
57
+ px.destroy()
58
+
59
+ try:
60
+ yield buf.data
61
+ finally:
62
+ buf.release()
63
+
64
+
65
+ def request_to_scope(req, env, ws=False, state=None):
66
+ from js import URL
67
+
68
+ # @app.get("/example")
69
+ # async def example(request: Request):
70
+ # request.headers.get("content-type")
71
+ # - this will error if header is not "bytes" as in ASGI spec.
72
+ headers = [(k.lower().encode(), v.encode()) for k, v in request_headers(req)]
73
+
74
+ url = URL.new(req.url)
75
+ assert url.protocol[-1] == ":"
76
+ assert "?".startswith(url.search[0:1])
77
+
78
+ scope = {
79
+ "asgi": ASGI,
80
+ "headers": headers,
81
+ "http_version": "1.1",
82
+ "method": req.method,
83
+ "scheme": url.protocol[:-1],
84
+ # ASGI requires "path" to be percent-decoded; the encoded original is
85
+ # available to apps via "raw_path".
86
+ # https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
87
+ "path": unquote(url.pathname),
88
+ "raw_path": url.pathname.encode(),
89
+ "query_string": url.search[1:].encode(),
90
+ "type": "websocket" if ws else "http",
91
+ "env": env,
92
+ }
93
+ if state is not None:
94
+ # ASGI requires a shallow copy of lifespan state for each request.
95
+ scope["state"] = dict(state)
96
+
97
+ return scope
98
+
99
+
100
+ def _resolve(future: Future, value: Any) -> None:
101
+ if not future.done():
102
+ future.set_result(value)
103
+
104
+
105
+ def _fail(future: Future, message: str) -> None:
106
+ if not future.done():
107
+ future.set_exception(RuntimeError(message))
108
+
109
+
110
+ class _Lifespan:
111
+ """One ASGI lifespan startup and shutdown cycle of an app.
112
+
113
+ `startup` resolves True on `lifespan.startup.complete`, False when the app
114
+ has no lifespan support, and raises on `lifespan.startup.failed`.
115
+ `shutdown_complete` mirrors the shutdown phase.
116
+ """
117
+
118
+ def __init__(self, app: Any) -> None:
119
+ self.app = app
120
+ self.state: dict[str, Any] = {}
121
+ self.startup: Future[bool] = Future()
122
+ self.shutdown_complete: Future[None] = Future()
123
+ self.receive_queue: Queue[dict[str, Any]] = Queue()
124
+ self.receive_queue.put_nowait({"type": "lifespan.startup"})
125
+
126
+ async def receive(self) -> dict[str, Any]:
127
+ return await self.receive_queue.get()
128
+
129
+ async def send(self, got: dict[str, Any]) -> None:
130
+ # Failures use `or`, not a .get default: an empty message field is
131
+ # legal and would otherwise produce RuntimeError('').
132
+ message = got.get("message")
133
+ if got["type"] == "lifespan.startup.complete":
134
+ _resolve(self.startup, True)
135
+ elif got["type"] == "lifespan.startup.failed":
136
+ _fail(self.startup, message or "ASGI lifespan startup failed")
137
+ elif got["type"] == "lifespan.shutdown.complete":
138
+ _resolve(self.shutdown_complete, None)
139
+ elif got["type"] == "lifespan.shutdown.failed":
140
+ _fail(self.shutdown_complete, message or "ASGI lifespan shutdown failed")
141
+ else:
142
+ raise RuntimeError(f"Unexpected lifespan event {got['type']}")
143
+
144
+ async def run(self) -> None:
145
+ scope = {"asgi": ASGI, "state": self.state, "type": "lifespan"}
146
+ try:
147
+ await self.app(scope, self.receive, self.send)
148
+ except Exception as exc:
149
+ # Spec: an exception raised before startup is acked signals that
150
+ # the app doesn't support lifespan; serve requests anyway. Log it,
151
+ # or an app that crashed mid-startup looks like one with no
152
+ # lifespan handler at all.
153
+ if not self.startup.done():
154
+ logger.debug(
155
+ "ASGI lifespan task raised before reporting startup; "
156
+ "treating the app as not supporting lifespan",
157
+ exc_info=exc,
158
+ )
159
+ self.startup.set_result(False)
160
+ return
161
+
162
+ # After a successful startup, a shutdown-phase error can't affect
163
+ # the already-served request, so log it and let shutdown complete.
164
+ logger.exception("Exception in ASGI lifespan application", exc_info=exc)
165
+ _resolve(self.shutdown_complete, None)
166
+ return
167
+
168
+ # App returned without acking: a missing startup ack means it has no
169
+ # lifespan handler, so mark startup unsupported rather than hanging.
170
+ if not self.startup.done():
171
+ self.startup.set_result(False)
172
+ else:
173
+ _resolve(self.shutdown_complete, None)
174
+
175
+ async def shutdown(self) -> None:
176
+ # Nothing to shut down if the app never completed a lifespan startup.
177
+ if self.startup.done() and not self.startup.result():
178
+ return
179
+
180
+ await self.receive_queue.put({"type": "lifespan.shutdown"})
181
+ await self.shutdown_complete
182
+
183
+
184
+ async def _no_lifespan_shutdown() -> None:
185
+ return
186
+
187
+
188
+ async def start_application(app):
189
+ # Drives one ASGI lifespan startup/shutdown cycle before/after serving the
190
+ # request. The lifespan protocol is optional, so we must tolerate apps that
191
+ # don't implement it and fall back to serving requests without lifespan.
192
+ # https://asgi.readthedocs.io/en/latest/specs/lifespan.html
193
+ lifespan = _Lifespan(app)
194
+ run_in_background(lifespan.run())
195
+ if not await lifespan.startup:
196
+ return _no_lifespan_shutdown, lifespan.state
197
+
198
+ return lifespan.shutdown, lifespan.state
199
+
200
+
201
+ class _HttpCycle:
202
+ """One HTTP request through an ASGI app, and the `js.Response` it sends."""
203
+
204
+ def __init__(self, app: Any) -> None:
205
+ self.app = app
206
+ self.status: int | None = None
207
+ self.headers: list[tuple[str, str]] | None = None
208
+ self.result: Future[js.Response] = Future()
209
+ self.finished_response = Event()
210
+ self.receive_queue: Queue[dict[str, Any]] = Queue()
211
+ # Streaming state, set on the first body chunk with more_body=True.
212
+ self.writer: Any = None
213
+ self.writer_closed = False
214
+
215
+ async def queue_body(self, req: "Request | js.Request") -> None:
216
+ if req.body:
217
+ async for data in req.body:
218
+ await self.receive_queue.put(
219
+ {"body": data.to_bytes(), "more_body": True, "type": "http.request"}
220
+ )
221
+ await self.receive_queue.put(
222
+ {"body": b"", "more_body": False, "type": "http.request"}
223
+ )
224
+
225
+ async def receive(self) -> dict[str, Any]:
226
+ if not self.receive_queue.empty():
227
+ return await self.receive_queue.get()
228
+
229
+ await self.finished_response.wait()
230
+ return {"type": "http.disconnect"}
231
+
232
+ async def send(self, got: dict[str, Any]) -> None:
233
+ if got["type"] == "http.response.start":
234
+ self.status = got["status"]
235
+ # Like above, we need to convert byte-pairs into string explicitly.
236
+ # A response header name may repeat (multiple Set-Cookie), so this
237
+ # must stay a sequence of pairs, not a name-keyed mapping.
238
+ self.headers = [(k.decode(), v.decode()) for k, v in got.get("headers", [])]
239
+ elif got["type"] == "http.response.body":
240
+ await self._send_body(got.get("body", b""), got.get("more_body", False))
241
+
242
+ async def _send_body(self, body: bytes, more_body: bool) -> None:
243
+ if self.writer is None and more_body:
244
+ self._start_stream()
245
+ if self.writer is None:
246
+ self._respond(body)
247
+ return
248
+
249
+ with acquire_js_buffer(body) as jsbytes:
250
+ await self.writer.write(jsbytes.slice())
251
+ if not more_body:
252
+ await self.writer.close()
253
+ self.writer_closed = True
254
+ self.finished_response.set()
255
+
256
+ def _start_stream(self) -> None:
257
+ # A TransformStream lets the runtime consume body chunks as they are
258
+ # written.
259
+ from js import Response, TransformStream
260
+
261
+ transform_stream = TransformStream.new()
262
+ self.writer = transform_stream.writable.getWriter()
263
+ self.result.set_result(
264
+ Response.new(transform_stream.readable, **self._response_init())
265
+ )
266
+
267
+ def _respond(self, body: Any) -> None:
268
+ from js import Response
269
+ from pyodide.ffi import create_proxy
270
+
271
+ if self.status in NULL_BODY_STATUSES:
272
+ data = None
273
+ else:
274
+ px = create_proxy(body)
275
+ buf = px.getBuffer()
276
+ px.destroy()
277
+ data = buf.data
278
+
279
+ self.result.set_result(Response.new(data, **self._response_init()))
280
+ self.finished_response.set()
281
+
282
+ def _response_init(self) -> dict[str, Any]:
283
+ return {
284
+ "headers": _to_js_headers(self.headers),
285
+ "status": self.status,
286
+ **body_encoding(self.headers or []),
287
+ }
288
+
289
+ async def run(
290
+ self, req: "Request | js.Request", env: Any, state: dict[str, Any] | None
291
+ ) -> None:
292
+ try:
293
+ scope = request_to_scope(req, env, state=state)
294
+ await self.app(scope, self.receive, self.send)
295
+ # If no response has been set yet, the app didn't generate one.
296
+ if not self.result.done():
297
+ raise RuntimeError("The application did not generate a response")
298
+ except Exception as e:
299
+ if not self.result.done():
300
+ self.result.set_exception(e)
301
+ if self.writer is not None:
302
+ await self.writer.close()
303
+ self.finished_response.set()
304
+ return
305
+
306
+ # Response already sent — exception can't be propagated to the
307
+ # client, so log it to avoid silently swallowing errors.
308
+ logger.exception("Exception in ASGI application after response started")
309
+ self.finished_response.set()
310
+ if self.writer is not None and not self.writer_closed:
311
+ # End the body so a client reading it sees the stream stop
312
+ # instead of waiting forever for chunks that will never come.
313
+ # Deliberately not awaited: close() settles only once the
314
+ # queued chunks reach a consumer, so a response body that
315
+ # nobody reads would block this task, which the runtime is
316
+ # waiting on through wait_until.
317
+ run_in_background(close_stream_quietly(self.writer))
318
+
319
+
320
+ async def process_request(
321
+ app: Any,
322
+ req: "Request | js.Request",
323
+ env: Any,
324
+ # Unused, kept for the stock signature.
325
+ ctx: "Context | ExecutionContext | None",
326
+ state: dict[str, Any] | None = None,
327
+ ) -> tuple[js.Response, Future[None]]:
328
+ cycle = _HttpCycle(app)
329
+ await cycle.queue_body(req)
330
+ request_task = create_task(cycle.run(req, env, state))
331
+
332
+ try:
333
+ response = await cycle.result
334
+ except Exception:
335
+ # The request task handles application exceptions and should be allowed
336
+ # to finish before its error is propagated to fetch().
337
+ await request_task
338
+ raise
339
+
340
+ # Buffered responses do not depend on a consumer, so request cleanup can
341
+ # finish before returning. Streaming responses must run alongside the
342
+ # consumer because writes can be subject to backpressure.
343
+ if cycle.writer is None:
344
+ await request_task
345
+
346
+ return response, request_task
347
+
348
+
349
+ def _wait_until(
350
+ ctx: "Context | ExecutionContext | None", awaitable: Awaitable[Any]
351
+ ) -> None:
352
+ if ctx is None:
353
+ from workers import wait_until
354
+
355
+ wait_until(awaitable)
356
+ else:
357
+ ctx.waitUntil(js_promise(awaitable))
358
+
359
+
360
+ class _WebSocketSession:
361
+ """Bridges a Workers server WebSocket to an ASGI app's receive and send."""
362
+
363
+ def __init__(self, server: Any) -> None:
364
+ self.server = server
365
+ self.queue: Queue[dict[str, Any]] = Queue()
366
+ self.app_closed = False
367
+
368
+ def onopen(self, evt: Any) -> None:
369
+ self.queue.put_nowait({"type": "websocket.connect"})
370
+
371
+ def onclose(self, evt: Any) -> None:
372
+ # Client-initiated closes surface to the app as "websocket.disconnect"
373
+ # per the ASGI spec ("websocket.close" is the app->server direction);
374
+ # frameworks raise their disconnect exceptions only on this type.
375
+ self.queue.put_nowait(
376
+ {"type": "websocket.disconnect", "code": evt.code, "reason": evt.reason}
377
+ )
378
+
379
+ def onmessage(self, evt: Any) -> None:
380
+ data = evt.data
381
+ if isinstance(data, str):
382
+ self.queue.put_nowait({"type": "websocket.receive", "text": data})
383
+ else:
384
+ # Binary frames arrive as an ArrayBuffer; the ASGI spec requires
385
+ # them under "bytes" (frameworks like Starlette dispatch on which
386
+ # key is present, so labeling them "text" breaks receive_bytes()).
387
+ self.queue.put_nowait(
388
+ {"type": "websocket.receive", "bytes": data.to_bytes()}
389
+ )
390
+
391
+ async def receive(self) -> dict[str, Any]:
392
+ return await self.queue.get()
393
+
394
+ async def send(self, got: dict[str, Any]) -> None:
395
+ if got["type"] == "websocket.accept":
396
+ # The Workers WebSocketPair is accepted before the upgrade response
397
+ # is returned, so there is no deferred accept operation here.
398
+ return
399
+ if got["type"] == "websocket.send":
400
+ self._send_frame(got.get("bytes"), got.get("text"))
401
+ return
402
+ if got["type"] == "websocket.close":
403
+ self.app_closed = True
404
+ self.server.close(got.get("code", 1000), got.get("reason", ""))
405
+ return
406
+ logger.warning(" == Not implemented %s", got["type"])
407
+
408
+ def _send_frame(self, data: bytes | None, text: str | None) -> None:
409
+ if data is not None:
410
+ with acquire_js_buffer(data) as jsbytes:
411
+ # Unlike the `Response` constructor, server.send seems to
412
+ # eagerly copy the source buffer
413
+ self.server.send(jsbytes)
414
+ if text is not None:
415
+ self.server.send(text)
416
+
417
+ def close_transport(self, finished: Future) -> None:
418
+ if self.app_closed:
419
+ return
420
+
421
+ # The app ended without closing, so a half-open connection would leave
422
+ # the client hanging instead of reconnecting.
423
+ failed = not finished.cancelled() and finished.exception() is not None
424
+ # The peer may already have closed the socket.
425
+ with suppress(Exception):
426
+ self.server.close(1011 if failed else 1000, "")
427
+
428
+
429
+ async def process_websocket(
430
+ app: Any,
431
+ req: "Request | js.Request",
432
+ env: Any = None,
433
+ ctx: "Context | ExecutionContext | None" = None,
434
+ ) -> js.Response:
435
+ from js import Response, WebSocketPair
436
+
437
+ client, server = WebSocketPair.new().object_values()
438
+ server.accept()
439
+ # Binary frames otherwise arrive as Blob (observed under wrangler dev),
440
+ # which cannot be read synchronously in the message callback.
441
+ server.binaryType = "arraybuffer"
442
+
443
+ session = _WebSocketSession(server)
444
+ # onopen doesn't seem to get called. WS lifecycle events are a bit messed up
445
+ # here.
446
+ session.onopen(1)
447
+ server.onopen = session.onopen
448
+ server.onclose = session.onclose
449
+ server.onmessage = session.onmessage
450
+
451
+ scope = request_to_scope(req, env if env is not None else {}, ws=True)
452
+ task = run_in_background(app(scope, session.receive, session.send))
453
+ task.add_done_callback(session.close_transport)
454
+ _wait_until(ctx, task)
455
+
456
+ return Response.new(None, status=101, webSocket=client)
457
+
458
+
459
+ async def fetch(
460
+ app: Any,
461
+ req: "Request | js.Request",
462
+ env: Any,
463
+ ctx: "Context | ExecutionContext | None" = None,
464
+ ) -> js.Response:
465
+ logger.debug("ASGI request: %s %s", req.method, req.url)
466
+ shutdown, state = await start_application(app)
467
+
468
+ try:
469
+ result, request_task = await process_request(app, req, env, ctx, state=state)
470
+ except Exception:
471
+ logger.exception("ASGI request failed")
472
+ await shutdown()
473
+ raise
474
+
475
+ if request_task.done():
476
+ await shutdown()
477
+ else:
478
+
479
+ async def finalize_request():
480
+ try:
481
+ await request_task
482
+ finally:
483
+ await shutdown()
484
+
485
+ _wait_until(ctx, run_in_background(finalize_request()))
486
+
487
+ return result
488
+
489
+
490
+ async def websocket(
491
+ app: Any,
492
+ req: "Request | js.Request",
493
+ env: Any = None,
494
+ ctx: "Context | ExecutionContext | None" = None,
495
+ ) -> js.Response:
496
+ return await process_websocket(app, req, env, ctx)
497
+
498
+
499
+ def entrypoint(app: Any) -> type[WorkerEntrypoint]:
500
+ """Create the default Worker entrypoint for an ASGI application."""
501
+
502
+ class Default(WorkerEntrypoint):
503
+ async def fetch(self, request):
504
+ # getattr: stock's tests build this class without calling __init__.
505
+ ctx = getattr(self, "ctx", None)
506
+ if (request.headers.get("upgrade") or "").lower() == "websocket":
507
+ return await websocket(app, request, self.env, ctx)
508
+ return await fetch(app, request, self.env, ctx)
509
+
510
+ return Default
511
+
512
+
513
+ def __getattr__(name):
514
+ if name == "env":
515
+ from fastapi import Depends, Request # ty: ignore[unresolved-import]
516
+
517
+ @Depends
518
+ async def env(request: Request):
519
+ return request.scope["env"]
520
+
521
+ return env
522
+
523
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,36 @@
1
+ """Typed classes for Worker bindings, and `Binding` for every other kind.
2
+
3
+ A name a class doesn't define passes through to the JS object under its exact
4
+ JS name, with arguments and results converted as for RPC. Typed methods take
5
+ snake_case keywords and forward any keyword they don't know into the JS
6
+ options object unchanged, so stock calls like `kv.put(k, v, expirationTtl=60)`
7
+ keep working.
8
+ """
9
+
10
+ from .base import Binding
11
+ from .base import _convert as _convert
12
+ from .base import _js_options as _js_options
13
+ from .base import _RpcStub as _RpcStub
14
+ from .d1 import D1Database, D1PreparedStatement
15
+ from .env import _EnvWrapper as _EnvWrapper
16
+ from .kv import KVNamespace
17
+ from .queues import Queue
18
+ from .r2 import R2Bucket, R2Object, R2ObjectBody
19
+ from .services import DurableObjectNamespace, Fetcher
20
+ from .workflow import Workflow, WorkflowInstance
21
+
22
+ # The private names above are for the rest of the SDK and rpc's stock aliases.
23
+ __all__ = [
24
+ "Binding",
25
+ "D1Database",
26
+ "D1PreparedStatement",
27
+ "DurableObjectNamespace",
28
+ "Fetcher",
29
+ "KVNamespace",
30
+ "Queue",
31
+ "R2Bucket",
32
+ "R2Object",
33
+ "R2ObjectBody",
34
+ "Workflow",
35
+ "WorkflowInstance",
36
+ ]
@@ -0,0 +1,135 @@
1
+ """`Binding`, the passthrough wrapper every binding class builds on, and the
2
+ conversions its methods share."""
3
+
4
+ import re
5
+ from collections.abc import Iterator
6
+ from typing import Any, Self
7
+
8
+ from .._js import js_name
9
+ from ..response import RESPONSE_ACCEPTED_TYPES
10
+ from ..rpc import python_from_rpc, python_to_rpc
11
+
12
+ __all__ = ["Binding"]
13
+
14
+ # JS values that stay raw when they come back from a binding, as in the stock
15
+ # SDK: they are meant for Response() and friends, have Python twins, or are
16
+ # promises Python awaits.
17
+ _RAW_JS_TYPES = RESPONSE_ACCEPTED_TYPES | {"Headers", "Promise"}
18
+
19
+
20
+ class Binding:
21
+ """A binding with no typed class. Every name passes through to JS."""
22
+
23
+ def __new__(cls, js_object: Any) -> Self:
24
+ # Wrapping a wrapper of this class returns it unchanged.
25
+ return js_object if isinstance(js_object, cls) else super().__new__(cls)
26
+
27
+ def __init__(self, js_object: Any) -> None:
28
+ if js_object is not self:
29
+ self.js_object = js_object
30
+
31
+ def __repr__(self) -> str:
32
+ return f"{type(self).__name__}({js_name(self.js_object)})"
33
+
34
+ @property
35
+ def _binding(self) -> Any:
36
+ # The stock SDK's private name for the JS object, which its tests read.
37
+ return self.js_object
38
+
39
+ def __getattr__(self, name: str) -> Any:
40
+ # Python's protocol lookups stay off JS. A JS name such as
41
+ # `__unsafe_ensureInitialized` has no trailing `__` and passes through.
42
+ if (name.startswith("__") and name.endswith("__")) or name == "js_object":
43
+ raise AttributeError(name)
44
+ return self._pass_through(name, getattr(self.js_object, name))
45
+
46
+ def _pass_through(self, name: str, value: Any) -> Any:
47
+ """A JS attribute's value as Python sees it, for a name the class
48
+ doesn't define. Subclasses override this to convert differently."""
49
+ if not callable(value):
50
+ # Read on every access. The stock SDK caches the first value.
51
+ return _convert(value)
52
+ return lambda *args, **kwargs: _invoke(value, args, kwargs)
53
+
54
+ def __getitem__(self, key: int | str) -> Any:
55
+ if isinstance(key, int):
56
+ return _convert(self.js_object[key])
57
+ return _convert(getattr(self.js_object, key))
58
+
59
+ def __iter__(self) -> Iterator[Any]:
60
+ if not hasattr(self.js_object, "__iter__"):
61
+ raise TypeError(f"'{js_name(self.js_object)}' object is not iterable")
62
+
63
+ for item in self.js_object:
64
+ yield _convert(item)
65
+
66
+ def __bool__(self) -> bool:
67
+ return True
68
+
69
+ def __len__(self) -> int:
70
+ length = getattr(self.js_object, "length", None)
71
+ # An RPC stub answers every attribute, so check for a real number.
72
+ if not isinstance(length, int):
73
+ raise TypeError(f"'{js_name(self.js_object)}' object has no len()")
74
+ return length
75
+
76
+
77
+ class _RpcStub(Binding):
78
+ """A function or RpcTarget received over RPC."""
79
+
80
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
81
+ return _invoke(self.js_object, args, kwargs)
82
+
83
+
84
+ def _invoke(function: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
85
+ result = function(
86
+ *map(python_to_rpc, args), **{k: python_to_rpc(v) for k, v in kwargs.items()}
87
+ )
88
+
89
+ if callable(getattr(result, "then", None)):
90
+
91
+ async def settle() -> Any:
92
+ return _convert(await result)
93
+
94
+ return settle()
95
+
96
+ return _convert(result)
97
+
98
+
99
+ def _convert(value: Any) -> Any:
100
+ converted = python_from_rpc(value)
101
+ if isinstance(converted, list):
102
+ return [_wrap(item) for item in converted]
103
+ return _wrap(converted)
104
+
105
+
106
+ def _wrap(value: Any) -> Any:
107
+ """Wrap a JS value that conversion left as JS, so its names pass through."""
108
+ name = js_name(value)
109
+ if name is None or name in _RAW_JS_TYPES:
110
+ return value
111
+ return _RpcStub(value) if callable(value) else Binding(value)
112
+
113
+
114
+ def _js_options(
115
+ given: Any = None, extra: dict[str, Any] | None = None, **named: Any
116
+ ) -> Any:
117
+ """One JS options object from a typed method's arguments.
118
+
119
+ `given` is the options argument in the stock SDK's position: None, a dict
120
+ of JS names, or a JS object. The snake_case `named` options become
121
+ camelCase and unset ones are left out. `extra` holds the caller's other
122
+ keywords, forwarded unchanged.
123
+ """
124
+ named = {_camel(key): value for key, value in named.items() if value is not None}
125
+
126
+ if given is not None and not isinstance(given, dict):
127
+ if named or extra:
128
+ raise TypeError("pass options as a JS object or as keywords, not both")
129
+ return given
130
+
131
+ return python_to_rpc(named | (given or {}) | (extra or {}))
132
+
133
+
134
+ def _camel(name: str) -> str:
135
+ return re.sub(r"_(\w)", lambda match: match.group(1).upper(), name)