Skip to content

Commit

Permalink
Add apis to discard extra arguments when calling Python functions (#4392
Browse files Browse the repository at this point in the history
)

This is intended to be useful for implementing handlers. The handler
might not care about all the arguments. It's convenient to have some
easy way to discard unneeded ones.
  • Loading branch information
hoodmane committed Jan 22, 2024
1 parent 0eb69be commit e586e7c
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 2 deletions.
6 changes: 6 additions & 0 deletions docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ myst:

# Change Log

## Unreleased

- {{ Enhancement }} Added apis to discard extra arguments when calling Python
functions.
{pr}`4392`

## Version 0.25.0

_January 18, 2023_
Expand Down
34 changes: 33 additions & 1 deletion src/core/pyproxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2347,7 +2347,7 @@ export class PyCallableMethods {
return Module.callPyObject(_getPtr(this), jsargs);
}
/**
* Call the function with key word arguments. The last argument must be an
* Call the function with keyword arguments. The last argument must be an
* object with the keyword arguments.
*/
callKwargs(...jsargs: any) {
Expand All @@ -2366,6 +2366,38 @@ export class PyCallableMethods {
return Module.callPyObjectKwargs(_getPtr(this), jsargs, kwargs);
}

/**
* Call the function in a "relaxed" manner. Any extra arguments will be
* ignored. This matches the behavior of JavaScript functions more accurately.
*
* Any extra arguments will be ignored. This matches the behavior of
* JavaScript functions more accurately. Missing arguments are **NOT** filled
* with `None`. If too few arguments are passed, this will still raise a
* TypeError.
*
* This uses :py:func:`pyodide.code.relaxed_call`.
*/
callRelaxed(...jsargs: any) {
return API.pyodide_code.relaxed_call(this, ...jsargs);
}

/**
* Call the function with keyword arguments in a "relaxed" manner. The last
* argument must be an object with the keyword arguments. Any extra arguments
* will be ignored. This matches the behavior of JavaScript functions more
* accurately.
*
* Missing arguments are **NOT** filled with `None`. If too few arguments are
* passed, this will still raise a TypeError. Also, if the same argument is
* passed as both a keyword argument and a positional argument, it will raise
* an error.
*
* This uses :py:func:`pyodide.code.relaxed_call`.
*/
callKwargsRelaxed(...jsargs: any) {
return API.pyodide_code.relaxed_call.callKwargs(this, ...jsargs);
}

/**
* Call the function with stack switching enabled. Functions called this way
* can use
Expand Down
84 changes: 83 additions & 1 deletion src/py/pyodide/code.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from typing import Any
from collections.abc import Callable
from functools import lru_cache, wraps
from inspect import Parameter, Signature, signature
from typing import Any, ParamSpec, TypeVar

from _pyodide._base import (
CodeRunner,
Expand All @@ -25,11 +28,90 @@ def run_js(code: str, /) -> Any:
return eval_(code)


def _relaxed_call_sig(func: Callable[..., Any]) -> Signature | None:
try:
sig = signature(func)
except (TypeError, ValueError):
return None
new_params = list(sig.parameters.values())
idx: int | None = -1
for idx, param in enumerate(new_params):
if param.kind in (Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD):
break
if param.kind == Parameter.VAR_POSITIONAL:
idx = None
break
else:
idx += 1
if idx is not None:
new_params.insert(idx, Parameter("__var_positional", Parameter.VAR_POSITIONAL))

for param in new_params:
if param.kind == Parameter.VAR_KEYWORD:
break
else:
new_params.append(Parameter("__var_keyword", Parameter.VAR_KEYWORD))
new_sig = sig.replace(parameters=new_params)
return new_sig


@lru_cache
def _relaxed_call_sig_cached(func: Callable[..., Any]) -> Signature | None:
return _relaxed_call_sig(func)


def _do_call(
func: Callable[..., Any], sig: Signature, args: Any, kwargs: dict[str, Any]
) -> Any:
bound = sig.bind(*args, **kwargs)
bound.arguments.pop("__var_positional", None)
bound.arguments.pop("__var_keyword", None)
return func(*bound.args, **bound.kwargs)


Param = ParamSpec("Param")
Param2 = ParamSpec("Param2")
RetType = TypeVar("RetType")


def relaxed_wrap(func: Callable[Param, RetType]) -> Callable[..., RetType]:
"""Decorator which creates a function that ignores extra arguments
If extra positional or keyword arguments are provided they will be
discarded.
"""
sig = _relaxed_call_sig(func)
if sig is None:
raise TypeError("Cannot wrap function")
else:
sig2 = sig

@wraps(func)
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
return _do_call(func, sig2, args, kwargs)

return wrapper


def relaxed_call(func: Callable[..., RetType], *args: Any, **kwargs: Any) -> RetType:
"""Call the function ignoring extra arguments
If extra positional or keyword arguments are provided they will be
discarded.
"""
sig = _relaxed_call_sig_cached(func)
if sig is None:
return func(*args, **kwargs)
return _do_call(func, sig, args, kwargs)


__all__ = [
"CodeRunner",
"eval_code",
"eval_code_async",
"find_imports",
"should_quiet",
"run_js",
"relaxed_wrap",
"relaxed_call",
]
120 changes: 120 additions & 0 deletions src/tests/test_pyodide.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,126 @@ def test():
assert res == "Hello"


def test_relaxed_call():
from pyodide.code import relaxed_call

assert relaxed_call(int, "7") == 7

def f1(a, b):
return [a, b]

assert relaxed_call(f1, 1, 2) == [1, 2]
assert relaxed_call(f1, 1, 2, 3) == [1, 2]
assert relaxed_call(f1, 1, b=7) == [1, 7]
assert relaxed_call(f1, a=2, b=7) == [2, 7]
assert relaxed_call(f1, 1, b=7, c=9) == [1, 7]
assert relaxed_call(f1, 1, 2, 3, c=9) == [1, 2]
with pytest.raises(TypeError, match="missing a required argument: 'b'"):
relaxed_call(f1, 1)
with pytest.raises(TypeError, match="multiple values for argument 'b'"):
relaxed_call(f1, 1, 2, b=3)

def f2(a, b=7):
return [a, b]

assert relaxed_call(f2, 1, 2) == [1, 2]
assert relaxed_call(f2, 1, 2, 3) == [1, 2]
assert relaxed_call(f2, 1, b=7) == [1, 7]
assert relaxed_call(f2, a=2, b=7) == [2, 7]
assert relaxed_call(f2, 1, b=7, c=9) == [1, 7]
assert relaxed_call(f2, 1, 2, 3, c=9) == [1, 2]
assert relaxed_call(f2, 1) == [1, 7]
with pytest.raises(TypeError, match="missing a required argument: 'a'"):
relaxed_call(f2)

def f3(a, *args, b=7):
return [a, args, b]

assert relaxed_call(f3, 1, 2) == [1, (2,), 7]
assert relaxed_call(f3, 1, 2, 3) == [1, (2, 3), 7]
assert relaxed_call(f3, 1, b=7) == [1, (), 7]
assert relaxed_call(f3, a=2, b=7) == [2, (), 7]
assert relaxed_call(f3, 1, b=7, c=9) == [1, (), 7]
assert relaxed_call(f3, 1, 2, 3, c=9) == [1, (2, 3), 7]
assert relaxed_call(f3, 1) == [1, (), 7]

def f4(a, /, *args, b=7):
return [a, args, b]

with pytest.raises(
TypeError, match="'a' parameter is positional only, but was passed as a keyword"
):
relaxed_call(f4, a=2, b=7)

def f5(a, *args, b=7, **kwargs):
return [a, args, b, kwargs]

assert relaxed_call(f5, 1, 2, 3, 4, b=7, c=9) == [1, (2, 3, 4), 7, {"c": 9}]


def test_relaxed_wrap():
from pyodide.code import relaxed_wrap

with pytest.raises(TypeError, match="Cannot wrap function"):
relaxed_wrap(int)

@relaxed_wrap
def f1(a, b):
return [a, b]

assert f1(1, 2) == [1, 2]
assert f1(1, 2, 3) == [1, 2]
assert f1(1, b=7) == [1, 7]
assert f1(a=2, b=7) == [2, 7]
assert f1(1, b=7, c=9) == [1, 7]
assert f1(1, 2, 3, c=9) == [1, 2]
with pytest.raises(TypeError, match="missing a required argument: 'b'"):
f1(1)
with pytest.raises(TypeError, match="multiple values for argument 'b'"):
f1(1, 2, b=3)

@relaxed_wrap
def f2(a, b=7):
return [a, b]

assert f2(1, 2) == [1, 2]
assert f2(1, 2, 3) == [1, 2]
assert f2(1, b=7) == [1, 7]
assert f2(a=2, b=7) == [2, 7]
assert f2(1, b=7, c=9) == [1, 7]
assert f2(1, 2, 3, c=9) == [1, 2]
assert f2(1) == [1, 7]
with pytest.raises(TypeError, match="missing a required argument: 'a'"):
f2()

@relaxed_wrap
def f3(a, *args, b=7):
return [a, args, b]

assert f3(1, 2) == [1, (2,), 7]
assert f3(1, 2, 3) == [1, (2, 3), 7]
assert f3(1, b=7) == [1, (), 7]
assert f3(a=2, b=7) == [2, (), 7]
assert f3(1, b=7, c=9) == [1, (), 7]
assert f3(1, 2, 3, c=9) == [1, (2, 3), 7]
assert f3(1) == [1, (), 7]

@relaxed_wrap
def f4(a, /, *args, b=7):
return [a, args, b]

with pytest.raises(
TypeError, match="'a' parameter is positional only, but was passed as a keyword"
):
f4(a=2, b=7)

@relaxed_wrap
def f5(a, *args, b=7, **kwargs):
return [a, args, b, kwargs]

assert f5(1, 2, 3, 4, b=7, c=9) == [1, (2, 3, 4), 7, {"c": 9}]


def test_unpack_archive(selenium_standalone):
selenium = selenium_standalone
js_error = selenium.run_js(
Expand Down

0 comments on commit e586e7c

Please sign in to comment.