├── .gitignore ├── README.md ├── icon.png ├── info.plist ├── list.py ├── set.py └── workflow ├── Notify.tgz ├── __init__.py ├── __init__.pyc ├── background.py ├── background.pyc ├── notify.py ├── notify.pyc ├── update.py ├── update.pyc ├── version ├── web.py ├── web.pyc ├── workflow.py ├── workflow.pyc ├── workflow3.py └── workflow3.pyc /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Karabiner Elements profile switcher (Alfred Workflow) 2 | 3 | **CAUTION (DEPRECATED): I'm no longer maintaining this project. I'm no longer using multiple keyboards daily and generally experiment with Alfred replacements – check out https://raycast.com/. If you wish to take over this repo, contact me.** 4 | 5 | The fresh macOS Sierra rewrite of the original Karabiner, the [Karabiner Elements](https://github.com/tekezo/Karabiner-Elements) qietly supports multiple profiles, which has been one of the core features, historically speaking. Although, as of now, you can define multiple profiles in the `~/.config/karabiner/karabiner.json` file, but you can't switch between them easily. This is an important features for people that regularly switch between internal / Apple keyboard and an external USB one, which usually have different layout, hence the need for 2+ profiles. 6 | 7 | Karabiner Elements listens for changes in `karabiner.json` config file, and there is a setting that specifies which profile is _currently active_ – `selected: true`. 8 | 9 | This workflow does the following: 10 | 11 | 1. Type `keprofile` (you can change this if you like), to fetch your profiles defined in `karabiner.json` and list them in Alfred 12 | 2. Once you select a profile you want to switch to, hit ENTER and the workflow script will **edit your karabiner.json file** so that the selected profile becomes active 13 | 14 | Note that after you switch to a different profile, you can adjust its settings inside Karabiner Elements preferences window, save them and it'll all work seamlessly; the workflow only changes `selected: true|false` attribute, nothing else. 15 | 16 | 17 | 18 | ## Commands 19 | 20 | - `keprofile` - lists your profiles; then ENTER to switch to one 21 | 22 | 23 | 24 | ## Example 25 | 26 | #### Workflow 27 | 28 | ![](http://i.imgur.com/8LN9mQI.png) 29 | 30 | 31 | 32 | #### You can freely edit settings of each profile after switching to it 33 | 34 | ![](http://i.imgur.com/9rIV5jS.png) 35 | 36 | 37 | 38 | #### Sample karabiner.json with multiple profiles 39 | 40 | ``` 41 | { 42 | "profiles": [ 43 | { 44 | "devices": [ 45 | { 46 | "disable_built_in_keyboard_if_exists": false, 47 | "identifiers": { 48 | "is_keyboard": true, 49 | "is_pointing_device": false, 50 | "product_id": 610, 51 | "vendor_id": 1452 52 | }, 53 | "ignore": false 54 | }, 55 | { 56 | "disable_built_in_keyboard_if_exists": false, 57 | "identifiers": { 58 | "is_keyboard": true, 59 | "is_pointing_device": false, 60 | "product_id": 5890, 61 | "vendor_id": 1241 62 | }, 63 | "ignore": false 64 | } 65 | ], 66 | "fn_function_keys": { 67 | "f1": "vk_consumer_brightness_down", 68 | "f10": "mute", 69 | "f11": "volume_down", 70 | "f12": "volume_up", 71 | "f2": "vk_consumer_brightness_up", 72 | "f3": "f3", 73 | "f4": "vk_launchpad", 74 | "f5": "vk_consumer_illumination_down", 75 | "f6": "vk_consumer_illumination_up", 76 | "f7": "vk_consumer_previous", 77 | "f8": "vk_consumer_play", 78 | "f9": "vk_consumer_next" 79 | }, 80 | "name": "USB Standard Keyboard", 81 | "selected": true, 82 | "simple_modifications": { 83 | "caps_lock": "escape", 84 | "escape": "caps_lock", 85 | "fn": "left_control", 86 | "left_command": "left_option", 87 | "left_option": "left_command" 88 | }, 89 | "virtual_hid_keyboard": { 90 | "caps_lock_delay_milliseconds": 0, 91 | "keyboard_type": "ansi" 92 | } 93 | }, 94 | { 95 | "devices": [ 96 | { 97 | "disable_built_in_keyboard_if_exists": false, 98 | "identifiers": { 99 | "is_keyboard": true, 100 | "is_pointing_device": false, 101 | "product_id": 610, 102 | "vendor_id": 1452 103 | }, 104 | "ignore": false 105 | }, 106 | { 107 | "disable_built_in_keyboard_if_exists": false, 108 | "identifiers": { 109 | "is_keyboard": true, 110 | "is_pointing_device": false, 111 | "product_id": 5890, 112 | "vendor_id": 1241 113 | }, 114 | "ignore": false 115 | } 116 | ], 117 | "fn_function_keys": { 118 | "f1": "vk_consumer_brightness_down", 119 | "f10": "mute", 120 | "f11": "volume_down", 121 | "f12": "volume_up", 122 | "f2": "vk_consumer_brightness_up", 123 | "f3": "f3", 124 | "f4": "vk_launchpad", 125 | "f5": "vk_consumer_illumination_down", 126 | "f6": "vk_consumer_illumination_up", 127 | "f7": "vk_consumer_previous", 128 | "f8": "vk_consumer_play", 129 | "f9": "vk_consumer_next" 130 | }, 131 | "name": "Apple Keyboard", 132 | "selected": true, 133 | "simple_modifications": { 134 | "caps_lock": "escape", 135 | "escape": "caps_lock", 136 | "fn": "left_control" 137 | }, 138 | "virtual_hid_keyboard": { 139 | "caps_lock_delay_milliseconds": 0, 140 | "keyboard_type": "ansi" 141 | } 142 | } 143 | ] 144 | } 145 | ``` 146 | 147 | 148 | 149 | 150 | 151 | ## Thanks to 152 | 153 | - [@tekezo](https://github.com/tekezo) for creating an awesome key switcher application for OSX / macOS – [Karabiner Elements](https://github.com/tekezo/Karabiner-Elements). 154 | - [@bennypowers](https://github.com/bennypowers) for a fresh Karabiner Elements logotype, as submitted in [this pull request](https://github.com/tekezo/Karabiner-Elements/pull/500/files). 155 | - [@deanishe](https://github.com/deanishe) for creating an amazing, easy to use Alfred Workflow creation framework – [Alfred-Workflow](http://www.deanishe.net/alfred-workflow/index.html). 156 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/icon.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.alfred.karabinerelements.profileswitcher 7 | category 8 | Tools 9 | connections 10 | 11 | 7DD3BDE5-A157-42E5-9376-F681FB50A4EE 12 | 13 | 14 | destinationuid 15 | 06C9C4A9-38CE-441A-8D06-E2F2D8B39B60 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | destinationuid 25 | 42F94CA6-114F-438B-9C60-183CB64B3B92 26 | modifiers 27 | 0 28 | modifiersubtext 29 | 30 | vitoclose 31 | 32 | 33 | 34 | 35 | createdby 36 | Andy Winecki 37 | description 38 | Easily switch selected profile as configured in ~/.config/karabiner/karabiner.json 39 | disabled 40 | 41 | name 42 | Karabiner Elements Profile Switcher 43 | objects 44 | 45 | 46 | config 47 | 48 | concurrently 49 | 50 | escaping 51 | 102 52 | script 53 | /usr/bin/python set.py "{query}" 54 | scriptargtype 55 | 0 56 | scriptfile 57 | 58 | type 59 | 0 60 | 61 | type 62 | alfred.workflow.action.script 63 | uid 64 | 06C9C4A9-38CE-441A-8D06-E2F2D8B39B60 65 | version 66 | 2 67 | 68 | 69 | config 70 | 71 | alfredfiltersresults 72 | 73 | argumenttype 74 | 1 75 | escaping 76 | 102 77 | keyword 78 | keprofile 79 | queuedelaycustom 80 | 1 81 | queuedelayimmediatelyinitially 82 | 83 | queuedelaymode 84 | 0 85 | queuemode 86 | 1 87 | runningsubtext 88 | Loading.. 89 | script 90 | /usr/bin/python list.py "{query}" 91 | scriptargtype 92 | 0 93 | scriptfile 94 | 95 | subtext 96 | Keyword: keprofile 97 | title 98 | Karabiner Elements Profile 99 | type 100 | 0 101 | withspace 102 | 103 | 104 | type 105 | alfred.workflow.input.scriptfilter 106 | uid 107 | 7DD3BDE5-A157-42E5-9376-F681FB50A4EE 108 | version 109 | 2 110 | 111 | 112 | config 113 | 114 | lastpathcomponent 115 | 116 | onlyshowifquerypopulated 117 | 118 | removeextension 119 | 120 | text 121 | Profile switched to: {query} 122 | title 123 | Karabiner Elements 124 | 125 | type 126 | alfred.workflow.output.notification 127 | uid 128 | 42F94CA6-114F-438B-9C60-183CB64B3B92 129 | version 130 | 1 131 | 132 | 133 | readme 134 | 135 | uidata 136 | 137 | 06C9C4A9-38CE-441A-8D06-E2F2D8B39B60 138 | 139 | xpos 140 | 490 141 | ypos 142 | 170 143 | 144 | 42F94CA6-114F-438B-9C60-183CB64B3B92 145 | 146 | xpos 147 | 490 148 | ypos 149 | 320 150 | 151 | 7DD3BDE5-A157-42E5-9376-F681FB50A4EE 152 | 153 | xpos 154 | 250 155 | ypos 156 | 170 157 | 158 | 159 | webaddress 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /list.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from workflow import Workflow 4 | from os.path import expanduser 5 | 6 | 7 | ICON_DEFAULT = 'icon.png' 8 | CONFIG_PATH = '.config/karabiner/karabiner.json' 9 | home = expanduser("~") 10 | 11 | 12 | def main(wf): 13 | with open('{}/{}'.format(home, CONFIG_PATH)) as json_data: 14 | config = json.load(json_data) 15 | for profile in config['profiles']: 16 | wf.add_item( 17 | profile['name'], 'Keyboard Preset Profile', 18 | arg=profile['name'], valid=True, icon=ICON_DEFAULT) 19 | wf.send_feedback() 20 | 21 | if __name__ == u"__main__": 22 | wf = Workflow() 23 | sys.exit(wf.run(main)) 24 | -------------------------------------------------------------------------------- /set.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from os.path import expanduser 4 | from collections import OrderedDict 5 | 6 | CHOSEN_PROFILE = sys.argv[1] 7 | CONFIG_PATH = '.config/karabiner/karabiner.json' 8 | 9 | 10 | home = expanduser("~") 11 | config = {} 12 | 13 | with open('{}/{}'.format(home, CONFIG_PATH)) as conf_file: 14 | config = json.load(conf_file, object_pairs_hook=OrderedDict) 15 | for profile in config['profiles']: 16 | profile['selected'] = profile['name'] == CHOSEN_PROFILE 17 | 18 | with open('{}/{}'.format(home, CONFIG_PATH), 'w') as conf_file: 19 | conf_file.write(json.dumps(config, indent=4, separators=(',', ': '))) 20 | -------------------------------------------------------------------------------- /workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/Notify.tgz -------------------------------------------------------------------------------- /workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Workflow', 71 | 'Workflow3', 72 | 'manager', 73 | 'PasswordNotFound', 74 | 'KeychainError', 75 | 'ICON_ACCOUNT', 76 | 'ICON_BURN', 77 | 'ICON_CLOCK', 78 | 'ICON_COLOR', 79 | 'ICON_COLOUR', 80 | 'ICON_EJECT', 81 | 'ICON_ERROR', 82 | 'ICON_FAVORITE', 83 | 'ICON_FAVOURITE', 84 | 'ICON_GROUP', 85 | 'ICON_HELP', 86 | 'ICON_HOME', 87 | 'ICON_INFO', 88 | 'ICON_NETWORK', 89 | 'ICON_NOTE', 90 | 'ICON_SETTINGS', 91 | 'ICON_SWIRL', 92 | 'ICON_SWITCH', 93 | 'ICON_SYNC', 94 | 'ICON_TRASH', 95 | 'ICON_USER', 96 | 'ICON_WARNING', 97 | 'ICON_WEB', 98 | 'MATCH_ALL', 99 | 'MATCH_ALLCHARS', 100 | 'MATCH_ATOM', 101 | 'MATCH_CAPITALS', 102 | 'MATCH_INITIALS', 103 | 'MATCH_INITIALS_CONTAIN', 104 | 'MATCH_INITIALS_STARTSWITH', 105 | 'MATCH_STARTSWITH', 106 | 'MATCH_SUBSTRING', 107 | ] 108 | -------------------------------------------------------------------------------- /workflow/__init__.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/__init__.pyc -------------------------------------------------------------------------------- /workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """Run background tasks.""" 12 | 13 | from __future__ import print_function, unicode_literals 14 | 15 | import sys 16 | import os 17 | import subprocess 18 | import pickle 19 | 20 | from workflow import Workflow 21 | 22 | __all__ = ['is_running', 'run_in_background'] 23 | 24 | _wf = None 25 | 26 | 27 | def wf(): 28 | global _wf 29 | if _wf is None: 30 | _wf = Workflow() 31 | return _wf 32 | 33 | 34 | def _arg_cache(name): 35 | """Return path to pickle cache file for arguments. 36 | 37 | :param name: name of task 38 | :type name: ``unicode`` 39 | :returns: Path to cache file 40 | :rtype: ``unicode`` filepath 41 | 42 | """ 43 | return wf().cachefile('{0}.argcache'.format(name)) 44 | 45 | 46 | def _pid_file(name): 47 | """Return path to PID file for ``name``. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to PID file for task 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile('{0}.pid'.format(name)) 56 | 57 | 58 | def _process_exists(pid): 59 | """Check if a process with PID ``pid`` exists. 60 | 61 | :param pid: PID to check 62 | :type pid: ``int`` 63 | :returns: ``True`` if process exists, else ``False`` 64 | :rtype: ``Boolean`` 65 | 66 | """ 67 | try: 68 | os.kill(pid, 0) 69 | except OSError: # not running 70 | return False 71 | return True 72 | 73 | 74 | def is_running(name): 75 | """Test whether task is running under ``name``. 76 | 77 | :param name: name of task 78 | :type name: ``unicode`` 79 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 80 | :rtype: ``Boolean`` 81 | 82 | """ 83 | pidfile = _pid_file(name) 84 | if not os.path.exists(pidfile): 85 | return False 86 | 87 | with open(pidfile, 'rb') as file_obj: 88 | pid = int(file_obj.read().strip()) 89 | 90 | if _process_exists(pid): 91 | return True 92 | 93 | elif os.path.exists(pidfile): 94 | os.unlink(pidfile) 95 | 96 | return False 97 | 98 | 99 | def _background(stdin='/dev/null', stdout='/dev/null', 100 | stderr='/dev/null'): # pragma: no cover 101 | """Fork the current process into a background daemon. 102 | 103 | :param stdin: where to read input 104 | :type stdin: filepath 105 | :param stdout: where to write stdout output 106 | :type stdout: filepath 107 | :param stderr: where to write stderr output 108 | :type stderr: filepath 109 | 110 | """ 111 | # Do first fork. 112 | try: 113 | pid = os.fork() 114 | if pid > 0: 115 | sys.exit(0) # Exit first parent. 116 | except OSError as e: 117 | wf().logger.critical("fork #1 failed: ({0:d}) {1}".format( 118 | e.errno, e.strerror)) 119 | sys.exit(1) 120 | # Decouple from parent environment. 121 | os.chdir(wf().workflowdir) 122 | os.umask(0) 123 | os.setsid() 124 | # Do second fork. 125 | try: 126 | pid = os.fork() 127 | if pid > 0: 128 | sys.exit(0) # Exit second parent. 129 | except OSError as e: 130 | wf().logger.critical("fork #2 failed: ({0:d}) {1}".format( 131 | e.errno, e.strerror)) 132 | sys.exit(1) 133 | # Now I am a daemon! 134 | # Redirect standard file descriptors. 135 | si = file(stdin, 'r', 0) 136 | so = file(stdout, 'a+', 0) 137 | se = file(stderr, 'a+', 0) 138 | if hasattr(sys.stdin, 'fileno'): 139 | os.dup2(si.fileno(), sys.stdin.fileno()) 140 | if hasattr(sys.stdout, 'fileno'): 141 | os.dup2(so.fileno(), sys.stdout.fileno()) 142 | if hasattr(sys.stderr, 'fileno'): 143 | os.dup2(se.fileno(), sys.stderr.fileno()) 144 | 145 | 146 | def run_in_background(name, args, **kwargs): 147 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 148 | 149 | :param name: name of task 150 | :type name: ``unicode`` 151 | :param args: arguments passed as first argument to :func:`subprocess.call` 152 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 153 | :returns: exit code of sub-process 154 | :rtype: ``int`` 155 | 156 | When you call this function, it caches its arguments and then calls 157 | ``background.py`` in a subprocess. The Python subprocess will load the 158 | cached arguments, fork into the background, and then run the command you 159 | specified. 160 | 161 | This function will return as soon as the ``background.py`` subprocess has 162 | forked, returning the exit code of *that* process (i.e. not of the command 163 | you're trying to run). 164 | 165 | If that process fails, an error will be written to the log file. 166 | 167 | If a process is already running under the same name, this function will 168 | return immediately and will not run the specified command. 169 | 170 | """ 171 | if is_running(name): 172 | wf().logger.info('Task `{0}` is already running'.format(name)) 173 | return 174 | 175 | argcache = _arg_cache(name) 176 | 177 | # Cache arguments 178 | with open(argcache, 'wb') as file_obj: 179 | pickle.dump({'args': args, 'kwargs': kwargs}, file_obj) 180 | wf().logger.debug('Command arguments cached to `{0}`'.format(argcache)) 181 | 182 | # Call this script 183 | cmd = ['/usr/bin/python', __file__, name] 184 | wf().logger.debug('Calling {0!r} ...'.format(cmd)) 185 | retcode = subprocess.call(cmd) 186 | if retcode: # pragma: no cover 187 | wf().logger.error('Failed to call task in background') 188 | else: 189 | wf().logger.debug('Executing task `{0}` in background...'.format(name)) 190 | return retcode 191 | 192 | 193 | def main(wf): # pragma: no cover 194 | """Run command in a background process. 195 | 196 | Load cached arguments, fork into background, then call 197 | :meth:`subprocess.call` with cached arguments. 198 | 199 | """ 200 | name = wf.args[0] 201 | argcache = _arg_cache(name) 202 | if not os.path.exists(argcache): 203 | wf.logger.critical('No arg cache found : {0!r}'.format(argcache)) 204 | return 1 205 | 206 | # Load cached arguments 207 | with open(argcache, 'rb') as file_obj: 208 | data = pickle.load(file_obj) 209 | 210 | # Cached arguments 211 | args = data['args'] 212 | kwargs = data['kwargs'] 213 | 214 | # Delete argument cache file 215 | os.unlink(argcache) 216 | 217 | pidfile = _pid_file(name) 218 | 219 | # Fork to background 220 | _background() 221 | 222 | # Write PID to file 223 | with open(pidfile, 'wb') as file_obj: 224 | file_obj.write('{0}'.format(os.getpid())) 225 | 226 | # Run the command 227 | try: 228 | wf.logger.debug('Task `{0}` running'.format(name)) 229 | wf.logger.debug('cmd : {0!r}'.format(args)) 230 | 231 | retcode = subprocess.call(args, **kwargs) 232 | 233 | if retcode: 234 | wf.logger.error('Command failed with [{0}] : {1!r}'.format( 235 | retcode, args)) 236 | 237 | finally: 238 | if os.path.exists(pidfile): 239 | os.unlink(pidfile) 240 | wf.logger.debug('Task `{0}` finished'.format(name)) 241 | 242 | 243 | if __name__ == '__main__': # pragma: no cover 244 | wf().run(main) 245 | -------------------------------------------------------------------------------- /workflow/background.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/background.pyc -------------------------------------------------------------------------------- /workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the OS X Notification Center. This feature 15 | is only available on Mountain Lion (10.8) and later. It will 16 | silently fail on older systems. 17 | 18 | The main API is a single function, :func:`~workflow.notify.notify`. 19 | 20 | It works by copying a simple application to your workflow's data 21 | directory. It replaces the application's icon with your workflow's 22 | icon and then calls the application to post notifications. 23 | """ 24 | 25 | from __future__ import print_function, unicode_literals 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | 36 | import workflow 37 | 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | 'Basso', 46 | 'Blow', 47 | 'Bottle', 48 | 'Frog', 49 | 'Funk', 50 | 'Glass', 51 | 'Hero', 52 | 'Morse', 53 | 'Ping', 54 | 'Pop', 55 | 'Purr', 56 | 'Sosumi', 57 | 'Submarine', 58 | 'Tink', 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return `Workflow` object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: `Workflow` object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app `applet` executable. 91 | """ 92 | return wf().datafile('Notify.app/Contents/MacOS/applet') 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to `applet.icns` within the app bundle. 100 | """ 101 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 102 | 103 | 104 | def install_notifier(): 105 | """Extract `Notify.app` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, 'Notify.app') 113 | n = notifier_program() 114 | log().debug("Installing Notify.app to %r ...", destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, 'r:gz') 118 | tgz.extractall(destdir) 119 | assert os.path.exists(n), ( 120 | "Notify.app could not be installed in {0!r}.".format(destdir)) 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile('icon.png') 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSWorkspace, NSImage 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 145 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.readPlist(ip_path) 147 | log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) 148 | data['CFBundleIdentifier'] = bundle_id 149 | plistlib.writePlist(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce `sound` to valid sound name. 154 | 155 | Returns `None` for invalid sounds. Sound names can be found 156 | in `System Preferences > Sound > Sound Effects`. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or `None`. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of OS X 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title='', text='', sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both `title` and `text` are empty. 184 | 185 | Returns: 186 | bool: `True` if notification was posted, else `False`. 187 | """ 188 | if title == text == '': 189 | raise ValueError('Empty notification') 190 | 191 | sound = validate_sound(sound) or '' 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = 'utf-8' 200 | env['NOTIFY_TITLE'] = title.encode(enc) 201 | env['NOTIFY_MESSAGE'] = text.encode(enc) 202 | env['NOTIFY_SOUND'] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error('Notify.app exited with status {0}.'.format(retcode)) 209 | return False 210 | 211 | 212 | def convert_image(inpath, outpath, size): 213 | """Convert an image file using `sips`. 214 | 215 | Args: 216 | inpath (str): Path of source file. 217 | outpath (str): Path to destination file. 218 | size (int): Width and height of destination image in pixels. 219 | 220 | Raises: 221 | RuntimeError: Raised if `sips` exits with non-zero status. 222 | """ 223 | cmd = [ 224 | b'sips', 225 | b'-z', b'{0}'.format(size), b'{0}'.format(size), 226 | inpath, 227 | b'--out', outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, 'w') as pipe: 230 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 231 | 232 | if retcode != 0: 233 | raise RuntimeError('sips exited with {0}'.format(retcode)) 234 | 235 | 236 | def png_to_icns(png_path, icns_path): 237 | """Convert PNG file to ICNS using `iconutil`. 238 | 239 | Create an iconset from the source PNG file. Generate PNG files 240 | in each size required by OS X, then call `iconutil` to turn 241 | them into a single ICNS file. 242 | 243 | Args: 244 | png_path (str): Path to source PNG file. 245 | icns_path (str): Path to destination ICNS file. 246 | 247 | Raises: 248 | RuntimeError: Raised if `iconutil` or `sips` fail. 249 | """ 250 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 251 | 252 | try: 253 | iconset = os.path.join(tempdir, 'Icon.iconset') 254 | 255 | assert not os.path.exists(iconset), ( 256 | "Iconset path already exists : {0!r}".format(iconset)) 257 | os.makedirs(iconset) 258 | 259 | # Copy source icon to icon set and generate all the other 260 | # sizes needed 261 | configs = [] 262 | for i in (16, 32, 128, 256, 512): 263 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 264 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) 265 | 266 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 267 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 268 | 269 | for name, size in configs: 270 | outpath = os.path.join(iconset, name) 271 | if os.path.exists(outpath): 272 | continue 273 | convert_image(png_path, outpath, size) 274 | 275 | cmd = [ 276 | b'iconutil', 277 | b'-c', b'icns', 278 | b'-o', icns_path, 279 | iconset] 280 | 281 | retcode = subprocess.call(cmd) 282 | if retcode != 0: 283 | raise RuntimeError("iconset exited with {0}".format(retcode)) 284 | 285 | assert os.path.exists(icns_path), ( 286 | "Generated ICNS file not found : {0!r}".format(icns_path)) 287 | finally: 288 | try: 289 | shutil.rmtree(tempdir) 290 | except OSError: # pragma: no cover 291 | pass 292 | 293 | 294 | # def notify_native(title='', text='', sound=''): 295 | # """Post notification via the native API (via pyobjc). 296 | 297 | # At least one of `title` or `text` must be specified. 298 | 299 | # This method will *always* show the Python launcher icon (i.e. the 300 | # rocket with the snakes on it). 301 | 302 | # Args: 303 | # title (str, optional): Notification title. 304 | # text (str, optional): Notification body text. 305 | # sound (str, optional): Name of sound to play. 306 | 307 | # """ 308 | 309 | # if title == text == '': 310 | # raise ValueError('Empty notification') 311 | 312 | # import Foundation 313 | 314 | # sound = sound or Foundation.NSUserNotificationDefaultSoundName 315 | 316 | # n = Foundation.NSUserNotification.alloc().init() 317 | # n.setTitle_(title) 318 | # n.setInformativeText_(text) 319 | # n.setSoundName_(sound) 320 | # nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() 321 | # nc.deliverNotification_(n) 322 | 323 | 324 | if __name__ == '__main__': # pragma: nocover 325 | # Simple command-line script to test module with 326 | # This won't work on 2.6, as `argparse` isn't available 327 | # by default. 328 | import argparse 329 | 330 | from unicodedata import normalize 331 | 332 | def uni(s): 333 | """Coerce `s` to normalised Unicode.""" 334 | ustr = s.decode('utf-8') 335 | return normalize('NFD', ustr) 336 | 337 | p = argparse.ArgumentParser() 338 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 339 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 340 | action='store_true') 341 | p.add_argument('-t', '--title', 342 | help="Notification title.", type=uni, 343 | default='') 344 | p.add_argument('-s', '--sound', type=uni, 345 | help="Optional notification sound.", default='') 346 | p.add_argument('text', type=uni, 347 | help="Notification body text.", default='', nargs='?') 348 | o = p.parse_args() 349 | 350 | # List available sounds 351 | if o.list_sounds: 352 | for sound in SOUNDS: 353 | print(sound) 354 | sys.exit(0) 355 | 356 | # Convert PNG to ICNS 357 | if o.png: 358 | icns = os.path.join( 359 | os.path.dirname(o.png), 360 | b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], 361 | '.icns')) 362 | 363 | print('Converting {0!r} to {1!r} ...'.format(o.png, icns), 364 | file=sys.stderr) 365 | 366 | assert not os.path.exists(icns), ( 367 | "Destination file already exists : {0}".format(icns)) 368 | 369 | png_to_icns(o.png, icns) 370 | sys.exit(0) 371 | 372 | # Post notification 373 | if o.title == o.text == '': 374 | print('ERROR: Empty notification.', file=sys.stderr) 375 | sys.exit(1) 376 | else: 377 | notify(o.title, o.text, o.sound) 378 | -------------------------------------------------------------------------------- /workflow/notify.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/notify.pyc -------------------------------------------------------------------------------- /workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | import os 27 | import tempfile 28 | import re 29 | import subprocess 30 | 31 | import workflow 32 | import web 33 | 34 | # __all__ = [] 35 | 36 | 37 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' 38 | 39 | 40 | _wf = None 41 | 42 | 43 | def wf(): 44 | """Lazy `Workflow` object.""" 45 | global _wf 46 | if _wf is None: 47 | _wf = workflow.Workflow() 48 | return _wf 49 | 50 | 51 | class Version(object): 52 | """Mostly semantic versioning. 53 | 54 | The main difference to proper :ref:`semantic versioning ` 55 | is that this implementation doesn't require a minor or patch version. 56 | 57 | Version strings may also be prefixed with "v", e.g.: 58 | 59 | >>> v = Version('v1.1.1') 60 | >>> v.tuple 61 | (1, 1, 1, '') 62 | 63 | >>> v = Version('2.0') 64 | >>> v.tuple 65 | (2, 0, 0, '') 66 | 67 | >>> Version('3.1-beta').tuple 68 | (3, 1, 0, 'beta') 69 | 70 | >>> Version('1.0.1') > Version('0.0.1') 71 | True 72 | """ 73 | 74 | #: Match version and pre-release/build information in version strings 75 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 76 | 77 | def __init__(self, vstr): 78 | """Create new `Version` object. 79 | 80 | Args: 81 | vstr (basestring): Semantic version string. 82 | """ 83 | self.vstr = vstr 84 | self.major = 0 85 | self.minor = 0 86 | self.patch = 0 87 | self.suffix = '' 88 | self.build = '' 89 | self._parse(vstr) 90 | 91 | def _parse(self, vstr): 92 | if vstr.startswith('v'): 93 | m = self.match_version(vstr[1:]) 94 | else: 95 | m = self.match_version(vstr) 96 | if not m: 97 | raise ValueError('Invalid version number: {0}'.format(vstr)) 98 | 99 | version, suffix = m.groups() 100 | parts = self._parse_dotted_string(version) 101 | self.major = parts.pop(0) 102 | if len(parts): 103 | self.minor = parts.pop(0) 104 | if len(parts): 105 | self.patch = parts.pop(0) 106 | if not len(parts) == 0: 107 | raise ValueError('Invalid version (too long) : {0}'.format(vstr)) 108 | 109 | if suffix: 110 | # Build info 111 | idx = suffix.find('+') 112 | if idx > -1: 113 | self.build = suffix[idx+1:] 114 | suffix = suffix[:idx] 115 | if suffix: 116 | if not suffix.startswith('-'): 117 | raise ValueError( 118 | 'Invalid suffix : `{0}`. Must start with `-`'.format( 119 | suffix)) 120 | self.suffix = suffix[1:] 121 | 122 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 123 | 124 | def _parse_dotted_string(self, s): 125 | """Parse string ``s`` into list of ints and strings.""" 126 | parsed = [] 127 | parts = s.split('.') 128 | for p in parts: 129 | if p.isdigit(): 130 | p = int(p) 131 | parsed.append(p) 132 | return parsed 133 | 134 | @property 135 | def tuple(self): 136 | """Version number as a tuple of major, minor, patch, pre-release.""" 137 | return (self.major, self.minor, self.patch, self.suffix) 138 | 139 | def __lt__(self, other): 140 | """Implement comparison.""" 141 | if not isinstance(other, Version): 142 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 143 | t = self.tuple[:3] 144 | o = other.tuple[:3] 145 | if t < o: 146 | return True 147 | if t == o: # We need to compare suffixes 148 | if self.suffix and not other.suffix: 149 | return True 150 | if other.suffix and not self.suffix: 151 | return False 152 | return (self._parse_dotted_string(self.suffix) < 153 | self._parse_dotted_string(other.suffix)) 154 | # t > o 155 | return False 156 | 157 | def __eq__(self, other): 158 | """Implement comparison.""" 159 | if not isinstance(other, Version): 160 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 161 | return self.tuple == other.tuple 162 | 163 | def __ne__(self, other): 164 | """Implement comparison.""" 165 | return not self.__eq__(other) 166 | 167 | def __gt__(self, other): 168 | """Implement comparison.""" 169 | if not isinstance(other, Version): 170 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 171 | return other.__lt__(self) 172 | 173 | def __le__(self, other): 174 | """Implement comparison.""" 175 | if not isinstance(other, Version): 176 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 177 | return not other.__lt__(self) 178 | 179 | def __ge__(self, other): 180 | """Implement comparison.""" 181 | return not self.__lt__(other) 182 | 183 | def __str__(self): 184 | """Return semantic version string.""" 185 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 186 | if self.suffix: 187 | vstr += '-{0}'.format(self.suffix) 188 | if self.build: 189 | vstr += '+{0}'.format(self.build) 190 | return vstr 191 | 192 | def __repr__(self): 193 | """Return 'code' representation of `Version`.""" 194 | return "Version('{0}')".format(str(self)) 195 | 196 | 197 | def download_workflow(url): 198 | """Download workflow at ``url`` to a local temporary file. 199 | 200 | :param url: URL to .alfredworkflow file in GitHub repo 201 | :returns: path to downloaded file 202 | 203 | """ 204 | filename = url.split("/")[-1] 205 | 206 | if (not url.endswith('.alfredworkflow') or 207 | not filename.endswith('.alfredworkflow')): 208 | raise ValueError('Attachment `{0}` not a workflow'.format(filename)) 209 | 210 | local_path = os.path.join(tempfile.gettempdir(), filename) 211 | 212 | wf().logger.debug( 213 | 'Downloading updated workflow from `%s` to `%s` ...', url, local_path) 214 | 215 | response = web.get(url) 216 | 217 | with open(local_path, 'wb') as output: 218 | output.write(response.content) 219 | 220 | return local_path 221 | 222 | 223 | def build_api_url(slug): 224 | """Generate releases URL from GitHub slug. 225 | 226 | :param slug: Repo name in form ``username/repo`` 227 | :returns: URL to the API endpoint for the repo's releases 228 | 229 | """ 230 | if len(slug.split('/')) != 2: 231 | raise ValueError('Invalid GitHub slug : {0}'.format(slug)) 232 | 233 | return RELEASES_BASE.format(slug) 234 | 235 | 236 | def _validate_release(release): 237 | """Return release for running version of Alfred.""" 238 | alf3 = wf().alfred_version.major == 3 239 | 240 | downloads = {'.alfredworkflow': [], '.alfred3workflow': []} 241 | dl_count = 0 242 | version = release['tag_name'] 243 | 244 | for asset in release.get('assets', []): 245 | url = asset.get('browser_download_url') 246 | if not url: # pragma: nocover 247 | continue 248 | 249 | ext = os.path.splitext(url)[1].lower() 250 | if ext not in downloads: 251 | continue 252 | 253 | # Ignore Alfred 3-only files if Alfred 2 is running 254 | if ext == '.alfred3workflow' and not alf3: 255 | continue 256 | 257 | downloads[ext].append(url) 258 | dl_count += 1 259 | 260 | # download_urls.append(url) 261 | 262 | if dl_count == 0: 263 | wf().logger.warning( 264 | 'Invalid release %s : No workflow file', version) 265 | return None 266 | 267 | for k in downloads: 268 | if len(downloads[k]) > 1: 269 | wf().logger.warning( 270 | 'Invalid release %s : multiple %s files', version, k) 271 | return None 272 | 273 | # Prefer .alfred3workflow file if there is one and Alfred 3 is 274 | # running. 275 | if alf3 and len(downloads['.alfred3workflow']): 276 | download_url = downloads['.alfred3workflow'][0] 277 | 278 | else: 279 | download_url = downloads['.alfredworkflow'][0] 280 | 281 | wf().logger.debug('Release `%s` : %s', version, download_url) 282 | 283 | return { 284 | 'version': version, 285 | 'download_url': download_url, 286 | 'prerelease': release['prerelease'] 287 | } 288 | 289 | 290 | def get_valid_releases(github_slug, prereleases=False): 291 | """Return list of all valid releases. 292 | 293 | :param github_slug: ``username/repo`` for workflow's GitHub repo 294 | :param prereleases: Whether to include pre-releases. 295 | :returns: list of dicts. Each :class:`dict` has the form 296 | ``{'version': '1.1', 'download_url': 'http://github.com/...', 297 | 'prerelease': False }`` 298 | 299 | 300 | A valid release is one that contains one ``.alfredworkflow`` file. 301 | 302 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading 303 | ``v`` will be stripped. 304 | 305 | """ 306 | api_url = build_api_url(github_slug) 307 | releases = [] 308 | 309 | wf().logger.debug('Retrieving releases list from `%s` ...', api_url) 310 | 311 | def retrieve_releases(): 312 | wf().logger.info( 313 | 'Retrieving releases for `%s` ...', github_slug) 314 | return web.get(api_url).json() 315 | 316 | slug = github_slug.replace('/', '-') 317 | for release in wf().cached_data('gh-releases-{0}'.format(slug), 318 | retrieve_releases): 319 | 320 | wf().logger.debug('Release : %r', release) 321 | 322 | release = _validate_release(release) 323 | if release is None: 324 | wf().logger.debug('Invalid release') 325 | continue 326 | 327 | elif release['prerelease'] and not prereleases: 328 | wf().logger.debug('Ignoring prerelease : %s', release['version']) 329 | continue 330 | 331 | releases.append(release) 332 | 333 | return releases 334 | 335 | 336 | def check_update(github_slug, current_version, prereleases=False): 337 | """Check whether a newer release is available on GitHub. 338 | 339 | :param github_slug: ``username/repo`` for workflow's GitHub repo 340 | :param current_version: the currently installed version of the 341 | workflow. :ref:`Semantic versioning ` is required. 342 | :param prereleases: Whether to include pre-releases. 343 | :type current_version: ``unicode`` 344 | :returns: ``True`` if an update is available, else ``False`` 345 | 346 | If an update is available, its version number and download URL will 347 | be cached. 348 | 349 | """ 350 | releases = get_valid_releases(github_slug, prereleases) 351 | 352 | wf().logger.info('%d releases for %s', len(releases), github_slug) 353 | 354 | if not len(releases): 355 | raise ValueError('No valid releases for %s', github_slug) 356 | 357 | # GitHub returns releases newest-first 358 | latest_release = releases[0] 359 | 360 | # (latest_version, download_url) = get_latest_release(releases) 361 | vr = Version(latest_release['version']) 362 | vl = Version(current_version) 363 | wf().logger.debug('Latest : %r Installed : %r', vr, vl) 364 | if vr > vl: 365 | 366 | wf().cache_data('__workflow_update_status', { 367 | 'version': latest_release['version'], 368 | 'download_url': latest_release['download_url'], 369 | 'available': True 370 | }) 371 | 372 | return True 373 | 374 | wf().cache_data('__workflow_update_status', { 375 | 'available': False 376 | }) 377 | return False 378 | 379 | 380 | def install_update(): 381 | """If a newer release is available, download and install it. 382 | 383 | :returns: ``True`` if an update is installed, else ``False`` 384 | 385 | """ 386 | update_data = wf().cached_data('__workflow_update_status', max_age=0) 387 | 388 | if not update_data or not update_data.get('available'): 389 | wf().logger.info('No update available') 390 | return False 391 | 392 | local_file = download_workflow(update_data['download_url']) 393 | 394 | wf().logger.info('Installing updated workflow ...') 395 | subprocess.call(['open', local_file]) 396 | 397 | update_data['available'] = False 398 | wf().cache_data('__workflow_update_status', update_data) 399 | return True 400 | 401 | 402 | if __name__ == '__main__': # pragma: nocover 403 | import sys 404 | 405 | def show_help(): 406 | """Print help message.""" 407 | print('Usage : update.py (check|install) github_slug version ' 408 | '[--prereleases]') 409 | sys.exit(1) 410 | 411 | argv = sys.argv[:] 412 | prereleases = '--prereleases' in argv 413 | 414 | if prereleases: 415 | argv.remove('--prereleases') 416 | 417 | if len(argv) != 4: 418 | show_help() 419 | 420 | action, github_slug, version = argv[1:] 421 | 422 | if action not in ('check', 'install'): 423 | show_help() 424 | 425 | if action == 'check': 426 | check_update(github_slug, version, prereleases) 427 | elif action == 'install': 428 | install_update() 429 | -------------------------------------------------------------------------------- /workflow/update.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/update.pyc -------------------------------------------------------------------------------- /workflow/version: -------------------------------------------------------------------------------- 1 | 1.24 -------------------------------------------------------------------------------- /workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | import codecs 13 | import json 14 | import mimetypes 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import string 20 | import unicodedata 21 | import urllib 22 | import urllib2 23 | import urlparse 24 | import zlib 25 | 26 | 27 | USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' 28 | 29 | # Valid characters for multipart form data boundaries 30 | BOUNDARY_CHARS = string.digits + string.ascii_letters 31 | 32 | # HTTP response codes 33 | RESPONSES = { 34 | 100: 'Continue', 35 | 101: 'Switching Protocols', 36 | 200: 'OK', 37 | 201: 'Created', 38 | 202: 'Accepted', 39 | 203: 'Non-Authoritative Information', 40 | 204: 'No Content', 41 | 205: 'Reset Content', 42 | 206: 'Partial Content', 43 | 300: 'Multiple Choices', 44 | 301: 'Moved Permanently', 45 | 302: 'Found', 46 | 303: 'See Other', 47 | 304: 'Not Modified', 48 | 305: 'Use Proxy', 49 | 307: 'Temporary Redirect', 50 | 400: 'Bad Request', 51 | 401: 'Unauthorized', 52 | 402: 'Payment Required', 53 | 403: 'Forbidden', 54 | 404: 'Not Found', 55 | 405: 'Method Not Allowed', 56 | 406: 'Not Acceptable', 57 | 407: 'Proxy Authentication Required', 58 | 408: 'Request Timeout', 59 | 409: 'Conflict', 60 | 410: 'Gone', 61 | 411: 'Length Required', 62 | 412: 'Precondition Failed', 63 | 413: 'Request Entity Too Large', 64 | 414: 'Request-URI Too Long', 65 | 415: 'Unsupported Media Type', 66 | 416: 'Requested Range Not Satisfiable', 67 | 417: 'Expectation Failed', 68 | 500: 'Internal Server Error', 69 | 501: 'Not Implemented', 70 | 502: 'Bad Gateway', 71 | 503: 'Service Unavailable', 72 | 504: 'Gateway Timeout', 73 | 505: 'HTTP Version Not Supported' 74 | } 75 | 76 | 77 | def str_dict(dic): 78 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 79 | 80 | :param dic: :class:`dict` of Unicode strings 81 | :returns: :class:`dict` 82 | 83 | """ 84 | if isinstance(dic, CaseInsensitiveDictionary): 85 | dic2 = CaseInsensitiveDictionary() 86 | else: 87 | dic2 = {} 88 | for k, v in dic.items(): 89 | if isinstance(k, unicode): 90 | k = k.encode('utf-8') 91 | if isinstance(v, unicode): 92 | v = v.encode('utf-8') 93 | dic2[k] = v 94 | return dic2 95 | 96 | 97 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 98 | """Prevent redirections.""" 99 | 100 | def redirect_request(self, *args): 101 | return None 102 | 103 | 104 | # Adapted from https://gist.github.com/babakness/3901174 105 | class CaseInsensitiveDictionary(dict): 106 | """Dictionary with caseless key search. 107 | 108 | Enables case insensitive searching while preserving case sensitivity 109 | when keys are listed, ie, via keys() or items() methods. 110 | 111 | Works by storing a lowercase version of the key as the new key and 112 | stores the original key-value pair as the key's value 113 | (values become dictionaries). 114 | 115 | """ 116 | 117 | def __init__(self, initval=None): 118 | """Create new case-insensitive dictionary.""" 119 | if isinstance(initval, dict): 120 | for key, value in initval.iteritems(): 121 | self.__setitem__(key, value) 122 | 123 | elif isinstance(initval, list): 124 | for (key, value) in initval: 125 | self.__setitem__(key, value) 126 | 127 | def __contains__(self, key): 128 | return dict.__contains__(self, key.lower()) 129 | 130 | def __getitem__(self, key): 131 | return dict.__getitem__(self, key.lower())['val'] 132 | 133 | def __setitem__(self, key, value): 134 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 135 | 136 | def get(self, key, default=None): 137 | try: 138 | v = dict.__getitem__(self, key.lower()) 139 | except KeyError: 140 | return default 141 | else: 142 | return v['val'] 143 | 144 | def update(self, other): 145 | for k, v in other.items(): 146 | self[k] = v 147 | 148 | def items(self): 149 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 150 | 151 | def keys(self): 152 | return [v['key'] for v in dict.itervalues(self)] 153 | 154 | def values(self): 155 | return [v['val'] for v in dict.itervalues(self)] 156 | 157 | def iteritems(self): 158 | for v in dict.itervalues(self): 159 | yield v['key'], v['val'] 160 | 161 | def iterkeys(self): 162 | for v in dict.itervalues(self): 163 | yield v['key'] 164 | 165 | def itervalues(self): 166 | for v in dict.itervalues(self): 167 | yield v['val'] 168 | 169 | 170 | class Response(object): 171 | """ 172 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 173 | 174 | Simplified version of the ``Response`` object in the ``requests`` library. 175 | 176 | >>> r = request('http://www.google.com') 177 | >>> r.status_code 178 | 200 179 | >>> r.encoding 180 | ISO-8859-1 181 | >>> r.content # bytes 182 | ... 183 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 184 | u' ...' 185 | >>> r.json() # content parsed as JSON 186 | 187 | """ 188 | 189 | def __init__(self, request, stream=False): 190 | """Call `request` with :mod:`urllib2` and process results. 191 | 192 | :param request: :class:`urllib2.Request` instance 193 | :param stream: Whether to stream response or retrieve it all at once 194 | :type stream: ``bool`` 195 | 196 | """ 197 | self.request = request 198 | self._stream = stream 199 | self.url = None 200 | self.raw = None 201 | self._encoding = None 202 | self.error = None 203 | self.status_code = None 204 | self.reason = None 205 | self.headers = CaseInsensitiveDictionary() 206 | self._content = None 207 | self._content_loaded = False 208 | self._gzipped = False 209 | 210 | # Execute query 211 | try: 212 | self.raw = urllib2.urlopen(request) 213 | except urllib2.HTTPError as err: 214 | self.error = err 215 | try: 216 | self.url = err.geturl() 217 | # sometimes (e.g. when authentication fails) 218 | # urllib can't get a URL from an HTTPError 219 | # This behaviour changes across Python versions, 220 | # so no test cover (it isn't important). 221 | except AttributeError: # pragma: no cover 222 | pass 223 | self.status_code = err.code 224 | else: 225 | self.status_code = self.raw.getcode() 226 | self.url = self.raw.geturl() 227 | self.reason = RESPONSES.get(self.status_code) 228 | 229 | # Parse additional info if request succeeded 230 | if not self.error: 231 | headers = self.raw.info() 232 | self.transfer_encoding = headers.getencoding() 233 | self.mimetype = headers.gettype() 234 | for key in headers.keys(): 235 | self.headers[key.lower()] = headers.get(key) 236 | 237 | # Is content gzipped? 238 | # Transfer-Encoding appears to not be used in the wild 239 | # (contrary to the HTTP standard), but no harm in testing 240 | # for it 241 | if ('gzip' in headers.get('content-encoding', '') or 242 | 'gzip' in headers.get('transfer-encoding', '')): 243 | self._gzipped = True 244 | 245 | @property 246 | def stream(self): 247 | """Whether response is streamed. 248 | 249 | Returns: 250 | bool: `True` if response is streamed. 251 | """ 252 | return self._stream 253 | 254 | @stream.setter 255 | def stream(self, value): 256 | if self._content_loaded: 257 | raise RuntimeError("`content` has already been read from " 258 | "this Response.") 259 | 260 | self._stream = value 261 | 262 | def json(self): 263 | """Decode response contents as JSON. 264 | 265 | :returns: object decoded from JSON 266 | :rtype: :class:`list` / :class:`dict` 267 | 268 | """ 269 | return json.loads(self.content, self.encoding or 'utf-8') 270 | 271 | @property 272 | def encoding(self): 273 | """Text encoding of document or ``None``. 274 | 275 | :returns: :class:`str` or ``None`` 276 | 277 | """ 278 | if not self._encoding: 279 | self._encoding = self._get_encoding() 280 | 281 | return self._encoding 282 | 283 | @property 284 | def content(self): 285 | """Raw content of response (i.e. bytes). 286 | 287 | :returns: Body of HTTP response 288 | :rtype: :class:`str` 289 | 290 | """ 291 | if not self._content: 292 | 293 | # Decompress gzipped content 294 | if self._gzipped: 295 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 296 | self._content = decoder.decompress(self.raw.read()) 297 | 298 | else: 299 | self._content = self.raw.read() 300 | 301 | self._content_loaded = True 302 | 303 | return self._content 304 | 305 | @property 306 | def text(self): 307 | """Unicode-decoded content of response body. 308 | 309 | If no encoding can be determined from HTTP headers or the content 310 | itself, the encoded response body will be returned instead. 311 | 312 | :returns: Body of HTTP response 313 | :rtype: :class:`unicode` or :class:`str` 314 | 315 | """ 316 | if self.encoding: 317 | return unicodedata.normalize('NFC', unicode(self.content, 318 | self.encoding)) 319 | return self.content 320 | 321 | def iter_content(self, chunk_size=4096, decode_unicode=False): 322 | """Iterate over response data. 323 | 324 | .. versionadded:: 1.6 325 | 326 | :param chunk_size: Number of bytes to read into memory 327 | :type chunk_size: ``int`` 328 | :param decode_unicode: Decode to Unicode using detected encoding 329 | :type decode_unicode: ``Boolean`` 330 | :returns: iterator 331 | 332 | """ 333 | if not self.stream: 334 | raise RuntimeError("You cannot call `iter_content` on a " 335 | "Response unless you passed `stream=True`" 336 | " to `get()`/`post()`/`request()`.") 337 | 338 | if self._content_loaded: 339 | raise RuntimeError( 340 | "`content` has already been read from this Response.") 341 | 342 | def decode_stream(iterator, r): 343 | 344 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') 345 | 346 | for chunk in iterator: 347 | data = decoder.decode(chunk) 348 | if data: 349 | yield data 350 | 351 | data = decoder.decode(b'', final=True) 352 | if data: # pragma: no cover 353 | yield data 354 | 355 | def generate(): 356 | 357 | if self._gzipped: 358 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 359 | 360 | while True: 361 | chunk = self.raw.read(chunk_size) 362 | if not chunk: 363 | break 364 | 365 | if self._gzipped: 366 | chunk = decoder.decompress(chunk) 367 | 368 | yield chunk 369 | 370 | chunks = generate() 371 | 372 | if decode_unicode and self.encoding: 373 | chunks = decode_stream(chunks, self) 374 | 375 | return chunks 376 | 377 | def save_to_path(self, filepath): 378 | """Save retrieved data to file at ``filepath``. 379 | 380 | .. versionadded: 1.9.6 381 | 382 | :param filepath: Path to save retrieved data. 383 | 384 | """ 385 | filepath = os.path.abspath(filepath) 386 | dirname = os.path.dirname(filepath) 387 | if not os.path.exists(dirname): 388 | os.makedirs(dirname) 389 | 390 | self.stream = True 391 | 392 | with open(filepath, 'wb') as fileobj: 393 | for data in self.iter_content(): 394 | fileobj.write(data) 395 | 396 | def raise_for_status(self): 397 | """Raise stored error if one occurred. 398 | 399 | error will be instance of :class:`urllib2.HTTPError` 400 | """ 401 | if self.error is not None: 402 | raise self.error 403 | return 404 | 405 | def _get_encoding(self): 406 | """Get encoding from HTTP headers or content. 407 | 408 | :returns: encoding or `None` 409 | :rtype: ``unicode`` or ``None`` 410 | 411 | """ 412 | headers = self.raw.info() 413 | encoding = None 414 | 415 | if headers.getparam('charset'): 416 | encoding = headers.getparam('charset') 417 | 418 | # HTTP Content-Type header 419 | for param in headers.getplist(): 420 | if param.startswith('charset='): 421 | encoding = param[8:] 422 | break 423 | 424 | if not self.stream: # Try sniffing response content 425 | # Encoding declared in document should override HTTP headers 426 | if self.mimetype == 'text/html': # sniff HTML headers 427 | m = re.search("""""", 428 | self.content) 429 | if m: 430 | encoding = m.group(1) 431 | 432 | elif ((self.mimetype.startswith('application/') or 433 | self.mimetype.startswith('text/')) and 434 | 'xml' in self.mimetype): 435 | m = re.search("""]*\?>""", 436 | self.content) 437 | if m: 438 | encoding = m.group(1) 439 | 440 | # Format defaults 441 | if self.mimetype == 'application/json' and not encoding: 442 | # The default encoding for JSON 443 | encoding = 'utf-8' 444 | 445 | elif self.mimetype == 'application/xml' and not encoding: 446 | # The default for 'application/xml' 447 | encoding = 'utf-8' 448 | 449 | if encoding: 450 | encoding = encoding.lower() 451 | 452 | return encoding 453 | 454 | 455 | def request(method, url, params=None, data=None, headers=None, cookies=None, 456 | files=None, auth=None, timeout=60, allow_redirects=False, 457 | stream=False): 458 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 459 | 460 | :param method: 'GET' or 'POST' 461 | :type method: ``unicode`` 462 | :param url: URL to open 463 | :type url: ``unicode`` 464 | :param params: mapping of URL parameters 465 | :type params: :class:`dict` 466 | :param data: mapping of form data ``{'field_name': 'value'}`` or 467 | :class:`str` 468 | :type data: :class:`dict` or :class:`str` 469 | :param headers: HTTP headers 470 | :type headers: :class:`dict` 471 | :param cookies: cookies to send to server 472 | :type cookies: :class:`dict` 473 | :param files: files to upload (see below). 474 | :type files: :class:`dict` 475 | :param auth: username, password 476 | :type auth: ``tuple`` 477 | :param timeout: connection timeout limit in seconds 478 | :type timeout: ``int`` 479 | :param allow_redirects: follow redirections 480 | :type allow_redirects: ``Boolean`` 481 | :param stream: Stream content instead of fetching it all at once. 482 | :type stream: ``bool`` 483 | :returns: :class:`Response` object 484 | 485 | 486 | The ``files`` argument is a dictionary:: 487 | 488 | {'fieldname' : { 'filename': 'blah.txt', 489 | 'content': '', 490 | 'mimetype': 'text/plain'} 491 | } 492 | 493 | * ``fieldname`` is the name of the field in the HTML form. 494 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 495 | be used to guess the mimetype, or ``application/octet-stream`` 496 | will be used. 497 | 498 | """ 499 | # TODO: cookies 500 | socket.setdefaulttimeout(timeout) 501 | 502 | # Default handlers 503 | openers = [] 504 | 505 | if not allow_redirects: 506 | openers.append(NoRedirectHandler()) 507 | 508 | if auth is not None: # Add authorisation handler 509 | username, password = auth 510 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 511 | password_manager.add_password(None, url, username, password) 512 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 513 | openers.append(auth_manager) 514 | 515 | # Install our custom chain of openers 516 | opener = urllib2.build_opener(*openers) 517 | urllib2.install_opener(opener) 518 | 519 | if not headers: 520 | headers = CaseInsensitiveDictionary() 521 | else: 522 | headers = CaseInsensitiveDictionary(headers) 523 | 524 | if 'user-agent' not in headers: 525 | headers['user-agent'] = USER_AGENT 526 | 527 | # Accept gzip-encoded content 528 | encodings = [s.strip() for s in 529 | headers.get('accept-encoding', '').split(',')] 530 | if 'gzip' not in encodings: 531 | encodings.append('gzip') 532 | 533 | headers['accept-encoding'] = ', '.join(encodings) 534 | 535 | # Force POST by providing an empty data string 536 | if method == 'POST' and not data: 537 | data = '' 538 | 539 | if files: 540 | if not data: 541 | data = {} 542 | new_headers, data = encode_multipart_formdata(data, files) 543 | headers.update(new_headers) 544 | elif data and isinstance(data, dict): 545 | data = urllib.urlencode(str_dict(data)) 546 | 547 | # Make sure everything is encoded text 548 | headers = str_dict(headers) 549 | 550 | if isinstance(url, unicode): 551 | url = url.encode('utf-8') 552 | 553 | if params: # GET args (POST args are handled in encode_multipart_formdata) 554 | 555 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 556 | 557 | if query: # Combine query string and `params` 558 | url_params = urlparse.parse_qs(query) 559 | # `params` take precedence over URL query string 560 | url_params.update(params) 561 | params = url_params 562 | 563 | query = urllib.urlencode(str_dict(params), doseq=True) 564 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 565 | 566 | req = urllib2.Request(url, data, headers) 567 | return Response(req, stream) 568 | 569 | 570 | def get(url, params=None, headers=None, cookies=None, auth=None, 571 | timeout=60, allow_redirects=True, stream=False): 572 | """Initiate a GET request. Arguments as for :func:`request`. 573 | 574 | :returns: :class:`Response` instance 575 | 576 | """ 577 | return request('GET', url, params, headers=headers, cookies=cookies, 578 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 579 | stream=stream) 580 | 581 | 582 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 583 | auth=None, timeout=60, allow_redirects=False, stream=False): 584 | """Initiate a POST request. Arguments as for :func:`request`. 585 | 586 | :returns: :class:`Response` instance 587 | 588 | """ 589 | return request('POST', url, params, data, headers, cookies, files, auth, 590 | timeout, allow_redirects, stream) 591 | 592 | 593 | def encode_multipart_formdata(fields, files): 594 | """Encode form data (``fields``) and ``files`` for POST request. 595 | 596 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 597 | :type fields: :class:`dict` 598 | :param files: dictionary of fieldnames/files elements for file data. 599 | See below for details. 600 | :type files: :class:`dict` of :class:`dicts` 601 | :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers 602 | :rtype: 2-tuple ``(dict, str)`` 603 | 604 | The ``files`` argument is a dictionary:: 605 | 606 | {'fieldname' : { 'filename': 'blah.txt', 607 | 'content': '', 608 | 'mimetype': 'text/plain'} 609 | } 610 | 611 | - ``fieldname`` is the name of the field in the HTML form. 612 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. 613 | 614 | """ 615 | def get_content_type(filename): 616 | """Return or guess mimetype of ``filename``. 617 | 618 | :param filename: filename of file 619 | :type filename: unicode/string 620 | :returns: mime-type, e.g. ``text/html`` 621 | :rtype: :class::class:`str` 622 | 623 | """ 624 | 625 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 626 | 627 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 628 | for i in range(30)) 629 | CRLF = '\r\n' 630 | output = [] 631 | 632 | # Normal form fields 633 | for (name, value) in fields.items(): 634 | if isinstance(name, unicode): 635 | name = name.encode('utf-8') 636 | if isinstance(value, unicode): 637 | value = value.encode('utf-8') 638 | output.append('--' + boundary) 639 | output.append('Content-Disposition: form-data; name="%s"' % name) 640 | output.append('') 641 | output.append(value) 642 | 643 | # Files to upload 644 | for name, d in files.items(): 645 | filename = d[u'filename'] 646 | content = d[u'content'] 647 | if u'mimetype' in d: 648 | mimetype = d[u'mimetype'] 649 | else: 650 | mimetype = get_content_type(filename) 651 | if isinstance(name, unicode): 652 | name = name.encode('utf-8') 653 | if isinstance(filename, unicode): 654 | filename = filename.encode('utf-8') 655 | if isinstance(mimetype, unicode): 656 | mimetype = mimetype.encode('utf-8') 657 | output.append('--' + boundary) 658 | output.append('Content-Disposition: form-data; ' 659 | 'name="%s"; filename="%s"' % (name, filename)) 660 | output.append('Content-Type: %s' % mimetype) 661 | output.append('') 662 | output.append(content) 663 | 664 | output.append('--' + boundary + '--') 665 | output.append('') 666 | body = CRLF.join(output) 667 | headers = { 668 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 669 | 'Content-Length': str(len(body)), 670 | } 671 | return (headers, body) 672 | -------------------------------------------------------------------------------- /workflow/web.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/web.pyc -------------------------------------------------------------------------------- /workflow/workflow.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """The :class:`Workflow` object is the main interface to this library. 11 | 12 | :class:`Workflow` is targeted at Alfred 2. Use 13 | :class:`~workflow.workflow3.Workflow3` if you want to use Alfred 3's new 14 | features, such as :ref:`workflow variables ` or 15 | more powerful modifiers. 16 | 17 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set 18 | up your Python script to best utilise the :class:`Workflow` object. 19 | 20 | """ 21 | 22 | from __future__ import print_function, unicode_literals 23 | 24 | import binascii 25 | from contextlib import contextmanager 26 | import cPickle 27 | from copy import deepcopy 28 | import errno 29 | import json 30 | import logging 31 | import logging.handlers 32 | import os 33 | import pickle 34 | import plistlib 35 | import re 36 | import shutil 37 | import signal 38 | import string 39 | import subprocess 40 | import sys 41 | import time 42 | import unicodedata 43 | 44 | try: 45 | import xml.etree.cElementTree as ET 46 | except ImportError: # pragma: no cover 47 | import xml.etree.ElementTree as ET 48 | 49 | 50 | #: Sentinel for properties that haven't been set yet (that might 51 | #: correctly have the value ``None``) 52 | UNSET = object() 53 | 54 | #################################################################### 55 | # Standard system icons 56 | #################################################################### 57 | 58 | # These icons are default OS X icons. They are super-high quality, and 59 | # will be familiar to users. 60 | # This library uses `ICON_ERROR` when a workflow dies in flames, so 61 | # in my own workflows, I use `ICON_WARNING` for less fatal errors 62 | # (e.g. bad user input, no results etc.) 63 | 64 | # The system icons are all in this directory. There are many more than 65 | # are listed here 66 | 67 | ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources' 68 | 69 | ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns') 70 | ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns') 71 | ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns') 72 | ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns') 73 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please 74 | ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns') 75 | # Shown when a workflow throws an error 76 | ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns') 77 | ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns') 78 | ICON_FAVOURITE = ICON_FAVORITE 79 | ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns') 80 | ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns') 81 | ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns') 82 | ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns') 83 | ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns') 84 | ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns') 85 | ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns') 86 | ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns') 87 | ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns') 88 | ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns') 89 | ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns') 90 | ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns') 91 | ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns') 92 | ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns') 93 | 94 | #################################################################### 95 | # non-ASCII to ASCII diacritic folding. 96 | # Used by `fold_to_ascii` method 97 | #################################################################### 98 | 99 | ASCII_REPLACEMENTS = { 100 | 'À': 'A', 101 | 'Á': 'A', 102 | 'Â': 'A', 103 | 'Ã': 'A', 104 | 'Ä': 'A', 105 | 'Å': 'A', 106 | 'Æ': 'AE', 107 | 'Ç': 'C', 108 | 'È': 'E', 109 | 'É': 'E', 110 | 'Ê': 'E', 111 | 'Ë': 'E', 112 | 'Ì': 'I', 113 | 'Í': 'I', 114 | 'Î': 'I', 115 | 'Ï': 'I', 116 | 'Ð': 'D', 117 | 'Ñ': 'N', 118 | 'Ò': 'O', 119 | 'Ó': 'O', 120 | 'Ô': 'O', 121 | 'Õ': 'O', 122 | 'Ö': 'O', 123 | 'Ø': 'O', 124 | 'Ù': 'U', 125 | 'Ú': 'U', 126 | 'Û': 'U', 127 | 'Ü': 'U', 128 | 'Ý': 'Y', 129 | 'Þ': 'Th', 130 | 'ß': 'ss', 131 | 'à': 'a', 132 | 'á': 'a', 133 | 'â': 'a', 134 | 'ã': 'a', 135 | 'ä': 'a', 136 | 'å': 'a', 137 | 'æ': 'ae', 138 | 'ç': 'c', 139 | 'è': 'e', 140 | 'é': 'e', 141 | 'ê': 'e', 142 | 'ë': 'e', 143 | 'ì': 'i', 144 | 'í': 'i', 145 | 'î': 'i', 146 | 'ï': 'i', 147 | 'ð': 'd', 148 | 'ñ': 'n', 149 | 'ò': 'o', 150 | 'ó': 'o', 151 | 'ô': 'o', 152 | 'õ': 'o', 153 | 'ö': 'o', 154 | 'ø': 'o', 155 | 'ù': 'u', 156 | 'ú': 'u', 157 | 'û': 'u', 158 | 'ü': 'u', 159 | 'ý': 'y', 160 | 'þ': 'th', 161 | 'ÿ': 'y', 162 | 'Ł': 'L', 163 | 'ł': 'l', 164 | 'Ń': 'N', 165 | 'ń': 'n', 166 | 'Ņ': 'N', 167 | 'ņ': 'n', 168 | 'Ň': 'N', 169 | 'ň': 'n', 170 | 'Ŋ': 'ng', 171 | 'ŋ': 'NG', 172 | 'Ō': 'O', 173 | 'ō': 'o', 174 | 'Ŏ': 'O', 175 | 'ŏ': 'o', 176 | 'Ő': 'O', 177 | 'ő': 'o', 178 | 'Œ': 'OE', 179 | 'œ': 'oe', 180 | 'Ŕ': 'R', 181 | 'ŕ': 'r', 182 | 'Ŗ': 'R', 183 | 'ŗ': 'r', 184 | 'Ř': 'R', 185 | 'ř': 'r', 186 | 'Ś': 'S', 187 | 'ś': 's', 188 | 'Ŝ': 'S', 189 | 'ŝ': 's', 190 | 'Ş': 'S', 191 | 'ş': 's', 192 | 'Š': 'S', 193 | 'š': 's', 194 | 'Ţ': 'T', 195 | 'ţ': 't', 196 | 'Ť': 'T', 197 | 'ť': 't', 198 | 'Ŧ': 'T', 199 | 'ŧ': 't', 200 | 'Ũ': 'U', 201 | 'ũ': 'u', 202 | 'Ū': 'U', 203 | 'ū': 'u', 204 | 'Ŭ': 'U', 205 | 'ŭ': 'u', 206 | 'Ů': 'U', 207 | 'ů': 'u', 208 | 'Ű': 'U', 209 | 'ű': 'u', 210 | 'Ŵ': 'W', 211 | 'ŵ': 'w', 212 | 'Ŷ': 'Y', 213 | 'ŷ': 'y', 214 | 'Ÿ': 'Y', 215 | 'Ź': 'Z', 216 | 'ź': 'z', 217 | 'Ż': 'Z', 218 | 'ż': 'z', 219 | 'Ž': 'Z', 220 | 'ž': 'z', 221 | 'ſ': 's', 222 | 'Α': 'A', 223 | 'Β': 'B', 224 | 'Γ': 'G', 225 | 'Δ': 'D', 226 | 'Ε': 'E', 227 | 'Ζ': 'Z', 228 | 'Η': 'E', 229 | 'Θ': 'Th', 230 | 'Ι': 'I', 231 | 'Κ': 'K', 232 | 'Λ': 'L', 233 | 'Μ': 'M', 234 | 'Ν': 'N', 235 | 'Ξ': 'Ks', 236 | 'Ο': 'O', 237 | 'Π': 'P', 238 | 'Ρ': 'R', 239 | 'Σ': 'S', 240 | 'Τ': 'T', 241 | 'Υ': 'U', 242 | 'Φ': 'Ph', 243 | 'Χ': 'Kh', 244 | 'Ψ': 'Ps', 245 | 'Ω': 'O', 246 | 'α': 'a', 247 | 'β': 'b', 248 | 'γ': 'g', 249 | 'δ': 'd', 250 | 'ε': 'e', 251 | 'ζ': 'z', 252 | 'η': 'e', 253 | 'θ': 'th', 254 | 'ι': 'i', 255 | 'κ': 'k', 256 | 'λ': 'l', 257 | 'μ': 'm', 258 | 'ν': 'n', 259 | 'ξ': 'x', 260 | 'ο': 'o', 261 | 'π': 'p', 262 | 'ρ': 'r', 263 | 'ς': 's', 264 | 'σ': 's', 265 | 'τ': 't', 266 | 'υ': 'u', 267 | 'φ': 'ph', 268 | 'χ': 'kh', 269 | 'ψ': 'ps', 270 | 'ω': 'o', 271 | 'А': 'A', 272 | 'Б': 'B', 273 | 'В': 'V', 274 | 'Г': 'G', 275 | 'Д': 'D', 276 | 'Е': 'E', 277 | 'Ж': 'Zh', 278 | 'З': 'Z', 279 | 'И': 'I', 280 | 'Й': 'I', 281 | 'К': 'K', 282 | 'Л': 'L', 283 | 'М': 'M', 284 | 'Н': 'N', 285 | 'О': 'O', 286 | 'П': 'P', 287 | 'Р': 'R', 288 | 'С': 'S', 289 | 'Т': 'T', 290 | 'У': 'U', 291 | 'Ф': 'F', 292 | 'Х': 'Kh', 293 | 'Ц': 'Ts', 294 | 'Ч': 'Ch', 295 | 'Ш': 'Sh', 296 | 'Щ': 'Shch', 297 | 'Ъ': "'", 298 | 'Ы': 'Y', 299 | 'Ь': "'", 300 | 'Э': 'E', 301 | 'Ю': 'Iu', 302 | 'Я': 'Ia', 303 | 'а': 'a', 304 | 'б': 'b', 305 | 'в': 'v', 306 | 'г': 'g', 307 | 'д': 'd', 308 | 'е': 'e', 309 | 'ж': 'zh', 310 | 'з': 'z', 311 | 'и': 'i', 312 | 'й': 'i', 313 | 'к': 'k', 314 | 'л': 'l', 315 | 'м': 'm', 316 | 'н': 'n', 317 | 'о': 'o', 318 | 'п': 'p', 319 | 'р': 'r', 320 | 'с': 's', 321 | 'т': 't', 322 | 'у': 'u', 323 | 'ф': 'f', 324 | 'х': 'kh', 325 | 'ц': 'ts', 326 | 'ч': 'ch', 327 | 'ш': 'sh', 328 | 'щ': 'shch', 329 | 'ъ': "'", 330 | 'ы': 'y', 331 | 'ь': "'", 332 | 'э': 'e', 333 | 'ю': 'iu', 334 | 'я': 'ia', 335 | # 'ᴀ': '', 336 | # 'ᴁ': '', 337 | # 'ᴂ': '', 338 | # 'ᴃ': '', 339 | # 'ᴄ': '', 340 | # 'ᴅ': '', 341 | # 'ᴆ': '', 342 | # 'ᴇ': '', 343 | # 'ᴈ': '', 344 | # 'ᴉ': '', 345 | # 'ᴊ': '', 346 | # 'ᴋ': '', 347 | # 'ᴌ': '', 348 | # 'ᴍ': '', 349 | # 'ᴎ': '', 350 | # 'ᴏ': '', 351 | # 'ᴐ': '', 352 | # 'ᴑ': '', 353 | # 'ᴒ': '', 354 | # 'ᴓ': '', 355 | # 'ᴔ': '', 356 | # 'ᴕ': '', 357 | # 'ᴖ': '', 358 | # 'ᴗ': '', 359 | # 'ᴘ': '', 360 | # 'ᴙ': '', 361 | # 'ᴚ': '', 362 | # 'ᴛ': '', 363 | # 'ᴜ': '', 364 | # 'ᴝ': '', 365 | # 'ᴞ': '', 366 | # 'ᴟ': '', 367 | # 'ᴠ': '', 368 | # 'ᴡ': '', 369 | # 'ᴢ': '', 370 | # 'ᴣ': '', 371 | # 'ᴤ': '', 372 | # 'ᴥ': '', 373 | 'ᴦ': 'G', 374 | 'ᴧ': 'L', 375 | 'ᴨ': 'P', 376 | 'ᴩ': 'R', 377 | 'ᴪ': 'PS', 378 | 'ẞ': 'Ss', 379 | 'Ỳ': 'Y', 380 | 'ỳ': 'y', 381 | 'Ỵ': 'Y', 382 | 'ỵ': 'y', 383 | 'Ỹ': 'Y', 384 | 'ỹ': 'y', 385 | } 386 | 387 | #################################################################### 388 | # Smart-to-dumb punctuation mapping 389 | #################################################################### 390 | 391 | DUMB_PUNCTUATION = { 392 | '‘': "'", 393 | '’': "'", 394 | '‚': "'", 395 | '“': '"', 396 | '”': '"', 397 | '„': '"', 398 | '–': '-', 399 | '—': '-' 400 | } 401 | 402 | 403 | #################################################################### 404 | # Used by `Workflow.filter` 405 | #################################################################### 406 | 407 | # Anchor characters in a name 408 | #: Characters that indicate the beginning of a "word" in CamelCase 409 | INITIALS = string.ascii_uppercase + string.digits 410 | 411 | #: Split on non-letters, numbers 412 | split_on_delimiters = re.compile('[^a-zA-Z0-9]').split 413 | 414 | # Match filter flags 415 | #: Match items that start with ``query`` 416 | MATCH_STARTSWITH = 1 417 | #: Match items whose capital letters start with ``query`` 418 | MATCH_CAPITALS = 2 419 | #: Match items with a component "word" that matches ``query`` 420 | MATCH_ATOM = 4 421 | #: Match items whose initials (based on atoms) start with ``query`` 422 | MATCH_INITIALS_STARTSWITH = 8 423 | #: Match items whose initials (based on atoms) contain ``query`` 424 | MATCH_INITIALS_CONTAIN = 16 425 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and 426 | #: :const:`MATCH_INITIALS_CONTAIN` 427 | MATCH_INITIALS = 24 428 | #: Match items if ``query`` is a substring 429 | MATCH_SUBSTRING = 32 430 | #: Match items if all characters in ``query`` appear in the item in order 431 | MATCH_ALLCHARS = 64 432 | #: Combination of all other ``MATCH_*`` constants 433 | MATCH_ALL = 127 434 | 435 | 436 | #################################################################### 437 | # Used by `Workflow.check_update` 438 | #################################################################### 439 | 440 | # Number of days to wait between checking for updates to the workflow 441 | DEFAULT_UPDATE_FREQUENCY = 1 442 | 443 | 444 | #################################################################### 445 | # Lockfile and Keychain access errors 446 | #################################################################### 447 | 448 | class AcquisitionError(Exception): 449 | """Raised if a lock cannot be acquired.""" 450 | 451 | 452 | class KeychainError(Exception): 453 | """Raised for unknown Keychain errors. 454 | 455 | Raised by methods :meth:`Workflow.save_password`, 456 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` 457 | when ``security`` CLI app returns an unknown error code. 458 | """ 459 | 460 | 461 | class PasswordNotFound(KeychainError): 462 | """Password not in Keychain. 463 | 464 | Raised by method :meth:`Workflow.get_password` when ``account`` 465 | is unknown to the Keychain. 466 | """ 467 | 468 | 469 | class PasswordExists(KeychainError): 470 | """Raised when trying to overwrite an existing account password. 471 | 472 | You should never receive this error: it is used internally 473 | by the :meth:`Workflow.save_password` method to know if it needs 474 | to delete the old password first (a Keychain implementation detail). 475 | """ 476 | 477 | 478 | #################################################################### 479 | # Helper functions 480 | #################################################################### 481 | 482 | def isascii(text): 483 | """Test if ``text`` contains only ASCII characters. 484 | 485 | :param text: text to test for ASCII-ness 486 | :type text: ``unicode`` 487 | :returns: ``True`` if ``text`` contains only ASCII characters 488 | :rtype: ``Boolean`` 489 | 490 | """ 491 | try: 492 | text.encode('ascii') 493 | except UnicodeEncodeError: 494 | return False 495 | return True 496 | 497 | 498 | #################################################################### 499 | # Implementation classes 500 | #################################################################### 501 | 502 | class SerializerManager(object): 503 | """Contains registered serializers. 504 | 505 | .. versionadded:: 1.8 506 | 507 | A configured instance of this class is available at 508 | ``workflow.manager``. 509 | 510 | Use :meth:`register()` to register new (or replace 511 | existing) serializers, which you can specify by name when calling 512 | :class:`Workflow` data storage methods. 513 | 514 | See :ref:`manual-serialization` and :ref:`manual-persistent-data` 515 | for further information. 516 | 517 | """ 518 | 519 | def __init__(self): 520 | """Create new SerializerManager object.""" 521 | self._serializers = {} 522 | 523 | def register(self, name, serializer): 524 | """Register ``serializer`` object under ``name``. 525 | 526 | Raises :class:`AttributeError` if ``serializer`` in invalid. 527 | 528 | .. note:: 529 | 530 | ``name`` will be used as the file extension of the saved files. 531 | 532 | :param name: Name to register ``serializer`` under 533 | :type name: ``unicode`` or ``str`` 534 | :param serializer: object with ``load()`` and ``dump()`` 535 | methods 536 | 537 | """ 538 | # Basic validation 539 | getattr(serializer, 'load') 540 | getattr(serializer, 'dump') 541 | 542 | self._serializers[name] = serializer 543 | 544 | def serializer(self, name): 545 | """Return serializer object for ``name``. 546 | 547 | :param name: Name of serializer to return 548 | :type name: ``unicode`` or ``str`` 549 | :returns: serializer object or ``None`` if no such serializer 550 | is registered. 551 | 552 | """ 553 | return self._serializers.get(name) 554 | 555 | def unregister(self, name): 556 | """Remove registered serializer with ``name``. 557 | 558 | Raises a :class:`ValueError` if there is no such registered 559 | serializer. 560 | 561 | :param name: Name of serializer to remove 562 | :type name: ``unicode`` or ``str`` 563 | :returns: serializer object 564 | 565 | """ 566 | if name not in self._serializers: 567 | raise ValueError('No such serializer registered : {0}'.format( 568 | name)) 569 | 570 | serializer = self._serializers[name] 571 | del self._serializers[name] 572 | 573 | return serializer 574 | 575 | @property 576 | def serializers(self): 577 | """Return names of registered serializers.""" 578 | return sorted(self._serializers.keys()) 579 | 580 | 581 | class JSONSerializer(object): 582 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``. 583 | 584 | .. versionadded:: 1.8 585 | 586 | Use this serializer if you need readable data files. JSON doesn't 587 | support Python objects as well as ``cPickle``/``pickle``, so be 588 | careful which data you try to serialize as JSON. 589 | 590 | """ 591 | 592 | @classmethod 593 | def load(cls, file_obj): 594 | """Load serialized object from open JSON file. 595 | 596 | .. versionadded:: 1.8 597 | 598 | :param file_obj: file handle 599 | :type file_obj: ``file`` object 600 | :returns: object loaded from JSON file 601 | :rtype: object 602 | 603 | """ 604 | return json.load(file_obj) 605 | 606 | @classmethod 607 | def dump(cls, obj, file_obj): 608 | """Serialize object ``obj`` to open JSON file. 609 | 610 | .. versionadded:: 1.8 611 | 612 | :param obj: Python object to serialize 613 | :type obj: JSON-serializable data structure 614 | :param file_obj: file handle 615 | :type file_obj: ``file`` object 616 | 617 | """ 618 | return json.dump(obj, file_obj, indent=2, encoding='utf-8') 619 | 620 | 621 | class CPickleSerializer(object): 622 | """Wrapper around :mod:`cPickle`. Sets ``protocol``. 623 | 624 | .. versionadded:: 1.8 625 | 626 | This is the default serializer and the best combination of speed and 627 | flexibility. 628 | 629 | """ 630 | 631 | @classmethod 632 | def load(cls, file_obj): 633 | """Load serialized object from open pickle file. 634 | 635 | .. versionadded:: 1.8 636 | 637 | :param file_obj: file handle 638 | :type file_obj: ``file`` object 639 | :returns: object loaded from pickle file 640 | :rtype: object 641 | 642 | """ 643 | return cPickle.load(file_obj) 644 | 645 | @classmethod 646 | def dump(cls, obj, file_obj): 647 | """Serialize object ``obj`` to open pickle file. 648 | 649 | .. versionadded:: 1.8 650 | 651 | :param obj: Python object to serialize 652 | :type obj: Python object 653 | :param file_obj: file handle 654 | :type file_obj: ``file`` object 655 | 656 | """ 657 | return cPickle.dump(obj, file_obj, protocol=-1) 658 | 659 | 660 | class PickleSerializer(object): 661 | """Wrapper around :mod:`pickle`. Sets ``protocol``. 662 | 663 | .. versionadded:: 1.8 664 | 665 | Use this serializer if you need to add custom pickling. 666 | 667 | """ 668 | 669 | @classmethod 670 | def load(cls, file_obj): 671 | """Load serialized object from open pickle file. 672 | 673 | .. versionadded:: 1.8 674 | 675 | :param file_obj: file handle 676 | :type file_obj: ``file`` object 677 | :returns: object loaded from pickle file 678 | :rtype: object 679 | 680 | """ 681 | return pickle.load(file_obj) 682 | 683 | @classmethod 684 | def dump(cls, obj, file_obj): 685 | """Serialize object ``obj`` to open pickle file. 686 | 687 | .. versionadded:: 1.8 688 | 689 | :param obj: Python object to serialize 690 | :type obj: Python object 691 | :param file_obj: file handle 692 | :type file_obj: ``file`` object 693 | 694 | """ 695 | return pickle.dump(obj, file_obj, protocol=-1) 696 | 697 | 698 | # Set up default manager and register built-in serializers 699 | manager = SerializerManager() 700 | manager.register('cpickle', CPickleSerializer) 701 | manager.register('pickle', PickleSerializer) 702 | manager.register('json', JSONSerializer) 703 | 704 | 705 | class Item(object): 706 | """Represents a feedback item for Alfred. 707 | 708 | Generates Alfred-compliant XML for a single item. 709 | 710 | You probably shouldn't use this class directly, but via 711 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` 712 | for details of arguments. 713 | 714 | """ 715 | 716 | def __init__(self, title, subtitle='', modifier_subtitles=None, 717 | arg=None, autocomplete=None, valid=False, uid=None, 718 | icon=None, icontype=None, type=None, largetext=None, 719 | copytext=None, quicklookurl=None): 720 | """Same arguments as :meth:`Workflow.add_item`.""" 721 | self.title = title 722 | self.subtitle = subtitle 723 | self.modifier_subtitles = modifier_subtitles or {} 724 | self.arg = arg 725 | self.autocomplete = autocomplete 726 | self.valid = valid 727 | self.uid = uid 728 | self.icon = icon 729 | self.icontype = icontype 730 | self.type = type 731 | self.largetext = largetext 732 | self.copytext = copytext 733 | self.quicklookurl = quicklookurl 734 | 735 | @property 736 | def elem(self): 737 | """Create and return feedback item for Alfred. 738 | 739 | :returns: :class:`ElementTree.Element ` 740 | instance for this :class:`Item` instance. 741 | 742 | """ 743 | # Attributes on element 744 | attr = {} 745 | if self.valid: 746 | attr['valid'] = 'yes' 747 | else: 748 | attr['valid'] = 'no' 749 | # Allow empty string for autocomplete. This is a useful value, 750 | # as TABing the result will revert the query back to just the 751 | # keyword 752 | if self.autocomplete is not None: 753 | attr['autocomplete'] = self.autocomplete 754 | 755 | # Optional attributes 756 | for name in ('uid', 'type'): 757 | value = getattr(self, name, None) 758 | if value: 759 | attr[name] = value 760 | 761 | root = ET.Element('item', attr) 762 | ET.SubElement(root, 'title').text = self.title 763 | ET.SubElement(root, 'subtitle').text = self.subtitle 764 | 765 | # Add modifier subtitles 766 | for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'): 767 | if mod in self.modifier_subtitles: 768 | ET.SubElement(root, 'subtitle', 769 | {'mod': mod}).text = self.modifier_subtitles[mod] 770 | 771 | # Add arg as element instead of attribute on , as it's more 772 | # flexible (newlines aren't allowed in attributes) 773 | if self.arg: 774 | ET.SubElement(root, 'arg').text = self.arg 775 | 776 | # Add icon if there is one 777 | if self.icon: 778 | if self.icontype: 779 | attr = dict(type=self.icontype) 780 | else: 781 | attr = {} 782 | ET.SubElement(root, 'icon', attr).text = self.icon 783 | 784 | if self.largetext: 785 | ET.SubElement(root, 'text', 786 | {'type': 'largetype'}).text = self.largetext 787 | 788 | if self.copytext: 789 | ET.SubElement(root, 'text', 790 | {'type': 'copy'}).text = self.copytext 791 | 792 | if self.quicklookurl: 793 | ET.SubElement(root, 'quicklookurl').text = self.quicklookurl 794 | 795 | return root 796 | 797 | 798 | class LockFile(object): 799 | """Context manager to create lock files.""" 800 | 801 | def __init__(self, protected_path, timeout=0, delay=0.05): 802 | """Create new :class:`LockFile` object.""" 803 | self.lockfile = protected_path + '.lock' 804 | self.timeout = timeout 805 | self.delay = delay 806 | self._locked = False 807 | 808 | @property 809 | def locked(self): 810 | """`True` if file is locked by this instance.""" 811 | return self._locked 812 | 813 | def acquire(self, blocking=True): 814 | """Acquire the lock if possible. 815 | 816 | If the lock is in use and ``blocking`` is ``False``, return 817 | ``False``. 818 | 819 | Otherwise, check every `self.delay` seconds until it acquires 820 | lock or exceeds `self.timeout` and raises an exception. 821 | 822 | """ 823 | start = time.time() 824 | while True: 825 | try: 826 | fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) 827 | with os.fdopen(fd, 'w') as fd: 828 | fd.write('{0}'.format(os.getpid())) 829 | break 830 | except OSError as err: 831 | if err.errno != errno.EEXIST: # pragma: no cover 832 | raise 833 | if self.timeout and (time.time() - start) >= self.timeout: 834 | raise AcquisitionError('Lock acquisition timed out.') 835 | if not blocking: 836 | return False 837 | time.sleep(self.delay) 838 | 839 | self._locked = True 840 | return True 841 | 842 | def release(self): 843 | """Release the lock by deleting `self.lockfile`.""" 844 | self._locked = False 845 | os.unlink(self.lockfile) 846 | 847 | def __enter__(self): 848 | """Acquire lock.""" 849 | self.acquire() 850 | return self 851 | 852 | def __exit__(self, typ, value, traceback): 853 | """Release lock.""" 854 | self.release() 855 | 856 | def __del__(self): 857 | """Clear up `self.lockfile`.""" 858 | if self._locked: # pragma: no cover 859 | self.release() 860 | 861 | 862 | @contextmanager 863 | def atomic_writer(file_path, mode): 864 | """Atomic file writer. 865 | 866 | :param file_path: path of file to write to. 867 | :type file_path: ``unicode`` 868 | :param mode: sames as for `func:open` 869 | :type mode: string 870 | 871 | .. versionadded:: 1.12 872 | 873 | Context manager that ensures the file is only written if the write 874 | succeeds. The data is first written to a temporary file. 875 | 876 | """ 877 | temp_suffix = '.aw.temp' 878 | temp_file_path = file_path + temp_suffix 879 | with open(temp_file_path, mode) as file_obj: 880 | try: 881 | yield file_obj 882 | os.rename(temp_file_path, file_path) 883 | finally: 884 | try: 885 | os.remove(temp_file_path) 886 | except (OSError, IOError): 887 | pass 888 | 889 | 890 | class uninterruptible(object): 891 | """Decorator that postpones SIGTERM until wrapped function is complete. 892 | 893 | .. versionadded:: 1.12 894 | 895 | Since version 2.7, Alfred allows Script Filters to be killed. If 896 | your workflow is killed in the middle of critical code (e.g. 897 | writing data to disk), this may corrupt your workflow's data. 898 | 899 | Use this decorator to wrap critical functions that *must* complete. 900 | If the script is killed while a wrapped function is executing, 901 | the SIGTERM will be caught and handled after your function has 902 | finished executing. 903 | 904 | Alfred-Workflow uses this internally to ensure its settings, data 905 | and cache writes complete. 906 | 907 | .. important:: 908 | 909 | This decorator is NOT thread-safe. 910 | 911 | """ 912 | 913 | def __init__(self, func, class_name=''): 914 | """Decorate `func`.""" 915 | self.func = func 916 | self._caught_signal = None 917 | 918 | def signal_handler(self, signum, frame): 919 | """Called when process receives SIGTERM.""" 920 | self._caught_signal = (signum, frame) 921 | 922 | def __call__(self, *args, **kwargs): 923 | """Trap ``SIGTERM`` and call wrapped function.""" 924 | self._caught_signal = None 925 | # Register handler for SIGTERM, then call `self.func` 926 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 927 | signal.signal(signal.SIGTERM, self.signal_handler) 928 | 929 | self.func(*args, **kwargs) 930 | 931 | # Restore old signal handler 932 | signal.signal(signal.SIGTERM, self.old_signal_handler) 933 | 934 | # Handle any signal caught during execution 935 | if self._caught_signal is not None: 936 | signum, frame = self._caught_signal 937 | if callable(self.old_signal_handler): 938 | self.old_signal_handler(signum, frame) 939 | elif self.old_signal_handler == signal.SIG_DFL: 940 | sys.exit(0) 941 | 942 | def __get__(self, obj=None, klass=None): 943 | """Decorator API.""" 944 | return self.__class__(self.func.__get__(obj, klass), 945 | klass.__name__) 946 | 947 | 948 | class Settings(dict): 949 | """A dictionary that saves itself when changed. 950 | 951 | Dictionary keys & values will be saved as a JSON file 952 | at ``filepath``. If the file does not exist, the dictionary 953 | (and settings file) will be initialised with ``defaults``. 954 | 955 | :param filepath: where to save the settings 956 | :type filepath: :class:`unicode` 957 | :param defaults: dict of default settings 958 | :type defaults: :class:`dict` 959 | 960 | 961 | An appropriate instance is provided by :class:`Workflow` instances at 962 | :attr:`Workflow.settings`. 963 | 964 | """ 965 | 966 | def __init__(self, filepath, defaults=None): 967 | """Create new :class:`Settings` object.""" 968 | super(Settings, self).__init__() 969 | self._filepath = filepath 970 | self._nosave = False 971 | self._original = {} 972 | if os.path.exists(self._filepath): 973 | self._load() 974 | elif defaults: 975 | for key, val in defaults.items(): 976 | self[key] = val 977 | self.save() # save default settings 978 | 979 | def _load(self): 980 | """Load cached settings from JSON file `self._filepath`.""" 981 | self._nosave = True 982 | d = {} 983 | with open(self._filepath, 'rb') as file_obj: 984 | for key, value in json.load(file_obj, encoding='utf-8').items(): 985 | d[key] = value 986 | self.update(d) 987 | self._original = deepcopy(d) 988 | self._nosave = False 989 | 990 | @uninterruptible 991 | def save(self): 992 | """Save settings to JSON file specified in ``self._filepath``. 993 | 994 | If you're using this class via :attr:`Workflow.settings`, which 995 | you probably are, ``self._filepath`` will be ``settings.json`` 996 | in your workflow's data directory (see :attr:`~Workflow.datadir`). 997 | """ 998 | if self._nosave: 999 | return 1000 | data = {} 1001 | data.update(self) 1002 | # for key, value in self.items(): 1003 | # data[key] = value 1004 | with LockFile(self._filepath): 1005 | with atomic_writer(self._filepath, 'wb') as file_obj: 1006 | json.dump(data, file_obj, sort_keys=True, indent=2, 1007 | encoding='utf-8') 1008 | 1009 | # dict methods 1010 | def __setitem__(self, key, value): 1011 | """Implement :class:`dict` interface.""" 1012 | if self._original.get(key) != value: 1013 | super(Settings, self).__setitem__(key, value) 1014 | self.save() 1015 | 1016 | def __delitem__(self, key): 1017 | """Implement :class:`dict` interface.""" 1018 | super(Settings, self).__delitem__(key) 1019 | self.save() 1020 | 1021 | def update(self, *args, **kwargs): 1022 | """Override :class:`dict` method to save on update.""" 1023 | super(Settings, self).update(*args, **kwargs) 1024 | self.save() 1025 | 1026 | def setdefault(self, key, value=None): 1027 | """Override :class:`dict` method to save on update.""" 1028 | ret = super(Settings, self).setdefault(key, value) 1029 | self.save() 1030 | return ret 1031 | 1032 | 1033 | class Workflow(object): 1034 | """Create new :class:`Workflow` instance. 1035 | 1036 | :param default_settings: default workflow settings. If no settings file 1037 | exists, :class:`Workflow.settings` will be pre-populated with 1038 | ``default_settings``. 1039 | :type default_settings: :class:`dict` 1040 | :param update_settings: settings for updating your workflow from GitHub. 1041 | This must be a :class:`dict` that contains ``github_slug`` and 1042 | ``version`` keys. ``github_slug`` is of the form ``username/repo`` 1043 | and ``version`` **must** correspond to the tag of a release. The 1044 | boolean ``prereleases`` key is optional and if ``True`` will 1045 | override the :ref:`magic argument ` preference. 1046 | This is only recommended when the installed workflow is a pre-release. 1047 | See :ref:`updates` for more information. 1048 | :type update_settings: :class:`dict` 1049 | :param input_encoding: encoding of command line arguments 1050 | :type input_encoding: :class:`unicode` 1051 | :param normalization: normalisation to apply to CLI args. 1052 | See :meth:`Workflow.decode` for more details. 1053 | :type normalization: :class:`unicode` 1054 | :param capture_args: capture and act on ``workflow:*`` arguments. See 1055 | :ref:`Magic arguments ` for details. 1056 | :type capture_args: :class:`Boolean` 1057 | :param libraries: sequence of paths to directories containing 1058 | libraries. These paths will be prepended to ``sys.path``. 1059 | :type libraries: :class:`tuple` or :class:`list` 1060 | :param help_url: URL to webpage where a user can ask for help with 1061 | the workflow, report bugs, etc. This could be the GitHub repo 1062 | or a page on AlfredForum.com. If your workflow throws an error, 1063 | this URL will be displayed in the log and Alfred's debugger. It can 1064 | also be opened directly in a web browser with the ``workflow:help`` 1065 | :ref:`magic argument `. 1066 | :type help_url: :class:`unicode` or :class:`str` 1067 | 1068 | """ 1069 | 1070 | # Which class to use to generate feedback items. You probably 1071 | # won't want to change this 1072 | item_class = Item 1073 | 1074 | def __init__(self, default_settings=None, update_settings=None, 1075 | input_encoding='utf-8', normalization='NFC', 1076 | capture_args=True, libraries=None, 1077 | help_url=None): 1078 | """Create new :class:`Workflow` object.""" 1079 | self._default_settings = default_settings or {} 1080 | self._update_settings = update_settings or {} 1081 | self._input_encoding = input_encoding 1082 | self._normalizsation = normalization 1083 | self._capture_args = capture_args 1084 | self.help_url = help_url 1085 | self._workflowdir = None 1086 | self._settings_path = None 1087 | self._settings = None 1088 | self._bundleid = None 1089 | self._debugging = None 1090 | self._name = None 1091 | self._cache_serializer = 'cpickle' 1092 | self._data_serializer = 'cpickle' 1093 | self._info = None 1094 | self._info_loaded = False 1095 | self._logger = None 1096 | self._items = [] 1097 | self._alfred_env = None 1098 | # Version number of the workflow 1099 | self._version = UNSET 1100 | # Version from last workflow run 1101 | self._last_version_run = UNSET 1102 | # Cache for regex patterns created for filter keys 1103 | self._search_pattern_cache = {} 1104 | # Magic arguments 1105 | #: The prefix for all magic arguments. Default is ``workflow:`` 1106 | self.magic_prefix = 'workflow:' 1107 | #: Mapping of available magic arguments. The built-in magic 1108 | #: arguments are registered by default. To add your own magic arguments 1109 | #: (or override built-ins), add a key:value pair where the key is 1110 | #: what the user should enter (prefixed with :attr:`magic_prefix`) 1111 | #: and the value is a callable that will be called when the argument 1112 | #: is entered. If you would like to display a message in Alfred, the 1113 | #: function should return a ``unicode`` string. 1114 | #: 1115 | #: By default, the magic arguments documented 1116 | #: :ref:`here ` are registered. 1117 | self.magic_arguments = {} 1118 | 1119 | self._register_default_magic() 1120 | 1121 | if libraries: 1122 | sys.path = libraries + sys.path 1123 | 1124 | #################################################################### 1125 | # API methods 1126 | #################################################################### 1127 | 1128 | # info.plist contents and alfred_* environment variables ---------- 1129 | 1130 | @property 1131 | def alfred_version(self): 1132 | """Alfred version as :class:`~workflow.update.Version` object.""" 1133 | from update import Version 1134 | return Version(self.alfred_env.get('version')) 1135 | 1136 | @property 1137 | def alfred_env(self): 1138 | """Dict of Alfred's environmental variables minus ``alfred_`` prefix. 1139 | 1140 | .. versionadded:: 1.7 1141 | 1142 | The variables Alfred 2.4+ exports are: 1143 | 1144 | ============================ ========================================= 1145 | Variable Description 1146 | ============================ ========================================= 1147 | alfred_debug Set to ``1`` if Alfred's debugger is 1148 | open, otherwise unset. 1149 | alfred_preferences Path to Alfred.alfredpreferences 1150 | (where your workflows and settings are 1151 | stored). 1152 | alfred_preferences_localhash Machine-specific preferences are stored 1153 | in ``Alfred.alfredpreferences/preferences/local/`` 1154 | (see ``alfred_preferences`` above for 1155 | the path to ``Alfred.alfredpreferences``) 1156 | alfred_theme ID of selected theme 1157 | alfred_theme_background Background colour of selected theme in 1158 | format ``rgba(r,g,b,a)`` 1159 | alfred_theme_subtext Show result subtext. 1160 | ``0`` = Always, 1161 | ``1`` = Alternative actions only, 1162 | ``2`` = Selected result only, 1163 | ``3`` = Never 1164 | alfred_version Alfred version number, e.g. ``'2.4'`` 1165 | alfred_version_build Alfred build number, e.g. ``277`` 1166 | alfred_workflow_bundleid Bundle ID, e.g. 1167 | ``net.deanishe.alfred-mailto`` 1168 | alfred_workflow_cache Path to workflow's cache directory 1169 | alfred_workflow_data Path to workflow's data directory 1170 | alfred_workflow_name Name of current workflow 1171 | alfred_workflow_uid UID of workflow 1172 | alfred_workflow_version The version number specified in the 1173 | workflow configuration sheet/info.plist 1174 | ============================ ========================================= 1175 | 1176 | **Note:** all values are Unicode strings except ``version_build`` and 1177 | ``theme_subtext``, which are integers. 1178 | 1179 | :returns: ``dict`` of Alfred's environmental variables without the 1180 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. 1181 | 1182 | """ 1183 | if self._alfred_env is not None: 1184 | return self._alfred_env 1185 | 1186 | data = {} 1187 | 1188 | for key in ( 1189 | 'alfred_debug', 1190 | 'alfred_preferences', 1191 | 'alfred_preferences_localhash', 1192 | 'alfred_theme', 1193 | 'alfred_theme_background', 1194 | 'alfred_theme_subtext', 1195 | 'alfred_version', 1196 | 'alfred_version_build', 1197 | 'alfred_workflow_bundleid', 1198 | 'alfred_workflow_cache', 1199 | 'alfred_workflow_data', 1200 | 'alfred_workflow_name', 1201 | 'alfred_workflow_uid', 1202 | 'alfred_workflow_version'): 1203 | 1204 | value = os.getenv(key) 1205 | 1206 | if isinstance(value, str): 1207 | if key in ('alfred_debug', 'alfred_version_build', 1208 | 'alfred_theme_subtext'): 1209 | value = int(value) 1210 | else: 1211 | value = self.decode(value) 1212 | 1213 | data[key[7:]] = value 1214 | 1215 | self._alfred_env = data 1216 | 1217 | return self._alfred_env 1218 | 1219 | @property 1220 | def info(self): 1221 | """:class:`dict` of ``info.plist`` contents.""" 1222 | if not self._info_loaded: 1223 | self._load_info_plist() 1224 | return self._info 1225 | 1226 | @property 1227 | def bundleid(self): 1228 | """Workflow bundle ID from environmental vars or ``info.plist``. 1229 | 1230 | :returns: bundle ID 1231 | :rtype: ``unicode`` 1232 | 1233 | """ 1234 | if not self._bundleid: 1235 | if self.alfred_env.get('workflow_bundleid'): 1236 | self._bundleid = self.alfred_env.get('workflow_bundleid') 1237 | else: 1238 | self._bundleid = unicode(self.info['bundleid'], 'utf-8') 1239 | 1240 | return self._bundleid 1241 | 1242 | @property 1243 | def debugging(self): 1244 | """Whether Alfred's debugger is open. 1245 | 1246 | :returns: ``True`` if Alfred's debugger is open. 1247 | :rtype: ``bool`` 1248 | 1249 | """ 1250 | if self._debugging is None: 1251 | if self.alfred_env.get('debug') == 1: 1252 | self._debugging = True 1253 | else: 1254 | self._debugging = False 1255 | return self._debugging 1256 | 1257 | @property 1258 | def name(self): 1259 | """Workflow name from Alfred's environmental vars or ``info.plist``. 1260 | 1261 | :returns: workflow name 1262 | :rtype: ``unicode`` 1263 | 1264 | """ 1265 | if not self._name: 1266 | if self.alfred_env.get('workflow_name'): 1267 | self._name = self.decode(self.alfred_env.get('workflow_name')) 1268 | else: 1269 | self._name = self.decode(self.info['name']) 1270 | 1271 | return self._name 1272 | 1273 | @property 1274 | def version(self): 1275 | """Return the version of the workflow. 1276 | 1277 | .. versionadded:: 1.9.10 1278 | 1279 | Get the workflow version from environment variable, 1280 | the ``update_settings`` dict passed on 1281 | instantiation, the ``version`` file located in the workflow's 1282 | root directory or ``info.plist``. Return ``None`` if none 1283 | exists or :class:`ValueError` if the version number is invalid 1284 | (i.e. not semantic). 1285 | 1286 | :returns: Version of the workflow (not Alfred-Workflow) 1287 | :rtype: :class:`~workflow.update.Version` object 1288 | 1289 | """ 1290 | if self._version is UNSET: 1291 | 1292 | version = None 1293 | # environment variable has priority 1294 | if self.alfred_env.get('workflow_version'): 1295 | version = self.alfred_env['workflow_version'] 1296 | 1297 | # Try `update_settings` 1298 | elif self._update_settings: 1299 | version = self._update_settings.get('version') 1300 | 1301 | # `version` file 1302 | if not version: 1303 | filepath = self.workflowfile('version') 1304 | 1305 | if os.path.exists(filepath): 1306 | with open(filepath, 'rb') as fileobj: 1307 | version = fileobj.read() 1308 | 1309 | # info.plist 1310 | if not version: 1311 | version = self.info.get('version') 1312 | 1313 | if version: 1314 | from update import Version 1315 | version = Version(version) 1316 | 1317 | self._version = version 1318 | 1319 | return self._version 1320 | 1321 | # Workflow utility methods ----------------------------------------- 1322 | 1323 | @property 1324 | def args(self): 1325 | """Return command line args as normalised unicode. 1326 | 1327 | Args are decoded and normalised via :meth:`~Workflow.decode`. 1328 | 1329 | The encoding and normalisation are the ``input_encoding`` and 1330 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` 1331 | and ``NFC`` are the defaults). 1332 | 1333 | If :class:`Workflow` is called with ``capture_args=True`` 1334 | (the default), :class:`Workflow` will look for certain 1335 | ``workflow:*`` args and, if found, perform the corresponding 1336 | actions and exit the workflow. 1337 | 1338 | See :ref:`Magic arguments ` for details. 1339 | 1340 | """ 1341 | msg = None 1342 | args = [self.decode(arg) for arg in sys.argv[1:]] 1343 | 1344 | # Handle magic args 1345 | if len(args) and self._capture_args: 1346 | for name in self.magic_arguments: 1347 | key = '{0}{1}'.format(self.magic_prefix, name) 1348 | if key in args: 1349 | msg = self.magic_arguments[name]() 1350 | 1351 | if msg: 1352 | self.logger.debug(msg) 1353 | if not sys.stdout.isatty(): # Show message in Alfred 1354 | self.add_item(msg, valid=False, icon=ICON_INFO) 1355 | self.send_feedback() 1356 | sys.exit(0) 1357 | return args 1358 | 1359 | @property 1360 | def cachedir(self): 1361 | """Path to workflow's cache directory. 1362 | 1363 | The cache directory is a subdirectory of Alfred's own cache directory 1364 | in ``~/Library/Caches``. The full path is: 1365 | 1366 | ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` 1367 | 1368 | ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. 1369 | 1370 | :returns: full path to workflow's cache directory 1371 | :rtype: ``unicode`` 1372 | 1373 | """ 1374 | if self.alfred_env.get('workflow_cache'): 1375 | dirpath = self.alfred_env.get('workflow_cache') 1376 | 1377 | else: 1378 | dirpath = self._default_cachedir 1379 | 1380 | return self._create(dirpath) 1381 | 1382 | @property 1383 | def _default_cachedir(self): 1384 | """Alfred 2's default cache directory.""" 1385 | return os.path.join( 1386 | os.path.expanduser( 1387 | '~/Library/Caches/com.runningwithcrayons.Alfred-2/' 1388 | 'Workflow Data/'), 1389 | self.bundleid) 1390 | 1391 | @property 1392 | def datadir(self): 1393 | """Path to workflow's data directory. 1394 | 1395 | The data directory is a subdirectory of Alfred's own data directory in 1396 | ``~/Library/Application Support``. The full path is: 1397 | 1398 | ``~/Library/Application Support/Alfred 2/Workflow Data/`` 1399 | 1400 | :returns: full path to workflow data directory 1401 | :rtype: ``unicode`` 1402 | 1403 | """ 1404 | if self.alfred_env.get('workflow_data'): 1405 | dirpath = self.alfred_env.get('workflow_data') 1406 | 1407 | else: 1408 | dirpath = self._default_datadir 1409 | 1410 | return self._create(dirpath) 1411 | 1412 | @property 1413 | def _default_datadir(self): 1414 | """Alfred 2's default data directory.""" 1415 | return os.path.join(os.path.expanduser( 1416 | '~/Library/Application Support/Alfred 2/Workflow Data/'), 1417 | self.bundleid) 1418 | 1419 | @property 1420 | def workflowdir(self): 1421 | """Path to workflow's root directory (where ``info.plist`` is). 1422 | 1423 | :returns: full path to workflow root directory 1424 | :rtype: ``unicode`` 1425 | 1426 | """ 1427 | if not self._workflowdir: 1428 | # Try the working directory first, then the directory 1429 | # the library is in. CWD will be the workflow root if 1430 | # a workflow is being run in Alfred 1431 | candidates = [ 1432 | os.path.abspath(os.getcwdu()), 1433 | os.path.dirname(os.path.abspath(os.path.dirname(__file__)))] 1434 | 1435 | # climb the directory tree until we find `info.plist` 1436 | for dirpath in candidates: 1437 | 1438 | # Ensure directory path is Unicode 1439 | dirpath = self.decode(dirpath) 1440 | 1441 | while True: 1442 | if os.path.exists(os.path.join(dirpath, 'info.plist')): 1443 | self._workflowdir = dirpath 1444 | break 1445 | 1446 | elif dirpath == '/': 1447 | # no `info.plist` found 1448 | break 1449 | 1450 | # Check the parent directory 1451 | dirpath = os.path.dirname(dirpath) 1452 | 1453 | # No need to check other candidates 1454 | if self._workflowdir: 1455 | break 1456 | 1457 | if not self._workflowdir: 1458 | raise IOError("'info.plist' not found in directory tree") 1459 | 1460 | return self._workflowdir 1461 | 1462 | def cachefile(self, filename): 1463 | """Path to ``filename`` in workflow's cache directory. 1464 | 1465 | Return absolute path to ``filename`` within your workflow's 1466 | :attr:`cache directory `. 1467 | 1468 | :param filename: basename of file 1469 | :type filename: ``unicode`` 1470 | :returns: full path to file within cache directory 1471 | :rtype: ``unicode`` 1472 | 1473 | """ 1474 | return os.path.join(self.cachedir, filename) 1475 | 1476 | def datafile(self, filename): 1477 | """Path to ``filename`` in workflow's data directory. 1478 | 1479 | Return absolute path to ``filename`` within your workflow's 1480 | :attr:`data directory `. 1481 | 1482 | :param filename: basename of file 1483 | :type filename: ``unicode`` 1484 | :returns: full path to file within data directory 1485 | :rtype: ``unicode`` 1486 | 1487 | """ 1488 | return os.path.join(self.datadir, filename) 1489 | 1490 | def workflowfile(self, filename): 1491 | """Return full path to ``filename`` in workflow's root directory. 1492 | 1493 | :param filename: basename of file 1494 | :type filename: ``unicode`` 1495 | :returns: full path to file within data directory 1496 | :rtype: ``unicode`` 1497 | 1498 | """ 1499 | return os.path.join(self.workflowdir, filename) 1500 | 1501 | @property 1502 | def logfile(self): 1503 | """Path to logfile. 1504 | 1505 | :returns: path to logfile within workflow's cache directory 1506 | :rtype: ``unicode`` 1507 | 1508 | """ 1509 | return self.cachefile('%s.log' % self.bundleid) 1510 | 1511 | @property 1512 | def logger(self): 1513 | """Logger that logs to both console and a log file. 1514 | 1515 | If Alfred's debugger is open, log level will be ``DEBUG``, 1516 | else it will be ``INFO``. 1517 | 1518 | Use :meth:`open_log` to open the log file in Console. 1519 | 1520 | :returns: an initialised :class:`~logging.Logger` 1521 | 1522 | """ 1523 | if self._logger: 1524 | return self._logger 1525 | 1526 | # Initialise new logger and optionally handlers 1527 | logger = logging.getLogger('workflow') 1528 | 1529 | if not len(logger.handlers): # Only add one set of handlers 1530 | 1531 | fmt = logging.Formatter( 1532 | '%(asctime)s %(filename)s:%(lineno)s' 1533 | ' %(levelname)-8s %(message)s', 1534 | datefmt='%H:%M:%S') 1535 | 1536 | logfile = logging.handlers.RotatingFileHandler( 1537 | self.logfile, 1538 | maxBytes=1024 * 1024, 1539 | backupCount=1) 1540 | logfile.setFormatter(fmt) 1541 | logger.addHandler(logfile) 1542 | 1543 | console = logging.StreamHandler() 1544 | console.setFormatter(fmt) 1545 | logger.addHandler(console) 1546 | 1547 | if self.debugging: 1548 | logger.setLevel(logging.DEBUG) 1549 | else: 1550 | logger.setLevel(logging.INFO) 1551 | 1552 | self._logger = logger 1553 | 1554 | return self._logger 1555 | 1556 | @logger.setter 1557 | def logger(self, logger): 1558 | """Set a custom logger. 1559 | 1560 | :param logger: The logger to use 1561 | :type logger: `~logging.Logger` instance 1562 | 1563 | """ 1564 | self._logger = logger 1565 | 1566 | @property 1567 | def settings_path(self): 1568 | """Path to settings file within workflow's data directory. 1569 | 1570 | :returns: path to ``settings.json`` file 1571 | :rtype: ``unicode`` 1572 | 1573 | """ 1574 | if not self._settings_path: 1575 | self._settings_path = self.datafile('settings.json') 1576 | return self._settings_path 1577 | 1578 | @property 1579 | def settings(self): 1580 | """Return a dictionary subclass that saves itself when changed. 1581 | 1582 | See :ref:`manual-settings` in the :ref:`user-manual` for more 1583 | information on how to use :attr:`settings` and **important 1584 | limitations** on what it can do. 1585 | 1586 | :returns: :class:`~workflow.workflow.Settings` instance 1587 | initialised from the data in JSON file at 1588 | :attr:`settings_path` or if that doesn't exist, with the 1589 | ``default_settings`` :class:`dict` passed to 1590 | :class:`Workflow` on instantiation. 1591 | :rtype: :class:`~workflow.workflow.Settings` instance 1592 | 1593 | """ 1594 | if not self._settings: 1595 | self.logger.debug('Reading settings from `{0}` ...'.format( 1596 | self.settings_path)) 1597 | self._settings = Settings(self.settings_path, 1598 | self._default_settings) 1599 | return self._settings 1600 | 1601 | @property 1602 | def cache_serializer(self): 1603 | """Name of default cache serializer. 1604 | 1605 | .. versionadded:: 1.8 1606 | 1607 | This serializer is used by :meth:`cache_data()` and 1608 | :meth:`cached_data()` 1609 | 1610 | See :class:`SerializerManager` for details. 1611 | 1612 | :returns: serializer name 1613 | :rtype: ``unicode`` 1614 | 1615 | """ 1616 | return self._cache_serializer 1617 | 1618 | @cache_serializer.setter 1619 | def cache_serializer(self, serializer_name): 1620 | """Set the default cache serialization format. 1621 | 1622 | .. versionadded:: 1.8 1623 | 1624 | This serializer is used by :meth:`cache_data()` and 1625 | :meth:`cached_data()` 1626 | 1627 | The specified serializer must already by registered with the 1628 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1629 | otherwise a :class:`ValueError` will be raised. 1630 | 1631 | :param serializer_name: Name of default serializer to use. 1632 | :type serializer_name: 1633 | 1634 | """ 1635 | if manager.serializer(serializer_name) is None: 1636 | raise ValueError( 1637 | 'Unknown serializer : `{0}`. Register your serializer ' 1638 | 'with `manager` first.'.format(serializer_name)) 1639 | 1640 | self.logger.debug( 1641 | 'default cache serializer set to `{0}`'.format(serializer_name)) 1642 | 1643 | self._cache_serializer = serializer_name 1644 | 1645 | @property 1646 | def data_serializer(self): 1647 | """Name of default data serializer. 1648 | 1649 | .. versionadded:: 1.8 1650 | 1651 | This serializer is used by :meth:`store_data()` and 1652 | :meth:`stored_data()` 1653 | 1654 | See :class:`SerializerManager` for details. 1655 | 1656 | :returns: serializer name 1657 | :rtype: ``unicode`` 1658 | 1659 | """ 1660 | return self._data_serializer 1661 | 1662 | @data_serializer.setter 1663 | def data_serializer(self, serializer_name): 1664 | """Set the default cache serialization format. 1665 | 1666 | .. versionadded:: 1.8 1667 | 1668 | This serializer is used by :meth:`store_data()` and 1669 | :meth:`stored_data()` 1670 | 1671 | The specified serializer must already by registered with the 1672 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1673 | otherwise a :class:`ValueError` will be raised. 1674 | 1675 | :param serializer_name: Name of serializer to use by default. 1676 | 1677 | """ 1678 | if manager.serializer(serializer_name) is None: 1679 | raise ValueError( 1680 | 'Unknown serializer : `{0}`. Register your serializer ' 1681 | 'with `manager` first.'.format(serializer_name)) 1682 | 1683 | self.logger.debug( 1684 | 'default data serializer set to `{0}`'.format(serializer_name)) 1685 | 1686 | self._data_serializer = serializer_name 1687 | 1688 | def stored_data(self, name): 1689 | """Retrieve data from data directory. 1690 | 1691 | Returns ``None`` if there are no data stored under ``name``. 1692 | 1693 | .. versionadded:: 1.8 1694 | 1695 | :param name: name of datastore 1696 | 1697 | """ 1698 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) 1699 | 1700 | if not os.path.exists(metadata_path): 1701 | self.logger.debug('No data stored for `{0}`'.format(name)) 1702 | return None 1703 | 1704 | with open(metadata_path, 'rb') as file_obj: 1705 | serializer_name = file_obj.read().strip() 1706 | 1707 | serializer = manager.serializer(serializer_name) 1708 | 1709 | if serializer is None: 1710 | raise ValueError( 1711 | 'Unknown serializer `{0}`. Register a corresponding ' 1712 | 'serializer with `manager.register()` ' 1713 | 'to load this data.'.format(serializer_name)) 1714 | 1715 | self.logger.debug('Data `{0}` stored in `{1}` format'.format( 1716 | name, serializer_name)) 1717 | 1718 | filename = '{0}.{1}'.format(name, serializer_name) 1719 | data_path = self.datafile(filename) 1720 | 1721 | if not os.path.exists(data_path): 1722 | self.logger.debug('No data stored for `{0}`'.format(name)) 1723 | if os.path.exists(metadata_path): 1724 | os.unlink(metadata_path) 1725 | 1726 | return None 1727 | 1728 | with open(data_path, 'rb') as file_obj: 1729 | data = serializer.load(file_obj) 1730 | 1731 | self.logger.debug('Stored data loaded from : {0}'.format(data_path)) 1732 | 1733 | return data 1734 | 1735 | def store_data(self, name, data, serializer=None): 1736 | """Save data to data directory. 1737 | 1738 | .. versionadded:: 1.8 1739 | 1740 | If ``data`` is ``None``, the datastore will be deleted. 1741 | 1742 | Note that the datastore does NOT support mutliple threads. 1743 | 1744 | :param name: name of datastore 1745 | :param data: object(s) to store. **Note:** some serializers 1746 | can only handled certain types of data. 1747 | :param serializer: name of serializer to use. If no serializer 1748 | is specified, the default will be used. See 1749 | :class:`SerializerManager` for more information. 1750 | :returns: data in datastore or ``None`` 1751 | 1752 | """ 1753 | # Ensure deletion is not interrupted by SIGTERM 1754 | @uninterruptible 1755 | def delete_paths(paths): 1756 | """Clear one or more data stores""" 1757 | for path in paths: 1758 | if os.path.exists(path): 1759 | os.unlink(path) 1760 | self.logger.debug('Deleted data file : {0}'.format(path)) 1761 | 1762 | serializer_name = serializer or self.data_serializer 1763 | 1764 | # In order for `stored_data()` to be able to load data stored with 1765 | # an arbitrary serializer, yet still have meaningful file extensions, 1766 | # the format (i.e. extension) is saved to an accompanying file 1767 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) 1768 | filename = '{0}.{1}'.format(name, serializer_name) 1769 | data_path = self.datafile(filename) 1770 | 1771 | if data_path == self.settings_path: 1772 | raise ValueError( 1773 | 'Cannot save data to' + 1774 | '`{0}` with format `{1}`. '.format(name, serializer_name) + 1775 | "This would overwrite Alfred-Workflow's settings file.") 1776 | 1777 | serializer = manager.serializer(serializer_name) 1778 | 1779 | if serializer is None: 1780 | raise ValueError( 1781 | 'Invalid serializer `{0}`. Register your serializer with ' 1782 | '`manager.register()` first.'.format(serializer_name)) 1783 | 1784 | if data is None: # Delete cached data 1785 | delete_paths((metadata_path, data_path)) 1786 | return 1787 | 1788 | # Ensure write is not interrupted by SIGTERM 1789 | @uninterruptible 1790 | def _store(): 1791 | # Save file extension 1792 | with atomic_writer(metadata_path, 'wb') as file_obj: 1793 | file_obj.write(serializer_name) 1794 | 1795 | with atomic_writer(data_path, 'wb') as file_obj: 1796 | serializer.dump(data, file_obj) 1797 | 1798 | _store() 1799 | 1800 | self.logger.debug('Stored data saved at : {0}'.format(data_path)) 1801 | 1802 | def cached_data(self, name, data_func=None, max_age=60): 1803 | """Return cached data if younger than ``max_age`` seconds. 1804 | 1805 | Retrieve data from cache or re-generate and re-cache data if 1806 | stale/non-existant. If ``max_age`` is 0, return cached data no 1807 | matter how old. 1808 | 1809 | :param name: name of datastore 1810 | :param data_func: function to (re-)generate data. 1811 | :type data_func: ``callable`` 1812 | :param max_age: maximum age of cached data in seconds 1813 | :type max_age: ``int`` 1814 | :returns: cached data, return value of ``data_func`` or ``None`` 1815 | if ``data_func`` is not set 1816 | 1817 | """ 1818 | serializer = manager.serializer(self.cache_serializer) 1819 | 1820 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1821 | age = self.cached_data_age(name) 1822 | 1823 | if (age < max_age or max_age == 0) and os.path.exists(cache_path): 1824 | 1825 | with open(cache_path, 'rb') as file_obj: 1826 | self.logger.debug('Loading cached data from : %s', 1827 | cache_path) 1828 | return serializer.load(file_obj) 1829 | 1830 | if not data_func: 1831 | return None 1832 | 1833 | data = data_func() 1834 | self.cache_data(name, data) 1835 | 1836 | return data 1837 | 1838 | def cache_data(self, name, data): 1839 | """Save ``data`` to cache under ``name``. 1840 | 1841 | If ``data`` is ``None``, the corresponding cache file will be 1842 | deleted. 1843 | 1844 | :param name: name of datastore 1845 | :param data: data to store. This may be any object supported by 1846 | the cache serializer 1847 | 1848 | """ 1849 | serializer = manager.serializer(self.cache_serializer) 1850 | 1851 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1852 | 1853 | if data is None: 1854 | if os.path.exists(cache_path): 1855 | os.unlink(cache_path) 1856 | self.logger.debug('Deleted cache file : %s', cache_path) 1857 | return 1858 | 1859 | with atomic_writer(cache_path, 'wb') as file_obj: 1860 | serializer.dump(data, file_obj) 1861 | 1862 | self.logger.debug('Cached data saved at : %s', cache_path) 1863 | 1864 | def cached_data_fresh(self, name, max_age): 1865 | """Whether cache `name` is less than `max_age` seconds old. 1866 | 1867 | :param name: name of datastore 1868 | :param max_age: maximum age of data in seconds 1869 | :type max_age: ``int`` 1870 | :returns: ``True`` if data is less than ``max_age`` old, else 1871 | ``False`` 1872 | 1873 | """ 1874 | age = self.cached_data_age(name) 1875 | 1876 | if not age: 1877 | return False 1878 | 1879 | return age < max_age 1880 | 1881 | def cached_data_age(self, name): 1882 | """Return age in seconds of cache `name` or 0 if cache doesn't exist. 1883 | 1884 | :param name: name of datastore 1885 | :type name: ``unicode`` 1886 | :returns: age of datastore in seconds 1887 | :rtype: ``int`` 1888 | 1889 | """ 1890 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1891 | 1892 | if not os.path.exists(cache_path): 1893 | return 0 1894 | 1895 | return time.time() - os.stat(cache_path).st_mtime 1896 | 1897 | def filter(self, query, items, key=lambda x: x, ascending=False, 1898 | include_score=False, min_score=0, max_results=0, 1899 | match_on=MATCH_ALL, fold_diacritics=True): 1900 | """Fuzzy search filter. Returns list of ``items`` that match ``query``. 1901 | 1902 | ``query`` is case-insensitive. Any item that does not contain the 1903 | entirety of ``query`` is rejected. 1904 | 1905 | .. warning:: 1906 | 1907 | If ``query`` is an empty string or contains only whitespace, 1908 | a :class:`ValueError` will be raised. 1909 | 1910 | :param query: query to test items against 1911 | :type query: ``unicode`` 1912 | :param items: iterable of items to test 1913 | :type items: ``list`` or ``tuple`` 1914 | :param key: function to get comparison key from ``items``. 1915 | Must return a ``unicode`` string. The default simply returns 1916 | the item. 1917 | :type key: ``callable`` 1918 | :param ascending: set to ``True`` to get worst matches first 1919 | :type ascending: ``Boolean`` 1920 | :param include_score: Useful for debugging the scoring algorithm. 1921 | If ``True``, results will be a list of tuples 1922 | ``(item, score, rule)``. 1923 | :type include_score: ``Boolean`` 1924 | :param min_score: If non-zero, ignore results with a score lower 1925 | than this. 1926 | :type min_score: ``int`` 1927 | :param max_results: If non-zero, prune results list to this length. 1928 | :type max_results: ``int`` 1929 | :param match_on: Filter option flags. Bitwise-combined list of 1930 | ``MATCH_*`` constants (see below). 1931 | :type match_on: ``int`` 1932 | :param fold_diacritics: Convert search keys to ASCII-only 1933 | characters if ``query`` only contains ASCII characters. 1934 | :type fold_diacritics: ``Boolean`` 1935 | :returns: list of ``items`` matching ``query`` or list of 1936 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. 1937 | ``rule`` is the ``MATCH_*`` rule that matched the item. 1938 | :rtype: ``list`` 1939 | 1940 | **Matching rules** 1941 | 1942 | By default, :meth:`filter` uses all of the following flags (i.e. 1943 | :const:`MATCH_ALL`). The tests are always run in the given order: 1944 | 1945 | 1. :const:`MATCH_STARTSWITH` : Item search key startswith 1946 | ``query``(case-insensitive). 1947 | 2. :const:`MATCH_CAPITALS` : The list of capital letters in item 1948 | search key starts with ``query`` (``query`` may be 1949 | lower-case). E.g., ``of`` would match ``OmniFocus``, 1950 | ``gc`` would match ``Google Chrome``. 1951 | 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on 1952 | non-word characters (.,-,' etc.). Matches if ``query`` is 1953 | one of these atoms (case-insensitive). 1954 | 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first 1955 | characters of the above-described "atoms" (case-insensitive). 1956 | 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of 1957 | the above-described initials. 1958 | 6. :const:`MATCH_INITIALS` : Combination of (4) and (5). 1959 | 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring 1960 | of item search key (case-insensitive). 1961 | 8. :const:`MATCH_ALLCHARS` : Matches if all characters in 1962 | ``query`` appear in item search key in the same order 1963 | (case-insensitive). 1964 | 9. :const:`MATCH_ALL` : Combination of all the above. 1965 | 1966 | 1967 | :const:`MATCH_ALLCHARS` is considerably slower than the other 1968 | tests and provides much less accurate results. 1969 | 1970 | **Examples:** 1971 | 1972 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst 1973 | matches and is expensive to run), use 1974 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. 1975 | 1976 | To match only on capitals, use ``match_on=MATCH_CAPITALS``. 1977 | 1978 | To match only on startswith and substring, use 1979 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. 1980 | 1981 | **Diacritic folding** 1982 | 1983 | .. versionadded:: 1.3 1984 | 1985 | If ``fold_diacritics`` is ``True`` (the default), and ``query`` 1986 | contains only ASCII characters, non-ASCII characters in search keys 1987 | will be converted to ASCII equivalents (e.g. **ü** -> **u**, 1988 | **ß** -> **ss**, **é** -> **e**). 1989 | 1990 | See :const:`ASCII_REPLACEMENTS` for all replacements. 1991 | 1992 | If ``query`` contains non-ASCII characters, search keys will not be 1993 | altered. 1994 | 1995 | """ 1996 | if not query: 1997 | raise ValueError('Empty `query`') 1998 | 1999 | # Remove preceding/trailing spaces 2000 | query = query.strip() 2001 | 2002 | if not query: 2003 | raise ValueError('`query` contains only whitespace') 2004 | 2005 | # Use user override if there is one 2006 | fold_diacritics = self.settings.get('__workflow_diacritic_folding', 2007 | fold_diacritics) 2008 | 2009 | results = [] 2010 | 2011 | for item in items: 2012 | skip = False 2013 | score = 0 2014 | words = [s.strip() for s in query.split(' ')] 2015 | value = key(item).strip() 2016 | if value == '': 2017 | continue 2018 | for word in words: 2019 | if word == '': 2020 | continue 2021 | s, rule = self._filter_item(value, word, match_on, 2022 | fold_diacritics) 2023 | 2024 | if not s: # Skip items that don't match part of the query 2025 | skip = True 2026 | score += s 2027 | 2028 | if skip: 2029 | continue 2030 | 2031 | if score: 2032 | # use "reversed" `score` (i.e. highest becomes lowest) and 2033 | # `value` as sort key. This means items with the same score 2034 | # will be sorted in alphabetical not reverse alphabetical order 2035 | results.append(((100.0 / score, value.lower(), score), 2036 | (item, score, rule))) 2037 | 2038 | # sort on keys, then discard the keys 2039 | results.sort(reverse=ascending) 2040 | results = [t[1] for t in results] 2041 | 2042 | if min_score: 2043 | results = [r for r in results if r[1] > min_score] 2044 | 2045 | if max_results and len(results) > max_results: 2046 | results = results[:max_results] 2047 | 2048 | # return list of ``(item, score, rule)`` 2049 | if include_score: 2050 | return results 2051 | # just return list of items 2052 | return [t[0] for t in results] 2053 | 2054 | def _filter_item(self, value, query, match_on, fold_diacritics): 2055 | """Filter ``value`` against ``query`` using rules ``match_on``. 2056 | 2057 | :returns: ``(score, rule)`` 2058 | 2059 | """ 2060 | query = query.lower() 2061 | 2062 | if not isascii(query): 2063 | fold_diacritics = False 2064 | 2065 | if fold_diacritics: 2066 | value = self.fold_to_ascii(value) 2067 | 2068 | # pre-filter any items that do not contain all characters 2069 | # of ``query`` to save on running several more expensive tests 2070 | if not set(query) <= set(value.lower()): 2071 | 2072 | return (0, None) 2073 | 2074 | # item starts with query 2075 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query): 2076 | score = 100.0 - (len(value) / len(query)) 2077 | 2078 | return (score, MATCH_STARTSWITH) 2079 | 2080 | # query matches capitalised letters in item, 2081 | # e.g. of = OmniFocus 2082 | if match_on & MATCH_CAPITALS: 2083 | initials = ''.join([c for c in value if c in INITIALS]) 2084 | if initials.lower().startswith(query): 2085 | score = 100.0 - (len(initials) / len(query)) 2086 | 2087 | return (score, MATCH_CAPITALS) 2088 | 2089 | # split the item into "atoms", i.e. words separated by 2090 | # spaces or other non-word characters 2091 | if (match_on & MATCH_ATOM or 2092 | match_on & MATCH_INITIALS_CONTAIN or 2093 | match_on & MATCH_INITIALS_STARTSWITH): 2094 | atoms = [s.lower() for s in split_on_delimiters(value)] 2095 | # print('atoms : %s --> %s' % (value, atoms)) 2096 | # initials of the atoms 2097 | initials = ''.join([s[0] for s in atoms if s]) 2098 | 2099 | if match_on & MATCH_ATOM: 2100 | # is `query` one of the atoms in item? 2101 | # similar to substring, but scores more highly, as it's 2102 | # a word within the item 2103 | if query in atoms: 2104 | score = 100.0 - (len(value) / len(query)) 2105 | 2106 | return (score, MATCH_ATOM) 2107 | 2108 | # `query` matches start (or all) of the initials of the 2109 | # atoms, e.g. ``himym`` matches "How I Met Your Mother" 2110 | # *and* "how i met your mother" (the ``capitals`` rule only 2111 | # matches the former) 2112 | if (match_on & MATCH_INITIALS_STARTSWITH and 2113 | initials.startswith(query)): 2114 | score = 100.0 - (len(initials) / len(query)) 2115 | 2116 | return (score, MATCH_INITIALS_STARTSWITH) 2117 | 2118 | # `query` is a substring of initials, e.g. ``doh`` matches 2119 | # "The Dukes of Hazzard" 2120 | elif (match_on & MATCH_INITIALS_CONTAIN and 2121 | query in initials): 2122 | score = 95.0 - (len(initials) / len(query)) 2123 | 2124 | return (score, MATCH_INITIALS_CONTAIN) 2125 | 2126 | # `query` is a substring of item 2127 | if match_on & MATCH_SUBSTRING and query in value.lower(): 2128 | score = 90.0 - (len(value) / len(query)) 2129 | 2130 | return (score, MATCH_SUBSTRING) 2131 | 2132 | # finally, assign a score based on how close together the 2133 | # characters in `query` are in item. 2134 | if match_on & MATCH_ALLCHARS: 2135 | search = self._search_for_query(query) 2136 | match = search(value) 2137 | if match: 2138 | score = 100.0 / ((1 + match.start()) * 2139 | (match.end() - match.start() + 1)) 2140 | 2141 | return (score, MATCH_ALLCHARS) 2142 | 2143 | # Nothing matched 2144 | return (0, None) 2145 | 2146 | def _search_for_query(self, query): 2147 | if query in self._search_pattern_cache: 2148 | return self._search_pattern_cache[query] 2149 | 2150 | # Build pattern: include all characters 2151 | pattern = [] 2152 | for c in query: 2153 | # pattern.append('[^{0}]*{0}'.format(re.escape(c))) 2154 | pattern.append('.*?{0}'.format(re.escape(c))) 2155 | pattern = ''.join(pattern) 2156 | search = re.compile(pattern, re.IGNORECASE).search 2157 | 2158 | self._search_pattern_cache[query] = search 2159 | return search 2160 | 2161 | def run(self, func, text_errors=False): 2162 | """Call ``func`` to run your workflow. 2163 | 2164 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` 2165 | instance) as first argument. 2166 | :param text_errors: Emit error messages in plain text, not in 2167 | Alfred's XML/JSON feedback format. Use this when you're not 2168 | running Alfred-Workflow in a Script Filter and would like 2169 | to pass the error message to, say, a notification. 2170 | :type text_errors: ``Boolean`` 2171 | 2172 | ``func`` will be called with :class:`Workflow` instance as first 2173 | argument. 2174 | 2175 | ``func`` should be the main entry point to your workflow. 2176 | 2177 | Any exceptions raised will be logged and an error message will be 2178 | output to Alfred. 2179 | 2180 | """ 2181 | start = time.time() 2182 | 2183 | # Call workflow's entry function/method within a try-except block 2184 | # to catch any errors and display an error message in Alfred 2185 | try: 2186 | 2187 | if self.version: 2188 | self.logger.debug( 2189 | 'Workflow version : {0}'.format(self.version)) 2190 | 2191 | # Run update check if configured for self-updates. 2192 | # This call has to go in the `run` try-except block, as it will 2193 | # initialise `self.settings`, which will raise an exception 2194 | # if `settings.json` isn't valid. 2195 | 2196 | if self._update_settings: 2197 | self.check_update() 2198 | 2199 | # Run workflow's entry function/method 2200 | func(self) 2201 | 2202 | # Set last version run to current version after a successful 2203 | # run 2204 | self.set_last_version() 2205 | 2206 | except Exception as err: 2207 | self.logger.exception(err) 2208 | if self.help_url: 2209 | self.logger.info( 2210 | 'For assistance, see: {0}'.format(self.help_url)) 2211 | 2212 | if not sys.stdout.isatty(): # Show error in Alfred 2213 | if text_errors: 2214 | print(unicode(err).encode('utf-8'), end='') 2215 | else: 2216 | self._items = [] 2217 | if self._name: 2218 | name = self._name 2219 | elif self._bundleid: 2220 | name = self._bundleid 2221 | else: # pragma: no cover 2222 | name = os.path.dirname(__file__) 2223 | self.add_item("Error in workflow '%s'" % name, 2224 | unicode(err), 2225 | icon=ICON_ERROR) 2226 | self.send_feedback() 2227 | return 1 2228 | 2229 | finally: 2230 | self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format( 2231 | time.time() - start)) 2232 | 2233 | return 0 2234 | 2235 | # Alfred feedback methods ------------------------------------------ 2236 | 2237 | def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, 2238 | autocomplete=None, valid=False, uid=None, icon=None, 2239 | icontype=None, type=None, largetext=None, copytext=None, 2240 | quicklookurl=None): 2241 | """Add an item to be output to Alfred. 2242 | 2243 | :param title: Title shown in Alfred 2244 | :type title: ``unicode`` 2245 | :param subtitle: Subtitle shown in Alfred 2246 | :type subtitle: ``unicode`` 2247 | :param modifier_subtitles: Subtitles shown when modifier 2248 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase 2249 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` 2250 | :type modifier_subtitles: ``dict`` 2251 | :param arg: Argument passed by Alfred as ``{query}`` when item is 2252 | actioned 2253 | :type arg: ``unicode`` 2254 | :param autocomplete: Text expanded in Alfred when item is TABbed 2255 | :type autocomplete: ``unicode`` 2256 | :param valid: Whether or not item can be actioned 2257 | :type valid: ``Boolean`` 2258 | :param uid: Used by Alfred to remember/sort items 2259 | :type uid: ``unicode`` 2260 | :param icon: Filename of icon to use 2261 | :type icon: ``unicode`` 2262 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` 2263 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype 2264 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to 2265 | use the icon of the file specified as ``icon``, e.g. 2266 | ``icon='/Applications/Safari.app', icontype='fileicon'``. 2267 | Leave as `None` if ``icon`` points to an actual 2268 | icon file. 2269 | :type icontype: ``unicode`` 2270 | :param type: Result type. Currently only ``'file'`` is supported 2271 | (by Alfred). This will tell Alfred to enable file actions for 2272 | this item. 2273 | :type type: ``unicode`` 2274 | :param largetext: Text to be displayed in Alfred's large text box 2275 | if user presses CMD+L on item. 2276 | :type largetext: ``unicode`` 2277 | :param copytext: Text to be copied to pasteboard if user presses 2278 | CMD+C on item. 2279 | :type copytext: ``unicode`` 2280 | :param quicklookurl: URL to be displayed using Alfred's Quick Look 2281 | feature (tapping ``SHIFT`` or ``⌘+Y`` on a result). 2282 | :type quicklookurl: ``unicode`` 2283 | :returns: :class:`Item` instance 2284 | 2285 | See the :ref:`script-filter-results` section of the documentation 2286 | for a detailed description of what the various parameters do and how 2287 | they interact with one another. 2288 | 2289 | See :ref:`icons` for a list of the supported system icons. 2290 | 2291 | .. note:: 2292 | 2293 | Although this method returns an :class:`Item` instance, you don't 2294 | need to hold onto it or worry about it. All generated :class:`Item` 2295 | instances are also collected internally and sent to Alfred when 2296 | :meth:`send_feedback` is called. 2297 | 2298 | The generated :class:`Item` is only returned in case you want to 2299 | edit it or do something with it other than send it to Alfred. 2300 | 2301 | """ 2302 | item = self.item_class(title, subtitle, modifier_subtitles, arg, 2303 | autocomplete, valid, uid, icon, icontype, type, 2304 | largetext, copytext, quicklookurl) 2305 | self._items.append(item) 2306 | return item 2307 | 2308 | def send_feedback(self): 2309 | """Print stored items to console/Alfred as XML.""" 2310 | root = ET.Element('items') 2311 | for item in self._items: 2312 | root.append(item.elem) 2313 | sys.stdout.write('\n') 2314 | sys.stdout.write(ET.tostring(root).encode('utf-8')) 2315 | sys.stdout.flush() 2316 | 2317 | #################################################################### 2318 | # Updating methods 2319 | #################################################################### 2320 | 2321 | @property 2322 | def first_run(self): 2323 | """Return ``True`` if it's the first time this version has run. 2324 | 2325 | .. versionadded:: 1.9.10 2326 | 2327 | Raises a :class:`ValueError` if :attr:`version` isn't set. 2328 | 2329 | """ 2330 | if not self.version: 2331 | raise ValueError('No workflow version set') 2332 | 2333 | if not self.last_version_run: 2334 | return True 2335 | 2336 | return self.version != self.last_version_run 2337 | 2338 | @property 2339 | def last_version_run(self): 2340 | """Return version of last version to run (or ``None``). 2341 | 2342 | .. versionadded:: 1.9.10 2343 | 2344 | :returns: :class:`~workflow.update.Version` instance 2345 | or ``None`` 2346 | 2347 | """ 2348 | if self._last_version_run is UNSET: 2349 | 2350 | version = self.settings.get('__workflow_last_version') 2351 | if version: 2352 | from update import Version 2353 | version = Version(version) 2354 | 2355 | self._last_version_run = version 2356 | 2357 | self.logger.debug('Last run version : {0}'.format( 2358 | self._last_version_run)) 2359 | 2360 | return self._last_version_run 2361 | 2362 | def set_last_version(self, version=None): 2363 | """Set :attr:`last_version_run` to current version. 2364 | 2365 | .. versionadded:: 1.9.10 2366 | 2367 | :param version: version to store (default is current version) 2368 | :type version: :class:`~workflow.update.Version` instance 2369 | or ``unicode`` 2370 | :returns: ``True`` if version is saved, else ``False`` 2371 | 2372 | """ 2373 | if not version: 2374 | if not self.version: 2375 | self.logger.warning( 2376 | "Can't save last version: workflow has no version") 2377 | return False 2378 | 2379 | version = self.version 2380 | 2381 | if isinstance(version, basestring): 2382 | from update import Version 2383 | version = Version(version) 2384 | 2385 | self.settings['__workflow_last_version'] = str(version) 2386 | 2387 | self.logger.debug('Set last run version : {0}'.format(version)) 2388 | 2389 | return True 2390 | 2391 | @property 2392 | def update_available(self): 2393 | """Whether an update is available. 2394 | 2395 | .. versionadded:: 1.9 2396 | 2397 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2398 | information on how to enable your workflow to update itself. 2399 | 2400 | :returns: ``True`` if an update is available, else ``False`` 2401 | 2402 | """ 2403 | update_data = self.cached_data('__workflow_update_status', max_age=0) 2404 | self.logger.debug('update_data : {0}'.format(update_data)) 2405 | 2406 | if not update_data or not update_data.get('available'): 2407 | return False 2408 | 2409 | return update_data['available'] 2410 | 2411 | @property 2412 | def prereleases(self): 2413 | """Whether workflow should update to pre-release versions. 2414 | 2415 | .. versionadded:: 1.16 2416 | 2417 | :returns: ``True`` if pre-releases are enabled with the :ref:`magic 2418 | argument ` or the ``update_settings`` dict, else 2419 | ``False``. 2420 | 2421 | """ 2422 | if self._update_settings.get('prereleases'): 2423 | return True 2424 | 2425 | return self.settings.get('__workflow_prereleases') or False 2426 | 2427 | def check_update(self, force=False): 2428 | """Call update script if it's time to check for a new release. 2429 | 2430 | .. versionadded:: 1.9 2431 | 2432 | The update script will be run in the background, so it won't 2433 | interfere in the execution of your workflow. 2434 | 2435 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2436 | information on how to enable your workflow to update itself. 2437 | 2438 | :param force: Force update check 2439 | :type force: ``Boolean`` 2440 | 2441 | """ 2442 | frequency = self._update_settings.get('frequency', 2443 | DEFAULT_UPDATE_FREQUENCY) 2444 | 2445 | if not force and not self.settings.get('__workflow_autoupdate', True): 2446 | self.logger.debug('Auto update turned off by user') 2447 | return 2448 | 2449 | # Check for new version if it's time 2450 | if (force or not self.cached_data_fresh( 2451 | '__workflow_update_status', frequency * 86400)): 2452 | 2453 | github_slug = self._update_settings['github_slug'] 2454 | # version = self._update_settings['version'] 2455 | version = str(self.version) 2456 | 2457 | from background import run_in_background 2458 | 2459 | # update.py is adjacent to this file 2460 | update_script = os.path.join(os.path.dirname(__file__), 2461 | b'update.py') 2462 | 2463 | cmd = ['/usr/bin/python', update_script, 'check', github_slug, 2464 | version] 2465 | 2466 | if self.prereleases: 2467 | cmd.append('--prereleases') 2468 | 2469 | self.logger.info('Checking for update ...') 2470 | 2471 | run_in_background('__workflow_update_check', cmd) 2472 | 2473 | else: 2474 | self.logger.debug('Update check not due') 2475 | 2476 | def start_update(self): 2477 | """Check for update and download and install new workflow file. 2478 | 2479 | .. versionadded:: 1.9 2480 | 2481 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2482 | information on how to enable your workflow to update itself. 2483 | 2484 | :returns: ``True`` if an update is available and will be 2485 | installed, else ``False`` 2486 | 2487 | """ 2488 | import update 2489 | 2490 | github_slug = self._update_settings['github_slug'] 2491 | # version = self._update_settings['version'] 2492 | version = str(self.version) 2493 | 2494 | if not update.check_update(github_slug, version, self.prereleases): 2495 | return False 2496 | 2497 | from background import run_in_background 2498 | 2499 | # update.py is adjacent to this file 2500 | update_script = os.path.join(os.path.dirname(__file__), 2501 | b'update.py') 2502 | 2503 | cmd = ['/usr/bin/python', update_script, 'install', github_slug, 2504 | version] 2505 | 2506 | if self.prereleases: 2507 | cmd.append('--prereleases') 2508 | 2509 | self.logger.debug('Downloading update ...') 2510 | run_in_background('__workflow_update_install', cmd) 2511 | 2512 | return True 2513 | 2514 | #################################################################### 2515 | # Keychain password storage methods 2516 | #################################################################### 2517 | 2518 | def save_password(self, account, password, service=None): 2519 | """Save account credentials. 2520 | 2521 | If the account exists, the old password will first be deleted 2522 | (Keychain throws an error otherwise). 2523 | 2524 | If something goes wrong, a :class:`KeychainError` exception will 2525 | be raised. 2526 | 2527 | :param account: name of the account the password is for, e.g. 2528 | "Pinboard" 2529 | :type account: ``unicode`` 2530 | :param password: the password to secure 2531 | :type password: ``unicode`` 2532 | :param service: Name of the service. By default, this is the 2533 | workflow's bundle ID 2534 | :type service: ``unicode`` 2535 | 2536 | """ 2537 | if not service: 2538 | service = self.bundleid 2539 | 2540 | try: 2541 | self._call_security('add-generic-password', service, account, 2542 | '-w', password) 2543 | self.logger.debug('Saved password : %s:%s', service, account) 2544 | 2545 | except PasswordExists: 2546 | self.logger.debug('Password exists : %s:%s', service, account) 2547 | current_password = self.get_password(account, service) 2548 | 2549 | if current_password == password: 2550 | self.logger.debug('Password unchanged') 2551 | 2552 | else: 2553 | self.delete_password(account, service) 2554 | self._call_security('add-generic-password', service, 2555 | account, '-w', password) 2556 | self.logger.debug('save_password : %s:%s', service, account) 2557 | 2558 | def get_password(self, account, service=None): 2559 | """Retrieve the password saved at ``service/account``. 2560 | 2561 | Raise :class:`PasswordNotFound` exception if password doesn't exist. 2562 | 2563 | :param account: name of the account the password is for, e.g. 2564 | "Pinboard" 2565 | :type account: ``unicode`` 2566 | :param service: Name of the service. By default, this is the workflow's 2567 | bundle ID 2568 | :type service: ``unicode`` 2569 | :returns: account password 2570 | :rtype: ``unicode`` 2571 | 2572 | """ 2573 | if not service: 2574 | service = self.bundleid 2575 | 2576 | output = self._call_security('find-generic-password', service, 2577 | account, '-g') 2578 | 2579 | # Parsing of `security` output is adapted from python-keyring 2580 | # by Jason R. Coombs 2581 | # https://pypi.python.org/pypi/keyring 2582 | m = re.search( 2583 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', 2584 | output) 2585 | 2586 | if m: 2587 | groups = m.groupdict() 2588 | h = groups.get('hex') 2589 | password = groups.get('pw') 2590 | if h: 2591 | password = unicode(binascii.unhexlify(h), 'utf-8') 2592 | 2593 | self.logger.debug('Got password : %s:%s', service, account) 2594 | 2595 | return password 2596 | 2597 | def delete_password(self, account, service=None): 2598 | """Delete the password stored at ``service/account``. 2599 | 2600 | Raise :class:`PasswordNotFound` if account is unknown. 2601 | 2602 | :param account: name of the account the password is for, e.g. 2603 | "Pinboard" 2604 | :type account: ``unicode`` 2605 | :param service: Name of the service. By default, this is the workflow's 2606 | bundle ID 2607 | :type service: ``unicode`` 2608 | 2609 | """ 2610 | if not service: 2611 | service = self.bundleid 2612 | 2613 | self._call_security('delete-generic-password', service, account) 2614 | 2615 | self.logger.debug('Deleted password : %s:%s', service, account) 2616 | 2617 | #################################################################### 2618 | # Methods for workflow:* magic args 2619 | #################################################################### 2620 | 2621 | def _register_default_magic(self): 2622 | """Register the built-in magic arguments.""" 2623 | # TODO: refactor & simplify 2624 | # Wrap callback and message with callable 2625 | def callback(func, msg): 2626 | def wrapper(): 2627 | func() 2628 | return msg 2629 | 2630 | return wrapper 2631 | 2632 | self.magic_arguments['delcache'] = callback(self.clear_cache, 2633 | 'Deleted workflow cache') 2634 | self.magic_arguments['deldata'] = callback(self.clear_data, 2635 | 'Deleted workflow data') 2636 | self.magic_arguments['delsettings'] = callback( 2637 | self.clear_settings, 'Deleted workflow settings') 2638 | self.magic_arguments['reset'] = callback(self.reset, 2639 | 'Reset workflow') 2640 | self.magic_arguments['openlog'] = callback(self.open_log, 2641 | 'Opening workflow log file') 2642 | self.magic_arguments['opencache'] = callback( 2643 | self.open_cachedir, 'Opening workflow cache directory') 2644 | self.magic_arguments['opendata'] = callback( 2645 | self.open_datadir, 'Opening workflow data directory') 2646 | self.magic_arguments['openworkflow'] = callback( 2647 | self.open_workflowdir, 'Opening workflow directory') 2648 | self.magic_arguments['openterm'] = callback( 2649 | self.open_terminal, 'Opening workflow root directory in Terminal') 2650 | 2651 | # Diacritic folding 2652 | def fold_on(): 2653 | self.settings['__workflow_diacritic_folding'] = True 2654 | return 'Diacritics will always be folded' 2655 | 2656 | def fold_off(): 2657 | self.settings['__workflow_diacritic_folding'] = False 2658 | return 'Diacritics will never be folded' 2659 | 2660 | def fold_default(): 2661 | if '__workflow_diacritic_folding' in self.settings: 2662 | del self.settings['__workflow_diacritic_folding'] 2663 | return 'Diacritics folding reset' 2664 | 2665 | self.magic_arguments['foldingon'] = fold_on 2666 | self.magic_arguments['foldingoff'] = fold_off 2667 | self.magic_arguments['foldingdefault'] = fold_default 2668 | 2669 | # Updates 2670 | def update_on(): 2671 | self.settings['__workflow_autoupdate'] = True 2672 | return 'Auto update turned on' 2673 | 2674 | def update_off(): 2675 | self.settings['__workflow_autoupdate'] = False 2676 | return 'Auto update turned off' 2677 | 2678 | def prereleases_on(): 2679 | self.settings['__workflow_prereleases'] = True 2680 | return 'Prerelease updates turned on' 2681 | 2682 | def prereleases_off(): 2683 | self.settings['__workflow_prereleases'] = False 2684 | return 'Prerelease updates turned off' 2685 | 2686 | def do_update(): 2687 | if self.start_update(): 2688 | return 'Downloading and installing update ...' 2689 | else: 2690 | return 'No update available' 2691 | 2692 | self.magic_arguments['autoupdate'] = update_on 2693 | self.magic_arguments['noautoupdate'] = update_off 2694 | self.magic_arguments['prereleases'] = prereleases_on 2695 | self.magic_arguments['noprereleases'] = prereleases_off 2696 | self.magic_arguments['update'] = do_update 2697 | 2698 | # Help 2699 | def do_help(): 2700 | if self.help_url: 2701 | self.open_help() 2702 | return 'Opening workflow help URL in browser' 2703 | else: 2704 | return 'Workflow has no help URL' 2705 | 2706 | def show_version(): 2707 | if self.version: 2708 | return 'Version: {0}'.format(self.version) 2709 | else: 2710 | return 'This workflow has no version number' 2711 | 2712 | def list_magic(): 2713 | """Display all available magic args in Alfred.""" 2714 | isatty = sys.stderr.isatty() 2715 | for name in sorted(self.magic_arguments.keys()): 2716 | if name == 'magic': 2717 | continue 2718 | arg = '{0}{1}'.format(self.magic_prefix, name) 2719 | self.logger.debug(arg) 2720 | 2721 | if not isatty: 2722 | self.add_item(arg, icon=ICON_INFO) 2723 | 2724 | if not isatty: 2725 | self.send_feedback() 2726 | 2727 | self.magic_arguments['help'] = do_help 2728 | self.magic_arguments['magic'] = list_magic 2729 | self.magic_arguments['version'] = show_version 2730 | 2731 | def clear_cache(self, filter_func=lambda f: True): 2732 | """Delete all files in workflow's :attr:`cachedir`. 2733 | 2734 | :param filter_func: Callable to determine whether a file should be 2735 | deleted or not. ``filter_func`` is called with the filename 2736 | of each file in the data directory. If it returns ``True``, 2737 | the file will be deleted. 2738 | By default, *all* files will be deleted. 2739 | :type filter_func: ``callable`` 2740 | """ 2741 | self._delete_directory_contents(self.cachedir, filter_func) 2742 | 2743 | def clear_data(self, filter_func=lambda f: True): 2744 | """Delete all files in workflow's :attr:`datadir`. 2745 | 2746 | :param filter_func: Callable to determine whether a file should be 2747 | deleted or not. ``filter_func`` is called with the filename 2748 | of each file in the data directory. If it returns ``True``, 2749 | the file will be deleted. 2750 | By default, *all* files will be deleted. 2751 | :type filter_func: ``callable`` 2752 | """ 2753 | self._delete_directory_contents(self.datadir, filter_func) 2754 | 2755 | def clear_settings(self): 2756 | """Delete workflow's :attr:`settings_path`.""" 2757 | if os.path.exists(self.settings_path): 2758 | os.unlink(self.settings_path) 2759 | self.logger.debug('Deleted : %r', self.settings_path) 2760 | 2761 | def reset(self): 2762 | """Delete workflow settings, cache and data. 2763 | 2764 | File :attr:`settings ` and directories 2765 | :attr:`cache ` and :attr:`data ` are deleted. 2766 | 2767 | """ 2768 | self.clear_cache() 2769 | self.clear_data() 2770 | self.clear_settings() 2771 | 2772 | def open_log(self): 2773 | """Open :attr:`logfile` in default app (usually Console.app).""" 2774 | subprocess.call(['open', self.logfile]) 2775 | 2776 | def open_cachedir(self): 2777 | """Open the workflow's :attr:`cachedir` in Finder.""" 2778 | subprocess.call(['open', self.cachedir]) 2779 | 2780 | def open_datadir(self): 2781 | """Open the workflow's :attr:`datadir` in Finder.""" 2782 | subprocess.call(['open', self.datadir]) 2783 | 2784 | def open_workflowdir(self): 2785 | """Open the workflow's :attr:`workflowdir` in Finder.""" 2786 | subprocess.call(['open', self.workflowdir]) 2787 | 2788 | def open_terminal(self): 2789 | """Open a Terminal window at workflow's :attr:`workflowdir`.""" 2790 | subprocess.call(['open', '-a', 'Terminal', 2791 | self.workflowdir]) 2792 | 2793 | def open_help(self): 2794 | """Open :attr:`help_url` in default browser.""" 2795 | subprocess.call(['open', self.help_url]) 2796 | 2797 | return 'Opening workflow help URL in browser' 2798 | 2799 | #################################################################### 2800 | # Helper methods 2801 | #################################################################### 2802 | 2803 | def decode(self, text, encoding=None, normalization=None): 2804 | """Return ``text`` as normalised unicode. 2805 | 2806 | If ``encoding`` and/or ``normalization`` is ``None``, the 2807 | ``input_encoding``and ``normalization`` parameters passed to 2808 | :class:`Workflow` are used. 2809 | 2810 | :param text: string 2811 | :type text: encoded or Unicode string. If ``text`` is already a 2812 | Unicode string, it will only be normalised. 2813 | :param encoding: The text encoding to use to decode ``text`` to 2814 | Unicode. 2815 | :type encoding: ``unicode`` or ``None`` 2816 | :param normalization: The nomalisation form to apply to ``text``. 2817 | :type normalization: ``unicode`` or ``None`` 2818 | :returns: decoded and normalised ``unicode`` 2819 | 2820 | :class:`Workflow` uses "NFC" normalisation by default. This is the 2821 | standard for Python and will work well with data from the web (via 2822 | :mod:`~workflow.web` or :mod:`json`). 2823 | 2824 | OS X, on the other hand, uses "NFD" normalisation (nearly), so data 2825 | coming from the system (e.g. via :mod:`subprocess` or 2826 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either 2827 | normalise this data, too, or change the default normalisation used by 2828 | :class:`Workflow`. 2829 | 2830 | """ 2831 | encoding = encoding or self._input_encoding 2832 | normalization = normalization or self._normalizsation 2833 | if not isinstance(text, unicode): 2834 | text = unicode(text, encoding) 2835 | return unicodedata.normalize(normalization, text) 2836 | 2837 | def fold_to_ascii(self, text): 2838 | """Convert non-ASCII characters to closest ASCII equivalent. 2839 | 2840 | .. versionadded:: 1.3 2841 | 2842 | .. note:: This only works for a subset of European languages. 2843 | 2844 | :param text: text to convert 2845 | :type text: ``unicode`` 2846 | :returns: text containing only ASCII characters 2847 | :rtype: ``unicode`` 2848 | 2849 | """ 2850 | if isascii(text): 2851 | return text 2852 | text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text]) 2853 | return unicode(unicodedata.normalize('NFKD', 2854 | text).encode('ascii', 'ignore')) 2855 | 2856 | def dumbify_punctuation(self, text): 2857 | """Convert non-ASCII punctuation to closest ASCII equivalent. 2858 | 2859 | This method replaces "smart" quotes and n- or m-dashes with their 2860 | workaday ASCII equivalents. This method is currently not used 2861 | internally, but exists as a helper method for workflow authors. 2862 | 2863 | .. versionadded: 1.9.7 2864 | 2865 | :param text: text to convert 2866 | :type text: ``unicode`` 2867 | :returns: text with only ASCII punctuation 2868 | :rtype: ``unicode`` 2869 | 2870 | """ 2871 | if isascii(text): 2872 | return text 2873 | 2874 | text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text]) 2875 | return text 2876 | 2877 | def _delete_directory_contents(self, dirpath, filter_func): 2878 | """Delete all files in a directory. 2879 | 2880 | :param dirpath: path to directory to clear 2881 | :type dirpath: ``unicode`` or ``str`` 2882 | :param filter_func function to determine whether a file shall be 2883 | deleted or not. 2884 | :type filter_func ``callable`` 2885 | 2886 | """ 2887 | if os.path.exists(dirpath): 2888 | for filename in os.listdir(dirpath): 2889 | if not filter_func(filename): 2890 | continue 2891 | path = os.path.join(dirpath, filename) 2892 | if os.path.isdir(path): 2893 | shutil.rmtree(path) 2894 | else: 2895 | os.unlink(path) 2896 | self.logger.debug('Deleted : %r', path) 2897 | 2898 | def _load_info_plist(self): 2899 | """Load workflow info from ``info.plist``.""" 2900 | # info.plist should be in the directory above this one 2901 | self._info = plistlib.readPlist(self.workflowfile('info.plist')) 2902 | self._info_loaded = True 2903 | 2904 | def _create(self, dirpath): 2905 | """Create directory `dirpath` if it doesn't exist. 2906 | 2907 | :param dirpath: path to directory 2908 | :type dirpath: ``unicode`` 2909 | :returns: ``dirpath`` argument 2910 | :rtype: ``unicode`` 2911 | 2912 | """ 2913 | if not os.path.exists(dirpath): 2914 | os.makedirs(dirpath) 2915 | return dirpath 2916 | 2917 | def _call_security(self, action, service, account, *args): 2918 | """Call ``security`` CLI program that provides access to keychains. 2919 | 2920 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` 2921 | exceptions (the first two are subclasses of `KeychainError`). 2922 | 2923 | :param action: The ``security`` action to call, e.g. 2924 | ``add-generic-password`` 2925 | :type action: ``unicode`` 2926 | :param service: Name of the service. 2927 | :type service: ``unicode`` 2928 | :param account: name of the account the password is for, e.g. 2929 | "Pinboard" 2930 | :type account: ``unicode`` 2931 | :param password: the password to secure 2932 | :type password: ``unicode`` 2933 | :param *args: list of command line arguments to be passed to 2934 | ``security`` 2935 | :type *args: `list` or `tuple` 2936 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a 2937 | ``unicode`` string. 2938 | :rtype: `tuple` (`int`, ``unicode``) 2939 | 2940 | """ 2941 | cmd = ['security', action, '-s', service, '-a', account] + list(args) 2942 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, 2943 | stderr=subprocess.STDOUT) 2944 | stdout, _ = p.communicate() 2945 | if p.returncode == 44: # password does not exist 2946 | raise PasswordNotFound() 2947 | elif p.returncode == 45: # password already exists 2948 | raise PasswordExists() 2949 | elif p.returncode > 0: 2950 | err = KeychainError('Unknown Keychain error : %s' % stdout) 2951 | err.retcode = p.returncode 2952 | raise err 2953 | return stdout.strip().decode('utf-8') 2954 | -------------------------------------------------------------------------------- /workflow/workflow.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/workflow.pyc -------------------------------------------------------------------------------- /workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """ 11 | :class:`Workflow3` supports Alfred 3's new features. 12 | 13 | It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`. 14 | 15 | It supports setting :ref:`workflow-variables` and 16 | :class:`the more advanced modifiers ` supported by Alfred 3. 17 | 18 | In order for the feedback mechanism to work correctly, it's important 19 | to create :class:`Item3` and :class:`Modifier` objects via the 20 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 21 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 22 | objects directly, the current :class:`~workflow.workflow3.Workflow3` 23 | object won't be aware of them, and they won't be sent to Alfred when 24 | you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`. 25 | """ 26 | 27 | from __future__ import print_function, unicode_literals, absolute_import 28 | 29 | import json 30 | import os 31 | import sys 32 | 33 | from .workflow import Workflow 34 | 35 | 36 | class Modifier(object): 37 | """Modify ``Item3`` values for when specified modifier keys are pressed. 38 | 39 | Valid modifiers (i.e. values for ``key``) are: 40 | 41 | * cmd 42 | * alt 43 | * shift 44 | * ctrl 45 | * fn 46 | 47 | Attributes: 48 | arg (unicode): Arg to pass to following action. 49 | key (unicode): Modifier key (see above). 50 | subtitle (unicode): Override item subtitle. 51 | valid (bool): Override item validity. 52 | variables (dict): Workflow variables set by this modifier. 53 | """ 54 | 55 | def __init__(self, key, subtitle=None, arg=None, valid=None): 56 | """Create a new :class:`Modifier`. 57 | 58 | You probably don't want to use this class directly, but rather 59 | use :meth:`Item3.add_modifier()` to add modifiers to results. 60 | 61 | Args: 62 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 63 | subtitle (unicode, optional): Override default subtitle. 64 | arg (unicode, optional): Argument to pass for this modifier. 65 | valid (bool, optional): Override item's validity. 66 | """ 67 | self.key = key 68 | self.subtitle = subtitle 69 | self.arg = arg 70 | self.valid = valid 71 | 72 | self.config = {} 73 | self.variables = {} 74 | 75 | def setvar(self, name, value): 76 | """Set a workflow variable for this Item. 77 | 78 | Args: 79 | name (unicode): Name of variable. 80 | value (unicode): Value of variable. 81 | """ 82 | self.variables[name] = value 83 | 84 | def getvar(self, name, default=None): 85 | """Return value of workflow variable for ``name`` or ``default``. 86 | 87 | Args: 88 | name (unicode): Variable name. 89 | default (None, optional): Value to return if variable is unset. 90 | 91 | Returns: 92 | unicode or ``default``: Value of variable if set or ``default``. 93 | """ 94 | return self.variables.get(name, default) 95 | 96 | @property 97 | def obj(self): 98 | """Modifier formatted for JSON serialization for Alfred 3. 99 | 100 | Returns: 101 | dict: Modifier for serializing to JSON. 102 | """ 103 | o = {} 104 | 105 | if self.subtitle is not None: 106 | o['subtitle'] = self.subtitle 107 | 108 | if self.arg is not None: 109 | o['arg'] = self.arg 110 | 111 | if self.valid is not None: 112 | o['valid'] = self.valid 113 | 114 | # Variables and config 115 | if self.variables or self.config: 116 | d = {} 117 | if self.variables: 118 | d['variables'] = self.variables 119 | 120 | if self.config: 121 | d['config'] = self.config 122 | 123 | if self.arg is not None: 124 | d['arg'] = self.arg 125 | 126 | o['arg'] = json.dumps({'alfredworkflow': d}) 127 | 128 | return o 129 | 130 | 131 | class Item3(object): 132 | """Represents a feedback item for Alfred 3. 133 | 134 | Generates Alfred-compliant JSON for a single item. 135 | 136 | You probably shouldn't use this class directly, but via 137 | :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item` 138 | for details of arguments. 139 | """ 140 | 141 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 142 | valid=False, uid=None, icon=None, icontype=None, 143 | type=None, largetext=None, copytext=None, quicklookurl=None): 144 | """Use same arguments as for :meth:`Workflow.add_item`. 145 | 146 | Argument ``subtitle_modifiers`` is not supported. 147 | """ 148 | self.title = title 149 | self.subtitle = subtitle 150 | self.arg = arg 151 | self.autocomplete = autocomplete 152 | self.valid = valid 153 | self.uid = uid 154 | self.icon = icon 155 | self.icontype = icontype 156 | self.type = type 157 | self.quicklookurl = quicklookurl 158 | self.largetext = largetext 159 | self.copytext = copytext 160 | 161 | self.modifiers = {} 162 | 163 | self.config = {} 164 | self.variables = {} 165 | 166 | def setvar(self, name, value): 167 | """Set a workflow variable for this Item. 168 | 169 | Args: 170 | name (unicode): Name of variable. 171 | value (unicode): Value of variable. 172 | 173 | """ 174 | self.variables[name] = value 175 | 176 | def getvar(self, name, default=None): 177 | """Return value of workflow variable for ``name`` or ``default``. 178 | 179 | Args: 180 | name (unicode): Variable name. 181 | default (None, optional): Value to return if variable is unset. 182 | 183 | Returns: 184 | unicode or ``default``: Value of variable if set or ``default``. 185 | """ 186 | return self.variables.get(name, default) 187 | 188 | def add_modifier(self, key, subtitle=None, arg=None, valid=None): 189 | """Add alternative values for a modifier key. 190 | 191 | Args: 192 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 193 | subtitle (unicode, optional): Override item subtitle. 194 | arg (unicode, optional): Input for following action. 195 | valid (bool, optional): Override item validity. 196 | 197 | Returns: 198 | Modifier: Configured :class:`Modifier`. 199 | """ 200 | mod = Modifier(key, subtitle, arg, valid) 201 | 202 | for k in self.variables: 203 | mod.setvar(k, self.variables[k]) 204 | 205 | self.modifiers[key] = mod 206 | 207 | return mod 208 | 209 | @property 210 | def obj(self): 211 | """Item formatted for JSON serialization. 212 | 213 | Returns: 214 | dict: Data suitable for Alfred 3 feedback. 215 | """ 216 | # Basic values 217 | o = {'title': self.title, 218 | 'subtitle': self.subtitle, 219 | 'valid': self.valid} 220 | 221 | icon = {} 222 | 223 | # Optional values 224 | if self.arg is not None: 225 | o['arg'] = self.arg 226 | 227 | if self.autocomplete is not None: 228 | o['autocomplete'] = self.autocomplete 229 | 230 | if self.uid is not None: 231 | o['uid'] = self.uid 232 | 233 | if self.type is not None: 234 | o['type'] = self.type 235 | 236 | if self.quicklookurl is not None: 237 | o['quicklookurl'] = self.quicklookurl 238 | 239 | # Largetype and copytext 240 | text = self._text() 241 | if text: 242 | o['text'] = text 243 | 244 | icon = self._icon() 245 | if icon: 246 | o['icon'] = icon 247 | 248 | # Variables and config 249 | js = self._vars_and_config() 250 | if js: 251 | o['arg'] = js 252 | 253 | # Modifiers 254 | mods = self._modifiers() 255 | if mods: 256 | o['mods'] = mods 257 | 258 | return o 259 | 260 | def _icon(self): 261 | """Return `icon` object for item. 262 | 263 | Returns: 264 | dict: Mapping for item `icon` (may be empty). 265 | """ 266 | icon = {} 267 | if self.icon is not None: 268 | icon['path'] = self.icon 269 | 270 | if self.icontype is not None: 271 | icon['type'] = self.icontype 272 | 273 | return icon 274 | 275 | def _text(self): 276 | """Return `largetext` and `copytext` object for item. 277 | 278 | Returns: 279 | dict: `text` mapping (may be empty) 280 | """ 281 | text = {} 282 | if self.largetext is not None: 283 | text['largetype'] = self.largetext 284 | 285 | if self.copytext is not None: 286 | text['copy'] = self.copytext 287 | 288 | return text 289 | 290 | def _vars_and_config(self): 291 | """Build `arg` including workflow variables and configuration. 292 | 293 | Returns: 294 | str: JSON string value for `arg` (or `None`) 295 | """ 296 | if self.variables or self.config: 297 | d = {} 298 | if self.variables: 299 | d['variables'] = self.variables 300 | 301 | if self.config: 302 | d['config'] = self.config 303 | 304 | if self.arg is not None: 305 | d['arg'] = self.arg 306 | 307 | return json.dumps({'alfredworkflow': d}) 308 | 309 | return None 310 | 311 | def _modifiers(self): 312 | """Build `mods` dictionary for JSON feedback. 313 | 314 | Returns: 315 | dict: Modifier mapping or `None`. 316 | """ 317 | if self.modifiers: 318 | mods = {} 319 | for k, mod in self.modifiers.items(): 320 | mods[k] = mod.obj 321 | 322 | return mods 323 | 324 | return None 325 | 326 | 327 | class Workflow3(Workflow): 328 | """Workflow class that generates Alfred 3 feedback. 329 | 330 | Attributes: 331 | item_class (class): Class used to generate feedback items. 332 | variables (dict): Top level workflow variables. 333 | """ 334 | 335 | item_class = Item3 336 | 337 | def __init__(self, **kwargs): 338 | """Create a new :class:`Workflow3` object. 339 | 340 | See :class:`~workflow.workflow.Workflow` for documentation. 341 | """ 342 | Workflow.__init__(self, **kwargs) 343 | self.variables = {} 344 | self._rerun = 0 345 | 346 | @property 347 | def _default_cachedir(self): 348 | """Alfred 3's default cache directory.""" 349 | return os.path.join( 350 | os.path.expanduser( 351 | '~/Library/Caches/com.runningwithcrayons.Alfred-3/' 352 | 'Workflow Data/'), 353 | self.bundleid) 354 | 355 | @property 356 | def _default_datadir(self): 357 | """Alfred 3's default data directory.""" 358 | return os.path.join(os.path.expanduser( 359 | '~/Library/Application Support/Alfred 3/Workflow Data/'), 360 | self.bundleid) 361 | 362 | @property 363 | def rerun(self): 364 | """How often (in seconds) Alfred should re-run the Script Filter.""" 365 | return self._rerun 366 | 367 | @rerun.setter 368 | def rerun(self, seconds): 369 | """Interval at which Alfred should re-run the Script Filter. 370 | 371 | Args: 372 | seconds (int): Interval between runs. 373 | """ 374 | self._rerun = seconds 375 | 376 | def setvar(self, name, value): 377 | """Set a "global" workflow variable. 378 | 379 | These variables are always passed to downstream workflow objects. 380 | 381 | If you have set :attr:`rerun`, these variables are also passed 382 | back to the script when Alfred runs it again. 383 | 384 | Args: 385 | name (unicode): Name of variable. 386 | value (unicode): Value of variable. 387 | """ 388 | self.variables[name] = value 389 | 390 | def getvar(self, name, default=None): 391 | """Return value of workflow variable for ``name`` or ``default``. 392 | 393 | Args: 394 | name (unicode): Variable name. 395 | default (None, optional): Value to return if variable is unset. 396 | 397 | Returns: 398 | unicode or ``default``: Value of variable if set or ``default``. 399 | """ 400 | return self.variables.get(name, default) 401 | 402 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 403 | valid=False, uid=None, icon=None, icontype=None, 404 | type=None, largetext=None, copytext=None, quicklookurl=None): 405 | """Add an item to be output to Alfred. 406 | 407 | See :meth:`~workflow.workflow.Workflow.add_item` for the main 408 | documentation. 409 | 410 | The key difference is that this method does not support the 411 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 412 | method instead on the returned item instead. 413 | 414 | Returns: 415 | Item3: Alfred feedback item. 416 | """ 417 | item = self.item_class(title, subtitle, arg, 418 | autocomplete, valid, uid, icon, icontype, type, 419 | largetext, copytext, quicklookurl) 420 | 421 | self._items.append(item) 422 | return item 423 | 424 | @property 425 | def obj(self): 426 | """Feedback formatted for JSON serialization. 427 | 428 | Returns: 429 | dict: Data suitable for Alfred 3 feedback. 430 | """ 431 | items = [] 432 | for item in self._items: 433 | items.append(item.obj) 434 | 435 | o = {'items': items} 436 | if self.variables: 437 | o['variables'] = self.variables 438 | if self.rerun: 439 | o['rerun'] = self.rerun 440 | return o 441 | 442 | def send_feedback(self): 443 | """Print stored items to console/Alfred as JSON.""" 444 | json.dump(self.obj, sys.stdout) 445 | sys.stdout.flush() 446 | -------------------------------------------------------------------------------- /workflow/workflow3.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awinecki/karabiner-elements-profile-switcher/810809ae0f9cb96859eb626ee11ecd050c8b807e/workflow/workflow3.pyc --------------------------------------------------------------------------------