├── .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 | [](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 | 
27 | * 使用快捷键划词查询
28 | 
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 |
--------------------------------------------------------------------------------