From bb94cbc72e15d479d801f4983398967fe282f911 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Sat, 20 Dec 2025 20:38:17 -0500 Subject: [PATCH 1/5] No-op code simplifications --- builder/v8_build.py | 2 +- mkdocs_hooks.py | 3 +- pyproject.toml | 2 + setup.py | 2 +- src/py_mini_racer/_abstract_context.py | 35 ++--- src/py_mini_racer/_context.py | 210 +++++++++---------------- src/py_mini_racer/_dll.py | 45 ++---- src/py_mini_racer/_mini_racer.py | 9 +- src/py_mini_racer/_objects.py | 38 ++--- src/py_mini_racer/_value_handle.py | 23 +-- tests/test_babel.py | 7 +- tests/test_call.py | 8 +- tests/test_eval.py | 27 ++-- tests/test_functions.py | 10 +- tests/test_objects.py | 28 ++-- tests/test_py_js_function.py | 18 +-- tests/test_types.py | 2 +- tests/test_wasm.py | 4 +- 18 files changed, 164 insertions(+), 309 deletions(-) diff --git a/builder/v8_build.py b/builder/v8_build.py index 521e92d3..4800ac56 100644 --- a/builder/v8_build.py +++ b/builder/v8_build.py @@ -168,7 +168,7 @@ def ensure_v8_src(revision: str) -> None: "custom_vars": {}, }, ] -""", +""" ) run( diff --git a/mkdocs_hooks.py b/mkdocs_hooks.py index 51f3d0fd..a15c07d2 100644 --- a/mkdocs_hooks.py +++ b/mkdocs_hooks.py @@ -17,6 +17,5 @@ def on_page_markdown(markdown: str, **kwargs: Any) -> str: # noqa: ANN401 # so the URLs look better. To make the cross-links between the pages work, do light # surgery on them: return markdown.replace("ARCHITECTURE.md", "architecture.md").replace( - "CONTRIBUTING.md", - "contributing.md", + "CONTRIBUTING.md", "contributing.md" ) diff --git a/pyproject.toml b/pyproject.toml index d73def5f..33dfb43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,12 @@ per-file-ignores = { "tests/*" = ["S101"] } [tool.ruff.lint.isort] required-imports = ["from __future__ import annotations"] +split-on-trailing-comma = false [tool.ruff.format] # Enable reformatting of code snippets in docstrings. docstring-code-format = true +skip-magic-trailing-comma = true [tool.coverage.run] omit = [ diff --git a/setup.py b/setup.py index 2fdca385..380195cd 100644 --- a/setup.py +++ b/setup.py @@ -87,7 +87,7 @@ def _generate_readme() -> str: .read_text(encoding="utf-8") .splitlines()[1:] ).replace("\n## ", "\n### "), - ], + ] ) diff --git a/src/py_mini_racer/_abstract_context.py b/src/py_mini_racer/_abstract_context.py index 7e066423..8bd924f7 100644 --- a/src/py_mini_racer/_abstract_context.py +++ b/src/py_mini_racer/_abstract_context.py @@ -1,9 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import ( - TYPE_CHECKING, -) +from typing import TYPE_CHECKING from py_mini_racer._types import JSUndefined @@ -50,25 +48,19 @@ def get_identity_hash(self, obj: JSObject) -> int: @abstractmethod def get_own_property_names( - self, - obj: JSObject, + self, obj: JSObject ) -> tuple[PythonJSConvertedTypes, ...]: pass @abstractmethod def get_object_item( - self, - obj: JSObject, - key: PythonJSConvertedTypes, + self, obj: JSObject, key: PythonJSConvertedTypes ) -> PythonJSConvertedTypes: pass @abstractmethod def set_object_item( - self, - obj: JSObject, - key: PythonJSConvertedTypes, - val: PythonJSConvertedTypes, + self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: pass @@ -82,10 +74,7 @@ def del_from_array(self, arr: JSArray, index: int) -> None: @abstractmethod def array_insert( - self, - arr: JSArray, - index: int, - new_val: PythonJSConvertedTypes, + self, arr: JSArray, index: int, new_val: PythonJSConvertedTypes ) -> None: pass @@ -100,18 +89,14 @@ def call_function( pass @abstractmethod - def js_callback( - self, - func: Callable[[PythonJSConvertedTypes | JSEvalException], None], + def js_to_py_callback( + self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] ) -> AbstractContextManager[JSFunction]: pass @abstractmethod def promise_then( - self, - promise: JSPromise, - on_resolved: JSFunction, - on_rejected: JSFunction, + self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction ) -> None: pass @@ -133,8 +118,6 @@ def free(self, val_handle: AbstractValueHandle) -> None: @abstractmethod def evaluate( - self, - code: str, - timeout_sec: float | None = None, + self, code: str, timeout_sec: float | None = None ) -> PythonJSConvertedTypes: pass diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index e95ad2d5..059e0216 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,21 +1,11 @@ from __future__ import annotations -from asyncio import ( - FIRST_COMPLETED, - Task, - create_task, - get_running_loop, - wait, -) +from asyncio import FIRST_COMPLETED, Task, create_task, get_running_loop, wait from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from contextlib import asynccontextmanager, contextmanager, suppress from itertools import count from traceback import format_exc -from typing import ( - TYPE_CHECKING, - Any, - cast, -) +from typing import TYPE_CHECKING, Any, cast from py_mini_racer._abstract_context import AbstractContext from py_mini_racer._dll import init_mini_racer, mr_callback_func @@ -30,10 +20,7 @@ JSUndefinedType, PythonJSConvertedTypes, ) -from py_mini_racer._value_handle import ( - ValueHandle, - python_to_value_handle, -) +from py_mini_racer._value_handle import ValueHandle, python_to_value_handle if TYPE_CHECKING: import ctypes @@ -55,12 +42,10 @@ def context_count() -> int: class _CallbackRegistry: def __init__( - self, - raw_handle_wrapper: Callable[[RawValueHandleType], AbstractValueHandle], + self, raw_handle_wrapper: Callable[[RawValueHandleType], AbstractValueHandle] ) -> None: self._active_callbacks: dict[ - int, - Callable[[PythonJSConvertedTypes | JSEvalException], None], + int, Callable[[PythonJSConvertedTypes | JSEvalException], None] ] = {} # define an all-purpose callback: @@ -75,8 +60,7 @@ def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: self._next_callback_id = count() def register( - self, - func: Callable[[PythonJSConvertedTypes | JSEvalException], None], + self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] ) -> int: callback_id = next(self._next_callback_id) @@ -91,10 +75,7 @@ def cleanup(self, callback_id: int) -> None: class Context(AbstractContext): """Wrapper for all operations involving the DLL and C++ MiniRacer::Context.""" - def __init__( - self, - dll: ctypes.CDLL, - ) -> None: + def __init__(self, dll: ctypes.CDLL) -> None: self._dll: ctypes.CDLL | None = dll self._callback_registry = _CallbackRegistry(self._wrap_raw_handle) @@ -116,33 +97,24 @@ def v8_is_using_sandbox(self) -> bool: return bool(self._get_dll().mr_v8_is_using_sandbox()) def evaluate( - self, - code: str, - timeout_sec: float | None = None, + self, code: str, timeout_sec: float | None = None ) -> PythonJSConvertedTypes: code_handle = python_to_value_handle(self, code) with self._run_mr_task( - self._get_dll().mr_eval, - self._ctx, - code_handle.raw, + self._get_dll().mr_eval, self._ctx, code_handle.raw ) as future: return future.get(timeout=timeout_sec) def promise_then( - self, - promise: JSPromise, - on_resolved: JSFunction, - on_rejected: JSFunction, + self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction ) -> None: promise_handle = python_to_value_handle(self, promise) then_name_handle = python_to_value_handle(self, "then") then_func = self._wrap_raw_handle( self._get_dll().mr_get_object_item( - self._ctx, - promise_handle.raw, - then_name_handle.raw, - ), + self._ctx, promise_handle.raw, then_name_handle.raw + ) ).to_python_or_raise() then_func = cast("JSFunction", then_func) @@ -151,45 +123,39 @@ def promise_then( def get_identity_hash(self, obj: JSObject) -> int: obj_handle = python_to_value_handle(self, obj) - ret = self._wrap_raw_handle( - self._get_dll().mr_get_identity_hash(self._ctx, obj_handle.raw), - ).to_python_or_raise() - return cast("int", ret) + return cast( + "int", + self._wrap_raw_handle( + self._get_dll().mr_get_identity_hash(self._ctx, obj_handle.raw) + ).to_python_or_raise(), + ) def get_own_property_names( - self, - obj: JSObject, + self, obj: JSObject ) -> tuple[PythonJSConvertedTypes, ...]: obj_handle = python_to_value_handle(self, obj) names = self._wrap_raw_handle( - self._get_dll().mr_get_own_property_names(self._ctx, obj_handle.raw), + self._get_dll().mr_get_own_property_names(self._ctx, obj_handle.raw) ).to_python_or_raise() if not isinstance(names, JSArray): raise TypeError return tuple(names) def get_object_item( - self, - obj: JSObject, - key: PythonJSConvertedTypes, + self, obj: JSObject, key: PythonJSConvertedTypes ) -> PythonJSConvertedTypes: obj_handle = python_to_value_handle(self, obj) key_handle = python_to_value_handle(self, key) return self._wrap_raw_handle( self._get_dll().mr_get_object_item( - self._ctx, - obj_handle.raw, - key_handle.raw, - ), + self._ctx, obj_handle.raw, key_handle.raw + ) ).to_python_or_raise() def set_object_item( - self, - obj: JSObject, - key: PythonJSConvertedTypes, - val: PythonJSConvertedTypes, + self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: obj_handle = python_to_value_handle(self, obj) key_handle = python_to_value_handle(self, key) @@ -198,11 +164,8 @@ def set_object_item( # Convert the value just to convert any exceptions (and GC the result) self._wrap_raw_handle( self._get_dll().mr_set_object_item( - self._ctx, - obj_handle.raw, - key_handle.raw, - val_handle.raw, - ), + self._ctx, obj_handle.raw, key_handle.raw, val_handle.raw + ) ).to_python_or_raise() def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: @@ -212,10 +175,8 @@ def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: # Convert the value just to convert any exceptions (and GC the result) self._wrap_raw_handle( self._get_dll().mr_del_object_item( - self._ctx, - obj_handle.raw, - key_handle.raw, - ), + self._ctx, obj_handle.raw, key_handle.raw + ) ).to_python_or_raise() def del_from_array(self, arr: JSArray, index: int) -> None: @@ -223,14 +184,11 @@ def del_from_array(self, arr: JSArray, index: int) -> None: # Convert the value just to convert any exceptions (and GC the result) self._wrap_raw_handle( - self._get_dll().mr_splice_array(self._ctx, arr_handle.raw, index, 1, None), + self._get_dll().mr_splice_array(self._ctx, arr_handle.raw, index, 1, None) ).to_python_or_raise() def array_insert( - self, - arr: JSArray, - index: int, - new_val: PythonJSConvertedTypes, + self, arr: JSArray, index: int, new_val: PythonJSConvertedTypes ) -> None: arr_handle = python_to_value_handle(self, arr) new_val_handle = python_to_value_handle(self, new_val) @@ -238,12 +196,8 @@ def array_insert( # Convert the value just to convert any exceptions (and GC the result) self._wrap_raw_handle( self._get_dll().mr_splice_array( - self._ctx, - arr_handle.raw, - index, - 0, - new_val_handle.raw, - ), + self._ctx, arr_handle.raw, index, 0, new_val_handle.raw + ) ).to_python_or_raise() def call_function( @@ -301,9 +255,8 @@ def value_count(self) -> int: return int(self._get_dll().mr_value_count(self._ctx)) @contextmanager - def js_callback( - self, - func: Callable[[PythonJSConvertedTypes | JSEvalException], None], + def js_to_py_callback( + self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] ) -> Iterator[JSFunction]: """Make a JS callback which forwards to the given Python function. @@ -316,11 +269,10 @@ def js_callback( callback_id = self._callback_registry.register(func) cb = self._wrap_raw_handle( - self._get_dll().mr_make_js_callback(self._ctx, callback_id), + self._get_dll().mr_make_js_callback(self._ctx, callback_id) ) - cb_py = cast("JSFunction", cb.to_python_or_raise()) - yield cb_py + yield cast("JSFunction", cb.to_python_or_raise()) self._callback_registry.cleanup(callback_id) @@ -329,31 +281,18 @@ def _wrap_raw_handle(self, raw: RawValueHandleType) -> ValueHandle: def create_intish_val(self, val: int, typ: int) -> AbstractValueHandle: return self._wrap_raw_handle( - self._get_dll().mr_alloc_int_val( - self._ctx, - val, - typ, - ), + self._get_dll().mr_alloc_int_val(self._ctx, val, typ) ) def create_doublish_val(self, val: float, typ: int) -> AbstractValueHandle: return self._wrap_raw_handle( - self._get_dll().mr_alloc_double_val( - self._ctx, - val, - typ, - ), + self._get_dll().mr_alloc_double_val(self._ctx, val, typ) ) def create_string_val(self, val: str, typ: int) -> AbstractValueHandle: b = val.encode("utf-8") return self._wrap_raw_handle( - self._get_dll().mr_alloc_string_val( - self._ctx, - b, - len(b), - typ, - ), + self._get_dll().mr_alloc_string_val(self._ctx, b, len(b), typ) ) def free(self, val_handle: AbstractValueHandle) -> None: @@ -402,10 +341,9 @@ def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: @asynccontextmanager async def wrap_py_function( - self, - func: PyJsFunctionType, + self, func: PyJsFunctionType ) -> AsyncIterator[JSFunction]: - async def run_one(params: JSArray) -> None: + async def process_one_call_from_js(params: JSArray) -> None: arguments, resolve, reject = params arguments = cast("JSArray", arguments) resolve = cast("JSFunction", resolve) @@ -416,42 +354,51 @@ async def run_one(params: JSArray) -> None: except Exception: # noqa: BLE001 # Convert this Python exception into a JS exception so we can send it # into JS: - s = f"Error running Python function:\n{format_exc()}" - err_maker = self.evaluate("s => new Error(s)") - err_maker = cast("JSFunction", err_maker) - err = err_maker(s) - reject(err) - - pending: set[Task[PythonJSConvertedTypes | JSEvalException] | Future[bool]] = ( - set() - ) + err_maker = cast("JSFunction", self.evaluate("s => new Error(s)")) + reject(err_maker(f"Error running Python function:\n{format_exc()}")) - def process(params: PythonJSConvertedTypes | JSEvalException) -> None: + tasks_for_processing_calls: set[ + Task[PythonJSConvertedTypes | JSEvalException] | Future[bool] + ] = set() + + def handle_one_call_from_js( + params: PythonJSConvertedTypes | JSEvalException, + ) -> None: params = cast("JSArray", params) # Start a new task to run the new task: - task = create_task(run_one(params)) - pending.add(task) + task = create_task(process_one_call_from_js(params)) + tasks_for_processing_calls.add(task) loop = get_running_loop() - def on_called(value: PythonJSConvertedTypes | JSEvalException) -> None: - loop.call_soon_threadsafe(process, value) + def on_called_from_js(value: PythonJSConvertedTypes | JSEvalException) -> None: + loop.call_soon_threadsafe(handle_one_call_from_js, value) - async def await_pending() -> None: - nonlocal pending - while pending: - done, pending = await wait(pending, return_when=FIRST_COMPLETED) + async def process_all_calls() -> None: + nonlocal tasks_for_processing_calls + while tasks_for_processing_calls: + done, tasks_for_processing_calls = await wait( + tasks_for_processing_calls, return_when=FIRST_COMPLETED + ) for coro in done: await coro shutdown: Future[bool] = loop.create_future() - pending.add(shutdown) - pending_awaiter = create_task(await_pending()) + tasks_for_processing_calls.add(shutdown) + process_all_calls_task = create_task(process_all_calls()) try: - with self.js_callback(on_called) as callback: - wrapper = self.evaluate( - """ + with self.js_to_py_callback(on_called_from_js) as js_to_py_callback: + # Every time our callback is called, instantiate a JS Promise + # and immediately pass its resolution functions into our Python + # callback function. While we wait on Python's asyncio loop + # to process this call, we can return the Promise to the JS + # caller, thus exposing what looks like an ordinary async + # function on the JS side of things. + wrap_calls_in_js_promises = cast( + "JSFunction", + self.evaluate( + """ callback => { return (...arguments) => { let p = Promise.withResolvers(); @@ -461,18 +408,15 @@ async def await_pending() -> None: return p.promise; } } -""", +""" + ), ) - wrapper = cast("JSFunction", wrapper) - - wrapped = wrapper(callback) - wrapped = cast("JSFunction", wrapped) - yield wrapped + yield cast("JSFunction", wrap_calls_in_js_promises(js_to_py_callback)) finally: # Stop accepting calls: shutdown.set_result(True) - await pending_awaiter + await process_all_calls_task def close(self) -> None: dll, self._dll = self._dll, None diff --git a/src/py_mini_racer/_dll.py b/src/py_mini_racer/_dll.py index 53e37bdb..30b89ded 100644 --- a/src/py_mini_racer/_dll.py +++ b/src/py_mini_racer/_dll.py @@ -56,11 +56,7 @@ def _build_dll_handle(dll_path: Path) -> ctypes.CDLL: # noqa: PLR0915 handle.mr_init_context.argtypes = [MR_CALLBACK] handle.mr_init_context.restype = ctypes.c_uint64 - handle.mr_eval.argtypes = [ - ctypes.c_uint64, - RawValueHandle, - ctypes.c_uint64, - ] + handle.mr_eval.argtypes = [ctypes.c_uint64, RawValueHandle, ctypes.c_uint64] handle.mr_eval.restype = ctypes.c_uint64 handle.mr_free_value.argtypes = [ctypes.c_uint64, RawValueHandle] @@ -90,36 +86,21 @@ def _build_dll_handle(dll_path: Path) -> ctypes.CDLL: # noqa: PLR0915 handle.mr_cancel_task.argtypes = [ctypes.c_uint64, ctypes.c_uint64] - handle.mr_heap_stats.argtypes = [ - ctypes.c_uint64, - ctypes.c_uint64, - ] + handle.mr_heap_stats.argtypes = [ctypes.c_uint64, ctypes.c_uint64] handle.mr_heap_stats.restype = ctypes.c_uint64 handle.mr_low_memory_notification.argtypes = [ctypes.c_uint64] - handle.mr_make_js_callback.argtypes = [ - ctypes.c_uint64, - ctypes.c_uint64, - ] + handle.mr_make_js_callback.argtypes = [ctypes.c_uint64, ctypes.c_uint64] handle.mr_make_js_callback.restype = RawValueHandle - handle.mr_heap_snapshot.argtypes = [ - ctypes.c_uint64, - ctypes.c_uint64, - ] + handle.mr_heap_snapshot.argtypes = [ctypes.c_uint64, ctypes.c_uint64] handle.mr_heap_snapshot.restype = ctypes.c_uint64 - handle.mr_get_identity_hash.argtypes = [ - ctypes.c_uint64, - RawValueHandle, - ] + handle.mr_get_identity_hash.argtypes = [ctypes.c_uint64, RawValueHandle] handle.mr_get_identity_hash.restype = RawValueHandle - handle.mr_get_own_property_names.argtypes = [ - ctypes.c_uint64, - RawValueHandle, - ] + handle.mr_get_own_property_names.argtypes = [ctypes.c_uint64, RawValueHandle] handle.mr_get_own_property_names.restype = RawValueHandle handle.mr_get_object_item.argtypes = [ @@ -203,16 +184,14 @@ class LibAlreadyInitializedError(MiniRacerBaseException): def __init__(self) -> None: super().__init__( - "MiniRacer was already initialized before the call to init_mini_racer", + "MiniRacer was already initialized before the call to init_mini_racer" ) def _open_resource_file(filename: str, exit_stack: ExitStack) -> Path: - resource_path = resources.files("py_mini_racer") / filename - - context_manager = resources.as_file(resource_path) - - return exit_stack.enter_context(context_manager) + return exit_stack.enter_context( + resources.as_file(resources.files("py_mini_racer") / filename) + ) def _check_path(path: Path) -> None: @@ -255,9 +234,7 @@ def _open_dll(flags: Iterable[str]) -> Iterator[ctypes.CDLL]: def init_mini_racer( - *, - flags: Iterable[str] = DEFAULT_V8_FLAGS, - ignore_duplicate_init: bool = False, + *, flags: Iterable[str] = DEFAULT_V8_FLAGS, ignore_duplicate_init: bool = False ) -> ctypes.CDLL: """Initialize py_mini_racer (and V8). diff --git a/src/py_mini_racer/_mini_racer.py b/src/py_mini_racer/_mini_racer.py index 4d1c3b6b..6f7fe02a 100644 --- a/src/py_mini_racer/_mini_racer.py +++ b/src/py_mini_racer/_mini_racer.py @@ -2,11 +2,7 @@ import json from json import JSONEncoder -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, -) +from typing import TYPE_CHECKING, Any, ClassVar from py_mini_racer._context import Context from py_mini_racer._dll import init_mini_racer @@ -195,8 +191,7 @@ def call( return self.execute(js, timeout_sec=timeout_sec, max_memory=max_memory) def wrap_py_function( - self, - func: PyJsFunctionType, + self, func: PyJsFunctionType ) -> AbstractAsyncContextManager[JSFunction]: """Wrap a Python function such that it can be called from JS. diff --git a/src/py_mini_racer/_objects.py b/src/py_mini_racer/_objects.py index d33da124..16c9aaa8 100644 --- a/src/py_mini_racer/_objects.py +++ b/src/py_mini_racer/_objects.py @@ -42,11 +42,7 @@ def _get_exception_msg(reason: PythonJSConvertedTypes) -> str: class JSObjectImpl(JSObject): """A JavaScript object.""" - def __init__( - self, - ctx: AbstractContext, - handle: AbstractValueHandle, - ) -> None: + def __init__(self, ctx: AbstractContext, handle: AbstractValueHandle) -> None: self._ctx = ctx self._handle = handle @@ -73,9 +69,7 @@ def __getitem__(self, key: PythonJSConvertedTypes) -> PythonJSConvertedTypes: return self._ctx.get_object_item(self, key) def __setitem__( - self, - key: PythonJSConvertedTypes, - val: PythonJSConvertedTypes, + self, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: self._ctx.set_object_item(self, key, val) @@ -96,8 +90,7 @@ class JSArrayImpl(JSArray, JSObjectImpl): """ def __len__(self) -> int: - ret = self._ctx.get_object_item(self, "length") - return cast("int", ret) + return cast("int", self._ctx.get_object_item(self, "length")) def __getitem__(self, index: int | slice) -> Any: # noqa: ANN401 if not isinstance(index, int): @@ -185,8 +178,7 @@ def future_caller(value: Any) -> None: # noqa: ANN401 self._attach_callbacks_to_promise(future_caller) - results = future.get(timeout=timeout) - return self._unpack_promise_results(results) + return self._unpack_promise_results(future.get(timeout=timeout)) def __await__(self) -> Generator[Any, None, Any]: return self._do_await().__await__() @@ -200,12 +192,10 @@ def future_caller(value: Any) -> None: # noqa: ANN401 self._attach_callbacks_to_promise(future_caller) - results = await future - return self._unpack_promise_results(results) + return self._unpack_promise_results(await future) def _attach_callbacks_to_promise( - self, - future_caller: Callable[[Any], None], + self, future_caller: Callable[[Any], None] ) -> None: """Attach the given Python callbacks to a JS Promise.""" @@ -217,22 +207,22 @@ def on_resolved_and_cleanup( exit_stack.__exit__(None, None, None) future_caller([False, cast("JSArray", value)]) - on_resolved_js_func = exit_stack.enter_context( - self._ctx.js_callback(on_resolved_and_cleanup), - ) - def on_rejected_and_cleanup( value: PythonJSConvertedTypes | JSEvalException, ) -> None: exit_stack.__exit__(None, None, None) future_caller([True, cast("JSArray", value)]) - on_rejected_js_func = exit_stack.enter_context( - self._ctx.js_callback(on_rejected_and_cleanup), + self._ctx.promise_then( + self, + exit_stack.enter_context( + self._ctx.js_to_py_callback(on_resolved_and_cleanup) + ), + exit_stack.enter_context( + self._ctx.js_to_py_callback(on_rejected_and_cleanup) + ), ) - self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) - def _unpack_promise_results(self, results: Any) -> PythonJSConvertedTypes: # noqa: ANN401 is_rejected, argv = results result = cast("JSArray", argv)[0] diff --git a/src/py_mini_racer/_value_handle.py b/src/py_mini_racer/_value_handle.py index 438d94d7..aa74a608 100644 --- a/src/py_mini_racer/_value_handle.py +++ b/src/py_mini_racer/_value_handle.py @@ -2,10 +2,7 @@ import ctypes from datetime import datetime, timezone -from typing import ( - TYPE_CHECKING, - ClassVar, -) +from typing import TYPE_CHECKING, ClassVar from py_mini_racer._abstract_context import AbstractContext, AbstractValueHandle from py_mini_racer._exc import JSEvalException, MiniRacerBaseException @@ -17,10 +14,7 @@ JSPromiseImpl, JSSymbolImpl, ) -from py_mini_racer._types import ( - JSUndefined, - PythonJSConvertedTypes, -) +from py_mini_racer._types import JSUndefined, PythonJSConvertedTypes if TYPE_CHECKING: from collections.abc import Sequence @@ -130,10 +124,7 @@ class MiniRacerTypes: JSTerminatedException, "JavaScript was terminated", ), - MiniRacerTypes.key_exception: ( - JSKeyError, - "No such key found in object", - ), + MiniRacerTypes.key_exception: (JSKeyError, "No such key found in object"), MiniRacerTypes.value_exception: ( JSValueError, "Bad value passed to JavaScript engine", @@ -187,10 +178,7 @@ def to_python(self) -> PythonJSConvertedTypes | JSEvalException: # noqa: C901, if error_info: klass, generic_msg = error_info - msg = val.bytes_val[0:length].decode("utf-8") - msg = msg or generic_msg - - return klass(msg) + return klass(val.bytes_val[0:length].decode("utf-8") or generic_msg) if typ == MiniRacerTypes.null: return None @@ -269,8 +257,7 @@ def python_to_value_handle( # noqa: PLR0911 if isinstance(obj, datetime): # JS timestamps are milliseconds. In Python we are in seconds: return context.create_doublish_val( - obj.timestamp() * 1000.0, - MiniRacerTypes.date, + obj.timestamp() * 1000.0, MiniRacerTypes.date ) # Note: we skip shared array buffers, so for now at least, handles to shared diff --git a/tests/test_babel.py b/tests/test_babel.py index 127eb47b..cb9fc4f0 100644 --- a/tests/test_babel.py +++ b/tests/test_babel.py @@ -10,15 +10,14 @@ def test_babel() -> None: mr = MiniRacer() - source = f""" + + mr.eval(f""" var self = this; {(Path(__file__).parent / "fixtures" / "babel.js").read_text(encoding="utf-8")} babel.eval = function(code) {{ return eval(babel.transform(code)["code"]); }} - """ - - mr.eval(source) + """) assert mr.eval("babel.eval(((x) => x * x)(8))") == 64 # noqa: PLR2004 assert_no_v8_objects(mr) diff --git a/tests/test_call.py b/tests/test_call.py index 766275a6..1558c593 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -36,12 +36,10 @@ def default(self, obj: Any) -> str: # noqa: ANN401 return str(JSONEncoder.default(self, obj)) now = datetime.now(tz=timezone.utc) - js_func = """var f = function(args) { - return args; - }""" mr = MiniRacer() - mr.eval(js_func) - + mr.eval("""var f = function(args) { + return args; + }""") assert mr.call("f", now, encoder=CustomEncoder) == now.isoformat() assert_no_v8_objects(mr) diff --git a/tests/test_eval.py b/tests/test_eval.py index e86e7704..6bbd51b4 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -102,9 +102,7 @@ def test_multiple_ctx() -> None: def test_exception_thrown() -> None: mr = MiniRacer() - js_source = "var f = function() {throw new Error('blah')};" - - mr.eval(js_source) + mr.eval("var f = function() {throw new Error('blah')};") with pytest.raises(JSEvalException) as exc_info: mr.eval("f()") @@ -129,9 +127,7 @@ def test_exception_thrown() -> None: def test_string_thrown() -> None: mr = MiniRacer() - js_source = "var f = function() {throw 'blah'};" - - mr.eval(js_source) + mr.eval("var f = function() {throw 'blah'};") with pytest.raises(JSEvalException) as exc_info: mr.eval("f()") @@ -153,10 +149,9 @@ def test_string_thrown() -> None: def test_cannot_parse() -> None: mr = MiniRacer() - js_source = "var f = function(" with pytest.raises(JSParseException) as exc_info: - mr.eval(js_source) + mr.eval("var f = function(") assert ( exc_info.value.args[0] @@ -239,7 +234,7 @@ def test_max_memory_soft() -> None: n.fill(0); a = a.concat(n); } -""", +""" ) assert mr.was_soft_memory_limit_reached() @@ -264,7 +259,7 @@ def test_max_memory_hard() -> None: let n = new Array(Math.floor(s)); n.fill(0); a = a.concat(n); -}""", +}""" ) assert not mr.was_soft_memory_limit_reached() @@ -328,7 +323,7 @@ def test_microtask() -> None: p.then(() => {done = true}); done -""", +""" ) assert mr.eval("done") @@ -348,7 +343,7 @@ def test_longer_microtask() -> None: done = true; } foo(); -""", +""" ) assert not mr.eval("done") @@ -367,7 +362,7 @@ def test_polling() -> None: var done = false; setTimeout(() => { done = true; }, 1000); done -""", +""" ) assert not mr.eval("done") start = time() @@ -388,7 +383,7 @@ def test_settimeout() -> None: let c = setTimeout(() => { results.push("c"); }, 1000); let d = setTimeout(() => { results.push("d"); }, 4000); clearTimeout(b) -""", +""" ) start = time() while ( @@ -410,7 +405,7 @@ def test_promise_sync() -> None: mr.eval( """ new Promise((res, rej) => setTimeout(() => res(42), 1000)); // 1 s timeout -""", +""" ), ) start = time() @@ -431,7 +426,7 @@ async def run_test() -> None: mr.eval( """ new Promise((res, rej) => setTimeout(() => res(42), 1000)); // 1 s timeout -""", +""" ), ) start = time() diff --git a/tests/test_functions.py b/tests/test_functions.py index d34a240e..e8c54a55 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -6,11 +6,7 @@ import pytest -from py_mini_racer import ( - JSEvalException, - JSTimeoutException, - MiniRacer, -) +from py_mini_racer import JSEvalException, JSTimeoutException, MiniRacer from tests.gc_check import assert_no_v8_objects if TYPE_CHECKING: @@ -37,7 +33,7 @@ class Thing { } } new Thing('start'); -""", +""" ), ) stuff = cast("JSFunction", thing["stuff"]) @@ -57,7 +53,7 @@ def test_exceptions() -> None: throw new Error('asdf'); } func -""", +""" ), ) diff --git a/tests/test_objects.py b/tests/test_objects.py index 7e108f53..9363e642 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -32,7 +32,7 @@ def test_object_read() -> None: null: "null_value", }; a -""", +""" ), ) @@ -66,7 +66,7 @@ def test_object_read() -> None: null: "null_value", }; a -""", +""" ) assert obj == obj2 @@ -74,7 +74,7 @@ def test_object_read() -> None: """\ var a = {}; a -""", +""" ) assert not obj3 @@ -90,7 +90,7 @@ def test_object_mutation() -> None: """\ var a = {}; a -""", +""" ), ) @@ -122,7 +122,7 @@ def test_object_mutation() -> None: """\ var b = {"k": "v"}; b -""", +""" ) obj["inner"] = inner_obj assert len(obj) == 3 # noqa: PLR2004 @@ -142,7 +142,7 @@ def test_object_prototype() -> None: var a = Object.create(proto); a.foo = "bar"; a -""", +""" ), ) assert sorted(obj.items(), key=lambda x: str(x[0])) == [ @@ -161,7 +161,7 @@ def test_array() -> None: """\ var a = [ "some_string", 42, undefined, null ]; a -""", +""" ) assert isinstance(obj, JSArray) @@ -190,7 +190,7 @@ def test_array() -> None: """\ var a = []; a -""", +""" ) assert not obj2 @@ -206,7 +206,7 @@ def test_array_mutation() -> None: """\ var a = []; a -""", +""" ), ) @@ -232,7 +232,7 @@ def test_array_mutation() -> None: """\ var b = {"k": "v"}; b -""", +""" ) obj.append(inner_obj) assert len(obj) == 3 # noqa: PLR2004 @@ -248,7 +248,7 @@ def test_function() -> None: """\ function foo() {}; foo -""", +""" ) assert isinstance(obj, JSFunction) @@ -265,7 +265,7 @@ def test_symbol() -> None: """\ var sym = Symbol("foo"); sym -""", +""" ) assert isinstance(obj, JSSymbol) @@ -282,7 +282,7 @@ def test_promise() -> None: """\ var p = Promise.resolve(42); p -""", +""" ) assert isinstance(promise, JSPromise) @@ -307,7 +307,7 @@ def test_nested_object() -> None: "some_symbol": Symbol("sym"), }; a -""", +""" ), ) diff --git a/tests/test_py_js_function.py b/tests/test_py_js_function.py index 07318693..8ac0c8fa 100644 --- a/tests/test_py_js_function.py +++ b/tests/test_py_js_function.py @@ -10,11 +10,7 @@ import pytest -from py_mini_racer import ( - JSPromise, - JSPromiseError, - MiniRacer, -) +from py_mini_racer import JSPromise, JSPromiseError, MiniRacer from tests.gc_check import assert_no_v8_objects if TYPE_CHECKING: @@ -68,14 +64,14 @@ async def run() -> None: """\ JavaScript rejected promise with reason: Error: Error running Python function: Traceback (most recent call last): -""", +""" ) assert exc_info.value.args[0].endswith( """\ at :1:6 -""", +""" ) for _ in range(100): @@ -104,13 +100,7 @@ async def run() -> None: assert await gather(*pending) == ["foobar"] * 100 - assert ( - data - == [ - (42,), - ] - * 100 - ) + assert data == [(42,)] * 100 start = time() asyncio_run(run()) diff --git a/tests/test_types.py b/tests/test_types.py index 74b2ca77..99a68f02 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -118,7 +118,7 @@ def test_complex() -> None: {"z": [4, 5, 6, {"eqewr": 1, "zxczxc": "qweqwe", "z": {"1": 2}}]}, ], "qwe": 1, - }, + } ) diff --git a/tests/test_wasm.py b/tests/test_wasm.py index a0cf99df..89f9ecbb 100644 --- a/tests/test_wasm.py +++ b/tests/test_wasm.py @@ -23,7 +23,7 @@ def test_add() -> None: f""" const moduleRaw = new SharedArrayBuffer({size}); moduleRaw - """, + """ ), ) @@ -38,7 +38,7 @@ def test_add() -> None: WebAssembly.instantiate(new Uint8Array(moduleRaw)).then(result => { res = result.instance; }).catch(result => { res = result.message; }); - """, + """ ) # 4. Wait for WASM module instantiation From 5ffd8ebf729b30672ffe9b08ba9ab09132341267 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Sat, 20 Dec 2025 21:29:48 -0500 Subject: [PATCH 2/5] Turn callback registry into a context manager --- src/py_mini_racer/_context.py | 59 ++++++++++++++++------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index 059e0216..e93ae963 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,7 +1,7 @@ from __future__ import annotations from asyncio import FIRST_COMPLETED, Task, create_task, get_running_loop, wait -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator from contextlib import asynccontextmanager, contextmanager, suppress from itertools import count from traceback import format_exc @@ -59,17 +59,18 @@ def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: self._next_callback_id = count() + @contextmanager def register( self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] - ) -> int: + ) -> Generator[int, None, None]: callback_id = next(self._next_callback_id) self._active_callbacks[callback_id] = func - return callback_id - - def cleanup(self, callback_id: int) -> None: - self._active_callbacks.pop(callback_id) + try: + yield callback_id + finally: + self._active_callbacks.pop(callback_id) class Context(AbstractContext): @@ -266,15 +267,12 @@ def js_to_py_callback( future. """ - callback_id = self._callback_registry.register(func) - - cb = self._wrap_raw_handle( - self._get_dll().mr_make_js_callback(self._ctx, callback_id) - ) - - yield cast("JSFunction", cb.to_python_or_raise()) + with self._callback_registry.register(func) as callback_id: + cb = self._wrap_raw_handle( + self._get_dll().mr_make_js_callback(self._ctx, callback_id) + ) - self._callback_registry.cleanup(callback_id) + yield cast("JSFunction", cb.to_python_or_raise()) def _wrap_raw_handle(self, raw: RawValueHandleType) -> ValueHandle: return ValueHandle(self, raw) @@ -320,24 +318,21 @@ def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: else: future.set_result(value) - callback_id = self._callback_registry.register(callback) - - # Start the task: - task_id = dll_method(*args, callback_id) - try: - # Let the caller handle waiting on the result: - yield future - finally: - # Cancel the task if it's not already done (this call is ignored if it's - # already done) - self._get_dll().mr_cancel_task(self._ctx, task_id) - - # If the caller gives up on waiting, let's at least await the - # cancelation error for GC purposes: - with suppress(Exception): - future.get() - - self._callback_registry.cleanup(callback_id) + with self._callback_registry.register(callback) as callback_id: + # Start the task: + task_id = dll_method(*args, callback_id) + try: + # Let the caller handle waiting on the result: + yield future + finally: + # Cancel the task if it's not already done (this call is ignored if it's + # already done) + self._get_dll().mr_cancel_task(self._ctx, task_id) + + # If the caller gives up on waiting, let's at least await the + # cancelation error for GC purposes: + with suppress(Exception): + future.get() @asynccontextmanager async def wrap_py_function( From 4298c1a7de3b730caedfdcea8f18139f502c5805 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Tue, 23 Dec 2025 16:39:19 -0500 Subject: [PATCH 3/5] Make js-to-py function wrapping more readable --- src/py_mini_racer/_context.py | 146 +++++++++++++++-------------- src/py_mini_racer/_mini_racer.py | 4 +- src/py_mini_racer/_value_handle.py | 6 +- tests/test_py_js_function.py | 23 +++-- 4 files changed, 95 insertions(+), 84 deletions(-) diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index e93ae963..4fa233a7 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,8 +1,9 @@ from __future__ import annotations -from asyncio import FIRST_COMPLETED, Task, create_task, get_running_loop, wait -from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator +from asyncio import Task, create_task, get_running_loop +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator from contextlib import asynccontextmanager, contextmanager, suppress +from dataclasses import dataclass, field from itertools import count from traceback import format_exc from typing import TYPE_CHECKING, Any, cast @@ -24,7 +25,6 @@ if TYPE_CHECKING: import ctypes - from asyncio import Future from py_mini_racer._abstract_context import AbstractValueHandle from py_mini_racer._value_handle import RawValueHandleType @@ -334,89 +334,93 @@ def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: with suppress(Exception): future.get() - @asynccontextmanager - async def wrap_py_function( - self, func: PyJsFunctionType - ) -> AsyncIterator[JSFunction]: - async def process_one_call_from_js(params: JSArray) -> None: - arguments, resolve, reject = params - arguments = cast("JSArray", arguments) - resolve = cast("JSFunction", resolve) - reject = cast("JSFunction", reject) - try: - result = await func(*arguments) - resolve(result) - except Exception: # noqa: BLE001 - # Convert this Python exception into a JS exception so we can send it - # into JS: - err_maker = cast("JSFunction", self.evaluate("s => new Error(s)")) - reject(err_maker(f"Error running Python function:\n{format_exc()}")) - - tasks_for_processing_calls: set[ - Task[PythonJSConvertedTypes | JSEvalException] | Future[bool] - ] = set() + def close(self) -> None: + dll, self._dll = self._dll, None + if dll: + dll.mr_free_context(self._ctx) - def handle_one_call_from_js( - params: PythonJSConvertedTypes | JSEvalException, - ) -> None: - params = cast("JSArray", params) + def __del__(self) -> None: + self.close() - # Start a new task to run the new task: - task = create_task(process_one_call_from_js(params)) - tasks_for_processing_calls.add(task) - loop = get_running_loop() +@asynccontextmanager +async def wrap_py_function_as_js_function( + context: Context, func: PyJsFunctionType +) -> AsyncGenerator[JSFunction, None]: + js_to_py_callback_processor = _JsToPyCallbackProcessor(func, context) - def on_called_from_js(value: PythonJSConvertedTypes | JSEvalException) -> None: - loop.call_soon_threadsafe(handle_one_call_from_js, value) + loop = get_running_loop() - async def process_all_calls() -> None: - nonlocal tasks_for_processing_calls - while tasks_for_processing_calls: - done, tasks_for_processing_calls = await wait( - tasks_for_processing_calls, return_when=FIRST_COMPLETED - ) - for coro in done: - await coro + def on_called_from_js(value: PythonJSConvertedTypes | JSEvalException) -> None: + loop.call_soon_threadsafe( + js_to_py_callback_processor.process_one_invocation_from_js, value + ) - shutdown: Future[bool] = loop.create_future() - tasks_for_processing_calls.add(shutdown) - process_all_calls_task = create_task(process_all_calls()) - try: - with self.js_to_py_callback(on_called_from_js) as js_to_py_callback: - # Every time our callback is called, instantiate a JS Promise - # and immediately pass its resolution functions into our Python - # callback function. While we wait on Python's asyncio loop - # to process this call, we can return the Promise to the JS - # caller, thus exposing what looks like an ordinary async - # function on the JS side of things. - wrap_calls_in_js_promises = cast( - "JSFunction", - self.evaluate( - """ -callback => { + with context.js_to_py_callback(on_called_from_js) as js_to_py_callback: + # Every time our callback is called from JS, on the JS side we + # instantiate a JS Promise and immediately pass its resolution functions + # into our Python callback function. While we wait on Python's asyncio + # loop to process this call, we can return the Promise to the JS caller, + # thus exposing what looks like an ordinary async function on the JS + # side of things. + wrap_outbound_calls_with_js_promises = cast( + "JSFunction", + context.evaluate( + """ +fn => { return (...arguments) => { let p = Promise.withResolvers(); - callback(arguments, p.resolve, p.reject); + fn(arguments, p.resolve, p.reject); return p.promise; } } """ - ), + ), + ) + + yield cast( + "JSFunction", wrap_outbound_calls_with_js_promises(js_to_py_callback) + ) + + +@dataclass(frozen=True) +class _JsToPyCallbackProcessor: + """Processes incoming calls from JS into Python. + + Note that this is not thread-safe and is thus suitable for use with only one asyncio + loop.""" + + _py_func: PyJsFunctionType + _context: Context + _ongoing_callbacks: set[Task[PythonJSConvertedTypes | JSEvalException]] = field( + default_factory=set + ) + + def process_one_invocation_from_js( + self, params: PythonJSConvertedTypes | JSEvalException + ) -> None: + arguments, resolve, reject = cast("JSArray", params) + arguments = cast("JSArray", arguments) + resolve = cast("JSFunction", resolve) + reject = cast("JSFunction", reject) + + async def await_into_promise() -> None: + try: + result = await self._py_func(*arguments) + resolve(result) + except Exception: # noqa: BLE001 + # Convert this Python exception into a JS exception so we can send + # it into JS: + err_maker = cast( + "JSFunction", self._context.evaluate("s => new Error(s)") ) + reject(err_maker(f"Error running Python function:\n{format_exc()}")) - yield cast("JSFunction", wrap_calls_in_js_promises(js_to_py_callback)) - finally: - # Stop accepting calls: - shutdown.set_result(True) - await process_all_calls_task + # Start a new task to await this invocation: + task = create_task(await_into_promise()) - def close(self) -> None: - dll, self._dll = self._dll, None - if dll: - dll.mr_free_context(self._ctx) + self._ongoing_callbacks.add(task) - def __del__(self) -> None: - self.close() + task.add_done_callback(self._ongoing_callbacks.discard) diff --git a/src/py_mini_racer/_mini_racer.py b/src/py_mini_racer/_mini_racer.py index 6f7fe02a..e57305a5 100644 --- a/src/py_mini_racer/_mini_racer.py +++ b/src/py_mini_racer/_mini_racer.py @@ -4,7 +4,7 @@ from json import JSONEncoder from typing import TYPE_CHECKING, Any, ClassVar -from py_mini_racer._context import Context +from py_mini_racer._context import Context, wrap_py_function_as_js_function from py_mini_racer._dll import init_mini_racer from py_mini_racer._exc import MiniRacerBaseException from py_mini_racer._set_timeout import INSTALL_SET_TIMEOUT @@ -211,7 +211,7 @@ def wrap_py_function( can be passed into MiniRacer and called by JS code. """ - return self._ctx.wrap_py_function(func) + return wrap_py_function_as_js_function(self._ctx, func) def set_hard_memory_limit(self, limit: int) -> None: """Set a hard memory limit on this V8 isolate. diff --git a/src/py_mini_racer/_value_handle.py b/src/py_mini_racer/_value_handle.py index aa74a608..93e3c9ef 100644 --- a/src/py_mini_racer/_value_handle.py +++ b/src/py_mini_racer/_value_handle.py @@ -134,7 +134,11 @@ class MiniRacerTypes: class ValueHandle(AbstractValueHandle): """An object which holds open a Python reference to a _RawValue owned by - a C++ MiniRacer context.""" + a C++ MiniRacer context. + + Upon construction, immediately assumes ownership of the handle. To avoid + memory leaks, any raw handles received from the MiniRacer DLL should + generally be wrapped in a ValueHandle as early as possible.""" def __init__(self, ctx: AbstractContext, raw: RawValueHandleType) -> None: self.ctx = ctx diff --git a/tests/test_py_js_function.py b/tests/test_py_js_function.py index 8ac0c8fa..ec968d43 100644 --- a/tests/test_py_js_function.py +++ b/tests/test_py_js_function.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import gather +from asyncio import Future, gather from asyncio import run as asyncio_run from asyncio import sleep as asyncio_sleep from time import time @@ -120,11 +120,17 @@ def test_call_on_exit() -> None: data = [] - async def append(*args: Any) -> str: # noqa: ANN401 - data.append(args) - return "foobar" - async def run() -> None: + fut: Future[None] = Future() + + async def append(*args: Any) -> str: # noqa: ANN401 + data.append(args) + fut.set_result(None) + # sleep long enough that the test will fail unless this is either + # interrupted, or never started to begin with: + await asyncio_sleep(10000) + return "foobar" + async with mr.wrap_py_function(append) as jsfunc: # "Install" our JS function on the global "this" object: cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) @@ -133,11 +139,8 @@ async def run() -> None: # finish it: assert isinstance(mr.eval("this.func(42)"), JSPromise) - # Generally (subject to race conditions) at this point the - # callback initiated by the above this.func(42) will be half-received: - # _Context.wrap_py_function.on_called will have gotten the callback, and - # will have told asyncio to deal with it on the loop thread. We will - # generally *not* have yet processed the call. + await fut + # After this line, we start tearing down the mr.wrap_py_function context # manager, which entails stopping the call processor. # Let's make sure we don't fall over ourselves (it's fair to either process From 6c551fbdce8f7efb498beb415d6fd0867ea696d5 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Tue, 23 Dec 2025 17:25:19 -0500 Subject: [PATCH 4/5] Replace SyncFuture with concurrent.futures.* --- src/py_mini_racer/__init__.py | 1 - src/py_mini_racer/_context.py | 34 +++++++++++++-------- src/py_mini_racer/_objects.py | 50 ++++++++++++++----------------- src/py_mini_racer/_sync_future.py | 45 ---------------------------- 4 files changed, 44 insertions(+), 86 deletions(-) delete mode 100644 src/py_mini_racer/_sync_future.py diff --git a/src/py_mini_racer/__init__.py b/src/py_mini_racer/__init__.py index dea57d5f..49874be2 100644 --- a/src/py_mini_racer/__init__.py +++ b/src/py_mini_racer/__init__.py @@ -34,7 +34,6 @@ __all__ = [ "DEFAULT_V8_FLAGS", - "AsyncCleanupType", "JSArray", "JSArrayIndexError", "JSEvalException", diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index 4fa233a7..10fa4a10 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -2,6 +2,8 @@ from asyncio import Task, create_task, get_running_loop from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator +from concurrent.futures import Future as SyncFuture +from concurrent.futures import TimeoutError as SyncTimeoutError from contextlib import asynccontextmanager, contextmanager, suppress from dataclasses import dataclass, field from itertools import count @@ -10,8 +12,7 @@ from py_mini_racer._abstract_context import AbstractContext from py_mini_racer._dll import init_mini_racer, mr_callback_func -from py_mini_racer._exc import JSEvalException -from py_mini_racer._sync_future import SyncFuture +from py_mini_racer._exc import JSEvalException, JSTimeoutException from py_mini_racer._types import ( JSArray, JSFunction, @@ -30,7 +31,6 @@ from py_mini_racer._value_handle import RawValueHandleType PyJsFunctionType = Callable[..., Awaitable[PythonJSConvertedTypes]] -AsyncCleanupType = Callable[[], Awaitable[None]] def context_count() -> int: @@ -105,7 +105,10 @@ def evaluate( with self._run_mr_task( self._get_dll().mr_eval, self._ctx, code_handle.raw ) as future: - return future.get(timeout=timeout_sec) + try: + return future.result(timeout=timeout_sec) + except SyncTimeoutError as e: + raise JSTimeoutException from e def promise_then( self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction @@ -223,7 +226,10 @@ def call_function( this_handle.raw, argv_handle.raw, ) as future: - return future.get(timeout=timeout_sec) + try: + return future.result(timeout=timeout_sec) + except SyncTimeoutError as e: + raise JSTimeoutException from e def set_hard_memory_limit(self, limit: int) -> None: self._get_dll().mr_set_hard_memory_limit(self._ctx, limit) @@ -242,13 +248,13 @@ def low_memory_notification(self) -> None: def heap_stats(self) -> str: with self._run_mr_task(self._get_dll().mr_heap_stats, self._ctx) as future: - return cast("str", future.get()) + return cast("str", future.result()) def heap_snapshot(self) -> str: """Return a snapshot of the V8 isolate heap.""" with self._run_mr_task(self._get_dll().mr_heap_snapshot, self._ctx) as future: - return cast("str", future.get()) + return cast("str", future.result()) def value_count(self) -> int: """For tests only: how many value handles are still allocated?""" @@ -299,7 +305,11 @@ def free(self, val_handle: AbstractValueHandle) -> None: dll.mr_free_value(self._ctx, val_handle.raw) @contextmanager - def _run_mr_task(self, dll_method: Any, *args: Any) -> Iterator[SyncFuture]: # noqa: ANN401 + def _run_mr_task( + self, + dll_method: Any, # noqa: ANN401 + *args: Any, # noqa: ANN401 + ) -> Iterator[SyncFuture[PythonJSConvertedTypes]]: """Manages those tasks which generate callbacks from the MiniRacer DLL. Several MiniRacer functions (JS evaluation and 2 heap stats calls) are @@ -310,7 +320,7 @@ def _run_mr_task(self, dll_method: Any, *args: Any) -> Iterator[SyncFuture]: # the right caller, and we manage the lifecycle of the task and task handle. """ - future = SyncFuture() + future: SyncFuture[PythonJSConvertedTypes] = SyncFuture() def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: if isinstance(value, JSEvalException): @@ -332,7 +342,7 @@ def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: # If the caller gives up on waiting, let's at least await the # cancelation error for GC purposes: with suppress(Exception): - future.get() + future.result() def close(self) -> None: dll, self._dll = self._dll, None @@ -406,7 +416,7 @@ def process_one_invocation_from_js( resolve = cast("JSFunction", resolve) reject = cast("JSFunction", reject) - async def await_into_promise() -> None: + async def await_into_js_promise_resolvers() -> None: try: result = await self._py_func(*arguments) resolve(result) @@ -419,7 +429,7 @@ async def await_into_promise() -> None: reject(err_maker(f"Error running Python function:\n{format_exc()}")) # Start a new task to await this invocation: - task = create_task(await_into_promise()) + task = create_task(await_into_js_promise_resolvers()) self._ongoing_callbacks.add(task) diff --git a/src/py_mini_racer/_objects.py b/src/py_mini_racer/_objects.py index 16c9aaa8..febdf75a 100644 --- a/src/py_mini_racer/_objects.py +++ b/src/py_mini_racer/_objects.py @@ -3,12 +3,12 @@ from __future__ import annotations from asyncio import get_running_loop -from contextlib import ExitStack +from concurrent.futures import Future as SyncFuture +from contextlib import contextmanager from operator import index as op_index from typing import TYPE_CHECKING, Any, cast from py_mini_racer._exc import JSArrayIndexError, JSPromiseError -from py_mini_racer._sync_future import SyncFuture from py_mini_racer._types import ( JSArray, JSFunction, @@ -171,14 +171,13 @@ def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: This is deprecated; use timeout_sec instead. """ - future = SyncFuture() + future: SyncFuture[PythonJSConvertedTypes] = SyncFuture() - def future_caller(value: Any) -> None: # noqa: ANN401 + def future_caller(value: PythonJSConvertedTypes) -> None: future.set_result(value) - self._attach_callbacks_to_promise(future_caller) - - return self._unpack_promise_results(future.get(timeout=timeout)) + with self._attach_callbacks_to_promise(future_caller): + return self._unpack_promise_results(future.result(timeout=timeout)) def __await__(self) -> Generator[Any, None, Any]: return self._do_await().__await__() @@ -187,44 +186,39 @@ async def _do_await(self) -> PythonJSConvertedTypes: loop = get_running_loop() future: Future[PythonJSConvertedTypes] = loop.create_future() - def future_caller(value: Any) -> None: # noqa: ANN401 + def future_caller(value: PythonJSConvertedTypes) -> None: loop.call_soon_threadsafe(future.set_result, value) - self._attach_callbacks_to_promise(future_caller) - - return self._unpack_promise_results(await future) + with self._attach_callbacks_to_promise(future_caller): + return self._unpack_promise_results(await future) + @contextmanager def _attach_callbacks_to_promise( self, future_caller: Callable[[Any], None] - ) -> None: + ) -> Generator[None, None, None]: """Attach the given Python callbacks to a JS Promise.""" - exit_stack = ExitStack() - def on_resolved_and_cleanup( value: PythonJSConvertedTypes | JSEvalException, ) -> None: - exit_stack.__exit__(None, None, None) future_caller([False, cast("JSArray", value)]) def on_rejected_and_cleanup( value: PythonJSConvertedTypes | JSEvalException, ) -> None: - exit_stack.__exit__(None, None, None) future_caller([True, cast("JSArray", value)]) - self._ctx.promise_then( - self, - exit_stack.enter_context( - self._ctx.js_to_py_callback(on_resolved_and_cleanup) - ), - exit_stack.enter_context( - self._ctx.js_to_py_callback(on_rejected_and_cleanup) - ), - ) - - def _unpack_promise_results(self, results: Any) -> PythonJSConvertedTypes: # noqa: ANN401 - is_rejected, argv = results + with ( + self._ctx.js_to_py_callback(on_resolved_and_cleanup) as on_resolved_js_func, + self._ctx.js_to_py_callback(on_rejected_and_cleanup) as on_rejected_js_func, + ): + self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) + yield + + def _unpack_promise_results( + self, results: PythonJSConvertedTypes + ) -> PythonJSConvertedTypes: + is_rejected, argv = cast("JSArray", results) result = cast("JSArray", argv)[0] if is_rejected: msg = _get_exception_msg(result) diff --git a/src/py_mini_racer/_sync_future.py b/src/py_mini_racer/_sync_future.py deleted file mode 100644 index 9c933596..00000000 --- a/src/py_mini_racer/_sync_future.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from threading import Condition -from typing import TYPE_CHECKING - -from py_mini_racer._exc import JSTimeoutException - -if TYPE_CHECKING: - from py_mini_racer._types import PythonJSConvertedTypes - - -class SyncFuture: - """A blocking synchronization object for function return values. - - This is like asyncio.Future but blocking, or like - concurrent.futures.Future but without an executor. - """ - - def __init__(self) -> None: - self._cv = Condition() - self._settled: bool = False - self._res: PythonJSConvertedTypes = None - self._exc: Exception | None = None - - def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: - with self._cv: - while not self._settled: - if not self._cv.wait(timeout=timeout): - raise JSTimeoutException - - if self._exc: - raise self._exc - return self._res - - def set_result(self, res: PythonJSConvertedTypes) -> None: - with self._cv: - self._res = res - self._settled = True - self._cv.notify() - - def set_exception(self, exc: Exception) -> None: - with self._cv: - self._exc = exc - self._settled = True - self._cv.notify() From 8b97302fda6dd83f1f4da06533d8a5c5a5c85142 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Tue, 23 Dec 2025 17:48:49 -0500 Subject: [PATCH 5/5] Simplify Promise handling --- src/py_mini_racer/__init__.py | 2 +- src/py_mini_racer/_context.py | 92 +--------------------- src/py_mini_racer/_exc.py | 2 +- src/py_mini_racer/_mini_racer.py | 10 ++- src/py_mini_racer/_objects.py | 87 ++++++++++---------- src/py_mini_racer/_types.py | 10 ++- src/py_mini_racer/_wrap_py_function.py | 105 +++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 140 deletions(-) create mode 100644 src/py_mini_racer/_wrap_py_function.py diff --git a/src/py_mini_racer/__init__.py b/src/py_mini_racer/__init__.py index 49874be2..8562e544 100644 --- a/src/py_mini_racer/__init__.py +++ b/src/py_mini_racer/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from py_mini_racer._context import PyJsFunctionType from py_mini_racer._dll import ( DEFAULT_V8_FLAGS, LibAlreadyInitializedError, @@ -23,6 +22,7 @@ JSSymbol, JSUndefined, JSUndefinedType, + PyJsFunctionType, PythonJSConvertedTypes, ) from py_mini_racer._value_handle import ( diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index 10fa4a10..88f8042c 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,13 +1,9 @@ from __future__ import annotations -from asyncio import Task, create_task, get_running_loop -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator from concurrent.futures import Future as SyncFuture from concurrent.futures import TimeoutError as SyncTimeoutError -from contextlib import asynccontextmanager, contextmanager, suppress -from dataclasses import dataclass, field +from contextlib import contextmanager, suppress from itertools import count -from traceback import format_exc from typing import TYPE_CHECKING, Any, cast from py_mini_racer._abstract_context import AbstractContext @@ -26,12 +22,11 @@ if TYPE_CHECKING: import ctypes + from collections.abc import Callable, Generator, Iterator from py_mini_racer._abstract_context import AbstractValueHandle from py_mini_racer._value_handle import RawValueHandleType -PyJsFunctionType = Callable[..., Awaitable[PythonJSConvertedTypes]] - def context_count() -> int: """For tests only: how many context handles are still allocated?""" @@ -351,86 +346,3 @@ def close(self) -> None: def __del__(self) -> None: self.close() - - -@asynccontextmanager -async def wrap_py_function_as_js_function( - context: Context, func: PyJsFunctionType -) -> AsyncGenerator[JSFunction, None]: - js_to_py_callback_processor = _JsToPyCallbackProcessor(func, context) - - loop = get_running_loop() - - def on_called_from_js(value: PythonJSConvertedTypes | JSEvalException) -> None: - loop.call_soon_threadsafe( - js_to_py_callback_processor.process_one_invocation_from_js, value - ) - - with context.js_to_py_callback(on_called_from_js) as js_to_py_callback: - # Every time our callback is called from JS, on the JS side we - # instantiate a JS Promise and immediately pass its resolution functions - # into our Python callback function. While we wait on Python's asyncio - # loop to process this call, we can return the Promise to the JS caller, - # thus exposing what looks like an ordinary async function on the JS - # side of things. - wrap_outbound_calls_with_js_promises = cast( - "JSFunction", - context.evaluate( - """ -fn => { - return (...arguments) => { - let p = Promise.withResolvers(); - - fn(arguments, p.resolve, p.reject); - - return p.promise; - } -} -""" - ), - ) - - yield cast( - "JSFunction", wrap_outbound_calls_with_js_promises(js_to_py_callback) - ) - - -@dataclass(frozen=True) -class _JsToPyCallbackProcessor: - """Processes incoming calls from JS into Python. - - Note that this is not thread-safe and is thus suitable for use with only one asyncio - loop.""" - - _py_func: PyJsFunctionType - _context: Context - _ongoing_callbacks: set[Task[PythonJSConvertedTypes | JSEvalException]] = field( - default_factory=set - ) - - def process_one_invocation_from_js( - self, params: PythonJSConvertedTypes | JSEvalException - ) -> None: - arguments, resolve, reject = cast("JSArray", params) - arguments = cast("JSArray", arguments) - resolve = cast("JSFunction", resolve) - reject = cast("JSFunction", reject) - - async def await_into_js_promise_resolvers() -> None: - try: - result = await self._py_func(*arguments) - resolve(result) - except Exception: # noqa: BLE001 - # Convert this Python exception into a JS exception so we can send - # it into JS: - err_maker = cast( - "JSFunction", self._context.evaluate("s => new Error(s)") - ) - reject(err_maker(f"Error running Python function:\n{format_exc()}")) - - # Start a new task to await this invocation: - task = create_task(await_into_js_promise_resolvers()) - - self._ongoing_callbacks.add(task) - - task.add_done_callback(self._ongoing_callbacks.discard) diff --git a/src/py_mini_racer/_exc.py b/src/py_mini_racer/_exc.py index 2d4da7ff..744757f7 100644 --- a/src/py_mini_racer/_exc.py +++ b/src/py_mini_racer/_exc.py @@ -14,7 +14,7 @@ class JSEvalException(MiniRacerBaseException): """JavaScript could not be executed.""" -class JSTimeoutException(JSEvalException): +class JSTimeoutException(JSEvalException, TimeoutError): # noqa: N818 """JavaScript execution timed out.""" def __init__(self) -> None: diff --git a/src/py_mini_racer/_mini_racer.py b/src/py_mini_racer/_mini_racer.py index e57305a5..30eb4187 100644 --- a/src/py_mini_racer/_mini_racer.py +++ b/src/py_mini_racer/_mini_racer.py @@ -4,10 +4,11 @@ from json import JSONEncoder from typing import TYPE_CHECKING, Any, ClassVar -from py_mini_racer._context import Context, wrap_py_function_as_js_function +from py_mini_racer._context import Context from py_mini_racer._dll import init_mini_racer from py_mini_racer._exc import MiniRacerBaseException from py_mini_racer._set_timeout import INSTALL_SET_TIMEOUT +from py_mini_racer._wrap_py_function import wrap_py_function_as_js_function if TYPE_CHECKING: from contextlib import AbstractAsyncContextManager @@ -15,8 +16,11 @@ from typing_extensions import Self - from py_mini_racer._context import PyJsFunctionType - from py_mini_racer._types import JSFunction, PythonJSConvertedTypes + from py_mini_racer._types import ( + JSFunction, + PyJsFunctionType, + PythonJSConvertedTypes, + ) class WrongReturnTypeException(MiniRacerBaseException): diff --git a/src/py_mini_racer/_objects.py b/src/py_mini_racer/_objects.py index febdf75a..4b081f1a 100644 --- a/src/py_mini_racer/_objects.py +++ b/src/py_mini_racer/_objects.py @@ -4,7 +4,6 @@ from asyncio import get_running_loop from concurrent.futures import Future as SyncFuture -from contextlib import contextmanager from operator import index as op_index from typing import TYPE_CHECKING, Any, cast @@ -20,10 +19,11 @@ JSUndefinedType, PythonJSConvertedTypes, ) +from py_mini_racer._wrap_py_function import wrap_py_function_as_js_function if TYPE_CHECKING: from asyncio import Future - from collections.abc import Callable, Generator, Iterator + from collections.abc import Generator, Iterator from py_mini_racer._abstract_context import AbstractContext, AbstractValueHandle from py_mini_racer._exc import JSEvalException @@ -171,56 +171,55 @@ def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: This is deprecated; use timeout_sec instead. """ - future: SyncFuture[PythonJSConvertedTypes] = SyncFuture() + future: SyncFuture[JSArray] = SyncFuture() + is_rejected = False - def future_caller(value: PythonJSConvertedTypes) -> None: - future.set_result(value) + def on_resolved(value: PythonJSConvertedTypes | JSEvalException) -> None: + future.set_result(cast("JSArray", value)) - with self._attach_callbacks_to_promise(future_caller): - return self._unpack_promise_results(future.result(timeout=timeout)) + def on_rejected(value: PythonJSConvertedTypes | JSEvalException) -> None: + nonlocal is_rejected + is_rejected = True + future.set_result(cast("JSArray", value)) - def __await__(self) -> Generator[Any, None, Any]: - return self._do_await().__await__() - - async def _do_await(self) -> PythonJSConvertedTypes: - loop = get_running_loop() - future: Future[PythonJSConvertedTypes] = loop.create_future() - - def future_caller(value: PythonJSConvertedTypes) -> None: - loop.call_soon_threadsafe(future.set_result, value) + with ( + self._ctx.js_to_py_callback(on_resolved) as on_resolved_js_func, + self._ctx.js_to_py_callback(on_rejected) as on_rejected_js_func, + ): + self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) - with self._attach_callbacks_to_promise(future_caller): - return self._unpack_promise_results(await future) + result = future.result(timeout=timeout) - @contextmanager - def _attach_callbacks_to_promise( - self, future_caller: Callable[[Any], None] - ) -> Generator[None, None, None]: - """Attach the given Python callbacks to a JS Promise.""" + if is_rejected: + msg = _get_exception_msg(result[0]) + raise JSPromiseError(msg) - def on_resolved_and_cleanup( - value: PythonJSConvertedTypes | JSEvalException, - ) -> None: - future_caller([False, cast("JSArray", value)]) + return result[0] - def on_rejected_and_cleanup( - value: PythonJSConvertedTypes | JSEvalException, - ) -> None: - future_caller([True, cast("JSArray", value)]) + def __await__(self) -> Generator[Any, None, Any]: + return self._do_await().__await__() - with ( - self._ctx.js_to_py_callback(on_resolved_and_cleanup) as on_resolved_js_func, - self._ctx.js_to_py_callback(on_rejected_and_cleanup) as on_rejected_js_func, + async def _do_await(self) -> PythonJSConvertedTypes: + future: Future[PythonJSConvertedTypes] = get_running_loop().create_future() + + async def on_resolved(value: PythonJSConvertedTypes | JSEvalException) -> None: + future.set_result(cast("PythonJSConvertedTypes", value)) + + async def on_rejected(value: PythonJSConvertedTypes | JSEvalException) -> None: + future.set_exception( + JSPromiseError( + _get_exception_msg(cast("PythonJSConvertedTypes", value)) + ) + ) + + async with ( + wrap_py_function_as_js_function( + self._ctx, on_resolved + ) as on_resolved_js_func, + wrap_py_function_as_js_function( + self._ctx, on_rejected + ) as on_rejected_js_func, ): self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) - yield - def _unpack_promise_results( - self, results: PythonJSConvertedTypes - ) -> PythonJSConvertedTypes: - is_rejected, argv = cast("JSArray", results) - result = cast("JSArray", argv)[0] - if is_rejected: - msg = _get_exception_msg(result) - raise JSPromiseError(msg) - return result + return await future diff --git a/src/py_mini_racer/_types.py b/src/py_mini_racer/_types.py index 9e2dc81c..a2ae9ffc 100644 --- a/src/py_mini_racer/_types.py +++ b/src/py_mini_racer/_types.py @@ -3,7 +3,13 @@ from __future__ import annotations from abc import ABC -from collections.abc import Generator, MutableMapping, MutableSequence +from collections.abc import ( + Awaitable, + Callable, + Generator, + MutableMapping, + MutableSequence, +) from datetime import datetime from typing import Any, TypeAlias @@ -100,3 +106,5 @@ async def _do_await(self) -> PythonJSConvertedTypes: | JSArray | None ) + +PyJsFunctionType = Callable[..., Awaitable[PythonJSConvertedTypes]] diff --git a/src/py_mini_racer/_wrap_py_function.py b/src/py_mini_racer/_wrap_py_function.py new file mode 100644 index 00000000..f2132379 --- /dev/null +++ b/src/py_mini_racer/_wrap_py_function.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from traceback import format_exc +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from py_mini_racer._abstract_context import AbstractContext + from py_mini_racer._exc import JSEvalException + from py_mini_racer._types import ( + JSArray, + JSFunction, + PyJsFunctionType, + PythonJSConvertedTypes, + ) + + +@asynccontextmanager +async def wrap_py_function_as_js_function( + context: AbstractContext, func: PyJsFunctionType +) -> AsyncGenerator[JSFunction, None]: + with context.js_to_py_callback( + _JsToPyCallbackProcessor( + func, context, asyncio.get_running_loop() + ).process_one_invocation_from_js + ) as js_to_py_callback: + # Every time our callback is called from JS, on the JS side we + # instantiate a JS Promise and immediately pass its resolution functions + # into our Python callback function. While we wait on Python's asyncio + # loop to process this call, we can return the Promise to the JS caller, + # thus exposing what looks like an ordinary async function on the JS + # side of things. + wrap_outbound_calls_with_js_promises = cast( + "JSFunction", + context.evaluate( + """ +fn => { + return (...arguments) => { + let p = Promise.withResolvers(); + + fn(arguments, p.resolve, p.reject); + + return p.promise; + } +} +""" + ), + ) + + yield cast( + "JSFunction", wrap_outbound_calls_with_js_promises(js_to_py_callback) + ) + + +@dataclass(frozen=True) +class _JsToPyCallbackProcessor: + """Processes incoming calls from JS into Python. + + Note that this is not thread-safe and is thus suitable for use with only one asyncio + loop.""" + + _py_func: PyJsFunctionType + _context: AbstractContext + _loop: asyncio.AbstractEventLoop + _ongoing_callbacks: set[asyncio.Task[PythonJSConvertedTypes | JSEvalException]] = ( + field(default_factory=set) + ) + + def process_one_invocation_from_js( + self, params: PythonJSConvertedTypes | JSEvalException + ) -> None: + async def await_into_js_promise_resolvers( + arguments: JSArray, resolve: JSFunction, reject: JSFunction + ) -> None: + try: + result = await self._py_func(*arguments) + resolve(result) + except Exception: # noqa: BLE001 + # Convert this Python exception into a JS exception so we can send + # it into JS: + err_maker = cast( + "JSFunction", self._context.evaluate("s => new Error(s)") + ) + reject(err_maker(f"Error running Python function:\n{format_exc()}")) + + # Start a new task to await this invocation: + def start_task() -> None: + arguments, resolve, reject = cast("JSArray", params) + task = self._loop.create_task( + await_into_js_promise_resolvers( + cast("JSArray", arguments), + cast("JSFunction", resolve), + cast("JSFunction", reject), + ) + ) + + self._ongoing_callbacks.add(task) + + task.add_done_callback(self._ongoing_callbacks.discard) + + self._loop.call_soon_threadsafe(start_task)