├── .gitignore
├── LICENSE
├── README.md
├── assets
├── config.png
├── demo.gif
└── demosheet.png
├── cheat.py
├── config.py
├── icon.png
├── info.plist
├── lib
├── __init__.py
├── config.py
├── options.py
└── parser.py
├── setup.cfg
├── version
└── workflow
├── .alfredversionchecked
├── Notify.tgz
├── __init__.py
├── background.py
├── notify.py
├── update.py
├── util.py
├── version
├── web.py
├── workflow.py
└── workflow3.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | wheels/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 | MANIFEST
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *.cover
46 | .hypothesis/
47 | .pytest_cache/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 | db.sqlite3
57 |
58 | # Flask stuff:
59 | instance/
60 | .webassets-cache
61 |
62 | # Scrapy stuff:
63 | .scrapy
64 |
65 | # Sphinx documentation
66 | docs/_build/
67 |
68 | # PyBuilder
69 | target/
70 |
71 | # Jupyter Notebook
72 | .ipynb_checkpoints
73 |
74 | # pyenv
75 | .python-version
76 |
77 | # celery beat schedule file
78 | celerybeat-schedule
79 |
80 | # SageMath parsed files
81 | *.sage.py
82 |
83 | # Environments
84 | .env
85 | .venv
86 | env/
87 | venv/
88 | ENV/
89 | env.bak/
90 | venv.bak/
91 |
92 | # Spyder project settings
93 | .spyderproject
94 | .spyproject
95 |
96 | # Rope project settings
97 | .ropeproject
98 |
99 | # mkdocs documentation
100 | /site
101 |
102 | # mypy
103 | .mypy_cache/
104 | .DS_Store
105 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Wayne Yao
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Alfred-cheat

