Repository: pyodide/pyodide

Issue:
Wrong typing for `create_proxy` and `create_once_callable`
## 🐛 Bug

`create_proxy` is often used to register a callback to a JavaScript instance, like `addeventlistener` or `some_promise.then`, and `create_once_callable` is often used to decorate single-use functions.

So I think their biggest usage is **to decorate callables**. But the typing is wrong. A callable decorated with it lose all of its typing signatures, makes type checkers keep throwing false positive errors.

### To Reproduce

```py
from typing import reveal_type
from pyodide.ffi import create_once_callable

@create_once_callable
def f(x: int) -> int:
    return x

a = f(123)

reveal_type(a)
```

```sh
> mypy ./test.py
error: Too many arguments for "__call__"  [call-arg]
test.py:13: note: Revealed type is "Any"

> pyright ./test.py
error: Expected 0 positional arguments (reportCallIssue)
information: Type of "a" is "Unknown"
```

### Expected behavior

1. No error
2. The revealed type should be `int`

### Environment

- Pyodide Version<!-- (e.g. 0.25.0) -->: `0.26.0`
- Browser version<!-- (e.g. Chrome 122.0.4638.54) -->: none
- Any other relevant information: none

### Additional context

I think it is easy to solve this by generic typing. Also since pyodide is already using 3.12 (and pyodide-py has a `requires-python = ">=3.12"`), we can use python3.12's new type syntax to do this elegantly.

<details><summary>Like this</summary>
<p>

```py
from typing import Callable

class JsCallable[**P, T](JsProxy):
    def __init__(self, obj: Callable[P, T]): ...
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
```

Instead of

```py
from typing import Callable, Generic, ParamSpec, TypeVar

P = ParamSpec("P")
T = TypeVar("T")

class JsCallable(JsProxy, Generic(P, T)):
    def __init__(self, obj: Callable[P, T]): ...
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
```

</p>
</details> 

But I just noticed that **PEP 695 generics** are not yet supported by `mypy` yet, so maybe I shouldn't use it.



Discussion:

Resolving patch:
diff --git a/src/py/_pyodide/_core_docs.py b/src/py/_pyodide/_core_docs.py
--- a/src/py/_pyodide/_core_docs.py
+++ b/src/py/_pyodide/_core_docs.py
@@ -17,7 +17,7 @@
 )
 from functools import reduce
 from types import TracebackType
-from typing import IO, Any, Generic, Protocol, TypeVar, overload
+from typing import IO, Any, Generic, ParamSpec, Protocol, TypeVar, overload
 
 from .docs_argspec import docs_argspec
 
@@ -33,6 +33,7 @@
 _save_name = __name__
 __name__ = ""
 
+P = ParamSpec("P")
 T = TypeVar("T")
 S = TypeVar("S")
 KT = TypeVar("KT")  # Key type.
@@ -346,7 +347,7 @@ def default_converter(value, convert, cache):
         raise NotImplementedError
 
 
-class JsDoubleProxy(JsProxy):
+class JsDoubleProxy(JsProxy, Generic[T]):
     """A double proxy created with :py:func:`create_proxy`."""
 
     _js_type_flags = ["IS_DOUBLE_PROXY"]
@@ -355,7 +356,7 @@ def destroy(self) -> None:
         """Destroy the proxy."""
         pass
 
-    def unwrap(self) -> Any:
+    def unwrap(self) -> T:
         """Unwrap a double proxy created with :py:func:`create_proxy` into the
         wrapped Python object.
         """
@@ -843,11 +844,15 @@ def aclose(self) -> Awaitable[None]:
         raise NotImplementedError
 
 
-class JsCallable(JsProxy):
+class JsCallable(JsProxy, Generic[P, T]):
     _js_type_flags = ["IS_CALLABLE"]
 
