diff --git a/Makefile b/Makefile index fc526d7..eb8e28e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ .DEFAULT_GOAL := all sources = python/jsrun tests +# Pinned so lint results stay reproducible; bump deliberately. +ruff_version = 0.16.10 # using pip install cargo (via maturin via pip) doesn't get the tty handle # so doesn't render color without some help @@ -37,18 +39,18 @@ build-profiling: .PHONY: format ## Auto-format rust and python source files format: - uv tool run ruff format $(sources) + uv tool run ruff@$(ruff_version) format $(sources) cargo fmt .PHONY: lint-python ## Lint python source files lint-python: - uv tool run ruff check $(sources) - uv tool run ruff format --check $(sources) + uv tool run ruff@$(ruff_version) check $(sources) + uv tool run ruff@$(ruff_version) format --check $(sources) .PHONY: lint-python-fix ## Auto-fix python linting issues lint-python-fix: - uv tool run ruff check --fix $(sources) - uv tool run ruff format $(sources) + uv tool run ruff@$(ruff_version) check --fix $(sources) + uv tool run ruff@$(ruff_version) format $(sources) .PHONY: lint-rust ## Lint rust source files lint-rust: diff --git a/python/jsrun/__init__.py b/python/jsrun/__init__.py index e426614..df1f5e1 100644 --- a/python/jsrun/__init__.py +++ b/python/jsrun/__init__.py @@ -1,11 +1,11 @@ """High-level Python bindings for the jsrun runtime.""" -import contextvars import asyncio import atexit +import contextvars import threading -from dataclasses import dataclass from collections.abc import Callable +from dataclasses import dataclass from typing import Any, TypeVar, cast, overload from ._jsrun import ( @@ -92,7 +92,7 @@ def _schedule_owner_cleanup(slot: _RuntimeSlot) -> None: owner.add_done_callback(lambda _: slot.close()) -setattr(Runtime, "bind", _runtime_bind) +Runtime.bind = _runtime_bind _default_runtime_var: contextvars.ContextVar[_RuntimeSlot | None] = ( @@ -234,22 +234,22 @@ def bind_object(name: str, obj: dict) -> None: __all__ = [ - "eval", - "eval_async", - "get_default_runtime", - "close_default_runtime", - "bind_function", - "bind_object", - "Runtime", - "RuntimeConfig", "InspectorConfig", "InspectorEndpoints", - "SnapshotBuilder", + "JavaScriptError", "JsFunction", + "JsStream", "JsUndefined", + "Runtime", + "RuntimeConfig", "RuntimeStats", - "JavaScriptError", "RuntimeTerminated", + "SnapshotBuilder", + "bind_function", + "bind_object", + "close_default_runtime", + "eval", + "eval_async", + "get_default_runtime", "undefined", - "JsStream", ] diff --git a/python/jsrun/__main__.py b/python/jsrun/__main__.py index 5cb5070..74b75a6 100644 --- a/python/jsrun/__main__.py +++ b/python/jsrun/__main__.py @@ -54,7 +54,7 @@ def main() -> None: except JavaScriptError as e: print(f"JavaScript Error: {e}", file=sys.stderr) sys.exit(1) - except Exception as e: + except Exception as e: # noqa: BLE001 - CLI entrypoint reports any failure print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/python/jsrun/_jsrun.pyi b/python/jsrun/_jsrun.pyi index 94da781..1eff2d1 100644 --- a/python/jsrun/_jsrun.pyi +++ b/python/jsrun/_jsrun.pyi @@ -3,13 +3,13 @@ Type stubs for the jsrun Python extension module. """ import types -from datetime import timedelta from collections.abc import AsyncIterable, Awaitable, Callable, Mapping +from datetime import timedelta from typing import ( Any, Self, - TypeVar, TypedDict, + TypeVar, overload, ) @@ -17,18 +17,18 @@ __all__ = [ "InspectorConfig", "InspectorEndpoints", "JavaScriptError", - "Runtime", - "RuntimeConfig", - "RuntimeStats", "JsFunction", "JsStream", "JsUndefined", + "Runtime", + "RuntimeConfig", + "RuntimeStats", "RuntimeTerminated", "SnapshotBuilder", "undefined", ] -F = TypeVar("F", bound=Callable[..., Any]) +_F = TypeVar("_F", bound=Callable[..., Any]) # Core runtime types @@ -58,7 +58,6 @@ class InspectorConfig: target_url: Optional string reported to DevTools for the inspected target display_name: Optional display title surfaced in ``chrome://inspect`` """ - ... @property def host(self) -> str: ... @@ -82,9 +81,6 @@ class InspectorConfig: def display_name(self, value: str | None) -> None: ... def endpoint(self) -> str: """Return the ``host:port`` pair that DevTools should connect to.""" - ... - - def __repr__(self) -> str: ... class RuntimeConfig: """ @@ -99,7 +95,7 @@ class RuntimeConfig: max_heap_size: int | None = None, initial_heap_size: int | None = None, bootstrap: str | None = None, - timeout: float | int | None = None, + timeout: float | None = None, enable_console: bool | None = False, inspector: InspectorConfig | None = None, snapshot: bytes | None = None, @@ -120,89 +116,71 @@ class RuntimeConfig: max_serialization_depth: Maximum nesting depth when transferring values max_serialization_bytes: Maximum serialized byte size when transferring values """ - ... @property def max_heap_size(self) -> int | None: """Maximum heap size in bytes.""" - ... @max_heap_size.setter def max_heap_size(self, bytes: int) -> None: """Set maximum heap size in bytes.""" - ... @property def initial_heap_size(self) -> int | None: """Initial heap size in bytes.""" - ... @initial_heap_size.setter def initial_heap_size(self, bytes: int) -> None: """Set initial heap size in bytes.""" - ... @property def bootstrap(self) -> str | None: """Bootstrap script to execute on runtime startup.""" - ... @bootstrap.setter def bootstrap(self, source: str) -> None: """Set bootstrap script to execute on runtime startup.""" - ... @property def timeout(self) -> float | None: """Execution timeout in seconds.""" - ... @timeout.setter - def timeout(self, timeout: float | int) -> None: + def timeout(self, timeout: float) -> None: """ Set execution timeout. Args: timeout: Timeout in seconds (float or int) """ - ... @property def enable_console(self) -> bool | None: """Whether ``console`` APIs are enabled inside the runtime.""" - ... @property def inspector(self) -> InspectorConfig | None: """Inspector configuration if debugging is enabled.""" - ... @inspector.setter def inspector(self, value: InspectorConfig | None) -> None: """Set or clear the inspector configuration.""" - ... @property def max_serialization_depth(self) -> int: """Maximum recursion depth allowed when serializing values.""" - ... @max_serialization_depth.setter def max_serialization_depth(self, value: int) -> None: """Set the maximum recursion depth allowed when serializing values.""" - ... @property def max_serialization_bytes(self) -> int: """Maximum byte size allowed when serializing values.""" - ... @max_serialization_bytes.setter def max_serialization_bytes(self, value: int) -> None: """Set the maximum byte size allowed when serializing values.""" - ... - - def __repr__(self) -> str: ... class RuntimeStats: """ @@ -241,119 +219,94 @@ class RuntimeStats: @property def heap_total_bytes(self) -> int: """Total heap size in bytes allocated by V8.""" - ... @property def heap_used_bytes(self) -> int: """Currently used heap memory in bytes.""" - ... @property def external_memory_bytes(self) -> int: """External memory tracked by V8 (e.g., ArrayBuffers).""" - ... @property def physical_total_bytes(self) -> int: """Physical memory in bytes (RSS).""" - ... @property def total_execution_time_ms(self) -> int: """Cumulative execution time in milliseconds.""" - ... @property def last_execution_time_ms(self) -> int: """Duration of the most recent execution in milliseconds.""" - ... @property def last_execution_kind(self) -> str | None: """Type of last operation (e.g., "eval_async", "call_function_sync").""" - ... @property def eval_sync_count(self) -> int: """Number of synchronous eval operations.""" - ... @property def eval_async_count(self) -> int: """Number of asynchronous eval operations.""" - ... @property def eval_module_sync_count(self) -> int: """Number of synchronous module evaluations.""" - ... @property def eval_module_async_count(self) -> int: """Number of asynchronous module evaluations.""" - ... @property def call_function_async_count(self) -> int: """Number of asynchronous function calls.""" - ... @property def call_function_sync_count(self) -> int: """Number of synchronous function calls.""" - ... @property def active_async_ops(self) -> int: """Currently active async operations.""" - ... @property def open_resources(self) -> int: """Number of open resources (timers, streams, etc.).""" - ... @property def active_timers(self) -> int: """Active setTimeout timers.""" - ... @property def active_intervals(self) -> int: """Active setInterval timers.""" - ... @property def active_js_streams(self) -> int: """Active JavaScript ReadableStreams exposed to Python.""" - ... @property def active_py_streams(self) -> int: """Active Python async iterables exposed to JavaScript.""" - ... @property def total_js_streams(self) -> int: """Total JavaScript streams created.""" - ... @property def total_py_streams(self) -> int: """Total Python streams created.""" - ... @property def bytes_streamed_js_to_py(self) -> int: """Total bytes transferred from JavaScript to Python.""" - ... @property def bytes_streamed_py_to_js(self) -> int: """Total bytes transferred from Python to JavaScript.""" - ... - - def __repr__(self) -> str: ... class InspectorEndpoints: """ @@ -379,8 +332,6 @@ class InspectorEndpoints: favicon_url: str host: str - def __repr__(self) -> str: ... - class JsFunction: """ Proxy for a JavaScript function returned from the runtime. @@ -391,7 +342,7 @@ class JsFunction: """ def __call__( - self, *args: Any, timeout: float | int | timedelta | None = ... + self, *args: Any, timeout: float | timedelta | None = ... ) -> Any | Awaitable[Any]: """ Invoke the JavaScript function with the provided arguments. If the JS @@ -405,16 +356,14 @@ class JsFunction: Returns: Either the JavaScript return value or an awaitable resolving to it. """ - ... def call_async( - self, *args: Any, timeout: float | int | timedelta | None = ... + self, *args: Any, timeout: float | timedelta | None = ... ) -> Awaitable[Any]: """ Always invoke the JavaScript function asynchronously, returning an awaitable regardless of whether the underlying JS completes synchronously. """ - ... def close(self) -> Awaitable[None]: """ @@ -422,9 +371,6 @@ class JsFunction: After closing, the proxy can no longer be awaited. """ - ... - - def __repr__(self) -> str: ... class JsStream: """ @@ -440,13 +386,9 @@ class JsStream: StopAsyncIteration: When the stream finishes. JavaScriptError: If the stream reader errors. """ - ... def close(self) -> None: """Cancel the stream and release its runtime resources.""" - ... - - def __repr__(self) -> str: ... class PyStreamSource: """ @@ -454,7 +396,6 @@ class PyStreamSource: """ def close(self) -> None: ... - def __repr__(self) -> str: ... class JsUndefined: """ @@ -462,8 +403,6 @@ class JsUndefined: """ def __bool__(self) -> bool: ... - def __repr__(self) -> str: ... - def __str__(self) -> str: ... undefined: JsUndefined @@ -506,15 +445,9 @@ class JavaScriptError(Exception): stack: str | None frames: list[JsFrame] - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - class RuntimeTerminated(RuntimeError): """Raised when JavaScript execution is aborted by Runtime.terminate().""" - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - class Runtime: """ JavaScript runtime. @@ -534,7 +467,6 @@ class Runtime: config: Optional :class:`RuntimeConfig` to customize heap limits, bootstrap scripts, inspector, etc. """ - ... def eval(self, code: str) -> Any: """ Evaluate JavaScript code synchronously. @@ -560,10 +492,9 @@ class Runtime: 2 ``` """ - ... async def eval_async( - self, code: str, *, timeout: float | int | timedelta | None = None + self, code: str, *, timeout: float | timedelta | None = None ) -> Any: """ Evaluate JavaScript code asynchronously. @@ -590,7 +521,6 @@ class Runtime: 42 ``` """ - ... def register_op( self, @@ -628,7 +558,6 @@ class Runtime: >>> # From JavaScript: __host_op_sync__(op_id, 10, 20) # Returns 30 ``` """ - ... def is_closed(self) -> bool: """ @@ -637,7 +566,6 @@ class Runtime: Returns: True if the runtime is closed, False otherwise """ - ... def get_stats(self) -> RuntimeStats: """ @@ -646,7 +574,6 @@ class Runtime: Returns: RuntimeStats: Structured metrics describing the runtime state. """ - ... def inspector_endpoints(self) -> InspectorEndpoints | None: """ @@ -656,7 +583,6 @@ class Runtime: InspectorEndpoints: describing websocket and devtools:// URLs, or \ ``None`` when the runtime was created without inspector support. """ - ... def close(self) -> None: """ @@ -666,7 +592,6 @@ class Runtime: This method is called automatically when using the runtime as a context manager. """ - ... def terminate(self) -> None: """ @@ -675,7 +600,6 @@ class Runtime: Must be invoked from the same thread that owns the runtime. After termination, subsequent operations raise ``RuntimeTerminated``. """ - ... def bind_function( self, @@ -705,7 +629,6 @@ class Runtime: 3 ``` """ - ... def stream_from_async_iterable( self, iterable: AsyncIterable[Any] @@ -719,19 +642,16 @@ class Runtime: Returns: Handle that can be passed into JavaScript and consumed via stream readers. """ - ... @overload - def bind(self, handler: F, /, *, name: str | None = ...) -> F: + def bind(self, handler: _F, /, *, name: str | None = ...) -> _F: """Bind a synchronous or asynchronous callable to ``globalThis``.""" - ... @overload def bind( self, handler: None = ..., /, *, name: str | None = ... - ) -> Callable[[F], F]: + ) -> Callable[[_F], _F]: """Return a decorator for binding sync or async callables to ``globalThis``.""" - ... def bind_object(self, name: str, obj: Mapping[str, Any]) -> None: """ @@ -756,7 +676,6 @@ class Runtime: 42 ``` """ - ... def set_module_resolver(self, resolver: Callable[[str, str], str | None]) -> None: """ @@ -783,7 +702,6 @@ class Runtime: >>> runtime.set_module_resolver(my_resolver) ``` """ - ... def set_module_loader(self, loader: Callable[[str], Any]) -> None: """ @@ -811,7 +729,6 @@ class Runtime: >>> runtime.set_module_loader(my_loader) ``` """ - ... def add_static_module(self, name: str, source: str) -> None: """ @@ -832,7 +749,6 @@ class Runtime: 42 ``` """ - ... def eval_module(self, specifier: str) -> Any: """ @@ -859,10 +775,9 @@ class Runtime: 42 ``` """ - ... async def eval_module_async( - self, specifier: str, *, timeout: float | int | timedelta | None = None + self, specifier: str, *, timeout: float | timedelta | None = None ) -> Any: """ Evaluate a JavaScript module asynchronously. @@ -889,11 +804,9 @@ class Runtime: 42 ``` """ - ... def __enter__(self) -> Self: """Context manager entry - returns self.""" - ... def __exit__( self, @@ -902,9 +815,6 @@ class Runtime: exc_tb: types.TracebackType | None, ) -> bool: """Context manager exit - closes the runtime.""" - ... - - def __repr__(self) -> str: ... class SnapshotBuilder: """ @@ -928,7 +838,6 @@ class SnapshotBuilder: enable_console: Whether ``console`` APIs remain available while preparing the snapshot. Defaults to ``False`` (console is disabled by default). """ - ... def execute_script(self, name: str, source: str) -> None: """ @@ -941,10 +850,8 @@ class SnapshotBuilder: rejected. Wrap CommonJS bundles or other globals in an IIFE before calling this method so only plain script statements reach V8. """ - ... def build(self) -> bytes: """ Finalize the snapshot and return its serialized bytes. """ - ... diff --git a/src/runtime/error.rs b/src/runtime/error.rs index da3dcaf..1c7e056 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -200,6 +200,7 @@ mod tests { is_async: false, is_promise_all: false, promise_index: None, + is_wasm: false, } } @@ -208,6 +209,7 @@ mod tests { name: Some("TypeError".to_string()), message: Some("boom".to_string()), stack: Some("TypeError: boom\n at :1:1".to_string()), + stack_is_custom: false, cause: None, exception_message: "Uncaught TypeError: boom".to_string(), frames: vec![sample_js_frame()], diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index df972cb..0dcc92d 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -2654,6 +2654,9 @@ impl RuntimeCoreState { Js(JsError), } + // Function-local error enum on a cold path; boxing JsError here is + // not worth the indirection. + #[allow(clippy::result_large_err)] let call_outcome: Result = (|| { deno_core::scope!(scope, self.js_runtime); v8::tc_scope!(let try_catch, scope); diff --git a/tests/test_jsrun_api.py b/tests/test_jsrun_api.py index 20cfbdc..6bdc902 100644 --- a/tests/test_jsrun_api.py +++ b/tests/test_jsrun_api.py @@ -4,9 +4,8 @@ import contextvars from concurrent.futures import ThreadPoolExecutor -import pytest - import jsrun +import pytest class TestModuleLevelEval: diff --git a/tests/test_modules.py b/tests/test_modules.py index b6dd3c6..8ac94ce 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -245,7 +245,7 @@ def test_module_syntax_error(self): """Test that module syntax errors are reported.""" with Runtime() as rt: rt.add_static_module("bad", "export const x = ;") # Syntax error - with pytest.raises(Exception): + with pytest.raises(RuntimeError): rt.eval_module("bad") def test_async_loader_with_sync_eval_fails(self): diff --git a/tests/test_ops.py b/tests/test_ops.py index 5d52c3c..7ba2d92 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -287,7 +287,6 @@ def test_sync_op_receives_uint8array(self): def capture(value): captured.append(value) - return None op_id = runtime.register_op("captureBytes", capture, mode="sync") runtime.eval(f"__host_op_sync__({op_id}, new Uint8Array([1, 2, 3]))") @@ -317,7 +316,6 @@ def test_sync_op_receives_date(self): def capture(value): captured.append(value) - return None op_id = runtime.register_op("captureDate", capture, mode="sync") runtime.eval(f"__host_op_sync__({op_id}, new Date(1704067200000))") @@ -348,7 +346,6 @@ def test_sync_op_receives_set(self): def capture(value): captured.append(value) - return None op_id = runtime.register_op("captureSet", capture, mode="sync") runtime.eval(f"__host_op_sync__({op_id}, new Set([1, 2, 3]))") @@ -378,7 +375,6 @@ def test_sync_op_receives_bigint(self): def capture(value): captured.append(value) - return None op_id = runtime.register_op("captureBigInt", capture, mode="sync") runtime.eval(f"__host_op_sync__({op_id}, 2n ** 64n)") @@ -407,7 +403,6 @@ def test_sync_op_receives_undefined(self): def capture(value): captured.append(value) - return None op_id = runtime.register_op("captureUndefined", capture, mode="sync") runtime.eval(f"__host_op_sync__({op_id}, undefined)") @@ -565,9 +560,9 @@ def record(*args): return len(audit_log) op_id = runtime.register_op("record", record) - count = runtime.eval("__host_op_sync__({0}, 'a', 'b')".format(op_id)) + count = runtime.eval(f"__host_op_sync__({op_id}, 'a', 'b')") assert count == 1 - count = runtime.eval("__host_op_sync__({0}, 'c')".format(op_id)) + count = runtime.eval(f"__host_op_sync__({op_id}, 'c')") assert count == 2 assert audit_log == [("a", "b"), ("c",)] finally: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 4fc1dd4..8d0db40 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -600,20 +600,22 @@ def test_python_to_js_respects_serialization_depth_limit(self): def test_js_to_python_respects_serialization_bytes_limit(self): config = RuntimeConfig(max_serialization_bytes=32) - with Runtime(config) as runtime: - with pytest.raises( + with ( + Runtime(config) as runtime, + pytest.raises( RuntimeError, match=r"Size \(\d+ bytes\) exceeded maximum limit of \d+ bytes", - ): - runtime.eval("'x'.repeat(64)") + ), + ): + runtime.eval("'x'.repeat(64)") def test_js_to_python_respects_serialization_depth_limit(self): config = RuntimeConfig(max_serialization_depth=2) - with Runtime(config) as runtime: - with pytest.raises( - RuntimeError, match=r"Depth exceeded maximum limit of \d+" - ): - runtime.eval("({a: {b: {c: 1}}})") + with ( + Runtime(config) as runtime, + pytest.raises(RuntimeError, match=r"Depth exceeded maximum limit of \d+"), + ): + runtime.eval("({a: {b: {c: 1}}})") class TestRuntimeBindings: @@ -872,7 +874,7 @@ def run_with_timeout(name: str, timeout: float, code: str): config = RuntimeConfig(timeout=timeout) with Runtime(config) as rt: results[name] = rt.eval(code) - except Exception as e: + except Exception as e: # noqa: BLE001 - collect any failure per thread errors[name] = str(e) # Fast operation with short timeout @@ -984,9 +986,8 @@ class TestTimeoutValidation: @pytest.mark.asyncio async def test_eval_async_timeout_validation(self, timeout_value, expected_error): """Test eval_async rejects invalid timeout values.""" - with Runtime() as runtime: - with pytest.raises(ValueError, match=expected_error): - await runtime.eval_async("Promise.resolve(1)", timeout=timeout_value) + with Runtime() as runtime, pytest.raises(ValueError, match=expected_error): + await runtime.eval_async("Promise.resolve(1)", timeout=timeout_value) @pytest.mark.parametrize( "timeout_value,expected_error",