File: argument.py

package info (click to toggle)
input-remapper 2.1.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 2,856 kB
  • sloc: python: 27,277; sh: 191; xml: 33; makefile: 3
file content (320 lines) | stat: -rw-r--r-- 12,043 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper.  If not, see <https://www.gnu.org/licenses/>.

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING

from evdev._ecodes import EV_KEY

from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import (
    MacroError,
    SymbolNotAvailableInTargetError,
)
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.variable import Variable

if TYPE_CHECKING:
    from inputremapper.injection.macros.raw_value import RawValue
    from inputremapper.configs.mapping import Mapping


class ArgumentFlags(Enum):
    # No default value is set, and the user has to provide one when using the macro
    required = "required"

    # If used, acts like foo(*bar)
    spread = "spread"


@dataclass
class ArgumentConfig:
    """Definition what kind of arguments a task may take."""

    position: Union[int, Literal[ArgumentFlags.spread]]
    name: str
    types: List[Optional[Type]]
    is_symbol: bool = False
    default: Any = ArgumentFlags.required

    # If True, then the value (which should be a string), is the name of a non-constant
    # variable. Tasks that overwrite their value need this, like `set`. The specified
    # types are those that the current value of that variable may have. For `set` this
    # doesn't matter, but something like `add` requires them to be numbers.
    is_variable_name: bool = False

    def is_required(self) -> bool:
        return self.default == ArgumentFlags.required

    def is_spread(self):
        """Does this Argument store all remaining Variables of a Task as a list?"""
        return self.position == ArgumentFlags.spread