-    def __call__(self):
-        pass
+    __call__: Callable[P, T]
+
+
+class JsCallableDoubleProxy(
+    JsDoubleProxy[Callable[P, T]], JsCallable[P, T], Generic[P, T]
+): ...
 
 
 class JsArray(JsIterable[T], Generic[T], MutableSequence[T], metaclass=_ABCMeta):
@@ -1113,7 +1118,7 @@ def __delitem__(self, idx: KT) -> None:
         return None
 
 
-class JsOnceCallable(JsCallable):
+class JsOnceCallable(JsCallable[P, T], Generic[P, T]):
     def destroy(self):
         pass
 
@@ -1198,20 +1203,32 @@ def style(self) -> Any:
 
 @docs_argspec("(obj: Callable[..., Any], /) -> JsOnceCallable")
 def create_once_callable(
-    obj: Callable[..., Any], /, *, _may_syncify: bool = False
-) -> JsOnceCallable:
+    obj: Callable[P, T], /, *, _may_syncify: bool = False
+) -> JsOnceCallable[P, T]:
     """Wrap a Python Callable in a JavaScript function that can be called once.
 
     After being called the proxy will decrement the reference count
     of the Callable. The JavaScript function also has a ``destroy`` API that
     can be used to release the proxy without calling it.
     """
-    return obj  # type: ignore[return-value]
+    return obj  # type:ignore[return-value]
+
+
+@overload
+def create_proxy(
+    obj: Callable[P, T], /, *, capture_this: bool = False, roundtrip: bool = True
+) -> JsCallableDoubleProxy[P, T]: ...
+
+
+@overload
+def create_proxy(
+    obj: T, /, *, capture_this: bool = False, roundtrip: bool = True
+) -> JsDoubleProxy[T]: ...
 
 
 def create_proxy(
-    obj: Any, /, *, capture_this: bool = False, roundtrip: bool = True
-) -> JsDoubleProxy:
+    obj: T, /, *, capture_this: bool = False, roundtrip: bool = True
+) -> JsDoubleProxy[T]:
     """Create a :py:class:`JsProxy` of a :js:class:`~pyodide.ffi.PyProxy`.
 
     This allows explicit control over the lifetime of the
@@ -1244,7 +1261,7 @@ def create_proxy(
 
         With ``roundtrip=False`` this would be an error.
     """
-    return obj
+    return obj  # type:ignore[return-value]
 
 
 # from python2js
diff --git a/src/py/pyodide/_state.py b/src/py/pyodide/_state.py
--- a/src/py/pyodide/_state.py
+++ b/src/py/pyodide/_state.py
@@ -40,7 +40,7 @@ def restore_state(state: dict[str, Any]) -> int:
             del sys.modules[key]
     sys.modules.update(loaded_js_modules)
 
-    sys.last_exc = None  # type:ignore[attr-defined]
+    sys.last_exc = None  # type:ignore[assignment]
     sys.last_type = None
     sys.last_value = None
     sys.last_traceback = None
diff --git a/src/py/pyodide/console.py b/src/py/pyodide/console.py
--- a/src/py/pyodide/console.py
+++ b/src/py/pyodide/console.py
@@ -398,7 +398,7 @@ def formatsyntaxerror(self, e: Exception) -> str:
         This doesn't include a stack trace because there isn't one. The actual
         error object is stored into :py:data:`sys.last_value`.
         """
-        sys.last_exc = e  # type:ignore[attr-defined]
+        sys.last_exc = e
         sys.last_type = type(e)
         sys.last_value = e
         sys.last_traceback = None
@@ -420,7 +420,7 @@ def formattraceback(self, e: BaseException) -> str:
 
         The actual error object is stored into :py:data:`sys.last_value`.
         """
-        sys.last_exc = e  # type:ignore[attr-defined]
+        sys.last_exc = e
         sys.last_type = type(e)
         sys.last_value = e
         sys.last_traceback = e.__traceback__


