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/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 0676fe1294..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) @@ -1118,7 +1120,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. @@ -1224,7 +1226,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. 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) 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/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 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/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/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() 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 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 =