# 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)))