├── .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 | 
29 |
30 |
31 |
32 | #### You can freely edit settings of each profile after switching to it
33 |
34 | 
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
--------------------------------------------------------------------------------