├── Ariafred.alfredworkflow
├── README.md
├── screenshots
├── ariafred.gif
├── bt.png
├── filter.png
├── run.png
└── stat.png
└── src
├── Ariafred.app
└── Contents
│ ├── Info.plist
│ ├── MacOS
│ └── Ariafred
│ ├── PkgInfo
│ └── Resources
│ ├── Ariafred.icns
│ └── en.lproj
│ ├── Credits.rtf
│ ├── InfoPlist.strings
│ └── MainMenu.nib
├── LICENSE
├── active.png
├── aria.py
├── aria_actions.py
├── aria_actions.pyc
├── complete.png
├── deleted.png
├── download.png
├── error.png
├── icon.png
├── info.plist
├── notifier.py
├── paused.png
├── removed.png
├── stopped.png
├── upload.png
├── version
├── waiting.png
└── workflow
├── Notify.tgz
├── __init__.py
├── background.py
├── notify.py
├── update.py
├── util.py
├── version
├── web.py
├── workflow.py
└── workflow3.py
/Ariafred.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/Ariafred.alfredworkflow
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ariafred
2 |
3 | 
4 |
5 | Manage Aria2 downloads directly in Alfred 3, with background notification.
6 |
7 | ## Usage
8 |
9 | ### Activate Ariafred
10 |
11 | You can set hotkey to activate Ariafred after installing it, my personal recommendation is `Command` + `Shift` + `A`, you can also type keyword `aria` in Alfred to activate Ariafred
12 |
13 | ### Filter by query
14 |
15 | 1. Type keywords to filter by task name
16 | 2. Type `active` / `done` / `paused` / `pending` / `error` to filter by status
17 | 3. You can filter by status and task name simultaneously:
18 |
19 | 
20 |
21 | ### Overall status
22 |
23 | 
24 |
25 | 1. Type keyword `stat` to view overall status
26 | 2. Press `Enter` on Active / Waiting / Stopped to view tasks in corresponding status
27 | 3. Press `Enter` on Download / Upload to go to speed limit settings
28 |
29 | ### Add a task
30 |
31 | Type `add` plus the url then press `Enter`, HTTP/FTP/SFTP/Magnet links are supported.
32 |
33 | It is recommended that you add a default download path in your `aria2.conf`, take it as an example: `dir=/foo/bar`, tasks added by Ariafred will be downloaded to this path.
34 |
35 | ### Add BT task via .torrent files
36 |
37 | 
38 |
39 | Execute [file action](https://www.alfredapp.com/help/features/file-search/#file-actions) 'Add BT download to Aria2'
40 |
41 | ### Reveal download
42 |
43 | 1. Press `Enter` on a task to reveal download in Finder
44 | 2. Press `Ctrl` + `Enter` on a task to reveal download in Alfred
45 | 3. Click on a notification to reveal related download in Finder
46 |
47 | ### Pause/Resume tasks
48 |
49 | 1. Press `Command` + `Enter` on a task
50 | 2. Or type `pause` / `resume` then press `Enter` on a task
51 | 3. Type `pauseall` / `resumeall` will pause/resume all task
52 |
53 | ### Remove tasks
54 |
55 | 1. Press `Option` + `Enter` on a task
56 | 2. Or type `rm` then press `Enter` on a task
57 |
58 | ### Copy URL to clipboard
59 |
60 | 1. Press `Control` + `Enter` on a task
61 | 2. Type `url` then press `Enter` on a task
62 |
63 | ### Clear all stopped tasks
64 |
65 | Type `clear` then press `Enter`
66 |
67 | ### Set speed limit and max concurrent downloads
68 |
69 | 1. Type `limit` plus speed(KiB/s) to set download speed limit
70 | 2. Type `limitup` plus speed(KiB/s) to set upload speed limit
71 | 3. Type `limitnum` plus a number to set max concurrent downloads
72 |
73 | ### Set RPC
74 |
75 | 1. The default RPC address Ariafred connects to is `http://localhost:6800/rpc`, change via typing `rpc` plus your own RPC address then press `Enter`. FYI, Ariafred uses xml-rpc instead of json-rpc that some WebUI uses for Aria2, so make sure your RPC address end with `/rpc` but not `/jsonrpc`.
76 | 2. The default rpc-secret Ariafred uses is empty, if you have configured your own rpc-secret in your `aria2.conf` you should set the secret by typing `secret` plus your own rpc-secret then press `Enter`
77 |
78 | ### Start/Quit Aria2
79 |
80 | 
81 |
82 | 1. Ariafred will prompt you to start Aria2 or change RPC address if it fails to connect to Aria2, press `Enter` on starting aria2 to try launching Aria2
83 | 2. Type `quit` to quit Aria2
84 |
85 | ### Get help
86 |
87 | Type `help` to view this page anytime
88 |
89 | ### Update
90 |
91 | Ariafred will automatically check for update and prompt you to update
92 |
93 | ### Caveat
94 |
95 | Background notification will not work under Mac OS X 10.8 or older system
96 |
97 | ## License
98 |
99 | MIT
100 |
101 | ## Credit
102 |
103 | @miniers
104 | @fatestigma
105 | @chyiz
106 | @ecbrodie
107 |
--------------------------------------------------------------------------------
/screenshots/ariafred.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/ariafred.gif
--------------------------------------------------------------------------------
/screenshots/bt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/bt.png
--------------------------------------------------------------------------------
/screenshots/filter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/filter.png
--------------------------------------------------------------------------------
/screenshots/run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/run.png
--------------------------------------------------------------------------------
/screenshots/stat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/stat.png
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BuildMachineOSBuild
6 | 15E65
7 | CFBundleDevelopmentRegion
8 | en
9 | CFBundleExecutable
10 | Ariafred
11 | CFBundleIconFile
12 | Ariafred
13 | CFBundleIdentifier
14 | dog.wil.ariafrednotification
15 | CFBundleInfoDictionaryVersion
16 | 6.0
17 | CFBundleName
18 | Ariafred
19 | CFBundlePackageType
20 | APPL
21 | CFBundleShortVersionString
22 | 1.6.3
23 | CFBundleSignature
24 | ????
25 | CFBundleSupportedPlatforms
26 |
27 | MacOSX
28 |
29 | CFBundleVersion
30 | 14
31 | DTCompiler
32 | com.apple.compilers.llvm.clang.1_0
33 | DTPlatformBuild
34 | 7D175
35 | DTPlatformVersion
36 | GM
37 | DTSDKBuild
38 | 15E60
39 | DTSDKName
40 | macosx10.11
41 | DTXcode
42 | 0730
43 | DTXcodeBuild
44 | 7D175
45 | LSMinimumSystemVersion
46 | 10.8
47 | LSUIElement
48 |
49 | NSHumanReadableCopyright
50 | Copyright © 2012-2015 Eloy Durán, Julien Blanchard. All rights reserved.
51 | NSMainNibFile
52 | MainMenu
53 | NSPrincipalClass
54 | NSApplication
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/MacOS/Ariafred:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/MacOS/Ariafred
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/PkgInfo:
--------------------------------------------------------------------------------
1 | APPL????
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/Resources/Ariafred.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/Ariafred.icns
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/Resources/en.lproj/Credits.rtf:
--------------------------------------------------------------------------------
1 | {\rtf0\ansi{\fonttbl\f0\fswiss Helvetica;}
2 | {\colortbl;\red255\green255\blue255;}
3 | \paperw9840\paperh8400
4 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\ql\qnatural
5 |
6 | \f0\b\fs24 \cf0 Engineering:
7 | \b0 \
8 | Some people\
9 | \
10 |
11 | \b Human Interface Design:
12 | \b0 \
13 | Some other people\
14 | \
15 |
16 | \b Testing:
17 | \b0 \
18 | Hopefully not nobody\
19 | \
20 |
21 | \b Documentation:
22 | \b0 \
23 | Whoever\
24 | \
25 |
26 | \b With special thanks to:
27 | \b0 \
28 | Mom\
29 | }
30 |
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/Resources/en.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/en.lproj/InfoPlist.strings
--------------------------------------------------------------------------------
/src/Ariafred.app/Contents/Resources/en.lproj/MainMenu.nib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/en.lproj/MainMenu.nib
--------------------------------------------------------------------------------
/src/LICENSE:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Wildog
3 | * Author: Wildog
4 | *
5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | * this software and associated documentation files (the "Software"), to deal in
7 | * the Software without restriction, including without limitation the rights to
8 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | * the Software, and to permit persons to whom the Software is furnished to do so,
10 | * subject to the following conditions:
11 | *
12 | * The above copyright notice and this permission notice shall be included in all
13 | * copies or substantial portions of the Software.
14 | *
15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 | */
22 |
--------------------------------------------------------------------------------
/src/active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/active.png
--------------------------------------------------------------------------------
/src/aria.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 | import socket
4 | import sys
5 | import xmlrpclib
6 | from aria_actions import speed_convert
7 | from workflow import Workflow3
8 | from workflow.background import run_in_background, is_running
9 |
10 |
11 | def get_rpc():
12 | global server
13 | rpc_path = wf.settings['rpc_path']
14 | server = xmlrpclib.ServerProxy(rpc_path).aria2
15 | try:
16 | version = server.getVersion(secret)
17 | except (xmlrpclib.Fault, socket.error):
18 | current_secret = secret.split(':')[1]
19 | wf.add_item(u'Aria2 may not be running, try starting it?', u'Press Enter',
20 | arg=u'--run-aria2', valid=True)
21 | wf.add_item(u'Or change RPC path?', u'Currently using ' + rpc_path,
22 | arg=u'--go-rpc-setting', valid=True)
23 | wf.add_item(u'Or change RPC secret?', u'Currently using \'' + current_secret + '\'',
24 | arg=u'--go-secret-setting', valid=True)
25 | return False
26 | else:
27 | return True
28 |
29 |
30 | def size_fmt(num, suffix='B'):
31 | for unit in ['','Ki','Mi','Gi','Ti']:
32 | if abs(num) < 1024.0:
33 | return '{num:.2f} {unit}{suffix}'.format(num=num, unit=unit, suffix=suffix)
34 | num /= 1024.0
35 | return '{num:.1f} {unit}{suffix}'.format(num=num, unit='Pi', suffix=suffix)
36 |
37 |
38 | def time_fmt(seconds):
39 | m, s = divmod(seconds, 60)
40 | h, m = divmod(m, 60)
41 | d, h = divmod(h, 24)
42 | w, d = divmod(d, 7)
43 | y, w = divmod(w, 52)
44 | units = ['y', 'w', 'd', 'h', 'm', 's']
45 | parts = [str(eval(unit)) + str(unit) for unit in units if eval(unit) > 0]
46 | time = ''.join(parts)
47 | return time
48 |
49 |
50 | def apply_filter(tasks, filters):
51 | if filters:
52 | for filter in filters:
53 | tasks = wf.filter(filter, tasks,
54 | lambda task: get_task_name(task), min_score=20)
55 | return tasks
56 |
57 |
58 | def kill_notifier():
59 | if os.path.isfile(wf.cachefile('notifier.pid')):
60 | with open(wf.cachefile('notifier.pid'), 'r') as pid_file:
61 | pid = pid_file.readline()
62 | os_command = 'pkill -TERM -P ' + pid
63 | os.system(os_command)
64 |
65 |
66 | def get_task_name(task):
67 | gid = task['gid']
68 | bt = server.tellStatus(secret, gid, ['bittorrent'])
69 | path = server.getFiles(secret, gid)[0]['path']
70 | if bt:
71 | file_num = len(server.getFiles(secret, gid))
72 | if 'info' in bt:
73 | bt_name = bt['bittorrent']['info']['name']
74 | else:
75 | bt_name = os.path.basename(os.path.dirname(path))
76 | if not bt_name:
77 | bt_name = 'Task name not obtained yet'
78 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num)
79 | else:
80 | name = os.path.basename(path)
81 | if not name:
82 | name = 'Task name not obtained yet'
83 | return name
84 |
85 |
86 | def no_result_notify(status, filters):
87 | info = 'No ' + status + ' download'
88 | if filters:
89 | info += ' with '
90 | for filter in filters:
91 | info += '\'' + filter + '\' '
92 | wf.add_item(info)
93 |
94 | def add_modifier_subs(item, active=False, done=False, info=''):
95 | subs = {'cmd': 'Resume download',
96 | 'shift': 'Copy URL',
97 | 'alt': 'Remove download'}
98 | if active:
99 | subs['cmd'] = 'Pause download'
100 | if done:
101 | subs['cmd'] = info
102 | item.add_modifier('cmd',subs['cmd'])
103 | item.add_modifier('shift',subs['shift'])
104 | item.add_modifier('alt',subs['alt'])
105 |
106 |
107 |
108 | def get_tasks(command, status, filters):
109 | if status == 'active':
110 | if not get_active_tasks(command, filters):
111 | no_result_notify(status, filters)
112 | elif status == 'pending':
113 | if not get_pending_tasks(command, filters):
114 | no_result_notify(status, filters)
115 | elif status == 'paused':
116 | if not get_paused_tasks(command, filters):
117 | no_result_notify(status, filters)
118 | elif status == 'done':
119 | if not get_completed_tasks(command, filters):
120 | no_result_notify(status, filters)
121 | elif status == 'error':
122 | if not get_error_tasks(command, filters):
123 | no_result_notify(status, filters)
124 | elif status == 'removed':
125 | if not get_removed_tasks(command, filters):
126 | no_result_notify(status, filters)
127 | elif status == 'waiting':
128 | a = get_pending_tasks(command, filters)
129 | b = get_paused_tasks(command, filters)
130 | if not (a or b):
131 | no_result_notify(status, filters)
132 | elif status == 'incomplete':
133 | a = get_paused_tasks(command, filters)
134 | b = get_error_tasks(command, filters)
135 | c = get_removed_tasks(command, filters)
136 | if not (a or b or c):
137 | no_result_notify(status, filters)
138 | elif status == 'stopped':
139 | a = get_completed_tasks(command, filters)
140 | b = get_error_tasks(command, filters)
141 | c = get_removed_tasks(command, filters)
142 | if not (a or b or c):
143 | no_result_notify(status, filters)
144 | elif status == 'all':
145 | a = get_active_tasks(command, filters)
146 | b = get_pending_tasks(command, filters)
147 | c = get_paused_tasks(command, filters)
148 | d = get_completed_tasks(command, filters)
149 | e = get_error_tasks(command, filters)
150 | f = get_removed_tasks(command, filters)
151 | if not (a or b or c or d or e or f):
152 | no_result_notify('Aria2', filters)
153 |
154 |
155 | def get_active_tasks(command, filters):
156 | active = server.tellActive(secret, ['gid', 'completedLength', 'totalLength',
157 | 'downloadSpeed', 'uploadSpeed', 'connections'])
158 | active = apply_filter(active, filters)
159 | if not active:
160 | return False
161 | for task in active:
162 | name = get_task_name(task)
163 | speed = int(task['downloadSpeed'])
164 | completed = int(task['completedLength'])
165 | total = int(task['totalLength'])
166 | if total == 0:
167 | percentage = 0
168 | else:
169 | percentage = float(completed) / float(total) * 100
170 | if speed > 0:
171 | seconds = (total - completed) / speed
172 | remaining = time_fmt(seconds)
173 | else:
174 | remaining = u'∞'
175 | info = u'{percentage:.2f}%, {completed} / {total}, {speed}, {remaining} left'.format(
176 | percentage=percentage,
177 | completed=size_fmt(completed),
178 | total=size_fmt(total),
179 | speed=size_fmt(speed, suffix='B/s'),
180 | remaining=remaining
181 | )
182 | arg = '--' + command + ' ' + task['gid']
183 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_active)
184 | add_modifier_subs(item=item,active=True)
185 | return True
186 |
187 |
188 | def get_pending_tasks(command, filters):
189 | waiting = server.tellWaiting(secret, -1, 10, ['gid', 'status', 'completedLength',
190 | 'totalLength'])
191 | waiting = [task for task in waiting if task['status'] == 'waiting']
192 | waiting = apply_filter(waiting, filters)
193 | if not waiting:
194 | return False
195 | for task in waiting:
196 | name = get_task_name(task)
197 | completed = int(task['completedLength'])
198 | total = int(task['totalLength'])
199 | if total == 0:
200 | percentage = 0
201 | else:
202 | percentage = float(completed) / float(total) * 100
203 | info = '{percentage:.2f}%, {completed} / {total}'.format(
204 | percentage=percentage,
205 | completed=size_fmt(completed),
206 | total=size_fmt(total))
207 | arg = '--' + command + ' ' + task['gid']
208 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_waiting)
209 | add_modifier_subs(item=item,active=True)
210 | return True
211 |
212 |
213 | def get_paused_tasks(command, filters):
214 | waiting = server.tellWaiting(secret, -1, 10, ['gid', 'status', 'completedLength',
215 | 'totalLength'])
216 | paused = [task for task in waiting if task['status'] == 'paused']
217 | paused = apply_filter(paused, filters)
218 | if not paused:
219 | return False
220 | for task in paused:
221 | name = get_task_name(task)
222 | completed = int(task['completedLength'])
223 | total = int(task['totalLength'])
224 | if total == 0:
225 | percentage = 0
226 | else:
227 | percentage = float(completed) / float(total) * 100
228 | info = '{percentage:.2f}%, {completed} / {total}'.format(
229 | percentage=percentage,
230 | completed=size_fmt(completed),
231 | total=size_fmt(total))
232 | arg = '--' + command + ' ' + task['gid']
233 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_paused)
234 | add_modifier_subs(item=item)
235 | return True
236 |
237 |
238 | def get_stopped_tasks():
239 | stopped = server.tellStopped(secret, -1, 20, ['gid', 'status', 'completedLength',
240 | 'totalLength', 'errorMessage'])
241 | return stopped
242 |
243 |
244 | def get_completed_tasks(command, filters):
245 | stopped = get_stopped_tasks()
246 | completed = [task for task in stopped if task['status'] == 'complete']
247 | completed = apply_filter(completed, filters)
248 | if not completed:
249 | return False
250 | for task in completed:
251 | name = get_task_name(task)
252 | size = int(task['completedLength'])
253 | info = '100%, File Size: {size}'.format(size=size_fmt(size))
254 | arg = '--' + command + ' ' + task['gid']
255 | filepath = server.getFiles(secret, task['gid'])[0]['path'].encode('utf-8')
256 | if not os.path.exists(filepath):
257 | info = '[deleted] ' + info
258 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_deleted)
259 | else:
260 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_complete)
261 | add_modifier_subs(item=item,done=True, info=info)
262 | return True
263 |
264 |
265 | def get_error_tasks(command, filters):
266 | stopped = get_stopped_tasks()
267 | error = [task for task in stopped if task['status'] == 'error']
268 | error = apply_filter(error, filters)
269 | if not error:
270 | return False
271 | for task in error:
272 | name = get_task_name(task)
273 | arg = '--' + command + ' ' + task['gid']
274 | completed = int(task['completedLength'])
275 | total = int(task['totalLength'])
276 | if total == 0:
277 | percentage = 0
278 | else:
279 | percentage = float(completed) / float(total) * 100
280 | info = u'{percentage:.2f}%, {completed} / {total}, {msg}'.format(
281 | percentage=percentage,
282 | completed=size_fmt(completed),
283 | total=size_fmt(total),
284 | msg=task.get('errorMessage', u'Unknown Error.'))
285 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_error)
286 | add_modifier_subs(item=item,done=True, info=info)
287 | return True
288 |
289 |
290 | def get_removed_tasks(command, filters):
291 | stopped = get_stopped_tasks()
292 | removed = [task for task in stopped if task['status'] == 'removed']
293 | removed = apply_filter(removed, filters)
294 | if not removed:
295 | return False
296 | for task in removed:
297 | name = get_task_name(task)
298 | arg = '--' + command + ' ' + task['gid']
299 | info = 'Removed'
300 | item = wf.add_item(name, u'This task is removed by user.', arg=arg, valid=True, icon=icon_removed)
301 | add_modifier_subs(item=item,done=True, info=info)
302 | return True
303 |
304 |
305 | def get_stats():
306 | if get_rpc():
307 | stats = server.getGlobalStat(secret)
308 | options = server.getGlobalOption(secret)
309 | wf.add_item('Active: ' + stats['numActive'],
310 | arg='--go-active', valid=True, icon=icon_active)
311 | wf.add_item('Waiting: ' + stats['numWaiting'],
312 | arg='--go-waiting', valid=True, icon=icon_waiting)
313 | wf.add_item('Stopped: ' + stats['numStopped'],
314 | arg='--go-stopped', valid=True, icon=icon_stopped)
315 | wf.add_item('Download: ' + size_fmt(int(stats['downloadSpeed']), suffix='B/s'),
316 | 'Current download limit: {limit} KiB/s'.format(
317 | limit=options['max-overall-download-limit']),
318 | arg='--go-download-limit-setting', valid=True, icon=icon_download)
319 | wf.add_item('Upload: ' + size_fmt(int(stats['uploadSpeed']), suffix='B/s'),
320 | 'Current upload limit: {limit} KiB/s'.format(
321 | limit=options['max-overall-upload-limit']),
322 | arg='--go-upload-limit-setting', valid=True, icon=icon_upload)
323 |
324 |
325 | def limit_speed(type, param):
326 | if get_rpc():
327 | limit = int(server.getGlobalOption(secret)['max-overall-' + type +'-limit'])
328 | limit = speed_convert(limit)[1]
329 | param_s = speed_convert(param)[1]
330 | wf.add_item('Limit ' + type +' speed to: {limit}'.format(limit=param_s),
331 | 'Current ' + type + ' limit (0 for no limit): ' + limit,
332 | arg='--limit-' + type + ' ' + param, valid=True)
333 |
334 |
335 | def limit_num(param):
336 | if get_rpc():
337 | limit = server.getGlobalOption(secret)['max-concurrent-downloads']
338 | wf.add_item('Limit concurrent downloads to: {limit}'.format(limit=param),
339 | 'Current concurrent downloads limit: ' + limit,
340 | arg='--limit-num ' + param, valid=True)
341 |
342 |
343 | def main(wf):
344 | if wf.first_run:
345 | kill_notifier()
346 |
347 | statuses = ['all', 'active', 'pending', 'paused', 'waiting',
348 | 'done', 'error', 'removed', 'stopped']
349 | actions = ['reveal', 'rm', 'url', 'pause', 'resume']
350 | settings = ['rpc', 'secret', 'limit', 'limitup', 'limitnum', 'clear', 'add', 'quit',
351 | 'stat', 'help', 'pauseall', 'resumeall']
352 | commands = actions + settings
353 |
354 | command = 'reveal'
355 | status = 'all'
356 | param = ''
357 |
358 | if len(wf.args) == 1:
359 | if wf.args[0] in commands:
360 | command = wf.args[0]
361 | elif wf.args[0] in statuses:
362 | status = wf.args[0]
363 | else:
364 | param = wf.args[0:]
365 | elif len(wf.args) > 1:
366 | if wf.args[0] in settings:
367 | command = wf.args[0]
368 | param = wf.args[1] #settings take one param only
369 | elif wf.args[0] in actions:
370 | command = wf.args[0]
371 | param = wf.args[1:] #actions can take multiple param to filter the result
372 | elif wf.args[0] in statuses:
373 | status = wf.args[0]
374 | param = wf.args[1:] #statuses can take multiple param to filter the result
375 | else:
376 | param = wf.args[0:]
377 |
378 | if command not in settings:
379 | if command == 'pause':
380 | status = 'active'
381 | elif command == 'resume':
382 | status = 'incomplete'
383 | if get_rpc():
384 | get_tasks(command, status, param)
385 | else:
386 | if command == 'rpc':
387 | wf.add_item('Set Aria2\'s RPC Path', 'Set the path to ' + param,
388 | arg=u'--rpc-setting ' + param, valid=True)
389 | elif command == 'secret':
390 | wf.add_item('Set Aria2\'s RPC Secret', 'Set the secret to ' + param,
391 | arg=u'--secret-setting ' + param, valid=True)
392 | elif command == 'add':
393 | wf.add_item('Add new download: ' + param, arg='--add ' + param, valid=True)
394 | elif command == 'clear':
395 | wf.add_item('Clear all stopped download?', arg='--clear', valid=True)
396 | elif command == 'pauseall':
397 | wf.add_item('Pause all active downloads?', arg='--pauseall', valid=True)
398 | elif command == 'resumeall':
399 | wf.add_item('Resume all paused downloads?', arg='--resumeall', valid=True)
400 | elif command == 'help':
401 | wf.add_item('Need some help?', arg='--help', valid=True)
402 | elif command == 'quit':
403 | wf.add_item('Quit Aria2?', arg='--quit', valid=True)
404 | elif command == 'limit':
405 | limit_speed('download', param)
406 | elif command == 'limitup':
407 | limit_speed('upload', param)
408 | elif command == 'limitnum':
409 | limit_num(param)
410 | elif command == 'stat':
411 | get_stats()
412 |
413 | if wf.update_available:
414 | wf.add_item('New version available',
415 | 'Action this item to install the update',
416 | autocomplete='workflow:update')
417 |
418 | wf.send_feedback()
419 |
420 | if not is_running('notifier'):
421 | cmd = ['/usr/bin/python', wf.workflowfile('notifier.py')]
422 | run_in_background('notifier', cmd)
423 |
424 |
425 | if __name__ == '__main__':
426 |
427 | icon_active = 'active.png'
428 | icon_paused = 'paused.png'
429 | icon_waiting = 'waiting.png'
430 | icon_complete = 'complete.png'
431 | icon_deleted = 'deleted.png'
432 | icon_removed = 'removed.png'
433 | icon_error = 'error.png'
434 | icon_download = 'download.png'
435 | icon_upload = 'upload.png'
436 | icon_stopped = 'stopped.png'
437 |
438 | defaults = {
439 | 'rpc_path': 'http://localhost:6800/rpc',
440 | 'secret': ''
441 | }
442 | update_settings = {
443 | 'github_slug': 'Wildog/Ariafred',
444 | 'frequency': 1
445 | }
446 |
447 | wf = Workflow3(default_settings=defaults, update_settings=update_settings)
448 |
449 | server = None
450 |
451 | if 'secret' not in wf.settings:
452 | wf.settings['secret'] = ''
453 | secret = 'token:' + wf.settings['secret']
454 | wf.rerun=1
455 | sys.exit(wf.run(main))
456 |
--------------------------------------------------------------------------------
/src/aria_actions.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function
3 | import os
4 | import sys
5 | import xmlrpclib
6 | from workflow import Workflow3
7 |
8 |
9 | def escape(s, char=' '):
10 | return s.replace(char, '\\' + char)
11 |
12 |
13 | def notify(msg, title='Ariafred', gid=''):
14 | notifier = os.path.join(wf.workflowdir, 'Ariafred.app/Contents/MacOS/Ariafred')
15 | notifier = escape(notifier)
16 | msg = escape(msg, char='[')
17 | os_command = '%s -title "%s" -message "%s"' % (notifier.encode('utf-8'),
18 | title.encode('utf-8'),
19 | msg.encode('utf-8'))
20 | if gid:
21 | dir = server.tellStatus(secret, gid, ['dir'])['dir']
22 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8')
23 | if os.path.exists(filepath):
24 | click_command = 'open -R "%s"' % filepath
25 | else:
26 | click_command = 'open "%s" ' % dir
27 | os_command = '%s -execute \'%s\'' % (os_command, click_command)
28 | os.system(os_command)
29 |
30 |
31 | def set_query(query):
32 | alfred_2_cmd = 'if application "Alfred 2" is running then tell application "Alfred 2" to search "%s"' % query
33 | alfred_3_cmd = 'if application "Alfred 3" is running then tell application "Alfred 3" to search "%s"' % query
34 | os_command = "osascript -e '%s' & osascript -e '%s'" % (alfred_2_cmd, alfred_3_cmd)
35 | os.system(os_command)
36 |
37 |
38 | def run_aria():
39 | os_command = 'export PATH=$PATH:/usr/local/bin:/usr/local/aria2/bin && aria2c --enable-rpc --rpc-listen-all=true --rpc-allow-origin-all -c -D'
40 | if os.system(os_command) == 0:
41 | notify('Aria2 has started successfully')
42 | else:
43 | notify('Failed to start Aria2, please run manually')
44 |
45 |
46 | def get_task_name(gid):
47 | bt = server.tellStatus(secret, gid, ['bittorrent'])
48 | path = server.getFiles(secret, gid)[0]['path']
49 | if bt:
50 | file_num = len(server.getFiles(secret, gid))
51 | if 'info' in bt:
52 | bt_name = bt['bittorrent']['info']['name']
53 | else:
54 | bt_name = os.path.basename(os.path.dirname(path))
55 | if not bt_name:
56 | bt_name = 'Task name not obtained yet'
57 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num)
58 | else:
59 | name = os.path.basename(path)
60 | if not name:
61 | name = 'Task name not obtained yet'
62 | return name
63 |
64 |
65 | def reveal(gid, alfred=False):
66 | dir = server.tellStatus(secret, gid, ['dir'])['dir']
67 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8')
68 | if os.path.exists(filepath):
69 | if alfred:
70 | alfred_2_cmd = 'if application "Alfred 2" is running then tell application "Alfred 2" to search "%s"' % filepath
71 | alfred_3_cmd = 'if application "Alfred 3" is running then tell application "Alfred 3" to search "%s"' % filepath
72 | os_command = "osascript -e '%s' & osascript -e '%s'" % (alfred_2_cmd, alfred_3_cmd)
73 | else:
74 | os_command = 'open -R "%s"' % filepath
75 | else:
76 | os_command = 'open "%s" ' % dir
77 | os.system(os_command)
78 |
79 |
80 | def pause_all():
81 | server.pauseAll(secret)
82 | notify('All active downloads paused')
83 |
84 |
85 | def resume_all():
86 | server.unpauseAll(secret)
87 | notify('All paused downloads resumed')
88 |
89 |
90 | def switch_task(gid):
91 | name = get_task_name(gid)
92 | status = server.tellStatus(secret, gid, ['status'])['status']
93 | if status in ['active', 'waiting']:
94 | server.pause(secret, gid)
95 | notify(title='Download paused:', msg=name, gid=gid)
96 | elif status == 'paused':
97 | server.unpause(secret, gid)
98 | notify(title='Download resumed:', msg=name, gid=gid)
99 | elif status == 'complete':
100 | pass
101 | else:
102 | urls = server.getFiles(secret, gid)[0]['uris']
103 | if urls:
104 | url = urls[0]['uri']
105 | server.addUri(secret, [url])
106 | server.removeDownloadResult(secret, gid)
107 | notify(title='Download resumed:', msg=name, gid=gid)
108 | else:
109 | notify(title='Cannot resume download:', msg=name, gid=gid)
110 |
111 |
112 | def get_url(gid):
113 | urls = server.getFiles(secret, gid)[0]['uris']
114 | if urls:
115 | url = urls[0]['uri']
116 | notify(title='URL has been copied to clipboard:', msg=url)
117 | print(url, end='')
118 | else:
119 | notify('No URL found')
120 |
121 |
122 | def add_task(url):
123 | gid = server.addUri(secret, [url])
124 | notify(title='Download added:', msg=url, gid=gid)
125 |
126 |
127 | def add_bt_task(filepath):
128 | gid = server.addTorrent(secret, xmlrpclib.Binary(open(filepath, mode='rb').read()))
129 | notify(title='BT download added:', msg=os.path.basename(filepath), gid=gid)
130 |
131 |
132 | def remove_task(gid):
133 | name = get_task_name(gid)
134 | status = server.tellStatus(secret, gid, ['status'])['status']
135 | if status in ['active', 'waiting', 'paused']:
136 | server.remove(secret, gid)
137 | notify(title='Download removed:', msg=name, gid=gid)
138 | server.removeDownloadResult(secret, gid)
139 |
140 |
141 | def clear_stopped():
142 | server.purgeDownloadResult(secret)
143 | notify('All stopped downloads cleared')
144 |
145 |
146 | def quit_aria():
147 | server.shutdown(secret)
148 | notify('Aria2 shutting down')
149 | kill_notifier()
150 |
151 | def speed_convert(s):
152 | try:
153 | speed = int(s)
154 | m = speed / (1024 * 1024)
155 | k = speed / 1024
156 | if m != 0:
157 | string = '%d MiB/s' % m
158 | elif k != 0:
159 | string = '%d KiB/s' % k
160 | else:
161 | string = '%d Byte/s' % speed
162 | return (s, string)
163 | except:
164 | import re
165 | m = re.match(r'\s*(\d+)\s*(\w+)\s*', s)
166 | if m:
167 | number = m.group(1)
168 | unit = m.group(2)[0]
169 | if unit == 'K' or unit == 'k':
170 | exp = 1
171 | unit = 'KiB/s'
172 | elif unit == 'M' or unit == 'm':
173 | exp = 2
174 | unit = 'MiB/s'
175 | elif unit == 'G' or unit == 'g':
176 | exp = 3
177 | unit = 'GiB/s'
178 | else:
179 | exp = 0
180 | unit = 'Byte/s'
181 | string = '%s %s' % (number, unit)
182 | speed = int(number) * (1024 ** exp)
183 | return (str(speed), string)
184 | else:
185 | return ('0', '0 Byte')
186 |
187 | def limit_speed(type, speed):
188 | option = 'max-overall-' + type + '-limit'
189 | speed_value,speed_string = speed_convert(speed)
190 | server.changeGlobalOption(secret, {option: speed_value})
191 | notify('Limit ' + type + ' speed to: ' + speed_string)
192 |
193 | def limit_num(num):
194 | server.changeGlobalOption(secret, {'max-concurrent-downloads': num})
195 | notify('Limit concurrent downloads to: ' + num)
196 |
197 |
198 | def kill_notifier():
199 | with open(wf.cachefile('notifier.pid'), 'r') as pid_file:
200 | pid = pid_file.readline()
201 | os_command = 'pkill -TERM -P ' + pid
202 | os.system(os_command)
203 |
204 | def set_rpc(path):
205 | wf.settings['rpc_path'] = path
206 | notify('Set RPC path to: ' + path)
207 | kill_notifier()
208 |
209 | def set_secret(str):
210 | wf.settings['secret'] = str
211 | notify('Set RPC secret to: ' + str)
212 | kill_notifier()
213 |
214 | def get_help():
215 | os_command = 'open https://github.com/Wildog/Ariafred'
216 | os.system(os_command)
217 |
218 |
219 | def main(wf):
220 | command = wf.args[0]
221 |
222 | if command == '--reveal':
223 | reveal(wf.args[1])
224 | elif command == '--alfred':
225 | reveal(wf.args[1], True)
226 | elif command == '--rm':
227 | remove_task(wf.args[1])
228 | elif command == '--add':
229 | add_task(wf.args[1])
230 | elif command == '--bt':
231 | add_bt_task(wf.args[1])
232 | elif (command == '--pause'
233 | or command == '--resume'
234 | or command == '--switch'):
235 | switch_task(wf.args[1])
236 | elif command == '--pauseall':
237 | pause_all()
238 | elif command == '--resumeall':
239 | resume_all()
240 | elif command == '--clear':
241 | clear_stopped()
242 | elif command == '--url':
243 | get_url(wf.args[1])
244 | elif command == '--rpc-setting':
245 | set_rpc(wf.args[1])
246 | elif command == '--secret-setting':
247 | set_secret(wf.args[1])
248 | elif command == '--run-aria2':
249 | run_aria()
250 | elif command == '--quit':
251 | quit_aria()
252 | elif command == '--help':
253 | get_help()
254 | elif command == '--limit-download':
255 | limit_speed('download', wf.args[1])
256 | elif command == '--limit-upload':
257 | limit_speed('upload', wf.args[1])
258 | elif command == '--limit-num':
259 | limit_num(wf.args[1])
260 | elif command == '--go-rpc-setting':
261 | set_query('aria rpc ')
262 | elif command == '--go-secret-setting':
263 | set_query('aria secret ')
264 | elif command == '--go-active':
265 | set_query('aria active ')
266 | elif command == '--go-stopped':
267 | set_query('aria stopped ')
268 | elif command == '--go-waiting':
269 | set_query('aria waiting ')
270 | elif command == '--go-download-limit-setting':
271 | set_query('aria limit ')
272 | elif command == '--go-upload-limit-setting':
273 | set_query('aria limitup ')
274 |
275 |
276 | if __name__ == '__main__':
277 |
278 | wf = Workflow3()
279 | rpc_path = wf.settings['rpc_path']
280 | server = xmlrpclib.ServerProxy(rpc_path).aria2
281 | secret = 'token:' + wf.settings['secret']
282 | sys.exit(wf.run(main))
283 |
--------------------------------------------------------------------------------
/src/aria_actions.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/aria_actions.pyc
--------------------------------------------------------------------------------
/src/complete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/complete.png
--------------------------------------------------------------------------------
/src/deleted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/deleted.png
--------------------------------------------------------------------------------
/src/download.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/download.png
--------------------------------------------------------------------------------
/src/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/error.png
--------------------------------------------------------------------------------
/src/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/icon.png
--------------------------------------------------------------------------------
/src/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | dog.wil.ariafred
7 | connections
8 |
9 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D
10 |
11 |
12 | destinationuid
13 | 85E562C1-0EB6-4234-B34E-BE1843D9E441
14 | modifiers
15 | 0
16 | modifiersubtext
17 |
18 |
19 |
20 | 82126C11-9FA3-4DFC-8917-FCD788552F38
21 |
22 |
23 | destinationuid
24 | 032DABC0-4F0C-4875-A556-DF07D92D1563
25 | modifiers
26 | 0
27 | modifiersubtext
28 |
29 |
30 |
31 | 85E562C1-0EB6-4234-B34E-BE1843D9E441
32 |
33 |
34 | destinationuid
35 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7
36 | modifiers
37 | 0
38 | modifiersubtext
39 |
40 |
41 |
42 | destinationuid
43 | 60B41887-2E94-4CAF-8BFD-E3108279053A
44 | modifiers
45 | 1048576
46 | modifiersubtext
47 |
48 |
49 |
50 | destinationuid
51 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733
52 | modifiers
53 | 524288
54 | modifiersubtext
55 |
56 |
57 |
58 | destinationuid
59 | 82126C11-9FA3-4DFC-8917-FCD788552F38
60 | modifiers
61 | 131072
62 | modifiersubtext
63 |
64 |
65 |
66 | destinationuid
67 | F918CA67-023E-430D-9903-03DDE35F0E4E
68 | modifiers
69 | 262144
70 | modifiersubtext
71 | Show in Alfred
72 |
73 |
74 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7
75 |
76 |
77 | destinationuid
78 | 032DABC0-4F0C-4875-A556-DF07D92D1563
79 | modifiers
80 | 0
81 | modifiersubtext
82 |
83 |
84 |
85 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733
86 |
87 | F49892BB-5FD7-431B-98A6-C84DA5923E02
88 |
89 |
90 | destinationuid
91 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612
92 | modifiers
93 | 0
94 | modifiersubtext
95 |
96 |
97 |
98 |
99 | createdby
100 | Wildog
101 | description
102 | Manage Aria2 downloads with Alfred
103 | disabled
104 |
105 | name
106 | Ariafred
107 | objects
108 |
109 |
110 | config
111 |
112 | argumenttype
113 | 1
114 | escaping
115 | 102
116 | keyword
117 | aria
118 | queuedelaycustom
119 | 3
120 | queuedelayimmediatelyinitially
121 |
122 | queuedelaymode
123 | 1
124 | queuemode
125 | 1
126 | runningsubtext
127 | Connecting Aria2...
128 | script
129 | /usr/bin/python aria.py {query}
130 | title
131 | Manage Aria2 downloads
132 | type
133 | 0
134 | withspace
135 |
136 |
137 | type
138 | alfred.workflow.input.scriptfilter
139 | uid
140 | 85E562C1-0EB6-4234-B34E-BE1843D9E441
141 | version
142 | 0
143 |
144 |
145 | config
146 |
147 | action
148 | 0
149 | argument
150 | 0
151 | hotkey
152 | 0
153 | hotmod
154 | 1179648
155 | hotstring
156 | A
157 | leftcursor
158 |
159 | modsmode
160 | 0
161 | relatedAppsMode
162 | 0
163 |
164 | type
165 | alfred.workflow.trigger.hotkey
166 | uid
167 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D
168 | version
169 | 1
170 |
171 |
172 | config
173 |
174 | concurrently
175 |
176 | escaping
177 | 102
178 | script
179 | /usr/bin/python aria_actions.py {query}
180 | type
181 | 0
182 |
183 | type
184 | alfred.workflow.action.script
185 | uid
186 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7
187 | version
188 | 0
189 |
190 |
191 | config
192 |
193 | concurrently
194 |
195 | escaping
196 | 102
197 | script
198 | query="{query}"
199 | gid=${query#--*[ ]}
200 | command="--switch "
201 | /usr/bin/python aria_actions.py $command$gid
202 | type
203 | 0
204 |
205 | type
206 | alfred.workflow.action.script
207 | uid
208 | 60B41887-2E94-4CAF-8BFD-E3108279053A
209 | version
210 | 0
211 |
212 |
213 | config
214 |
215 | autopaste
216 |
217 | clipboardtext
218 | {query}
219 |
220 | type
221 | alfred.workflow.output.clipboard
222 | uid
223 | 032DABC0-4F0C-4875-A556-DF07D92D1563
224 | version
225 | 0
226 |
227 |
228 | config
229 |
230 | concurrently
231 |
232 | escaping
233 | 102
234 | script
235 | query="{query}"
236 | gid=${query#--*[ ]}
237 | command="--rm "
238 | /usr/bin/python aria_actions.py $command$gid
239 | type
240 | 0
241 |
242 | type
243 | alfred.workflow.action.script
244 | uid
245 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733
246 | version
247 | 0
248 |
249 |
250 | config
251 |
252 | concurrently
253 |
254 | escaping
255 | 102
256 | script
257 | query="{query}"
258 | gid=${query#--*[ ]}
259 | command="--url "
260 | /usr/bin/python aria_actions.py $command$gid
261 | type
262 | 0
263 |
264 | type
265 | alfred.workflow.action.script
266 | uid
267 | 82126C11-9FA3-4DFC-8917-FCD788552F38
268 | version
269 | 0
270 |
271 |
272 | config
273 |
274 | concurrently
275 |
276 | escaping
277 | 102
278 | script
279 | query="{query}"
280 | gid=${query#--*[ ]}
281 | command="--alfred "
282 | /usr/bin/python aria_actions.py $command$gid
283 | type
284 | 0
285 |
286 | type
287 | alfred.workflow.action.script
288 | uid
289 | F918CA67-023E-430D-9903-03DDE35F0E4E
290 | version
291 | 0
292 |
293 |
294 | config
295 |
296 | acceptsmulti
297 |
298 | filetypes
299 |
300 | dyn.ah62d4rv4ge81k55wsmw067a
301 |
302 | name
303 | Add BT download to Aria2
304 |
305 | type
306 | alfred.workflow.trigger.action
307 | uid
308 | F49892BB-5FD7-431B-98A6-C84DA5923E02
309 | version
310 | 0
311 |
312 |
313 | config
314 |
315 | concurrently
316 |
317 | escaping
318 | 102
319 | script
320 | files=("{query}")
321 | for file in ${files[@]}; do
322 | filepath=$file
323 | /usr/bin/python aria_actions.py --bt $filepath
324 | done
325 | type
326 | 0
327 |
328 | type
329 | alfred.workflow.action.script
330 | uid
331 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612
332 | version
333 | 0
334 |
335 |
336 | readme
337 |
338 | uidata
339 |
340 | 032DABC0-4F0C-4875-A556-DF07D92D1563
341 |
342 | ypos
343 | 240
344 |
345 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612
346 |
347 | ypos
348 | 710
349 |
350 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D
351 |
352 | ypos
353 | 70
354 |
355 | 60B41887-2E94-4CAF-8BFD-E3108279053A
356 |
357 | ypos
358 | 190
359 |
360 | 82126C11-9FA3-4DFC-8917-FCD788552F38
361 |
362 | ypos
363 | 450
364 |
365 | 85E562C1-0EB6-4234-B34E-BE1843D9E441
366 |
367 | ypos
368 | 70
369 |
370 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7
371 |
372 | ypos
373 | 70
374 |
375 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733
376 |
377 | ypos
378 | 320
379 |
380 | F49892BB-5FD7-431B-98A6-C84DA5923E02
381 |
382 | ypos
383 | 710
384 |
385 | F918CA67-023E-430D-9903-03DDE35F0E4E
386 |
387 | ypos
388 | 580
389 |
390 |
391 | webaddress
392 | http://wil.dog
393 |
394 |
395 |
--------------------------------------------------------------------------------
/src/notifier.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 | import socket
4 | import sys
5 | import threading
6 | import xmlrpclib
7 | from workflow import Workflow3
8 |
9 |
10 | def escape(s, char=' '):
11 | return s.replace(char, '\\' + char)
12 |
13 |
14 | def notify(msg, title='Ariafred', gid=''):
15 | notifier = os.path.join(wf.workflowdir, 'Ariafred.app/Contents/MacOS/Ariafred')
16 | notifier = escape(notifier)
17 | msg = escape(msg, char='[')
18 | os_command = '%s -title "%s" -message "%s"' % (notifier.encode('utf-8'),
19 | title.encode('utf-8'),
20 | msg.encode('utf-8'))
21 | if gid:
22 | dir = server.tellStatus(secret, gid, ['dir'])['dir']
23 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8')
24 | if os.path.exists(filepath):
25 | click_command = 'open -R "%s"' % filepath
26 | else:
27 | click_command = 'open "%s" ' % dir
28 | os_command = '%s -execute \'%s\'' % (os_command, click_command)
29 | os.system(os_command)
30 |
31 |
32 | def main(wf):
33 | update_watch_list()
34 | get_notified()
35 |
36 |
37 | def update_watch_list():
38 | threading.Timer(2.0, update_watch_list).start()
39 | try:
40 | active = server.tellActive(secret, ['gid'])
41 | except (xmlrpclib.Fault, socket.error):
42 | pass
43 | else:
44 | for task in active:
45 | gid = task['gid']
46 | with lock:
47 | if gid not in watch_list:
48 | watch_list.append(gid)
49 |
50 |
51 | def get_notified():
52 | threading.Timer(1.0, get_notified).start()
53 | for gid in watch_list:
54 | try:
55 | task = server.tellStatus(secret, gid, ['status', 'errorMessage'])
56 | status = task['status']
57 | except (xmlrpclib.Fault, socket.error):
58 | pass
59 | else:
60 | if status == 'active':
61 | return
62 | elif status == 'complete':
63 | notify(title='Download completed: ', msg=get_task_name(gid), gid=gid)
64 | elif status == 'error':
65 | notify(title='Error occurred while downloading "' + get_task_name(gid) + '":',
66 | msg=task['errorMessage'], gid=gid)
67 | with lock:
68 | watch_list.remove(gid)
69 |
70 |
71 | def get_task_name(gid):
72 | bt = server.tellStatus(secret, gid, ['bittorrent'])
73 | path = server.getFiles(secret, gid)[0]['path']
74 | if bt:
75 | file_num = len(server.getFiles(secret, gid))
76 | if 'info' in bt:
77 | bt_name = bt['bittorrent']['info']['name']
78 | else:
79 | bt_name = os.path.basename(os.path.dirname(path))
80 | if not bt_name:
81 | bt_name = 'Task name not obtained yet'
82 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num)
83 | else:
84 | name = os.path.basename(path)
85 | if not name:
86 | name = 'Task name not obtained yet'
87 | return name
88 |
89 |
90 | if __name__ == '__main__':
91 |
92 | watch_list = []
93 | lock = threading.Lock()
94 |
95 | wf = Workflow3()
96 |
97 | rpc_path = wf.settings['rpc_path']
98 | secret = 'token:' + wf.settings['secret']
99 | server = xmlrpclib.ServerProxy(rpc_path).aria2
100 |
101 | sys.exit(wf.run(main))
102 |
--------------------------------------------------------------------------------
/src/paused.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/paused.png
--------------------------------------------------------------------------------
/src/removed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/removed.png
--------------------------------------------------------------------------------
/src/stopped.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/stopped.png
--------------------------------------------------------------------------------
/src/upload.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/upload.png
--------------------------------------------------------------------------------
/src/version:
--------------------------------------------------------------------------------
1 | 1.5.4
2 |
--------------------------------------------------------------------------------
/src/waiting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/waiting.png
--------------------------------------------------------------------------------
/src/workflow/Notify.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/workflow/Notify.tgz
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/workflow/util.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright (c) 2017 Dean Jackson
5 | #
6 | # MIT Licence. See http://opensource.org/licenses/MIT
7 | #
8 | # Created on 2017-12-17
9 | #
10 |
11 | """A selection of helper functions useful for building workflows."""
12 |
13 | from __future__ import print_function, absolute_import
14 |
15 | import atexit
16 | from collections import namedtuple
17 | from contextlib import contextmanager
18 | import errno
19 | import fcntl
20 | import functools
21 | import os
22 | import signal
23 | import subprocess
24 | import sys
25 | from threading import Event
26 | import time
27 |
28 | # AppleScript to call an External Trigger in Alfred
29 | AS_TRIGGER = """
30 | tell application "Alfred 3"
31 | run trigger "{name}" in workflow "{bundleid}" {arg}
32 | end tell
33 | """
34 |
35 |
36 | class AcquisitionError(Exception):
37 | """Raised if a lock cannot be acquired."""
38 |
39 |
40 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
41 | """Information about an installed application.
42 |
43 | Returned by :func:`appinfo`. All attributes are Unicode.
44 |
45 | .. py:attribute:: name
46 |
47 | Name of the application, e.g. ``u'Safari'``.
48 |
49 | .. py:attribute:: path
50 |
51 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
52 |
53 | .. py:attribute:: bundleid
54 |
55 | Application's bundle ID, e.g. ``u'com.apple.Safari'``.
56 | """
57 |
58 |
59 | def unicodify(s, encoding='utf-8', norm=None):
60 | """Ensure string is Unicode.
61 |
62 | .. versionadded:: 1.31
63 |
64 | Decode encoded strings using ``encoding`` and normalise Unicode
65 | to form ``norm`` if specified.
66 |
67 | Args:
68 | s (str): String to decode. May also be Unicode.
69 | encoding (str, optional): Encoding to use on bytestrings.
70 | norm (None, optional): Normalisation form to apply to Unicode string.
71 |
72 | Returns:
73 | unicode: Decoded, optionally normalised, Unicode string.
74 |
75 | """
76 | if not isinstance(s, unicode):
77 | s = unicode(s, encoding)
78 |
79 | if norm:
80 | from unicodedata import normalize
81 | s = normalize(norm, s)
82 |
83 | return s
84 |
85 |
86 | def utf8ify(s):
87 | """Ensure string is a bytestring.
88 |
89 | .. versionadded:: 1.31
90 |
91 | Returns `str` objects unchanced, encodes `unicode` objects to
92 | UTF-8, and calls :func:`str` on anything else.
93 |
94 | Args:
95 | s (object): A Python object
96 |
97 | Returns:
98 | str: UTF-8 string or string representation of s.
99 | """
100 | if isinstance(s, str):
101 | return s
102 |
103 | if isinstance(s, unicode):
104 | return s.encode('utf-8')
105 |
106 | return str(s)
107 |
108 |
109 | def applescriptify(s):
110 | """Escape string for insertion into an AppleScript string.
111 |
112 | .. versionadded:: 1.31
113 |
114 | Replaces ``"`` with `"& quote &"`. Use this function if you want
115 |
116 | to insert a string into an AppleScript script:
117 | >>> script = 'tell application "Alfred 3" to search "{}"'
118 | >>> query = 'g "python" test'
119 | >>> script.format(applescriptify(query))
120 | 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"'
121 |
122 | Args:
123 | s (unicode): Unicode string to escape.
124 |
125 | Returns:
126 | unicode: Escaped string
127 | """
128 | return s.replace(u'"', u'" & quote & "')
129 |
130 |
131 | def run_command(cmd, **kwargs):
132 | """Run a command and return the output.
133 |
134 | .. versionadded:: 1.31
135 |
136 | A thin wrapper around :func:`subprocess.check_output` that ensures
137 | all arguments are encoded to UTF-8 first.
138 |
139 | Args:
140 | cmd (list): Command arguments to pass to ``check_output``.
141 | **kwargs: Keyword arguments to pass to ``check_output``.
142 |
143 | Returns:
144 | str: Output returned by ``check_output``.
145 | """
146 | cmd = [utf8ify(s) for s in cmd]
147 | return subprocess.check_output(cmd, **kwargs)
148 |
149 |
150 | def run_applescript(script, *args, **kwargs):
151 | """Execute an AppleScript script and return its output.
152 |
153 | .. versionadded:: 1.31
154 |
155 | Run AppleScript either by filepath or code. If ``script`` is a valid
156 | filepath, that script will be run, otherwise ``script`` is treated
157 | as code.
158 |
159 | Args:
160 | script (str, optional): Filepath of script or code to run.
161 | *args: Optional command-line arguments to pass to the script.
162 | **kwargs: Pass ``lang`` to run a language other than AppleScript.
163 |
164 | Returns:
165 | str: Output of run command.
166 |
167 | """
168 | cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')]
169 |
170 | if os.path.exists(script):
171 | cmd += [script]
172 | else:
173 | cmd += ['-e', script]
174 |
175 | cmd.extend(args)
176 |
177 | return run_command(cmd)
178 |
179 |
180 | def run_jxa(script, *args):
181 | """Execute a JXA script and return its output.
182 |
183 | .. versionadded:: 1.31
184 |
185 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
186 |
187 | Args:
188 | script (str): Filepath of script or code to run.
189 | *args: Optional command-line arguments to pass to script.
190 |
191 | Returns:
192 | str: Output of script.
193 | """
194 | return run_applescript(script, *args, lang='JavaScript')
195 |
196 |
197 | def run_trigger(name, bundleid=None, arg=None):
198 | """Call an Alfred External Trigger.
199 |
200 | .. versionadded:: 1.31
201 |
202 | If ``bundleid`` is not specified, reads the bundle ID of the current
203 | workflow from Alfred's environment variables.
204 |
205 | Args:
206 | name (str): Name of External Trigger to call.
207 | bundleid (str, optional): Bundle ID of workflow trigger belongs to.
208 | arg (str, optional): Argument to pass to trigger.
209 | """
210 | if not bundleid:
211 | bundleid = os.getenv('alfred_workflow_bundleid')
212 |
213 | if arg:
214 | arg = 'with argument "{}"'.format(applescriptify(arg))
215 | else:
216 | arg = ''
217 |
218 | script = AS_TRIGGER.format(name=name, bundleid=bundleid,
219 | arg=arg)
220 |
221 | run_applescript(script)
222 |
223 |
224 | def appinfo(name):
225 | """Get information about an installed application.
226 |
227 | .. versionadded:: 1.31
228 |
229 | Args:
230 | name (str): Name of application to look up.
231 |
232 | Returns:
233 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
234 | """
235 | cmd = ['mdfind', '-onlyin', '/',
236 | '(kMDItemContentTypeTree == com.apple.application &&'
237 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
238 | .format(name)]
239 |
240 | path = run_command(cmd).strip()
241 | if not path:
242 | return None
243 |
244 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
245 | bid = run_command(cmd).strip()
246 | if not bid: # pragma: no cover
247 | return None
248 |
249 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
250 |
251 |
252 | @contextmanager
253 | def atomic_writer(fpath, mode):
254 | """Atomic file writer.
255 |
256 | .. versionadded:: 1.12
257 |
258 | Context manager that ensures the file is only written if the write
259 | succeeds. The data is first written to a temporary file.
260 |
261 | :param fpath: path of file to write to.
262 | :type fpath: ``unicode``
263 | :param mode: sames as for :func:`open`
264 | :type mode: string
265 |
266 | """
267 | suffix = '.{}.tmp'.format(os.getpid())
268 | temppath = fpath + suffix
269 | with open(temppath, mode) as fp:
270 | try:
271 | yield fp
272 | os.rename(temppath, fpath)
273 | finally:
274 | try:
275 | os.remove(temppath)
276 | except (OSError, IOError):
277 | pass
278 |
279 |
280 | class LockFile(object):
281 | """Context manager to protect filepaths with lockfiles.
282 |
283 | .. versionadded:: 1.13
284 |
285 | Creates a lockfile alongside ``protected_path``. Other ``LockFile``
286 | instances will refuse to lock the same path.
287 |
288 | >>> path = '/path/to/file'
289 | >>> with LockFile(path):
290 | >>> with open(path, 'wb') as fp:
291 | >>> fp.write(data)
292 |
293 | Args:
294 | protected_path (unicode): File to protect with a lockfile
295 | timeout (float, optional): Raises an :class:`AcquisitionError`
296 | if lock cannot be acquired within this number of seconds.
297 | If ``timeout`` is 0 (the default), wait forever.
298 | delay (float, optional): How often to check (in seconds) if
299 | lock has been released.
300 |
301 | Attributes:
302 | delay (float): How often to check (in seconds) whether the lock
303 | can be acquired.
304 | lockfile (unicode): Path of the lockfile.
305 | timeout (float): How long to wait to acquire the lock.
306 |
307 | """
308 |
309 | def __init__(self, protected_path, timeout=0.0, delay=0.05):
310 | """Create new :class:`LockFile` object."""
311 | self.lockfile = protected_path + '.lock'
312 | self._lockfile = None
313 | self.timeout = timeout
314 | self.delay = delay
315 | self._lock = Event()
316 | atexit.register(self.release)
317 |
318 | @property
319 | def locked(self):
320 | """``True`` if file is locked by this instance."""
321 | return self._lock.is_set()
322 |
323 | def acquire(self, blocking=True):
324 | """Acquire the lock if possible.
325 |
326 | If the lock is in use and ``blocking`` is ``False``, return
327 | ``False``.
328 |
329 | Otherwise, check every :attr:`delay` seconds until it acquires
330 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
331 |
332 | """
333 | if self.locked and not blocking:
334 | return False
335 |
336 | start = time.time()
337 | while True:
338 |
339 | # Raise error if we've been waiting too long to acquire the lock
340 | if self.timeout and (time.time() - start) >= self.timeout:
341 | raise AcquisitionError('lock acquisition timed out')
342 |
343 | # If already locked, wait then try again
344 | if self.locked:
345 | time.sleep(self.delay)
346 | continue
347 |
348 | # Create in append mode so we don't lose any contents
349 | if self._lockfile is None:
350 | self._lockfile = open(self.lockfile, 'a')
351 |
352 | # Try to acquire the lock
353 | try:
354 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
355 | self._lock.set()
356 | break
357 | except IOError as err: # pragma: no cover
358 | if err.errno not in (errno.EACCES, errno.EAGAIN):
359 | raise
360 |
361 | # Don't try again
362 | if not blocking: # pragma: no cover
363 | return False
364 |
365 | # Wait, then try again
366 | time.sleep(self.delay)
367 |
368 | return True
369 |
370 | def release(self):
371 | """Release the lock by deleting `self.lockfile`."""
372 | if not self._lock.is_set():
373 | return False
374 |
375 | try:
376 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
377 | except IOError: # pragma: no cover
378 | pass
379 | finally:
380 | self._lock.clear()
381 | self._lockfile = None
382 | try:
383 | os.unlink(self.lockfile)
384 | except (IOError, OSError): # pragma: no cover
385 | pass
386 |
387 | return True
388 |
389 | def __enter__(self):
390 | """Acquire lock."""
391 | self.acquire()
392 | return self
393 |
394 | def __exit__(self, typ, value, traceback):
395 | """Release lock."""
396 | self.release()
397 |
398 | def __del__(self):
399 | """Clear up `self.lockfile`."""
400 | self.release() # pragma: no cover
401 |
402 |
403 | class uninterruptible(object):
404 | """Decorator that postpones SIGTERM until wrapped function returns.
405 |
406 | .. versionadded:: 1.12
407 |
408 | .. important:: This decorator is NOT thread-safe.
409 |
410 | As of version 2.7, Alfred allows Script Filters to be killed. If
411 | your workflow is killed in the middle of critical code (e.g.
412 | writing data to disk), this may corrupt your workflow's data.
413 |
414 | Use this decorator to wrap critical functions that *must* complete.
415 | If the script is killed while a wrapped function is executing,
416 | the SIGTERM will be caught and handled after your function has
417 | finished executing.
418 |
419 | Alfred-Workflow uses this internally to ensure its settings, data
420 | and cache writes complete.
421 |
422 | """
423 |
424 | def __init__(self, func, class_name=''):
425 | """Decorate `func`."""
426 | self.func = func
427 | functools.update_wrapper(self, func)
428 | self._caught_signal = None
429 |
430 | def signal_handler(self, signum, frame):
431 | """Called when process receives SIGTERM."""
432 | self._caught_signal = (signum, frame)
433 |
434 | def __call__(self, *args, **kwargs):
435 | """Trap ``SIGTERM`` and call wrapped function."""
436 | self._caught_signal = None
437 | # Register handler for SIGTERM, then call `self.func`
438 | self.old_signal_handler = signal.getsignal(signal.SIGTERM)
439 | signal.signal(signal.SIGTERM, self.signal_handler)
440 |
441 | self.func(*args, **kwargs)
442 |
443 | # Restore old signal handler
444 | signal.signal(signal.SIGTERM, self.old_signal_handler)
445 |
446 | # Handle any signal caught during execution
447 | if self._caught_signal is not None:
448 | signum, frame = self._caught_signal
449 | if callable(self.old_signal_handler):
450 | self.old_signal_handler(signum, frame)
451 | elif self.old_signal_handler == signal.SIG_DFL:
452 | sys.exit(0)
453 |
454 | def __get__(self, obj=None, klass=None):
455 | """Decorator API."""
456 | return self.__class__(self.func.__get__(obj, klass),
457 | klass.__name__)
458 |
--------------------------------------------------------------------------------
/src/workflow/version:
--------------------------------------------------------------------------------
1 | 1.32
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/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 ICON_WARNING, 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 warn_empty(self, title, subtitle=u'', icon=None):
685 | """Add a warning to feedback if there are no items.
686 |
687 | .. versionadded:: 1.31
688 |
689 | Add a "warning" item to Alfred feedback if no other items
690 | have been added. This is a handy shortcut to prevent Alfred
691 | from showing its fallback searches, which is does if no
692 | items are returned.
693 |
694 | Args:
695 | title (unicode): Title of feedback item.
696 | subtitle (unicode, optional): Subtitle of feedback item.
697 | icon (str, optional): Icon for feedback item. If not
698 | specified, ``ICON_WARNING`` is used.
699 |
700 | Returns:
701 | Item3: Newly-created item.
702 | """
703 | if len(self._items):
704 | return
705 |
706 | icon = icon or ICON_WARNING
707 | return self.add_item(title, subtitle, icon=icon)
708 |
709 | def send_feedback(self):
710 | """Print stored items to console/Alfred as JSON."""
711 | json.dump(self.obj, sys.stdout)
712 | sys.stdout.flush()
713 |
--------------------------------------------------------------------------------