macros.py split, improved type checking
parent
fc57ccb361
commit
f812710bd0
@ -0,0 +1,280 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# key-mapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of key-mapper.
|
||||
#
|
||||
# key-mapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# key-mapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Parse macro code"""
|
||||
|
||||
|
||||
import re
|
||||
import traceback
|
||||
import inspect
|
||||
|
||||
from keymapper.logger import logger
|
||||
from keymapper.injection.macros.macro import Macro
|
||||
|
||||
|
||||
def is_this_a_macro(output):
|
||||
"""Figure out if this is a macro."""
|
||||
if not isinstance(output, str):
|
||||
return False
|
||||
|
||||
if "+" in output.strip():
|
||||
# for example "a + b"
|
||||
return True
|
||||
|
||||
return "(" in output and ")" in output and len(output) >= 4
|
||||
|
||||
|
||||
FUNCTIONS = {
|
||||
"m": Macro.modify,
|
||||
"r": Macro.repeat,
|
||||
"k": Macro.keycode,
|
||||
"e": Macro.event,
|
||||
"w": Macro.wait,
|
||||
"h": Macro.hold,
|
||||
"mouse": Macro.mouse,
|
||||
"wheel": Macro.wheel,
|
||||
"ifeq": Macro.ifeq,
|
||||
"set": Macro.set,
|
||||
"if_tap": Macro.if_tap,
|
||||
"if_single": Macro.if_single,
|
||||
}
|
||||
|
||||
|
||||
def get_num_parameters(function):
|
||||
"""Get the number of required parameters and the maximum number of parameters."""
|
||||
fullargspec = inspect.getfullargspec(function)
|
||||
num_args = len(fullargspec.args) - 1 # one is `self`
|
||||
return num_args - len(fullargspec.defaults or ()), num_args
|
||||
|
||||
|
||||
def _extract_params(inner):
|
||||
"""Extract parameters from the inner contents of a call.
|
||||
|
||||
This does not parse them.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inner : string
|
||||
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
|
||||
"""
|
||||
inner = inner.strip()
|
||||
brackets = 0
|
||||
params = []
|
||||
start = 0
|
||||
for position, char in enumerate(inner):
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if char == "," and brackets == 0:
|
||||
# , potentially starts another parameter, but only if
|
||||
# the current brackets are all closed.
|
||||
params.append(inner[start:position].strip())
|
||||
# skip the comma
|
||||
start = position + 1
|
||||
|
||||
# one last parameter
|
||||
params.append(inner[start:].strip())
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _count_brackets(macro):
|
||||
"""Find where the first opening bracket closes."""
|
||||
openings = macro.count("(")
|
||||
closings = macro.count(")")
|
||||
if openings != closings:
|
||||
raise Exception(
|
||||
f"You entered {openings} opening and {closings} " "closing brackets"
|
||||
)
|
||||
|
||||
brackets = 0
|
||||
position = 0
|
||||
for char in macro:
|
||||
position += 1
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
continue
|
||||
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if brackets == 0:
|
||||
# the closing bracket of the call
|
||||
break
|
||||
|
||||
return position
|
||||
|
||||
|
||||
def _parse_recurse(macro, context, macro_instance=None, depth=0):
|
||||
"""Handle a subset of the macro, e.g. one parameter or function call.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
macro : string
|
||||
Just like parse
|
||||
context : Context
|
||||
macro_instance : Macro or None
|
||||
A macro instance to add tasks to
|
||||
depth : int
|
||||
"""
|
||||
# not using eval for security reasons
|
||||
assert isinstance(macro, str)
|
||||
assert isinstance(depth, int)
|
||||
|
||||
if macro == "":
|
||||
return None
|
||||
|
||||
if macro_instance is None:
|
||||
macro_instance = Macro(macro, context)
|
||||
else:
|
||||
assert isinstance(macro_instance, Macro)
|
||||
|
||||
macro = macro.strip()
|
||||
space = " " * depth
|
||||
|
||||
# is it another macro?
|
||||
call_match = re.match(r"^(\w+)\(", macro)
|
||||
call = call_match[1] if call_match else None
|
||||
if call is not None:
|
||||
# available functions in the macro and the minimum and maximum number
|
||||
# of their parameters
|
||||
function = FUNCTIONS.get(call)
|
||||
if function is None:
|
||||
raise Exception(f"Unknown function {call}")
|
||||
|
||||
# get all the stuff inbetween
|
||||
position = _count_brackets(macro)
|
||||
|
||||
inner = macro[macro.index("(") + 1 : position - 1]
|
||||
|
||||
# split "3, k(a).w(10)" into parameters
|
||||
string_params = _extract_params(inner)
|
||||
logger.spam("%scalls %s with %s", space, call, string_params)
|
||||
# evaluate the params
|
||||
params = [
|
||||
_parse_recurse(param.strip(), context, None, depth + 1)
|
||||
for param in string_params
|
||||
]
|
||||
|
||||
logger.spam("%sadd call to %s with %s", space, call, params)
|
||||
|
||||
min_params, max_params = get_num_parameters(function)
|
||||
if len(params) < min_params or len(params) > max_params:
|
||||
if min_params != max_params:
|
||||
msg = (
|
||||
f"{call} takes between {min_params} and {max_params}, "
|
||||
f"not {len(params)} parameters"
|
||||
)
|
||||
else:
|
||||
msg = f"{call} takes {min_params}, " f"not {len(params)} parameters"
|
||||
|
||||
raise ValueError(msg)
|
||||
|
||||
function(macro_instance, *params)
|
||||
|
||||
# is after this another call? Chain it to the macro_instance
|
||||
if len(macro) > position and macro[position] == ".":
|
||||
chain = macro[position + 1 :]
|
||||
logger.spam("%sfollowed by %s", space, chain)
|
||||
_parse_recurse(chain, context, macro_instance, depth)
|
||||
|
||||
return macro_instance
|
||||
|
||||
# probably a parameter for an outer function
|
||||
try:
|
||||
# if possible, parse as int
|
||||
macro = int(macro)
|
||||
except ValueError:
|
||||
# use as string instead
|
||||
pass
|
||||
|
||||
logger.spam("%s%s %s", space, type(macro), macro)
|
||||
return macro
|
||||
|
||||
|
||||
def handle_plus_syntax(macro):
|
||||
"""transform a + b + c to m(a, m(b, m(c, h())))"""
|
||||
if "+" not in macro:
|
||||
return macro
|
||||
|
||||
if "(" in macro or ")" in macro:
|
||||
logger.error('Mixing "+" and macros is unsupported: "%s"', macro)
|
||||
return macro
|
||||
|
||||
chunks = [chunk.strip() for chunk in macro.split("+")]
|
||||
output = ""
|
||||
depth = 0
|
||||
for chunk in chunks:
|
||||
if chunk == "":
|
||||
# invalid syntax
|
||||
logger.error('Invalid syntax for "%s"', macro)
|
||||
return macro
|
||||
|
||||
depth += 1
|
||||
output += f"m({chunk},"
|
||||
|
||||
output += "h()"
|
||||
output += depth * ")"
|
||||
|
||||
logger.debug('Transformed "%s" to "%s"', macro, output)
|
||||
return output
|
||||
|
||||
|
||||
def parse(macro, context, return_errors=False):
|
||||
"""parse and generate a Macro that can be run as often as you want.
|
||||
|
||||
If it could not be parsed, possibly due to syntax errors, will log the
|
||||
error and return None.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
macro : string
|
||||
"r(3, k(a).w(10))"
|
||||
"r(2, k(a).k(-)).k(b)"
|
||||
"w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)"
|
||||
context : Context
|
||||
return_errors : bool
|
||||
If True, returns errors as a string or None if parsing worked.
|
||||
If False, returns the parsed macro.
|
||||
"""
|
||||
macro = handle_plus_syntax(macro)
|
||||
|
||||
# whitespaces, tabs, newlines and such don't serve a purpose. make
|
||||
# the log output clearer and the parsing easier.
|
||||
macro = re.sub(r"\s", "", macro)
|
||||
|
||||
if '"' in macro or "'" in macro:
|
||||
logger.info("Quotation marks in macros are not needed")
|
||||
macro = macro.replace('"', "").replace("'", "")
|
||||
|
||||
if return_errors:
|
||||
logger.spam("checking the syntax of %s", macro)
|
||||
else:
|
||||
logger.spam("preparing macro %s for later execution", macro)
|
||||
|
||||
try:
|
||||
macro_object = _parse_recurse(macro, context)
|
||||
return macro_object if not return_errors else None
|
||||
except Exception as error:
|
||||
logger.error('Failed to parse macro "%s": %s', macro, error.__repr__())
|
||||
# print the traceback in case this is a bug of key-mapper
|
||||
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
|
||||
return f"{error.__class__.__name__}: {str(error)}" if return_errors else None
|
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# key-mapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||
#
|
||||
# This file is part of key-mapper.
|
||||
#
|
||||
# key-mapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# key-mapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Share a dictionary across processes."""
|
||||
|
||||
|
||||
import multiprocessing
|
||||
import atexit
|
||||
import select
|
||||
|
||||
from keymapper.logger import logger
|
||||
|
||||
|
||||
class SharedDict:
|
||||
"""Share a dictionary across processes."""
|
||||
|
||||
# because unittests terminate all child processes in cleanup I can't use
|
||||
# multiprocessing.Manager
|
||||
def __init__(self):
|
||||
"""Create a shared dictionary."""
|
||||
super().__init__()
|
||||
self.pipe = multiprocessing.Pipe()
|
||||
self.process = None
|
||||
atexit.register(self._stop)
|
||||
self._start()
|
||||
|
||||
# To avoid blocking forever if something goes wrong. The maximum
|
||||
# observed time communication takes was 0.001 for me on a slow pc
|
||||
self._timeout = 0.02
|
||||
|
||||
def _start(self):
|
||||
"""Ensure the process to manage the dictionary is running."""
|
||||
if self.process is not None and self.process.is_alive():
|
||||
return
|
||||
|
||||
# if the manager has already been running in the past but stopped
|
||||
# for some reason, the dictionary contents are lost
|
||||
self.process = multiprocessing.Process(target=self.manage)
|
||||
self.process.start()
|
||||
|
||||
def manage(self):
|
||||
"""Manage the dictionary, handle read and write requests."""
|
||||
shared_dict = dict()
|
||||
while True:
|
||||
message = self.pipe[0].recv()
|
||||
logger.spam("SharedDict got %s", message)
|
||||
|
||||
if message[0] == "stop":
|
||||
return
|
||||
|
||||
if message[0] == "set":
|
||||
shared_dict[message[1]] = message[2]
|
||||
|
||||
if message[0] == "get":
|
||||
self.pipe[0].send(shared_dict.get(message[1]))
|
||||
|
||||
if message[0] == "ping":
|
||||
self.pipe[0].send("pong")
|
||||
|
||||
def _stop(self):
|
||||
"""Stop the managing process."""
|
||||
self.pipe[1].send(("stop",))
|
||||
|
||||
def get(self, key):
|
||||
"""Get a value from the dictionary."""
|
||||
return self.__getitem__(key)
|
||||
|
||||
def is_alive(self, timeout=None):
|
||||
"""Check if the manager process is running."""
|
||||
self.pipe[1].send(("ping",))
|
||||
select.select([self.pipe[1]], [], [], timeout or self._timeout)
|
||||
if self.pipe[1].poll():
|
||||
return self.pipe[1].recv() == "pong"
|
||||
|
||||
return False
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.pipe[1].send(("set", key, value))
|
||||
|
||||
def __getitem__(self, key):
|
||||
self.pipe[1].send(("get", key))
|
||||
|
||||
select.select([self.pipe[1]], [], [], self._timeout)
|
||||
if self.pipe[1].poll():
|
||||
return self.pipe[1].recv()
|
||||
|
||||
logger.error("select.select timed out")
|
||||
return None
|
||||
|
||||
def __del__(self):
|
||||
self._stop()
|
Loading…
Reference in New Issue