├── LICENSE
├── README.md
├── ScreenShots
├── alfred_query.png
├── alfred_search.gif
└── alfred_send.png
├── WeChat Plugin.alfredworkflow
└── src
├── 7DECB235-5455-435D-90AF-1A51C26046DD.png
├── convert.py
├── icon.png
├── info.plist
├── openSession.py
├── search.py
├── sendMsg.py
├── userChatLog.py
├── version
├── web.py
└── workflow
├── .alfredversionchecked
├── Notify.tgz
├── __init__.py
├── __init__.pyc
├── background.py
├── notify.py
├── update.py
├── util.py
├── version
├── workflow.py
└── workflow3.py
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 TK
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, 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,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## wechat workflow for Alfred 3.0
3 |
4 |  
5 | 
6 | [](https://github.com/TKkk-iOSer/wechat-workflow/blob/master/LICENSE)
7 |
8 | 一款让你不用打开微信就能聊天的`alfred workflow` 3.0
9 |
10 | ---
11 |
12 | ### 安装
13 |
14 | * 下载 WeChat Plugin.alfredworkflow
15 | * 安装requests:pip install requests
16 | * 打开微信-菜单栏-微信小助手-小助手-开启alfred功能
17 |
18 | ### 功能
19 |
20 | * 快速搜索微信好友、群聊
21 | * 快捷发送消息
22 | * 快捷打开聊天窗口
23 | * 支持搜索好友,匹配昵称、备注、微信号、国家、省份、市。
24 | * 支持搜索群聊,匹配群聊昵称、群成员昵称、群成员备注名、群成员微信号
25 | * 支持复制微信号、聊天内容(**2.0**)
26 | * 聊天记录界面支持快捷打开微信聊天窗口(`Command + ↩︎`)(**2.0**)
27 | * 支持预览长文本消息(`Command + L`)(**2.0**)
28 | * 支持查看好友高清头像(`Command + Y`或者`shift`)(**2.0**)
29 | * 支持预览视频、表情、图片、网址等消息(`Command + Y`或者`shift`)(**2.0**)
30 | * 支持播放音频消息(直接选中音频消息回车)(**2.0**)
31 | * 聊天记录显示发送时间(**2.0**)
32 | * 默认打开最近聊天记录 & 获取聊天内容(**2.0**)
33 | * 支持python3(**3.0**)
34 |
35 | ---
36 |
37 | ### Demo 演示
38 |
39 | 
40 |
41 | ~~懒得录制 gif 图了,自己看[功能](#功能) 或者 [使用](#使用) 或者 `Alfred` 中的提示~~
42 |
43 | ---
44 |
45 | ### 使用
46 | * 下载该 [wechat-alfred-workflow](https://github.com/TKkk-iOSer/wechat-alfred-workflow/releases) & [WeChatPlugin-macOS](https://github.com/TKkk-iOSer/WeChatPlugin-MacOS)
47 |
48 | * 启动`Alfred`,输入`wx`启动该 workflow。
49 |
50 | * 启动`Alfred`,输入 `wx` + `空格` 键,快捷打开最近聊天会话。
51 |
52 | 
53 |
54 | * 搜索到好友,点击 `Enter` 键,并输入内容,则发送消息给好友(此时可看到下方30条最新聊天记录)。
55 |
56 | 
57 |
58 | * 搜索到好友(或者发送消息界面),点击 `Command + Enter` 键,快捷打开聊天窗口。
59 | * 选中好友(聊天记录),点击 `Command + C` 键,复制好友微信号(聊天内容)。
60 | * 选中聊天消息,`Command + L`预览长文本消息。
61 | * 选中好友,`Command + Y`(`shift`)查看好友高清头像。
62 | * 选中视频、表情、图片、网址等消息`Command + Y`(`shift`)预览非文本内容。
63 | * 选中音频消息,在未输入发送内容时回车,可播放音频。
64 |
65 | ---
66 |
67 | ### 依赖
68 |
69 | * [Alfred-Workflow](http://www.deanishe.net/alfred-workflow/index.html)
70 |
71 | ---
72 |
73 | #### 听说你想请我喝下午茶?😏
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/ScreenShots/alfred_query.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_query.png
--------------------------------------------------------------------------------
/ScreenShots/alfred_search.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_search.gif
--------------------------------------------------------------------------------
/ScreenShots/alfred_send.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_send.png
--------------------------------------------------------------------------------
/WeChat Plugin.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/WeChat Plugin.alfredworkflow
--------------------------------------------------------------------------------
/src/7DECB235-5455-435D-90AF-1A51C26046DD.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/7DECB235-5455-435D-90AF-1A51C26046DD.png
--------------------------------------------------------------------------------
/src/convert.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | import sys,os
3 | from workflow import Workflow3
4 |
5 | # reload(sys)
6 | # sys.setdefaultencoding('utf-8')
7 |
8 | def main(wf):
9 | userId = os.getenv('userId')
10 | data = wf.stored_data('wechat_search_user_list')
11 | for item in data:
12 | if item['userId'] == userId:
13 | title = 'To:'+item['title']
14 | subTitle = item['subTitle']
15 | icon = item['icon']
16 | wf.add_item(title=title, subtitle=subTitle, icon=icon, valid=True, arg=sys.argv[1])
17 | wf.send_feedback()
18 | if __name__ == '__main__':
19 | wf = Workflow3()
20 | sys.exit(wf.run(main))
21 |
--------------------------------------------------------------------------------
/src/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/icon.png
--------------------------------------------------------------------------------
/src/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | tkkk.wechatPlugin
7 | category
8 | Tools
9 | connections
10 |
11 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187
12 |
13 |
14 | destinationuid
15 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1
16 | modifiers
17 | 1048576
18 | modifiersubtext
19 | ↩: 打开聊天窗口; C: 复制消息内容; L: 查看消息详情; Y: 预览非文本信息
20 | vitoclose
21 |
22 |
23 |
24 | destinationuid
25 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7
26 | modifiers
27 | 0
28 | modifiersubtext
29 |
30 | vitoclose
31 |
32 |
33 |
34 | 7DECB235-5455-435D-90AF-1A51C26046DD
35 |
36 |
37 | destinationuid
38 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE
39 | modifiers
40 | 1048576
41 | modifiersubtext
42 | ↩: 打开聊天窗口; C: 复制微信号
43 | vitoclose
44 |
45 |
46 |
47 | destinationuid
48 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83
49 | modifiers
50 | 0
51 | modifiersubtext
52 |
53 | vitoclose
54 |
55 |
56 |
57 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83
58 |
59 |
60 | destinationuid
61 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187
62 | modifiers
63 | 0
64 | modifiersubtext
65 |
66 | vitoclose
67 |
68 |
69 |
70 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1
71 |
72 |
73 | destinationuid
74 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D
75 | modifiers
76 | 0
77 | modifiersubtext
78 |
79 | vitoclose
80 |
81 |
82 |
83 | C06A928C-83DB-4998-A6C9-04DDE70705A3
84 |
85 |
86 | destinationuid
87 | 7DECB235-5455-435D-90AF-1A51C26046DD
88 | modifiers
89 | 0
90 | modifiersubtext
91 |
92 | vitoclose
93 |
94 |
95 |
96 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE
97 |
98 |
99 | destinationuid
100 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1
101 | modifiers
102 | 0
103 | modifiersubtext
104 |
105 | vitoclose
106 |
107 |
108 |
109 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7
110 |
111 |
112 | createdby
113 | TKkk
114 | description
115 | 快速搜索好友(群聊)、发送消息
116 | disabled
117 |
118 | name
119 | WeChat Plugin
120 | objects
121 |
122 |
123 | config
124 |
125 | concurrently
126 |
127 | escaping
128 | 127
129 | script
130 | python3 openSession.py {query}
131 | scriptargtype
132 | 0
133 | scriptfile
134 |
135 | type
136 | 0
137 |
138 | type
139 | alfred.workflow.action.script
140 | uid
141 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1
142 | version
143 | 2
144 |
145 |
146 | config
147 |
148 | paths
149 |
150 | /Applications/WeChat.app
151 |
152 | toggle
153 |
154 |
155 | type
156 | alfred.workflow.action.launchfiles
157 | uid
158 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D
159 | version
160 | 1
161 |
162 |
163 | config
164 |
165 | json
166 | {
167 | "alfredworkflow" : {
168 | "arg" : "",
169 | "config" : {
170 | },
171 | "variables" : {
172 | "userId" : "{query}",
173 | }
174 | }
175 | }
176 |
177 | type
178 | alfred.workflow.utility.json
179 | uid
180 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE
181 | version
182 | 1
183 |
184 |
185 | config
186 |
187 | alfredfiltersresults
188 |
189 | alfredfiltersresultsmatchmode
190 | 0
191 | argumenttreatemptyqueryasnil
192 |
193 | argumenttrimmode
194 | 1
195 | argumenttype
196 | 1
197 | escaping
198 | 102
199 | keyword
200 | wx
201 | queuedelaycustom
202 | 3
203 | queuedelayimmediatelyinitially
204 |
205 | queuedelaymode
206 | 0
207 | queuemode
208 | 1
209 | runningsubtext
210 | 搜索中…
211 | script
212 | python3 search.py "{query}"
213 | scriptargtype
214 | 0
215 | scriptfile
216 |
217 | subtext
218 | ↩: 快捷发送消息;⌘ + ↩:快捷打开窗口; ⌘ + C: 复制微信号
219 | title
220 | 微信小助手
221 | type
222 | 0
223 | withspace
224 |
225 |
226 | type
227 | alfred.workflow.input.scriptfilter
228 | uid
229 | 7DECB235-5455-435D-90AF-1A51C26046DD
230 | version
231 | 3
232 |
233 |
234 | config
235 |
236 | action
237 | 0
238 | argument
239 | 0
240 | focusedappvariable
241 |
242 | focusedappvariablename
243 |
244 | hotkey
245 | -1
246 | hotmod
247 | 524288
248 | hotstring
249 | double tap
250 | leftcursor
251 |
252 | modsmode
253 | 0
254 | relatedAppsMode
255 | 0
256 |
257 | type
258 | alfred.workflow.trigger.hotkey
259 | uid
260 | C06A928C-83DB-4998-A6C9-04DDE70705A3
261 | version
262 | 2
263 |
264 |
265 | config
266 |
267 | alfredfiltersresults
268 |
269 | alfredfiltersresultsmatchmode
270 | 0
271 | argumenttreatemptyqueryasnil
272 |
273 | argumenttrimmode
274 | 0
275 | argumenttype
276 | 1
277 | escaping
278 | 102
279 | queuedelaycustom
280 | 3
281 | queuedelayimmediatelyinitially
282 |
283 | queuedelaymode
284 | 0
285 | queuemode
286 | 1
287 | runningsubtext
288 | 正在搜索中…
289 | script
290 | python3 userChatLog.py "{query}"
291 | scriptargtype
292 | 0
293 | scriptfile
294 |
295 | subtext
296 |
297 | title
298 |
299 | type
300 | 0
301 | withspace
302 |
303 |
304 | type
305 | alfred.workflow.input.scriptfilter
306 | uid
307 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187
308 | version
309 | 3
310 |
311 |
312 | config
313 |
314 | concurrently
315 |
316 | escaping
317 | 102
318 | script
319 | python3 sendMsg.py "{query}"
320 | scriptargtype
321 | 0
322 | scriptfile
323 |
324 | type
325 | 0
326 |
327 | type
328 | alfred.workflow.action.script
329 | uid
330 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7
331 | version
332 | 2
333 |
334 |
335 | config
336 |
337 | json
338 | {
339 | "alfredworkflow" : {
340 | "arg" : "",
341 | "config" : {
342 | },
343 | "variables" : {
344 | "userId" : "{query}",
345 | }
346 | }
347 | }
348 |
349 | type
350 | alfred.workflow.utility.json
351 | uid
352 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83
353 | version
354 | 1
355 |
356 |
357 | readme
358 | 配合微信插件
359 | 支持python3
360 | uidata
361 |
362 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187
363 |
364 | xpos
365 | 510
366 | ypos
367 | 190
368 |
369 | 7DECB235-5455-435D-90AF-1A51C26046DD
370 |
371 | xpos
372 | 220
373 | ypos
374 | 100
375 |
376 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83
377 |
378 | xpos
379 | 430
380 | ypos
381 | 220
382 |
383 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1
384 |
385 | xpos
386 | 730
387 | ypos
388 | 10
389 |
390 | C06A928C-83DB-4998-A6C9-04DDE70705A3
391 |
392 | xpos
393 | 40
394 | ypos
395 | 100
396 |
397 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE
398 |
399 | xpos
400 | 430
401 | ypos
402 | 40
403 |
404 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D
405 |
406 | xpos
407 | 930
408 | ypos
409 | 10
410 |
411 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7
412 |
413 | xpos
414 | 860
415 | ypos
416 | 190
417 |
418 |
419 | variables
420 |
421 | baseUrl
422 | http://127.0.0.1:57270/wechat-plugin/
423 |
424 | variablesdontexport
425 |
426 | version
427 | 3.0
428 | webaddress
429 | https://github.com/TKkk-iOSer
430 |
431 |
432 |
--------------------------------------------------------------------------------
/src/openSession.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | import json,sys,os
3 | from workflow import Workflow
4 | import web
5 |
6 | def main(wf):
7 | srvId = 0
8 | if len(sys.argv) > 1:
9 | srvId = sys.argv[1]
10 | pass
11 | baseUrl = os.getenv('baseUrl')
12 | userId = os.getenv('userId')
13 | url = baseUrl + 'open-session'
14 | data = {'userId': userId, 'srvId': srvId}
15 | r = web.post(url=url,data=data)
16 | r.raise_for_status()
17 | wf.send_feedback()
18 |
19 | if __name__ == '__main__':
20 | wf = Workflow()
21 | sys.exit(wf.run(main))
22 |
--------------------------------------------------------------------------------
/src/search.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | import json,sys,os
3 | from workflow import Workflow
4 | import web
5 |
6 | def main(wf):
7 | query = sys.argv[1]
8 | baseUrl = os.getenv('baseUrl')
9 | url = baseUrl + 'user?keyword=' + query
10 | try:
11 | userList = web.get(url)
12 | if len(userList) > 0:
13 | for item in userList:
14 | title = item['title']
15 | subtitle = item['subTitle']
16 | icon = item['icon']
17 | userId = item['userId']
18 | copyText = item['copyText']
19 | qlurl = item['url']
20 | if 'unReadCount' in item:
21 | unReadCount = item['unReadCount']
22 | if unReadCount > 0:
23 | title = title + ' (未读: ' + str(unReadCount) + ')'
24 | wf.add_item(title=title, subtitle=subtitle, icon=icon, largetext=title, copytext=copyText, quicklookurl=qlurl, arg=userId, valid=True)
25 | else:
26 | wf.add_item(title='找不到联系人…',subtitle='请重新输入')
27 | except IOError:
28 | wf.add_item(title='请先启动微信 & 登录…',subtitle='并确保安装微信小助手')
29 | wf.send_feedback()
30 |
31 | if __name__ == '__main__':
32 | wf = Workflow()
33 | sys.exit(wf.run(main))
34 |
--------------------------------------------------------------------------------
/src/sendMsg.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | import sys,os
3 | from workflow import Workflow
4 | import web
5 |
6 | def main(wf):
7 | srvId = sys.argv[1]
8 | userId = os.getenv('userId')
9 | baseUrl = os.getenv('baseUrl')
10 | msgContent = wf.stored_data('wechat_send_content')
11 |
12 | url = baseUrl + 'send-message'
13 | data = {'userId':userId, 'content': msgContent, 'srvId': srvId}
14 | r = web.post(url=url,data=data)
15 | wf.send_feedback()
16 |
17 | if __name__ == '__main__':
18 | wf = Workflow()
19 | sys.exit(wf.run(main))
20 |
--------------------------------------------------------------------------------
/src/userChatLog.py:
--------------------------------------------------------------------------------
1 | # -*- coding:utf-8 -*-
2 | import json,sys,os
3 | from workflow import Workflow
4 | import web
5 |
6 |
7 | def main(wf):
8 | query = sys.argv[1].encode()
9 | userId = os.getenv('userId')
10 | baseUrl = os.getenv('baseUrl')
11 | url = baseUrl + 'chatlog?userId=' + userId + '&count=60'
12 | try:
13 | userList = web.get(url=url)
14 | if len(userList) > 0:
15 | wf.store_data('wechat_send_content',query)
16 | for item in userList:
17 | title = item['title']
18 | subtitle = item['subTitle']
19 | icon = item['icon']
20 | userId = item['userId']
21 | copyText = item['copyText']
22 | qlurl = item['url']
23 | srvId = str(item['srvId'])
24 | l = len(title)
25 | lineNun = 70
26 | # if l < lineNun:
27 | largetext = title
28 | # else:
29 | # b = []
30 | # for n in range(l):
31 | # if n % lineNun == 0:
32 | # b.append(title[n:n+lineNun])
33 | # largetext='\n'.join(b)
34 | wf.add_item(title=title, subtitle=subtitle, icon=icon, valid=True, largetext=largetext, quicklookurl=qlurl, copytext=copyText, arg=srvId)
35 | else:
36 | wf.add_item(title='找不到联系人…',subtitle='请重新输入')
37 | except IOError:
38 | wf.add_item(title='请先启动微信 & 登录…',subtitle='并确保安装微信小助手')
39 |
40 | wf.send_feedback()
41 | if __name__ == '__main__':
42 | wf = Workflow()
43 | sys.exit(wf.run(main))
44 |
--------------------------------------------------------------------------------
/src/version:
--------------------------------------------------------------------------------
1 | 3.0
--------------------------------------------------------------------------------
/src/web.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #!/usr/bin/env python3
3 | # @File : web.py
4 | # @Time : 2023/03/06 12:34:48
5 | # @Author : TKkk
6 | # @Email : tkk.ioser@gmail.com
7 |
8 | import os, json
9 | import requests
10 |
11 | def get(url):
12 | data = requests.get(url).text
13 | return json.loads(data)
14 |
15 | def post(url, data):
16 | data = requests.post(url, data)
17 | return data
18 |
19 | def main():
20 | None
21 |
22 | if __name__ == "__main__":
23 | main()
--------------------------------------------------------------------------------
/src/workflow/.alfredversionchecked:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/workflow/.alfredversionchecked
--------------------------------------------------------------------------------
/src/workflow/Notify.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/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 | # Filter matching rules
16 | # Icons
17 | # Exceptions
18 | # Workflow objects
19 | from .workflow import (
20 | ICON_ACCOUNT,
21 | ICON_BURN,
22 | ICON_CLOCK,
23 | ICON_COLOR,
24 | ICON_COLOUR,
25 | ICON_EJECT,
26 | ICON_ERROR,
27 | ICON_FAVORITE,
28 | ICON_FAVOURITE,
29 | ICON_GROUP,
30 | ICON_HELP,
31 | ICON_HOME,
32 | ICON_INFO,
33 | ICON_NETWORK,
34 | ICON_NOTE,
35 | ICON_SETTINGS,
36 | ICON_SWIRL,
37 | ICON_SWITCH,
38 | ICON_SYNC,
39 | ICON_TRASH,
40 | ICON_USER,
41 | ICON_WARNING,
42 | ICON_WEB,
43 | MATCH_ALL,
44 | MATCH_ALLCHARS,
45 | MATCH_ATOM,
46 | MATCH_CAPITALS,
47 | MATCH_INITIALS,
48 | MATCH_INITIALS_CONTAIN,
49 | MATCH_INITIALS_STARTSWITH,
50 | MATCH_STARTSWITH,
51 | MATCH_SUBSTRING,
52 | KeychainError,
53 | PasswordNotFound,
54 | Workflow,
55 | manager,
56 | )
57 | from .workflow3 import Variables, Workflow3
58 |
59 | __title__ = "Alfred-Workflow"
60 | __version__ = open(os.path.join(os.path.dirname(__file__), "version")).read()
61 | __author__ = "Dean Jackson"
62 | __licence__ = "MIT"
63 | __copyright__ = "Copyright 2014-2019 Dean Jackson"
64 |
65 | __all__ = [
66 | "Variables",
67 | "Workflow",
68 | "Workflow3",
69 | "manager",
70 | "PasswordNotFound",
71 | "KeychainError",
72 | "ICON_ACCOUNT",
73 | "ICON_BURN",
74 | "ICON_CLOCK",
75 | "ICON_COLOR",
76 | "ICON_COLOUR",
77 | "ICON_EJECT",
78 | "ICON_ERROR",
79 | "ICON_FAVORITE",
80 | "ICON_FAVOURITE",
81 | "ICON_GROUP",
82 | "ICON_HELP",
83 | "ICON_HOME",
84 | "ICON_INFO",
85 | "ICON_NETWORK",
86 | "ICON_NOTE",
87 | "ICON_SETTINGS",
88 | "ICON_SWIRL",
89 | "ICON_SWITCH",
90 | "ICON_SYNC",
91 | "ICON_TRASH",
92 | "ICON_USER",
93 | "ICON_WARNING",
94 | "ICON_WEB",
95 | "MATCH_ALL",
96 | "MATCH_ALLCHARS",
97 | "MATCH_ATOM",
98 | "MATCH_CAPITALS",
99 | "MATCH_INITIALS",
100 | "MATCH_INITIALS_CONTAIN",
101 | "MATCH_INITIALS_STARTSWITH",
102 | "MATCH_STARTSWITH",
103 | "MATCH_SUBSTRING",
104 | ]
105 |
--------------------------------------------------------------------------------
/src/workflow/__init__.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/workflow/__init__.pyc
--------------------------------------------------------------------------------
/src/workflow/background.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | #
3 | # Copyright (c) 2014 deanishe@deanishe.net
4 | #
5 | # MIT Licence. See http://opensource.org/licenses/MIT
6 | #
7 | # Created on 2014-04-06
8 | #
9 |
10 | """This module provides an API to run commands in background processes.
11 |
12 | Combine with the :ref:`caching API ` to work from cached data
13 | while you fetch fresh data in the background.
14 |
15 | See :ref:`the User Manual ` for more information
16 | and examples.
17 | """
18 |
19 |
20 | import os
21 | import pickle
22 | import signal
23 | import subprocess
24 | import sys
25 |
26 | from workflow import Workflow
27 |
28 | __all__ = ["is_running", "run_in_background"]
29 |
30 | _wf = None
31 |
32 |
33 | def wf():
34 | global _wf
35 | if _wf is None:
36 | _wf = Workflow()
37 | return _wf
38 |
39 |
40 | def _log():
41 | return wf().logger
42 |
43 |
44 | def _arg_cache(name):
45 | """Return path to pickle cache file for arguments.
46 |
47 | :param name: name of task
48 | :type name: ``unicode``
49 | :returns: Path to cache file
50 | :rtype: ``unicode`` filepath
51 |
52 | """
53 | return wf().cachefile(name + ".argcache")
54 |
55 |
56 | def _pid_file(name):
57 | """Return path to PID file for ``name``.
58 |
59 | :param name: name of task
60 | :type name: ``unicode``
61 | :returns: Path to PID file for task
62 | :rtype: ``unicode`` filepath
63 |
64 | """
65 | return wf().cachefile(name + ".pid")
66 |
67 |
68 | def _process_exists(pid):
69 | """Check if a process with PID ``pid`` exists.
70 |
71 | :param pid: PID to check
72 | :type pid: ``int``
73 | :returns: ``True`` if process exists, else ``False``
74 | :rtype: ``Boolean``
75 |
76 | """
77 | try:
78 | os.kill(pid, 0)
79 | except OSError: # not running
80 | return False
81 | return True
82 |
83 |
84 | def _job_pid(name):
85 | """Get PID of job or `None` if job does not exist.
86 |
87 | Args:
88 | name (str): Name of job.
89 |
90 | Returns:
91 | int: PID of job process (or `None` if job doesn't exist).
92 | """
93 | pidfile = _pid_file(name)
94 | if not os.path.exists(pidfile):
95 | return
96 |
97 | with open(pidfile, "rb") as fp:
98 | read = fp.read()
99 | # print(str(read))
100 | pid = int.from_bytes(read, sys.byteorder)
101 | # print(pid)
102 |
103 | if _process_exists(pid):
104 | return pid
105 |
106 | os.unlink(pidfile)
107 |
108 |
109 | def is_running(name):
110 | """Test whether task ``name`` is currently running.
111 |
112 | :param name: name of task
113 | :type name: unicode
114 | :returns: ``True`` if task with name ``name`` is running, else ``False``
115 | :rtype: bool
116 |
117 | """
118 | if _job_pid(name) is not None:
119 | return True
120 |
121 | return False
122 |
123 |
124 | def _background(
125 | pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null"
126 | ): # pragma: no cover
127 | """Fork the current process into a background daemon.
128 |
129 | :param pidfile: file to write PID of daemon process to.
130 | :type pidfile: filepath
131 | :param stdin: where to read input
132 | :type stdin: filepath
133 | :param stdout: where to write stdout output
134 | :type stdout: filepath
135 | :param stderr: where to write stderr output
136 | :type stderr: filepath
137 |
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(pid.to_bytes(4, sys.byteorder))
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", 1)
169 | so = open(stdout, "a+", 1)
170 | se = open(stderr, "a+", 1)
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 = [sys.executable, "-m", "workflow.background", 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.
15 |
16 | This feature is only available on Mountain Lion (10.8) and later.
17 | It will silently fail on older systems.
18 |
19 | The main API is a single function, :func:`~workflow.notify.notify`.
20 |
21 | It works by copying a simple application to your workflow's data
22 | directory. It replaces the application's icon with your workflow's
23 | icon and then calls the application to post notifications.
24 | """
25 |
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 | from typing import List
36 |
37 | from . import workflow
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 | if not os.path.exists(n): # pragma: nocover
120 | raise RuntimeError("Notify.app could not be installed in " + 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 NSImage, NSWorkspace
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.load(ip_path)
147 | log().debug("changing bundle ID to %r", bundle_id)
148 | data["CFBundleIdentifier"] = bundle_id
149 | plistlib.dump(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 usr_bin_env(*args: str) -> List[str]:
213 | return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args)
214 |
215 |
216 | def convert_image(inpath, outpath, size):
217 | """Convert an image file using ``sips``.
218 |
219 | Args:
220 | inpath (str): Path of source file.
221 | outpath (str): Path to destination file.
222 | size (int): Width and height of destination image in pixels.
223 |
224 | Raises:
225 | RuntimeError: Raised if ``sips`` exits with non-zero status.
226 | """
227 | cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath]
228 | # log().debug(cmd)
229 | with open(os.devnull, "w") as pipe:
230 | retcode = subprocess.call(
231 | cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT
232 | )
233 |
234 | if retcode != 0:
235 | raise RuntimeError("sips exited with %d" % retcode)
236 |
237 |
238 | def png_to_icns(png_path, icns_path):
239 | """Convert PNG file to ICNS using ``iconutil``.
240 |
241 | Create an iconset from the source PNG file. Generate PNG files
242 | in each size required by macOS, then call ``iconutil`` to turn
243 | them into a single ICNS file.
244 |
245 | Args:
246 | png_path (str): Path to source PNG file.
247 | icns_path (str): Path to destination ICNS file.
248 |
249 | Raises:
250 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail.
251 | """
252 | tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir)
253 |
254 | try:
255 | iconset = os.path.join(tempdir, "Icon.iconset")
256 |
257 | if os.path.exists(iconset): # pragma: nocover
258 | raise RuntimeError("iconset already exists: " + iconset)
259 |
260 | os.makedirs(iconset)
261 |
262 | # Copy source icon to icon set and generate all the other
263 | # sizes needed
264 | configs = []
265 | for i in (16, 32, 128, 256, 512):
266 | configs.append(("icon_{0}x{0}.png".format(i), i))
267 | configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2)))
268 |
269 | shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png"))
270 | shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png"))
271 |
272 | for name, size in configs:
273 | outpath = os.path.join(iconset, name)
274 | if os.path.exists(outpath):
275 | continue
276 | convert_image(png_path, outpath, size)
277 |
278 | cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset]
279 |
280 | retcode = subprocess.call(cmd)
281 | if retcode != 0:
282 | raise RuntimeError("iconset exited with %d" % retcode)
283 |
284 | if not os.path.exists(icns_path): # pragma: nocover
285 | raise ValueError("generated ICNS file not found: " + repr(icns_path))
286 | finally:
287 | try:
288 | shutil.rmtree(tempdir)
289 | except OSError: # pragma: no cover
290 | pass
291 |
292 |
293 | if __name__ == "__main__": # pragma: nocover
294 | # Simple command-line script to test module with
295 | # This won't work on 2.6, as `argparse` isn't available
296 | # by default.
297 | import argparse
298 | from unicodedata import normalize
299 |
300 | def ustr(s):
301 | """Coerce `s` to normalised Unicode."""
302 | return normalize("NFD", s.decode("utf-8"))
303 |
304 | p = argparse.ArgumentParser()
305 | p.add_argument("-p", "--png", help="PNG image to convert to ICNS.")
306 | p.add_argument(
307 | "-l", "--list-sounds", help="Show available sounds.", action="store_true"
308 | )
309 | p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="")
310 | p.add_argument(
311 | "-s", "--sound", type=ustr, help="Optional notification sound.", default=""
312 | )
313 | p.add_argument(
314 | "text", type=ustr, help="Notification body text.", default="", nargs="?"
315 | )
316 | o = p.parse_args()
317 |
318 | # List available sounds
319 | if o.list_sounds:
320 | for sound in SOUNDS:
321 | print(sound)
322 | sys.exit(0)
323 |
324 | # Convert PNG to ICNS
325 | if o.png:
326 | icns = os.path.join(
327 | os.path.dirname(o.png),
328 | os.path.splitext(os.path.basename(o.png))[0] + ".icns",
329 | )
330 |
331 | print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr)
332 |
333 | if os.path.exists(icns):
334 | raise ValueError("destination file already exists: " + icns)
335 |
336 | png_to_icns(o.png, icns)
337 | sys.exit(0)
338 |
339 | # Post notification
340 | if o.title == o.text == "":
341 | print("ERROR: empty notification.", file=sys.stderr)
342 | sys.exit(1)
343 | else:
344 | notify(o.title, o.text, o.sound)
345 |
--------------------------------------------------------------------------------
/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 |
25 | import json
26 | import os
27 | import re
28 | import subprocess
29 | import tempfile
30 | from collections import defaultdict
31 | from functools import total_ordering
32 | from itertools import zip_longest
33 | from urllib import request
34 |
35 | from workflow.util import atomic_writer
36 |
37 | from . import workflow
38 |
39 | # __all__ = []
40 |
41 |
42 | RELEASES_BASE = "https://api.github.com/repos/{}/releases"
43 | match_workflow = re.compile(r"\.alfred(\d+)?workflow$").search
44 |
45 | _wf = None
46 |
47 |
48 | def wf():
49 | """Lazy `Workflow` object."""
50 | global _wf
51 | if _wf is None:
52 | _wf = workflow.Workflow()
53 | return _wf
54 |
55 |
56 | @total_ordering
57 | class Download(object):
58 | """A workflow file that is available for download.
59 |
60 | .. versionadded: 1.37
61 |
62 | Attributes:
63 | url (str): URL of workflow file.
64 | filename (str): Filename of workflow file.
65 | version (Version): Semantic version of workflow.
66 | prerelease (bool): Whether version is a pre-release.
67 | alfred_version (Version): Minimum compatible version
68 | of Alfred.
69 |
70 | """
71 |
72 | @classmethod
73 | def from_dict(cls, d):
74 | """Create a `Download` from a `dict`."""
75 | return cls(
76 | url=d["url"],
77 | filename=d["filename"],
78 | version=Version(d["version"]),
79 | prerelease=d["prerelease"],
80 | )
81 |
82 | @classmethod
83 | def from_releases(cls, js):
84 | """Extract downloads from GitHub releases.
85 |
86 | Searches releases with semantic tags for assets with
87 | file extension .alfredworkflow or .alfredXworkflow where
88 | X is a number.
89 |
90 | Files are returned sorted by latest version first. Any
91 | releases containing multiple files with the same (workflow)
92 | extension are rejected as ambiguous.
93 |
94 | Args:
95 | js (str): JSON response from GitHub's releases endpoint.
96 |
97 | Returns:
98 | list: Sequence of `Download`.
99 | """
100 | releases = json.loads(js)
101 | downloads = []
102 | for release in releases:
103 | tag = release["tag_name"]
104 | dupes = defaultdict(int)
105 | try:
106 | version = Version(tag)
107 | except ValueError as err:
108 | wf().logger.debug('ignored release: bad version "%s": %s', tag, err)
109 | continue
110 |
111 | dls = []
112 | for asset in release.get("assets", []):
113 | url = asset.get("browser_download_url")
114 | filename = os.path.basename(url)
115 | m = match_workflow(filename)
116 | if not m:
117 | wf().logger.debug("unwanted file: %s", filename)
118 | continue
119 |
120 | ext = m.group(0)
121 | dupes[ext] = dupes[ext] + 1
122 | dls.append(Download(url, filename, version, release["prerelease"]))
123 |
124 | valid = True
125 | for ext, n in list(dupes.items()):
126 | if n > 1:
127 | wf().logger.debug(
128 | 'ignored release "%s": multiple assets ' 'with extension "%s"',
129 | tag,
130 | ext,
131 | )
132 | valid = False
133 | break
134 |
135 | if valid:
136 | downloads.extend(dls)
137 |
138 | downloads.sort(reverse=True)
139 | return downloads
140 |
141 | def __init__(self, url, filename, version, prerelease=False):
142 | """Create a new Download.
143 |
144 | Args:
145 | url (str): URL of workflow file.
146 | filename (str): Filename of workflow file.
147 | version (Version): Version of workflow.
148 | prerelease (bool, optional): Whether version is
149 | pre-release. Defaults to False.
150 |
151 | """
152 | if isinstance(version, str):
153 | version = Version(version)
154 |
155 | self.url = url
156 | self.filename = filename
157 | self.version = version
158 | self.prerelease = prerelease
159 |
160 | @property
161 | def alfred_version(self):
162 | """Minimum Alfred version based on filename extension."""
163 | m = match_workflow(self.filename)
164 | if not m or not m.group(1):
165 | return Version("0")
166 | return Version(m.group(1))
167 |
168 | @property
169 | def dict(self):
170 | """Convert `Download` to `dict`."""
171 | return dict(
172 | url=self.url,
173 | filename=self.filename,
174 | version=str(self.version),
175 | prerelease=self.prerelease,
176 | )
177 |
178 | def __str__(self):
179 | """Format `Download` for printing."""
180 | return (
181 | "Download("
182 | "url={dl.url!r}, "
183 | "filename={dl.filename!r}, "
184 | "version={dl.version!r}, "
185 | "prerelease={dl.prerelease!r}"
186 | ")"
187 | ).format(dl=self)
188 |
189 | def __repr__(self):
190 | """Code-like representation of `Download`."""
191 | return str(self)
192 |
193 | def __eq__(self, other):
194 | """Compare Downloads based on version numbers."""
195 | if (
196 | self.url != other.url
197 | or self.filename != other.filename
198 | or self.version != other.version
199 | or self.prerelease != other.prerelease
200 | ):
201 | return False
202 | return True
203 |
204 | def __ne__(self, other):
205 | """Compare Downloads based on version numbers."""
206 | return not self.__eq__(other)
207 |
208 | def __lt__(self, other):
209 | """Compare Downloads based on version numbers."""
210 | if self.version != other.version:
211 | return self.version < other.version
212 | return self.alfred_version < other.alfred_version
213 |
214 |
215 | class Version(object):
216 | """Mostly semantic versioning.
217 |
218 | The main difference to proper :ref:`semantic versioning `
219 | is that this implementation doesn't require a minor or patch version.
220 |
221 | Version strings may also be prefixed with "v", e.g.:
222 |
223 | >>> v = Version('v1.1.1')
224 | >>> v.tuple
225 | (1, 1, 1, '')
226 |
227 | >>> v = Version('2.0')
228 | >>> v.tuple
229 | (2, 0, 0, '')
230 |
231 | >>> Version('3.1-beta').tuple
232 | (3, 1, 0, 'beta')
233 |
234 | >>> Version('1.0.1') > Version('0.0.1')
235 | True
236 | """
237 |
238 | #: Match version and pre-release/build information in version strings
239 | match_version = re.compile(r"([0-9][0-9\.]*)(.+)?").match
240 |
241 | def __init__(self, vstr):
242 | """Create new `Version` object.
243 |
244 | Args:
245 | vstr (basestring): Semantic version string.
246 | """
247 | if not vstr:
248 | raise ValueError("invalid version number: {!r}".format(vstr))
249 |
250 | self.vstr = vstr
251 | self.major = 0
252 | self.minor = 0
253 | self.patch = 0
254 | self.suffix = ""
255 | self.build = ""
256 | self._parse(vstr)
257 |
258 | def _parse(self, vstr):
259 | vstr = str(vstr)
260 | if vstr.startswith("v"):
261 | m = self.match_version(vstr[1:])
262 | else:
263 | m = self.match_version(vstr)
264 | if not m:
265 | raise ValueError("invalid version number: " + vstr)
266 |
267 | version, suffix = m.groups()
268 | parts = self._parse_dotted_string(version)
269 | self.major = parts.pop(0)
270 | if len(parts):
271 | self.minor = parts.pop(0)
272 | if len(parts):
273 | self.patch = parts.pop(0)
274 | if not len(parts) == 0:
275 | raise ValueError("version number too long: " + vstr)
276 |
277 | if suffix:
278 | # Build info
279 | idx = suffix.find("+")
280 | if idx > -1:
281 | self.build = suffix[idx + 1 :]
282 | suffix = suffix[:idx]
283 | if suffix:
284 | if not suffix.startswith("-"):
285 | raise ValueError("suffix must start with - : " + suffix)
286 | self.suffix = suffix[1:]
287 |
288 | def _parse_dotted_string(self, s):
289 | """Parse string ``s`` into list of ints and strings."""
290 | parsed = []
291 | parts = s.split(".")
292 | for p in parts:
293 | if p.isdigit():
294 | p = int(p)
295 | parsed.append(p)
296 | return parsed
297 |
298 | @property
299 | def tuple(self):
300 | """Version number as a tuple of major, minor, patch, pre-release."""
301 | return (self.major, self.minor, self.patch, self.suffix)
302 |
303 | def __lt__(self, other):
304 | """Implement comparison."""
305 | if not isinstance(other, Version):
306 | raise ValueError("not a Version instance: {0!r}".format(other))
307 | t = self.tuple[:3]
308 | o = other.tuple[:3]
309 | if t < o:
310 | return True
311 | if t == o: # We need to compare suffixes
312 | if self.suffix and not other.suffix:
313 | return True
314 | if other.suffix and not self.suffix:
315 | return False
316 |
317 | self_suffix = self._parse_dotted_string(self.suffix)
318 | other_suffix = self._parse_dotted_string(other.suffix)
319 |
320 | for s, o in zip_longest(self_suffix, other_suffix):
321 | if s is None: # shorter value wins
322 | return True
323 | elif o is None: # longer value loses
324 | return False
325 | elif type(s) != type(o): # type coersion
326 | s, o = str(s), str(o)
327 | if s == o: # next if the same compare
328 | continue
329 | return s < o # finally compare
330 | # t > o
331 | return False
332 |
333 | def __eq__(self, other):
334 | """Implement comparison."""
335 | if not isinstance(other, Version):
336 | raise ValueError("not a Version instance: {0!r}".format(other))
337 | return self.tuple == other.tuple
338 |
339 | def __ne__(self, other):
340 | """Implement comparison."""
341 | return not self.__eq__(other)
342 |
343 | def __gt__(self, other):
344 | """Implement comparison."""
345 | if not isinstance(other, Version):
346 | raise ValueError("not a Version instance: {0!r}".format(other))
347 | return other.__lt__(self)
348 |
349 | def __le__(self, other):
350 | """Implement comparison."""
351 | if not isinstance(other, Version):
352 | raise ValueError("not a Version instance: {0!r}".format(other))
353 | return not other.__lt__(self)
354 |
355 | def __ge__(self, other):
356 | """Implement comparison."""
357 | return not self.__lt__(other)
358 |
359 | def __str__(self):
360 | """Return semantic version string."""
361 | vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch)
362 | if self.suffix:
363 | vstr = "{0}-{1}".format(vstr, self.suffix)
364 | if self.build:
365 | vstr = "{0}+{1}".format(vstr, self.build)
366 | return vstr
367 |
368 | def __repr__(self):
369 | """Return 'code' representation of `Version`."""
370 | return "Version('{0}')".format(str(self))
371 |
372 |
373 | def retrieve_download(dl):
374 | """Saves a download to a temporary file and returns path.
375 |
376 | .. versionadded: 1.37
377 |
378 | Args:
379 | url (unicode): URL to .alfredworkflow file in GitHub repo
380 |
381 | Returns:
382 | unicode: path to downloaded file
383 |
384 | """
385 | if not match_workflow(dl.filename):
386 | raise ValueError("attachment not a workflow: " + dl.filename)
387 |
388 | path = os.path.join(tempfile.gettempdir(), dl.filename)
389 | wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path)
390 |
391 | r = request.urlopen(dl.url)
392 |
393 | with atomic_writer(path, "wb") as file_obj:
394 | file_obj.write(r.read())
395 |
396 | return path
397 |
398 |
399 | def build_api_url(repo):
400 | """Generate releases URL from GitHub repo.
401 |
402 | Args:
403 | repo (unicode): Repo name in form ``username/repo``
404 |
405 | Returns:
406 | unicode: URL to the API endpoint for the repo's releases
407 |
408 | """
409 | if len(repo.split("/")) != 2:
410 | raise ValueError("invalid GitHub repo: {!r}".format(repo))
411 |
412 | return RELEASES_BASE.format(repo)
413 |
414 |
415 | def get_downloads(repo):
416 | """Load available ``Download``s for GitHub repo.
417 |
418 | .. versionadded: 1.37
419 |
420 | Args:
421 | repo (unicode): GitHub repo to load releases for.
422 |
423 | Returns:
424 | list: Sequence of `Download` contained in GitHub releases.
425 | """
426 | url = build_api_url(repo)
427 |
428 | def _fetch():
429 | wf().logger.info("retrieving releases for %r ...", repo)
430 | r = request.urlopen(url)
431 | return r.read()
432 |
433 | key = "github-releases-" + repo.replace("/", "-")
434 | js = wf().cached_data(key, _fetch, max_age=60)
435 |
436 | return Download.from_releases(js)
437 |
438 |
439 | def latest_download(dls, alfred_version=None, prereleases=False):
440 | """Return newest `Download`."""
441 | alfred_version = alfred_version or os.getenv("alfred_version")
442 | version = None
443 | if alfred_version:
444 | version = Version(alfred_version)
445 |
446 | dls.sort(reverse=True)
447 | for dl in dls:
448 | if dl.prerelease and not prereleases:
449 | wf().logger.debug("ignored prerelease: %s", dl.version)
450 | continue
451 | if version and dl.alfred_version > version:
452 | wf().logger.debug(
453 | "ignored incompatible (%s > %s): %s",
454 | dl.alfred_version,
455 | version,
456 | dl.filename,
457 | )
458 | continue
459 |
460 | wf().logger.debug("latest version: %s (%s)", dl.version, dl.filename)
461 | return dl
462 |
463 | return None
464 |
465 |
466 | def check_update(repo, current_version, prereleases=False, alfred_version=None):
467 | """Check whether a newer release is available on GitHub.
468 |
469 | Args:
470 | repo (unicode): ``username/repo`` for workflow's GitHub repo
471 | current_version (unicode): the currently installed version of the
472 | workflow. :ref:`Semantic versioning ` is required.
473 | prereleases (bool): Whether to include pre-releases.
474 | alfred_version (unicode): version of currently-running Alfred.
475 | if empty, defaults to ``$alfred_version`` environment variable.
476 |
477 | Returns:
478 | bool: ``True`` if an update is available, else ``False``
479 |
480 | If an update is available, its version number and download URL will
481 | be cached.
482 |
483 | """
484 | key = "__workflow_latest_version"
485 | # data stored when no update is available
486 | no_update = {"available": False, "download": None, "version": None}
487 | current = Version(current_version)
488 |
489 | dls = get_downloads(repo)
490 | if not len(dls):
491 | wf().logger.warning("no valid downloads for %s", repo)
492 | wf().cache_data(key, no_update)
493 | return False
494 |
495 | wf().logger.info("%d download(s) for %s", len(dls), repo)
496 |
497 | dl = latest_download(dls, alfred_version, prereleases)
498 |
499 | if not dl:
500 | wf().logger.warning("no compatible downloads for %s", repo)
501 | wf().cache_data(key, no_update)
502 | return False
503 |
504 | wf().logger.debug("latest=%r, installed=%r", dl.version, current)
505 |
506 | if dl.version > current:
507 | wf().cache_data(
508 | key, {"version": str(dl.version), "download": dl.dict, "available": True}
509 | )
510 | return True
511 |
512 | wf().cache_data(key, no_update)
513 | return False
514 |
515 |
516 | def install_update():
517 | """If a newer release is available, download and install it.
518 |
519 | :returns: ``True`` if an update is installed, else ``False``
520 |
521 | """
522 | key = "__workflow_latest_version"
523 | # data stored when no update is available
524 | no_update = {"available": False, "download": None, "version": None}
525 | status = wf().cached_data(key, max_age=0)
526 |
527 | if not status or not status.get("available"):
528 | wf().logger.info("no update available")
529 | return False
530 |
531 | dl = status.get("download")
532 | if not dl:
533 | wf().logger.info("no download information")
534 | return False
535 |
536 | path = retrieve_download(Download.from_dict(dl))
537 |
538 | wf().logger.info("installing updated workflow ...")
539 | subprocess.call(["open", path]) # nosec
540 |
541 | wf().cache_data(key, no_update)
542 | return True
543 |
544 |
545 | if __name__ == "__main__": # pragma: nocover
546 | import sys
547 |
548 | prereleases = False
549 |
550 | def show_help(status=0):
551 | """Print help message."""
552 | print("usage: update.py (check|install) " "[--prereleases] ")
553 | sys.exit(status)
554 |
555 | argv = sys.argv[:]
556 | if "-h" in argv or "--help" in argv:
557 | show_help()
558 |
559 | if "--prereleases" in argv:
560 | argv.remove("--prereleases")
561 | prereleases = True
562 |
563 | if len(argv) != 4:
564 | show_help(1)
565 |
566 | action = argv[1]
567 | repo = argv[2]
568 | version = argv[3]
569 |
570 | try:
571 |
572 | if action == "check":
573 | check_update(repo, version, prereleases)
574 | elif action == "install":
575 | install_update()
576 | else:
577 | show_help(1)
578 |
579 | except Exception as err: # ensure traceback is in log file
580 | wf().logger.exception(err)
581 | raise err
582 |
--------------------------------------------------------------------------------
/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 |
14 | import atexit
15 | import errno
16 | import fcntl
17 | import functools
18 | import json
19 | import os
20 | import signal
21 | import subprocess
22 | import sys
23 | import time
24 | from collections import namedtuple
25 | from contextlib import contextmanager
26 | from threading import Event
27 |
28 | # JXA scripts to call Alfred's API via the Scripting Bridge
29 | # {app} is automatically replaced with "Alfred 3" or
30 | # "com.runningwithcrayons.Alfred" depending on version.
31 | #
32 | # Open Alfred in search (regular) mode
33 | JXA_SEARCH = "Application({app}).search({arg});"
34 | # Open Alfred's File Actions on an argument
35 | JXA_ACTION = "Application({app}).action({arg});"
36 | # Open Alfred's navigation mode at path
37 | JXA_BROWSE = "Application({app}).browse({arg});"
38 | # Set the specified theme
39 | JXA_SET_THEME = "Application({app}).setTheme({arg});"
40 | # Call an External Trigger
41 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});"
42 | # Save a variable to the workflow configuration sheet/info.plist
43 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});"
44 | # Delete a variable from the workflow configuration sheet/info.plist
45 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});"
46 | # Tell Alfred to reload a workflow from disk
47 | JXA_RELOAD_WORKFLOW = "Application({app}).reloadWorkflow({arg});"
48 |
49 |
50 | class AcquisitionError(Exception):
51 | """Raised if a lock cannot be acquired."""
52 |
53 |
54 | AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"])
55 | """Information about an installed application.
56 |
57 | Returned by :func:`appinfo`. All attributes are Unicode.
58 |
59 | .. py:attribute:: name
60 |
61 | Name of the application, e.g. ``u'Safari'``.
62 |
63 | .. py:attribute:: path
64 |
65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
66 |
67 | .. py:attribute:: bundleid
68 |
69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``.
70 |
71 | """
72 |
73 |
74 | def jxa_app_name():
75 | """Return name of application to call currently running Alfred.
76 |
77 | .. versionadded: 1.37
78 |
79 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending
80 | on which version of Alfred is running.
81 |
82 | This name is suitable for use with ``Application(name)`` in JXA.
83 |
84 | Returns:
85 | unicode: Application name or ID.
86 |
87 | """
88 | if os.getenv("alfred_version", "").startswith("3"):
89 | # Alfred 3
90 | return "Alfred 3"
91 | # Alfred 4+
92 | return "com.runningwithcrayons.Alfred"
93 |
94 |
95 | def unicodify(s, encoding="utf-8", norm=None):
96 | """Ensure string is Unicode.
97 |
98 | .. versionadded:: 1.31
99 |
100 | Decode encoded strings using ``encoding`` and normalise Unicode
101 | to form ``norm`` if specified.
102 |
103 | Args:
104 | s (str): String to decode. May also be Unicode.
105 | encoding (str, optional): Encoding to use on bytestrings.
106 | norm (None, optional): Normalisation form to apply to Unicode string.
107 |
108 | Returns:
109 | unicode: Decoded, optionally normalised, Unicode string.
110 |
111 | """
112 | if not isinstance(s, str):
113 | s = str(s, encoding)
114 |
115 | if norm:
116 | from unicodedata import normalize
117 |
118 | s = normalize(norm, s)
119 |
120 | return s
121 |
122 |
123 | def utf8ify(s):
124 | """Ensure string is a bytestring.
125 |
126 | .. versionadded:: 1.31
127 |
128 | Returns `str` objects unchanced, encodes `unicode` objects to
129 | UTF-8, and calls :func:`str` on anything else.
130 |
131 | Args:
132 | s (object): A Python object
133 |
134 | Returns:
135 | str: UTF-8 string or string representation of s.
136 |
137 | """
138 | if isinstance(s, str):
139 | return s
140 |
141 | if isinstance(s, str):
142 | return s.encode("utf-8")
143 |
144 | return str(s)
145 |
146 |
147 | def applescriptify(s):
148 | """Escape string for insertion into an AppleScript string.
149 |
150 | .. versionadded:: 1.31
151 |
152 | Replaces ``"`` with `"& quote &"`. Use this function if you want
153 | to insert a string into an AppleScript script:
154 |
155 | >>> applescriptify('g "python" test')
156 | 'g " & quote & "python" & quote & "test'
157 |
158 | Args:
159 | s (unicode): Unicode string to escape.
160 |
161 | Returns:
162 | unicode: Escaped string.
163 |
164 | """
165 | return s.replace('"', '" & quote & "')
166 |
167 |
168 | def run_command(cmd, **kwargs):
169 | """Run a command and return the output.
170 |
171 | .. versionadded:: 1.31
172 |
173 | A thin wrapper around :func:`subprocess.check_output` that ensures
174 | all arguments are encoded to UTF-8 first.
175 |
176 | Args:
177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`.
178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`.
179 |
180 | Returns:
181 | str: Output returned by :func:`~subprocess.check_output`.
182 |
183 | """
184 | cmd = [str(s) for s in cmd]
185 | return subprocess.check_output(cmd, **kwargs).decode()
186 |
187 |
188 | def run_applescript(script, *args, **kwargs):
189 | """Execute an AppleScript script and return its output.
190 |
191 | .. versionadded:: 1.31
192 |
193 | Run AppleScript either by filepath or code. If ``script`` is a valid
194 | filepath, that script will be run, otherwise ``script`` is treated
195 | as code.
196 |
197 | Args:
198 | script (str, optional): Filepath of script or code to run.
199 | *args: Optional command-line arguments to pass to the script.
200 | **kwargs: Pass ``lang`` to run a language other than AppleScript.
201 | Any other keyword arguments are passed to :func:`run_command`.
202 |
203 | Returns:
204 | str: Output of run command.
205 |
206 | """
207 | lang = "AppleScript"
208 | if "lang" in kwargs:
209 | lang = kwargs["lang"]
210 | del kwargs["lang"]
211 |
212 | cmd = ["/usr/bin/osascript", "-l", lang]
213 |
214 | if os.path.exists(script):
215 | cmd += [script]
216 | else:
217 | cmd += ["-e", script]
218 |
219 | cmd.extend(args)
220 |
221 | return run_command(cmd, **kwargs)
222 |
223 |
224 | def run_jxa(script, *args):
225 | """Execute a JXA script and return its output.
226 |
227 | .. versionadded:: 1.31
228 |
229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
230 |
231 | Args:
232 | script (str): Filepath of script or code to run.
233 | *args: Optional command-line arguments to pass to script.
234 |
235 | Returns:
236 | str: Output of script.
237 |
238 | """
239 | return run_applescript(script, *args, lang="JavaScript")
240 |
241 |
242 | def run_trigger(name, bundleid=None, arg=None):
243 | """Call an Alfred External Trigger.
244 |
245 | .. versionadded:: 1.31
246 |
247 | If ``bundleid`` is not specified, the bundle ID of the calling
248 | workflow is used.
249 |
250 | Args:
251 | name (str): Name of External Trigger to call.
252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to.
253 | arg (str, optional): Argument to pass to trigger.
254 |
255 | """
256 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
257 | appname = jxa_app_name()
258 | opts = {"inWorkflow": bundleid}
259 | if arg:
260 | opts["withArgument"] = arg
261 |
262 | script = JXA_TRIGGER.format(
263 | app=json.dumps(appname),
264 | arg=json.dumps(name),
265 | opts=json.dumps(opts, sort_keys=True),
266 | )
267 |
268 | run_applescript(script, lang="JavaScript")
269 |
270 |
271 | def set_theme(theme_name):
272 | """Change Alfred's theme.
273 |
274 | .. versionadded:: 1.39.0
275 |
276 | Args:
277 | theme_name (unicode): Name of theme Alfred should use.
278 |
279 | """
280 | appname = jxa_app_name()
281 | script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name))
282 | run_applescript(script, lang="JavaScript")
283 |
284 |
285 | def set_config(name, value, bundleid=None, exportable=False):
286 | """Set a workflow variable in ``info.plist``.
287 |
288 | .. versionadded:: 1.33
289 |
290 | If ``bundleid`` is not specified, the bundle ID of the calling
291 | workflow is used.
292 |
293 | Args:
294 | name (str): Name of variable to set.
295 | value (str): Value to set variable to.
296 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
297 | exportable (bool, optional): Whether variable should be marked
298 | as exportable (Don't Export checkbox).
299 |
300 | """
301 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
302 | appname = jxa_app_name()
303 | opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable}
304 |
305 | script = JXA_SET_CONFIG.format(
306 | app=json.dumps(appname),
307 | arg=json.dumps(name),
308 | opts=json.dumps(opts, sort_keys=True),
309 | )
310 |
311 | run_applescript(script, lang="JavaScript")
312 |
313 |
314 | def unset_config(name, bundleid=None):
315 | """Delete a workflow variable from ``info.plist``.
316 |
317 | .. versionadded:: 1.33
318 |
319 | If ``bundleid`` is not specified, the bundle ID of the calling
320 | workflow is used.
321 |
322 | Args:
323 | name (str): Name of variable to delete.
324 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
325 |
326 | """
327 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
328 | appname = jxa_app_name()
329 | opts = {"inWorkflow": bundleid}
330 |
331 | script = JXA_UNSET_CONFIG.format(
332 | app=json.dumps(appname),
333 | arg=json.dumps(name),
334 | opts=json.dumps(opts, sort_keys=True),
335 | )
336 |
337 | run_applescript(script, lang="JavaScript")
338 |
339 |
340 | def search_in_alfred(query=None):
341 | """Open Alfred with given search query.
342 |
343 | .. versionadded:: 1.39.0
344 |
345 | Omit ``query`` to simply open Alfred's main window.
346 |
347 | Args:
348 | query (unicode, optional): Search query.
349 |
350 | """
351 | query = query or ""
352 | appname = jxa_app_name()
353 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query))
354 | run_applescript(script, lang="JavaScript")
355 |
356 |
357 | def browse_in_alfred(path):
358 | """Open Alfred's filesystem navigation mode at ``path``.
359 |
360 | .. versionadded:: 1.39.0
361 |
362 | Args:
363 | path (unicode): File or directory path.
364 |
365 | """
366 | appname = jxa_app_name()
367 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path))
368 | run_applescript(script, lang="JavaScript")
369 |
370 |
371 | def action_in_alfred(paths):
372 | """Action the give filepaths in Alfred.
373 |
374 | .. versionadded:: 1.39.0
375 |
376 | Args:
377 | paths (list): Unicode paths to files/directories to action.
378 |
379 | """
380 | appname = jxa_app_name()
381 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths))
382 | run_applescript(script, lang="JavaScript")
383 |
384 |
385 | def reload_workflow(bundleid=None):
386 | """Tell Alfred to reload a workflow from disk.
387 |
388 | .. versionadded:: 1.39.0
389 |
390 | If ``bundleid`` is not specified, the bundle ID of the calling
391 | workflow is used.
392 |
393 | Args:
394 | bundleid (unicode, optional): Bundle ID of workflow to reload.
395 |
396 | """
397 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
398 | appname = jxa_app_name()
399 | script = JXA_RELOAD_WORKFLOW.format(
400 | app=json.dumps(appname), arg=json.dumps(bundleid)
401 | )
402 |
403 | run_applescript(script, lang="JavaScript")
404 |
405 |
406 | def appinfo(name):
407 | """Get information about an installed application.
408 |
409 | .. versionadded:: 1.31
410 |
411 | Args:
412 | name (str): Name of application to look up.
413 |
414 | Returns:
415 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
416 |
417 | """
418 | cmd = [
419 | "mdfind",
420 | "-onlyin",
421 | "/Applications",
422 | "-onlyin",
423 | "/System/Applications",
424 | "-onlyin",
425 | os.path.expanduser("~/Applications"),
426 | "(kMDItemContentTypeTree == com.apple.application &&"
427 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name),
428 | ]
429 |
430 | output = run_command(cmd).strip()
431 | if not output:
432 | return None
433 |
434 | path = output.split("\n")[0]
435 |
436 | cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path]
437 | bid = run_command(cmd).strip()
438 | if not bid: # pragma: no cover
439 | return None
440 |
441 | return AppInfo(name, path, bid)
442 |
443 |
444 | @contextmanager
445 | def atomic_writer(fpath, mode):
446 | """Atomic file writer.
447 |
448 | .. versionadded:: 1.12
449 |
450 | Context manager that ensures the file is only written if the write
451 | succeeds. The data is first written to a temporary file.
452 |
453 | :param fpath: path of file to write to.
454 | :type fpath: ``unicode``
455 | :param mode: sames as for :func:`open`
456 | :type mode: string
457 |
458 | """
459 | suffix = ".{}.tmp".format(os.getpid())
460 | temppath = fpath + suffix
461 | with open(temppath, mode) as fp:
462 | try:
463 | yield fp
464 | os.rename(temppath, fpath)
465 | finally:
466 | try:
467 | os.remove(temppath)
468 | except OSError:
469 | pass
470 |
471 |
472 | class LockFile(object):
473 | """Context manager to protect filepaths with lockfiles.
474 |
475 | .. versionadded:: 1.13
476 |
477 | Creates a lockfile alongside ``protected_path``. Other ``LockFile``
478 | instances will refuse to lock the same path.
479 |
480 | >>> path = '/path/to/file'
481 | >>> with LockFile(path):
482 | >>> with open(path, 'w') as fp:
483 | >>> fp.write(data)
484 |
485 | Args:
486 | protected_path (unicode): File to protect with a lockfile
487 | timeout (float, optional): Raises an :class:`AcquisitionError`
488 | if lock cannot be acquired within this number of seconds.
489 | If ``timeout`` is 0 (the default), wait forever.
490 | delay (float, optional): How often to check (in seconds) if
491 | lock has been released.
492 |
493 | Attributes:
494 | delay (float): How often to check (in seconds) whether the lock
495 | can be acquired.
496 | lockfile (unicode): Path of the lockfile.
497 | timeout (float): How long to wait to acquire the lock.
498 |
499 | """
500 |
501 | def __init__(self, protected_path, timeout=0.0, delay=0.05):
502 | """Create new :class:`LockFile` object."""
503 | self.lockfile = protected_path + ".lock"
504 | self._lockfile = None
505 | self.timeout = timeout
506 | self.delay = delay
507 | self._lock = Event()
508 | atexit.register(self.release)
509 |
510 | @property
511 | def locked(self):
512 | """``True`` if file is locked by this instance."""
513 | return self._lock.is_set()
514 |
515 | def acquire(self, blocking=True):
516 | """Acquire the lock if possible.
517 |
518 | If the lock is in use and ``blocking`` is ``False``, return
519 | ``False``.
520 |
521 | Otherwise, check every :attr:`delay` seconds until it acquires
522 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
523 |
524 | """
525 | if self.locked and not blocking:
526 | return False
527 |
528 | start = time.time()
529 | while True:
530 | # Raise error if we've been waiting too long to acquire the lock
531 | if self.timeout and (time.time() - start) >= self.timeout:
532 | raise AcquisitionError("lock acquisition timed out")
533 |
534 | # If already locked, wait then try again
535 | if self.locked:
536 | time.sleep(self.delay)
537 | continue
538 |
539 | # Create in append mode so we don't lose any contents
540 | if self._lockfile is None:
541 | self._lockfile = open(self.lockfile, "a")
542 |
543 | # Try to acquire the lock
544 | try:
545 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
546 | self._lock.set()
547 | break
548 | except IOError as err: # pragma: no cover
549 | if err.errno not in (errno.EACCES, errno.EAGAIN):
550 | raise
551 |
552 | # Don't try again
553 | if not blocking: # pragma: no cover
554 | return False
555 |
556 | # Wait, then try again
557 | time.sleep(self.delay)
558 |
559 | return True
560 |
561 | def release(self):
562 | """Release the lock by deleting `self.lockfile`."""
563 | if not self._lock.is_set():
564 | return False
565 |
566 | try:
567 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
568 | except IOError: # pragma: no cover
569 | pass
570 | finally:
571 | self._lock.clear()
572 | self._lockfile = None
573 | try:
574 | os.unlink(self.lockfile)
575 | except OSError: # pragma: no cover
576 | pass
577 |
578 | return True # noqa: B012
579 |
580 | def __enter__(self):
581 | """Acquire lock."""
582 | self.acquire()
583 | return self
584 |
585 | def __exit__(self, typ, value, traceback):
586 | """Release lock."""
587 | self.release()
588 |
589 | def __del__(self):
590 | """Clear up `self.lockfile`."""
591 | self.release() # pragma: no cover
592 |
593 |
594 | class uninterruptible(object):
595 | """Decorator that postpones SIGTERM until wrapped function returns.
596 |
597 | .. versionadded:: 1.12
598 |
599 | .. important:: This decorator is NOT thread-safe.
600 |
601 | As of version 2.7, Alfred allows Script Filters to be killed. If
602 | your workflow is killed in the middle of critical code (e.g.
603 | writing data to disk), this may corrupt your workflow's data.
604 |
605 | Use this decorator to wrap critical functions that *must* complete.
606 | If the script is killed while a wrapped function is executing,
607 | the SIGTERM will be caught and handled after your function has
608 | finished executing.
609 |
610 | Alfred-Workflow uses this internally to ensure its settings, data
611 | and cache writes complete.
612 |
613 | """
614 |
615 | def __init__(self, func, class_name=""):
616 | """Decorate `func`."""
617 | self.func = func
618 | functools.update_wrapper(self, func)
619 | self._caught_signal = None
620 |
621 | def signal_handler(self, signum, frame):
622 | """Called when process receives SIGTERM."""
623 | self._caught_signal = (signum, frame)
624 |
625 | def __call__(self, *args, **kwargs):
626 | """Trap ``SIGTERM`` and call wrapped function."""
627 | self._caught_signal = None
628 | # Register handler for SIGTERM, then call `self.func`
629 | self.old_signal_handler = signal.getsignal(signal.SIGTERM)
630 | signal.signal(signal.SIGTERM, self.signal_handler)
631 |
632 | self.func(*args, **kwargs)
633 |
634 | # Restore old signal handler
635 | signal.signal(signal.SIGTERM, self.old_signal_handler)
636 |
637 | # Handle any signal caught during execution
638 | if self._caught_signal is not None:
639 | signum, frame = self._caught_signal
640 | if callable(self.old_signal_handler):
641 | self.old_signal_handler(signum, frame)
642 | elif self.old_signal_handler == signal.SIG_DFL:
643 | sys.exit(0)
644 |
645 | def __get__(self, obj=None, klass=None):
646 | """Decorator API."""
647 | return self.__class__(self.func.__get__(obj, klass), klass.__name__)
648 |
--------------------------------------------------------------------------------
/src/workflow/version:
--------------------------------------------------------------------------------
1 | 1.40.0
--------------------------------------------------------------------------------
/src/workflow/workflow.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | #
3 | # Copyright (c) 2014 Dean Jackson
4 | #
5 | # MIT Licence. See http://opensource.org/licenses/MIT
6 | #
7 | # Created on 2014-02-15
8 | #
9 |
10 | """The :class:`Workflow` object is the main interface to this library.
11 |
12 | :class:`Workflow` is targeted at Alfred 2. Use
13 | :class:`~workflow.Workflow3` if you want to use Alfred 3's new
14 | features, such as :ref:`workflow variables ` or
15 | more powerful modifiers.
16 |
17 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set
18 | up your Python script to best utilise the :class:`Workflow` object.
19 |
20 | """
21 |
22 |
23 | import binascii
24 | import json
25 | import logging
26 | import logging.handlers
27 | import os
28 | import pickle
29 | import plistlib
30 | import re
31 | import shutil
32 | import string
33 | import subprocess
34 | import sys
35 | import time
36 | import unicodedata
37 | from contextlib import contextmanager
38 | from copy import deepcopy
39 | from typing import Optional
40 |
41 | try:
42 | import xml.etree.cElementTree as ET
43 | except ImportError: # pragma: no cover
44 | import xml.etree.ElementTree as ET
45 |
46 | # imported to maintain API
47 | from workflow.util import AcquisitionError # noqa: F401
48 | from workflow.util import LockFile, atomic_writer, uninterruptible
49 |
50 | assert sys.version_info[0] == 3
51 |
52 | #: Sentinel for properties that haven't been set yet (that might
53 | #: correctly have the value ``None``)
54 | UNSET = object()
55 |
56 | ####################################################################
57 | # Standard system icons
58 | ####################################################################
59 |
60 | # These icons are default macOS icons. They are super-high quality, and
61 | # will be familiar to users.
62 | # This library uses `ICON_ERROR` when a workflow dies in flames, so
63 | # in my own workflows, I use `ICON_WARNING` for less fatal errors
64 | # (e.g. bad user input, no results etc.)
65 |
66 | # The system icons are all in this directory. There are many more than
67 | # are listed here
68 |
69 | ICON_ROOT = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources"
70 |
71 | ICON_ACCOUNT = os.path.join(ICON_ROOT, "Accounts.icns")
72 | ICON_BURN = os.path.join(ICON_ROOT, "BurningIcon.icns")
73 | ICON_CLOCK = os.path.join(ICON_ROOT, "Clock.icns")
74 | ICON_COLOR = os.path.join(ICON_ROOT, "ProfileBackgroundColor.icns")
75 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please
76 | ICON_EJECT = os.path.join(ICON_ROOT, "EjectMediaIcon.icns")
77 | # Shown when a workflow throws an error
78 | ICON_ERROR = os.path.join(ICON_ROOT, "AlertStopIcon.icns")
79 | ICON_FAVORITE = os.path.join(ICON_ROOT, "ToolbarFavoritesIcon.icns")
80 | ICON_FAVOURITE = ICON_FAVORITE
81 | ICON_GROUP = os.path.join(ICON_ROOT, "GroupIcon.icns")
82 | ICON_HELP = os.path.join(ICON_ROOT, "HelpIcon.icns")
83 | ICON_HOME = os.path.join(ICON_ROOT, "HomeFolderIcon.icns")
84 | ICON_INFO = os.path.join(ICON_ROOT, "ToolbarInfo.icns")
85 | ICON_NETWORK = os.path.join(ICON_ROOT, "GenericNetworkIcon.icns")
86 | ICON_NOTE = os.path.join(ICON_ROOT, "AlertNoteIcon.icns")
87 | ICON_SETTINGS = os.path.join(ICON_ROOT, "ToolbarAdvanced.icns")
88 | ICON_SWIRL = os.path.join(ICON_ROOT, "ErasingIcon.icns")
89 | ICON_SWITCH = os.path.join(ICON_ROOT, "General.icns")
90 | ICON_SYNC = os.path.join(ICON_ROOT, "Sync.icns")
91 | ICON_TRASH = os.path.join(ICON_ROOT, "TrashIcon.icns")
92 | ICON_USER = os.path.join(ICON_ROOT, "UserIcon.icns")
93 | ICON_WARNING = os.path.join(ICON_ROOT, "AlertCautionIcon.icns")
94 | ICON_WEB = os.path.join(ICON_ROOT, "BookmarkIcon.icns")
95 |
96 | ####################################################################
97 | # non-ASCII to ASCII diacritic folding.
98 | # Used by `fold_to_ascii` method
99 | ####################################################################
100 |
101 | ASCII_REPLACEMENTS = {
102 | "À": "A",
103 | "Á": "A",
104 | "Â": "A",
105 | "Ã": "A",
106 | "Ä": "A",
107 | "Å": "A",
108 | "Æ": "AE",
109 | "Ç": "C",
110 | "È": "E",
111 | "É": "E",
112 | "Ê": "E",
113 | "Ë": "E",
114 | "Ì": "I",
115 | "Í": "I",
116 | "Î": "I",
117 | "Ï": "I",
118 | "Ð": "D",
119 | "Ñ": "N",
120 | "Ò": "O",
121 | "Ó": "O",
122 | "Ô": "O",
123 | "Õ": "O",
124 | "Ö": "O",
125 | "Ø": "O",
126 | "Ù": "U",
127 | "Ú": "U",
128 | "Û": "U",
129 | "Ü": "U",
130 | "Ý": "Y",
131 | "Þ": "Th",
132 | "ß": "ss",
133 | "à": "a",
134 | "á": "a",
135 | "â": "a",
136 | "ã": "a",
137 | "ä": "a",
138 | "å": "a",
139 | "æ": "ae",
140 | "ç": "c",
141 | "è": "e",
142 | "é": "e",
143 | "ê": "e",
144 | "ë": "e",
145 | "ì": "i",
146 | "í": "i",
147 | "î": "i",
148 | "ï": "i",
149 | "ð": "d",
150 | "ñ": "n",
151 | "ò": "o",
152 | "ó": "o",
153 | "ô": "o",
154 | "õ": "o",
155 | "ö": "o",
156 | "ø": "o",
157 | "ù": "u",
158 | "ú": "u",
159 | "û": "u",
160 | "ü": "u",
161 | "ý": "y",
162 | "þ": "th",
163 | "ÿ": "y",
164 | "Ł": "L",
165 | "ł": "l",
166 | "Ń": "N",
167 | "ń": "n",
168 | "Ņ": "N",
169 | "ņ": "n",
170 | "Ň": "N",
171 | "ň": "n",
172 | "Ŋ": "ng",
173 | "ŋ": "NG",
174 | "Ō": "O",
175 | "ō": "o",
176 | "Ŏ": "O",
177 | "ŏ": "o",
178 | "Ő": "O",
179 | "ő": "o",
180 | "Œ": "OE",
181 | "œ": "oe",
182 | "Ŕ": "R",
183 | "ŕ": "r",
184 | "Ŗ": "R",
185 | "ŗ": "r",
186 | "Ř": "R",
187 | "ř": "r",
188 | "Ś": "S",
189 | "ś": "s",
190 | "Ŝ": "S",
191 | "ŝ": "s",
192 | "Ş": "S",
193 | "ş": "s",
194 | "Š": "S",
195 | "š": "s",
196 | "Ţ": "T",
197 | "ţ": "t",
198 | "Ť": "T",
199 | "ť": "t",
200 | "Ŧ": "T",
201 | "ŧ": "t",
202 | "Ũ": "U",
203 | "ũ": "u",
204 | "Ū": "U",
205 | "ū": "u",
206 | "Ŭ": "U",
207 | "ŭ": "u",
208 | "Ů": "U",
209 | "ů": "u",
210 | "Ű": "U",
211 | "ű": "u",
212 | "Ŵ": "W",
213 | "ŵ": "w",
214 | "Ŷ": "Y",
215 | "ŷ": "y",
216 | "Ÿ": "Y",
217 | "Ź": "Z",
218 | "ź": "z",
219 | "Ż": "Z",
220 | "ż": "z",
221 | "Ž": "Z",
222 | "ž": "z",
223 | "ſ": "s",
224 | "Α": "A",
225 | "Β": "B",
226 | "Γ": "G",
227 | "Δ": "D",
228 | "Ε": "E",
229 | "Ζ": "Z",
230 | "Η": "E",
231 | "Θ": "Th",
232 | "Ι": "I",
233 | "Κ": "K",
234 | "Λ": "L",
235 | "Μ": "M",
236 | "Ν": "N",
237 | "Ξ": "Ks",
238 | "Ο": "O",
239 | "Π": "P",
240 | "Ρ": "R",
241 | "Σ": "S",
242 | "Τ": "T",
243 | "Υ": "U",
244 | "Φ": "Ph",
245 | "Χ": "Kh",
246 | "Ψ": "Ps",
247 | "Ω": "O",
248 | "α": "a",
249 | "β": "b",
250 | "γ": "g",
251 | "δ": "d",
252 | "ε": "e",
253 | "ζ": "z",
254 | "η": "e",
255 | "θ": "th",
256 | "ι": "i",
257 | "κ": "k",
258 | "λ": "l",
259 | "μ": "m",
260 | "ν": "n",
261 | "ξ": "x",
262 | "ο": "o",
263 | "π": "p",
264 | "ρ": "r",
265 | "ς": "s",
266 | "σ": "s",
267 | "τ": "t",
268 | "υ": "u",
269 | "φ": "ph",
270 | "χ": "kh",
271 | "ψ": "ps",
272 | "ω": "o",
273 | "А": "A",
274 | "Б": "B",
275 | "В": "V",
276 | "Г": "G",
277 | "Д": "D",
278 | "Е": "E",
279 | "Ж": "Zh",
280 | "З": "Z",
281 | "И": "I",
282 | "Й": "I",
283 | "К": "K",
284 | "Л": "L",
285 | "М": "M",
286 | "Н": "N",
287 | "О": "O",
288 | "П": "P",
289 | "Р": "R",
290 | "С": "S",
291 | "Т": "T",
292 | "У": "U",
293 | "Ф": "F",
294 | "Х": "Kh",
295 | "Ц": "Ts",
296 | "Ч": "Ch",
297 | "Ш": "Sh",
298 | "Щ": "Shch",
299 | "Ъ": "'",
300 | "Ы": "Y",
301 | "Ь": "'",
302 | "Э": "E",
303 | "Ю": "Iu",
304 | "Я": "Ia",
305 | "а": "a",
306 | "б": "b",
307 | "в": "v",
308 | "г": "g",
309 | "д": "d",
310 | "е": "e",
311 | "ж": "zh",
312 | "з": "z",
313 | "и": "i",
314 | "й": "i",
315 | "к": "k",
316 | "л": "l",
317 | "м": "m",
318 | "н": "n",
319 | "о": "o",
320 | "п": "p",
321 | "р": "r",
322 | "с": "s",
323 | "т": "t",
324 | "у": "u",
325 | "ф": "f",
326 | "х": "kh",
327 | "ц": "ts",
328 | "ч": "ch",
329 | "ш": "sh",
330 | "щ": "shch",
331 | "ъ": "'",
332 | "ы": "y",
333 | "ь": "'",
334 | "э": "e",
335 | "ю": "iu",
336 | "я": "ia",
337 | # 'ᴀ': '',
338 | # 'ᴁ': '',
339 | # 'ᴂ': '',
340 | # 'ᴃ': '',
341 | # 'ᴄ': '',
342 | # 'ᴅ': '',
343 | # 'ᴆ': '',
344 | # 'ᴇ': '',
345 | # 'ᴈ': '',
346 | # 'ᴉ': '',
347 | # 'ᴊ': '',
348 | # 'ᴋ': '',
349 | # 'ᴌ': '',
350 | # 'ᴍ': '',
351 | # 'ᴎ': '',
352 | # 'ᴏ': '',
353 | # 'ᴐ': '',
354 | # 'ᴑ': '',
355 | # 'ᴒ': '',
356 | # 'ᴓ': '',
357 | # 'ᴔ': '',
358 | # 'ᴕ': '',
359 | # 'ᴖ': '',
360 | # 'ᴗ': '',
361 | # 'ᴘ': '',
362 | # 'ᴙ': '',
363 | # 'ᴚ': '',
364 | # 'ᴛ': '',
365 | # 'ᴜ': '',
366 | # 'ᴝ': '',
367 | # 'ᴞ': '',
368 | # 'ᴟ': '',
369 | # 'ᴠ': '',
370 | # 'ᴡ': '',
371 | # 'ᴢ': '',
372 | # 'ᴣ': '',
373 | # 'ᴤ': '',
374 | # 'ᴥ': '',
375 | "ᴦ": "G",
376 | "ᴧ": "L",
377 | "ᴨ": "P",
378 | "ᴩ": "R",
379 | "ᴪ": "PS",
380 | "ẞ": "Ss",
381 | "Ỳ": "Y",
382 | "ỳ": "y",
383 | "Ỵ": "Y",
384 | "ỵ": "y",
385 | "Ỹ": "Y",
386 | "ỹ": "y",
387 | }
388 |
389 | ####################################################################
390 | # Smart-to-dumb punctuation mapping
391 | ####################################################################
392 |
393 | DUMB_PUNCTUATION = {
394 | "‘": "'",
395 | "’": "'",
396 | "‚": "'",
397 | "“": '"',
398 | "”": '"',
399 | "„": '"',
400 | "–": "-",
401 | "—": "-",
402 | }
403 |
404 |
405 | ####################################################################
406 | # Used by `Workflow.filter`
407 | ####################################################################
408 |
409 | # Anchor characters in a name
410 | #: Characters that indicate the beginning of a "word" in CamelCase
411 | INITIALS = string.ascii_uppercase + string.digits
412 |
413 | #: Split on non-letters, numbers
414 | split_on_delimiters = re.compile("[^a-zA-Z0-9]").split
415 |
416 | # Match filter flags
417 | #: Match items that start with ``query``
418 | MATCH_STARTSWITH = 1
419 | #: Match items whose capital letters start with ``query``
420 | MATCH_CAPITALS = 2
421 | #: Match items with a component "word" that matches ``query``
422 | MATCH_ATOM = 4
423 | #: Match items whose initials (based on atoms) start with ``query``
424 | MATCH_INITIALS_STARTSWITH = 8
425 | #: Match items whose initials (based on atoms) contain ``query``
426 | MATCH_INITIALS_CONTAIN = 16
427 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and
428 | #: :const:`MATCH_INITIALS_CONTAIN`
429 | MATCH_INITIALS = 24
430 | #: Match items if ``query`` is a substring
431 | MATCH_SUBSTRING = 32
432 | #: Match items if all characters in ``query`` appear in the item in order
433 | MATCH_ALLCHARS = 64
434 | #: Combination of all other ``MATCH_*`` constants
435 | MATCH_ALL = 127
436 |
437 |
438 | ####################################################################
439 | # Used by `Workflow.check_update`
440 | ####################################################################
441 |
442 | # Number of days to wait between checking for updates to the workflow
443 | DEFAULT_UPDATE_FREQUENCY = 1
444 |
445 |
446 | ####################################################################
447 | # Keychain access errors
448 | ####################################################################
449 |
450 |
451 | class KeychainError(Exception):
452 | """Raised for unknown Keychain errors.
453 |
454 | Raised by methods :meth:`Workflow.save_password`,
455 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
456 | when ``security`` CLI app returns an unknown error code.
457 |
458 | """
459 |
460 |
461 | class PasswordNotFound(KeychainError):
462 | """Password not in Keychain.
463 |
464 | Raised by method :meth:`Workflow.get_password` when ``account``
465 | is unknown to the Keychain.
466 |
467 | """
468 |
469 |
470 | class PasswordExists(KeychainError):
471 | """Raised when trying to overwrite an existing account password.
472 |
473 | You should never receive this error: it is used internally
474 | by the :meth:`Workflow.save_password` method to know if it needs
475 | to delete the old password first (a Keychain implementation detail).
476 |
477 | """
478 |
479 |
480 | ####################################################################
481 | # Helper functions
482 | ####################################################################
483 |
484 |
485 | def isascii(text):
486 | """Test if ``text`` contains only ASCII characters.
487 |
488 | :param text: text to test for ASCII-ness
489 | :type text: ``unicode``
490 | :returns: ``True`` if ``text`` contains only ASCII characters
491 | :rtype: ``Boolean``
492 |
493 | """
494 | try:
495 | text.encode("ascii")
496 | except UnicodeEncodeError:
497 | return False
498 | return True
499 |
500 |
501 | ####################################################################
502 | # Implementation classes
503 | ####################################################################
504 |
505 |
506 | class SerializerManager(object):
507 | """Contains registered serializers.
508 |
509 | .. versionadded:: 1.8
510 |
511 | A configured instance of this class is available at
512 | :attr:`workflow.manager`.
513 |
514 | Use :meth:`register()` to register new (or replace
515 | existing) serializers, which you can specify by name when calling
516 | :class:`~workflow.Workflow` data storage methods.
517 |
518 | See :ref:`guide-serialization` and :ref:`guide-persistent-data`
519 | for further information.
520 |
521 | """
522 |
523 | def __init__(self):
524 | """Create new SerializerManager object."""
525 | self._serializers = {}
526 |
527 | def register(self, name, serializer):
528 | """Register ``serializer`` object under ``name``.
529 |
530 | Raises :class:`AttributeError` if ``serializer`` in invalid.
531 |
532 | .. note::
533 |
534 | ``name`` will be used as the file extension of the saved files.
535 |
536 | :param name: Name to register ``serializer`` under
537 | :type name: ``unicode`` or ``str``
538 | :param serializer: object with ``load()`` and ``dump()``
539 | methods
540 |
541 | """
542 | # Basic validation
543 | serializer.load
544 | serializer.dump
545 |
546 | self._serializers[name] = serializer
547 |
548 | def serializer(self, name):
549 | """Return serializer object for ``name``.
550 |
551 | :param name: Name of serializer to return
552 | :type name: ``unicode`` or ``str``
553 | :returns: serializer object or ``None`` if no such serializer
554 | is registered.
555 |
556 | """
557 | return self._serializers.get(name)
558 |
559 | def unregister(self, name):
560 | """Remove registered serializer with ``name``.
561 |
562 | Raises a :class:`ValueError` if there is no such registered
563 | serializer.
564 |
565 | :param name: Name of serializer to remove
566 | :type name: ``unicode`` or ``str``
567 | :returns: serializer object
568 |
569 | """
570 | if name not in self._serializers:
571 | raise ValueError("No such serializer registered : {0}".format(name))
572 |
573 | serializer = self._serializers[name]
574 | del self._serializers[name]
575 |
576 | return serializer
577 |
578 | @property
579 | def serializers(self):
580 | """Return names of registered serializers."""
581 | return sorted(self._serializers.keys())
582 |
583 |
584 | class BaseSerializer:
585 | is_binary: Optional[bool] = None
586 |
587 | @classmethod
588 | def binary_mode(cls):
589 | return "b" if cls.is_binary else ""
590 |
591 | @classmethod
592 | def _opener(cls, opener, path, mode="r"):
593 | with opener(path, mode + cls.binary_mode()) as fp:
594 | yield fp
595 |
596 | @classmethod
597 | @contextmanager
598 | def atomic_writer(cls, path, mode):
599 | yield from cls._opener(atomic_writer, path, mode)
600 |
601 | @classmethod
602 | @contextmanager
603 | def open(cls, path, mode):
604 | yield from cls._opener(open, path, mode)
605 |
606 |
607 | class JSONSerializer(BaseSerializer):
608 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.
609 |
610 | .. versionadded:: 1.8
611 |
612 | Use this serializer if you need readable data files. JSON doesn't
613 | support Python objects as well as ``pickle``, so be
614 | careful which data you try to serialize as JSON.
615 |
616 | """
617 |
618 | is_binary = False
619 |
620 | @classmethod
621 | def load(cls, file_obj):
622 | """Load serialized object from open JSON file.
623 |
624 | .. versionadded:: 1.8
625 |
626 | :param file_obj: file handle
627 | :type file_obj: ``file`` object
628 | :returns: object loaded from JSON file
629 | :rtype: object
630 |
631 | """
632 | return json.load(file_obj)
633 |
634 | @classmethod
635 | def dump(cls, obj, file_obj):
636 | """Serialize object ``obj`` to open JSON file.
637 |
638 | .. versionadded:: 1.8
639 |
640 | :param obj: Python object to serialize
641 | :type obj: JSON-serializable data structure
642 | :param file_obj: file handle
643 | :type file_obj: ``file`` object
644 |
645 | """
646 | return json.dump(obj, file_obj, indent=2)
647 |
648 |
649 | class PickleSerializer(BaseSerializer):
650 | """Wrapper around :mod:`pickle`. Sets ``protocol``.
651 |
652 | .. versionadded:: 1.8
653 |
654 | Use this serializer if you need to add custom pickling.
655 |
656 | """
657 |
658 | is_binary = True
659 |
660 | @classmethod
661 | def load(cls, file_obj):
662 | """Load serialized object from open pickle file.
663 |
664 | .. versionadded:: 1.8
665 |
666 | :param file_obj: file handle
667 | :type file_obj: ``file`` object
668 | :returns: object loaded from pickle file
669 | :rtype: object
670 |
671 | """
672 | return pickle.load(file_obj)
673 |
674 | @classmethod
675 | def dump(cls, obj, file_obj):
676 | """Serialize object ``obj`` to open pickle file.
677 |
678 | .. versionadded:: 1.8
679 |
680 | :param obj: Python object to serialize
681 | :type obj: Python object
682 | :param file_obj: file handle
683 | :type file_obj: ``file`` object
684 |
685 | """
686 | return pickle.dump(obj, file_obj, protocol=-1)
687 |
688 |
689 | # Set up default manager and register built-in serializers
690 | manager = SerializerManager()
691 | manager.register("pickle", PickleSerializer)
692 | manager.register("json", JSONSerializer)
693 |
694 |
695 | class Item(object):
696 | """Represents a feedback item for Alfred.
697 |
698 | Generates Alfred-compliant XML for a single item.
699 |
700 | You probably shouldn't use this class directly, but via
701 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item`
702 | for details of arguments.
703 |
704 | """
705 |
706 | def __init__(
707 | self,
708 | title,
709 | subtitle="",
710 | modifier_subtitles=None,
711 | arg=None,
712 | autocomplete=None,
713 | valid=False,
714 | uid=None,
715 | icon=None,
716 | icontype=None,
717 | type=None,
718 | largetext=None,
719 | copytext=None,
720 | quicklookurl=None,
721 | ):
722 | """Same arguments as :meth:`Workflow.add_item`."""
723 | self.title = title
724 | self.subtitle = subtitle
725 | self.modifier_subtitles = modifier_subtitles or {}
726 | self.arg = arg
727 | self.autocomplete = autocomplete
728 | self.valid = valid
729 | self.uid = uid
730 | self.icon = icon
731 | self.icontype = icontype
732 | self.type = type
733 | self.largetext = largetext
734 | self.copytext = copytext
735 | self.quicklookurl = quicklookurl
736 |
737 | @property
738 | def elem(self):
739 | """Create and return feedback item for Alfred.
740 |
741 | :returns: :class:`ElementTree.Element `
742 | instance for this :class:`Item` instance.
743 |
744 | """
745 | # Attributes on - element
746 | attr = {}
747 | if self.valid:
748 | attr["valid"] = "yes"
749 | else:
750 | attr["valid"] = "no"
751 | # Allow empty string for autocomplete. This is a useful value,
752 | # as TABing the result will revert the query back to just the
753 | # keyword
754 | if self.autocomplete is not None:
755 | attr["autocomplete"] = self.autocomplete
756 |
757 | # Optional attributes
758 | for name in ("uid", "type"):
759 | value = getattr(self, name, None)
760 | if value:
761 | attr[name] = value
762 |
763 | root = ET.Element("item", attr)
764 | ET.SubElement(root, "title").text = self.title
765 | ET.SubElement(root, "subtitle").text = self.subtitle
766 |
767 | # Add modifier subtitles
768 | for mod in ("cmd", "ctrl", "alt", "shift", "fn"):
769 | if mod in self.modifier_subtitles:
770 | ET.SubElement(
771 | root, "subtitle", {"mod": mod}
772 | ).text = self.modifier_subtitles[mod]
773 |
774 | # Add arg as element instead of attribute on
- , as it's more
775 | # flexible (newlines aren't allowed in attributes)
776 | if self.arg:
777 | ET.SubElement(root, "arg").text = self.arg
778 |
779 | # Add icon if there is one
780 | if self.icon:
781 | if self.icontype:
782 | attr = dict(type=self.icontype)
783 | else:
784 | attr = {}
785 | ET.SubElement(root, "icon", attr).text = self.icon
786 |
787 | if self.largetext:
788 | ET.SubElement(root, "text", {"type": "largetype"}).text = self.largetext
789 |
790 | if self.copytext:
791 | ET.SubElement(root, "text", {"type": "copy"}).text = self.copytext
792 |
793 | if self.quicklookurl:
794 | ET.SubElement(root, "quicklookurl").text = self.quicklookurl
795 |
796 | return root
797 |
798 |
799 | class Settings(dict):
800 | """A dictionary that saves itself when changed.
801 |
802 | Dictionary keys & values will be saved as a JSON file
803 | at ``filepath``. If the file does not exist, the dictionary
804 | (and settings file) will be initialised with ``defaults``.
805 |
806 | :param filepath: where to save the settings
807 | :type filepath: :class:`unicode`
808 | :param defaults: dict of default settings
809 | :type defaults: :class:`dict`
810 |
811 |
812 | An appropriate instance is provided by :class:`Workflow` instances at
813 | :attr:`Workflow.settings`.
814 |
815 | """
816 |
817 | def __init__(self, filepath, defaults=None):
818 | """Create new :class:`Settings` object."""
819 | super(Settings, self).__init__()
820 | self._filepath = filepath
821 | self._nosave = False
822 | self._original = {}
823 | if os.path.exists(self._filepath):
824 | self._load()
825 | elif defaults:
826 | for key, val in list(defaults.items()):
827 | self[key] = val
828 | self.save() # save default settings
829 |
830 | def _load(self):
831 | """Load cached settings from JSON file `self._filepath`."""
832 | data = {}
833 | with LockFile(self._filepath, 0.5):
834 | with open(self._filepath, "r") as fp:
835 | data.update(json.load(fp))
836 |
837 | self._original = deepcopy(data)
838 |
839 | self._nosave = True
840 | self.update(data)
841 | self._nosave = False
842 |
843 | @uninterruptible
844 | def save(self):
845 | """Save settings to JSON file specified in ``self._filepath``.
846 |
847 | If you're using this class via :attr:`Workflow.settings`, which
848 | you probably are, ``self._filepath`` will be ``settings.json``
849 | in your workflow's data directory (see :attr:`~Workflow.datadir`).
850 | """
851 | if self._nosave:
852 | return
853 |
854 | data = {}
855 | data.update(self)
856 |
857 | with LockFile(self._filepath, 0.5):
858 | with atomic_writer(self._filepath, "w") as fp:
859 | json.dump(data, fp, sort_keys=True, indent=2)
860 |
861 | # dict methods
862 | def __setitem__(self, key, value):
863 | """Implement :class:`dict` interface."""
864 | if self._original.get(key) != value:
865 | super(Settings, self).__setitem__(key, value)
866 | self.save()
867 |
868 | def __delitem__(self, key):
869 | """Implement :class:`dict` interface."""
870 | super(Settings, self).__delitem__(key)
871 | self.save()
872 |
873 | def update(self, *args, **kwargs):
874 | """Override :class:`dict` method to save on update."""
875 | super(Settings, self).update(*args, **kwargs)
876 | self.save()
877 |
878 | def setdefault(self, key, value=None):
879 | """Override :class:`dict` method to save on update."""
880 | ret = super(Settings, self).setdefault(key, value)
881 | self.save()
882 | return ret
883 |
884 |
885 | class Workflow(object):
886 | """The ``Workflow`` object is the main interface to Alfred-Workflow.
887 |
888 | It provides APIs for accessing the Alfred/workflow environment,
889 | storing & caching data, using Keychain, and generating Script
890 | Filter feedback.
891 |
892 | ``Workflow`` is compatible with Alfred 2+. Subclass
893 | :class:`~workflow.Workflow3` provides additional features,
894 | only available in Alfred 3+, such as workflow variables.
895 |
896 | :param default_settings: default workflow settings. If no settings file
897 | exists, :class:`Workflow.settings` will be pre-populated with
898 | ``default_settings``.
899 | :type default_settings: :class:`dict`
900 | :param update_settings: settings for updating your workflow from
901 | GitHub releases. The only required key is ``github_slug``,
902 | whose value must take the form of ``username/repo``.
903 | If specified, ``Workflow`` will check the repo's releases
904 | for updates. Your workflow must also have a semantic version
905 | number. Please see the :ref:`User Manual ` and
906 | `update API docs ` for more information.
907 | :type update_settings: :class:`dict`
908 | :param input_encoding: encoding of command line arguments. You
909 | should probably leave this as the default (``utf-8``), which
910 | is the encoding Alfred uses.
911 | :type input_encoding: :class:`unicode`
912 | :param normalization: normalisation to apply to CLI args.
913 | See :meth:`Workflow.decode` for more details.
914 | :type normalization: :class:`unicode`
915 | :param capture_args: Capture and act on ``workflow:*`` arguments. See
916 | :ref:`Magic arguments ` for details.
917 | :type capture_args: :class:`Boolean`
918 | :param libraries: sequence of paths to directories containing
919 | libraries. These paths will be prepended to ``sys.path``.
920 | :type libraries: :class:`tuple` or :class:`list`
921 | :param help_url: URL to webpage where a user can ask for help with
922 | the workflow, report bugs, etc. This could be the GitHub repo
923 | or a page on AlfredForum.com. If your workflow throws an error,
924 | this URL will be displayed in the log and Alfred's debugger. It can
925 | also be opened directly in a web browser with the ``workflow:help``
926 | :ref:`magic argument `.
927 | :type help_url: :class:`unicode` or :class:`str`
928 |
929 | """
930 |
931 | # Which class to use to generate feedback items. You probably
932 | # won't want to change this
933 | item_class = Item
934 |
935 | def __init__(
936 | self,
937 | default_settings=None,
938 | update_settings=None,
939 | input_encoding="utf-8",
940 | normalization="NFC",
941 | capture_args=True,
942 | libraries=None,
943 | help_url=None,
944 | ):
945 | """Create new :class:`Workflow` object."""
946 |
947 | seralizer = "pickle"
948 |
949 | self._default_settings = default_settings or {}
950 | self._update_settings = update_settings or {}
951 | self._input_encoding = input_encoding
952 | self._normalizsation = normalization
953 | self._capture_args = capture_args
954 | self.help_url = help_url
955 | self._workflowdir = None
956 | self._settings_path = None
957 | self._settings = None
958 | self._bundleid = None
959 | self._debugging = None
960 | self._name = None
961 | self._cache_serializer = seralizer
962 | self._data_serializer = seralizer
963 | self._info = None
964 | self._info_loaded = False
965 | self._logger = None
966 | self._items = []
967 | self._alfred_env = None
968 | # Version number of the workflow
969 | self._version = UNSET
970 | # Version from last workflow run
971 | self._last_version_run = UNSET
972 | # Cache for regex patterns created for filter keys
973 | self._search_pattern_cache = {}
974 | #: Prefix for all magic arguments.
975 | #: The default value is ``workflow:`` so keyword
976 | #: ``config`` would match user query ``workflow:config``.
977 | self.magic_prefix = "workflow:"
978 | #: Mapping of available magic arguments. The built-in magic
979 | #: arguments are registered by default. To add your own magic arguments
980 | #: (or override built-ins), add a key:value pair where the key is
981 | #: what the user should enter (prefixed with :attr:`magic_prefix`)
982 | #: and the value is a callable that will be called when the argument
983 | #: is entered. If you would like to display a message in Alfred, the
984 | #: function should return a ``unicode`` string.
985 | #:
986 | #: By default, the magic arguments documented
987 | #: :ref:`here ` are registered.
988 | self.magic_arguments = {}
989 |
990 | self._register_default_magic()
991 |
992 | if libraries:
993 | sys.path = libraries + sys.path
994 |
995 | ####################################################################
996 | # API methods
997 | ####################################################################
998 |
999 | # info.plist contents and alfred_* environment variables ----------
1000 |
1001 | @property
1002 | def alfred_version(self):
1003 | """Alfred version as :class:`~workflow.update.Version` object."""
1004 | from .update import Version
1005 |
1006 | return Version(self.alfred_env.get("version"))
1007 |
1008 | @property
1009 | def alfred_env(self):
1010 | """Dict of Alfred's environmental variables minus ``alfred_`` prefix.
1011 |
1012 | .. versionadded:: 1.7
1013 |
1014 | The variables Alfred 2.4+ exports are:
1015 |
1016 | ============================ =========================================
1017 | Variable Description
1018 | ============================ =========================================
1019 | debug Set to ``1`` if Alfred's debugger is
1020 | open, otherwise unset.
1021 | preferences Path to Alfred.alfredpreferences
1022 | (where your workflows and settings are
1023 | stored).
1024 | preferences_localhash Machine-specific preferences are stored
1025 | in ``Alfred.alfredpreferences/preferences/local/``
1026 | (see ``preferences`` above for
1027 | the path to ``Alfred.alfredpreferences``)
1028 | theme ID of selected theme
1029 | theme_background Background colour of selected theme in
1030 | format ``rgba(r,g,b,a)``
1031 | theme_subtext Show result subtext.
1032 | ``0`` = Always,
1033 | ``1`` = Alternative actions only,
1034 | ``2`` = Selected result only,
1035 | ``3`` = Never
1036 | version Alfred version number, e.g. ``'2.4'``
1037 | version_build Alfred build number, e.g. ``277``
1038 | workflow_bundleid Bundle ID, e.g.
1039 | ``net.deanishe.alfred-mailto``
1040 | workflow_cache Path to workflow's cache directory
1041 | workflow_data Path to workflow's data directory
1042 | workflow_name Name of current workflow
1043 | workflow_uid UID of workflow
1044 | workflow_version The version number specified in the
1045 | workflow configuration sheet/info.plist
1046 | ============================ =========================================
1047 |
1048 | **Note:** all values are Unicode strings except ``version_build`` and
1049 | ``theme_subtext``, which are integers.
1050 |
1051 | :returns: ``dict`` of Alfred's environmental variables without the
1052 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``.
1053 |
1054 | """
1055 | if self._alfred_env is not None:
1056 | return self._alfred_env
1057 |
1058 | data = {}
1059 |
1060 | for key in (
1061 | "debug",
1062 | "preferences",
1063 | "preferences_localhash",
1064 | "theme",
1065 | "theme_background",
1066 | "theme_subtext",
1067 | "version",
1068 | "version_build",
1069 | "workflow_bundleid",
1070 | "workflow_cache",
1071 | "workflow_data",
1072 | "workflow_name",
1073 | "workflow_uid",
1074 | "workflow_version",
1075 | ):
1076 |
1077 | value = os.getenv("alfred_" + key, "")
1078 |
1079 | if value:
1080 | if key in ("debug", "version_build", "theme_subtext"):
1081 | if value.isdigit():
1082 | value = int(value)
1083 | else:
1084 | value = False
1085 | else:
1086 | value = self.decode(value)
1087 |
1088 | data[key] = value
1089 |
1090 | self._alfred_env = data
1091 |
1092 | return self._alfred_env
1093 |
1094 | @property
1095 | def info(self):
1096 | """:class:`dict` of ``info.plist`` contents."""
1097 | if not self._info_loaded:
1098 | self._load_info_plist()
1099 | return self._info
1100 |
1101 | @property
1102 | def bundleid(self):
1103 | """Workflow bundle ID from environmental vars or ``info.plist``.
1104 |
1105 | :returns: bundle ID
1106 | :rtype: ``unicode``
1107 |
1108 | """
1109 | if not self._bundleid:
1110 | if self.alfred_env.get("workflow_bundleid"):
1111 | self._bundleid = self.alfred_env.get("workflow_bundleid")
1112 | else:
1113 | self._bundleid = self.info["bundleid"]
1114 |
1115 | return self._bundleid
1116 |
1117 | @property
1118 | def debugging(self):
1119 | """Whether Alfred's debugger is open.
1120 |
1121 | :returns: ``True`` if Alfred's debugger is open.
1122 | :rtype: ``bool``
1123 |
1124 | """
1125 | return bool(
1126 | self.alfred_env.get("debug") == 1 or os.environ.get("PYTEST_RUNNING")
1127 | )
1128 |
1129 | @property
1130 | def name(self):
1131 | """Workflow name from Alfred's environmental vars or ``info.plist``.
1132 |
1133 | :returns: workflow name
1134 | :rtype: ``unicode``
1135 |
1136 | """
1137 | if not self._name:
1138 | if self.alfred_env.get("workflow_name"):
1139 | self._name = self.decode(self.alfred_env.get("workflow_name"))
1140 | else:
1141 | self._name = self.decode(self.info["name"])
1142 |
1143 | return self._name
1144 |
1145 | @property
1146 | def version(self):
1147 | """Return the version of the workflow.
1148 |
1149 | .. versionadded:: 1.9.10
1150 |
1151 | Get the workflow version from environment variable,
1152 | the ``update_settings`` dict passed on
1153 | instantiation, the ``version`` file located in the workflow's
1154 | root directory or ``info.plist``. Return ``None`` if none
1155 | exists or :class:`ValueError` if the version number is invalid
1156 | (i.e. not semantic).
1157 |
1158 | :returns: Version of the workflow (not Alfred-Workflow)
1159 | :rtype: :class:`~workflow.update.Version` object
1160 |
1161 | """
1162 | if self._version is UNSET:
1163 |
1164 | version = None
1165 | # environment variable has priority
1166 | if self.alfred_env.get("workflow_version"):
1167 | version = self.alfred_env["workflow_version"]
1168 |
1169 | # Try `update_settings`
1170 | elif self._update_settings:
1171 | version = self._update_settings.get("version")
1172 |
1173 | # `version` file
1174 | if not version:
1175 | filepath = self.workflowfile("version")
1176 |
1177 | if os.path.exists(filepath):
1178 | with open(filepath, "r") as fileobj:
1179 | version = fileobj.read()
1180 |
1181 | # info.plist
1182 | if not version:
1183 | version = self.info.get("version")
1184 |
1185 | if version:
1186 | from .update import Version
1187 |
1188 | version = Version(version)
1189 |
1190 | self._version = version
1191 |
1192 | return self._version
1193 |
1194 | # Workflow utility methods -----------------------------------------
1195 |
1196 | @property
1197 | def args(self):
1198 | """Return command line args as normalised unicode.
1199 |
1200 | Args are decoded and normalised via :meth:`~Workflow.decode`.
1201 |
1202 | The encoding and normalisation are the ``input_encoding`` and
1203 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
1204 | and ``NFC`` are the defaults).
1205 |
1206 | If :class:`Workflow` is called with ``capture_args=True``
1207 | (the default), :class:`Workflow` will look for certain
1208 | ``workflow:*`` args and, if found, perform the corresponding
1209 | actions and exit the workflow.
1210 |
1211 | See :ref:`Magic arguments ` for details.
1212 |
1213 | """
1214 | msg = None
1215 | args = [self.decode(arg) for arg in sys.argv[1:]]
1216 |
1217 | # Handle magic args
1218 | if len(args) and self._capture_args:
1219 | for name in self.magic_arguments:
1220 | key = "{0}{1}".format(self.magic_prefix, name)
1221 | if key in args:
1222 | msg = self.magic_arguments[name]()
1223 |
1224 | if msg:
1225 | self.logger.debug(msg)
1226 | if not sys.stdout.isatty(): # Show message in Alfred
1227 | self.add_item(msg, valid=False, icon=ICON_INFO)
1228 | self.send_feedback()
1229 | sys.exit(0)
1230 | return args
1231 |
1232 | @property
1233 | def cachedir(self):
1234 | """Path to workflow's cache directory.
1235 |
1236 | The cache directory is a subdirectory of Alfred's own cache directory
1237 | in ``~/Library/Caches``. The full path is in Alfred 4+ is:
1238 |
1239 | ``~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/``
1240 |
1241 | For earlier versions:
1242 |
1243 | ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/``
1244 |
1245 | where ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``.
1246 |
1247 | Returns:
1248 | unicode: full path to workflow's cache directory
1249 |
1250 | """
1251 | if self.alfred_env.get("workflow_cache"):
1252 | dirpath = self.alfred_env.get("workflow_cache")
1253 |
1254 | else:
1255 | dirpath = self._default_cachedir
1256 |
1257 | return self._create(dirpath)
1258 |
1259 | @property
1260 | def _default_cachedir(self):
1261 | """Alfred 2's default cache directory."""
1262 | return os.path.join(
1263 | os.path.expanduser(
1264 | "~/Library/Caches/com.runningwithcrayons.Alfred-2/" "Workflow Data/"
1265 | ),
1266 | self.bundleid,
1267 | )
1268 |
1269 | @property
1270 | def datadir(self):
1271 | """Path to workflow's data directory.
1272 |
1273 | The data directory is a subdirectory of Alfred's own data directory in
1274 | ``~/Library/Application Support``. The full path for Alfred 4+ is:
1275 |
1276 | ``~/Library/Application Support/Alfred/Workflow Data/``
1277 |
1278 | For earlier versions, the path is:
1279 |
1280 | ``~/Library/Application Support/Alfred X/Workflow Data/``
1281 |
1282 | where ``Alfred X` is ``Alfred 2`` or ``Alfred 3``.
1283 |
1284 | Returns:
1285 | unicode: full path to workflow data directory
1286 |
1287 | """
1288 | if self.alfred_env.get("workflow_data"):
1289 | dirpath = self.alfred_env.get("workflow_data")
1290 |
1291 | else:
1292 | dirpath = self._default_datadir
1293 |
1294 | return self._create(dirpath)
1295 |
1296 | @property
1297 | def _default_datadir(self):
1298 | """Alfred 2's default data directory."""
1299 | return os.path.join(
1300 | os.path.expanduser("~/Library/Application Support/Alfred 2/Workflow Data/"),
1301 | self.bundleid,
1302 | )
1303 |
1304 | @property
1305 | def workflowdir(self):
1306 | """Path to workflow's root directory (where ``info.plist`` is).
1307 |
1308 | Returns:
1309 | unicode: full path to workflow root directory
1310 |
1311 | """
1312 | if not self._workflowdir:
1313 | # Try the working directory first, then the directory
1314 | # the library is in. CWD will be the workflow root if
1315 | # a workflow is being run in Alfred
1316 | candidates = [
1317 | os.path.abspath(os.getcwd()),
1318 | os.path.dirname(os.path.abspath(os.path.dirname(__file__))),
1319 | ]
1320 |
1321 | # climb the directory tree until we find `info.plist`
1322 | for dirpath in candidates:
1323 |
1324 | # Ensure directory path is Unicode
1325 | dirpath = self.decode(dirpath)
1326 |
1327 | while True:
1328 | if os.path.exists(os.path.join(dirpath, "info.plist")):
1329 | self._workflowdir = dirpath
1330 | break
1331 |
1332 | elif dirpath == "/":
1333 | # no `info.plist` found
1334 | break
1335 |
1336 | # Check the parent directory
1337 | dirpath = os.path.dirname(dirpath)
1338 |
1339 | # No need to check other candidates
1340 | if self._workflowdir:
1341 | break
1342 |
1343 | if not self._workflowdir:
1344 | raise IOError("'info.plist' not found in directory tree")
1345 |
1346 | return self._workflowdir
1347 |
1348 | def cachefile(self, filename):
1349 | """Path to ``filename`` in workflow's cache directory.
1350 |
1351 | Return absolute path to ``filename`` within your workflow's
1352 | :attr:`cache directory `.
1353 |
1354 | :param filename: basename of file
1355 | :type filename: ``unicode``
1356 | :returns: full path to file within cache directory
1357 | :rtype: ``unicode``
1358 |
1359 | """
1360 | return os.path.join(self.cachedir, filename)
1361 |
1362 | def datafile(self, filename):
1363 | """Path to ``filename`` in workflow's data directory.
1364 |
1365 | Return absolute path to ``filename`` within your workflow's
1366 | :attr:`data directory `.
1367 |
1368 | :param filename: basename of file
1369 | :type filename: ``unicode``
1370 | :returns: full path to file within data directory
1371 | :rtype: ``unicode``
1372 |
1373 | """
1374 | return os.path.join(self.datadir, filename)
1375 |
1376 | def workflowfile(self, filename):
1377 | """Return full path to ``filename`` in workflow's root directory.
1378 |
1379 | :param filename: basename of file
1380 | :type filename: ``unicode``
1381 | :returns: full path to file within data directory
1382 | :rtype: ``unicode``
1383 |
1384 | """
1385 | return os.path.join(self.workflowdir, filename)
1386 |
1387 | @property
1388 | def logfile(self):
1389 | """Path to logfile.
1390 |
1391 | :returns: path to logfile within workflow's cache directory
1392 | :rtype: ``unicode``
1393 |
1394 | """
1395 | return self.cachefile("%s.log" % self.bundleid)
1396 |
1397 | @property
1398 | def logger(self):
1399 | """Logger that logs to both console and a log file.
1400 |
1401 | If Alfred's debugger is open, log level will be ``DEBUG``,
1402 | else it will be ``INFO``.
1403 |
1404 | Use :meth:`open_log` to open the log file in Console.
1405 |
1406 | :returns: an initialised :class:`~logging.Logger`
1407 |
1408 | """
1409 | if self._logger:
1410 | return self._logger
1411 |
1412 | # Initialise new logger and optionally handlers
1413 | logger = logging.getLogger("")
1414 |
1415 | # Only add one set of handlers
1416 | # Exclude from coverage, as pytest will have configured the
1417 | # root logger already
1418 | if not len(logger.handlers): # pragma: no cover
1419 |
1420 | fmt = logging.Formatter(
1421 | "%(asctime)s %(filename)s:%(lineno)s" " %(levelname)-8s %(message)s",
1422 | datefmt="%H:%M:%S",
1423 | )
1424 |
1425 | logfile = logging.handlers.RotatingFileHandler(
1426 | self.logfile, maxBytes=1024 * 1024, backupCount=1
1427 | )
1428 | logfile.setFormatter(fmt)
1429 | logger.addHandler(logfile)
1430 |
1431 | console = logging.StreamHandler()
1432 | console.setFormatter(fmt)
1433 | logger.addHandler(console)
1434 |
1435 | if self.debugging:
1436 | logger.setLevel(logging.DEBUG)
1437 | else:
1438 | logger.setLevel(logging.INFO)
1439 |
1440 | self._logger = logger
1441 |
1442 | return self._logger
1443 |
1444 | @logger.setter
1445 | def logger(self, logger):
1446 | """Set a custom logger.
1447 |
1448 | :param logger: The logger to use
1449 | :type logger: `~logging.Logger` instance
1450 |
1451 | """
1452 | self._logger = logger
1453 |
1454 | @property
1455 | def settings_path(self):
1456 | """Path to settings file within workflow's data directory.
1457 |
1458 | :returns: path to ``settings.json`` file
1459 | :rtype: ``unicode``
1460 |
1461 | """
1462 | if not self._settings_path:
1463 | self._settings_path = self.datafile("settings.json")
1464 | return self._settings_path
1465 |
1466 | @property
1467 | def settings(self):
1468 | """Return a dictionary subclass that saves itself when changed.
1469 |
1470 | See :ref:`guide-settings` in the :ref:`user-manual` for more
1471 | information on how to use :attr:`settings` and **important
1472 | limitations** on what it can do.
1473 |
1474 | :returns: :class:`~workflow.workflow.Settings` instance
1475 | initialised from the data in JSON file at
1476 | :attr:`settings_path` or if that doesn't exist, with the
1477 | ``default_settings`` :class:`dict` passed to
1478 | :class:`Workflow` on instantiation.
1479 | :rtype: :class:`~workflow.workflow.Settings` instance
1480 |
1481 | """
1482 | if not self._settings:
1483 | self.logger.debug("reading settings from %s", self.settings_path)
1484 | self._settings = Settings(self.settings_path, self._default_settings)
1485 | return self._settings
1486 |
1487 | @property
1488 | def cache_serializer(self):
1489 | """Name of default cache serializer.
1490 |
1491 | .. versionadded:: 1.8
1492 |
1493 | This serializer is used by :meth:`cache_data()` and
1494 | :meth:`cached_data()`
1495 |
1496 | See :class:`SerializerManager` for details.
1497 |
1498 | :returns: serializer name
1499 | :rtype: ``unicode``
1500 |
1501 | """
1502 | return self._cache_serializer
1503 |
1504 | @cache_serializer.setter
1505 | def cache_serializer(self, serializer_name):
1506 | """Set the default cache serialization format.
1507 |
1508 | .. versionadded:: 1.8
1509 |
1510 | This serializer is used by :meth:`cache_data()` and
1511 | :meth:`cached_data()`
1512 |
1513 | The specified serializer must already by registered with the
1514 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1515 | otherwise a :class:`ValueError` will be raised.
1516 |
1517 | :param serializer_name: Name of default serializer to use.
1518 | :type serializer_name:
1519 |
1520 | """
1521 | if manager.serializer(serializer_name) is None:
1522 | raise ValueError(
1523 | "Unknown serializer : `{0}`. Register your serializer "
1524 | "with `manager` first.".format(serializer_name)
1525 | )
1526 |
1527 | self.logger.debug("default cache serializer: %s", serializer_name)
1528 |
1529 | self._cache_serializer = serializer_name
1530 |
1531 | @property
1532 | def data_serializer(self):
1533 | """Name of default data serializer.
1534 |
1535 | .. versionadded:: 1.8
1536 |
1537 | This serializer is used by :meth:`store_data()` and
1538 | :meth:`stored_data()`
1539 |
1540 | See :class:`SerializerManager` for details.
1541 |
1542 | :returns: serializer name
1543 | :rtype: ``unicode``
1544 |
1545 | """
1546 | return self._data_serializer
1547 |
1548 | @data_serializer.setter
1549 | def data_serializer(self, serializer_name):
1550 | """Set the default cache serialization format.
1551 |
1552 | .. versionadded:: 1.8
1553 |
1554 | This serializer is used by :meth:`store_data()` and
1555 | :meth:`stored_data()`
1556 |
1557 | The specified serializer must already by registered with the
1558 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1559 | otherwise a :class:`ValueError` will be raised.
1560 |
1561 | :param serializer_name: Name of serializer to use by default.
1562 |
1563 | """
1564 | if manager.serializer(serializer_name) is None:
1565 | raise ValueError(
1566 | "Unknown serializer : `{0}`. Register your serializer "
1567 | "with `manager` first.".format(serializer_name)
1568 | )
1569 |
1570 | self.logger.debug("default data serializer: %s", serializer_name)
1571 |
1572 | self._data_serializer = serializer_name
1573 |
1574 | def stored_data(self, name):
1575 | """Retrieve data from data directory.
1576 |
1577 | Returns ``None`` if there are no data stored under ``name``.
1578 |
1579 | .. versionadded:: 1.8
1580 |
1581 | :param name: name of datastore
1582 |
1583 | """
1584 | metadata_path = self.datafile(".{0}.alfred-workflow".format(name))
1585 |
1586 | if not os.path.exists(metadata_path):
1587 | self.logger.debug("no data stored for `%s`", name)
1588 | return None
1589 |
1590 | with open(metadata_path, "r") as file_obj:
1591 | serializer_name = file_obj.read().strip()
1592 |
1593 | serializer = manager.serializer(serializer_name)
1594 |
1595 | if serializer is None:
1596 | raise ValueError(
1597 | "Unknown serializer `{0}`. Register a corresponding "
1598 | "serializer with `manager.register()` "
1599 | "to load this data.".format(serializer_name)
1600 | )
1601 |
1602 | self.logger.debug("data `%s` stored as `%s`", name, serializer_name)
1603 |
1604 | filename = "{0}.{1}".format(name, serializer_name)
1605 | data_path = self.datafile(filename)
1606 |
1607 | if not os.path.exists(data_path):
1608 | self.logger.debug("no data stored: %s", name)
1609 | if os.path.exists(metadata_path):
1610 | os.unlink(metadata_path)
1611 |
1612 | return None
1613 |
1614 | with open(data_path, "rb") as file_obj:
1615 | data = serializer.load(file_obj)
1616 |
1617 | self.logger.debug("stored data loaded: %s", data_path)
1618 |
1619 | return data
1620 |
1621 | def store_data(self, name, data, serializer=None):
1622 | """Save data to data directory.
1623 |
1624 | .. versionadded:: 1.8
1625 |
1626 | If ``data`` is ``None``, the datastore will be deleted.
1627 |
1628 | Note that the datastore does NOT support mutliple threads.
1629 |
1630 | :param name: name of datastore
1631 | :param data: object(s) to store. **Note:** some serializers
1632 | can only handled certain types of data.
1633 | :param serializer: name of serializer to use. If no serializer
1634 | is specified, the default will be used. See
1635 | :class:`SerializerManager` for more information.
1636 | :returns: data in datastore or ``None``
1637 |
1638 | """
1639 | # Ensure deletion is not interrupted by SIGTERM
1640 | @uninterruptible
1641 | def delete_paths(paths):
1642 | """Clear one or more data stores"""
1643 | for path in paths:
1644 | if os.path.exists(path):
1645 | os.unlink(path)
1646 | self.logger.debug("deleted data file: %s", path)
1647 |
1648 | serializer_name = serializer or self.data_serializer
1649 |
1650 | # In order for `stored_data()` to be able to load data stored with
1651 | # an arbitrary serializer, yet still have meaningful file extensions,
1652 | # the format (i.e. extension) is saved to an accompanying file
1653 | metadata_path = self.datafile(".{0}.alfred-workflow".format(name))
1654 | filename = "{0}.{1}".format(name, serializer_name)
1655 | data_path = self.datafile(filename)
1656 |
1657 | if data_path == self.settings_path:
1658 | raise ValueError(
1659 | "Cannot save data to"
1660 | + "`{0}` with format `{1}`. ".format(name, serializer_name)
1661 | + "This would overwrite Alfred-Workflow's settings file."
1662 | )
1663 |
1664 | serializer = manager.serializer(serializer_name)
1665 |
1666 | if serializer is None:
1667 | raise ValueError(
1668 | "Invalid serializer `{0}`. Register your serializer with "
1669 | "`manager.register()` first.".format(serializer_name)
1670 | )
1671 |
1672 | if data is None: # Delete cached data
1673 | delete_paths((metadata_path, data_path))
1674 | return
1675 |
1676 | if isinstance(data, str):
1677 | data = bytearray(data)
1678 |
1679 | # Ensure write is not interrupted by SIGTERM
1680 | @uninterruptible
1681 | def _store():
1682 | # Save file extension
1683 | with atomic_writer(metadata_path, "w") as file_obj:
1684 | file_obj.write(serializer_name)
1685 |
1686 | with serializer.atomic_writer(data_path, "w") as file_obj:
1687 | serializer.dump(data, file_obj)
1688 |
1689 | _store()
1690 |
1691 | self.logger.debug("saved data: %s", data_path)
1692 |
1693 | def cached_data(self, name, data_func=None, max_age=60):
1694 | """Return cached data if younger than ``max_age`` seconds.
1695 |
1696 | Retrieve data from cache or re-generate and re-cache data if
1697 | stale/non-existant. If ``max_age`` is 0, return cached data no
1698 | matter how old.
1699 |
1700 | :param name: name of datastore
1701 | :param data_func: function to (re-)generate data.
1702 | :type data_func: ``callable``
1703 | :param max_age: maximum age of cached data in seconds
1704 | :type max_age: ``int``
1705 | :returns: cached data, return value of ``data_func`` or ``None``
1706 | if ``data_func`` is not set
1707 |
1708 | """
1709 | serializer = manager.serializer(self.cache_serializer)
1710 |
1711 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer))
1712 | age = self.cached_data_age(name)
1713 |
1714 | if (age < max_age or max_age == 0) and os.path.exists(cache_path):
1715 |
1716 | with open(cache_path, "rb") as file_obj:
1717 | self.logger.debug("loading cached data: %s", cache_path)
1718 | return serializer.load(file_obj)
1719 |
1720 | if not data_func:
1721 | return None
1722 |
1723 | data = data_func()
1724 | self.cache_data(name, data)
1725 |
1726 | return data
1727 |
1728 | def cache_data(self, name, data):
1729 | """Save ``data`` to cache under ``name``.
1730 |
1731 | If ``data`` is ``None``, the corresponding cache file will be
1732 | deleted.
1733 |
1734 | :param name: name of datastore
1735 | :param data: data to store. This may be any object supported by
1736 | the cache serializer
1737 |
1738 | """
1739 | serializer = manager.serializer(self.cache_serializer)
1740 |
1741 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer))
1742 |
1743 | if data is None:
1744 | if os.path.exists(cache_path):
1745 | os.unlink(cache_path)
1746 | self.logger.debug("deleted cache file: %s", cache_path)
1747 | return
1748 |
1749 | with serializer.atomic_writer(cache_path, "w") as file_obj:
1750 | serializer.dump(data, file_obj)
1751 |
1752 | self.logger.debug("cached data: %s", cache_path)
1753 |
1754 | def cached_data_fresh(self, name, max_age):
1755 | """Whether cache `name` is less than `max_age` seconds old.
1756 |
1757 | :param name: name of datastore
1758 | :param max_age: maximum age of data in seconds
1759 | :type max_age: ``int``
1760 | :returns: ``True`` if data is less than ``max_age`` old, else
1761 | ``False``
1762 |
1763 | """
1764 | age = self.cached_data_age(name)
1765 |
1766 | if not age:
1767 | return False
1768 |
1769 | return age < max_age
1770 |
1771 | def cached_data_age(self, name):
1772 | """Return age in seconds of cache `name` or 0 if cache doesn't exist.
1773 |
1774 | :param name: name of datastore
1775 | :type name: ``unicode``
1776 | :returns: age of datastore in seconds
1777 | :rtype: ``int``
1778 |
1779 | """
1780 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer))
1781 |
1782 | if not os.path.exists(cache_path):
1783 | return 0
1784 |
1785 | return time.time() - os.stat(cache_path).st_mtime
1786 |
1787 | def filter(
1788 | self,
1789 | query,
1790 | items,
1791 | key=lambda x: x,
1792 | ascending=False,
1793 | include_score=False,
1794 | min_score=0,
1795 | max_results=0,
1796 | match_on=MATCH_ALL,
1797 | fold_diacritics=True,
1798 | ):
1799 | """Fuzzy search filter. Returns list of ``items`` that match ``query``.
1800 |
1801 | ``query`` is case-insensitive. Any item that does not contain the
1802 | entirety of ``query`` is rejected.
1803 |
1804 | If ``query`` is an empty string or contains only whitespace,
1805 | all items will match.
1806 |
1807 | :param query: query to test items against
1808 | :type query: ``unicode``
1809 | :param items: iterable of items to test
1810 | :type items: ``list`` or ``tuple``
1811 | :param key: function to get comparison key from ``items``.
1812 | Must return a ``unicode`` string. The default simply returns
1813 | the item.
1814 | :type key: ``callable``
1815 | :param ascending: set to ``True`` to get worst matches first
1816 | :type ascending: ``Boolean``
1817 | :param include_score: Useful for debugging the scoring algorithm.
1818 | If ``True``, results will be a list of tuples
1819 | ``(item, score, rule)``.
1820 | :type include_score: ``Boolean``
1821 | :param min_score: If non-zero, ignore results with a score lower
1822 | than this.
1823 | :type min_score: ``int``
1824 | :param max_results: If non-zero, prune results list to this length.
1825 | :type max_results: ``int``
1826 | :param match_on: Filter option flags. Bitwise-combined list of
1827 | ``MATCH_*`` constants (see below).
1828 | :type match_on: ``int``
1829 | :param fold_diacritics: Convert search keys to ASCII-only
1830 | characters if ``query`` only contains ASCII characters.
1831 | :type fold_diacritics: ``Boolean``
1832 | :returns: list of ``items`` matching ``query`` or list of
1833 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
1834 | ``rule`` is the ``MATCH_*`` rule that matched the item.
1835 | :rtype: ``list``
1836 |
1837 | **Matching rules**
1838 |
1839 | By default, :meth:`filter` uses all of the following flags (i.e.
1840 | :const:`MATCH_ALL`). The tests are always run in the given order:
1841 |
1842 | 1. :const:`MATCH_STARTSWITH`
1843 | Item search key starts with ``query`` (case-insensitive).
1844 | 2. :const:`MATCH_CAPITALS`
1845 | The list of capital letters in item search key starts with
1846 | ``query`` (``query`` may be lower-case). E.g., ``of``
1847 | would match ``OmniFocus``, ``gc`` would match ``Google Chrome``.
1848 | 3. :const:`MATCH_ATOM`
1849 | Search key is split into "atoms" on non-word characters
1850 | (.,-,' etc.). Matches if ``query`` is one of these atoms
1851 | (case-insensitive).
1852 | 4. :const:`MATCH_INITIALS_STARTSWITH`
1853 | Initials are the first characters of the above-described
1854 | "atoms" (case-insensitive).
1855 | 5. :const:`MATCH_INITIALS_CONTAIN`
1856 | ``query`` is a substring of the above-described initials.
1857 | 6. :const:`MATCH_INITIALS`
1858 | Combination of (4) and (5).
1859 | 7. :const:`MATCH_SUBSTRING`
1860 | ``query`` is a substring of item search key (case-insensitive).
1861 | 8. :const:`MATCH_ALLCHARS`
1862 | All characters in ``query`` appear in item search key in
1863 | the same order (case-insensitive).
1864 | 9. :const:`MATCH_ALL`
1865 | Combination of all the above.
1866 |
1867 |
1868 | :const:`MATCH_ALLCHARS` is considerably slower than the other
1869 | tests and provides much less accurate results.
1870 |
1871 | **Examples:**
1872 |
1873 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
1874 | matches and is expensive to run), use
1875 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.
1876 |
1877 | To match only on capitals, use ``match_on=MATCH_CAPITALS``.
1878 |
1879 | To match only on startswith and substring, use
1880 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.
1881 |
1882 | **Diacritic folding**
1883 |
1884 | .. versionadded:: 1.3
1885 |
1886 | If ``fold_diacritics`` is ``True`` (the default), and ``query``
1887 | contains only ASCII characters, non-ASCII characters in search keys
1888 | will be converted to ASCII equivalents (e.g. **ü** -> **u**,
1889 | **ß** -> **ss**, **é** -> **e**).
1890 |
1891 | See :const:`ASCII_REPLACEMENTS` for all replacements.
1892 |
1893 | If ``query`` contains non-ASCII characters, search keys will not be
1894 | altered.
1895 |
1896 | """
1897 | if not query:
1898 | return items
1899 |
1900 | # Remove preceding/trailing spaces
1901 | query = query.strip()
1902 |
1903 | if not query:
1904 | return items
1905 |
1906 | # Use user override if there is one
1907 | fold_diacritics = self.settings.get(
1908 | "__workflow_diacritic_folding", fold_diacritics
1909 | )
1910 |
1911 | results = []
1912 |
1913 | for item in items:
1914 | skip = False
1915 | score = 0
1916 | words = [s.strip() for s in query.split(" ")]
1917 | value = key(item).strip()
1918 | if value == "":
1919 | continue
1920 | for word in words:
1921 | if word == "":
1922 | continue
1923 | s, rule = self._filter_item(value, word, match_on, fold_diacritics)
1924 |
1925 | if not s: # Skip items that don't match part of the query
1926 | skip = True
1927 | score += s
1928 |
1929 | if skip:
1930 | continue
1931 |
1932 | if score:
1933 | # use "reversed" `score` (i.e. highest becomes lowest) and
1934 | # `value` as sort key. This means items with the same score
1935 | # will be sorted in alphabetical not reverse alphabetical order
1936 | results.append(
1937 | ((100.0 / score, value.lower(), score), (item, score, rule))
1938 | )
1939 |
1940 | # sort on keys, then discard the keys
1941 | results.sort(reverse=ascending)
1942 | results = [t[1] for t in results]
1943 |
1944 | if min_score:
1945 | results = [r for r in results if r[1] > min_score]
1946 |
1947 | if max_results and len(results) > max_results:
1948 | results = results[:max_results]
1949 |
1950 | # return list of ``(item, score, rule)``
1951 | if include_score:
1952 | return results
1953 | # just return list of items
1954 | return [t[0] for t in results]
1955 |
1956 | def _filter_item(self, value, query, match_on, fold_diacritics):
1957 | """Filter ``value`` against ``query`` using rules ``match_on``.
1958 |
1959 | :returns: ``(score, rule)``
1960 |
1961 | """
1962 | query = query.lower()
1963 |
1964 | if not isascii(query):
1965 | fold_diacritics = False
1966 |
1967 | if fold_diacritics:
1968 | value = self.fold_to_ascii(value)
1969 |
1970 | # pre-filter any items that do not contain all characters
1971 | # of ``query`` to save on running several more expensive tests
1972 | if not set(query) <= set(value.lower()):
1973 |
1974 | return (0, None)
1975 |
1976 | # item starts with query
1977 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query):
1978 | score = 100.0 - (len(value) / len(query))
1979 |
1980 | return (score, MATCH_STARTSWITH)
1981 |
1982 | # query matches capitalised letters in item,
1983 | # e.g. of = OmniFocus
1984 | if match_on & MATCH_CAPITALS:
1985 | initials = "".join([c for c in value if c in INITIALS])
1986 | if initials.lower().startswith(query):
1987 | score = 100.0 - (len(initials) / len(query))
1988 |
1989 | return (score, MATCH_CAPITALS)
1990 |
1991 | # split the item into "atoms", i.e. words separated by
1992 | # spaces or other non-word characters
1993 | if (
1994 | match_on & MATCH_ATOM
1995 | or match_on & MATCH_INITIALS_CONTAIN
1996 | or match_on & MATCH_INITIALS_STARTSWITH
1997 | ):
1998 | atoms = [s.lower() for s in split_on_delimiters(value)]
1999 | # print('atoms : %s --> %s' % (value, atoms))
2000 | # initials of the atoms
2001 | initials = "".join([s[0] for s in atoms if s])
2002 |
2003 | if match_on & MATCH_ATOM:
2004 | # is `query` one of the atoms in item?
2005 | # similar to substring, but scores more highly, as it's
2006 | # a word within the item
2007 | if query in atoms:
2008 | score = 100.0 - (len(value) / len(query))
2009 |
2010 | return (score, MATCH_ATOM)
2011 |
2012 | # `query` matches start (or all) of the initials of the
2013 | # atoms, e.g. ``himym`` matches "How I Met Your Mother"
2014 | # *and* "how i met your mother" (the ``capitals`` rule only
2015 | # matches the former)
2016 | if match_on & MATCH_INITIALS_STARTSWITH and initials.startswith(query):
2017 | score = 100.0 - (len(initials) / len(query))
2018 |
2019 | return (score, MATCH_INITIALS_STARTSWITH)
2020 |
2021 | # `query` is a substring of initials, e.g. ``doh`` matches
2022 | # "The Dukes of Hazzard"
2023 | elif match_on & MATCH_INITIALS_CONTAIN and query in initials:
2024 | score = 95.0 - (len(initials) / len(query))
2025 |
2026 | return (score, MATCH_INITIALS_CONTAIN)
2027 |
2028 | # `query` is a substring of item
2029 | if match_on & MATCH_SUBSTRING and query in value.lower():
2030 | score = 90.0 - (len(value) / len(query))
2031 |
2032 | return (score, MATCH_SUBSTRING)
2033 |
2034 | # finally, assign a score based on how close together the
2035 | # characters in `query` are in item.
2036 | if match_on & MATCH_ALLCHARS:
2037 | search = self._search_for_query(query)
2038 | match = search(value)
2039 | if match:
2040 | score = 100.0 / (
2041 | (1 + match.start()) * (match.end() - match.start() + 1)
2042 | )
2043 |
2044 | return (score, MATCH_ALLCHARS)
2045 |
2046 | # Nothing matched
2047 | return (0, None)
2048 |
2049 | def _search_for_query(self, query):
2050 | if query in self._search_pattern_cache:
2051 | return self._search_pattern_cache[query]
2052 |
2053 | # Build pattern: include all characters
2054 | pattern = []
2055 | for c in query:
2056 | # pattern.append('[^{0}]*{0}'.format(re.escape(c)))
2057 | pattern.append(".*?{0}".format(re.escape(c)))
2058 | pattern = "".join(pattern)
2059 | search = re.compile(pattern, re.IGNORECASE).search
2060 |
2061 | self._search_pattern_cache[query] = search
2062 | return search
2063 |
2064 | def run(self, func, text_errors=False):
2065 | """Call ``func`` to run your workflow.
2066 |
2067 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
2068 | instance) as first argument.
2069 | :param text_errors: Emit error messages in plain text, not in
2070 | Alfred's XML/JSON feedback format. Use this when you're not
2071 | running Alfred-Workflow in a Script Filter and would like
2072 | to pass the error message to, say, a notification.
2073 | :type text_errors: ``Boolean``
2074 |
2075 | ``func`` will be called with :class:`Workflow` instance as first
2076 | argument.
2077 |
2078 | ``func`` should be the main entry point to your workflow.
2079 |
2080 | Any exceptions raised will be logged and an error message will be
2081 | output to Alfred.
2082 |
2083 | """
2084 | start = time.time()
2085 |
2086 | # Write to debugger to ensure "real" output starts on a new line
2087 | print(".", file=sys.stderr)
2088 |
2089 | # Call workflow's entry function/method within a try-except block
2090 | # to catch any errors and display an error message in Alfred
2091 | try:
2092 | if self.version:
2093 | self.logger.debug(
2094 | "---------- %s (%s) ----------", self.name, self.version
2095 | )
2096 | else:
2097 | self.logger.debug("---------- %s ----------", self.name)
2098 |
2099 | # Run update check if configured for self-updates.
2100 | # This call has to go in the `run` try-except block, as it will
2101 | # initialise `self.settings`, which will raise an exception
2102 | # if `settings.json` isn't valid.
2103 | if self._update_settings:
2104 | self.check_update()
2105 |
2106 | # Run workflow's entry function/method
2107 | func(self)
2108 |
2109 | # Set last version run to current version after a successful
2110 | # run
2111 | self.set_last_version()
2112 |
2113 | except Exception as err:
2114 | self.logger.exception(err)
2115 | if self.help_url:
2116 | self.logger.info("for assistance, see: %s", self.help_url)
2117 |
2118 | if not sys.stdout.isatty(): # Show error in Alfred
2119 | if text_errors:
2120 | print(str(err).encode("utf-8"), end="")
2121 | else:
2122 | self._items = []
2123 | if self._name:
2124 | name = self._name
2125 | elif self._bundleid: # pragma: no cover
2126 | name = self._bundleid
2127 | else: # pragma: no cover
2128 | name = os.path.dirname(__file__)
2129 | self.add_item(
2130 | "Error in workflow '%s'" % name, str(err), icon=ICON_ERROR
2131 | )
2132 | self.send_feedback()
2133 | return 1
2134 |
2135 | finally:
2136 | self.logger.debug(
2137 | "---------- finished in %0.3fs ----------", time.time() - start
2138 | )
2139 |
2140 | return 0
2141 |
2142 | # Alfred feedback methods ------------------------------------------
2143 |
2144 | def add_item(
2145 | self,
2146 | title,
2147 | subtitle="",
2148 | modifier_subtitles=None,
2149 | arg=None,
2150 | autocomplete=None,
2151 | valid=False,
2152 | uid=None,
2153 | icon=None,
2154 | icontype=None,
2155 | type=None,
2156 | largetext=None,
2157 | copytext=None,
2158 | quicklookurl=None,
2159 | ):
2160 | """Add an item to be output to Alfred.
2161 |
2162 | :param title: Title shown in Alfred
2163 | :type title: ``unicode``
2164 | :param subtitle: Subtitle shown in Alfred
2165 | :type subtitle: ``unicode``
2166 | :param modifier_subtitles: Subtitles shown when modifier
2167 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase
2168 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn``
2169 | :type modifier_subtitles: ``dict``
2170 | :param arg: Argument passed by Alfred as ``{query}`` when item is
2171 | actioned
2172 | :type arg: ``unicode``
2173 | :param autocomplete: Text expanded in Alfred when item is TABbed
2174 | :type autocomplete: ``unicode``
2175 | :param valid: Whether or not item can be actioned
2176 | :type valid: ``Boolean``
2177 | :param uid: Used by Alfred to remember/sort items
2178 | :type uid: ``unicode``
2179 | :param icon: Filename of icon to use
2180 | :type icon: ``unicode``
2181 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'``
2182 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype
2183 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to
2184 | use the icon of the file specified as ``icon``, e.g.
2185 | ``icon='/Applications/Safari.app', icontype='fileicon'``.
2186 | Leave as `None` if ``icon`` points to an actual
2187 | icon file.
2188 | :type icontype: ``unicode``
2189 | :param type: Result type. Currently only ``'file'`` is supported
2190 | (by Alfred). This will tell Alfred to enable file actions for
2191 | this item.
2192 | :type type: ``unicode``
2193 | :param largetext: Text to be displayed in Alfred's large text box
2194 | if user presses CMD+L on item.
2195 | :type largetext: ``unicode``
2196 | :param copytext: Text to be copied to pasteboard if user presses
2197 | CMD+C on item.
2198 | :type copytext: ``unicode``
2199 | :param quicklookurl: URL to be displayed using Alfred's Quick Look
2200 | feature (tapping ``SHIFT`` or ``⌘+Y`` on a result).
2201 | :type quicklookurl: ``unicode``
2202 | :returns: :class:`Item` instance
2203 |
2204 | See :ref:`icons` for a list of the supported system icons.
2205 |
2206 | .. note::
2207 |
2208 | Although this method returns an :class:`Item` instance, you don't
2209 | need to hold onto it or worry about it. All generated :class:`Item`
2210 | instances are also collected internally and sent to Alfred when
2211 | :meth:`send_feedback` is called.
2212 |
2213 | The generated :class:`Item` is only returned in case you want to
2214 | edit it or do something with it other than send it to Alfred.
2215 |
2216 | """
2217 | item = self.item_class(
2218 | title,
2219 | subtitle,
2220 | modifier_subtitles,
2221 | arg,
2222 | autocomplete,
2223 | valid,
2224 | uid,
2225 | icon,
2226 | icontype,
2227 | type,
2228 | largetext,
2229 | copytext,
2230 | quicklookurl,
2231 | )
2232 | self._items.append(item)
2233 | return item
2234 |
2235 | def send_feedback(self):
2236 | """Print stored items to console/Alfred as XML."""
2237 | root = ET.Element("items")
2238 | for item in self._items:
2239 | root.append(item.elem)
2240 | sys.stdout.write('\n')
2241 | sys.stdout.write(ET.tostring(root, encoding="unicode"))
2242 | sys.stdout.flush()
2243 |
2244 | ####################################################################
2245 | # Updating methods
2246 | ####################################################################
2247 |
2248 | @property
2249 | def first_run(self):
2250 | """Return ``True`` if it's the first time this version has run.
2251 |
2252 | .. versionadded:: 1.9.10
2253 |
2254 | Raises a :class:`ValueError` if :attr:`version` isn't set.
2255 |
2256 | """
2257 | if not self.version:
2258 | raise ValueError("No workflow version set")
2259 |
2260 | if not self.last_version_run:
2261 | return True
2262 |
2263 | return self.version != self.last_version_run
2264 |
2265 | @property
2266 | def last_version_run(self):
2267 | """Return version of last version to run (or ``None``).
2268 |
2269 | .. versionadded:: 1.9.10
2270 |
2271 | :returns: :class:`~workflow.update.Version` instance
2272 | or ``None``
2273 |
2274 | """
2275 | if self._last_version_run is UNSET:
2276 |
2277 | version = self.settings.get("__workflow_last_version")
2278 | if version:
2279 | from .update import Version
2280 |
2281 | version = Version(version)
2282 |
2283 | self._last_version_run = version
2284 |
2285 | self.logger.debug("last run version: %s", self._last_version_run)
2286 |
2287 | return self._last_version_run
2288 |
2289 | def set_last_version(self, version=None):
2290 | """Set :attr:`last_version_run` to current version.
2291 |
2292 | .. versionadded:: 1.9.10
2293 |
2294 | :param version: version to store (default is current version)
2295 | :type version: :class:`~workflow.update.Version` instance
2296 | or ``unicode``
2297 | :returns: ``True`` if version is saved, else ``False``
2298 |
2299 | """
2300 | if not version:
2301 | if not self.version:
2302 | self.logger.warning("Can't save last version: workflow has no version")
2303 | return False
2304 |
2305 | version = self.version
2306 |
2307 | if isinstance(version, str):
2308 | from .update import Version
2309 |
2310 | version = Version(version)
2311 |
2312 | self.settings["__workflow_last_version"] = str(version)
2313 |
2314 | self.logger.debug("set last run version: %s", version)
2315 |
2316 | return True
2317 |
2318 | @property
2319 | def update_available(self):
2320 | """Whether an update is available.
2321 |
2322 | .. versionadded:: 1.9
2323 |
2324 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2325 | information on how to enable your workflow to update itself.
2326 |
2327 | :returns: ``True`` if an update is available, else ``False``
2328 |
2329 | """
2330 | key = "__workflow_latest_version"
2331 | # Create a new workflow object to ensure standard serialiser
2332 | # is used (update.py is called without the user's settings)
2333 | status = Workflow().cached_data(key, max_age=0)
2334 |
2335 | # self.logger.debug('update status: %r', status)
2336 | if not status or not status.get("available"):
2337 | return False
2338 |
2339 | return status["available"]
2340 |
2341 | @property
2342 | def prereleases(self):
2343 | """Whether workflow should update to pre-release versions.
2344 |
2345 | .. versionadded:: 1.16
2346 |
2347 | :returns: ``True`` if pre-releases are enabled with the :ref:`magic
2348 | argument ` or the ``update_settings`` dict, else
2349 | ``False``.
2350 |
2351 | """
2352 | if self._update_settings.get("prereleases"):
2353 | return True
2354 |
2355 | return self.settings.get("__workflow_prereleases") or False
2356 |
2357 | def check_update(self, force=False):
2358 | """Call update script if it's time to check for a new release.
2359 |
2360 | .. versionadded:: 1.9
2361 |
2362 | The update script will be run in the background, so it won't
2363 | interfere in the execution of your workflow.
2364 |
2365 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2366 | information on how to enable your workflow to update itself.
2367 |
2368 | :param force: Force update check
2369 | :type force: ``Boolean``
2370 |
2371 | """
2372 | key = "__workflow_latest_version"
2373 | frequency = self._update_settings.get("frequency", DEFAULT_UPDATE_FREQUENCY)
2374 |
2375 | if not force and not self.settings.get("__workflow_autoupdate", True):
2376 | self.logger.debug("Auto update turned off by user")
2377 | return
2378 |
2379 | # Check for new version if it's time
2380 | if force or not self.cached_data_fresh(key, frequency * 86400):
2381 | repo = self._update_settings["github_slug"]
2382 | # version = self._update_settings['version']
2383 | version = str(self.version)
2384 |
2385 | from .background import run_in_background
2386 |
2387 | # update.py is adjacent to this file
2388 | update_script = os.path.join(os.path.dirname(__file__), "update.py")
2389 |
2390 | cmd = [sys.executable, update_script, "check", repo, version]
2391 | if self.prereleases:
2392 | cmd.append("--prereleases")
2393 |
2394 | self.logger.info("checking for update ...")
2395 |
2396 | run_in_background("__workflow_update_check", cmd)
2397 |
2398 | else:
2399 | self.logger.debug("update check not due")
2400 |
2401 | def start_update(self):
2402 | """Check for update and download and install new workflow file.
2403 |
2404 | .. versionadded:: 1.9
2405 |
2406 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2407 | information on how to enable your workflow to update itself.
2408 |
2409 | :returns: ``True`` if an update is available and will be
2410 | installed, else ``False``
2411 |
2412 | """
2413 | from . import update
2414 |
2415 | repo = self._update_settings["github_slug"]
2416 | # version = self._update_settings['version']
2417 | version = str(self.version)
2418 |
2419 | if not update.check_update(repo, version, self.prereleases):
2420 | return False
2421 |
2422 | from .background import run_in_background
2423 |
2424 | # update.py is adjacent to this file
2425 | update_script = os.path.join(os.path.dirname(__file__), "update.py")
2426 |
2427 | cmd = [sys.executable, update_script, "install", repo, version]
2428 |
2429 | if self.prereleases:
2430 | cmd.append("--prereleases")
2431 |
2432 | self.logger.debug("downloading update ...")
2433 | run_in_background("__workflow_update_install", cmd)
2434 |
2435 | return True
2436 |
2437 | ####################################################################
2438 | # Keychain password storage methods
2439 | ####################################################################
2440 |
2441 | def save_password(self, account, password, service=None):
2442 | """Save account credentials.
2443 |
2444 | If the account exists, the old password will first be deleted
2445 | (Keychain throws an error otherwise).
2446 |
2447 | If something goes wrong, a :class:`KeychainError` exception will
2448 | be raised.
2449 |
2450 | :param account: name of the account the password is for, e.g.
2451 | "Pinboard"
2452 | :type account: ``unicode``
2453 | :param password: the password to secure
2454 | :type password: ``unicode``
2455 | :param service: Name of the service. By default, this is the
2456 | workflow's bundle ID
2457 | :type service: ``unicode``
2458 |
2459 | """
2460 | if not service:
2461 | service = self.bundleid
2462 |
2463 | try:
2464 | self._call_security(
2465 | "add-generic-password", service, account, "-w", password
2466 | )
2467 | self.logger.debug("saved password : %s:%s", service, account)
2468 |
2469 | except PasswordExists:
2470 | self.logger.debug("password exists : %s:%s", service, account)
2471 | current_password = self.get_password(account, service)
2472 |
2473 | if current_password == password:
2474 | self.logger.debug("password unchanged")
2475 |
2476 | else:
2477 | self.delete_password(account, service)
2478 | self._call_security(
2479 | "add-generic-password", service, account, "-w", password
2480 | )
2481 | self.logger.debug("save_password : %s:%s", service, account)
2482 |
2483 | def get_password(self, account, service=None):
2484 | """Retrieve the password saved at ``service/account``.
2485 |
2486 | Raise :class:`PasswordNotFound` exception if password doesn't exist.
2487 |
2488 | :param account: name of the account the password is for, e.g.
2489 | "Pinboard"
2490 | :type account: ``unicode``
2491 | :param service: Name of the service. By default, this is the workflow's
2492 | bundle ID
2493 | :type service: ``unicode``
2494 | :returns: account password
2495 | :rtype: ``unicode``
2496 |
2497 | """
2498 | if not service:
2499 | service = self.bundleid
2500 |
2501 | output = self._call_security("find-generic-password", service, account, "-g")
2502 |
2503 | # Parsing of `security` output is adapted from python-keyring
2504 | # by Jason R. Coombs
2505 | # https://pypi.python.org/pypi/keyring
2506 | m = re.search(
2507 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', output
2508 | )
2509 |
2510 | if m:
2511 | groups = m.groupdict()
2512 | h = groups.get("hex")
2513 | password = groups.get("pw")
2514 | if h:
2515 | password = str(binascii.unhexlify(h), "utf-8")
2516 |
2517 | self.logger.debug("got password : %s:%s", service, account)
2518 |
2519 | return password
2520 |
2521 | def delete_password(self, account, service=None):
2522 | """Delete the password stored at ``service/account``.
2523 |
2524 | Raise :class:`PasswordNotFound` if account is unknown.
2525 |
2526 | :param account: name of the account the password is for, e.g.
2527 | "Pinboard"
2528 | :type account: ``unicode``
2529 | :param service: Name of the service. By default, this is the workflow's
2530 | bundle ID
2531 | :type service: ``unicode``
2532 |
2533 | """
2534 | if not service:
2535 | service = self.bundleid
2536 |
2537 | self._call_security("delete-generic-password", service, account)
2538 |
2539 | self.logger.debug("deleted password : %s:%s", service, account)
2540 |
2541 | ####################################################################
2542 | # Methods for workflow:* magic args
2543 | ####################################################################
2544 |
2545 | def _register_default_magic(self): # noqa: C901
2546 | """Register the built-in magic arguments."""
2547 | # TODO: refactor & simplify
2548 | # Wrap callback and message with callable
2549 | def callback(func, msg):
2550 | def wrapper():
2551 | func()
2552 | return msg
2553 |
2554 | return wrapper
2555 |
2556 | self.magic_arguments["delcache"] = callback(
2557 | self.clear_cache, "Deleted workflow cache"
2558 | )
2559 | self.magic_arguments["deldata"] = callback(
2560 | self.clear_data, "Deleted workflow data"
2561 | )
2562 | self.magic_arguments["delsettings"] = callback(
2563 | self.clear_settings, "Deleted workflow settings"
2564 | )
2565 | self.magic_arguments["reset"] = callback(self.reset, "Reset workflow")
2566 | self.magic_arguments["openlog"] = callback(
2567 | self.open_log, "Opening workflow log file"
2568 | )
2569 | self.magic_arguments["opencache"] = callback(
2570 | self.open_cachedir, "Opening workflow cache directory"
2571 | )
2572 | self.magic_arguments["opendata"] = callback(
2573 | self.open_datadir, "Opening workflow data directory"
2574 | )
2575 | self.magic_arguments["openworkflow"] = callback(
2576 | self.open_workflowdir, "Opening workflow directory"
2577 | )
2578 | self.magic_arguments["openterm"] = callback(
2579 | self.open_terminal, "Opening workflow root directory in Terminal"
2580 | )
2581 |
2582 | # Diacritic folding
2583 | def fold_on():
2584 | self.settings["__workflow_diacritic_folding"] = True
2585 | return "Diacritics will always be folded"
2586 |
2587 | def fold_off():
2588 | self.settings["__workflow_diacritic_folding"] = False
2589 | return "Diacritics will never be folded"
2590 |
2591 | def fold_default():
2592 | if "__workflow_diacritic_folding" in self.settings:
2593 | del self.settings["__workflow_diacritic_folding"]
2594 | return "Diacritics folding reset"
2595 |
2596 | self.magic_arguments["foldingon"] = fold_on
2597 | self.magic_arguments["foldingoff"] = fold_off
2598 | self.magic_arguments["foldingdefault"] = fold_default
2599 |
2600 | # Updates
2601 | def update_on():
2602 | self.settings["__workflow_autoupdate"] = True
2603 | return "Auto update turned on"
2604 |
2605 | def update_off():
2606 | self.settings["__workflow_autoupdate"] = False
2607 | return "Auto update turned off"
2608 |
2609 | def prereleases_on():
2610 | self.settings["__workflow_prereleases"] = True
2611 | return "Prerelease updates turned on"
2612 |
2613 | def prereleases_off():
2614 | self.settings["__workflow_prereleases"] = False
2615 | return "Prerelease updates turned off"
2616 |
2617 | def do_update():
2618 | if self.start_update():
2619 | return "Downloading and installing update ..."
2620 | else:
2621 | return "No update available"
2622 |
2623 | self.magic_arguments["autoupdate"] = update_on
2624 | self.magic_arguments["noautoupdate"] = update_off
2625 | self.magic_arguments["prereleases"] = prereleases_on
2626 | self.magic_arguments["noprereleases"] = prereleases_off
2627 | self.magic_arguments["update"] = do_update
2628 |
2629 | # Help
2630 | def do_help():
2631 | if self.help_url:
2632 | self.open_help()
2633 | return "Opening workflow help URL in browser"
2634 | else:
2635 | return "Workflow has no help URL"
2636 |
2637 | def show_version():
2638 | if self.version:
2639 | return "Version: {0}".format(self.version)
2640 | else:
2641 | return "This workflow has no version number"
2642 |
2643 | def list_magic():
2644 | """Display all available magic args in Alfred."""
2645 | isatty = sys.stderr.isatty()
2646 | for name in sorted(self.magic_arguments.keys()):
2647 | if name == "magic":
2648 | continue
2649 | arg = self.magic_prefix + name
2650 | self.logger.debug(arg)
2651 |
2652 | if not isatty:
2653 | self.add_item(arg, icon=ICON_INFO)
2654 |
2655 | if not isatty:
2656 | self.send_feedback()
2657 |
2658 | self.magic_arguments["help"] = do_help
2659 | self.magic_arguments["magic"] = list_magic
2660 | self.magic_arguments["version"] = show_version
2661 |
2662 | def clear_cache(self, filter_func=lambda f: True):
2663 | """Delete all files in workflow's :attr:`cachedir`.
2664 |
2665 | :param filter_func: Callable to determine whether a file should be
2666 | deleted or not. ``filter_func`` is called with the filename
2667 | of each file in the data directory. If it returns ``True``,
2668 | the file will be deleted.
2669 | By default, *all* files will be deleted.
2670 | :type filter_func: ``callable``
2671 | """
2672 | self._delete_directory_contents(self.cachedir, filter_func)
2673 |
2674 | def clear_data(self, filter_func=lambda f: True):
2675 | """Delete all files in workflow's :attr:`datadir`.
2676 |
2677 | :param filter_func: Callable to determine whether a file should be
2678 | deleted or not. ``filter_func`` is called with the filename
2679 | of each file in the data directory. If it returns ``True``,
2680 | the file will be deleted.
2681 | By default, *all* files will be deleted.
2682 | :type filter_func: ``callable``
2683 | """
2684 | self._delete_directory_contents(self.datadir, filter_func)
2685 |
2686 | def clear_settings(self):
2687 | """Delete workflow's :attr:`settings_path`."""
2688 | if os.path.exists(self.settings_path):
2689 | os.unlink(self.settings_path)
2690 | self.logger.debug("deleted : %r", self.settings_path)
2691 |
2692 | def reset(self):
2693 | """Delete workflow settings, cache and data.
2694 |
2695 | File :attr:`settings ` and directories
2696 | :attr:`cache ` and :attr:`data ` are deleted.
2697 |
2698 | """
2699 | self.clear_cache()
2700 | self.clear_data()
2701 | self.clear_settings()
2702 |
2703 | def open_log(self):
2704 | """Open :attr:`logfile` in default app (usually Console.app)."""
2705 | subprocess.call(["open", self.logfile]) # nosec
2706 |
2707 | def open_cachedir(self):
2708 | """Open the workflow's :attr:`cachedir` in Finder."""
2709 | subprocess.call(["open", self.cachedir]) # nosec
2710 |
2711 | def open_datadir(self):
2712 | """Open the workflow's :attr:`datadir` in Finder."""
2713 | subprocess.call(["open", self.datadir]) # nosec
2714 |
2715 | def open_workflowdir(self):
2716 | """Open the workflow's :attr:`workflowdir` in Finder."""
2717 | subprocess.call(["open", self.workflowdir]) # nosec
2718 |
2719 | def open_terminal(self):
2720 | """Open a Terminal window at workflow's :attr:`workflowdir`."""
2721 | subprocess.call(["open", "-a", "Terminal", self.workflowdir]) # nosec
2722 |
2723 | def open_help(self):
2724 | """Open :attr:`help_url` in default browser."""
2725 | subprocess.call(["open", self.help_url]) # nosec
2726 |
2727 | return "Opening workflow help URL in browser"
2728 |
2729 | ####################################################################
2730 | # Helper methods
2731 | ####################################################################
2732 |
2733 | def decode(self, text, encoding=None, normalization=None):
2734 | """Return ``text`` as normalised unicode.
2735 |
2736 | If ``encoding`` and/or ``normalization`` is ``None``, the
2737 | ``input_encoding``and ``normalization`` parameters passed to
2738 | :class:`Workflow` are used.
2739 |
2740 | :param text: string
2741 | :type text: encoded or Unicode string. If ``text`` is already a
2742 | Unicode string, it will only be normalised.
2743 | :param encoding: The text encoding to use to decode ``text`` to
2744 | Unicode.
2745 | :type encoding: ``unicode`` or ``None``
2746 | :param normalization: The nomalisation form to apply to ``text``.
2747 | :type normalization: ``unicode`` or ``None``
2748 | :returns: decoded and normalised ``unicode``
2749 |
2750 | :class:`Workflow` uses "NFC" normalisation by default. This is the
2751 | standard for Python and will work well with data from the web (via
2752 | :mod:`~workflow.web` or :mod:`json`).
2753 |
2754 | macOS, on the other hand, uses "NFD" normalisation (nearly), so data
2755 | coming from the system (e.g. via :mod:`subprocess` or
2756 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either
2757 | normalise this data, too, or change the default normalisation used by
2758 | :class:`Workflow`.
2759 |
2760 | """
2761 | encoding = encoding or self._input_encoding
2762 | normalization = normalization or self._normalizsation
2763 | if not isinstance(text, str):
2764 | text = str(text, encoding)
2765 | return unicodedata.normalize(normalization, text)
2766 |
2767 | def fold_to_ascii(self, text):
2768 | """Convert non-ASCII characters to closest ASCII equivalent.
2769 |
2770 | .. versionadded:: 1.3
2771 |
2772 | .. note:: This only works for a subset of European languages.
2773 |
2774 | :param text: text to convert
2775 | :type text: ``unicode``
2776 | :returns: text containing only ASCII characters
2777 | :rtype: ``unicode``
2778 |
2779 | """
2780 | if isascii(text):
2781 | return text
2782 | text = "".join([ASCII_REPLACEMENTS.get(c, c) for c in text])
2783 | return unicodedata.normalize("NFKD", text)
2784 |
2785 | def dumbify_punctuation(self, text):
2786 | """Convert non-ASCII punctuation to closest ASCII equivalent.
2787 |
2788 | This method replaces "smart" quotes and n- or m-dashes with their
2789 | workaday ASCII equivalents. This method is currently not used
2790 | internally, but exists as a helper method for workflow authors.
2791 |
2792 | .. versionadded: 1.9.7
2793 |
2794 | :param text: text to convert
2795 | :type text: ``unicode``
2796 | :returns: text with only ASCII punctuation
2797 | :rtype: ``unicode``
2798 |
2799 | """
2800 | if isascii(text):
2801 | return text
2802 |
2803 | text = "".join([DUMB_PUNCTUATION.get(c, c) for c in text])
2804 | return text
2805 |
2806 | def _delete_directory_contents(self, dirpath, filter_func):
2807 | """Delete all files in a directory.
2808 |
2809 | :param dirpath: path to directory to clear
2810 | :type dirpath: ``unicode`` or ``str``
2811 | :param filter_func function to determine whether a file shall be
2812 | deleted or not.
2813 | :type filter_func ``callable``
2814 |
2815 | """
2816 | if os.path.exists(dirpath):
2817 | for filename in os.listdir(dirpath):
2818 | if not filter_func(filename):
2819 | continue
2820 | path = os.path.join(dirpath, filename)
2821 | if os.path.isdir(path):
2822 | shutil.rmtree(path)
2823 | else:
2824 | os.unlink(path)
2825 | self.logger.debug("deleted : %r", path)
2826 |
2827 | def _load_info_plist(self):
2828 | """Load workflow info from ``info.plist``."""
2829 | # info.plist should be in the directory above this one
2830 | with open(self.workflowfile("info.plist"), "rb") as file_obj:
2831 | self._info = plistlib.load(file_obj)
2832 | self._info_loaded = True
2833 |
2834 | def _create(self, dirpath):
2835 | """Create directory `dirpath` if it doesn't exist.
2836 |
2837 | :param dirpath: path to directory
2838 | :type dirpath: ``unicode``
2839 | :returns: ``dirpath`` argument
2840 | :rtype: ``unicode``
2841 |
2842 | """
2843 | if not os.path.exists(dirpath):
2844 | os.makedirs(dirpath)
2845 | return dirpath
2846 |
2847 | def _call_security(self, action, service, account, *args):
2848 | """Call ``security`` CLI program that provides access to keychains.
2849 |
2850 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
2851 | exceptions (the first two are subclasses of `KeychainError`).
2852 |
2853 | :param action: The ``security`` action to call, e.g.
2854 | ``add-generic-password``
2855 | :type action: ``unicode``
2856 | :param service: Name of the service.
2857 | :type service: ``unicode``
2858 | :param account: name of the account the password is for, e.g.
2859 | "Pinboard"
2860 | :type account: ``unicode``
2861 | :param password: the password to secure
2862 | :type password: ``unicode``
2863 | :param *args: list of command line arguments to be passed to
2864 | ``security``
2865 | :type *args: `list` or `tuple`
2866 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a
2867 | ``unicode`` string.
2868 | :rtype: `tuple` (`int`, ``unicode``)
2869 |
2870 | """
2871 | cmd = ["security", action, "-s", service, "-a", account] + list(args)
2872 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
2873 | stdout, _ = p.communicate()
2874 | if p.returncode == 44: # password does not exist
2875 | raise PasswordNotFound()
2876 | elif p.returncode == 45: # password already exists
2877 | raise PasswordExists()
2878 | elif p.returncode > 0:
2879 | err = KeychainError("Unknown Keychain error : %s" % stdout)
2880 | err.retcode = p.returncode
2881 | raise err
2882 | return stdout.strip().decode("utf-8")
2883 |
--------------------------------------------------------------------------------
/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+ version of :class:`~workflow.Workflow`.
11 |
12 | :class:`~workflow.Workflow3` supports 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 |
27 | import json
28 | import os
29 | import sys
30 |
31 | from .workflow import ICON_WARNING, Workflow
32 |
33 |
34 | class Variables(dict):
35 | """Workflow variables for Run Script actions.
36 |
37 | .. versionadded: 1.26
38 |
39 | This class allows you to set workflow variables from
40 | Run Script actions.
41 |
42 | It is a subclass of :class:`dict`.
43 |
44 | >>> v = Variables(username='deanishe', password='hunter2')
45 | >>> v.arg = u'output value'
46 | >>> print(v)
47 |
48 | See :ref:`variables-run-script` in the User Guide for more
49 | information.
50 |
51 | Args:
52 | arg (unicode or list, optional): Main output/``{query}``.
53 | **variables: Workflow variables to set.
54 |
55 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
56 | :class:`list` or :class:`tuple`.
57 |
58 | Attributes:
59 | arg (unicode or list): Output value (``{query}``).
60 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
61 | :class:`list` or :class:`tuple`.
62 | config (dict): Configuration for downstream workflow element.
63 |
64 | """
65 |
66 | def __init__(self, arg=None, **variables):
67 | """Create a new `Variables` object."""
68 | self.arg = arg
69 | self.config = {}
70 | super(Variables, self).__init__(**variables)
71 |
72 | @property
73 | def obj(self):
74 | """``alfredworkflow`` :class:`dict`."""
75 | o = {}
76 | if self:
77 | d2 = {}
78 | for k, v in list(self.items()):
79 | d2[k] = v
80 | o["variables"] = d2
81 |
82 | if self.config:
83 | o["config"] = self.config
84 |
85 | if self.arg is not None:
86 | o["arg"] = self.arg
87 |
88 | return {"alfredworkflow": o}
89 |
90 | def __str__(self):
91 | """Convert to ``alfredworkflow`` JSON object.
92 |
93 | Returns:
94 | unicode: ``alfredworkflow`` JSON object
95 |
96 | """
97 | if not self and not self.config:
98 | if not self.arg:
99 | return ""
100 | if isinstance(self.arg, str):
101 | return self.arg
102 |
103 | return json.dumps(self.obj)
104 |
105 |
106 | class Modifier(object):
107 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed.
108 |
109 | Don't use this class directly (as it won't be associated with any
110 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
111 | to add modifiers to results.
112 |
113 | >>> it = wf.add_item('Title', 'Subtitle', valid=True)
114 | >>> it.setvar('name', 'default')
115 | >>> m = it.add_modifier('cmd')
116 | >>> m.setvar('name', 'alternate')
117 |
118 | See :ref:`workflow-variables` in the User Guide for more information
119 | and :ref:`example usage `.
120 |
121 | Args:
122 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
123 | subtitle (unicode, optional): Override default subtitle.
124 | arg (unicode, optional): Argument to pass for this modifier.
125 | valid (bool, optional): Override item's validity.
126 | icon (unicode, optional): Filepath/UTI of icon to use
127 | icontype (unicode, optional): Type of icon. See
128 | :meth:`Workflow.add_item() `
129 | for valid values.
130 |
131 | Attributes:
132 | arg (unicode): Arg to pass to following action.
133 | config (dict): Configuration for a downstream element, such as
134 | a File Filter.
135 | icon (unicode): Filepath/UTI of icon.
136 | icontype (unicode): Type of icon. See
137 | :meth:`Workflow.add_item() `
138 | for valid values.
139 | key (unicode): Modifier key (see above).
140 | subtitle (unicode): Override item subtitle.
141 | valid (bool): Override item validity.
142 | variables (dict): Workflow variables set by this modifier.
143 |
144 | """
145 |
146 | def __init__(
147 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
148 | ):
149 | """Create a new :class:`Modifier`.
150 |
151 | Don't use this class directly (as it won't be associated with any
152 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
153 | to add modifiers to results.
154 |
155 | Args:
156 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
157 | subtitle (unicode, optional): Override default subtitle.
158 | arg (unicode, optional): Argument to pass for this modifier.
159 | valid (bool, optional): Override item's validity.
160 | icon (unicode, optional): Filepath/UTI of icon to use
161 | icontype (unicode, optional): Type of icon. See
162 | :meth:`Workflow.add_item() `
163 | for valid values.
164 |
165 | """
166 | self.key = key
167 | self.subtitle = subtitle
168 | self.arg = arg
169 | self.valid = valid
170 | self.icon = icon
171 | self.icontype = icontype
172 |
173 | self.config = {}
174 | self.variables = {}
175 |
176 | def setvar(self, name, value):
177 | """Set a workflow variable for this Item.
178 |
179 | Args:
180 | name (unicode): Name of variable.
181 | value (unicode): Value of variable.
182 |
183 | """
184 | self.variables[name] = value
185 |
186 | def getvar(self, name, default=None):
187 | """Return value of workflow variable for ``name`` or ``default``.
188 |
189 | Args:
190 | name (unicode): Variable name.
191 | default (None, optional): Value to return if variable is unset.
192 |
193 | Returns:
194 | unicode or ``default``: Value of variable if set or ``default``.
195 |
196 | """
197 | return self.variables.get(name, default)
198 |
199 | @property
200 | def obj(self):
201 | """Modifier formatted for JSON serialization for Alfred 3.
202 |
203 | Returns:
204 | dict: Modifier for serializing to JSON.
205 |
206 | """
207 | o = {}
208 |
209 | if self.subtitle is not None:
210 | o["subtitle"] = self.subtitle
211 |
212 | if self.arg is not None:
213 | o["arg"] = self.arg
214 |
215 | if self.valid is not None:
216 | o["valid"] = self.valid
217 |
218 | if self.variables:
219 | o["variables"] = self.variables
220 |
221 | if self.config:
222 | o["config"] = self.config
223 |
224 | icon = self._icon()
225 | if icon:
226 | o["icon"] = icon
227 |
228 | return o
229 |
230 | def _icon(self):
231 | """Return `icon` object for item.
232 |
233 | Returns:
234 | dict: Mapping for item `icon` (may be empty).
235 |
236 | """
237 | icon = {}
238 | if self.icon is not None:
239 | icon["path"] = self.icon
240 |
241 | if self.icontype is not None:
242 | icon["type"] = self.icontype
243 |
244 | return icon
245 |
246 |
247 | class Item3(object):
248 | """Represents a feedback item for Alfred 3+.
249 |
250 | Generates Alfred-compliant JSON for a single item.
251 |
252 | Don't use this class directly (as it then won't be associated with
253 | any :class:`Workflow3 ` object), but rather use
254 | :meth:`Workflow3.add_item() `.
255 | See :meth:`~workflow.Workflow3.add_item` for details of arguments.
256 |
257 | """
258 |
259 | def __init__(
260 | self,
261 | title,
262 | subtitle="",
263 | arg=None,
264 | autocomplete=None,
265 | match=None,
266 | valid=False,
267 | uid=None,
268 | icon=None,
269 | icontype=None,
270 | type=None,
271 | largetext=None,
272 | copytext=None,
273 | quicklookurl=None,
274 | ):
275 | """Create a new :class:`Item3` object.
276 |
277 | Use same arguments as for
278 | :class:`Workflow.Item `.
279 |
280 | Argument ``subtitle_modifiers`` is not supported.
281 |
282 | """
283 | self.title = title
284 | self.subtitle = subtitle
285 | self.arg = arg
286 | self.autocomplete = autocomplete
287 | self.match = match
288 | self.valid = valid
289 | self.uid = uid
290 | self.icon = icon
291 | self.icontype = icontype
292 | self.type = type
293 | self.quicklookurl = quicklookurl
294 | self.largetext = largetext
295 | self.copytext = copytext
296 |
297 | self.modifiers = {}
298 |
299 | self.config = {}
300 | self.variables = {}
301 |
302 | def setvar(self, name, value):
303 | """Set a workflow variable for this Item.
304 |
305 | Args:
306 | name (unicode): Name of variable.
307 | value (unicode): Value of variable.
308 |
309 | """
310 | self.variables[name] = value
311 |
312 | def getvar(self, name, default=None):
313 | """Return value of workflow variable for ``name`` or ``default``.
314 |
315 | Args:
316 | name (unicode): Variable name.
317 | default (None, optional): Value to return if variable is unset.
318 |
319 | Returns:
320 | unicode or ``default``: Value of variable if set or ``default``.
321 |
322 | """
323 | return self.variables.get(name, default)
324 |
325 | def add_modifier(
326 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
327 | ):
328 | """Add alternative values for a modifier key.
329 |
330 | Args:
331 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"``
332 | subtitle (unicode, optional): Override item subtitle.
333 | arg (unicode, optional): Input for following action.
334 | valid (bool, optional): Override item validity.
335 | icon (unicode, optional): Filepath/UTI of icon.
336 | icontype (unicode, optional): Type of icon. See
337 | :meth:`Workflow.add_item() `
338 | for valid values.
339 |
340 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
341 | :class:`list` or :class:`tuple`.
342 |
343 | Returns:
344 | Modifier: Configured :class:`Modifier`.
345 |
346 | """
347 | mod = Modifier(key, subtitle, arg, valid, icon, icontype)
348 |
349 | # Add Item variables to Modifier
350 | mod.variables.update(self.variables)
351 |
352 | self.modifiers[key] = mod
353 |
354 | return mod
355 |
356 | @property
357 | def obj(self):
358 | """Item formatted for JSON serialization.
359 |
360 | Returns:
361 | dict: Data suitable for Alfred 3 feedback.
362 |
363 | """
364 | # Required values
365 | o = {"title": self.title, "subtitle": self.subtitle, "valid": self.valid}
366 |
367 | # Optional values
368 | if self.arg is not None:
369 | o["arg"] = self.arg
370 |
371 | if self.autocomplete is not None:
372 | o["autocomplete"] = self.autocomplete
373 |
374 | if self.match is not None:
375 | o["match"] = self.match
376 |
377 | if self.uid is not None:
378 | o["uid"] = self.uid
379 |
380 | if self.type is not None:
381 | o["type"] = self.type
382 |
383 | if self.quicklookurl is not None:
384 | o["quicklookurl"] = self.quicklookurl
385 |
386 | if self.variables:
387 | o["variables"] = self.variables
388 |
389 | if self.config:
390 | o["config"] = self.config
391 |
392 | # Largetype and copytext
393 | text = self._text()
394 | if text:
395 | o["text"] = text
396 |
397 | icon = self._icon()
398 | if icon:
399 | o["icon"] = icon
400 |
401 | # Modifiers
402 | mods = self._modifiers()
403 | if mods:
404 | o["mods"] = mods
405 |
406 | return o
407 |
408 | def _icon(self):
409 | """Return `icon` object for item.
410 |
411 | Returns:
412 | dict: Mapping for item `icon` (may be empty).
413 |
414 | """
415 | icon = {}
416 | if self.icon is not None:
417 | icon["path"] = self.icon
418 |
419 | if self.icontype is not None:
420 | icon["type"] = self.icontype
421 |
422 | return icon
423 |
424 | def _text(self):
425 | """Return `largetext` and `copytext` object for item.
426 |
427 | Returns:
428 | dict: `text` mapping (may be empty)
429 |
430 | """
431 | text = {}
432 | if self.largetext is not None:
433 | text["largetype"] = self.largetext
434 |
435 | if self.copytext is not None:
436 | text["copy"] = self.copytext
437 |
438 | return text
439 |
440 | def _modifiers(self):
441 | """Build `mods` dictionary for JSON feedback.
442 |
443 | Returns:
444 | dict: Modifier mapping or `None`.
445 |
446 | """
447 | if self.modifiers:
448 | mods = {}
449 | for k, mod in list(self.modifiers.items()):
450 | mods[k] = mod.obj
451 |
452 | return mods
453 |
454 | return None
455 |
456 |
457 | class Workflow3(Workflow):
458 | """Workflow class that generates Alfred 3+ feedback.
459 |
460 | It is a subclass of :class:`~workflow.Workflow` and most of its
461 | methods are documented there.
462 |
463 | Attributes:
464 | item_class (class): Class used to generate feedback items.
465 | variables (dict): Top level workflow variables.
466 |
467 | """
468 |
469 | item_class = Item3
470 |
471 | def __init__(self, **kwargs):
472 | """Create a new :class:`Workflow3` object.
473 |
474 | See :class:`~workflow.Workflow` for documentation.
475 |
476 | """
477 | Workflow.__init__(self, **kwargs)
478 | self.variables = {}
479 | self._rerun = 0
480 | # Get session ID from environment if present
481 | self._session_id = os.getenv("_WF_SESSION_ID") or None
482 | if self._session_id:
483 | self.setvar("_WF_SESSION_ID", self._session_id)
484 |
485 | @property
486 | def _default_cachedir(self):
487 | """Alfred 4's default cache directory."""
488 | return os.path.join(
489 | os.path.expanduser(
490 | "~/Library/Caches/com.runningwithcrayons.Alfred/" "Workflow Data/"
491 | ),
492 | self.bundleid,
493 | )
494 |
495 | @property
496 | def _default_datadir(self):
497 | """Alfred 4's default data directory."""
498 | return os.path.join(
499 | os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"),
500 | self.bundleid,
501 | )
502 |
503 | @property
504 | def rerun(self):
505 | """How often (in seconds) Alfred should re-run the Script Filter."""
506 | return self._rerun
507 |
508 | @rerun.setter
509 | def rerun(self, seconds):
510 | """Interval at which Alfred should re-run the Script Filter.
511 |
512 | Args:
513 | seconds (int): Interval between runs.
514 | """
515 | self._rerun = seconds
516 |
517 | @property
518 | def session_id(self):
519 | """A unique session ID every time the user uses the workflow.
520 |
521 | .. versionadded:: 1.25
522 |
523 | The session ID persists while the user is using this workflow.
524 | It expires when the user runs a different workflow or closes
525 | Alfred.
526 |
527 | """
528 | if not self._session_id:
529 | from uuid import uuid4
530 |
531 | self._session_id = uuid4().hex
532 | self.setvar("_WF_SESSION_ID", self._session_id)
533 |
534 | return self._session_id
535 |
536 | def setvar(self, name, value, persist=False):
537 | """Set a "global" workflow variable.
538 |
539 | .. versionchanged:: 1.33
540 |
541 | These variables are always passed to downstream workflow objects.
542 |
543 | If you have set :attr:`rerun`, these variables are also passed
544 | back to the script when Alfred runs it again.
545 |
546 | Args:
547 | name (unicode): Name of variable.
548 | value (unicode): Value of variable.
549 | persist (bool, optional): Also save variable to ``info.plist``?
550 |
551 | """
552 | self.variables[name] = value
553 | if persist:
554 | from .util import set_config
555 |
556 | set_config(name, value, self.bundleid)
557 | self.logger.debug(
558 | "saved variable %r with value %r to info.plist", name, value
559 | )
560 |
561 | def getvar(self, name, default=None):
562 | """Return value of workflow variable for ``name`` or ``default``.
563 |
564 | Args:
565 | name (unicode): Variable name.
566 | default (None, optional): Value to return if variable is unset.
567 |
568 | Returns:
569 | unicode or ``default``: Value of variable if set or ``default``.
570 |
571 | """
572 | return self.variables.get(name, default)
573 |
574 | def add_item(
575 | self,
576 | title,
577 | subtitle="",
578 | arg=None,
579 | autocomplete=None,
580 | valid=False,
581 | uid=None,
582 | icon=None,
583 | icontype=None,
584 | type=None,
585 | largetext=None,
586 | copytext=None,
587 | quicklookurl=None,
588 | match=None,
589 | ):
590 | """Add an item to be output to Alfred.
591 |
592 | Args:
593 | match (unicode, optional): If you have "Alfred filters results"
594 | turned on for your Script Filter, Alfred (version 3.5 and
595 | above) will filter against this field, not ``title``.
596 |
597 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
598 | :class:`list` or :class:`tuple`.
599 |
600 | See :meth:`Workflow.add_item() ` for
601 | the main documentation and other parameters.
602 |
603 | The key difference is that this method does not support the
604 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
605 | method instead on the returned item instead.
606 |
607 | Returns:
608 | Item3: Alfred feedback item.
609 |
610 | """
611 | item = self.item_class(
612 | title,
613 | subtitle,
614 | arg,
615 | autocomplete,
616 | match,
617 | valid,
618 | uid,
619 | icon,
620 | icontype,
621 | type,
622 | largetext,
623 | copytext,
624 | quicklookurl,
625 | )
626 |
627 | # Add variables to child item
628 | item.variables.update(self.variables)
629 |
630 | self._items.append(item)
631 | return item
632 |
633 | @property
634 | def _session_prefix(self):
635 | """Filename prefix for current session."""
636 | return "_wfsess-{0}-".format(self.session_id)
637 |
638 | def _mk_session_name(self, name):
639 | """New cache name/key based on session ID."""
640 | return self._session_prefix + name
641 |
642 | def cache_data(self, name, data, session=False):
643 | """Cache API with session-scoped expiry.
644 |
645 | .. versionadded:: 1.25
646 |
647 | Args:
648 | name (str): Cache key
649 | data (object): Data to cache
650 | session (bool, optional): Whether to scope the cache
651 | to the current session.
652 |
653 | ``name`` and ``data`` are the same as for the
654 | :meth:`~workflow.Workflow.cache_data` method on
655 | :class:`~workflow.Workflow`.
656 |
657 | If ``session`` is ``True``, then ``name`` is prefixed
658 | with :attr:`session_id`.
659 |
660 | """
661 | if session:
662 | name = self._mk_session_name(name)
663 |
664 | return super(Workflow3, self).cache_data(name, data)
665 |
666 | def cached_data(self, name, data_func=None, max_age=60, session=False):
667 | """Cache API with session-scoped expiry.
668 |
669 | .. versionadded:: 1.25
670 |
671 | Args:
672 | name (str): Cache key
673 | data_func (callable): Callable that returns fresh data. It
674 | is called if the cache has expired or doesn't exist.
675 | max_age (int): Maximum allowable age of cache in seconds.
676 | session (bool, optional): Whether to scope the cache
677 | to the current session.
678 |
679 | ``name``, ``data_func`` and ``max_age`` are the same as for the
680 | :meth:`~workflow.Workflow.cached_data` method on
681 | :class:`~workflow.Workflow`.
682 |
683 | If ``session`` is ``True``, then ``name`` is prefixed
684 | with :attr:`session_id`.
685 |
686 | """
687 | if session:
688 | name = self._mk_session_name(name)
689 |
690 | return super(Workflow3, self).cached_data(name, data_func, max_age)
691 |
692 | def clear_session_cache(self, current=False):
693 | """Remove session data from the cache.
694 |
695 | .. versionadded:: 1.25
696 | .. versionchanged:: 1.27
697 |
698 | By default, data belonging to the current session won't be
699 | deleted. Set ``current=True`` to also clear current session.
700 |
701 | Args:
702 | current (bool, optional): If ``True``, also remove data for
703 | current session.
704 |
705 | """
706 |
707 | def _is_session_file(filename):
708 | if current:
709 | return filename.startswith("_wfsess-")
710 | return filename.startswith("_wfsess-") and not filename.startswith(
711 | self._session_prefix
712 | )
713 |
714 | self.clear_cache(_is_session_file)
715 |
716 | @property
717 | def obj(self):
718 | """Feedback formatted for JSON serialization.
719 |
720 | Returns:
721 | dict: Data suitable for Alfred 3 feedback.
722 |
723 | """
724 | items = []
725 | for item in self._items:
726 | items.append(item.obj)
727 |
728 | o = {"items": items}
729 | if self.variables:
730 | o["variables"] = self.variables
731 | if self.rerun:
732 | o["rerun"] = self.rerun
733 | return o
734 |
735 | def warn_empty(self, title, subtitle="", icon=None):
736 | """Add a warning to feedback if there are no items.
737 |
738 | .. versionadded:: 1.31
739 |
740 | Add a "warning" item to Alfred feedback if no other items
741 | have been added. This is a handy shortcut to prevent Alfred
742 | from showing its fallback searches, which is does if no
743 | items are returned.
744 |
745 | Args:
746 | title (unicode): Title of feedback item.
747 | subtitle (unicode, optional): Subtitle of feedback item.
748 | icon (str, optional): Icon for feedback item. If not
749 | specified, ``ICON_WARNING`` is used.
750 |
751 | Returns:
752 | Item3: Newly-created item.
753 |
754 | """
755 | if len(self._items):
756 | return
757 |
758 | icon = icon or ICON_WARNING
759 | return self.add_item(title, subtitle, icon=icon)
760 |
761 | def send_feedback(self):
762 | """Print stored items to console/Alfred as JSON."""
763 | if self.debugging:
764 | json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": "))
765 | else:
766 | json.dump(self.obj, sys.stdout)
767 | sys.stdout.flush()
768 |
--------------------------------------------------------------------------------