├── .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 | ![按两下 command 设置快捷键](https://cloud.githubusercontent.com/assets/344283/12189204/b0d21524-b5f6-11e5-9cc8-33c17561f9ee.gif) 18 | 19 | 20 | 21 | ### 3、配置有道词典账号信息 22 | 23 | ![配置账号信息](https://cloud.githubusercontent.com/assets/344283/12175374/c776aef2-b59c-11e5-90ec-20e3801ff7ed.png) 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 | ![](http://ww2.sinaimg.cn/large/48910e01gw1erucr05z85g213p0kbqhn.gif) 38 | 39 | ### 中译英 40 | 41 | ![](http://ww2.sinaimg.cn/large/48910e01gw1erucrd5tnmg213p0kbk6q.gif) 42 | 43 | ### 翻译短语 44 | 45 | ![](http://ww2.sinaimg.cn/large/48910e01gw1erucrvb9a8g213p0kbqhn.gif) 46 | 47 | ### 使用浏览器搜索 48 | 49 | ![](http://ww4.sinaimg.cn/large/48910e01gw1erucsmvtkgg213l0kaqq2.gif) 50 | 51 | ### 输出结果到光标所在应用程序 52 | 53 | ![](http://ww3.sinaimg.cn/large/48910e01gw1eructbvt9rg213p0jh0wi.gif) 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 + '\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 | --------------------------------------------------------------------------------