├── .gitignore
├── License
├── README.md
├── icon.png
├── icon_basic.png
├── icon_phonetic.png
├── icon_web.png
├── info.plist
├── saveword.py
├── splitargs.py
├── version
├── workflow
├── __init__.py
├── background.py
├── update.py
├── version
├── web.py
└── workflow.py
├── youdao.alfredworkflow
└── youdao.py
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | *.pyc
3 |
--------------------------------------------------------------------------------
/License:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 catgecn@gmail.com
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
13 | all 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
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # kaiye/workflows-youdao
2 |
3 | 使用方法,选中需要翻译的文本,按两下 `command` 键即可。选中结果后,配合以下功能键可实现不同功能:
4 |
5 | * `enter` 同步单词到有道在线单词本(若未配置有道账号则保存至本地单词本)
6 | * `alt + enter` 翻译至剪切板
7 | * `shift + enter` 直接发音
8 | * `control + enter` 打开有道翻译页面
9 | * `command + enter` 直接在光标处打出翻译结果
10 |
11 | ## 安装
12 |
13 | ### 1、[点击下载](https://github.com/kaiye/workflows-youdao/blob/master/youdao.alfredworkflow?raw=true)
14 |
15 | ### 2、安装后设置双击快捷键
16 |
17 | 
18 |
19 |
20 |
21 | ### 3、配置有道词典账号信息
22 |
23 | 
24 |
25 | 如上图所示,双击 alt 相关的 Run Script,在弹出的 Script 框中参照以上格式配置相关参数:
26 |
27 | * `-filepath` 指定本地单词本的绝对路径,若不设置则默认为当前用户 Documents/Alfred-youdao-wordbook.xml 路径
28 | * `-username` 有道词典用户邮箱,用于模拟登录、同步单词信息
29 | * `-password` 有道词典用户密码
30 |
31 |
32 |
33 | ## 演示
34 |
35 | ### 英译中
36 |
37 | 
38 |
39 | ### 中译英
40 |
41 | 
42 |
43 | ### 翻译短语
44 |
45 | 
46 |
47 | ### 使用浏览器搜索
48 |
49 | 
50 |
51 | ### 输出结果到光标所在应用程序
52 |
53 | 
54 |
55 | 注:本插件 fork 自 liszd/whyliam.workflows.youdao 的 v1.2.1 版本,由于改动较大就不提 PR 了,协议保持 MIT 不变,请随意订制。
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaiye/workflows-youdao/192050fb0cdd94809d35c4fa3b4e5bca33fa37b3/icon.png
--------------------------------------------------------------------------------
/icon_basic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaiye/workflows-youdao/192050fb0cdd94809d35c4fa3b4e5bca33fa37b3/icon_basic.png
--------------------------------------------------------------------------------
/icon_phonetic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaiye/workflows-youdao/192050fb0cdd94809d35c4fa3b4e5bca33fa37b3/icon_phonetic.png
--------------------------------------------------------------------------------
/icon_web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaiye/workflows-youdao/192050fb0cdd94809d35c4fa3b4e5bca33fa37b3/icon_web.png
--------------------------------------------------------------------------------
/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | com.kaiye.workflows.youdao
7 | category
8 | Tools
9 | connections
10 |
11 | 27E60581-8105-41DD-8E29-4FE811179098
12 |
13 |
14 | destinationuid
15 | DBA62127-3B78-4B80-B82B-1C6AEC393003
16 | modifiers
17 | 0
18 | modifiersubtext
19 |
20 |
21 |
22 | 6A8F8E8B-4808-4A45-A681-099ABBA5144F
23 |
24 | 7C1ABC41-3B36-401F-96C7-30BCB39181FF
25 |
26 |
27 | destinationuid
28 | 0907BEF4-816F-48FF-B157-03F5C2AACEAB
29 | modifiers
30 | 0
31 | modifiersubtext
32 |
33 |
34 |
35 | 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
36 |
37 |
38 | destinationuid
39 | C11D2565-8CE6-4681-939E-56E8BFEDED90
40 | modifiers
41 | 262144
42 | modifiersubtext
43 | 从有道网页打开
44 |
45 |
46 | destinationuid
47 | B4E481EB-6577-4586-A01A-496FF37EFACC
48 | modifiers
49 | 131072
50 | modifiersubtext
51 | 有道发音
52 |
53 |
54 | destinationuid
55 | 27E60581-8105-41DD-8E29-4FE811179098
56 | modifiers
57 | 0
58 | modifiersubtext
59 |
60 |
61 |
62 | destinationuid
63 | 7C1ABC41-3B36-401F-96C7-30BCB39181FF
64 | modifiers
65 | 1048576
66 | modifiersubtext
67 | 直接打印
68 |
69 |
70 | destinationuid
71 | F92EE0BE-7B26-42BF-8580-593B56ACD948
72 | modifiers
73 | 524288
74 | modifiersubtext
75 | 保存至单词本
76 |
77 |
78 | F92EE0BE-7B26-42BF-8580-593B56ACD948
79 |
80 |
81 | createdby
82 | kaiye
83 | description
84 | 使用有道翻译你想知道的单词和语句
85 | disabled
86 |
87 | name
88 | Youdao
89 | objects
90 |
91 |
92 | config
93 |
94 | applescript
95 | on alfred_script(q)
96 | set qq to quoted form of q
97 | set search_word to (do shell script "echo " & qq & " | cut -d '$' -f1")
98 | do shell script "open 'http://dict.youdao.com/search?q=" & search_word & "'"
99 | end alfred_script
100 | cachescript
101 |
102 |
103 | type
104 | alfred.workflow.action.applescript
105 | uid
106 | C11D2565-8CE6-4681-939E-56E8BFEDED90
107 | version
108 | 0
109 |
110 |
111 | config
112 |
113 | escaping
114 | 39
115 | script
116 | /usr/bin/python splitargs.py "{query}" 2
117 | type
118 | 0
119 |
120 | type
121 | alfred.workflow.action.script
122 | uid
123 | B4E481EB-6577-4586-A01A-496FF37EFACC
124 | version
125 | 0
126 |
127 |
128 | config
129 |
130 | action
131 | 1
132 | argument
133 | 1
134 | argumenttext
135 | d
136 | hotkey
137 | -1
138 | hotmod
139 | 524288
140 | hotstring
141 | double tap
142 | leftcursor
143 |
144 | modsmode
145 | 0
146 | relatedAppsMode
147 | 0
148 |
149 | type
150 | alfred.workflow.trigger.hotkey
151 | uid
152 | 6A8F8E8B-4808-4A45-A681-099ABBA5144F
153 | version
154 | 1
155 |
156 |
157 | config
158 |
159 | argumenttype
160 | 0
161 | escaping
162 | 32
163 | keyword
164 | d
165 | runningsubtext
166 | 正在获取中...
167 | script
168 | /usr/bin/python youdao.py "{query}"
169 | subtext
170 | 使用有道翻译你想知道的单词和语句 {query}
171 | title
172 | 有道翻译
173 | type
174 | 0
175 | withspace
176 |
177 |
178 | type
179 | alfred.workflow.input.scriptfilter
180 | uid
181 | 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
182 | version
183 | 0
184 |
185 |
186 | config
187 |
188 | autopaste
189 |
190 | clipboardtext
191 | {query}
192 |
193 | type
194 | alfred.workflow.output.clipboard
195 | uid
196 | DBA62127-3B78-4B80-B82B-1C6AEC393003
197 | version
198 | 0
199 |
200 |
201 | config
202 |
203 | escaping
204 | 38
205 | script
206 | /usr/bin/python splitargs.py "{query}" 1
207 | type
208 | 0
209 |
210 | type
211 | alfred.workflow.action.script
212 | uid
213 | 27E60581-8105-41DD-8E29-4FE811179098
214 | version
215 | 0
216 |
217 |
218 | config
219 |
220 | escaping
221 | 38
222 | script
223 | /usr/bin/python splitargs.py "{query}" 1
224 | type
225 | 0
226 |
227 | type
228 | alfred.workflow.action.script
229 | uid
230 | 7C1ABC41-3B36-401F-96C7-30BCB39181FF
231 | version
232 | 0
233 |
234 |
235 | config
236 |
237 | autopaste
238 |
239 | clipboardtext
240 | {query}
241 |
242 | type
243 | alfred.workflow.output.clipboard
244 | uid
245 | 0907BEF4-816F-48FF-B157-03F5C2AACEAB
246 | version
247 | 0
248 |
249 |
250 | config
251 |
252 | escaping
253 | 38
254 | script
255 | /usr/bin/python saveword.py "{query}" "us"
256 | type
257 | 0
258 |
259 | type
260 | alfred.workflow.action.script
261 | uid
262 | F92EE0BE-7B26-42BF-8580-593B56ACD948
263 | version
264 | 0
265 |
266 |
267 | readme
268 | 使用方法,选中需要翻译的文本,按两下 `alt` 键即可。选中结果后,配合以下功能键可实现不同功能:
269 |
270 | * `enter` 复制
271 | * `alt + enter` 存入本地单词本
272 | * `shift + enter` 直接发音
273 | * `control + enter` 打开有道翻译页面
274 | * `command + enter` 直接在光标处打出翻译结果
275 | uidata
276 |
277 | 0907BEF4-816F-48FF-B157-03F5C2AACEAB
278 |
279 | ypos
280 | 390
281 |
282 | 27E60581-8105-41DD-8E29-4FE811179098
283 |
284 | ypos
285 | 260
286 |
287 | 6A8F8E8B-4808-4A45-A681-099ABBA5144F
288 |
289 | ypos
290 | 140
291 |
292 | 7C1ABC41-3B36-401F-96C7-30BCB39181FF
293 |
294 | ypos
295 | 390
296 |
297 | 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
298 |
299 | ypos
300 | 140
301 |
302 | B4E481EB-6577-4586-A01A-496FF37EFACC
303 |
304 | ypos
305 | 140
306 |
307 | C11D2565-8CE6-4681-939E-56E8BFEDED90
308 |
309 | ypos
310 | 20
311 |
312 | DBA62127-3B78-4B80-B82B-1C6AEC393003
313 |
314 | ypos
315 | 260
316 |
317 | F92EE0BE-7B26-42BF-8580-593B56ACD948
318 |
319 | ypos
320 | 510
321 |
322 |
323 | webaddress
324 | https://github.com/kaiye/workflows-youdao
325 |
326 |
327 |
--------------------------------------------------------------------------------
/saveword.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import sys,os
3 | import re
4 | import json
5 | import cookielib, urllib2, urllib
6 | import hashlib
7 |
8 | from workflow import Workflow
9 |
10 | reload(sys)
11 | sys.setdefaultencoding('utf8')
12 |
13 |
14 | class SmartRedirectHandler(urllib2.HTTPRedirectHandler):
15 | def http_error_302(self, req, fp, code, msg, headers):
16 | result = urllib2.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers)
17 | result.status = code
18 | result.headers = headers
19 | return result
20 |
21 |
22 | cookie_filename = 'youdao_cookie'
23 | fake_header = [
24 | ('User-Agent', 'Mozilla/5.0 (Macintosh Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36'),
25 | ('Content-Type', 'application/x-www-form-urlencoded'),
26 | ('Cache-Control', 'no-cache'),
27 | ('Accept', '*/*'),
28 | ('Connection', 'Keep-Alive'),
29 | ]
30 |
31 | class SaveWord(object):
32 |
33 | def __init__(self, username, password, localfile, word):
34 |
35 | self.username = username
36 | self.password = password
37 | self.localfile = localfile
38 | self.word = word
39 | self.cj = cookielib.LWPCookieJar(cookie_filename)
40 | if os.access(cookie_filename, os.F_OK):
41 | self.cj.load(cookie_filename, ignore_discard=True, ignore_expires=True)
42 | self.opener = urllib2.build_opener(
43 | SmartRedirectHandler(),
44 | urllib2.HTTPHandler(debuglevel=0),
45 | urllib2.HTTPSHandler(debuglevel=0),
46 | urllib2.HTTPCookieProcessor(self.cj)
47 | )
48 | self.opener.addheaders = fake_header
49 |
50 | def loginToYoudao(self):
51 | self.cj.clear()
52 | first_page = self.opener.open('http://account.youdao.com/login?back_url=http://dict.youdao.com&service=dict')
53 | login_data = urllib.urlencode({
54 | 'app' : 'web',
55 | 'tp' : 'urstoken',
56 | 'cf' : '7',
57 | 'fr' : '1',
58 | 'ru' : 'http://dict.youdao.com',
59 | 'product' : 'DICT',
60 | 'type' : '1',
61 | 'um' : 'true',
62 | 'username' : self.username,
63 | 'password' : self.password,
64 | 'savelogin' : '1',
65 | })
66 | response = self.opener.open('https://logindict.youdao.com/login/acc/login', login_data)
67 | if response.headers.get('Set-Cookie').find(self.username) > -1:
68 | self.cj.save(cookie_filename, ignore_discard=True, ignore_expires=True)
69 | return True
70 | else:
71 | return False
72 |
73 | def syncToYoudao(self):
74 | post_data = urllib.urlencode({
75 | 'word' : self.word.get('word'),
76 | 'phonetic' : self.word.get('phonetic'),
77 | 'desc': self.word.get('trans'),
78 | 'tags' : self.word.get('tags'),
79 | })
80 | self.opener.addheaders = fake_header + [
81 | ('Referer', 'http://dict.youdao.com/wordbook/wordlist'),
82 | ]
83 | response = self.opener.open('http://dict.youdao.com/wordbook/wordlist?action=add', post_data)
84 | return response.headers.get('Location') == 'http://dict.youdao.com/wordbook/wordlist'
85 |
86 | def generateWordBook(self, source_xml):
87 | item = self.word
88 | item_xml = '- '
89 | for i in item:
90 | value = '' if i in ["trans", "phonetic"] else item[i]
91 | item_xml = item_xml + '<' + i + '>' + value + '' + i + '>\n'
92 | item_xml = item_xml + '
\n'
93 |
94 | source_xml = re.sub('- (?:(?!<\/item>)[\s\S])*'+ item.get("word") +'<\/word>[\s\S]*?<\/item>\n', '', source_xml)
95 | if source_xml.find('') > -1:
96 | source_xml = source_xml.replace('','') + item_xml
97 | else:
98 | source_xml = '\n' + item_xml
99 | return source_xml + ''
100 |
101 | def saveLocal(self):
102 | try:
103 | source_xml = ''
104 | if os.path.exists(self.localfile):
105 | f = open(self.localfile,'r')
106 | source_xml = f.read()
107 | f.close()
108 | f = open(self.localfile,'w')
109 | f.write(self.generateWordBook(source_xml))
110 | f.close()
111 | except Exception,e:
112 | return e
113 | return 0
114 |
115 | def save(self, wf):
116 | if self.syncToYoudao() or (self.loginToYoudao() and self.syncToYoudao()):
117 | print '已成功保存至线上单词本'
118 | else:
119 | result = self.saveLocal()
120 | print result if result else '帐号出错,已临时保存至本地单词本'
121 |
122 |
123 | if __name__ == '__main__':
124 | params = sys.argv[1].split('$')
125 | extra_args = json.loads(params[4])
126 | phonetic_type = sys.argv[2] if sys.argv[2] in ["uk","us"] else "uk"
127 | phonetic = extra_args.get(phonetic_type) if extra_args.get(phonetic_type) else ''
128 |
129 | username = sys.argv[ sys.argv.index('-username') + 1] if '-username' in sys.argv else None
130 | password = sys.argv[ sys.argv.index('-password') + 1] if '-password' in sys.argv else None
131 | filepath = sys.argv[ sys.argv.index('-filepath') + 1] if '-filepath' in sys.argv else os.path.join(os.environ['HOME'] , 'Documents/Alfred-youdao-wordbook.xml')
132 |
133 | m2 = hashlib.md5()
134 | m2.update(password)
135 | password_md5 = m2.hexdigest()
136 |
137 | item = {
138 | "word" : params[0],
139 | "trans" : params[1],
140 | "phonetic" : phonetic,
141 | "tags" : "Alfred",
142 | "progress" : "-1",
143 | }
144 |
145 | saver = SaveWord(username, password_md5 , filepath, item)
146 | wf = Workflow()
147 |
148 | sys.exit(wf.run(saver.save))
--------------------------------------------------------------------------------
/splitargs.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import sys
3 | import os
4 | from workflow import Workflow
5 |
6 | reload(sys)
7 | sys.setdefaultencoding('utf8')
8 |
9 |
10 | def getargs(wf):
11 | query = sys.argv[1]
12 | query = query.split('$')
13 | part = int(sys.argv[2])
14 |
15 | if part == 1:
16 | print query[1]
17 | elif part == 2:
18 | if query[2]:
19 | bashCommand = "say --voice='Samantha' " + query[2]
20 | os.system(bashCommand)
21 | if query[3]:
22 | bashCommand = "say --voice='Ting-Ting' " + query[3]
23 | os.system(bashCommand)
24 | return 0
25 |
26 | if __name__ == '__main__':
27 | wf = Workflow()
28 | sys.exit(wf.run(getargs))
29 |
--------------------------------------------------------------------------------
/version:
--------------------------------------------------------------------------------
1 | 1.1.2
--------------------------------------------------------------------------------
/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 | """
12 | A Python helper library for `Alfred 2 `_ Workflow
13 | authors.
14 | """
15 |
16 | import os
17 |
18 | __title__ = 'Alfred-Workflow'
19 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
20 | __author__ = 'Dean Jackson'
21 | __licence__ = 'MIT'
22 | __copyright__ = 'Copyright 2014 Dean Jackson'
23 |
24 |
25 | # Workflow objects
26 | from .workflow import Workflow, manager
27 |
28 | # Exceptions
29 | from .workflow import PasswordNotFound, KeychainError
30 |
31 | # Icons
32 | from .workflow import (
33 | ICON_ACCOUNT,
34 | ICON_BURN,
35 | ICON_CLOCK,
36 | ICON_COLOR,
37 | ICON_COLOUR,
38 | ICON_EJECT,
39 | ICON_ERROR,
40 | ICON_FAVORITE,
41 | ICON_FAVOURITE,
42 | ICON_GROUP,
43 | ICON_HELP,
44 | ICON_HOME,
45 | ICON_INFO,
46 | ICON_NETWORK,
47 | ICON_NOTE,
48 | ICON_SETTINGS,
49 | ICON_SWIRL,
50 | ICON_SWITCH,
51 | ICON_SYNC,
52 | ICON_TRASH,
53 | ICON_USER,
54 | ICON_WARNING,
55 | ICON_WEB,
56 | )
57 |
58 | # Filter matching rules
59 | from .workflow import (
60 | MATCH_ALL,
61 | MATCH_ALLCHARS,
62 | MATCH_ATOM,
63 | MATCH_CAPITALS,
64 | MATCH_INITIALS,
65 | MATCH_INITIALS_CONTAIN,
66 | MATCH_INITIALS_STARTSWITH,
67 | MATCH_STARTSWITH,
68 | MATCH_SUBSTRING,
69 | )
70 |
71 | __all__ = [
72 | 'Workflow',
73 | 'manager',
74 | 'PasswordNotFound',
75 | 'KeychainError',
76 | 'ICON_ACCOUNT',
77 | 'ICON_BURN',
78 | 'ICON_CLOCK',
79 | 'ICON_COLOR',
80 | 'ICON_COLOUR',
81 | 'ICON_EJECT',
82 | 'ICON_ERROR',
83 | 'ICON_FAVORITE',
84 | 'ICON_FAVOURITE',
85 | 'ICON_GROUP',
86 | 'ICON_HELP',
87 | 'ICON_HOME',
88 | 'ICON_INFO',
89 | 'ICON_NETWORK',
90 | 'ICON_NOTE',
91 | 'ICON_SETTINGS',
92 | 'ICON_SWIRL',
93 | 'ICON_SWITCH',
94 | 'ICON_SYNC',
95 | 'ICON_TRASH',
96 | 'ICON_USER',
97 | 'ICON_WARNING',
98 | 'ICON_WEB',
99 | 'MATCH_ALL',
100 | 'MATCH_ALLCHARS',
101 | 'MATCH_ATOM',
102 | 'MATCH_CAPITALS',
103 | 'MATCH_INITIALS',
104 | 'MATCH_INITIALS_CONTAIN',
105 | 'MATCH_INITIALS_STARTSWITH',
106 | 'MATCH_STARTSWITH',
107 | 'MATCH_SUBSTRING',
108 | ]
109 |
--------------------------------------------------------------------------------
/workflow/background.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright © 2014 deanishe@deanishe.net
5 | #
6 | # MIT Licence. See http://opensource.org/licenses/MIT
7 | #
8 | # Created on 2014-04-06
9 | #
10 |
11 | """
12 | Run background tasks
13 | """
14 |
15 | from __future__ import print_function, unicode_literals
16 |
17 | import sys
18 | import os
19 | import subprocess
20 | import pickle
21 |
22 | from workflow import Workflow
23 |
24 | __all__ = ['is_running', 'run_in_background']
25 |
26 | _wf = None
27 |
28 |
29 | def wf():
30 | global _wf
31 | if _wf is None:
32 | _wf = Workflow()
33 | return _wf
34 |
35 |
36 | def _arg_cache(name):
37 | """Return path to pickle cache file for arguments
38 |
39 | :param name: name of task
40 | :type name: ``unicode``
41 | :returns: Path to cache file
42 | :rtype: ``unicode`` filepath
43 |
44 | """
45 |
46 | return wf().cachefile('{0}.argcache'.format(name))
47 |
48 |
49 | def _pid_file(name):
50 | """Return path to PID file for ``name``
51 |
52 | :param name: name of task
53 | :type name: ``unicode``
54 | :returns: Path to PID file for task
55 | :rtype: ``unicode`` filepath
56 |
57 | """
58 |
59 | return wf().cachefile('{0}.pid'.format(name))
60 |
61 |
62 | def _process_exists(pid):
63 | """Check if a process with PID ``pid`` exists
64 |
65 | :param pid: PID to check
66 | :type pid: ``int``
67 | :returns: ``True`` if process exists, else ``False``
68 | :rtype: ``Boolean``
69 | """
70 |
71 | try:
72 | os.kill(pid, 0)
73 | except OSError: # not running
74 | return False
75 | return True
76 |
77 |
78 | def is_running(name):
79 | """
80 | Test whether task is running under ``name``
81 |
82 | :param name: name of task
83 | :type name: ``unicode``
84 | :returns: ``True`` if task with name ``name`` is running, else ``False``
85 | :rtype: ``Boolean``
86 |
87 | """
88 | pidfile = _pid_file(name)
89 | if not os.path.exists(pidfile):
90 | return False
91 |
92 | with open(pidfile, 'rb') as file_obj:
93 | pid = int(file_obj.read().strip())
94 |
95 | if _process_exists(pid):
96 | return True
97 |
98 | elif os.path.exists(pidfile):
99 | os.unlink(pidfile)
100 |
101 | return False
102 |
103 |
104 | def _background(stdin='/dev/null', stdout='/dev/null',
105 | stderr='/dev/null'): # pragma: no cover
106 | """Fork the current process into a background daemon.
107 |
108 | :param stdin: where to read input
109 | :type stdin: filepath
110 | :param stdout: where to write stdout output
111 | :type stdout: filepath
112 | :param stderr: where to write stderr output
113 | :type stderr: filepath
114 |
115 | """
116 |
117 | # Do first fork.
118 | try:
119 | pid = os.fork()
120 | if pid > 0:
121 | sys.exit(0) # Exit first parent.
122 | except OSError as e:
123 | wf().logger.critical("fork #1 failed: ({0:d}) {1}".format(
124 | e.errno, e.strerror))
125 | sys.exit(1)
126 | # Decouple from parent environment.
127 | os.chdir(wf().workflowdir)
128 | os.umask(0)
129 | os.setsid()
130 | # Do second fork.
131 | try:
132 | pid = os.fork()
133 | if pid > 0:
134 | sys.exit(0) # Exit second parent.
135 | except OSError as e:
136 | wf().logger.critical("fork #2 failed: ({0:d}) {1}".format(
137 | e.errno, e.strerror))
138 | sys.exit(1)
139 | # Now I am a daemon!
140 | # Redirect standard file descriptors.
141 | si = file(stdin, 'r', 0)
142 | so = file(stdout, 'a+', 0)
143 | se = file(stderr, 'a+', 0)
144 | if hasattr(sys.stdin, 'fileno'):
145 | os.dup2(si.fileno(), sys.stdin.fileno())
146 | if hasattr(sys.stdout, 'fileno'):
147 | os.dup2(so.fileno(), sys.stdout.fileno())
148 | if hasattr(sys.stderr, 'fileno'):
149 | os.dup2(se.fileno(), sys.stderr.fileno())
150 |
151 |
152 | def run_in_background(name, args, **kwargs):
153 | """Pickle arguments to cache file, then call this script again via
154 | :func:`subprocess.call`.
155 |
156 | :param name: name of task
157 | :type name: ``unicode``
158 | :param args: arguments passed as first argument to :func:`subprocess.call`
159 | :param \**kwargs: keyword arguments to :func:`subprocess.call`
160 | :returns: exit code of sub-process
161 | :rtype: ``int``
162 |
163 | When you call this function, it caches its arguments and then calls
164 | ``background.py`` in a subprocess. The Python subprocess will load the
165 | cached arguments, fork into the background, and then run the command you
166 | specified.
167 |
168 | This function will return as soon as the ``background.py`` subprocess has
169 | forked, returning the exit code of *that* process (i.e. not of the command
170 | you're trying to run).
171 |
172 | If that process fails, an error will be written to the log file.
173 |
174 | If a process is already running under the same name, this function will
175 | return immediately and will not run the specified command.
176 |
177 | """
178 |
179 | if is_running(name):
180 | wf().logger.info('Task `{0}` is already running'.format(name))
181 | return
182 |
183 | argcache = _arg_cache(name)
184 |
185 | # Cache arguments
186 | with open(argcache, 'wb') as file_obj:
187 | pickle.dump({'args': args, 'kwargs': kwargs}, file_obj)
188 | wf().logger.debug('Command arguments cached to `{0}`'.format(argcache))
189 |
190 | # Call this script
191 | cmd = ['/usr/bin/python', __file__, name]
192 | wf().logger.debug('Calling {0!r} ...'.format(cmd))
193 | retcode = subprocess.call(cmd)
194 | if retcode: # pragma: no cover
195 | wf().logger.error('Failed to call task in background')
196 | else:
197 | wf().logger.debug('Executing task `{0}` in background...'.format(name))
198 | return retcode
199 |
200 |
201 | def main(wf): # pragma: no cover
202 | """
203 | Load cached arguments, fork into background, then call
204 | :meth:`subprocess.call` with cached arguments
205 |
206 | """
207 |
208 | name = wf.args[0]
209 | argcache = _arg_cache(name)
210 | if not os.path.exists(argcache):
211 | wf.logger.critical('No arg cache found : {0!r}'.format(argcache))
212 | return 1
213 |
214 | # Load cached arguments
215 | with open(argcache, 'rb') as file_obj:
216 | data = pickle.load(file_obj)
217 |
218 | # Cached arguments
219 | args = data['args']
220 | kwargs = data['kwargs']
221 |
222 | # Delete argument cache file
223 | os.unlink(argcache)
224 |
225 | pidfile = _pid_file(name)
226 |
227 | # Fork to background
228 | _background()
229 |
230 | # Write PID to file
231 | with open(pidfile, 'wb') as file_obj:
232 | file_obj.write('{0}'.format(os.getpid()))
233 |
234 | # Run the command
235 | try:
236 | wf.logger.debug('Task `{0}` running'.format(name))
237 | wf.logger.debug('cmd : {0!r}'.format(args))
238 |
239 | retcode = subprocess.call(args, **kwargs)
240 |
241 | if retcode:
242 | wf.logger.error('Command failed with [{0}] : {1!r}'.format(
243 | retcode, args))
244 |
245 | finally:
246 | if os.path.exists(pidfile):
247 | os.unlink(pidfile)
248 | wf.logger.debug('Task `{0}` finished'.format(name))
249 |
250 |
251 | if __name__ == '__main__': # pragma: no cover
252 | wf().run(main)
253 |
--------------------------------------------------------------------------------
/workflow/update.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright © 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 | """
13 | Self-updating from GitHub
14 |
15 | .. versionadded:: 1.9
16 |
17 | .. note::
18 |
19 | This module is not intended to be used directly. Automatic updates
20 | are controlled by the ``update_settings`` :class:`dict` passed to
21 | :class:`~workflow.workflow.Workflow` objects.
22 |
23 | """
24 |
25 | from __future__ import print_function, unicode_literals
26 |
27 | import os
28 | import tempfile
29 | import re
30 | import subprocess
31 |
32 | import workflow
33 | import web
34 |
35 | # __all__ = []
36 |
37 |
38 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases'
39 |
40 |
41 | _wf = None
42 |
43 |
44 | def wf():
45 | global _wf
46 | if _wf is None:
47 | _wf = workflow.Workflow()
48 | return _wf
49 |
50 |
51 | class Version(object):
52 | """Mostly semantic versioning
53 |
54 | The main difference to proper :ref:`semantic versioning `
55 | is that this implementation doesn't require a minor or patch version.
56 | """
57 |
58 | #: Match version and pre-release/build information in version strings
59 | match_version = re.compile(r'([0-9\.]+)(.+)?').match
60 |
61 | def __init__(self, vstr):
62 | self.vstr = vstr
63 | self.major = 0
64 | self.minor = 0
65 | self.patch = 0
66 | self.suffix = ''
67 | self.build = ''
68 | self._parse(vstr)
69 |
70 | def _parse(self, vstr):
71 | if vstr.startswith('v'):
72 | m = self.match_version(vstr[1:])
73 | else:
74 | m = self.match_version(vstr)
75 | if not m:
76 | raise ValueError('Invalid version number: {0}'.format(vstr))
77 |
78 | version, suffix = m.groups()
79 | parts = self._parse_dotted_string(version)
80 | self.major = parts.pop(0)
81 | if len(parts):
82 | self.minor = parts.pop(0)
83 | if len(parts):
84 | self.patch = parts.pop(0)
85 | if not len(parts) == 0:
86 | raise ValueError('Invalid version (too long) : {0}'.format(vstr))
87 |
88 | if suffix:
89 | # Build info
90 | idx = suffix.find('+')
91 | if idx > -1:
92 | self.build = suffix[idx+1:]
93 | suffix = suffix[:idx]
94 | if suffix:
95 | if not suffix.startswith('-'):
96 | raise ValueError(
97 | 'Invalid suffix : `{0}`. Must start with `-`'.format(
98 | suffix))
99 | self.suffix = suffix[1:]
100 |
101 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self)))
102 |
103 | def _parse_dotted_string(self, s):
104 | """Parse string ``s`` into list of ints and strings"""
105 | parsed = []
106 | parts = s.split('.')
107 | for p in parts:
108 | if p.isdigit():
109 | p = int(p)
110 | parsed.append(p)
111 | return parsed
112 |
113 | @property
114 | def tuple(self):
115 | """Return version number as a tuple of major, minor, patch, pre-release
116 | """
117 |
118 | return (self.major, self.minor, self.patch, self.suffix)
119 |
120 | def __lt__(self, other):
121 | if not isinstance(other, Version):
122 | raise ValueError('Not a Version instance: {0!r}'.format(other))
123 | t = self.tuple[:3]
124 | o = other.tuple[:3]
125 | if t < o:
126 | return True
127 | if t == o: # We need to compare suffixes
128 | if self.suffix and not other.suffix:
129 | return True
130 | if other.suffix and not self.suffix:
131 | return False
132 | return (self._parse_dotted_string(self.suffix) <
133 | self._parse_dotted_string(other.suffix))
134 | # t > o
135 | return False
136 |
137 | def __eq__(self, other):
138 | if not isinstance(other, Version):
139 | raise ValueError('Not a Version instance: {0!r}'.format(other))
140 | return self.tuple == other.tuple
141 |
142 | def __ne__(self, other):
143 | return not self.__eq__(other)
144 |
145 | def __gt__(self, other):
146 | if not isinstance(other, Version):
147 | raise ValueError('Not a Version instance: {0!r}'.format(other))
148 | return other.__lt__(self)
149 |
150 | def __le__(self, other):
151 | if not isinstance(other, Version):
152 | raise ValueError('Not a Version instance: {0!r}'.format(other))
153 | return not other.__lt__(self)
154 |
155 | def __ge__(self, other):
156 | return not self.__lt__(other)
157 |
158 | def __str__(self):
159 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
160 | if self.suffix:
161 | vstr += '-{0}'.format(self.suffix)
162 | if self.build:
163 | vstr += '+{0}'.format(self.build)
164 | return vstr
165 |
166 | def __repr__(self):
167 | return "Version('{0}')".format(str(self))
168 |
169 |
170 | def download_workflow(url):
171 | """Download workflow at ``url`` to a local temporary file
172 |
173 | :param url: URL to .alfredworkflow file in GitHub repo
174 | :returns: path to downloaded file
175 |
176 | """
177 |
178 | filename = url.split("/")[-1]
179 |
180 | if (not url.endswith('.alfredworkflow') or
181 | not filename.endswith('.alfredworkflow')):
182 | raise ValueError('Attachment `{}` not a workflow'.format(filename))
183 |
184 | local_path = os.path.join(tempfile.gettempdir(), filename)
185 |
186 | wf().logger.debug(
187 | 'Downloading updated workflow from `{0}` to `{1}` ...'.format(
188 | url, local_path))
189 |
190 | response = web.get(url)
191 |
192 | with open(local_path, 'wb') as output:
193 | output.write(response.content)
194 |
195 | return local_path
196 |
197 |
198 | def build_api_url(slug):
199 | """Generate releases URL from GitHub slug
200 |
201 | :param slug: Repo name in form ``username/repo``
202 | :returns: URL to the API endpoint for the repo's releases
203 |
204 | """
205 |
206 | if len(slug.split('/')) != 2:
207 | raise ValueError('Invalid GitHub slug : {0}'.format(slug))
208 |
209 | return RELEASES_BASE.format(slug)
210 |
211 |
212 | def get_valid_releases(github_slug):
213 | """Return list of all valid releases
214 |
215 | :param github_slug: ``username/repo`` for workflow's GitHub repo
216 | :returns: list of dicts. Each :class:`dict` has the form
217 | ``{'version': '1.1', 'download_url': 'http://github.com/...'}``
218 |
219 |
220 | A valid release is one that contains one ``.alfredworkflow`` file.
221 |
222 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading
223 | ``v`` will be stripped.
224 |
225 | """
226 |
227 | api_url = build_api_url(github_slug)
228 | releases = []
229 |
230 | wf().logger.debug('Retrieving releases list from `{0}` ...'.format(
231 | api_url))
232 |
233 | def retrieve_releases():
234 | wf().logger.info(
235 | 'Retrieving releases for `{0}` ...'.format(github_slug))
236 | return web.get(api_url).json()
237 |
238 | slug = github_slug.replace('/', '-')
239 | for release in wf().cached_data('gh-releases-{0}'.format(slug),
240 | retrieve_releases):
241 | version = release['tag_name']
242 | download_urls = []
243 | for asset in release.get('assets', []):
244 | url = asset.get('browser_download_url')
245 | if not url or not url.endswith('.alfredworkflow'):
246 | continue
247 | download_urls.append(url)
248 |
249 | # Validate release
250 | if release['prerelease']:
251 | wf().logger.warning(
252 | 'Invalid release {0} : pre-release detected'.format(version))
253 | continue
254 | if not download_urls:
255 | wf().logger.warning(
256 | 'Invalid release {0} : No workflow file'.format(version))
257 | continue
258 | if len(download_urls) > 1:
259 | wf().logger.warning(
260 | 'Invalid release {0} : multiple workflow files'.format(version))
261 | continue
262 |
263 | wf().logger.debug('Release `{0}` : {1}'.format(version, url))
264 | releases.append({'version': version, 'download_url': download_urls[0]})
265 |
266 | return releases
267 |
268 |
269 | def check_update(github_slug, current_version):
270 | """Check whether a newer release is available on GitHub
271 |
272 | :param github_slug: ``username/repo`` for workflow's GitHub repo
273 | :param current_version: the currently installed version of the
274 | workflow. :ref:`Semantic versioning ` is required.
275 | :type current_version: ``unicode``
276 | :returns: ``True`` if an update is available, else ``False``
277 |
278 | If an update is available, its version number and download URL will
279 | be cached.
280 |
281 | """
282 |
283 | releases = get_valid_releases(github_slug)
284 |
285 | wf().logger.info('{0} releases for {1}'.format(len(releases),
286 | github_slug))
287 |
288 | if not len(releases):
289 | raise ValueError('No valid releases for {0}'.format(github_slug))
290 |
291 | # GitHub returns releases newest-first
292 | latest_release = releases[0]
293 |
294 | # (latest_version, download_url) = get_latest_release(releases)
295 | vr = Version(latest_release['version'])
296 | vl = Version(current_version)
297 | wf().logger.debug('Latest : {0!r} Installed : {1!r}'.format(vr, vl))
298 | if vr > vl:
299 |
300 | wf().cache_data('__workflow_update_status', {
301 | 'version': latest_release['version'],
302 | 'download_url': latest_release['download_url'],
303 | 'available': True
304 | })
305 |
306 | return True
307 |
308 | wf().cache_data('__workflow_update_status', {
309 | 'available': False
310 | })
311 | return False
312 |
313 |
314 | def install_update(github_slug, current_version):
315 | """If a newer release is available, download and install it
316 |
317 | :param github_slug: ``username/repo`` for workflow's GitHub repo
318 | :param current_version: the currently installed version of the
319 | workflow. :ref:`Semantic versioning ` is required.
320 | :type current_version: ``unicode``
321 |
322 | If an update is available, it will be downloaded and installed.
323 |
324 | :returns: ``True`` if an update is installed, else ``False``
325 |
326 | """
327 | # TODO: `github_slug` and `current_version` are both unusued.
328 |
329 | update_data = wf().cached_data('__workflow_update_status', max_age=0)
330 |
331 | if not update_data or not update_data.get('available'):
332 | wf().logger.info('No update available')
333 | return False
334 |
335 | local_file = download_workflow(update_data['download_url'])
336 |
337 | wf().logger.info('Installing updated workflow ...')
338 | subprocess.call(['open', local_file])
339 |
340 | update_data['available'] = False
341 | wf().cache_data('__workflow_update_status', update_data)
342 | return True
343 |
344 |
345 | if __name__ == '__main__': # pragma: nocover
346 | import sys
347 |
348 | def show_help():
349 | print('Usage : update.py (check|install) github_slug version')
350 | sys.exit(1)
351 |
352 | if len(sys.argv) != 4:
353 | show_help()
354 |
355 | action, github_slug, version = sys.argv[1:]
356 |
357 | if action not in ('check', 'install'):
358 | show_help()
359 |
360 | if action == 'check':
361 | check_update(github_slug, version)
362 | elif action == 'install':
363 | install_update(github_slug, version)
364 |
--------------------------------------------------------------------------------
/workflow/version:
--------------------------------------------------------------------------------
1 | 1.11.1
--------------------------------------------------------------------------------
/workflow/web.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | #
3 | # Copyright (c) 2014 Dean Jackson
4 | #
5 | # MIT Licence. See http://opensource.org/licenses/MIT
6 | #
7 | # Created on 2014-02-15
8 | #
9 |
10 | """
11 | A lightweight HTTP library with a requests-like interface.
12 | """
13 |
14 | from __future__ import print_function
15 |
16 | import codecs
17 | import json
18 | import mimetypes
19 | import os
20 | import random
21 | import re
22 | import socket
23 | import string
24 | import unicodedata
25 | import urllib
26 | import urllib2
27 | import zlib
28 |
29 |
30 | USER_AGENT = u'Alfred-Workflow/1.11 (http://www.deanishe.net)'
31 |
32 | # Valid characters for multipart form data boundaries
33 | BOUNDARY_CHARS = string.digits + string.ascii_letters
34 |
35 | # HTTP response codes
36 | RESPONSES = {
37 | 100: 'Continue',
38 | 101: 'Switching Protocols',
39 | 200: 'OK',
40 | 201: 'Created',
41 | 202: 'Accepted',
42 | 203: 'Non-Authoritative Information',
43 | 204: 'No Content',
44 | 205: 'Reset Content',
45 | 206: 'Partial Content',
46 | 300: 'Multiple Choices',
47 | 301: 'Moved Permanently',
48 | 302: 'Found',
49 | 303: 'See Other',
50 | 304: 'Not Modified',
51 | 305: 'Use Proxy',
52 | 307: 'Temporary Redirect',
53 | 400: 'Bad Request',
54 | 401: 'Unauthorized',
55 | 402: 'Payment Required',
56 | 403: 'Forbidden',
57 | 404: 'Not Found',
58 | 405: 'Method Not Allowed',
59 | 406: 'Not Acceptable',
60 | 407: 'Proxy Authentication Required',
61 | 408: 'Request Timeout',
62 | 409: 'Conflict',
63 | 410: 'Gone',
64 | 411: 'Length Required',
65 | 412: 'Precondition Failed',
66 | 413: 'Request Entity Too Large',
67 | 414: 'Request-URI Too Long',
68 | 415: 'Unsupported Media Type',
69 | 416: 'Requested Range Not Satisfiable',
70 | 417: 'Expectation Failed',
71 | 500: 'Internal Server Error',
72 | 501: 'Not Implemented',
73 | 502: 'Bad Gateway',
74 | 503: 'Service Unavailable',
75 | 504: 'Gateway Timeout',
76 | 505: 'HTTP Version Not Supported'
77 | }
78 |
79 |
80 | def str_dict(dic):
81 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`
82 |
83 | :param dic: :class:`dict` of Unicode strings
84 | :returns: :class:`dict`
85 |
86 | """
87 | if isinstance(dic, CaseInsensitiveDictionary):
88 | dic2 = CaseInsensitiveDictionary()
89 | else:
90 | dic2 = {}
91 | for k, v in dic.items():
92 | if isinstance(k, unicode):
93 | k = k.encode('utf-8')
94 | if isinstance(v, unicode):
95 | v = v.encode('utf-8')
96 | dic2[k] = v
97 | return dic2
98 |
99 |
100 | class NoRedirectHandler(urllib2.HTTPRedirectHandler):
101 | """Prevent redirections"""
102 |
103 | def redirect_request(self, *args):
104 | return None
105 |
106 |
107 | # Adapted from https://gist.github.com/babakness/3901174
108 | class CaseInsensitiveDictionary(dict):
109 | """
110 | Dictionary that enables case insensitive searching while preserving
111 | case sensitivity when keys are listed, ie, via keys() or items() methods.
112 |
113 | Works by storing a lowercase version of the key as the new key and
114 | stores the original key-value pair as the key's value
115 | (values become dictionaries).
116 |
117 | """
118 |
119 | def __init__(self, initval=None):
120 |
121 | if isinstance(initval, dict):
122 | for key, value in initval.iteritems():
123 | self.__setitem__(key, value)
124 |
125 | elif isinstance(initval, list):
126 | for (key, value) in initval:
127 | self.__setitem__(key, value)
128 |
129 | def __contains__(self, key):
130 | return dict.__contains__(self, key.lower())
131 |
132 | def __getitem__(self, key):
133 | return dict.__getitem__(self, key.lower())['val']
134 |
135 | def __setitem__(self, key, value):
136 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
137 |
138 | def get(self, key, default=None):
139 | try:
140 | v = dict.__getitem__(self, key.lower())
141 | except KeyError:
142 | return default
143 | else:
144 | return v['val']
145 |
146 | def update(self, other):
147 | for k, v in other.items():
148 | self[k] = v
149 |
150 | def items(self):
151 | return [(v['key'], v['val']) for v in dict.itervalues(self)]
152 |
153 | def keys(self):
154 | return [v['key'] for v in dict.itervalues(self)]
155 |
156 | def values(self):
157 | return [v['val'] for v in dict.itervalues(self)]
158 |
159 | def iteritems(self):
160 | for v in dict.itervalues(self):
161 | yield v['key'], v['val']
162 |
163 | def iterkeys(self):
164 | for v in dict.itervalues(self):
165 | yield v['key']
166 |
167 | def itervalues(self):
168 | for v in dict.itervalues(self):
169 | yield v['val']
170 |
171 |
172 | class Response(object):
173 | """
174 | Returned by :func:`request` / :func:`get` / :func:`post` functions.
175 |
176 | A simplified version of the ``Response`` object in the ``requests`` library.
177 |
178 | >>> r = request('http://www.google.com')
179 | >>> r.status_code
180 | 200
181 | >>> r.encoding
182 | ISO-8859-1
183 | >>> r.content # bytes
184 | ...
185 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag
186 | u' ...'
187 | >>> r.json() # content parsed as JSON
188 |
189 | """
190 |
191 | def __init__(self, request):
192 | """Call `request` with :mod:`urllib2` and process results.
193 |
194 | :param request: :class:`urllib2.Request` instance
195 |
196 | """
197 |
198 | self.request = request
199 | self.url = None
200 | self.raw = None
201 | self._encoding = None
202 | self.error = None
203 | self.status_code = None
204 | self.reason = None
205 | self.headers = CaseInsensitiveDictionary()
206 | self._content = None
207 | self._gzipped = False
208 |
209 | # Execute query
210 | try:
211 | self.raw = urllib2.urlopen(request)
212 | except urllib2.HTTPError as err:
213 | self.error = err
214 | try:
215 | self.url = err.geturl()
216 | # sometimes (e.g. when authentication fails)
217 | # urllib can't get a URL from an HTTPError
218 | # This behaviour changes across Python versions,
219 | # so no test cover (it isn't important).
220 | except AttributeError: # pragma: no cover
221 | pass
222 | self.status_code = err.code
223 | else:
224 | self.status_code = self.raw.getcode()
225 | self.url = self.raw.geturl()
226 | self.reason = RESPONSES.get(self.status_code)
227 |
228 | # Parse additional info if request succeeded
229 | if not self.error:
230 | headers = self.raw.info()
231 | self.transfer_encoding = headers.getencoding()
232 | self.mimetype = headers.gettype()
233 | for key in headers.keys():
234 | self.headers[key.lower()] = headers.get(key)
235 |
236 | # Is content gzipped?
237 | # Transfer-Encoding appears to not be used in the wild
238 | # (contrary to the HTTP standard), but no harm in testing
239 | # for it
240 | if ('gzip' in headers.get('content-encoding', '') or
241 | 'gzip' in headers.get('transfer-encoding', '')):
242 | self._gzipped = True
243 |
244 | def json(self):
245 | """Decode response contents as JSON.
246 |
247 | :returns: object decoded from JSON
248 | :rtype: :class:`list` / :class:`dict`
249 |
250 | """
251 |
252 | return json.loads(self.content, self.encoding or 'utf-8')
253 |
254 | @property
255 | def encoding(self):
256 | """Text encoding of document or ``None``
257 |
258 | :returns: :class:`str` or ``None``
259 |
260 | """
261 |
262 | if not self._encoding:
263 | self._encoding = self._get_encoding()
264 |
265 | return self._encoding
266 |
267 | @property
268 | def content(self):
269 | """Raw content of response (i.e. bytes)
270 |
271 | :returns: Body of HTTP response
272 | :rtype: :class:`str`
273 |
274 | """
275 |
276 | if not self._content:
277 |
278 | # Decompress gzipped content
279 | if self._gzipped:
280 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
281 | self._content = decoder.decompress(self.raw.read())
282 |
283 | else:
284 | self._content = self.raw.read()
285 |
286 | return self._content
287 |
288 | @property
289 | def text(self):
290 | """Unicode-decoded content of response body.
291 |
292 | If no encoding can be determined from HTTP headers or the content
293 | itself, the encoded response body will be returned instead.
294 |
295 | :returns: Body of HTTP response
296 | :rtype: :class:`unicode` or :class:`str`
297 |
298 | """
299 |
300 | if self.encoding:
301 | return unicodedata.normalize('NFC', unicode(self.content,
302 | self.encoding))
303 | return self.content
304 |
305 | def iter_content(self, chunk_size=4096, decode_unicode=False):
306 | """Iterate over response data.
307 |
308 | .. versionadded:: 1.6
309 |
310 | :param chunk_size: Number of bytes to read into memory
311 | :type chunk_size: ``int``
312 | :param decode_unicode: Decode to Unicode using detected encoding
313 | :type decode_unicode: ``Boolean``
314 | :returns: iterator
315 |
316 | """
317 |
318 | def decode_stream(iterator, r):
319 |
320 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace')
321 |
322 | for chunk in iterator:
323 | data = decoder.decode(chunk)
324 | if data:
325 | yield data
326 |
327 | data = decoder.decode(b'', final=True)
328 | if data:
329 | yield data # pragma: nocover
330 |
331 | def generate():
332 |
333 | if self._gzipped:
334 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
335 |
336 | while True:
337 | chunk = self.raw.read(chunk_size)
338 | if not chunk:
339 | break
340 |
341 | if self._gzipped:
342 | chunk = decoder.decompress(chunk)
343 |
344 | yield chunk
345 |
346 | chunks = generate()
347 |
348 | if decode_unicode and self.encoding:
349 | chunks = decode_stream(chunks, self)
350 |
351 | return chunks
352 |
353 | def save_to_path(self, filepath):
354 | """Save retrieved data to file at ``filepath``
355 |
356 | .. versionadded: 1.9.6
357 |
358 | :param filepath: Path to save retrieved data.
359 |
360 | """
361 |
362 | filepath = os.path.abspath(filepath)
363 | dirname = os.path.dirname(filepath)
364 | if not os.path.exists(dirname):
365 | os.makedirs(dirname)
366 |
367 | with open(filepath, 'wb') as fileobj:
368 | for data in self.iter_content():
369 | fileobj.write(data)
370 |
371 | def raise_for_status(self):
372 | """Raise stored error if one occurred.
373 |
374 | error will be instance of :class:`urllib2.HTTPError`
375 | """
376 |
377 | if self.error is not None:
378 | raise self.error
379 | return
380 |
381 | def _get_encoding(self):
382 | """Get encoding from HTTP headers or content.
383 |
384 | :returns: encoding or `None`
385 | :rtype: ``unicode`` or ``None``
386 |
387 | """
388 |
389 | headers = self.raw.info()
390 | encoding = None
391 |
392 | if headers.getparam('charset'):
393 | encoding = headers.getparam('charset')
394 |
395 | # HTTP Content-Type header
396 | for param in headers.getplist():
397 | if param.startswith('charset='):
398 | encoding = param[8:]
399 | break
400 |
401 | # Encoding declared in document should override HTTP headers
402 | if self.mimetype == 'text/html': # sniff HTML headers
403 | m = re.search("""""",
404 | self.content)
405 | if m:
406 | encoding = m.group(1)
407 |
408 | elif ((self.mimetype.startswith('application/') or
409 | self.mimetype.startswith('text/')) and
410 | 'xml' in self.mimetype):
411 | m = re.search("""]*\?>""",
412 | self.content)
413 | if m:
414 | encoding = m.group(1)
415 |
416 | # Format defaults
417 | if self.mimetype == 'application/json' and not encoding:
418 | # The default encoding for JSON
419 | encoding = 'utf-8'
420 |
421 | elif self.mimetype == 'application/xml' and not encoding:
422 | # The default for 'application/xml'
423 | encoding = 'utf-8'
424 |
425 | if encoding:
426 | encoding = encoding.lower()
427 |
428 | return encoding
429 |
430 |
431 | def request(method, url, params=None, data=None, headers=None, cookies=None,
432 | files=None, auth=None, timeout=60, allow_redirects=False):
433 | """Initiate an HTTP(S) request. Returns :class:`Response` object.
434 |
435 | :param method: 'GET' or 'POST'
436 | :type method: ``unicode``
437 | :param url: URL to open
438 | :type url: ``unicode``
439 | :param params: mapping of URL parameters
440 | :type params: :class:`dict`
441 | :param data: mapping of form data ``{'field_name': 'value'}`` or
442 | :class:`str`
443 | :type data: :class:`dict` or :class:`str`
444 | :param headers: HTTP headers
445 | :type headers: :class:`dict`
446 | :param cookies: cookies to send to server
447 | :type cookies: :class:`dict`
448 | :param files: files to upload (see below).
449 | :type files: :class:`dict`
450 | :param auth: username, password
451 | :type auth: ``tuple``
452 | :param timeout: connection timeout limit in seconds
453 | :type timeout: ``int``
454 | :param allow_redirects: follow redirections
455 | :type allow_redirects: ``Boolean``
456 | :returns: :class:`Response` object
457 |
458 |
459 | The ``files`` argument is a dictionary::
460 |
461 | {'fieldname' : { 'filename': 'blah.txt',
462 | 'content': '',
463 | 'mimetype': 'text/plain'}
464 | }
465 |
466 | * ``fieldname`` is the name of the field in the HTML form.
467 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
468 | be used to guess the mimetype, or ``application/octet-stream``
469 | will be used.
470 |
471 | """
472 |
473 | # TODO: cookies
474 | # TODO: any way to force GET or POST?
475 | socket.setdefaulttimeout(timeout)
476 |
477 | # Default handlers
478 | openers = []
479 |
480 | if not allow_redirects:
481 | openers.append(NoRedirectHandler())
482 |
483 | if auth is not None: # Add authorisation handler
484 | username, password = auth
485 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
486 | password_manager.add_password(None, url, username, password)
487 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager)
488 | openers.append(auth_manager)
489 |
490 | # Install our custom chain of openers
491 | opener = urllib2.build_opener(*openers)
492 | urllib2.install_opener(opener)
493 |
494 | if not headers:
495 | headers = CaseInsensitiveDictionary()
496 | else:
497 | headers = CaseInsensitiveDictionary(headers)
498 |
499 | if 'user-agent' not in headers:
500 | headers['user-agent'] = USER_AGENT
501 |
502 | # Accept gzip-encoded content
503 | encodings = [s.strip() for s in
504 | headers.get('accept-encoding', '').split(',')]
505 | if 'gzip' not in encodings:
506 | encodings.append('gzip')
507 |
508 | headers['accept-encoding'] = ', '.join(encodings)
509 |
510 | if files:
511 | if not data:
512 | data = {}
513 | new_headers, data = encode_multipart_formdata(data, files)
514 | headers.update(new_headers)
515 | elif data and isinstance(data, dict):
516 | data = urllib.urlencode(str_dict(data))
517 |
518 | # Make sure everything is encoded text
519 | headers = str_dict(headers)
520 |
521 | if isinstance(url, unicode):
522 | url = url.encode('utf-8')
523 |
524 | if params: # GET args (POST args are handled in encode_multipart_formdata)
525 | url = url + '?' + urllib.urlencode(str_dict(params))
526 |
527 | req = urllib2.Request(url, data, headers)
528 | return Response(req)
529 |
530 |
531 | def get(url, params=None, headers=None, cookies=None, auth=None,
532 | timeout=60, allow_redirects=True):
533 | """Initiate a GET request. Arguments as for :func:`request`.
534 |
535 | :returns: :class:`Response` instance
536 |
537 | """
538 |
539 | return request('GET', url, params, headers=headers, cookies=cookies,
540 | auth=auth, timeout=timeout, allow_redirects=allow_redirects)
541 |
542 |
543 | def post(url, params=None, data=None, headers=None, cookies=None, files=None,
544 | auth=None, timeout=60, allow_redirects=False):
545 | """Initiate a POST request. Arguments as for :func:`request`.
546 |
547 | :returns: :class:`Response` instance
548 |
549 | """
550 | return request('POST', url, params, data, headers, cookies, files, auth,
551 | timeout, allow_redirects)
552 |
553 |
554 | def encode_multipart_formdata(fields, files):
555 | """Encode form data (``fields``) and ``files`` for POST request.
556 |
557 | :param fields: mapping of ``{name : value}`` pairs for normal form fields.
558 | :type fields: :class:`dict`
559 | :param files: dictionary of fieldnames/files elements for file data.
560 | See below for details.
561 | :type files: :class:`dict` of :class:`dicts`
562 | :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers
563 | :rtype: 2-tuple ``(dict, str)``
564 |
565 | The ``files`` argument is a dictionary::
566 |
567 | {'fieldname' : { 'filename': 'blah.txt',
568 | 'content': '',
569 | 'mimetype': 'text/plain'}
570 | }
571 |
572 | - ``fieldname`` is the name of the field in the HTML form.
573 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used.
574 |
575 | """
576 |
577 | def get_content_type(filename):
578 | """Return or guess mimetype of ``filename``.
579 |
580 | :param filename: filename of file
581 | :type filename: unicode/string
582 | :returns: mime-type, e.g. ``text/html``
583 | :rtype: :class::class:`str`
584 |
585 | """
586 |
587 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
588 |
589 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
590 | for i in range(30))
591 | CRLF = '\r\n'
592 | output = []
593 |
594 | # Normal form fields
595 | for (name, value) in fields.items():
596 | if isinstance(name, unicode):
597 | name = name.encode('utf-8')
598 | if isinstance(value, unicode):
599 | value = value.encode('utf-8')
600 | output.append('--' + boundary)
601 | output.append('Content-Disposition: form-data; name="%s"' % name)
602 | output.append('')
603 | output.append(value)
604 |
605 | # Files to upload
606 | for name, d in files.items():
607 | filename = d[u'filename']
608 | content = d[u'content']
609 | if u'mimetype' in d:
610 | mimetype = d[u'mimetype']
611 | else:
612 | mimetype = get_content_type(filename)
613 | if isinstance(name, unicode):
614 | name = name.encode('utf-8')
615 | if isinstance(filename, unicode):
616 | filename = filename.encode('utf-8')
617 | if isinstance(mimetype, unicode):
618 | mimetype = mimetype.encode('utf-8')
619 | output.append('--' + boundary)
620 | output.append('Content-Disposition: form-data; '
621 | 'name="%s"; filename="%s"' % (name, filename))
622 | output.append('Content-Type: %s' % mimetype)
623 | output.append('')
624 | output.append(content)
625 |
626 | output.append('--' + boundary + '--')
627 | output.append('')
628 | body = CRLF.join(output)
629 | headers = {
630 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
631 | 'Content-Length': str(len(body)),
632 | }
633 | return (headers, body)
634 |
--------------------------------------------------------------------------------
/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 | """
11 | The :class:`Workflow` object is the main interface to this library.
12 |
13 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set
14 | up your Python script to best utilise the :class:`Workflow` object.
15 |
16 | """
17 |
18 | from __future__ import print_function, unicode_literals
19 |
20 | import binascii
21 | import os
22 | import sys
23 | import string
24 | import re
25 | import plistlib
26 | import subprocess
27 | import unicodedata
28 | import shutil
29 | import json
30 | import cPickle
31 | import pickle
32 | import time
33 | import logging
34 | import logging.handlers
35 | try:
36 | import xml.etree.cElementTree as ET
37 | except ImportError: # pragma: no cover
38 | import xml.etree.ElementTree as ET
39 |
40 |
41 | #: Sentinel for properties that haven't been set yet (that might
42 | #: correctly have the value ``None``)
43 | UNSET = object()
44 |
45 | ####################################################################
46 | # Standard system icons
47 | ####################################################################
48 |
49 | # These icons are default OS X icons. They are super-high quality, and
50 | # will be familiar to users.
51 | # This library uses `ICON_ERROR` when a workflow dies in flames, so
52 | # in my own workflows, I use `ICON_WARNING` for less fatal errors
53 | # (e.g. bad user input, no results etc.)
54 |
55 | # The system icons are all in this directory. There are many more than
56 | # are listed here
57 |
58 | ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources'
59 |
60 | ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns')
61 | ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns')
62 | ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns')
63 | ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns')
64 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please
65 | ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns')
66 | # Shown when a workflow throws an error
67 | ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns')
68 | ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns')
69 | ICON_FAVOURITE = ICON_FAVORITE
70 | ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns')
71 | ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns')
72 | ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns')
73 | ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns')
74 | ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns')
75 | ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns')
76 | ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns')
77 | ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns')
78 | ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns')
79 | ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns')
80 | ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns')
81 | ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns')
82 | ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns')
83 | ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns')
84 |
85 | ####################################################################
86 | # non-ASCII to ASCII diacritic folding.
87 | # Used by `fold_to_ascii` method
88 | ####################################################################
89 |
90 | ASCII_REPLACEMENTS = {
91 | 'À': 'A',
92 | 'Á': 'A',
93 | 'Â': 'A',
94 | 'Ã': 'A',
95 | 'Ä': 'A',
96 | 'Å': 'A',
97 | 'Æ': 'AE',
98 | 'Ç': 'C',
99 | 'È': 'E',
100 | 'É': 'E',
101 | 'Ê': 'E',
102 | 'Ë': 'E',
103 | 'Ì': 'I',
104 | 'Í': 'I',
105 | 'Î': 'I',
106 | 'Ï': 'I',
107 | 'Ð': 'D',
108 | 'Ñ': 'N',
109 | 'Ò': 'O',
110 | 'Ó': 'O',
111 | 'Ô': 'O',
112 | 'Õ': 'O',
113 | 'Ö': 'O',
114 | 'Ø': 'O',
115 | 'Ù': 'U',
116 | 'Ú': 'U',
117 | 'Û': 'U',
118 | 'Ü': 'U',
119 | 'Ý': 'Y',
120 | 'Þ': 'Th',
121 | 'ß': 'ss',
122 | 'à': 'a',
123 | 'á': 'a',
124 | 'â': 'a',
125 | 'ã': 'a',
126 | 'ä': 'a',
127 | 'å': 'a',
128 | 'æ': 'ae',
129 | 'ç': 'c',
130 | 'è': 'e',
131 | 'é': 'e',
132 | 'ê': 'e',
133 | 'ë': 'e',
134 | 'ì': 'i',
135 | 'í': 'i',
136 | 'î': 'i',
137 | 'ï': 'i',
138 | 'ð': 'd',
139 | 'ñ': 'n',
140 | 'ò': 'o',
141 | 'ó': 'o',
142 | 'ô': 'o',
143 | 'õ': 'o',
144 | 'ö': 'o',
145 | 'ø': 'o',
146 | 'ù': 'u',
147 | 'ú': 'u',
148 | 'û': 'u',
149 | 'ü': 'u',
150 | 'ý': 'y',
151 | 'þ': 'th',
152 | 'ÿ': 'y',
153 | 'Ł': 'L',
154 | 'ł': 'l',
155 | 'Ń': 'N',
156 | 'ń': 'n',
157 | 'Ņ': 'N',
158 | 'ņ': 'n',
159 | 'Ň': 'N',
160 | 'ň': 'n',
161 | 'Ŋ': 'ng',
162 | 'ŋ': 'NG',
163 | 'Ō': 'O',
164 | 'ō': 'o',
165 | 'Ŏ': 'O',
166 | 'ŏ': 'o',
167 | 'Ő': 'O',
168 | 'ő': 'o',
169 | 'Œ': 'OE',
170 | 'œ': 'oe',
171 | 'Ŕ': 'R',
172 | 'ŕ': 'r',
173 | 'Ŗ': 'R',
174 | 'ŗ': 'r',
175 | 'Ř': 'R',
176 | 'ř': 'r',
177 | 'Ś': 'S',
178 | 'ś': 's',
179 | 'Ŝ': 'S',
180 | 'ŝ': 's',
181 | 'Ş': 'S',
182 | 'ş': 's',
183 | 'Š': 'S',
184 | 'š': 's',
185 | 'Ţ': 'T',
186 | 'ţ': 't',
187 | 'Ť': 'T',
188 | 'ť': 't',
189 | 'Ŧ': 'T',
190 | 'ŧ': 't',
191 | 'Ũ': 'U',
192 | 'ũ': 'u',
193 | 'Ū': 'U',
194 | 'ū': 'u',
195 | 'Ŭ': 'U',
196 | 'ŭ': 'u',
197 | 'Ů': 'U',
198 | 'ů': 'u',
199 | 'Ű': 'U',
200 | 'ű': 'u',
201 | 'Ŵ': 'W',
202 | 'ŵ': 'w',
203 | 'Ŷ': 'Y',
204 | 'ŷ': 'y',
205 | 'Ÿ': 'Y',
206 | 'Ź': 'Z',
207 | 'ź': 'z',
208 | 'Ż': 'Z',
209 | 'ż': 'z',
210 | 'Ž': 'Z',
211 | 'ž': 'z',
212 | 'ſ': 's',
213 | 'Α': 'A',
214 | 'Β': 'B',
215 | 'Γ': 'G',
216 | 'Δ': 'D',
217 | 'Ε': 'E',
218 | 'Ζ': 'Z',
219 | 'Η': 'E',
220 | 'Θ': 'Th',
221 | 'Ι': 'I',
222 | 'Κ': 'K',
223 | 'Λ': 'L',
224 | 'Μ': 'M',
225 | 'Ν': 'N',
226 | 'Ξ': 'Ks',
227 | 'Ο': 'O',
228 | 'Π': 'P',
229 | 'Ρ': 'R',
230 | 'Σ': 'S',
231 | 'Τ': 'T',
232 | 'Υ': 'U',
233 | 'Φ': 'Ph',
234 | 'Χ': 'Kh',
235 | 'Ψ': 'Ps',
236 | 'Ω': 'O',
237 | 'α': 'a',
238 | 'β': 'b',
239 | 'γ': 'g',
240 | 'δ': 'd',
241 | 'ε': 'e',
242 | 'ζ': 'z',
243 | 'η': 'e',
244 | 'θ': 'th',
245 | 'ι': 'i',
246 | 'κ': 'k',
247 | 'λ': 'l',
248 | 'μ': 'm',
249 | 'ν': 'n',
250 | 'ξ': 'x',
251 | 'ο': 'o',
252 | 'π': 'p',
253 | 'ρ': 'r',
254 | 'ς': 's',
255 | 'σ': 's',
256 | 'τ': 't',
257 | 'υ': 'u',
258 | 'φ': 'ph',
259 | 'χ': 'kh',
260 | 'ψ': 'ps',
261 | 'ω': 'o',
262 | 'А': 'A',
263 | 'Б': 'B',
264 | 'В': 'V',
265 | 'Г': 'G',
266 | 'Д': 'D',
267 | 'Е': 'E',
268 | 'Ж': 'Zh',
269 | 'З': 'Z',
270 | 'И': 'I',
271 | 'Й': 'I',
272 | 'К': 'K',
273 | 'Л': 'L',
274 | 'М': 'M',
275 | 'Н': 'N',
276 | 'О': 'O',
277 | 'П': 'P',
278 | 'Р': 'R',
279 | 'С': 'S',
280 | 'Т': 'T',
281 | 'У': 'U',
282 | 'Ф': 'F',
283 | 'Х': 'Kh',
284 | 'Ц': 'Ts',
285 | 'Ч': 'Ch',
286 | 'Ш': 'Sh',
287 | 'Щ': 'Shch',
288 | 'Ъ': "'",
289 | 'Ы': 'Y',
290 | 'Ь': "'",
291 | 'Э': 'E',
292 | 'Ю': 'Iu',
293 | 'Я': 'Ia',
294 | 'а': 'a',
295 | 'б': 'b',
296 | 'в': 'v',
297 | 'г': 'g',
298 | 'д': 'd',
299 | 'е': 'e',
300 | 'ж': 'zh',
301 | 'з': 'z',
302 | 'и': 'i',
303 | 'й': 'i',
304 | 'к': 'k',
305 | 'л': 'l',
306 | 'м': 'm',
307 | 'н': 'n',
308 | 'о': 'o',
309 | 'п': 'p',
310 | 'р': 'r',
311 | 'с': 's',
312 | 'т': 't',
313 | 'у': 'u',
314 | 'ф': 'f',
315 | 'х': 'kh',
316 | 'ц': 'ts',
317 | 'ч': 'ch',
318 | 'ш': 'sh',
319 | 'щ': 'shch',
320 | 'ъ': "'",
321 | 'ы': 'y',
322 | 'ь': "'",
323 | 'э': 'e',
324 | 'ю': 'iu',
325 | 'я': 'ia',
326 | # 'ᴀ': '',
327 | # 'ᴁ': '',
328 | # 'ᴂ': '',
329 | # 'ᴃ': '',
330 | # 'ᴄ': '',
331 | # 'ᴅ': '',
332 | # 'ᴆ': '',
333 | # 'ᴇ': '',
334 | # 'ᴈ': '',
335 | # 'ᴉ': '',
336 | # 'ᴊ': '',
337 | # 'ᴋ': '',
338 | # 'ᴌ': '',
339 | # 'ᴍ': '',
340 | # 'ᴎ': '',
341 | # 'ᴏ': '',
342 | # 'ᴐ': '',
343 | # 'ᴑ': '',
344 | # 'ᴒ': '',
345 | # 'ᴓ': '',
346 | # 'ᴔ': '',
347 | # 'ᴕ': '',
348 | # 'ᴖ': '',
349 | # 'ᴗ': '',
350 | # 'ᴘ': '',
351 | # 'ᴙ': '',
352 | # 'ᴚ': '',
353 | # 'ᴛ': '',
354 | # 'ᴜ': '',
355 | # 'ᴝ': '',
356 | # 'ᴞ': '',
357 | # 'ᴟ': '',
358 | # 'ᴠ': '',
359 | # 'ᴡ': '',
360 | # 'ᴢ': '',
361 | # 'ᴣ': '',
362 | # 'ᴤ': '',
363 | # 'ᴥ': '',
364 | 'ᴦ': 'G',
365 | 'ᴧ': 'L',
366 | 'ᴨ': 'P',
367 | 'ᴩ': 'R',
368 | 'ᴪ': 'PS',
369 | 'ẞ': 'Ss',
370 | 'Ỳ': 'Y',
371 | 'ỳ': 'y',
372 | 'Ỵ': 'Y',
373 | 'ỵ': 'y',
374 | 'Ỹ': 'Y',
375 | 'ỹ': 'y',
376 | }
377 |
378 | ####################################################################
379 | # Smart-to-dumb punctuation mapping
380 | ####################################################################
381 |
382 | DUMB_PUNCTUATION = {
383 | '‘': "'",
384 | '’': "'",
385 | '‚': "'",
386 | '“': '"',
387 | '”': '"',
388 | '„': '"',
389 | '–': '-',
390 | '—': '-'
391 | }
392 |
393 |
394 | ####################################################################
395 | # Used by `Workflow.filter`
396 | ####################################################################
397 |
398 | # Anchor characters in a name
399 | #: Characters that indicate the beginning of a "word" in CamelCase
400 | INITIALS = string.ascii_uppercase + string.digits
401 |
402 | #: Split on non-letters, numbers
403 | split_on_delimiters = re.compile('[^a-zA-Z0-9]').split
404 |
405 | # Match filter flags
406 | #: Match items that start with ``query``
407 | MATCH_STARTSWITH = 1
408 | #: Match items whose capital letters start with ``query``
409 | MATCH_CAPITALS = 2
410 | #: Match items with a component "word" that matches ``query``
411 | MATCH_ATOM = 4
412 | #: Match items whose initials (based on atoms) start with ``query``
413 | MATCH_INITIALS_STARTSWITH = 8
414 | #: Match items whose initials (based on atoms) contain ``query``
415 | MATCH_INITIALS_CONTAIN = 16
416 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and
417 | #: :const:`MATCH_INITIALS_CONTAIN`
418 | MATCH_INITIALS = 24
419 | #: Match items if ``query`` is a substring
420 | MATCH_SUBSTRING = 32
421 | #: Match items if all characters in ``query`` appear in the item in order
422 | MATCH_ALLCHARS = 64
423 | #: Combination of all other ``MATCH_*`` constants
424 | MATCH_ALL = 127
425 |
426 |
427 | ####################################################################
428 | # Used by `Workflow.check_update`
429 | ####################################################################
430 |
431 | # Number of days to wait between checking for updates to the workflow
432 | DEFAULT_UPDATE_FREQUENCY = 1
433 |
434 |
435 | ####################################################################
436 | # Keychain access errors
437 | ####################################################################
438 |
439 | class KeychainError(Exception):
440 | """Raised by methods :meth:`Workflow.save_password`,
441 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
442 | when ``security`` CLI app returns an unknown error code.
443 |
444 | """
445 |
446 |
447 | class PasswordNotFound(KeychainError):
448 | """Raised by method :meth:`Workflow.get_password` when ``account``
449 | is unknown to the Keychain.
450 |
451 | """
452 |
453 |
454 | class PasswordExists(KeychainError):
455 | """Raised when trying to overwrite an existing account password.
456 |
457 | You should never receive this error: it is used internally
458 | by the :meth:`Workflow.save_password` method to know if it needs
459 | to delete the old password first (a Keychain implementation detail).
460 |
461 | """
462 |
463 |
464 | ####################################################################
465 | # Helper functions
466 | ####################################################################
467 |
468 | def isascii(text):
469 | """Test if ``text`` contains only ASCII characters
470 |
471 | :param text: text to test for ASCII-ness
472 | :type text: ``unicode``
473 | :returns: ``True`` if ``text`` contains only ASCII characters
474 | :rtype: ``Boolean``
475 | """
476 |
477 | try:
478 | text.encode('ascii')
479 | except UnicodeEncodeError:
480 | return False
481 | return True
482 |
483 |
484 | ####################################################################
485 | # Implementation classes
486 | ####################################################################
487 |
488 | class SerializerManager(object):
489 | """Contains registered serializers.
490 |
491 | .. versionadded:: 1.8
492 |
493 | A configured instance of this class is available at
494 | ``workflow.manager``.
495 |
496 | Use :meth:`register()` to register new (or replace
497 | existing) serializers, which you can specify by name when calling
498 | :class:`Workflow` data storage methods.
499 |
500 | See :ref:`manual-serialization` and :ref:`manual-persistent-data`
501 | for further information.
502 |
503 | """
504 |
505 | def __init__(self):
506 | self._serializers = {}
507 |
508 | def register(self, name, serializer):
509 | """Register ``serializer`` object under ``name``.
510 |
511 | Raises :class:`AttributeError` if ``serializer`` in invalid.
512 |
513 | .. note::
514 |
515 | ``name`` will be used as the file extension of the saved files.
516 |
517 | :param name: Name to register ``serializer`` under
518 | :type name: ``unicode`` or ``str``
519 | :param serializer: object with ``load()`` and ``dump()``
520 | methods
521 |
522 | """
523 |
524 | # Basic validation
525 | getattr(serializer, 'load')
526 | getattr(serializer, 'dump')
527 |
528 | self._serializers[name] = serializer
529 |
530 | def serializer(self, name):
531 | """Return serializer object for ``name`` or ``None`` if no such
532 | serializer is registered
533 |
534 | :param name: Name of serializer to return
535 | :type name: ``unicode`` or ``str``
536 | :returns: serializer object or ``None``
537 |
538 | """
539 |
540 | return self._serializers.get(name)
541 |
542 | def unregister(self, name):
543 | """Remove registered serializer with ``name``
544 |
545 | Raises a :class:`ValueError` if there is no such registered
546 | serializer.
547 |
548 | :param name: Name of serializer to remove
549 | :type name: ``unicode`` or ``str``
550 | :returns: serializer object
551 |
552 | """
553 |
554 | if name not in self._serializers:
555 | raise ValueError('No such serializer registered : {0}'.format(name))
556 |
557 | serializer = self._serializers[name]
558 | del self._serializers[name]
559 |
560 | return serializer
561 |
562 | @property
563 | def serializers(self):
564 | """Return names of registered serializers"""
565 | return sorted(self._serializers.keys())
566 |
567 |
568 | class JSONSerializer(object):
569 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.
570 |
571 | .. versionadded:: 1.8
572 |
573 | Use this serializer if you need readable data files. JSON doesn't
574 | support Python objects as well as ``cPickle``/``pickle``, so be
575 | careful which data you try to serialize as JSON.
576 |
577 | """
578 |
579 | @classmethod
580 | def load(cls, file_obj):
581 | """Load serialized object from open JSON file.
582 |
583 | .. versionadded:: 1.8
584 |
585 | :param file_obj: file handle
586 | :type file_obj: ``file`` object
587 | :returns: object loaded from JSON file
588 | :rtype: object
589 |
590 | """
591 |
592 | return json.load(file_obj)
593 |
594 | @classmethod
595 | def dump(cls, obj, file_obj):
596 | """Serialize object ``obj`` to open JSON file.
597 |
598 | .. versionadded:: 1.8
599 |
600 | :param obj: Python object to serialize
601 | :type obj: JSON-serializable data structure
602 | :param file_obj: file handle
603 | :type file_obj: ``file`` object
604 |
605 | """
606 |
607 | return json.dump(obj, file_obj, indent=2, encoding='utf-8')
608 |
609 |
610 | class CPickleSerializer(object):
611 | """Wrapper around :mod:`cPickle`. Sets ``protocol``.
612 |
613 | .. versionadded:: 1.8
614 |
615 | This is the default serializer and the best combination of speed and
616 | flexibility.
617 |
618 | """
619 |
620 | @classmethod
621 | def load(cls, file_obj):
622 | """Load serialized object from open pickle file.
623 |
624 | .. versionadded:: 1.8
625 |
626 | :param file_obj: file handle
627 | :type file_obj: ``file`` object
628 | :returns: object loaded from pickle file
629 | :rtype: object
630 |
631 | """
632 |
633 | return cPickle.load(file_obj)
634 |
635 | @classmethod
636 | def dump(cls, obj, file_obj):
637 | """Serialize object ``obj`` to open pickle file.
638 |
639 | .. versionadded:: 1.8
640 |
641 | :param obj: Python object to serialize
642 | :type obj: Python object
643 | :param file_obj: file handle
644 | :type file_obj: ``file`` object
645 |
646 | """
647 |
648 | return cPickle.dump(obj, file_obj, protocol=-1)
649 |
650 |
651 | class PickleSerializer(object):
652 | """Wrapper around :mod:`pickle`. Sets ``protocol``.
653 |
654 | .. versionadded:: 1.8
655 |
656 | Use this serializer if you need to add custom pickling.
657 |
658 | """
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 |
673 | return pickle.load(file_obj)
674 |
675 | @classmethod
676 | def dump(cls, obj, file_obj):
677 | """Serialize object ``obj`` to open pickle file.
678 |
679 | .. versionadded:: 1.8
680 |
681 | :param obj: Python object to serialize
682 | :type obj: Python object
683 | :param file_obj: file handle
684 | :type file_obj: ``file`` object
685 |
686 | """
687 |
688 | return pickle.dump(obj, file_obj, protocol=-1)
689 |
690 |
691 | # Set up default manager and register built-in serializers
692 | manager = SerializerManager()
693 | manager.register('cpickle', CPickleSerializer)
694 | manager.register('pickle', PickleSerializer)
695 | manager.register('json', JSONSerializer)
696 |
697 |
698 | class Item(object):
699 | """Represents a feedback item for Alfred. Generates Alfred-compliant
700 | XML for a single item.
701 |
702 | You probably shouldn't use this class directly, but via
703 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item`
704 | for details of arguments.
705 |
706 | """
707 |
708 | def __init__(self, title, subtitle='', modifier_subtitles=None,
709 | arg=None, autocomplete=None, valid=False, uid=None,
710 | icon=None, icontype=None, type=None, largetext=None,
711 | copytext=None):
712 | """Arguments the same as for :meth:`Workflow.add_item`.
713 |
714 | """
715 |
716 | self.title = title
717 | self.subtitle = subtitle
718 | self.modifier_subtitles = modifier_subtitles or {}
719 | self.arg = arg
720 | self.autocomplete = autocomplete
721 | self.valid = valid
722 | self.uid = uid
723 | self.icon = icon
724 | self.icontype = icontype
725 | self.type = type
726 | self.largetext = largetext
727 | self.copytext = copytext
728 |
729 | @property
730 | def elem(self):
731 | """Create and return feedback item for Alfred.
732 |
733 | :returns: :class:`ElementTree.Element `
734 | instance for this :class:`Item` instance.
735 |
736 | """
737 |
738 | # Attributes on
- element
739 | attr = {}
740 | if self.valid:
741 | attr['valid'] = 'yes'
742 | else:
743 | attr['valid'] = 'no'
744 | # Allow empty string for autocomplete. This is a useful value,
745 | # as TABing the result will revert the query back to just the
746 | # keyword
747 | if self.autocomplete is not None:
748 | attr['autocomplete'] = self.autocomplete
749 |
750 | # Optional attributes
751 | for name in ('uid', 'type'):
752 | value = getattr(self, name, None)
753 | if value:
754 | attr[name] = value
755 |
756 | root = ET.Element('item', attr)
757 | ET.SubElement(root, 'title').text = self.title
758 | ET.SubElement(root, 'subtitle').text = self.subtitle
759 |
760 | # Add modifier subtitles
761 | for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'):
762 | if mod in self.modifier_subtitles:
763 | ET.SubElement(root, 'subtitle',
764 | {'mod': mod}).text = self.modifier_subtitles[mod]
765 |
766 | # Add arg as element instead of attribute on
- , as it's more
767 | # flexible (newlines aren't allowed in attributes)
768 | if self.arg:
769 | ET.SubElement(root, 'arg').text = self.arg
770 |
771 | # Add icon if there is one
772 | if self.icon:
773 | if self.icontype:
774 | attr = dict(type=self.icontype)
775 | else:
776 | attr = {}
777 | ET.SubElement(root, 'icon', attr).text = self.icon
778 |
779 | if self.largetext:
780 | ET.SubElement(root, 'text',
781 | {'type': 'largetype'}).text = self.largetext
782 |
783 | if self.copytext:
784 | ET.SubElement(root, 'text',
785 | {'type': 'copy'}).text = self.copytext
786 |
787 | return root
788 |
789 |
790 | class Settings(dict):
791 | """A dictionary that saves itself when changed.
792 |
793 | Dictionary keys & values will be saved as a JSON file
794 | at ``filepath``. If the file does not exist, the dictionary
795 | (and settings file) will be initialised with ``defaults``.
796 |
797 | :param filepath: where to save the settings
798 | :type filepath: :class:`unicode`
799 | :param defaults: dict of default settings
800 | :type defaults: :class:`dict`
801 |
802 |
803 | An appropriate instance is provided by :class:`Workflow` instances at
804 | :attr:`Workflow.settings`.
805 |
806 | """
807 |
808 | def __init__(self, filepath, defaults=None):
809 |
810 | super(Settings, self).__init__()
811 | self._filepath = filepath
812 | self._nosave = False
813 | if os.path.exists(self._filepath):
814 | self._load()
815 | elif defaults:
816 | for key, val in defaults.items():
817 | self[key] = val
818 | self.save() # save default settings
819 |
820 | def _load(self):
821 | """Load cached settings from JSON file `self._filepath`"""
822 |
823 | self._nosave = True
824 | with open(self._filepath, 'rb') as file_obj:
825 | for key, value in json.load(file_obj, encoding='utf-8').items():
826 | self[key] = value
827 | self._nosave = False
828 |
829 | def save(self):
830 | """Save settings to JSON file specified in ``self._filepath``
831 |
832 | If you're using this class via :attr:`Workflow.settings`, which
833 | you probably are, ``self._filepath`` will be ``settings.json``
834 | in your workflow's data directory (see :attr:`~Workflow.datadir`).
835 | """
836 | if self._nosave:
837 | return
838 | data = {}
839 | for key, value in self.items():
840 | data[key] = value
841 | with open(self._filepath, 'wb') as file_obj:
842 | json.dump(data, file_obj, sort_keys=True, indent=2,
843 | encoding='utf-8')
844 |
845 | # dict methods
846 | def __setitem__(self, key, value):
847 | super(Settings, self).__setitem__(key, value)
848 | self.save()
849 |
850 | def __delitem__(self, key):
851 | super(Settings, self).__delitem__(key)
852 | self.save()
853 |
854 | def update(self, *args, **kwargs):
855 | """Override :class:`dict` method to save on update."""
856 | super(Settings, self).update(*args, **kwargs)
857 | self.save()
858 |
859 | def setdefault(self, key, value=None):
860 | """Override :class:`dict` method to save on update."""
861 | ret = super(Settings, self).setdefault(key, value)
862 | self.save()
863 | return ret
864 |
865 |
866 | class Workflow(object):
867 | """Create new :class:`Workflow` instance.
868 |
869 | :param default_settings: default workflow settings. If no settings file
870 | exists, :class:`Workflow.settings` will be pre-populated with
871 | ``default_settings``.
872 | :type default_settings: :class:`dict`
873 | :param update_settings: settings for updating your workflow from GitHub.
874 | This must be a :class:`dict` that contains ``github_slug`` and
875 | ``version`` keys. ``github_slug`` is of the form ``username/repo``
876 | and ``version`` **must** correspond to the tag of a release.
877 | See :ref:`updates` for more information.
878 | :type update_settings: :class:`dict`
879 | :param input_encoding: encoding of command line arguments
880 | :type input_encoding: :class:`unicode`
881 | :param normalization: normalisation to apply to CLI args.
882 | See :meth:`Workflow.decode` for more details.
883 | :type normalization: :class:`unicode`
884 | :param capture_args: capture and act on ``workflow:*`` arguments. See
885 | :ref:`Magic arguments ` for details.
886 | :type capture_args: :class:`Boolean`
887 | :param libraries: sequence of paths to directories containing
888 | libraries. These paths will be prepended to ``sys.path``.
889 | :type libraries: :class:`tuple` or :class:`list`
890 | :param help_url: URL to webpage where a user can ask for help with
891 | the workflow, report bugs, etc. This could be the GitHub repo
892 | or a page on AlfredForum.com. If your workflow throws an error,
893 | this URL will be displayed in the log and Alfred's debugger. It can
894 | also be opened directly in a web browser with the ``workflow:help``
895 | :ref:`magic argument `.
896 | :type help_url: :class:`unicode` or :class:`str`
897 |
898 | """
899 |
900 | # Which class to use to generate feedback items. You probably
901 | # won't want to change this
902 | item_class = Item
903 |
904 | def __init__(self, default_settings=None, update_settings=None,
905 | input_encoding='utf-8', normalization='NFC',
906 | capture_args=True, libraries=None,
907 | help_url=None):
908 |
909 | self._default_settings = default_settings or {}
910 | self._update_settings = update_settings or {}
911 | self._input_encoding = input_encoding
912 | self._normalizsation = normalization
913 | self._capture_args = capture_args
914 | self.help_url = help_url
915 | self._workflowdir = None
916 | self._settings_path = None
917 | self._settings = None
918 | self._bundleid = None
919 | self._name = None
920 | self._cache_serializer = 'cpickle'
921 | self._data_serializer = 'cpickle'
922 | # info.plist should be in the directory above this one
923 | self._info_plist = self.workflowfile('info.plist')
924 | self._info = None
925 | self._info_loaded = False
926 | self._logger = None
927 | self._items = []
928 | self._alfred_env = None
929 | # Version number of the workflow
930 | self._version = UNSET
931 | # Version from last workflow run
932 | self._last_version_run = UNSET
933 | # Cache for regex patterns created for filter keys
934 | self._search_pattern_cache = {}
935 | # Magic arguments
936 | #: The prefix for all magic arguments. Default is ``workflow:``
937 | self.magic_prefix = 'workflow:'
938 | #: Mapping of available magic arguments. The built-in magic
939 | #: arguments are registered by default. To add your own magic arguments
940 | #: (or override built-ins), add a key:value pair where the key is
941 | #: what the user should enter (prefixed with :attr:`magic_prefix`)
942 | #: and the value is a callable that will be called when the argument
943 | #: is entered. If you would like to display a message in Alfred, the
944 | #: function should return a ``unicode`` string.
945 | #:
946 | #: By default, the magic arguments documented
947 | #: :ref:`here ` are registered.
948 | self.magic_arguments = {}
949 |
950 | self._register_default_magic()
951 |
952 | if libraries:
953 | sys.path = libraries + sys.path
954 |
955 | ####################################################################
956 | # API methods
957 | ####################################################################
958 |
959 | # info.plist contents and alfred_* environment variables ----------
960 |
961 | @property
962 | def alfred_env(self):
963 | """Alfred's environmental variables minus the ``alfred_`` prefix.
964 |
965 | .. versionadded:: 1.7
966 |
967 | The variables Alfred 2.4+ exports are:
968 |
969 | ============================ =========================================
970 | Variable Description
971 | ============================ =========================================
972 | alfred_preferences Path to Alfred.alfredpreferences
973 | (where your workflows and settings are
974 | stored).
975 | alfred_preferences_localhash Machine-specific preferences are stored
976 | in ``Alfred.alfredpreferences/preferences/local/``
977 | (see ``alfred_preferences`` above for
978 | the path to ``Alfred.alfredpreferences``)
979 | alfred_theme ID of selected theme
980 | alfred_theme_background Background colour of selected theme in
981 | format ``rgba(r,g,b,a)``
982 | alfred_theme_subtext Show result subtext.
983 | ``0`` = Always,
984 | ``1`` = Alternative actions only,
985 | ``2`` = Selected result only,
986 | ``3`` = Never
987 | alfred_version Alfred version number, e.g. ``'2.4'``
988 | alfred_version_build Alfred build number, e.g. ``277``
989 | alfred_workflow_bundleid Bundle ID, e.g.
990 | ``net.deanishe.alfred-mailto``
991 | alfred_workflow_cache Path to workflow's cache directory
992 | alfred_workflow_data Path to workflow's data directory
993 | alfred_workflow_name Name of current workflow
994 | alfred_workflow_uid UID of workflow
995 | ============================ =========================================
996 |
997 | **Note:** all values are Unicode strings except ``version_build`` and
998 | ``theme_subtext``, which are integers.
999 |
1000 | :returns: ``dict`` of Alfred's environmental variables without the
1001 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``.
1002 |
1003 | """
1004 |
1005 | if self._alfred_env is not None:
1006 | return self._alfred_env
1007 |
1008 | data = {}
1009 |
1010 | for key in (
1011 | 'alfred_preferences',
1012 | 'alfred_preferences_localhash',
1013 | 'alfred_theme',
1014 | 'alfred_theme_background',
1015 | 'alfred_theme_subtext',
1016 | 'alfred_version',
1017 | 'alfred_version_build',
1018 | 'alfred_workflow_bundleid',
1019 | 'alfred_workflow_cache',
1020 | 'alfred_workflow_data',
1021 | 'alfred_workflow_name',
1022 | 'alfred_workflow_uid'):
1023 |
1024 | value = os.getenv(key)
1025 |
1026 | if isinstance(value, str):
1027 | if key in ('alfred_version_build', 'alfred_theme_subtext'):
1028 | value = int(value)
1029 | else:
1030 | value = self.decode(value)
1031 |
1032 | data[key[7:]] = value
1033 |
1034 | self._alfred_env = data
1035 |
1036 | return self._alfred_env
1037 |
1038 | @property
1039 | def info(self):
1040 | """:class:`dict` of ``info.plist`` contents."""
1041 |
1042 | if not self._info_loaded:
1043 | self._load_info_plist()
1044 | return self._info
1045 |
1046 | @property
1047 | def bundleid(self):
1048 | """Workflow bundle ID from environmental vars or ``info.plist``.
1049 |
1050 | :returns: bundle ID
1051 | :rtype: ``unicode``
1052 |
1053 | """
1054 |
1055 | if not self._bundleid:
1056 | if self.alfred_env.get('workflow_bundleid'):
1057 | self._bundleid = self.alfred_env.get('workflow_bundleid')
1058 | else:
1059 | self._bundleid = unicode(self.info['bundleid'], 'utf-8')
1060 |
1061 | return self._bundleid
1062 |
1063 | @property
1064 | def name(self):
1065 | """Workflow name from Alfred's environmental vars or ``info.plist``.
1066 |
1067 | :returns: workflow name
1068 | :rtype: ``unicode``
1069 |
1070 | """
1071 |
1072 | if not self._name:
1073 | if self.alfred_env.get('workflow_name'):
1074 | self._name = self.decode(self.alfred_env.get('workflow_name'))
1075 | else:
1076 | self._name = self.decode(self.info['name'])
1077 |
1078 | return self._name
1079 |
1080 | @property
1081 | def version(self):
1082 | """Return the version of the workflow
1083 |
1084 | .. versionadded:: 1.9.10
1085 |
1086 | Get the version from the ``update_settings`` dict passed on
1087 | instantiation or the ``version`` file located in the workflow's
1088 | root directory. Return ``None`` if neither exist or
1089 | :class:`ValueError` if the version number is invalid (i.e. not
1090 | semantic).
1091 |
1092 | :returns: Version of the workflow (not Alfred-Workflow)
1093 | :rtype: :class:`~workflow.update.Version` object
1094 |
1095 | """
1096 |
1097 | if self._version is UNSET:
1098 |
1099 | version = None
1100 | # First check `update_settings`
1101 | if self._update_settings:
1102 | version = self._update_settings.get('version')
1103 |
1104 | # Fallback to `version` file
1105 | if not version:
1106 | filepath = self.workflowfile('version')
1107 |
1108 | if os.path.exists(filepath):
1109 | with open(filepath, 'rb') as fileobj:
1110 | version = fileobj.read()
1111 |
1112 | if version:
1113 | from update import Version
1114 | version = Version(version)
1115 |
1116 | self._version = version
1117 |
1118 | return self._version
1119 |
1120 | # Workflow utility methods -----------------------------------------
1121 |
1122 | @property
1123 | def args(self):
1124 | """Return command line args as normalised unicode.
1125 |
1126 | Args are decoded and normalised via :meth:`~Workflow.decode`.
1127 |
1128 | The encoding and normalisation are the ``input_encoding`` and
1129 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
1130 | and ``NFC`` are the defaults).
1131 |
1132 | If :class:`Workflow` is called with ``capture_args=True``
1133 | (the default), :class:`Workflow` will look for certain
1134 | ``workflow:*`` args and, if found, perform the corresponding
1135 | actions and exit the workflow.
1136 |
1137 | See :ref:`Magic arguments ` for details.
1138 |
1139 | """
1140 |
1141 | msg = None
1142 | args = [self.decode(arg) for arg in sys.argv[1:]]
1143 |
1144 | # Handle magic args
1145 | if len(args) and self._capture_args:
1146 | for name in self.magic_arguments:
1147 | key = '{0}{1}'.format(self.magic_prefix, name)
1148 | if key in args:
1149 | msg = self.magic_arguments[name]()
1150 |
1151 | if msg:
1152 | self.logger.debug(msg)
1153 | if not sys.stdout.isatty(): # Show message in Alfred
1154 | self.add_item(msg, valid=False, icon=ICON_INFO)
1155 | self.send_feedback()
1156 | sys.exit(0)
1157 | return args
1158 |
1159 | @property
1160 | def cachedir(self):
1161 | """Path to workflow's cache directory.
1162 |
1163 | The cache directory is a subdirectory of Alfred's own cache directory in
1164 | ``~/Library/Caches``. The full path is:
1165 |
1166 | ``~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/``
1167 |
1168 | :returns: full path to workflow's cache directory
1169 | :rtype: ``unicode``
1170 |
1171 | """
1172 |
1173 | if self.alfred_env.get('workflow_cache'):
1174 | dirpath = self.alfred_env.get('workflow_cache')
1175 |
1176 | else:
1177 | dirpath = os.path.join(
1178 | os.path.expanduser(
1179 | '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
1180 | 'Workflow Data/'),
1181 | self.bundleid)
1182 |
1183 | return self._create(dirpath)
1184 |
1185 | @property
1186 | def datadir(self):
1187 | """Path to workflow's data directory.
1188 |
1189 | The data directory is a subdirectory of Alfred's own data directory in
1190 | ``~/Library/Application Support``. The full path is:
1191 |
1192 | ``~/Library/Application Support/Alfred 2/Workflow Data/``
1193 |
1194 | :returns: full path to workflow data directory
1195 | :rtype: ``unicode``
1196 |
1197 | """
1198 |
1199 | if self.alfred_env.get('workflow_data'):
1200 | dirpath = self.alfred_env.get('workflow_data')
1201 |
1202 | else:
1203 | dirpath = os.path.join(os.path.expanduser(
1204 | '~/Library/Application Support/Alfred 2/Workflow Data/'),
1205 | self.bundleid)
1206 |
1207 | return self._create(dirpath)
1208 |
1209 | @property
1210 | def workflowdir(self):
1211 | """Path to workflow's root directory (where ``info.plist`` is).
1212 |
1213 | :returns: full path to workflow root directory
1214 | :rtype: ``unicode``
1215 |
1216 | """
1217 |
1218 | if not self._workflowdir:
1219 | # Try the working directory first, then the directory
1220 | # the library is in. CWD will be the workflow root if
1221 | # a workflow is being run in Alfred
1222 | candidates = [
1223 | os.path.abspath(os.getcwdu()),
1224 | os.path.dirname(os.path.abspath(os.path.dirname(__file__)))]
1225 |
1226 | # climb the directory tree until we find `info.plist`
1227 | for dirpath in candidates:
1228 |
1229 | # Ensure directory path is Unicode
1230 | dirpath = self.decode(dirpath)
1231 |
1232 | while True:
1233 | if os.path.exists(os.path.join(dirpath, 'info.plist')):
1234 | self._workflowdir = dirpath
1235 | break
1236 |
1237 | elif dirpath == '/':
1238 | # no `info.plist` found
1239 | break
1240 |
1241 | # Check the parent directory
1242 | dirpath = os.path.dirname(dirpath)
1243 |
1244 | # No need to check other candidates
1245 | if self._workflowdir:
1246 | break
1247 |
1248 | if not self._workflowdir:
1249 | raise IOError("'info.plist' not found in directory tree")
1250 |
1251 | return self._workflowdir
1252 |
1253 | def cachefile(self, filename):
1254 | """Return full path to ``filename`` within your workflow's
1255 | :attr:`cache directory `.
1256 |
1257 | :param filename: basename of file
1258 | :type filename: ``unicode``
1259 | :returns: full path to file within cache directory
1260 | :rtype: ``unicode``
1261 |
1262 | """
1263 |
1264 | return os.path.join(self.cachedir, filename)
1265 |
1266 | def datafile(self, filename):
1267 | """Return full path to ``filename`` within your workflow's
1268 | :attr:`data directory `.
1269 |
1270 | :param filename: basename of file
1271 | :type filename: ``unicode``
1272 | :returns: full path to file within data directory
1273 | :rtype: ``unicode``
1274 |
1275 | """
1276 |
1277 | return os.path.join(self.datadir, filename)
1278 |
1279 | def workflowfile(self, filename):
1280 | """Return full path to ``filename`` in workflow's root dir
1281 | (where ``info.plist`` is).
1282 |
1283 | :param filename: basename of file
1284 | :type filename: ``unicode``
1285 | :returns: full path to file within data directory
1286 | :rtype: ``unicode``
1287 |
1288 | """
1289 |
1290 | return os.path.join(self.workflowdir, filename)
1291 |
1292 | @property
1293 | def logfile(self):
1294 | """Return path to logfile
1295 |
1296 | :returns: path to logfile within workflow's cache directory
1297 | :rtype: ``unicode``
1298 |
1299 | """
1300 |
1301 | return self.cachefile('%s.log' % self.bundleid)
1302 |
1303 | @property
1304 | def logger(self):
1305 | """Create and return a logger that logs to both console and
1306 | a log file.
1307 |
1308 | Use :meth:`open_log` to open the log file in Console.
1309 |
1310 | :returns: an initialised :class:`~logging.Logger`
1311 |
1312 | """
1313 |
1314 | if self._logger:
1315 | return self._logger
1316 |
1317 | # Initialise new logger and optionally handlers
1318 | logger = logging.getLogger('workflow')
1319 |
1320 | if not len(logger.handlers): # Only add one set of handlers
1321 | logfile = logging.handlers.RotatingFileHandler(
1322 | self.logfile,
1323 | maxBytes=1024*1024,
1324 | backupCount=0)
1325 |
1326 | console = logging.StreamHandler()
1327 |
1328 | fmt = logging.Formatter(
1329 | '%(asctime)s %(filename)s:%(lineno)s'
1330 | ' %(levelname)-8s %(message)s',
1331 | datefmt='%H:%M:%S')
1332 |
1333 | logfile.setFormatter(fmt)
1334 | console.setFormatter(fmt)
1335 |
1336 | logger.addHandler(logfile)
1337 | logger.addHandler(console)
1338 |
1339 | logger.setLevel(logging.DEBUG)
1340 | self._logger = logger
1341 |
1342 | return self._logger
1343 |
1344 | @logger.setter
1345 | def logger(self, logger):
1346 | """Set a custom logger.
1347 |
1348 | :param logger: The logger to use
1349 | :type logger: `~logging.Logger` instance
1350 |
1351 | """
1352 |
1353 | self._logger = logger
1354 |
1355 | @property
1356 | def settings_path(self):
1357 | """Path to settings file within workflow's data directory.
1358 |
1359 | :returns: path to ``settings.json`` file
1360 | :rtype: ``unicode``
1361 |
1362 | """
1363 |
1364 | if not self._settings_path:
1365 | self._settings_path = self.datafile('settings.json')
1366 | return self._settings_path
1367 |
1368 | @property
1369 | def settings(self):
1370 | """Return a dictionary subclass that saves itself when changed.
1371 |
1372 | See :ref:`manual-settings` in the :ref:`user-manual` for more
1373 | information on how to use :attr:`settings` and **important
1374 | limitations** on what it can do.
1375 |
1376 | :returns: :class:`~workflow.workflow.Settings` instance
1377 | initialised from the data in JSON file at
1378 | :attr:`settings_path` or if that doesn't exist, with the
1379 | ``default_settings`` :class:`dict` passed to
1380 | :class:`Workflow` on instantiation.
1381 | :rtype: :class:`~workflow.workflow.Settings` instance
1382 |
1383 | """
1384 |
1385 | if not self._settings:
1386 | self.logger.debug('Reading settings from `{0}` ...'.format(
1387 | self.settings_path))
1388 | self._settings = Settings(self.settings_path,
1389 | self._default_settings)
1390 | return self._settings
1391 |
1392 | @property
1393 | def cache_serializer(self):
1394 | """Name of default cache serializer.
1395 |
1396 | .. versionadded:: 1.8
1397 |
1398 | This serializer is used by :meth:`cache_data()` and
1399 | :meth:`cached_data()`
1400 |
1401 | See :class:`SerializerManager` for details.
1402 |
1403 | :returns: serializer name
1404 | :rtype: ``unicode``
1405 |
1406 | """
1407 |
1408 | return self._cache_serializer
1409 |
1410 | @cache_serializer.setter
1411 | def cache_serializer(self, serializer_name):
1412 | """Set the default cache serialization format.
1413 |
1414 | .. versionadded:: 1.8
1415 |
1416 | This serializer is used by :meth:`cache_data()` and
1417 | :meth:`cached_data()`
1418 |
1419 | The specified serializer must already by registered with the
1420 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1421 | otherwise a :class:`ValueError` will be raised.
1422 |
1423 | :param serializer_name: Name of default serializer to use.
1424 | :type serializer_name:
1425 |
1426 | """
1427 |
1428 | if manager.serializer(serializer_name) is None:
1429 | raise ValueError(
1430 | 'Unknown serializer : `{0}`. Register your serializer '
1431 | 'with `manager` first.'.format(serializer_name))
1432 |
1433 | self.logger.debug(
1434 | 'default cache serializer set to `{0}`'.format(serializer_name))
1435 |
1436 | self._cache_serializer = serializer_name
1437 |
1438 | @property
1439 | def data_serializer(self):
1440 | """Name of default data serializer.
1441 |
1442 | .. versionadded:: 1.8
1443 |
1444 | This serializer is used by :meth:`store_data()` and
1445 | :meth:`stored_data()`
1446 |
1447 | See :class:`SerializerManager` for details.
1448 |
1449 | :returns: serializer name
1450 | :rtype: ``unicode``
1451 |
1452 | """
1453 |
1454 | return self._data_serializer
1455 |
1456 | @data_serializer.setter
1457 | def data_serializer(self, serializer_name):
1458 | """Set the default cache serialization format.
1459 |
1460 | .. versionadded:: 1.8
1461 |
1462 | This serializer is used by :meth:`store_data()` and
1463 | :meth:`stored_data()`
1464 |
1465 | The specified serializer must already by registered with the
1466 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1467 | otherwise a :class:`ValueError` will be raised.
1468 |
1469 | :param serializer_name: Name of serializer to use by default.
1470 |
1471 | """
1472 |
1473 | if manager.serializer(serializer_name) is None:
1474 | raise ValueError(
1475 | 'Unknown serializer : `{0}`. Register your serializer '
1476 | 'with `manager` first.'.format(serializer_name))
1477 |
1478 | self.logger.debug(
1479 | 'default data serializer set to `{0}`'.format(serializer_name))
1480 |
1481 | self._data_serializer = serializer_name
1482 |
1483 | def stored_data(self, name):
1484 | """Retrieve data from data directory. Returns ``None`` if there
1485 | are no data stored.
1486 |
1487 | .. versionadded:: 1.8
1488 |
1489 | :param name: name of datastore
1490 |
1491 | """
1492 |
1493 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
1494 |
1495 | if not os.path.exists(metadata_path):
1496 | self.logger.debug('No data stored for `{0}`'.format(name))
1497 | return None
1498 |
1499 | with open(metadata_path, 'rb') as file_obj:
1500 | serializer_name = file_obj.read().strip()
1501 |
1502 | serializer = manager.serializer(serializer_name)
1503 |
1504 | if serializer is None:
1505 | raise ValueError(
1506 | 'Unknown serializer `{0}`. Register a corresponding serializer '
1507 | 'with `manager.register()` to load this data.'.format(
1508 | serializer_name))
1509 |
1510 | self.logger.debug('Data `{0}` stored in `{1}` format'.format(
1511 | name, serializer_name))
1512 |
1513 | filename = '{0}.{1}'.format(name, serializer_name)
1514 | data_path = self.datafile(filename)
1515 |
1516 | if not os.path.exists(data_path):
1517 | self.logger.debug('No data stored for `{0}`'.format(name))
1518 | if os.path.exists(metadata_path):
1519 | os.unlink(metadata_path)
1520 |
1521 | return None
1522 |
1523 | with open(data_path, 'rb') as file_obj:
1524 | data = serializer.load(file_obj)
1525 |
1526 | self.logger.debug('Stored data loaded from : {0}'.format(data_path))
1527 |
1528 | return data
1529 |
1530 | def store_data(self, name, data, serializer=None):
1531 | """Save data to data directory.
1532 |
1533 | .. versionadded:: 1.8
1534 |
1535 | If ``data`` is ``None``, the datastore will be deleted.
1536 |
1537 | :param name: name of datastore
1538 | :param data: object(s) to store. **Note:** some serializers
1539 | can only handled certain types of data.
1540 | :param serializer: name of serializer to use. If no serializer
1541 | is specified, the default will be used. See
1542 | :class:`SerializerManager` for more information.
1543 | :returns: data in datastore or ``None``
1544 |
1545 | """
1546 |
1547 | serializer_name = serializer or self.data_serializer
1548 |
1549 | # In order for `stored_data()` to be able to load data stored with
1550 | # an arbitrary serializer, yet still have meaningful file extensions,
1551 | # the format (i.e. extension) is saved to an accompanying file
1552 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
1553 | filename = '{0}.{1}'.format(name, serializer_name)
1554 | data_path = self.datafile(filename)
1555 |
1556 | if data_path == self.settings_path:
1557 | raise ValueError(
1558 | 'Cannot save data to' +
1559 | '`{0}` with format `{1}`. '.format(name, serializer_name) +
1560 | "This would overwrite Alfred-Workflow's settings file.")
1561 |
1562 | serializer = manager.serializer(serializer_name)
1563 |
1564 | if serializer is None:
1565 | raise ValueError(
1566 | 'Invalid serializer `{0}`. Register your serializer with '
1567 | '`manager.register()` first.'.format(serializer_name))
1568 |
1569 | if data is None: # Delete cached data
1570 | for path in (metadata_path, data_path):
1571 | if os.path.exists(path):
1572 | os.unlink(path)
1573 | self.logger.debug('Deleted data file : {0}'.format(path))
1574 |
1575 | return
1576 |
1577 | # Save file extension
1578 | with open(metadata_path, 'wb') as file_obj:
1579 | file_obj.write(serializer_name)
1580 |
1581 | with open(data_path, 'wb') as file_obj:
1582 | serializer.dump(data, file_obj)
1583 |
1584 | self.logger.debug('Stored data saved at : {0}'.format(data_path))
1585 |
1586 | def cached_data(self, name, data_func=None, max_age=60):
1587 | """Retrieve data from cache or re-generate and re-cache data if
1588 | stale/non-existant. If ``max_age`` is 0, return cached data no
1589 | matter how old.
1590 |
1591 | :param name: name of datastore
1592 | :param data_func: function to (re-)generate data.
1593 | :type data_func: ``callable``
1594 | :param max_age: maximum age of cached data in seconds
1595 | :type max_age: ``int``
1596 | :returns: cached data, return value of ``data_func`` or ``None``
1597 | if ``data_func`` is not set
1598 |
1599 | """
1600 |
1601 | serializer = manager.serializer(self.cache_serializer)
1602 |
1603 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1604 | age = self.cached_data_age(name)
1605 |
1606 | if (age < max_age or max_age == 0) and os.path.exists(cache_path):
1607 |
1608 | with open(cache_path, 'rb') as file_obj:
1609 | self.logger.debug('Loading cached data from : %s',
1610 | cache_path)
1611 | return serializer.load(file_obj)
1612 |
1613 | if not data_func:
1614 | return None
1615 |
1616 | data = data_func()
1617 | self.cache_data(name, data)
1618 |
1619 | return data
1620 |
1621 | def cache_data(self, name, data):
1622 | """Save ``data`` to cache under ``name``.
1623 |
1624 | If ``data`` is ``None``, the corresponding cache file will be
1625 | deleted.
1626 |
1627 | :param name: name of datastore
1628 | :param data: data to store. This may be any object supported by
1629 | the cache serializer
1630 |
1631 | """
1632 |
1633 | serializer = manager.serializer(self.cache_serializer)
1634 |
1635 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1636 |
1637 | if data is None:
1638 | if os.path.exists(cache_path):
1639 | os.unlink(cache_path)
1640 | self.logger.debug('Deleted cache file : %s', cache_path)
1641 | return
1642 |
1643 | with open(cache_path, 'wb') as file_obj:
1644 | serializer.dump(data, file_obj)
1645 |
1646 | self.logger.debug('Cached data saved at : %s', cache_path)
1647 |
1648 | def cached_data_fresh(self, name, max_age):
1649 | """Is data cached at `name` less than `max_age` old?
1650 |
1651 | :param name: name of datastore
1652 | :param max_age: maximum age of data in seconds
1653 | :type max_age: ``int``
1654 | :returns: ``True`` if data is less than ``max_age`` old, else
1655 | ``False``
1656 |
1657 | """
1658 |
1659 | age = self.cached_data_age(name)
1660 |
1661 | if not age:
1662 | return False
1663 |
1664 | return age < max_age
1665 |
1666 | def cached_data_age(self, name):
1667 | """Return age of data cached at `name` in seconds or 0 if
1668 | cache doesn't exist
1669 |
1670 | :param name: name of datastore
1671 | :type name: ``unicode``
1672 | :returns: age of datastore in seconds
1673 | :rtype: ``int``
1674 |
1675 | """
1676 |
1677 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1678 |
1679 | if not os.path.exists(cache_path):
1680 | return 0
1681 |
1682 | return time.time() - os.stat(cache_path).st_mtime
1683 |
1684 | def filter(self, query, items, key=lambda x: x, ascending=False,
1685 | include_score=False, min_score=0, max_results=0,
1686 | match_on=MATCH_ALL, fold_diacritics=True):
1687 | """Fuzzy search filter. Returns list of ``items`` that match ``query``.
1688 |
1689 | ``query`` is case-insensitive. Any item that does not contain the
1690 | entirety of ``query`` is rejected.
1691 |
1692 | .. warning::
1693 |
1694 | If ``query`` is an empty string or contains only whitespace,
1695 | a :class:`ValueError` will be raised.
1696 |
1697 | :param query: query to test items against
1698 | :type query: ``unicode``
1699 | :param items: iterable of items to test
1700 | :type items: ``list`` or ``tuple``
1701 | :param key: function to get comparison key from ``items``.
1702 | Must return a ``unicode`` string. The default simply returns
1703 | the item.
1704 | :type key: ``callable``
1705 | :param ascending: set to ``True`` to get worst matches first
1706 | :type ascending: ``Boolean``
1707 | :param include_score: Useful for debugging the scoring algorithm.
1708 | If ``True``, results will be a list of tuples
1709 | ``(item, score, rule)``.
1710 | :type include_score: ``Boolean``
1711 | :param min_score: If non-zero, ignore results with a score lower
1712 | than this.
1713 | :type min_score: ``int``
1714 | :param max_results: If non-zero, prune results list to this length.
1715 | :type max_results: ``int``
1716 | :param match_on: Filter option flags. Bitwise-combined list of
1717 | ``MATCH_*`` constants (see below).
1718 | :type match_on: ``int``
1719 | :param fold_diacritics: Convert search keys to ASCII-only
1720 | characters if ``query`` only contains ASCII characters.
1721 | :type fold_diacritics: ``Boolean``
1722 | :returns: list of ``items`` matching ``query`` or list of
1723 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
1724 | ``rule`` is the ``MATCH_*`` rule that matched the item.
1725 | :rtype: ``list``
1726 |
1727 | **Matching rules**
1728 |
1729 | By default, :meth:`filter` uses all of the following flags (i.e.
1730 | :const:`MATCH_ALL`). The tests are always run in the given order:
1731 |
1732 | 1. :const:`MATCH_STARTSWITH` : Item search key startswith
1733 | ``query``(case-insensitive).
1734 | 2. :const:`MATCH_CAPITALS` : The list of capital letters in item
1735 | search key starts with ``query`` (``query`` may be
1736 | lower-case). E.g., ``of`` would match ``OmniFocus``,
1737 | ``gc`` would match ``Google Chrome``
1738 | 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on
1739 | non-word characters (.,-,' etc.). Matches if ``query`` is
1740 | one of these atoms (case-insensitive).
1741 | 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first
1742 | characters of the above-described "atoms" (case-insensitive).
1743 | 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of
1744 | the above-described initials.
1745 | 6. :const:`MATCH_INITIALS` : Combination of (4) and (5).
1746 | 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring
1747 | of item search key (case-insensitive).
1748 | 8. :const:`MATCH_ALLCHARS` : Matches if all characters in
1749 | ``query`` appear in item search key in the same order
1750 | (case-insensitive).
1751 | 9. :const:`MATCH_ALL` : Combination of all the above.
1752 |
1753 |
1754 | :const:`MATCH_ALLCHARS` is considerably slower than the other
1755 | tests and provides much less accurate results.
1756 |
1757 | **Examples:**
1758 |
1759 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
1760 | matches and is expensive to run), use
1761 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.
1762 |
1763 | To match only on capitals, use ``match_on=MATCH_CAPITALS``.
1764 |
1765 | To match only on startswith and substring, use
1766 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.
1767 |
1768 | **Diacritic folding**
1769 |
1770 | .. versionadded:: 1.3
1771 |
1772 | If ``fold_diacritics`` is ``True`` (the default), and ``query``
1773 | contains only ASCII characters, non-ASCII characters in search keys
1774 | will be converted to ASCII equivalents (e.g. **ü** -> **u**,
1775 | **ß** -> **ss**, **é** -> **e**).
1776 |
1777 | See :const:`ASCII_REPLACEMENTS` for all replacements.
1778 |
1779 | If ``query`` contains non-ASCII characters, search keys will not be
1780 | altered.
1781 |
1782 | """
1783 |
1784 | if not query:
1785 | raise ValueError('Empty `query`')
1786 |
1787 | # Remove preceding/trailing spaces
1788 | query = query.strip()
1789 |
1790 | if not query:
1791 | raise ValueError('`query` contains only whitespace')
1792 |
1793 | # Use user override if there is one
1794 | fold_diacritics = self.settings.get('__workflow_diacritic_folding',
1795 | fold_diacritics)
1796 |
1797 | results = []
1798 |
1799 | for item in items:
1800 | skip = False
1801 | score = 0
1802 | words = [s.strip() for s in query.split(' ')]
1803 | value = key(item).strip()
1804 | if value == '':
1805 | continue
1806 | for word in words:
1807 | if word == '':
1808 | continue
1809 | s, rule = self._filter_item(value, word, match_on,
1810 | fold_diacritics)
1811 |
1812 | if not s: # Skip items that don't match part of the query
1813 | skip = True
1814 | score += s
1815 |
1816 | if skip:
1817 | continue
1818 |
1819 | if score:
1820 | # use "reversed" `score` (i.e. highest becomes lowest) and
1821 | # `value` as sort key. This means items with the same score
1822 | # will be sorted in alphabetical not reverse alphabetical order
1823 | results.append(((100.0 / score, value.lower(), score),
1824 | (item, score, rule)))
1825 |
1826 | # sort on keys, then discard the keys
1827 | results.sort(reverse=ascending)
1828 | results = [t[1] for t in results]
1829 |
1830 | if min_score:
1831 | results = [r for r in results if r[1] > min_score]
1832 |
1833 | if max_results and len(results) > max_results:
1834 | results = results[:max_results]
1835 |
1836 | # return list of ``(item, score, rule)``
1837 | if include_score:
1838 | return results
1839 | # just return list of items
1840 | return [t[0] for t in results]
1841 |
1842 | def _filter_item(self, value, query, match_on, fold_diacritics):
1843 | """Filter ``value`` against ``query`` using rules ``match_on``
1844 |
1845 | :returns: ``(score, rule)``
1846 |
1847 | """
1848 |
1849 | query = query.lower()
1850 |
1851 | if not isascii(query):
1852 | fold_diacritics = False
1853 |
1854 | if fold_diacritics:
1855 | value = self.fold_to_ascii(value)
1856 |
1857 | # pre-filter any items that do not contain all characters
1858 | # of ``query`` to save on running several more expensive tests
1859 | if not set(query) <= set(value.lower()):
1860 |
1861 | return (0, None)
1862 |
1863 | # item starts with query
1864 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query):
1865 | score = 100.0 - (len(value) / len(query))
1866 |
1867 | return (score, MATCH_STARTSWITH)
1868 |
1869 | # query matches capitalised letters in item,
1870 | # e.g. of = OmniFocus
1871 | if match_on & MATCH_CAPITALS:
1872 | initials = ''.join([c for c in value if c in INITIALS])
1873 | if initials.lower().startswith(query):
1874 | score = 100.0 - (len(initials) / len(query))
1875 |
1876 | return (score, MATCH_CAPITALS)
1877 |
1878 | # split the item into "atoms", i.e. words separated by
1879 | # spaces or other non-word characters
1880 | if (match_on & MATCH_ATOM or
1881 | match_on & MATCH_INITIALS_CONTAIN or
1882 | match_on & MATCH_INITIALS_STARTSWITH):
1883 | atoms = [s.lower() for s in split_on_delimiters(value)]
1884 | # print('atoms : %s --> %s' % (value, atoms))
1885 | # initials of the atoms
1886 | initials = ''.join([s[0] for s in atoms if s])
1887 |
1888 | if match_on & MATCH_ATOM:
1889 | # is `query` one of the atoms in item?
1890 | # similar to substring, but scores more highly, as it's
1891 | # a word within the item
1892 | if query in atoms:
1893 | score = 100.0 - (len(value) / len(query))
1894 |
1895 | return (score, MATCH_ATOM)
1896 |
1897 | # `query` matches start (or all) of the initials of the
1898 | # atoms, e.g. ``himym`` matches "How I Met Your Mother"
1899 | # *and* "how i met your mother" (the ``capitals`` rule only
1900 | # matches the former)
1901 | if (match_on & MATCH_INITIALS_STARTSWITH and
1902 | initials.startswith(query)):
1903 | score = 100.0 - (len(initials) / len(query))
1904 |
1905 | return (score, MATCH_INITIALS_STARTSWITH)
1906 |
1907 | # `query` is a substring of initials, e.g. ``doh`` matches
1908 | # "The Dukes of Hazzard"
1909 | elif (match_on & MATCH_INITIALS_CONTAIN and
1910 | query in initials):
1911 | score = 95.0 - (len(initials) / len(query))
1912 |
1913 | return (score, MATCH_INITIALS_CONTAIN)
1914 |
1915 | # `query` is a substring of item
1916 | if match_on & MATCH_SUBSTRING and query in value.lower():
1917 | score = 90.0 - (len(value) / len(query))
1918 |
1919 | return (score, MATCH_SUBSTRING)
1920 |
1921 | # finally, assign a score based on how close together the
1922 | # characters in `query` are in item.
1923 | if match_on & MATCH_ALLCHARS:
1924 | search = self._search_for_query(query)
1925 | match = search(value)
1926 | if match:
1927 | score = 100.0 / ((1 + match.start()) *
1928 | (match.end() - match.start() + 1))
1929 |
1930 | return (score, MATCH_ALLCHARS)
1931 |
1932 | # Nothing matched
1933 | return (0, None)
1934 |
1935 | def _search_for_query(self, query):
1936 | if query in self._search_pattern_cache:
1937 | return self._search_pattern_cache[query]
1938 |
1939 | # Build pattern: include all characters
1940 | pattern = []
1941 | for c in query:
1942 | # pattern.append('[^{0}]*{0}'.format(re.escape(c)))
1943 | pattern.append('.*?{0}'.format(re.escape(c)))
1944 | pattern = ''.join(pattern)
1945 | search = re.compile(pattern, re.IGNORECASE).search
1946 |
1947 | self._search_pattern_cache[query] = search
1948 | return search
1949 |
1950 | def run(self, func):
1951 | """Call ``func`` to run your workflow
1952 |
1953 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
1954 | instance) as first argument.
1955 |
1956 | ``func`` will be called with :class:`Workflow` instance as first
1957 | argument.
1958 |
1959 | ``func`` should be the main entry point to your workflow.
1960 |
1961 | Any exceptions raised will be logged and an error message will be
1962 | output to Alfred.
1963 |
1964 | """
1965 |
1966 | start = time.time()
1967 |
1968 | # Call workflow's entry function/method within a try-except block
1969 | # to catch any errors and display an error message in Alfred
1970 | try:
1971 | if self.version:
1972 | self.logger.debug('Workflow version : {0}'.format(self.version))
1973 |
1974 | # Run update check if configured for self-updates.
1975 | # This call has to go in the `run` try-except block, as it will
1976 | # initialise `self.settings`, which will raise an exception
1977 | # if `settings.json` isn't valid.
1978 |
1979 | if self._update_settings:
1980 | self.check_update()
1981 |
1982 | # Run workflow's entry function/method
1983 | func(self)
1984 |
1985 | # Set last version run to current version after a successful
1986 | # run
1987 | self.set_last_version()
1988 |
1989 | except Exception as err:
1990 | self.logger.exception(err)
1991 | if self.help_url:
1992 | self.logger.info(
1993 | 'For assistance, see: {0}'.format(self.help_url))
1994 | if not sys.stdout.isatty(): # Show error in Alfred
1995 | self._items = []
1996 | if self._name:
1997 | name = self._name
1998 | elif self._bundleid:
1999 | name = self._bundleid
2000 | else: # pragma: no cover
2001 | name = os.path.dirname(__file__)
2002 | self.add_item("Error in workflow '%s'" % name, unicode(err),
2003 | icon=ICON_ERROR)
2004 | self.send_feedback()
2005 | return 1
2006 | finally:
2007 | self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format(
2008 | time.time() - start))
2009 | return 0
2010 |
2011 | # Alfred feedback methods ------------------------------------------
2012 |
2013 | def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
2014 | autocomplete=None, valid=False, uid=None, icon=None,
2015 | icontype=None, type=None, largetext=None, copytext=None):
2016 | """Add an item to be output to Alfred
2017 |
2018 | :param title: Title shown in Alfred
2019 | :type title: ``unicode``
2020 | :param subtitle: Subtitle shown in Alfred
2021 | :type subtitle: ``unicode``
2022 | :param modifier_subtitles: Subtitles shown when modifier
2023 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase
2024 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn``
2025 | :type modifier_subtitles: ``dict``
2026 | :param arg: Argument passed by Alfred as ``{query}`` when item is
2027 | actioned
2028 | :type arg: ``unicode``
2029 | :param autocomplete: Text expanded in Alfred when item is TABbed
2030 | :type autocomplete: ``unicode``
2031 | :param valid: Whether or not item can be actioned
2032 | :type valid: ``Boolean``
2033 | :param uid: Used by Alfred to remember/sort items
2034 | :type uid: ``unicode``
2035 | :param icon: Filename of icon to use
2036 | :type icon: ``unicode``
2037 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'``
2038 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype
2039 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to
2040 | use the icon of the file specified as ``icon``, e.g.
2041 | ``icon='/Applications/Safari.app', icontype='fileicon'``.
2042 | Leave as `None` if ``icon`` points to an actual
2043 | icon file.
2044 | :type icontype: ``unicode``
2045 | :param type: Result type. Currently only ``'file'`` is supported
2046 | (by Alfred). This will tell Alfred to enable file actions for
2047 | this item.
2048 | :type type: ``unicode``
2049 | :param largetext: Text to be displayed in Alfred's large text box
2050 | if user presses CMD+L on item.
2051 | :type largetext: ``unicode``
2052 | :param copytext: Text to be copied to pasteboard if user presses
2053 | CMD+C on item.
2054 | :type copytext: ``unicode``
2055 | :returns: :class:`Item` instance
2056 |
2057 | See the :ref:`script-filter-results` section of the documentation
2058 | for a detailed description of what the various parameters do and how
2059 | they interact with one another.
2060 |
2061 | See :ref:`icons` for a list of the supported system icons.
2062 |
2063 | .. note::
2064 |
2065 | Although this method returns an :class:`Item` instance, you don't
2066 | need to hold onto it or worry about it. All generated :class:`Item`
2067 | instances are also collected internally and sent to Alfred when
2068 | :meth:`send_feedback` is called.
2069 |
2070 | The generated :class:`Item` is only returned in case you want to
2071 | edit it or do something with it other than send it to Alfred.
2072 |
2073 | """
2074 |
2075 | item = self.item_class(title, subtitle, modifier_subtitles, arg,
2076 | autocomplete, valid, uid, icon, icontype, type,
2077 | largetext, copytext)
2078 | self._items.append(item)
2079 | return item
2080 |
2081 | def send_feedback(self):
2082 | """Print stored items to console/Alfred as XML."""
2083 | root = ET.Element('items')
2084 | for item in self._items:
2085 | root.append(item.elem)
2086 | sys.stdout.write('\n')
2087 | sys.stdout.write(ET.tostring(root).encode('utf-8'))
2088 | sys.stdout.flush()
2089 |
2090 | ####################################################################
2091 | # Updating methods
2092 | ####################################################################
2093 |
2094 | @property
2095 | def first_run(self):
2096 | """Return ``True`` if it's the first time this version has run.
2097 |
2098 | .. versionadded:: 1.9.10
2099 |
2100 | Raises a :class:`ValueError` if :attr:`version` isn't set.
2101 |
2102 | """
2103 |
2104 | if not self.version:
2105 | raise ValueError('No workflow version set')
2106 |
2107 | if not self.last_version_run:
2108 | return True
2109 |
2110 | return self.version != self.last_version_run
2111 |
2112 | @property
2113 | def last_version_run(self):
2114 | """Return version of last version to run (or ``None``)
2115 |
2116 | .. versionadded:: 1.9.10
2117 |
2118 | :returns: :class:`~workflow.update.Version` instance
2119 | or ``None``
2120 |
2121 | """
2122 |
2123 | if self._last_version_run is UNSET:
2124 |
2125 | version = self.settings.get('__workflow_last_version')
2126 | if version:
2127 | from update import Version
2128 | version = Version(version)
2129 |
2130 | self._last_version_run = version
2131 |
2132 | self.logger.debug('Last run version : {0}'.format(
2133 | self._last_version_run))
2134 |
2135 | return self._last_version_run
2136 |
2137 | def set_last_version(self, version=None):
2138 | """Set :attr:`last_version_run` to current version
2139 |
2140 | .. versionadded:: 1.9.10
2141 |
2142 | :param version: version to store (default is current version)
2143 | :type version: :class:`~workflow.update.Version` instance
2144 | or ``unicode``
2145 | :returns: ``True`` if version is saved, else ``False``
2146 |
2147 | """
2148 |
2149 | if not version:
2150 | if not self.version:
2151 | self.logger.warning(
2152 | "Can't save last version: workflow has no version")
2153 | return False
2154 |
2155 | version = self.version
2156 |
2157 | if isinstance(version, basestring):
2158 | from update import Version
2159 | version = Version(version)
2160 |
2161 | self.settings['__workflow_last_version'] = str(version)
2162 |
2163 | self.logger.debug('Set last run version : {0}'.format(version))
2164 |
2165 | return True
2166 |
2167 | @property
2168 | def update_available(self):
2169 | """Is an update available?
2170 |
2171 | .. versionadded:: 1.9
2172 |
2173 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed
2174 | information on how to enable your workflow to update itself.
2175 |
2176 | :returns: ``True`` if an update is available, else ``False``
2177 |
2178 | """
2179 |
2180 | update_data = self.cached_data('__workflow_update_status', max_age=0)
2181 | self.logger.debug('update_data : {0}'.format(update_data))
2182 |
2183 | if not update_data or not update_data.get('available'):
2184 | return False
2185 |
2186 | return update_data['available']
2187 |
2188 | def check_update(self, force=False):
2189 | """Call update script if it's time to check for a new release
2190 |
2191 | .. versionadded:: 1.9
2192 |
2193 | The update script will be run in the background, so it won't
2194 | interfere in the execution of your workflow.
2195 |
2196 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed
2197 | information on how to enable your workflow to update itself.
2198 |
2199 | :param force: Force update check
2200 | :type force: ``Boolean``
2201 |
2202 | """
2203 |
2204 | frequency = self._update_settings.get('frequency',
2205 | DEFAULT_UPDATE_FREQUENCY)
2206 |
2207 | if not force and not self.settings.get('__workflow_autoupdate', True):
2208 | self.logger.debug('Auto update turned off by user')
2209 | return
2210 |
2211 | # Check for new version if it's time
2212 | if (force or not self.cached_data_fresh(
2213 | '__workflow_update_status', frequency * 86400)):
2214 |
2215 | github_slug = self._update_settings['github_slug']
2216 | # version = self._update_settings['version']
2217 | version = str(self.version)
2218 |
2219 | from background import run_in_background
2220 |
2221 | # update.py is adjacent to this file
2222 | update_script = os.path.join(os.path.dirname(__file__),
2223 | b'update.py')
2224 |
2225 | cmd = ['/usr/bin/python', update_script, 'check', github_slug,
2226 | version]
2227 |
2228 | self.logger.info('Checking for update ...')
2229 |
2230 | run_in_background('__workflow_update_check', cmd)
2231 |
2232 | else:
2233 | self.logger.debug('Update check not due')
2234 |
2235 | def start_update(self):
2236 | """Check for update and download and install new workflow file
2237 |
2238 | .. versionadded:: 1.9
2239 |
2240 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed
2241 | information on how to enable your workflow to update itself.
2242 |
2243 | :returns: ``True`` if an update is available and will be
2244 | installed, else ``False``
2245 |
2246 | """
2247 |
2248 | import update
2249 |
2250 | github_slug = self._update_settings['github_slug']
2251 | # version = self._update_settings['version']
2252 | version = str(self.version)
2253 |
2254 | if not update.check_update(github_slug, version):
2255 | return False
2256 |
2257 | from background import run_in_background
2258 |
2259 | # update.py is adjacent to this file
2260 | update_script = os.path.join(os.path.dirname(__file__),
2261 | b'update.py')
2262 |
2263 | cmd = ['/usr/bin/python', update_script, 'install', github_slug,
2264 | version]
2265 |
2266 | self.logger.debug('Downloading update ...')
2267 | run_in_background('__workflow_update_install', cmd)
2268 |
2269 | return True
2270 |
2271 | ####################################################################
2272 | # Keychain password storage methods
2273 | ####################################################################
2274 |
2275 | def save_password(self, account, password, service=None):
2276 | """Save account credentials.
2277 |
2278 | If the account exists, the old password will first be deleted
2279 | (Keychain throws an error otherwise).
2280 |
2281 | If something goes wrong, a :class:`KeychainError` exception will
2282 | be raised.
2283 |
2284 | :param account: name of the account the password is for, e.g.
2285 | "Pinboard"
2286 | :type account: ``unicode``
2287 | :param password: the password to secure
2288 | :type password: ``unicode``
2289 | :param service: Name of the service. By default, this is the
2290 | workflow's bundle ID
2291 | :type service: ``unicode``
2292 |
2293 | """
2294 | if not service:
2295 | service = self.bundleid
2296 |
2297 | try:
2298 | self._call_security('add-generic-password', service, account,
2299 | '-w', password)
2300 | self.logger.debug('Saved password : %s:%s', service, account)
2301 |
2302 | except PasswordExists:
2303 | self.logger.debug('Password exists : %s:%s', service, account)
2304 | current_password = self.get_password(account, service)
2305 |
2306 | if current_password == password:
2307 | self.logger.debug('Password unchanged')
2308 |
2309 | else:
2310 | self.delete_password(account, service)
2311 | self._call_security('add-generic-password', service,
2312 | account, '-w', password)
2313 | self.logger.debug('save_password : %s:%s', service, account)
2314 |
2315 | def get_password(self, account, service=None):
2316 | """Retrieve the password saved at ``service/account``. Raise
2317 | :class:`PasswordNotFound` exception if password doesn't exist.
2318 |
2319 | :param account: name of the account the password is for, e.g.
2320 | "Pinboard"
2321 | :type account: ``unicode``
2322 | :param service: Name of the service. By default, this is the workflow's
2323 | bundle ID
2324 | :type service: ``unicode``
2325 | :returns: account password
2326 | :rtype: ``unicode``
2327 |
2328 | """
2329 |
2330 | if not service:
2331 | service = self.bundleid
2332 |
2333 | output = self._call_security('find-generic-password', service,
2334 | account, '-g')
2335 |
2336 | # Parsing of `security` output is adapted from python-keyring
2337 | # by Jason R. Coombs
2338 | # https://pypi.python.org/pypi/keyring
2339 | m = re.search(
2340 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?',
2341 | output)
2342 |
2343 | if m:
2344 | groups = m.groupdict()
2345 | h = groups.get('hex')
2346 | password = groups.get('pw')
2347 | if h:
2348 | password = unicode(binascii.unhexlify(h), 'utf-8')
2349 |
2350 | self.logger.debug('Got password : %s:%s', service, account)
2351 |
2352 | return password
2353 |
2354 | def delete_password(self, account, service=None):
2355 | """Delete the password stored at ``service/account``. Raises
2356 | :class:`PasswordNotFound` if account is unknown.
2357 |
2358 | :param account: name of the account the password is for, e.g.
2359 | "Pinboard"
2360 | :type account: ``unicode``
2361 | :param service: Name of the service. By default, this is the workflow's
2362 | bundle ID
2363 | :type service: ``unicode``
2364 |
2365 | """
2366 |
2367 | if not service:
2368 | service = self.bundleid
2369 |
2370 | self._call_security('delete-generic-password', service, account)
2371 |
2372 | self.logger.debug('Deleted password : %s:%s', service, account)
2373 |
2374 | ####################################################################
2375 | # Methods for workflow:* magic args
2376 | ####################################################################
2377 |
2378 | def _register_default_magic(self):
2379 | """Register the built-in magic arguments"""
2380 | # TODO: refactor & simplify
2381 |
2382 | # Wrap callback and message with callable
2383 | def callback(func, msg):
2384 | def wrapper():
2385 | func()
2386 | return msg
2387 |
2388 | return wrapper
2389 |
2390 | self.magic_arguments['delcache'] = callback(self.clear_cache,
2391 | 'Deleted workflow cache')
2392 | self.magic_arguments['deldata'] = callback(self.clear_data,
2393 | 'Deleted workflow data')
2394 | self.magic_arguments['delsettings'] = callback(
2395 | self.clear_settings, 'Deleted workflow settings')
2396 | self.magic_arguments['reset'] = callback(self.reset,
2397 | 'Reset workflow')
2398 | self.magic_arguments['openlog'] = callback(self.open_log,
2399 | 'Opening workflow log file')
2400 | self.magic_arguments['opencache'] = callback(
2401 | self.open_cachedir, 'Opening workflow cache directory')
2402 | self.magic_arguments['opendata'] = callback(
2403 | self.open_datadir, 'Opening workflow data directory')
2404 | self.magic_arguments['openworkflow'] = callback(
2405 | self.open_workflowdir, 'Opening workflow directory')
2406 | self.magic_arguments['openterm'] = callback(
2407 | self.open_terminal, 'Opening workflow root directory in Terminal')
2408 |
2409 | # Diacritic folding
2410 | def fold_on():
2411 | self.settings['__workflow_diacritic_folding'] = True
2412 | return 'Diacritics will always be folded'
2413 |
2414 | def fold_off():
2415 | self.settings['__workflow_diacritic_folding'] = False
2416 | return 'Diacritics will never be folded'
2417 |
2418 | def fold_default():
2419 | if '__workflow_diacritic_folding' in self.settings:
2420 | del self.settings['__workflow_diacritic_folding']
2421 | return 'Diacritics folding reset'
2422 |
2423 | self.magic_arguments['foldingon'] = fold_on
2424 | self.magic_arguments['foldingoff'] = fold_off
2425 | self.magic_arguments['foldingdefault'] = fold_default
2426 |
2427 | # Updates
2428 | def update_on():
2429 | self.settings['__workflow_autoupdate'] = True
2430 | return 'Auto update turned on'
2431 |
2432 | def update_off():
2433 | self.settings['__workflow_autoupdate'] = False
2434 | return 'Auto update turned off'
2435 |
2436 | def do_update():
2437 | if self.start_update():
2438 | return 'Downloading and installing update ...'
2439 | else:
2440 | return 'No update available'
2441 |
2442 | self.magic_arguments['autoupdate'] = update_on
2443 | self.magic_arguments['noautoupdate'] = update_off
2444 | self.magic_arguments['update'] = do_update
2445 |
2446 | # Help
2447 | def do_help():
2448 | if self.help_url:
2449 | self.open_help()
2450 | return 'Opening workflow help URL in browser'
2451 | else:
2452 | return 'Workflow has no help URL'
2453 |
2454 | def show_version():
2455 | if self.version:
2456 | return 'Version: {0}'.format(self.version)
2457 | else:
2458 | return 'This workflow has no version number'
2459 |
2460 | def list_magic():
2461 | """Display all available magic args in Alfred"""
2462 | isatty = sys.stderr.isatty()
2463 | for name in sorted(self.magic_arguments.keys()):
2464 | if name == 'magic':
2465 | continue
2466 | arg = '{0}{1}'.format(self.magic_prefix, name)
2467 | self.logger.debug(arg)
2468 |
2469 | if not isatty:
2470 | self.add_item(arg, icon=ICON_INFO)
2471 |
2472 | if not isatty:
2473 | self.send_feedback()
2474 |
2475 | self.magic_arguments['help'] = do_help
2476 | self.magic_arguments['magic'] = list_magic
2477 | self.magic_arguments['version'] = show_version
2478 |
2479 | def clear_cache(self, filter_func=lambda f: True):
2480 | """Delete all files in workflow's :attr:`cachedir`.
2481 |
2482 | :param filter_func: Callable to determine whether a file should be
2483 | deleted or not. ``filter_func`` is called with the filename
2484 | of each file in the data directory. If it returns ``True``,
2485 | the file will be deleted.
2486 | By default, *all* files will be deleted.
2487 | :type filter_func: ``callable``
2488 | """
2489 | self._delete_directory_contents(self.cachedir, filter_func)
2490 |
2491 | def clear_data(self, filter_func=lambda f: True):
2492 | """Delete all files in workflow's :attr:`datadir`.
2493 |
2494 | :param filter_func: Callable to determine whether a file should be
2495 | deleted or not. ``filter_func`` is called with the filename
2496 | of each file in the data directory. If it returns ``True``,
2497 | the file will be deleted.
2498 | By default, *all* files will be deleted.
2499 | :type filter_func: ``callable``
2500 | """
2501 | self._delete_directory_contents(self.datadir, filter_func)
2502 |
2503 | def clear_settings(self):
2504 | """Delete workflow's :attr:`settings_path`."""
2505 | if os.path.exists(self.settings_path):
2506 | os.unlink(self.settings_path)
2507 | self.logger.debug('Deleted : %r', self.settings_path)
2508 |
2509 | def reset(self):
2510 | """Delete :attr:`settings `, :attr:`cache `
2511 | and :attr:`data `
2512 |
2513 | """
2514 |
2515 | self.clear_cache()
2516 | self.clear_data()
2517 | self.clear_settings()
2518 |
2519 | def open_log(self):
2520 | """Open workflows :attr:`logfile` in standard
2521 | application (usually Console.app).
2522 |
2523 | """
2524 |
2525 | subprocess.call(['open', self.logfile])
2526 |
2527 | def open_cachedir(self):
2528 | """Open the workflow's :attr:`cachedir` in Finder."""
2529 | subprocess.call(['open', self.cachedir])
2530 |
2531 | def open_datadir(self):
2532 | """Open the workflow's :attr:`datadir` in Finder."""
2533 | subprocess.call(['open', self.datadir])
2534 |
2535 | def open_workflowdir(self):
2536 | """Open the workflow's :attr:`workflowdir` in Finder."""
2537 | subprocess.call(['open', self.workflowdir])
2538 |
2539 | def open_terminal(self):
2540 | """Open a Terminal window at workflow's :attr:`workflowdir`."""
2541 |
2542 | subprocess.call(['open', '-a', 'Terminal',
2543 | self.workflowdir])
2544 |
2545 | def open_help(self):
2546 | """Open :attr:`help_url` in default browser"""
2547 | subprocess.call(['open', self.help_url])
2548 |
2549 | return 'Opening workflow help URL in browser'
2550 |
2551 | ####################################################################
2552 | # Helper methods
2553 | ####################################################################
2554 |
2555 | def decode(self, text, encoding=None, normalization=None):
2556 | """Return ``text`` as normalised unicode.
2557 |
2558 | If ``encoding`` and/or ``normalization`` is ``None``, the
2559 | ``input_encoding``and ``normalization`` parameters passed to
2560 | :class:`Workflow` are used.
2561 |
2562 | :param text: string
2563 | :type text: encoded or Unicode string. If ``text`` is already a
2564 | Unicode string, it will only be normalised.
2565 | :param encoding: The text encoding to use to decode ``text`` to
2566 | Unicode.
2567 | :type encoding: ``unicode`` or ``None``
2568 | :param normalization: The nomalisation form to apply to ``text``.
2569 | :type normalization: ``unicode`` or ``None``
2570 | :returns: decoded and normalised ``unicode``
2571 |
2572 | :class:`Workflow` uses "NFC" normalisation by default. This is the
2573 | standard for Python and will work well with data from the web (via
2574 | :mod:`~workflow.web` or :mod:`json`).
2575 |
2576 | OS X, on the other hand, uses "NFD" normalisation (nearly), so data
2577 | coming from the system (e.g. via :mod:`subprocess` or
2578 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either
2579 | normalise this data, too, or change the default normalisation used by
2580 | :class:`Workflow`.
2581 |
2582 | """
2583 |
2584 | encoding = encoding or self._input_encoding
2585 | normalization = normalization or self._normalizsation
2586 | if not isinstance(text, unicode):
2587 | text = unicode(text, encoding)
2588 | return unicodedata.normalize(normalization, text)
2589 |
2590 | def fold_to_ascii(self, text):
2591 | """Convert non-ASCII characters to closest ASCII equivalent.
2592 |
2593 | .. versionadded:: 1.3
2594 |
2595 | .. note:: This only works for a subset of European languages.
2596 |
2597 | :param text: text to convert
2598 | :type text: ``unicode``
2599 | :returns: text containing only ASCII characters
2600 | :rtype: ``unicode``
2601 |
2602 | """
2603 | if isascii(text):
2604 | return text
2605 | text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text])
2606 | return unicode(unicodedata.normalize('NFKD',
2607 | text).encode('ascii', 'ignore'))
2608 |
2609 | def dumbify_punctuation(self, text):
2610 | """Convert non-ASCII punctuation to closest ASCII equivalent.
2611 |
2612 | This method replaces "smart" quotes and n- or m-dashes with their
2613 | workaday ASCII equivalents. This method is currently not used
2614 | internally, but exists as a helper method for workflow authors.
2615 |
2616 | .. versionadded: 1.9.7
2617 |
2618 | :param text: text to convert
2619 | :type text: ``unicode``
2620 | :returns: text with only ASCII punctuation
2621 | :rtype: ``unicode``
2622 |
2623 | """
2624 | if isascii(text):
2625 | return text
2626 |
2627 | text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text])
2628 | return text
2629 |
2630 | def _delete_directory_contents(self, dirpath, filter_func):
2631 | """Delete all files in a directory
2632 |
2633 | :param dirpath: path to directory to clear
2634 | :type dirpath: ``unicode`` or ``str``
2635 | :param filter_func function to determine whether a file shall be
2636 | deleted or not.
2637 | :type filter_func ``callable``
2638 | """
2639 |
2640 | if os.path.exists(dirpath):
2641 | for filename in os.listdir(dirpath):
2642 | if not filter_func(filename):
2643 | continue
2644 | path = os.path.join(dirpath, filename)
2645 | if os.path.isdir(path):
2646 | shutil.rmtree(path)
2647 | else:
2648 | os.unlink(path)
2649 | self.logger.debug('Deleted : %r', path)
2650 |
2651 | def _load_info_plist(self):
2652 | """Load workflow info from ``info.plist``
2653 |
2654 | """
2655 |
2656 | self._info = plistlib.readPlist(self._info_plist)
2657 | self._info_loaded = True
2658 |
2659 | def _create(self, dirpath):
2660 | """Create directory `dirpath` if it doesn't exist
2661 |
2662 | :param dirpath: path to directory
2663 | :type dirpath: ``unicode``
2664 | :returns: ``dirpath`` argument
2665 | :rtype: ``unicode``
2666 |
2667 | """
2668 |
2669 | if not os.path.exists(dirpath):
2670 | os.makedirs(dirpath)
2671 | return dirpath
2672 |
2673 | def _call_security(self, action, service, account, *args):
2674 | """Call the ``security`` CLI app that provides access to keychains.
2675 |
2676 |
2677 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
2678 | exceptions (the first two are subclasses of `KeychainError`).
2679 |
2680 | :param action: The ``security`` action to call, e.g.
2681 | ``add-generic-password``
2682 | :type action: ``unicode``
2683 | :param service: Name of the service.
2684 | :type service: ``unicode``
2685 | :param account: name of the account the password is for, e.g.
2686 | "Pinboard"
2687 | :type account: ``unicode``
2688 | :param password: the password to secure
2689 | :type password: ``unicode``
2690 | :param *args: list of command line arguments to be passed to
2691 | ``security``
2692 | :type *args: `list` or `tuple`
2693 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a
2694 | ``unicode`` string.
2695 | :rtype: `tuple` (`int`, ``unicode``)
2696 |
2697 | """
2698 |
2699 | cmd = ['security', action, '-s', service, '-a', account] + list(args)
2700 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
2701 | stderr=subprocess.STDOUT)
2702 | retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8')
2703 | if retcode == 44: # password does not exist
2704 | raise PasswordNotFound()
2705 | elif retcode == 45: # password already exists
2706 | raise PasswordExists()
2707 | elif retcode > 0:
2708 | err = KeychainError('Unknown Keychain error : %s' % output)
2709 | err.retcode = retcode
2710 | raise err
2711 | return output
2712 |
--------------------------------------------------------------------------------
/youdao.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaiye/workflows-youdao/192050fb0cdd94809d35c4fa3b4e5bca33fa37b3/youdao.alfredworkflow
--------------------------------------------------------------------------------
/youdao.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import re
4 | import urllib
5 | from workflow import Workflow, ICON_WEB, web
6 | import sys
7 | import json
8 |
9 | reload(sys)
10 | sys.setdefaultencoding('utf8')
11 |
12 | apikey = 1185055258
13 | keyfrom = 'imgxqb'
14 | ICON_DEFAULT = 'icon.png'
15 | ICON_PHONETIC = 'icon_phonetic.png'
16 | ICON_BASIC = 'icon_basic.png'
17 | ICON_WEB = 'icon_web.png'
18 |
19 |
20 | def get_web_data(query):
21 | query = urllib.quote(str(query))
22 | url = 'http://fanyi.youdao.com/openapi.do?keyfrom=' + keyfrom + \
23 | '&key=' + str(apikey) + \
24 | '&type=data&doctype=json&version=1.1&q=' + query
25 | return web.get(url).json()
26 |
27 | def get_phonetic_args(s):
28 | result = {}
29 | if u'basic' in s.keys():
30 | if s["basic"].get("us-phonetic"):
31 | result["us"] = "[" + s["basic"]["us-phonetic"] + "]"
32 | if s["basic"].get("uk-phonetic"):
33 | result["uk"] = "[" + s["basic"]["uk-phonetic"] + "]"
34 | return result
35 |
36 | def main(wf):
37 | query = wf.args[0].strip().replace("\\", "")
38 | extra_args = {}
39 |
40 | if not query:
41 | wf.add_item('有道翻译')
42 | wf.send_feedback()
43 | return 0
44 |
45 | s = get_web_data(query)
46 | extra_args.update(get_phonetic_args(s))
47 |
48 | if s.get("errorCode") == 0:
49 | # '翻译结果'
50 | title = s["translation"]
51 | title = ''.join(title)
52 | # url = u'http://dict.youdao.com/search?q=' + query
53 | tran = 'EtC'
54 | if not isinstance(query, unicode):
55 | query = query.decode('utf8')
56 | if re.search(ur"[\u4e00-\u9fa5]+", query):
57 | tran = 'CtE'
58 | subtitle = '翻译结果'
59 |
60 | arg = [query, title, query, ' ', json.dumps(extra_args)] if tran == 'EtC' else [
61 | query, title, title, ' ', json.dumps(extra_args)]
62 | arg = '$'.join(arg)
63 | wf.add_item(
64 | title=title, subtitle=subtitle, arg=arg, valid=True, icon=ICON_DEFAULT)
65 |
66 | if u'basic' in s.keys():
67 | # '发音'
68 | if s["basic"].get("phonetic"):
69 | title = ""
70 | if s["basic"].get("us-phonetic"):
71 | title += (" [美: " + s["basic"]["us-phonetic"] + "]")
72 | if s["basic"].get("uk-phonetic"):
73 | title += (" [英: " + s["basic"]["uk-phonetic"] + "]")
74 | title = title if title else "[" + s["basic"]["phonetic"] + "]"
75 | subtitle = '有道发音'
76 | arg = [query, title, query, ' ', json.dumps(extra_args)] if tran == 'EtC' else [
77 | query, title, ' ', query, json.dumps(extra_args)]
78 | arg = '$'.join(arg)
79 | wf.add_item(
80 | title=title, subtitle=subtitle, arg=arg, valid=True, icon=ICON_PHONETIC)
81 |
82 | # '简明释意'
83 | for be in range(len(s["basic"]["explains"])):
84 | title = s["basic"]["explains"][be]
85 | subtitle = '简明释意'
86 | arg = [query, title, query, ' ', json.dumps(extra_args)] if tran == 'EtC' else [
87 | query, title, title, ' ', json.dumps(extra_args)]
88 | arg = '$'.join(arg)
89 | wf.add_item(
90 | title=title, subtitle=subtitle, arg=arg, valid=True, icon=ICON_BASIC)
91 |
92 | # '网络翻译'
93 | if u'web' in s.keys():
94 | for w in range(len(s["web"])):
95 | title = s["web"][w]["value"]
96 | title = ', '.join(title)
97 | subtitle = '网络翻译: ' + s["web"][w]["key"]
98 |
99 | if tran == 'EtC':
100 | key = ''.join(s["web"][w]["key"])
101 | arg = [query, title, key, ' ', json.dumps(extra_args)]
102 | else:
103 | value = ' '.join(s["web"][w]["value"])
104 | arg = [query, title, value, ' ', json.dumps(extra_args)]
105 |
106 | arg = '$'.join(arg)
107 | wf.add_item(
108 | title=title, subtitle=subtitle, arg=arg, valid=True, icon=ICON_WEB)
109 |
110 | else:
111 | title = '有道也翻译不出来了'
112 | subtitle = '尝试一下去网站搜索'
113 | arg = [query, ' ', ' ', ' ', json.dumps(extra_args)]
114 | arg = '$'.join(arg)
115 | wf.add_item(
116 | title=title, subtitle=subtitle, arg=arg, valid=True, icon=ICON_DEFAULT)
117 |
118 | wf.send_feedback()
119 |
120 | if __name__ == '__main__':
121 | wf = Workflow(update_settings={
122 | 'github_slug': 'kaiye/workflows-youdao',
123 | 'frequency': 7
124 | })
125 |
126 | sys.exit(wf.run(main))
127 | if wf.update_available:
128 | wf.start_update()
129 |
--------------------------------------------------------------------------------