diff --git a/src/py_mini_racer/__init__.py b/src/py_mini_racer/__init__.py index 8562e544..9fd157c0 100644 --- a/src/py_mini_racer/__init__.py +++ b/src/py_mini_racer/__init__.py @@ -9,8 +9,12 @@ from py_mini_racer._exc import ( JSArrayIndexError, JSEvalException, + JSKeyError, + JSOOMException, + JSParseException, JSPromiseError, JSTimeoutException, + JSValueError, ) from py_mini_racer._mini_racer import MiniRacer, StrictMiniRacer from py_mini_racer._types import ( @@ -25,12 +29,6 @@ PyJsFunctionType, PythonJSConvertedTypes, ) -from py_mini_racer._value_handle import ( - JSKeyError, - JSOOMException, - JSParseException, - JSValueError, -) __all__ = [ "DEFAULT_V8_FLAGS", diff --git a/src/py_mini_racer/_abstract_context.py b/src/py_mini_racer/_abstract_context.py deleted file mode 100644 index b22d0777..00000000 --- a/src/py_mini_racer/_abstract_context.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from py_mini_racer._types import JSUndefined - -if TYPE_CHECKING: - from collections.abc import Callable - from contextlib import AbstractContextManager - - from py_mini_racer._exc import JSEvalException - from py_mini_racer._types import ( - JSArray, - JSFunction, - JSObject, - JSPromise, - JSUndefinedType, - PythonJSConvertedTypes, - ) - - -class AbstractValueHandle(ABC): - @property - @abstractmethod - def raw(self) -> object: - pass - - @abstractmethod - def to_python(self) -> PythonJSConvertedTypes | JSEvalException: - pass - - @abstractmethod - def to_python_or_raise(self) -> PythonJSConvertedTypes: - pass - - -class AbstractContext(ABC): - """A Context provides Pythonic wrappers around the MiniRacer C API. - - This is intended for internal usage by py_mini_racer. MiniRacerContext provides - a further wrapper around this interface. - """ - - @abstractmethod - def get_identity_hash(self, obj: JSObject) -> int: - pass - - @abstractmethod - def get_own_property_names( - self, obj: JSObject - ) -> tuple[PythonJSConvertedTypes, ...]: - pass - - @abstractmethod - def get_object_item( - self, obj: JSObject, key: PythonJSConvertedTypes - ) -> PythonJSConvertedTypes: - pass - - @abstractmethod - def set_object_item( - self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes - ) -> None: - pass - - @abstractmethod - def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: - pass - - @abstractmethod - def del_from_array(self, arr: JSArray, index: int) -> None: - pass - - @abstractmethod - def array_insert( - self, arr: JSArray, index: int, new_val: PythonJSConvertedTypes - ) -> None: - pass - - @abstractmethod - def array_push(self, arr: JSArray, new_val: PythonJSConvertedTypes) -> None: - pass - - @abstractmethod - def call_function( - self, - func: JSFunction, - *args: PythonJSConvertedTypes, - this: JSObject | JSUndefinedType = JSUndefined, - timeout_sec: float | None = None, - ) -> PythonJSConvertedTypes: - pass - - @abstractmethod - 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 - ) -> None: - pass - - @abstractmethod - def create_intish_val(self, val: int, typ: int) -> AbstractValueHandle: - pass - - @abstractmethod - def create_doublish_val(self, val: float, typ: int) -> AbstractValueHandle: - pass - - @abstractmethod - def create_string_val(self, val: str, typ: int) -> AbstractValueHandle: - pass - - @abstractmethod - def free(self, val_handle: AbstractValueHandle) -> None: - pass - - @abstractmethod - def evaluate( - 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 1894ffc4..1844820f 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,14 +1,33 @@ from __future__ import annotations +import ctypes from concurrent.futures import Future as SyncFuture from concurrent.futures import TimeoutError as SyncTimeoutError from contextlib import contextmanager, suppress +from datetime import datetime, timezone from itertools import count -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast -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, JSTimeoutException +from py_mini_racer._exc import ( + JSConversionException, + JSEvalException, + JSKeyError, + JSOOMException, + JSParseException, + JSTerminatedException, + JSTimeoutException, + JSValueError, +) +from py_mini_racer._js_value_manipulator import JSValueManipulator +from py_mini_racer._objects import ( + JSArrayImpl, + JSFunctionImpl, + JSMappedObjectImpl, + JSObjectImpl, + JSPromiseImpl, + JSSymbolImpl, +) from py_mini_racer._types import ( JSArray, JSFunction, @@ -18,13 +37,12 @@ JSUndefinedType, PythonJSConvertedTypes, ) -from py_mini_racer._value_handle import ValueHandle, python_to_value_handle +from py_mini_racer._value_handle import ValueHandle if TYPE_CHECKING: - import ctypes - from collections.abc import Callable, Generator, Iterator + from collections.abc import Callable, Generator, Iterator, Sequence - from py_mini_racer._abstract_context import AbstractValueHandle + from py_mini_racer._dll import RawValueHandleTypeImpl from py_mini_racer._value_handle import RawValueHandleType @@ -35,20 +53,81 @@ def context_count() -> int: return int(dll.mr_context_count()) +class _ArrayBufferByte(ctypes.Structure): + # Cannot use c_ubyte directly because it uses None: - self._active_callbacks: dict[ - int, Callable[[PythonJSConvertedTypes | JSEvalException], None] - ] = {} + self._active_callbacks: dict[int, Callable[[ValueHandle], None]] = {} # define an all-purpose callback: @mr_callback_func def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: val_handle = raw_handle_wrapper(raw_val_handle) callback = self._active_callbacks[callback_id] - callback(val_handle.to_python()) + callback(val_handle) self.mr_callback = mr_callback @@ -56,7 +135,7 @@ def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: @contextmanager def register( - self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] + self, func: Callable[[ValueHandle], None] ) -> Generator[int, None, None]: callback_id = next(self._next_callback_id) @@ -68,7 +147,7 @@ def register( self._active_callbacks.pop(callback_id) -class Context(AbstractContext): +class Context(JSValueManipulator): """Wrapper for all operations involving the DLL and C++ MiniRacer::Context.""" def __init__(self, dll: ctypes.CDLL) -> None: @@ -95,7 +174,7 @@ def v8_is_using_sandbox(self) -> bool: def evaluate( self, code: str, timeout_sec: float | None = None ) -> PythonJSConvertedTypes: - code_handle = python_to_value_handle(self, code) + code_handle = self._python_to_value_handle(code) with self._run_mr_task( self._get_dll().mr_eval, self._ctx, code_handle.raw @@ -108,35 +187,44 @@ def evaluate( def promise_then( 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 - ) - ).to_python_or_raise() + promise_handle = self._python_to_value_handle(promise) + then_name_handle = self._python_to_value_handle("then") + + then_func = cast( + "JSFunction", + self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_get_object_item( + self._ctx, promise_handle.raw, then_name_handle.raw + ) + ) + ), + ) - then_func = cast("JSFunction", then_func) then_func(on_resolved, on_rejected, this=promise) def get_identity_hash(self, obj: JSObject) -> int: - obj_handle = python_to_value_handle(self, obj) + obj_handle = self._python_to_value_handle(obj) return cast( "int", - self._wrap_raw_handle( - self._get_dll().mr_get_identity_hash(self._ctx, obj_handle.raw) - ).to_python_or_raise(), + self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_get_identity_hash(self._ctx, obj_handle.raw) + ) + ), ) def get_own_property_names( self, obj: JSObject ) -> tuple[PythonJSConvertedTypes, ...]: - obj_handle = python_to_value_handle(self, obj) + obj_handle = self._python_to_value_handle(obj) - names = self._wrap_raw_handle( - self._get_dll().mr_get_own_property_names(self._ctx, obj_handle.raw) - ).to_python_or_raise() + names = self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_get_own_property_names(self._ctx, obj_handle.raw) + ) + ) if not isinstance(names, JSArray): raise TypeError return tuple(names) @@ -144,69 +232,85 @@ def get_own_property_names( def get_object_item( self, obj: JSObject, key: PythonJSConvertedTypes ) -> PythonJSConvertedTypes: - obj_handle = python_to_value_handle(self, obj) - key_handle = python_to_value_handle(self, key) + obj_handle = self._python_to_value_handle(obj) + key_handle = self._python_to_value_handle(key) - return self._wrap_raw_handle( - self._get_dll().mr_get_object_item( - self._ctx, obj_handle.raw, key_handle.raw + return self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_get_object_item( + self._ctx, obj_handle.raw, key_handle.raw + ) ) - ).to_python_or_raise() + ) def set_object_item( self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: - obj_handle = python_to_value_handle(self, obj) - key_handle = python_to_value_handle(self, key) - val_handle = python_to_value_handle(self, val) + obj_handle = self._python_to_value_handle(obj) + key_handle = self._python_to_value_handle(key) + val_handle = self._python_to_value_handle(val) # 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._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_set_object_item( + 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: - obj_handle = python_to_value_handle(self, obj) - key_handle = python_to_value_handle(self, key) + obj_handle = self._python_to_value_handle(obj) + key_handle = self._python_to_value_handle(key) # 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._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_del_object_item( + self._ctx, obj_handle.raw, key_handle.raw + ) ) - ).to_python_or_raise() + ) def del_from_array(self, arr: JSArray, index: int) -> None: - arr_handle = python_to_value_handle(self, arr) + arr_handle = self._python_to_value_handle(arr) # 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) - ).to_python_or_raise() + self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_splice_array( + self._ctx, arr_handle.raw, index, 1, None + ) + ) + ) def array_insert( 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) + arr_handle = self._python_to_value_handle(arr) + new_val_handle = self._python_to_value_handle(new_val) # 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._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_splice_array( + self._ctx, arr_handle.raw, index, 0, new_val_handle.raw + ) ) - ).to_python_or_raise() + ) def array_push(self, arr: JSArray, new_val: PythonJSConvertedTypes) -> None: - arr_handle = python_to_value_handle(self, arr) - new_val_handle = python_to_value_handle(self, new_val) + arr_handle = self._python_to_value_handle(arr) + new_val_handle = self._python_to_value_handle(new_val) # Convert the value just to convert any exceptions (and GC the result) - self._wrap_raw_handle( - self._get_dll().mr_array_push(self._ctx, arr_handle.raw, new_val_handle.raw) - ).to_python_or_raise() + self._value_handle_to_python( + self._wrap_raw_handle( + self._get_dll().mr_array_push( + self._ctx, arr_handle.raw, new_val_handle.raw + ) + ) + ) def call_function( self, @@ -219,9 +323,9 @@ def call_function( for arg in args: argv.append(arg) - func_handle = python_to_value_handle(self, func) - this_handle = python_to_value_handle(self, this) - argv_handle = python_to_value_handle(self, argv) + func_handle = self._python_to_value_handle(func) + this_handle = self._python_to_value_handle(this) + argv_handle = self._python_to_value_handle(argv) with self._run_mr_task( self._get_dll().mr_call_function, @@ -277,36 +381,45 @@ def js_to_py_callback( future. """ - with self._callback_registry.register(func) as callback_id: + def func_py(val_handle: ValueHandle) -> None: + try: + value = self._value_handle_to_python(val_handle) + except JSEvalException as e: + func(e) + return + + func(value) + + with self._callback_registry.register(func_py) as callback_id: cb = self._wrap_raw_handle( self._get_dll().mr_make_js_callback(self._ctx, callback_id) ) - yield cast("JSFunction", cb.to_python_or_raise()) + yield cast("JSFunction", self._value_handle_to_python(cb)) def _wrap_raw_handle(self, raw: RawValueHandleType) -> ValueHandle: - return ValueHandle(self, raw) + return ValueHandle(lambda: self._free(raw), raw) - def create_intish_val(self, val: int, typ: int) -> AbstractValueHandle: + def _create_intish_val(self, val: int, typ: int) -> ValueHandle: return self._wrap_raw_handle( self._get_dll().mr_alloc_int_val(self._ctx, val, typ) ) - def create_doublish_val(self, val: float, typ: int) -> AbstractValueHandle: + def _create_doublish_val(self, val: float, typ: int) -> ValueHandle: return self._wrap_raw_handle( self._get_dll().mr_alloc_double_val(self._ctx, val, typ) ) - def create_string_val(self, val: str, typ: int) -> AbstractValueHandle: + def _create_string_val(self, val: str, typ: int) -> ValueHandle: b = val.encode("utf-8") return self._wrap_raw_handle( self._get_dll().mr_alloc_string_val(self._ctx, b, len(b), typ) ) - def free(self, val_handle: AbstractValueHandle) -> None: + def _free(self, raw: RawValueHandleType) -> None: dll = self._dll if dll is not None: - dll.mr_free_value(self._ctx, val_handle.raw) + dll.mr_free_value(self._ctx, raw) @contextmanager def _run_mr_task( @@ -326,11 +439,14 @@ def _run_mr_task( future: SyncFuture[PythonJSConvertedTypes] = SyncFuture() - def callback(value: PythonJSConvertedTypes | JSEvalException) -> None: - if isinstance(value, JSEvalException): - future.set_exception(value) - else: - future.set_result(value) + def callback(val_handle: ValueHandle) -> None: + try: + value = self._value_handle_to_python(val_handle) + except JSEvalException as e: + future.set_exception(e) + return + + future.set_result(value) with self._callback_registry.register(callback) as callback_id: # Start the task: @@ -355,3 +471,119 @@ def close(self) -> None: def __del__(self) -> None: self.close() + + def _value_handle_to_python( # noqa: C901, PLR0911, PLR0912 + self, val_handle: ValueHandle + ) -> PythonJSConvertedTypes: + """Convert a binary value handle from the C++ side into a Python object.""" + + # A MiniRacer binary value handle is a pointer to a structure which, for some + # simple types like ints, floats, and strings, is sufficient to describe the + # data, enabling us to convert the value immediately and free the handle. + + # For more complex types, like Objects and Arrays, the handle is just an opaque + # pointer to a V8 object. In these cases, we retain the binary value handle, + # wrapping it in a Python object. We can then use the handle in follow-on API + # calls to work with the underlying V8 object. + + # In either case the handle is owned by the C++ side. It's the responsibility + # of the Python side to call mr_free_value() when done with with the handle + # to free up memory, but the C++ side will eventually free it on context + # teardown either way. + + raw = cast("RawValueHandleTypeImpl", val_handle.raw) + + typ = raw.contents.type + val = raw.contents.value + length = raw.contents.len + + error_info = _ERRORS.get(raw.contents.type) + if error_info: + klass, generic_msg = error_info + + msg = val.bytes_val[0:length].decode("utf-8") or generic_msg + raise klass(msg) + + if typ == _MiniRacerTypes.null: + return None + if typ == _MiniRacerTypes.undefined: + return JSUndefined + if typ == _MiniRacerTypes.bool: + return bool(val.int_val == 1) + if typ == _MiniRacerTypes.integer: + return int(val.int_val) + if typ == _MiniRacerTypes.double: + return float(val.double_val) + if typ == _MiniRacerTypes.str_utf8: + return str(val.bytes_val[0:length].decode("utf-8")) + if typ == _MiniRacerTypes.function: + return JSFunctionImpl(self, val_handle) + if typ == _MiniRacerTypes.date: + timestamp = val.double_val + # JS timestamps are milliseconds. In Python we are in seconds: + return datetime.fromtimestamp(timestamp / 1000.0, timezone.utc) + if typ == _MiniRacerTypes.symbol: + return JSSymbolImpl(self, val_handle) + if typ in (_MiniRacerTypes.shared_array_buffer, _MiniRacerTypes.array_buffer): + buf = _ArrayBufferByte * length + cdata = buf.from_address(val.value_ptr) + # Save a reference to ourselves to prevent garbage collection of the + # backing store: + cdata._origin = self # noqa: SLF001 + result = memoryview(cdata) + # Avoids "NotImplementedError: memoryview: unsupported format T{ ValueHandle: + if isinstance(obj, JSObjectImpl): + # JSObjects originate from the V8 side. We can just send back the handle + # we originally got. (This also covers derived types JSFunction, JSSymbol, + # JSPromise, and JSArray.) + return obj.raw_handle + + if obj is None: + return self._create_intish_val(0, _MiniRacerTypes.null) + if obj is JSUndefined: + return self._create_intish_val(0, _MiniRacerTypes.undefined) + if isinstance(obj, bool): + return self._create_intish_val(1 if obj else 0, _MiniRacerTypes.bool) + if isinstance(obj, int): + if obj - 2**31 <= obj < 2**31: + return self._create_intish_val(obj, _MiniRacerTypes.integer) + + # We transmit ints as int32, so "upgrade" to double upon overflow. + # (ECMAScript numeric is double anyway, but V8 does internally distinguish + # int types, so we try and preserve integer-ness for round-tripping + # purposes.) + # JS BigInt would be a closer representation of Python int, but upgrading + # to BigInt would probably be surprising for most applications, so for now, + # we approximate with double: + return self._create_doublish_val(obj, _MiniRacerTypes.double) + if isinstance(obj, float): + return self._create_doublish_val(obj, _MiniRacerTypes.double) + if isinstance(obj, str): + return self._create_string_val(obj, _MiniRacerTypes.str_utf8) + if isinstance(obj, datetime): + # JS timestamps are milliseconds. In Python we are in seconds: + return self._create_doublish_val( + obj.timestamp() * 1000.0, _MiniRacerTypes.date + ) + + # Note: we skip shared array buffers, so for now at least, handles to shared + # array buffers can only be transmitted from JS to Python. + + raise JSConversionException diff --git a/src/py_mini_racer/_dll.py b/src/py_mini_racer/_dll.py index ae3bf254..a23066b4 100644 --- a/src/py_mini_racer/_dll.py +++ b/src/py_mini_racer/_dll.py @@ -7,13 +7,12 @@ from pathlib import Path from sys import platform from threading import Lock -from typing import TYPE_CHECKING, Protocol, cast +from typing import TYPE_CHECKING, ClassVar, Protocol, cast from py_mini_racer._exc import MiniRacerBaseException -from py_mini_racer._value_handle import RawValueHandle if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Iterable, Iterator, Sequence from py_mini_racer._value_handle import RawValueHandleType @@ -30,15 +29,38 @@ def _get_lib_filename(name: str) -> str: return prefix + name + ext -MR_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.c_uint64, RawValueHandle) +class _RawValueUnion(ctypes.Union): + _fields_: ClassVar[Sequence[tuple[str, type]]] = [ + ("value_ptr", ctypes.c_void_p), + ("bytes_val", ctypes.POINTER(ctypes.c_char)), + ("char_p_val", ctypes.c_char_p), + ("int_val", ctypes.c_int64), + ("double_val", ctypes.c_double), + ] + + +class _RawValue(ctypes.Structure): + _fields_: ClassVar[Sequence[tuple[str, type]]] = [ + ("value", _RawValueUnion), + ("len", ctypes.c_size_t), + ("type", ctypes.c_uint8), + ] + _pack_ = 1 +RawValueHandle = ctypes.POINTER(_RawValue) + if TYPE_CHECKING: + RawValueHandleTypeImpl = ctypes._Pointer[_RawValue] # noqa: SLF001 + + +MR_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.c_uint64, RawValueHandle) + - class MrCallback(Protocol): - def __call__( - self, callback_id: int, raw_val_handle: RawValueHandleType - ) -> None: ... +class MrCallback(Protocol): + def __call__( + self, callback_id: int, raw_val_handle: RawValueHandleType + ) -> None: ... class MrCallbackDecorator(Protocol): diff --git a/src/py_mini_racer/_exc.py b/src/py_mini_racer/_exc.py index 744757f7..4fa7dae3 100644 --- a/src/py_mini_racer/_exc.py +++ b/src/py_mini_racer/_exc.py @@ -34,3 +34,27 @@ class JSArrayIndexError(IndexError, MiniRacerBaseException): def __init__(self) -> None: super().__init__("JSArray deletion out of range") + + +class JSParseException(JSEvalException): + """JavaScript could not be parsed.""" + + +class JSKeyError(JSEvalException, KeyError): + """No such key found.""" + + +class JSOOMException(JSEvalException): + """JavaScript execution ran out of memory.""" + + +class JSTerminatedException(JSEvalException): + """JavaScript execution terminated.""" + + +class JSValueError(JSEvalException, ValueError): + """Bad value passed to JavaScript engine.""" + + +class JSConversionException(MiniRacerBaseException): + """Type could not be converted to or from JavaScript.""" diff --git a/src/py_mini_racer/_js_value_manipulator.py b/src/py_mini_racer/_js_value_manipulator.py new file mode 100644 index 00000000..ce4f2d42 --- /dev/null +++ b/src/py_mini_racer/_js_value_manipulator.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from py_mini_racer._types import ( + JSArray, + JSFunction, + JSObject, + JSPromise, + JSUndefined, + JSUndefinedType, + PythonJSConvertedTypes, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + from py_mini_racer._exc import JSEvalException + + +class JSValueManipulator(Protocol): + def get_identity_hash(self, obj: JSObject) -> int: ... + + def get_own_property_names( + self, obj: JSObject + ) -> tuple[PythonJSConvertedTypes, ...]: ... + + def get_object_item( + self, obj: JSObject, key: PythonJSConvertedTypes + ) -> PythonJSConvertedTypes: ... + + def set_object_item( + self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes + ) -> None: ... + + def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: ... + + def del_from_array(self, arr: JSArray, index: int) -> None: ... + + def array_insert( + self, arr: JSArray, index: int, new_val: PythonJSConvertedTypes + ) -> None: ... + + def array_push(self, arr: JSArray, new_val: PythonJSConvertedTypes) -> None: ... + + def call_function( + self, + func: JSFunction, + *args: PythonJSConvertedTypes, + this: JSObject | JSUndefinedType = JSUndefined, + timeout_sec: float | None = None, + ) -> PythonJSConvertedTypes: ... + + def js_to_py_callback( + self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] + ) -> AbstractContextManager[JSFunction]: ... + + def promise_then( + self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction + ) -> None: ... + + def evaluate( + self, code: str, timeout_sec: float | None = None + ) -> PythonJSConvertedTypes: ... diff --git a/src/py_mini_racer/_objects.py b/src/py_mini_racer/_objects.py index 98d7528e..4cea1df4 100644 --- a/src/py_mini_racer/_objects.py +++ b/src/py_mini_racer/_objects.py @@ -25,8 +25,9 @@ from asyncio import Future from collections.abc import Generator, Iterator - from py_mini_racer._abstract_context import AbstractContext, AbstractValueHandle from py_mini_racer._exc import JSEvalException + from py_mini_racer._js_value_manipulator import JSValueManipulator + from py_mini_racer._value_handle import ValueHandle def _get_exception_msg(reason: PythonJSConvertedTypes) -> str: @@ -42,15 +43,17 @@ def _get_exception_msg(reason: PythonJSConvertedTypes) -> str: class JSObjectImpl(JSObject): """A JavaScript object.""" - def __init__(self, ctx: AbstractContext, handle: AbstractValueHandle) -> None: - self._ctx = ctx + def __init__( + self, val_manipulator: JSValueManipulator, handle: ValueHandle + ) -> None: + self._val_manipulator = val_manipulator self._handle = handle def __hash__(self) -> int: - return self._ctx.get_identity_hash(self) + return self._val_manipulator.get_identity_hash(self) @property - def raw_handle(self) -> AbstractValueHandle: + def raw_handle(self) -> ValueHandle: return self._handle @@ -66,21 +69,21 @@ def __iter__(self) -> Iterator[PythonJSConvertedTypes]: return iter(self._get_own_property_names()) def __getitem__(self, key: PythonJSConvertedTypes) -> PythonJSConvertedTypes: - return self._ctx.get_object_item(self, key) + return self._val_manipulator.get_object_item(self, key) def __setitem__( self, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: - self._ctx.set_object_item(self, key, val) + self._val_manipulator.set_object_item(self, key, val) def __delitem__(self, key: PythonJSConvertedTypes) -> None: - self._ctx.del_object_item(self, key) + self._val_manipulator.del_object_item(self, key) def __len__(self) -> int: return len(self._get_own_property_names()) def _get_own_property_names(self) -> tuple[PythonJSConvertedTypes, ...]: - return self._ctx.get_own_property_names(self) + return self._val_manipulator.get_own_property_names(self) class JSArrayImpl(JSArray, JSObjectImpl): @@ -90,7 +93,7 @@ class JSArrayImpl(JSArray, JSObjectImpl): """ def __len__(self) -> int: - return cast("int", self._ctx.get_object_item(self, "length")) + return cast("int", self._val_manipulator.get_object_item(self, "length")) def __getitem__(self, index: int | slice) -> Any: # noqa: ANN401 if not isinstance(index, int): @@ -101,7 +104,7 @@ def __getitem__(self, index: int | slice) -> Any: # noqa: ANN401 index += len(self) if 0 <= index < len(self): - return self._ctx.get_object_item(self, index) + return self._val_manipulator.get_object_item(self, index) raise IndexError @@ -109,7 +112,7 @@ def __setitem__(self, index: int | slice, val: Any) -> None: # noqa: ANN401 if not isinstance(index, int): raise TypeError - self._ctx.set_object_item(self, index, val) + self._val_manipulator.set_object_item(self, index, val) def __delitem__(self, index: int | slice) -> None: if not isinstance(index, int): @@ -125,17 +128,17 @@ def __delitem__(self, index: int | slice) -> None: # bounds: raise JSArrayIndexError - self._ctx.del_from_array(self, index) + self._val_manipulator.del_from_array(self, index) def insert(self, index: int, new_obj: PythonJSConvertedTypes) -> None: - self._ctx.array_insert(self, index, new_obj) + self._val_manipulator.array_insert(self, index, new_obj) def __iter__(self) -> Iterator[PythonJSConvertedTypes]: for i in range(len(self)): - yield self._ctx.get_object_item(self, i) + yield self._val_manipulator.get_object_item(self, i) def append(self, value: PythonJSConvertedTypes) -> None: - self._ctx.array_push(self, value) + self._val_manipulator.array_push(self, value) class JSFunctionImpl(JSMappedObjectImpl, JSFunction): @@ -151,7 +154,9 @@ def __call__( this: JSObject | JSUndefinedType = JSUndefined, timeout_sec: float | None = None, ) -> PythonJSConvertedTypes: - return self._ctx.call_function(self, *args, this=this, timeout_sec=timeout_sec) + return self._val_manipulator.call_function( + self, *args, this=this, timeout_sec=timeout_sec + ) class JSSymbolImpl(JSMappedObjectImpl, JSSymbol): @@ -186,10 +191,12 @@ def on_rejected(value: PythonJSConvertedTypes | JSEvalException) -> None: future.set_result(cast("JSArray", 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._val_manipulator.js_to_py_callback(on_resolved) as on_resolved_js_func, + self._val_manipulator.js_to_py_callback(on_rejected) as on_rejected_js_func, ): - self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) + self._val_manipulator.promise_then( + self, on_resolved_js_func, on_rejected_js_func + ) result = future.result(timeout=timeout) @@ -205,24 +212,22 @@ def __await__(self) -> Generator[Any, None, Any]: 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_resolved(value: PythonJSConvertedTypes) -> None: + future.set_result(value) - async def on_rejected(value: PythonJSConvertedTypes | JSEvalException) -> None: - future.set_exception( - JSPromiseError( - _get_exception_msg(cast("PythonJSConvertedTypes", value)) - ) - ) + async def on_rejected(value: PythonJSConvertedTypes) -> None: + future.set_exception(JSPromiseError(_get_exception_msg(value))) async with ( wrap_py_function_as_js_function( - self._ctx, on_resolved + self._val_manipulator, on_resolved ) as on_resolved_js_func, wrap_py_function_as_js_function( - self._ctx, on_rejected + self._val_manipulator, on_rejected ) as on_rejected_js_func, ): - self._ctx.promise_then(self, on_resolved_js_func, on_rejected_js_func) + self._val_manipulator.promise_then( + self, on_resolved_js_func, on_rejected_js_func + ) return await future diff --git a/src/py_mini_racer/_types.py b/src/py_mini_racer/_types.py index a2ae9ffc..af0d73d9 100644 --- a/src/py_mini_racer/_types.py +++ b/src/py_mini_racer/_types.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC from collections.abc import ( Awaitable, Callable, @@ -30,7 +29,7 @@ def __repr__(self) -> str: JSUndefined = JSUndefinedType() -class JSObject(ABC): # noqa: B024 +class JSObject: """A JavaScript object.""" @@ -107,4 +106,5 @@ async def _do_await(self) -> PythonJSConvertedTypes: | None ) + PyJsFunctionType = Callable[..., Awaitable[PythonJSConvertedTypes]] diff --git a/src/py_mini_racer/_value_handle.py b/src/py_mini_racer/_value_handle.py index 93e3c9ef..719fb6e7 100644 --- a/src/py_mini_racer/_value_handle.py +++ b/src/py_mini_racer/_value_handle.py @@ -1,138 +1,14 @@ from __future__ import annotations -import ctypes -from datetime import datetime, timezone -from typing import TYPE_CHECKING, ClassVar - -from py_mini_racer._abstract_context import AbstractContext, AbstractValueHandle -from py_mini_racer._exc import JSEvalException, MiniRacerBaseException -from py_mini_racer._objects import ( - JSArrayImpl, - JSFunctionImpl, - JSMappedObjectImpl, - JSObjectImpl, - JSPromiseImpl, - JSSymbolImpl, -) -from py_mini_racer._types import JSUndefined, PythonJSConvertedTypes +from typing import TYPE_CHECKING, NewType if TYPE_CHECKING: - from collections.abc import Sequence - - -class _RawValueUnion(ctypes.Union): - _fields_: ClassVar[Sequence[tuple[str, type]]] = [ - ("value_ptr", ctypes.c_void_p), - ("bytes_val", ctypes.POINTER(ctypes.c_char)), - ("char_p_val", ctypes.c_char_p), - ("int_val", ctypes.c_int64), - ("double_val", ctypes.c_double), - ] - - -class _RawValue(ctypes.Structure): - _fields_: ClassVar[Sequence[tuple[str, type]]] = [ - ("value", _RawValueUnion), - ("len", ctypes.c_size_t), - ("type", ctypes.c_uint8), - ] - _pack_ = 1 - - -RawValueHandle = ctypes.POINTER(_RawValue) - -if TYPE_CHECKING: - RawValueHandleType = ctypes._Pointer[_RawValue] # noqa: SLF001 - - -class _ArrayBufferByte(ctypes.Structure): - # Cannot use c_ubyte directly because it uses None: - self.ctx = ctx + def __init__(self, free: Callable[[], None], raw: RawValueHandleType) -> None: + self._free = free self._raw = raw def __del__(self) -> None: - self.ctx.free(self) + self._free() @property def raw(self) -> RawValueHandleType: return self._raw - - def to_python_or_raise(self) -> PythonJSConvertedTypes: - val = self.to_python() - if isinstance(val, JSEvalException): - raise val - return val - - def to_python(self) -> PythonJSConvertedTypes | JSEvalException: # noqa: C901, PLR0911, PLR0912 - """Convert a binary value handle from the C++ side into a Python object.""" - - # A MiniRacer binary value handle is a pointer to a structure which, for some - # simple types like ints, floats, and strings, is sufficient to describe the - # data, enabling us to convert the value immediately and free the handle. - - # For more complex types, like Objects and Arrays, the handle is just an opaque - # pointer to a V8 object. In these cases, we retain the binary value handle, - # wrapping it in a Python object. We can then use the handle in follow-on API - # calls to work with the underlying V8 object. - - # In either case the handle is owned by the C++ side. It's the responsibility - # of the Python side to call mr_free_value() when done with with the handle - # to free up memory, but the C++ side will eventually free it on context - # teardown either way. - - typ = self._raw.contents.type - val = self._raw.contents.value - length = self._raw.contents.len - - error_info = _ERRORS.get(self._raw.contents.type) - if error_info: - klass, generic_msg = error_info - - return klass(val.bytes_val[0:length].decode("utf-8") or generic_msg) - - if typ == MiniRacerTypes.null: - return None - if typ == MiniRacerTypes.undefined: - return JSUndefined - if typ == MiniRacerTypes.bool: - return bool(val.int_val == 1) - if typ == MiniRacerTypes.integer: - return int(val.int_val) - if typ == MiniRacerTypes.double: - return float(val.double_val) - if typ == MiniRacerTypes.str_utf8: - return str(val.bytes_val[0:length].decode("utf-8")) - if typ == MiniRacerTypes.function: - return JSFunctionImpl(self.ctx, self) - if typ == MiniRacerTypes.date: - timestamp = val.double_val - # JS timestamps are milliseconds. In Python we are in seconds: - return datetime.fromtimestamp(timestamp / 1000.0, timezone.utc) - if typ == MiniRacerTypes.symbol: - return JSSymbolImpl(self.ctx, self) - if typ in (MiniRacerTypes.shared_array_buffer, MiniRacerTypes.array_buffer): - buf = _ArrayBufferByte * length - cdata = buf.from_address(val.value_ptr) - # Save a reference to ourselves to prevent garbage collection of the - # backing store: - cdata._origin = self # noqa: SLF001 - result = memoryview(cdata) - # Avoids "NotImplementedError: memoryview: unsupported format T{ AbstractValueHandle: - if isinstance(obj, JSObjectImpl): - # JSObjects originate from the V8 side. We can just send back the handle - # we originally got. (This also covers derived types JSFunction, JSSymbol, - # JSPromise, and JSArray.) - return obj.raw_handle - - if obj is None: - return context.create_intish_val(0, MiniRacerTypes.null) - if obj is JSUndefined: - return context.create_intish_val(0, MiniRacerTypes.undefined) - if isinstance(obj, bool): - return context.create_intish_val(1 if obj else 0, MiniRacerTypes.bool) - if isinstance(obj, int): - if obj - 2**31 <= obj < 2**31: - return context.create_intish_val(obj, MiniRacerTypes.integer) - - # We transmit ints as int32, so "upgrade" to double upon overflow. - # (ECMAScript numeric is double anyway, but V8 does internally distinguish - # int types, so we try and preserve integer-ness for round-tripping - # purposes.) - # JS BigInt would be a closer representation of Python int, but upgrading - # to BigInt would probably be surprising for most applications, so for now, - # we approximate with double: - return context.create_doublish_val(obj, MiniRacerTypes.double) - if isinstance(obj, float): - return context.create_doublish_val(obj, MiniRacerTypes.double) - if isinstance(obj, str): - return context.create_string_val(obj, MiniRacerTypes.str_utf8) - 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 - ) - - # Note: we skip shared array buffers, so for now at least, handles to shared - # array buffers can only be transmitted from JS to Python. - - raise JSConversionException diff --git a/src/py_mini_racer/_wrap_py_function.py b/src/py_mini_racer/_wrap_py_function.py index f2132379..85045fa1 100644 --- a/src/py_mini_racer/_wrap_py_function.py +++ b/src/py_mini_racer/_wrap_py_function.py @@ -9,8 +9,8 @@ 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._js_value_manipulator import JSValueManipulator from py_mini_racer._types import ( JSArray, JSFunction, @@ -21,7 +21,7 @@ @asynccontextmanager async def wrap_py_function_as_js_function( - context: AbstractContext, func: PyJsFunctionType + context: JSValueManipulator, func: PyJsFunctionType ) -> AsyncGenerator[JSFunction, None]: with context.js_to_py_callback( _JsToPyCallbackProcessor( @@ -64,7 +64,7 @@ class _JsToPyCallbackProcessor: loop.""" _py_func: PyJsFunctionType - _context: AbstractContext + _val_manipulator: JSValueManipulator _loop: asyncio.AbstractEventLoop _ongoing_callbacks: set[asyncio.Task[PythonJSConvertedTypes | JSEvalException]] = ( field(default_factory=set) @@ -83,7 +83,7 @@ async def await_into_js_promise_resolvers( # 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)") + "JSFunction", self._val_manipulator.evaluate("s => new Error(s)") ) reject(err_maker(f"Error running Python function:\n{format_exc()}")) diff --git a/tests/test_strict.py b/tests/test_strict.py index c3d4f9f0..7ebbbc85 100644 --- a/tests/test_strict.py +++ b/tests/test_strict.py @@ -1,7 +1,5 @@ from __future__ import annotations -from traceback import clear_frames - import pytest from py_mini_racer import JSEvalException, JSUndefined, StrictMiniRacer @@ -54,5 +52,5 @@ def test_message() -> None: with pytest.raises(JSEvalException) as exc_info: mr.eval("throw new EvalError('Hello', 'someFile.js', 10);") - clear_frames(exc_info.tb) + del exc_info assert_no_v8_objects(mr)