Test patch:
diff --git a/src/tests/test_jsproxy.py b/src/tests/test_jsproxy.py
--- a/src/tests/test_jsproxy.py
+++ b/src/tests/test_jsproxy.py
@@ -1780,7 +1780,7 @@ def test_jsarray_remove(selenium):
         a.remove(78)
     assert a.to_py() == l
     l.append([])  # type:ignore[arg-type]
-    p = create_proxy([], roundtrip=False)
+    p = create_proxy([], roundtrip=False)  # type:ignore[var-annotated]
     a.append(p)
     assert a.to_py() == l
     l.remove([])  # type:ignore[arg-type]
diff --git a/src/tests/test_pyodide.py b/src/tests/test_pyodide.py
--- a/src/tests/test_pyodide.py
+++ b/src/tests/test_pyodide.py
@@ -836,7 +836,7 @@ def __del__(self):
     assert sys.getrefcount(f) == 2
     proxy = create_proxy(f)
     assert sys.getrefcount(f) == 3
-    assert proxy() == 7  # type:ignore[operator]
+    assert proxy() == 7
     testAddListener(proxy)
     assert sys.getrefcount(f) == 3
     assert testCallListener() == 7
@@ -890,7 +890,7 @@ def test_return_destroyed_value(selenium):
     from pyodide.ffi import JsException, create_proxy
 
     f = run_js("(function(x){ return x; })")
-    p = create_proxy([])
+    p = create_proxy([])  # type: ignore[var-annotated]
     p.destroy()
     with pytest.raises(JsException, match="Object has already been destroyed"):
         f(p)
diff --git a/src/tests/test_static_typing.py b/src/tests/test_static_typing.py
new file mode 100644
--- /dev/null
+++ b/src/tests/test_static_typing.py
@@ -0,0 +1,76 @@
+from inspect import getsource
+from pathlib import Path
+from textwrap import dedent
+from typing import TypeVar, assert_type
+
+from mypy.api import run
+from pytest import raises
+
+
+def _mypy_check(source: str) -> tuple[str, str, int]:
+    path = str(Path(__file__).parent.parent.parent / "pyproject.toml")
+    stdout, stderr, exit_status = run(["-c", source, "--config-file", path])
+    return stdout, stderr, exit_status
+
+
+def assert_no_error(source: str) -> None:
+    stdout, _, exit_status = _mypy_check(f"from typing import *\n\n{dedent(source)}")
+    assert exit_status == 0, stdout
+
+
+def test_self():
+    with raises(AssertionError):
+        assert_no_error("a: str = 123")
+
+
+def test_create_proxy():
+    def _():
+        from pyodide.ffi import create_proxy
+
+        a: int = 2
+        assert_type(create_proxy(a).unwrap(), int)
+
+    assert_no_error(getsource(_))
+
+
+def test_generic():
+    def _():
+        from pyodide.ffi import JsDoubleProxy, JsPromise
+
+        def _(a: JsPromise[int]) -> None:
+            assert_type(a.then(str), JsPromise[str])
+
+        def _(b: JsDoubleProxy[str]) -> None:
+            assert_type(b.unwrap(), str)
+
+    assert_no_error(getsource(_))
+
+
+def test_callable_generic():
+    def _():
+        from pyodide.ffi import create_once_callable, create_proxy
+
+        T = TypeVar("T", int, float, str)
+
+        def f(x: T) -> T:
+            return x * 2
+
+        assert_type(create_once_callable(f)(2), int)
+        assert_type(create_proxy(f)(""), str)
+
+    assert_no_error(getsource(_))
+
+
+def test_decorator_usage():
+    def _():
+        from pyodide.ffi import create_once_callable
+
+        T = TypeVar("T")
+
+        @create_once_callable
+        def f(x: T) -> list[T]:
+            return [x]
+
+        assert_type(f((int(input()), str(input()))), list[tuple[int, str]])
+
+    assert_no_error(getsource(_))