Skip to content

Commit

Permalink
Merge f455541 into 2da82cc
Browse files Browse the repository at this point in the history
  • Loading branch information
tych0 committed Apr 8, 2024
2 parents 2da82cc + f455541 commit 95d5049
Show file tree
Hide file tree
Showing 15 changed files with 195 additions and 58 deletions.
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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 == '' }}
Expand All @@ -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
Expand All @@ -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 == '' }}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions libqtile/command/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions libqtile/command/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
138 changes: 126 additions & 12 deletions libqtile/command/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,40 @@
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
ERROR = 1
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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"""

Expand All @@ -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)
Expand All @@ -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:
Expand Down
14 changes: 8 additions & 6 deletions libqtile/core/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion libqtile/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion libqtile/scripts/cmd_obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 95d5049

Please sign in to comment.