├── .gitignore ├── LICENSE ├── demo_gif ├── 1_0.gif └── 2_1.gif ├── info.plist ├── query.py ├── readme.md └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── web.py ├── workflow.py └── workflow3.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,pycharm+all 3 | # Edit at https://www.gitignore.io/?templates=python,pycharm+all 4 | 5 | ### PyCharm+all ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### PyCharm+all Patch ### 76 | # Ignores the whole .idea folder and all .iml files 77 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 78 | 79 | .idea/ 80 | 81 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 82 | 83 | *.iml 84 | modules.xml 85 | .idea/misc.xml 86 | *.ipr 87 | 88 | # Sonarlint plugin 89 | .idea/sonarlint 90 | 91 | ### Python ### 92 | # Byte-compiled / optimized / DLL files 93 | __pycache__/ 94 | *.py[cod] 95 | *$py.class 96 | 97 | # C extensions 98 | *.so 99 | 100 | # Distribution / packaging 101 | .Python 102 | build/ 103 | develop-eggs/ 104 | dist/ 105 | downloads/ 106 | eggs/ 107 | .eggs/ 108 | lib/ 109 | lib64/ 110 | parts/ 111 | sdist/ 112 | var/ 113 | wheels/ 114 | pip-wheel-metadata/ 115 | share/python-wheels/ 116 | *.egg-info/ 117 | .installed.cfg 118 | *.egg 119 | MANIFEST 120 | 121 | # PyInstaller 122 | # Usually these files are written by a python script from a template 123 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 124 | *.manifest 125 | *.spec 126 | 127 | # Installer logs 128 | pip-log.txt 129 | pip-delete-this-directory.txt 130 | 131 | # Unit test / coverage reports 132 | htmlcov/ 133 | .tox/ 134 | .nox/ 135 | .coverage 136 | .coverage.* 137 | .cache 138 | nosetests.xml 139 | coverage.xml 140 | *.cover 141 | .hypothesis/ 142 | .pytest_cache/ 143 | 144 | # Translations 145 | *.mo 146 | *.pot 147 | 148 | # Scrapy stuff: 149 | .scrapy 150 | 151 | # Sphinx documentation 152 | docs/_build/ 153 | 154 | # PyBuilder 155 | target/ 156 | 157 | # pyenv 158 | .python-version 159 | 160 | # pipenv 161 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 162 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 163 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 164 | # install all needed dependencies. 165 | #Pipfile.lock 166 | 167 | # celery beat schedule file 168 | celerybeat-schedule 169 | 170 | # SageMath parsed files 171 | *.sage.py 172 | 173 | # Spyder project settings 174 | .spyderproject 175 | .spyproject 176 | 177 | # Rope project settings 178 | .ropeproject 179 | 180 | # Mr Developer 181 | .mr.developer.cfg 182 | .project 183 | .pydevproject 184 | 185 | # mkdocs documentation 186 | /site 187 | 188 | # mypy 189 | .mypy_cache/ 190 | .dmypy.json 191 | dmypy.json 192 | 193 | # Pyre type checker 194 | .pyre/ 195 | 196 | # End of https://www.gitignore.io/api/python,pycharm+all -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 TheVoid 2 | 3 | "Anti 996" License Version 1.0 (Draft) 4 | 5 | Permission is hereby granted to any individual or legal entity 6 | obtaining a copy of this licensed work (including the source code, 7 | documentation and/or related items, hereinafter collectively referred 8 | to as the "licensed work"), free of charge, to deal with the licensed 9 | work for any purpose, including without limitation, the rights to use, 10 | reproduce, modify, prepare derivative works of, distribute, publish 11 | and sublicense the licensed work, subject to the following conditions: 12 | 13 | 1. The individual or the legal entity must conspicuously display, 14 | without modification, this License and the notice on each redistributed 15 | or derivative copy of the Licensed Work. 16 | 17 | 2. The individual or the legal entity must strictly comply with all 18 | applicable laws, regulations, rules and standards of the jurisdiction 19 | relating to labor and employment where the individual is physically 20 | located or where the individual was born or naturalized; or where the 21 | legal entity is registered or is operating (whichever is stricter). In 22 | case that the jurisdiction has no such laws, regulations, rules and 23 | standards or its laws, regulations, rules and standards are 24 | unenforceable, the individual or the legal entity are required to 25 | comply with Core International Labor Standards. 26 | 27 | 3. The individual or the legal entity shall not induce, suggest or force 28 | its employee(s), whether full-time or part-time, or its independent 29 | contractor(s), in any methods, to agree in oral or written form, to 30 | directly or indirectly restrict, weaken or relinquish his or her 31 | rights or remedies under such laws, regulations, rules and standards 32 | relating to labor and employment as mentioned above, no matter whether 33 | such written or oral agreements are enforceable under the laws of the 34 | said jurisdiction, nor shall such individual or the legal entity 35 | limit, in any methods, the rights of its employee(s) or independent 36 | contractor(s) from reporting or complaining to the copyright holder or 37 | relevant authorities monitoring the compliance of the license about 38 | its violation(s) of the said license. 39 | 40 | THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 41 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 42 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 43 | IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, 44 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 45 | OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION WITH THE 46 | LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK. 47 | Apache License 48 | Version 2.0, January 2004 49 | http://www.apache.org/licenses/ 50 | 51 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 52 | 53 | 1. Definitions. 54 | 55 | "License" shall mean the terms and conditions for use, reproduction, 56 | and distribution as defined by Sections 1 through 9 of this document. 57 | 58 | "Licensor" shall mean the copyright owner or entity authorized by 59 | the copyright owner that is granting the License. 60 | 61 | "Legal Entity" shall mean the union of the acting entity and all 62 | other entities that control, are controlled by, or are under common 63 | control with that entity. For the purposes of this definition, 64 | "control" means (i) the power, direct or indirect, to cause the 65 | direction or management of such entity, whether by contract or 66 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 67 | outstanding shares, or (iii) beneficial ownership of such entity. 68 | 69 | "You" (or "Your") shall mean an individual or Legal Entity 70 | exercising permissions granted by this License. 71 | 72 | "Source" form shall mean the preferred form for making modifications, 73 | including but not limited to software source code, documentation 74 | source, and configuration files. 75 | 76 | "Object" form shall mean any form resulting from mechanical 77 | transformation or translation of a Source form, including but 78 | not limited to compiled object code, generated documentation, 79 | and conversions to other media types. 80 | 81 | "Work" shall mean the work of authorship, whether in Source or 82 | Object form, made available under the License, as indicated by a 83 | copyright notice that is included in or attached to the work 84 | (an example is provided in the Appendix below). 85 | 86 | "Derivative Works" shall mean any work, whether in Source or Object 87 | form, that is based on (or derived from) the Work and for which the 88 | editorial revisions, annotations, elaborations, or other modifications 89 | represent, as a whole, an original work of authorship. For the purposes 90 | of this License, Derivative Works shall not include works that remain 91 | separable from, or merely link (or bind by name) to the interfaces of, 92 | the Work and Derivative Works thereof. 93 | 94 | "Contribution" shall mean any work of authorship, including 95 | the original version of the Work and any modifications or additions 96 | to that Work or Derivative Works thereof, that is intentionally 97 | submitted to Licensor for inclusion in the Work by the copyright owner 98 | or by an individual or Legal Entity authorized to submit on behalf of 99 | the copyright owner. For the purposes of this definition, "submitted" 100 | means any form of electronic, verbal, or written communication sent 101 | to the Licensor or its representatives, including but not limited to 102 | communication on electronic mailing lists, source code control systems, 103 | and issue tracking systems that are managed by, or on behalf of, the 104 | Licensor for the purpose of discussing and improving the Work, but 105 | excluding communication that is conspicuously marked or otherwise 106 | designated in writing by the copyright owner as "Not a Contribution." 107 | 108 | "Contributor" shall mean Licensor and any individual or Legal Entity 109 | on behalf of whom a Contribution has been received by Licensor and 110 | subsequently incorporated within the Work. 111 | 112 | 2. Grant of Copyright License. Subject to the terms and conditions of 113 | this License, each Contributor hereby grants to You a perpetual, 114 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 115 | copyright license to reproduce, prepare Derivative Works of, 116 | publicly display, publicly perform, sublicense, and distribute the 117 | Work and such Derivative Works in Source or Object form. 118 | 119 | 3. Grant of Patent License. Subject to the terms and conditions of 120 | this License, each Contributor hereby grants to You a perpetual, 121 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 122 | (except as stated in this section) patent license to make, have made, 123 | use, offer to sell, sell, import, and otherwise transfer the Work, 124 | where such license applies only to those patent claims licensable 125 | by such Contributor that are necessarily infringed by their 126 | Contribution(s) alone or by combination of their Contribution(s) 127 | with the Work to which such Contribution(s) was submitted. If You 128 | institute patent litigation against any entity (including a 129 | cross-claim or counterclaim in a lawsuit) alleging that the Work 130 | or a Contribution incorporated within the Work constitutes direct 131 | or contributory patent infringement, then any patent licenses 132 | granted to You under this License for that Work shall terminate 133 | as of the date such litigation is filed. 134 | 135 | 4. Redistribution. You may reproduce and distribute copies of the 136 | Work or Derivative Works thereof in any medium, with or without 137 | modifications, and in Source or Object form, provided that You 138 | meet the following conditions: 139 | 140 | (a) You must give any other recipients of the Work or 141 | Derivative Works a copy of this License; and 142 | 143 | (b) You must cause any modified files to carry prominent notices 144 | stating that You changed the files; and 145 | 146 | (c) You must retain, in the Source form of any Derivative Works 147 | that You distribute, all copyright, patent, trademark, and 148 | attribution notices from the Source form of the Work, 149 | excluding those notices that do not pertain to any part of 150 | the Derivative Works; and 151 | 152 | (d) If the Work includes a "NOTICE" text file as part of its 153 | distribution, then any Derivative Works that You distribute must 154 | include a readable copy of the attribution notices contained 155 | within such NOTICE file, excluding those notices that do not 156 | pertain to any part of the Derivative Works, in at least one 157 | of the following places: within a NOTICE text file distributed 158 | as part of the Derivative Works; within the Source form or 159 | documentation, if provided along with the Derivative Works; or, 160 | within a display generated by the Derivative Works, if and 161 | wherever such third-party notices normally appear. The contents 162 | of the NOTICE file are for informational purposes only and 163 | do not modify the License. You may add Your own attribution 164 | notices within Derivative Works that You distribute, alongside 165 | or as an addendum to the NOTICE text from the Work, provided 166 | that such additional attribution notices cannot be construed 167 | as modifying the License. 168 | 169 | You may add Your own copyright statement to Your modifications and 170 | may provide additional or different license terms and conditions 171 | for use, reproduction, or distribution of Your modifications, or 172 | for any such Derivative Works as a whole, provided Your use, 173 | reproduction, and distribution of the Work otherwise complies with 174 | the conditions stated in this License. 175 | 176 | 5. Submission of Contributions. Unless You explicitly state otherwise, 177 | any Contribution intentionally submitted for inclusion in the Work 178 | by You to the Licensor shall be under the terms and conditions of 179 | this License, without any additional terms or conditions. 180 | Notwithstanding the above, nothing herein shall supersede or modify 181 | the terms of any separate license agreement you may have executed 182 | with Licensor regarding such Contributions. 183 | 184 | 6. Trademarks. This License does not grant permission to use the trade 185 | names, trademarks, service marks, or product names of the Licensor, 186 | except as required for reasonable and customary use in describing the 187 | origin of the Work and reproducing the content of the NOTICE file. 188 | 189 | 7. Disclaimer of Warranty. Unless required by applicable law or 190 | agreed to in writing, Licensor provides the Work (and each 191 | Contributor provides its Contributions) on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 193 | implied, including, without limitation, any warranties or conditions 194 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 195 | PARTICULAR PURPOSE. You are solely responsible for determining the 196 | appropriateness of using or redistributing the Work and assume any 197 | risks associated with Your exercise of permissions under this License. 198 | 199 | 8. Limitation of Liability. In no event and under no legal theory, 200 | whether in tort (including negligence), contract, or otherwise, 201 | unless required by applicable law (such as deliberate and grossly 202 | negligent acts) or agreed to in writing, shall any Contributor be 203 | liable to You for damages, including any direct, indirect, special, 204 | incidental, or consequential damages of any character arising as a 205 | result of this License or out of the use or inability to use the 206 | Work (including but not limited to damages for loss of goodwill, 207 | work stoppage, computer failure or malfunction, or any and all 208 | other commercial damages or losses), even if such Contributor 209 | has been advised of the possibility of such damages. 210 | 211 | 9. Accepting Warranty or Additional Liability. While redistributing 212 | the Work or Derivative Works thereof, You may choose to offer, 213 | and charge a fee for, acceptance of support, warranty, indemnity, 214 | or other liability obligations and/or rights consistent with this 215 | License. However, in accepting such obligations, You may act only 216 | on Your own behalf and on Your sole responsibility, not on behalf 217 | of any other Contributor, and only if You agree to indemnify, 218 | defend, and hold each Contributor harmless for any liability 219 | incurred by, or claims asserted against, such Contributor by reason 220 | of your accepting any such warranty or additional liability. 221 | 222 | END OF TERMS AND CONDITIONS 223 | 224 | APPENDIX: How to apply the Apache License to your work. 225 | 226 | To apply the Apache License to your work, attach the following 227 | boilerplate notice, with the fields enclosed by brackets "[]" 228 | replaced with your own identifying information. (Don't include 229 | the brackets!) The text should be enclosed in the appropriate 230 | comment syntax for the file format. We also recommend that a 231 | file or class name and description of purpose be included on the 232 | same "printed page" as the copyright notice for easier 233 | identification within third-party archives. 234 | 235 | Copyright [2020] TheVoid 236 | 237 | Licensed under the Apache License, Version 2.0 (the "License"); 238 | you may not use this file except in compliance with the License. 239 | You may obtain a copy of the License at 240 | 241 | http://www.apache.org/licenses/LICENSE-2.0 242 | 243 | Unless required by applicable law or agreed to in writing, software 244 | distributed under the License is distributed on an "AS IS" BASIS, 245 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 246 | See the License for the specific language governing permissions and 247 | limitations under the License. -------------------------------------------------------------------------------- /demo_gif/1_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheColdVoid/nbnhhsh-alfred-workflow/9a6ab04fce2fece5dcc84db43ad56aa19a93c9a0/demo_gif/1_0.gif -------------------------------------------------------------------------------- /demo_gif/2_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheColdVoid/nbnhhsh-alfred-workflow/9a6ab04fce2fece5dcc84db43ad56aa19a93c9a0/demo_gif/2_1.gif -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | 7 | connections 8 | 9 | createdby 10 | TheVoid,itorr 11 | description 12 | 「能不能好好说话?」 拼音首字母缩写翻译工具的Alfred工作流版本 13 | disabled 14 | 15 | name 16 | 能不能好好说话 17 | objects 18 | 19 | 20 | config 21 | 22 | alfredfiltersresults 23 | 24 | alfredfiltersresultsmatchmode 25 | 0 26 | argumenttreatemptyqueryasnil 27 | 28 | argumenttrimmode 29 | 0 30 | argumenttype 31 | 0 32 | escaping 33 | 32 34 | keyword 35 | sx 36 | queuedelaycustom 37 | 3 38 | queuedelayimmediatelyinitially 39 | 40 | queuedelaymode 41 | 1 42 | queuemode 43 | 2 44 | runningsubtext 45 | cxz...... 46 | script 47 | /usr/bin/python query.py '{query}' 48 | 49 | scriptargtype 50 | 0 51 | scriptfile 52 | 53 | subtext 54 | 通过「能不能好好说话?」网站查询缩写 55 | title 56 | 查询缩写 57 | type 58 | 0 59 | withspace 60 | 61 | 62 | type 63 | alfred.workflow.input.scriptfilter 64 | uid 65 | 75B0EE27-8969-4A08-821B-C1FBD8368F4C 66 | version 67 | 3 68 | 69 | 70 | config 71 | 72 | action 73 | 1 74 | argument 75 | 1 76 | argumenttext 77 | sx 78 | focusedappvariable 79 | 80 | focusedappvariablename 81 | 82 | hotkey 83 | 1 84 | hotmod 85 | 1179648 86 | hotstring 87 | S 88 | leftcursor 89 | 90 | modsmode 91 | 0 92 | relatedAppsMode 93 | 0 94 | 95 | type 96 | alfred.workflow.trigger.hotkey 97 | uid 98 | 53A5BA63-2278-4D9B-BCDC-D7F64C4B6057 99 | version 100 | 2 101 | 102 | 103 | readme 104 | 「能不能好好说话?」 拼音首字母缩写翻译工具的Alfred工作流版本 105 | 原版:https://github.com/itorr/nbnhhsh 106 | uidata 107 | 108 | 53A5BA63-2278-4D9B-BCDC-D7F64C4B6057 109 | 110 | xpos 111 | 135 112 | ypos 113 | 350 114 | 115 | 75B0EE27-8969-4A08-821B-C1FBD8368F4C 116 | 117 | xpos 118 | 130 119 | ypos 120 | 200 121 | 122 | 123 | variablesdontexport 124 | 125 | version 126 | 1.0.1 127 | webaddress 128 | https://github.com/TheColdVoid/nbnhhsh 129 | 130 | 131 | -------------------------------------------------------------------------------- /query.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import sys 4 | 5 | from workflow import web, Workflow3 6 | 7 | reload(sys) 8 | sys.setdefaultencoding('utf-8') 9 | 10 | 11 | def main(wf=Workflow3()): 12 | queryStr = wf.args[0].strip() 13 | result = queryTrans(queryStr) 14 | for word in result: 15 | wf.add_item(word) 16 | if len(result) == 0: 17 | wf.add_item(u'未查询到该缩写的翻译') 18 | wf.send_feedback() 19 | 20 | 21 | def queryTrans(queryStr): 22 | data = json.dumps({'text': queryStr}) 23 | 24 | res = web.post('https://lab.magiconch.com/api/nbnhhsh/guess' 25 | , headers={'content-type': 'application/json'} 26 | , data=str(data) 27 | ) 28 | 29 | if res.status_code != 200: 30 | return [u"错误" + res.status_code] 31 | 32 | resJson = res.json() 33 | if len(resJson) == 0: 34 | return [] 35 | 36 | if 'trans' in resJson[0]: 37 | return resJson[0]['trans'] 38 | if 'inputting' in resJson[0]: 39 | return resJson[0]['inputting'] 40 | 41 | 42 | if __name__ == '__main__': 43 | wf = Workflow3() 44 | sys.exit(wf.run(main)) 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 「能不能好好说话?」 拼音首字母缩写翻译工具的Alfred工具流版本 2 | [![LICENSE](https://img.shields.io/badge/license-Anti%20996-blue.svg?style=flat-square)](https://github.com/996icu/996.ICU/blob/master/LICENSE) 3 | 4 |
5 | 6 | 基于 7 | [@itorr](https://github.com/itorr) 8 | 的 9 | [能不能好好说话?](https://github.com/itorr/nbnhhsh) 10 | 工具制作的一个[Alfred](https://www.alfredapp.com)工具流, 11 | 12 | 13 | 14 | 可以用于很方便地查询拼音首字母缩写 15 | 16 | 17 | 18 | 工具流文件下载:https://github.com/TheColdVoid/nbnhhsh/raw/master/nbnhhsh.alfredworkflow 19 | 20 | 21 | 22 | 安装Alfred后双击该工具流文件即可自动导入至Alfred 23 |

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