From cf01699c5fba4d24bb81cce5c2f8bf024ffee265 Mon Sep 17 00:00:00 2001 From: Stephen Kitt Date: Sat, 5 Oct 2024 10:47:01 +0200 Subject: [PATCH 1/3] Use packaging.version instead of pkg_resources (#966) pkg_resources is deprecated, this removes one set of uses. Signed-off-by: Stephen Kitt --- inputremapper/configs/mapping.py | 6 ++---- inputremapper/configs/migrations.py | 22 +++++++++++----------- tests/unit/test_migrations.py | 6 +++--- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py index f98c320d3..dbe160c1f 100644 --- a/inputremapper/configs/mapping.py +++ b/inputremapper/configs/mapping.py @@ -21,9 +21,9 @@ import enum from collections import namedtuple +from packaging import version from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict -import pkg_resources from evdev.ecodes import ( EV_KEY, EV_ABS, @@ -84,9 +84,7 @@ # TODO: remove pydantic VERSION check as soon as we no longer support # Ubuntu 20.04 and with it the ancient pydantic 1.2 -needs_workaround = pkg_resources.parse_version( - str(VERSION) -) < pkg_resources.parse_version("1.7.1") +needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1") EMPTY_MAPPING_NAME: str = _("Empty Mapping") diff --git a/inputremapper/configs/migrations.py b/inputremapper/configs/migrations.py index 9b65cc73d..7c258e711 100644 --- a/inputremapper/configs/migrations.py +++ b/inputremapper/configs/migrations.py @@ -30,9 +30,9 @@ import re import shutil from pathlib import Path +from packaging import version from typing import Iterator, Tuple, Dict, List -import pkg_resources from evdev.ecodes import ( EV_KEY, EV_ABS, @@ -86,15 +86,15 @@ def config_version(): config_path = os.path.join(CONFIG_PATH, "config.json") if not os.path.exists(config_path): - return pkg_resources.parse_version("0.0.0") + return version.parse("0.0.0") with open(config_path, "r") as file: config = json.load(file) if "version" in config.keys(): - return pkg_resources.parse_version(config["version"]) + return version.parse(config["version"]) - return pkg_resources.parse_version("0.0.0") + return version.parse("0.0.0") def _config_suffix(): @@ -481,27 +481,27 @@ def migrate(): v = config_version() - if v < pkg_resources.parse_version("0.4.0"): + if v < version.parse("0.4.0"): _config_suffix() _preset_path() - if v < pkg_resources.parse_version("1.2.2"): + if v < version.parse("1.2.2"): _mapping_keys() - if v < pkg_resources.parse_version("1.4.0"): + if v < version.parse("1.4.0"): global_uinputs.prepare_all() _add_target() - if v < pkg_resources.parse_version("1.4.1"): + if v < version.parse("1.4.1"): _otherwise_to_else() - if v < pkg_resources.parse_version("1.5.0"): + if v < version.parse("1.5.0"): _remove_logs() - if v < pkg_resources.parse_version("1.6.0-beta"): + if v < version.parse("1.6.0-beta"): _convert_to_individual_mappings() # add new migrations here - if v < pkg_resources.parse_version(VERSION): + if v < version.parse(VERSION): _update_version() diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index fa56abbb4..da6946bcf 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -20,7 +20,7 @@ import unittest import shutil import json -import pkg_resources +from packaging import version from evdev.ecodes import ( EV_KEY, @@ -380,7 +380,7 @@ def test_add_version(self): file.write("{}") migrate() - self.assertEqual(pkg_resources.parse_version(VERSION), config_version()) + self.assertEqual(version.parse(VERSION), config_version()) def test_update_version(self): path = os.path.join(CONFIG_PATH, "config.json") @@ -389,7 +389,7 @@ def test_update_version(self): json.dump({"version": "0.1.0"}, file) migrate() - self.assertEqual(pkg_resources.parse_version(VERSION), config_version()) + self.assertEqual(version.parse(VERSION), config_version()) def test_config_version(self): path = os.path.join(CONFIG_PATH, "config.json") From 4da1f7f20e01d8c7cfa67cdc3ad6a068079d78bb Mon Sep 17 00:00:00 2001 From: KAGEYAM4 <75798544+KAGEYAM4@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:02:00 +0530 Subject: [PATCH 2/3] Docs: clears misunderstanding on where Macros can be used (#973) * Docs: clears misunderstanding on where Macros can be used * Apply suggestions from code review Co-authored-by: Tobi <28510156+sezanzeb@users.noreply.github.com> --------- Co-authored-by: Tobi <28510156+sezanzeb@users.noreply.github.com> --- readme/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme/usage.md b/readme/usage.md index 422bb78bf..792e44517 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -13,10 +13,10 @@ as shown in the screenshots below.

-In the text input field, type the key to which you would like to map this input. +In the "Output" textbox on the right, type the key to which you would like to map this input. More information about the possible mappings can be found in [examples.md](./examples.md) and [below](#key-names). You can also write your macro -into the text input field. If you hit enter, it will switch to a multiline-editor with +into the "Output" textbox. If you hit enter, it will switch to a multiline-editor with line-numbers. Changes are saved automatically. Press the "Apply" button to activate (inject) the From 08226a24f940a4a0c62d61bb4f984c7fc593a08d Mon Sep 17 00:00:00 2001 From: Tobi <28510156+sezanzeb@users.noreply.github.com> Date: Wed, 16 Oct 2024 21:15:11 +0200 Subject: [PATCH 3/3] Improved test setup (#960) --- .github/workflows/test.yml | 4 +- .run/All Tests.run.xml | 17 - .run/Only Integration Tests.run.xml | 17 - .run/Only Unit Tests.run.xml | 17 - bin/input-remapper-control | 465 +++++----- bin/input-remapper-gtk | 43 +- bin/input-remapper-reader-service | 21 +- bin/input-remapper-service | 26 +- inputremapper/configs/base_config.py | 4 +- inputremapper/configs/data.py | 4 +- inputremapper/configs/global_config.py | 14 +- inputremapper/configs/input_config.py | 4 +- inputremapper/configs/mapping.py | 9 +- inputremapper/configs/migrations.py | 807 +++++++++--------- inputremapper/configs/paths.py | 248 +++--- inputremapper/configs/preset.py | 8 +- inputremapper/configs/system_mapping.py | 11 +- inputremapper/configs/validation_errors.py | 6 +- inputremapper/daemon.py | 21 +- inputremapper/exceptions.py | 2 +- inputremapper/groups.py | 14 +- inputremapper/gui/autocompletion.py | 6 +- inputremapper/gui/components/common.py | 2 +- inputremapper/gui/components/device_groups.py | 4 +- inputremapper/gui/components/editor.py | 2 +- inputremapper/gui/components/main.py | 3 +- inputremapper/gui/components/presets.py | 4 +- inputremapper/gui/controller.py | 12 +- inputremapper/gui/data_manager.py | 49 +- inputremapper/gui/gettext.py | 2 +- inputremapper/gui/messages/message_broker.py | 4 +- inputremapper/gui/messages/message_data.py | 2 +- inputremapper/gui/messages/message_types.py | 2 +- inputremapper/gui/reader_client.py | 24 +- inputremapper/gui/reader_service.py | 10 +- inputremapper/gui/user_interface.py | 16 +- inputremapper/gui/utils.py | 7 +- inputremapper/injection/context.py | 6 +- inputremapper/injection/event_reader.py | 6 +- inputremapper/injection/global_uinputs.py | 47 +- inputremapper/injection/injector.py | 4 +- inputremapper/injection/macros/macro.py | 10 +- inputremapper/injection/macros/parse.py | 4 +- .../injection/mapping_handlers/__init__.py | 2 +- .../mapping_handlers/abs_to_abs_handler.py | 6 +- .../mapping_handlers/abs_to_btn_handler.py | 3 +- .../mapping_handlers/abs_to_rel_handler.py | 4 +- .../mapping_handlers/axis_switch_handler.py | 8 +- .../mapping_handlers/axis_transform.py | 3 +- .../mapping_handlers/combination_handler.py | 5 +- .../mapping_handlers/hierarchy_handler.py | 3 +- .../injection/mapping_handlers/key_handler.py | 6 +- .../mapping_handlers/macro_handler.py | 4 +- .../mapping_handlers/mapping_handler.py | 4 +- .../mapping_handlers/mapping_parser.py | 4 +- .../mapping_handlers/null_handler.py | 2 +- .../mapping_handlers/rel_to_abs_handler.py | 6 +- .../mapping_handlers/rel_to_btn_handler.py | 4 +- .../mapping_handlers/rel_to_rel_handler.py | 6 +- inputremapper/injection/numlock.py | 4 +- inputremapper/input_event.py | 40 +- inputremapper/ipc/__init__.py | 2 +- inputremapper/ipc/pipe.py | 12 +- inputremapper/ipc/shared_dict.py | 4 +- inputremapper/ipc/socket.py | 10 +- inputremapper/logger.py | 336 -------- inputremapper/logging/__init__.py | 0 inputremapper/logging/formatter.py | 143 ++++ inputremapper/logging/logger.py | 183 ++++ inputremapper/user.py | 64 +- inputremapper/utils.py | 2 +- readme/development.md | 18 +- readme/usage.md | 10 +- setup.py | 2 +- tests/__init__.py | 2 - tests/integration/__init__.py | 1 - tests/integration/test_components.py | 39 +- tests/integration/test_daemon.py | 13 +- tests/integration/test_data.py | 35 +- tests/integration/test_gui.py | 235 ++--- tests/integration/test_numlock.py | 13 +- tests/integration/test_user_interface.py | 4 +- tests/lib/cleanup.py | 13 +- tests/lib/constants.py | 2 +- tests/lib/fixture_pipes.py | 36 + tests/lib/fixtures.py | 12 +- tests/lib/global_uinputs.py | 2 +- tests/lib/is_service_running.py | 32 + tests/lib/logger.py | 6 +- tests/lib/patches.py | 51 +- tests/lib/pipes.py | 10 +- tests/lib/project_root.py | 37 + tests/lib/{stuff.py => spy.py} | 7 +- tests/lib/test_setup.py | 111 +++ tests/lib/tmp.py | 2 +- tests/lib/xmodmap.py | 2 +- tests/test.py | 167 ---- tests/unit/__init__.py | 2 - tests/unit/test_config.py | 17 +- tests/unit/test_context.py | 21 +- tests/unit/test_control.py | 159 ++-- tests/unit/test_controller.py | 219 +++-- tests/unit/test_daemon.py | 66 +- tests/unit/test_data_manager.py | 102 +-- .../test_axis_transformation.py | 7 +- .../test_event_pipeline.py | 11 +- .../test_mapping_handlers.py | 15 +- tests/unit/test_event_reader.py | 10 +- tests/unit/test_global_uinputs.py | 17 +- tests/unit/test_groups.py | 23 +- tests/unit/test_injector.py | 59 +- tests/unit/test_input_config.py | 5 +- tests/unit/test_input_event.py | 4 +- tests/unit/test_ipc.py | 19 +- tests/unit/test_logger.py | 43 +- tests/unit/test_macros.py | 10 +- tests/unit/test_mapping.py | 5 +- tests/unit/test_message_broker.py | 5 +- tests/unit/test_migrations.py | 233 ++--- tests/unit/test_paths.py | 47 +- tests/unit/test_preset.py | 52 +- tests/unit/test_reader.py | 21 +- tests/unit/test_system_mapping.py | 16 +- tests/unit/test_test.py | 28 +- tests/unit/test_user.py | 21 +- tests/unit/test_util.py | 4 +- 126 files changed, 2667 insertions(+), 2294 deletions(-) delete mode 100644 .run/All Tests.run.xml delete mode 100644 .run/Only Integration Tests.run.xml delete mode 100644 .run/Only Unit Tests.run.xml delete mode 100644 inputremapper/logger.py create mode 100644 inputremapper/logging/__init__.py create mode 100644 inputremapper/logging/formatter.py create mode 100644 inputremapper/logging/logger.py create mode 100644 tests/lib/fixture_pipes.py create mode 100644 tests/lib/is_service_running.py create mode 100644 tests/lib/project_root.py rename tests/lib/{stuff.py => spy.py} (89%) create mode 100644 tests/lib/test_setup.py delete mode 100644 tests/test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d43023e35..6faec95a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository strategy: matrix: - python-version: ["3.7", "3.11"] # min and max supported versions? + python-version: ["3.8", "3.12"] # min and max supported versions? steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -34,4 +34,4 @@ jobs: # try this if input-remappers data cannot be found, and set DATA_DIR to a matching directory # find / -type f -name "input-remapper.glade" - sudo -E python tests/test.py --start-dir unit + sudo -E python -m unittest discover tests/unit diff --git a/.run/All Tests.run.xml b/.run/All Tests.run.xml deleted file mode 100644 index e442be10d..000000000 --- a/.run/All Tests.run.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.run/Only Integration Tests.run.xml b/.run/Only Integration Tests.run.xml deleted file mode 100644 index 704925419..000000000 --- a/.run/Only Integration Tests.run.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.run/Only Unit Tests.run.xml b/.run/Only Unit Tests.run.xml deleted file mode 100644 index 941c9b1ab..000000000 --- a/.run/Only Unit Tests.run.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - \ No newline at end of file diff --git a/bin/input-remapper-control b/bin/input-remapper-control index 639ffdb3f..499e1262d 100755 --- a/bin/input-remapper-control +++ b/bin/input-remapper-control @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -21,93 +21,111 @@ """Control the dbus service from the command line.""" -import os -import sys import argparse import logging +import os import subprocess +import sys +from enum import Enum +from typing import Union, Optional -from inputremapper.logger import logger, update_verbosity, log_info -from inputremapper.configs.migrations import migrate from inputremapper.configs.global_config import global_config +from inputremapper.configs.migrations import Migrations +from inputremapper.logging.logger import logger + +# TODO this sounds like something DI would fix, making everything independent of +# input order: # import inputremapper modules as late as possible to make sure the correct # log level is applied before anything is logged -AUTOLOAD = 'autoload' -START = 'start' -STOP = 'stop' -STOP_ALL = 'stop-all' -HELLO = 'hello' - -# internal stuff that the gui uses -START_DAEMON = 'start-daemon' -START_READER_SERVICE = 'start-reader-service' +class Commands(Enum): + AUTOLOAD = "autoload" + START = "start" + STOP = "stop" + STOP_ALL = "stop-all" + HELLO = "hello" -def run(cmd): - """Run and log a command.""" - logger.info('Running `%s`...', cmd) - code = os.system(cmd) - if code != 0: - logger.error('Failed. exit code %d', code) +class Internals(Enum): + # internal stuff that the gui uses + START_DAEMON = "start-daemon" + START_READER_SERVICE = "start-reader-service" -COMMANDS = [AUTOLOAD, START, STOP, HELLO, STOP_ALL] +class Options: + command: Union[Commands, Internals] + config_dir: str + preset: str + device: str + list_devices: bool + key_names: str + debug: bool + version: str -INTERNALS = [START_DAEMON, START_READER_SERVICE] +class InputRemapperControl: + def run(self, cmd) -> None: + """Run and log a command.""" + logger.info("Running `%s`...", cmd) + code = os.system(cmd) + if code != 0: + logger.error("Failed. exit code %d", code) -def utils(options): - """Listing names, tasks that don't require a running daemon.""" - if options.list_devices: + def list_devices(self): logger.setLevel(logging.ERROR) from inputremapper.groups import groups + for group in groups: print(group.key) - if options.key_names: + def list_key_names(self): from inputremapper.configs.system_mapping import system_mapping - print('\n'.join(system_mapping.list_names())) + print("\n".join(system_mapping.list_names())) -def communicate(options, daemon): - """Commands that require a running daemon.""" - # import stuff late to make sure the correct log level is applied - # before anything is logged - from inputremapper.groups import groups - from inputremapper.configs.paths import USER + def communicate( + self, + command: Commands, + device: str, + config_dir: Optional[str], + preset: str, + ) -> None: + """Commands that require a running daemon.""" + if self.daemon is None: + # probably broken tests + logger.error("Daemon missing") + sys.exit(5) - def require_group(): - if options.device is None: - logger.error('--device missing') - sys.exit(3) + if config_dir is not None: + self._load_config(config_dir) - if options.device.startswith('/dev'): - group = groups.find(path=options.device) - else: - group = groups.find(key=options.device) + self.ensure_migrated() - if group is None: - logger.error( - 'Device "%s" is unknown or not an appropriate input device', - options.device - ) - sys.exit(4) + if command == Commands.AUTOLOAD.value: + self._autoload(device) - return group + if command == Commands.START.value: + self._start(device, preset) + + if command == Commands.STOP.value: + self._stop(device) - if daemon is None: - # probably broken tests - logger.error('Daemon missing') - sys.exit(5) + if command == Commands.STOP_ALL.value: + self.daemon.stop_all() - if options.config_dir is not None: - path = os.path.abspath(os.path.expanduser(os.path.join( - options.config_dir, - 'config.json' - ))) + if command == Commands.HELLO.value: + self._hello() + + def _hello(self): + response = self.daemon.hello("hello") + logger.info('Daemon answered with "%s"', response) + + def _load_config(self, config_dir: str) -> None: + path = os.path.abspath( + os.path.expanduser(os.path.join(config_dir, "config.json")) + ) if not os.path.exists(path): logger.error('"%s" does not exist', path) sys.exit(6) @@ -115,127 +133,155 @@ def communicate(options, daemon): logger.info('Using config from "%s" instead', path) global_config.load_config(path) - if USER != 'root': - # Might be triggered by udev, so skip the root user. - # This will also refresh the config of the daemon if the user changed - # it in the meantime. - # config_dir is either the cli arg or the default path in home - config_dir = os.path.dirname(global_config.path) - daemon.set_config_dir(config_dir) - migrate() + def ensure_migrated(self) -> None: + # import stuff late to make sure the correct log level is applied + # before anything is logged + # TODO since imports shouldn't run any code, this is fixed by moving towards DI + from inputremapper.user import UserUtils - if options.command == AUTOLOAD: - # if device was specified, autoload for that one. if None autoload - # for all devices. - if options.device is None: - logger.info('Autoloading all') - # timeout is not documented, for more info see - # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py - daemon.autoload(timeout=10) - else: - group = require_group() - logger.info('Asking daemon to autoload for %s', options.device) - daemon.autoload_single(group.key, timeout=2) + if UserUtils.user != "root": + # Might be triggered by udev, so skip the root user. + # This will also refresh the config of the daemon if the user changed + # it in the meantime. + # config_dir is either the cli arg or the default path in home + config_dir = os.path.dirname(global_config.path) + self.daemon.set_config_dir(config_dir) + Migrations.migrate() + + def _stop(self, device: str) -> None: + group = self._require_group(device) + self.daemon.stop_injecting(group.key) - if options.command == START: - group = require_group() + def _start(self, device: str, preset: str) -> None: + group = self._require_group(device) logger.info( 'Starting injection: "%s", "%s"', - options.device, options.preset + device, + preset, ) - daemon.start_injecting(group.key, options.preset) - - if options.command == STOP: - group = require_group() - daemon.stop_injecting(group.key) - - if options.command == STOP_ALL: - daemon.stop_all() - - if options.command == HELLO: - response = daemon.hello('hello') - logger.info('Daemon answered with "%s"', response) - - -def internals(options): - """Methods that are needed to get the gui to work and that require root. + self.daemon.start_injecting(group.key, preset) - input-remapper-control should be started with sudo or pkexec for this. - """ - debug = ' -d' if options.debug else '' - - if options.command == START_READER_SERVICE: - cmd = f'input-remapper-reader-service{debug}' - elif options.command == START_DAEMON: - cmd = f'input-remapper-service --hide-info{debug}' - else: - return - - # daemonize - cmd = f'{cmd} &' - logger.debug(f'Running `{cmd}`') - os.system(cmd) - - -def _num_logged_in_users(): - """Check how many users are logged in.""" - who = subprocess.run(['who'], stdout=subprocess.PIPE).stdout.decode() - return len([user for user in who.split('\n') if user.strip() != ""]) - - -def _systemd_finished(): - """Check if systemd finished booting.""" - try: - systemd_analyze = subprocess.run(['systemd-analyze'], stdout=subprocess.PIPE) - except FileNotFoundError: - # probably not systemd, lets assume true to not block input-remapper for good - # on certain installations - return True + def _require_group(self, device: str): + # import stuff late to make sure the correct log level is applied + # before anything is logged + # TODO since imports shouldn't run any code, this is fixed by moving towards DI + from inputremapper.groups import groups - if 'finished' in systemd_analyze.stdout.decode(): - # it writes into stderr otherwise or something - return True + if device is None: + logger.error("--device missing") + sys.exit(3) - return False + if device.startswith("/dev"): + group = groups.find(path=device) + else: + group = groups.find(key=device) + if group is None: + logger.error( + 'Device "%s" is unknown or not an appropriate input device', + device, + ) + sys.exit(4) -def boot_finished(): - """Check if booting is completed.""" - # Get as much information as needed to really safely determine if booting up is - # complete. - # - `who` returns an empty list on some system for security purposes - # - something might be broken and might make systemd_analyze fail: - # Bootup is not yet finished - # (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0). - # Please try again later. - # Hint: Use 'systemctl list-jobs' to see active jobs - if _systemd_finished(): - logger.debug('System is booted') - return True + return group - if _num_logged_in_users() > 0: - logger.debug('User(s) logged in') - return True + def _autoload(self, device: str) -> None: + # if device was specified, autoload for that one. if None autoload + # for all devices. + if device is None: + logger.info("Autoloading all") + # timeout is not documented, for more info see + # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py + self.daemon.autoload(timeout=10) + else: + group = self._require_group(device) + logger.info("Asking daemon to autoload for %s", device) + self.daemon.autoload_single(group.key, timeout=2) - return False + def internals(self, command: Internals, debug: True) -> None: + """Methods that are needed to get the gui to work and that require root. + input-remapper-control should be started with sudo or pkexec for this. + """ + debug = " -d" if debug else "" -def main(options): + if command == Internals.START_READER_SERVICE.value: + cmd = f"input-remapper-reader-service{debug}" + elif command == Internals.START_DAEMON.value: + cmd = f"input-remapper-service --hide-info{debug}" + else: + return + + # daemonize + cmd = f"{cmd} &" + logger.debug(f"Running `{cmd}`") + os.system(cmd) + + def _num_logged_in_users(self) -> int: + """Check how many users are logged in.""" + who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode() + return len([user for user in who.split("\n") if user.strip() != ""]) + + def _is_systemd_finished(self) -> bool: + """Check if systemd finished booting.""" + try: + systemd_analyze = subprocess.run( + ["systemd-analyze"], stdout=subprocess.PIPE + ) + except FileNotFoundError: + # probably not systemd, lets assume true to not block input-remapper for good + # on certain installations + return True + + if "finished" in systemd_analyze.stdout.decode(): + # it writes into stderr otherwise or something + return True + + return False + + def boot_finished(self) -> bool: + """Check if booting is completed.""" + # Get as much information as needed to really safely determine if booting up is + # complete. + # - `who` returns an empty list on some system for security purposes + # - something might be broken and might make systemd_analyze fail: + # Bootup is not yet finished + # (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0). + # Please try again later. + # Hint: Use 'systemctl list-jobs' to see active jobs + if self._is_systemd_finished(): + logger.debug("System is booted") + return True + + if self._num_logged_in_users() > 0: + logger.debug("User(s) logged in") + return True + + return False + + def set_daemon(self, daemon): + # TODO DI? + self.daemon = daemon + + +def main(options: Options) -> None: + input_remapper_control = InputRemapperControl() if options.debug: - update_verbosity(True) + logger.update_verbosity(True) if options.version: - log_info() + logger.log_info() return logger.debug('Call for "%s"', sys.argv) - from inputremapper.configs.paths import USER - boot_finished_ = boot_finished() - is_root = USER == "root" - is_autoload = options.command == AUTOLOAD + from inputremapper.user import UserUtils + + boot_finished_ = input_remapper_control.boot_finished() + is_root = UserUtils.user == "root" + is_autoload = options.command == Commands.AUTOLOAD config_dir_set = options.config_dir is not None if is_autoload and not boot_finished_ and is_root and not config_dir_set: # this is probably happening during boot time and got @@ -244,70 +290,109 @@ def main(options): # of confusing service logs. And also avoids potential for problems when # input-remapper-control stresses about evdev, dbus and multiprocessing already # while the system hasn't even booted completely. - logger.warning('Skipping autoload command without a logged in user') + logger.warning("Skipping autoload command without a logged in user") return if options.command is not None: - if options.command in INTERNALS: - internals(options) - elif options.command in COMMANDS: + if options.command in Internals: + input_remapper_control.internals(options.command, options.debug) + elif options.command in Commands: from inputremapper.daemon import Daemon + daemon = Daemon.connect(fallback=False) - communicate(options, daemon) + + input_remapper_control.set_daemon(daemon) + + input_remapper_control.communicate( + options.command, + options.device, + options.config_dir, + options.preset, + ) else: logger.error('Unknown command "%s"', options.command) else: - utils(options) + if options.list_devices: + input_remapper_control.list_devices() + + if options.key_names: + input_remapper_control.key_names() if options.command: - logger.info('Done') + logger.info("Done") -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument( - '--command', action='store', dest='command', help=( - 'Communicate with the daemon. Available commands are start, ' - 'stop, autoload, hello or stop-all' - ), default=None, metavar='NAME' + "--command", + action="store", + dest="command", + help=( + "Communicate with the daemon. Available commands are start, " + "stop, autoload, hello or stop-all" + ), + default=None, + metavar="NAME", ) parser.add_argument( - '--config-dir', action='store', dest='config_dir', + "--config-dir", + action="store", + dest="config_dir", help=( - 'path to the config directory containing config.json, ' - 'xmodmap.json and the presets folder. ' - 'defaults to ~/.config/input-remapper/' + "path to the config directory containing config.json, " + "xmodmap.json and the presets folder. " + "defaults to ~/.config/input-remapper/" ), - default=None, metavar='PATH', + default=None, + metavar="PATH", ) parser.add_argument( - '--preset', action='store', dest='preset', - help='The filename of the preset without the .json extension.', - default=None, metavar='NAME', + "--preset", + action="store", + dest="preset", + help="The filename of the preset without the .json extension.", + default=None, + metavar="NAME", ) parser.add_argument( - '--device', action='store', dest='device', - help='One of the device keys from --list-devices', - default=None, metavar='NAME' + "--device", + action="store", + dest="device", + help="One of the device keys from --list-devices", + default=None, + metavar="NAME", ) parser.add_argument( - '--list-devices', action='store_true', dest='list_devices', - help='List available device keys and exit', - default=False + "--list-devices", + action="store_true", + dest="list_devices", + help="List available device keys and exit", + default=False, ) parser.add_argument( - '--symbol-names', action='store_true', dest='key_names', - help='Print all available names for the preset', - default=False + "--symbol-names", + action="store_true", + dest="key_names", + help="Print all available names for the preset", + default=False, ) parser.add_argument( - '-d', '--debug', action='store_true', dest='debug', - help='Displays additional debug information', - default=False + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, ) parser.add_argument( - '-v', '--version', action='store_true', dest='version', - help='Print the version and exit', default=False + "-v", + "--version", + action="store_true", + dest="version", + help="Print the version and exit", + default=False, ) - main(parser.parse_args(sys.argv[1:])) + options = parser.parse_args(sys.argv[1:]) + main(options) diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk index 06cdf4977..40cc25630 100755 --- a/bin/input-remapper-gtk +++ b/bin/input-remapper-gtk @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -22,14 +22,15 @@ from __future__ import annotations -import sys import atexit +import sys from argparse import ArgumentParser import gi -gi.require_version('Gtk', '3.0') -gi.require_version('GLib', '2.0') -gi.require_version('GtkSource', '4') + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") from gi.repository import Gtk # https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096 @@ -38,7 +39,7 @@ Gtk.init() from inputremapper.gui.gettext import _, LOCALE_DIR from inputremapper.gui.reader_service import ReaderService from inputremapper.daemon import DaemonProxy -from inputremapper.logger import logger, update_verbosity, log_info +from inputremapper.logging.logger import logger def start_processes() -> DaemonProxy: @@ -53,19 +54,24 @@ def start_processes() -> DaemonProxy: return Daemon.connect() -if __name__ == '__main__': +if __name__ == "__main__": parser = ArgumentParser() parser.add_argument( - '-d', '--debug', action='store_true', dest='debug', - help=_('Displays additional debug information'), - default=False + "-d", + "--debug", + action="store_true", + dest="debug", + help=_("Displays additional debug information"), + default=False, ) options = parser.parse_args(sys.argv[1:]) - update_verbosity(options.debug) - log_info('input-remapper-gtk') - logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) + logger.update_verbosity(options.debug) + logger.log_info("input-remapper-gtk") + logger.debug("Using locale directory: {}".format(LOCALE_DIR)) + # TODO no. Importing files shouldn't run any code, so it shouldn't matter when the + # log-level is set. # import input-remapper stuff after setting the log verbosity from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.configs.system_mapping import system_mapping @@ -77,9 +83,9 @@ if __name__ == '__main__': from inputremapper.gui.reader_client import ReaderClient from inputremapper.daemon import Daemon from inputremapper.configs.global_config import GlobalConfig - from inputremapper.configs.migrations import migrate + from inputremapper.configs.migrations import Migrations - migrate() + Migrations.migrate() message_broker = MessageBroker() @@ -89,7 +95,12 @@ if __name__ == '__main__': daemon = start_processes() data_manager = DataManager( - message_broker, GlobalConfig(), reader_client, daemon, GlobalUInputs(), system_mapping + message_broker, + GlobalConfig(), + reader_client, + daemon, + GlobalUInputs(), + system_mapping, ) controller = Controller(message_broker, data_manager) user_interface = UserInterface(message_broker, controller) diff --git a/bin/input-remapper-reader-service b/bin/input-remapper-reader-service index c8ca6ba6c..e564b0426 100755 --- a/bin/input-remapper-reader-service +++ b/bin/input-remapper-reader-service @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -21,26 +21,29 @@ """Starts the root reader-service.""" import asyncio -import os -import sys import atexit +import os import signal +import sys from argparse import ArgumentParser -from inputremapper.logger import update_verbosity from inputremapper.groups import _Groups +from inputremapper.logging.logger import logger - -if __name__ == '__main__': +if __name__ == "__main__": parser = ArgumentParser() parser.add_argument( - '-d', '--debug', action='store_true', dest='debug', - help='Displays additional debug information', default=False + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, ) options = parser.parse_args(sys.argv[1:]) - update_verbosity(options.debug) + logger.update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.gui.reader_service import ReaderService diff --git a/bin/input-remapper-service b/bin/input-remapper-service index 6d588e4ae..7e3cc2d9e 100755 --- a/bin/input-remapper-service +++ b/bin/input-remapper-service @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -25,29 +25,35 @@ import sys from argparse import ArgumentParser -from inputremapper.logger import update_verbosity, log_info +from inputremapper.logging.logger import logger - -if __name__ == '__main__': +if __name__ == "__main__": parser = ArgumentParser() parser.add_argument( - '-d', '--debug', action='store_true', dest='debug', - help='Displays additional debug information', default=False + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, ) parser.add_argument( - '--hide-info', action='store_true', dest='hide_info', - help='Don\'t display version information', default=False + "--hide-info", + action="store_true", + dest="hide_info", + help="Don't display version information", + default=False, ) options = parser.parse_args(sys.argv[1:]) - update_verbosity(options.debug) + logger.update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.daemon import Daemon if not options.hide_info: - log_info('input-remapper-service') + logger.log_info("input-remapper-service") daemon = Daemon() daemon.publish() diff --git a/inputremapper/configs/base_config.py b/inputremapper/configs/base_config.py index 85a5de588..a6a4407e6 100644 --- a/inputremapper/configs/base_config.py +++ b/inputremapper/configs/base_config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -22,7 +22,7 @@ import copy from typing import Union, List, Optional, Callable, Any -from inputremapper.logger import logger, VERSION +from inputremapper.logging.logger import logger, VERSION NONE = "none" diff --git a/inputremapper/configs/data.py b/inputremapper/configs/data.py index 7635d2510..a1eca35dc 100644 --- a/inputremapper/configs/data.py +++ b/inputremapper/configs/data.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -27,7 +27,7 @@ import pkg_resources -from inputremapper.logger import logger +from inputremapper.logging.logger import logger logged = False diff --git a/inputremapper/configs/global_config.py b/inputremapper/configs/global_config.py index 9513151c2..3c08a5cd0 100644 --- a/inputremapper/configs/global_config.py +++ b/inputremapper/configs/global_config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -24,8 +24,9 @@ from typing import Optional from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG -from inputremapper.configs.paths import CONFIG_PATH, USER, touch -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.user import UserUtils +from inputremapper.logging.logger import logger MOUSE = "mouse" WHEEL = "wheel" @@ -42,7 +43,7 @@ class GlobalConfig(ConfigBase): """ def __init__(self): - self.path = os.path.join(CONFIG_PATH, "config.json") + self.path = os.path.join(PathUtils.config_path(), "config.json") super().__init__() def get_dir(self) -> str: @@ -118,11 +119,11 @@ def load_config(self, path: Optional[str] = None): def _save_config(self): """Save the config to the file system.""" - if USER == "root": + if UserUtils.user == "root": logger.debug("Skipping config file creation for the root user") return - touch(self.path) + PathUtils.touch(self.path) with open(self.path, "w") as file: json.dump(self._config, file, indent=4) @@ -130,4 +131,5 @@ def _save_config(self): file.write("\n") +# TODO DI global_config = GlobalConfig() diff --git a/inputremapper/configs/input_config.py b/inputremapper/configs/input_config.py index 1424962b0..5648859a7 100644 --- a/inputremapper/configs/input_config.py +++ b/inputremapper/configs/input_config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -33,7 +33,7 @@ from inputremapper.configs.system_mapping import system_mapping from inputremapper.gui.messages.message_types import MessageType -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name # having shift in combinations modifies the configured output, diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py index dbe160c1f..819e8238f 100644 --- a/inputremapper/configs/mapping.py +++ b/inputremapper/configs/mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -73,11 +73,10 @@ SymbolAndCodeMismatchError, MissingMacroOrKeyError, MissingOutputAxisError, - MacroParsingError, ) from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_types import MessageType -from inputremapper.injection.global_uinputs import can_default_uinput_emit +from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.parse import is_this_a_macro, parse from inputremapper.utils import get_evdev_constant_name @@ -391,7 +390,9 @@ def validate_symbol(cls, values): raise OutputSymbolUnknownError(symbol) target = values.get("target_uinput") - if target is not None and not can_default_uinput_emit(target, EV_KEY, code): + if target is not None and not GlobalUInputs.can_default_uinput_emit( + target, EV_KEY, code + ): raise SymbolNotAvailableInTargetError(symbol, target) return values diff --git a/inputremapper/configs/migrations.py b/inputremapper/configs/migrations.py index 7c258e711..84d762959 100644 --- a/inputremapper/configs/migrations.py +++ b/inputremapper/configs/migrations.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,7 +19,7 @@ """Migration functions. -Only write changes to disk, if there actually are changes. Otherwise file-modification +Only write changes to disk, if there actually are changes. Otherwise, file-modification dates are destroyed. """ from __future__ import annotations @@ -31,7 +31,7 @@ import shutil from pathlib import Path from packaging import version -from typing import Iterator, Tuple, Dict, List +from typing import Iterator, Tuple, Dict, List, Optional, TypedDict from evdev.ecodes import ( EV_KEY, @@ -49,459 +49,476 @@ from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping, UIMapping -from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.macros.parse import is_this_a_macro -from inputremapper.logger import logger, VERSION -from inputremapper.user import HOME +from inputremapper.logging.logger import logger, VERSION +from inputremapper.user import UserUtils -def all_presets() -> Iterator[Tuple[os.PathLike, Dict | List]]: - """Get all presets for all groups as list.""" - if not os.path.exists(get_preset_path()): - return +class Config(TypedDict): + input_combination: Optional[InputCombination] + target_uinput: str + output_type: int + output_code: Optional[int] - preset_path = Path(get_preset_path()) - for folder in preset_path.iterdir(): - if not folder.is_dir(): - continue - for preset in folder.iterdir(): - if preset.suffix != ".json": - continue +class Migrations: + @staticmethod + def migrate(): + """Migrate config files to the current release.""" - try: - with open(preset, "r") as f: - preset_structure = json.load(f) - yield preset, preset_structure - except json.decoder.JSONDecodeError: - logger.warning('Invalid json format in preset "%s"', preset) - continue + Migrations._rename_to_input_remapper() + Migrations._copy_to_v2() -def config_version(): - """Get the version string in config.json as packaging.Version object.""" - config_path = os.path.join(CONFIG_PATH, "config.json") + v = Migrations.config_version() - if not os.path.exists(config_path): - return version.parse("0.0.0") + if v < version.parse("0.4.0"): + Migrations._config_suffix() + Migrations._preset_path() - with open(config_path, "r") as file: - config = json.load(file) + if v < version.parse("1.2.2"): + Migrations._mapping_keys() - if "version" in config.keys(): - return version.parse(config["version"]) + if v < version.parse("1.4.0"): + global_uinputs.prepare_all() + Migrations._add_target() - return version.parse("0.0.0") + if v < version.parse("1.4.1"): + Migrations._otherwise_to_else() + if v < version.parse("1.5.0"): + Migrations._remove_logs() -def _config_suffix(): - """Append the .json suffix to the config file.""" - deprecated_path = os.path.join(CONFIG_PATH, "config") - config_path = os.path.join(CONFIG_PATH, "config.json") - if os.path.exists(deprecated_path) and not os.path.exists(config_path): - logger.info('Moving "%s" to "%s"', deprecated_path, config_path) - os.rename(deprecated_path, config_path) + if v < version.parse("1.6.0-beta"): + Migrations._convert_to_individual_mappings() + # add new migrations here -def _preset_path(): - """Migrate the folder structure from < 0.4.0. + if v < version.parse(VERSION): + Migrations._update_version() - Move existing presets into the new subfolder 'presets' - """ - new_preset_folder = os.path.join(CONFIG_PATH, "presets") - if os.path.exists(get_preset_path()) or not os.path.exists(CONFIG_PATH): - return + @staticmethod + def all_presets() -> Iterator[Tuple[os.PathLike, Dict | List]]: + """Get all presets for all groups as list.""" + if not os.path.exists(PathUtils.get_preset_path()): + return - logger.info("Migrating presets from < 0.4.0...") - groups = os.listdir(CONFIG_PATH) - mkdir(get_preset_path()) - for group in groups: - path = os.path.join(CONFIG_PATH, group) - if os.path.isdir(path): - target = path.replace(CONFIG_PATH, new_preset_folder) - logger.info('Moving "%s" to "%s"', path, target) - os.rename(path, target) - - logger.info("done") + preset_path = Path(PathUtils.get_preset_path()) + for folder in preset_path.iterdir(): + if not folder.is_dir(): + continue + for preset in folder.iterdir(): + if preset.suffix != ".json": + continue -def _mapping_keys(): - """Update all preset mappings. + try: + with open(preset, "r") as f: + preset_structure = json.load(f) + yield preset, preset_structure + except json.decoder.JSONDecodeError: + logger.warning('Invalid json format in preset "%s"', preset) + continue - Update all keys in preset to include value e.g.: '1,5'->'1,5,1' - """ - for preset, preset_structure in all_presets(): - if isinstance(preset_structure, list): - continue # the preset must be at least 1.6-beta version + @staticmethod + def config_version(): + """Get the version string in config.json as packaging.Version object.""" + config_path = os.path.join(PathUtils.config_path(), "config.json") - changes = 0 - if "mapping" in preset_structure.keys(): - mapping = copy.deepcopy(preset_structure["mapping"]) - for key in mapping.keys(): - if key.count(",") == 1: - preset_structure["mapping"][f"{key},1"] = preset_structure[ - "mapping" - ].pop(key) - changes += 1 + if not os.path.exists(config_path): + return version.parse("0.0.0") - if changes: - with open(preset, "w") as file: - logger.info('Updating mapping keys of "%s"', preset) - json.dump(preset_structure, file, indent=4) - file.write("\n") + with open(config_path, "r") as file: + config = json.load(file) + if "version" in config.keys(): + return version.parse(config["version"]) -def _update_version(): - """Write the current version to the config file.""" - config_file = os.path.join(CONFIG_PATH, "config.json") - if not os.path.exists(config_file): - return + return version.parse("0.0.0") - with open(config_file, "r") as file: - config = json.load(file) + @staticmethod + def _config_suffix(): + """Append the .json suffix to the config file.""" + deprecated_path = os.path.join(PathUtils.config_path(), "config") + config_path = os.path.join(PathUtils.config_path(), "config.json") + if os.path.exists(deprecated_path) and not os.path.exists(config_path): + logger.info('Moving "%s" to "%s"', deprecated_path, config_path) + os.rename(deprecated_path, config_path) + + @staticmethod + def _preset_path(): + """Migrate the folder structure from < 0.4.0. + + Move existing presets into the new subfolder 'presets' + """ + new_preset_folder = os.path.join(PathUtils.config_path(), "presets") + if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists( + PathUtils.config_path() + ): + return + + logger.info("Migrating presets from < 0.4.0...") + groups = os.listdir(PathUtils.config_path()) + PathUtils.mkdir(PathUtils.get_preset_path()) + for group in groups: + path = os.path.join(PathUtils.config_path(), group) + if os.path.isdir(path): + target = path.replace(PathUtils.config_path(), new_preset_folder) + logger.info('Moving "%s" to "%s"', path, target) + os.rename(path, target) + + logger.info("done") + + @staticmethod + def _mapping_keys(): + """Update all preset mappings. + + Update all keys in preset to include value e.g.: '1,5'->'1,5,1' + """ + for preset, preset_structure in Migrations.all_presets(): + if isinstance(preset_structure, list): + continue # the preset must be at least 1.6-beta version + + changes = 0 + if "mapping" in preset_structure.keys(): + mapping = copy.deepcopy(preset_structure["mapping"]) + for key in mapping.keys(): + if key.count(",") == 1: + preset_structure["mapping"][f"{key},1"] = preset_structure[ + "mapping" + ].pop(key) + changes += 1 + + if changes: + with open(preset, "w") as file: + logger.info('Updating mapping keys of "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + @staticmethod + def _update_version(): + """Write the current version to the config file.""" + config_file = os.path.join(PathUtils.config_path(), "config.json") + if not os.path.exists(config_file): + return + + with open(config_file, "r") as file: + config = json.load(file) + + config["version"] = VERSION + with open(config_file, "w") as file: + logger.info('Updating version in config to "%s"', VERSION) + json.dump(config, file, indent=4) + + @staticmethod + def _rename_to_input_remapper(): + """Rename .config/key-mapper to .config/input-remapper.""" + old_config_path = os.path.join(UserUtils.home, ".config/key-mapper") + if not os.path.exists(PathUtils.config_path()) and os.path.exists( + old_config_path + ): + logger.info("Moving %s to %s", old_config_path, PathUtils.config_path()) + shutil.move(old_config_path, PathUtils.config_path()) - config["version"] = VERSION - with open(config_file, "w") as file: - logger.info('Updating version in config to "%s"', VERSION) - json.dump(config, file, indent=4) + @staticmethod + def _find_target(symbol): + """Try to find a uinput with the required capabilities for the symbol.""" + capabilities = {EV_KEY: set(), EV_REL: set()} + if is_this_a_macro(symbol): + # deprecated mechanic, cannot figure this out anymore + # capabilities = parse(symbol).get_capabilities() + return None -def _rename_to_input_remapper(): - """Rename .config/key-mapper to .config/input-remapper.""" - old_config_path = os.path.join(HOME, ".config/key-mapper") - if not os.path.exists(CONFIG_PATH) and os.path.exists(old_config_path): - logger.info("Moving %s to %s", old_config_path, CONFIG_PATH) - shutil.move(old_config_path, CONFIG_PATH) + capabilities[EV_KEY] = {system_mapping.get(symbol)} + if len(capabilities[EV_REL]) > 0: + return "mouse" -def _find_target(symbol): - """Try to find a uinput with the required capabilities for the symbol.""" - capabilities = {EV_KEY: set(), EV_REL: set()} + for name, uinput in global_uinputs.devices.items(): + if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): + return name - if is_this_a_macro(symbol): - # deprecated mechanic, cannot figure this out anymore - # capabilities = parse(symbol).get_capabilities() + logger.info('could not find a suitable target UInput for "%s"', symbol) return None - capabilities[EV_KEY] = {system_mapping.get(symbol)} - - if len(capabilities[EV_REL]) > 0: - return "mouse" - - for name, uinput in global_uinputs.devices.items(): - if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): - return name + @staticmethod + def _add_target(): + """Add the target field to each preset mapping.""" + for preset, preset_structure in Migrations.all_presets(): + if isinstance(preset_structure, list): + continue - logger.info('could not find a suitable target UInput for "%s"', symbol) - return None + if "mapping" not in preset_structure.keys(): + continue + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if isinstance(symbol, list): + continue -def _add_target(): - """Add the target field to each preset mapping.""" - for preset, preset_structure in all_presets(): - if isinstance(preset_structure, list): - continue + target = Migrations._find_target(symbol) + if target is None: + target = "keyboard" + symbol = ( + f"{symbol}\n" + "# Broken mapping:\n" + "# No target can handle all specified keycodes" + ) - if "mapping" not in preset_structure.keys(): - continue + logger.info( + 'Changing target of mapping for "%s" in preset "%s" to "%s"', + key, + preset, + target, + ) + symbol = [symbol, target] + preset_structure["mapping"][key] = symbol + changed = True - changed = False - for key, symbol in preset_structure["mapping"].copy().items(): - if isinstance(symbol, list): + if not changed: continue - target = _find_target(symbol) - if target is None: - target = "keyboard" - symbol = ( - f"{symbol}\n" - "# Broken mapping:\n" - "# No target can handle all specified keycodes" - ) + with open(preset, "w") as file: + logger.info('Adding targets for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") - logger.info( - 'Changing target of mapping for "%s" in preset "%s" to "%s"', - key, - preset, - target, - ) - symbol = [symbol, target] - preset_structure["mapping"][key] = symbol - changed = True + @staticmethod + def _otherwise_to_else(): + """Conditional macros should use an "else" parameter instead of "otherwise".""" + for preset, preset_structure in Migrations.all_presets(): + if isinstance(preset_structure, list): + continue - if not changed: - continue + if "mapping" not in preset_structure.keys(): + continue - with open(preset, "w") as file: - logger.info('Adding targets for "%s"', preset) - json.dump(preset_structure, file, indent=4) - file.write("\n") + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if not is_this_a_macro(symbol[0]): + continue + symbol_before = symbol[0] + symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0]) -def _otherwise_to_else(): - """Conditional macros should use an "else" parameter instead of "otherwise".""" - for preset, preset_structure in all_presets(): - if isinstance(preset_structure, list): - continue + if symbol_before == symbol[0]: + continue - if "mapping" not in preset_structure.keys(): - continue + changed = changed or symbol_before != symbol[0] - changed = False - for key, symbol in preset_structure["mapping"].copy().items(): - if not is_this_a_macro(symbol[0]): - continue + logger.info( + 'Changing mapping for "%s" in preset "%s" to "%s"', + key, + preset, + symbol[0], + ) - symbol_before = symbol[0] - symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0]) + preset_structure["mapping"][key] = symbol - if symbol_before == symbol[0]: + if not changed: continue - changed = changed or symbol_before != symbol[0] + with open(preset, "w") as file: + logger.info('Changing otherwise to else for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") - logger.info( - 'Changing mapping for "%s" in preset "%s" to "%s"', - key, - preset, - symbol[0], + @staticmethod + def _input_combination_from_string(combination_string: str) -> InputCombination: + configs = [] + for event_str in combination_string.split("+"): + type_, code, analog_threshold = event_str.split(",") + configs.append( + { + "type": int(type_), + "code": int(code), + "analog_threshold": int(analog_threshold), + } ) - preset_structure["mapping"][key] = symbol - - if not changed: - continue - - with open(preset, "w") as file: - logger.info('Changing otherwise to else for "%s"', preset) - json.dump(preset_structure, file, indent=4) - file.write("\n") + return InputCombination(configs) + @staticmethod + def _convert_to_individual_mappings() -> None: + """Convert preset.json + from {key: [symbol, target]} + to [{input_combination: ..., output_symbol: symbol, ...}] + """ -def _input_combination_from_string(combination_string: str) -> InputCombination: - configs = [] - for event_str in combination_string.split("+"): - type_, code, analog_threshold = event_str.split(",") - configs.append( - { - "type": int(type_), - "code": int(code), - "analog_threshold": int(analog_threshold), - } - ) - - return InputCombination(configs) - - -def _convert_to_individual_mappings(): - """Convert preset.json - from {key: [symbol, target]} - to [{input_combination: ..., output_symbol: symbol, ...}] - """ - - for old_preset_path, old_preset in all_presets(): - if isinstance(old_preset, list): - continue + for old_preset_path, old_preset in Migrations.all_presets(): + if isinstance(old_preset, list): + continue - migrated_preset = Preset(old_preset_path, UIMapping) - if "mapping" in old_preset.keys(): - for combination, symbol_target in old_preset["mapping"].items(): - logger.info( - 'migrating from "%s: %s" to mapping dict', - combination, - symbol_target, - ) - try: - combination = _input_combination_from_string(combination) - except ValueError: - logger.error( - "unable to migrate mapping with invalid combination %s", + migrated_preset = Preset(old_preset_path, UIMapping) + if "mapping" in old_preset.keys(): + for combination, symbol_target in old_preset["mapping"].items(): + logger.info( + 'migrating from "%s: %s" to mapping dict', combination, + symbol_target, ) - continue - - mapping = UIMapping( - input_combination=combination, - target_uinput=symbol_target[1], - output_symbol=symbol_target[0], - ) - migrated_preset.add(mapping) - - if ( - "gamepad" in old_preset.keys() - and "joystick" in old_preset["gamepad"].keys() - ): - joystick_dict = old_preset["gamepad"]["joystick"] - left_purpose = joystick_dict.get("left_purpose") - right_purpose = joystick_dict.get("right_purpose") - # TODO if pointer_speed is migrated, why is it in my config? - pointer_speed = joystick_dict.get("pointer_speed") - if pointer_speed: - pointer_speed /= 100 - non_linearity = joystick_dict.get("non_linearity") # Todo - x_scroll_speed = joystick_dict.get("x_scroll_speed") - y_scroll_speed = joystick_dict.get("y_scroll_speed") - - cfg = { - "input_combination": None, - "target_uinput": "mouse", - "output_type": EV_REL, - "output_code": None, - } - - if left_purpose == "mouse": - x_config = cfg.copy() - y_config = cfg.copy() - x_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_X)] - ) - y_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_Y)] - ) - x_config["output_code"] = REL_X - y_config["output_code"] = REL_Y - mapping_x = Mapping(**x_config) - mapping_y = Mapping(**y_config) - if pointer_speed: - mapping_x.gain = pointer_speed - mapping_y.gain = pointer_speed - migrated_preset.add(mapping_x) - migrated_preset.add(mapping_y) - - if right_purpose == "mouse": - x_config = cfg.copy() - y_config = cfg.copy() - x_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_RX)] - ) - y_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_RY)] - ) - x_config["output_code"] = REL_X - y_config["output_code"] = REL_Y - mapping_x = Mapping(**x_config) - mapping_y = Mapping(**y_config) + try: + combination = Migrations._input_combination_from_string( + combination + ) + except ValueError: + logger.error( + "unable to migrate mapping with invalid combination %s", + combination, + ) + continue + + mapping = UIMapping( + input_combination=combination, + target_uinput=symbol_target[1], + output_symbol=symbol_target[0], + ) + migrated_preset.add(mapping) + + if ( + "gamepad" in old_preset.keys() + and "joystick" in old_preset["gamepad"].keys() + ): + joystick_dict = old_preset["gamepad"]["joystick"] + left_purpose = joystick_dict.get("left_purpose") + right_purpose = joystick_dict.get("right_purpose") + # TODO if pointer_speed is migrated, why is it in my config? + pointer_speed = joystick_dict.get("pointer_speed") if pointer_speed: - mapping_x.gain = pointer_speed - mapping_y.gain = pointer_speed - migrated_preset.add(mapping_x) - migrated_preset.add(mapping_y) - - if left_purpose == "wheel": - x_config = cfg.copy() - y_config = cfg.copy() - x_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_X)] - ) - y_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_Y)] - ) - x_config["output_code"] = REL_HWHEEL_HI_RES - y_config["output_code"] = REL_WHEEL_HI_RES - mapping_x = Mapping(**x_config) - mapping_y = Mapping(**y_config) - if x_scroll_speed: - mapping_x.gain = x_scroll_speed - if y_scroll_speed: - mapping_y.gain = y_scroll_speed - migrated_preset.add(mapping_x) - migrated_preset.add(mapping_y) - - if right_purpose == "wheel": - x_config = cfg.copy() - y_config = cfg.copy() - x_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_RX)] - ) - y_config["input_combination"] = InputCombination( - [InputConfig(type=EV_ABS, code=ABS_RY)] - ) - x_config["output_code"] = REL_HWHEEL_HI_RES - y_config["output_code"] = REL_WHEEL_HI_RES - mapping_x = Mapping(**x_config) - mapping_y = Mapping(**y_config) - if x_scroll_speed: - mapping_x.gain = x_scroll_speed - if y_scroll_speed: - mapping_y.gain = y_scroll_speed - migrated_preset.add(mapping_x) - migrated_preset.add(mapping_y) - - migrated_preset.save() - - -def _copy_to_v2(): - """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" - # TODO test - if os.path.exists(CONFIG_PATH): - # don't copy to already existing folder - # users should delete the input-remapper-2 folder if they need to - return - - # prioritize the v1 configs over beta configs - old_path = os.path.join(HOME, ".config/input-remapper") - if os.path.exists(os.path.join(old_path, "config.json")): - # no beta path, only old presets exist. COPY to v2 path, which will then be - # migrated by the various migrations. - logger.debug("copying all from %s to %s", old_path, CONFIG_PATH) - shutil.copytree(old_path, CONFIG_PATH) - return - - # if v1 configs don't exist, try to find beta configs. - beta_path = os.path.join(HOME, ".config/input-remapper/beta_1.6.0-beta") - if os.path.exists(beta_path): - # There has never been a different version than "1.6.0-beta" in beta, so we - # only need to check for that exact directory - # already migrated, possibly new presets in them, move to v2 path - logger.debug("moving %s to %s", beta_path, CONFIG_PATH) - shutil.move(beta_path, CONFIG_PATH) - - -def _remove_logs(): - """We will try to rely on journalctl for this in the future.""" - try: - remove(f"{HOME}/.log/input-remapper") - remove("/var/log/input-remapper") - remove("/var/log/input-remapper-control") - except Exception as error: - logger.debug("Failed to remove deprecated logfiles: %s", str(error)) - # this migration is not important. Continue - pass - - -def migrate(): - """Migrate config files to the current release.""" - - _rename_to_input_remapper() - - _copy_to_v2() - - v = config_version() - - if v < version.parse("0.4.0"): - _config_suffix() - _preset_path() - - if v < version.parse("1.2.2"): - _mapping_keys() - - if v < version.parse("1.4.0"): - global_uinputs.prepare_all() - _add_target() - - if v < version.parse("1.4.1"): - _otherwise_to_else() - - if v < version.parse("1.5.0"): - _remove_logs() - - if v < version.parse("1.6.0-beta"): - _convert_to_individual_mappings() - - # add new migrations here - - if v < version.parse(VERSION): - _update_version() + pointer_speed /= 100 + non_linearity = joystick_dict.get("non_linearity") # Todo + x_scroll_speed = joystick_dict.get("x_scroll_speed") + y_scroll_speed = joystick_dict.get("y_scroll_speed") + + cfg: Config = { + "input_combination": None, + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": None, + } + + if left_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if left_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + migrated_preset.save() + + @staticmethod + def _copy_to_v2(): + """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" + # TODO test + if os.path.exists(PathUtils.config_path()): + # don't copy to already existing folder + # users should delete the input-remapper-2 folder if they need to + return + + # prioritize the v1 configs over beta configs + old_path = os.path.join(UserUtils.home, ".config/input-remapper") + if os.path.exists(os.path.join(old_path, "config.json")): + # no beta path, only old presets exist. COPY to v2 path, which will then be + # migrated by the various migrations. + logger.debug("copying all from %s to %s", old_path, PathUtils.config_path()) + shutil.copytree(old_path, PathUtils.config_path()) + return + + # if v1 configs don't exist, try to find beta configs. + beta_path = os.path.join( + UserUtils.home, ".config/input-remapper/beta_1.6.0-beta" + ) + if os.path.exists(beta_path): + # There has never been a different version than "1.6.0-beta" in beta, so we + # only need to check for that exact directory + # already migrated, possibly new presets in them, move to v2 path + logger.debug("moving %s to %s", beta_path, PathUtils.config_path()) + shutil.move(beta_path, PathUtils.config_path()) + + @staticmethod + def _remove_logs(): + """We will try to rely on journalctl for this in the future.""" + try: + PathUtils.remove(f"{UserUtils.home}/.log/input-remapper") + PathUtils.remove("/var/log/input-remapper") + PathUtils.remove("/var/log/input-remapper-control") + except Exception as error: + logger.debug("Failed to remove deprecated logfiles: %s", str(error)) + # this migration is not important. Continue + pass diff --git a/inputremapper/configs/paths.py b/inputremapper/configs/paths.py index 54ac5ccde..c450619c4 100644 --- a/inputremapper/configs/paths.py +++ b/inputremapper/configs/paths.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -26,122 +26,130 @@ import shutil from typing import List, Union, Optional -from inputremapper.logger import logger, VERSION -from inputremapper.user import USER, HOME - -rel_path = ".config/input-remapper-2" - -CONFIG_PATH = os.path.join(HOME, rel_path) - - -def chown(path): - """Set the owner of a path to the user.""" - try: - shutil.chown(path, user=USER, group=USER) - except LookupError: - # the users group was unknown in one case for whatever reason - shutil.chown(path, user=USER) - - -def touch(path: Union[str, os.PathLike], log=True): - """Create an empty file and all its parent dirs, give it to the user.""" - if str(path).endswith("/"): - raise ValueError(f"Expected path to not end with a slash: {path}") - - if os.path.exists(path): - return - - if log: - logger.info('Creating file "%s"', path) - - mkdir(os.path.dirname(path), log=False) - - os.mknod(path) - chown(path) - - -def mkdir(path, log=True): - """Create a folder, give it to the user.""" - if path == "" or path is None: - return - - if os.path.exists(path): - return - - if log: - logger.info('Creating dir "%s"', path) - - # give all newly created folders to the user. - # e.g. if .config/input-remapper/mouse/ is created the latter two - base = os.path.split(path)[0] - mkdir(base, log=False) - - os.makedirs(path) - chown(path) - - -def split_all(path: Union[os.PathLike, str]) -> List[str]: - """Split the path into its segments.""" - parts = [] - while True: - path, tail = os.path.split(path) - parts.append(tail) - if path == os.path.sep: - # we arrived at the root '/' - parts.append(path) - break - if not path: - # arrived at start of relative path - break - - parts.reverse() - return parts - - -def remove(path): - """Remove whatever is at the path.""" - if not os.path.exists(path): - return - - if os.path.isdir(path): - shutil.rmtree(path) - else: - os.remove(path) - - -def sanitize_path_component(group_name: str) -> str: - """Replace characters listed in - https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words - with an underscore. - """ - for character in '/\\?%*:|"<>': - if character in group_name: - group_name = group_name.replace(character, "_") - return group_name - - -def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None): - """Get a path to the stored preset, or to store a preset to.""" - presets_base = os.path.join(CONFIG_PATH, "presets") - - if group_name is None: - return presets_base - - group_name = sanitize_path_component(group_name) - - if preset is not None: - # the extension of the preset should not be shown in the ui. - # if a .json extension arrives this place, it has not been - # stripped away properly prior to this. - if not preset.endswith(".json"): - preset = f"{preset}.json" - - if preset is None: - return os.path.join(presets_base, group_name) - - return os.path.join(presets_base, group_name, preset) - - -def get_config_path(*paths) -> str: - """Get a path in ~/.config/input-remapper/.""" - return os.path.join(CONFIG_PATH, *paths) +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils + + +# TODO maybe this could become, idk, ConfigService and PresetService +class PathUtils: + rel_path = ".config/input-remapper-2" + + @staticmethod + def config_path() -> str: + # TODO when proper DI is being done, construct PathUtils and configure it in + # the constructor. Then there is no need to recompute the config_path + # each time. Tests might have overwritten UserUtils.home. + return os.path.join(UserUtils.home, PathUtils.rel_path) + + @staticmethod + def chown(path): + """Set the owner of a path to the user.""" + try: + shutil.chown(path, user=UserUtils.user, group=UserUtils.user) + except LookupError: + # the users group was unknown in one case for whatever reason + shutil.chown(path, user=UserUtils.user) + + @staticmethod + def touch(path: Union[str, os.PathLike], log=True): + """Create an empty file and all its parent dirs, give it to the user.""" + if str(path).endswith("/"): + raise ValueError(f"Expected path to not end with a slash: {path}") + + if os.path.exists(path): + return + + if log: + logger.info('Creating file "%s"', path) + + PathUtils.mkdir(os.path.dirname(path), log=False) + + os.mknod(path) + PathUtils.chown(path) + + @staticmethod + def mkdir(path, log=True): + """Create a folder, give it to the user.""" + if path == "" or path is None: + return + + if os.path.exists(path): + return + + if log: + logger.info('Creating dir "%s"', path) + + # give all newly created folders to the user. + # e.g. if .config/input-remapper/mouse/ is created the latter two + base = os.path.split(path)[0] + PathUtils.mkdir(base, log=False) + + os.makedirs(path) + PathUtils.chown(path) + + @staticmethod + def split_all(path: Union[os.PathLike, str]) -> List[str]: + """Split the path into its segments.""" + parts = [] + while True: + path, tail = os.path.split(path) + parts.append(tail) + if path == os.path.sep: + # we arrived at the root '/' + parts.append(path) + break + if not path: + # arrived at start of relative path + break + + parts.reverse() + return parts + + @staticmethod + def remove(path): + """Remove whatever is at the path.""" + if not os.path.exists(path): + return + + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + + @staticmethod + def sanitize_path_component(group_name: str) -> str: + """Replace characters listed in + https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words + with an underscore. + """ + for character in '/\\?%*:|"<>': + if character in group_name: + group_name = group_name.replace(character, "_") + return group_name + + @staticmethod + def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None): + """Get a path to the stored preset, or to store a preset to.""" + presets_base = os.path.join(PathUtils.config_path(), "presets") + + if group_name is None: + return presets_base + + group_name = PathUtils.sanitize_path_component(group_name) + + if preset is not None: + # the extension of the preset should not be shown in the ui. + # if a .json extension arrives this place, it has not been + # stripped away properly prior to this. + if not preset.endswith(".json"): + preset = f"{preset}.json" + + if preset is None: + return os.path.join(presets_base, group_name) + + return os.path.join(presets_base, group_name, preset) + + @staticmethod + def get_config_path(*paths) -> str: + """Get a path in ~/.config/input-remapper/.""" + return os.path.join(PathUtils.config_path(), *paths) diff --git a/inputremapper/configs/preset.py b/inputremapper/configs/preset.py index c1cce2f44..973d11f39 100644 --- a/inputremapper/configs/preset.py +++ b/inputremapper/configs/preset.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -43,8 +43,8 @@ from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping, UIMapping -from inputremapper.configs.paths import touch -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger MappingModel = TypeVar("MappingModel", bound=UIMapping) @@ -175,7 +175,7 @@ def save(self) -> None: logger.debug("unable to save preset without a path set Preset.path first") return - touch(self.path) + PathUtils.touch(self.path) if not self.has_unsaved_changes(): logger.debug("Not saving unchanged preset") return diff --git a/inputremapper/configs/system_mapping.py b/inputremapper/configs/system_mapping.py index 8067c12cf..f88493b3a 100644 --- a/inputremapper/configs/system_mapping.py +++ b/inputremapper/configs/system_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -25,8 +25,8 @@ import evdev -from inputremapper.configs.paths import get_config_path, touch -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger from inputremapper.utils import is_service DISABLE_NAME = "disable" @@ -108,8 +108,8 @@ def _use_xmodmap_symbols(self): # Write this stuff into the input-remapper config directory, because # the systemd service won't know the user sessions xmodmap. - path = get_config_path(XMODMAP_FILENAME) - touch(path) + path = PathUtils.get_config_path(XMODMAP_FILENAME) + PathUtils.touch(path) with open(path, "w") as file: logger.debug('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) @@ -214,5 +214,6 @@ def _find_legit_mappings(self) -> dict: return xmodmap_dict +# TODO DI # this mapping represents the xmodmap output, which stays constant system_mapping = SystemMapping() diff --git a/inputremapper/configs/validation_errors.py b/inputremapper/configs/validation_errors.py index 238256c05..156a7c980 100644 --- a/inputremapper/configs/validation_errors.py +++ b/inputremapper/configs/validation_errors.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -32,7 +32,7 @@ from evdev.ecodes import EV_KEY from inputremapper.configs.system_mapping import system_mapping -from inputremapper.injection.global_uinputs import find_fitting_default_uinputs +from inputremapper.injection.global_uinputs import GlobalUInputs class OutputSymbolVariantError(ValueError): @@ -64,7 +64,7 @@ class SymbolNotAvailableInTargetError(ValueError): def __init__(self, symbol, target): code = system_mapping.get(symbol) - fitting_targets = find_fitting_default_uinputs(EV_KEY, code) + fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code) fitting_targets_string = '", "'.join(fitting_targets) super().__init__( diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 8d9167c5c..cf949e9cd 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -38,13 +38,14 @@ gi.require_version("GLib", "2.0") from gi.repository import GLib -from inputremapper.logger import logger, is_debug +from inputremapper.logging.logger import logger from inputremapper.injection.injector import Injector, InjectorState from inputremapper.configs.preset import Preset from inputremapper.configs.global_config import global_config from inputremapper.configs.system_mapping import system_mapping from inputremapper.groups import groups -from inputremapper.configs.paths import get_config_path, sanitize_path_component, USER +from inputremapper.configs.paths import PathUtils +from inputremapper.user import UserUtils from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import global_uinputs @@ -190,8 +191,8 @@ def __init__(self): self.config_dir = None - if USER != "root": - self.set_config_dir(get_config_path()) + if UserUtils.home != "root": + self.set_config_dir(PathUtils.get_config_path()) # check privileges if os.getuid() != 0: @@ -227,7 +228,7 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: # Blocks until pkexec is done asking for the password. # Runs via input-remapper-control so that auth_admin_keep works # for all pkexec calls of the gui - debug = " -d" if is_debug() else "" + debug = " -d" if logger.is_debug() else "" cmd = f"pkexec input-remapper-control --command start-daemon {debug}" # using pkexec will also cause the service to continue running in @@ -251,10 +252,10 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: logger.error("Failed to connect to the service") sys.exit(8) - if USER != "root": - config_path = get_config_path() + if UserUtils.home != "root": + config_path = PathUtils.get_config_path() logger.debug('Telling service about "%s"', config_path) - interface.set_config_dir(get_config_path(), timeout=2) + interface.set_config_dir(PathUtils.get_config_path(), timeout=2) return interface @@ -458,7 +459,7 @@ def start_injecting(self, group_key: str, preset_name: str) -> bool: preset_path = PurePath( self.config_dir, "presets", - sanitize_path_component(group.name), + PathUtils.sanitize_path_component(group.name), f"{preset_name}.json", ) diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py index 9b9bf6a0b..979a6f03a 100644 --- a/inputremapper/exceptions.py +++ b/inputremapper/exceptions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/groups.py b/inputremapper/groups.py index b7846d5ef..ae9a86b0b 100644 --- a/inputremapper/groups.py +++ b/inputremapper/groups.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -37,11 +37,9 @@ import os import re import threading -import traceback from typing import List, Optional import evdev -from evdev import InputDevice from evdev.ecodes import ( EV_KEY, EV_ABS, @@ -56,8 +54,8 @@ REL_WHEEL, ) -from inputremapper.configs.paths import get_preset_path -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash TABLET_KEYS = [ @@ -197,7 +195,7 @@ def classify(device) -> DeviceType: DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] -def is_denylisted(device: InputDevice): +def is_denylisted(device: evdev.InputDevice): """Check if a device should not be used in input-remapper. Parameters @@ -211,7 +209,7 @@ def is_denylisted(device: InputDevice): return False -def get_unique_key(device: InputDevice): +def get_unique_key(device: evdev.InputDevice): """Find a string key that is unique for a single hardware device. All InputDevices in /dev/input that originate from the same physical @@ -299,7 +297,7 @@ def get_preset_path(self, preset: Optional[str] = None): This path is unique per device-model, not per group. Groups of the same model share the same preset paths. """ - return get_preset_path(self.name, preset) + return PathUtils.get_preset_path(self.name, preset) def get_devices(self) -> List[evdev.InputDevice]: devices: List[evdev.InputDevice] = [] diff --git a/inputremapper/gui/autocompletion.py b/inputremapper/gui/autocompletion.py index 6d775ce69..455be58cb 100644 --- a/inputremapper/gui/autocompletion.py +++ b/inputremapper/gui/autocompletion.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -27,10 +27,10 @@ from evdev.ecodes import EV_KEY from gi.repository import Gdk, Gtk, GLib, GObject -from inputremapper.gui.controller import Controller from inputremapper.configs.mapping import MappingData from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME from inputremapper.gui.components.editor import CodeEditor +from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.gui.messages.message_data import UInputsData from inputremapper.gui.utils import debounce @@ -39,7 +39,7 @@ get_macro_argument_names, remove_comments, ) -from inputremapper.logger import logger +from inputremapper.logging.logger import logger # no deprecated shorthand function-names FUNCTION_NAMES = [name for name in TASK_FACTORIES.keys() if len(name) > 1] diff --git a/inputremapper/gui/components/common.py b/inputremapper/gui/components/common.py index 275ba1d9f..27de5aea8 100644 --- a/inputremapper/gui/components/common.py +++ b/inputremapper/gui/components/common.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/gui/components/device_groups.py b/inputremapper/gui/components/device_groups.py index d1ef1d8f3..01c994c76 100644 --- a/inputremapper/gui/components/device_groups.py +++ b/inputremapper/gui/components/device_groups.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -36,7 +36,7 @@ GroupData, DoStackSwitch, ) -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class DeviceGroupEntry(FlowBoxEntry): diff --git a/inputremapper/gui/components/editor.py b/inputremapper/gui/components/editor.py index 332eab84f..f250a42f4 100644 --- a/inputremapper/gui/components/editor.py +++ b/inputremapper/gui/components/editor.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/gui/components/main.py b/inputremapper/gui/components/main.py index ec3544580..5169e41e5 100644 --- a/inputremapper/gui/components/main.py +++ b/inputremapper/gui/components/main.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,7 +23,6 @@ from __future__ import annotations -import gi from gi.repository import Gtk, Pango from inputremapper.gui.controller import Controller diff --git a/inputremapper/gui/components/presets.py b/inputremapper/gui/components/presets.py index d552194f1..9adc1c08a 100644 --- a/inputremapper/gui/components/presets.py +++ b/inputremapper/gui/components/presets.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -37,7 +37,7 @@ PresetData, DoStackSwitch, ) -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class PresetEntry(FlowBoxEntry): diff --git a/inputremapper/gui/controller.py b/inputremapper/gui/controller.py index fd854be8b..5b8e9fb87 100644 --- a/inputremapper/gui/controller.py +++ b/inputremapper/gui/controller.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -33,11 +33,10 @@ Any, ) -import gi from evdev.ecodes import EV_KEY, EV_REL, EV_ABS - from gi.repository import Gtk +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( MappingData, UIMapping, @@ -47,8 +46,7 @@ MissingMacroOrKeyError, OutputSymbolVariantError, ) -from inputremapper.configs.paths import sanitize_path_component -from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.paths import PathUtils from inputremapper.configs.validation_errors import pydantify from inputremapper.exceptions import DataManagementError from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME @@ -69,7 +67,7 @@ InjectorState, InjectorStateMessage, ) -from inputremapper.logger import logger +from inputremapper.logging.logger import logger if TYPE_CHECKING: # avoids gtk import error in tests @@ -494,7 +492,7 @@ def rename_preset(self, new_name: str): ): return - new_name = sanitize_path_component(new_name) + new_name = PathUtils.sanitize_path_component(new_name) new_name = self.data_manager.get_available_preset_name(new_name) self.data_manager.rename_preset(new_name) diff --git a/inputremapper/gui/data_manager.py b/inputremapper/gui/data_manager.py index fa08399c9..f4d89feae 100644 --- a/inputremapper/gui/data_manager.py +++ b/inputremapper/gui/data_manager.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,19 +23,18 @@ import time from typing import Optional, List, Tuple, Set -import gi from gi.repository import GLib from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping, MappingData -from inputremapper.configs.paths import get_preset_path, mkdir, split_all +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import SystemMapping from inputremapper.daemon import DaemonProxy -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.exceptions import DataManagementError -from inputremapper.gui.gettext import _ from inputremapper.groups import _Group +from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, ) @@ -51,7 +50,7 @@ InjectorState, InjectorStateMessage, ) -from inputremapper.logger import logger +from inputremapper.logging.logger import logger DEFAULT_PRESET_NAME = _("new preset") @@ -185,8 +184,8 @@ def get_preset_names(self) -> Tuple[Name, ...]: """Get all preset names for active_group and current user sorted by age.""" if not self.active_group: raise DataManagementError("Cannot find presets: Group is not set") - device_folder = get_preset_path(self.active_group.name) - mkdir(device_folder) + device_folder = PathUtils.get_preset_path(self.active_group.name) + PathUtils.mkdir(device_folder) paths = glob.glob(os.path.join(device_folder, "*.json")) presets = [ @@ -232,15 +231,15 @@ def set_autoload(self, status: bool): def get_newest_group_key(self) -> GroupKey: """group_key of the group with the most recently modified preset.""" paths = [] - for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")): - if self._reader_client.groups.find(key=split_all(path)[-2]): + for path in glob.glob(os.path.join(PathUtils.get_preset_path(), "*/*.json")): + if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]): paths.append((path, os.path.getmtime(path))) if not paths: raise FileNotFoundError() path, _ = max(paths, key=lambda x: x[1]) - return split_all(path)[-2] + return PathUtils.split_all(path)[-2] def get_newest_preset_name(self) -> Name: """Preset name of the most recently modified preset in the active group.""" @@ -250,7 +249,9 @@ def get_newest_preset_name(self) -> Name: paths = [ (path, os.path.getmtime(path)) for path in glob.glob( - os.path.join(get_preset_path(self.active_group.name), "*.json") + os.path.join( + PathUtils.get_preset_path(self.active_group.name), "*.json" + ) ) ] if not paths: @@ -267,7 +268,7 @@ def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: name = name.strip() # find a name that is not already taken - if os.path.exists(get_preset_path(self.active_group.name, name)): + if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)): # if there already is a trailing number, increment it instead of # adding another one match = re.match(r"^(.+) (\d+)$", name) @@ -278,7 +279,7 @@ def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: i = 2 while os.path.exists( - get_preset_path(self.active_group.name, f"{name} {i}") + PathUtils.get_preset_path(self.active_group.name, f"{name} {i}") ): i += 1 @@ -314,7 +315,7 @@ def load_preset(self, name: str): logger.info('Loading preset "%s"', name) - preset_path = get_preset_path(self.active_group.name, name) + preset_path = PathUtils.get_preset_path(self.active_group.name, name) preset = Preset(preset_path, mapping_factory=UIMapping) preset.load() self._active_input_config = None @@ -363,13 +364,15 @@ def rename_preset(self, new_name: str): if not self.active_preset or not self.active_group: raise DataManagementError("Unable rename preset: Preset is not set") - if self.active_preset.path == get_preset_path(self.active_group.name, new_name): + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, new_name + ): return old_path = self.active_preset.path assert old_path is not None old_name = os.path.basename(old_path).split(".")[0] - new_path = get_preset_path(self.active_group.name, new_name) + new_path = PathUtils.get_preset_path(self.active_group.name, new_name) if os.path.exists(new_path): raise ValueError( f"cannot rename {old_name} to " f"{new_name}, preset already exists" @@ -383,7 +386,9 @@ def rename_preset(self, new_name: str): if self._config.is_autoloaded(self.active_group.key, old_name): self._config.set_autoload_preset(self.active_group.key, new_name) - self.active_preset.path = get_preset_path(self.active_group.name, new_name) + self.active_preset.path = PathUtils.get_preset_path( + self.active_group.name, new_name + ) self.publish_group() self.publish_preset() @@ -396,13 +401,15 @@ def copy_preset(self, name: str): if not self.active_preset or not self.active_group: raise DataManagementError("Unable to copy preset: Preset is not set") - if self.active_preset.path == get_preset_path(self.active_group.name, name): + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, name + ): return if name in self.get_preset_names(): raise ValueError(f"a preset with the name {name} already exits") - new_path = get_preset_path(self.active_group.name, name) + new_path = PathUtils.get_preset_path(self.active_group.name, name) logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path) self.active_preset.path = new_path self.save() @@ -417,7 +424,7 @@ def create_preset(self, name: str): if not self.active_group: raise DataManagementError("Unable to add preset. Group is not set") - path = get_preset_path(self.active_group.name, name) + path = PathUtils.get_preset_path(self.active_group.name, name) if os.path.exists(path): raise DataManagementError("Unable to add preset. Preset exists") diff --git a/inputremapper/gui/gettext.py b/inputremapper/gui/gettext.py index 1d1f1687d..0788f8cce 100644 --- a/inputremapper/gui/gettext.py +++ b/inputremapper/gui/gettext.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/gui/messages/message_broker.py b/inputremapper/gui/messages/message_broker.py index 88b425c6d..13ed7e57b 100644 --- a/inputremapper/gui/messages/message_broker.py +++ b/inputremapper/gui/messages/message_broker.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -33,7 +33,7 @@ ) from inputremapper.gui.messages.message_types import MessageType -from inputremapper.logger import logger +from inputremapper.logging.logger import logger if TYPE_CHECKING: pass diff --git a/inputremapper/gui/messages/message_data.py b/inputremapper/gui/messages/message_data.py index cf33b8882..3957f6e70 100644 --- a/inputremapper/gui/messages/message_data.py +++ b/inputremapper/gui/messages/message_data.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/gui/messages/message_types.py b/inputremapper/gui/messages/message_types.py index df0ac2575..91b907c1a 100644 --- a/inputremapper/gui/messages/message_types.py +++ b/inputremapper/gui/messages/message_types.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/gui/reader_client.py b/inputremapper/gui/reader_client.py index 65a31cf7a..91dd99429 100644 --- a/inputremapper/gui/reader_client.py +++ b/inputremapper/gui/reader_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -24,15 +24,21 @@ """ import time -from typing import Optional, List, Generator, Dict, Tuple, Set +from typing import Optional, List, Generator, Dict, Set import evdev -import gi - from gi.repository import GLib from inputremapper.configs.input_config import InputCombination from inputremapper.groups import _Groups, _Group +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import MessageBroker +from inputremapper.gui.messages.message_data import ( + GroupsData, + CombinationRecorded, + StatusData, +) +from inputremapper.gui.messages.message_types import MessageType from inputremapper.gui.reader_service import ( MSG_EVENT, MSG_GROUPS, @@ -42,18 +48,10 @@ get_pipe_paths, ReaderService, ) -from inputremapper.gui.messages.message_types import MessageType -from inputremapper.gui.messages.message_broker import MessageBroker -from inputremapper.gui.messages.message_data import ( - GroupsData, - CombinationRecorded, - StatusData, -) from inputremapper.gui.utils import CTX_ERROR -from inputremapper.gui.gettext import _ from inputremapper.input_event import InputEvent from inputremapper.ipc.pipe import Pipe -from inputremapper.logger import logger +from inputremapper.logging.logger import logger BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)] RecordingGenerator = Generator[None, InputEvent, None] diff --git a/inputremapper/gui/reader_service.py b/inputremapper/gui/reader_service.py index f5bb70e75..b0180e38d 100644 --- a/inputremapper/gui/reader_service.py +++ b/inputremapper/gui/reader_service.py @@ -50,7 +50,6 @@ import evdev from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL -from inputremapper.utils import get_device_hash from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping @@ -65,8 +64,9 @@ from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.input_event import InputEvent, EventActions from inputremapper.ipc.pipe import Pipe -from inputremapper.logger import logger -from inputremapper.user import USER +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils +from inputremapper.utils import get_device_hash # received by the reader-service CMD_TERMINATE = "terminate" @@ -82,8 +82,8 @@ def get_pipe_paths(): """Get the path where the pipe can be found.""" return ( - f"/tmp/input-remapper-{USER}/reader-results", - f"/tmp/input-remapper-{USER}/reader-commands", + f"/tmp/input-remapper-{UserUtils.home}/reader-results", + f"/tmp/input-remapper-{UserUtils.home}/reader-commands", ) diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py index 0b9b2b50d..46b63be4a 100644 --- a/inputremapper/gui/user_interface.py +++ b/inputremapper/gui/user_interface.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -21,14 +21,14 @@ """User Interface.""" from typing import Dict, Callable -import gi - from gi.repository import Gtk, GtkSource, Gdk, GObject from inputremapper.configs.data import get_data_path -from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import MappingData from inputremapper.gui.autocompletion import Autocompletion +from inputremapper.gui.components.common import Breadcrumbs +from inputremapper.gui.components.device_groups import DeviceGroupSelection from inputremapper.gui.components.editor import ( MappingListBox, TargetSelection, @@ -49,11 +49,10 @@ RequireActiveMapping, GdkEventRecorder, ) -from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.components.main import Stack, StatusBar -from inputremapper.gui.components.common import Breadcrumbs -from inputremapper.gui.components.device_groups import DeviceGroupSelection +from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.controller import Controller +from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, @@ -63,8 +62,7 @@ gtk_iteration, ) from inputremapper.injection.injector import InjectorStateMessage -from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION -from inputremapper.gui.gettext import _ +from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION # https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ GObject.type_register(GtkSource.View) diff --git a/inputremapper/gui/utils.py b/inputremapper/gui/utils.py index 58ec81e02..15122514d 100644 --- a/inputremapper/gui/utils.py +++ b/inputremapper/gui/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,12 +23,9 @@ from dataclasses import dataclass from typing import List, Callable, Dict, Optional -import gi - from gi.repository import Gtk, GLib, Gdk -from inputremapper.logger import logger - +from inputremapper.logging.logger import logger # status ctx ids diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py index b247e1026..cf56608b1 100644 --- a/inputremapper/injection/context.py +++ b/inputremapper/injection/context.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -28,7 +28,6 @@ import evdev from inputremapper.configs.input_config import DeviceHash -from inputremapper.input_event import InputEvent from inputremapper.configs.preset import Preset from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, @@ -38,7 +37,8 @@ parse_mappings, EventPipelines, ) -from inputremapper.logger import logger +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger class Context: diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py index 67e16bc03..218e4049b 100644 --- a/inputremapper/injection/event_reader.py +++ b/inputremapper/injection/event_reader.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -27,13 +27,13 @@ import evdev -from inputremapper.utils import get_device_hash, DeviceHash from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, NotifyCallback, ) from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash, DeviceHash class Context(Protocol): diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index 78b33e7fb..3fba120c7 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,7 +23,7 @@ import inputremapper.exceptions import inputremapper.utils -from inputremapper.logger import logger +from inputremapper.logging.logger import logger MIN_ABS = -(2**15) # -32768 MAX_ABS = 2**15 # 32768 @@ -59,36 +59,24 @@ } -def can_default_uinput_emit(target: str, type_: int, code: int) -> bool: - """Check if the uinput with the target name is capable of the event.""" - capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_) - return capabilities is not None and code in capabilities - - -def find_fitting_default_uinputs(type_: int, code: int) -> List[str]: - """Find the names of default uinputs that are able to emit this event.""" - return [ - uinput - for uinput in DEFAULT_UINPUTS - if code in DEFAULT_UINPUTS[uinput].get(type_, []) - ] - - class UInput(evdev.UInput): + _capabilities_cache: Optional[Dict] = None + def __init__(self, *args, **kwargs): name = kwargs["name"] logger.debug('creating UInput device: "%s"', name) super().__init__(*args, **kwargs) - # this will never change, so we cache it since evdev runs an expensive loop to - # gather the capabilities. (can_emit is called regularly) - self._capabilities_cache = self.capabilities(absinfo=False) - def can_emit(self, event: Tuple[int, int, int]): """Check if an event can be emitted by the UIinput. Wrong events might be injected if the group mappings are wrong, """ + # this will never change, so we cache it since evdev runs an expensive loop to + # gather the capabilities. (can_emit is called regularly) + if self._capabilities_cache is None: + self._capabilities_cache = self.capabilities(absinfo=False) + return event[1] in self._capabilities_cache.get(event[0], []) @@ -117,6 +105,21 @@ def __init__(self): def __iter__(self): return iter(uinput for _, uinput in self.devices.items()) + @staticmethod + def can_default_uinput_emit(target: str, type_: int, code: int) -> bool: + """Check if the uinput with the target name is capable of the event.""" + capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_) + return capabilities is not None and code in capabilities + + @staticmethod + def find_fitting_default_uinputs(type_: int, code: int) -> List[str]: + """Find the names of default uinputs that are able to emit this event.""" + return [ + uinput + for uinput in DEFAULT_UINPUTS + if code in DEFAULT_UINPUTS[uinput].get(type_, []) + ] + def reset(self): self.is_service = inputremapper.utils.is_service() self._uinput_factory = None @@ -127,6 +130,7 @@ def ensure_uinput_factory_set(self): if self._uinput_factory is not None: return + # TODO this should be solved via DI # overwrite global_uinputs.is_service in tests to control this if self.is_service: logger.debug("Creating regular UInputs") @@ -202,4 +206,5 @@ def get_uinput(self, name: str) -> Optional[evdev.UInput]: return self.devices.get(name) +# TODO DI global_uinputs = GlobalUInputs() diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 4ca7470ce..35db52f49 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -44,7 +44,7 @@ from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash CapabilitiesDict = Dict[int, List[int]] diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index 88e5c4a39..b0d3fd0e9 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -59,9 +59,9 @@ SymbolNotAvailableInTargetError, MacroParsingError, ) -from inputremapper.injection.global_uinputs import can_default_uinput_emit +from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.ipc.shared_dict import SharedDict -from inputremapper.logger import logger +from inputremapper.logging.logger import logger Handler = Callable[[Tuple[int, int, int]], None] MacroTask = Callable[[Handler], Awaitable] @@ -735,7 +735,9 @@ def _type_check_symbol(self, keyname: Union[str, Variable]) -> Union[Variable, i if self.mapping is not None: target = self.mapping.target_uinput - if target is not None and not can_default_uinput_emit(target, EV_KEY, code): + if target is not None and not GlobalUInputs.can_default_uinput_emit( + target, EV_KEY, code + ): raise SymbolNotAvailableInTargetError(symbol, target) return code diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index e1cf588a8..13af186cb 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -27,7 +27,7 @@ from inputremapper.configs.validation_errors import MacroParsingError from inputremapper.injection.macros.macro import Macro, Variable -from inputremapper.logger import logger +from inputremapper.logging.logger import logger def is_this_a_macro(output: Any): diff --git a/inputremapper/injection/mapping_handlers/__init__.py b/inputremapper/injection/mapping_handlers/__init__.py index 7c07f39cf..00ef810c0 100644 --- a/inputremapper/injection/mapping_handlers/__init__.py +++ b/inputremapper/injection/mapping_handlers/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py index 4756ac0ba..2159896fe 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -22,8 +22,8 @@ import evdev from evdev.ecodes import EV_ABS -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.mapping_handlers.axis_transform import Transformation @@ -33,7 +33,7 @@ InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py index 136ed4baf..544786fde 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - from typing import Tuple import evdev diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py index e2f0873ae..fe323b1a0 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -49,7 +49,7 @@ InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py index 992e3b64f..f96b1fc0e 100644 --- a/inputremapper/injection/mapping_handlers/axis_switch_handler.py +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,12 +17,12 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from typing import Dict, Tuple, Hashable, TYPE_CHECKING +from typing import Dict, Tuple, Hashable import evdev -from inputremapper.configs.input_config import InputConfig from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.input_config import InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, @@ -31,7 +31,7 @@ ContextProtocol, ) from inputremapper.input_event import InputEvent, EventActions -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash diff --git a/inputremapper/injection/mapping_handlers/axis_transform.py b/inputremapper/injection/mapping_handlers/axis_transform.py index 0460fc36f..ed8e46b4e 100644 --- a/inputremapper/injection/mapping_handlers/axis_transform.py +++ b/inputremapper/injection/mapping_handlers/axis_transform.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + import math from typing import Dict, Union diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py index 3b9b68c5d..0ec784867 100644 --- a/inputremapper/injection/mapping_handlers/combination_handler.py +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,6 +18,7 @@ # along with input-remapper. If not, see . from __future__ import annotations # needed for the TYPE_CHECKING import + from typing import TYPE_CHECKING, Dict, Hashable import evdev @@ -31,7 +32,7 @@ HandlerEnums, ) from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger if TYPE_CHECKING: from inputremapper.injection.context import Context diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py index 13d09b720..4f71581eb 100644 --- a/inputremapper/injection/mapping_handlers/hierarchy_handler.py +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + from typing import List, Dict import evdev diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py index 5ab0f1f6a..7ce023fb4 100644 --- a/inputremapper/injection/mapping_handlers/key_handler.py +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,8 +19,8 @@ from typing import Tuple, Dict -from inputremapper.configs.input_config import InputCombination from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.exceptions import MappingParsingError from inputremapper.injection.global_uinputs import global_uinputs @@ -29,7 +29,7 @@ HandlerEnums, ) from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py index 4f2d6e394..ed6af7103 100644 --- a/inputremapper/injection/mapping_handlers/macro_handler.py +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -31,7 +31,7 @@ HandlerEnums, ) from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class MacroHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py index e610f7c00..87608a043 100644 --- a/inputremapper/injection/mapping_handlers/mapping_handler.py +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -69,7 +69,7 @@ from inputremapper.configs.mapping import Mapping from inputremapper.exceptions import MappingParsingError from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class EventListener(Protocol): diff --git a/inputremapper/injection/mapping_handlers/mapping_parser.py b/inputremapper/injection/mapping_handlers/mapping_parser.py index 3bc3fa1e7..65012e833 100644 --- a/inputremapper/injection/mapping_handlers/mapping_parser.py +++ b/inputremapper/injection/mapping_handlers/mapping_parser.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -52,7 +52,7 @@ from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler -from inputremapper.logger import logger +from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name EventPipelines = Dict[InputConfig, Set[InputEventHandler]] diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py index f8402c11f..ebd1d3720 100644 --- a/inputremapper/injection/mapping_handlers/null_handler.py +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py index 5d670ec2f..e8b5ec6a4 100644 --- a/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py +++ b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -30,8 +30,8 @@ REL_WHEEL_HI_RES, ) -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( Mapping, WHEEL_SCALING, @@ -47,7 +47,7 @@ InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class RelToAbsHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py index 1f9bf1453..bbc39d6c8 100644 --- a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -30,7 +30,7 @@ InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class RelToBtnHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py index 98c5d6be0..d41a2c465 100644 --- a/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py +++ b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -29,8 +29,8 @@ REL_HWHEEL_HI_RES, ) -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( Mapping, REL_XY_SCALING, @@ -45,7 +45,7 @@ InputEventHandler, ) from inputremapper.input_event import InputEvent -from inputremapper.logger import logger +from inputremapper.logging.logger import logger def is_wheel(event) -> bool: diff --git a/inputremapper/injection/numlock.py b/inputremapper/injection/numlock.py index 4006cb280..2598e24c5 100644 --- a/inputremapper/injection/numlock.py +++ b/inputremapper/injection/numlock.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -28,7 +28,7 @@ import re import subprocess -from inputremapper.logger import logger +from inputremapper.logging.logger import logger def is_numlock_on(): diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py index 6d3e7ad5d..9a0fddf92 100644 --- a/inputremapper/input_event.py +++ b/inputremapper/input_event.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -41,21 +41,6 @@ class EventActions(enum.Enum): negative_trigger = enum.auto() # original event was negative direction -def validate_event(event): - """Test if the event is valid.""" - if not isinstance(event.type, int): - raise TypeError(f"Expected type to be an int, but got {event.type}") - - if not isinstance(event.code, int): - raise TypeError(f"Expected code to be an int, but got {event.code}") - - if not isinstance(event.value, int): - # this happened to me because I screwed stuff up - raise TypeError(f"Expected value to be an int, but got {event.value}") - - return event - - # Todo: add slots=True as soon as python 3.10 is in common distros @dataclass(frozen=True) class InputEvent: @@ -81,6 +66,21 @@ def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]): return self.event_tuple == other raise TypeError(f"cannot compare {type(other)} with InputEvent") + @staticmethod + def validate_event(event): + """Test if the event is valid.""" + if not isinstance(event.type, int): + raise TypeError(f"Expected type to be an int, but got {event.type}") + + if not isinstance(event.code, int): + raise TypeError(f"Expected code to be an int, but got {event.code}") + + if not isinstance(event.value, int): + # this happened to me because I screwed stuff up + raise TypeError(f"Expected value to be an int, but got {event.value}") + + return event + @property def input_match_hash(self) -> Hashable: """a Hashable object which is intended to match the InputEvent with a @@ -119,7 +119,7 @@ def from_tuple( f"failed to create InputEvent {event_tuple = } must have length 3" ) - return validate_event( + return cls.validate_event( cls( 0, 0, @@ -133,7 +133,7 @@ def from_tuple( @classmethod def abs(cls, code: int, value: int, origin_hash: Optional[str] = None): """Create an abs event, like joystick movements.""" - return validate_event( + return cls.validate_event( cls( 0, 0, @@ -147,7 +147,7 @@ def abs(cls, code: int, value: int, origin_hash: Optional[str] = None): @classmethod def rel(cls, code: int, value: int, origin_hash: Optional[str] = None): """Create a rel event, like mouse movements.""" - return validate_event( + return cls.validate_event( cls( 0, 0, @@ -164,7 +164,7 @@ def key(cls, code: int, value: Literal[0, 1], origin_hash: Optional[str] = None) A value of 1 means "press", a value of 0 means "release". """ - return validate_event( + return cls.validate_event( cls( 0, 0, diff --git a/inputremapper/ipc/__init__.py b/inputremapper/ipc/__init__.py index dd31976ae..d17180c85 100644 --- a/inputremapper/ipc/__init__.py +++ b/inputremapper/ipc/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/inputremapper/ipc/pipe.py b/inputremapper/ipc/pipe.py index 74509bd5d..755bbac22 100644 --- a/inputremapper/ipc/pipe.py +++ b/inputremapper/ipc/pipe.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -41,8 +41,8 @@ import time from typing import Optional, AsyncIterator, Union -from inputremapper.configs.paths import mkdir, chown -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger class Pipe: @@ -64,7 +64,7 @@ def __init__(self, path): paths = (f"{path}r", f"{path}w") - mkdir(os.path.dirname(path)) + PathUtils.mkdir(os.path.dirname(path)) if not os.path.exists(paths[0]): logger.debug('Creating new pipe for "%s"', path) @@ -77,8 +77,8 @@ def __init__(self, path): self._fds = os.pipe() fds_dir = f"/proc/{os.getpid()}/fd/" - chown(f"{fds_dir}{self._fds[0]}") - chown(f"{fds_dir}{self._fds[1]}") + PathUtils.chown(f"{fds_dir}{self._fds[0]}") + PathUtils.chown(f"{fds_dir}{self._fds[1]}") # to make it accessible by path constants, create symlinks os.symlink(f"{fds_dir}{self._fds[0]}", paths[0]) diff --git a/inputremapper/ipc/shared_dict.py b/inputremapper/ipc/shared_dict.py index 9f57829b2..e2ab4bd84 100644 --- a/inputremapper/ipc/shared_dict.py +++ b/inputremapper/ipc/shared_dict.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -26,7 +26,7 @@ import select from typing import Optional, Any -from inputremapper.logger import logger +from inputremapper.logging.logger import logger class SharedDict: diff --git a/inputremapper/ipc/socket.py b/inputremapper/ipc/socket.py index 3a0510cd8..1b7613963 100644 --- a/inputremapper/ipc/socket.py +++ b/inputremapper/ipc/socket.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -57,8 +57,8 @@ import time from typing import Union -from inputremapper.configs.paths import mkdir, chown -from inputremapper.logger import logger +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger # something funny that most likely won't appear in messages. # also add some ones so that 01 in the payload won't offset @@ -82,7 +82,7 @@ def __init__(self, path): self._path = path self._unread = [] self.unsent = [] - mkdir(os.path.dirname(path)) + PathUtils.mkdir(os.path.dirname(path)) self.connection = None self.socket = None self._created_at = 0 @@ -261,7 +261,7 @@ def connect(self): _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) _socket.bind(self._path) _socket.listen(1) - chown(self._path) + PathUtils.chown(self._path) logger.debug('Created socket: "%s"', self._path) self.socket = _socket self.socket.setblocking(False) diff --git a/inputremapper/logger.py b/inputremapper/logger.py deleted file mode 100644 index 16862ae73..000000000 --- a/inputremapper/logger.py +++ /dev/null @@ -1,336 +0,0 @@ -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb -# -# 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 . - -"""Logging setup for input-remapper.""" - -import logging -import os -import sys -import time -from datetime import datetime -from typing import cast - -try: - from inputremapper.commit_hash import COMMIT_HASH -except ImportError: - COMMIT_HASH = "" - - -start = time.time() - -previous_key_debug_log = None -previous_write_debug_log = None - - -def parse_mapping_handler(mapping_handler): - indent = 0 - lines_and_indent = [] - while True: - if isinstance(handler, str): - lines_and_indent.append([mapping_handler, indent]) - break - - if isinstance(mapping_handler, list): - for sub_handler in mapping_handler: - sub_list = parse_mapping_handler(sub_handler) - for line in sub_list: - line[1] += indent - lines_and_indent.extend(sub_list) - break - - lines_and_indent.append([repr(mapping_handler), indent]) - try: - mapping_handler = mapping_handler.child - except AttributeError: - break - - indent += 1 - return lines_and_indent - - -class Logger(logging.Logger): - def debug_mapping_handler(self, mapping_handler): - """Parse the structure of a mapping_handler and log it.""" - if not self.isEnabledFor(logging.DEBUG): - return - - lines_and_indent = parse_mapping_handler(mapping_handler) - for line in lines_and_indent: - indent = " " - msg = indent * line[1] + line[0] - self._log(logging.DEBUG, msg, args=None) - - def write(self, key, uinput): - """Log that an event is being written - - Parameters - ---------- - key - anything that can be string formatted, but usually a tuple of - (type, code, value) tuples - """ - # pylint: disable=protected-access - if not self.isEnabledFor(logging.DEBUG): - return - - global previous_write_debug_log - - str_key = repr(key) - str_key = str_key.replace(",)", ")") - - msg = f'Writing {str_key} to "{uinput.name}"' - - if msg == previous_write_debug_log: - # avoid some super spam from EV_ABS events - return - - previous_write_debug_log = msg - - self._log(logging.DEBUG, msg, args=None, stacklevel=2) - - -# https://github.com/python/typeshed/issues/1801 -logging.setLoggerClass(Logger) -logger = cast(Logger, logging.getLogger("input-remapper")) - - -def is_debug(): - """True, if the logger is currently in DEBUG or DEBUG mode.""" - return logger.level <= logging.DEBUG - - -class ColorfulFormatter(logging.Formatter): - """Overwritten Formatter to print nicer logs. - - It colors all logs from the same filename in the same color to visually group them - together. It also adds process name, process id, file, line-number and time. - - If debug mode is not active, it will not do any of this. - """ - - def __init__(self): - super().__init__() - - self.file_color_mapping = {} - - # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit - self.allowed_colors = [] - for r in range(0, 6): - for g in range(0, 6): - for b in range(0, 6): - # https://stackoverflow.com/a/596243 - brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b - if brightness < 1: - # prefer light colors, because most people have a dark - # terminal background - continue - - if g + b <= 1: - # red makes it look like it's an error - continue - - if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: - # no colors that are too grey - continue - - self.allowed_colors.append(self._get_ansi_code(r, g, b)) - - self.level_based_colors = { - logging.WARNING: 11, - logging.ERROR: 9, - logging.FATAL: 9, - } - - def _get_ansi_code(self, r: int, g: int, b: int): - return 16 + b + (6 * g) + (36 * r) - - def _word_to_color(self, word: str): - """Convert a word to a 8bit ansi color code.""" - digit_sum = sum([ord(char) for char in word]) - index = digit_sum % len(self.allowed_colors) - return self.allowed_colors[index] - - def _allocate_debug_log_color(self, record: logging.LogRecord): - """Get the color that represents the source file of the log.""" - if self.file_color_mapping.get(record.filename) is not None: - return self.file_color_mapping[record.filename] - - color = self._word_to_color(record.filename) - - if self.file_color_mapping.get(record.filename) is None: - # calculate the color for each file only once - self.file_color_mapping[record.filename] = color - - return color - - def _get_process_name(self): - """Generate a beaitiful to read name for this process.""" - process_path = sys.argv[0] - process_name = process_path.split("/")[-1] - - if "input-remapper-" in process_name: - process_name = process_name.replace("input-remapper-", "") - - if process_name == "gtk": - process_name = "GUI" - - return process_name - - def _get_format(self, record: logging.LogRecord): - """Generate a message format string.""" - debug_mode = is_debug() - - if record.levelno == logging.INFO and not debug_mode: - # if not launched with --debug, then don't print "INFO:" - return "%(message)s" - - if not debug_mode: - color = self.level_based_colors[record.levelno] - return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" - - color = self._allocate_debug_log_color(record) - if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: - # underline - style = f"\033[4;38;5;{color}m" - else: - style = f"\033[38;5;{color}m" - - process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") - - return ( # noqa - f'{datetime.now().strftime("%H:%M:%S.%f")} ' - f"\033[38;5;{process_color}m" # color - f"{os.getpid()} " - f"{self._get_process_name()} " - "\033[0m" # end style - f"{style}" - f"%(levelname)s " - f"%(filename)s:%(lineno)d: " - "%(message)s" - "\033[0m" # end style - ).replace(" ", " ") - - def format(self, record: logging.LogRecord): - """Overwritten format function.""" - # pylint: disable=protected-access - self._style._fmt = self._get_format(record) - return super().format(record) - - -handler = logging.StreamHandler() -handler.setFormatter(ColorfulFormatter()) -logger.addHandler(handler) -logger.setLevel(logging.INFO) -logging.getLogger("asyncio").setLevel(logging.WARNING) - - -# using pkg_resources to figure out the version fails in many cases, -# so we hardcode it instead -VERSION = "2.0.1" -EVDEV_VERSION = None -try: - # pkg_resources very commonly fails/breaks - import pkg_resources - - EVDEV_VERSION = pkg_resources.require("evdev")[0].version -except Exception as error: - # there have been pkg_resources.DistributionNotFound and - # pkg_resources.ContextualVersionConflict errors so far. - # We can safely ignore all Exceptions here - logger.info("Could not figure out the version") - logger.debug(error) - -# check if the version is something like 1.5.0-beta or 1.5.0-beta.5 -IS_BETA = "beta" in VERSION - - -def log_info(name="input-remapper"): - """Log version and name to the console.""" - logger.info( - "%s %s %s https://github.com/sezanzeb/input-remapper", - name, - VERSION, - COMMIT_HASH, - ) - - if EVDEV_VERSION: - logger.info("python-evdev %s", EVDEV_VERSION) - - if is_debug(): - logger.warning( - "Debug level will log all your keystrokes! Do not post this " - "output in the internet if you typed in sensitive or private " - "information with your device!" - ) - - -def update_verbosity(debug): - """Set the logging verbosity according to the settings object. - - Also enable rich tracebacks in debug mode. - """ - # pylint really doesn't like what I'm doing with rich.traceback here - # pylint: disable=broad-except,import-error,import-outside-toplevel - if debug: - logger.setLevel(logging.DEBUG) - - try: - from rich.traceback import install - - install(show_locals=True) - logger.debug("Using rich.traceback") - except Exception as error: - # since this is optional, just skip all exceptions - if not isinstance(error, ImportError): - logger.debug("Cannot use rich.traceback: %s", error) - else: - logger.setLevel(logging.INFO) - - -def trim_logfile(log_path): - """Keep the logfile short.""" - if not os.path.exists(log_path): - return - - file_size_mb = os.path.getsize(log_path) / 1000 / 1000 - if file_size_mb > 100: - # something went terribly wrong here. The service might timeout because - # it takes too long to trim this file. delete it instead. This probably - # only happens when doing funny things while in debug mode. - logger.warning( - "Removing enormous log file of %dMB", - file_size_mb, - ) - os.remove(log_path) - return - - # the logfile should not be too long to avoid overflowing the storage - try: - with open(log_path, "rb") as file: - binary = file.readlines()[-1000:] - content = [line.decode("utf-8", errors="ignore") for line in binary] - - with open(log_path, "w") as file: - file.truncate(0) - file.writelines(content) - except PermissionError: - # let the outermost PermissionError handler handle it - raise - except Exception as exception: - logger.error('Failed to trim logfile: "%s"', str(exception)) diff --git a/inputremapper/logging/__init__.py b/inputremapper/logging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/inputremapper/logging/formatter.py b/inputremapper/logging/formatter.py new file mode 100644 index 000000000..ac08f63b8 --- /dev/null +++ b/inputremapper/logging/formatter.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +"""Logging setup for input-remapper.""" + +import logging +import os +import sys +from datetime import datetime +from typing import Dict + + +class ColorfulFormatter(logging.Formatter): + """Overwritten Formatter to print nicer logs. + + It colors all logs from the same filename in the same color to visually group them + together. It also adds process name, process id, file, line-number and time. + + If debug mode is not active, it will not do any of this. + """ + + def __init__(self, debug_mode: bool = False): + super().__init__() + + self.debug_mode = debug_mode + self.file_color_mapping: Dict[str, int] = {} + + # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + self.allowed_colors = [] + for r in range(0, 6): + for g in range(0, 6): + for b in range(0, 6): + # https://stackoverflow.com/a/596243 + brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b + if brightness < 1: + # prefer light colors, because most people have a dark + # terminal background + continue + + if g + b <= 1: + # red makes it look like it's an error + continue + + if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: + # no colors that are too grey + continue + + self.allowed_colors.append(self._get_ansi_code(r, g, b)) + + self.level_based_colors = { + logging.WARNING: 11, + logging.ERROR: 9, + logging.FATAL: 9, + } + + def _get_ansi_code(self, r: int, g: int, b: int) -> int: + return 16 + b + (6 * g) + (36 * r) + + def _word_to_color(self, word: str) -> int: + """Convert a word to a 8bit ansi color code.""" + digit_sum = sum([ord(char) for char in word]) + index = digit_sum % len(self.allowed_colors) + return self.allowed_colors[index] + + def _allocate_debug_log_color(self, record: logging.LogRecord): + """Get the color that represents the source file of the log.""" + if self.file_color_mapping.get(record.filename) is not None: + return self.file_color_mapping[record.filename] + + color = self._word_to_color(record.filename) + + if self.file_color_mapping.get(record.filename) is None: + # calculate the color for each file only once + self.file_color_mapping[record.filename] = color + + return color + + def _get_process_name(self): + """Generate a beaitiful to read name for this process.""" + process_path = sys.argv[0] + process_name = process_path.split("/")[-1] + + if "input-remapper-" in process_name: + process_name = process_name.replace("input-remapper-", "") + + if process_name == "gtk": + process_name = "GUI" + + return process_name + + def _get_format(self, record: logging.LogRecord): + """Generate a message format string.""" + if record.levelno == logging.INFO and not self.debug_mode: + # if not launched with --debug, then don't print "INFO:" + return "%(message)s" + + if not self.debug_mode: + color = self.level_based_colors.get(record.levelno, 9) + return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" + + color = self._allocate_debug_log_color(record) + if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: + # underline + style = f"\033[4;38;5;{color}m" + else: + style = f"\033[38;5;{color}m" + + process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") + + return ( # noqa + f'{datetime.now().strftime("%H:%M:%S.%f")} ' + f"\033[38;5;{process_color}m" # color + f"{os.getpid()} " + f"{self._get_process_name()} " + "\033[0m" # end style + f"{style}" + f"%(levelname)s " + f"%(filename)s:%(lineno)d: " + "%(message)s" + "\033[0m" # end style + ).replace(" ", " ") + + def format(self, record: logging.LogRecord): + """Overwritten format function.""" + # pylint: disable=protected-access + self._style._fmt = self._get_format(record) + return super().format(record) diff --git a/inputremapper/logging/logger.py b/inputremapper/logging/logger.py new file mode 100644 index 000000000..abc4f3992 --- /dev/null +++ b/inputremapper/logging/logger.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +"""Logging setup for input-remapper.""" + +import logging +import time +from typing import cast + +from inputremapper.logging.formatter import ColorfulFormatter + +try: + from inputremapper.commit_hash import COMMIT_HASH +except ImportError: + COMMIT_HASH = "" + + +start = time.time() + +previous_key_debug_log = None +previous_write_debug_log = None + + +class Logger(logging.Logger): + def debug_mapping_handler(self, mapping_handler): + """Parse the structure of a mapping_handler and log it.""" + if not self.isEnabledFor(logging.DEBUG): + return + + lines_and_indent = self._parse_mapping_handler(mapping_handler) + for line in lines_and_indent: + indent = " " + msg = indent * line[1] + line[0] + self._log(logging.DEBUG, msg, args=None) + + def write(self, key, uinput): + """Log that an event is being written + + Parameters + ---------- + key + anything that can be string formatted, but usually a tuple of + (type, code, value) tuples + """ + # pylint: disable=protected-access + if not self.isEnabledFor(logging.DEBUG): + return + + global previous_write_debug_log + + str_key = repr(key) + str_key = str_key.replace(",)", ")") + + msg = f'Writing {str_key} to "{uinput.name}"' + + if msg == previous_write_debug_log: + # avoid some super spam from EV_ABS events + return + + previous_write_debug_log = msg + + self._log(logging.DEBUG, msg, args=None, stacklevel=2) + + def _parse_mapping_handler(self, mapping_handler): + indent = 0 + lines_and_indent = [] + while True: + if isinstance(mapping_handler, list): + for sub_handler in mapping_handler: + sub_list = self._parse_mapping_handler(sub_handler) + for line in sub_list: + line[1] += indent + lines_and_indent.extend(sub_list) + break + + lines_and_indent.append([repr(mapping_handler), indent]) + try: + mapping_handler = mapping_handler.child + except AttributeError: + break + + indent += 1 + return lines_and_indent + + def is_debug(self): + """True, if the logger is currently in DEBUG mode.""" + return self.level <= logging.DEBUG + + def log_info(self, name="input-remapper"): + """Log version and name to the console.""" + logger.info( + "%s %s %s https://github.com/sezanzeb/input-remapper", + name, + VERSION, + COMMIT_HASH, + ) + + if EVDEV_VERSION: + logger.info("python-evdev %s", EVDEV_VERSION) + + if self.is_debug(): + logger.warning( + "Debug level will log all your keystrokes! Do not post this " + "output in the internet if you typed in sensitive or private " + "information with your device!" + ) + + def update_verbosity(self, debug): + """Set the logging verbosity according to the settings object. + + Also enable rich tracebacks in debug mode. + """ + # pylint really doesn't like what I'm doing with rich.traceback here + # pylint: disable=broad-except,import-error,import-outside-toplevel + if debug: + self.setLevel(logging.DEBUG) + + try: + from rich.traceback import install + + install(show_locals=True) + self.debug("Using rich.traceback") + except Exception as error: + # since this is optional, just skip all exceptions + if not isinstance(error, ImportError): + self.debug("Cannot use rich.traceback: %s", error) + else: + self.setLevel(logging.INFO) + + for handler in self.handlers: + handler.setFormatter(ColorfulFormatter(debug)) + + @classmethod + def bootstrap_logger(cls): + # https://github.com/python/typeshed/issues/1801 + logging.setLoggerClass(cls) + logger = cast(cls, logging.getLogger("input-remapper")) + + handler = logging.StreamHandler() + handler.setFormatter(ColorfulFormatter(False)) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.WARNING) + return logger + + +logger = Logger.bootstrap_logger() + + +# using pkg_resources to figure out the version fails in many cases, +# so we hardcode it instead +VERSION = "2.0.1" +EVDEV_VERSION = None +try: + # pkg_resources very commonly fails/breaks + import pkg_resources + + EVDEV_VERSION = pkg_resources.require("evdev")[0].version +except Exception as error: + # there have been pkg_resources.DistributionNotFound and + # pkg_resources.ContextualVersionConflict errors so far. + # We can safely ignore all Exceptions here + logger.info("Could not figure out the version") + logger.debug(error) + +# check if the version is something like 1.5.0-beta or 1.5.0-beta.5 +IS_BETA = "beta" in VERSION diff --git a/inputremapper/user.py b/inputremapper/user.py index 93669d19e..6a47d84e7 100644 --- a/inputremapper/user.py +++ b/inputremapper/user.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -26,42 +26,46 @@ import pwd -def get_user(): - """Try to find the user who called sudo/pkexec.""" - try: - return os.getlogin() - except OSError: - # failed in some ubuntu installations and in systemd services - pass - - try: - user = os.environ["USER"] - except KeyError: - # possibly the systemd service. no sudo was used - return getpass.getuser() - - if user == "root": +class UserUtils: + @staticmethod + def get_user(): + """Try to find the user who called sudo/pkexec.""" try: - return os.environ["SUDO_USER"] - except KeyError: - # no sudo was used + return os.getlogin() + except OSError: + # failed in some ubuntu installations and in systemd services pass try: - pkexec_uid = int(os.environ["PKEXEC_UID"]) - return pwd.getpwuid(pkexec_uid).pw_name + user = os.environ["USER"] except KeyError: - # no pkexec was used or the uid is unknown - pass - - return user + # possibly the systemd service. no sudo was used + return getpass.getuser() + if user == "root": + try: + return os.environ["SUDO_USER"] + except KeyError: + # no sudo was used + pass -def get_home(user): - """Try to find the user's home directory.""" - return pwd.getpwnam(user).pw_dir + try: + pkexec_uid = int(os.environ["PKEXEC_UID"]) + return pwd.getpwuid(pkexec_uid).pw_name + except KeyError: + # no pkexec was used or the uid is unknown + pass + return user -USER = get_user() + @staticmethod + def get_home(user): + """Try to find the user's home directory.""" + return pwd.getpwnam(user).pw_dir -HOME = get_home(USER) + # An odd construct, but it can't really be helped because this was done as an + # afterthought, after I learned about proper object-oriented software architecture. + # TODO Eventually, UserUtils should be constructed and injected as a UserService, + # which then initializes stuff. + user = get_user() + home = get_home(user) diff --git a/inputremapper/utils.py b/inputremapper/utils.py index 68d42922d..feeee5994 100644 --- a/inputremapper/utils.py +++ b/inputremapper/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/readme/development.md b/readme/development.md index 6f32c0b74..5bf0fd843 100644 --- a/readme/development.md +++ b/readme/development.md @@ -37,22 +37,15 @@ coverage combine && coverage report -m This assumes you are using your system's `pip`. If you are in a virtual env, a `sudo pip install` is not recommened. See [Scripts](#scripts) for alternatives. -Single tests can be executed via `tests/test.py` using the full code path as argment. -``` -python3 tests/test.py tests.unit.test_ipc.TestSocket.test_select -``` - -Also `python -m unittest` can be used, which provides more control over which tests to -run and how to fail on errors. See `python -m unittest -h` for more. ``` +python -m unittest tests/unit/test_daemon.py python -m unittest tests.unit.test_ipc.TestPipe -k "test_pipe" -f +# See `python -m unittest -h` for more. ``` Don't use your computer during integration tests to avoid interacting with the gui, which might make tests fail. -There is also a "run configuration" for PyCharm called "All Tests" included. - To read events for manual testing, `evtest` is very helpful. Add `-d` to `input-remapper-gtk` to get debug output. @@ -90,6 +83,13 @@ completely. Use `get_children()` and iterate over it with regular python `for` l Use `gtk_iteration()` in tests when interacting with GTK methods to trigger events to be emitted. +Do not do `from evdev import list_devices; list_devices()`, and instead do +`import evdev; evdev.list_devices()`. The first variant cannot be easily patched in +tests (there are ways, but as far as I can tell it has to be configured individually +for each source-file/module). The second option allows for patches to be defiend in +one central places. Importing `KEY_*`, `BTN_*`, etc. constants via `from evdev` is +fine. + Releasing --------- diff --git a/readme/usage.md b/readme/usage.md index 792e44517..0ee50ed26 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -49,14 +49,20 @@ on the `Advanced` button. A mapping with an input combination is only injected once all combination keys are pressed. This means all the input keys you press before the combination is complete -will be injected unmodified. In some cases this can be desirable, in others not. -In the advanced input configuration there is the `Release Input` toggle. +will be injected unmodified. In some cases this can be desirable, in others not. + +*Option 1*: In the advanced input configuration there is the `Release Input` toggle. This will release all inputs which are part of the combination before the mapping is injected. Consider a mapping `Shift+1 -> a` this will inject a lowercase `a` if the toggle is on and an uppercase `A` if it is off. The exact behaviour if the toggle is off is dependent on keys (are modifiers involved?), the order in which they are pressed and on your environment (X11/Wayland). By default the toggle is on. +*Option 2*: Disable the keys that are part of the combination individually. So with +a mapping of `Super+1 -> a`, you could additionally map `Super` to `disable`. Now +`Super` won't do anything anymore, and therefore pressing the combination won't have +any side effects anymore. + ## Writing Combinations You can write `Control_L + a` as mapping, which will inject those two diff --git a/setup.py b/setup.py index 5b768ae8b..94820acd0 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/tests/__init__.py b/tests/__init__.py index 6ab9b83bb..e69de29bb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +0,0 @@ -# make sure patches are loaded -import tests.test diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 66d541b11..a0922b87f 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,6 +1,5 @@ """Tests that require a linux desktop environment to be running.""" -import tests.test import gi gi.require_version("Gdk", "3.0") diff --git a/tests/integration/test_components.py b/tests/integration/test_components.py index 84869de38..6d1f23b70 100644 --- a/tests/integration/test_components.py +++ b/tests/integration/test_components.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,15 +18,14 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . +import time import unittest from typing import Optional, Tuple, Union from unittest.mock import MagicMock, call -import time import evdev -from evdev.ecodes import KEY_A, KEY_B, KEY_C - import gi +from evdev.ecodes import KEY_A, KEY_B, KEY_C gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") @@ -34,8 +33,7 @@ gi.require_version("GtkSource", "4") from gi.repository import Gtk, GLib, GtkSource, Gdk -from tests.lib.cleanup import quick_cleanup -from tests.lib.stuff import spy +from tests.lib.spy import spy from tests.lib.logger import logger from inputremapper.gui.controller import Controller @@ -86,6 +84,7 @@ ) from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup class ComponentBaseTest(unittest.TestCase): @@ -118,8 +117,6 @@ def tearDown(self) -> None: # starts without pending events. Gtk.main() - quick_cleanup() - class FlowBoxTestUtils: """Methods to test the FlowBoxes that contain presets and devices. @@ -169,6 +166,7 @@ def get_child_icons(flow_box: Gtk.FlowBox): return icon_names +@test_setup class TestDeviceGroupSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -233,6 +231,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.load_group.assert_not_called() +@test_setup class TestTargetSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -267,7 +266,7 @@ def test_populates_devices(self): def test_updates_mapping(self): self.gui.set_active_id("baz") - self.controller_mock.update_mapping.called_once_with(target_uinput="baz") + self.controller_mock.update_mapping.assert_called_once_with(target_uinput="baz") def test_selects_correct_target(self): self.message_broker.publish(MappingData(target_uinput="baz")) @@ -280,6 +279,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.update_mapping.assert_not_called() +@test_setup class TestPresetSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -349,6 +349,7 @@ def test_loads_preset(self): self.controller_mock.load_preset.assert_called_once_with("preset2") +@test_setup class TestMappingListbox(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -491,6 +492,7 @@ def test_sorts_empty_mapping_to_bottom(self): self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) +@test_setup class TestMappingSelectionLabel(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -775,6 +777,7 @@ def test_removes_name_when_name_matches_combination(self): self.controller_mock.update_mapping.assert_called_once_with(name="") +@test_setup class TestGdkEventRecorder(ComponentBaseTest): def _emit_key(self, window, code, type_): event = Gdk.Event() @@ -825,6 +828,7 @@ def test_records_combinations(self): self.assertEqual(label.get_text(), "a") +@test_setup class TestCodeEditor(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -928,6 +932,7 @@ def unfocus(): self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) +@test_setup class TestRecordingToggle(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -974,6 +979,7 @@ def test_shows_not_recording_when_recording_finished(self): self.assert_not_recording() +@test_setup class TestStatusBar(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1057,6 +1063,7 @@ def test_sets_msg_as_tooltip_if_tooltip_is_none(self): self.assertEqual(self.get_tooltip(), "msg") +@test_setup class TestAutoloadSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1084,6 +1091,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.set_autoload.assert_not_called() +@test_setup class TestReleaseCombinationSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1115,6 +1123,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.update_mapping.assert_not_called() +@test_setup class TestEventEntry(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1135,6 +1144,7 @@ def test_move_event(self): ) +@test_setup class TestCombinationListbox(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1197,6 +1207,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.load_event.assert_not_called() +@test_setup class TestAnalogInputSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1240,6 +1251,7 @@ def test_enables_switch_when_axis_event(self): self.assertTrue(self.gui.get_sensitive()) +@test_setup class TestTriggerThresholdInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1285,6 +1297,7 @@ def test_updates_configuration_according_to_selected_event(self): self.assert_key_event_config() +@test_setup class TestReleaseTimeoutInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1381,6 +1394,7 @@ def test_disables_input_based_on_input_combination(self): self.assertLess(self.gui.get_opacity(), 0.6) +@test_setup class TestOutputAxisSelector(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1449,6 +1463,7 @@ def test_updates_dropdown_model(self): self.assertEqual(len(self.gui.get_model()), 9) +@test_setup class TestKeyAxisStackSwitcher(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1511,6 +1526,7 @@ def test_avoids_infinite_recursion(self): self.controller_mock.update_mapping.assert_not_called() +@test_setup class TestTransformationDrawArea(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1548,6 +1564,7 @@ def test_redraws_when_mapping_updates(self): mock.assert_called() +@test_setup class TestSliders(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1624,6 +1641,7 @@ def test_avoids_recursion(self): self.controller_mock.update_mapping.assert_not_called() +@test_setup class TestRelativeInputCutoffInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1725,6 +1743,7 @@ def test_enables_input(self): self.assert_active() +@test_setup class TestRequireActiveMapping(ComponentBaseTest): def test_no_reqorded_input_required(self): self.box = Gtk.Box() @@ -1788,6 +1807,7 @@ def assert_active(self, widget: Gtk.Widget): self.assertEqual(widget.get_opacity(), 1) +@test_setup class TestStack(ComponentBaseTest): def test_switches_pages(self): self.stack = Gtk.Stack() @@ -1807,6 +1827,7 @@ def test_switches_pages(self): self.assertEqual(self.stack.get_visible_child_name(), "Editor") +@test_setup class TestBreadcrumbs(ComponentBaseTest): def test_breadcrumbs(self): self.label_1 = Gtk.Label() diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 5edad3b58..1ed96839f 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,20 +18,20 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - -from tests.test import is_service_running - -import os import multiprocessing -import unittest +import os import time +import unittest import gi +from tests.lib.test_setup import is_service_running + gi.require_version("Gtk", "3.0") from gi.repository import Gtk from inputremapper.daemon import Daemon, BUS_NAME +from tests.lib.test_setup import test_setup def gtk_iteration(): @@ -40,6 +40,7 @@ def gtk_iteration(): Gtk.main_iteration() +@test_setup class TestDBusDaemon(unittest.TestCase): def setUp(self): self.process = multiprocessing.Process( diff --git a/tests/integration/test_data.py b/tests/integration/test_data.py index 08d00d9db..9ed77a844 100644 --- a/tests/integration/test_data.py +++ b/tests/integration/test_data.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,32 +18,33 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - -import unittest import os +import unittest +from unittest.mock import patch + import pkg_resources from inputremapper.configs.data import get_data_path +from tests.lib.test_setup import test_setup +egg_info_distribution = pkg_resources.require("input-remapper")[0] -class TestData(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.original_location = pkg_resources.require("input-remapper")[0].location +project_root = os.getcwd().replace("/tests/integration", "") - def tearDown(self): - pkg_resources.require("input-remapper")[0].location = self.original_location +@test_setup +class TestData(unittest.TestCase): + @patch.object(egg_info_distribution, "location", project_root) def test_data_editable(self): - path = os.getcwd().replace("/tests/integration", "") - pkg_resources.require("input-remapper")[0].location = path - self.assertEqual(get_data_path(), path + "/data/") - self.assertEqual(get_data_path("a"), path + "/data/a") - + self.assertEqual(get_data_path(), project_root + "/data/") + self.assertEqual(get_data_path("a"), project_root + "/data/a") + + @patch.object( + egg_info_distribution, + "location", + "/usr/some/where/python3.8/dist-packages/", + ) def test_data_usr(self): - path = "/usr/some/where/python3.8/dist-packages/" - pkg_resources.require("input-remapper")[0].location = path - self.assertTrue(get_data_path().startswith("/usr/")) self.assertTrue(get_data_path().endswith("input-remapper/")) diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index 8224f58cb..be79a778f 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,35 +19,20 @@ # along with input-remapper. If not, see . import asyncio - -# the tests file needs to be imported first to make sure patches are loaded -from tests.test import get_project_root - -from contextlib import contextmanager -from typing import Tuple, List, Optional, Iterable - -from inputremapper.gui.autocompletion import ( - get_incomplete_parameter, - get_incomplete_function_name, -) - -from tests.lib.global_uinputs import reset_global_uinputs_for_service -from tests.lib.cleanup import cleanup -from tests.lib.stuff import spy -from tests.lib.constants import EVENT_READ_TIMEOUT -from tests.lib.fixtures import prepare_presets -from tests.lib.logger import logger -from tests.lib.fixtures import fixtures -from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe -from tests.integration.test_components import FlowBoxTestUtils - -import sys -import time import atexit +import multiprocessing import os +import sys +import time import unittest -import multiprocessing +from contextlib import contextmanager +from importlib.machinery import SourceFileLoader +from importlib.util import spec_from_loader, module_from_spec +from typing import Tuple, List, Optional, Iterable +from unittest.mock import patch, MagicMock, call + import evdev +import gi from evdev.ecodes import ( EV_KEY, EV_ABS, @@ -56,13 +41,22 @@ KEY_Q, EV_REL, ) -from unittest.mock import patch, MagicMock, call -from importlib.util import spec_from_loader, module_from_spec -from importlib.machinery import SourceFileLoader +from inputremapper.gui.autocompletion import ( + get_incomplete_parameter, + get_incomplete_function_name, +) from inputremapper.input_event import InputEvent - -import gi +from tests.integration.test_components import FlowBoxTestUtils +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import prepare_presets +from tests.lib.global_uinputs import reset_global_uinputs_for_service +from tests.lib.logger import logger +from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe +from tests.lib.project_root import get_project_root +from tests.lib.spy import spy gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") @@ -72,7 +66,7 @@ from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.mapping import Mapping -from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path +from inputremapper.configs.paths import PathUtils from inputremapper.configs.global_config import global_config from inputremapper.groups import _Groups from inputremapper.gui.data_manager import DataManager @@ -94,6 +88,7 @@ from inputremapper.injection.injector import InjectorState from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.daemon import Daemon, DaemonProxy +from tests.lib.test_setup import test_setup # iterate a few times when Gtk.main() is called, but don't block @@ -144,28 +139,31 @@ def process(): multiprocessing.Process(target=process).start() +def os_system_patch(cmd, original_os_system=os.system): + # instead of running pkexec, fork instead. This will make + # the reader-service aware of all the test patches + if "pkexec input-remapper-control --command start-reader-service" in cmd: + logger.info("pkexec-patch starting ReaderService process") + start_reader_service() + return 0 + + return original_os_system(cmd) + + @contextmanager def patch_launch(): """patch the launch function such that we don't connect to the dbus and don't use pkexec to start the reader-service""" - original_connect = Daemon.connect - original_os_system = os.system - Daemon.connect = Daemon - - def os_system(cmd): - # instead of running pkexec, fork instead. This will make - # the reader-service aware of all the test patches - if "pkexec input-remapper-control --command start-reader-service" in cmd: - logger.info("pkexec-patch starting ReaderService process") - start_reader_service() - return 0 - - return original_os_system(cmd) - - os.system = os_system - yield - os.system = original_os_system - Daemon.connect = original_connect + with patch.object( + os, + "system", + os_system_patch, + ), patch.object( + Daemon, + "connect", + Daemon, + ): + yield def clean_up_integration(test): @@ -191,19 +189,10 @@ def get_keyval(self): return True, self.keyval +@test_setup class TestGroupsFromReaderService(unittest.TestCase): - def setUp(self): - # don't try to connect, return an object instance of it instead - self.original_connect = Daemon.connect - Daemon.connect = Daemon - - # this is already part of the test. we need a bit of patching and hacking - # because we want to discover the groups as early a possible, to reduce startup - # time for the application - self.original_os_system = os.system - self.reader_service_started = MagicMock() - - def os_system(cmd): + def patch_os_system(self): + def os_system(cmd, original_os_system=os.system): # instead of running pkexec, fork instead. This will make # the reader-service aware of all the test patches if "pkexec input-remapper-control --command start-reader-service" in cmd: @@ -211,9 +200,33 @@ def os_system(cmd): self.reader_service_started() return 0 - return self.original_os_system(cmd) + return original_os_system(cmd) + + self.os_system_patch = patch.object( + os, + "system", + os_system, + ) + + # this is already part of the test. we need a bit of patching and hacking + # because we want to discover the groups as early a possible, to reduce startup + # time for the application + self.os_system_patch.start() + + def patch_daemon(self): + # don't try to connect, return an object instance of it instead + self.daemon_connect_patch = patch.object( + Daemon, + "connect", + Daemon, + ) + self.daemon_connect_patch.start() + + def setUp(self): + self.reader_service_started = MagicMock() + self.patch_os_system() + self.patch_daemon() - os.system = os_system ( self.user_interface, self.controller, @@ -224,8 +237,8 @@ def os_system(cmd): def tearDown(self): clean_up_integration(self) - os.system = self.original_os_system - Daemon.connect = self.original_connect + self.os_system_patch.stop() + self.daemon_connect_patch.stop() def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge @@ -250,37 +263,37 @@ def test_knows_devices(self): self.assertEqual(self.data_manager.active_group.name, "Foo Device") -class PatchedConfirmDelete: - def __init__(self, user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT): - self.response = response - self.user_interface = user_interface - self._original_create_dialog = user_interface._create_dialog - self.patch = None +@contextmanager +def patch_confirm_delete( + user_interface: UserInterface, + response=Gtk.ResponseType.ACCEPT, +): + original_create_dialog = user_interface._create_dialog - def _create_dialog_patch(self, *args, **kwargs): + def _create_dialog_patch(*args, **kwargs): """A patch for the deletion confirmation that briefly shows the dialog.""" - confirm_cancel_dialog = self._original_create_dialog(*args, **kwargs) + confirm_cancel_dialog = original_create_dialog(*args, **kwargs) + # the emitted signal causes the dialog to close GLib.timeout_add( 100, - lambda: confirm_cancel_dialog.emit("response", self.response), + lambda: confirm_cancel_dialog.emit("response", response), ) - Gtk.MessageDialog.run(confirm_cancel_dialog) # don't recursively call the patch - confirm_cancel_dialog.run = lambda: self.response + # don't recursively call the patch + Gtk.MessageDialog.run(confirm_cancel_dialog) - return confirm_cancel_dialog + confirm_cancel_dialog.run = lambda: response - def __enter__(self): - self.patch = patch.object( - self.user_interface, - "_create_dialog", - self._create_dialog_patch, - ) - self.patch.__enter__() + return confirm_cancel_dialog - def __exit__(self, *args, **kwargs): - self.patch.__exit__(*args, **kwargs) + with patch.object( + user_interface, + "_create_dialog", + _create_dialog_patch, + ): + # Tests are run during `yield` + yield class GuiTestBase(unittest.TestCase): @@ -468,6 +481,7 @@ def sleep(self, num_events): gtk_iteration() +@test_setup class TestColors(GuiTestBase): # requires a running ui, otherwise fails with segmentation faults def test_get_color_falls_back(self): @@ -489,10 +503,10 @@ def test_get_color_works(self): ) self.assertIsInstance(color, Gdk.RGBA) - self.assertNotAlmostEquals(color.red, fallback.red, delta=0.01) - self.assertNotAlmostEquals(color.green, fallback.blue, delta=0.01) - self.assertNotAlmostEquals(color.blue, fallback.green, delta=0.01) - self.assertNotAlmostEquals(color.alpha, fallback.alpha, delta=0.01) + self.assertNotAlmostEqual(color.red, fallback.red, delta=0.01) + self.assertNotAlmostEqual(color.green, fallback.blue, delta=0.01) + self.assertNotAlmostEqual(color.blue, fallback.green, delta=0.01) + self.assertNotAlmostEqual(color.alpha, fallback.alpha, delta=0.01) def _test_color_wont_fallback(self, get_color, fallback): color = get_color() @@ -517,6 +531,7 @@ def test_get_colors(self): self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font) +@test_setup class TestGui(GuiTestBase): """For tests that use the window. @@ -657,7 +672,7 @@ def test_select_device_without_preset(self): # it creates the file for that right away. It may have been possible # to write it such that it doesn't (its empty anyway), but it does, # so use that to test it in more detail. - path = get_preset_path("Bar Device", "new preset") + path = PathUtils.get_preset_path("Bar Device", "new preset") self.assertTrue(os.path.exists(path)) with open(path, "r") as file: self.assertEqual(file.read(), "") @@ -1398,7 +1413,7 @@ def test_remove_mapping(self): self.assertEqual(len(self.data_manager.active_preset), 2) self.assertEqual(len(self.selection_label_listbox.get_children()), 2) - with PatchedConfirmDelete(self.user_interface): + with patch_confirm_delete(self.user_interface): self.delete_mapping_btn.clicked() gtk_iteration() @@ -1443,7 +1458,7 @@ def test_rename_and_save(self): self.rename_btn.clicked() gtk_iteration() - preset_path = f"{CONFIG_PATH}/presets/Foo Device/foo.json" + preset_path = f"{PathUtils.config_path()}/presets/Foo Device/foo.json" self.assertTrue(os.path.exists(preset_path)) error_icon = self.user_interface.get("error_status_icon") self.assertFalse(error_icon.get_visible()) @@ -1457,7 +1472,7 @@ def save(): status = self.get_status_text() self.assertIn("Permission denied", status) - with PatchedConfirmDelete(self.user_interface): + with patch_confirm_delete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() self.assertFalse(os.path.exists(preset_path)) @@ -1484,7 +1499,7 @@ def test_check_for_unknown_symbols(self): self.assertFalse(warning_icon.get_visible()) # it will still save it though - with open(get_preset_path("Foo Device", "preset1")) as f: + with open(PathUtils.get_preset_path("Foo Device", "preset1")) as f: content = f.read() self.assertIn("qux", content) self.assertIn("foo", content) @@ -1780,7 +1795,7 @@ def test_start_injecting(self): # correctly uses group.key, not group.name spy2.assert_called_once_with("Foo Device 2", "preset3") - spy1.assert_called_once_with(get_config_path()) + spy1.assert_called_once_with(PathUtils.get_config_path()) for _ in range(10): time.sleep(0.1) @@ -1883,19 +1898,25 @@ def test_stop_injecting(self): def test_delete_preset(self): # as per test_initial_state we already have preset3 loaded - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) - with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL): + with patch_confirm_delete(self.user_interface, Gtk.ResponseType.CANCEL): self.delete_preset_btn.clicked() gtk_iteration() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) self.assertEqual(self.data_manager.active_preset.name, "preset3") self.assertEqual(self.data_manager.active_group.name, "Foo Device") - with PatchedConfirmDelete(self.user_interface): + with patch_confirm_delete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) self.assertEqual(self.data_manager.active_preset.name, "preset2") self.assertEqual(self.data_manager.active_group.name, "Foo Device") @@ -1971,7 +1992,7 @@ def test_shared_presets(self): self.assertNotEqual(presets1, presets3) def test_delete_last_preset(self): - with PatchedConfirmDelete(self.user_interface): + with patch_confirm_delete(self.user_interface): # as per test_initial_state we already have preset3 loaded self.assertEqual(self.data_manager.active_preset.name, "preset3") @@ -1984,14 +2005,14 @@ def test_delete_last_preset(self): self.delete_preset_btn.clicked() # the ui should be clean self.assert_gui_clean() - device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}" + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) self.delete_preset_btn.clicked() gtk_iteration() # deleting an empty preset als doesn't do weird stuff self.assert_gui_clean() - device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}" + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) def test_enable_disable_output(self): @@ -2028,7 +2049,7 @@ def test_enable_disable_output(self): self.assertTrue(self.output_box.get_sensitive()) # disable it by deleting the mapping - with PatchedConfirmDelete(self.user_interface): + with patch_confirm_delete(self.user_interface): self.delete_mapping_btn.clicked() gtk_iteration() @@ -2036,6 +2057,7 @@ def test_enable_disable_output(self): self.assertFalse(self.output_box.get_sensitive()) +@test_setup class TestAutocompletion(GuiTestBase): def press_key(self, keyval): event = Gdk.EventKey() @@ -2249,6 +2271,7 @@ def test_cycling(self): ) +@test_setup class TestDebounce(unittest.TestCase): def test_debounce(self): calls = 0 diff --git a/tests/integration/test_numlock.py b/tests/integration/test_numlock.py index b3a96ef49..d4d7f706c 100644 --- a/tests/integration/test_numlock.py +++ b/tests/integration/test_numlock.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,21 +19,14 @@ # along with input-remapper. If not, see . -from tests.lib.cleanup import quick_cleanup - import unittest from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock +from tests.lib.test_setup import test_setup +@test_setup class TestNumlock(unittest.TestCase): - @classmethod - def setUpClass(cls): - quick_cleanup() - - def tearDown(self): - quick_cleanup() - def test_numlock(self): before = is_numlock_on() diff --git a/tests/integration/test_user_interface.py b/tests/integration/test_user_interface.py index 87539bbf3..37365d124 100644 --- a/tests/integration/test_user_interface.py +++ b/tests/integration/test_user_interface.py @@ -10,14 +10,15 @@ gi.require_version("GtkSource", "4") from gi.repository import Gtk, Gdk, GLib -from tests.lib.cleanup import quick_cleanup from inputremapper.gui.utils import gtk_iteration from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.gui.user_interface import UserInterface from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup +@test_setup class TestUserInterface(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() @@ -30,7 +31,6 @@ def tearDown(self) -> None: GLib.timeout_add(0, self.user_interface.window.destroy) GLib.timeout_add(0, Gtk.main_quit) Gtk.main() - quick_cleanup() def test_shortcut(self): mock = MagicMock() diff --git a/tests/lib/cleanup.py b/tests/lib/cleanup.py index 58af6458d..14b688a70 100644 --- a/tests/lib/cleanup.py +++ b/tests/lib/cleanup.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -20,6 +20,7 @@ from __future__ import annotations +import copy import os import shutil import time @@ -27,6 +28,7 @@ import psutil from pickle import UnpicklingError +# TODO on it. You don't need a framework for this by the way: # don't import anything from input_remapper gloablly here, because some files execute # code when imported, which can screw up patches. I wish we had a dependency injection # framework that patches together the dependencies during runtime... @@ -41,10 +43,12 @@ from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.tmp import tmp from tests.lib.fixtures import fixtures -from tests.lib.stuff import environ_copy from tests.lib.patches import uinputs +environ_copy = copy.deepcopy(os.environ) + + def join_children(): """Wait for child processes to exit. Stop them if it takes too long.""" this = psutil.Process(os.getpid()) @@ -73,13 +77,14 @@ def clear_write_history(): def quick_cleanup(log=True): """Reset the applications state.""" + # TODO no: # Reminder: before patches are applied in test.py, no inputremapper module # may be imported. So tests.lib imports them just-in-time in functions instead. from inputremapper.injection.macros.macro import macro_variables from inputremapper.configs.global_config import global_config from inputremapper.configs.system_mapping import system_mapping from inputremapper.gui.utils import debounce_manager - from inputremapper.configs.paths import get_config_path + from inputremapper.configs.paths import PathUtils from inputremapper.injection.global_uinputs import global_uinputs from tests.lib.global_uinputs import reset_global_uinputs_for_service @@ -125,7 +130,7 @@ def quick_cleanup(log=True): if os.path.exists(tmp): shutil.rmtree(tmp) - global_config.path = os.path.join(get_config_path(), "config.json") + global_config.path = os.path.join(PathUtils.get_config_path(), "config.json") global_config.clear_config() global_config._save_config() diff --git a/tests/lib/constants.py b/tests/lib/constants.py index 7a6638b0e..783c92cdc 100644 --- a/tests/lib/constants.py +++ b/tests/lib/constants.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/tests/lib/fixture_pipes.py b/tests/lib/fixture_pipes.py new file mode 100644 index 000000000..f12d31584 --- /dev/null +++ b/tests/lib/fixture_pipes.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +from __future__ import annotations + +from tests.lib.fixtures import fixtures +from tests.lib.pipes import setup_pipe, close_pipe + + +def create_fixture_pipes(): + # make sure those pipes exist before any process (the reader-service) gets forked, + # so that events can be pushed after the fork. + for _fixture in fixtures: + setup_pipe(_fixture) + + +def remove_fixture_pipes(): + for _fixture in fixtures: + close_pipe(_fixture) diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py index 13d5198da..2cbe9b6c2 100644 --- a/tests/lib/fixtures.py +++ b/tests/lib/fixtures.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -349,11 +349,11 @@ def prepare_presets(): """ from inputremapper.configs.preset import Preset from inputremapper.configs.mapping import Mapping - from inputremapper.configs.paths import get_config_path, get_preset_path + from inputremapper.configs.paths import PathUtils from inputremapper.configs.global_config import global_config from inputremapper.configs.input_config import InputCombination - preset1 = Preset(get_preset_path("Foo Device", "preset1")) + preset1 = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) preset1.add( Mapping.from_combination( InputCombination.from_tuples((1, 1)), @@ -364,7 +364,7 @@ def prepare_presets(): preset1.save() time.sleep(0.1) - preset2 = Preset(get_preset_path("Foo Device", "preset2")) + preset2 = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 3)))) preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 4)))) preset2.save() @@ -372,11 +372,11 @@ def prepare_presets(): # make sure the timestamp of preset 3 is the newest, # so that it will be automatically loaded by the GUI time.sleep(0.1) - preset3 = Preset(get_preset_path("Foo Device", "preset3")) + preset3 = Preset(PathUtils.get_preset_path("Foo Device", "preset3")) preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5)))) preset3.save() - with open(get_config_path("config.json"), "w") as file: + with open(PathUtils.get_config_path("config.json"), "w") as file: json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) global_config.load_config() diff --git a/tests/lib/global_uinputs.py b/tests/lib/global_uinputs.py index d3b76a23b..cf6608c3c 100644 --- a/tests/lib/global_uinputs.py +++ b/tests/lib/global_uinputs.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/tests/lib/is_service_running.py b/tests/lib/is_service_running.py new file mode 100644 index 000000000..86e789511 --- /dev/null +++ b/tests/lib/is_service_running.py @@ -0,0 +1,32 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +from __future__ import annotations + +import subprocess + + +def is_service_running(): + """Check if the daemon is running.""" + try: + subprocess.check_output(["pgrep", "-f", "input-remapper-service"]) + return True + except subprocess.CalledProcessError: + return False diff --git a/tests/lib/logger.py b/tests/lib/logger.py index 3af684489..ab7ace952 100644 --- a/tests/lib/logger.py +++ b/tests/lib/logger.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -37,9 +37,9 @@ def update_inputremapper_verbosity(): - from inputremapper.logger import update_verbosity + from inputremapper.logging.logger import logger - update_verbosity(True) + logger.update_verbosity(True) def warn_with_traceback(message, category, filename, lineno, file=None, line=None): diff --git a/tests/lib/patches.py b/tests/lib/patches.py index 4213de695..1d34b37da 100644 --- a/tests/lib/patches.py +++ b/tests/lib/patches.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -26,6 +26,7 @@ import subprocess import time from pickle import UnpicklingError +from unittest.mock import patch import evdev @@ -45,9 +46,9 @@ def patch_paths(): - from inputremapper import user + from inputremapper.user import UserUtils - user.HOME = tmp + return patch.object(UserUtils, "home", tmp) class InputDevice: @@ -201,7 +202,7 @@ def input_props(self): uinputs = {} -class UInput: +class UInputMock: def __init__(self, events=None, name="unnamed", *args, **kwargs): self.fd = 0 self.write_count = 0 @@ -256,16 +257,23 @@ def copy(self): self.value, ) - evdev.list_devices = list_devices - evdev.InputDevice = InputDevice - evdev.UInput = UInput - evdev.InputEvent = PatchedInputEvent + return [ + patch.object(evdev, "list_devices", list_devices), + patch.object(evdev, "InputDevice", InputDevice), + patch.object(evdev.UInput, "capabilities", UInputMock.capabilities), + patch.object(evdev.UInput, "write", UInputMock.write), + patch.object(evdev.UInput, "syn", UInputMock.syn), + patch.object(evdev.UInput, "__init__", UInputMock.__init__), + patch.object(evdev, "InputEvent", PatchedInputEvent), + ] def patch_events(): # improve logging of stuff - evdev.InputEvent.__str__ = lambda self: ( - f"InputEvent{(self.type, self.code, self.value)}" + return patch.object( + evdev.InputEvent, + "__str__", + lambda self: (f"InputEvent{(self.type, self.code, self.value)}"), ) @@ -281,7 +289,7 @@ def system(command): raise Exception("Write patches to avoid running pkexec stuff") return original_system(command) - os.system = system + return patch.object(os, "system", system) def patch_check_output(): @@ -297,14 +305,14 @@ def check_output(command, *args, **kwargs): return xmodmap return original_check_output(command, *args, **kwargs) - subprocess.check_output = check_output + return patch.object(subprocess, "check_output", check_output) def patch_regrab_timeout(): # no need for a high number in tests from inputremapper.injection.injector import Injector - Injector.regrab_timeout = 0.05 + return patch.object(Injector, "regrab_timeout", 0.05) def is_running_patch(): @@ -315,7 +323,7 @@ def is_running_patch(): def patch_is_running(): from inputremapper.gui.reader_service import ReaderService - setattr(ReaderService, "is_running", is_running_patch) + return patch.object(ReaderService, "is_running", is_running_patch) class FakeDaemonProxy: @@ -359,3 +367,18 @@ def autoload_single(self, group_key: str) -> None: def hello(self, out: str) -> str: self.calls["hello"].append(out) return out + + +def create_patches(): + return [ + # Sketchy, they only work because the whole modules are imported, instead of + # importing `check_output` and `system` from the module. + *patch_evdev(), + patch_os_system(), + patch_check_output(), + # Those are comfortably wrapped in a class, and are therefore easy to patch + patch_paths(), + patch_regrab_timeout(), + patch_is_running(), + patch_events(), + ] diff --git a/tests/lib/pipes.py b/tests/lib/pipes.py index bee33494e..dc0d05cb6 100644 --- a/tests/lib/pipes.py +++ b/tests/lib/pipes.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -52,6 +52,14 @@ def setup_pipe(fixture: Fixture): pending_events[fixture] = multiprocessing.Pipe() +def close_pipe(fixture: Fixture): + if fixture in pending_events: + pipe1, pipe2 = pending_events[fixture] + pipe1.close() + pipe2.close() + del pending_events[fixture] + + def get_events(): """Get all events written by the injector.""" return uinput_write_history diff --git a/tests/lib/project_root.py b/tests/lib/project_root.py new file mode 100644 index 000000000..53ab9e594 --- /dev/null +++ b/tests/lib/project_root.py @@ -0,0 +1,37 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +from __future__ import annotations + +import os + + +def get_project_root(): + """Find the projects root, i.e. the uppermost directory of the repo.""" + # when tests are started in pycharm via the green arrow, the working directory + # is not the project root. Go up until it is found. + root = os.getcwd() + for _ in range(10): + if "setup.py" in os.listdir(root): + return root + + root = os.path.dirname(root) + + raise Exception("Could not find project root") diff --git a/tests/lib/stuff.py b/tests/lib/spy.py similarity index 89% rename from tests/lib/stuff.py rename to tests/lib/spy.py index 1bf9913a8..5475e1f1a 100644 --- a/tests/lib/stuff.py +++ b/tests/lib/spy.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,14 +18,9 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import os -import copy from unittest.mock import patch def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" return patch.object(obj, name, wraps=obj.__getattribute__(name)) - - -environ_copy = copy.deepcopy(os.environ) diff --git a/tests/lib/test_setup.py b/tests/lib/test_setup.py new file mode 100644 index 000000000..4961128e1 --- /dev/null +++ b/tests/lib/test_setup.py @@ -0,0 +1,111 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# 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 . + +from __future__ import annotations + +import os +import tracemalloc + +from inputremapper.configs.global_config import global_config +from inputremapper.configs.paths import PathUtils +from tests.lib.cleanup import cleanup, quick_cleanup +from tests.lib.fixture_pipes import create_fixture_pipes, remove_fixture_pipes +from tests.lib.is_service_running import is_service_running +from tests.lib.logger import update_inputremapper_verbosity +from tests.lib.patches import create_patches + + +def test_setup(cls): + """A class decorator to + - apply the patches to all tests + - check if the deamon is already running + - create pipes to send events to the reader service + - reset stuff automatically + """ + original_setUp = cls.setUp + original_tearDown = cls.tearDown + original_setUpClass = cls.setUpClass + original_tearDownClass = cls.tearDownClass + + tracemalloc.start() + os.environ["UNITTEST"] = "1" + update_inputremapper_verbosity() + + patches = create_patches() + + def setUpClass(): + if is_service_running(): + # let tests control daemon existance + raise Exception("Expected the service not to be running already.") + + create_fixture_pipes() + + # I don't know. Somehow tearDownClass is called before the test, so lets + # make sure the patches are started already when the class is set up, so that + # an unpatched `prepare_all` doesn't take ages to finish, and doesn't do funky + # stuff with the real evdev. + for patch in patches: + patch.start() + + # TODO if global_config is injected instead, it could work without doing this + # load_config call here. Because right now the constructor uses variables + # that are unpatched once global_config.py is imported. + global_config.path = os.path.join( + PathUtils.config_path(), + "config.json", + ) + + original_setUpClass() + + def tearDownClass(): + original_tearDownClass() + + remove_fixture_pipes() + + # Do the more thorough cleanup only after all tests of classes, because it + # slows tests down. If this is required after each test, call it in your + # tearDown method. + cleanup() + + def setUp(self): + for patch in patches: + patch.start() + + original_setUp(self) + + def tearDown(self): + original_tearDown(self) + + quick_cleanup() + + # This assertion was important to me somehow in other tests after the cleanup, + # so I added it to the test setup. I don't remember if this is important to + # check. + assert len(global_config.iterate_autoload_presets()) == 0 + + for patch in patches: + patch.stop() + + cls.setUp = setUp + cls.tearDown = tearDown + cls.setUpClass = setUpClass + cls.tearDownClass = tearDownClass + + return cls diff --git a/tests/lib/tmp.py b/tests/lib/tmp.py index 863b431cc..b9f832f44 100644 --- a/tests/lib/tmp.py +++ b/tests/lib/tmp.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/tests/lib/xmodmap.py b/tests/lib/xmodmap.py index 141dbe404..53947636c 100644 --- a/tests/lib/xmodmap.py +++ b/tests/lib/xmodmap.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index 201078f2c..000000000 --- a/tests/test.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb -# -# 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 . - -from __future__ import annotations - -import argparse -import os -import sys -import tracemalloc - -tracemalloc.start() - -# ensure nothing has loaded -if module := sys.modules.get("inputremapper"): - imported = [m for m in module.__dict__ if not m.startswith("__")] - raise AssertionError( - f"The modules {imported} from inputremapper where already imported, this can " - f"cause issues with the tests. Make sure to always import tests.test before any" - f" inputremapper module." - ) -try: - sys.modules.get("tests.test").main - raise AssertionError( - "test.py was already imported. " - "Always use 'from tests.test import ...' " - "not 'from test import ...' to import this" - ) - # have fun debugging infinitely blocking tests without this -except AttributeError: - pass - - -def get_project_root(): - """Find the projects root, i.e. the uppermost directory of the repo.""" - # when tests are started in pycharm via the green arrow, the working directory - # is not the project root. Go up until it is found. - root = os.getcwd() - for _ in range(10): - if "setup.py" in os.listdir(root): - return root - - root = os.path.dirname(root) - - raise Exception("Could not find project root") - - -# make sure the "tests" module visible -sys.path.append(get_project_root()) -if __name__ == "__main__": - # import this file to itself to make sure is not run twice and all global - # variables end up in sys.modules - # https://stackoverflow.com/questions/13181559/importing-modules-main-vs-import-as-module - import tests.test - - tests.test.main() - -import unittest -import subprocess - -os.environ["UNITTEST"] = "1" - -from tests.lib.fixtures import fixtures -from tests.lib.pipes import setup_pipe -from tests.lib.patches import ( - patch_paths, - patch_events, - patch_os_system, - patch_check_output, - patch_regrab_timeout, - patch_is_running, - patch_evdev, -) -from tests.lib.cleanup import cleanup -from tests.lib.logger import update_inputremapper_verbosity - - -def is_service_running(): - """Check if the daemon is running.""" - try: - subprocess.check_output(["pgrep", "-f", "input-remapper-service"]) - return True - except subprocess.CalledProcessError: - return False - - -if is_service_running(): - # let tests control daemon existance - raise Exception("Expected the service not to be running already.") - - -# make sure those pipes exist before any process (the reader-service) gets forked, -# so that events can be pushed after the fork. -for _fixture in fixtures: - setup_pipe(_fixture) - - -# applying patches before importing input-remappers modules is important, otherwise -# input-remapper might use non-patched modules. Importing modules from inputremapper -# just-in-time in the test-setup functions instead of globally helps. This way, -# it is ensured that the patches on evdev and such are already applied, without having -# to take care about ordering the files in a special way. -patch_paths() -patch_evdev() -patch_events() -patch_os_system() -patch_check_output() -patch_regrab_timeout() -patch_is_running() -# patch_warnings() - - -update_inputremapper_verbosity() - - -def main(): - cleanup() - # https://docs.python.org/3/library/argparse.html - parser = argparse.ArgumentParser(description=__doc__) - # repeated argument 0 or more times with modules - parser.add_argument("modules", type=str, nargs="*") - # start-dir value if not using modules, allows eg python tests/test.py --start-dir unit - parser.add_argument("--start-dir", type=str, default=".") - parsed_args = parser.parse_args() # takes from sys.argv by default - modules = parsed_args.modules - - # discoverer is really convenient, but it can't find a specific test - # in all of the available tests like unittest.main() does..., - # so provide both options. - if len(modules) > 0: - # for example - # `tests/test.py integration.test_gui.TestGui.test_can_start` - # or `tests/test.py integration.test_gui integration.test_daemon` - testsuite = unittest.defaultTestLoader.loadTestsFromNames(modules) - else: - # run all tests by default - testsuite = unittest.defaultTestLoader.discover( - parsed_args.start_dir, pattern="test_*.py" - ) - - # add a newline to each "qux (foo.bar)..." output before each test, - # because the first log will be on the same line otherwise - original_start_test = unittest.TextTestResult.startTest - - def start_test(self, test): - original_start_test(self, test) - print() - - unittest.TextTestResult.startTest = start_test - result = unittest.TextTestRunner(verbosity=2).run(testsuite) - sys.exit(not result.wasSuccessful()) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 52b471dd6..8a6637a25 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,3 +1 @@ """Tests that don't require a complete linux desktop.""" - -import tests.test diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2b0244420..f80720eeb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,21 +19,18 @@ # along with input-remapper. If not, see . -from tests.lib.cleanup import quick_cleanup -from tests.lib.tmp import tmp - import os import unittest from inputremapper.configs.global_config import global_config -from inputremapper.configs.paths import touch +from inputremapper.configs.paths import PathUtils + +from tests.lib.tmp import tmp +from tests.lib.test_setup import test_setup +@test_setup class TestConfig(unittest.TestCase): - def tearDown(self): - quick_cleanup() - self.assertEqual(len(global_config.iterate_autoload_presets()), 0) - def test_basic(self): self.assertEqual(global_config.get("a"), None) @@ -122,7 +119,7 @@ def test_save_load(self): ) config_2 = os.path.join(tmp, "config_2.json") - touch(config_2) + PathUtils.touch(config_2) with open(config_2, "w") as f: f.write('{"a":"b"}') diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 1e4503ae5..87b6dacd6 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,8 +17,9 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from inputremapper.input_event import InputEvent -from tests.lib.cleanup import quick_cleanup + +import unittest + from evdev.ecodes import ( EV_REL, EV_ABS, @@ -27,19 +28,17 @@ REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) -import unittest -from inputremapper.injection.context import Context -from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping -from inputremapper.configs.input_config import InputConfig, InputCombination +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup +@test_setup class TestContext(unittest.TestCase): - @classmethod - def setUpClass(cls): - quick_cleanup() - def test_callbacks(self): preset = Preset() cfg = { diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py index 0a7fdba5a..f2d800385 100644 --- a/tests/unit/test_control.py +++ b/tests/unit/test_control.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -20,24 +20,21 @@ """Testing the input-remapper-control command""" - - -from tests.lib.cleanup import quick_cleanup -from tests.lib.tmp import tmp - +import collections import os import time import unittest -from unittest import mock -import collections -from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader +from importlib.util import spec_from_loader, module_from_spec +from unittest.mock import patch from inputremapper.configs.global_config import global_config -from inputremapper.daemon import Daemon +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset -from inputremapper.configs.paths import get_preset_path +from inputremapper.daemon import Daemon from inputremapper.groups import groups +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp def import_control(): @@ -53,10 +50,10 @@ def import_control(): module = module_from_spec(spec) spec.loader.exec_module(module) - return module.communicate, module.utils, module.internals + return module.InputRemapperControl, module.Options -communicate, utils, internals = import_control() +InputRemapperControl, Options = import_control() options = collections.namedtuple( @@ -65,18 +62,19 @@ def import_control(): ) +@test_setup class TestControl(unittest.TestCase): - def tearDown(self): - quick_cleanup() + def setUp(self): + self.input_remapper_control = InputRemapperControl() def test_autoload(self): device_keys = ["Foo Device 2", "Bar Device"] groups_ = [groups.find(key=key) for key in device_keys] presets = ["bar0", "bar", "bar2"] paths = [ - get_preset_path(groups_[0].name, presets[0]), - get_preset_path(groups_[1].name, presets[1]), - get_preset_path(groups_[1].name, presets[2]), + PathUtils.get_preset_path(groups_[0].name, presets[0]), + PathUtils.get_preset_path(groups_[1].name, presets[1]), + PathUtils.get_preset_path(groups_[1].name, presets[2]), ] Preset(paths[0]).save() @@ -85,6 +83,8 @@ def test_autoload(self): daemon = Daemon() + self.input_remapper_control.set_daemon(daemon) + start_history = [] stop_counter = 0 @@ -99,12 +99,17 @@ def start_injecting(device: str, preset: str): start_history.append((device, preset)) daemon.injectors[device] = Injector() - daemon.start_injecting = start_injecting + patch.object(daemon, "start_injecting", start_injecting).start() global_config.set_autoload_preset(groups_[0].key, presets[0]) global_config.set_autoload_preset(groups_[1].key, presets[1]) - communicate(options("autoload", None, None, None, False, False, False), daemon) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) @@ -118,7 +123,12 @@ def start_injecting(device: str, preset: str): ) # calling autoload again doesn't load redundantly - communicate(options("autoload", None, None, None, False, False, False), daemon) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) self.assertEqual(len(start_history), 2) self.assertEqual(stop_counter, 0) self.assertFalse( @@ -129,9 +139,11 @@ def start_injecting(device: str, preset: str): ) # unless the injection in question ist stopped - communicate( - options("stop", None, None, groups_[0].key, False, False, False), - daemon, + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=groups_[0].key, ) self.assertEqual(stop_counter, 1) self.assertTrue( @@ -140,7 +152,12 @@ def start_injecting(device: str, preset: str): self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) - communicate(options("autoload", None, None, None, False, False, False), daemon) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) self.assertEqual(len(start_history), 3) self.assertEqual(start_history[2], (groups_[0].key, presets[0])) self.assertFalse( @@ -151,7 +168,12 @@ def start_injecting(device: str, preset: str): ) # if a device name is passed, will only start injecting for that one - communicate(options("stop-all", None, None, None, False, False, False), daemon) + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) @@ -160,9 +182,11 @@ def start_injecting(device: str, preset: str): ) self.assertEqual(stop_counter, 3) global_config.set_autoload_preset(groups_[1].key, presets[2]) - communicate( - options("autoload", None, None, groups_[1].key, False, False, False), - daemon, + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, ) self.assertEqual(len(start_history), 4) self.assertEqual(start_history[3], (groups_[1].key, presets[2])) @@ -175,9 +199,11 @@ def start_injecting(device: str, preset: str): # autoloading for the same device again redundantly will not autoload # again - communicate( - options("autoload", None, None, groups_[1].key, False, False, False), - daemon, + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, ) self.assertEqual(len(start_history), 4) self.assertEqual(stop_counter, 3) @@ -211,6 +237,7 @@ def test_autoload_other_path(self): Preset(paths[1]).save() daemon = Daemon() + self.input_remapper_control.set_daemon(daemon) start_history = [] daemon.start_injecting = lambda *args: start_history.append(args) @@ -220,9 +247,11 @@ def test_autoload_other_path(self): global_config.set_autoload_preset(device_names[0], presets[0]) global_config.set_autoload_preset(device_names[1], presets[1]) - communicate( - options("autoload", config_dir, None, None, False, False, False), - daemon, + self.input_remapper_control.communicate( + command="autoload", + config_dir=config_dir, + preset=None, + device=None, ) self.assertEqual(len(start_history), 2) @@ -234,6 +263,7 @@ def test_start_stop(self): preset = "preset9" daemon = Daemon() + self.input_remapper_control.set_daemon(daemon) start_history = [] stop_history = [] @@ -242,23 +272,32 @@ def test_start_stop(self): daemon.stop_injecting = lambda *args: stop_history.append(args) daemon.stop_all = lambda *args: stop_all_history.append(args) - communicate( - options("start", None, preset, group.paths[0], False, False, False), - daemon, + self.input_remapper_control.communicate( + command="start", + config_dir=None, + preset=preset, + device=group.paths[0], ) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (group.key, preset)) - communicate( - options("stop", None, None, group.paths[1], False, False, False), - daemon, + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=group.paths[1], ) self.assertEqual(len(stop_history), 1) # provided any of the groups paths as --device argument, figures out # the correct group.key to use here self.assertEqual(stop_history[0], (group.key,)) - communicate(options("stop-all", None, None, None, False, False, False), daemon) + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) self.assertEqual(len(stop_all_history), 1) self.assertEqual(stop_all_history[0], ()) @@ -268,17 +307,32 @@ def test_config_not_found(self): config_dir = "/foo/bar" daemon = Daemon() + self.input_remapper_control.set_daemon(daemon) start_history = [] stop_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) - options_1 = options("start", config_dir, path, key, False, False, False) - self.assertRaises(SystemExit, lambda: communicate(options_1, daemon)) + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="start", + config_dir=config_dir, + preset=path, + device=key, + ), + ) - options_2 = options("stop", config_dir, None, key, False, False, False) - self.assertRaises(SystemExit, lambda: communicate(options_2, daemon)) + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="stop", + config_dir=config_dir, + preset=None, + device=key, + ), + ) def test_autoload_config_dir(self): daemon = Daemon() @@ -302,19 +356,18 @@ def test_autoload_config_dir(self): daemon.set_config_dir(os.path.join(tmp, "qux")) self.assertEqual(global_config.get("foo"), "bar") - def test_internals(self): - with mock.patch("os.system") as os_system_patch: - internals( - options("start-reader-service", None, None, None, False, False, False) - ) + def test_internals_reader(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-reader-service", False) os_system_patch.assert_called_once() self.assertIn( "input-remapper-reader-service", os_system_patch.call_args.args[0] ) self.assertNotIn("-d", os_system_patch.call_args.args[0]) - with mock.patch("os.system") as os_system_patch: - internals(options("start-daemon", None, None, None, False, False, True)) + def test_internals_daemon(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-daemon", True) os_system_patch.assert_called_once() self.assertIn("input-remapper-service", os_system_patch.call_args.args[0]) self.assertIn("-d", os_system_patch.call_args.args[0]) diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py index 6e714f773..39015835d 100644 --- a/tests/unit/test_controller.py +++ b/tests/unit/test_controller.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -28,7 +28,6 @@ from inputremapper.configs.system_mapping import system_mapping from inputremapper.injection.injector import InjectorState -from tests.lib.logger import logger gi.require_version("Gtk", "3.0") from gi.repository import Gtk @@ -52,18 +51,19 @@ from inputremapper.gui.utils import CTX_ERROR, CTX_APPLY, gtk_iteration from inputremapper.gui.gettext import _ from inputremapper.injection.global_uinputs import GlobalUInputs -from inputremapper.configs.mapping import UIMapping, MappingData, MappingType, Mapping -from tests.lib.cleanup import quick_cleanup -from tests.lib.stuff import spy +from inputremapper.configs.mapping import UIMapping, MappingData, Mapping +from tests.lib.spy import spy from tests.lib.patches import FakeDaemonProxy from tests.lib.fixtures import fixtures, prepare_presets from inputremapper.configs.global_config import GlobalConfig from inputremapper.gui.controller import Controller, MAPPING_DEFAULTS from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME -from inputremapper.configs.paths import get_preset_path, CONFIG_PATH +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset +from tests.lib.test_setup import test_setup +@test_setup class TestController(unittest.TestCase): def setUp(self) -> None: super().setUp() @@ -82,9 +82,6 @@ def setUp(self) -> None: self.controller = Controller(self.message_broker, self.data_manager) self.controller.set_gui(self.user_interface) - def tearDown(self) -> None: - quick_cleanup() - def test_should_get_newest_group(self): """get_a_group should the newest group.""" with patch.object( @@ -231,7 +228,7 @@ def f(data): def test_on_load_preset_should_provide_default_mapping(self): """If there is none.""" - Preset(get_preset_path("Foo Device", "bar")).save() + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() self.data_manager.load_group("Foo Device 2") calls: List[MappingData] = [] @@ -253,7 +250,9 @@ def test_on_delete_preset_asks_for_confirmation(self): def test_deletes_preset_when_confirmed(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -261,32 +260,46 @@ def test_deletes_preset_when_confirmed(self): MessageType.user_confirm_request, lambda msg: msg.respond(True) ) self.controller.delete_preset() - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) def test_does_not_delete_preset_when_not_confirmed(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.user_interface.confirm_delete.configure_mock( return_value=Gtk.ResponseType.CANCEL ) self.controller.delete_preset() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) def test_copy_preset(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) def test_copy_preset_should_add_number(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -294,13 +307,21 @@ def test_copy_preset_should_add_number(self): self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 2" - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) def test_copy_preset_should_increment_existing_number(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -310,27 +331,45 @@ def test_copy_preset_should_increment_existing_number(self): self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 3" - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 3"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) def test_copy_preset_should_not_append_copy_twice(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy" self.controller.copy_preset() # creates "preset2 copy 2" not "preset2 copy copy" - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) def test_copy_preset_should_not_append_copy_to_copy_with_number(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -339,28 +378,42 @@ def test_copy_preset_should_not_append_copy_to_copy_with_number(self): self.controller.copy_preset() # creates "preset2 copy 2" self.controller.copy_preset() # creates "preset2 copy 3" not "preset2 copy 2 copy" - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 3"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) def test_rename_preset(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "foo"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="foo") - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "foo"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) def test_rename_preset_sanitized(self): - Preset(get_preset_path("Qux/Device?", "bla")).save() + Preset(PathUtils.get_preset_path("Qux/Device?", "bla")).save() - self.assertTrue(os.path.isfile(get_preset_path("Qux/Device?", "bla"))) - self.assertFalse(os.path.exists(get_preset_path("Qux/Device?", "blubb"))) + self.assertTrue(os.path.isfile(PathUtils.get_preset_path("Qux/Device?", "bla"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/Device?", "blubb")) + ) self.data_manager.load_group("Qux/Device?") self.data_manager.load_preset("bla") @@ -368,70 +421,102 @@ def test_rename_preset_sanitized(self): # all functions expect the true name, which is also shown to the user, but on # the file system it always uses sanitized names. - self.assertTrue(os.path.exists(get_preset_path("Qux/Device?", "foo__bar"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Qux/Device?", "foo__bar")) + ) # since the name is never stored in an un-sanitized way, this can't work - self.assertFalse(os.path.exists(get_preset_path("Qux/Device?", "foo:/bar"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/Device?", "foo:/bar")) + ) - path = os.path.join(CONFIG_PATH, "presets", "Qux_Device_", "foo__bar.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Qux_Device_", "foo__bar.json" + ) self.assertTrue(os.path.exists(path)) # using the sanitized name in function calls works as well - self.assertTrue(os.path.isfile(get_preset_path("Qux_Device_", "foo__bar"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Qux_Device_", "foo__bar")) + ) def test_rename_preset_should_pick_available_name(self): prepare_presets() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset3 2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="preset3") - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3 2"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) def test_rename_preset_should_not_rename_to_empty_name(self): prepare_presets() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="") - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) def test_rename_preset_should_not_update_same_name(self): """When the new name is the same as the current name.""" prepare_presets() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="preset2") - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2 2"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 2")) + ) def test_on_add_preset_uses_default_name(self): self.assertFalse( - os.path.exists(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) + os.path.exists(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) ) self.data_manager.load_group("Foo Device 2") self.controller.add_preset() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "new preset"))) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "new preset")) + ) def test_on_add_preset_uses_provided_name(self): - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "foo"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) self.data_manager.load_group("Foo Device 2") self.controller.add_preset(name="foo") - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "foo"))) + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) def test_on_add_preset_shows_permission_error_status(self): self.data_manager.load_group("Foo Device 2") @@ -513,7 +598,9 @@ def test_delete_mapping_asks_for_confirmation(self): def test_deletes_mapping_when_confirmed(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -524,7 +611,7 @@ def test_deletes_mapping_when_confirmed(self): self.controller.delete_mapping() self.controller.save() - preset = Preset(get_preset_path("Foo Device", "preset2")) + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset.load() self.assertIsNone( preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) @@ -532,7 +619,9 @@ def test_deletes_mapping_when_confirmed(self): def test_does_not_delete_mapping_when_not_confirmed(self): prepare_presets() - self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") @@ -544,7 +633,7 @@ def test_does_not_delete_mapping_when_not_confirmed(self): self.controller.delete_mapping() self.controller.save() - preset = Preset(get_preset_path("Foo Device", "preset2")) + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset.load() self.assertIsNotNone( preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 2381a6e55..e6f01314a 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,10 +17,12 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + +from unittest.mock import patch, MagicMock + from evdev._ecodes import EV_ABS from inputremapper.input_event import InputEvent -from tests.test import is_service_running from tests.lib.logger import logger from tests.lib.cleanup import cleanup from tests.lib.fixtures import Fixture @@ -31,7 +33,6 @@ import os import unittest import time -import subprocess import json import evdev @@ -42,26 +43,22 @@ from inputremapper.configs.mapping import Mapping from inputremapper.configs.global_config import global_config from inputremapper.groups import groups -from inputremapper.configs.paths import get_config_path, mkdir, get_preset_path +from inputremapper.configs.paths import PathUtils from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.preset import Preset from inputremapper.injection.injector import InjectorState from inputremapper.daemon import Daemon from inputremapper.injection.global_uinputs import global_uinputs +from tests.lib.test_setup import test_setup, is_service_running -check_output = subprocess.check_output -os_system = os.system -dbus_get = type(SystemBus()).get - - +@test_setup class TestDaemon(unittest.TestCase): new_fixture_path = "/dev/input/event9876" def setUp(self): - self.grab = evdev.InputDevice.grab self.daemon = None - mkdir(get_config_path()) + PathUtils.mkdir(PathUtils.get_config_path()) global_config._save_config() # the daemon should be able to create them on demand: @@ -73,24 +70,17 @@ def tearDown(self): if self.daemon is not None: self.daemon.stop_all() self.daemon = None - evdev.InputDevice.grab = self.grab - - subprocess.check_output = check_output - os.system = os_system - type(SystemBus()).get = dbus_get cleanup() - def test_connect(self): - os_system_history = [] - os.system = os_system_history.append - + @patch.object(os, "system") + def test_connect(self, os_system_mock: MagicMock): self.assertFalse(is_service_running()) # no daemon runs, should try to run it via pkexec instead. # It fails due to the patch on os.system and therefore exits the process self.assertRaises(SystemExit, Daemon.connect) - self.assertEqual(len(os_system_history), 1) + os_system_mock.assert_called_once() self.assertIsNone(Daemon.connect(False)) # make the connect command work this time by acting like a connection is @@ -99,21 +89,23 @@ def test_connect(self): set_config_dir_callcount = 0 class FakeConnection: - def set_config_dir(self, *args, **kwargs): + def set_config_dir(self, *_, **__): nonlocal set_config_dir_callcount set_config_dir_callcount += 1 - type(SystemBus()).get = lambda *args, **kwargs: FakeConnection() - self.assertIsInstance(Daemon.connect(), FakeConnection) - self.assertEqual(set_config_dir_callcount, 1) + system_bus = SystemBus() + with patch.object(type(system_bus), "get") as get_mock: + get_mock.return_value = FakeConnection() + self.assertIsInstance(Daemon.connect(), FakeConnection) + self.assertEqual(set_config_dir_callcount, 1) - self.assertIsInstance(Daemon.connect(False), FakeConnection) - self.assertEqual(set_config_dir_callcount, 2) + self.assertIsInstance(Daemon.connect(False), FakeConnection) + self.assertEqual(set_config_dir_callcount, 2) def test_daemon(self): # remove the existing system mapping to force our own into it - if os.path.exists(get_config_path("xmodmap.json")): - os.remove(get_config_path("xmodmap.json")) + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) preset_name = "foo" @@ -217,12 +209,12 @@ def test_config_dir(self): # This is important so that if the service is started via sudo or pkexec # it knows where to look for configuration files. self.daemon = Daemon() - self.assertEqual(self.daemon.config_dir, get_config_path()) + self.assertEqual(self.daemon.config_dir, PathUtils.get_config_path()) self.assertIsNone(global_config.get("foo")) def test_refresh_on_start(self): - if os.path.exists(get_config_path("xmodmap.json")): - os.remove(get_config_path("xmodmap.json")) + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) preset_name = "foo" key_code = 9 @@ -238,7 +230,7 @@ def test_refresh_on_start(self): system_mapping.clear() system_mapping._set("a", KEY_A) - preset = Preset(get_preset_path(group_name, preset_name)) + preset = Preset(PathUtils.get_preset_path(group_name, preset_name)) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=key_code)]), @@ -248,7 +240,7 @@ def test_refresh_on_start(self): ) # make the daemon load the file instead - with open(get_config_path("xmodmap.json"), "w") as file: + with open(PathUtils.get_config_path("xmodmap.json"), "w") as file: json.dump(system_mapping._mapping, file, indent=4) system_mapping.clear() @@ -473,7 +465,8 @@ def test_autoload(self): self.daemon._autoload(group_key) len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual( - daemon.autoload_history._autoload_history[group_key][1], preset_name + daemon.autoload_history._autoload_history[group_key][1], + preset_name, ) self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) injector = daemon.injectors[group_key] @@ -482,7 +475,8 @@ def test_autoload(self): # calling duplicate get_autoload does nothing self.daemon._autoload(group_key) self.assertEqual( - daemon.autoload_history._autoload_history[group_key][1], preset_name + daemon.autoload_history._autoload_history[group_key][1], + preset_name, ) self.assertEqual(injector, daemon.injectors[group_key]) self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) diff --git a/tests/unit/test_data_manager.py b/tests/unit/test_data_manager.py index 1583f794c..18c5873b3 100644 --- a/tests/unit/test_data_manager.py +++ b/tests/unit/test_data_manager.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,6 +17,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + import os import time import unittest @@ -25,11 +26,14 @@ from unittest.mock import MagicMock, call from inputremapper.configs.global_config import global_config +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping, MappingData +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.exceptions import DataManagementError from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, @@ -40,13 +44,9 @@ ) from inputremapper.gui.reader_client import ReaderClient from inputremapper.injection.global_uinputs import GlobalUInputs -from tests.lib.cleanup import quick_cleanup -from tests.lib.patches import FakeDaemonProxy from tests.lib.fixtures import prepare_presets - -from inputremapper.configs.paths import get_preset_path -from inputremapper.configs.preset import Preset -from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from tests.lib.patches import FakeDaemonProxy +from tests.lib.test_setup import test_setup class Listener: @@ -57,6 +57,7 @@ def __call__(self, data): self.calls.append(data) +@test_setup class TestDataManager(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() @@ -72,9 +73,6 @@ def setUp(self) -> None: system_mapping, ) - def tearDown(self) -> None: - quick_cleanup() - def test_load_group_provides_presets(self): """we should get all preset of a group, when loading it""" prepare_presets() @@ -135,7 +133,7 @@ def test_load_preset(self): mappings = listener.calls[0].mappings preset_name = listener.calls[0].name - expected_preset = Preset(get_preset_path("Foo Device", "preset1")) + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) expected_preset.load() expected_mappings = list(expected_preset) @@ -167,7 +165,7 @@ def test_save_preset(self): ) mapping: MappingData = listener.calls[0] - control_preset = Preset(get_preset_path("Foo Device", "preset1")) + control_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) control_preset.load() self.assertEqual( control_preset.get_mapping( @@ -353,9 +351,9 @@ def test_delete_preset(self): def test_delete_preset_sanitized(self): """should be able to delete the current preset""" - Preset(get_preset_path("Qux/Device?", "bla")).save() - Preset(get_preset_path("Qux/Device?", "foo")).save() - self.assertTrue(os.path.exists(get_preset_path("Qux/Device?", "bla"))) + Preset(PathUtils.get_preset_path("Qux/Device?", "bla")).save() + Preset(PathUtils.get_preset_path("Qux/Device?", "foo")).save() + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Qux/Device?", "bla"))) self.data_manager.load_group(group_key="Qux/Device?") self.data_manager.load_preset(name="bla") @@ -373,7 +371,9 @@ def test_delete_preset_sanitized(self): self.assertIn("foo", presets_in_group) self.assertEqual(len(listener.calls), 1) - self.assertFalse(os.path.exists(get_preset_path("Qux/Device?", "bla"))) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/Device?", "bla")) + ) def test_load_mapping(self): """should be able to load a mapping""" @@ -540,7 +540,7 @@ def test_updated_mapping_can_be_saved(self): ) self.data_manager.save() - preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) preset.load() mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) self.assertEqual(mapping.format_name(), "foo") @@ -561,7 +561,7 @@ def test_updated_mapping_saves_invalid_mapping(self): ) self.data_manager.save() - preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) preset.load() mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) self.assertIsNotNone(mapping.get_error()) @@ -667,7 +667,7 @@ def test_delete_mapping(self): """should be able to delete a mapping""" prepare_presets() - old_preset = Preset(get_preset_path("Foo Device", "preset2")) + old_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) old_preset.load() self.data_manager.load_group(group_key="Foo Device 2") @@ -688,7 +688,7 @@ def test_delete_mapping(self): ) mappings = listener.calls[0].mappings preset_name = listener.calls[0].name - expected_preset = Preset(get_preset_path("Foo Device", "preset2")) + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) expected_preset.load() expected_mappings = list(expected_preset) @@ -757,40 +757,40 @@ def test_cannot_set_autoload_without_preset(self): ) def test_finds_newest_group(self): - Preset(get_preset_path("Foo Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("Bar Device", "preset 2")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_finds_newest_preset(self): - Preset(get_preset_path("Foo Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("Foo Device", "preset 2")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2") def test_newest_group_ignores_unknown_filetypes(self): - Preset(get_preset_path("Foo Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("Bar Device", "preset 2")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() # not a preset, ignore time.sleep(0.01) - path = os.path.join(get_preset_path("Foo Device"), "picture.png") + path = os.path.join(PathUtils.get_preset_path("Foo Device"), "picture.png") os.mknod(path) self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_newest_preset_ignores_unknown_filetypes(self): - Preset(get_preset_path("Bar Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("Bar Device", "preset 2")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() time.sleep(0.01) - Preset(get_preset_path("Bar Device", "preset 3")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 3")).save() # not a preset, ignore time.sleep(0.01) - path = os.path.join(get_preset_path("Bar Device"), "picture.png") + path = os.path.join(PathUtils.get_preset_path("Bar Device"), "picture.png") os.mknod(path) self.data_manager.load_group("Bar Device") @@ -798,16 +798,18 @@ def test_newest_preset_ignores_unknown_filetypes(self): self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") def test_newest_group_ignores_unknon_groups(self): - Preset(get_preset_path("Bar Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("unknown_group", "preset 2")).save() # not a known group + Preset( + PathUtils.get_preset_path("unknown_group", "preset 2") + ).save() # not a known group self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_newest_group_and_preset_raises_file_not_found(self): """should raise file not found error when all preset folders are empty""" self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) - os.makedirs(get_preset_path("Bar Device")) + os.makedirs(PathUtils.get_preset_path("Bar Device")) self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) self.data_manager.load_group("Bar Device") self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name) @@ -817,11 +819,11 @@ def test_newest_preset_raises_data_management_error(self): self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name) def test_newest_preset_only_searches_active_group(self): - Preset(get_preset_path("Foo Device", "preset 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) - Preset(get_preset_path("Foo Device", "preset 3")).save() + Preset(PathUtils.get_preset_path("Foo Device", "preset 3")).save() time.sleep(0.01) - Preset(get_preset_path("Bar Device", "preset 2")).save() + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") @@ -833,7 +835,7 @@ def test_available_preset_name_default(self): ) def test_available_preset_name_adds_number_to_default(self): - Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() self.data_manager.load_group("Foo Device") self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" @@ -844,7 +846,7 @@ def test_available_preset_name_returns_provided_name(self): self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar") def test_available_preset_name__adds_number_to_provided_name(self): - Preset(get_preset_path("Foo Device", "bar")).save() + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2") @@ -860,27 +862,31 @@ def test_available_preset_name_sanitized(self): self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME ) - Preset(get_preset_path("Qux/Device?", DEFAULT_PRESET_NAME)).save() + Preset(PathUtils.get_preset_path("Qux/Device?", DEFAULT_PRESET_NAME)).save() self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" ) - Preset(get_preset_path("Qux/Device?", "foo")).save() + Preset(PathUtils.get_preset_path("Qux/Device?", "foo")).save() self.assertEqual(self.data_manager.get_available_preset_name("foo"), "foo 2") def test_available_preset_name_increments_default(self): - Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() - Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")).save() - Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3")).save() + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2") + ).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3") + ).save() self.data_manager.load_group("Foo Device") self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4" ) def test_available_preset_name_increments_provided_name(self): - Preset(get_preset_path("Foo Device", "foo")).save() - Preset(get_preset_path("Foo Device", "foo 1")).save() - Preset(get_preset_path("Foo Device", "foo 2")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3") diff --git a/tests/unit/test_event_pipeline/test_axis_transformation.py b/tests/unit/test_event_pipeline/test_axis_transformation.py index 6e19dbc01..08867df64 100644 --- a/tests/unit/test_event_pipeline/test_axis_transformation.py +++ b/tests/unit/test_event_pipeline/test_axis_transformation.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,15 +17,18 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + import dataclasses import functools -import unittest import itertools +import unittest from typing import Iterable, List from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from tests.lib.test_setup import test_setup +@test_setup class TestAxisTransformation(unittest.TestCase): @dataclasses.dataclass class InitArgs: diff --git a/tests/unit/test_event_pipeline/test_event_pipeline.py b/tests/unit/test_event_pipeline/test_event_pipeline.py index 078890cb1..97271993c 100644 --- a/tests/unit/test_event_pipeline/test_event_pipeline.py +++ b/tests/unit/test_event_pipeline/test_event_pipeline.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,6 +17,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + import asyncio import unittest from typing import Iterable @@ -63,6 +64,7 @@ from tests.lib.logger import logger from tests.lib.constants import MAX_ABS, MIN_ABS from tests.lib.fixtures import Fixture, fixtures +from tests.lib.test_setup import test_setup class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase): @@ -108,6 +110,7 @@ def create_event_reader( return reader +@test_setup class TestIdk(EventPipelineTestBase): async def test_any_event_as_button(self): """As long as there is an event handler and a mapping we should be able @@ -933,6 +936,7 @@ async def test_key_axis_combination_to_disable(self): ) +@test_setup class TestAbsToAbs(EventPipelineTestBase): async def test_abs_to_abs(self): gain = 0.5 @@ -1033,6 +1037,7 @@ async def test_abs_to_abs_with_input_switch(self): ) +@test_setup class TestRelToAbs(EventPipelineTestBase): async def test_rel_to_abs(self): timestamp = 0 @@ -1168,6 +1173,7 @@ async def test_rel_to_abs_with_input_switch(self): ) +@test_setup class TestAbsToRel(EventPipelineTestBase): async def test_abs_to_rel(self): """Map gamepad EV_ABS events to EV_REL events.""" @@ -1314,6 +1320,7 @@ async def test_abs_to_wheel_hi_res_quirk(self): self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0) +@test_setup class TestRelToBtn(EventPipelineTestBase): async def test_rel_to_btn(self): """Rel axis mapped to buttons are automatically released if no new rel event arrives.""" @@ -1446,6 +1453,7 @@ async def test_rel_trigger_threshold(self): ) +@test_setup class TestAbsToBtn(EventPipelineTestBase): async def test_abs_trigger_threshold(self): """Test that different activation points for abs_to_btn work correctly.""" @@ -1514,6 +1522,7 @@ async def test_abs_trigger_threshold(self): self.assertEqual(len(forwarded_history), 0) +@test_setup class TestRelToRel(EventPipelineTestBase): async def _test(self, input_code, input_value, output_code, output_value, gain=1): preset = Preset() diff --git a/tests/unit/test_event_pipeline/test_mapping_handlers.py b/tests/unit/test_event_pipeline/test_mapping_handlers.py index 05269889f..1b3262740 100644 --- a/tests/unit/test_event_pipeline/test_mapping_handlers.py +++ b/tests/unit/test_event_pipeline/test_mapping_handlers.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -64,10 +64,10 @@ from inputremapper.input_event import InputEvent, EventActions from tests.lib.cleanup import cleanup -from tests.lib.logger import logger from tests.lib.patches import InputDevice from tests.lib.constants import MAX_ABS from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup class BaseTests: @@ -90,6 +90,7 @@ def test_reset(self): mock.reset.assert_called() +@test_setup class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( @@ -110,6 +111,7 @@ def setUp(self): ) +@test_setup class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( @@ -125,6 +127,7 @@ def setUp(self): ) +@test_setup class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) @@ -151,6 +154,7 @@ async def test_reset(self): ) +@test_setup class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) @@ -214,6 +218,7 @@ async def test_rate_stays(self): self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE) +@test_setup class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) @@ -242,6 +247,7 @@ async def test_reset(self): self.assertEqual(count, global_uinputs.get_uinput("mouse").write_count) +@test_setup class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase): handler: CombinationHandler @@ -382,6 +388,7 @@ def test_no_forwards(self): self.assertListEqual(uinputs[self.keyboard_hash].write_history, []) +@test_setup class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock1 = MagicMock() @@ -399,6 +406,7 @@ def test_reset(self): self.mock3.reset.assert_called() +@test_setup class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( @@ -431,6 +439,7 @@ def test_reset(self): self.assertEqual(len(history), 2) +@test_setup class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( @@ -470,6 +479,7 @@ async def test_reset(self): self.assertEqual(len(history), 4) +@test_setup class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( @@ -485,6 +495,7 @@ def setUp(self): ) +@test_setup class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): handler: RelToRelHandler diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py index 650ec6b39..c3e39a3ff 100644 --- a/tests/unit/test_event_reader.py +++ b/tests/unit/test_event_reader.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -36,19 +36,20 @@ REL_WHEEL_HI_RES, ) +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash from tests.lib.fixtures import fixtures -from tests.lib.cleanup import quick_cleanup +from tests.lib.test_setup import test_setup +@test_setup class TestEventReader(unittest.IsolatedAsyncioTestCase): def setUp(self): self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path) @@ -58,9 +59,6 @@ def setUp(self): global_uinputs.is_service = True global_uinputs.prepare_all() - def tearDown(self): - quick_cleanup() - async def setup(self, source, mapping): """Set a EventReader up for the test and run it in the background.""" context = Context(mapping, {}, {}) diff --git a/tests/unit/test_global_uinputs.py b/tests/unit/test_global_uinputs.py index 199e483c5..458b8c1a7 100644 --- a/tests/unit/test_global_uinputs.py +++ b/tests/unit/test_global_uinputs.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,29 +17,29 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from inputremapper.input_event import InputEvent -from tests.lib.cleanup import cleanup import sys import unittest -import evdev - from unittest.mock import patch + +import evdev from evdev.ecodes import ( - EV_KEY, - EV_ABS, KEY_A, ABS_X, ) +from inputremapper.exceptions import EventNotHandled, UinputNotAvailable from inputremapper.injection.global_uinputs import ( global_uinputs, FrontendUInput, GlobalUInputs, ) -from inputremapper.exceptions import EventNotHandled, UinputNotAvailable +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.test_setup import test_setup +@test_setup class TestFrontendUinput(unittest.TestCase): def setUp(self) -> None: cleanup() @@ -57,6 +57,7 @@ def test_init(self): self.assertEqual(uinput_custom.capabilities(), capabilities) +@test_setup class TestGlobalUinputs(unittest.TestCase): def setUp(self) -> None: cleanup() diff --git a/tests/unit/test_groups.py b/tests/unit/test_groups.py index 2178bfbea..3f62bdc0c 100644 --- a/tests/unit/test_groups.py +++ b/tests/unit/test_groups.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,18 +18,14 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - -from tests.lib.cleanup import quick_cleanup -from tests.lib.fixtures import fixtures, keyboard_keys - +import json import os import unittest -import json import evdev from evdev.ecodes import EV_KEY, KEY_A -from inputremapper.configs.paths import CONFIG_PATH +from inputremapper.configs.paths import PathUtils from inputremapper.groups import ( _FindGroups, groups, @@ -37,6 +33,8 @@ DeviceType, _Group, ) +from tests.lib.fixtures import fixtures, keyboard_keys +from tests.lib.test_setup import test_setup class FakePipe: @@ -46,10 +44,8 @@ def send(self, groups): self.groups = groups +@test_setup class TestGroups(unittest.TestCase): - def tearDown(self): - quick_cleanup() - def test_group(self): group = _Group( paths=["/dev/a", "/dev/b", "/dev/c"], @@ -61,7 +57,12 @@ def test_group(self): self.assertEqual(group.key, "key") self.assertEqual( group.get_preset_path("preset1234"), - os.path.join(CONFIG_PATH, "presets", group.name, "preset1234.json"), + os.path.join( + PathUtils.config_path(), + "presets", + group.name, + "preset1234.json", + ), ) def test_find_groups(self): diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index b04e2dce8..9636b375c 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,22 +23,9 @@ except ImportError: from pydantic import ValidationError -from inputremapper.input_event import InputEvent -from tests.lib.global_uinputs import ( - reset_global_uinputs_for_service, - reset_global_uinputs_for_gui, -) -from tests.lib.patches import uinputs -from tests.lib.cleanup import quick_cleanup -from tests.lib.constants import EVENT_READ_TIMEOUT -from tests.lib.fixtures import fixtures -from tests.lib.pipes import uinput_write_history_pipe -from tests.lib.pipes import read_write_history_pipe, push_events -from tests.lib.fixtures import keyboard_keys - +import time import unittest from unittest import mock -import time import evdev from evdev.ecodes import ( @@ -53,24 +40,32 @@ ABS_VOLUME, ) +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.configs.system_mapping import ( + system_mapping, + DISABLE_CODE, + DISABLE_NAME, +) +from inputremapper.groups import groups, classify, DeviceType +from inputremapper.injection.context import Context from inputremapper.injection.injector import ( Injector, is_in_capabilities, InjectorState, get_udev_name, ) -from inputremapper.injection.numlock import is_numlock_on -from inputremapper.configs.system_mapping import ( - system_mapping, - DISABLE_CODE, - DISABLE_NAME, -) -from inputremapper.configs.preset import Preset -from inputremapper.configs.mapping import Mapping -from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.macros.parse import parse -from inputremapper.injection.context import Context -from inputremapper.groups import groups, classify, DeviceType +from inputremapper.injection.numlock import is_numlock_on +from inputremapper.input_event import InputEvent +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import keyboard_keys +from tests.lib.patches import uinputs +from tests.lib.pipes import read_write_history_pipe, push_events +from tests.lib.pipes import uinput_write_history_pipe +from tests.lib.test_setup import test_setup def wait_for_uinput_write(): @@ -80,6 +75,7 @@ def wait_for_uinput_write(): return float(time.time() - start) +@test_setup class TestInjector(unittest.IsolatedAsyncioTestCase): new_gamepad_path = "/dev/input/event100" @@ -87,7 +83,6 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): def setUpClass(cls): cls.injector = None cls.grab = evdev.InputDevice.grab - quick_cleanup() def setUp(self): self.failed = 0 @@ -111,8 +106,6 @@ def tearDown(self): self.injector = None evdev.InputDevice.grab = self.grab - quick_cleanup() - def initialize_injector(self, group, preset: Preset): self.injector = Injector(group, preset) self.injector._devices = self.injector.group.get_devices() @@ -548,11 +541,8 @@ def test_is_in_capabilities(self): self.assertTrue(is_in_capabilities(key, capabilities)) +@test_setup class TestModifyCapabilities(unittest.TestCase): - @classmethod - def setUpClass(cls): - quick_cleanup() - def setUp(self): class FakeDevice: def __init__(self): @@ -648,9 +638,6 @@ def check_keys(self, capabilities): self.assertIn(self.shift_l, keys) self.assertNotIn(DISABLE_CODE, keys) - def tearDown(self): - quick_cleanup() - def test_copy_capabilities(self): # I don't know what ABS_VOLUME is, for now I would like to just always # remove it until somebody complains, since its presence broke stuff diff --git a/tests/unit/test_input_config.py b/tests/unit/test_input_config.py index 2d4c205ce..34821e879 100644 --- a/tests/unit/test_input_config.py +++ b/tests/unit/test_input_config.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -44,8 +44,10 @@ ) from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup +@test_setup class TestInputConfig(unittest.TestCase): def test_input_config(self): test_cases = [ @@ -320,6 +322,7 @@ def test_is_immutable(self): input_config.origin_hash = "foo" +@test_setup class TestInputCombination(unittest.TestCase): def test_eq(self): a = InputCombination( diff --git a/tests/unit/test_input_event.py b/tests/unit/test_input_event.py index aab44bd86..7a1531dbe 100644 --- a/tests/unit/test_input_event.py +++ b/tests/unit/test_input_event.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -23,8 +23,10 @@ import evdev from dataclasses import FrozenInstanceError from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup +@test_setup class TestInputEvent(unittest.TestCase): def test_from_event(self): e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5)) diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py index e055e9775..70fc47188 100644 --- a/tests/unit/test_ipc.py +++ b/tests/unit/test_ipc.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,31 +17,28 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . + import asyncio import multiprocessing - -from tests.lib.cleanup import quick_cleanup -from tests.lib.tmp import tmp - -import unittest +import os import select import time -import os +import unittest from inputremapper.ipc.pipe import Pipe from inputremapper.ipc.shared_dict import SharedDict from inputremapper.ipc.socket import Server, Client, Base +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp +@test_setup class TestSharedDict(unittest.TestCase): def setUp(self): self.shared_dict = SharedDict() self.shared_dict.start() time.sleep(0.02) - def tearDown(self): - quick_cleanup() - def test_returns_none(self): self.assertIsNone(self.shared_dict.get("a")) self.assertIsNone(self.shared_dict["a"]) @@ -52,6 +49,7 @@ def test_set_get(self): self.assertEqual(self.shared_dict["a"], 3) +@test_setup class TestSocket(unittest.TestCase): def test_socket(self): def test(s1, s2): @@ -127,6 +125,7 @@ def test_base_abstract(self): self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) +@test_setup class TestPipe(unittest.IsolatedAsyncioTestCase): def test_pipe_single(self): p1 = Pipe(os.path.join(tmp, "pipe")) diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index f80454715..c89e61bf2 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -18,31 +18,37 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import evdev +from tests.lib.tmp import tmp + +import logging import os import shutil import unittest -import logging -from tests.lib.tmp import tmp +import evdev -from inputremapper.logger import logger, update_verbosity, log_info, ColorfulFormatter -from inputremapper.configs.paths import remove +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import ( + logger, + ColorfulFormatter, +) +from tests.lib.test_setup import test_setup -def add_filehandler(log_path): +def add_filehandler(log_path: str, debug: bool) -> None: """Start logging to a file.""" log_path = os.path.expanduser(log_path) os.makedirs(os.path.dirname(log_path), exist_ok=True) file_handler = logging.FileHandler(log_path) - file_handler.setFormatter(ColorfulFormatter()) + file_handler.setFormatter(ColorfulFormatter(debug)) logger.addHandler(file_handler) logger.info('Starting logging to "%s"', log_path) +@test_setup class TestLogger(unittest.TestCase): def tearDown(self): - update_verbosity(debug=True) + logger.update_verbosity(debug=True) # remove the file handler logger.handlers = [ @@ -51,12 +57,12 @@ def tearDown(self): if not isinstance(logger.handlers, logging.FileHandler) ] path = os.path.join(tmp, "logger-test") - remove(path) + PathUtils.remove(path) def test_write(self): uinput = evdev.UInput(name="foo") path = os.path.join(tmp, "logger-test") - add_filehandler(path) + add_filehandler(path, False) logger.write((evdev.ecodes.EV_KEY, evdev.ecodes.KEY_A, 1), uinput) with open(path, "r") as f: content = f.read() @@ -66,10 +72,10 @@ def test_write(self): ) def test_log_info(self): - update_verbosity(debug=False) + logger.update_verbosity(debug=False) path = os.path.join(tmp, "logger-test") - add_filehandler(path) - log_info() + add_filehandler(path, False) + logger.log_info() with open(path, "r") as f: content = f.read().lower() self.assertIn("input-remapper", content) @@ -80,12 +86,13 @@ def test_makes_path(self): shutil.rmtree(path) new_path = os.path.join(tmp, "logger-test", "a", "b", "c") - add_filehandler(new_path) + add_filehandler(new_path, False) self.assertTrue(os.path.exists(new_path)) def test_debug(self): path = os.path.join(tmp, "logger-test") - add_filehandler(path) + logger.update_verbosity(True) + add_filehandler(path, True) logger.error("abc") logger.warning("foo") logger.info("123") @@ -112,8 +119,8 @@ def test_debug(self): def test_default(self): path = os.path.join(tmp, "logger-test") - update_verbosity(debug=False) - add_filehandler(path) + logger.update_verbosity(debug=False) + add_filehandler(path, False) logger.error("abc") logger.warning("foo") logger.info("123") diff --git a/tests/unit/test_macros.py b/tests/unit/test_macros.py index 939ac6526..d05b2b78d 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -28,7 +28,6 @@ from evdev.ecodes import ( EV_REL, - EV_ABS, EV_KEY, ABS_Y, REL_Y, @@ -70,7 +69,7 @@ ) from inputremapper.input_event import InputEvent from tests.lib.logger import logger -from tests.lib.cleanup import quick_cleanup +from tests.lib.test_setup import test_setup class MacroTestBase(unittest.IsolatedAsyncioTestCase): @@ -89,7 +88,6 @@ def setUp(self): def tearDown(self): self.result = [] - quick_cleanup() def handler(self, type_: int, code: int, value: int): """Where macros should write codes to.""" @@ -123,6 +121,7 @@ class DummyMapping: target_uinput = "keyboard + mouse" +@test_setup class TestMacros(MacroTestBase): async def test_named_parameter(self): result = [] @@ -1152,6 +1151,7 @@ async def test_multiline_macro_and_comments(self): ) +@test_setup class TestIfEq(MacroTestBase): async def test_ifeq_runs(self): # deprecated ifeq function, but kept for compatibility reasons @@ -1304,6 +1304,7 @@ def set_foo(value): ) +@test_setup class TestIfSingle(MacroTestBase): async def test_if_single(self): macro = parse("if_single(key(x), key(y))", self.context, DummyMapping) @@ -1451,6 +1452,7 @@ async def test_if_single_ignores_joystick(self): self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) +@test_setup class TestIfTap(MacroTestBase): async def test_if_tap(self): macro = parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping) diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 2c5bfb227..283747c10 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -40,8 +40,10 @@ from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.gui.messages.message_broker import MessageType +from tests.lib.test_setup import test_setup +@test_setup class TestMapping(unittest.IsolatedAsyncioTestCase): def test_init(self): """Test init and that defaults are set.""" @@ -410,6 +412,7 @@ def test_wrong_target_for_macro(self): ) +@test_setup class TestUIMapping(unittest.IsolatedAsyncioTestCase): def test_init(self): """Should be able to initialize without throwing errors.""" diff --git a/tests/unit/test_message_broker.py b/tests/unit/test_message_broker.py index e5e45a4c1..6373c11a9 100644 --- a/tests/unit/test_message_broker.py +++ b/tests/unit/test_message_broker.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -22,6 +22,7 @@ from dataclasses import dataclass from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal +from tests.lib.test_setup import test_setup class Listener: @@ -38,6 +39,7 @@ class Message: msg: str +@test_setup class TestMessageBroker(unittest.TestCase): def test_calls_listeners(self): """The correct Listeners get called""" @@ -99,6 +101,7 @@ def listener4(_): self.assertEqual([4, 4, 4], calls[3:]) +@test_setup class TestSignal(unittest.TestCase): def test_eq(self): self.assertEqual(Signal(MessageType.uinputs), Signal(MessageType.uinputs)) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index da6946bcf..08c494e74 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -12,14 +12,10 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - -from tests.lib.cleanup import quick_cleanup -from tests.lib.tmp import tmp - +import json import os -import unittest import shutil -import json +import unittest from packaging import version from evdev.ecodes import ( @@ -35,66 +31,55 @@ REL_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, - KEY_A, ) +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping -from inputremapper.configs.migrations import migrate, config_version +from inputremapper.configs.migrations import Migrations +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset -from inputremapper.configs.global_config import global_config -from inputremapper.configs.paths import ( - touch, - CONFIG_PATH, - mkdir, - get_preset_path, - get_config_path, - remove, -) -from inputremapper.configs.input_config import InputCombination, InputConfig -from inputremapper.user import HOME - -from inputremapper.logger import VERSION +from inputremapper.logging.logger import VERSION +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp +@test_setup class TestMigrations(unittest.TestCase): def setUp(self): # some extra care to ensure those tests are not destroying actual presets - self.assertTrue(HOME.startswith("/tmp")) - self.assertTrue(CONFIG_PATH.startswith("/tmp")) - self.assertTrue(get_preset_path().startswith("/tmp")) - self.assertTrue(get_preset_path("foo", "bar").startswith("/tmp")) - self.assertTrue(get_config_path().startswith("/tmp")) - self.assertTrue(get_config_path("foo").startswith("/tmp")) - - self.v1_dir = os.path.join(HOME, ".config", "input-remapper") + self.assertTrue(UserUtils.home.startswith("/tmp")) + self.assertTrue(PathUtils.config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path("foo", "bar").startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path("foo").startswith("/tmp")) + + self.v1_dir = os.path.join(UserUtils.home, ".config", "input-remapper") self.beta_dir = os.path.join( - HOME, ".config", "input-remapper", "beta_1.6.0-beta" + UserUtils.home, ".config", "input-remapper", "beta_1.6.0-beta" ) - def tearDown(self): - quick_cleanup() - self.assertEqual(len(global_config.iterate_autoload_presets()), 0) - def test_migrate_suffix(self): - old = os.path.join(CONFIG_PATH, "config") - new = os.path.join(CONFIG_PATH, "config.json") + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") try: os.remove(new) except FileNotFoundError: pass - touch(old) + PathUtils.touch(old) with open(old, "w") as f: f.write("{}") - migrate() + Migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) def test_rename_config(self): - old = os.path.join(HOME, ".config", "key-mapper") - new = CONFIG_PATH + old = os.path.join(UserUtils.home, ".config", "key-mapper") + new = PathUtils.config_path() # we are not destroying our actual config files with this test self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"') @@ -105,11 +90,11 @@ def test_rename_config(self): pass old_config_json = os.path.join(old, "config.json") - touch(old_config_json) + PathUtils.touch(old_config_json) with open(old_config_json, "w") as f: f.write('{"foo":"bar"}') - migrate() + Migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) @@ -120,29 +105,29 @@ def test_rename_config(self): self.assertEqual(moved_config["foo"], "bar") def test_wont_migrate_suffix(self): - old = os.path.join(CONFIG_PATH, "config") - new = os.path.join(CONFIG_PATH, "config.json") + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") - touch(new) + PathUtils.touch(new) with open(new, "w") as f: f.write("{}") - touch(old) + PathUtils.touch(old) with open(old, "w") as f: f.write("{}") - migrate() + Migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertTrue(os.path.exists(old)) def test_migrate_preset(self): - if os.path.exists(CONFIG_PATH): - shutil.rmtree(CONFIG_PATH) + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) - p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json") - p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json") - touch(p1) - touch(p2) + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) with open(p1, "w") as f: f.write("{}") @@ -150,26 +135,34 @@ def test_migrate_preset(self): with open(p2, "w") as f: f.write("{}") - migrate() + Migrations.migrate() - self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json"))) - self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json"))) + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) self.assertTrue( - os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json")), + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), ) self.assertTrue( - os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo2", "bar2.json")), + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), ) def test_wont_migrate_preset(self): - if os.path.exists(CONFIG_PATH): - shutil.rmtree(CONFIG_PATH) + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) - p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json") - p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json") - touch(p1) - touch(p2) + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) with open(p1, "w") as f: f.write("{}") @@ -178,18 +171,26 @@ def test_wont_migrate_preset(self): f.write("{}") # already migrated - mkdir(os.path.join(CONFIG_PATH, "presets")) + PathUtils.mkdir(os.path.join(PathUtils.config_path(), "presets")) - migrate() + Migrations.migrate() - self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json"))) - self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json"))) + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) self.assertFalse( - os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json")), + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), ) self.assertFalse( - os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo2", "bar2.json")), + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), ) def test_migrate_mappings(self): @@ -199,7 +200,9 @@ def test_migrate_mappings(self): {(type, code): symbol} or {(type, code, value): symbol} should migrate to {InputCombination: {target: target, symbol: symbol, ...}} """ - path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( @@ -222,9 +225,9 @@ def test_migrate_mappings(self): }, file, ) - migrate() + Migrations.migrate() # use UIMapping to also load invalid mappings - preset = Preset(get_preset_path("Foo Device", "test"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() self.assertEqual( @@ -311,7 +314,9 @@ def test_migrate_mappings(self): self.assertEqual(8, len(preset)) def test_migrate_otherwise(self): - path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( @@ -327,9 +332,9 @@ def test_migrate_otherwise(self): file, ) - migrate() + Migrations.migrate() - preset = Preset(get_preset_path("Foo Device", "test"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() self.assertEqual( @@ -374,39 +379,41 @@ def test_migrate_otherwise(self): ) def test_add_version(self): - path = os.path.join(CONFIG_PATH, "config.json") + path = os.path.join(PathUtils.config_path(), "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: file.write("{}") - migrate() - self.assertEqual(version.parse(VERSION), config_version()) + Migrations.migrate() + self.assertEqual(version.parse(VERSION), Migrations.config_version()) def test_update_version(self): - path = os.path.join(CONFIG_PATH, "config.json") + path = os.path.join(PathUtils.config_path(), "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump({"version": "0.1.0"}, file) - migrate() - self.assertEqual(version.parse(VERSION), config_version()) + Migrations.migrate() + self.assertEqual(version.parse(VERSION), Migrations.config_version()) def test_config_version(self): - path = os.path.join(CONFIG_PATH, "config.json") + path = os.path.join(PathUtils.config_path(), "config.json") with open(path, "w") as file: file.write("{}") - self.assertEqual("0.0.0", config_version().public) + self.assertEqual("0.0.0", Migrations.config_version().public) try: os.remove(path) except FileNotFoundError: pass - self.assertEqual("0.0.0", config_version().public) + self.assertEqual("0.0.0", Migrations.config_version().public) def test_migrate_left_and_right_purpose(self): - path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( @@ -423,9 +430,9 @@ def test_migrate_left_and_right_purpose(self): }, file, ) - migrate() + Migrations.migrate() - preset = Preset(get_preset_path("Foo Device", "test"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() # 2 mappings for mouse # 2 mappings for wheel @@ -490,7 +497,9 @@ def test_migrate_left_and_right_purpose(self): def test_migrate_left_and_right_purpose2(self): # same as above, but left and right is swapped - path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( @@ -507,9 +516,9 @@ def test_migrate_left_and_right_purpose2(self): }, file, ) - migrate() + Migrations.migrate() - preset = Preset(get_preset_path("Foo Device", "test"), UIMapping) + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() # 2 mappings for mouse # 2 mappings for wheel @@ -575,7 +584,7 @@ def _create_v1_setup(self): """Create all files needed to mimic an outdated v1 configuration.""" device_name = "device_name" - mkdir(os.path.join(self.v1_dir, "presets", device_name)) + PathUtils.mkdir(os.path.join(self.v1_dir, "presets", device_name)) v1_config = {"autoload": {device_name: "foo"}, "version": "1.0"} with open(os.path.join(self.v1_dir, "config.json"), "w") as file: json.dump(v1_config, file) @@ -591,7 +600,7 @@ def _create_beta_setup(self): device_name = "device_name" # same here, but a different contents to tell the difference - mkdir(os.path.join(self.beta_dir, "presets", device_name)) + PathUtils.mkdir(os.path.join(self.beta_dir, "presets", device_name)) beta_config = {"autoload": {device_name: "bar"}, "version": "1.6"} with open(os.path.join(self.beta_dir, "config.json"), "w") as file: json.dump(beta_config, file) @@ -614,20 +623,20 @@ def _create_beta_setup(self): def test_prioritize_v1_over_beta_configs(self): # if both v1 and beta presets and config exist, migrate v1 - remove(get_config_path()) + PathUtils.remove(PathUtils.get_config_path()) device_name = "device_name" self._create_v1_setup() self._create_beta_setup() - self.assertFalse(os.path.exists(get_preset_path(device_name, "foo"))) - self.assertFalse(os.path.exists(get_config_path("config.json"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) - migrate() + Migrations.migrate() - self.assertTrue(os.path.exists(get_preset_path(device_name, "foo"))) - self.assertTrue(os.path.exists(get_config_path("config.json"))) - self.assertFalse(os.path.exists(get_preset_path(device_name, "bar"))) + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) # expect all original files to still exist self.assertTrue(os.path.join(self.v1_dir, "config.json")) @@ -636,13 +645,13 @@ def test_prioritize_v1_over_beta_configs(self): self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) # v1 configs should be in the v2 dir now, and migrated - with open(get_config_path("config.json"), "r") as f: + with open(PathUtils.get_config_path("config.json"), "r") as f: config_json = json.load(f) self.assertDictEqual( config_json, {"autoload": {device_name: "foo"}, "version": VERSION} ) - with open(get_preset_path(device_name, "foo.json"), "r") as f: - os.system(f'cat { get_preset_path(device_name, "foo.json") }') + with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }') preset_foo_json = json.load(f) self.assertEqual( preset_foo_json, @@ -661,31 +670,31 @@ def test_prioritize_v1_over_beta_configs(self): def test_copy_over_beta_configs(self): # same as test_prioritize_v1_over_beta_configs, but only create the beta # directory without any v1 presets. - remove(get_config_path()) + PathUtils.remove(PathUtils.get_config_path()) device_name = "device_name" self._create_beta_setup() - self.assertFalse(os.path.exists(get_preset_path(device_name, "bar"))) - self.assertFalse(os.path.exists(get_config_path("config.json"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) - migrate() + Migrations.migrate() - self.assertTrue(os.path.exists(get_preset_path(device_name, "bar"))) - self.assertTrue(os.path.exists(get_config_path("config.json"))) + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) # expect all original files to still exist self.assertTrue(os.path.join(self.beta_dir, "config.json")) self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) # beta configs should be in the v2 dir now - with open(get_config_path("config.json"), "r") as f: + with open(PathUtils.get_config_path("config.json"), "r") as f: config_json = json.load(f) self.assertDictEqual( config_json, {"autoload": {device_name: "bar"}, "version": VERSION} ) - with open(get_preset_path(device_name, "bar.json"), "r") as f: - os.system(f'cat { get_preset_path(device_name, "bar.json") }') + with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }') preset_foo_json = json.load(f) self.assertEqual( preset_foo_json, diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 6be62be0d..c24c80e7d 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,58 +19,55 @@ # along with input-remapper. If not, see . -from tests.lib.cleanup import quick_cleanup -from tests.lib.tmp import tmp - import os -import unittest import tempfile +import unittest -from inputremapper.configs.paths import ( - touch, - mkdir, - get_preset_path, - get_config_path, - split_all, -) +from inputremapper.configs.paths import PathUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp def _raise(error): raise error +@test_setup class TestPaths(unittest.TestCase): - def tearDown(self): - quick_cleanup() - def test_touch(self): with tempfile.TemporaryDirectory() as local_tmp: path_abcde = os.path.join(local_tmp, "a/b/c/d/e") - touch(path_abcde) + PathUtils.touch(path_abcde) self.assertTrue(os.path.exists(path_abcde)) self.assertTrue(os.path.isfile(path_abcde)) self.assertRaises( ValueError, - lambda: touch(os.path.join(local_tmp, "a/b/c/d/f/")), + lambda: PathUtils.touch(os.path.join(local_tmp, "a/b/c/d/f/")), ) def test_mkdir(self): with tempfile.TemporaryDirectory() as local_tmp: path_bcde = os.path.join(local_tmp, "b/c/d/e") - mkdir(path_bcde) + PathUtils.mkdir(path_bcde) self.assertTrue(os.path.exists(path_bcde)) self.assertTrue(os.path.isdir(path_bcde)) def test_get_preset_path(self): - self.assertTrue(get_preset_path().startswith(get_config_path())) - self.assertTrue(get_preset_path().endswith("presets")) - self.assertTrue(get_preset_path("a").endswith("presets/a")) - self.assertTrue(get_preset_path("a", "b").endswith("presets/a/b.json")) + self.assertTrue( + PathUtils.get_preset_path().startswith(PathUtils.get_config_path()) + ) + self.assertTrue(PathUtils.get_preset_path().endswith("presets")) + self.assertTrue(PathUtils.get_preset_path("a").endswith("presets/a")) + self.assertTrue( + PathUtils.get_preset_path("a", "b").endswith("presets/a/b.json") + ) def test_get_config_path(self): # might end with /beta_XXX - self.assertTrue(get_config_path().startswith(f"{tmp}/.config/input-remapper")) - self.assertTrue(get_config_path("a", "b").endswith("a/b")) + self.assertTrue( + PathUtils.get_config_path().startswith(f"{tmp}/.config/input-remapper") + ) + self.assertTrue(PathUtils.get_config_path("a", "b").endswith("a/b")) def test_split_all(self): - self.assertListEqual(split_all("a/b/c/d"), ["a", "b", "c", "d"]) + self.assertListEqual(PathUtils.split_all("a/b/c/d"), ["a", "b", "c", "d"]) diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py index 7ee781050..ea4e96983 100644 --- a/tests/unit/test_preset.py +++ b/tests/unit/test_preset.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -24,22 +24,20 @@ from evdev.ecodes import EV_KEY, EV_ABS +from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import UIMapping -from inputremapper.configs.paths import get_preset_path, get_config_path, CONFIG_PATH +from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset -from inputremapper.configs.input_config import InputCombination, InputConfig -from tests.lib.cleanup import quick_cleanup +from tests.lib.test_setup import test_setup +@test_setup class TestPreset(unittest.TestCase): def setUp(self): - self.preset = Preset(get_preset_path("foo", "bar2")) + self.preset = Preset(PathUtils.get_preset_path("foo", "bar2")) self.assertFalse(self.preset.has_unsaved_changes()) - def tearDown(self): - quick_cleanup() - def test_is_mapped_multiple_times(self): combination = InputCombination( InputCombination.from_tuples((1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4)) @@ -62,7 +60,7 @@ def test_is_mapped_multiple_times(self): self.assertTrue(self.preset._is_mapped_multiple_times(permutations[2])) def test_has_unsaved_changes(self): - self.preset.path = get_preset_path("foo", "bar2") + self.preset.path = PathUtils.get_preset_path("foo", "bar2") self.preset.add(Mapping.from_combination()) self.assertTrue(self.preset.has_unsaved_changes()) self.preset.save() @@ -82,12 +80,12 @@ def test_has_unsaved_changes(self): self.assertFalse(self.preset.has_unsaved_changes()) # change the path to a non exiting file - self.preset.path = get_preset_path("bar", "foo") + self.preset.path = PathUtils.get_preset_path("bar", "foo") # the preset has a mapping, the file has not self.assertTrue(self.preset.has_unsaved_changes()) # change back to the original path - self.preset.path = get_preset_path("foo", "bar2") + self.preset.path = PathUtils.get_preset_path("foo", "bar2") # no difference between file and memory self.assertFalse(self.preset.has_unsaved_changes()) @@ -97,12 +95,12 @@ def test_has_unsaved_changes(self): self.assertTrue(self.preset.has_unsaved_changes()) self.preset.load() - self.preset.path = get_preset_path("bar", "foo") + self.preset.path = PathUtils.get_preset_path("bar", "foo") self.preset.remove(Mapping.from_combination().input_combination) # empty preset and empty file self.assertFalse(self.preset.has_unsaved_changes()) - self.preset.path = get_preset_path("foo", "bar2") + self.preset.path = PathUtils.get_preset_path("foo", "bar2") # empty preset, but non-empty file self.assertTrue(self.preset.has_unsaved_changes()) self.preset.load() @@ -130,13 +128,15 @@ def test_save_load(self): self.preset.add( Mapping.from_combination(InputCombination((two, three)), "keyboard", "3"), ) - self.preset.path = get_preset_path("Foo Device", "test") + self.preset.path = PathUtils.get_preset_path("Foo Device", "test") self.preset.save() - path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) self.assertTrue(os.path.exists(path)) - loaded = Preset(get_preset_path("Foo Device", "test")) + loaded = Preset(PathUtils.get_preset_path("Foo Device", "test")) self.assertEqual(len(loaded), 0) loaded.load() @@ -156,7 +156,7 @@ def test_save_load(self): ) # load missing file - preset = Preset(get_config_path("missing_file.json")) + preset = Preset(PathUtils.get_config_path("missing_file.json")) self.assertRaises(FileNotFoundError, preset.load) def test_modify_mapping(self): @@ -219,11 +219,11 @@ def test_modify_mapping(self): def test_avoids_redundant_saves(self): with patch.object(self.preset, "has_unsaved_changes", lambda: False): - self.preset.path = get_preset_path("foo", "bar2") + self.preset.path = PathUtils.get_preset_path("foo", "bar2") self.preset.add(Mapping.from_combination()) self.preset.save() - with open(get_preset_path("foo", "bar2"), "r") as f: + with open(PathUtils.get_preset_path("foo", "bar2"), "r") as f: content = f.read() self.assertFalse(content) @@ -350,12 +350,12 @@ def test_empty(self): ), ) self.assertEqual(len(self.preset), 3) - self.preset.path = get_config_path("test.json") + self.preset.path = PathUtils.get_config_path("test.json") self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) self.preset.empty() - self.assertEqual(self.preset.path, get_config_path("test.json")) + self.assertEqual(self.preset.path, PathUtils.get_config_path("test.json")) self.assertTrue(self.preset.has_unsaved_changes()) self.assertEqual(len(self.preset), 0) @@ -382,7 +382,7 @@ def test_clear(self): ), ) self.assertEqual(len(self.preset), 3) - self.preset.path = get_config_path("test.json") + self.preset.path = PathUtils.get_config_path("test.json") self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) @@ -435,7 +435,9 @@ def test_dangerously_mapped_btn_left(self): self.assertFalse(self.preset.dangerously_mapped_btn_left()) def test_save_load_with_invalid_mappings(self): - ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping) + ui_preset = Preset( + PathUtils.get_config_path("test.json"), mapping_factory=UIMapping + ) ui_preset.add(UIMapping()) self.assertFalse(ui_preset.is_valid()) @@ -454,7 +456,7 @@ def test_save_load_with_invalid_mappings(self): ui_preset.save() # only the valid preset is loaded - preset = Preset(get_config_path("test.json")) + preset = Preset(PathUtils.get_config_path("test.json")) preset.load() self.assertEqual(len(preset), 1) @@ -467,7 +469,7 @@ def test_save_load_with_invalid_mappings(self): # both presets load ui_preset.clear() - ui_preset.path = get_config_path("test.json") + ui_preset.path = PathUtils.get_config_path("test.json") ui_preset.load() self.assertEqual(len(ui_preset), 2) diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py index fd486dedc..c0e0b5e46 100644 --- a/tests/unit/test_reader.py +++ b/tests/unit/test_reader.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,9 +19,9 @@ # along with input-remapper. If not, see . import asyncio -import os import json import multiprocessing +import os import time import unittest from typing import List, Optional @@ -53,17 +53,17 @@ from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.reader_service import ReaderService, ContextDummy from inputremapper.input_event import InputEvent -from tests.lib.fixtures import new_event -from tests.lib.cleanup import quick_cleanup from tests.lib.constants import ( EVENT_READ_TIMEOUT, START_READING_DELAY, MAX_ABS, MIN_ABS, ) -from tests.lib.pipes import push_event, push_events from tests.lib.fixtures import fixtures -from tests.lib.stuff import spy +from tests.lib.fixtures import new_event +from tests.lib.pipes import push_event, push_events +from tests.lib.spy import spy +from tests.lib.test_setup import test_setup CODE_1 = 100 CODE_2 = 101 @@ -89,6 +89,7 @@ def wait(func, timeout=1.0): break +@test_setup class TestReaderAsyncio(unittest.IsolatedAsyncioTestCase): def setUp(self): self.reader_service = None @@ -97,7 +98,6 @@ def setUp(self): self.reader_client = ReaderClient(self.message_broker, self.groups) def tearDown(self): - quick_cleanup() try: self.reader_client.terminate() except (BrokenPipeError, OSError): @@ -126,8 +126,9 @@ def remember_context(*args, **kwargs): context = original_create_event_pipeline(*args, **kwargs) return context - with mock.patch( - "inputremapper.gui.reader_service.ReaderService._create_event_pipeline", + with mock.patch.object( + ReaderService, + "_create_event_pipeline", remember_context, ): await self.create_reader_service() @@ -157,6 +158,7 @@ def remember_context(*args, **kwargs): self.assertEqual([call[0] for call in write_spy.call_args_list], events) +@test_setup class TestReaderMultiprocessing(unittest.TestCase): def setUp(self): self.reader_service_process = None @@ -165,7 +167,6 @@ def setUp(self): self.reader_client = ReaderClient(self.message_broker, self.groups) def tearDown(self): - quick_cleanup() try: self.reader_client.terminate() except (BrokenPipeError, OSError): diff --git a/tests/unit/test_system_mapping.py b/tests/unit/test_system_mapping.py index ab4010dbf..3b2a5d462 100644 --- a/tests/unit/test_system_mapping.py +++ b/tests/unit/test_system_mapping.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -26,15 +26,13 @@ from evdev.ecodes import BTN_LEFT, KEY_A -from inputremapper.configs.paths import CONFIG_PATH +from inputremapper.configs.paths import PathUtils from inputremapper.configs.system_mapping import SystemMapping, XMODMAP_FILENAME -from tests.lib.cleanup import quick_cleanup +from tests.lib.test_setup import test_setup +@test_setup class TestSystemMapping(unittest.TestCase): - def tearDown(self): - quick_cleanup() - def test_update(self): system_mapping = SystemMapping() system_mapping.update({"foo1": 101, "bar1": 102}) @@ -44,7 +42,7 @@ def test_update(self): def test_xmodmap_file(self): system_mapping = SystemMapping() - path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) system_mapping.populate() @@ -70,7 +68,7 @@ def check_output(*args, **kwargs): with patch.object(subprocess, "check_output", check_output): system_mapping = SystemMapping() - path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) system_mapping.populate() @@ -83,7 +81,7 @@ def check_output(*args, **kwargs): with patch.object(subprocess, "check_output", check_output): system_mapping = SystemMapping() - path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) system_mapping.populate() diff --git a/tests/unit/test_test.py b/tests/unit/test_test.py index 5c53bd90c..913eb1912 100644 --- a/tests/unit/test_test.py +++ b/tests/unit/test_test.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -17,38 +17,36 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import asyncio - -from tests.lib.cleanup import cleanup, quick_cleanup -from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY -from tests.lib.logger import logger -from tests.lib.fixtures import fixtures -from tests.lib.pipes import push_events -from tests.lib.patches import InputDevice +import asyncio +import multiprocessing import os -import unittest import time -import multiprocessing +import unittest import evdev from evdev.ecodes import EV_ABS, EV_KEY from inputremapper.groups import groups, _Groups +from inputremapper.gui.messages.message_broker import MessageBroker from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.reader_service import ReaderService from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash -from inputremapper.gui.messages.message_broker import MessageBroker +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import InputDevice +from tests.lib.pipes import push_events +from tests.lib.test_setup import test_setup +@test_setup class TestTest(unittest.TestCase): def test_stubs(self): self.assertIsNotNone(groups.find(key="Foo Device 2")) - def tearDown(self): - quick_cleanup() - def test_fake_capabilities(self): device = InputDevice("/dev/input/event30") capabilities = device.capabilities(absinfo=False) diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py index 1322dbe97..d9e35f09c 100644 --- a/tests/unit/test_user.py +++ b/tests/unit/test_user.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -19,29 +19,26 @@ # along with input-remapper. If not, see . -from tests.lib.cleanup import quick_cleanup - import os import unittest from unittest import mock -from inputremapper.user import get_user, get_home +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup def _raise(error): raise error +@test_setup class TestUser(unittest.TestCase): - def tearDown(self): - quick_cleanup() - def test_get_user(self): with mock.patch("os.getlogin", lambda: "foo"): - self.assertEqual(get_user(), "foo") + self.assertEqual(UserUtils.get_user(), "foo") with mock.patch("os.getlogin", lambda: "root"): - self.assertEqual(get_user(), "root") + self.assertEqual(UserUtils.get_user(), "root") property_mock = mock.Mock() property_mock.configure_mock(pw_name="quix") @@ -50,15 +47,15 @@ def test_get_user(self): ): os.environ["USER"] = "root" os.environ["SUDO_USER"] = "qux" - self.assertEqual(get_user(), "qux") + self.assertEqual(UserUtils.get_user(), "qux") os.environ["USER"] = "root" del os.environ["SUDO_USER"] os.environ["PKEXEC_UID"] = "1000" - self.assertNotEqual(get_user(), "root") + self.assertNotEqual(UserUtils.get_user(), "root") def test_get_home(self): property_mock = mock.Mock() property_mock.configure_mock(pw_dir="/custom/home/foo") with mock.patch("pwd.getpwnam", return_value=property_mock): - self.assertEqual(get_home("foo"), "/custom/home/foo") + self.assertEqual(UserUtils.get_home("foo"), "/custom/home/foo") diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index ef0e28d9a..e8069de05 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2023 sezanzeb +# Copyright (C) 2024 sezanzeb # # This file is part of input-remapper. # @@ -24,8 +24,10 @@ from evdev._ecodes import EV_ABS, ABS_X, BTN_WEST, BTN_Y, EV_KEY, KEY_A from inputremapper.utils import get_evdev_constant_name +from tests.lib.test_setup import test_setup +@test_setup class TestUtil(unittest.TestCase): def test_get_evdev_constant_name(self): # BTN_WEST and BTN_Y both are code 308. I don't care which one is chosen