From 9b4a04b404a6c5add2a95c23c85eca2d687e4fec Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Mon, 25 Mar 2024 20:04:17 -0600 Subject: [PATCH 1/6] lift ColorType outside of TYPE_CHECKING we'll need this in the next patch to make sure the type checking all compiles and works. Signed-off-by: Tycho Andersen --- libqtile/utils.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/libqtile/utils.py b/libqtile/utils.py index 968abb8c05..9d2c2f2a7c 100644 --- a/libqtile/utils.py +++ b/libqtile/utils.py @@ -31,15 +31,7 @@ from pathlib import Path from random import randint from shutil import which -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any, Callable, Coroutine, TypeVar, Union - - ColorType = Union[str, tuple[int, int, int], tuple[int, int, int, float]] - ColorsType = Union[ColorType, list[ColorType]] - - T = TypeVar("T") +from typing import TYPE_CHECKING, Union try: from dbus_next import AuthError, Message, Variant @@ -52,6 +44,13 @@ from libqtile.log_utils import logger +ColorType = Union[str, tuple[int, int, int], tuple[int, int, int, float]] +ColorsType = Union[ColorType, list[ColorType]] +if TYPE_CHECKING: + from typing import Any, Callable, Coroutine, TypeVar + + T = TypeVar("T") + dbus_bus_connections = set() # Create a list to collect references to tasks so they're not garbage collected From 6eb5715a6e54078c906120a7c8c3d41a66667ebc Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sun, 31 Mar 2024 02:28:56 -0600 Subject: [PATCH 2/6] fix deprecation warning pytest tries to collect all classes that match Test*: test/widgets/test_tasklist.py:29 /home/runner/work/qtile/qtile/test/widgets/test_tasklist.py:29: PytestCollectionWarning: cannot collect test class 'TestTaskList' because it has a __init__ constructor (from: test/widgets/test_tasklist.py) class TestTaskList(TaskList): this is really just a test widget, so let's rename it so pytest doesn't get confused. Signed-off-by: Tycho Andersen --- test/widgets/test_tasklist.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/widgets/test_tasklist.py b/test/widgets/test_tasklist.py index 6da604a972..9f74df6bfd 100644 --- a/test/widgets/test_tasklist.py +++ b/test/widgets/test_tasklist.py @@ -26,7 +26,7 @@ from libqtile.widget.tasklist import TaskList -class TestTaskList(TaskList): +class TaskListTestWidget(TaskList): def __init__(self, *args, **kwargs): TaskList.__init__(self, *args, **kwargs) self._text = "" @@ -64,7 +64,7 @@ class TasklistConfig(Config): floating_layout = libqtile.resources.default_config.floating_layout keys = [] mouse = [] - screens = [Screen(top=bar.Bar([TestTaskList(name="tasklist", **config)], 28))] + screens = [Screen(top=bar.Bar([TaskListTestWidget(name="tasklist", **config)], 28))] manager_nospawn.start(TasklistConfig) yield manager_nospawn From c58796d2f84615d2ace8b32d6a613a0751e0ca56 Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sun, 31 Mar 2024 15:45:42 -0600 Subject: [PATCH 3/6] drop support for python 3.9 We get some test failures for the type lifting code: 2024-03-31T08:55:43.2263813Z 2024-03-31 08:54:10,239 [1m[31mlibqtile loop.py:_handle_exception():L62 [0m Exception in event loop: 2024-03-31T08:55:43.2263925Z Traceback (most recent call last): 2024-03-31T08:55:43.2264270Z File "/home/runner/work/qtile/qtile/libqtile/ipc.py", line 235, in _server_callback 2024-03-31T08:55:43.2264389Z rep = self.handler(req) 2024-03-31T08:55:43.2264973Z File "/home/runner/work/qtile/qtile/libqtile/command/interface.py", line 372, in call 2024-03-31T08:55:43.2265292Z params = typing.get_type_hints(cmd, globalns=globals()) 2024-03-31T08:55:43.2265926Z File "/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/typing.py", line 1497, in get_type_hints 2024-03-31T08:55:43.2266086Z value = _eval_type(value, globalns, localns) 2024-03-31T08:55:43.2266451Z File "/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/typing.py", line 292, in _eval_type 2024-03-31T08:55:43.2266630Z return t._evaluate(globalns, localns, recursive_guard) 2024-03-31T08:55:43.2266998Z File "/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/typing.py", line 554, in _evaluate 2024-03-31T08:55:43.2267151Z eval(self.__forward_code__, globalns, localns), 2024-03-31T08:55:43.2267291Z File "", line 1, in 2024-03-31T08:55:43.2267716Z TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' because the "str | None" syntax isn't natively supported (i.e. requries __future__ imports), so py3.9's get_type_hints() can't resolve it correctly. Signed-off-by: Tycho Andersen --- .github/workflows/ci.yml | 4 +--- .github/workflows/release.yml | 18 +++++++++--------- pyproject.toml | 2 +- setup.cfg | 2 +- tox.ini | 12 ++++++------ 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f388529ff2..b8391933b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: # - /setup.cfg:[mypy] # If adding new python versions, consider also updating # python version in .readthedocs.yaml - python-version: [pypy-3.10, 3.9, '3.10', '3.11', '3.12'] + python-version: [pypy-3.10, '3.10', '3.11', '3.12'] backend: ['x11', 'wayland'] steps: - uses: actions/checkout@v4 @@ -44,8 +44,6 @@ jobs: env: BACKEND: ${{ matrix.backend }} - name: Upload coverage data to coveralls.io - # For speed purposes, we don't do coverage reporting on pypy 3.9 - if: ${{ matrix.python-version != 'pypy-3.9' }} run: | pip -q install coveralls >=3.3.0 coveralls --service=github diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b3e7c6faf..981ef84d3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,7 @@ jobs: container: quay.io/pypa/manylinux_2_28_x86_64 strategy: matrix: - python-version: ["cp3.9", "cp3.10", "cp3.11", "pp3.9"] + python-version: ["cp3.10", "cp3.11", "cp3.12", "pp3.10"] steps: - name: Install dependencies run: | @@ -176,10 +176,10 @@ jobs: needs: build-wheel strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] include: - - python-version: "pypy-3.9" - manual-version: "pp3.9" + - python-version: "pypy-3.10" + manual-version: "pp3.10" steps: - name: Download wheels if: ${{ matrix.manual-version == '' }} @@ -204,7 +204,7 @@ jobs: runs-on: ubuntu-latest container: quay.io/pypa/manylinux_2_28_x86_64 env: - python-version: "cp3.11" + python-version: "cp3.12" steps: - name: Install dependencies run: dnf install -y cairo-devel libffi-devel libxkbcommon-devel @@ -231,10 +231,10 @@ jobs: needs: [test-wheel, build-source] strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] include: - - python-version: "pypy-3.9" - manual-version: "pp3.9" + - python-version: "pypy-3.10" + manual-version: "pp3.10" steps: - name: Download wheels if: ${{ matrix.manual-version == '' }} @@ -267,7 +267,7 @@ jobs: runs-on: ubuntu-latest needs: [test-wheel, build-source] env: - python-version: "cp3.11" + python-version: "cp3.12" steps: - name: Download source uses: actions/download-artifact@v4 diff --git a/pyproject.toml b/pyproject.toml index 638a661966..338a812031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,9 @@ classifiers = [ "Operating System :: POSIX :: BSD :: FreeBSD", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Desktop Environment :: Window Managers", diff --git a/setup.cfg b/setup.cfg index ce96d40d32..8ce98f0315 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [options] packages = find_namespace: -python_requires >= 3.9 +python_requires >= 3.10 setup_requires = cffi >= 1.1.0 cairocffi[xcb] >= 1.6.0 diff --git a/tox.ini b/tox.ini index 80fb2fac96..b77cf10325 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ skipsdist=True minversion = 4.0.12 envlist = # Python environments with specific backend - py{py3,39,310,311,312}-{x11,wayland} + py{py3,310,311,312}-{x11,wayland} docs, packaging-{x11,wayland}, # For running pytest locally @@ -35,7 +35,7 @@ commands = !x11: {toxinidir}/scripts/ffibuild # These are the environments that should be triggered by Github Actions -[testenv:py{py3,39,310,311}-{wayland,x11}] +[testenv:py{py3,310,311,312}-{wayland,x11}] # This is required in order to get UTF-8 output inside of the subprocesses # that our tests use. setenv = LC_CTYPE = en_US.UTF-8 @@ -54,8 +54,8 @@ commands = # pypy3 is very slow when running coverage reports so we skip it pypy3-x11: python3 -m pytest --backend=x11 {posargs} pypy3-wayland: python3 -m pytest --backend=wayland {posargs} - py3{9,10,11}-x11: coverage run -m pytest --backend=x11 {posargs} - py3{9,10,11}-wayland: coverage run -m pytest --backend=wayland {posargs} + py3{10,11,12}-x11: coverage run -m pytest --backend=x11 {posargs} + py3{10,11,12}-wayland: coverage run -m pytest --backend=wayland {posargs} # Coverage is only run via GithubActions # Coverage runs tests in parallel so we need to combine results into a single file @@ -122,9 +122,9 @@ commands = [gh-actions] python = pypy-3.10: pypy3 - 3.9: py39 3.10: py310 - 3.11: py311, packaging + 3.11: py311 + 3.12: py312, packaging [gh-actions:env] BACKEND = From c6e0c7802adab98628872a76db875d5eb53688ec Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sun, 31 Mar 2024 17:08:16 -0600 Subject: [PATCH 4/6] spawn: rearrange order of type annotations In prep for argument lifting over IPC, we need to list the type annotations in the order we want to try lifting. We want to try and lift to a list before we lift to a string, in case people actually pass us the list. Signed-off-by: Tycho Andersen --- libqtile/core/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libqtile/core/manager.py b/libqtile/core/manager.py index 0676fe1294..668f680b03 100644 --- a/libqtile/core/manager.py +++ b/libqtile/core/manager.py @@ -1224,7 +1224,7 @@ def validate_config(self) -> None: @expose_command() def spawn( - self, cmd: str | list[str], shell: bool = False, env: dict[str, str] = dict() + self, cmd: list[str] | str, shell: bool = False, env: dict[str, str] = dict() ) -> int: """ Spawn a new process. From 64300906523d4c0cbdcd8a4ff75f0751fcb25214 Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sun, 31 Mar 2024 18:53:12 -0600 Subject: [PATCH 5/6] core: fix arg type for to_layout_index() the argument `index` is passed to group.use_layout(), which immediately asserts that it is an integer, so let's annotate as an integer, not a str. 2024-03-31T21:57:05.3153093Z self = 2024-03-31T21:57:05.3153832Z call = 2024-03-31T21:57:05.3154506Z args = (-1,), kwargs = {} 2024-03-31T21:57:05.3154764Z 2024-03-31T21:57:05.3155121Z def execute(self, call: CommandGraphCall, args: tuple, kwargs: dict) -> Any: 2024-03-31T21:57:05.3155823Z """Execute the given call, returning the result of the execution 2024-03-31T21:57:05.3156370Z 2024-03-31T21:57:05.3156820Z Executes the given command over the given IPC client. Returns the 2024-03-31T21:57:05.3157370Z result of the execution. 2024-03-31T21:57:05.3157774Z 2024-03-31T21:57:05.3158158Z Parameters 2024-03-31T21:57:05.3158507Z ---------- 2024-03-31T21:57:05.3158897Z call: CommandGraphCall 2024-03-31T21:57:05.3159644Z The call on the command graph that is to be performed. 2024-03-31T21:57:05.3160147Z args: 2024-03-31T21:57:05.3160615Z The arguments to pass into the command graph call. 2024-03-31T21:57:05.3161087Z kwargs: 2024-03-31T21:57:05.3161692Z The keyword arguments to pass into the command graph call. 2024-03-31T21:57:05.3162239Z """ 2024-03-31T21:57:05.3162759Z status, result = self._client.send((call.parent.selectors, call.name, args, kwargs)) 2024-03-31T21:57:05.3163398Z if status == SUCCESS: 2024-03-31T21:57:05.3163816Z return result 2024-03-31T21:57:05.3164245Z if status == ERROR: 2024-03-31T21:57:05.3164699Z raise CommandError(result) 2024-03-31T21:57:05.3165153Z > raise CommandException(result) 2024-03-31T21:57:05.3165756Z E libqtile.command.base.CommandException: Traceback (most recent call last): 2024-03-31T21:57:05.3166625Z E File "/home/runner/work/qtile/qtile/libqtile/command/interface.py", line 402, in call 2024-03-31T21:57:05.3167418Z E return SUCCESS, cmd(*converted_args, **converted_kwargs) 2024-03-31T21:57:05.3167961Z E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 2024-03-31T21:57:05.3168712Z E File "/home/runner/work/qtile/qtile/libqtile/core/manager.py", line 1136, in to_layout_index 2024-03-31T21:57:05.3169482Z E group.use_layout(index) 2024-03-31T21:57:05.3170057Z E File "/home/runner/work/qtile/qtile/libqtile/group.py", line 132, in use_layout 2024-03-31T21:57:05.3171136Z E assert -len(self.layouts) <= index < len(self.layouts), "layout index out of bounds" 2024-03-31T21:57:05.3171921Z E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 2024-03-31T21:57:05.3172533Z E TypeError: '<=' not supported between instances of 'int' and 'str' I think this probably means nobody is using this, but it's neat that the type lifting behavior caught it, so I'm fixing it :) Signed-off-by: Tycho Andersen --- libqtile/core/manager.py | 2 +- libqtile/group.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libqtile/core/manager.py b/libqtile/core/manager.py index 668f680b03..2d7eb09402 100644 --- a/libqtile/core/manager.py +++ b/libqtile/core/manager.py @@ -1118,7 +1118,7 @@ def list_widgets(self) -> list[str]: return list(self.widgets_map.keys()) @expose_command() - def to_layout_index(self, index: str, name: str | None = None) -> None: + def to_layout_index(self, index: int, name: str | None = None) -> None: """ Switch to the layout with the given index in self.layouts. diff --git a/libqtile/group.py b/libqtile/group.py index eaa80b17d1..88561de438 100644 --- a/libqtile/group.py +++ b/libqtile/group.py @@ -128,7 +128,7 @@ def layout(self, layout): return logger.error("No such layout: %s", layout) - def use_layout(self, index): + def use_layout(self, index: int): assert -len(self.layouts) <= index < len(self.layouts), "layout index out of bounds" self.layout.hide() self.current_layout = index % len(self.layouts) From f455541341eb80ae93606be430fe2477b6ed023d Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sat, 16 Mar 2024 11:58:36 -0600 Subject: [PATCH 6/6] implement IPC parameter conversion Let's use our type annotations to resolve how to lift strings into whatever their right type is. For now, this just works on "normal" base+hand handled compound types. I have implemented stuff for Union and Literal, since we have those kinds of type annotations. However, I have not implemented e.g. lists as a compound type, since as near as I can tell we do not have any exposed commands that take lists (although it would not be hard, I just didn't want to add a bunch of unused code; see the discussion on the issues cited below for some sample code if you're looking at this in the future). I put normal in quotes, because of e.g. the behavior with bool. It's a bit ugly, but... best to only have to write it once. Maybe we should use eval() for some of these instead? It would let us "parse" lists etc. potentially... For supporting "custom" types (i.e. ones defined in libqtile somewhere that are more complex than just a constructor that accepts a string), we could do some kind of poking around in the type for a `from_str`, e.g.: >>> from __future__ import annotations >>> class C: ... def from_str(s: str) -> C: ... pass ... >>> class D: ... def foo(c: C): pass ... >>> typing.get_type_hints(D().foo)["c"] >>> hasattr(typing.get_type_hints(D().foo)["c"], "from_str") True again, though, we don't have any of these, so no need to define this convention right now. Finally, one annoying part of all this is that all of the type annotations that anyone uses in the qtile code base need to exist in interface.py, hence the new list noqa import. Otherwise, you end up with, 2024-03-25 19:35:19,812 libqtile loop.py:_handle_exception():L62 Exception in event loop: Traceback (most recent call last): File "/home/tycho/packages/qtile/libqtile/ipc.py", line 235, in _server_callback rep = self.handler(req) File "/home/tycho/packages/qtile/libqtile/command/interface.py", line 357, in call params = typing.get_type_hints(cmd, globalns=globals()) File "/usr/lib/python3.10/typing.py", line 1871, in get_type_hints value = _eval_type(value, globalns, localns) File "/usr/lib/python3.10/typing.py", line 327, in _eval_type return t._evaluate(globalns, localns, recursive_guard) File "/usr/lib/python3.10/typing.py", line 694, in _evaluate eval(self.__forward_code__, globalns, localns), File "", line 1, in NameError: name 'ColorType' is not defined this is annoying, but IMO a small price to pay for only having to write this code once. If we end up with circular dependencies or such, we can split this code out into its own module, but for now it seemed to work fine. This is also the reason for the `Layout` and `_Extension` classes in that file: they act as sentinels for the real types, since we can't really construct those. Right now this won't work if someone tries to pass us a real Layout, but we could use eval() to make it work if we wanted to. Fixes #2433 Fixes #2435 Fixes #4737 Signed-off-by: Tycho Andersen --- libqtile/command/client.py | 4 +- libqtile/command/graph.py | 7 +- libqtile/command/interface.py | 138 +++++++++++++++++++++++++++++++--- libqtile/core/manager.py | 10 ++- libqtile/scripts/cmd_obj.py | 2 +- libqtile/widget/base.py | 2 +- test/test_command.py | 25 +++++- 7 files changed, 164 insertions(+), 24 deletions(-) diff --git a/libqtile/command/client.py b/libqtile/command/client.py index 27ea928836..1f8870f04f 100644 --- a/libqtile/command/client.py +++ b/libqtile/command/client.py @@ -107,7 +107,7 @@ def navigate(self, name: str, selector: str | None) -> CommandClient: next_node = self._current_node.navigate(name, normalized_selector) return self.__class__(self._command, current_node=next_node) - def call(self, name: str, *args, **kwargs) -> Any: + def call(self, name: str, *args, lifted=True, **kwargs) -> Any: """Resolve and invoke the call into the command graph Parameters @@ -126,7 +126,7 @@ def call(self, name: str, *args, **kwargs) -> Any: if name not in self.commands: raise SelectError("Not valid child or command", name, self._current_node.selectors) - call = self._current_node.call(name) + call = self._current_node.call(name, lifted=lifted) return self._command.execute(call, args, kwargs) diff --git a/libqtile/command/graph.py b/libqtile/command/graph.py index 27a784eeb1..e4e4ce23a0 100644 --- a/libqtile/command/graph.py +++ b/libqtile/command/graph.py @@ -67,15 +67,15 @@ def navigate(self, name: str, selector: str | int | None) -> CommandGraphNode: return _COMMAND_GRAPH_MAP[name](selector, self) raise KeyError("Given node is not an object: {}".format(name)) - def call(self, name: str) -> CommandGraphCall: + def call(self, name: str, lifted: bool = False) -> CommandGraphCall: """Execute the given call on the selected object""" - return CommandGraphCall(name, self) + return CommandGraphCall(name, self, lifted=lifted) class CommandGraphCall: """A call performed on a particular object in the command graph""" - def __init__(self, name: str, parent: CommandGraphNode) -> None: + def __init__(self, name: str, parent: CommandGraphNode, lifted: bool = False) -> None: """A command to be executed on the selected object A terminal node in the command graph, specifying an actual command to @@ -90,6 +90,7 @@ def __init__(self, name: str, parent: CommandGraphNode) -> None: """ self._name = name self._parent = parent + self.lifted = lifted @property def name(self) -> str: diff --git a/libqtile/command/interface.py b/libqtile/command/interface.py index 50b11b8490..171588c4ad 100644 --- a/libqtile/command/interface.py +++ b/libqtile/command/interface.py @@ -25,17 +25,18 @@ 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, Literal, get_args, get_origin 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 ColorsType, ColorType # noqa: F401 if TYPE_CHECKING: - from typing import Any - from libqtile.command.graph import SelectorType SUCCESS = 0 @@ -43,6 +44,21 @@ EXCEPTION = 2 +# these two mask their aliases from elsewhere in the tree (i.e. +# libqtile.extension.base._Extension, and libqtile.layout.base.Layout +# +# since neither of these have constructors from a single string, we can't +# really lift them to a type. probably nobody actually passes us layouts here +# so it doesn't matter, but in the event that it does, we can probably fix it +# up with some eval() hackery whackery. +class _Extension: + pass + + +class Layout: + pass + + def format_selectors(selectors: list[SelectorType]) -> str: """Build the path to the selected command graph node""" path_elements = [] @@ -229,7 +245,9 @@ def execute(self, call: CommandGraphCall, args: tuple, kwargs: dict) -> Any: kwargs: The keyword arguments to pass into the command graph call. """ - status, result = self._client.send((call.parent.selectors, call.name, args, kwargs)) + status, result = self._client.send( + (call.parent.selectors, call.name, args, kwargs, call.lifted) + ) if status == SUCCESS: return result if status == ERROR: @@ -284,6 +302,96 @@ def has_item(self, node: CommandGraphNode, object_type: str, item: str | int) -> return items is not None and item in items +def lift_args(cmd, args, kwargs): + """ + Lift args lifts the arguments to the type annotations on cmd's parameters. + """ + + def lift_arg(typ, arg): + # for stuff like int | None, allow either + if get_origin(typ) in [types.UnionType, typing.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" or arg is True: + return True + if arg == "False" or arg is 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 + + if typ in [_Extension, Layout]: + # these are "complex" objects that can't be created with a + # single string argument. we generally don't expect people to + # be passing these over the command line, so let's ignore then. + return arg + + if get_origin(typ) in [list, dict]: + # again, we do not want to be in the business of parsing + # lists/dicts of types out of strings; just pass on whatever we + # got + return arg + + return typ(arg) + + converted_args = [] + converted_kwargs = dict() + + params = typing.get_type_hints(cmd, globalns=globals()) + + 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. + # + # if we really want to fix this, we + # inspect.signature(foo).parameters.keys() gives us the ordered + # parameters to reason about. + if len(converted_args) < len(args): + converted_args.extend(args[len(converted_args) :]) + + 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: + converted_kwargs[k] = v + + return tuple(converted_args), converted_kwargs + + class IPCCommandServer: """Execute the object commands for the calls that are sent to it""" @@ -295,9 +403,12 @@ def __init__(self, qtile) -> None: """ self.qtile = qtile - def call(self, data: tuple[list[SelectorType], str, tuple, dict]) -> tuple[int, Any]: + def call( + self, + data: tuple[list[SelectorType], str, tuple, dict, bool], + ) -> tuple[int, Any]: """Receive and parse the given data""" - selectors, name, args, kwargs = data + selectors, name, args, kwargs, lifted = data try: obj = self.qtile.select(selectors) cmd = obj.command(name) @@ -308,13 +419,16 @@ 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) + + if lifted: + args, kwargs = lift_args(cmd, args, kwargs) + + # Check if method is bound, if itis, insert magic self + if not hasattr(cmd, "__self__"): + args = (obj,) + args + try: - # Check if method is bound - if hasattr(cmd, "__self__"): - return SUCCESS, cmd(*args, **kwargs) - else: - # If not, pass object as first argument - return SUCCESS, cmd(obj, *args, **kwargs) + return SUCCESS, cmd(*args, **kwargs) except CommandError as err: return ERROR, err.args[0] except Exception: diff --git a/libqtile/core/manager.py b/libqtile/core/manager.py index 2d7eb09402..fa3759883e 100644 --- a/libqtile/core/manager.py +++ b/libqtile/core/manager.py @@ -448,7 +448,7 @@ def process_key_event(self, keysym: int, mask: int) -> tuple[Key | KeyChord | No for cmd in key.commands: if cmd.check(self): status, val = self.server.call( - (cmd.selectors, cmd.name, cmd.args, cmd.kwargs) + (cmd.selectors, cmd.name, cmd.args, cmd.kwargs, False) ) if status in (interface.ERROR, interface.EXCEPTION): logger.error("KB command error %s: %s", cmd.name, val) @@ -796,7 +796,9 @@ def process_button_click(self, button_code: int, modmask: int, x: int, y: int) - if isinstance(m, Click): for i in m.commands: if i.check(self): - status, val = self.server.call((i.selectors, i.name, i.args, i.kwargs)) + status, val = self.server.call( + (i.selectors, i.name, i.args, i.kwargs, False) + ) if status in (interface.ERROR, interface.EXCEPTION): logger.error("Mouse command error %s: %s", i.name, val) handled = True @@ -805,7 +807,7 @@ def process_button_click(self, button_code: int, modmask: int, x: int, y: int) - ): if m.start: i = m.start - status, val = self.server.call((i.selectors, i.name, i.args, i.kwargs)) + status, val = self.server.call((i.selectors, i.name, i.args, i.kwargs, False)) if status in (interface.ERROR, interface.EXCEPTION): logger.error("Mouse command error %s: %s", i.name, val) continue @@ -844,7 +846,7 @@ def process_button_motion(self, x: int, y: int) -> None: for i in cmd: if i.check(self): status, val = self.server.call( - (i.selectors, i.name, i.args + (rx + dx, ry + dy), i.kwargs) + (i.selectors, i.name, i.args + (rx + dx, ry + dy), i.kwargs, False) ) if status in (interface.ERROR, interface.EXCEPTION): logger.error("Mouse command error %s: %s", i.name, val) diff --git a/libqtile/scripts/cmd_obj.py b/libqtile/scripts/cmd_obj.py index 294f9ea111..ecb1fe1301 100644 --- a/libqtile/scripts/cmd_obj.py +++ b/libqtile/scripts/cmd_obj.py @@ -129,7 +129,7 @@ def get_object(client: CommandClient, argv: list[str]) -> CommandClient: def run_function(client: CommandClient, funcname: str, args: list[str]) -> str: "Run command with specified args on given object." try: - ret = client.call(funcname, *args) + ret = client.call(funcname, *args, lifted=True) except SelectError: print("error: Sorry no function ", funcname) sys.exit(1) diff --git a/libqtile/widget/base.py b/libqtile/widget/base.py index 5a30555d88..6fadfef72e 100644 --- a/libqtile/widget/base.py +++ b/libqtile/widget/base.py @@ -288,7 +288,7 @@ def button_press(self, x, y, button): if isinstance(cmd, LazyCall): if cmd.check(self.qtile): status, val = self.qtile.server.call( - (cmd.selectors, cmd.name, cmd.args, cmd.kwargs) + (cmd.selectors, cmd.name, cmd.args, cmd.kwargs, False) ) if status in (interface.ERROR, interface.EXCEPTION): logger.error("Mouse callback command error %s: %s", cmd.name, val) diff --git a/test/test_command.py b/test/test_command.py index 08ffd1deb6..d31baff360 100644 --- a/test/test_command.py +++ b/test/test_command.py @@ -32,8 +32,10 @@ import libqtile.log_utils import libqtile.widget from libqtile.command.base import CommandObject, expose_command -from libqtile.command.interface import CommandError +from libqtile.command.client import CommandClient +from libqtile.command.interface import CommandError, IPCCommandInterface from libqtile.confreader import Config +from libqtile.ipc import Client, IPCError from libqtile.lazy import lazy from test.conftest import dualmonitor @@ -88,6 +90,27 @@ def test_layout_filter(manager): assert manager.c.get_groups()["a"]["focus"] == "two" +@call_config +def test_param_hoisting(manager): + manager.test_window("two") + + client = Client(manager.sockfile) + command = IPCCommandInterface(client) + cmd_client = CommandClient(command) + + # 'zomg' is not a valid warp command + with pytest.raises(IPCError): + cmd_client.navigate("window", None).call("focus", warp="zomg", lifted=True) + + cmd_client.navigate("window", None).call("focus", warp=False, lifted=True) + + # 'zomg' is not a valid bar position + with pytest.raises(IPCError): + cmd_client.call("hide_show_bar", position="zomg", lifted=True) + + cmd_client.call("hide_show_bar", position="top", lifted=True) + + class FakeCommandObject(CommandObject): @staticmethod @expose_command()