Source code for autokey.scripting.engine

# Copyright (C) 2011 Chris Dekter
#
# This program 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.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.

"""Engine backend for Autokey"""

import pathlib

from collections.abc import Iterable

from typing import Tuple, Optional, List, Union

import autokey.model.folder
import autokey.model.helpers
import autokey.model.phrase
import autokey.model.script
from autokey import configmanager
from autokey.model.key import Key

from autokey.scripting.system import System

logger = __import__("autokey.logger").logger.get_logger(__name__)

[docs]class Engine: """ Provides access to the internals of AutoKey. Note that any configuration changes made using this API while the configuration window is open will not appear until it is closed and re-opened. """ SendMode = autokey.model.phrase.SendMode Key = Key def __init__(self, config_manager, runner): """ """ self.configManager = config_manager self.runner = runner self.monitor = config_manager.app.monitor self._macro_args = [] self._script_args = [] self._script_kwargs = {} self._return_value = '' self._triggered_abbreviation = None # type: Optional[str]
[docs] def get_folder(self, title: str): """ Retrieve a folder by its title Usage: C{engine.get_folder(title)} Note that if more than one folder has the same title, only the first match will be returned. """ validateType(title, "title", str) for folder in self.configManager.allFolders: if folder.title == title: return folder return None
[docs] def create_folder(self, title: str, parent_folder=None, temporary=False): """ Create and return a new folder. Usage: C{engine.create_folder("new folder"), parent_folder=folder, temporary=True} Descriptions for the optional arguments: @param parentFolder: Folder to make this folder a subfolder of. If passed as a folder, it will be that folder within auotkey. If passed as pathlib.Path, it will be created or added at that path. Paths expand ~ to $HOME. @param temporary: Folders created with temporary=True are not persisted. Used for single-source rc-style scripts. Cannot be used if parent_folder is a Path. If a folder of that name already exists, this will return it unchanged. If the folder wasn't already added to autokey, it will be. The 'temporary' property is not touched to avoid deleting an existing folder. Note that if more than one folder has the same title, only the first match will be returned. """ validateType(title, "title", str) validateType(parent_folder, "parent_folder", [autokey.model.folder.Folder, pathlib.Path]) validateType(temporary, "temporary", bool) # XXX Doesn't check if a folder already exists at this path in autokey. if isinstance(parent_folder, pathlib.Path): if temporary: raise ValueError("Parameter 'temporary' is True, but a path \ was given as the parent folder. Temporary folders \ cannot use absolute paths.") path = parent_folder.expanduser() / title path.mkdir(parents=True, exist_ok=True) new_folder = autokey.model.folder.Folder(title, path=str(path.resolve())) self.configManager.allFolders.append(new_folder) return new_folder # TODO: Convert this to use get_folder, when we change to specifying # the exact folder by more than just title. if parent_folder is None: parent_folders = self.configManager.allFolders elif isinstance(parent_folder, autokey.model.folder.Folder): parent_folders = parent_folder.folders else: # Input is previously validated, must match one of the above. pass for folder in parent_folders: if folder.title == title: return folder else: new_folder = autokey.model.folder.Folder(title) if parent_folder is None: self.configManager.allFolders.append(new_folder) else: parent_folder.add_folder(new_folder) if not temporary and parent_folder.temporary: raise ValueError("Parameter 'temporary' is False, but parent folder is a temporary one. \ Folders created within temporary folders must themselves be set temporary") if not temporary: new_folder.persist() else: new_folder.temporary = True return new_folder
[docs] def create_phrase(self, folder, name: str, contents: str, abbreviations: Union[str, List[str]]=None, hotkey: Tuple[List[Union[Key, str]], Union[Key, str]]=None, send_mode: autokey.model.phrase.SendMode = autokey.model.phrase.SendMode.CB_CTRL_V, window_filter: str=None, show_in_system_tray: bool=False, always_prompt: bool=False, temporary=False, replace_existing_hotkey=False): """ Create a new text phrase inside the given folder. Use C{engine.get_folder(folder_name)} to retrieve the folder you wish to create the Phrase in. If the folder is a temporary one, the phrase will be created as temporary. The first three arguments (folder, name and contents) are required. All further arguments are optional and considered to be keyword-argument only. Do not rely on the order of the optional arguments. The optional parameters can be used to configure the newly created Phrase. Usage (minimal example): C{engine.create_phrase(folder, name, contents)} Further concrete examples: C{ engine.create_phrase(folder, "My new Phrase", "This is the Phrase content", abbreviations=["abc", "def"], hotkey=([engine.Key.SHIFT], engine.Key.NP_DIVIDE), send_mode=engine.SendMode.CB_CTRL_SHIFT_V, window_filter="konsole\\.Konsole", show_in_system_tray=True) } Descriptions for the optional arguments: abbreviations may be a single string or a list of strings. Each given string is assigned as an abbreviation to the newly created phrase. hotkey parameter: The hotkey parameter accepts a 2-tuple, consisting of a list of modifier keys in the first element and an unshifted (lowercase) key as the second element. Modifier keys must be given as a list of strings (or Key enum instances), with the following values permitted: <ctrl> <alt> <super> <hyper> <meta> <shift> The key must be an unshifted character (i.e. lowercase) or a Key enum instance. Modifier keys from the list above are NOT allowed here. Example: (["<ctrl>", "<alt>"], "9") to assign "<Ctrl>+<Alt>+9" as a hotkey. The Key enum contains objects representing various special keys and is available as an attribute of the "engine" object, named "Key". So to access a function key, you can use the string "<f12>" or engine.Key.F12 See the AutoKey Wiki for an overview of all available keys in the enumeration. send_mode: This parameter configures how AutoKey sends the phrase content, for example by typing or by pasting using the clipboard. It accepts items from the SendMode enumeration, which is also available from the engine object as engine.SendMode. The parameter defaults to engine.SendMode.KEYBOARD. Available send modes are: KEYBOARD CB_CTRL_V CB_CTRL_SHIFT_V CB_SHIFT_INSERT SELECTION To paste the Phrase using "<shift>+<insert>, set send_mode=engine.SendMode.CB_SHIFT_INSERT window_filter: Accepts a string which will be used as a regular expression to match window titles or applications using the WM_CLASS attribute. @param folder: folder to place the abbreviation in, retrieved using C{engine.get_folder()} @param name: Name/description for the phrase. @param contents: the expansion text @param abbreviations: Can be a single string or a list (or other iterable) of strings. Assigned to the Phrase @param hotkey: A tuple containing a keyboard combination that will be assigned as a hotkey. First element is a list of modifiers, second element is the key. @param send_mode: The pasting mode that will be used to expand the Phrase. Used to configure, how the Phrase is expanded. Defaults to typing using the "CTRL+V" method. @param window_filter: A string containing a regular expression that will be used as the window filter. @param show_in_system_tray: A boolean defaulting to False. If set to True, the new Phrase will be shown in the tray icon context menu. @param always_prompt: A boolean defaulting to False. If set to True, the Phrase expansion has to be manually confirmed, each time it is triggered. @param temporary: Hotkeys created with temporary=True are not persisted as .jsons, and are replaced if the description is not unique within the folder. Used for single-source rc-style scripts. @param replace_existing_hotkey: If true, instead of warning if the hotkey is already in use by another phrase or folder, it removes the hotkey from those clashes and keeps this phrase's hotkey. @raise ValueError: If a given abbreviation or hotkey is already in use or parameters are otherwise invalid @return The created Phrase object. This object is NOT considered part of the public API and exposes the raw internals of AutoKey. Ignore it, if you don’t need it or don’t know what to do with it. It can be used for _really_ advanced use cases, where further customizations are desired. Use at your own risk. No guarantees are made about the object’s structure. Read the AutoKey source code for details. """ validateArguments(folder, name, contents, abbreviations, hotkey, send_mode, window_filter, show_in_system_tray, always_prompt, temporary, replace_existing_hotkey) if abbreviations and isinstance(abbreviations, str): abbreviations = [abbreviations] check_abbreviation_unique(self.configManager, abbreviations, window_filter) if not replace_existing_hotkey: check_hotkey_unique(self.configManager, hotkey, window_filter) else: # XXX If something causes the phrase creation to fail after this, # this will unset the hotkey without replacing it. self.__clear_existing_hotkey(hotkey, window_filter) self.monitor.suspend() try: p = autokey.model.phrase.Phrase(name, contents) if send_mode in autokey.model.phrase.SendMode: p.sendMode = send_mode if abbreviations: p.add_abbreviations(abbreviations) if hotkey: p.set_hotkey(*hotkey) if window_filter: p.set_window_titles(window_filter) p.show_in_tray_menu = show_in_system_tray p.prompt = always_prompt p.temporary = temporary folder.add_item(p) # Don't save a json if it is a temporary hotkey. Won't persist across # reloads. if not temporary: p.persist() return p finally: self.monitor.unsuspend() self.configManager.config_altered(False)
def __clear_existing_hotkey(self, hotkey, window_filter): existing_item = self.get_item_with_hotkey(hotkey) if existing_item and not isinstance(existing_item, configmanager.configmanager.GlobalHotkey): if existing_item.filter_matches(window_filter): existing_item.unset_hotkey()
[docs] def create_abbreviation(self, folder, description, abbr, contents): """ DEPRECATED. Use engine.create_phrase() with appropriate keyword arguments instead. Create a new text phrase inside the given folder and assign the abbreviation given. Usage: C{engine.create_abbreviation(folder, description, abbr, contents)} When the given abbreviation is typed, it will be replaced with the given text. @param folder: folder to place the abbreviation in, retrieved using C{engine.get_folder()} @param description: description for the phrase @param abbr: the abbreviation that will trigger the expansion @param contents: the expansion text @raise Exception: if the specified abbreviation is not unique """ if not self.configManager.check_abbreviation_unique(abbr, None, None)[0]: raise Exception("The specified abbreviation is already in use") self.monitor.suspend() p = autokey.model.phrase.Phrase(description, contents) p.modes.append(autokey.model.helpers.TriggerMode.ABBREVIATION) p.abbreviations = [abbr] folder.add_item(p) p.persist() self.monitor.unsuspend() self.configManager.config_altered(False)
[docs] def create_hotkey(self, folder, description, modifiers, key, contents): """ DEPRECATED. Use engine.create_phrase() with appropriate keyword arguments instead. Create a text hotkey Usage: C{engine.create_hotkey(folder, description, modifiers, key, contents)} When the given hotkey is pressed, it will be replaced with the given text. Modifiers must be given as a list of strings, with the following values permitted: <ctrl> <alt> <super> <hyper> <meta> <shift> The key must be an unshifted character (i.e. lowercase) @param folder: folder to place the abbreviation in, retrieved using C{engine.get_folder()} @param description: description for the phrase @param modifiers: modifiers to use with the hotkey (as a list) @param key: the hotkey @param contents: the expansion text @raise Exception: if the specified hotkey is not unique """ modifiers.sort() if not self.configManager.check_hotkey_unique(modifiers, key, None, None)[0]: raise Exception("The specified hotkey and modifier combination is already in use") self.monitor.suspend() p = autokey.model.phrase.Phrase(description, contents) p.modes.append(autokey.model.helpers.TriggerMode.HOTKEY) p.set_hotkey(modifiers, key) folder.add_item(p) p.persist() self.monitor.unsuspend() self.configManager.config_altered(False)
[docs] def run_script(self, description, *args, **kwargs): """ Run an existing script using its description or path to look it up Usage: C{engine.run_script(description, 'foo', 'bar', foobar='foobar')} @param description: description of the script to run. If parsable as an absolute path to an existing file, that will be run instead. @raise Exception: if the specified script does not exist """ self._script_args = args self._script_kwargs = kwargs path = pathlib.Path(description) path = path.expanduser() # Check if absolute path. if pathlib.PurePath(path).is_absolute() and path.exists(): self.runner.run_subscript(path) else: target_script = None for item in self.configManager.allItems: if item.description == description and isinstance(item, autokey.model.script.Script): target_script = item if target_script is not None: self.runner.run_subscript(target_script) else: raise Exception("No script with description '%s' found" % description) return self._return_value
[docs] def run_script_from_macro(self, args): """ Used internally by AutoKey for phrase macros """ self._macro_args = args["args"].split(',') try: self.run_script(args["name"]) except Exception as e: # TODO: Log more information here, instead of setting the return # value. self.set_return_value("{ERROR: %s}" % str(e))
[docs] def run_system_command_from_macro(self, args): """ Used internally by AutoKey for system macros """ try: self._return_value = System.exec_command(args["command"], getOutput=True) except Exception as e: self.set_return_value("{ERROR: %s}" % str(e))
[docs] def get_script_arguments(self): """ Get the arguments supplied to the current script via the scripting api Usage: C{engine.get_script_arguments()} @return: the arguments @rtype: C{list[Any]} """ return self._script_args
[docs] def get_script_keyword_arguments(self): """ Get the arguments supplied to the current script via the scripting api as keyword args. Usage: C{engine.get_script_keyword_arguments()} @return: the arguments @rtype: C{Dict[str, Any]} """ return self._script_kwargs
[docs] def get_macro_arguments(self): """ Get the arguments supplied to the current script via its macro Usage: C{engine.get_macro_arguments()} @return: the arguments @rtype: C{list(str())} """ return self._macro_args
[docs] def set_return_value(self, val): """ Store a return value to be used by a phrase macro Usage: C{engine.set_return_value(val)} @param val: value to be stored """ self._return_value = val
def _get_return_value(self): """ Used internally by AutoKey for phrase macros """ ret = self._return_value self._return_value = '' return ret def _set_triggered_abbreviation(self, abbreviation: str, trigger_character: str): """ Used internally by AutoKey to provide the abbreviation and trigger that caused the script to execute. @param abbreviation: Abbreviation that caused the script to execute @param trigger_character: Possibly empty "trigger character". As defined in the abbreviation configuration. """ self._triggered_abbreviation = abbreviation self._triggered_character = trigger_character
[docs] def get_triggered_abbreviation(self) -> Tuple[Optional[str], Optional[str]]: """ This function can be queried by a script to get the abbreviation text that triggered it’s execution. If a script is triggered by an abbreviation, this function returns a tuple containing two strings. First element is the abbreviation text. The second element is the trigger character that finally caused the execution. It is typically some whitespace character, like ' ', '\t' or a newline character. It is empty, if the abbreviation was configured to "trigger immediately". If the script execution was triggered by a hotkey, a call to the DBus interface, the tray icon, the "Run" button in the main window or any other means, this function returns a tuple containing two None values. Usage: C{abbreviation, trigger_character = engine.get_triggered_abbreviation()} You can determine if the script was triggered by an abbreviation by simply testing the truth value of the first returned value. @return: Abbreviation that triggered the script execution, if any. @rtype: C{Tuple[Optional[str], Optional[str]]} """ return self._triggered_abbreviation, self._triggered_character
[docs] def remove_all_temporary(self, folder=None, in_temp_parent=False): """ Removes all temporary folders and phrases, as well as any within temporary folders. Useful for rc-style scripts that want to change a set of keys. """ self.configManager.remove_all_temporary(folder, in_temp_parent)
def get_item_with_hotkey(self, hotkey): if not hotkey: return modifiers = sorted(hotkey[0]) return self.configManager.get_item_with_hotkey(modifiers, hotkey[1])
def validateAbbreviations(abbreviations): """ Checks if the given abbreviations are a list/iterable of strings @param abbreviations: Abbreviations list to be validated @raise ValueError: Raises C{ValueError} if C{abbreviations} is anything other than C{str} or C{Iterable} """ if abbreviations is None: return fail=False if not isinstance(abbreviations, str): fail=True if isinstance(abbreviations, Iterable): fail=False for item in abbreviations: if not isinstance(item, str): fail=True if fail: raise ValueError("Expected abbreviations to be a single string or a list/iterable of strings, not {}".format( type(abbreviations)) ) def check_abbreviation_unique(configmanager, abbreviations, window_filter): """ Checks if the given abbreviations are unique @param configmanager: ConfigManager Instance to check abbrevations @param abbreviations: List of abbreviations to be checked @param window_filter: Window filter that the abbreviation will apply to. @raise ValueError: Raises C{ValueError} if an abbreviation is already in use. """ if not abbreviations: return for abbr in abbreviations: if not configmanager.check_abbreviation_unique(abbr, window_filter, None)[0]: raise ValueError("The specified abbreviation '{}' is already in use.".format(abbr)) def check_hotkey_unique(configmanager, hotkey, window_filter): """ Checks if the given hotkey is unique @param configmanager: ConfigManager Instance used to check hotkey @param hotkey: hotkey to be check if unique @param window_filter: Window filter to be applied to the hotkey """ if not hotkey: return modifiers = sorted(hotkey[0]) if not configmanager.check_hotkey_unique(modifiers, hotkey[1], window_filter, None)[0]: raise ValueError("The specified hotkey and modifier combination is already in use: {}".format(hotkey)) def isValidHotkeyType(item): """ Checks if the hotkey is valid. @param item: Hotkey to be checked @return: Returns C{True} if hotkey is valid, C{False} otherwise """ fail=False if isinstance(item, Key): fail=False elif isinstance(item, str): if len(item) == 1: fail=False else: fail = not Key.is_key(item) else: fail=True return not fail def validateHotkey(hotkey): """ """ failmsg = "Expected hotkey to be a tuple of modifiers then keys, as lists of Key or str, not {}".format(type(hotkey)) if hotkey is None: return fail=False if not isinstance(hotkey, tuple): fail=True else: if len(hotkey) != 2: fail=True else: # First check modifiers is list of valid hotkeys. if isinstance(hotkey[0], list): for item in hotkey[0]: if not isValidHotkeyType(item): fail=True failmsg = "Hotkey is not a valid modifier: {}".format(item) else: fail=True failmsg = "Hotkey modifiers is not a list" # Then check second element is a key or str if not isValidHotkeyType(hotkey[1]): fail=True failmsg = "Hotkey is not a valid key: {}".format(hotkey[1]) if fail: raise ValueError(failmsg) def validateArguments(folder, name, contents, abbreviations, hotkey, send_mode, window_filter, show_in_system_tray, always_prompt, temporary, replace_existing_hotkey): if folder is None: raise ValueError("Parameter 'folder' is None. Check the folder is a valid autokey folder") validateType(folder, "folder", autokey.model.folder.Folder) # For when we allow pathlib.Path # validateType(folder, "folder", # [model.Folder, pathlib.Path]) validateType(name, "name", str) validateType(contents, "contents", str) validateAbbreviations(abbreviations) validateHotkey(hotkey) validateType(send_mode, "send_mode", autokey.model.phrase.SendMode) validateType(window_filter, "window_filter", str) validateType(show_in_system_tray, "show_in_system_tray", bool) validateType(always_prompt, "always_prompt", bool) validateType(temporary, "temporary", bool) validateType(replace_existing_hotkey, "replace_existing_hotkey", bool) # TODO: The validation should be done by some controller functions in the model base classes. if folder.temporary and not temporary: raise ValueError("Parameter 'temporary' is False, but parent folder is a temporary one. \ Phrases created within temporary folders must themselves be explicitly set temporary") def validateType(item, name, type_): """ type_ may be a list, in which case if item matches any type, no error is raised. """ if item is None: return if isinstance(type_, list): failed=True for type__ in type_: if isinstance(item, type__): failed=False if failed: raise ValueError("Expected {} to be one of {}, not {}".format( name, type_, type(item))) else: if not isinstance(item, type_): raise ValueError("Expected {} to be {}, not {}".format( name, type_, type(item)))