├── .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 |
--------------------------------------------------------------------------------