2 | **Start writing your very own cheat sheets in your way and make them searchable using Alfred!**
3 |
4 | # Notice
5 |
6 | - Thanks to [@giovannicoppola](https://github.com/giovannicoppola) for porting a [python3 version dependency](https://github.com/NorthIsUp/alfred-workflow-py3) so that this workflow works on macOS >= 12.3 now where default Python version is Python3. Starting from alfred-cheat 1.2.1, the workflow requires Python3 to run, and any version lower than 1.2.1 requires python2. So depending on you macOS version, you migth want to figure out whether you need to install an extra python.
7 |
8 | - I'm fully switching to Linux DE for personal tasks and Windows for daily work hence won't be able to put a lot of effort on this workflow. It served me well for a long time. I'm still open to new Pull Request if you think new features should be added. But generally this workflow doesn't have a big scope and should always work.
9 |
10 | # Demo
11 |
12 | *Every sheet shown in the demo should be your knowledge base and is totally customizable.*
13 |
14 | 
15 |
16 | # About & Acknowledgement
17 |
18 | This project was initally inspired by [cheat](https://github.com/cheat/cheat). I attempted to wrap around it but failed because that project wasn't intended to be wrapped around. So this project ended up a separate one. These two projects serve similar purpose in different working environments. With the help of alfred, your efficiency in searching your cheat sheets will be significantly boosted. And the better news is, you're in complete control of your cheat sheets unlike [tldr](https://github.com/tldr-pages/tldr) (It's good though if you want it "just work").
19 |
20 | I built this workflow because:
21 |
22 | 1. I want faster searching than the original [cheat](https://github.com/cheat/cheat) because that project is commandline based. Sometimes I want a very quick view and don't wanna popup a shell.
23 |
24 | 2. I want to build my own knowledge base instead of community-driven cheat sheets like [tldr](https://github.com/tldr-pages/tldr) does.
25 |
26 | **Disclaimer**:
27 |
28 | All codes in directory `workflow` are dependencies from [this project](https://github.com/deanishe/alfred-workflow). They're not my work and is the only "dependency" for this project. Since it's included in this repo, the workflow user doesn't have to concern about dependencies.
29 |
30 | # Download via [release](https://github.com/wx-Yao/alfred-cheat/releases)
31 |
32 | # How it works
33 |
34 | 1. You define a directory to store your cheat sheets, which are essentially text files. And name the file the command your wanna record. e.g, `nmap`, `top`, `tar` etc. (tips: you can start with the cheat sheets provided by [cheat](https://github.com/cheat/cheat))
35 |
36 | 2. You write your cheat sheet according to the [rules](#parsing-rule) (very intuitive and tolerant) bit by bit.
37 |
38 | 3. Tell the workflow where that directory is and start searching.
39 |
40 | # Usage
41 |
42 | First, you need to specify your sheet directory like this. Otherwise, it doesn't work. Both absolute or relative path will work.
43 |
44 | 
45 |
46 | Then, you're good to go.
47 |
48 | - To list all your cheat: `cheat`
49 |
50 | - To search and list the content of one of your cheat: `cheat `. Fuzzy search and autocomplete is supported.
51 |
52 | - To search in a specific sheet indexed by some keyword: `cheat `.
53 |
54 | - To search across all your sheets for some keyword: `cheat --search/-s `
55 |
56 | - When you find your desired record and you wanna paste it directly to the app you're using (e.g., Terminal or iTerm2), hit `Enter`. This behavior can be changed in the Alfred setting ([#3](https://github.com/wx-Yao/alfred-cheat/issues/2#issuecomment-509689404)).
57 |
58 | - If you like to just copy, hit `cmd-c`.
59 |
60 | # Parsing rule
61 |
62 | It's not even a rule... You just need to remember two things when writing your cheat sheet:
63 |
64 | 1. Comment first, then the command.
65 |
66 | 2. Separate each `comment, command` pair with 2 newlines. (one newline visually)
67 |
68 | That's it.
69 |
70 | e.g. this cheat sheet is called `demosheet`. Its content is the following:
71 |
72 | ```
73 | # This is a one line comment.
74 | command one goes here.
75 |
76 | # This is a second comment for the second command
77 | # Yes we can have multiple line comment.
78 | # But remember only the last line will be considered "command".
79 | command two goes here
80 |
81 | #
82 | command three: in rare cases you don't have any comment, keep an empty # above.
83 |
84 | # Any failed parsing will be ignored, like this line because it isn't associated with a command
85 |
86 | or this line because it's a single line.
87 | ```
88 |
89 | The above sheet will be parsed like this:
90 |
91 | 
92 |
93 | Kindly note that **hidden cheatsheets (starting with `.`) will be ignored and hidden directory will be ignored as well**. Hierachical structure is supported but that's only for your management purpose. This tool will only "flatten" every cheatsheets in the base directory. i.e., `cheat/mydir/yourdir/somecheat` will be equivalent to `cheat/somecheat` in its perspective. Also make sure you don't have duplicated cheatsheets in different directories otherwise only one of them will be dominant. Thanks for [@Blackvz](https://github.com/Blackvz) for the feature suggestion [#4](https://github.com/wx-Yao/alfred-cheat/issues/4).
94 |
95 | # Compatibility
96 |
97 | This workflow works out of the box (zero dependencies). It's tested on **macOS 10.14.5 Mojave** with **Alfred 4**. You need the [powerpack](https://www.alfredapp.com/shop/) to get it working. I believe it works with Alfred3 on any macOS after 10.10 Yosemite but that hasn't been tested. Report an issue if there's a problem.
98 |
99 | # Contribution
100 |
101 | Any idea of improvement will be welcomed. But I don't wanna add the feature of modifying cheat sheet right in Alfred because it isn't what it is supposed to do. Use vim or other editors you like.
102 |
--------------------------------------------------------------------------------
/assets/config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/config.png
--------------------------------------------------------------------------------
/assets/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/demo.gif
--------------------------------------------------------------------------------
/assets/demosheet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/demosheet.png
--------------------------------------------------------------------------------
/cheat.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | from sys import exit
3 | from workflow import Workflow3 as Workflow
4 | from lib.parser import Parser
5 | from lib.options import Options
6 |
7 |
8 | def main(workflow):
9 | # Try to read configuration from local disk
10 | config = workflow.stored_data("configuration")
11 | if config is None:
12 | Options.warning("Didn't find your configuration", "Please supply your cheat sheet path using 'cf ~/your/path'", workflow)
13 | workflow.send_feedback()
14 | return -1
15 |
16 | parser = Parser(config.getPath())
17 | # Note: by pasing workflow as a variable, its state is changed in Options.py logic
18 | options = Options(parser, workflow)
19 |
20 | # Query is whatever comes after "cheat". Stored in one single variable
21 | query = "" if len(workflow.args) == 0 else workflow.args[0]
22 | tokens = query.strip().split(" ", 1) # len 2 list
23 | tokens = [i.strip() for i in tokens if i != ""]
24 |
25 | if len(tokens) == 0:
26 | options.showAvailable()
27 | workflow.send_feedback()
28 | return None
29 |
30 | if len(tokens) == 1 and tokens[0] in ("--search", "-s"):
31 | Options.hint("Globally searching for ...?", "In global mode", workflow)
32 | workflow.send_feedback()
33 | return None
34 |
35 | if len(tokens) == 1 and tokens[0] not in parser.availableSheets():
36 | options.showAvailable(tokens[0])
37 | workflow.send_feedback()
38 | return None
39 |
40 | if len(tokens) == 1:
41 | options.list(tokens[0])
42 | workflow.send_feedback()
43 | return None
44 |
45 | sheetName = None if tokens[0] in ["--search", "-s"] else tokens[0]
46 | searchTerm = tokens[1]
47 | options.searchInSheetByKeyword(sheetName, searchTerm)
48 |
49 | workflow.send_feedback()
50 | return None
51 |
52 |
53 | if __name__ == "__main__":
54 | workflow = Workflow()
55 | exit(workflow.run(main))
56 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 |
3 | # Separated unit for configuration, in case we have extra features in the future.
4 | from workflow import Workflow3 as Workflow
5 | from lib.config import Config
6 | from workflow.notify import notify
7 |
8 | def main(workflow):
9 | path=workflow.args[0].strip()
10 | config=Config(path)
11 | if config.validate():
12 | # Behavior: overwrite existing data
13 | workflow.store_data("configuration", config)
14 | notify(title="Success!", text="Cheat sheets updated to {}".format(config.getPath()))
15 | else:
16 | notify(title="Error:(", text="The path doesn't exist")
17 | return 0
18 |
19 | if __name__=="__main__":
20 | workflow=Workflow()
21 | exit(workflow.run(main))
22 |
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/icon.png
--------------------------------------------------------------------------------
/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | com.wayneyao.cheat
7 | connections
8 |
9 | 5C498F63-6E9D-4A07-B42A-727B78BE3825
10 |
11 |
12 | destinationuid
13 | 92C80F9D-D58C-469A-9534-FCEC27394F49
14 | modifiers
15 | 0
16 | modifiersubtext
17 |
18 | vitoclose
19 |
20 |
21 |
22 | destinationuid
23 | 01426939-712C-4E7E-A86C-41FB76D04041
24 | modifiers
25 | 0
26 | modifiersubtext
27 |
28 | vitoclose
29 |
30 |
31 |
32 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA
33 |
34 |
35 | destinationuid
36 | A6FA6395-A792-4367-9D59-7B4619B74892
37 | modifiers
38 | 0
39 | modifiersubtext
40 |
41 | vitoclose
42 |
43 |
44 |
45 | A6FA6395-A792-4367-9D59-7B4619B74892
46 |
47 |
48 | createdby
49 | Wayne Yao
50 | description
51 | Manage your self-defined cheat sheets & knowledge base in Alfred
52 | disabled
53 |
54 | name
55 | Cheat
56 | objects
57 |
58 |
59 | config
60 |
61 | autopaste
62 |
63 | clipboardtext
64 | {query}
65 | ignoredynamicplaceholders
66 |
67 | transient
68 |
69 |
70 | type
71 | alfred.workflow.output.clipboard
72 | uid
73 | 92C80F9D-D58C-469A-9534-FCEC27394F49
74 | version
75 | 3
76 |
77 |
78 | config
79 |
80 | alfredfiltersresults
81 |
82 | alfredfiltersresultsmatchmode
83 | 0
84 | argumenttreatemptyqueryasnil
85 |
86 | argumenttrimmode
87 | 1
88 | argumenttype
89 | 1
90 | escaping
91 | 102
92 | keyword
93 | cheat
94 | queuedelaycustom
95 | 1
96 | queuedelayimmediatelyinitially
97 |
98 | queuedelaymode
99 | 2
100 | queuemode
101 | 1
102 | runningsubtext
103 | wait a sec...
104 | script
105 | export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}"
106 | python3 cheat.py "{query}"
107 | scriptargtype
108 | 0
109 | scriptfile
110 |
111 | subtext
112 |
113 | title
114 | Search through your personal cheat sheets
115 | type
116 | 0
117 | withspace
118 |
119 |
120 | type
121 | alfred.workflow.input.scriptfilter
122 | uid
123 | 5C498F63-6E9D-4A07-B42A-727B78BE3825
124 | version
125 | 3
126 |
127 |
128 | config
129 |
130 | lastpathcomponent
131 |
132 | onlyshowifquerypopulated
133 |
134 | removeextension
135 |
136 | text
137 | command "{query}" pasted
138 | title
139 | Success
140 |
141 | type
142 | alfred.workflow.output.notification
143 | uid
144 | 01426939-712C-4E7E-A86C-41FB76D04041
145 | version
146 | 1
147 |
148 |
149 | config
150 |
151 | argumenttype
152 | 0
153 | keyword
154 | cf
155 | subtext
156 | e.g. /Users/wayne/.cheat or ~/.cheat
157 | text
158 | Configure your cheat sheet directory
159 | withspace
160 |
161 |
162 | type
163 | alfred.workflow.input.keyword
164 | uid
165 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA
166 | version
167 | 1
168 |
169 |
170 | config
171 |
172 | concurrently
173 |
174 | escaping
175 | 102
176 | script
177 | export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}"
178 | python3 config.py "{query}"
179 | scriptargtype
180 | 0
181 | scriptfile
182 |
183 | type
184 | 0
185 |
186 | type
187 | alfred.workflow.action.script
188 | uid
189 | A6FA6395-A792-4367-9D59-7B4619B74892
190 | version
191 | 2
192 |
193 |
194 | readme
195 | Refer to github https://github.com/wayneyaoo/Alfred-cheat for details.
196 | uidata
197 |
198 | 01426939-712C-4E7E-A86C-41FB76D04041
199 |
200 | xpos
201 | 470
202 | ypos
203 | 160
204 |
205 | 5C498F63-6E9D-4A07-B42A-727B78BE3825
206 |
207 | xpos
208 | 165
209 | ypos
210 | 90
211 |
212 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA
213 |
214 | xpos
215 | 165
216 | ypos
217 | 305
218 |
219 | 92C80F9D-D58C-469A-9534-FCEC27394F49
220 |
221 | xpos
222 | 465
223 | ypos
224 | 15
225 |
226 | A6FA6395-A792-4367-9D59-7B4619B74892
227 |
228 | xpos
229 | 480
230 | ypos
231 | 305
232 |
233 |
234 | userconfigurationconfig
235 |
236 | variablesdontexport
237 |
238 | version
239 | 1.2.1
240 | webaddress
241 | https://github.com/wayneyaoo
242 |
243 |
244 |
--------------------------------------------------------------------------------
/lib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/lib/__init__.py
--------------------------------------------------------------------------------
/lib/config.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | import os
3 |
4 |
5 | class Config:
6 | def __init__(self, cheatPath):
7 | self._cheatPath = cheatPath
8 | return None
9 |
10 | def validate(self):
11 | if os.path.exists(os.path.expanduser(self._cheatPath)):
12 | self._cheatPath = os.path.expanduser(self._cheatPath)
13 | return True
14 | return False
15 |
16 | def getPath(self):
17 | return self._cheatPath
18 |
--------------------------------------------------------------------------------
/lib/options.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | from workflow.workflow import ICON_HELP as WARNINGICON
3 | from workflow.workflow import ICON_NOTE as HINT
4 |
5 |
6 | # Switches that autually controls the workflow behavior
7 | class Options:
8 |
9 | LARGETEXTPATTERN = "{}\n\n{}"
10 |
11 | def __init__(self, parser, workflow):
12 | self._parser = parser
13 | self._workflow = workflow
14 | return None
15 |
16 | def searchInSheetByKeyword(self, sheetName, keyword):
17 | if sheetName is None:
18 | ret = self._parser.searchAcrossAll(keyword, self._workflow)
19 | else:
20 | if sheetName not in self._parser.availableSheets():
21 | Options.warning("Cheat sheet not found.", "", self._workflow)
22 | return None
23 | ret = self._parser.searchInSheet(keyword, sheetName, self._workflow)
24 | if ret == []:
25 | Options.warning("Not found", "No match found for search {}".format(keyword), self._workflow)
26 | return None
27 | for item in ret:
28 | self._workflow.add_item(
29 | title=item["command"],
30 | subtitle=item["comment"],
31 | copytext=item.get("command"),
32 | valid=True,
33 | arg=item.get("command"),
34 | largetext=self.LARGETEXTPATTERN.format(item.get("command"), item.get("comment"))
35 | ).add_modifier(
36 | 'cmd',
37 | subtitle="open in editor",
38 | valid=True,
39 | arg=self._parser._sheetMapping.get(sheetName))
40 | return None
41 |
42 | def list(self, sheetName):
43 | ret = self._parser.list(sheetName)
44 | if ret == []:
45 | Options.hint("Empty cheatsheet", "", self._workflow)
46 | for item in ret:
47 | self._workflow.add_item(
48 | title=item.get("command"),
49 | subtitle=item.get("comment"),
50 | valid=True,
51 | copytext=item.get("command"),
52 | arg=item.get("command"),
53 | largetext=self.LARGETEXTPATTERN.format(item.get("command"), item.get("comment"))
54 | ).add_modifier(
55 | 'cmd',
56 | subtitle="open in editor",
57 | valid=True,
58 | arg=self._parser._sheetMapping.get(sheetName))
59 | return None
60 |
61 | def showAvailable(self, sheetName=""):
62 | ret = self._FilterSheetName(sheetName)
63 | if ret == []:
64 | Options.warning("Cheat sheet not found.", "", self._workflow)
65 | return None
66 | for sheet in ret:
67 | self._workflow.add_item(
68 | title=sheet,
69 | autocomplete=sheet,
70 | largetext=sheet
71 | ).add_modifier(
72 | 'cmd',
73 | subtitle="open in editor",
74 | valid=True,
75 | arg=self._parser._sheetMapping.get(sheet))
76 | return None
77 |
78 | def _FilterSheetName(self, query):
79 | names = self._parser.availableSheets()
80 | return self._workflow.filter(query, names, key=lambda x: x)
81 |
82 | @staticmethod
83 | def warning(msg, subtitle, workflow):
84 | workflow.warn_empty(
85 | title=msg,
86 | subtitle=subtitle,
87 | icon=WARNINGICON,
88 | )
89 | return None
90 |
91 | @staticmethod
92 | def hint(msg, subtitle, workflow):
93 | workflow.warn_empty(
94 | title=msg,
95 | subtitle=subtitle,
96 | icon=HINT,
97 | )
98 | return None
99 |
--------------------------------------------------------------------------------
/lib/parser.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | from workflow import MATCH_ALL, MATCH_ALLCHARS
4 |
5 |
6 | class Parser:
7 | def __init__(self, path):
8 | self._path = path
9 | self._sheetMapping = {} # {cheatsheet: /home/somedir/cheatsheet}
10 | self._available = self._enumAvailableSheets()
11 | return None
12 |
13 | def availableSheets(self):
14 | return self._available
15 |
16 | def _enumAvailableSheets(self):
17 | ret = []
18 | for root, dirname, files in os.walk(self._path, followlinks=True):
19 | dirname[:] = [d for d in dirname if not d.startswith(".")]
20 | files = [f for f in files if not f.startswith(".")]
21 | ret.extend(files)
22 | # update the cheat sheet mapping so that we can find the file location
23 | self._sheetMapping.update({cheatsheet: "".join([root, "/", cheatsheet]) for cheatsheet in files})
24 | return ret
25 |
26 | def list(self, sheetName):
27 | return [] if sheetName not in self._available else self.__parseSheet(sheetName) # return value: [{}, {}, {}, ...]
28 |
29 | def searchAcrossAll(self, keyword, workflow):
30 | ret = []
31 | for sheet in self._available:
32 | ret.extend(self.__parseSheet(sheet))
33 | return self.filter(ret, keyword, workflow) # [{}, {}, {}...]
34 |
35 | def searchInSheet(self, keyword, sheetName, workflow):
36 | return self.filter(self.__parseSheet(sheetName), keyword, workflow)
37 |
38 | def __parseSheet(self, filename):
39 | with open(self._sheetMapping.get(filename), 'r') as f:
40 | content = f.read().strip()
41 | # Tokenize to get each "item" by spliting the "\n\n". This rule must be repspected
42 | content = [item.strip() for item in content.split("\n\n")]
43 | # ASSUME the item pattern is "comment, comment, comment ..., command"
44 | # An item doesn't have to contain comments but must have a command
45 | items = []
46 | for item in content:
47 | try:
48 | comment, command = item.rsplit("\n", 1)
49 | except ValueError:
50 | continue
51 | # Or you wanna see which line doesn't comform to the format
52 | # comment=""
53 | # command="Fail to parse: {}".format(item)
54 | # cleanup "#" and \n
55 | comment = comment.replace("#", "").replace("\n", ". ").strip()
56 | command = command.strip()
57 | items.append((comment, command))
58 | return [dict(comment=comment, command=command) for comment, command in items] # [{}, {}, {}...]
59 |
60 | def filter(self, content, keyword, workflow):
61 | def searchIndex(item):
62 | return u" ".join([item["comment"], item["command"]])
63 | return workflow.filter(keyword, content, key=searchIndex, match_on=MATCH_ALL ^ MATCH_ALLCHARS, min_score=50)
64 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [install]
2 | prefix=
3 |
--------------------------------------------------------------------------------
/version:
--------------------------------------------------------------------------------
1 | 1.2.2
2 |
--------------------------------------------------------------------------------
/workflow/.alfredversionchecked:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/workflow/.alfredversionchecked
--------------------------------------------------------------------------------
/workflow/Notify.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/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 | # Filter matching rules
16 | # Icons
17 | # Exceptions
18 | # Workflow objects
19 | from .workflow import (
20 | ICON_ACCOUNT,
21 | ICON_BURN,
22 | ICON_CLOCK,
23 | ICON_COLOR,
24 | ICON_COLOUR,
25 | ICON_EJECT,
26 | ICON_ERROR,
27 | ICON_FAVORITE,
28 | ICON_FAVOURITE,
29 | ICON_GROUP,
30 | ICON_HELP,
31 | ICON_HOME,
32 | ICON_INFO,
33 | ICON_NETWORK,
34 | ICON_NOTE,
35 | ICON_SETTINGS,
36 | ICON_SWIRL,
37 | ICON_SWITCH,
38 | ICON_SYNC,
39 | ICON_TRASH,
40 | ICON_USER,
41 | ICON_WARNING,
42 | ICON_WEB,
43 | MATCH_ALL,
44 | MATCH_ALLCHARS,
45 | MATCH_ATOM,
46 | MATCH_CAPITALS,
47 | MATCH_INITIALS,
48 | MATCH_INITIALS_CONTAIN,
49 | MATCH_INITIALS_STARTSWITH,
50 | MATCH_STARTSWITH,
51 | MATCH_SUBSTRING,
52 | KeychainError,
53 | PasswordNotFound,
54 | Workflow,
55 | manager,
56 | )
57 | from .workflow3 import Variables, Workflow3
58 |
59 | __title__ = "Alfred-Workflow"
60 | __version__ = open(os.path.join(os.path.dirname(__file__), "version")).read()
61 | __author__ = "Dean Jackson"
62 | __licence__ = "MIT"
63 | __copyright__ = "Copyright 2014-2019 Dean Jackson"
64 |
65 | __all__ = [
66 | "Variables",
67 | "Workflow",
68 | "Workflow3",
69 | "manager",
70 | "PasswordNotFound",
71 | "KeychainError",
72 | "ICON_ACCOUNT",
73 | "ICON_BURN",
74 | "ICON_CLOCK",
75 | "ICON_COLOR",
76 | "ICON_COLOUR",
77 | "ICON_EJECT",
78 | "ICON_ERROR",
79 | "ICON_FAVORITE",
80 | "ICON_FAVOURITE",
81 | "ICON_GROUP",
82 | "ICON_HELP",
83 | "ICON_HOME",
84 | "ICON_INFO",
85 | "ICON_NETWORK",
86 | "ICON_NOTE",
87 | "ICON_SETTINGS",
88 | "ICON_SWIRL",
89 | "ICON_SWITCH",
90 | "ICON_SYNC",
91 | "ICON_TRASH",
92 | "ICON_USER",
93 | "ICON_WARNING",
94 | "ICON_WEB",
95 | "MATCH_ALL",
96 | "MATCH_ALLCHARS",
97 | "MATCH_ATOM",
98 | "MATCH_CAPITALS",
99 | "MATCH_INITIALS",
100 | "MATCH_INITIALS_CONTAIN",
101 | "MATCH_INITIALS_STARTSWITH",
102 | "MATCH_STARTSWITH",
103 | "MATCH_SUBSTRING",
104 | ]
105 |
--------------------------------------------------------------------------------
/workflow/background.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | #
3 | # Copyright (c) 2014 deanishe@deanishe.net
4 | #
5 | # MIT Licence. See http://opensource.org/licenses/MIT
6 | #
7 | # Created on 2014-04-06
8 | #
9 |
10 | """This module provides an API to run commands in background processes.
11 |
12 | Combine with the :ref:`caching API ` to work from cached data
13 | while you fetch fresh data in the background.
14 |
15 | See :ref:`the User Manual ` for more information
16 | and examples.
17 | """
18 |
19 |
20 | import os
21 | import pickle
22 | import signal
23 | import subprocess
24 | import sys
25 |
26 | from workflow import Workflow
27 |
28 | __all__ = ["is_running", "run_in_background"]
29 |
30 | _wf = None
31 |
32 |
33 | def wf():
34 | global _wf
35 | if _wf is None:
36 | _wf = Workflow()
37 | return _wf
38 |
39 |
40 | def _log():
41 | return wf().logger
42 |
43 |
44 | def _arg_cache(name):
45 | """Return path to pickle cache file for arguments.
46 |
47 | :param name: name of task
48 | :type name: ``unicode``
49 | :returns: Path to cache file
50 | :rtype: ``unicode`` filepath
51 |
52 | """
53 | return wf().cachefile(name + ".argcache")
54 |
55 |
56 | def _pid_file(name):
57 | """Return path to PID file for ``name``.
58 |
59 | :param name: name of task
60 | :type name: ``unicode``
61 | :returns: Path to PID file for task
62 | :rtype: ``unicode`` filepath
63 |
64 | """
65 | return wf().cachefile(name + ".pid")
66 |
67 |
68 | def _process_exists(pid):
69 | """Check if a process with PID ``pid`` exists.
70 |
71 | :param pid: PID to check
72 | :type pid: ``int``
73 | :returns: ``True`` if process exists, else ``False``
74 | :rtype: ``Boolean``
75 |
76 | """
77 | try:
78 | os.kill(pid, 0)
79 | except OSError: # not running
80 | return False
81 | return True
82 |
83 |
84 | def _job_pid(name):
85 | """Get PID of job or `None` if job does not exist.
86 |
87 | Args:
88 | name (str): Name of job.
89 |
90 | Returns:
91 | int: PID of job process (or `None` if job doesn't exist).
92 | """
93 | pidfile = _pid_file(name)
94 | if not os.path.exists(pidfile):
95 | return
96 |
97 | with open(pidfile, "rb") as fp:
98 | read = fp.read()
99 | # print(str(read))
100 | pid = int.from_bytes(read, sys.byteorder)
101 | # print(pid)
102 |
103 | if _process_exists(pid):
104 | return pid
105 |
106 | os.unlink(pidfile)
107 |
108 |
109 | def is_running(name):
110 | """Test whether task ``name`` is currently running.
111 |
112 | :param name: name of task
113 | :type name: unicode
114 | :returns: ``True`` if task with name ``name`` is running, else ``False``
115 | :rtype: bool
116 |
117 | """
118 | if _job_pid(name) is not None:
119 | return True
120 |
121 | return False
122 |
123 |
124 | def _background(
125 | pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null"
126 | ): # pragma: no cover
127 | """Fork the current process into a background daemon.
128 |
129 | :param pidfile: file to write PID of daemon process to.
130 | :type pidfile: filepath
131 | :param stdin: where to read input
132 | :type stdin: filepath
133 | :param stdout: where to write stdout output
134 | :type stdout: filepath
135 | :param stderr: where to write stderr output
136 | :type stderr: filepath
137 |
138 | """
139 |
140 | def _fork_and_exit_parent(errmsg, wait=False, write=False):
141 | try:
142 | pid = os.fork()
143 | if pid > 0:
144 | if write: # write PID of child process to `pidfile`
145 | tmp = pidfile + ".tmp"
146 | with open(tmp, "wb") as fp:
147 | fp.write(pid.to_bytes(4, sys.byteorder))
148 | os.rename(tmp, pidfile)
149 | if wait: # wait for child process to exit
150 | os.waitpid(pid, 0)
151 | os._exit(0)
152 | except OSError as err:
153 | _log().critical("%s: (%d) %s", errmsg, err.errno, err.strerror)
154 | raise err
155 |
156 | # Do first fork and wait for second fork to finish.
157 | _fork_and_exit_parent("fork #1 failed", wait=True)
158 |
159 | # Decouple from parent environment.
160 | os.chdir(wf().workflowdir)
161 | os.setsid()
162 |
163 | # Do second fork and write PID to pidfile.
164 | _fork_and_exit_parent("fork #2 failed", write=True)
165 |
166 | # Now I am a daemon!
167 | # Redirect standard file descriptors.
168 | si = open(stdin, "r", 1)
169 | so = open(stdout, "a+", 1)
170 | se = open(stderr, "a+", 1)
171 | if hasattr(sys.stdin, "fileno"):
172 | os.dup2(si.fileno(), sys.stdin.fileno())
173 | if hasattr(sys.stdout, "fileno"):
174 | os.dup2(so.fileno(), sys.stdout.fileno())
175 | if hasattr(sys.stderr, "fileno"):
176 | os.dup2(se.fileno(), sys.stderr.fileno())
177 |
178 |
179 | def kill(name, sig=signal.SIGTERM):
180 | """Send a signal to job ``name`` via :func:`os.kill`.
181 |
182 | .. versionadded:: 1.29
183 |
184 | Args:
185 | name (str): Name of the job
186 | sig (int, optional): Signal to send (default: SIGTERM)
187 |
188 | Returns:
189 | bool: `False` if job isn't running, `True` if signal was sent.
190 | """
191 | pid = _job_pid(name)
192 | if pid is None:
193 | return False
194 |
195 | os.kill(pid, sig)
196 | return True
197 |
198 |
199 | def run_in_background(name, args, **kwargs):
200 | r"""Cache arguments then call this script again via :func:`subprocess.call`.
201 |
202 | :param name: name of job
203 | :type name: unicode
204 | :param args: arguments passed as first argument to :func:`subprocess.call`
205 | :param \**kwargs: keyword arguments to :func:`subprocess.call`
206 | :returns: exit code of sub-process
207 | :rtype: int
208 |
209 | When you call this function, it caches its arguments and then calls
210 | ``background.py`` in a subprocess. The Python subprocess will load the
211 | cached arguments, fork into the background, and then run the command you
212 | specified.
213 |
214 | This function will return as soon as the ``background.py`` subprocess has
215 | forked, returning the exit code of *that* process (i.e. not of the command
216 | you're trying to run).
217 |
218 | If that process fails, an error will be written to the log file.
219 |
220 | If a process is already running under the same name, this function will
221 | return immediately and will not run the specified command.
222 |
223 | """
224 | if is_running(name):
225 | _log().info("[%s] job already running", name)
226 | return
227 |
228 | argcache = _arg_cache(name)
229 |
230 | # Cache arguments
231 | with open(argcache, "wb") as fp:
232 | pickle.dump({"args": args, "kwargs": kwargs}, fp)
233 | _log().debug("[%s] command cached: %s", name, argcache)
234 |
235 | # Call this script
236 | cmd = [sys.executable, "-m", "workflow.background", name]
237 | _log().debug("[%s] passing job to background runner: %r", name, cmd)
238 | retcode = subprocess.call(cmd)
239 |
240 | if retcode: # pragma: no cover
241 | _log().error("[%s] background runner failed with %d", name, retcode)
242 | else:
243 | _log().debug("[%s] background job started", name)
244 |
245 | return retcode
246 |
247 |
248 | def main(wf): # pragma: no cover
249 | """Run command in a background process.
250 |
251 | Load cached arguments, fork into background, then call
252 | :meth:`subprocess.call` with cached arguments.
253 |
254 | """
255 | log = wf.logger
256 | name = wf.args[0]
257 | argcache = _arg_cache(name)
258 | if not os.path.exists(argcache):
259 | msg = "[{0}] command cache not found: {1}".format(name, argcache)
260 | log.critical(msg)
261 | raise IOError(msg)
262 |
263 | # Fork to background and run command
264 | pidfile = _pid_file(name)
265 | _background(pidfile)
266 |
267 | # Load cached arguments
268 | with open(argcache, "rb") as fp:
269 | data = pickle.load(fp)
270 |
271 | # Cached arguments
272 | args = data["args"]
273 | kwargs = data["kwargs"]
274 |
275 | # Delete argument cache file
276 | os.unlink(argcache)
277 |
278 | try:
279 | # Run the command
280 | log.debug("[%s] running command: %r", name, args)
281 |
282 | retcode = subprocess.call(args, **kwargs)
283 |
284 | if retcode:
285 | log.error("[%s] command failed with status %d", name, retcode)
286 | finally:
287 | os.unlink(pidfile)
288 |
289 | log.debug("[%s] job complete", name)
290 |
291 |
292 | if __name__ == "__main__": # pragma: no cover
293 | wf().run(main)
294 |
--------------------------------------------------------------------------------
/workflow/notify.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright (c) 2015 deanishe@deanishe.net
5 | #
6 | # MIT Licence. See http://opensource.org/licenses/MIT
7 | #
8 | # Created on 2015-11-26
9 | #
10 |
11 | # TODO: Exclude this module from test and code coverage in py2.6
12 |
13 | """
14 | Post notifications via the macOS Notification Center.
15 |
16 | This feature is only available on Mountain Lion (10.8) and later.
17 | It will silently fail on older systems.
18 |
19 | The main API is a single function, :func:`~workflow.notify.notify`.
20 |
21 | It works by copying a simple application to your workflow's data
22 | directory. It replaces the application's icon with your workflow's
23 | icon and then calls the application to post notifications.
24 | """
25 |
26 |
27 | import os
28 | import plistlib
29 | import shutil
30 | import subprocess
31 | import sys
32 | import tarfile
33 | import tempfile
34 | import uuid
35 | from typing import List
36 |
37 | from . import workflow
38 |
39 | _wf = None
40 | _log = None
41 |
42 |
43 | #: Available system sounds from System Preferences > Sound > Sound Effects
44 | SOUNDS = (
45 | "Basso",
46 | "Blow",
47 | "Bottle",
48 | "Frog",
49 | "Funk",
50 | "Glass",
51 | "Hero",
52 | "Morse",
53 | "Ping",
54 | "Pop",
55 | "Purr",
56 | "Sosumi",
57 | "Submarine",
58 | "Tink",
59 | )
60 |
61 |
62 | def wf():
63 | """Return Workflow object for this module.
64 |
65 | Returns:
66 | workflow.Workflow: Workflow object for current workflow.
67 | """
68 | global _wf
69 | if _wf is None:
70 | _wf = workflow.Workflow()
71 | return _wf
72 |
73 |
74 | def log():
75 | """Return logger for this module.
76 |
77 | Returns:
78 | logging.Logger: Logger for this module.
79 | """
80 | global _log
81 | if _log is None:
82 | _log = wf().logger
83 | return _log
84 |
85 |
86 | def notifier_program():
87 | """Return path to notifier applet executable.
88 |
89 | Returns:
90 | unicode: Path to Notify.app ``applet`` executable.
91 | """
92 | return wf().datafile("Notify.app/Contents/MacOS/applet")
93 |
94 |
95 | def notifier_icon_path():
96 | """Return path to icon file in installed Notify.app.
97 |
98 | Returns:
99 | unicode: Path to ``applet.icns`` within the app bundle.
100 | """
101 | return wf().datafile("Notify.app/Contents/Resources/applet.icns")
102 |
103 |
104 | def install_notifier():
105 | """Extract ``Notify.app`` from the workflow to data directory.
106 |
107 | Changes the bundle ID of the installed app and gives it the
108 | workflow's icon.
109 | """
110 | archive = os.path.join(os.path.dirname(__file__), "Notify.tgz")
111 | destdir = wf().datadir
112 | app_path = os.path.join(destdir, "Notify.app")
113 | n = notifier_program()
114 | log().debug("installing Notify.app to %r ...", destdir)
115 | # z = zipfile.ZipFile(archive, 'r')
116 | # z.extractall(destdir)
117 | tgz = tarfile.open(archive, "r:gz")
118 | tgz.extractall(destdir)
119 | if not os.path.exists(n): # pragma: nocover
120 | raise RuntimeError("Notify.app could not be installed in " + destdir)
121 |
122 | # Replace applet icon
123 | icon = notifier_icon_path()
124 | workflow_icon = wf().workflowfile("icon.png")
125 | if os.path.exists(icon):
126 | os.unlink(icon)
127 |
128 | png_to_icns(workflow_icon, icon)
129 |
130 | # Set file icon
131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually,
132 | # none of this code will "work" on pre-10.8 systems. Let it run
133 | # until I figure out a better way of excluding this module
134 | # from coverage in py2.6.
135 | if sys.version_info >= (2, 7): # pragma: no cover
136 | from AppKit import NSImage, NSWorkspace
137 |
138 | ws = NSWorkspace.sharedWorkspace()
139 | img = NSImage.alloc().init()
140 | img.initWithContentsOfFile_(icon)
141 | ws.setIcon_forFile_options_(img, app_path, 0)
142 |
143 | # Change bundle ID of installed app
144 | ip_path = os.path.join(app_path, "Contents/Info.plist")
145 | bundle_id = "{0}.{1}".format(wf().bundleid, uuid.uuid4().hex)
146 | data = plistlib.load(ip_path)
147 | log().debug("changing bundle ID to %r", bundle_id)
148 | data["CFBundleIdentifier"] = bundle_id
149 | plistlib.dump(data, ip_path)
150 |
151 |
152 | def validate_sound(sound):
153 | """Coerce ``sound`` to valid sound name.
154 |
155 | Returns ``None`` for invalid sounds. Sound names can be found
156 | in ``System Preferences > Sound > Sound Effects``.
157 |
158 | Args:
159 | sound (str): Name of system sound.
160 |
161 | Returns:
162 | str: Proper name of sound or ``None``.
163 | """
164 | if not sound:
165 | return None
166 |
167 | # Case-insensitive comparison of `sound`
168 | if sound.lower() in [s.lower() for s in SOUNDS]:
169 | # Title-case is correct for all system sounds as of macOS 10.11
170 | return sound.title()
171 | return None
172 |
173 |
174 | def notify(title="", text="", sound=None):
175 | """Post notification via Notify.app helper.
176 |
177 | Args:
178 | title (str, optional): Notification title.
179 | text (str, optional): Notification body text.
180 | sound (str, optional): Name of sound to play.
181 |
182 | Raises:
183 | ValueError: Raised if both ``title`` and ``text`` are empty.
184 |
185 | Returns:
186 | bool: ``True`` if notification was posted, else ``False``.
187 | """
188 | if title == text == "":
189 | raise ValueError("Empty notification")
190 |
191 | sound = validate_sound(sound) or ""
192 |
193 | n = notifier_program()
194 |
195 | if not os.path.exists(n):
196 | install_notifier()
197 |
198 | env = os.environ.copy()
199 | enc = "utf-8"
200 | env["NOTIFY_TITLE"] = title.encode(enc)
201 | env["NOTIFY_MESSAGE"] = text.encode(enc)
202 | env["NOTIFY_SOUND"] = sound.encode(enc)
203 | cmd = [n]
204 | retcode = subprocess.call(cmd, env=env)
205 | if retcode == 0:
206 | return True
207 |
208 | log().error("Notify.app exited with status {0}.".format(retcode))
209 | return False
210 |
211 |
212 | def usr_bin_env(*args: str) -> List[str]:
213 | return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args)
214 |
215 |
216 | def convert_image(inpath, outpath, size):
217 | """Convert an image file using ``sips``.
218 |
219 | Args:
220 | inpath (str): Path of source file.
221 | outpath (str): Path to destination file.
222 | size (int): Width and height of destination image in pixels.
223 |
224 | Raises:
225 | RuntimeError: Raised if ``sips`` exits with non-zero status.
226 | """
227 | cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath]
228 | # log().debug(cmd)
229 | with open(os.devnull, "w") as pipe:
230 | retcode = subprocess.call(
231 | cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT
232 | )
233 |
234 | if retcode != 0:
235 | raise RuntimeError("sips exited with %d" % retcode)
236 |
237 |
238 | def png_to_icns(png_path, icns_path):
239 | """Convert PNG file to ICNS using ``iconutil``.
240 |
241 | Create an iconset from the source PNG file. Generate PNG files
242 | in each size required by macOS, then call ``iconutil`` to turn
243 | them into a single ICNS file.
244 |
245 | Args:
246 | png_path (str): Path to source PNG file.
247 | icns_path (str): Path to destination ICNS file.
248 |
249 | Raises:
250 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail.
251 | """
252 | tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir)
253 |
254 | try:
255 | iconset = os.path.join(tempdir, "Icon.iconset")
256 |
257 | if os.path.exists(iconset): # pragma: nocover
258 | raise RuntimeError("iconset already exists: " + iconset)
259 |
260 | os.makedirs(iconset)
261 |
262 | # Copy source icon to icon set and generate all the other
263 | # sizes needed
264 | configs = []
265 | for i in (16, 32, 128, 256, 512):
266 | configs.append(("icon_{0}x{0}.png".format(i), i))
267 | configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2)))
268 |
269 | shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png"))
270 | shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png"))
271 |
272 | for name, size in configs:
273 | outpath = os.path.join(iconset, name)
274 | if os.path.exists(outpath):
275 | continue
276 | convert_image(png_path, outpath, size)
277 |
278 | cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset]
279 |
280 | retcode = subprocess.call(cmd)
281 | if retcode != 0:
282 | raise RuntimeError("iconset exited with %d" % retcode)
283 |
284 | if not os.path.exists(icns_path): # pragma: nocover
285 | raise ValueError("generated ICNS file not found: " + repr(icns_path))
286 | finally:
287 | try:
288 | shutil.rmtree(tempdir)
289 | except OSError: # pragma: no cover
290 | pass
291 |
292 |
293 | if __name__ == "__main__": # pragma: nocover
294 | # Simple command-line script to test module with
295 | # This won't work on 2.6, as `argparse` isn't available
296 | # by default.
297 | import argparse
298 | from unicodedata import normalize
299 |
300 | def ustr(s):
301 | """Coerce `s` to normalised Unicode."""
302 | return normalize("NFD", s.decode("utf-8"))
303 |
304 | p = argparse.ArgumentParser()
305 | p.add_argument("-p", "--png", help="PNG image to convert to ICNS.")
306 | p.add_argument(
307 | "-l", "--list-sounds", help="Show available sounds.", action="store_true"
308 | )
309 | p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="")
310 | p.add_argument(
311 | "-s", "--sound", type=ustr, help="Optional notification sound.", default=""
312 | )
313 | p.add_argument(
314 | "text", type=ustr, help="Notification body text.", default="", nargs="?"
315 | )
316 | o = p.parse_args()
317 |
318 | # List available sounds
319 | if o.list_sounds:
320 | for sound in SOUNDS:
321 | print(sound)
322 | sys.exit(0)
323 |
324 | # Convert PNG to ICNS
325 | if o.png:
326 | icns = os.path.join(
327 | os.path.dirname(o.png),
328 | os.path.splitext(os.path.basename(o.png))[0] + ".icns",
329 | )
330 |
331 | print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr)
332 |
333 | if os.path.exists(icns):
334 | raise ValueError("destination file already exists: " + icns)
335 |
336 | png_to_icns(o.png, icns)
337 | sys.exit(0)
338 |
339 | # Post notification
340 | if o.title == o.text == "":
341 | print("ERROR: empty notification.", file=sys.stderr)
342 | sys.exit(1)
343 | else:
344 | notify(o.title, o.text, o.sound)
345 |
--------------------------------------------------------------------------------
/workflow/update.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright (c) 2014 Fabio Niephaus ,
5 | # Dean Jackson
6 | #
7 | # MIT Licence. See http://opensource.org/licenses/MIT
8 | #
9 | # Created on 2014-08-16
10 | #
11 |
12 | """Self-updating from GitHub.
13 |
14 | .. versionadded:: 1.9
15 |
16 | .. note::
17 |
18 | This module is not intended to be used directly. Automatic updates
19 | are controlled by the ``update_settings`` :class:`dict` passed to
20 | :class:`~workflow.workflow.Workflow` objects.
21 |
22 | """
23 |
24 |
25 | import json
26 | import os
27 | import re
28 | import subprocess
29 | import tempfile
30 | from collections import defaultdict
31 | from functools import total_ordering
32 | from itertools import zip_longest
33 | from urllib import request
34 |
35 | from workflow.util import atomic_writer
36 |
37 | from . import workflow
38 |
39 | # __all__ = []
40 |
41 |
42 | RELEASES_BASE = "https://api.github.com/repos/{}/releases"
43 | match_workflow = re.compile(r"\.alfred(\d+)?workflow$").search
44 |
45 | _wf = None
46 |
47 |
48 | def wf():
49 | """Lazy `Workflow` object."""
50 | global _wf
51 | if _wf is None:
52 | _wf = workflow.Workflow()
53 | return _wf
54 |
55 |
56 | @total_ordering
57 | class Download(object):
58 | """A workflow file that is available for download.
59 |
60 | .. versionadded: 1.37
61 |
62 | Attributes:
63 | url (str): URL of workflow file.
64 | filename (str): Filename of workflow file.
65 | version (Version): Semantic version of workflow.
66 | prerelease (bool): Whether version is a pre-release.
67 | alfred_version (Version): Minimum compatible version
68 | of Alfred.
69 |
70 | """
71 |
72 | @classmethod
73 | def from_dict(cls, d):
74 | """Create a `Download` from a `dict`."""
75 | return cls(
76 | url=d["url"],
77 | filename=d["filename"],
78 | version=Version(d["version"]),
79 | prerelease=d["prerelease"],
80 | )
81 |
82 | @classmethod
83 | def from_releases(cls, js):
84 | """Extract downloads from GitHub releases.
85 |
86 | Searches releases with semantic tags for assets with
87 | file extension .alfredworkflow or .alfredXworkflow where
88 | X is a number.
89 |
90 | Files are returned sorted by latest version first. Any
91 | releases containing multiple files with the same (workflow)
92 | extension are rejected as ambiguous.
93 |
94 | Args:
95 | js (str): JSON response from GitHub's releases endpoint.
96 |
97 | Returns:
98 | list: Sequence of `Download`.
99 | """
100 | releases = json.loads(js)
101 | downloads = []
102 | for release in releases:
103 | tag = release["tag_name"]
104 | dupes = defaultdict(int)
105 | try:
106 | version = Version(tag)
107 | except ValueError as err:
108 | wf().logger.debug('ignored release: bad version "%s": %s', tag, err)
109 | continue
110 |
111 | dls = []
112 | for asset in release.get("assets", []):
113 | url = asset.get("browser_download_url")
114 | filename = os.path.basename(url)
115 | m = match_workflow(filename)
116 | if not m:
117 | wf().logger.debug("unwanted file: %s", filename)
118 | continue
119 |
120 | ext = m.group(0)
121 | dupes[ext] = dupes[ext] + 1
122 | dls.append(Download(url, filename, version, release["prerelease"]))
123 |
124 | valid = True
125 | for ext, n in list(dupes.items()):
126 | if n > 1:
127 | wf().logger.debug(
128 | 'ignored release "%s": multiple assets ' 'with extension "%s"',
129 | tag,
130 | ext,
131 | )
132 | valid = False
133 | break
134 |
135 | if valid:
136 | downloads.extend(dls)
137 |
138 | downloads.sort(reverse=True)
139 | return downloads
140 |
141 | def __init__(self, url, filename, version, prerelease=False):
142 | """Create a new Download.
143 |
144 | Args:
145 | url (str): URL of workflow file.
146 | filename (str): Filename of workflow file.
147 | version (Version): Version of workflow.
148 | prerelease (bool, optional): Whether version is
149 | pre-release. Defaults to False.
150 |
151 | """
152 | if isinstance(version, str):
153 | version = Version(version)
154 |
155 | self.url = url
156 | self.filename = filename
157 | self.version = version
158 | self.prerelease = prerelease
159 |
160 | @property
161 | def alfred_version(self):
162 | """Minimum Alfred version based on filename extension."""
163 | m = match_workflow(self.filename)
164 | if not m or not m.group(1):
165 | return Version("0")
166 | return Version(m.group(1))
167 |
168 | @property
169 | def dict(self):
170 | """Convert `Download` to `dict`."""
171 | return dict(
172 | url=self.url,
173 | filename=self.filename,
174 | version=str(self.version),
175 | prerelease=self.prerelease,
176 | )
177 |
178 | def __str__(self):
179 | """Format `Download` for printing."""
180 | return (
181 | "Download("
182 | "url={dl.url!r}, "
183 | "filename={dl.filename!r}, "
184 | "version={dl.version!r}, "
185 | "prerelease={dl.prerelease!r}"
186 | ")"
187 | ).format(dl=self)
188 |
189 | def __repr__(self):
190 | """Code-like representation of `Download`."""
191 | return str(self)
192 |
193 | def __eq__(self, other):
194 | """Compare Downloads based on version numbers."""
195 | if (
196 | self.url != other.url
197 | or self.filename != other.filename
198 | or self.version != other.version
199 | or self.prerelease != other.prerelease
200 | ):
201 | return False
202 | return True
203 |
204 | def __ne__(self, other):
205 | """Compare Downloads based on version numbers."""
206 | return not self.__eq__(other)
207 |
208 | def __lt__(self, other):
209 | """Compare Downloads based on version numbers."""
210 | if self.version != other.version:
211 | return self.version < other.version
212 | return self.alfred_version < other.alfred_version
213 |
214 |
215 | class Version(object):
216 | """Mostly semantic versioning.
217 |
218 | The main difference to proper :ref:`semantic versioning `
219 | is that this implementation doesn't require a minor or patch version.
220 |
221 | Version strings may also be prefixed with "v", e.g.:
222 |
223 | >>> v = Version('v1.1.1')
224 | >>> v.tuple
225 | (1, 1, 1, '')
226 |
227 | >>> v = Version('2.0')
228 | >>> v.tuple
229 | (2, 0, 0, '')
230 |
231 | >>> Version('3.1-beta').tuple
232 | (3, 1, 0, 'beta')
233 |
234 | >>> Version('1.0.1') > Version('0.0.1')
235 | True
236 | """
237 |
238 | #: Match version and pre-release/build information in version strings
239 | match_version = re.compile(r"([0-9][0-9\.]*)(.+)?").match
240 |
241 | def __init__(self, vstr):
242 | """Create new `Version` object.
243 |
244 | Args:
245 | vstr (basestring): Semantic version string.
246 | """
247 | if not vstr:
248 | raise ValueError("invalid version number: {!r}".format(vstr))
249 |
250 | self.vstr = vstr
251 | self.major = 0
252 | self.minor = 0
253 | self.patch = 0
254 | self.suffix = ""
255 | self.build = ""
256 | self._parse(vstr)
257 |
258 | def _parse(self, vstr):
259 | vstr = str(vstr)
260 | if vstr.startswith("v"):
261 | m = self.match_version(vstr[1:])
262 | else:
263 | m = self.match_version(vstr)
264 | if not m:
265 | raise ValueError("invalid version number: " + vstr)
266 |
267 | version, suffix = m.groups()
268 | parts = self._parse_dotted_string(version)
269 | self.major = parts.pop(0)
270 | if len(parts):
271 | self.minor = parts.pop(0)
272 | if len(parts):
273 | self.patch = parts.pop(0)
274 | if not len(parts) == 0:
275 | raise ValueError("version number too long: " + vstr)
276 |
277 | if suffix:
278 | # Build info
279 | idx = suffix.find("+")
280 | if idx > -1:
281 | self.build = suffix[idx + 1 :]
282 | suffix = suffix[:idx]
283 | if suffix:
284 | if not suffix.startswith("-"):
285 | raise ValueError("suffix must start with - : " + suffix)
286 | self.suffix = suffix[1:]
287 |
288 | def _parse_dotted_string(self, s):
289 | """Parse string ``s`` into list of ints and strings."""
290 | parsed = []
291 | parts = s.split(".")
292 | for p in parts:
293 | if p.isdigit():
294 | p = int(p)
295 | parsed.append(p)
296 | return parsed
297 |
298 | @property
299 | def tuple(self):
300 | """Version number as a tuple of major, minor, patch, pre-release."""
301 | return (self.major, self.minor, self.patch, self.suffix)
302 |
303 | def __lt__(self, other):
304 | """Implement comparison."""
305 | if not isinstance(other, Version):
306 | raise ValueError("not a Version instance: {0!r}".format(other))
307 | t = self.tuple[:3]
308 | o = other.tuple[:3]
309 | if t < o:
310 | return True
311 | if t == o: # We need to compare suffixes
312 | if self.suffix and not other.suffix:
313 | return True
314 | if other.suffix and not self.suffix:
315 | return False
316 |
317 | self_suffix = self._parse_dotted_string(self.suffix)
318 | other_suffix = self._parse_dotted_string(other.suffix)
319 |
320 | for s, o in zip_longest(self_suffix, other_suffix):
321 | if s is None: # shorter value wins
322 | return True
323 | elif o is None: # longer value loses
324 | return False
325 | elif type(s) != type(o): # type coersion
326 | s, o = str(s), str(o)
327 | if s == o: # next if the same compare
328 | continue
329 | return s < o # finally compare
330 | # t > o
331 | return False
332 |
333 | def __eq__(self, other):
334 | """Implement comparison."""
335 | if not isinstance(other, Version):
336 | raise ValueError("not a Version instance: {0!r}".format(other))
337 | return self.tuple == other.tuple
338 |
339 | def __ne__(self, other):
340 | """Implement comparison."""
341 | return not self.__eq__(other)
342 |
343 | def __gt__(self, other):
344 | """Implement comparison."""
345 | if not isinstance(other, Version):
346 | raise ValueError("not a Version instance: {0!r}".format(other))
347 | return other.__lt__(self)
348 |
349 | def __le__(self, other):
350 | """Implement comparison."""
351 | if not isinstance(other, Version):
352 | raise ValueError("not a Version instance: {0!r}".format(other))
353 | return not other.__lt__(self)
354 |
355 | def __ge__(self, other):
356 | """Implement comparison."""
357 | return not self.__lt__(other)
358 |
359 | def __str__(self):
360 | """Return semantic version string."""
361 | vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch)
362 | if self.suffix:
363 | vstr = "{0}-{1}".format(vstr, self.suffix)
364 | if self.build:
365 | vstr = "{0}+{1}".format(vstr, self.build)
366 | return vstr
367 |
368 | def __repr__(self):
369 | """Return 'code' representation of `Version`."""
370 | return "Version('{0}')".format(str(self))
371 |
372 |
373 | def retrieve_download(dl):
374 | """Saves a download to a temporary file and returns path.
375 |
376 | .. versionadded: 1.37
377 |
378 | Args:
379 | url (unicode): URL to .alfredworkflow file in GitHub repo
380 |
381 | Returns:
382 | unicode: path to downloaded file
383 |
384 | """
385 | if not match_workflow(dl.filename):
386 | raise ValueError("attachment not a workflow: " + dl.filename)
387 |
388 | path = os.path.join(tempfile.gettempdir(), dl.filename)
389 | wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path)
390 |
391 | r = request.urlopen(dl.url)
392 |
393 | with atomic_writer(path, "wb") as file_obj:
394 | file_obj.write(r.read())
395 |
396 | return path
397 |
398 |
399 | def build_api_url(repo):
400 | """Generate releases URL from GitHub repo.
401 |
402 | Args:
403 | repo (unicode): Repo name in form ``username/repo``
404 |
405 | Returns:
406 | unicode: URL to the API endpoint for the repo's releases
407 |
408 | """
409 | if len(repo.split("/")) != 2:
410 | raise ValueError("invalid GitHub repo: {!r}".format(repo))
411 |
412 | return RELEASES_BASE.format(repo)
413 |
414 |
415 | def get_downloads(repo):
416 | """Load available ``Download``s for GitHub repo.
417 |
418 | .. versionadded: 1.37
419 |
420 | Args:
421 | repo (unicode): GitHub repo to load releases for.
422 |
423 | Returns:
424 | list: Sequence of `Download` contained in GitHub releases.
425 | """
426 | url = build_api_url(repo)
427 |
428 | def _fetch():
429 | wf().logger.info("retrieving releases for %r ...", repo)
430 | r = request.urlopen(url)
431 | return r.read()
432 |
433 | key = "github-releases-" + repo.replace("/", "-")
434 | js = wf().cached_data(key, _fetch, max_age=60)
435 |
436 | return Download.from_releases(js)
437 |
438 |
439 | def latest_download(dls, alfred_version=None, prereleases=False):
440 | """Return newest `Download`."""
441 | alfred_version = alfred_version or os.getenv("alfred_version")
442 | version = None
443 | if alfred_version:
444 | version = Version(alfred_version)
445 |
446 | dls.sort(reverse=True)
447 | for dl in dls:
448 | if dl.prerelease and not prereleases:
449 | wf().logger.debug("ignored prerelease: %s", dl.version)
450 | continue
451 | if version and dl.alfred_version > version:
452 | wf().logger.debug(
453 | "ignored incompatible (%s > %s): %s",
454 | dl.alfred_version,
455 | version,
456 | dl.filename,
457 | )
458 | continue
459 |
460 | wf().logger.debug("latest version: %s (%s)", dl.version, dl.filename)
461 | return dl
462 |
463 | return None
464 |
465 |
466 | def check_update(repo, current_version, prereleases=False, alfred_version=None):
467 | """Check whether a newer release is available on GitHub.
468 |
469 | Args:
470 | repo (unicode): ``username/repo`` for workflow's GitHub repo
471 | current_version (unicode): the currently installed version of the
472 | workflow. :ref:`Semantic versioning ` is required.
473 | prereleases (bool): Whether to include pre-releases.
474 | alfred_version (unicode): version of currently-running Alfred.
475 | if empty, defaults to ``$alfred_version`` environment variable.
476 |
477 | Returns:
478 | bool: ``True`` if an update is available, else ``False``
479 |
480 | If an update is available, its version number and download URL will
481 | be cached.
482 |
483 | """
484 | key = "__workflow_latest_version"
485 | # data stored when no update is available
486 | no_update = {"available": False, "download": None, "version": None}
487 | current = Version(current_version)
488 |
489 | dls = get_downloads(repo)
490 | if not len(dls):
491 | wf().logger.warning("no valid downloads for %s", repo)
492 | wf().cache_data(key, no_update)
493 | return False
494 |
495 | wf().logger.info("%d download(s) for %s", len(dls), repo)
496 |
497 | dl = latest_download(dls, alfred_version, prereleases)
498 |
499 | if not dl:
500 | wf().logger.warning("no compatible downloads for %s", repo)
501 | wf().cache_data(key, no_update)
502 | return False
503 |
504 | wf().logger.debug("latest=%r, installed=%r", dl.version, current)
505 |
506 | if dl.version > current:
507 | wf().cache_data(
508 | key, {"version": str(dl.version), "download": dl.dict, "available": True}
509 | )
510 | return True
511 |
512 | wf().cache_data(key, no_update)
513 | return False
514 |
515 |
516 | def install_update():
517 | """If a newer release is available, download and install it.
518 |
519 | :returns: ``True`` if an update is installed, else ``False``
520 |
521 | """
522 | key = "__workflow_latest_version"
523 | # data stored when no update is available
524 | no_update = {"available": False, "download": None, "version": None}
525 | status = wf().cached_data(key, max_age=0)
526 |
527 | if not status or not status.get("available"):
528 | wf().logger.info("no update available")
529 | return False
530 |
531 | dl = status.get("download")
532 | if not dl:
533 | wf().logger.info("no download information")
534 | return False
535 |
536 | path = retrieve_download(Download.from_dict(dl))
537 |
538 | wf().logger.info("installing updated workflow ...")
539 | subprocess.call(["open", path]) # nosec
540 |
541 | wf().cache_data(key, no_update)
542 | return True
543 |
544 |
545 | if __name__ == "__main__": # pragma: nocover
546 | import sys
547 |
548 | prereleases = False
549 |
550 | def show_help(status=0):
551 | """Print help message."""
552 | print("usage: update.py (check|install) " "[--prereleases] ")
553 | sys.exit(status)
554 |
555 | argv = sys.argv[:]
556 | if "-h" in argv or "--help" in argv:
557 | show_help()
558 |
559 | if "--prereleases" in argv:
560 | argv.remove("--prereleases")
561 | prereleases = True
562 |
563 | if len(argv) != 4:
564 | show_help(1)
565 |
566 | action = argv[1]
567 | repo = argv[2]
568 | version = argv[3]
569 |
570 | try:
571 |
572 | if action == "check":
573 | check_update(repo, version, prereleases)
574 | elif action == "install":
575 | install_update()
576 | else:
577 | show_help(1)
578 |
579 | except Exception as err: # ensure traceback is in log file
580 | wf().logger.exception(err)
581 | raise err
582 |
--------------------------------------------------------------------------------
/workflow/util.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | #
4 | # Copyright (c) 2017 Dean Jackson
5 | #
6 | # MIT Licence. See http://opensource.org/licenses/MIT
7 | #
8 | # Created on 2017-12-17
9 | #
10 |
11 | """A selection of helper functions useful for building workflows."""
12 |
13 |
14 | import atexit
15 | import errno
16 | import fcntl
17 | import functools
18 | import json
19 | import os
20 | import signal
21 | import subprocess
22 | import sys
23 | import time
24 | from collections import namedtuple
25 | from contextlib import contextmanager
26 | from threading import Event
27 |
28 | # JXA scripts to call Alfred's API via the Scripting Bridge
29 | # {app} is automatically replaced with "Alfred 3" or
30 | # "com.runningwithcrayons.Alfred" depending on version.
31 | #
32 | # Open Alfred in search (regular) mode
33 | JXA_SEARCH = "Application({app}).search({arg});"
34 | # Open Alfred's File Actions on an argument
35 | JXA_ACTION = "Application({app}).action({arg});"
36 | # Open Alfred's navigation mode at path
37 | JXA_BROWSE = "Application({app}).browse({arg});"
38 | # Set the specified theme
39 | JXA_SET_THEME = "Application({app}).setTheme({arg});"
40 | # Call an External Trigger
41 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});"
42 | # Save a variable to the workflow configuration sheet/info.plist
43 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});"
44 | # Delete a variable from the workflow configuration sheet/info.plist
45 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});"
46 | # Tell Alfred to reload a workflow from disk
47 | JXA_RELOAD_WORKFLOW = "Application({app}).reloadWorkflow({arg});"
48 |
49 |
50 | class AcquisitionError(Exception):
51 | """Raised if a lock cannot be acquired."""
52 |
53 |
54 | AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"])
55 | """Information about an installed application.
56 |
57 | Returned by :func:`appinfo`. All attributes are Unicode.
58 |
59 | .. py:attribute:: name
60 |
61 | Name of the application, e.g. ``u'Safari'``.
62 |
63 | .. py:attribute:: path
64 |
65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
66 |
67 | .. py:attribute:: bundleid
68 |
69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``.
70 |
71 | """
72 |
73 |
74 | def jxa_app_name():
75 | """Return name of application to call currently running Alfred.
76 |
77 | .. versionadded: 1.37
78 |
79 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending
80 | on which version of Alfred is running.
81 |
82 | This name is suitable for use with ``Application(name)`` in JXA.
83 |
84 | Returns:
85 | unicode: Application name or ID.
86 |
87 | """
88 | if os.getenv("alfred_version", "").startswith("3"):
89 | # Alfred 3
90 | return "Alfred 3"
91 | # Alfred 4+
92 | return "com.runningwithcrayons.Alfred"
93 |
94 |
95 | def unicodify(s, encoding="utf-8", norm=None):
96 | """Ensure string is Unicode.
97 |
98 | .. versionadded:: 1.31
99 |
100 | Decode encoded strings using ``encoding`` and normalise Unicode
101 | to form ``norm`` if specified.
102 |
103 | Args:
104 | s (str): String to decode. May also be Unicode.
105 | encoding (str, optional): Encoding to use on bytestrings.
106 | norm (None, optional): Normalisation form to apply to Unicode string.
107 |
108 | Returns:
109 | unicode: Decoded, optionally normalised, Unicode string.
110 |
111 | """
112 | if not isinstance(s, str):
113 | s = str(s, encoding)
114 |
115 | if norm:
116 | from unicodedata import normalize
117 |
118 | s = normalize(norm, s)
119 |
120 | return s
121 |
122 |
123 | def utf8ify(s):
124 | """Ensure string is a bytestring.
125 |
126 | .. versionadded:: 1.31
127 |
128 | Returns `str` objects unchanced, encodes `unicode` objects to
129 | UTF-8, and calls :func:`str` on anything else.
130 |
131 | Args:
132 | s (object): A Python object
133 |
134 | Returns:
135 | str: UTF-8 string or string representation of s.
136 |
137 | """
138 | if isinstance(s, str):
139 | return s
140 |
141 | if isinstance(s, str):
142 | return s.encode("utf-8")
143 |
144 | return str(s)
145 |
146 |
147 | def applescriptify(s):
148 | """Escape string for insertion into an AppleScript string.
149 |
150 | .. versionadded:: 1.31
151 |
152 | Replaces ``"`` with `"& quote &"`. Use this function if you want
153 | to insert a string into an AppleScript script:
154 |
155 | >>> applescriptify('g "python" test')
156 | 'g " & quote & "python" & quote & "test'
157 |
158 | Args:
159 | s (unicode): Unicode string to escape.
160 |
161 | Returns:
162 | unicode: Escaped string.
163 |
164 | """
165 | return s.replace('"', '" & quote & "')
166 |
167 |
168 | def run_command(cmd, **kwargs):
169 | """Run a command and return the output.
170 |
171 | .. versionadded:: 1.31
172 |
173 | A thin wrapper around :func:`subprocess.check_output` that ensures
174 | all arguments are encoded to UTF-8 first.
175 |
176 | Args:
177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`.
178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`.
179 |
180 | Returns:
181 | str: Output returned by :func:`~subprocess.check_output`.
182 |
183 | """
184 | cmd = [str(s) for s in cmd]
185 | return subprocess.check_output(cmd, **kwargs).decode()
186 |
187 |
188 | def run_applescript(script, *args, **kwargs):
189 | """Execute an AppleScript script and return its output.
190 |
191 | .. versionadded:: 1.31
192 |
193 | Run AppleScript either by filepath or code. If ``script`` is a valid
194 | filepath, that script will be run, otherwise ``script`` is treated
195 | as code.
196 |
197 | Args:
198 | script (str, optional): Filepath of script or code to run.
199 | *args: Optional command-line arguments to pass to the script.
200 | **kwargs: Pass ``lang`` to run a language other than AppleScript.
201 | Any other keyword arguments are passed to :func:`run_command`.
202 |
203 | Returns:
204 | str: Output of run command.
205 |
206 | """
207 | lang = "AppleScript"
208 | if "lang" in kwargs:
209 | lang = kwargs["lang"]
210 | del kwargs["lang"]
211 |
212 | cmd = ["/usr/bin/osascript", "-l", lang]
213 |
214 | if os.path.exists(script):
215 | cmd += [script]
216 | else:
217 | cmd += ["-e", script]
218 |
219 | cmd.extend(args)
220 |
221 | return run_command(cmd, **kwargs)
222 |
223 |
224 | def run_jxa(script, *args):
225 | """Execute a JXA script and return its output.
226 |
227 | .. versionadded:: 1.31
228 |
229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
230 |
231 | Args:
232 | script (str): Filepath of script or code to run.
233 | *args: Optional command-line arguments to pass to script.
234 |
235 | Returns:
236 | str: Output of script.
237 |
238 | """
239 | return run_applescript(script, *args, lang="JavaScript")
240 |
241 |
242 | def run_trigger(name, bundleid=None, arg=None):
243 | """Call an Alfred External Trigger.
244 |
245 | .. versionadded:: 1.31
246 |
247 | If ``bundleid`` is not specified, the bundle ID of the calling
248 | workflow is used.
249 |
250 | Args:
251 | name (str): Name of External Trigger to call.
252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to.
253 | arg (str, optional): Argument to pass to trigger.
254 |
255 | """
256 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
257 | appname = jxa_app_name()
258 | opts = {"inWorkflow": bundleid}
259 | if arg:
260 | opts["withArgument"] = arg
261 |
262 | script = JXA_TRIGGER.format(
263 | app=json.dumps(appname),
264 | arg=json.dumps(name),
265 | opts=json.dumps(opts, sort_keys=True),
266 | )
267 |
268 | run_applescript(script, lang="JavaScript")
269 |
270 |
271 | def set_theme(theme_name):
272 | """Change Alfred's theme.
273 |
274 | .. versionadded:: 1.39.0
275 |
276 | Args:
277 | theme_name (unicode): Name of theme Alfred should use.
278 |
279 | """
280 | appname = jxa_app_name()
281 | script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name))
282 | run_applescript(script, lang="JavaScript")
283 |
284 |
285 | def set_config(name, value, bundleid=None, exportable=False):
286 | """Set a workflow variable in ``info.plist``.
287 |
288 | .. versionadded:: 1.33
289 |
290 | If ``bundleid`` is not specified, the bundle ID of the calling
291 | workflow is used.
292 |
293 | Args:
294 | name (str): Name of variable to set.
295 | value (str): Value to set variable to.
296 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
297 | exportable (bool, optional): Whether variable should be marked
298 | as exportable (Don't Export checkbox).
299 |
300 | """
301 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
302 | appname = jxa_app_name()
303 | opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable}
304 |
305 | script = JXA_SET_CONFIG.format(
306 | app=json.dumps(appname),
307 | arg=json.dumps(name),
308 | opts=json.dumps(opts, sort_keys=True),
309 | )
310 |
311 | run_applescript(script, lang="JavaScript")
312 |
313 |
314 | def unset_config(name, bundleid=None):
315 | """Delete a workflow variable from ``info.plist``.
316 |
317 | .. versionadded:: 1.33
318 |
319 | If ``bundleid`` is not specified, the bundle ID of the calling
320 | workflow is used.
321 |
322 | Args:
323 | name (str): Name of variable to delete.
324 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
325 |
326 | """
327 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
328 | appname = jxa_app_name()
329 | opts = {"inWorkflow": bundleid}
330 |
331 | script = JXA_UNSET_CONFIG.format(
332 | app=json.dumps(appname),
333 | arg=json.dumps(name),
334 | opts=json.dumps(opts, sort_keys=True),
335 | )
336 |
337 | run_applescript(script, lang="JavaScript")
338 |
339 |
340 | def search_in_alfred(query=None):
341 | """Open Alfred with given search query.
342 |
343 | .. versionadded:: 1.39.0
344 |
345 | Omit ``query`` to simply open Alfred's main window.
346 |
347 | Args:
348 | query (unicode, optional): Search query.
349 |
350 | """
351 | query = query or ""
352 | appname = jxa_app_name()
353 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query))
354 | run_applescript(script, lang="JavaScript")
355 |
356 |
357 | def browse_in_alfred(path):
358 | """Open Alfred's filesystem navigation mode at ``path``.
359 |
360 | .. versionadded:: 1.39.0
361 |
362 | Args:
363 | path (unicode): File or directory path.
364 |
365 | """
366 | appname = jxa_app_name()
367 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path))
368 | run_applescript(script, lang="JavaScript")
369 |
370 |
371 | def action_in_alfred(paths):
372 | """Action the give filepaths in Alfred.
373 |
374 | .. versionadded:: 1.39.0
375 |
376 | Args:
377 | paths (list): Unicode paths to files/directories to action.
378 |
379 | """
380 | appname = jxa_app_name()
381 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths))
382 | run_applescript(script, lang="JavaScript")
383 |
384 |
385 | def reload_workflow(bundleid=None):
386 | """Tell Alfred to reload a workflow from disk.
387 |
388 | .. versionadded:: 1.39.0
389 |
390 | If ``bundleid`` is not specified, the bundle ID of the calling
391 | workflow is used.
392 |
393 | Args:
394 | bundleid (unicode, optional): Bundle ID of workflow to reload.
395 |
396 | """
397 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid")
398 | appname = jxa_app_name()
399 | script = JXA_RELOAD_WORKFLOW.format(
400 | app=json.dumps(appname), arg=json.dumps(bundleid)
401 | )
402 |
403 | run_applescript(script, lang="JavaScript")
404 |
405 |
406 | def appinfo(name):
407 | """Get information about an installed application.
408 |
409 | .. versionadded:: 1.31
410 |
411 | Args:
412 | name (str): Name of application to look up.
413 |
414 | Returns:
415 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
416 |
417 | """
418 | cmd = [
419 | "mdfind",
420 | "-onlyin",
421 | "/Applications",
422 | "-onlyin",
423 | "/System/Applications",
424 | "-onlyin",
425 | os.path.expanduser("~/Applications"),
426 | "(kMDItemContentTypeTree == com.apple.application &&"
427 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name),
428 | ]
429 |
430 | output = run_command(cmd).strip()
431 | if not output:
432 | return None
433 |
434 | path = output.split("\n")[0]
435 |
436 | cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path]
437 | bid = run_command(cmd).strip()
438 | if not bid: # pragma: no cover
439 | return None
440 |
441 | return AppInfo(name, path, bid)
442 |
443 |
444 | @contextmanager
445 | def atomic_writer(fpath, mode):
446 | """Atomic file writer.
447 |
448 | .. versionadded:: 1.12
449 |
450 | Context manager that ensures the file is only written if the write
451 | succeeds. The data is first written to a temporary file.
452 |
453 | :param fpath: path of file to write to.
454 | :type fpath: ``unicode``
455 | :param mode: sames as for :func:`open`
456 | :type mode: string
457 |
458 | """
459 | suffix = ".{}.tmp".format(os.getpid())
460 | temppath = fpath + suffix
461 | with open(temppath, mode) as fp:
462 | try:
463 | yield fp
464 | os.rename(temppath, fpath)
465 | finally:
466 | try:
467 | os.remove(temppath)
468 | except OSError:
469 | pass
470 |
471 |
472 | class LockFile(object):
473 | """Context manager to protect filepaths with lockfiles.
474 |
475 | .. versionadded:: 1.13
476 |
477 | Creates a lockfile alongside ``protected_path``. Other ``LockFile``
478 | instances will refuse to lock the same path.
479 |
480 | >>> path = '/path/to/file'
481 | >>> with LockFile(path):
482 | >>> with open(path, 'w') as fp:
483 | >>> fp.write(data)
484 |
485 | Args:
486 | protected_path (unicode): File to protect with a lockfile
487 | timeout (float, optional): Raises an :class:`AcquisitionError`
488 | if lock cannot be acquired within this number of seconds.
489 | If ``timeout`` is 0 (the default), wait forever.
490 | delay (float, optional): How often to check (in seconds) if
491 | lock has been released.
492 |
493 | Attributes:
494 | delay (float): How often to check (in seconds) whether the lock
495 | can be acquired.
496 | lockfile (unicode): Path of the lockfile.
497 | timeout (float): How long to wait to acquire the lock.
498 |
499 | """
500 |
501 | def __init__(self, protected_path, timeout=0.0, delay=0.05):
502 | """Create new :class:`LockFile` object."""
503 | self.lockfile = protected_path + ".lock"
504 | self._lockfile = None
505 | self.timeout = timeout
506 | self.delay = delay
507 | self._lock = Event()
508 | atexit.register(self.release)
509 |
510 | @property
511 | def locked(self):
512 | """``True`` if file is locked by this instance."""
513 | return self._lock.is_set()
514 |
515 | def acquire(self, blocking=True):
516 | """Acquire the lock if possible.
517 |
518 | If the lock is in use and ``blocking`` is ``False``, return
519 | ``False``.
520 |
521 | Otherwise, check every :attr:`delay` seconds until it acquires
522 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
523 |
524 | """
525 | if self.locked and not blocking:
526 | return False
527 |
528 | start = time.time()
529 | while True:
530 | # Raise error if we've been waiting too long to acquire the lock
531 | if self.timeout and (time.time() - start) >= self.timeout:
532 | raise AcquisitionError("lock acquisition timed out")
533 |
534 | # If already locked, wait then try again
535 | if self.locked:
536 | time.sleep(self.delay)
537 | continue
538 |
539 | # Create in append mode so we don't lose any contents
540 | if self._lockfile is None:
541 | self._lockfile = open(self.lockfile, "a")
542 |
543 | # Try to acquire the lock
544 | try:
545 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
546 | self._lock.set()
547 | break
548 | except IOError as err: # pragma: no cover
549 | if err.errno not in (errno.EACCES, errno.EAGAIN):
550 | raise
551 |
552 | # Don't try again
553 | if not blocking: # pragma: no cover
554 | return False
555 |
556 | # Wait, then try again
557 | time.sleep(self.delay)
558 |
559 | return True
560 |
561 | def release(self):
562 | """Release the lock by deleting `self.lockfile`."""
563 | if not self._lock.is_set():
564 | return False
565 |
566 | try:
567 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
568 | except IOError: # pragma: no cover
569 | pass
570 | finally:
571 | self._lock.clear()
572 | self._lockfile = None
573 | try:
574 | os.unlink(self.lockfile)
575 | except OSError: # pragma: no cover
576 | pass
577 |
578 | return True # noqa: B012
579 |
580 | def __enter__(self):
581 | """Acquire lock."""
582 | self.acquire()
583 | return self
584 |
585 | def __exit__(self, typ, value, traceback):
586 | """Release lock."""
587 | self.release()
588 |
589 | def __del__(self):
590 | """Clear up `self.lockfile`."""
591 | self.release() # pragma: no cover
592 |
593 |
594 | class uninterruptible(object):
595 | """Decorator that postpones SIGTERM until wrapped function returns.
596 |
597 | .. versionadded:: 1.12
598 |
599 | .. important:: This decorator is NOT thread-safe.
600 |
601 | As of version 2.7, Alfred allows Script Filters to be killed. If
602 | your workflow is killed in the middle of critical code (e.g.
603 | writing data to disk), this may corrupt your workflow's data.
604 |
605 | Use this decorator to wrap critical functions that *must* complete.
606 | If the script is killed while a wrapped function is executing,
607 | the SIGTERM will be caught and handled after your function has
608 | finished executing.
609 |
610 | Alfred-Workflow uses this internally to ensure its settings, data
611 | and cache writes complete.
612 |
613 | """
614 |
615 | def __init__(self, func, class_name=""):
616 | """Decorate `func`."""
617 | self.func = func
618 | functools.update_wrapper(self, func)
619 | self._caught_signal = None
620 |
621 | def signal_handler(self, signum, frame):
622 | """Called when process receives SIGTERM."""
623 | self._caught_signal = (signum, frame)
624 |
625 | def __call__(self, *args, **kwargs):
626 | """Trap ``SIGTERM`` and call wrapped function."""
627 | self._caught_signal = None
628 | # Register handler for SIGTERM, then call `self.func`
629 | self.old_signal_handler = signal.getsignal(signal.SIGTERM)
630 | signal.signal(signal.SIGTERM, self.signal_handler)
631 |
632 | self.func(*args, **kwargs)
633 |
634 | # Restore old signal handler
635 | signal.signal(signal.SIGTERM, self.old_signal_handler)
636 |
637 | # Handle any signal caught during execution
638 | if self._caught_signal is not None:
639 | signum, frame = self._caught_signal
640 | if callable(self.old_signal_handler):
641 | self.old_signal_handler(signum, frame)
642 | elif self.old_signal_handler == signal.SIG_DFL:
643 | sys.exit(0)
644 |
645 | def __get__(self, obj=None, klass=None):
646 | """Decorator API."""
647 | return self.__class__(self.func.__get__(obj, klass), klass.__name__)
648 |
--------------------------------------------------------------------------------
/workflow/version:
--------------------------------------------------------------------------------
1 | 1.40.0
--------------------------------------------------------------------------------
/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 | # modified on 2022-02-04 by Kyeongwon Lee
8 | #
9 |
10 | """Lightweight HTTP library with a requests-like interface."""
11 |
12 | from __future__ import absolute_import, print_function
13 |
14 | import codecs
15 | import json
16 | import mimetypes
17 | import os
18 | import random
19 | import re
20 | import socket
21 | import string
22 | import unicodedata
23 | import urllib
24 | import urllib.parse as urlparse
25 | import urllib.request as request3
26 | import zlib
27 |
28 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
29 |
30 | USER_AGENT = (u'Alfred-Workflow/' + __version__ +
31 | ' (+http://www.deanishe.net/alfred-workflow)')
32 |
33 | # Valid characters for multipart form data boundaries
34 | BOUNDARY_CHARS = string.digits + string.ascii_letters
35 |
36 | # HTTP response codes
37 | RESPONSES = {
38 | 100: 'Continue',
39 | 101: 'Switching Protocols',
40 | 200: 'OK',
41 | 201: 'Created',
42 | 202: 'Accepted',
43 | 203: 'Non-Authoritative Information',
44 | 204: 'No Content',
45 | 205: 'Reset Content',
46 | 206: 'Partial Content',
47 | 300: 'Multiple Choices',
48 | 301: 'Moved Permanently',
49 | 302: 'Found',
50 | 303: 'See Other',
51 | 304: 'Not Modified',
52 | 305: 'Use Proxy',
53 | 307: 'Temporary Redirect',
54 | 400: 'Bad Request',
55 | 401: 'Unauthorized',
56 | 402: 'Payment Required',
57 | 403: 'Forbidden',
58 | 404: 'Not Found',
59 | 405: 'Method Not Allowed',
60 | 406: 'Not Acceptable',
61 | 407: 'Proxy Authentication Required',
62 | 408: 'Request Timeout',
63 | 409: 'Conflict',
64 | 410: 'Gone',
65 | 411: 'Length Required',
66 | 412: 'Precondition Failed',
67 | 413: 'Request Entity Too Large',
68 | 414: 'Request-URI Too Long',
69 | 415: 'Unsupported Media Type',
70 | 416: 'Requested Range Not Satisfiable',
71 | 417: 'Expectation Failed',
72 | 500: 'Internal Server Error',
73 | 501: 'Not Implemented',
74 | 502: 'Bad Gateway',
75 | 503: 'Service Unavailable',
76 | 504: 'Gateway Timeout',
77 | 505: 'HTTP Version Not Supported'
78 | }
79 |
80 |
81 | def str_dict(dic):
82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
83 |
84 | :param dic: Mapping of Unicode strings
85 | :type dic: dict
86 | :returns: Dictionary containing only UTF-8 strings
87 | :rtype: dict
88 |
89 | """
90 | if isinstance(dic, CaseInsensitiveDictionary):
91 | dic2 = CaseInsensitiveDictionary()
92 | else:
93 | dic2 = {}
94 | for k, v in dic.items():
95 | if isinstance(k, str):
96 | k = k.encode('utf-8')
97 | if isinstance(v, str):
98 | v = v.encode('utf-8')
99 | dic2[k] = v
100 | return dic2
101 |
102 | class NoRedirectHandler(request3.HTTPRedirectHandler):
103 | """Prevent redirections."""
104 |
105 | def redirect_request(self, *args):
106 | """Ignore redirect."""
107 | return None
108 |
109 |
110 | # Adapted from https://gist.github.com/babakness/3901174
111 | class CaseInsensitiveDictionary(dict):
112 | """Dictionary with caseless key search.
113 |
114 | Enables case insensitive searching while preserving case sensitivity
115 | when keys are listed, ie, via keys() or items() methods.
116 |
117 | Works by storing a lowercase version of the key as the new key and
118 | stores the original key-value pair as the key's value
119 | (values become dictionaries).
120 |
121 | """
122 |
123 | def __init__(self, initval=None):
124 | """Create new case-insensitive dictionary."""
125 | if isinstance(initval, dict):
126 | for key, value in initval.items():
127 | self.__setitem__(key, value)
128 |
129 | elif isinstance(initval, list):
130 | for (key, value) in initval:
131 | self.__setitem__(key, value)
132 |
133 | def __contains__(self, key):
134 | return dict.__contains__(self, key.lower())
135 |
136 | def __getitem__(self, key):
137 | return dict.__getitem__(self, key.lower())['val']
138 |
139 | def __setitem__(self, key, value):
140 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
141 |
142 | def get(self, key, default=None):
143 | """Return value for case-insensitive key or default."""
144 | try:
145 | v = dict.__getitem__(self, key.lower())
146 | except KeyError:
147 | return default
148 | else:
149 | return v['val']
150 |
151 | def update(self, other):
152 | """Update values from other ``dict``."""
153 | for k, v in other.items():
154 | self[k] = v
155 |
156 | def items(self):
157 | """Return ``(key, value)`` pairs."""
158 | return [(v['key'], v['val']) for v in iter(dict(self).values())]
159 |
160 | def keys(self):
161 | """Return original keys."""
162 | return [v['key'] for v in iter(dict(self).values())]
163 |
164 | def values(self):
165 | """Return all values."""
166 | return [v['val'] for v in iter(dict(self).values())]
167 |
168 | def iteritems(self):
169 | """Iterate over ``(key, value)`` pairs."""
170 | for v in iter(dict(self).values()):
171 | yield v['key'], v['val']
172 |
173 | def iterkeys(self):
174 | """Iterate over original keys."""
175 | for v in iter(dict(self).values()):
176 | yield v['key']
177 |
178 | def itervalues(self):
179 | """Interate over values."""
180 | for v in iter(dict(self).values()):
181 | yield v['val']
182 |
183 |
184 | class Request(request3.Request):
185 | """Subclass of :class:`request3.Request` that supports custom methods."""
186 |
187 | def __init__(self, *args, **kwargs):
188 | """Create a new :class:`Request`."""
189 | self._method = kwargs.pop('method', None)
190 | request3.Request.__init__(self, *args, **kwargs)
191 |
192 | def get_method(self):
193 | return self._method.upper()
194 |
195 |
196 | class Response(object):
197 | """
198 | Returned by :func:`request` / :func:`get` / :func:`post` functions.
199 |
200 | Simplified version of the ``Response`` object in the ``requests`` library.
201 |
202 | >>> r = request('http://www.google.com')
203 | >>> r.status_code
204 | 200
205 | >>> r.encoding
206 | ISO-8859-1
207 | >>> r.content # bytes
208 | ...
209 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag
210 | u' ...'
211 | >>> r.json() # content parsed as JSON
212 |
213 | """
214 |
215 | def __init__(self, request, stream=False):
216 | """Call `request` with :mod:`urllib2` and process results.
217 |
218 | :param request: :class:`Request` instance
219 | :param stream: Whether to stream response or retrieve it all at once
220 | :type stream: bool
221 |
222 | """
223 | self.request = request
224 | self._stream = stream
225 | self.url = None
226 | self.raw = None
227 | self._encoding = None
228 | self.error = None
229 | self.status_code = None
230 | self.reason = None
231 | self.headers = CaseInsensitiveDictionary()
232 | self._content = None
233 | self._content_loaded = False
234 | self._gzipped = False
235 |
236 | # Execute query
237 | try:
238 | self.raw = request3.urlopen(request)
239 | except request3.HTTPError as err:
240 | self.error = err
241 | try:
242 | self.url = err.geturl()
243 | # sometimes (e.g. when authentication fails)
244 | # urllib can't get a URL from an HTTPError
245 | # This behaviour changes across Python versions,
246 | # so no test cover (it isn't important).
247 | except AttributeError: # pragma: no cover
248 | pass
249 | self.status_code = err.code
250 | else:
251 | self.status_code = self.raw.getcode()
252 | self.url = self.raw.geturl()
253 | self.reason = RESPONSES.get(self.status_code)
254 |
255 | # Parse additional info if request succeeded
256 | if not self.error:
257 | headers = self.raw.info()
258 | self.transfer_encoding = headers.get_content_charset()
259 | self.mimetype = headers.get("content-type")
260 | for key in headers.keys():
261 | self.headers[key.lower()] = headers.get(key)
262 |
263 | # Is content gzipped?
264 | # Transfer-Encoding appears to not be used in the wild
265 | # (contrary to the HTTP standard), but no harm in testing
266 | # for it
267 | if 'gzip' in headers.get('content-encoding', '') or \
268 | 'gzip' in headers.get('transfer-encoding', ''):
269 | self._gzipped = True
270 |
271 | @property
272 | def stream(self):
273 | """Whether response is streamed.
274 |
275 | Returns:
276 | bool: `True` if response is streamed.
277 |
278 | """
279 | return self._stream
280 |
281 | @stream.setter
282 | def stream(self, value):
283 | if self._content_loaded:
284 | raise RuntimeError("`content` has already been read from "
285 | "this Response.")
286 |
287 | self._stream = value
288 |
289 | def json(self):
290 | """Decode response contents as JSON.
291 |
292 | :returns: object decoded from JSON
293 | :rtype: list, dict or unicode
294 |
295 | """
296 | return json.loads(self.content)
297 |
298 | @property
299 | def encoding(self):
300 | """Text encoding of document or ``None``.
301 |
302 | :returns: Text encoding if found.
303 | :rtype: str or ``None``
304 |
305 | """
306 | if not self._encoding:
307 | self._encoding = self._get_encoding()
308 |
309 | return self._encoding
310 |
311 | @property
312 | def content(self):
313 | """Raw content of response (i.e. bytes).
314 |
315 | :returns: Body of HTTP response
316 | :rtype: str
317 |
318 | """
319 | if not self._content:
320 |
321 | # Decompress gzipped content
322 | if self._gzipped:
323 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
324 | self._content = decoder.decompress(self.raw.read())
325 |
326 | else:
327 | self._content = self.raw.read()
328 |
329 | self._content_loaded = True
330 |
331 | return self._content
332 |
333 | @property
334 | def text(self):
335 | """Unicode-decoded content of response body.
336 |
337 | If no encoding can be determined from HTTP headers or the content
338 | itself, the encoded response body will be returned instead.
339 |
340 | :returns: Body of HTTP response
341 | :rtype: unicode or str
342 |
343 | """
344 | if self.encoding:
345 | return unicodedata.normalize('NFC', str(self.content))
346 | return self.content
347 |
348 | def iter_content(self, chunk_size=4096, decode_unicode=False):
349 | """Iterate over response data.
350 |
351 | .. versionadded:: 1.6
352 |
353 | :param chunk_size: Number of bytes to read into memory
354 | :type chunk_size: int
355 | :param decode_unicode: Decode to Unicode using detected encoding
356 | :type decode_unicode: bool
357 | :returns: iterator
358 |
359 | """
360 | if not self.stream:
361 | raise RuntimeError("You cannot call `iter_content` on a "
362 | "Response unless you passed `stream=True`"
363 | " to `get()`/`post()`/`request()`.")
364 |
365 | if self._content_loaded:
366 | raise RuntimeError(
367 | "`content` has already been read from this Response.")
368 |
369 | def decode_stream(iterator, r):
370 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace')
371 |
372 | for chunk in iterator:
373 | data = dec.decode(chunk)
374 | if data:
375 | yield data
376 |
377 | data = dec.decode(b'', final=True)
378 | if data: # pragma: no cover
379 | yield data
380 |
381 | def generate():
382 | if self._gzipped:
383 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
384 |
385 | while True:
386 | chunk = self.raw.read(chunk_size)
387 | if not chunk:
388 | break
389 |
390 | if self._gzipped:
391 | chunk = decoder.decompress(chunk)
392 |
393 | yield chunk
394 |
395 | chunks = generate()
396 |
397 | if decode_unicode and self.encoding:
398 | chunks = decode_stream(chunks, self)
399 |
400 | return chunks
401 |
402 | def save_to_path(self, filepath):
403 | """Save retrieved data to file at ``filepath``.
404 |
405 | .. versionadded: 1.9.6
406 |
407 | :param filepath: Path to save retrieved data.
408 |
409 | """
410 | filepath = os.path.abspath(filepath)
411 | dirname = os.path.dirname(filepath)
412 | if not os.path.exists(dirname):
413 | os.makedirs(dirname)
414 |
415 | self.stream = True
416 |
417 | with open(filepath, 'wb') as fileobj:
418 | for data in self.iter_content():
419 | fileobj.write(data)
420 |
421 | def raise_for_status(self):
422 | """Raise stored error if one occurred.
423 |
424 | error will be instance of :class:`request3.HTTPError`
425 | """
426 | if self.error is not None:
427 | raise self.error
428 | return
429 |
430 | def _get_encoding(self):
431 | """Get encoding from HTTP headers or content.
432 |
433 | :returns: encoding or `None`
434 | :rtype: unicode or ``None``
435 |
436 | """
437 | headers = self.raw.info()
438 | encoding = None
439 |
440 | if headers.get_param('charset'):
441 | encoding = headers.get_param('charset')
442 |
443 | if not self.stream: # Try sniffing response content
444 | # Encoding declared in document should override HTTP headers
445 | if self.mimetype == 'text/html': # sniff HTML headers
446 | m = re.search(r"""""",
447 | self.content)
448 | if m:
449 | encoding = m.group(1)
450 |
451 | elif ((self.mimetype.startswith('application/')
452 | or self.mimetype.startswith('text/'))
453 | and 'xml' in self.mimetype):
454 | m = re.search(r"""]*\?>""",
455 | self.content)
456 | if m:
457 | encoding = m.group(1)
458 |
459 | # Format defaults
460 | if self.mimetype == 'application/json' and not encoding:
461 | # The default encoding for JSON
462 | encoding = 'utf-8'
463 |
464 | elif self.mimetype == 'application/xml' and not encoding:
465 | # The default for 'application/xml'
466 | encoding = 'utf-8'
467 |
468 | if encoding:
469 | encoding = encoding.lower()
470 |
471 | return encoding
472 |
473 |
474 | def request(method, url, params=None, data=None, headers=None, cookies=None,
475 | files=None, auth=None, timeout=60, allow_redirects=False,
476 | stream=False):
477 | """Initiate an HTTP(S) request. Returns :class:`Response` object.
478 |
479 | :param method: 'GET' or 'POST'
480 | :type method: unicode
481 | :param url: URL to open
482 | :type url: unicode
483 | :param params: mapping of URL parameters
484 | :type params: dict
485 | :param data: mapping of form data ``{'field_name': 'value'}`` or
486 | :class:`str`
487 | :type data: dict or str
488 | :param headers: HTTP headers
489 | :type headers: dict
490 | :param cookies: cookies to send to server
491 | :type cookies: dict
492 | :param files: files to upload (see below).
493 | :type files: dict
494 | :param auth: username, password
495 | :type auth: tuple
496 | :param timeout: connection timeout limit in seconds
497 | :type timeout: int
498 | :param allow_redirects: follow redirections
499 | :type allow_redirects: bool
500 | :param stream: Stream content instead of fetching it all at once.
501 | :type stream: bool
502 | :returns: Response object
503 | :rtype: :class:`Response`
504 |
505 |
506 | The ``files`` argument is a dictionary::
507 |
508 | {'fieldname' : { 'filename': 'blah.txt',
509 | 'content': '',
510 | 'mimetype': 'text/plain'}
511 | }
512 |
513 | * ``fieldname`` is the name of the field in the HTML form.
514 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
515 | be used to guess the mimetype, or ``application/octet-stream``
516 | will be used.
517 |
518 | """
519 | # TODO: cookies
520 | socket.setdefaulttimeout(timeout)
521 |
522 | # Default handlers
523 | openers = [request3.ProxyHandler(request3.getproxies())]
524 |
525 | if not allow_redirects:
526 | openers.append(NoRedirectHandler())
527 |
528 | if auth is not None: # Add authorisation handler
529 | username, password = auth
530 | password_manager = request3.HTTPPasswordMgrWithDefaultRealm()
531 | password_manager.add_password(None, url, username, password)
532 | auth_manager = request3.HTTPBasicAuthHandler(password_manager)
533 | openers.append(auth_manager)
534 |
535 | # Install our custom chain of openers
536 | opener = request3.build_opener(*openers)
537 | request3.install_opener(opener)
538 |
539 | if not headers:
540 | headers = CaseInsensitiveDictionary()
541 | else:
542 | headers = CaseInsensitiveDictionary(headers)
543 |
544 | if 'user-agent' not in headers:
545 | headers['user-agent'] = USER_AGENT
546 |
547 | # Accept gzip-encoded content
548 | encodings = [s.strip() for s in
549 | headers.get('accept-encoding', '').split(',')]
550 | if 'gzip' not in encodings:
551 | encodings.append('gzip')
552 |
553 | headers['accept-encoding'] = ', '.join(encodings)
554 |
555 | if files:
556 | if not data:
557 | data = {}
558 | new_headers, data = encode_multipart_formdata(data, files)
559 | headers.update(new_headers)
560 | elif data and isinstance(data, dict):
561 | data = urlparse.urlencode(str_dict(data))
562 |
563 | # Make sure everything is encoded text
564 | headers = str_dict(headers)
565 |
566 | # if isinstance(url, str):
567 | # url = url.encode('utf-8')
568 |
569 | if params: # GET args (POST args are handled in encode_multipart_formdata)
570 |
571 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
572 |
573 | if query: # Combine query string and `params`
574 | url_params = urlparse.parse_qs(query)
575 | # `params` take precedence over URL query string
576 | url_params.update(params)
577 | params = url_params
578 |
579 | query = urlparse.urlencode(str_dict(params), doseq=True)
580 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
581 |
582 | req = Request(url, data, headers, method=method)
583 | return Response(req, stream)
584 |
585 |
586 | def get(url, params=None, headers=None, cookies=None, auth=None,
587 | timeout=60, allow_redirects=True, stream=False):
588 | """Initiate a GET request. Arguments as for :func:`request`.
589 |
590 | :returns: :class:`Response` instance
591 |
592 | """
593 | return request('GET', url, params, headers=headers, cookies=cookies,
594 | auth=auth, timeout=timeout, allow_redirects=allow_redirects,
595 | stream=stream)
596 |
597 |
598 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None,
599 | timeout=60, allow_redirects=True, stream=False):
600 | """Initiate a DELETE request. Arguments as for :func:`request`.
601 |
602 | :returns: :class:`Response` instance
603 |
604 | """
605 | return request('DELETE', url, params, data, headers=headers,
606 | cookies=cookies, auth=auth, timeout=timeout,
607 | allow_redirects=allow_redirects, stream=stream)
608 |
609 |
610 | def post(url, params=None, data=None, headers=None, cookies=None, files=None,
611 | auth=None, timeout=60, allow_redirects=False, stream=False):
612 | """Initiate a POST request. Arguments as for :func:`request`.
613 |
614 | :returns: :class:`Response` instance
615 |
616 | """
617 | return request('POST', url, params, data, headers, cookies, files, auth,
618 | timeout, allow_redirects, stream)
619 |
620 |
621 | def put(url, params=None, data=None, headers=None, cookies=None, files=None,
622 | auth=None, timeout=60, allow_redirects=False, stream=False):
623 | """Initiate a PUT request. Arguments as for :func:`request`.
624 |
625 | :returns: :class:`Response` instance
626 |
627 | """
628 | return request('PUT', url, params, data, headers, cookies, files, auth,
629 | timeout, allow_redirects, stream)
630 |
631 |
632 | def encode_multipart_formdata(fields, files):
633 | """Encode form data (``fields``) and ``files`` for POST request.
634 |
635 | :param fields: mapping of ``{name : value}`` pairs for normal form fields.
636 | :type fields: dict
637 | :param files: dictionary of fieldnames/files elements for file data.
638 | See below for details.
639 | :type files: dict of :class:`dict`
640 | :returns: ``(headers, body)`` ``headers`` is a
641 | :class:`dict` of HTTP headers
642 | :rtype: 2-tuple ``(dict, str)``
643 |
644 | The ``files`` argument is a dictionary::
645 |
646 | {'fieldname' : { 'filename': 'blah.txt',
647 | 'content': '',
648 | 'mimetype': 'text/plain'}
649 | }
650 |
651 | - ``fieldname`` is the name of the field in the HTML form.
652 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
653 | be used to guess the mimetype, or ``application/octet-stream``
654 | will be used.
655 |
656 | """
657 | def get_content_type(filename):
658 | """Return or guess mimetype of ``filename``.
659 |
660 | :param filename: filename of file
661 | :type filename: unicode/str
662 | :returns: mime-type, e.g. ``text/html``
663 | :rtype: str
664 |
665 | """
666 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
667 |
668 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
669 | for i in range(30))
670 | CRLF = '\r\n'
671 | output = []
672 |
673 | # Normal form fields
674 | for (name, value) in fields.items():
675 | if isinstance(name, str):
676 | name = name.encode('utf-8')
677 | if isinstance(value, str):
678 | value = value.encode('utf-8')
679 | output.append('--' + boundary)
680 | output.append('Content-Disposition: form-data; name="%s"' % name)
681 | output.append('')
682 | output.append(value)
683 |
684 | # Files to upload
685 | for name, d in files.items():
686 | filename = d[u'filename']
687 | content = d[u'content']
688 | if u'mimetype' in d:
689 | mimetype = d[u'mimetype']
690 | else:
691 | mimetype = get_content_type(filename)
692 | if isinstance(name, str):
693 | name = name.encode('utf-8')
694 | if isinstance(filename, str):
695 | filename = filename.encode('utf-8')
696 | if isinstance(mimetype, str):
697 | mimetype = mimetype.encode('utf-8')
698 | output.append('--' + boundary)
699 | output.append('Content-Disposition: form-data; '
700 | 'name="%s"; filename="%s"' % (name, filename))
701 | output.append('Content-Type: %s' % mimetype)
702 | output.append('')
703 | output.append(content)
704 |
705 | output.append('--' + boundary + '--')
706 | output.append('')
707 | body = CRLF.join(output)
708 | headers = {
709 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
710 | 'Content-Length': str(len(body)),
711 | }
712 | return (headers, body)
713 |
--------------------------------------------------------------------------------
/workflow/workflow3.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | #
3 | # Copyright (c) 2016 Dean Jackson
4 | #
5 | # MIT Licence. See http://opensource.org/licenses/MIT
6 | #
7 | # Created on 2016-06-25
8 | #
9 |
10 | """An Alfred 3+ version of :class:`~workflow.Workflow`.
11 |
12 | :class:`~workflow.Workflow3` supports new features, such as
13 | setting :ref:`workflow-variables` and
14 | :class:`the more advanced modifiers ` supported by Alfred 3+.
15 |
16 | In order for the feedback mechanism to work correctly, it's important
17 | to create :class:`Item3` and :class:`Modifier` objects via the
18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier`
20 | objects directly, the current :class:`Workflow3` object won't be aware
21 | of them, and they won't be sent to Alfred when you call
22 | :meth:`Workflow3.send_feedback()`.
23 |
24 | """
25 |
26 |
27 | import json
28 | import os
29 | import sys
30 |
31 | from .workflow import ICON_WARNING, Workflow
32 |
33 |
34 | class Variables(dict):
35 | """Workflow variables for Run Script actions.
36 |
37 | .. versionadded: 1.26
38 |
39 | This class allows you to set workflow variables from
40 | Run Script actions.
41 |
42 | It is a subclass of :class:`dict`.
43 |
44 | >>> v = Variables(username='deanishe', password='hunter2')
45 | >>> v.arg = u'output value'
46 | >>> print(v)
47 |
48 | See :ref:`variables-run-script` in the User Guide for more
49 | information.
50 |
51 | Args:
52 | arg (unicode or list, optional): Main output/``{query}``.
53 | **variables: Workflow variables to set.
54 |
55 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
56 | :class:`list` or :class:`tuple`.
57 |
58 | Attributes:
59 | arg (unicode or list): Output value (``{query}``).
60 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
61 | :class:`list` or :class:`tuple`.
62 | config (dict): Configuration for downstream workflow element.
63 |
64 | """
65 |
66 | def __init__(self, arg=None, **variables):
67 | """Create a new `Variables` object."""
68 | self.arg = arg
69 | self.config = {}
70 | super(Variables, self).__init__(**variables)
71 |
72 | @property
73 | def obj(self):
74 | """``alfredworkflow`` :class:`dict`."""
75 | o = {}
76 | if self:
77 | d2 = {}
78 | for k, v in list(self.items()):
79 | d2[k] = v
80 | o["variables"] = d2
81 |
82 | if self.config:
83 | o["config"] = self.config
84 |
85 | if self.arg is not None:
86 | o["arg"] = self.arg
87 |
88 | return {"alfredworkflow": o}
89 |
90 | def __str__(self):
91 | """Convert to ``alfredworkflow`` JSON object.
92 |
93 | Returns:
94 | unicode: ``alfredworkflow`` JSON object
95 |
96 | """
97 | if not self and not self.config:
98 | if not self.arg:
99 | return ""
100 | if isinstance(self.arg, str):
101 | return self.arg
102 |
103 | return json.dumps(self.obj)
104 |
105 |
106 | class Modifier(object):
107 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed.
108 |
109 | Don't use this class directly (as it won't be associated with any
110 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
111 | to add modifiers to results.
112 |
113 | >>> it = wf.add_item('Title', 'Subtitle', valid=True)
114 | >>> it.setvar('name', 'default')
115 | >>> m = it.add_modifier('cmd')
116 | >>> m.setvar('name', 'alternate')
117 |
118 | See :ref:`workflow-variables` in the User Guide for more information
119 | and :ref:`example usage `.
120 |
121 | Args:
122 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
123 | subtitle (unicode, optional): Override default subtitle.
124 | arg (unicode, optional): Argument to pass for this modifier.
125 | valid (bool, optional): Override item's validity.
126 | icon (unicode, optional): Filepath/UTI of icon to use
127 | icontype (unicode, optional): Type of icon. See
128 | :meth:`Workflow.add_item() `
129 | for valid values.
130 |
131 | Attributes:
132 | arg (unicode): Arg to pass to following action.
133 | config (dict): Configuration for a downstream element, such as
134 | a File Filter.
135 | icon (unicode): Filepath/UTI of icon.
136 | icontype (unicode): Type of icon. See
137 | :meth:`Workflow.add_item() `
138 | for valid values.
139 | key (unicode): Modifier key (see above).
140 | subtitle (unicode): Override item subtitle.
141 | valid (bool): Override item validity.
142 | variables (dict): Workflow variables set by this modifier.
143 |
144 | """
145 |
146 | def __init__(
147 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
148 | ):
149 | """Create a new :class:`Modifier`.
150 |
151 | Don't use this class directly (as it won't be associated with any
152 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
153 | to add modifiers to results.
154 |
155 | Args:
156 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
157 | subtitle (unicode, optional): Override default subtitle.
158 | arg (unicode, optional): Argument to pass for this modifier.
159 | valid (bool, optional): Override item's validity.
160 | icon (unicode, optional): Filepath/UTI of icon to use
161 | icontype (unicode, optional): Type of icon. See
162 | :meth:`Workflow.add_item() `
163 | for valid values.
164 |
165 | """
166 | self.key = key
167 | self.subtitle = subtitle
168 | self.arg = arg
169 | self.valid = valid
170 | self.icon = icon
171 | self.icontype = icontype
172 |
173 | self.config = {}
174 | self.variables = {}
175 |
176 | def setvar(self, name, value):
177 | """Set a workflow variable for this Item.
178 |
179 | Args:
180 | name (unicode): Name of variable.
181 | value (unicode): Value of variable.
182 |
183 | """
184 | self.variables[name] = value
185 |
186 | def getvar(self, name, default=None):
187 | """Return value of workflow variable for ``name`` or ``default``.
188 |
189 | Args:
190 | name (unicode): Variable name.
191 | default (None, optional): Value to return if variable is unset.
192 |
193 | Returns:
194 | unicode or ``default``: Value of variable if set or ``default``.
195 |
196 | """
197 | return self.variables.get(name, default)
198 |
199 | @property
200 | def obj(self):
201 | """Modifier formatted for JSON serialization for Alfred 3.
202 |
203 | Returns:
204 | dict: Modifier for serializing to JSON.
205 |
206 | """
207 | o = {}
208 |
209 | if self.subtitle is not None:
210 | o["subtitle"] = self.subtitle
211 |
212 | if self.arg is not None:
213 | o["arg"] = self.arg
214 |
215 | if self.valid is not None:
216 | o["valid"] = self.valid
217 |
218 | if self.variables:
219 | o["variables"] = self.variables
220 |
221 | if self.config:
222 | o["config"] = self.config
223 |
224 | icon = self._icon()
225 | if icon:
226 | o["icon"] = icon
227 |
228 | return o
229 |
230 | def _icon(self):
231 | """Return `icon` object for item.
232 |
233 | Returns:
234 | dict: Mapping for item `icon` (may be empty).
235 |
236 | """
237 | icon = {}
238 | if self.icon is not None:
239 | icon["path"] = self.icon
240 |
241 | if self.icontype is not None:
242 | icon["type"] = self.icontype
243 |
244 | return icon
245 |
246 |
247 | class Item3(object):
248 | """Represents a feedback item for Alfred 3+.
249 |
250 | Generates Alfred-compliant JSON for a single item.
251 |
252 | Don't use this class directly (as it then won't be associated with
253 | any :class:`Workflow3 ` object), but rather use
254 | :meth:`Workflow3.add_item() `.
255 | See :meth:`~workflow.Workflow3.add_item` for details of arguments.
256 |
257 | """
258 |
259 | def __init__(
260 | self,
261 | title,
262 | subtitle="",
263 | arg=None,
264 | autocomplete=None,
265 | match=None,
266 | valid=False,
267 | uid=None,
268 | icon=None,
269 | icontype=None,
270 | type=None,
271 | largetext=None,
272 | copytext=None,
273 | quicklookurl=None,
274 | ):
275 | """Create a new :class:`Item3` object.
276 |
277 | Use same arguments as for
278 | :class:`Workflow.Item `.
279 |
280 | Argument ``subtitle_modifiers`` is not supported.
281 |
282 | """
283 | self.title = title
284 | self.subtitle = subtitle
285 | self.arg = arg
286 | self.autocomplete = autocomplete
287 | self.match = match
288 | self.valid = valid
289 | self.uid = uid
290 | self.icon = icon
291 | self.icontype = icontype
292 | self.type = type
293 | self.quicklookurl = quicklookurl
294 | self.largetext = largetext
295 | self.copytext = copytext
296 |
297 | self.modifiers = {}
298 |
299 | self.config = {}
300 | self.variables = {}
301 |
302 | def setvar(self, name, value):
303 | """Set a workflow variable for this Item.
304 |
305 | Args:
306 | name (unicode): Name of variable.
307 | value (unicode): Value of variable.
308 |
309 | """
310 | self.variables[name] = value
311 |
312 | def getvar(self, name, default=None):
313 | """Return value of workflow variable for ``name`` or ``default``.
314 |
315 | Args:
316 | name (unicode): Variable name.
317 | default (None, optional): Value to return if variable is unset.
318 |
319 | Returns:
320 | unicode or ``default``: Value of variable if set or ``default``.
321 |
322 | """
323 | return self.variables.get(name, default)
324 |
325 | def add_modifier(
326 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
327 | ):
328 | """Add alternative values for a modifier key.
329 |
330 | Args:
331 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"``
332 | subtitle (unicode, optional): Override item subtitle.
333 | arg (unicode, optional): Input for following action.
334 | valid (bool, optional): Override item validity.
335 | icon (unicode, optional): Filepath/UTI of icon.
336 | icontype (unicode, optional): Type of icon. See
337 | :meth:`Workflow.add_item() `
338 | for valid values.
339 |
340 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
341 | :class:`list` or :class:`tuple`.
342 |
343 | Returns:
344 | Modifier: Configured :class:`Modifier`.
345 |
346 | """
347 | mod = Modifier(key, subtitle, arg, valid, icon, icontype)
348 |
349 | # Add Item variables to Modifier
350 | mod.variables.update(self.variables)
351 |
352 | self.modifiers[key] = mod
353 |
354 | return mod
355 |
356 | @property
357 | def obj(self):
358 | """Item formatted for JSON serialization.
359 |
360 | Returns:
361 | dict: Data suitable for Alfred 3 feedback.
362 |
363 | """
364 | # Required values
365 | o = {"title": self.title, "subtitle": self.subtitle, "valid": self.valid}
366 |
367 | # Optional values
368 | if self.arg is not None:
369 | o["arg"] = self.arg
370 |
371 | if self.autocomplete is not None:
372 | o["autocomplete"] = self.autocomplete
373 |
374 | if self.match is not None:
375 | o["match"] = self.match
376 |
377 | if self.uid is not None:
378 | o["uid"] = self.uid
379 |
380 | if self.type is not None:
381 | o["type"] = self.type
382 |
383 | if self.quicklookurl is not None:
384 | o["quicklookurl"] = self.quicklookurl
385 |
386 | if self.variables:
387 | o["variables"] = self.variables
388 |
389 | if self.config:
390 | o["config"] = self.config
391 |
392 | # Largetype and copytext
393 | text = self._text()
394 | if text:
395 | o["text"] = text
396 |
397 | icon = self._icon()
398 | if icon:
399 | o["icon"] = icon
400 |
401 | # Modifiers
402 | mods = self._modifiers()
403 | if mods:
404 | o["mods"] = mods
405 |
406 | return o
407 |
408 | def _icon(self):
409 | """Return `icon` object for item.
410 |
411 | Returns:
412 | dict: Mapping for item `icon` (may be empty).
413 |
414 | """
415 | icon = {}
416 | if self.icon is not None:
417 | icon["path"] = self.icon
418 |
419 | if self.icontype is not None:
420 | icon["type"] = self.icontype
421 |
422 | return icon
423 |
424 | def _text(self):
425 | """Return `largetext` and `copytext` object for item.
426 |
427 | Returns:
428 | dict: `text` mapping (may be empty)
429 |
430 | """
431 | text = {}
432 | if self.largetext is not None:
433 | text["largetype"] = self.largetext
434 |
435 | if self.copytext is not None:
436 | text["copy"] = self.copytext
437 |
438 | return text
439 |
440 | def _modifiers(self):
441 | """Build `mods` dictionary for JSON feedback.
442 |
443 | Returns:
444 | dict: Modifier mapping or `None`.
445 |
446 | """
447 | if self.modifiers:
448 | mods = {}
449 | for k, mod in list(self.modifiers.items()):
450 | mods[k] = mod.obj
451 |
452 | return mods
453 |
454 | return None
455 |
456 |
457 | class Workflow3(Workflow):
458 | """Workflow class that generates Alfred 3+ feedback.
459 |
460 | It is a subclass of :class:`~workflow.Workflow` and most of its
461 | methods are documented there.
462 |
463 | Attributes:
464 | item_class (class): Class used to generate feedback items.
465 | variables (dict): Top level workflow variables.
466 |
467 | """
468 |
469 | item_class = Item3
470 |
471 | def __init__(self, **kwargs):
472 | """Create a new :class:`Workflow3` object.
473 |
474 | See :class:`~workflow.Workflow` for documentation.
475 |
476 | """
477 | Workflow.__init__(self, **kwargs)
478 | self.variables = {}
479 | self._rerun = 0
480 | # Get session ID from environment if present
481 | self._session_id = os.getenv("_WF_SESSION_ID") or None
482 | if self._session_id:
483 | self.setvar("_WF_SESSION_ID", self._session_id)
484 |
485 | @property
486 | def _default_cachedir(self):
487 | """Alfred 4's default cache directory."""
488 | return os.path.join(
489 | os.path.expanduser(
490 | "~/Library/Caches/com.runningwithcrayons.Alfred/" "Workflow Data/"
491 | ),
492 | self.bundleid,
493 | )
494 |
495 | @property
496 | def _default_datadir(self):
497 | """Alfred 4's default data directory."""
498 | return os.path.join(
499 | os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"),
500 | self.bundleid,
501 | )
502 |
503 | @property
504 | def rerun(self):
505 | """How often (in seconds) Alfred should re-run the Script Filter."""
506 | return self._rerun
507 |
508 | @rerun.setter
509 | def rerun(self, seconds):
510 | """Interval at which Alfred should re-run the Script Filter.
511 |
512 | Args:
513 | seconds (int): Interval between runs.
514 | """
515 | self._rerun = seconds
516 |
517 | @property
518 | def session_id(self):
519 | """A unique session ID every time the user uses the workflow.
520 |
521 | .. versionadded:: 1.25
522 |
523 | The session ID persists while the user is using this workflow.
524 | It expires when the user runs a different workflow or closes
525 | Alfred.
526 |
527 | """
528 | if not self._session_id:
529 | from uuid import uuid4
530 |
531 | self._session_id = uuid4().hex
532 | self.setvar("_WF_SESSION_ID", self._session_id)
533 |
534 | return self._session_id
535 |
536 | def setvar(self, name, value, persist=False):
537 | """Set a "global" workflow variable.
538 |
539 | .. versionchanged:: 1.33
540 |
541 | These variables are always passed to downstream workflow objects.
542 |
543 | If you have set :attr:`rerun`, these variables are also passed
544 | back to the script when Alfred runs it again.
545 |
546 | Args:
547 | name (unicode): Name of variable.
548 | value (unicode): Value of variable.
549 | persist (bool, optional): Also save variable to ``info.plist``?
550 |
551 | """
552 | self.variables[name] = value
553 | if persist:
554 | from .util import set_config
555 |
556 | set_config(name, value, self.bundleid)
557 | self.logger.debug(
558 | "saved variable %r with value %r to info.plist", name, value
559 | )
560 |
561 | def getvar(self, name, default=None):
562 | """Return value of workflow variable for ``name`` or ``default``.
563 |
564 | Args:
565 | name (unicode): Variable name.
566 | default (None, optional): Value to return if variable is unset.
567 |
568 | Returns:
569 | unicode or ``default``: Value of variable if set or ``default``.
570 |
571 | """
572 | return self.variables.get(name, default)
573 |
574 | def add_item(
575 | self,
576 | title,
577 | subtitle="",
578 | arg=None,
579 | autocomplete=None,
580 | valid=False,
581 | uid=None,
582 | icon=None,
583 | icontype=None,
584 | type=None,
585 | largetext=None,
586 | copytext=None,
587 | quicklookurl=None,
588 | match=None,
589 | ):
590 | """Add an item to be output to Alfred.
591 |
592 | Args:
593 | match (unicode, optional): If you have "Alfred filters results"
594 | turned on for your Script Filter, Alfred (version 3.5 and
595 | above) will filter against this field, not ``title``.
596 |
597 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a
598 | :class:`list` or :class:`tuple`.
599 |
600 | See :meth:`Workflow.add_item() ` for
601 | the main documentation and other parameters.
602 |
603 | The key difference is that this method does not support the
604 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
605 | method instead on the returned item instead.
606 |
607 | Returns:
608 | Item3: Alfred feedback item.
609 |
610 | """
611 | item = self.item_class(
612 | title,
613 | subtitle,
614 | arg,
615 | autocomplete,
616 | match,
617 | valid,
618 | uid,
619 | icon,
620 | icontype,
621 | type,
622 | largetext,
623 | copytext,
624 | quicklookurl,
625 | )
626 |
627 | # Add variables to child item
628 | item.variables.update(self.variables)
629 |
630 | self._items.append(item)
631 | return item
632 |
633 | @property
634 | def _session_prefix(self):
635 | """Filename prefix for current session."""
636 | return "_wfsess-{0}-".format(self.session_id)
637 |
638 | def _mk_session_name(self, name):
639 | """New cache name/key based on session ID."""
640 | return self._session_prefix + name
641 |
642 | def cache_data(self, name, data, session=False):
643 | """Cache API with session-scoped expiry.
644 |
645 | .. versionadded:: 1.25
646 |
647 | Args:
648 | name (str): Cache key
649 | data (object): Data to cache
650 | session (bool, optional): Whether to scope the cache
651 | to the current session.
652 |
653 | ``name`` and ``data`` are the same as for the
654 | :meth:`~workflow.Workflow.cache_data` method on
655 | :class:`~workflow.Workflow`.
656 |
657 | If ``session`` is ``True``, then ``name`` is prefixed
658 | with :attr:`session_id`.
659 |
660 | """
661 | if session:
662 | name = self._mk_session_name(name)
663 |
664 | return super(Workflow3, self).cache_data(name, data)
665 |
666 | def cached_data(self, name, data_func=None, max_age=60, session=False):
667 | """Cache API with session-scoped expiry.
668 |
669 | .. versionadded:: 1.25
670 |
671 | Args:
672 | name (str): Cache key
673 | data_func (callable): Callable that returns fresh data. It
674 | is called if the cache has expired or doesn't exist.
675 | max_age (int): Maximum allowable age of cache in seconds.
676 | session (bool, optional): Whether to scope the cache
677 | to the current session.
678 |
679 | ``name``, ``data_func`` and ``max_age`` are the same as for the
680 | :meth:`~workflow.Workflow.cached_data` method on
681 | :class:`~workflow.Workflow`.
682 |
683 | If ``session`` is ``True``, then ``name`` is prefixed
684 | with :attr:`session_id`.
685 |
686 | """
687 | if session:
688 | name = self._mk_session_name(name)
689 |
690 | return super(Workflow3, self).cached_data(name, data_func, max_age)
691 |
692 | def clear_session_cache(self, current=False):
693 | """Remove session data from the cache.
694 |
695 | .. versionadded:: 1.25
696 | .. versionchanged:: 1.27
697 |
698 | By default, data belonging to the current session won't be
699 | deleted. Set ``current=True`` to also clear current session.
700 |
701 | Args:
702 | current (bool, optional): If ``True``, also remove data for
703 | current session.
704 |
705 | """
706 |
707 | def _is_session_file(filename):
708 | if current:
709 | return filename.startswith("_wfsess-")
710 | return filename.startswith("_wfsess-") and not filename.startswith(
711 | self._session_prefix
712 | )
713 |
714 | self.clear_cache(_is_session_file)
715 |
716 | @property
717 | def obj(self):
718 | """Feedback formatted for JSON serialization.
719 |
720 | Returns:
721 | dict: Data suitable for Alfred 3 feedback.
722 |
723 | """
724 | items = []
725 | for item in self._items:
726 | items.append(item.obj)
727 |
728 | o = {"items": items}
729 | if self.variables:
730 | o["variables"] = self.variables
731 | if self.rerun:
732 | o["rerun"] = self.rerun
733 | return o
734 |
735 | def warn_empty(self, title, subtitle="", icon=None):
736 | """Add a warning to feedback if there are no items.
737 |
738 | .. versionadded:: 1.31
739 |
740 | Add a "warning" item to Alfred feedback if no other items
741 | have been added. This is a handy shortcut to prevent Alfred
742 | from showing its fallback searches, which is does if no
743 | items are returned.
744 |
745 | Args:
746 | title (unicode): Title of feedback item.
747 | subtitle (unicode, optional): Subtitle of feedback item.
748 | icon (str, optional): Icon for feedback item. If not
749 | specified, ``ICON_WARNING`` is used.
750 |
751 | Returns:
752 | Item3: Newly-created item.
753 |
754 | """
755 | if len(self._items):
756 | return
757 |
758 | icon = icon or ICON_WARNING
759 | return self.add_item(title, subtitle, icon=icon)
760 |
761 | def send_feedback(self):
762 | """Print stored items to console/Alfred as JSON."""
763 | if self.debugging:
764 | json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": "))
765 | else:
766 | json.dump(self.obj, sys.stdout)
767 | sys.stdout.flush()
768 |
--------------------------------------------------------------------------------