├── .gitignore ├── Git-Repos-3.1.2.alfredworkflow ├── Icons ├── Git Repos.icns ├── Git Repos.iconset │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png └── Git Repos.iconsproj ├── LICENCE.txt ├── README.md ├── Todo.taskpaper ├── demo.gif ├── icon.png ├── requirements.txt └── src ├── LICENCE.txt ├── README.html ├── docopt.py ├── icon.png ├── info.plist ├── repos.py ├── update-available.png ├── update.py └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── web.py ├── workflow.py └── workflow3.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/sublimetext,vim,python 3 | 4 | ### SublimeText ### 5 | # cache files for sublime text 6 | *.tmlanguage.cache 7 | *.tmPreferences.cache 8 | *.stTheme.cache 9 | 10 | # workspace files are user-specific 11 | *.sublime-workspace 12 | 13 | # project files should be checked into the repository, unless a significant 14 | # proportion of contributors will probably not be using SublimeText 15 | # *.sublime-project 16 | 17 | # sftp configuration file 18 | sftp-config.json 19 | 20 | # Package control specific files 21 | Package Control.last-run 22 | Package Control.ca-list 23 | Package Control.ca-bundle 24 | Package Control.system-ca-bundle 25 | Package Control.cache/ 26 | Package Control.ca-certs/ 27 | bh_unicode_properties.cache 28 | 29 | # Sublime-github package stores a github token in this file 30 | # https://packagecontrol.io/packages/sublime-github 31 | GitHub.sublime-settings 32 | 33 | 34 | ### Vim ### 35 | # swap 36 | [._]*.s[a-w][a-z] 37 | [._]s[a-w][a-z] 38 | # session 39 | Session.vim 40 | # temporary 41 | .netrwhist 42 | *~ 43 | # auto-generated tag files 44 | tags 45 | 46 | 47 | ### Python ### 48 | # Byte-compiled / optimized / DLL files 49 | __pycache__/ 50 | *.py[cod] 51 | *$py.class 52 | 53 | # C extensions 54 | *.so 55 | 56 | # Distribution / packaging 57 | .Python 58 | env/ 59 | build/ 60 | develop-eggs/ 61 | dist/ 62 | downloads/ 63 | eggs/ 64 | .eggs/ 65 | parts/ 66 | sdist/ 67 | var/ 68 | *.egg-info/ 69 | *.dist-info/ 70 | .installed.cfg 71 | *.egg 72 | 73 | # PyInstaller 74 | # Usually these files are written by a python script from a template 75 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 76 | *.manifest 77 | *.spec 78 | 79 | # Installer logs 80 | pip-log.txt 81 | pip-delete-this-directory.txt 82 | 83 | # Unit test / coverage reports 84 | htmlcov/ 85 | .tox/ 86 | .coverage 87 | .coverage.* 88 | .cache 89 | nosetests.xml 90 | coverage.xml 91 | *,cover 92 | .hypothesis/ 93 | 94 | # Translations 95 | *.mo 96 | *.pot 97 | 98 | # Django stuff: 99 | *.log 100 | local_settings.py 101 | 102 | # Flask stuff: 103 | instance/ 104 | .webassets-cache 105 | 106 | # Scrapy stuff: 107 | .scrapy 108 | 109 | # Sphinx documentation 110 | docs/_build/ 111 | 112 | # PyBuilder 113 | target/ 114 | 115 | # IPython Notebook 116 | .ipynb_checkpoints 117 | 118 | # pyenv 119 | .python-version 120 | 121 | # celery beat schedule file 122 | celerybeat-schedule 123 | 124 | # dotenv 125 | .env 126 | 127 | # virtualenv 128 | venv/ 129 | ENV/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | -------------------------------------------------------------------------------- /Git-Repos-3.1.2.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Git-Repos-3.1.2.alfredworkflow -------------------------------------------------------------------------------- /Icons/Git Repos.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.icns -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_128x128.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_16x16.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_256x256.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_32x32.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_512x512.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Icons/Git Repos.iconsproj: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/Icons/Git Repos.iconsproj -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | ./src/LICENCE.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Git Repos Workflow for Alfred 6 | ============================= 7 | 8 | Browse, search and open Git repositories from within Alfred. 9 | 10 | ![][demo] 11 | 12 | 13 | Download 14 | -------- 15 | 16 | Get the workflow from [GitHub releases][gh-releases]. 17 | 18 | **Versions 2.2+ are not compatible with Alfred 3 and earlier.** 19 | 20 | For Alfred 3, download [version 2.1.2][v2.1.2], and for Alfred 2, please download [version 1.7][v1.7]. 21 | 22 | 23 | Usage 24 | ----- 25 | 26 | This workflow requires some configuration before use. See [Configuration](#configuration) for details. 27 | 28 | - `repos []` — Show a list of your Git repos filtered by `` 29 | + `↩` — Open selected repo in `app_default` (see [configuration](#configuration)) 30 | + `⌘+↩` — Open selected repo in `app_cmd` (see [configuration](#configuration)) 31 | + `⌥+↩` — Open selected repo in `app_alt` (requires [configuration](#configuration)) 32 | + `^+↩` — Open selected repo in `app_ctrl` (requires [configuration](#configuration)) 33 | + `⇧+↩` — Open selected repo in `app_shift` (requires [configuration](#configuration)) 34 | + `fn+↩` — Open selected repo in `app_fn` (requires [configuration](#configuration)) 35 | + `⌘⌥+↩` — Open selected repo in `app_cmd_alt` (requires [configuration](#configuration)) 36 | + `⌘⌥⇧+↩` — Open selected repo in `app_cmd_alt_shift` (requires [configuration](#configuration)) 37 | + etc. 38 | + etc. 39 | - `reposettings` — Open `settings.json` in default JSON editor 40 | - `reposupdate` — Force workflow to update its cached list of repositories. (By default, the list will only be updated—in the background—every 3 hours.) 41 | - `reposhelp` — Open this file in your browser 42 | 43 | 44 | Configuration 45 | ------------- 46 | 47 | Before you can use this workflow, you have to configure one or more folders in which the workflow should search for Git repos. The workflow uses `find` to search for `.git` directories, so you shouldn't add *huge* directory trees to it, and use the `depth` option to restrict the search depth. Typically, a `depth` of `2` will be what you want (i.e. search within subdirectories of specified directory, but no lower). Add directories to search to the `search_dir` array in `settings.json` (see below). 48 | 49 | The default `settings.json` file looks like this: 50 | 51 | ```javascript 52 | { 53 | "app_default": "Finder", // ↩ to open in this/these app(s) 54 | "app_cmd": "Terminal", // ⌘+↩ to open in this/these app(s) 55 | "app_alt": null, // ⌥+↩ to open in this/these app(s) 56 | "app_ctrl": null, // ^+↩ to open in this/these app(s) 57 | "app_shift": null, // ⇧+↩ to open in this/these app(s) 58 | "app_fn": null, // fn+↩ to open in this/these app(s) 59 | "global_exclude_patterns": [], // Exclude from all searches 60 | "search_dirs": [ 61 | { 62 | "path": "~/delete/this/example", // Path to search. ~/ is expanded 63 | "depth": 2, // Search subdirs of `path` 64 | "name_for_parent": 1, // Name Alfred entry after parent of `.git`. 2 = grandparent of `.git` etc. 65 | "excludes": [ // Excludes specific to this path 66 | "tmp", // Directories named `tmp` 67 | "bad/smell/*" // Subdirs of `bad/smell` directory 68 | ] 69 | } 70 | ] 71 | } 72 | ``` 73 | 74 | This is my `settings.json`: 75 | 76 | ```javascript 77 | { 78 | "app_alt": "iTerm", 79 | "app_cmd": "Finder", 80 | "app_cmd_alt": [ 81 | "VSCodium", 82 | "Finder", 83 | "SourceTree", 84 | "iTerm" 85 | ], 86 | "app_ctrl": "SourceTree", 87 | "app_default": "VSCodium", 88 | "app_shift": "Browser", 89 | "global_exclude_patterns": [], 90 | "search_dirs": [ 91 | { 92 | "depth": 3, 93 | "path": "~/Code" 94 | }, 95 | { 96 | "path": "~/Sites" 97 | }, 98 | { 99 | "path": "~/src/git.deanishe.net/deanishe" 100 | }, 101 | { 102 | "path": "~/src/github.com/deanishe" 103 | } 104 | ] 105 | } 106 | ``` 107 | 108 | **Note:** If you specify `Browser`, `Safari`, `Google Chrome`, `Webkit` or `Firefox` as an application, it will be passed the remote repo URL, not the local filepath. `Browser` will open the URL in your default browser. 109 | 110 | You can also change the default update interval (3h) in the workflow's configuration sheet in Alfred Preferences. Change the `UPDATE_EVERY_MINS` workflow variable to suit your needs. 111 | 112 | 113 | ### Search Directories ### 114 | 115 | Each entry in the `search_dirs` list must be a mapping. 116 | 117 | Only `path` is required. `depth` will default to `2` if not specified. `excludes` are globbing patterns, like in `.gitignore`. 118 | 119 | `name_for_parent` defaults to `1`, which means the entry in Alfred's results should be named after the directory containing the `.git` directory. If you want Alfred to show the name of the grandparent, set `name_for_parent` to `2` etc. 120 | 121 | This is useful if your projects are structured, for example, like this and `src` is the actual repo: 122 | 123 | ``` 124 | Code 125 | Project_1 126 | src 127 | other_stuff 128 | Project_2 129 | src 130 | other_stuff 131 | … 132 | … 133 | ``` 134 | 135 | Set `name_for_parent` to `2`, and `Project_1`, `Project_2` etc. will be shown in Alfred, not `src`, `src`, `src`… 136 | 137 | 138 | ### Open in Applications ### 139 | 140 | The applications specified by the `app_XYZ` options are all called using `open -a AppName path/to/directory`. You can configure any application that can open a directory in this manner. Some recommendations are Sublime Text, SourceTree, GitHub or iTerm. 141 | 142 | The meta app `Browser` will open the repo's `remote/origin` URL in your default browser. Other recognised browsers are `Safari`, `Google Chrome`, `Firefox` and `WebKit`. 143 | 144 | **Note:** As you can see from my `settings.json`, you can also set an `app_XYZ` value to an array of applications to open the selected repo in more than one app at once: 145 | 146 | ``` 147 | … 148 | "app_cmd": ["Finder", "Sublime Text", "SourceTree", "iTerm"], 149 | … 150 | ``` 151 | 152 | In versions 3+ (i.e. in Alfred 4), you can also arbitrarily combine modifiers to give yourself many more options: 153 | 154 | ``` 155 | "app_cmd_alt": "Finder", 156 | "app_shift_alt_cmd": "VSCodium", 157 | "app_cmd_fn_alt": "Oni", 158 | etc. 159 | etc. 160 | ``` 161 | 162 | Modifiers may be specified in any order. The only requirements are that the key must start with `app_` and the modifiers must be separated by `_`. 163 | 164 | You can also use `→` on a result to access Alfred's default File Actions menu. 165 | 166 | 167 | License, Thanks 168 | --------------- 169 | 170 | This workflow is released under the [MIT Licence][mit]. 171 | 172 | It uses the [Alfred-Workflow][aw] and [docopt][docopt] libraries (both MIT Licence). 173 | 174 | The icon is by [Jason Long][jlong], from [git-scm.com][git], released under the [Creative Commons Attribution 3.0 Unported Licence][cc]. 175 | 176 | 177 | [aw]: https://github.com/deanishe/alfred-workflow 178 | [cc]: http://creativecommons.org/licenses/by/3.0/ 179 | [demo]: https://raw.githubusercontent.com/deanishe/alfred-repos/master/demo.gif 180 | [docopt]: http://docopt.org/ 181 | [gh-releases]: https://github.com/deanishe/alfred-repos/releases/latest 182 | [git]: http://git-scm.com/downloads/logos 183 | [jlong]: http://twitter.com/jasonlong 184 | [mit]: http://opensource.org/licenses/MIT 185 | [packal]: http://www.packal.org/workflow/git-repos 186 | [v2.1.2]: https://github.com/deanishe/alfred-repos/releases/tag/v2.1.2 187 | [v1.7]: https://github.com/deanishe/alfred-repos/releases/tag/v1.7 188 | -------------------------------------------------------------------------------- /Todo.taskpaper: -------------------------------------------------------------------------------- 1 | Features: 2 | - make it go faster! @done(14-07-04 22:01) 3 | - otherwise, use manual reload @done(14-07-04 22:01) 4 | - add scope (list of directories) @done(14-07-04 22:01) 5 | - add excludes (using glob patterns) @done(14-07-04 22:01) -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/demo.gif -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- 1 | src/icon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Alfred-Workflow==1.39.0 2 | docopt==0.6.2 3 | -------------------------------------------------------------------------------- /src/LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Dean Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/docopt.py: -------------------------------------------------------------------------------- 1 | """Pythonic command-line interface parser that will make you smile. 2 | 3 | * http://docopt.org 4 | * Repository and issue-tracker: https://github.com/docopt/docopt 5 | * Licensed under terms of MIT license (see LICENSE-MIT) 6 | * Copyright (c) 2013 Vladimir Keleshev, vladimir@keleshev.com 7 | 8 | """ 9 | import sys 10 | import re 11 | 12 | 13 | __all__ = ['docopt'] 14 | __version__ = '0.6.2' 15 | 16 | 17 | class DocoptLanguageError(Exception): 18 | 19 | """Error in construction of usage-message by developer.""" 20 | 21 | 22 | class DocoptExit(SystemExit): 23 | 24 | """Exit in case user invoked program with incorrect arguments.""" 25 | 26 | usage = '' 27 | 28 | def __init__(self, message=''): 29 | SystemExit.__init__(self, (message + '\n' + self.usage).strip()) 30 | 31 | 32 | class Pattern(object): 33 | 34 | def __eq__(self, other): 35 | return repr(self) == repr(other) 36 | 37 | def __hash__(self): 38 | return hash(repr(self)) 39 | 40 | def fix(self): 41 | self.fix_identities() 42 | self.fix_repeating_arguments() 43 | return self 44 | 45 | def fix_identities(self, uniq=None): 46 | """Make pattern-tree tips point to same object if they are equal.""" 47 | if not hasattr(self, 'children'): 48 | return self 49 | uniq = list(set(self.flat())) if uniq is None else uniq 50 | for i, c in enumerate(self.children): 51 | if not hasattr(c, 'children'): 52 | assert c in uniq 53 | self.children[i] = uniq[uniq.index(c)] 54 | else: 55 | c.fix_identities(uniq) 56 | 57 | def fix_repeating_arguments(self): 58 | """Fix elements that should accumulate/increment values.""" 59 | either = [list(c.children) for c in self.either.children] 60 | for case in either: 61 | for e in [c for c in case if case.count(c) > 1]: 62 | if type(e) is Argument or type(e) is Option and e.argcount: 63 | if e.value is None: 64 | e.value = [] 65 | elif type(e.value) is not list: 66 | e.value = e.value.split() 67 | if type(e) is Command or type(e) is Option and e.argcount == 0: 68 | e.value = 0 69 | return self 70 | 71 | @property 72 | def either(self): 73 | """Transform pattern into an equivalent, with only top-level Either.""" 74 | # Currently the pattern will not be equivalent, but more "narrow", 75 | # although good enough to reason about list arguments. 76 | ret = [] 77 | groups = [[self]] 78 | while groups: 79 | children = groups.pop(0) 80 | types = [type(c) for c in children] 81 | if Either in types: 82 | either = [c for c in children if type(c) is Either][0] 83 | children.pop(children.index(either)) 84 | for c in either.children: 85 | groups.append([c] + children) 86 | elif Required in types: 87 | required = [c for c in children if type(c) is Required][0] 88 | children.pop(children.index(required)) 89 | groups.append(list(required.children) + children) 90 | elif Optional in types: 91 | optional = [c for c in children if type(c) is Optional][0] 92 | children.pop(children.index(optional)) 93 | groups.append(list(optional.children) + children) 94 | elif AnyOptions in types: 95 | optional = [c for c in children if type(c) is AnyOptions][0] 96 | children.pop(children.index(optional)) 97 | groups.append(list(optional.children) + children) 98 | elif OneOrMore in types: 99 | oneormore = [c for c in children if type(c) is OneOrMore][0] 100 | children.pop(children.index(oneormore)) 101 | groups.append(list(oneormore.children) * 2 + children) 102 | else: 103 | ret.append(children) 104 | return Either(*[Required(*e) for e in ret]) 105 | 106 | 107 | class ChildPattern(Pattern): 108 | 109 | def __init__(self, name, value=None): 110 | self.name = name 111 | self.value = value 112 | 113 | def __repr__(self): 114 | return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) 115 | 116 | def flat(self, *types): 117 | return [self] if not types or type(self) in types else [] 118 | 119 | def match(self, left, collected=None): 120 | collected = [] if collected is None else collected 121 | pos, match = self.single_match(left) 122 | if match is None: 123 | return False, left, collected 124 | left_ = left[:pos] + left[pos + 1:] 125 | same_name = [a for a in collected if a.name == self.name] 126 | if type(self.value) in (int, list): 127 | if type(self.value) is int: 128 | increment = 1 129 | else: 130 | increment = ([match.value] if type(match.value) is str 131 | else match.value) 132 | if not same_name: 133 | match.value = increment 134 | return True, left_, collected + [match] 135 | same_name[0].value += increment 136 | return True, left_, collected 137 | return True, left_, collected + [match] 138 | 139 | 140 | class ParentPattern(Pattern): 141 | 142 | def __init__(self, *children): 143 | self.children = list(children) 144 | 145 | def __repr__(self): 146 | return '%s(%s)' % (self.__class__.__name__, 147 | ', '.join(repr(a) for a in self.children)) 148 | 149 | def flat(self, *types): 150 | if type(self) in types: 151 | return [self] 152 | return sum([c.flat(*types) for c in self.children], []) 153 | 154 | 155 | class Argument(ChildPattern): 156 | 157 | def single_match(self, left): 158 | for n, p in enumerate(left): 159 | if type(p) is Argument: 160 | return n, Argument(self.name, p.value) 161 | return None, None 162 | 163 | @classmethod 164 | def parse(class_, source): 165 | name = re.findall('(<\S*?>)', source)[0] 166 | value = re.findall('\[default: (.*)\]', source, flags=re.I) 167 | return class_(name, value[0] if value else None) 168 | 169 | 170 | class Command(Argument): 171 | 172 | def __init__(self, name, value=False): 173 | self.name = name 174 | self.value = value 175 | 176 | def single_match(self, left): 177 | for n, p in enumerate(left): 178 | if type(p) is Argument: 179 | if p.value == self.name: 180 | return n, Command(self.name, True) 181 | else: 182 | break 183 | return None, None 184 | 185 | 186 | class Option(ChildPattern): 187 | 188 | def __init__(self, short=None, long=None, argcount=0, value=False): 189 | assert argcount in (0, 1) 190 | self.short, self.long = short, long 191 | self.argcount, self.value = argcount, value 192 | self.value = None if value is False and argcount else value 193 | 194 | @classmethod 195 | def parse(class_, option_description): 196 | short, long, argcount, value = None, None, 0, False 197 | options, _, description = option_description.strip().partition(' ') 198 | options = options.replace(',', ' ').replace('=', ' ') 199 | for s in options.split(): 200 | if s.startswith('--'): 201 | long = s 202 | elif s.startswith('-'): 203 | short = s 204 | else: 205 | argcount = 1 206 | if argcount: 207 | matched = re.findall('\[default: (.*)\]', description, flags=re.I) 208 | value = matched[0] if matched else None 209 | return class_(short, long, argcount, value) 210 | 211 | def single_match(self, left): 212 | for n, p in enumerate(left): 213 | if self.name == p.name: 214 | return n, p 215 | return None, None 216 | 217 | @property 218 | def name(self): 219 | return self.long or self.short 220 | 221 | def __repr__(self): 222 | return 'Option(%r, %r, %r, %r)' % (self.short, self.long, 223 | self.argcount, self.value) 224 | 225 | 226 | class Required(ParentPattern): 227 | 228 | def match(self, left, collected=None): 229 | collected = [] if collected is None else collected 230 | l = left 231 | c = collected 232 | for p in self.children: 233 | matched, l, c = p.match(l, c) 234 | if not matched: 235 | return False, left, collected 236 | return True, l, c 237 | 238 | 239 | class Optional(ParentPattern): 240 | 241 | def match(self, left, collected=None): 242 | collected = [] if collected is None else collected 243 | for p in self.children: 244 | m, left, collected = p.match(left, collected) 245 | return True, left, collected 246 | 247 | 248 | class AnyOptions(Optional): 249 | 250 | """Marker/placeholder for [options] shortcut.""" 251 | 252 | 253 | class OneOrMore(ParentPattern): 254 | 255 | def match(self, left, collected=None): 256 | assert len(self.children) == 1 257 | collected = [] if collected is None else collected 258 | l = left 259 | c = collected 260 | l_ = None 261 | matched = True 262 | times = 0 263 | while matched: 264 | # could it be that something didn't match but changed l or c? 265 | matched, l, c = self.children[0].match(l, c) 266 | times += 1 if matched else 0 267 | if l_ == l: 268 | break 269 | l_ = l 270 | if times >= 1: 271 | return True, l, c 272 | return False, left, collected 273 | 274 | 275 | class Either(ParentPattern): 276 | 277 | def match(self, left, collected=None): 278 | collected = [] if collected is None else collected 279 | outcomes = [] 280 | for p in self.children: 281 | matched, _, _ = outcome = p.match(left, collected) 282 | if matched: 283 | outcomes.append(outcome) 284 | if outcomes: 285 | return min(outcomes, key=lambda outcome: len(outcome[1])) 286 | return False, left, collected 287 | 288 | 289 | class TokenStream(list): 290 | 291 | def __init__(self, source, error): 292 | self += source.split() if hasattr(source, 'split') else source 293 | self.error = error 294 | 295 | def move(self): 296 | return self.pop(0) if len(self) else None 297 | 298 | def current(self): 299 | return self[0] if len(self) else None 300 | 301 | 302 | def parse_long(tokens, options): 303 | """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" 304 | long, eq, value = tokens.move().partition('=') 305 | assert long.startswith('--') 306 | value = None if eq == value == '' else value 307 | similar = [o for o in options if o.long == long] 308 | if tokens.error is DocoptExit and similar == []: # if no exact match 309 | similar = [o for o in options if o.long and o.long.startswith(long)] 310 | if len(similar) > 1: # might be simply specified ambiguously 2+ times? 311 | raise tokens.error('%s is not a unique prefix: %s?' % 312 | (long, ', '.join(o.long for o in similar))) 313 | elif len(similar) < 1: 314 | argcount = 1 if eq == '=' else 0 315 | o = Option(None, long, argcount) 316 | options.append(o) 317 | if tokens.error is DocoptExit: 318 | o = Option(None, long, argcount, value if argcount else True) 319 | else: 320 | o = Option(similar[0].short, similar[0].long, 321 | similar[0].argcount, similar[0].value) 322 | if o.argcount == 0: 323 | if value is not None: 324 | raise tokens.error('%s must not have an argument' % o.long) 325 | else: 326 | if value is None: 327 | if tokens.current() is None: 328 | raise tokens.error('%s requires argument' % o.long) 329 | value = tokens.move() 330 | if tokens.error is DocoptExit: 331 | o.value = value if value is not None else True 332 | return [o] 333 | 334 | 335 | def parse_shorts(tokens, options): 336 | """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" 337 | token = tokens.move() 338 | assert token.startswith('-') and not token.startswith('--') 339 | left = token.lstrip('-') 340 | parsed = [] 341 | while left != '': 342 | short, left = '-' + left[0], left[1:] 343 | similar = [o for o in options if o.short == short] 344 | if len(similar) > 1: 345 | raise tokens.error('%s is specified ambiguously %d times' % 346 | (short, len(similar))) 347 | elif len(similar) < 1: 348 | o = Option(short, None, 0) 349 | options.append(o) 350 | if tokens.error is DocoptExit: 351 | o = Option(short, None, 0, True) 352 | else: # why copying is necessary here? 353 | o = Option(short, similar[0].long, 354 | similar[0].argcount, similar[0].value) 355 | value = None 356 | if o.argcount != 0: 357 | if left == '': 358 | if tokens.current() is None: 359 | raise tokens.error('%s requires argument' % short) 360 | value = tokens.move() 361 | else: 362 | value = left 363 | left = '' 364 | if tokens.error is DocoptExit: 365 | o.value = value if value is not None else True 366 | parsed.append(o) 367 | return parsed 368 | 369 | 370 | def parse_pattern(source, options): 371 | tokens = TokenStream(re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source), 372 | DocoptLanguageError) 373 | result = parse_expr(tokens, options) 374 | if tokens.current() is not None: 375 | raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) 376 | return Required(*result) 377 | 378 | 379 | def parse_expr(tokens, options): 380 | """expr ::= seq ( '|' seq )* ;""" 381 | seq = parse_seq(tokens, options) 382 | if tokens.current() != '|': 383 | return seq 384 | result = [Required(*seq)] if len(seq) > 1 else seq 385 | while tokens.current() == '|': 386 | tokens.move() 387 | seq = parse_seq(tokens, options) 388 | result += [Required(*seq)] if len(seq) > 1 else seq 389 | return [Either(*result)] if len(result) > 1 else result 390 | 391 | 392 | def parse_seq(tokens, options): 393 | """seq ::= ( atom [ '...' ] )* ;""" 394 | result = [] 395 | while tokens.current() not in [None, ']', ')', '|']: 396 | atom = parse_atom(tokens, options) 397 | if tokens.current() == '...': 398 | atom = [OneOrMore(*atom)] 399 | tokens.move() 400 | result += atom 401 | return result 402 | 403 | 404 | def parse_atom(tokens, options): 405 | """atom ::= '(' expr ')' | '[' expr ']' | 'options' 406 | | long | shorts | argument | command ; 407 | """ 408 | token = tokens.current() 409 | result = [] 410 | if token in '([': 411 | tokens.move() 412 | matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] 413 | result = pattern(*parse_expr(tokens, options)) 414 | if tokens.move() != matching: 415 | raise tokens.error("unmatched '%s'" % token) 416 | return [result] 417 | elif token == 'options': 418 | tokens.move() 419 | return [AnyOptions()] 420 | elif token.startswith('--') and token != '--': 421 | return parse_long(tokens, options) 422 | elif token.startswith('-') and token not in ('-', '--'): 423 | return parse_shorts(tokens, options) 424 | elif token.startswith('<') and token.endswith('>') or token.isupper(): 425 | return [Argument(tokens.move())] 426 | else: 427 | return [Command(tokens.move())] 428 | 429 | 430 | def parse_argv(tokens, options, options_first=False): 431 | """Parse command-line argument vector. 432 | 433 | If options_first: 434 | argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; 435 | else: 436 | argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; 437 | 438 | """ 439 | parsed = [] 440 | while tokens.current() is not None: 441 | if tokens.current() == '--': 442 | return parsed + [Argument(None, v) for v in tokens] 443 | elif tokens.current().startswith('--'): 444 | parsed += parse_long(tokens, options) 445 | elif tokens.current().startswith('-') and tokens.current() != '-': 446 | parsed += parse_shorts(tokens, options) 447 | elif options_first: 448 | return parsed + [Argument(None, v) for v in tokens] 449 | else: 450 | parsed.append(Argument(None, tokens.move())) 451 | return parsed 452 | 453 | 454 | def parse_defaults(doc): 455 | # in python < 2.7 you can't pass flags=re.MULTILINE 456 | split = re.split('\n *(<\S+?>|-\S+?)', doc)[1:] 457 | split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] 458 | options = [Option.parse(s) for s in split if s.startswith('-')] 459 | #arguments = [Argument.parse(s) for s in split if s.startswith('<')] 460 | #return options, arguments 461 | return options 462 | 463 | 464 | def printable_usage(doc): 465 | # in python < 2.7 you can't pass flags=re.IGNORECASE 466 | usage_split = re.split(r'([Uu][Ss][Aa][Gg][Ee]:)', doc) 467 | if len(usage_split) < 3: 468 | raise DocoptLanguageError('"usage:" (case-insensitive) not found.') 469 | if len(usage_split) > 3: 470 | raise DocoptLanguageError('More than one "usage:" (case-insensitive).') 471 | return re.split(r'\n\s*\n', ''.join(usage_split[1:]))[0].strip() 472 | 473 | 474 | def formal_usage(printable_usage): 475 | pu = printable_usage.split()[1:] # split and drop "usage:" 476 | return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' 477 | 478 | 479 | def extras(help, version, options, doc): 480 | if help and any((o.name in ('-h', '--help')) and o.value for o in options): 481 | print(doc.strip("\n")) 482 | sys.exit() 483 | if version and any(o.name == '--version' and o.value for o in options): 484 | print(version) 485 | sys.exit() 486 | 487 | 488 | class Dict(dict): 489 | def __repr__(self): 490 | return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) 491 | 492 | 493 | def docopt(doc, argv=None, help=True, version=None, options_first=False): 494 | """Parse `argv` based on command-line interface described in `doc`. 495 | 496 | `docopt` creates your command-line interface based on its 497 | description that you pass as `doc`. Such description can contain 498 | --options, , commands, which could be 499 | [optional], (required), (mutually | exclusive) or repeated... 500 | 501 | Parameters 502 | ---------- 503 | doc : str 504 | Description of your command-line interface. 505 | argv : list of str, optional 506 | Argument vector to be parsed. sys.argv[1:] is used if not 507 | provided. 508 | help : bool (default: True) 509 | Set to False to disable automatic help on -h or --help 510 | options. 511 | version : any object 512 | If passed, the object will be printed if --version is in 513 | `argv`. 514 | options_first : bool (default: False) 515 | Set to True to require options preceed positional arguments, 516 | i.e. to forbid options and positional arguments intermix. 517 | 518 | Returns 519 | ------- 520 | args : dict 521 | A dictionary, where keys are names of command-line elements 522 | such as e.g. "--verbose" and "", and values are the 523 | parsed values of those elements. 524 | 525 | Example 526 | ------- 527 | >>> from docopt import docopt 528 | >>> doc = ''' 529 | Usage: 530 | my_program tcp [--timeout=] 531 | my_program serial [--baud=] [--timeout=] 532 | my_program (-h | --help | --version) 533 | 534 | Options: 535 | -h, --help Show this screen and exit. 536 | --baud= Baudrate [default: 9600] 537 | ''' 538 | >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] 539 | >>> docopt(doc, argv) 540 | {'--baud': '9600', 541 | '--help': False, 542 | '--timeout': '30', 543 | '--version': False, 544 | '': '127.0.0.1', 545 | '': '80', 546 | 'serial': False, 547 | 'tcp': True} 548 | 549 | See also 550 | -------- 551 | * For video introduction see http://docopt.org 552 | * Full documentation is available in README.rst as well as online 553 | at https://github.com/docopt/docopt#readme 554 | 555 | """ 556 | if argv is None: 557 | argv = sys.argv[1:] 558 | DocoptExit.usage = printable_usage(doc) 559 | options = parse_defaults(doc) 560 | pattern = parse_pattern(formal_usage(DocoptExit.usage), options) 561 | # [default] syntax for argument is disabled 562 | #for a in pattern.flat(Argument): 563 | # same_name = [d for d in arguments if d.name == a.name] 564 | # if same_name: 565 | # a.value = same_name[0].value 566 | argv = parse_argv(TokenStream(argv, DocoptExit), list(options), 567 | options_first) 568 | pattern_options = set(pattern.flat(Option)) 569 | for ao in pattern.flat(AnyOptions): 570 | doc_options = parse_defaults(doc) 571 | ao.children = list(set(doc_options) - pattern_options) 572 | #if any_options: 573 | # ao.children += [Option(o.short, o.long, o.argcount) 574 | # for o in argv if type(o) is Option] 575 | extras(help, version, argv, doc) 576 | matched, left, collected = pattern.fix().match(argv) 577 | if matched and left == []: # better error message if left? 578 | return Dict((a.name, a.value) for a in (pattern.flat() + collected)) 579 | raise DocoptExit() 580 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/src/icon.png -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.deanishe.alfred-git-repos 7 | connections 8 | 9 | 13C4EEDF-EC8D-4B23-8693-6E050109AFDC 10 | 11 | 12 | destinationuid 13 | FC60057D-548A-4394-9426-1764C21DC6F0 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 227B191C-26EA-4302-B5A1-79CBA4BF107A 23 | 24 | 25 | destinationuid 26 | 47FD6543-A4A6-4714-B190-9DF8B5B28444 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 25B07D69-2EF2-420F-8B3B-0E7E7CA7B34F 36 | 37 | 38 | destinationuid 39 | D2D75AF8-15E1-40D8-84D1-AE23BA5263F4 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | 25BDF5B8-CC9C-444A-B35A-CD0ED5D35A84 49 | 50 | 51 | destinationuid 52 | E36B8788-0F7C-494A-A3D5-3347CA273F85 53 | modifiers 54 | 0 55 | modifiersubtext 56 | 57 | vitoclose 58 | 59 | 60 | 61 | 71820CCE-FF52-4077-B790-ACA80D174C54 62 | 63 | 64 | destinationuid 65 | CB04399E-A00E-40F1-8753-994DF261B93C 66 | modifiers 67 | 0 68 | modifiersubtext 69 | 70 | vitoclose 71 | 72 | 73 | 74 | CB04399E-A00E-40F1-8753-994DF261B93C 75 | 76 | 77 | destinationuid 78 | 25B07D69-2EF2-420F-8B3B-0E7E7CA7B34F 79 | modifiers 80 | 0 81 | modifiersubtext 82 | 83 | vitoclose 84 | 85 | 86 | 87 | 88 | createdby 89 | Dean Jackson 90 | description 91 | Find and browse Git repositories 92 | disabled 93 | 94 | name 95 | Git Repos 96 | objects 97 | 98 | 99 | config 100 | 101 | alfredfiltersresults 102 | 103 | alfredfiltersresultsmatchmode 104 | 0 105 | argumenttreatemptyqueryasnil 106 | 107 | argumenttrimmode 108 | 0 109 | argumenttype 110 | 1 111 | escaping 112 | 102 113 | keyword 114 | repos 115 | queuedelaycustom 116 | 3 117 | queuedelayimmediatelyinitially 118 | 119 | queuedelaymode 120 | 0 121 | queuemode 122 | 1 123 | runningsubtext 124 | Loading list of repos… 125 | script 126 | /usr/bin/python repos.py search "$1" 127 | scriptargtype 128 | 1 129 | scriptfile 130 | 131 | subtext 132 | View, Filter, Open Your Git Repositories 133 | title 134 | Git Repos 135 | type 136 | 0 137 | withspace 138 | 139 | 140 | type 141 | alfred.workflow.input.scriptfilter 142 | uid 143 | 71820CCE-FF52-4077-B790-ACA80D174C54 144 | version 145 | 3 146 | 147 | 148 | config 149 | 150 | lastpathcomponent 151 | 152 | onlyshowifquerypopulated 153 | 154 | removeextension 155 | 156 | text 157 | {query} 158 | title 159 | Configuration error 160 | 161 | type 162 | alfred.workflow.output.notification 163 | uid 164 | D2D75AF8-15E1-40D8-84D1-AE23BA5263F4 165 | version 166 | 1 167 | 168 | 169 | config 170 | 171 | concurrently 172 | 173 | escaping 174 | 102 175 | script 176 | /usr/bin/python repos.py open $appkey "$1" 177 | scriptargtype 178 | 1 179 | scriptfile 180 | 181 | type 182 | 5 183 | 184 | type 185 | alfred.workflow.action.script 186 | uid 187 | 25B07D69-2EF2-420F-8B3B-0E7E7CA7B34F 188 | version 189 | 2 190 | 191 | 192 | config 193 | 194 | argument 195 | . 196 | query='{query}' 197 | variables={allvars} 198 | cleardebuggertext 199 | 200 | processoutputs 201 | 202 | 203 | type 204 | alfred.workflow.utility.debug 205 | uid 206 | CB04399E-A00E-40F1-8753-994DF261B93C 207 | version 208 | 1 209 | 210 | 211 | config 212 | 213 | concurrently 214 | 215 | escaping 216 | 127 217 | script 218 | /usr/bin/python repos.py settings 219 | scriptargtype 220 | 1 221 | scriptfile 222 | 223 | type 224 | 0 225 | 226 | type 227 | alfred.workflow.action.script 228 | uid 229 | E36B8788-0F7C-494A-A3D5-3347CA273F85 230 | version 231 | 2 232 | 233 | 234 | config 235 | 236 | argumenttype 237 | 2 238 | keyword 239 | reposettings 240 | subtext 241 | Change search directories, exclude patterns 242 | text 243 | Edit Repo Settings 244 | withspace 245 | 246 | 247 | type 248 | alfred.workflow.input.keyword 249 | uid 250 | 25BDF5B8-CC9C-444A-B35A-CD0ED5D35A84 251 | version 252 | 1 253 | 254 | 255 | config 256 | 257 | concurrently 258 | 259 | escaping 260 | 127 261 | script 262 | open README.html 263 | scriptargtype 264 | 1 265 | scriptfile 266 | 267 | type 268 | 0 269 | 270 | type 271 | alfred.workflow.action.script 272 | uid 273 | FC60057D-548A-4394-9426-1764C21DC6F0 274 | version 275 | 2 276 | 277 | 278 | config 279 | 280 | argumenttype 281 | 2 282 | keyword 283 | reposhelp 284 | subtext 285 | 286 | text 287 | Open Repos Help File 288 | withspace 289 | 290 | 291 | type 292 | alfred.workflow.input.keyword 293 | uid 294 | 13C4EEDF-EC8D-4B23-8693-6E050109AFDC 295 | version 296 | 1 297 | 298 | 299 | config 300 | 301 | argumenttype 302 | 2 303 | keyword 304 | reposupdate 305 | subtext 306 | 307 | text 308 | Update Git Repo Database 309 | withspace 310 | 311 | 312 | type 313 | alfred.workflow.input.keyword 314 | uid 315 | 227B191C-26EA-4302-B5A1-79CBA4BF107A 316 | version 317 | 1 318 | 319 | 320 | config 321 | 322 | concurrently 323 | 324 | escaping 325 | 127 326 | script 327 | /usr/bin/python repos.py update 328 | scriptargtype 329 | 1 330 | scriptfile 331 | 332 | type 333 | 0 334 | 335 | type 336 | alfred.workflow.action.script 337 | uid 338 | 47FD6543-A4A6-4714-B190-9DF8B5B28444 339 | version 340 | 2 341 | 342 | 343 | readme 344 | Browse, search and open Git repositories from within Alfred. 345 | 346 | You must configure your settings with `reposettings` before use. 347 | 348 | Use `reposhelp` for detailed instructions. 349 | 350 | 351 | Usage 352 | ----- 353 | 354 | - `repos [<query>]` — Show a list of your Git repos filtered by `<query>` 355 | + `↩` — Open selected repo in `app_default` 356 | + `⌘+↩` — Open selected repo in `app_cmd` 357 | + `⌥+↩` — Open selected repo in `app_alt` 358 | + `^+↩` — Open selected repo in `app_ctrl` 359 | + `⇧+↩` — Open selected repo in `app_shift` 360 | + `fn+↩` — Open selected repo in `app_fn` 361 | - `reposettings` — Open `settings.json` in default JSON editor 362 | - `reposupdate` — Force workflow to update its cached list of repositories. (By default, the list will only be updated every 3 hours.) 363 | - `reposhelp` — Open this file in your browser 364 | 365 | 366 | Configuration 367 | ------------- 368 | 369 | Use the `reposhelp` command to see the help file. 370 | uidata 371 | 372 | 13C4EEDF-EC8D-4B23-8693-6E050109AFDC 373 | 374 | note 375 | Open help file in your browser 376 | xpos 377 | 40 378 | ypos 379 | 370 380 | 381 | 227B191C-26EA-4302-B5A1-79CBA4BF107A 382 | 383 | note 384 | Force update of repo list 385 | xpos 386 | 40 387 | ypos 388 | 540 389 | 390 | 25B07D69-2EF2-420F-8B3B-0E7E7CA7B34F 391 | 392 | note 393 | Open repo in specified app 394 | xpos 395 | 300 396 | ypos 397 | 30 398 | 399 | 25BDF5B8-CC9C-444A-B35A-CD0ED5D35A84 400 | 401 | note 402 | Open settings in your default editor 403 | xpos 404 | 40 405 | ypos 406 | 200 407 | 408 | 47FD6543-A4A6-4714-B190-9DF8B5B28444 409 | 410 | xpos 411 | 300 412 | ypos 413 | 540 414 | 415 | 71820CCE-FF52-4077-B790-ACA80D174C54 416 | 417 | note 418 | View/filter git repos 419 | xpos 420 | 40 421 | ypos 422 | 30 423 | 424 | CB04399E-A00E-40F1-8753-994DF261B93C 425 | 426 | xpos 427 | 210 428 | ypos 429 | 60 430 | 431 | D2D75AF8-15E1-40D8-84D1-AE23BA5263F4 432 | 433 | note 434 | Show error message 435 | xpos 436 | 480 437 | ypos 438 | 30 439 | 440 | E36B8788-0F7C-494A-A3D5-3347CA273F85 441 | 442 | xpos 443 | 300 444 | ypos 445 | 200 446 | 447 | FC60057D-548A-4394-9426-1764C21DC6F0 448 | 449 | xpos 450 | 300 451 | ypos 452 | 370 453 | 454 | 455 | variables 456 | 457 | UPDATE_EVERY_MINS 458 | 180 459 | 460 | version 461 | 3.1.2 462 | webaddress 463 | https://github.com/deanishe/alfred-repos 464 | 465 | 466 | -------------------------------------------------------------------------------- /src/repos.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2013 deanishe@deanishe.net. 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2013-11-04 8 | # 9 | 10 | """repos.py [command] [options] [] [] 11 | 12 | Find, open and search Git repos on your system. 13 | 14 | Usage: 15 | repos.py search [] 16 | repos.py settings 17 | repos.py update 18 | repos.py open 19 | 20 | Options: 21 | -h, --help Show this message 22 | 23 | """ 24 | 25 | from __future__ import print_function 26 | 27 | from collections import namedtuple 28 | import os 29 | import re 30 | import subprocess 31 | import sys 32 | import time 33 | 34 | from workflow import Workflow3, ICON_WARNING, ICON_INFO 35 | from workflow.background import is_running, run_in_background 36 | from workflow.update import Version 37 | 38 | 39 | # How often to check for new/updated repos 40 | DEFAULT_UPDATE_INTERVAL = 180 # minutes 41 | 42 | # GitHub repo for self-updating 43 | UPDATE_SETTINGS = {'github_slug': 'deanishe/alfred-repos'} 44 | 45 | # GitHub Issues 46 | HELP_URL = 'https://github.com/deanishe/alfred-repos/issues' 47 | 48 | # Icon shown if a newer version is available 49 | ICON_UPDATE = 'update-available.png' 50 | 51 | # Available modifier keys 52 | # MODIFIERS = ('cmd', 'alt', 'ctrl', 'shift', 'fn') 53 | 54 | # These apps will be passed the remote repo URL instead 55 | # of the local directory path 56 | BROWSERS = [ 57 | 'Browser', # default browser 58 | 'Google Chrome', 59 | 'Firefox', 60 | 'Safari', 61 | 'WebKit', 62 | ] 63 | 64 | DEFAULT_SEARCH_PATH = '~/delete/this/example' 65 | DEFAULT_SETTINGS = { 66 | 'search_dirs': [{ 67 | 'path': DEFAULT_SEARCH_PATH, 68 | 'depth': 2, 69 | 'name_for_parent': 1, 70 | 'excludes': ['tmp', 'bad/smell/*'] 71 | }], 72 | 'global_exclude_patterns': [], 73 | 'app_default': 'Finder', 74 | 'app_cmd': 'Terminal', 75 | 'app_alt': None, 76 | 'app_ctrl': None, 77 | 'app_shift': None, 78 | 'app_fn': None, 79 | } 80 | 81 | # Will be populated later 82 | log = None 83 | 84 | 85 | Repo = namedtuple('Repo', 'name path') 86 | 87 | 88 | class AttrDict(dict): 89 | """Access dictionary keys as attributes.""" 90 | 91 | def __init__(self, *args, **kwargs): 92 | """Create new dictionary.""" 93 | super(AttrDict, self).__init__(*args, **kwargs) 94 | # Assigning self to __dict__ turns keys into attributes 95 | self.__dict__ = self 96 | 97 | 98 | def migrate_v1_config(): 99 | """Replace v1 format in settings with v2 format. 100 | 101 | Change numbered apps to named apps. 102 | """ 103 | log.debug('migrating v1 config to v2 ...') 104 | newkeys = { 105 | '1': 'default', 106 | '2': 'cmd', 107 | '3': 'alt', 108 | '4': 'ctrl', 109 | '5': 'shift', 110 | '6': 'fn', 111 | } 112 | for k, nk in newkeys.items(): 113 | wf.settings['app_' + nk] = wf.settings.get('app_' + k) 114 | try: 115 | del wf.settings['app_' + k] 116 | log.debug('changed `app_%s` to `app_%s`', k, nk) 117 | except KeyError: 118 | pass 119 | 120 | 121 | def is_defaults(d): 122 | """Return ``True`` if settings are do-nothing defaults. 123 | 124 | Args: 125 | d (dict): Workflow settings 126 | """ 127 | dirs = d.get('search_dirs') or [] 128 | return len(dirs) == 1 and dirs[0]['path'] == DEFAULT_SEARCH_PATH 129 | 130 | 131 | def settings_updated(): 132 | """Test whether settings file is newer than repos cache. 133 | 134 | Returns: 135 | bool: ``True`` if ``settings.json`` is newer than the repos cache. 136 | 137 | """ 138 | cache_age = wf.cached_data_age('repos') 139 | settings_age = time.time() - os.stat(wf.settings_path).st_mtime 140 | log.debug('cache_age=%0.2f, settings_age=%0.2f', cache_age, settings_age) 141 | return settings_age < cache_age 142 | 143 | 144 | def join_english(items): 145 | """Join a list of unicode objects with commas and/or 'and'.""" 146 | if isinstance(items, unicode): 147 | return items 148 | 149 | if len(items) == 1: 150 | return unicode(items[0]) 151 | 152 | elif len(items) == 2: 153 | return u' and '.join(items) 154 | 155 | return u', '.join(items[:-1]) + u' and {}'.format(items[-1]) 156 | 157 | 158 | def get_apps(): 159 | """Load applications configured in settings. 160 | 161 | Each value may be a string for a single app or a list for 162 | multiple apps. 163 | 164 | Returns: 165 | dict: Modkey to application mapping. 166 | 167 | """ 168 | apps = {} 169 | for key, app in wf.settings.items(): 170 | if not key.startswith('app_'): 171 | continue 172 | 173 | key = key[4:] 174 | if isinstance(app, list): 175 | app = app[:] 176 | apps[key] = app 177 | 178 | if not apps.get('default'): # Things will break if this isn't set 179 | apps['default'] = u'Finder' 180 | 181 | return apps 182 | 183 | 184 | def get_repos(opts): 185 | """Load repos from cache, triggering an update if necessary. 186 | 187 | Args: 188 | opts (AttrDict): CLI options 189 | 190 | Returns: 191 | list: Sequence of `Repo` tuples. 192 | 193 | """ 194 | # Load data, update if necessary 195 | if not wf.cached_data_fresh('repos', max_age=opts.update_interval): 196 | do_update() 197 | repos = wf.cached_data('repos', max_age=0) 198 | 199 | if not repos: 200 | do_update() 201 | return [] 202 | 203 | # Check if cached data is old version 204 | if isinstance(repos[0], basestring): 205 | do_update() 206 | return [] 207 | 208 | return repos 209 | 210 | 211 | def repo_url(path): 212 | """Return repo URL extracted from `.git/config`. 213 | 214 | Args: 215 | path (str): Path to git repo. 216 | 217 | Returns: 218 | str: URL of remote/origin. 219 | 220 | """ 221 | url = subprocess.check_output(['git', 'config', 'remote.origin.url'], 222 | cwd=path) 223 | url = re.sub(r'(^.+@)|(^https://)|(^git://)|(.git$)', '', url) 224 | return 'https://' + re.sub(r':', '/', url).strip() 225 | 226 | 227 | def do_open(opts): 228 | """Open repo in the specified application(s). 229 | 230 | Args: 231 | opts (AttrDict): CLI options. 232 | 233 | Returns: 234 | int: Exit status. 235 | 236 | """ 237 | all_apps = get_apps() 238 | apps = all_apps.get(opts.appkey) 239 | if apps is None: 240 | print('App {} not set. Use `reposettings`'.format(opts.appkey)) 241 | return 0 242 | 243 | if not isinstance(apps, list): 244 | apps = [apps] 245 | 246 | for app in apps: 247 | if app in BROWSERS: 248 | url = repo_url(opts.path) 249 | log.info('opening %s with %s ...', url, app) 250 | if app == 'Browser': 251 | subprocess.call(['open', url]) 252 | else: 253 | subprocess.call(['open', '-a', app, url]) 254 | else: 255 | log.info('opening %s with %s ...', opts.path, app) 256 | subprocess.call(['open', '-a', app, opts.path]) 257 | 258 | 259 | def do_settings(): 260 | """Open ``settings.json`` in default editor. 261 | 262 | Args: 263 | opts (AttrDict): CLI options. 264 | 265 | Returns: 266 | int: Exit status. 267 | 268 | """ 269 | subprocess.call(['open', wf.settings_path]) 270 | return 0 271 | 272 | 273 | def do_update(): 274 | """Update cached list of git repos. 275 | 276 | Args: 277 | opts (AttrDict): CLI options. 278 | 279 | Returns: 280 | int: Exit status. 281 | 282 | """ 283 | run_in_background('update', ['/usr/bin/python', 'update.py']) 284 | return 0 285 | 286 | 287 | def do_search(repos, opts): 288 | """Filter list of repos and show results in Alfred. 289 | 290 | Args: 291 | repos (list): Sequence of ``Repo`` tuples. 292 | opts (AttrDict): CLI options. 293 | 294 | Returns: 295 | int: Exit status. 296 | 297 | """ 298 | apps = get_apps() 299 | subtitles = {} 300 | valid = {} 301 | for key, app in apps.items(): 302 | if not app: 303 | subtitles[key] = ('App for ' + key + ' not set. ' 304 | 'Use `reposettings` to set it.') 305 | valid[key] = False 306 | else: 307 | subtitles[key] = u'Open in {}'.format(join_english(app)) 308 | valid[key] = True 309 | 310 | if opts.query: 311 | repos = wf.filter(opts.query, repos, lambda t: t[0], min_score=30) 312 | log.info(u'%d/%d repos match `%s`', len(repos), len(repos), opts.query) 313 | 314 | if not repos: 315 | wf.add_item('No matching repos found', icon=ICON_WARNING) 316 | 317 | home = os.environ['HOME'] 318 | for r in repos: 319 | log.debug(r) 320 | pretty_path = subtitle = r.path.replace(home, '~') 321 | app = subtitles.get('default') 322 | if app: 323 | subtitle += ' // ' + app 324 | it = wf.add_item( 325 | r.name, 326 | subtitle, 327 | arg=r.path, 328 | uid=r.path, 329 | valid=valid.get('default', False), 330 | type='file', 331 | icon='icon.png' 332 | ) 333 | it.setvar('appkey', 'default') 334 | 335 | for key in apps: 336 | if key == 'default': 337 | continue 338 | mod = it.add_modifier(key.replace('_', '+'), 339 | pretty_path + ' // ' + subtitles[key], 340 | arg=r.path, valid=valid[key]) 341 | mod.setvar('appkey', key) 342 | 343 | wf.send_feedback() 344 | return 0 345 | 346 | 347 | def parse_args(): 348 | """Extract options from CLI arguments. 349 | 350 | Returns: 351 | AttrDict: CLI options. 352 | 353 | """ 354 | from docopt import docopt 355 | 356 | args = docopt(__doc__, wf.args) 357 | 358 | log.debug('args=%r', args) 359 | 360 | update_interval = int(os.getenv('UPDATE_EVERY_MINS', 361 | DEFAULT_UPDATE_INTERVAL)) * 60 362 | 363 | opts = AttrDict( 364 | query=(args.get('') or u'').strip(), 365 | path=args.get(''), 366 | appkey=args.get('') or 'default', 367 | update_interval=update_interval, 368 | do_search=args.get('search'), 369 | do_update=args.get('update'), 370 | do_settings=args.get('settings'), 371 | do_open=args.get('open'), 372 | ) 373 | 374 | log.debug('opts=%r', opts) 375 | return opts 376 | 377 | 378 | def main(wf): 379 | """Run the workflow.""" 380 | # Update settings format 381 | if wf.last_version_run and wf.last_version_run < Version('2'): 382 | migrate_v1_config() 383 | 384 | opts = parse_args() 385 | 386 | # Alternate actions 387 | # ------------------------------------------------------------------ 388 | if opts.do_open: 389 | return do_open(opts) 390 | 391 | elif opts.do_settings: 392 | return do_settings() 393 | 394 | elif opts.do_update: 395 | return do_update() 396 | 397 | # Notify user if update is available 398 | # ------------------------------------------------------------------ 399 | if wf.update_available: 400 | wf.add_item(u'Workflow Update is Available', 401 | u'↩ or ⇥ to install', 402 | autocomplete='workflow:update', 403 | valid=False, 404 | icon=ICON_UPDATE) 405 | 406 | # Try to search git repos 407 | # ------------------------------------------------------------------ 408 | search_dirs = wf.settings.get('search_dirs', []) 409 | 410 | # Can't do anything with no directories to search 411 | if not search_dirs or is_defaults(wf.settings): 412 | wf.add_item("You haven't configured any directories to search", 413 | 'Use `reposettings` to edit your configuration', 414 | icon=ICON_WARNING) 415 | wf.send_feedback() 416 | return 0 417 | 418 | # Reload repos if settings file has been updated 419 | if settings_updated(): 420 | log.info('settings were updated. Reloading repos...') 421 | do_update() 422 | 423 | repos = get_repos(opts) 424 | 425 | # Show appropriate warning/info message if there are no repos to 426 | # show/search 427 | # ------------------------------------------------------------------ 428 | if not repos: 429 | if is_running('update'): 430 | wf.add_item(u'Updating list of repos…', 431 | 'Should be done in a few seconds', 432 | icon=ICON_INFO) 433 | wf.rerun = 0.5 434 | else: 435 | wf.add_item('No git repos found', 436 | 'Check your settings with `reposettings`', 437 | icon=ICON_WARNING) 438 | wf.send_feedback() 439 | return 0 440 | 441 | # Reload results if `update` is running 442 | if is_running('update'): 443 | wf.rerun = 0.5 444 | 445 | return do_search(repos, opts) 446 | 447 | 448 | if __name__ == '__main__': 449 | wf = Workflow3(default_settings=DEFAULT_SETTINGS, 450 | update_settings=UPDATE_SETTINGS, 451 | help_url=HELP_URL) 452 | log = wf.logger 453 | sys.exit(wf.run(main)) 454 | -------------------------------------------------------------------------------- /src/update-available.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/src/update-available.png -------------------------------------------------------------------------------- /src/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-07-04 8 | # 9 | 10 | """Update the cache of git repositories. 11 | 12 | Uses settings from the workflow's `settings.json` file. 13 | """ 14 | 15 | from __future__ import print_function, unicode_literals 16 | 17 | import sys 18 | import os 19 | import subprocess 20 | from fnmatch import fnmatch 21 | from time import time 22 | from multiprocessing.dummy import Pool 23 | 24 | from workflow import Workflow3 25 | from workflow.util import utf8ify 26 | 27 | from repos import Repo 28 | 29 | # How many search threads to run at the same time 30 | CONCURRENT_SEARCHES = 4 31 | 32 | # How deep to search in the directory. 33 | # 1 = look only in specified directory 34 | # 2 = also look in subdirectories of specified directory 35 | DEFAULT_DEPTH = 2 36 | 37 | # Will be populated later 38 | log = None 39 | decode = None 40 | 41 | 42 | def find_git_repos(dirpath, excludes, depth, uid, gids, name_for_parent=1): 43 | """Return list of directories containing a `.git` file or directory. 44 | 45 | Results matching globbing patterns in `excludes` will be ignored. 46 | 47 | `depth` is how many directories deep to search (2 is the minimum in 48 | most situations). 49 | 50 | `name_for_parent` is which level of the directory hierarchy to name 51 | the repo after relative to `.git` (1=immediate parent, 2=grandparent) 52 | 53 | """ 54 | 55 | def _group(args, primary, operator=None): 56 | """Pair each arg with primary, then join pairs with operator.""" 57 | out = ['('] 58 | for i, arg in enumerate(args): 59 | if operator and i > 0 and i < len(args) - 1: 60 | out.append(operator) 61 | 62 | out += [primary, arg] 63 | 64 | return out + [')'] 65 | 66 | start = time() 67 | 68 | cmd = ['find', '-L', dirpath, '-maxdepth', str(depth)] 69 | # excludes converted to `find` arguments 70 | if excludes: 71 | cmd += _group(excludes, '-name', '-o') + ['-prune', '-o'] 72 | 73 | # ignore unreadable directories 74 | # https://unix.stackexchange.com/a/257058 75 | cmd.append('(') 76 | # ignore user-owned that we can't open 77 | cmd += ['-uid', str(uid), '(', '-perm', '-u=rx', '-o', '-prune', ')'] 78 | 79 | # ignore group-owned that we can't open 80 | cmd += ['-o'] + _group([str(n) for n in gids], '-gid') 81 | cmd += ['(', '-perm', '-g=rx', '-o', '-prune', ')'] 82 | 83 | # ignore other-owned that we can't open 84 | cmd += ['-o', '(', '-perm', '-o=rx', '-o', '-prune', ')'] 85 | # close "unreadable" group 86 | cmd.append(')') 87 | 88 | cmd += ['-name', '.git', '-print'] 89 | cmd = [utf8ify(s) for s in cmd] 90 | try: 91 | output = subprocess.check_output(cmd) 92 | except Exception as err: 93 | log.exception('failed: %r', err) 94 | raise err 95 | 96 | output = [os.path.dirname(s.strip()) for s in decode(output).split('\n') 97 | if s.strip()] 98 | 99 | results = [] 100 | for filepath in output: 101 | ignore = False 102 | for pattern in excludes: 103 | if fnmatch(filepath, pattern): 104 | ignore = True 105 | break 106 | 107 | if ignore: 108 | continue 109 | 110 | # Work out name for repo 111 | if name_for_parent < 2: # ignore 0, it's pointless 112 | name = os.path.basename(filepath) 113 | else: 114 | components = filepath.rstrip('/').split('/') 115 | if name_for_parent >= len(components): 116 | log.warning('%s : `name_for_parent` is %d, but ' 117 | 'only %d levels in file tree', 118 | filepath, name_for_parent, len(components)) 119 | name = os.path.basename(filepath) 120 | else: 121 | name = components[-(name_for_parent)] 122 | 123 | results.append(Repo(name, filepath)) 124 | 125 | log.debug('%d repo(s) found in `%s` in %0.2fs', len(results), dirpath, 126 | time() - start) 127 | 128 | for r in results: 129 | log.debug(' %r', r) 130 | 131 | return results 132 | 133 | 134 | def main(wf): 135 | """Run script.""" 136 | start = time() 137 | 138 | search_dirs = wf.settings.get('search_dirs', []) 139 | 140 | if not search_dirs: 141 | log.error('No search directories configured. ' 142 | 'Nothing to update. Exiting.') 143 | return 0 144 | 145 | uid = os.getuid() 146 | gids = os.getgroups() 147 | global_excludes = wf.settings.get('global_exclude_patterns', []) 148 | 149 | repos = [] 150 | results = [] # For AsyncResults objects returned by `apply_async` 151 | pool = Pool(CONCURRENT_SEARCHES) 152 | 153 | for data in search_dirs: 154 | dirpath = os.path.expanduser(data['path']) 155 | depth = data.get('depth', DEFAULT_DEPTH) 156 | excludes = data.get('excludes', []) + global_excludes 157 | name_for_parent = data.get('name_for_parent', 1) 158 | 159 | if not os.path.exists(dirpath): 160 | log.error(u'directory does not exist: %s', dirpath) 161 | continue 162 | 163 | r = pool.apply_async(find_git_repos, 164 | (dirpath, excludes, depth, uid, gids, name_for_parent)) 165 | results.append(r) 166 | 167 | # Close the pool and wait for it to finish 168 | pool.close() 169 | pool.join() 170 | 171 | # Retrieve results 172 | for r in results: 173 | repos += r.get() 174 | 175 | wf.cache_data('repos', repos) 176 | 177 | log.info('%d repo(s) found in %0.2fs', len(repos), time() - start) 178 | log.info('update finished') 179 | [h.flush() for h in log.handlers] 180 | 181 | return 0 182 | 183 | 184 | if __name__ == '__main__': 185 | wf = Workflow3() 186 | log = wf.logger 187 | decode = wf.decode 188 | sys.exit(wf.run(main)) 189 | -------------------------------------------------------------------------------- /src/workflow/.alfredversionchecked: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/src/workflow/.alfredversionchecked -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-repos/7f7b3999331808cb58fc33e8793f6be692ed9fe5/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /src/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 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-2019 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Variables', 71 | 'Workflow', 72 | 'Workflow3', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """This module provides an API to run commands in background processes. 12 | 13 | Combine with the :ref:`caching API ` to work from cached data 14 | while you fetch fresh data in the background. 15 | 16 | See :ref:`the User Manual ` for more information 17 | and examples. 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import signal 23 | import sys 24 | import os 25 | import subprocess 26 | import pickle 27 | 28 | from workflow import Workflow 29 | 30 | __all__ = ['is_running', 'run_in_background'] 31 | 32 | _wf = None 33 | 34 | 35 | def wf(): 36 | global _wf 37 | if _wf is None: 38 | _wf = Workflow() 39 | return _wf 40 | 41 | 42 | def _log(): 43 | return wf().logger 44 | 45 | 46 | def _arg_cache(name): 47 | """Return path to pickle cache file for arguments. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to cache file 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile(name + '.argcache') 56 | 57 | 58 | def _pid_file(name): 59 | """Return path to PID file for ``name``. 60 | 61 | :param name: name of task 62 | :type name: ``unicode`` 63 | :returns: Path to PID file for task 64 | :rtype: ``unicode`` filepath 65 | 66 | """ 67 | return wf().cachefile(name + '.pid') 68 | 69 | 70 | def _process_exists(pid): 71 | """Check if a process with PID ``pid`` exists. 72 | 73 | :param pid: PID to check 74 | :type pid: ``int`` 75 | :returns: ``True`` if process exists, else ``False`` 76 | :rtype: ``Boolean`` 77 | 78 | """ 79 | try: 80 | os.kill(pid, 0) 81 | except OSError: # not running 82 | return False 83 | return True 84 | 85 | 86 | def _job_pid(name): 87 | """Get PID of job or `None` if job does not exist. 88 | 89 | Args: 90 | name (str): Name of job. 91 | 92 | Returns: 93 | int: PID of job process (or `None` if job doesn't exist). 94 | """ 95 | pidfile = _pid_file(name) 96 | if not os.path.exists(pidfile): 97 | return 98 | 99 | with open(pidfile, 'rb') as fp: 100 | pid = int(fp.read()) 101 | 102 | if _process_exists(pid): 103 | return pid 104 | 105 | os.unlink(pidfile) 106 | 107 | 108 | def is_running(name): 109 | """Test whether task ``name`` is currently running. 110 | 111 | :param name: name of task 112 | :type name: unicode 113 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 114 | :rtype: bool 115 | 116 | """ 117 | if _job_pid(name) is not None: 118 | return True 119 | 120 | return False 121 | 122 | 123 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 124 | stderr='/dev/null'): # pragma: no cover 125 | """Fork the current process into a background daemon. 126 | 127 | :param pidfile: file to write PID of daemon process to. 128 | :type pidfile: filepath 129 | :param stdin: where to read input 130 | :type stdin: filepath 131 | :param stdout: where to write stdout output 132 | :type stdout: filepath 133 | :param stderr: where to write stderr output 134 | :type stderr: filepath 135 | 136 | """ 137 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 138 | try: 139 | pid = os.fork() 140 | if pid > 0: 141 | if write: # write PID of child process to `pidfile` 142 | tmp = pidfile + '.tmp' 143 | with open(tmp, 'wb') as fp: 144 | fp.write(str(pid)) 145 | os.rename(tmp, pidfile) 146 | if wait: # wait for child process to exit 147 | os.waitpid(pid, 0) 148 | os._exit(0) 149 | except OSError as err: 150 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) 151 | raise err 152 | 153 | # Do first fork and wait for second fork to finish. 154 | _fork_and_exit_parent('fork #1 failed', wait=True) 155 | 156 | # Decouple from parent environment. 157 | os.chdir(wf().workflowdir) 158 | os.setsid() 159 | 160 | # Do second fork and write PID to pidfile. 161 | _fork_and_exit_parent('fork #2 failed', write=True) 162 | 163 | # Now I am a daemon! 164 | # Redirect standard file descriptors. 165 | si = open(stdin, 'r', 0) 166 | so = open(stdout, 'a+', 0) 167 | se = open(stderr, 'a+', 0) 168 | if hasattr(sys.stdin, 'fileno'): 169 | os.dup2(si.fileno(), sys.stdin.fileno()) 170 | if hasattr(sys.stdout, 'fileno'): 171 | os.dup2(so.fileno(), sys.stdout.fileno()) 172 | if hasattr(sys.stderr, 'fileno'): 173 | os.dup2(se.fileno(), sys.stderr.fileno()) 174 | 175 | 176 | def kill(name, sig=signal.SIGTERM): 177 | """Send a signal to job ``name`` via :func:`os.kill`. 178 | 179 | .. versionadded:: 1.29 180 | 181 | Args: 182 | name (str): Name of the job 183 | sig (int, optional): Signal to send (default: SIGTERM) 184 | 185 | Returns: 186 | bool: `False` if job isn't running, `True` if signal was sent. 187 | """ 188 | pid = _job_pid(name) 189 | if pid is None: 190 | return False 191 | 192 | os.kill(pid, sig) 193 | return True 194 | 195 | 196 | def run_in_background(name, args, **kwargs): 197 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 198 | 199 | :param name: name of job 200 | :type name: unicode 201 | :param args: arguments passed as first argument to :func:`subprocess.call` 202 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 203 | :returns: exit code of sub-process 204 | :rtype: int 205 | 206 | When you call this function, it caches its arguments and then calls 207 | ``background.py`` in a subprocess. The Python subprocess will load the 208 | cached arguments, fork into the background, and then run the command you 209 | specified. 210 | 211 | This function will return as soon as the ``background.py`` subprocess has 212 | forked, returning the exit code of *that* process (i.e. not of the command 213 | you're trying to run). 214 | 215 | If that process fails, an error will be written to the log file. 216 | 217 | If a process is already running under the same name, this function will 218 | return immediately and will not run the specified command. 219 | 220 | """ 221 | if is_running(name): 222 | _log().info('[%s] job already running', name) 223 | return 224 | 225 | argcache = _arg_cache(name) 226 | 227 | # Cache arguments 228 | with open(argcache, 'wb') as fp: 229 | pickle.dump({'args': args, 'kwargs': kwargs}, fp) 230 | _log().debug('[%s] command cached: %s', name, argcache) 231 | 232 | # Call this script 233 | cmd = ['/usr/bin/python', __file__, name] 234 | _log().debug('[%s] passing job to background runner: %r', name, cmd) 235 | retcode = subprocess.call(cmd) 236 | 237 | if retcode: # pragma: no cover 238 | _log().error('[%s] background runner failed with %d', name, retcode) 239 | else: 240 | _log().debug('[%s] background job started', name) 241 | 242 | return retcode 243 | 244 | 245 | def main(wf): # pragma: no cover 246 | """Run command in a background process. 247 | 248 | Load cached arguments, fork into background, then call 249 | :meth:`subprocess.call` with cached arguments. 250 | 251 | """ 252 | log = wf.logger 253 | name = wf.args[0] 254 | argcache = _arg_cache(name) 255 | if not os.path.exists(argcache): 256 | msg = '[{0}] command cache not found: {1}'.format(name, argcache) 257 | log.critical(msg) 258 | raise IOError(msg) 259 | 260 | # Fork to background and run command 261 | pidfile = _pid_file(name) 262 | _background(pidfile) 263 | 264 | # Load cached arguments 265 | with open(argcache, 'rb') as fp: 266 | data = pickle.load(fp) 267 | 268 | # Cached arguments 269 | args = data['args'] 270 | kwargs = data['kwargs'] 271 | 272 | # Delete argument cache file 273 | os.unlink(argcache) 274 | 275 | try: 276 | # Run the command 277 | log.debug('[%s] running command: %r', name, args) 278 | 279 | retcode = subprocess.call(args, **kwargs) 280 | 281 | if retcode: 282 | log.error('[%s] command failed with status %d', name, retcode) 283 | finally: 284 | os.unlink(pidfile) 285 | 286 | log.debug('[%s] job complete', name) 287 | 288 | 289 | if __name__ == '__main__': # pragma: no cover 290 | wf().run(main) 291 | -------------------------------------------------------------------------------- /src/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 | from __future__ import print_function, unicode_literals 27 | 28 | import os 29 | import plistlib 30 | import shutil 31 | import subprocess 32 | import sys 33 | import tarfile 34 | import tempfile 35 | import uuid 36 | 37 | import workflow 38 | 39 | 40 | _wf = None 41 | _log = None 42 | 43 | 44 | #: Available system sounds from System Preferences > Sound > Sound Effects 45 | SOUNDS = ( 46 | 'Basso', 47 | 'Blow', 48 | 'Bottle', 49 | 'Frog', 50 | 'Funk', 51 | 'Glass', 52 | 'Hero', 53 | 'Morse', 54 | 'Ping', 55 | 'Pop', 56 | 'Purr', 57 | 'Sosumi', 58 | 'Submarine', 59 | 'Tink', 60 | ) 61 | 62 | 63 | def wf(): 64 | """Return Workflow object for this module. 65 | 66 | Returns: 67 | workflow.Workflow: Workflow object for current workflow. 68 | """ 69 | global _wf 70 | if _wf is None: 71 | _wf = workflow.Workflow() 72 | return _wf 73 | 74 | 75 | def log(): 76 | """Return logger for this module. 77 | 78 | Returns: 79 | logging.Logger: Logger for this module. 80 | """ 81 | global _log 82 | if _log is None: 83 | _log = wf().logger 84 | return _log 85 | 86 | 87 | def notifier_program(): 88 | """Return path to notifier applet executable. 89 | 90 | Returns: 91 | unicode: Path to Notify.app ``applet`` executable. 92 | """ 93 | return wf().datafile('Notify.app/Contents/MacOS/applet') 94 | 95 | 96 | def notifier_icon_path(): 97 | """Return path to icon file in installed Notify.app. 98 | 99 | Returns: 100 | unicode: Path to ``applet.icns`` within the app bundle. 101 | """ 102 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 103 | 104 | 105 | def install_notifier(): 106 | """Extract ``Notify.app`` from the workflow to data directory. 107 | 108 | Changes the bundle ID of the installed app and gives it the 109 | workflow's icon. 110 | """ 111 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 112 | destdir = wf().datadir 113 | app_path = os.path.join(destdir, 'Notify.app') 114 | n = notifier_program() 115 | log().debug('installing Notify.app to %r ...', destdir) 116 | # z = zipfile.ZipFile(archive, 'r') 117 | # z.extractall(destdir) 118 | tgz = tarfile.open(archive, 'r:gz') 119 | tgz.extractall(destdir) 120 | if not os.path.exists(n): # pragma: nocover 121 | raise RuntimeError('Notify.app could not be installed in ' + destdir) 122 | 123 | # Replace applet icon 124 | icon = notifier_icon_path() 125 | workflow_icon = wf().workflowfile('icon.png') 126 | if os.path.exists(icon): 127 | os.unlink(icon) 128 | 129 | png_to_icns(workflow_icon, icon) 130 | 131 | # Set file icon 132 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 133 | # none of this code will "work" on pre-10.8 systems. Let it run 134 | # until I figure out a better way of excluding this module 135 | # from coverage in py2.6. 136 | if sys.version_info >= (2, 7): # pragma: no cover 137 | from AppKit import NSWorkspace, NSImage 138 | 139 | ws = NSWorkspace.sharedWorkspace() 140 | img = NSImage.alloc().init() 141 | img.initWithContentsOfFile_(icon) 142 | ws.setIcon_forFile_options_(img, app_path, 0) 143 | 144 | # Change bundle ID of installed app 145 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 146 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 147 | data = plistlib.readPlist(ip_path) 148 | log().debug('changing bundle ID to %r', bundle_id) 149 | data['CFBundleIdentifier'] = bundle_id 150 | plistlib.writePlist(data, ip_path) 151 | 152 | 153 | def validate_sound(sound): 154 | """Coerce ``sound`` to valid sound name. 155 | 156 | Returns ``None`` for invalid sounds. Sound names can be found 157 | in ``System Preferences > Sound > Sound Effects``. 158 | 159 | Args: 160 | sound (str): Name of system sound. 161 | 162 | Returns: 163 | str: Proper name of sound or ``None``. 164 | """ 165 | if not sound: 166 | return None 167 | 168 | # Case-insensitive comparison of `sound` 169 | if sound.lower() in [s.lower() for s in SOUNDS]: 170 | # Title-case is correct for all system sounds as of macOS 10.11 171 | return sound.title() 172 | return None 173 | 174 | 175 | def notify(title='', text='', sound=None): 176 | """Post notification via Notify.app helper. 177 | 178 | Args: 179 | title (str, optional): Notification title. 180 | text (str, optional): Notification body text. 181 | sound (str, optional): Name of sound to play. 182 | 183 | Raises: 184 | ValueError: Raised if both ``title`` and ``text`` are empty. 185 | 186 | Returns: 187 | bool: ``True`` if notification was posted, else ``False``. 188 | """ 189 | if title == text == '': 190 | raise ValueError('Empty notification') 191 | 192 | sound = validate_sound(sound) or '' 193 | 194 | n = notifier_program() 195 | 196 | if not os.path.exists(n): 197 | install_notifier() 198 | 199 | env = os.environ.copy() 200 | enc = 'utf-8' 201 | env['NOTIFY_TITLE'] = title.encode(enc) 202 | env['NOTIFY_MESSAGE'] = text.encode(enc) 203 | env['NOTIFY_SOUND'] = sound.encode(enc) 204 | cmd = [n] 205 | retcode = subprocess.call(cmd, env=env) 206 | if retcode == 0: 207 | return True 208 | 209 | log().error('Notify.app exited with status {0}.'.format(retcode)) 210 | return False 211 | 212 | 213 | def convert_image(inpath, outpath, size): 214 | """Convert an image file using ``sips``. 215 | 216 | Args: 217 | inpath (str): Path of source file. 218 | outpath (str): Path to destination file. 219 | size (int): Width and height of destination image in pixels. 220 | 221 | Raises: 222 | RuntimeError: Raised if ``sips`` exits with non-zero status. 223 | """ 224 | cmd = [ 225 | b'sips', 226 | b'-z', str(size), str(size), 227 | inpath, 228 | b'--out', outpath] 229 | # log().debug(cmd) 230 | with open(os.devnull, 'w') as pipe: 231 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 232 | 233 | if retcode != 0: 234 | raise RuntimeError('sips exited with %d' % retcode) 235 | 236 | 237 | def png_to_icns(png_path, icns_path): 238 | """Convert PNG file to ICNS using ``iconutil``. 239 | 240 | Create an iconset from the source PNG file. Generate PNG files 241 | in each size required by macOS, then call ``iconutil`` to turn 242 | them into a single ICNS file. 243 | 244 | Args: 245 | png_path (str): Path to source PNG file. 246 | icns_path (str): Path to destination ICNS file. 247 | 248 | Raises: 249 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 250 | """ 251 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 252 | 253 | try: 254 | iconset = os.path.join(tempdir, 'Icon.iconset') 255 | 256 | if os.path.exists(iconset): # pragma: nocover 257 | raise RuntimeError('iconset already exists: ' + iconset) 258 | 259 | os.makedirs(iconset) 260 | 261 | # Copy source icon to icon set and generate all the other 262 | # sizes needed 263 | configs = [] 264 | for i in (16, 32, 128, 256, 512): 265 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 266 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 267 | 268 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 269 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 270 | 271 | for name, size in configs: 272 | outpath = os.path.join(iconset, name) 273 | if os.path.exists(outpath): 274 | continue 275 | convert_image(png_path, outpath, size) 276 | 277 | cmd = [ 278 | b'iconutil', 279 | b'-c', b'icns', 280 | b'-o', icns_path, 281 | iconset] 282 | 283 | retcode = subprocess.call(cmd) 284 | if retcode != 0: 285 | raise RuntimeError('iconset exited with %d' % retcode) 286 | 287 | if not os.path.exists(icns_path): # pragma: nocover 288 | raise ValueError( 289 | 'generated ICNS file not found: ' + repr(icns_path)) 290 | finally: 291 | try: 292 | shutil.rmtree(tempdir) 293 | except OSError: # pragma: no cover 294 | pass 295 | 296 | 297 | if __name__ == '__main__': # pragma: nocover 298 | # Simple command-line script to test module with 299 | # This won't work on 2.6, as `argparse` isn't available 300 | # by default. 301 | import argparse 302 | 303 | from unicodedata import normalize 304 | 305 | def ustr(s): 306 | """Coerce `s` to normalised Unicode.""" 307 | return normalize('NFD', s.decode('utf-8')) 308 | 309 | p = argparse.ArgumentParser() 310 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 311 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 312 | action='store_true') 313 | p.add_argument('-t', '--title', 314 | help="Notification title.", type=ustr, 315 | default='') 316 | p.add_argument('-s', '--sound', type=ustr, 317 | help="Optional notification sound.", default='') 318 | p.add_argument('text', type=ustr, 319 | help="Notification body text.", default='', nargs='?') 320 | o = p.parse_args() 321 | 322 | # List available sounds 323 | if o.list_sounds: 324 | for sound in SOUNDS: 325 | print(sound) 326 | sys.exit(0) 327 | 328 | # Convert PNG to ICNS 329 | if o.png: 330 | icns = os.path.join( 331 | os.path.dirname(o.png), 332 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 333 | 334 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 335 | file=sys.stderr) 336 | 337 | if os.path.exists(icns): 338 | raise ValueError('destination file already exists: ' + icns) 339 | 340 | png_to_icns(o.png, icns) 341 | sys.exit(0) 342 | 343 | # Post notification 344 | if o.title == o.text == '': 345 | print('ERROR: empty notification.', file=sys.stderr) 346 | sys.exit(1) 347 | else: 348 | notify(o.title, o.text, o.sound) 349 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | from collections import defaultdict 27 | from functools import total_ordering 28 | import json 29 | import os 30 | import tempfile 31 | import re 32 | import subprocess 33 | 34 | import workflow 35 | import web 36 | 37 | # __all__ = [] 38 | 39 | 40 | RELEASES_BASE = 'https://api.github.com/repos/{}/releases' 41 | match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search 42 | 43 | _wf = None 44 | 45 | 46 | def wf(): 47 | """Lazy `Workflow` object.""" 48 | global _wf 49 | if _wf is None: 50 | _wf = workflow.Workflow() 51 | return _wf 52 | 53 | 54 | @total_ordering 55 | class Download(object): 56 | """A workflow file that is available for download. 57 | 58 | .. versionadded: 1.37 59 | 60 | Attributes: 61 | url (str): URL of workflow file. 62 | filename (str): Filename of workflow file. 63 | version (Version): Semantic version of workflow. 64 | prerelease (bool): Whether version is a pre-release. 65 | alfred_version (Version): Minimum compatible version 66 | of Alfred. 67 | 68 | """ 69 | 70 | @classmethod 71 | def from_dict(cls, d): 72 | """Create a `Download` from a `dict`.""" 73 | return cls(url=d['url'], filename=d['filename'], 74 | version=Version(d['version']), 75 | prerelease=d['prerelease']) 76 | 77 | @classmethod 78 | def from_releases(cls, js): 79 | """Extract downloads from GitHub releases. 80 | 81 | Searches releases with semantic tags for assets with 82 | file extension .alfredworkflow or .alfredXworkflow where 83 | X is a number. 84 | 85 | Files are returned sorted by latest version first. Any 86 | releases containing multiple files with the same (workflow) 87 | extension are rejected as ambiguous. 88 | 89 | Args: 90 | js (str): JSON response from GitHub's releases endpoint. 91 | 92 | Returns: 93 | list: Sequence of `Download`. 94 | """ 95 | releases = json.loads(js) 96 | downloads = [] 97 | for release in releases: 98 | tag = release['tag_name'] 99 | dupes = defaultdict(int) 100 | try: 101 | version = Version(tag) 102 | except ValueError as err: 103 | wf().logger.debug('ignored release: bad version "%s": %s', 104 | tag, err) 105 | continue 106 | 107 | dls = [] 108 | for asset in release.get('assets', []): 109 | url = asset.get('browser_download_url') 110 | filename = os.path.basename(url) 111 | m = match_workflow(filename) 112 | if not m: 113 | wf().logger.debug('unwanted file: %s', filename) 114 | continue 115 | 116 | ext = m.group(0) 117 | dupes[ext] = dupes[ext] + 1 118 | dls.append(Download(url, filename, version, 119 | release['prerelease'])) 120 | 121 | valid = True 122 | for ext, n in dupes.items(): 123 | if n > 1: 124 | wf().logger.debug('ignored release "%s": multiple assets ' 125 | 'with extension "%s"', tag, ext) 126 | valid = False 127 | break 128 | 129 | if valid: 130 | downloads.extend(dls) 131 | 132 | downloads.sort(reverse=True) 133 | return downloads 134 | 135 | def __init__(self, url, filename, version, prerelease=False): 136 | """Create a new Download. 137 | 138 | Args: 139 | url (str): URL of workflow file. 140 | filename (str): Filename of workflow file. 141 | version (Version): Version of workflow. 142 | prerelease (bool, optional): Whether version is 143 | pre-release. Defaults to False. 144 | 145 | """ 146 | if isinstance(version, basestring): 147 | version = Version(version) 148 | 149 | self.url = url 150 | self.filename = filename 151 | self.version = version 152 | self.prerelease = prerelease 153 | 154 | @property 155 | def alfred_version(self): 156 | """Minimum Alfred version based on filename extension.""" 157 | m = match_workflow(self.filename) 158 | if not m or not m.group(1): 159 | return Version('0') 160 | return Version(m.group(1)) 161 | 162 | @property 163 | def dict(self): 164 | """Convert `Download` to `dict`.""" 165 | return dict(url=self.url, filename=self.filename, 166 | version=str(self.version), prerelease=self.prerelease) 167 | 168 | def __str__(self): 169 | """Format `Download` for printing.""" 170 | u = ('Download(url={dl.url!r}, ' 171 | 'filename={dl.filename!r}, ' 172 | 'version={dl.version!r}, ' 173 | 'prerelease={dl.prerelease!r})'.format(dl=self)) 174 | 175 | return u.encode('utf-8') 176 | 177 | def __repr__(self): 178 | """Code-like representation of `Download`.""" 179 | return str(self) 180 | 181 | def __eq__(self, other): 182 | """Compare Downloads based on version numbers.""" 183 | if self.url != other.url \ 184 | or self.filename != other.filename \ 185 | or self.version != other.version \ 186 | or self.prerelease != other.prerelease: 187 | return False 188 | return True 189 | 190 | def __ne__(self, other): 191 | """Compare Downloads based on version numbers.""" 192 | return not self.__eq__(other) 193 | 194 | def __lt__(self, other): 195 | """Compare Downloads based on version numbers.""" 196 | if self.version != other.version: 197 | return self.version < other.version 198 | return self.alfred_version < other.alfred_version 199 | 200 | 201 | class Version(object): 202 | """Mostly semantic versioning. 203 | 204 | The main difference to proper :ref:`semantic versioning ` 205 | is that this implementation doesn't require a minor or patch version. 206 | 207 | Version strings may also be prefixed with "v", e.g.: 208 | 209 | >>> v = Version('v1.1.1') 210 | >>> v.tuple 211 | (1, 1, 1, '') 212 | 213 | >>> v = Version('2.0') 214 | >>> v.tuple 215 | (2, 0, 0, '') 216 | 217 | >>> Version('3.1-beta').tuple 218 | (3, 1, 0, 'beta') 219 | 220 | >>> Version('1.0.1') > Version('0.0.1') 221 | True 222 | """ 223 | 224 | #: Match version and pre-release/build information in version strings 225 | match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match 226 | 227 | def __init__(self, vstr): 228 | """Create new `Version` object. 229 | 230 | Args: 231 | vstr (basestring): Semantic version string. 232 | """ 233 | if not vstr: 234 | raise ValueError('invalid version number: {!r}'.format(vstr)) 235 | 236 | self.vstr = vstr 237 | self.major = 0 238 | self.minor = 0 239 | self.patch = 0 240 | self.suffix = '' 241 | self.build = '' 242 | self._parse(vstr) 243 | 244 | def _parse(self, vstr): 245 | if vstr.startswith('v'): 246 | m = self.match_version(vstr[1:]) 247 | else: 248 | m = self.match_version(vstr) 249 | if not m: 250 | raise ValueError('invalid version number: ' + vstr) 251 | 252 | version, suffix = m.groups() 253 | parts = self._parse_dotted_string(version) 254 | self.major = parts.pop(0) 255 | if len(parts): 256 | self.minor = parts.pop(0) 257 | if len(parts): 258 | self.patch = parts.pop(0) 259 | if not len(parts) == 0: 260 | raise ValueError('version number too long: ' + vstr) 261 | 262 | if suffix: 263 | # Build info 264 | idx = suffix.find('+') 265 | if idx > -1: 266 | self.build = suffix[idx+1:] 267 | suffix = suffix[:idx] 268 | if suffix: 269 | if not suffix.startswith('-'): 270 | raise ValueError( 271 | 'suffix must start with - : ' + suffix) 272 | self.suffix = suffix[1:] 273 | 274 | def _parse_dotted_string(self, s): 275 | """Parse string ``s`` into list of ints and strings.""" 276 | parsed = [] 277 | parts = s.split('.') 278 | for p in parts: 279 | if p.isdigit(): 280 | p = int(p) 281 | parsed.append(p) 282 | return parsed 283 | 284 | @property 285 | def tuple(self): 286 | """Version number as a tuple of major, minor, patch, pre-release.""" 287 | return (self.major, self.minor, self.patch, self.suffix) 288 | 289 | def __lt__(self, other): 290 | """Implement comparison.""" 291 | if not isinstance(other, Version): 292 | raise ValueError('not a Version instance: {0!r}'.format(other)) 293 | t = self.tuple[:3] 294 | o = other.tuple[:3] 295 | if t < o: 296 | return True 297 | if t == o: # We need to compare suffixes 298 | if self.suffix and not other.suffix: 299 | return True 300 | if other.suffix and not self.suffix: 301 | return False 302 | return self._parse_dotted_string(self.suffix) \ 303 | < self._parse_dotted_string(other.suffix) 304 | # t > o 305 | return False 306 | 307 | def __eq__(self, other): 308 | """Implement comparison.""" 309 | if not isinstance(other, Version): 310 | raise ValueError('not a Version instance: {0!r}'.format(other)) 311 | return self.tuple == other.tuple 312 | 313 | def __ne__(self, other): 314 | """Implement comparison.""" 315 | return not self.__eq__(other) 316 | 317 | def __gt__(self, other): 318 | """Implement comparison.""" 319 | if not isinstance(other, Version): 320 | raise ValueError('not a Version instance: {0!r}'.format(other)) 321 | return other.__lt__(self) 322 | 323 | def __le__(self, other): 324 | """Implement comparison.""" 325 | if not isinstance(other, Version): 326 | raise ValueError('not a Version instance: {0!r}'.format(other)) 327 | return not other.__lt__(self) 328 | 329 | def __ge__(self, other): 330 | """Implement comparison.""" 331 | return not self.__lt__(other) 332 | 333 | def __str__(self): 334 | """Return semantic version string.""" 335 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 336 | if self.suffix: 337 | vstr = '{0}-{1}'.format(vstr, self.suffix) 338 | if self.build: 339 | vstr = '{0}+{1}'.format(vstr, self.build) 340 | return vstr 341 | 342 | def __repr__(self): 343 | """Return 'code' representation of `Version`.""" 344 | return "Version('{0}')".format(str(self)) 345 | 346 | 347 | def retrieve_download(dl): 348 | """Saves a download to a temporary file and returns path. 349 | 350 | .. versionadded: 1.37 351 | 352 | Args: 353 | url (unicode): URL to .alfredworkflow file in GitHub repo 354 | 355 | Returns: 356 | unicode: path to downloaded file 357 | 358 | """ 359 | if not match_workflow(dl.filename): 360 | raise ValueError('attachment not a workflow: ' + dl.filename) 361 | 362 | path = os.path.join(tempfile.gettempdir(), dl.filename) 363 | wf().logger.debug('downloading update from ' 364 | '%r to %r ...', dl.url, path) 365 | 366 | r = web.get(dl.url) 367 | r.raise_for_status() 368 | 369 | r.save_to_path(path) 370 | 371 | return path 372 | 373 | 374 | def build_api_url(repo): 375 | """Generate releases URL from GitHub repo. 376 | 377 | Args: 378 | repo (unicode): Repo name in form ``username/repo`` 379 | 380 | Returns: 381 | unicode: URL to the API endpoint for the repo's releases 382 | 383 | """ 384 | if len(repo.split('/')) != 2: 385 | raise ValueError('invalid GitHub repo: {!r}'.format(repo)) 386 | 387 | return RELEASES_BASE.format(repo) 388 | 389 | 390 | def get_downloads(repo): 391 | """Load available ``Download``s for GitHub repo. 392 | 393 | .. versionadded: 1.37 394 | 395 | Args: 396 | repo (unicode): GitHub repo to load releases for. 397 | 398 | Returns: 399 | list: Sequence of `Download` contained in GitHub releases. 400 | """ 401 | url = build_api_url(repo) 402 | 403 | def _fetch(): 404 | wf().logger.info('retrieving releases for %r ...', repo) 405 | r = web.get(url) 406 | r.raise_for_status() 407 | return r.content 408 | 409 | key = 'github-releases-' + repo.replace('/', '-') 410 | js = wf().cached_data(key, _fetch, max_age=60) 411 | 412 | return Download.from_releases(js) 413 | 414 | 415 | def latest_download(dls, alfred_version=None, prereleases=False): 416 | """Return newest `Download`.""" 417 | alfred_version = alfred_version or os.getenv('alfred_version') 418 | version = None 419 | if alfred_version: 420 | version = Version(alfred_version) 421 | 422 | dls.sort(reverse=True) 423 | for dl in dls: 424 | if dl.prerelease and not prereleases: 425 | wf().logger.debug('ignored prerelease: %s', dl.version) 426 | continue 427 | if version and dl.alfred_version > version: 428 | wf().logger.debug('ignored incompatible (%s > %s): %s', 429 | dl.alfred_version, version, dl.filename) 430 | continue 431 | 432 | wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) 433 | return dl 434 | 435 | return None 436 | 437 | 438 | def check_update(repo, current_version, prereleases=False, 439 | alfred_version=None): 440 | """Check whether a newer release is available on GitHub. 441 | 442 | Args: 443 | repo (unicode): ``username/repo`` for workflow's GitHub repo 444 | current_version (unicode): the currently installed version of the 445 | workflow. :ref:`Semantic versioning ` is required. 446 | prereleases (bool): Whether to include pre-releases. 447 | alfred_version (unicode): version of currently-running Alfred. 448 | if empty, defaults to ``$alfred_version`` environment variable. 449 | 450 | Returns: 451 | bool: ``True`` if an update is available, else ``False`` 452 | 453 | If an update is available, its version number and download URL will 454 | be cached. 455 | 456 | """ 457 | key = '__workflow_latest_version' 458 | # data stored when no update is available 459 | no_update = { 460 | 'available': False, 461 | 'download': None, 462 | 'version': None, 463 | } 464 | current = Version(current_version) 465 | 466 | dls = get_downloads(repo) 467 | if not len(dls): 468 | wf().logger.warning('no valid downloads for %s', repo) 469 | wf().cache_data(key, no_update) 470 | return False 471 | 472 | wf().logger.info('%d download(s) for %s', len(dls), repo) 473 | 474 | dl = latest_download(dls, alfred_version, prereleases) 475 | 476 | if not dl: 477 | wf().logger.warning('no compatible downloads for %s', repo) 478 | wf().cache_data(key, no_update) 479 | return False 480 | 481 | wf().logger.debug('latest=%r, installed=%r', dl.version, current) 482 | 483 | if dl.version > current: 484 | wf().cache_data(key, { 485 | 'version': str(dl.version), 486 | 'download': dl.dict, 487 | 'available': True, 488 | }) 489 | return True 490 | 491 | wf().cache_data(key, no_update) 492 | return False 493 | 494 | 495 | def install_update(): 496 | """If a newer release is available, download and install it. 497 | 498 | :returns: ``True`` if an update is installed, else ``False`` 499 | 500 | """ 501 | key = '__workflow_latest_version' 502 | # data stored when no update is available 503 | no_update = { 504 | 'available': False, 505 | 'download': None, 506 | 'version': None, 507 | } 508 | status = wf().cached_data(key, max_age=0) 509 | 510 | if not status or not status.get('available'): 511 | wf().logger.info('no update available') 512 | return False 513 | 514 | dl = status.get('download') 515 | if not dl: 516 | wf().logger.info('no download information') 517 | return False 518 | 519 | path = retrieve_download(Download.from_dict(dl)) 520 | 521 | wf().logger.info('installing updated workflow ...') 522 | subprocess.call(['open', path]) # nosec 523 | 524 | wf().cache_data(key, no_update) 525 | return True 526 | 527 | 528 | if __name__ == '__main__': # pragma: nocover 529 | import sys 530 | 531 | prereleases = False 532 | 533 | def show_help(status=0): 534 | """Print help message.""" 535 | print('usage: update.py (check|install) ' 536 | '[--prereleases] ') 537 | sys.exit(status) 538 | 539 | argv = sys.argv[:] 540 | if '-h' in argv or '--help' in argv: 541 | show_help() 542 | 543 | if '--prereleases' in argv: 544 | argv.remove('--prereleases') 545 | prereleases = True 546 | 547 | if len(argv) != 4: 548 | show_help(1) 549 | 550 | action = argv[1] 551 | repo = argv[2] 552 | version = argv[3] 553 | 554 | try: 555 | 556 | if action == 'check': 557 | check_update(repo, version, prereleases) 558 | elif action == 'install': 559 | install_update() 560 | else: 561 | show_help(1) 562 | 563 | except Exception as err: # ensure traceback is in log file 564 | wf().logger.exception(err) 565 | raise err 566 | -------------------------------------------------------------------------------- /src/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import json 22 | import os 23 | import signal 24 | import subprocess 25 | import sys 26 | from threading import Event 27 | import time 28 | 29 | # JXA scripts to call Alfred's API via the Scripting Bridge 30 | # {app} is automatically replaced with "Alfred 3" or 31 | # "com.runningwithcrayons.Alfred" depending on version. 32 | # 33 | # Open Alfred in search (regular) mode 34 | JXA_SEARCH = 'Application({app}).search({arg});' 35 | # Open Alfred's File Actions on an argument 36 | JXA_ACTION = 'Application({app}).action({arg});' 37 | # Open Alfred's navigation mode at path 38 | JXA_BROWSE = 'Application({app}).browse({arg});' 39 | # Set the specified theme 40 | JXA_SET_THEME = 'Application({app}).setTheme({arg});' 41 | # Call an External Trigger 42 | JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});' 43 | # Save a variable to the workflow configuration sheet/info.plist 44 | JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});' 45 | # Delete a variable from the workflow configuration sheet/info.plist 46 | JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});' 47 | # Tell Alfred to reload a workflow from disk 48 | JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});' 49 | 50 | 51 | class AcquisitionError(Exception): 52 | """Raised if a lock cannot be acquired.""" 53 | 54 | 55 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 56 | """Information about an installed application. 57 | 58 | Returned by :func:`appinfo`. All attributes are Unicode. 59 | 60 | .. py:attribute:: name 61 | 62 | Name of the application, e.g. ``u'Safari'``. 63 | 64 | .. py:attribute:: path 65 | 66 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 67 | 68 | .. py:attribute:: bundleid 69 | 70 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 71 | 72 | """ 73 | 74 | 75 | def jxa_app_name(): 76 | """Return name of application to call currently running Alfred. 77 | 78 | .. versionadded: 1.37 79 | 80 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 81 | on which version of Alfred is running. 82 | 83 | This name is suitable for use with ``Application(name)`` in JXA. 84 | 85 | Returns: 86 | unicode: Application name or ID. 87 | 88 | """ 89 | if os.getenv('alfred_version', '').startswith('3'): 90 | # Alfred 3 91 | return u'Alfred 3' 92 | # Alfred 4+ 93 | return u'com.runningwithcrayons.Alfred' 94 | 95 | 96 | def unicodify(s, encoding='utf-8', norm=None): 97 | """Ensure string is Unicode. 98 | 99 | .. versionadded:: 1.31 100 | 101 | Decode encoded strings using ``encoding`` and normalise Unicode 102 | to form ``norm`` if specified. 103 | 104 | Args: 105 | s (str): String to decode. May also be Unicode. 106 | encoding (str, optional): Encoding to use on bytestrings. 107 | norm (None, optional): Normalisation form to apply to Unicode string. 108 | 109 | Returns: 110 | unicode: Decoded, optionally normalised, Unicode string. 111 | 112 | """ 113 | if not isinstance(s, unicode): 114 | s = unicode(s, encoding) 115 | 116 | if norm: 117 | from unicodedata import normalize 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, unicode): 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(u'"', u'" & 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 = [utf8ify(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs) 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(app=json.dumps(appname), 263 | arg=json.dumps(name), 264 | opts=json.dumps(opts, sort_keys=True)) 265 | 266 | run_applescript(script, lang='JavaScript') 267 | 268 | 269 | def set_theme(theme_name): 270 | """Change Alfred's theme. 271 | 272 | .. versionadded:: 1.39.0 273 | 274 | Args: 275 | theme_name (unicode): Name of theme Alfred should use. 276 | 277 | """ 278 | appname = jxa_app_name() 279 | script = JXA_SET_THEME.format(app=json.dumps(appname), 280 | arg=json.dumps(theme_name)) 281 | run_applescript(script, lang='JavaScript') 282 | 283 | 284 | def set_config(name, value, bundleid=None, exportable=False): 285 | """Set a workflow variable in ``info.plist``. 286 | 287 | .. versionadded:: 1.33 288 | 289 | If ``bundleid`` is not specified, the bundle ID of the calling 290 | workflow is used. 291 | 292 | Args: 293 | name (str): Name of variable to set. 294 | value (str): Value to set variable to. 295 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 296 | exportable (bool, optional): Whether variable should be marked 297 | as exportable (Don't Export checkbox). 298 | 299 | """ 300 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 301 | appname = jxa_app_name() 302 | opts = { 303 | 'toValue': value, 304 | 'inWorkflow': bundleid, 305 | 'exportable': exportable, 306 | } 307 | 308 | script = JXA_SET_CONFIG.format(app=json.dumps(appname), 309 | arg=json.dumps(name), 310 | opts=json.dumps(opts, sort_keys=True)) 311 | 312 | run_applescript(script, lang='JavaScript') 313 | 314 | 315 | def unset_config(name, bundleid=None): 316 | """Delete a workflow variable from ``info.plist``. 317 | 318 | .. versionadded:: 1.33 319 | 320 | If ``bundleid`` is not specified, the bundle ID of the calling 321 | workflow is used. 322 | 323 | Args: 324 | name (str): Name of variable to delete. 325 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 326 | 327 | """ 328 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 329 | appname = jxa_app_name() 330 | opts = {'inWorkflow': bundleid} 331 | 332 | script = JXA_UNSET_CONFIG.format(app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True)) 335 | 336 | run_applescript(script, lang='JavaScript') 337 | 338 | 339 | def search_in_alfred(query=None): 340 | """Open Alfred with given search query. 341 | 342 | .. versionadded:: 1.39.0 343 | 344 | Omit ``query`` to simply open Alfred's main window. 345 | 346 | Args: 347 | query (unicode, optional): Search query. 348 | 349 | """ 350 | query = query or u'' 351 | appname = jxa_app_name() 352 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 353 | run_applescript(script, lang='JavaScript') 354 | 355 | 356 | def browse_in_alfred(path): 357 | """Open Alfred's filesystem navigation mode at ``path``. 358 | 359 | .. versionadded:: 1.39.0 360 | 361 | Args: 362 | path (unicode): File or directory path. 363 | 364 | """ 365 | appname = jxa_app_name() 366 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 367 | run_applescript(script, lang='JavaScript') 368 | 369 | 370 | def action_in_alfred(paths): 371 | """Action the give filepaths in Alfred. 372 | 373 | .. versionadded:: 1.39.0 374 | 375 | Args: 376 | paths (list): Unicode paths to files/directories to action. 377 | 378 | """ 379 | appname = jxa_app_name() 380 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 381 | run_applescript(script, lang='JavaScript') 382 | 383 | 384 | def reload_workflow(bundleid=None): 385 | """Tell Alfred to reload a workflow from disk. 386 | 387 | .. versionadded:: 1.39.0 388 | 389 | If ``bundleid`` is not specified, the bundle ID of the calling 390 | workflow is used. 391 | 392 | Args: 393 | bundleid (unicode, optional): Bundle ID of workflow to reload. 394 | 395 | """ 396 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 397 | appname = jxa_app_name() 398 | script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname), 399 | arg=json.dumps(bundleid)) 400 | 401 | run_applescript(script, lang='JavaScript') 402 | 403 | 404 | def appinfo(name): 405 | """Get information about an installed application. 406 | 407 | .. versionadded:: 1.31 408 | 409 | Args: 410 | name (str): Name of application to look up. 411 | 412 | Returns: 413 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 414 | 415 | """ 416 | cmd = [ 417 | 'mdfind', 418 | '-onlyin', '/Applications', 419 | '-onlyin', '/System/Applications', 420 | '-onlyin', os.path.expanduser('~/Applications'), 421 | '(kMDItemContentTypeTree == com.apple.application &&' 422 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 423 | .format(name) 424 | ] 425 | 426 | output = run_command(cmd).strip() 427 | if not output: 428 | return None 429 | 430 | path = output.split('\n')[0] 431 | 432 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 433 | bid = run_command(cmd).strip() 434 | if not bid: # pragma: no cover 435 | return None 436 | 437 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 438 | 439 | 440 | @contextmanager 441 | def atomic_writer(fpath, mode): 442 | """Atomic file writer. 443 | 444 | .. versionadded:: 1.12 445 | 446 | Context manager that ensures the file is only written if the write 447 | succeeds. The data is first written to a temporary file. 448 | 449 | :param fpath: path of file to write to. 450 | :type fpath: ``unicode`` 451 | :param mode: sames as for :func:`open` 452 | :type mode: string 453 | 454 | """ 455 | suffix = '.{}.tmp'.format(os.getpid()) 456 | temppath = fpath + suffix 457 | with open(temppath, mode) as fp: 458 | try: 459 | yield fp 460 | os.rename(temppath, fpath) 461 | finally: 462 | try: 463 | os.remove(temppath) 464 | except (OSError, IOError): 465 | pass 466 | 467 | 468 | class LockFile(object): 469 | """Context manager to protect filepaths with lockfiles. 470 | 471 | .. versionadded:: 1.13 472 | 473 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 474 | instances will refuse to lock the same path. 475 | 476 | >>> path = '/path/to/file' 477 | >>> with LockFile(path): 478 | >>> with open(path, 'wb') as fp: 479 | >>> fp.write(data) 480 | 481 | Args: 482 | protected_path (unicode): File to protect with a lockfile 483 | timeout (float, optional): Raises an :class:`AcquisitionError` 484 | if lock cannot be acquired within this number of seconds. 485 | If ``timeout`` is 0 (the default), wait forever. 486 | delay (float, optional): How often to check (in seconds) if 487 | lock has been released. 488 | 489 | Attributes: 490 | delay (float): How often to check (in seconds) whether the lock 491 | can be acquired. 492 | lockfile (unicode): Path of the lockfile. 493 | timeout (float): How long to wait to acquire the lock. 494 | 495 | """ 496 | 497 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 498 | """Create new :class:`LockFile` object.""" 499 | self.lockfile = protected_path + '.lock' 500 | self._lockfile = None 501 | self.timeout = timeout 502 | self.delay = delay 503 | self._lock = Event() 504 | atexit.register(self.release) 505 | 506 | @property 507 | def locked(self): 508 | """``True`` if file is locked by this instance.""" 509 | return self._lock.is_set() 510 | 511 | def acquire(self, blocking=True): 512 | """Acquire the lock if possible. 513 | 514 | If the lock is in use and ``blocking`` is ``False``, return 515 | ``False``. 516 | 517 | Otherwise, check every :attr:`delay` seconds until it acquires 518 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 519 | 520 | """ 521 | if self.locked and not blocking: 522 | return False 523 | 524 | start = time.time() 525 | while True: 526 | # Raise error if we've been waiting too long to acquire the lock 527 | if self.timeout and (time.time() - start) >= self.timeout: 528 | raise AcquisitionError('lock acquisition timed out') 529 | 530 | # If already locked, wait then try again 531 | if self.locked: 532 | time.sleep(self.delay) 533 | continue 534 | 535 | # Create in append mode so we don't lose any contents 536 | if self._lockfile is None: 537 | self._lockfile = open(self.lockfile, 'a') 538 | 539 | # Try to acquire the lock 540 | try: 541 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 542 | self._lock.set() 543 | break 544 | except IOError as err: # pragma: no cover 545 | if err.errno not in (errno.EACCES, errno.EAGAIN): 546 | raise 547 | 548 | # Don't try again 549 | if not blocking: # pragma: no cover 550 | return False 551 | 552 | # Wait, then try again 553 | time.sleep(self.delay) 554 | 555 | return True 556 | 557 | def release(self): 558 | """Release the lock by deleting `self.lockfile`.""" 559 | if not self._lock.is_set(): 560 | return False 561 | 562 | try: 563 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 564 | except IOError: # pragma: no cover 565 | pass 566 | finally: 567 | self._lock.clear() 568 | self._lockfile = None 569 | try: 570 | os.unlink(self.lockfile) 571 | except (IOError, OSError): # pragma: no cover 572 | pass 573 | 574 | return True 575 | 576 | def __enter__(self): 577 | """Acquire lock.""" 578 | self.acquire() 579 | return self 580 | 581 | def __exit__(self, typ, value, traceback): 582 | """Release lock.""" 583 | self.release() 584 | 585 | def __del__(self): 586 | """Clear up `self.lockfile`.""" 587 | self.release() # pragma: no cover 588 | 589 | 590 | class uninterruptible(object): 591 | """Decorator that postpones SIGTERM until wrapped function returns. 592 | 593 | .. versionadded:: 1.12 594 | 595 | .. important:: This decorator is NOT thread-safe. 596 | 597 | As of version 2.7, Alfred allows Script Filters to be killed. If 598 | your workflow is killed in the middle of critical code (e.g. 599 | writing data to disk), this may corrupt your workflow's data. 600 | 601 | Use this decorator to wrap critical functions that *must* complete. 602 | If the script is killed while a wrapped function is executing, 603 | the SIGTERM will be caught and handled after your function has 604 | finished executing. 605 | 606 | Alfred-Workflow uses this internally to ensure its settings, data 607 | and cache writes complete. 608 | 609 | """ 610 | 611 | def __init__(self, func, class_name=''): 612 | """Decorate `func`.""" 613 | self.func = func 614 | functools.update_wrapper(self, func) 615 | self._caught_signal = None 616 | 617 | def signal_handler(self, signum, frame): 618 | """Called when process receives SIGTERM.""" 619 | self._caught_signal = (signum, frame) 620 | 621 | def __call__(self, *args, **kwargs): 622 | """Trap ``SIGTERM`` and call wrapped function.""" 623 | self._caught_signal = None 624 | # Register handler for SIGTERM, then call `self.func` 625 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 626 | signal.signal(signal.SIGTERM, self.signal_handler) 627 | 628 | self.func(*args, **kwargs) 629 | 630 | # Restore old signal handler 631 | signal.signal(signal.SIGTERM, self.old_signal_handler) 632 | 633 | # Handle any signal caught during execution 634 | if self._caught_signal is not None: 635 | signum, frame = self._caught_signal 636 | if callable(self.old_signal_handler): 637 | self.old_signal_handler(signum, frame) 638 | elif self.old_signal_handler == signal.SIG_DFL: 639 | sys.exit(0) 640 | 641 | def __get__(self, obj=None, klass=None): 642 | """Decorator API.""" 643 | return self.__class__(self.func.__get__(obj, klass), 644 | klass.__name__) 645 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.39.0 -------------------------------------------------------------------------------- /src/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | 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 urllib2 25 | import urlparse 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, unicode): 96 | k = k.encode('utf-8') 97 | if isinstance(v, unicode): 98 | v = v.encode('utf-8') 99 | dic2[k] = v 100 | return dic2 101 | 102 | 103 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 104 | """Prevent redirections.""" 105 | 106 | def redirect_request(self, *args): 107 | """Ignore redirect.""" 108 | return None 109 | 110 | 111 | # Adapted from https://gist.github.com/babakness/3901174 112 | class CaseInsensitiveDictionary(dict): 113 | """Dictionary with caseless key search. 114 | 115 | Enables case insensitive searching while preserving case sensitivity 116 | when keys are listed, ie, via keys() or items() methods. 117 | 118 | Works by storing a lowercase version of the key as the new key and 119 | stores the original key-value pair as the key's value 120 | (values become dictionaries). 121 | 122 | """ 123 | 124 | def __init__(self, initval=None): 125 | """Create new case-insensitive dictionary.""" 126 | if isinstance(initval, dict): 127 | for key, value in initval.iteritems(): 128 | self.__setitem__(key, value) 129 | 130 | elif isinstance(initval, list): 131 | for (key, value) in initval: 132 | self.__setitem__(key, value) 133 | 134 | def __contains__(self, key): 135 | return dict.__contains__(self, key.lower()) 136 | 137 | def __getitem__(self, key): 138 | return dict.__getitem__(self, key.lower())['val'] 139 | 140 | def __setitem__(self, key, value): 141 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 142 | 143 | def get(self, key, default=None): 144 | """Return value for case-insensitive key or default.""" 145 | try: 146 | v = dict.__getitem__(self, key.lower()) 147 | except KeyError: 148 | return default 149 | else: 150 | return v['val'] 151 | 152 | def update(self, other): 153 | """Update values from other ``dict``.""" 154 | for k, v in other.items(): 155 | self[k] = v 156 | 157 | def items(self): 158 | """Return ``(key, value)`` pairs.""" 159 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 160 | 161 | def keys(self): 162 | """Return original keys.""" 163 | return [v['key'] for v in dict.itervalues(self)] 164 | 165 | def values(self): 166 | """Return all values.""" 167 | return [v['val'] for v in dict.itervalues(self)] 168 | 169 | def iteritems(self): 170 | """Iterate over ``(key, value)`` pairs.""" 171 | for v in dict.itervalues(self): 172 | yield v['key'], v['val'] 173 | 174 | def iterkeys(self): 175 | """Iterate over original keys.""" 176 | for v in dict.itervalues(self): 177 | yield v['key'] 178 | 179 | def itervalues(self): 180 | """Interate over values.""" 181 | for v in dict.itervalues(self): 182 | yield v['val'] 183 | 184 | 185 | class Request(urllib2.Request): 186 | """Subclass of :class:`urllib2.Request` that supports custom methods.""" 187 | 188 | def __init__(self, *args, **kwargs): 189 | """Create a new :class:`Request`.""" 190 | self._method = kwargs.pop('method', None) 191 | urllib2.Request.__init__(self, *args, **kwargs) 192 | 193 | def get_method(self): 194 | return self._method.upper() 195 | 196 | 197 | class Response(object): 198 | """ 199 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 200 | 201 | Simplified version of the ``Response`` object in the ``requests`` library. 202 | 203 | >>> r = request('http://www.google.com') 204 | >>> r.status_code 205 | 200 206 | >>> r.encoding 207 | ISO-8859-1 208 | >>> r.content # bytes 209 | ... 210 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 211 | u' ...' 212 | >>> r.json() # content parsed as JSON 213 | 214 | """ 215 | 216 | def __init__(self, request, stream=False): 217 | """Call `request` with :mod:`urllib2` and process results. 218 | 219 | :param request: :class:`Request` instance 220 | :param stream: Whether to stream response or retrieve it all at once 221 | :type stream: bool 222 | 223 | """ 224 | self.request = request 225 | self._stream = stream 226 | self.url = None 227 | self.raw = None 228 | self._encoding = None 229 | self.error = None 230 | self.status_code = None 231 | self.reason = None 232 | self.headers = CaseInsensitiveDictionary() 233 | self._content = None 234 | self._content_loaded = False 235 | self._gzipped = False 236 | 237 | # Execute query 238 | try: 239 | self.raw = urllib2.urlopen(request) 240 | except urllib2.HTTPError as err: 241 | self.error = err 242 | try: 243 | self.url = err.geturl() 244 | # sometimes (e.g. when authentication fails) 245 | # urllib can't get a URL from an HTTPError 246 | # This behaviour changes across Python versions, 247 | # so no test cover (it isn't important). 248 | except AttributeError: # pragma: no cover 249 | pass 250 | self.status_code = err.code 251 | else: 252 | self.status_code = self.raw.getcode() 253 | self.url = self.raw.geturl() 254 | self.reason = RESPONSES.get(self.status_code) 255 | 256 | # Parse additional info if request succeeded 257 | if not self.error: 258 | headers = self.raw.info() 259 | self.transfer_encoding = headers.getencoding() 260 | self.mimetype = headers.gettype() 261 | for key in headers.keys(): 262 | self.headers[key.lower()] = headers.get(key) 263 | 264 | # Is content gzipped? 265 | # Transfer-Encoding appears to not be used in the wild 266 | # (contrary to the HTTP standard), but no harm in testing 267 | # for it 268 | if 'gzip' in headers.get('content-encoding', '') or \ 269 | 'gzip' in headers.get('transfer-encoding', ''): 270 | self._gzipped = True 271 | 272 | @property 273 | def stream(self): 274 | """Whether response is streamed. 275 | 276 | Returns: 277 | bool: `True` if response is streamed. 278 | 279 | """ 280 | return self._stream 281 | 282 | @stream.setter 283 | def stream(self, value): 284 | if self._content_loaded: 285 | raise RuntimeError("`content` has already been read from " 286 | "this Response.") 287 | 288 | self._stream = value 289 | 290 | def json(self): 291 | """Decode response contents as JSON. 292 | 293 | :returns: object decoded from JSON 294 | :rtype: list, dict or unicode 295 | 296 | """ 297 | return json.loads(self.content, self.encoding or 'utf-8') 298 | 299 | @property 300 | def encoding(self): 301 | """Text encoding of document or ``None``. 302 | 303 | :returns: Text encoding if found. 304 | :rtype: str or ``None`` 305 | 306 | """ 307 | if not self._encoding: 308 | self._encoding = self._get_encoding() 309 | 310 | return self._encoding 311 | 312 | @property 313 | def content(self): 314 | """Raw content of response (i.e. bytes). 315 | 316 | :returns: Body of HTTP response 317 | :rtype: str 318 | 319 | """ 320 | if not self._content: 321 | 322 | # Decompress gzipped content 323 | if self._gzipped: 324 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 325 | self._content = decoder.decompress(self.raw.read()) 326 | 327 | else: 328 | self._content = self.raw.read() 329 | 330 | self._content_loaded = True 331 | 332 | return self._content 333 | 334 | @property 335 | def text(self): 336 | """Unicode-decoded content of response body. 337 | 338 | If no encoding can be determined from HTTP headers or the content 339 | itself, the encoded response body will be returned instead. 340 | 341 | :returns: Body of HTTP response 342 | :rtype: unicode or str 343 | 344 | """ 345 | if self.encoding: 346 | return unicodedata.normalize('NFC', unicode(self.content, 347 | self.encoding)) 348 | return self.content 349 | 350 | def iter_content(self, chunk_size=4096, decode_unicode=False): 351 | """Iterate over response data. 352 | 353 | .. versionadded:: 1.6 354 | 355 | :param chunk_size: Number of bytes to read into memory 356 | :type chunk_size: int 357 | :param decode_unicode: Decode to Unicode using detected encoding 358 | :type decode_unicode: bool 359 | :returns: iterator 360 | 361 | """ 362 | if not self.stream: 363 | raise RuntimeError("You cannot call `iter_content` on a " 364 | "Response unless you passed `stream=True`" 365 | " to `get()`/`post()`/`request()`.") 366 | 367 | if self._content_loaded: 368 | raise RuntimeError( 369 | "`content` has already been read from this Response.") 370 | 371 | def decode_stream(iterator, r): 372 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') 373 | 374 | for chunk in iterator: 375 | data = dec.decode(chunk) 376 | if data: 377 | yield data 378 | 379 | data = dec.decode(b'', final=True) 380 | if data: # pragma: no cover 381 | yield data 382 | 383 | def generate(): 384 | if self._gzipped: 385 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 386 | 387 | while True: 388 | chunk = self.raw.read(chunk_size) 389 | if not chunk: 390 | break 391 | 392 | if self._gzipped: 393 | chunk = decoder.decompress(chunk) 394 | 395 | yield chunk 396 | 397 | chunks = generate() 398 | 399 | if decode_unicode and self.encoding: 400 | chunks = decode_stream(chunks, self) 401 | 402 | return chunks 403 | 404 | def save_to_path(self, filepath): 405 | """Save retrieved data to file at ``filepath``. 406 | 407 | .. versionadded: 1.9.6 408 | 409 | :param filepath: Path to save retrieved data. 410 | 411 | """ 412 | filepath = os.path.abspath(filepath) 413 | dirname = os.path.dirname(filepath) 414 | if not os.path.exists(dirname): 415 | os.makedirs(dirname) 416 | 417 | self.stream = True 418 | 419 | with open(filepath, 'wb') as fileobj: 420 | for data in self.iter_content(): 421 | fileobj.write(data) 422 | 423 | def raise_for_status(self): 424 | """Raise stored error if one occurred. 425 | 426 | error will be instance of :class:`urllib2.HTTPError` 427 | """ 428 | if self.error is not None: 429 | raise self.error 430 | return 431 | 432 | def _get_encoding(self): 433 | """Get encoding from HTTP headers or content. 434 | 435 | :returns: encoding or `None` 436 | :rtype: unicode or ``None`` 437 | 438 | """ 439 | headers = self.raw.info() 440 | encoding = None 441 | 442 | if headers.getparam('charset'): 443 | encoding = headers.getparam('charset') 444 | 445 | # HTTP Content-Type header 446 | for param in headers.getplist(): 447 | if param.startswith('charset='): 448 | encoding = param[8:] 449 | break 450 | 451 | if not self.stream: # Try sniffing response content 452 | # Encoding declared in document should override HTTP headers 453 | if self.mimetype == 'text/html': # sniff HTML headers 454 | m = re.search(r"""""", 455 | self.content) 456 | if m: 457 | encoding = m.group(1) 458 | 459 | elif ((self.mimetype.startswith('application/') 460 | or self.mimetype.startswith('text/')) 461 | and 'xml' in self.mimetype): 462 | m = re.search(r"""]*\?>""", 463 | self.content) 464 | if m: 465 | encoding = m.group(1) 466 | 467 | # Format defaults 468 | if self.mimetype == 'application/json' and not encoding: 469 | # The default encoding for JSON 470 | encoding = 'utf-8' 471 | 472 | elif self.mimetype == 'application/xml' and not encoding: 473 | # The default for 'application/xml' 474 | encoding = 'utf-8' 475 | 476 | if encoding: 477 | encoding = encoding.lower() 478 | 479 | return encoding 480 | 481 | 482 | def request(method, url, params=None, data=None, headers=None, cookies=None, 483 | files=None, auth=None, timeout=60, allow_redirects=False, 484 | stream=False): 485 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 486 | 487 | :param method: 'GET' or 'POST' 488 | :type method: unicode 489 | :param url: URL to open 490 | :type url: unicode 491 | :param params: mapping of URL parameters 492 | :type params: dict 493 | :param data: mapping of form data ``{'field_name': 'value'}`` or 494 | :class:`str` 495 | :type data: dict or str 496 | :param headers: HTTP headers 497 | :type headers: dict 498 | :param cookies: cookies to send to server 499 | :type cookies: dict 500 | :param files: files to upload (see below). 501 | :type files: dict 502 | :param auth: username, password 503 | :type auth: tuple 504 | :param timeout: connection timeout limit in seconds 505 | :type timeout: int 506 | :param allow_redirects: follow redirections 507 | :type allow_redirects: bool 508 | :param stream: Stream content instead of fetching it all at once. 509 | :type stream: bool 510 | :returns: Response object 511 | :rtype: :class:`Response` 512 | 513 | 514 | The ``files`` argument is a dictionary:: 515 | 516 | {'fieldname' : { 'filename': 'blah.txt', 517 | 'content': '', 518 | 'mimetype': 'text/plain'} 519 | } 520 | 521 | * ``fieldname`` is the name of the field in the HTML form. 522 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 523 | be used to guess the mimetype, or ``application/octet-stream`` 524 | will be used. 525 | 526 | """ 527 | # TODO: cookies 528 | socket.setdefaulttimeout(timeout) 529 | 530 | # Default handlers 531 | openers = [] 532 | 533 | if not allow_redirects: 534 | openers.append(NoRedirectHandler()) 535 | 536 | if auth is not None: # Add authorisation handler 537 | username, password = auth 538 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 539 | password_manager.add_password(None, url, username, password) 540 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 541 | openers.append(auth_manager) 542 | 543 | # Install our custom chain of openers 544 | opener = urllib2.build_opener(*openers) 545 | urllib2.install_opener(opener) 546 | 547 | if not headers: 548 | headers = CaseInsensitiveDictionary() 549 | else: 550 | headers = CaseInsensitiveDictionary(headers) 551 | 552 | if 'user-agent' not in headers: 553 | headers['user-agent'] = USER_AGENT 554 | 555 | # Accept gzip-encoded content 556 | encodings = [s.strip() for s in 557 | headers.get('accept-encoding', '').split(',')] 558 | if 'gzip' not in encodings: 559 | encodings.append('gzip') 560 | 561 | headers['accept-encoding'] = ', '.join(encodings) 562 | 563 | if files: 564 | if not data: 565 | data = {} 566 | new_headers, data = encode_multipart_formdata(data, files) 567 | headers.update(new_headers) 568 | elif data and isinstance(data, dict): 569 | data = urllib.urlencode(str_dict(data)) 570 | 571 | # Make sure everything is encoded text 572 | headers = str_dict(headers) 573 | 574 | if isinstance(url, unicode): 575 | url = url.encode('utf-8') 576 | 577 | if params: # GET args (POST args are handled in encode_multipart_formdata) 578 | 579 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 580 | 581 | if query: # Combine query string and `params` 582 | url_params = urlparse.parse_qs(query) 583 | # `params` take precedence over URL query string 584 | url_params.update(params) 585 | params = url_params 586 | 587 | query = urllib.urlencode(str_dict(params), doseq=True) 588 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 589 | 590 | req = Request(url, data, headers, method=method) 591 | return Response(req, stream) 592 | 593 | 594 | def get(url, params=None, headers=None, cookies=None, auth=None, 595 | timeout=60, allow_redirects=True, stream=False): 596 | """Initiate a GET request. Arguments as for :func:`request`. 597 | 598 | :returns: :class:`Response` instance 599 | 600 | """ 601 | return request('GET', url, params, headers=headers, cookies=cookies, 602 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 603 | stream=stream) 604 | 605 | 606 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None, 607 | timeout=60, allow_redirects=True, stream=False): 608 | """Initiate a DELETE request. Arguments as for :func:`request`. 609 | 610 | :returns: :class:`Response` instance 611 | 612 | """ 613 | return request('DELETE', url, params, data, headers=headers, 614 | cookies=cookies, auth=auth, timeout=timeout, 615 | allow_redirects=allow_redirects, stream=stream) 616 | 617 | 618 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 619 | auth=None, timeout=60, allow_redirects=False, stream=False): 620 | """Initiate a POST request. Arguments as for :func:`request`. 621 | 622 | :returns: :class:`Response` instance 623 | 624 | """ 625 | return request('POST', url, params, data, headers, cookies, files, auth, 626 | timeout, allow_redirects, stream) 627 | 628 | 629 | def put(url, params=None, data=None, headers=None, cookies=None, files=None, 630 | auth=None, timeout=60, allow_redirects=False, stream=False): 631 | """Initiate a PUT request. Arguments as for :func:`request`. 632 | 633 | :returns: :class:`Response` instance 634 | 635 | """ 636 | return request('PUT', url, params, data, headers, cookies, files, auth, 637 | timeout, allow_redirects, stream) 638 | 639 | 640 | def encode_multipart_formdata(fields, files): 641 | """Encode form data (``fields``) and ``files`` for POST request. 642 | 643 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 644 | :type fields: dict 645 | :param files: dictionary of fieldnames/files elements for file data. 646 | See below for details. 647 | :type files: dict of :class:`dict` 648 | :returns: ``(headers, body)`` ``headers`` is a 649 | :class:`dict` of HTTP headers 650 | :rtype: 2-tuple ``(dict, str)`` 651 | 652 | The ``files`` argument is a dictionary:: 653 | 654 | {'fieldname' : { 'filename': 'blah.txt', 655 | 'content': '', 656 | 'mimetype': 'text/plain'} 657 | } 658 | 659 | - ``fieldname`` is the name of the field in the HTML form. 660 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 661 | be used to guess the mimetype, or ``application/octet-stream`` 662 | will be used. 663 | 664 | """ 665 | def get_content_type(filename): 666 | """Return or guess mimetype of ``filename``. 667 | 668 | :param filename: filename of file 669 | :type filename: unicode/str 670 | :returns: mime-type, e.g. ``text/html`` 671 | :rtype: str 672 | 673 | """ 674 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 675 | 676 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 677 | for i in range(30)) 678 | CRLF = '\r\n' 679 | output = [] 680 | 681 | # Normal form fields 682 | for (name, value) in fields.items(): 683 | if isinstance(name, unicode): 684 | name = name.encode('utf-8') 685 | if isinstance(value, unicode): 686 | value = value.encode('utf-8') 687 | output.append('--' + boundary) 688 | output.append('Content-Disposition: form-data; name="%s"' % name) 689 | output.append('') 690 | output.append(value) 691 | 692 | # Files to upload 693 | for name, d in files.items(): 694 | filename = d[u'filename'] 695 | content = d[u'content'] 696 | if u'mimetype' in d: 697 | mimetype = d[u'mimetype'] 698 | else: 699 | mimetype = get_content_type(filename) 700 | if isinstance(name, unicode): 701 | name = name.encode('utf-8') 702 | if isinstance(filename, unicode): 703 | filename = filename.encode('utf-8') 704 | if isinstance(mimetype, unicode): 705 | mimetype = mimetype.encode('utf-8') 706 | output.append('--' + boundary) 707 | output.append('Content-Disposition: form-data; ' 708 | 'name="%s"; filename="%s"' % (name, filename)) 709 | output.append('Content-Type: %s' % mimetype) 710 | output.append('') 711 | output.append(content) 712 | 713 | output.append('--' + boundary + '--') 714 | output.append('') 715 | body = CRLF.join(output) 716 | headers = { 717 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 718 | 'Content-Length': str(len(body)), 719 | } 720 | return (headers, body) 721 | -------------------------------------------------------------------------------- /src/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 | from __future__ import print_function, unicode_literals, absolute_import 27 | 28 | import json 29 | import os 30 | import sys 31 | 32 | from .workflow import ICON_WARNING, Workflow 33 | 34 | 35 | class Variables(dict): 36 | """Workflow variables for Run Script actions. 37 | 38 | .. versionadded: 1.26 39 | 40 | This class allows you to set workflow variables from 41 | Run Script actions. 42 | 43 | It is a subclass of :class:`dict`. 44 | 45 | >>> v = Variables(username='deanishe', password='hunter2') 46 | >>> v.arg = u'output value' 47 | >>> print(v) 48 | 49 | See :ref:`variables-run-script` in the User Guide for more 50 | information. 51 | 52 | Args: 53 | arg (unicode, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | 57 | Attributes: 58 | arg (unicode): Output value (``{query}``). 59 | config (dict): Configuration for downstream workflow element. 60 | 61 | """ 62 | 63 | def __init__(self, arg=None, **variables): 64 | """Create a new `Variables` object.""" 65 | self.arg = arg 66 | self.config = {} 67 | super(Variables, self).__init__(**variables) 68 | 69 | @property 70 | def obj(self): 71 | """Return ``alfredworkflow`` `dict`.""" 72 | o = {} 73 | if self: 74 | d2 = {} 75 | for k, v in self.items(): 76 | d2[k] = v 77 | o['variables'] = d2 78 | 79 | if self.config: 80 | o['config'] = self.config 81 | 82 | if self.arg is not None: 83 | o['arg'] = self.arg 84 | 85 | return {'alfredworkflow': o} 86 | 87 | def __unicode__(self): 88 | """Convert to ``alfredworkflow`` JSON object. 89 | 90 | Returns: 91 | unicode: ``alfredworkflow`` JSON object 92 | 93 | """ 94 | if not self and not self.config: 95 | if self.arg: 96 | return self.arg 97 | else: 98 | return u'' 99 | 100 | return json.dumps(self.obj) 101 | 102 | def __str__(self): 103 | """Convert to ``alfredworkflow`` JSON object. 104 | 105 | Returns: 106 | str: UTF-8 encoded ``alfredworkflow`` JSON object 107 | 108 | """ 109 | return unicode(self).encode('utf-8') 110 | 111 | 112 | class Modifier(object): 113 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 114 | 115 | Don't use this class directly (as it won't be associated with any 116 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 117 | to add modifiers to results. 118 | 119 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 120 | >>> it.setvar('name', 'default') 121 | >>> m = it.add_modifier('cmd') 122 | >>> m.setvar('name', 'alternate') 123 | 124 | See :ref:`workflow-variables` in the User Guide for more information 125 | and :ref:`example usage `. 126 | 127 | Args: 128 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 129 | subtitle (unicode, optional): Override default subtitle. 130 | arg (unicode, optional): Argument to pass for this modifier. 131 | valid (bool, optional): Override item's validity. 132 | icon (unicode, optional): Filepath/UTI of icon to use 133 | icontype (unicode, optional): Type of icon. See 134 | :meth:`Workflow.add_item() ` 135 | for valid values. 136 | 137 | Attributes: 138 | arg (unicode): Arg to pass to following action. 139 | config (dict): Configuration for a downstream element, such as 140 | a File Filter. 141 | icon (unicode): Filepath/UTI of icon. 142 | icontype (unicode): Type of icon. See 143 | :meth:`Workflow.add_item() ` 144 | for valid values. 145 | key (unicode): Modifier key (see above). 146 | subtitle (unicode): Override item subtitle. 147 | valid (bool): Override item validity. 148 | variables (dict): Workflow variables set by this modifier. 149 | 150 | """ 151 | 152 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 153 | icontype=None): 154 | """Create a new :class:`Modifier`. 155 | 156 | Don't use this class directly (as it won't be associated with any 157 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 158 | to add modifiers to results. 159 | 160 | Args: 161 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 162 | subtitle (unicode, optional): Override default subtitle. 163 | arg (unicode, optional): Argument to pass for this modifier. 164 | valid (bool, optional): Override item's validity. 165 | icon (unicode, optional): Filepath/UTI of icon to use 166 | icontype (unicode, optional): Type of icon. See 167 | :meth:`Workflow.add_item() ` 168 | for valid values. 169 | 170 | """ 171 | self.key = key 172 | self.subtitle = subtitle 173 | self.arg = arg 174 | self.valid = valid 175 | self.icon = icon 176 | self.icontype = icontype 177 | 178 | self.config = {} 179 | self.variables = {} 180 | 181 | def setvar(self, name, value): 182 | """Set a workflow variable for this Item. 183 | 184 | Args: 185 | name (unicode): Name of variable. 186 | value (unicode): Value of variable. 187 | 188 | """ 189 | self.variables[name] = value 190 | 191 | def getvar(self, name, default=None): 192 | """Return value of workflow variable for ``name`` or ``default``. 193 | 194 | Args: 195 | name (unicode): Variable name. 196 | default (None, optional): Value to return if variable is unset. 197 | 198 | Returns: 199 | unicode or ``default``: Value of variable if set or ``default``. 200 | 201 | """ 202 | return self.variables.get(name, default) 203 | 204 | @property 205 | def obj(self): 206 | """Modifier formatted for JSON serialization for Alfred 3. 207 | 208 | Returns: 209 | dict: Modifier for serializing to JSON. 210 | 211 | """ 212 | o = {} 213 | 214 | if self.subtitle is not None: 215 | o['subtitle'] = self.subtitle 216 | 217 | if self.arg is not None: 218 | o['arg'] = self.arg 219 | 220 | if self.valid is not None: 221 | o['valid'] = self.valid 222 | 223 | if self.variables: 224 | o['variables'] = self.variables 225 | 226 | if self.config: 227 | o['config'] = self.config 228 | 229 | icon = self._icon() 230 | if icon: 231 | o['icon'] = icon 232 | 233 | return o 234 | 235 | def _icon(self): 236 | """Return `icon` object for item. 237 | 238 | Returns: 239 | dict: Mapping for item `icon` (may be empty). 240 | 241 | """ 242 | icon = {} 243 | if self.icon is not None: 244 | icon['path'] = self.icon 245 | 246 | if self.icontype is not None: 247 | icon['type'] = self.icontype 248 | 249 | return icon 250 | 251 | 252 | class Item3(object): 253 | """Represents a feedback item for Alfred 3+. 254 | 255 | Generates Alfred-compliant JSON for a single item. 256 | 257 | Don't use this class directly (as it then won't be associated with 258 | any :class:`Workflow3 ` object), but rather use 259 | :meth:`Workflow3.add_item() `. 260 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 261 | 262 | """ 263 | 264 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 265 | match=None, valid=False, uid=None, icon=None, icontype=None, 266 | type=None, largetext=None, copytext=None, quicklookurl=None): 267 | """Create a new :class:`Item3` object. 268 | 269 | Use same arguments as for 270 | :class:`Workflow.Item `. 271 | 272 | Argument ``subtitle_modifiers`` is not supported. 273 | 274 | """ 275 | self.title = title 276 | self.subtitle = subtitle 277 | self.arg = arg 278 | self.autocomplete = autocomplete 279 | self.match = match 280 | self.valid = valid 281 | self.uid = uid 282 | self.icon = icon 283 | self.icontype = icontype 284 | self.type = type 285 | self.quicklookurl = quicklookurl 286 | self.largetext = largetext 287 | self.copytext = copytext 288 | 289 | self.modifiers = {} 290 | 291 | self.config = {} 292 | self.variables = {} 293 | 294 | def setvar(self, name, value): 295 | """Set a workflow variable for this Item. 296 | 297 | Args: 298 | name (unicode): Name of variable. 299 | value (unicode): Value of variable. 300 | 301 | """ 302 | self.variables[name] = value 303 | 304 | def getvar(self, name, default=None): 305 | """Return value of workflow variable for ``name`` or ``default``. 306 | 307 | Args: 308 | name (unicode): Variable name. 309 | default (None, optional): Value to return if variable is unset. 310 | 311 | Returns: 312 | unicode or ``default``: Value of variable if set or ``default``. 313 | 314 | """ 315 | return self.variables.get(name, default) 316 | 317 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 318 | icontype=None): 319 | """Add alternative values for a modifier key. 320 | 321 | Args: 322 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 323 | subtitle (unicode, optional): Override item subtitle. 324 | arg (unicode, optional): Input for following action. 325 | valid (bool, optional): Override item validity. 326 | icon (unicode, optional): Filepath/UTI of icon. 327 | icontype (unicode, optional): Type of icon. See 328 | :meth:`Workflow.add_item() ` 329 | for valid values. 330 | 331 | Returns: 332 | Modifier: Configured :class:`Modifier`. 333 | 334 | """ 335 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 336 | 337 | # Add Item variables to Modifier 338 | mod.variables.update(self.variables) 339 | 340 | self.modifiers[key] = mod 341 | 342 | return mod 343 | 344 | @property 345 | def obj(self): 346 | """Item formatted for JSON serialization. 347 | 348 | Returns: 349 | dict: Data suitable for Alfred 3 feedback. 350 | 351 | """ 352 | # Required values 353 | o = { 354 | 'title': self.title, 355 | 'subtitle': self.subtitle, 356 | 'valid': self.valid, 357 | } 358 | 359 | # Optional values 360 | if self.arg is not None: 361 | o['arg'] = self.arg 362 | 363 | if self.autocomplete is not None: 364 | o['autocomplete'] = self.autocomplete 365 | 366 | if self.match is not None: 367 | o['match'] = self.match 368 | 369 | if self.uid is not None: 370 | o['uid'] = self.uid 371 | 372 | if self.type is not None: 373 | o['type'] = self.type 374 | 375 | if self.quicklookurl is not None: 376 | o['quicklookurl'] = self.quicklookurl 377 | 378 | if self.variables: 379 | o['variables'] = self.variables 380 | 381 | if self.config: 382 | o['config'] = self.config 383 | 384 | # Largetype and copytext 385 | text = self._text() 386 | if text: 387 | o['text'] = text 388 | 389 | icon = self._icon() 390 | if icon: 391 | o['icon'] = icon 392 | 393 | # Modifiers 394 | mods = self._modifiers() 395 | if mods: 396 | o['mods'] = mods 397 | 398 | return o 399 | 400 | def _icon(self): 401 | """Return `icon` object for item. 402 | 403 | Returns: 404 | dict: Mapping for item `icon` (may be empty). 405 | 406 | """ 407 | icon = {} 408 | if self.icon is not None: 409 | icon['path'] = self.icon 410 | 411 | if self.icontype is not None: 412 | icon['type'] = self.icontype 413 | 414 | return icon 415 | 416 | def _text(self): 417 | """Return `largetext` and `copytext` object for item. 418 | 419 | Returns: 420 | dict: `text` mapping (may be empty) 421 | 422 | """ 423 | text = {} 424 | if self.largetext is not None: 425 | text['largetype'] = self.largetext 426 | 427 | if self.copytext is not None: 428 | text['copy'] = self.copytext 429 | 430 | return text 431 | 432 | def _modifiers(self): 433 | """Build `mods` dictionary for JSON feedback. 434 | 435 | Returns: 436 | dict: Modifier mapping or `None`. 437 | 438 | """ 439 | if self.modifiers: 440 | mods = {} 441 | for k, mod in self.modifiers.items(): 442 | mods[k] = mod.obj 443 | 444 | return mods 445 | 446 | return None 447 | 448 | 449 | class Workflow3(Workflow): 450 | """Workflow class that generates Alfred 3+ feedback. 451 | 452 | It is a subclass of :class:`~workflow.Workflow` and most of its 453 | methods are documented there. 454 | 455 | Attributes: 456 | item_class (class): Class used to generate feedback items. 457 | variables (dict): Top level workflow variables. 458 | 459 | """ 460 | 461 | item_class = Item3 462 | 463 | def __init__(self, **kwargs): 464 | """Create a new :class:`Workflow3` object. 465 | 466 | See :class:`~workflow.Workflow` for documentation. 467 | 468 | """ 469 | Workflow.__init__(self, **kwargs) 470 | self.variables = {} 471 | self._rerun = 0 472 | # Get session ID from environment if present 473 | self._session_id = os.getenv('_WF_SESSION_ID') or None 474 | if self._session_id: 475 | self.setvar('_WF_SESSION_ID', self._session_id) 476 | 477 | @property 478 | def _default_cachedir(self): 479 | """Alfred 4's default cache directory.""" 480 | return os.path.join( 481 | os.path.expanduser( 482 | '~/Library/Caches/com.runningwithcrayons.Alfred/' 483 | 'Workflow Data/'), 484 | self.bundleid) 485 | 486 | @property 487 | def _default_datadir(self): 488 | """Alfred 4's default data directory.""" 489 | return os.path.join(os.path.expanduser( 490 | '~/Library/Application Support/Alfred/Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def rerun(self): 495 | """How often (in seconds) Alfred should re-run the Script Filter.""" 496 | return self._rerun 497 | 498 | @rerun.setter 499 | def rerun(self, seconds): 500 | """Interval at which Alfred should re-run the Script Filter. 501 | 502 | Args: 503 | seconds (int): Interval between runs. 504 | """ 505 | self._rerun = seconds 506 | 507 | @property 508 | def session_id(self): 509 | """A unique session ID every time the user uses the workflow. 510 | 511 | .. versionadded:: 1.25 512 | 513 | The session ID persists while the user is using this workflow. 514 | It expires when the user runs a different workflow or closes 515 | Alfred. 516 | 517 | """ 518 | if not self._session_id: 519 | from uuid import uuid4 520 | self._session_id = uuid4().hex 521 | self.setvar('_WF_SESSION_ID', self._session_id) 522 | 523 | return self._session_id 524 | 525 | def setvar(self, name, value, persist=False): 526 | """Set a "global" workflow variable. 527 | 528 | .. versionchanged:: 1.33 529 | 530 | These variables are always passed to downstream workflow objects. 531 | 532 | If you have set :attr:`rerun`, these variables are also passed 533 | back to the script when Alfred runs it again. 534 | 535 | Args: 536 | name (unicode): Name of variable. 537 | value (unicode): Value of variable. 538 | persist (bool, optional): Also save variable to ``info.plist``? 539 | 540 | """ 541 | self.variables[name] = value 542 | if persist: 543 | from .util import set_config 544 | set_config(name, value, self.bundleid) 545 | self.logger.debug('saved variable %r with value %r to info.plist', 546 | name, value) 547 | 548 | def getvar(self, name, default=None): 549 | """Return value of workflow variable for ``name`` or ``default``. 550 | 551 | Args: 552 | name (unicode): Variable name. 553 | default (None, optional): Value to return if variable is unset. 554 | 555 | Returns: 556 | unicode or ``default``: Value of variable if set or ``default``. 557 | 558 | """ 559 | return self.variables.get(name, default) 560 | 561 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 562 | valid=False, uid=None, icon=None, icontype=None, type=None, 563 | largetext=None, copytext=None, quicklookurl=None, match=None): 564 | """Add an item to be output to Alfred. 565 | 566 | Args: 567 | match (unicode, optional): If you have "Alfred filters results" 568 | turned on for your Script Filter, Alfred (version 3.5 and 569 | above) will filter against this field, not ``title``. 570 | 571 | See :meth:`Workflow.add_item() ` for 572 | the main documentation and other parameters. 573 | 574 | The key difference is that this method does not support the 575 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 576 | method instead on the returned item instead. 577 | 578 | Returns: 579 | Item3: Alfred feedback item. 580 | 581 | """ 582 | item = self.item_class(title, subtitle, arg, autocomplete, 583 | match, valid, uid, icon, icontype, type, 584 | largetext, copytext, quicklookurl) 585 | 586 | # Add variables to child item 587 | item.variables.update(self.variables) 588 | 589 | self._items.append(item) 590 | return item 591 | 592 | @property 593 | def _session_prefix(self): 594 | """Filename prefix for current session.""" 595 | return '_wfsess-{0}-'.format(self.session_id) 596 | 597 | def _mk_session_name(self, name): 598 | """New cache name/key based on session ID.""" 599 | return self._session_prefix + name 600 | 601 | def cache_data(self, name, data, session=False): 602 | """Cache API with session-scoped expiry. 603 | 604 | .. versionadded:: 1.25 605 | 606 | Args: 607 | name (str): Cache key 608 | data (object): Data to cache 609 | session (bool, optional): Whether to scope the cache 610 | to the current session. 611 | 612 | ``name`` and ``data`` are the same as for the 613 | :meth:`~workflow.Workflow.cache_data` method on 614 | :class:`~workflow.Workflow`. 615 | 616 | If ``session`` is ``True``, then ``name`` is prefixed 617 | with :attr:`session_id`. 618 | 619 | """ 620 | if session: 621 | name = self._mk_session_name(name) 622 | 623 | return super(Workflow3, self).cache_data(name, data) 624 | 625 | def cached_data(self, name, data_func=None, max_age=60, session=False): 626 | """Cache API with session-scoped expiry. 627 | 628 | .. versionadded:: 1.25 629 | 630 | Args: 631 | name (str): Cache key 632 | data_func (callable): Callable that returns fresh data. It 633 | is called if the cache has expired or doesn't exist. 634 | max_age (int): Maximum allowable age of cache in seconds. 635 | session (bool, optional): Whether to scope the cache 636 | to the current session. 637 | 638 | ``name``, ``data_func`` and ``max_age`` are the same as for the 639 | :meth:`~workflow.Workflow.cached_data` method on 640 | :class:`~workflow.Workflow`. 641 | 642 | If ``session`` is ``True``, then ``name`` is prefixed 643 | with :attr:`session_id`. 644 | 645 | """ 646 | if session: 647 | name = self._mk_session_name(name) 648 | 649 | return super(Workflow3, self).cached_data(name, data_func, max_age) 650 | 651 | def clear_session_cache(self, current=False): 652 | """Remove session data from the cache. 653 | 654 | .. versionadded:: 1.25 655 | .. versionchanged:: 1.27 656 | 657 | By default, data belonging to the current session won't be 658 | deleted. Set ``current=True`` to also clear current session. 659 | 660 | Args: 661 | current (bool, optional): If ``True``, also remove data for 662 | current session. 663 | 664 | """ 665 | def _is_session_file(filename): 666 | if current: 667 | return filename.startswith('_wfsess-') 668 | return filename.startswith('_wfsess-') \ 669 | and not filename.startswith(self._session_prefix) 670 | 671 | self.clear_cache(_is_session_file) 672 | 673 | @property 674 | def obj(self): 675 | """Feedback formatted for JSON serialization. 676 | 677 | Returns: 678 | dict: Data suitable for Alfred 3 feedback. 679 | 680 | """ 681 | items = [] 682 | for item in self._items: 683 | items.append(item.obj) 684 | 685 | o = {'items': items} 686 | if self.variables: 687 | o['variables'] = self.variables 688 | if self.rerun: 689 | o['rerun'] = self.rerun 690 | return o 691 | 692 | def warn_empty(self, title, subtitle=u'', icon=None): 693 | """Add a warning to feedback if there are no items. 694 | 695 | .. versionadded:: 1.31 696 | 697 | Add a "warning" item to Alfred feedback if no other items 698 | have been added. This is a handy shortcut to prevent Alfred 699 | from showing its fallback searches, which is does if no 700 | items are returned. 701 | 702 | Args: 703 | title (unicode): Title of feedback item. 704 | subtitle (unicode, optional): Subtitle of feedback item. 705 | icon (str, optional): Icon for feedback item. If not 706 | specified, ``ICON_WARNING`` is used. 707 | 708 | Returns: 709 | Item3: Newly-created item. 710 | 711 | """ 712 | if len(self._items): 713 | return 714 | 715 | icon = icon or ICON_WARNING 716 | return self.add_item(title, subtitle, icon=icon) 717 | 718 | def send_feedback(self): 719 | """Print stored items to console/Alfred as JSON.""" 720 | if self.debugging: 721 | json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': ')) 722 | else: 723 | json.dump(self.obj, sys.stdout) 724 | sys.stdout.flush() 725 | --------------------------------------------------------------------------------