diff --git a/libqtile/command/interface.py b/libqtile/command/interface.py index 50b11b8490..11199b4aef 100644 --- a/libqtile/command/interface.py +++ b/libqtile/command/interface.py @@ -25,17 +25,20 @@ from __future__ import annotations import traceback +import types +import typing from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin + +from typing_extensions import Literal from libqtile import ipc from libqtile.command.base import CommandError, CommandException, CommandObject, SelectError from libqtile.command.graph import CommandGraphCall, CommandGraphNode from libqtile.log_utils import logger +from libqtile.utils import ColorType # noqa: F401 if TYPE_CHECKING: - from typing import Any - from libqtile.command.graph import SelectorType SUCCESS = 0 @@ -308,13 +311,82 @@ def call(self, data: tuple[list[SelectorType], str, tuple, dict]) -> tuple[int, return ERROR, "No such command" logger.debug("Command: %s(%s, %s)", name, args, kwargs) - try: - # Check if method is bound - if hasattr(cmd, "__self__"): - return SUCCESS, cmd(*args, **kwargs) + + def lift_arg(typ, arg): + # for stuff like int | None, allow either + if get_origin(typ) is Union: + for t in get_args(typ): + if t == types.NoneType: + # special case None? I don't know what this looks like + # coming over IPC + if arg == "": + return None + if arg is None: + return None + continue + + try: + return lift_arg(t, arg) + except TypeError: + pass + # uh oh, we couldn't lift it to anything + raise TypeError(f"{arg} is not a {typ}") + + # for literals, check that it is one of the valid strings + if get_origin(typ) is Literal: + if arg not in get_args(typ): + raise TypeError(f"{arg} is not one of {get_origin(typ)}") + return arg + + if typ is bool: + # >>> bool("False") is True + # True + # ... but we want it to be false :) + if arg == "True": + return True + if arg == "False": + return False + raise TypeError(f"{arg} is not a bool") + + if typ is Any: + # can't do any lifting if we don't know the type + return arg + + return typ(arg) + + converted_args = [] + converted_kwargs = dict() + + params = typing.get_type_hints(cmd, globalns=globals()) + logger.error(f"name {name} cmd {cmd} params {params} args {args}") + + non_return_annotated_args = filter(lambda k: k != "return", params.keys()) + for param, arg in zip(non_return_annotated_args, args): + converted_args.append(lift_arg(params[param], arg)) + + # if not all args were annotated, we need to keep them anyway. note + # that mixing some annotated and not annotated args will not work well: + # we will reorder args here and cause problems. this is solveable but + # somewhat ugly, and we can avoid it by always annotating all + # parameters. + logger.error(f"{converted_args} {args}") + if len(converted_args) < len(args): + converted_args.extend(args[len(converted_args):]) + logger.error(f"{converted_args} {args}") + + # Check if method is bound + if not hasattr(cmd, "__self__"): + converted_args.insert(0, obj) + + for k, v in kwargs.items(): + # if this kwarg has a type annotation, use it + if k in params: + converted_kwargs[k] = lift_arg(params[k], v) else: - # If not, pass object as first argument - return SUCCESS, cmd(obj, *args, **kwargs) + converted_kwargs[k] = v + + try: + return SUCCESS, cmd(*converted_args, **converted_kwargs) except CommandError as err: return ERROR, err.args[0] except Exception: diff --git a/test/test_command.py b/test/test_command.py index 08ffd1deb6..fe42e258db 100644 --- a/test/test_command.py +++ b/test/test_command.py @@ -34,6 +34,7 @@ from libqtile.command.base import CommandObject, expose_command from libqtile.command.interface import CommandError from libqtile.confreader import Config +from libqtile.ipc import IPCError from libqtile.lazy import lazy from test.conftest import dualmonitor @@ -88,6 +89,22 @@ def test_layout_filter(manager): assert manager.c.get_groups()["a"]["focus"] == "two" +@call_config +def test_param_hoisting(manager): + manager.test_window("two") + # 'zomg' is not a valid warp command + with pytest.raises(IPCError): + manager.c.window.focus(warp="zomg") + + manager.c.window.focus(warp="False") + + # 'zomg' is not a valid bar position + with pytest.raises(IPCError): + manager.c.hide_show_bar(position="zomg") + + manager.c.hide_show_bar(position="top") + + class FakeCommandObject(CommandObject): @staticmethod @expose_command()