Support for comments in macros

xkb
sezanzeb 3 years ago
parent b233e93eae
commit 1b2373133e

@ -66,6 +66,9 @@ class Variable:
"""Get the variables value from memory."""
return macro_variables.get(self.name)
def __repr__(self):
return f'<Variable "{self.name}">'
def _type_check(value, allowed_types, display_name=None, position=None):
"""Validate a parameter used in a macro.
@ -433,6 +436,10 @@ class Macro:
examples: 52, 'KEY_A'
value : int
"""
_type = _type_check(_type, [int, str], "e (event)", 1)
code = _type_check(code, [int, str], "e (event)", 2)
value = _type_check(value, [int, str], "e (event)", 3)
if isinstance(_type, str):
_type = ecodes[_type.upper()]
if isinstance(code, str):

@ -166,14 +166,23 @@ def _split_keyword_arg(param):
return None, param
def _parse_recurse(macro, context, macro_instance=None, depth=0):
def _is_number(value):
"""Check if the value can be turned into a number."""
try:
float(value)
return True
except ValueError:
return False
def _parse_recurse(code, context, macro_instance=None, depth=0):
"""Handle a subset of the macro, e.g. one parameter or function call.
Not using eval for security reasons.
Parameters
----------
macro : string
code : string
Just like parse.
A single parameter of a function or the complete macro as string.
context : Context
@ -182,45 +191,53 @@ def _parse_recurse(macro, context, macro_instance=None, depth=0):
depth : int
For logging porposes
"""
assert isinstance(macro, str)
assert isinstance(code, str)
assert isinstance(depth, int)
if macro == "":
space = " " * depth
code = code.strip()
if code == "":
return None
if macro.startswith('"'):
# a string, don't parse
return macro[1:-1]
if code.startswith('"'):
# a string, don't parse. remove quotes
string = code[1:-1]
logger.spam("%sstring %s", space, string)
return string
if macro.startswith("$"):
if code.startswith("$"):
# will be resolved during the macros runtime
return Variable(macro.split("$", 1)[1])
if macro_instance is None:
macro_instance = Macro(macro, context)
else:
assert isinstance(macro_instance, Macro)
return Variable(code.split("$", 1)[1])
macro = macro.strip()
space = " " * depth
if _is_number(code):
if "." in code:
code = float(code)
else:
code = int(code)
logger.spam("%snumber %s", space, code)
return code
# is it another macro?
call_match = re.match(r"^(\w+)\(", macro)
call_match = re.match(r"^(\w+)\(", code)
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 arguments
if macro_instance is None:
macro_instance = Macro(code, context)
else:
assert isinstance(macro_instance, Macro)
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]
position = _count_brackets(code)
inner = code[code.index("(") + 1 : position - 1]
logger.spam("%scalls %s with %s", space, call, inner)
# split "3, foo=k(a).w(10)" into arguments
# split "3, foo=a(2, k(a).w(10))" into arguments
raw_string_args = _extract_args(inner)
# parse and sort the params
@ -256,7 +273,7 @@ def _parse_recurse(macro, context, macro_instance=None, depth=0):
f"not {num_provided_args} parameters"
)
else:
msg = f"{call} takes {min_args}, " f"not {num_provided_args} parameters"
msg = f"{call} takes {min_args}, not {num_provided_args} parameters"
raise ValueError(msg)
@ -265,32 +282,18 @@ def _parse_recurse(macro, context, macro_instance=None, depth=0):
function(macro_instance, *positional_args, **keyword_args)
# is after this another call? Chain it to the macro_instance
if len(macro) > position and macro[position] == ".":
chain = macro[position + 1 :]
if len(code) > position and code[position] == ".":
chain = code[position + 1 :]
logger.spam("%sfollowed by %s", space, chain)
_parse_recurse(chain, context, macro_instance, depth)
return macro_instance
# at this point it is still a string since no previous rule matched
assert isinstance(macro, str)
try:
# If it can be parsed as number, then it's probably a parameter to a function.
# It isn't really needed to parse it here, but is nice for the logs.
macro = int(macro)
except ValueError:
try:
macro = float(macro)
except ValueError:
# use as string instead
# If a string, it is probably either a key name like KEY_A or a variable
# name as n `set(var, 1)`, both won't contain special characters that can
# break macro syntax so they don't have to be wrapped in quotes.
pass
logger.spam("%s%s %s", space, type(macro), macro)
return macro
# It is probably either a key name like KEY_A or a variable name as in `set(var,1)`,
# both won't contain special characters that can break macro syntax so they don't
# have to be wrapped in quotes.
logger.spam("%sstring %s", space, code)
return code
def handle_plus_syntax(macro):
@ -325,6 +328,7 @@ def _remove_whitespaces(macro, delimiter='"'):
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
result = ""
for i, chunk in enumerate(macro.split(delimiter)):
# every second chunk is inside string quotes
if i % 2 == 0:
result += re.sub(r"\s", "", chunk)
else:
@ -335,6 +339,31 @@ def _remove_whitespaces(macro, delimiter='"'):
return result[: -len(delimiter)]
def _remove_comments(macro):
"""Remove comments from the macro and return the resulting code."""
# keep hashtags inside quotes intact
result = ""
for i, line in enumerate(macro.split("\n")):
for j, chunk in enumerate(line.split('"')):
if j > 0:
# add back the string quote
chunk = f'"{chunk}'
# every second chunk is inside string quotes
if j % 2 == 0 and "#" in chunk:
# everything from now on is a comment and can be ignored
result += chunk.split("#")[0]
break
else:
result += chunk
if i < macro.count("\n"):
result += "\n"
return result
def parse(macro, context, return_errors=False):
"""parse and generate a Macro that can be run as often as you want.
@ -354,6 +383,8 @@ def parse(macro, context, return_errors=False):
"""
macro = handle_plus_syntax(macro)
macro = _remove_comments(macro)
macro = _remove_whitespaces(macro, '"')
if return_errors:

@ -17,7 +17,7 @@
<text x="32.5" y="14">coverage</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="83.0" y="15" fill="#010101" fill-opacity=".3">93%</text>
<text x="82.0" y="14">93%</text>
<text x="83.0" y="15" fill="#010101" fill-opacity=".3">94%</text>
<text x="82.0" y="14">94%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -240,3 +240,7 @@ Similar to python, arguments can be either positional or keyword arguments.
Using `$` resolves a variable during runtime. For example `set(a, $1)` and
`if_eq($a, 1, key(KEY_A), key(KEY_B))`.
Comments can be written with '#', like `key(KEY_A) # write an "a"`

@ -17,7 +17,7 @@
<text x="22.0" y="14">pylint</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.64</text>
<text x="62.0" y="14">9.64</text>
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.65</text>
<text x="62.0" y="14">9.65</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -21,11 +21,23 @@
import time
import unittest
import re
import asyncio
import multiprocessing
from unittest import mock
from evdev.ecodes import EV_REL, EV_KEY, REL_Y, REL_X, REL_WHEEL, REL_HWHEEL
from evdev.ecodes import (
EV_REL,
EV_KEY,
REL_Y,
REL_X,
REL_WHEEL,
REL_HWHEEL,
KEY_A,
KEY_B,
KEY_C,
KEY_E,
)
from keymapper.injection.macros.macro import (
Macro,
@ -44,6 +56,7 @@ from keymapper.injection.macros.parse import (
_count_brackets,
_split_keyword_arg,
_remove_whitespaces,
_remove_comments,
)
from keymapper.injection.context import Context
from keymapper.config import config
@ -111,6 +124,29 @@ class TestMacros(MacroTestBase):
self.assertEqual(_remove_whitespaces("a## #b", delimiter="##"), "a## #b")
self.assertEqual(_remove_whitespaces("a## ##b", delimiter="##"), "a## ##b")
def test_remove_comments(self):
self.assertEqual(_remove_comments("a#b"), "a")
self.assertEqual(_remove_comments('"a#b"'), '"a#b"')
self.assertEqual(_remove_comments('a"#"#b'), 'a"#"')
self.assertEqual(_remove_comments('a"#""#"#b'), 'a"#""#"')
self.assertEqual(_remove_comments('#a"#""#"#b'), "")
self.assertEqual(
re.sub(
r"\s",
"",
_remove_comments(
"""
# a
b
# c
d
"""
),
),
"bd",
)
async def test_count_brackets(self):
self.assertEqual(_count_brackets(""), 0)
self.assertEqual(_count_brackets("()"), 2)
@ -898,6 +934,41 @@ class TestMacros(MacroTestBase):
await parse("set(a, )", self.context).run(self.handler)
self.assertEqual(macro_variables.get("a"), None)
async def test_multiline_macro_and_comments(self):
# the parser is not confused by the code in the comments and can use hashtags
# in strings in the actual code
comment = '# r(1,k(KEY_D)).set(a,"#b")'
macro = parse(
f"""
{comment}
key(KEY_A).{comment}
key(KEY_B). {comment}
repeat({comment}
1, {comment}
key(KEY_C){comment}
). {comment}
{comment}
set(a, "#").{comment}
if_eq($a, "#", key(KEY_E), key(KEY_F)) {comment}
{comment}
""",
self.context,
)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, KEY_A, 1),
(EV_KEY, KEY_A, 0),
(EV_KEY, KEY_B, 1),
(EV_KEY, KEY_B, 0),
(EV_KEY, KEY_C, 1),
(EV_KEY, KEY_C, 0),
(EV_KEY, KEY_E, 1),
(EV_KEY, KEY_E, 0),
],
)
class TestIfEq(MacroTestBase):
async def test_ifeq_runs(self):

Loading…
Cancel
Save