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