class Argument(ArgumentConfig):
    """Validation of variables and access to their value for Tasks during runtime."""

    _variable: Optional[Variable] = None

    # If the position is set to ArgumentFlags.spread, then _variables will be filled
    # with all remaining positional arguments that were passed to a task.
    _variables: List[Variable]

    _mapping: Optional[Mapping] = None

    def __init__(
        self,
        argument_config: ArgumentConfig,
        mapping: Mapping,
    ) -> None:
        # If a default of None is specified, but None is not an allowed type, then
        # input-remapper has a bug here. Add "None" to your ArgumentConfig.types
        assert not (
            argument_config.default is None and None not in argument_config.types
        )

        self.position = argument_config.position
        self.name = argument_config.name
        self.types = argument_config.types
        self.is_symbol = argument_config.is_symbol
        self.default = argument_config.default
        self.is_variable_name = argument_config.is_variable_name

        self._mapping = mapping
        self._variables = []

    def initialize_variables(self, raw_values: List[RawValue]) -> None:
        """If the macro is supposed to contain multiple variables, set them.
        Should be done during parsing."""
        assert len(self._variables) == 0
        assert self._variable is None
        assert self.is_spread()

        for raw_value in raw_values:
            variable = self._parse_raw_value(raw_value)
            self._variables.append(variable)

    def initialize_variable(self, raw_value: RawValue) -> None:
        """Set the Arguments Variable. Done during parsing."""
        assert len(self._variables) == 0
        assert self._variable is None
        assert not self.is_spread()

        variable = self._parse_raw_value(raw_value)
        self._variable = variable

    def initialize_default(self) -> None:
        """Set the Arguments to its default value. Done during parsing."""
        assert len(self._variables) == 0
        assert self._variable is None
        assert not self.is_spread()

        variable = Variable(value=self.default, const=True)
        self._variable = variable

    def get_value(self) -> Any:
        """To ask for the current value of the variable during runtime."""
        assert not self.is_spread(), f"Use .{self.get_values.__name__}()"
        # If a user passed None as value, it should be a Variable(None, const=True) here.
        # If not, a test or input-remapper is broken.
        assert self._variable is not None

        value = self._variable.get_value()

        if not self._variable.const:
            # Dynamic value. Hasn't been validated yet
            value = self._validate_dynamic_value(self._variable)

        return value

    def get_values(self) -> List[Any]:
        """To ask for the current values of the variables during runtime."""
        assert self.is_spread(), f"Use .{self.get_value.__name__}()"

        values = []
        for variable in self._variables:
            if not variable.const:
                values.append(self._validate_dynamic_value(variable))
            else:
                values.append(variable.get_value())

        return values

    def get_variable_name(self) -> str:
        """If the variable is not const, return its name."""
        assert self._variable is not None
        return self._variable.get_name()

    def contains_macro(self) -> bool:
        """Does the underlying Variable contain another child-macro?"""
        assert self._variable is not None
        return isinstance(self._variable.get_value(), Macro)

    def set_value(self, value: Any) -> Any:
        """To set the value of the underlying Variable during runtime.
        Fails for constants."""
        assert self._variable is not None
        if self._variable.const:
            raise Exception("Can't set value of a constant")

        self._variable.set_value(value)

    def assert_is_symbol(self, symbol: str) -> None:
        """Checks if the key/symbol-name is valid. Like "KEY_A" or "escape".

        Using `is_symbol` on the ArgumentConfig is prefered, which causes it to
        automatically do this for you. But some macros may be a bit more flexible,
        and there we want to assert this ourselves only in certain cases."""
        symbol = str(symbol)
        code = keyboard_layout.get(symbol)

        if code is None:
            raise MacroError(msg=f'Unknown key "{symbol}"')

        if self._mapping is not None:
            target = self._mapping.target_uinput
            if target is not None and not GlobalUInputs.can_default_uinput_emit(
                target, EV_KEY, code
            ):
                raise SymbolNotAvailableInTargetError(symbol, target)

    def _parse_raw_value(self, raw_value: RawValue) -> Variable:
        """Validate and parse."""
        value = raw_value.value

        # The order of steps below matters.

        if isinstance(value, Macro):
            return Variable(value=value, const=True)

        if self.is_variable_name:
            # Treat this as a non-constant variable,
            # even without a `$` in front of its name
            if value.startswith('"'):
                # Remove quotes from the string
                value = value[1:-1]
            return Variable(value=value, const=False)

        if value.startswith("$"):
            # Will be resolved during the macros runtime
            return Variable(value=value[1:], const=False)

        if self.is_symbol:
            if value.startswith('"'):
                value = value[1:-1]
            self.assert_is_symbol(value)
            return Variable(value=value, const=True)

        if (value == "" or value == "None") and None in self.types:
            # I think "" is the deprecated alternative to "None"
            return Variable(value=None, const=True)

        if value.startswith('"') and str in self.types:
            # Something with explicit quotes should never be parsed as a number.
            # Treat it as a string no matter the content.
            value = value[1:-1]
            return Variable(value=value, const=True)

        if float in self.types and "." in value:
            try:
                return Variable(value=float(value), const=True)
            except (ValueError, TypeError) as e:
                pass

        if int in self.types:
            try:
                return Variable(value=int(value), const=True)
            except (ValueError, TypeError) as e:
                pass

        if not value.startswith('"') and ("(" in value or ")" in value):
            # Looks like something that should have been a macro. It is not explicitly
            # wrapped in quotes. Most likely an error. If it was a valid macro, the
            # parser would have parsed it as such.
            raise MacroError(
                msg=f"A broken macro was passed as parameter to {self.name}"
            )

        if str in self.types:
            # Treat as a string. Something like KEY_A in key(KEY_A)
            return Variable(value=value, const=True)

        raise self._type_error_factory(value)

    def _validate_dynamic_value(self, variable: Variable) -> Any:
        """To make sure the value of a non-const variable, asked for at runtime, is
        fitting for the given ArgumentConfig."""
        # Most of the stuff has already been taken care of when, for example,
        # the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the
        # first time. In the first case we get a number 1, and in the second a string
        # `bar` without quotes
        assert not variable.const
        value = variable.get_value()

        if self.is_symbol:
            # value might be int `1`, which is a valid symbol for `key(1)`
            value = str(value)
            self.assert_is_symbol(value)
            return value

        if None in self.types and value is None:
            return value

        if type(value) in self.types:
            return value

        if type(value) not in self.types and str in self.types:
            # `set` cannot make predictions where the variable will be used. Make sure
            # the type is compatible, and turn numbers back into strings if need be.
            return str(value)

        # If the value is "1", we don't attempt to parse it as a number. This being a
        # string means that something like `set(foo, "1")` was used, which enforces a
        # string datatype. Otherwise, `set` would have already turned it into an int.

        raise self._type_error_factory(value)

    def _is_numeric_string(self, value: str) -> bool:
        """Check if the value can be turned into a number."""
        try:
            float(value)
            return True
        except ValueError:
            return False

    def _type_error_factory(self, value: Any) -> MacroError:
        formatted_types: List[str] = []

        for type_ in self.types:
            if type_ is None:
                formatted_types.append("None")
            else:
                formatted_types.append(type_.__name__)

        return MacroError(
            msg=(
                f'Expected "{self.name}" to be one of {formatted_types}, but got '
                f'{type(value).__name__} "{value}"'
            )
        )