├── help ├── src │ ├── vendor │ │ ├── __init__.py │ │ ├── workflow │ │ │ ├── version │ │ │ ├── Notify.tgz │ │ │ ├── LICENSE.txt │ │ │ ├── __init__.py │ │ │ ├── background.py │ │ │ ├── notify.py │ │ │ ├── workflow3.py │ │ │ ├── update.py │ │ │ └── web.py │ │ └── README.md │ ├── icon.png │ ├── help.py │ ├── feedback.py │ ├── util.py │ ├── workflow_objects.py │ └── info.plist ├── screenshot.gif ├── Help.alfredworkflow └── README.md ├── file-actions ├── screenshot.gif ├── File Actions.alfredworkflow └── README.md ├── README.md ├── resources.md ├── LICENSE ├── .gitignore ├── themes └── Dark.alfredappearance └── _scripts ├── workflow-build.py ├── workflow-install.py └── docopt.py /help/src/vendor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /help/src/vendor/workflow/version: -------------------------------------------------------------------------------- 1 | 1.21.1 -------------------------------------------------------------------------------- /help/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/help/screenshot.gif -------------------------------------------------------------------------------- /help/src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/help/src/icon.png -------------------------------------------------------------------------------- /help/Help.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/help/Help.alfredworkflow -------------------------------------------------------------------------------- /file-actions/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/file-actions/screenshot.gif -------------------------------------------------------------------------------- /help/src/vendor/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/help/src/vendor/workflow/Notify.tgz -------------------------------------------------------------------------------- /file-actions/File Actions.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurhammer/alfred-workflows/HEAD/file-actions/File Actions.alfredworkflow -------------------------------------------------------------------------------- /help/src/vendor/README.md: -------------------------------------------------------------------------------- 1 | ## Bundled Python packages 2 | 3 | - [alfred-workflow](https://github.com/deanishe/alfred-workflow) 4 | 5 | --- 6 | 7 | `init.py` makes this folder into a package so we can use imports (hopefully): 8 | 9 | from vendor import alfred-workflow 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # My Alfred Workflows 2 | 3 | ### File Actions++ 4 | 5 | Brings Alfred's Finder file actions to any document-based app. 6 | 7 | [Info](file-actions) | [Install](file-actions/File%20Actions.alfredworkflow?raw=true) 8 | 9 | ### Help 10 | 11 | The Help Page for your Alfred Workflows. 12 | 13 | [Info](help) | [Install](help/Help.alfredworkflow?raw=true) 14 | 15 | --- 16 | 17 | ### Contribute 18 | 19 | Contributions are welcome! 👍😀 20 | 21 | ### License 22 | 23 | Unless specifically otherwise noted, available under the [MIT License](LICENSE). 24 | -------------------------------------------------------------------------------- /resources.md: -------------------------------------------------------------------------------- 1 | ## Resources 2 | 3 | - Dean Jackson's helpers: 4 | - [`alfred-workflow`](https://github.com/deanishe/alfred-workflow) Python package. 5 | - [`workflow-install.py`](https://gist.github.com/deanishe/35faae3e7f89f629a94e) install script. 6 | - [`workflow-build.py`](https://gist.github.com/deanishe/b16f018119ef3fe951af) build script. 7 | - Alfred forum discussion on workflow development ...workflows: 8 | - [One](http://www.alfredforum.com/topic/9251-what-is-your-workflow-for-developing-these-workflows/) 9 | - [Two](http://www.alfredforum.com/topic/5287-workflow-development/) 10 | -------------------------------------------------------------------------------- /file-actions/README.md: -------------------------------------------------------------------------------- 1 | ## File Actions++ 2 | 3 | Brings Alfred's Finder file actions to any document-based app. Allows you to quickly perform actions on the currently opened file. 4 | 5 | Read more about it [here](https://arthurhammer.de/2018/07/alfred-file-actions-plus-plus/). 6 | 7 | ![Screenshot](screenshot.gif) 8 | 9 | ### Usage 10 | 11 | Shortcut `⌘.` (can be configured) brings the file action panel for the current file. 12 | 13 | ### Install 14 | 15 | [Download and open the workflow](File%20Actions.alfredworkflow?raw=true). 16 | 17 | ### License 18 | 19 | - Code: [MIT](../LICENSE) 20 | - Icon: [icons8](https://icons8.com/license/) (CC BY-ND 3.0) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Arthur Hammer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | -------------------------------------------------------------------------------- /themes/Dark.alfredappearance: -------------------------------------------------------------------------------- 1 | { 2 | "alfredtheme" : { 3 | "result" : { 4 | "textSpacing" : 4, 5 | "subtext" : { 6 | "size" : 11, 7 | "colorSelected" : "#FFFFFFCC", 8 | "font" : "System", 9 | "color" : "#FFFFFF66" 10 | }, 11 | "shortcut" : { 12 | "size" : 14, 13 | "colorSelected" : "#FFFFFFFF", 14 | "font" : "System", 15 | "color" : "#FEFFFF66" 16 | }, 17 | "backgroundSelected" : "#A5A5A586", 18 | "text" : { 19 | "size" : 15, 20 | "colorSelected" : "#FFFFFFFF", 21 | "font" : "System", 22 | "color" : "#FFFFFFD8" 23 | }, 24 | "iconPaddingHorizontal" : 7, 25 | "paddingVertical" : 3, 26 | "iconSize" : 40 27 | }, 28 | "search" : { 29 | "paddingVertical" : 7, 30 | "background" : "#24242400", 31 | "spacing" : 4, 32 | "text" : { 33 | "size" : 34, 34 | "colorSelected" : "#000000FF", 35 | "font" : "System Light", 36 | "color" : "#FFFFFFFF" 37 | }, 38 | "backgroundSelected" : "#B2D7FFFF" 39 | }, 40 | "window" : { 41 | "color" : "#242424CD", 42 | "paddingHorizontal" : 0, 43 | "width" : 604, 44 | "borderPadding" : 0, 45 | "borderColor" : "#0000007F", 46 | "blur" : 25, 47 | "roundness" : 9, 48 | "paddingVertical" : 4 49 | }, 50 | "credit" : "Arthur Hammer", 51 | "separator" : { 52 | "color" : "#CBCBCB00", 53 | "thickness" : 4 54 | }, 55 | "scrollbar" : { 56 | "color" : "#6D6D6DFF", 57 | "thickness" : 2 58 | }, 59 | "name" : "Dark" 60 | } 61 | } -------------------------------------------------------------------------------- /help/src/vendor/workflow/LICENSE.txt: -------------------------------------------------------------------------------- 1 | All Python source code is under the MIT Licence. 2 | 3 | The documentation, in particular the tutorials, are under the 4 | Creative Commons Attribution-NonCommercial (CC BY-NC) licence. 5 | 6 | --------------------------------------------------------------------- 7 | 8 | The MIT License (MIT) 9 | 10 | Copyright (c) 2014 Dean Jackson 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | 30 | --------------------------------------------------------------------- 31 | 32 | Creative Commons Attribution-NonCommercial (CC BY-NC) licence 33 | 34 | https://creativecommons.org/licenses/by-nc/4.0/legalcode 35 | 36 | (This one's quite long.) 37 | -------------------------------------------------------------------------------- /help/src/help.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # https://github.com/arthurhammer/alfred-workflows 4 | 5 | """Main entry point for the Help Alfred Workflow.""" 6 | 7 | from __future__ import print_function, unicode_literals 8 | import sys 9 | 10 | import feedback 11 | import util 12 | from workflow_objects import Workflow 13 | 14 | from vendor.workflow import Workflow3 as AlfredWorkflow 15 | 16 | 17 | def read_workflows(): 18 | workflows = [] 19 | for dirpath in util.workflows_dirpaths(): 20 | try: 21 | workflow = Workflow.from_dir(dirpath) 22 | except Exception as e: 23 | msg = 'Couldn\'t read workflow from directory {}. Reason: {}\nSkipping.\n' 24 | sys.stderr.write(msg.format(dirpath, e)) 25 | continue 26 | workflows.append((dirpath, workflow)) 27 | return workflows 28 | 29 | 30 | def from_cache(alfred): 31 | return alfred.cached_data('workflows', read_workflows, max_age=60) 32 | 33 | 34 | def main(): 35 | # Args 36 | args = sys.argv 37 | title_pref = args[1] if (len(args) == 2) else None 38 | if title_pref not in ['keyword', 'title']: 39 | title_pref = 'keyword' 40 | 41 | include_disabled = False 42 | 43 | # Get workflows 44 | alfred = AlfredWorkflow() 45 | workflows = from_cache(alfred) 46 | workflows.sort(key=lambda p: p[1].name) 47 | 48 | # Massage into feedback 49 | for dirpath, workflow in workflows: 50 | if include_disabled or not workflow.disabled: 51 | items = feedback.items(workflow, 52 | dirpath=dirpath, 53 | title_pref=title_pref) 54 | for item in items: 55 | alfred.add_item(**item) 56 | 57 | alfred.send_feedback() 58 | 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /help/README.md: -------------------------------------------------------------------------------- 1 | ## Help 2 | 3 | The Help Page for your Alfred Workflows. 4 | 5 | Forgetting which keywords and shortcuts are available in your Alfred workflows, like I do? Type `help`. 6 | 7 | ![Screenshot](screenshot.gif) 8 | 9 | ***Note**: This workflow was built for Alfred 3. The new Alfred 4 finally comes with a [feature very similar to this](https://www.alfredapp.com/whats-new/).* 10 | 11 | 12 | ### Usage 13 | 14 | help 📖 List available commands 15 | help 🔍 Filter available commands 16 | enter 🚀 Execute the selected command 17 | 18 | More: 19 | 20 | `help` lists commands by keywords and shortcuts, `helptitle` lists commands by title. Customize the keywords in the variables section in Alfred Preferences. 21 | 22 | ### Install 23 | 24 | [Download and open `Help.alfredworkflow`](Help.alfredworkflow?raw=true). 25 | 26 | ### Improvements 27 | 28 | Ideas for improvements, just for reference. The workflow is currently working great for me so I don't have plans to necessarily implement these. 29 | 30 | - [ ] Auto-update 31 | - [ ] Option to include/exclude disabled workflows 32 | - [ ] Execute hotkey on enter 33 | - [ ] Search by workflow name, title, subtitle 34 | 35 | ### Related 36 | 37 | If this workflow isn't for you, Jaemok Jeong's `Manage Alfred Extension` might be: [Packal](http://www.packal.org/workflow/manage-alfred-extension-0), [GitHub](https://github.com/jmjeong/alfred-extension). It's geared a bit more towards workflow developers than users. 38 | 39 | ### License 40 | 41 | - Main Code: [MIT](../LICENSE) 42 | - Code in `src/vendor/workflow`: [MIT](src/vendor/workflow/LICENSE.txt) 43 | - `src/icon.png`: [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) — [Blue Help Icon](http://www.softicons.com/toolbar-icons/help-icons-by-kyo-tux/blue-help-icon) by [kyo-tux](http://www.softicons.com/designers/kyo-tux) 44 | -------------------------------------------------------------------------------- /help/src/feedback.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Creates Alfred feedback items from workflows.""" 4 | 5 | from __future__ import print_function, unicode_literals 6 | import util 7 | 8 | 9 | def items(workflow, dirpath, title_pref='keyword'): 10 | """List of feedback item dicts for a workflow. 11 | 12 | If `title_pref` is `keyword`, keywords and hotkeys will be displayed as 13 | titles in Alfred. Otherwise the titles of the objects themselves will be 14 | used. 15 | 16 | `dirpath` is the directory the workflow was read from. 17 | 18 | """ 19 | def item(obj): 20 | """Feedback item dict for a single workflow object.""" 21 | title, subtitle = titles(obj) 22 | icon = iconpath(obj) 23 | valid = (obj.keyword is not None) and (not workflow.disabled) 24 | arg_whitespace = ' ' if obj.withspace else '' 25 | arg = (obj.keyword + arg_whitespace) if obj.keyword else '' 26 | 27 | # Replace `{var:}` placeholders 28 | title = sub(title) 29 | subtitle = sub(subtitle) 30 | arg = sub(arg) 31 | 32 | return { 33 | 'title': title, 34 | 'subtitle': subtitle, 35 | 'icon': icon, 36 | 'valid': valid, 37 | 'arg': arg, 38 | 'autocomplete': title, 39 | 'copytext': title, 40 | 'largetext': title 41 | } 42 | 43 | def iconpath(obj): 44 | return util.iconpath(obj, workflow, dirpath=dirpath) 45 | 46 | def sub(s): 47 | return util.substitute(s, workflow.variables) 48 | 49 | def titles(obj): 50 | keyword = obj.keyword or obj.full_hotkey 51 | if title_pref == 'keyword': 52 | titles = (keyword, obj.maintext, obj.subtext) 53 | else: 54 | titles = (obj.maintext, keyword, obj.subtext) 55 | title = titles[0] or workflow.name or '' 56 | if titles[1] and titles[2]: 57 | subtitle = '{} | {}'.format(titles[1], titles[2]) 58 | else: 59 | subtitle = titles[1] or titles[2] or workflow.name or '' 60 | return title, subtitle 61 | 62 | # Interested only in keywords and hotkeys 63 | objects = [obj for obj in workflow.objects if obj.keyword or obj.hotstring] 64 | return map(item, objects) 65 | -------------------------------------------------------------------------------- /help/src/vendor/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 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 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Workflow', 71 | 'Workflow3', 72 | 'manager', 73 | 'PasswordNotFound', 74 | 'KeychainError', 75 | 'ICON_ACCOUNT', 76 | 'ICON_BURN', 77 | 'ICON_CLOCK', 78 | 'ICON_COLOR', 79 | 'ICON_COLOUR', 80 | 'ICON_EJECT', 81 | 'ICON_ERROR', 82 | 'ICON_FAVORITE', 83 | 'ICON_FAVOURITE', 84 | 'ICON_GROUP', 85 | 'ICON_HELP', 86 | 'ICON_HOME', 87 | 'ICON_INFO', 88 | 'ICON_NETWORK', 89 | 'ICON_NOTE', 90 | 'ICON_SETTINGS', 91 | 'ICON_SWIRL', 92 | 'ICON_SWITCH', 93 | 'ICON_SYNC', 94 | 'ICON_TRASH', 95 | 'ICON_USER', 96 | 'ICON_WARNING', 97 | 'ICON_WEB', 98 | 'MATCH_ALL', 99 | 'MATCH_ALLCHARS', 100 | 'MATCH_ATOM', 101 | 'MATCH_CAPITALS', 102 | 'MATCH_INITIALS', 103 | 'MATCH_INITIALS_CONTAIN', 104 | 'MATCH_INITIALS_STARTSWITH', 105 | 'MATCH_STARTSWITH', 106 | 'MATCH_SUBSTRING', 107 | ] 108 | -------------------------------------------------------------------------------- /help/src/util.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Utilities.""" 4 | 5 | from __future__ import print_function, unicode_literals 6 | import os 7 | import re 8 | import sys 9 | 10 | 11 | # Paths & Files 12 | 13 | def workflows_dirpaths(): 14 | """List of paths of all installed Alfred workflows.""" 15 | prefs = os.environ['alfred_preferences'] # Set by Alfred 16 | workflows = os.path.join(prefs, 'workflows') 17 | return _subdirpaths(workflows) 18 | 19 | 20 | def iconpath(wf_obj, wf, dirpath=''): 21 | """The path to the icon for the workflow object `wf_obj` in `dirpath`. 22 | 23 | If the path doesn't exist, falls back to the icon of the parent workflow 24 | `wf` or the default workflow icon. 25 | 26 | """ 27 | def p(x): 28 | return os.path.join(dirpath, x) 29 | 30 | paths = [p(wf_obj.icon), p(wf.icon), workflow_default_iconpath()] 31 | for p in paths: 32 | if os.path.isfile(p): 33 | return p 34 | return None 35 | 36 | 37 | def workflow_default_iconpath(alfred_path='/Applications/Alfred 3.app'): 38 | """The path to Alfred's default workflow icon. 39 | 40 | Can't bundle cuz copyright. 41 | 42 | Since this is hard-coded, this is most likely not valid if: 43 | - Alfred 4 comes out 44 | - User installed Alfred in a custom location 45 | - Alfred changes the icon's location 46 | 47 | """ 48 | icon = ('Contents/Frameworks/Alfred Framework.framework/Versions/A/' 49 | 'Resources/workflow_default.png') 50 | return os.path.join(alfred_path, icon) 51 | 52 | 53 | # Variables 54 | 55 | def substitute(string, vars): 56 | """Return a copy of `string` where Alfred's variable placeholders 57 | `{var:}` in `replacements` are replaced by their values. 58 | 59 | """ 60 | if not vars: 61 | return string 62 | 63 | var = '{{var:{}}}' 64 | vars = _map_keys(lambda k: var.format(k), vars) 65 | return _replace(string, vars) 66 | 67 | 68 | # Util^2 69 | 70 | def _subdirpaths(dirpath): 71 | """List of paths for `dirpath`'s subdirectories.""" 72 | for _, dirnames, _ in os.walk(dirpath): 73 | # Return immediately 74 | return [os.path.join(dirpath, d) for d in dirnames] 75 | return [] 76 | 77 | 78 | def _replace(string, replacements): 79 | """Return a copy of `string` where all occurrences of keys in 80 | `replacements` are replaced by their values. 81 | 82 | With help from: http://stackoverflow.com/a/15175239/4994382 83 | 84 | """ 85 | def sub(match): 86 | m = match.string[match.start(): match.end()] 87 | return replacements[m] 88 | 89 | if not replacements: 90 | return string 91 | 92 | # Regex disjunction 93 | disj = map(re.escape, replacements.keys()) 94 | disj = '|'.join(disj) 95 | disj = '({})'.format(disj) 96 | 97 | regex = re.compile(disj) 98 | return regex.sub(sub, string) 99 | 100 | 101 | def _map_keys(function, d): 102 | """Return a dictionary where keys from `d` are mapped using `function`.""" 103 | return {function(k): v for k, v in d.iteritems()} 104 | -------------------------------------------------------------------------------- /help/src/workflow_objects.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Wrappers around workflow `info.plist` files.""" 4 | 5 | from __future__ import print_function, unicode_literals 6 | import os 7 | import plistlib 8 | 9 | # PyObjC 10 | from AppKit import NSCommandKeyMask, NSAlternateKeyMask, \ 11 | NSControlKeyMask, NSShiftKeyMask 12 | 13 | 14 | class Workflow(object): 15 | """Thin wrapper around a workflow `info.plist` file. 16 | 17 | Does not support all keys. 18 | 19 | """ 20 | def __init__(self, plist): 21 | """`plist` is a dictionary as read from a workflow `info.plist` file.""" 22 | self._store = plist 23 | 24 | @classmethod 25 | def from_dir(cls, dirpath): 26 | """Read a workflow from a directory, passing on any `plistlib` error.""" 27 | return cls.from_plist(os.path.join(dirpath, 'info.plist')) 28 | 29 | @classmethod 30 | def from_plist(cls, path): 31 | """Read a workflow from a plist file, passing on any `plistlib` error.""" 32 | return cls(plistlib.readPlist(path)) 33 | 34 | # Keys 35 | 36 | @property 37 | def name(self): 38 | return self._store.get('name') 39 | 40 | @property 41 | def disabled(self): 42 | return self._store.get('disabled', False) 43 | 44 | @property 45 | def objects(self): 46 | objects = self._store.get('objects', []) 47 | return map(WorkflowObject, objects) 48 | 49 | @property 50 | def variables(self): 51 | return self._store.get('variables', {}) 52 | 53 | # Computed 54 | 55 | @property 56 | def icon(self): 57 | return 'icon.png' 58 | 59 | def __repr__(self): 60 | pattern = '{}(name=\'{}\', objects=\'{}\')' 61 | return pattern.format(type(self).__name__, 62 | self.name, 63 | self.objects) 64 | 65 | 66 | class WorkflowObject(object): 67 | """Thin wrapper around a single workflow object as read from a 68 | `info.plist` file. 69 | 70 | Does not support all keys. 71 | 72 | """ 73 | def __init__(self, plist_obj): 74 | self._store = plist_obj 75 | 76 | # Top-level keys 77 | 78 | @property 79 | def uid(self): 80 | return self._store.get('uid') 81 | 82 | @property 83 | def type(self): 84 | return self._store.get('type') 85 | 86 | @property 87 | def config(self): 88 | return self._store.get('config', {}) 89 | 90 | # Flattened config keys 91 | 92 | @property 93 | def title(self): 94 | return self.config.get('title') 95 | 96 | @property 97 | def text(self): 98 | return self.config.get('text') 99 | 100 | @property 101 | def subtext(self): 102 | return self.config.get('subtext') 103 | 104 | @property 105 | def keyword(self): 106 | return self.config.get('keyword') 107 | 108 | @property 109 | def withspace(self): 110 | return self.config.get('withspace') 111 | 112 | @property 113 | def hotstring(self): 114 | return self.config.get('hotstring') 115 | 116 | @property 117 | def hotmod(self): 118 | return self.config.get('hotmod') 119 | 120 | # Computed 121 | 122 | @property 123 | def maintext(self): 124 | return self.title or self.text 125 | 126 | @property 127 | def icon(self): 128 | return self.uid + '.png' 129 | 130 | @property 131 | def full_hotkey(self): 132 | if self.hotstring is None: 133 | return None 134 | 135 | mods = '' 136 | 137 | # In the order Alfred displays them 138 | if self._has_keymask(NSControlKeyMask): 139 | mods += '⌃' 140 | if self._has_keymask(NSAlternateKeyMask): 141 | mods += '⌥' 142 | if self._has_keymask(NSShiftKeyMask): 143 | mods += '⇧' 144 | if self._has_keymask(NSCommandKeyMask): 145 | mods += '⌘' 146 | 147 | return mods + self.hotstring 148 | 149 | def _has_keymask(self, mask): 150 | return _has_mask(self.hotmod or 0, mask) 151 | 152 | def __repr__(self): 153 | return '{}(\'{}\')'.format(type(self).__name__, 154 | str(self._store)) 155 | 156 | 157 | # Util 158 | 159 | def _has_mask(flags, mask): 160 | return flags & mask != 0 161 | -------------------------------------------------------------------------------- /help/src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | me.ahammer.help 7 | category 8 | Tools 9 | connections 10 | 11 | 5A2A1B03-7834-4BBB-970F-C0B91ED66294 12 | 13 | 14 | destinationuid 15 | CBB7F5A8-E368-4DB6-A486-40D968365EF2 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 63B54A8D-FCC5-4D1F-B32D-6D53ABDD7620 25 | 26 | 27 | destinationuid 28 | CBB7F5A8-E368-4DB6-A486-40D968365EF2 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 38 | createdby 39 | Arthur Hammer 40 | description 41 | Shows available Workflow commands 👍 42 | disabled 43 | 44 | name 45 | Help 46 | objects 47 | 48 | 49 | config 50 | 51 | alfredfiltersresults 52 | 53 | argumenttype 54 | 1 55 | escaping 56 | 102 57 | keyword 58 | {var:keywordkeyword} 59 | queuedelaycustom 60 | 3 61 | queuedelayimmediatelyinitially 62 | 63 | queuedelaymode 64 | 1 65 | queuemode 66 | 1 67 | runningsubtext 68 | Reading... 69 | script 70 | python help.py "keyword" 71 | scriptargtype 72 | 1 73 | scriptfile 74 | 75 | subtext 76 | Show all Workflow commands by keywords & hotkeys 77 | title 78 | Help 79 | type 80 | 0 81 | withspace 82 | 83 | 84 | type 85 | alfred.workflow.input.scriptfilter 86 | uid 87 | 63B54A8D-FCC5-4D1F-B32D-6D53ABDD7620 88 | version 89 | 2 90 | 91 | 92 | config 93 | 94 | applescript 95 | on alfred_script(q) 96 | tell application "Alfred 3" to search q 97 | end alfred_script 98 | cachescript 99 | 100 | 101 | type 102 | alfred.workflow.action.applescript 103 | uid 104 | CBB7F5A8-E368-4DB6-A486-40D968365EF2 105 | version 106 | 1 107 | 108 | 109 | config 110 | 111 | alfredfiltersresults 112 | 113 | argumenttype 114 | 1 115 | escaping 116 | 102 117 | keyword 118 | {var:keywordtitle} 119 | queuedelaycustom 120 | 3 121 | queuedelayimmediatelyinitially 122 | 123 | queuedelaymode 124 | 1 125 | queuemode 126 | 1 127 | runningsubtext 128 | Reading... 129 | script 130 | python help.py "title" 131 | scriptargtype 132 | 1 133 | scriptfile 134 | 135 | subtext 136 | Show all Workflow commands by titles 137 | title 138 | Help 139 | type 140 | 0 141 | withspace 142 | 143 | 144 | type 145 | alfred.workflow.input.scriptfilter 146 | uid 147 | 5A2A1B03-7834-4BBB-970F-C0B91ED66294 148 | version 149 | 2 150 | 151 | 152 | readme 153 | # Help 154 | 155 | The Help Page for your Alfred Workflows. 156 | 157 | Forgetting which keywords and shortcuts are available in your Alfred workflows, like I do? Type `help`. 158 | 159 | Usage: 160 | 161 | help 📖 List available commands 162 | help <query> 🔍 Filter available commands 163 | enter 🚀 Execute the selected command 164 | 165 | More: 166 | 167 | `help` lists commands by keywords and shortcuts, `helptitle` lists commands by title. Customize the keywords in the variables section in Alfred Preferences. 168 | 169 | --- 170 | 171 | https://github.com/arthurhammer/alfred-workflows 172 | uidata 173 | 174 | 5A2A1B03-7834-4BBB-970F-C0B91ED66294 175 | 176 | xpos 177 | 100 178 | ypos 179 | 180 180 | 181 | 63B54A8D-FCC5-4D1F-B32D-6D53ABDD7620 182 | 183 | xpos 184 | 100 185 | ypos 186 | 40 187 | 188 | CBB7F5A8-E368-4DB6-A486-40D968365EF2 189 | 190 | xpos 191 | 330 192 | ypos 193 | 100 194 | 195 | 196 | variables 197 | 198 | keywordkeyword 199 | help 200 | keywordtitle 201 | helptitle 202 | 203 | version 204 | 0.0.1 205 | webaddress 206 | https://github.com/arthurhammer/alfred-workflows 207 | 208 | 209 | -------------------------------------------------------------------------------- /help/src/vendor/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """ 12 | Run background tasks 13 | """ 14 | 15 | from __future__ import print_function, unicode_literals 16 | 17 | import sys 18 | import os 19 | import subprocess 20 | import pickle 21 | 22 | from workflow import Workflow 23 | 24 | __all__ = ['is_running', 'run_in_background'] 25 | 26 | _wf = None 27 | 28 | 29 | def wf(): 30 | global _wf 31 | if _wf is None: 32 | _wf = Workflow() 33 | return _wf 34 | 35 | 36 | def _arg_cache(name): 37 | """Return path to pickle cache file for arguments 38 | 39 | :param name: name of task 40 | :type name: ``unicode`` 41 | :returns: Path to cache file 42 | :rtype: ``unicode`` filepath 43 | 44 | """ 45 | 46 | return wf().cachefile('{0}.argcache'.format(name)) 47 | 48 | 49 | def _pid_file(name): 50 | """Return path to PID file for ``name`` 51 | 52 | :param name: name of task 53 | :type name: ``unicode`` 54 | :returns: Path to PID file for task 55 | :rtype: ``unicode`` filepath 56 | 57 | """ 58 | 59 | return wf().cachefile('{0}.pid'.format(name)) 60 | 61 | 62 | def _process_exists(pid): 63 | """Check if a process with PID ``pid`` exists 64 | 65 | :param pid: PID to check 66 | :type pid: ``int`` 67 | :returns: ``True`` if process exists, else ``False`` 68 | :rtype: ``Boolean`` 69 | """ 70 | 71 | try: 72 | os.kill(pid, 0) 73 | except OSError: # not running 74 | return False 75 | return True 76 | 77 | 78 | def is_running(name): 79 | """ 80 | Test whether task is running under ``name`` 81 | 82 | :param name: name of task 83 | :type name: ``unicode`` 84 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 85 | :rtype: ``Boolean`` 86 | 87 | """ 88 | pidfile = _pid_file(name) 89 | if not os.path.exists(pidfile): 90 | return False 91 | 92 | with open(pidfile, 'rb') as file_obj: 93 | pid = int(file_obj.read().strip()) 94 | 95 | if _process_exists(pid): 96 | return True 97 | 98 | elif os.path.exists(pidfile): 99 | os.unlink(pidfile) 100 | 101 | return False 102 | 103 | 104 | def _background(stdin='/dev/null', stdout='/dev/null', 105 | stderr='/dev/null'): # pragma: no cover 106 | """Fork the current process into a background daemon. 107 | 108 | :param stdin: where to read input 109 | :type stdin: filepath 110 | :param stdout: where to write stdout output 111 | :type stdout: filepath 112 | :param stderr: where to write stderr output 113 | :type stderr: filepath 114 | 115 | """ 116 | 117 | # Do first fork. 118 | try: 119 | pid = os.fork() 120 | if pid > 0: 121 | sys.exit(0) # Exit first parent. 122 | except OSError as e: 123 | wf().logger.critical("fork #1 failed: ({0:d}) {1}".format( 124 | e.errno, e.strerror)) 125 | sys.exit(1) 126 | # Decouple from parent environment. 127 | os.chdir(wf().workflowdir) 128 | os.umask(0) 129 | os.setsid() 130 | # Do second fork. 131 | try: 132 | pid = os.fork() 133 | if pid > 0: 134 | sys.exit(0) # Exit second parent. 135 | except OSError as e: 136 | wf().logger.critical("fork #2 failed: ({0:d}) {1}".format( 137 | e.errno, e.strerror)) 138 | sys.exit(1) 139 | # Now I am a daemon! 140 | # Redirect standard file descriptors. 141 | si = file(stdin, 'r', 0) 142 | so = file(stdout, 'a+', 0) 143 | se = file(stderr, 'a+', 0) 144 | if hasattr(sys.stdin, 'fileno'): 145 | os.dup2(si.fileno(), sys.stdin.fileno()) 146 | if hasattr(sys.stdout, 'fileno'): 147 | os.dup2(so.fileno(), sys.stdout.fileno()) 148 | if hasattr(sys.stderr, 'fileno'): 149 | os.dup2(se.fileno(), sys.stderr.fileno()) 150 | 151 | 152 | def run_in_background(name, args, **kwargs): 153 | """Pickle arguments to cache file, then call this script again via 154 | :func:`subprocess.call`. 155 | 156 | :param name: name of task 157 | :type name: ``unicode`` 158 | :param args: arguments passed as first argument to :func:`subprocess.call` 159 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 160 | :returns: exit code of sub-process 161 | :rtype: ``int`` 162 | 163 | When you call this function, it caches its arguments and then calls 164 | ``background.py`` in a subprocess. The Python subprocess will load the 165 | cached arguments, fork into the background, and then run the command you 166 | specified. 167 | 168 | This function will return as soon as the ``background.py`` subprocess has 169 | forked, returning the exit code of *that* process (i.e. not of the command 170 | you're trying to run). 171 | 172 | If that process fails, an error will be written to the log file. 173 | 174 | If a process is already running under the same name, this function will 175 | return immediately and will not run the specified command. 176 | 177 | """ 178 | 179 | if is_running(name): 180 | wf().logger.info('Task `{0}` is already running'.format(name)) 181 | return 182 | 183 | argcache = _arg_cache(name) 184 | 185 | # Cache arguments 186 | with open(argcache, 'wb') as file_obj: 187 | pickle.dump({'args': args, 'kwargs': kwargs}, file_obj) 188 | wf().logger.debug('Command arguments cached to `{0}`'.format(argcache)) 189 | 190 | # Call this script 191 | cmd = ['/usr/bin/python', __file__, name] 192 | wf().logger.debug('Calling {0!r} ...'.format(cmd)) 193 | retcode = subprocess.call(cmd) 194 | if retcode: # pragma: no cover 195 | wf().logger.error('Failed to call task in background') 196 | else: 197 | wf().logger.debug('Executing task `{0}` in background...'.format(name)) 198 | return retcode 199 | 200 | 201 | def main(wf): # pragma: no cover 202 | """ 203 | Load cached arguments, fork into background, then call 204 | :meth:`subprocess.call` with cached arguments 205 | 206 | """ 207 | 208 | name = wf.args[0] 209 | argcache = _arg_cache(name) 210 | if not os.path.exists(argcache): 211 | wf.logger.critical('No arg cache found : {0!r}'.format(argcache)) 212 | return 1 213 | 214 | # Load cached arguments 215 | with open(argcache, 'rb') as file_obj: 216 | data = pickle.load(file_obj) 217 | 218 | # Cached arguments 219 | args = data['args'] 220 | kwargs = data['kwargs'] 221 | 222 | # Delete argument cache file 223 | os.unlink(argcache) 224 | 225 | pidfile = _pid_file(name) 226 | 227 | # Fork to background 228 | _background() 229 | 230 | # Write PID to file 231 | with open(pidfile, 'wb') as file_obj: 232 | file_obj.write('{0}'.format(os.getpid())) 233 | 234 | # Run the command 235 | try: 236 | wf.logger.debug('Task `{0}` running'.format(name)) 237 | wf.logger.debug('cmd : {0!r}'.format(args)) 238 | 239 | retcode = subprocess.call(args, **kwargs) 240 | 241 | if retcode: 242 | wf.logger.error('Command failed with [{0}] : {1!r}'.format( 243 | retcode, args)) 244 | 245 | finally: 246 | if os.path.exists(pidfile): 247 | os.unlink(pidfile) 248 | wf.logger.debug('Task `{0}` finished'.format(name)) 249 | 250 | 251 | if __name__ == '__main__': # pragma: no cover 252 | wf().run(main) 253 | -------------------------------------------------------------------------------- /_scripts/workflow-build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2013 deanishe@deanishe.net. 5 | # https://gist.github.com/deanishe/b16f018119ef3fe951af 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2013-11-01 10 | # 11 | 12 | """workflow-build [options] 13 | 14 | Build Alfred Workflows. 15 | 16 | Compile contents of to a ZIP file (with extension 17 | `.alfredworkflow`). 18 | 19 | The name of the output file is generated from the workflow name, 20 | which is extracted from the workflow's `info.plist`. If a `version` 21 | file is contained within the workflow directory, it's contents 22 | will be appended to the compiled workflow's filename. 23 | 24 | Usage: 25 | workflow-build [-v|-q|-d] [-f] [-o ] ... 26 | workflow-build (-h|--version) 27 | 28 | Options: 29 | -o, --output= Directory to save workflow(s) to. 30 | Default is current working directory. 31 | -f, --force Overwrite existing files. 32 | -h, --help Show this message and exit. 33 | -V, --version Show version number and exit. 34 | -q, --quiet Only show errors and above. 35 | -v, --verbose Show info messages and above. 36 | -d, --debug Show debug messages. 37 | 38 | """ 39 | 40 | from __future__ import print_function 41 | 42 | import sys 43 | import os 44 | import logging 45 | import logging.handlers 46 | import plistlib 47 | from subprocess import check_call, CalledProcessError 48 | 49 | from docopt import docopt 50 | 51 | __version__ = "0.2" 52 | __author__ = "deanishe@deanishe.net" 53 | 54 | DEFAULT_LOG_LEVEL = logging.WARNING 55 | LOGPATH = os.path.expanduser('~/Library/Logs/MyScripts.log') 56 | LOGSIZE = 1024 * 1024 * 5 # 5 megabytes 57 | 58 | 59 | EXCLUDE_PATTERNS = [ 60 | '*.pyc', 61 | '*.log', 62 | '.DS_Store', 63 | '*.acorn', 64 | '*.swp', 65 | '*.sublime-project', 66 | '*.sublime-workflow', 67 | '*.git', 68 | '*.dist-info', 69 | ] 70 | 71 | 72 | class TechnicolorFormatter(logging.Formatter): 73 | """ 74 | Prepend level name to any message not level logging.INFO. 75 | 76 | Also, colour! 77 | 78 | """ 79 | 80 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 81 | 82 | RESET = "\033[0m" 83 | COLOUR_BASE = "\033[1;{:d}m" 84 | BOLD = "\033[1m" 85 | 86 | LEVEL_COLOURS = { 87 | logging.DEBUG: BLUE, 88 | logging.INFO: WHITE, 89 | logging.WARNING: YELLOW, 90 | logging.ERROR: MAGENTA, 91 | logging.CRITICAL: RED 92 | } 93 | 94 | def __init__(self, fmt=None, datefmt=None, technicolor=True): 95 | logging.Formatter.__init__(self, fmt, datefmt) 96 | self.technicolor = technicolor 97 | self._isatty = sys.stderr.isatty() 98 | 99 | def format(self, record): 100 | if record.levelno == logging.INFO: 101 | msg = logging.Formatter.format(self, record) 102 | return msg 103 | if self.technicolor and self._isatty: 104 | colour = self.LEVEL_COLOURS[record.levelno] 105 | bold = (False, True)[record.levelno > logging.INFO] 106 | levelname = self.colourise('{:9s}'.format(record.levelname), 107 | colour, bold) 108 | else: 109 | levelname = '{:9s}'.format(record.levelname) 110 | return (levelname + logging.Formatter.format(self, record)) 111 | 112 | def colourise(self, text, colour, bold=False): 113 | colour = self.COLOUR_BASE.format(colour + 30) 114 | output = [] 115 | if bold: 116 | output.append(self.BOLD) 117 | output.append(colour) 118 | output.append(text) 119 | output.append(self.RESET) 120 | return ''.join(output) 121 | 122 | 123 | # logfile 124 | logfile = logging.handlers.RotatingFileHandler(LOGPATH, maxBytes=LOGSIZE, 125 | backupCount=5) 126 | formatter = logging.Formatter( 127 | '%(asctime)s %(levelname)-8s [%(name)-12s] %(message)s', 128 | datefmt="%d/%m %H:%M:%S") 129 | logfile.setFormatter(formatter) 130 | logfile.setLevel(logging.DEBUG) 131 | 132 | # console output 133 | console = logging.StreamHandler() 134 | formatter = TechnicolorFormatter('%(message)s') 135 | console.setFormatter(formatter) 136 | console.setLevel(logging.DEBUG) 137 | 138 | log = logging.getLogger('') 139 | log.addHandler(logfile) 140 | log.addHandler(console) 141 | 142 | 143 | def safename(name): 144 | """Make name filesystem-safe.""" 145 | name = name.replace(u'/', u'-') 146 | name = name.replace(u':', u'-') 147 | return name 148 | 149 | 150 | def build_workflow(workflow_dir, outputdir, overwrite=False, verbose=False): 151 | """Create an .alfredworkflow file from the contents of `workflow_dir`.""" 152 | curdir = os.curdir 153 | os.chdir(workflow_dir) 154 | version = None 155 | if not os.path.exists(u'info.plist'): 156 | log.error(u'info.plist not found') 157 | return False 158 | 159 | if os.path.exists(u'version'): 160 | with open('version') as fp: 161 | version = fp.read().strip().decode('utf-8') 162 | 163 | name = safename(plistlib.readPlist(u'info.plist')[u'name']) 164 | zippath = os.path.join(outputdir, name) 165 | if version: 166 | zippath += u'-' + version 167 | zippath += u'.alfredworkflow' 168 | 169 | if os.path.exists(zippath): 170 | if overwrite: 171 | log.info(u'Overwriting existing workflow') 172 | os.unlink(zippath) 173 | else: 174 | log.error(u"File '{}' already exists. Use -f to overwrite".format( 175 | zippath)) 176 | return False 177 | 178 | # build workflow 179 | command = [u'zip'] 180 | if not verbose: 181 | command.append(u'-q') 182 | command.append(zippath) 183 | for root, dirnames, filenames in os.walk(u'.'): 184 | for filename in filenames: 185 | path = os.path.join(root, filename) 186 | command.append(path) 187 | command.append(u'-x') 188 | command.extend(EXCLUDE_PATTERNS) 189 | log.debug(u'command : {}'.format(u' '.join(command))) 190 | try: 191 | check_call(command) 192 | except CalledProcessError as err: 193 | log.error(u'zip returned : {}'.format(err.returncode)) 194 | os.chdir(curdir) 195 | return False 196 | log.info(u'Wrote {}'.format(zippath)) 197 | os.chdir(curdir) 198 | return True 199 | 200 | 201 | def main(args=None): 202 | """Run CLI.""" 203 | args = docopt(__doc__, version=__version__) 204 | 205 | if args.get('--verbose'): 206 | log.setLevel(logging.INFO) 207 | elif args.get('--quiet'): 208 | log.setLevel(logging.ERROR) 209 | elif args.get('--debug'): 210 | log.setLevel(logging.DEBUG) 211 | else: 212 | log.setLevel(DEFAULT_LOG_LEVEL) 213 | 214 | log.debug("Set log level to %s" % 215 | logging.getLevelName(log.level)) 216 | 217 | log.debug('args :\n{}'.format(args)) 218 | 219 | # Build options 220 | outputdir = os.path.abspath(args.get(u'--output') or os.curdir) 221 | workflow_dirs = [os.path.abspath(p) for p in args.get(u'')] 222 | log.debug(u'outputdir : {}, workflow_dirs : {}'.format(outputdir, 223 | workflow_dirs)) 224 | errors = False 225 | verbose = False 226 | if log.level == logging.DEBUG: 227 | verbose = True 228 | 229 | # Build workflow(s) 230 | for path in workflow_dirs: 231 | ok = build_workflow(path, outputdir, args.get(u'--force'), verbose) 232 | if not ok: 233 | errors = True 234 | if errors: 235 | return 1 236 | return 0 237 | 238 | 239 | if __name__ == '__main__': 240 | sys.exit(main(sys.argv[1:])) 241 | -------------------------------------------------------------------------------- /_scripts/workflow-install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2013 deanishe@deanishe.net. 5 | # https://gist.github.com/deanishe/35faae3e7f89f629a94e 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2013-11-01 10 | # 11 | 12 | """workflow-install [options] [...] 13 | 14 | Install Alfred workflow(s). 15 | 16 | You can specify where to install with -w or in ~/.workflow-install.json 17 | 18 | If is not specified, the script will search the 19 | current working directory recursively for a workflow (a directory 20 | containing an `info.plist` file). 21 | 22 | Usage: 23 | workflow-install [-v|-q|-d] [-s] [-w ] [...] 24 | workflow-install (-h|--help) 25 | 26 | Options: 27 | -s, --symlink Symlink workflow directory 28 | instead of copying it. 29 | -w, --workflows= Where to install workflows. 30 | -V, --version Show version number and exit. 31 | -h, --help Show this message and exit. 32 | -q, --quiet Show error messages and above. 33 | -v, --verbose Show info messages and above. 34 | -d, --debug Show debug messages. 35 | 36 | """ 37 | 38 | from __future__ import print_function, unicode_literals 39 | 40 | import sys 41 | import os 42 | import logging 43 | import logging.handlers 44 | import json 45 | import plistlib 46 | import shutil 47 | import subprocess 48 | 49 | __version__ = "0.1" 50 | __author__ = "deanishe@deanishe.net" 51 | 52 | 53 | log = None 54 | 55 | DEFAULT_LOG_LEVEL = logging.WARNING 56 | # LOGPATH = os.path.expanduser('~/Library/Logs/MyScripts.log') 57 | # LOGSIZE = 1024 * 1024 * 5 # 5 megabytes 58 | 59 | CONFIG_PATH = os.path.expanduser('~/.workflow-install.json') 60 | DEFAULT_CONFIG = dict(workflows_directory='') 61 | 62 | ALFRED_PREFS = os.path.expanduser( 63 | '~/Library/Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist') 64 | 65 | 66 | class TechnicolorFormatter(logging.Formatter): 67 | """ 68 | Prepend level name to any message not level logging.INFO. 69 | 70 | Also, colour! 71 | 72 | """ 73 | 74 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 75 | 76 | RESET = "\033[0m" 77 | COLOUR_BASE = "\033[1;{:d}m" 78 | BOLD = "\033[1m" 79 | 80 | LEVEL_COLOURS = { 81 | logging.DEBUG: BLUE, 82 | logging.INFO: WHITE, 83 | logging.WARNING: YELLOW, 84 | logging.ERROR: MAGENTA, 85 | logging.CRITICAL: RED 86 | } 87 | 88 | def __init__(self, fmt=None, datefmt=None, technicolor=True): 89 | logging.Formatter.__init__(self, fmt, datefmt) 90 | self.technicolor = technicolor 91 | self._isatty = sys.stderr.isatty() 92 | 93 | def format(self, record): 94 | if record.levelno == logging.INFO: 95 | msg = logging.Formatter.format(self, record) 96 | return msg 97 | if self.technicolor and self._isatty: 98 | colour = self.LEVEL_COLOURS[record.levelno] 99 | bold = (False, True)[record.levelno > logging.INFO] 100 | levelname = self.colourise('{:9s}'.format(record.levelname), 101 | colour, bold) 102 | else: 103 | levelname = '{:9s}'.format(record.levelname) 104 | return (levelname + logging.Formatter.format(self, record)) 105 | 106 | def colourise(self, text, colour, bold=False): 107 | colour = self.COLOUR_BASE.format(colour + 30) 108 | output = [] 109 | if bold: 110 | output.append(self.BOLD) 111 | output.append(colour) 112 | output.append(text) 113 | output.append(self.RESET) 114 | return ''.join(output) 115 | 116 | 117 | # logfile 118 | # logfile = logging.handlers.RotatingFileHandler(LOGPATH, maxBytes=LOGSIZE, 119 | # backupCount=5) 120 | # formatter = logging.Formatter( 121 | # '%(asctime)s %(levelname)-8s [%(name)-12s] %(message)s', 122 | # datefmt="%d/%m %H:%M:%S") 123 | # logfile.setFormatter(formatter) 124 | # logfile.setLevel(logging.DEBUG) 125 | 126 | # console output 127 | console = logging.StreamHandler() 128 | formatter = TechnicolorFormatter('%(message)s') 129 | console.setFormatter(formatter) 130 | console.setLevel(logging.DEBUG) 131 | 132 | log = logging.getLogger('') 133 | # log.addHandler(logfile) 134 | log.addHandler(console) 135 | 136 | 137 | def read_plist(path): 138 | """Convert plist to XML and read its contents.""" 139 | cmd = [b'plutil', b'-convert', b'xml1', b'-o', b'-', path] 140 | xml = subprocess.check_output(cmd) 141 | return plistlib.readPlistFromString(xml) 142 | 143 | 144 | def get_workflow_directory(): 145 | """Return path to Alfred's workflow directory.""" 146 | prefs = read_plist(ALFRED_PREFS) 147 | syncdir = prefs.get('syncfolder') 148 | 149 | if not syncdir: 150 | log.debug('Alfred sync folder not found') 151 | return None 152 | 153 | syncdir = os.path.expanduser(syncdir) 154 | wf_dir = os.path.join(syncdir, 'Alfred.alfredpreferences/workflows') 155 | log.debug('Workflow sync dir : %r', wf_dir) 156 | 157 | if os.path.exists(wf_dir): 158 | log.debug('Workflow directory retrieved from Alfred preferences') 159 | return wf_dir 160 | 161 | log.debug('Alfred.alfredpreferences/workflows not found') 162 | return None 163 | 164 | 165 | def find_workflow_dir(dirpath): 166 | """Recursively search `dirpath` for a workflow. 167 | 168 | A workflow is a directory containing an `info.plist` file. 169 | 170 | """ 171 | for root, dirnames, filenames in os.walk(dirpath): 172 | if 'info.plist' in filenames: 173 | log.debug('Workflow found at %r', root) 174 | return root 175 | 176 | return None 177 | 178 | 179 | def printable_path(dirpath): 180 | """Replace $HOME with ~.""" 181 | return dirpath.replace(os.getenv('HOME'), '~') 182 | 183 | 184 | def load_config(): 185 | """Load configuration from file.""" 186 | if not os.path.exists(CONFIG_PATH): 187 | with open(CONFIG_PATH, 'wb') as file: 188 | json.dump(DEFAULT_CONFIG, file) 189 | return DEFAULT_CONFIG 190 | 191 | with open(CONFIG_PATH) as file: 192 | return json.load(file) 193 | 194 | 195 | def install_workflow(workflow_dir, install_base, symlink=False): 196 | """Install workflow at `workflow_dir` under directory `install_base`.""" 197 | if symlink: 198 | log.debug("Linking workflow at {!r} to {!r}".format( 199 | workflow_dir, install_base)) 200 | else: 201 | log.debug("Installing workflow at {!r} to {!r}".format( 202 | workflow_dir, install_base)) 203 | 204 | infopath = os.path.join(workflow_dir, 'info.plist') 205 | if not os.path.exists(infopath): 206 | log.error('info.plist not found : {}'.format(infopath)) 207 | return False 208 | 209 | info = plistlib.readPlist(infopath) 210 | name = info['name'] 211 | bundleid = info['bundleid'] 212 | 213 | if not bundleid: 214 | log.error('Bundle ID is not set : %s', infopath) 215 | return False 216 | 217 | install_path = os.path.join(install_base, bundleid) 218 | 219 | action = ('Installing', 'Linking')[symlink] 220 | log.info('%s workflow `%s` to `%s` ...', 221 | action, name, printable_path(install_path)) 222 | 223 | # delete existing workflow 224 | if os.path.exists(install_path) or os.path.lexists(install_path): 225 | 226 | log.info('Deleting existing workflow ...') 227 | 228 | if os.path.islink(install_path) or os.path.isfile(install_path): 229 | os.unlink(install_path) 230 | elif os.path.isdir(install_path): 231 | log.info('Directory : {}'.format(install_path)) 232 | shutil.rmtree(install_path) 233 | else: 234 | log.info('Something else : {}'.format(install_path)) 235 | os.unlink(install_path) 236 | 237 | # Symlink or copy workflow to destination 238 | if symlink: 239 | relpath = os.path.relpath(workflow_dir, os.path.dirname(install_path)) 240 | log.debug('relative path : %r', relpath) 241 | os.symlink(relpath, install_path) 242 | else: 243 | shutil.copytree(workflow_dir, install_path) 244 | 245 | return True 246 | 247 | 248 | def main(args=None): 249 | """Run program.""" 250 | from docopt import docopt 251 | args = docopt(__doc__, version=__version__) 252 | 253 | if args.get('--verbose'): 254 | log.setLevel(logging.INFO) 255 | elif args.get('--quiet'): 256 | log.setLevel(logging.ERROR) 257 | elif args.get('--debug'): 258 | log.setLevel(logging.DEBUG) 259 | else: 260 | log.setLevel(DEFAULT_LOG_LEVEL) 261 | 262 | log.debug("Set log level to %s" % 263 | logging.getLevelName(log.level)) 264 | 265 | log.debug('args : \n{}'.format(args)) 266 | 267 | workflows_directory = (args.get('--workflows') or 268 | get_workflow_directory() or 269 | load_config().get('workflows_directory')) 270 | if not workflows_directory: 271 | log.error("You didn't specify where to install the workflow(s).\n" 272 | "Try -w workflow/install/path or -h for more info.") 273 | return 1 274 | workflows_directory = os.path.expanduser(workflows_directory) 275 | 276 | # Ensure workflows_directory is Unicode 277 | if not isinstance(workflows_directory, unicode): 278 | workflows_directory = unicode(workflows_directory, 'utf-8') 279 | 280 | workflow_paths = args.get('') 281 | 282 | if not workflow_paths: 283 | cwd = os.getcwd() 284 | wfdir = find_workflow_dir(cwd) 285 | if not wfdir: 286 | log.critical('No workflow found under %r', cwd) 287 | return 1 288 | workflow_paths = [wfdir] 289 | errors = False 290 | 291 | for path in workflow_paths: 292 | if not isinstance(path, unicode): 293 | path = unicode(path, 'utf-8') 294 | path = os.path.abspath(path) 295 | if not os.path.exists(path): 296 | log.error('Directory does not exist : {}'.format(path)) 297 | continue 298 | if not os.path.isdir(path): 299 | log.error('Not a directory : {}'.format(path)) 300 | continue 301 | if not install_workflow(path, workflows_directory, 302 | args.get('--symlink')): 303 | errors = True 304 | 305 | if errors: 306 | return 1 307 | return 0 308 | 309 | if __name__ == '__main__': 310 | sys.exit(main(sys.argv[1:])) 311 | -------------------------------------------------------------------------------- /help/src/vendor/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 OS X Notification Center. This feature 15 | is only available on Mountain Lion (10.8) and later. It will 16 | silently fail on older systems. 17 | 18 | The main API is a single function, :func:`~workflow.notify.notify`. 19 | 20 | It works by copying a simple application to your workflow's data 21 | directory. It replaces the application's icon with your workflow's 22 | icon and then calls the application to post notifications. 23 | """ 24 | 25 | from __future__ import print_function, unicode_literals 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | 36 | import workflow 37 | 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | 'Basso', 46 | 'Blow', 47 | 'Bottle', 48 | 'Frog', 49 | 'Funk', 50 | 'Glass', 51 | 'Hero', 52 | 'Morse', 53 | 'Ping', 54 | 'Pop', 55 | 'Purr', 56 | 'Sosumi', 57 | 'Submarine', 58 | 'Tink', 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return `Workflow` object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: `Workflow` object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app `applet` executable. 91 | """ 92 | return wf().datafile('Notify.app/Contents/MacOS/applet') 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to `applet.icns` within the app bundle. 100 | """ 101 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 102 | 103 | 104 | def install_notifier(): 105 | """Extract `Notify.app` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, 'Notify.app') 113 | n = notifier_program() 114 | log().debug("Installing Notify.app to %r ...", destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, 'r:gz') 118 | tgz.extractall(destdir) 119 | assert os.path.exists(n), ( 120 | "Notify.app could not be installed in {0!r}.".format(destdir)) 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile('icon.png') 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSWorkspace, NSImage 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 145 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.readPlist(ip_path) 147 | log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) 148 | data['CFBundleIdentifier'] = bundle_id 149 | plistlib.writePlist(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce `sound` to valid sound name. 154 | 155 | Returns `None` for invalid sounds. Sound names can be found 156 | in `System Preferences > Sound > Sound Effects`. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or `None`. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of OS X 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title='', text='', sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both `title` and `text` are empty. 184 | 185 | Returns: 186 | bool: `True` if notification was posted, else `False`. 187 | """ 188 | if title == text == '': 189 | raise ValueError('Empty notification') 190 | 191 | sound = validate_sound(sound) or '' 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = 'utf-8' 200 | env['NOTIFY_TITLE'] = title.encode(enc) 201 | env['NOTIFY_MESSAGE'] = text.encode(enc) 202 | env['NOTIFY_SOUND'] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error('Notify.app exited with status {0}.'.format(retcode)) 209 | return False 210 | 211 | 212 | def convert_image(inpath, outpath, size): 213 | """Convert an image file using `sips`. 214 | 215 | Args: 216 | inpath (str): Path of source file. 217 | outpath (str): Path to destination file. 218 | size (int): Width and height of destination image in pixels. 219 | 220 | Raises: 221 | RuntimeError: Raised if `sips` exits with non-zero status. 222 | """ 223 | cmd = [ 224 | b'sips', 225 | b'-z', b'{0}'.format(size), b'{0}'.format(size), 226 | inpath, 227 | b'--out', outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, 'w') as pipe: 230 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 231 | 232 | if retcode != 0: 233 | raise RuntimeError('sips exited with {0}'.format(retcode)) 234 | 235 | 236 | def png_to_icns(png_path, icns_path): 237 | """Convert PNG file to ICNS using `iconutil`. 238 | 239 | Create an iconset from the source PNG file. Generate PNG files 240 | in each size required by OS X, then call `iconutil` to turn 241 | them into a single ICNS file. 242 | 243 | Args: 244 | png_path (str): Path to source PNG file. 245 | icns_path (str): Path to destination ICNS file. 246 | 247 | Raises: 248 | RuntimeError: Raised if `iconutil` or `sips` fail. 249 | """ 250 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 251 | 252 | try: 253 | iconset = os.path.join(tempdir, 'Icon.iconset') 254 | 255 | assert not os.path.exists(iconset), ( 256 | "Iconset path already exists : {0!r}".format(iconset)) 257 | os.makedirs(iconset) 258 | 259 | # Copy source icon to icon set and generate all the other 260 | # sizes needed 261 | configs = [] 262 | for i in (16, 32, 128, 256, 512): 263 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 264 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) 265 | 266 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 267 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 268 | 269 | for name, size in configs: 270 | outpath = os.path.join(iconset, name) 271 | if os.path.exists(outpath): 272 | continue 273 | convert_image(png_path, outpath, size) 274 | 275 | cmd = [ 276 | b'iconutil', 277 | b'-c', b'icns', 278 | b'-o', icns_path, 279 | iconset] 280 | 281 | retcode = subprocess.call(cmd) 282 | if retcode != 0: 283 | raise RuntimeError("iconset exited with {0}".format(retcode)) 284 | 285 | assert os.path.exists(icns_path), ( 286 | "Generated ICNS file not found : {0!r}".format(icns_path)) 287 | finally: 288 | try: 289 | shutil.rmtree(tempdir) 290 | except OSError: # pragma: no cover 291 | pass 292 | 293 | 294 | # def notify_native(title='', text='', sound=''): 295 | # """Post notification via the native API (via pyobjc). 296 | 297 | # At least one of `title` or `text` must be specified. 298 | 299 | # This method will *always* show the Python launcher icon (i.e. the 300 | # rocket with the snakes on it). 301 | 302 | # Args: 303 | # title (str, optional): Notification title. 304 | # text (str, optional): Notification body text. 305 | # sound (str, optional): Name of sound to play. 306 | 307 | # """ 308 | 309 | # if title == text == '': 310 | # raise ValueError('Empty notification') 311 | 312 | # import Foundation 313 | 314 | # sound = sound or Foundation.NSUserNotificationDefaultSoundName 315 | 316 | # n = Foundation.NSUserNotification.alloc().init() 317 | # n.setTitle_(title) 318 | # n.setInformativeText_(text) 319 | # n.setSoundName_(sound) 320 | # nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() 321 | # nc.deliverNotification_(n) 322 | 323 | 324 | if __name__ == '__main__': # pragma: nocover 325 | # Simple command-line script to test module with 326 | # This won't work on 2.6, as `argparse` isn't available 327 | # by default. 328 | import argparse 329 | 330 | from unicodedata import normalize 331 | 332 | def uni(s): 333 | """Coerce `s` to normalised Unicode.""" 334 | ustr = s.decode('utf-8') 335 | return normalize('NFD', ustr) 336 | 337 | p = argparse.ArgumentParser() 338 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 339 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 340 | action='store_true') 341 | p.add_argument('-t', '--title', 342 | help="Notification title.", type=uni, 343 | default='') 344 | p.add_argument('-s', '--sound', type=uni, 345 | help="Optional notification sound.", default='') 346 | p.add_argument('text', type=uni, 347 | help="Notification body text.", default='', nargs='?') 348 | o = p.parse_args() 349 | 350 | # List available sounds 351 | if o.list_sounds: 352 | for sound in SOUNDS: 353 | print(sound) 354 | sys.exit(0) 355 | 356 | # Convert PNG to ICNS 357 | if o.png: 358 | icns = os.path.join( 359 | os.path.dirname(o.png), 360 | b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], 361 | '.icns')) 362 | 363 | print('Converting {0!r} to {1!r} ...'.format(o.png, icns), 364 | file=sys.stderr) 365 | 366 | assert not os.path.exists(icns), ( 367 | "Destination file already exists : {0}".format(icns)) 368 | 369 | png_to_icns(o.png, icns) 370 | sys.exit(0) 371 | 372 | # Post notification 373 | if o.title == o.text == '': 374 | print('ERROR: Empty notification.', file=sys.stderr) 375 | sys.exit(1) 376 | else: 377 | notify(o.title, o.text, o.sound) 378 | -------------------------------------------------------------------------------- /help/src/vendor/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 | """ 11 | :class:`Workflow3` supports Alfred 3's new features. 12 | 13 | It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`. 14 | 15 | It supports setting :ref:`workflow-variables` and 16 | :class:`the more advanced modifiers ` supported by Alfred 3. 17 | 18 | In order for the feedback mechanism to work correctly, it's important 19 | to create :class:`Item3` and :class:`Modifier` objects via the 20 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 21 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 22 | objects directly, the current :class:`~workflow.workflow3.Workflow3` 23 | object won't be aware of them, and they won't be sent to Alfred when 24 | you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`. 25 | """ 26 | 27 | from __future__ import print_function, unicode_literals, absolute_import 28 | 29 | import json 30 | import os 31 | import sys 32 | 33 | from .workflow import Workflow 34 | 35 | 36 | class Modifier(object): 37 | """Modify ``Item3`` values for when specified modifier keys are pressed. 38 | 39 | Valid modifiers (i.e. values for ``key``) are: 40 | 41 | * cmd 42 | * alt 43 | * shift 44 | * ctrl 45 | * fn 46 | 47 | Attributes: 48 | arg (unicode): Arg to pass to following action. 49 | key (unicode): Modifier key (see above). 50 | subtitle (unicode): Override item subtitle. 51 | valid (bool): Override item validity. 52 | variables (dict): Workflow variables set by this modifier. 53 | 54 | """ 55 | 56 | def __init__(self, key, subtitle=None, arg=None, valid=None): 57 | """Create a new :class:`Modifier`. 58 | 59 | You probably don't want to use this class directly, but rather 60 | use :meth:`Item3.add_modifier()` to add modifiers to results. 61 | 62 | Args: 63 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 64 | subtitle (unicode, optional): Override default subtitle. 65 | arg (unicode, optional): Argument to pass for this modifier. 66 | valid (bool, optional): Override item's validity. 67 | """ 68 | self.key = key 69 | self.subtitle = subtitle 70 | self.arg = arg 71 | self.valid = valid 72 | 73 | self.config = {} 74 | self.variables = {} 75 | 76 | def setvar(self, name, value): 77 | """Set a workflow variable for this Item. 78 | 79 | Args: 80 | name (unicode): Name of variable. 81 | value (unicode): Value of variable. 82 | 83 | """ 84 | self.variables[name] = value 85 | 86 | def getvar(self, name, default=None): 87 | """Return value of workflow variable for ``name`` or ``default``. 88 | 89 | Args: 90 | name (unicode): Variable name. 91 | default (None, optional): Value to return if variable is unset. 92 | 93 | Returns: 94 | unicode or ``default``: Value of variable if set or ``default``. 95 | """ 96 | return self.variables.get(name, default) 97 | 98 | @property 99 | def obj(self): 100 | """Return modifier formatted for JSON serialization for Alfred 3. 101 | 102 | Returns: 103 | dict: Modifier for serializing to JSON. 104 | """ 105 | o = {} 106 | 107 | if self.subtitle is not None: 108 | o['subtitle'] = self.subtitle 109 | 110 | if self.arg is not None: 111 | o['arg'] = self.arg 112 | 113 | if self.valid is not None: 114 | o['valid'] = self.valid 115 | 116 | # Variables and config 117 | if self.variables or self.config: 118 | d = {} 119 | if self.variables: 120 | d['variables'] = self.variables 121 | 122 | if self.config: 123 | d['config'] = self.config 124 | 125 | if self.arg is not None: 126 | d['arg'] = self.arg 127 | 128 | o['arg'] = json.dumps({'alfredworkflow': d}) 129 | 130 | return o 131 | 132 | 133 | class Item3(object): 134 | """Represents a feedback item for Alfred 3. 135 | 136 | Generates Alfred-compliant JSON for a single item. 137 | 138 | You probably shouldn't use this class directly, but via 139 | :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item` 140 | for details of arguments. 141 | 142 | """ 143 | 144 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 145 | valid=False, uid=None, icon=None, icontype=None, 146 | type=None, largetext=None, copytext=None, quicklookurl=None): 147 | """Use same arguments as for :meth:`Workflow.add_item`. 148 | 149 | Argument ``subtitle_modifiers`` is not supported. 150 | 151 | """ 152 | self.title = title 153 | self.subtitle = subtitle 154 | self.arg = arg 155 | self.autocomplete = autocomplete 156 | self.valid = valid 157 | self.uid = uid 158 | self.icon = icon 159 | self.icontype = icontype 160 | self.type = type 161 | self.quicklookurl = quicklookurl 162 | self.largetext = largetext 163 | self.copytext = copytext 164 | 165 | self.modifiers = {} 166 | 167 | self.config = {} 168 | self.variables = {} 169 | 170 | def setvar(self, name, value): 171 | """Set a workflow variable for this Item. 172 | 173 | Args: 174 | name (unicode): Name of variable. 175 | value (unicode): Value of variable. 176 | 177 | """ 178 | self.variables[name] = value 179 | 180 | def getvar(self, name, default=None): 181 | """Return value of workflow variable for ``name`` or ``default``. 182 | 183 | Args: 184 | name (unicode): Variable name. 185 | default (None, optional): Value to return if variable is unset. 186 | 187 | Returns: 188 | unicode or ``default``: Value of variable if set or ``default``. 189 | """ 190 | return self.variables.get(name, default) 191 | 192 | def add_modifier(self, key, subtitle=None, arg=None, valid=None): 193 | """Add alternative values for a modifier key. 194 | 195 | Args: 196 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 197 | subtitle (unicode, optional): Override item subtitle. 198 | arg (unicode, optional): Input for following action. 199 | valid (bool, optional): Override item validity. 200 | 201 | Returns: 202 | Modifier: Configured :class:`Modifier`. 203 | """ 204 | mod = Modifier(key, subtitle, arg, valid) 205 | 206 | for k in self.variables: 207 | mod.setvar(k, self.variables[k]) 208 | 209 | self.modifiers[key] = mod 210 | 211 | return mod 212 | 213 | @property 214 | def obj(self): 215 | """Return Modifier formatted for JSON serialization. 216 | 217 | Returns: 218 | dict: Data suitable for Alfred 3 feedback. 219 | """ 220 | # Basic values 221 | o = {'title': self.title, 222 | 'subtitle': self.subtitle, 223 | 'valid': self.valid} 224 | 225 | icon = {} 226 | 227 | # Optional values 228 | if self.arg is not None: 229 | o['arg'] = self.arg 230 | 231 | if self.autocomplete is not None: 232 | o['autocomplete'] = self.autocomplete 233 | 234 | if self.uid is not None: 235 | o['uid'] = self.uid 236 | 237 | if self.type is not None: 238 | o['type'] = self.type 239 | 240 | if self.quicklookurl is not None: 241 | o['quicklookurl'] = self.quicklookurl 242 | 243 | # Largetype and copytext 244 | text = self._text() 245 | if text: 246 | o['text'] = text 247 | 248 | icon = self._icon() 249 | if icon: 250 | o['icon'] = icon 251 | 252 | # Variables and config 253 | js = self._vars_and_config() 254 | if js: 255 | o['arg'] = js 256 | 257 | # Modifiers 258 | mods = self._modifiers() 259 | if mods: 260 | o['mods'] = mods 261 | 262 | return o 263 | 264 | def _icon(self): 265 | """Return `icon` object for item. 266 | 267 | Returns: 268 | dict: Mapping for item `icon` (may be empty). 269 | """ 270 | icon = {} 271 | if self.icon is not None: 272 | icon['path'] = self.icon 273 | 274 | if self.icontype is not None: 275 | icon['type'] = self.icontype 276 | 277 | return icon 278 | 279 | def _text(self): 280 | """Return `largetext` and `copytext` object for item. 281 | 282 | Returns: 283 | dict: `text` mapping (may be empty) 284 | 285 | """ 286 | text = {} 287 | if self.largetext is not None: 288 | text['largetype'] = self.largetext 289 | 290 | if self.copytext is not None: 291 | text['copy'] = self.copytext 292 | 293 | return text 294 | 295 | def _vars_and_config(self): 296 | """Build `arg` including workflow variables and configuration. 297 | 298 | Returns: 299 | str: JSON string value for `arg` (or `None`) 300 | 301 | """ 302 | if self.variables or self.config: 303 | d = {} 304 | if self.variables: 305 | d['variables'] = self.variables 306 | 307 | if self.config: 308 | d['config'] = self.config 309 | 310 | if self.arg is not None: 311 | d['arg'] = self.arg 312 | 313 | return json.dumps({'alfredworkflow': d}) 314 | 315 | return None 316 | 317 | def _modifiers(self): 318 | """Build `mods` dictionary for JSON feedback. 319 | 320 | Returns: 321 | dict: Modifier mapping or `None`. 322 | 323 | """ 324 | if self.modifiers: 325 | mods = {} 326 | for k, mod in self.modifiers.items(): 327 | mods[k] = mod.obj 328 | 329 | return mods 330 | 331 | return None 332 | 333 | 334 | class Workflow3(Workflow): 335 | """Workflow class that generates Alfred 3 feedback.""" 336 | 337 | item_class = Item3 338 | 339 | def __init__(self, **kwargs): 340 | """Create a new :class:`Workflow3` object. 341 | 342 | See :class:`~workflow.workflow.Workflow` for documentation. 343 | """ 344 | Workflow.__init__(self, **kwargs) 345 | self.variables = {} 346 | 347 | @property 348 | def _default_cachedir(self): 349 | """Alfred 3's default cache directory.""" 350 | return os.path.join( 351 | os.path.expanduser( 352 | '~/Library/Caches/com.runningwithcrayons.Alfred-3/' 353 | 'Workflow Data/'), 354 | self.bundleid) 355 | 356 | @property 357 | def _default_datadir(self): 358 | """Alfred 3's default data directory.""" 359 | return os.path.join(os.path.expanduser( 360 | '~/Library/Application Support/Alfred 3/Workflow Data/'), 361 | self.bundleid) 362 | 363 | def setvar(self, name, value): 364 | """Set a workflow variable that will be inherited by all new items. 365 | 366 | Args: 367 | name (unicode): Name of variable. 368 | value (unicode): Value of variable. 369 | 370 | """ 371 | self.variables[name] = value 372 | 373 | def getvar(self, name, default=None): 374 | """Return value of workflow variable for ``name`` or ``default``. 375 | 376 | Args: 377 | name (unicode): Variable name. 378 | default (None, optional): Value to return if variable is unset. 379 | 380 | Returns: 381 | unicode or ``default``: Value of variable if set or ``default``. 382 | """ 383 | return self.variables.get(name, default) 384 | 385 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 386 | valid=False, uid=None, icon=None, icontype=None, 387 | type=None, largetext=None, copytext=None, quicklookurl=None): 388 | """Add an item to be output to Alfred. 389 | 390 | See :meth:`~workflow.workflow.Workflow.add_item` for the main 391 | documentation. 392 | 393 | The key difference is that this method does not support the 394 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 395 | method instead on the returned item instead. 396 | 397 | Returns: 398 | Item3: Alfred feedback item. 399 | 400 | """ 401 | item = self.item_class(title, subtitle, arg, 402 | autocomplete, valid, uid, icon, icontype, type, 403 | largetext, copytext, quicklookurl) 404 | 405 | for k in self.variables: 406 | item.setvar(k, self.variables[k]) 407 | 408 | self._items.append(item) 409 | return item 410 | 411 | def send_feedback(self): 412 | """Print stored items to console/Alfred as JSON.""" 413 | items = [] 414 | for item in self._items: 415 | items.append(item.obj) 416 | 417 | json.dump({'items': items}, sys.stdout) 418 | sys.stdout.flush() 419 | -------------------------------------------------------------------------------- /help/src/vendor/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | import os 27 | import tempfile 28 | import re 29 | import subprocess 30 | 31 | import workflow 32 | import web 33 | 34 | # __all__ = [] 35 | 36 | 37 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' 38 | 39 | 40 | _wf = None 41 | 42 | 43 | def wf(): 44 | """Lazy `Workflow` object.""" 45 | global _wf 46 | if _wf is None: 47 | _wf = workflow.Workflow() 48 | return _wf 49 | 50 | 51 | class Version(object): 52 | """Mostly semantic versioning. 53 | 54 | The main difference to proper :ref:`semantic versioning ` 55 | is that this implementation doesn't require a minor or patch version. 56 | 57 | Version strings may also be prefixed with "v", e.g.: 58 | 59 | >>> v = Version('v1.1.1') 60 | >>> v.tuple 61 | (1, 1, 1, '') 62 | 63 | >>> v = Version('2.0') 64 | >>> v.tuple 65 | (2, 0, 0, '') 66 | 67 | >>> Version('3.1-beta').tuple 68 | (3, 1, 0, 'beta') 69 | 70 | >>> Version('1.0.1') > Version('0.0.1') 71 | True 72 | """ 73 | 74 | #: Match version and pre-release/build information in version strings 75 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 76 | 77 | def __init__(self, vstr): 78 | """Create new `Version` object. 79 | 80 | Args: 81 | vstr (basestring): Semantic version string. 82 | """ 83 | self.vstr = vstr 84 | self.major = 0 85 | self.minor = 0 86 | self.patch = 0 87 | self.suffix = '' 88 | self.build = '' 89 | self._parse(vstr) 90 | 91 | def _parse(self, vstr): 92 | if vstr.startswith('v'): 93 | m = self.match_version(vstr[1:]) 94 | else: 95 | m = self.match_version(vstr) 96 | if not m: 97 | raise ValueError('Invalid version number: {0}'.format(vstr)) 98 | 99 | version, suffix = m.groups() 100 | parts = self._parse_dotted_string(version) 101 | self.major = parts.pop(0) 102 | if len(parts): 103 | self.minor = parts.pop(0) 104 | if len(parts): 105 | self.patch = parts.pop(0) 106 | if not len(parts) == 0: 107 | raise ValueError('Invalid version (too long) : {0}'.format(vstr)) 108 | 109 | if suffix: 110 | # Build info 111 | idx = suffix.find('+') 112 | if idx > -1: 113 | self.build = suffix[idx+1:] 114 | suffix = suffix[:idx] 115 | if suffix: 116 | if not suffix.startswith('-'): 117 | raise ValueError( 118 | 'Invalid suffix : `{0}`. Must start with `-`'.format( 119 | suffix)) 120 | self.suffix = suffix[1:] 121 | 122 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 123 | 124 | def _parse_dotted_string(self, s): 125 | """Parse string ``s`` into list of ints and strings.""" 126 | parsed = [] 127 | parts = s.split('.') 128 | for p in parts: 129 | if p.isdigit(): 130 | p = int(p) 131 | parsed.append(p) 132 | return parsed 133 | 134 | @property 135 | def tuple(self): 136 | """Version number as a tuple of major, minor, patch, pre-release.""" 137 | return (self.major, self.minor, self.patch, self.suffix) 138 | 139 | def __lt__(self, other): 140 | """Implement comparison.""" 141 | if not isinstance(other, Version): 142 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 143 | t = self.tuple[:3] 144 | o = other.tuple[:3] 145 | if t < o: 146 | return True 147 | if t == o: # We need to compare suffixes 148 | if self.suffix and not other.suffix: 149 | return True 150 | if other.suffix and not self.suffix: 151 | return False 152 | return (self._parse_dotted_string(self.suffix) < 153 | self._parse_dotted_string(other.suffix)) 154 | # t > o 155 | return False 156 | 157 | def __eq__(self, other): 158 | """Implement comparison.""" 159 | if not isinstance(other, Version): 160 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 161 | return self.tuple == other.tuple 162 | 163 | def __ne__(self, other): 164 | """Implement comparison.""" 165 | return not self.__eq__(other) 166 | 167 | def __gt__(self, other): 168 | """Implement comparison.""" 169 | if not isinstance(other, Version): 170 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 171 | return other.__lt__(self) 172 | 173 | def __le__(self, other): 174 | """Implement comparison.""" 175 | if not isinstance(other, Version): 176 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 177 | return not other.__lt__(self) 178 | 179 | def __ge__(self, other): 180 | """Implement comparison.""" 181 | return not self.__lt__(other) 182 | 183 | def __str__(self): 184 | """Return semantic version string.""" 185 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 186 | if self.suffix: 187 | vstr += '-{0}'.format(self.suffix) 188 | if self.build: 189 | vstr += '+{0}'.format(self.build) 190 | return vstr 191 | 192 | def __repr__(self): 193 | """Return 'code' representation of `Version`.""" 194 | return "Version('{0}')".format(str(self)) 195 | 196 | 197 | def download_workflow(url): 198 | """Download workflow at ``url`` to a local temporary file. 199 | 200 | :param url: URL to .alfredworkflow file in GitHub repo 201 | :returns: path to downloaded file 202 | 203 | """ 204 | filename = url.split("/")[-1] 205 | 206 | if (not url.endswith('.alfredworkflow') or 207 | not filename.endswith('.alfredworkflow')): 208 | raise ValueError('Attachment `{0}` not a workflow'.format(filename)) 209 | 210 | local_path = os.path.join(tempfile.gettempdir(), filename) 211 | 212 | wf().logger.debug( 213 | 'Downloading updated workflow from `%s` to `%s` ...', url, local_path) 214 | 215 | response = web.get(url) 216 | 217 | with open(local_path, 'wb') as output: 218 | output.write(response.content) 219 | 220 | return local_path 221 | 222 | 223 | def build_api_url(slug): 224 | """Generate releases URL from GitHub slug. 225 | 226 | :param slug: Repo name in form ``username/repo`` 227 | :returns: URL to the API endpoint for the repo's releases 228 | 229 | """ 230 | if len(slug.split('/')) != 2: 231 | raise ValueError('Invalid GitHub slug : {0}'.format(slug)) 232 | 233 | return RELEASES_BASE.format(slug) 234 | 235 | 236 | def _validate_release(release): 237 | """Return release for running version of Alfred.""" 238 | alf3 = wf().alfred_version.major == 3 239 | 240 | downloads = {'.alfredworkflow': [], '.alfred3workflow': []} 241 | dl_count = 0 242 | version = release['tag_name'] 243 | 244 | for asset in release.get('assets', []): 245 | url = asset.get('browser_download_url') 246 | if not url: # pragma: nocover 247 | continue 248 | 249 | ext = os.path.splitext(url)[1].lower() 250 | if ext not in downloads: 251 | continue 252 | 253 | # Ignore Alfred 3-only files if Alfred 2 is running 254 | if ext == '.alfred3workflow' and not alf3: 255 | continue 256 | 257 | downloads[ext].append(url) 258 | dl_count += 1 259 | 260 | # download_urls.append(url) 261 | 262 | if dl_count == 0: 263 | wf().logger.warning( 264 | 'Invalid release %s : No workflow file', version) 265 | return None 266 | 267 | for k in downloads: 268 | if len(downloads[k]) > 1: 269 | wf().logger.warning( 270 | 'Invalid release %s : multiple %s files', version, k) 271 | return None 272 | 273 | # Prefer .alfred3workflow file if there is one and Alfred 3 is 274 | # running. 275 | if alf3 and len(downloads['.alfred3workflow']): 276 | download_url = downloads['.alfred3workflow'][0] 277 | 278 | else: 279 | download_url = downloads['.alfredworkflow'][0] 280 | 281 | wf().logger.debug('Release `%s` : %s', version, download_url) 282 | 283 | return { 284 | 'version': version, 285 | 'download_url': download_url, 286 | 'prerelease': release['prerelease'] 287 | } 288 | 289 | 290 | def get_valid_releases(github_slug, prereleases=False): 291 | """Return list of all valid releases. 292 | 293 | :param github_slug: ``username/repo`` for workflow's GitHub repo 294 | :param prereleases: Whether to include pre-releases. 295 | :returns: list of dicts. Each :class:`dict` has the form 296 | ``{'version': '1.1', 'download_url': 'http://github.com/...', 297 | 'prerelease': False }`` 298 | 299 | 300 | A valid release is one that contains one ``.alfredworkflow`` file. 301 | 302 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading 303 | ``v`` will be stripped. 304 | 305 | """ 306 | api_url = build_api_url(github_slug) 307 | releases = [] 308 | 309 | wf().logger.debug('Retrieving releases list from `%s` ...', api_url) 310 | 311 | def retrieve_releases(): 312 | wf().logger.info( 313 | 'Retrieving releases for `%s` ...', github_slug) 314 | return web.get(api_url).json() 315 | 316 | slug = github_slug.replace('/', '-') 317 | for release in wf().cached_data('gh-releases-{0}'.format(slug), 318 | retrieve_releases): 319 | 320 | wf().logger.debug('Release : %r', release) 321 | 322 | release = _validate_release(release) 323 | if release is None: 324 | wf().logger.debug('Invalid release') 325 | continue 326 | 327 | elif release['prerelease'] and not prereleases: 328 | wf().logger.debug('Ignoring prerelease : %s', release['version']) 329 | continue 330 | 331 | releases.append(release) 332 | 333 | return releases 334 | 335 | 336 | def check_update(github_slug, current_version, prereleases=False): 337 | """Check whether a newer release is available on GitHub. 338 | 339 | :param github_slug: ``username/repo`` for workflow's GitHub repo 340 | :param current_version: the currently installed version of the 341 | workflow. :ref:`Semantic versioning ` is required. 342 | :param prereleases: Whether to include pre-releases. 343 | :type current_version: ``unicode`` 344 | :returns: ``True`` if an update is available, else ``False`` 345 | 346 | If an update is available, its version number and download URL will 347 | be cached. 348 | 349 | """ 350 | releases = get_valid_releases(github_slug, prereleases) 351 | 352 | wf().logger.info('%d releases for %s', len(releases), github_slug) 353 | 354 | if not len(releases): 355 | raise ValueError('No valid releases for %s', github_slug) 356 | 357 | # GitHub returns releases newest-first 358 | latest_release = releases[0] 359 | 360 | # (latest_version, download_url) = get_latest_release(releases) 361 | vr = Version(latest_release['version']) 362 | vl = Version(current_version) 363 | wf().logger.debug('Latest : %r Installed : %r', vr, vl) 364 | if vr > vl: 365 | 366 | wf().cache_data('__workflow_update_status', { 367 | 'version': latest_release['version'], 368 | 'download_url': latest_release['download_url'], 369 | 'available': True 370 | }) 371 | 372 | return True 373 | 374 | wf().cache_data('__workflow_update_status', { 375 | 'available': False 376 | }) 377 | return False 378 | 379 | 380 | def install_update(): 381 | """If a newer release is available, download and install it. 382 | 383 | :returns: ``True`` if an update is installed, else ``False`` 384 | 385 | """ 386 | update_data = wf().cached_data('__workflow_update_status', max_age=0) 387 | 388 | if not update_data or not update_data.get('available'): 389 | wf().logger.info('No update available') 390 | return False 391 | 392 | local_file = download_workflow(update_data['download_url']) 393 | 394 | wf().logger.info('Installing updated workflow ...') 395 | subprocess.call(['open', local_file]) 396 | 397 | update_data['available'] = False 398 | wf().cache_data('__workflow_update_status', update_data) 399 | return True 400 | 401 | 402 | if __name__ == '__main__': # pragma: nocover 403 | import sys 404 | 405 | def show_help(): 406 | """Print help message.""" 407 | print('Usage : update.py (check|install) github_slug version ' 408 | '[--prereleases]') 409 | sys.exit(1) 410 | 411 | argv = sys.argv[:] 412 | prereleases = '--prereleases' in argv 413 | 414 | if prereleases: 415 | argv.remove('--prereleases') 416 | 417 | if len(argv) != 4: 418 | show_help() 419 | 420 | action, github_slug, version = argv[1:] 421 | 422 | if action not in ('check', 'install'): 423 | show_help() 424 | 425 | if action == 'check': 426 | check_update(github_slug, version, prereleases) 427 | elif action == 'install': 428 | install_update() 429 | -------------------------------------------------------------------------------- /_scripts/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, child in enumerate(self.children): 51 | if not hasattr(child, 'children'): 52 | assert child in uniq 53 | self.children[i] = uniq[uniq.index(child)] 54 | else: 55 | child.fix_identities(uniq) 56 | 57 | def fix_repeating_arguments(self): 58 | """Fix elements that should accumulate/increment values.""" 59 | either = [list(child.children) for child in transform(self).children] 60 | for case in either: 61 | for e in [child for child in case if case.count(child) > 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 | 72 | def transform(pattern): 73 | """Expand pattern into an (almost) equivalent one, but with single Either. 74 | 75 | Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) 76 | Quirks: [-a] => (-a), (-a...) => (-a -a) 77 | 78 | """ 79 | result = [] 80 | groups = [[pattern]] 81 | while groups: 82 | children = groups.pop(0) 83 | parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] 84 | if any(t in map(type, children) for t in parents): 85 | child = [c for c in children if type(c) in parents][0] 86 | children.remove(child) 87 | if type(child) is Either: 88 | for c in child.children: 89 | groups.append([c] + children) 90 | elif type(child) is OneOrMore: 91 | groups.append(child.children * 2 + children) 92 | else: 93 | groups.append(child.children + children) 94 | else: 95 | result.append(children) 96 | return Either(*[Required(*e) for e in result]) 97 | 98 | 99 | class LeafPattern(Pattern): 100 | 101 | """Leaf/terminal node of a pattern tree.""" 102 | 103 | def __init__(self, name, value=None): 104 | self.name, self.value = name, value 105 | 106 | def __repr__(self): 107 | return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) 108 | 109 | def flat(self, *types): 110 | return [self] if not types or type(self) in types else [] 111 | 112 | def match(self, left, collected=None): 113 | collected = [] if collected is None else collected 114 | pos, match = self.single_match(left) 115 | if match is None: 116 | return False, left, collected 117 | left_ = left[:pos] + left[pos + 1:] 118 | same_name = [a for a in collected if a.name == self.name] 119 | if type(self.value) in (int, list): 120 | if type(self.value) is int: 121 | increment = 1 122 | else: 123 | increment = ([match.value] if type(match.value) is str 124 | else match.value) 125 | if not same_name: 126 | match.value = increment 127 | return True, left_, collected + [match] 128 | same_name[0].value += increment 129 | return True, left_, collected 130 | return True, left_, collected + [match] 131 | 132 | 133 | class BranchPattern(Pattern): 134 | 135 | """Branch/inner node of a pattern tree.""" 136 | 137 | def __init__(self, *children): 138 | self.children = list(children) 139 | 140 | def __repr__(self): 141 | return '%s(%s)' % (self.__class__.__name__, 142 | ', '.join(repr(a) for a in self.children)) 143 | 144 | def flat(self, *types): 145 | if type(self) in types: 146 | return [self] 147 | return sum([child.flat(*types) for child in self.children], []) 148 | 149 | 150 | class Argument(LeafPattern): 151 | 152 | def single_match(self, left): 153 | for n, pattern in enumerate(left): 154 | if type(pattern) is Argument: 155 | return n, Argument(self.name, pattern.value) 156 | return None, None 157 | 158 | @classmethod 159 | def parse(class_, source): 160 | name = re.findall('(<\S*?>)', source)[0] 161 | value = re.findall('\[default: (.*)\]', source, flags=re.I) 162 | return class_(name, value[0] if value else None) 163 | 164 | 165 | class Command(Argument): 166 | 167 | def __init__(self, name, value=False): 168 | self.name, self.value = name, value 169 | 170 | def single_match(self, left): 171 | for n, pattern in enumerate(left): 172 | if type(pattern) is Argument: 173 | if pattern.value == self.name: 174 | return n, Command(self.name, True) 175 | else: 176 | break 177 | return None, None 178 | 179 | 180 | class Option(LeafPattern): 181 | 182 | def __init__(self, short=None, long=None, argcount=0, value=False): 183 | assert argcount in (0, 1) 184 | self.short, self.long, self.argcount = short, long, argcount 185 | self.value = None if value is False and argcount else value 186 | 187 | @classmethod 188 | def parse(class_, option_description): 189 | short, long, argcount, value = None, None, 0, False 190 | options, _, description = option_description.strip().partition(' ') 191 | options = options.replace(',', ' ').replace('=', ' ') 192 | for s in options.split(): 193 | if s.startswith('--'): 194 | long = s 195 | elif s.startswith('-'): 196 | short = s 197 | else: 198 | argcount = 1 199 | if argcount: 200 | matched = re.findall('\[default: (.*)\]', description, flags=re.I) 201 | value = matched[0] if matched else None 202 | return class_(short, long, argcount, value) 203 | 204 | def single_match(self, left): 205 | for n, pattern in enumerate(left): 206 | if self.name == pattern.name: 207 | return n, pattern 208 | return None, None 209 | 210 | @property 211 | def name(self): 212 | return self.long or self.short 213 | 214 | def __repr__(self): 215 | return 'Option(%r, %r, %r, %r)' % (self.short, self.long, 216 | self.argcount, self.value) 217 | 218 | 219 | class Required(BranchPattern): 220 | 221 | def match(self, left, collected=None): 222 | collected = [] if collected is None else collected 223 | l = left 224 | c = collected 225 | for pattern in self.children: 226 | matched, l, c = pattern.match(l, c) 227 | if not matched: 228 | return False, left, collected 229 | return True, l, c 230 | 231 | 232 | class Optional(BranchPattern): 233 | 234 | def match(self, left, collected=None): 235 | collected = [] if collected is None else collected 236 | for pattern in self.children: 237 | m, left, collected = pattern.match(left, collected) 238 | return True, left, collected 239 | 240 | 241 | class OptionsShortcut(Optional): 242 | 243 | """Marker/placeholder for [options] shortcut.""" 244 | 245 | 246 | class OneOrMore(BranchPattern): 247 | 248 | def match(self, left, collected=None): 249 | assert len(self.children) == 1 250 | collected = [] if collected is None else collected 251 | l = left 252 | c = collected 253 | l_ = None 254 | matched = True 255 | times = 0 256 | while matched: 257 | # could it be that something didn't match but changed l or c? 258 | matched, l, c = self.children[0].match(l, c) 259 | times += 1 if matched else 0 260 | if l_ == l: 261 | break 262 | l_ = l 263 | if times >= 1: 264 | return True, l, c 265 | return False, left, collected 266 | 267 | 268 | class Either(BranchPattern): 269 | 270 | def match(self, left, collected=None): 271 | collected = [] if collected is None else collected 272 | outcomes = [] 273 | for pattern in self.children: 274 | matched, _, _ = outcome = pattern.match(left, collected) 275 | if matched: 276 | outcomes.append(outcome) 277 | if outcomes: 278 | return min(outcomes, key=lambda outcome: len(outcome[1])) 279 | return False, left, collected 280 | 281 | 282 | class Tokens(list): 283 | 284 | def __init__(self, source, error=DocoptExit): 285 | self += source.split() if hasattr(source, 'split') else source 286 | self.error = error 287 | 288 | @staticmethod 289 | def from_pattern(source): 290 | source = re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source) 291 | source = [s for s in re.split('\s+|(\S*<.*?>)', source) if s] 292 | return Tokens(source, error=DocoptLanguageError) 293 | 294 | def move(self): 295 | return self.pop(0) if len(self) else None 296 | 297 | def current(self): 298 | return self[0] if len(self) else None 299 | 300 | 301 | def parse_long(tokens, options): 302 | """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" 303 | long, eq, value = tokens.move().partition('=') 304 | assert long.startswith('--') 305 | value = None if eq == value == '' else value 306 | similar = [o for o in options if o.long == long] 307 | if tokens.error is DocoptExit and similar == []: # if no exact match 308 | similar = [o for o in options if o.long and o.long.startswith(long)] 309 | if len(similar) > 1: # might be simply specified ambiguously 2+ times? 310 | raise tokens.error('%s is not a unique prefix: %s?' % 311 | (long, ', '.join(o.long for o in similar))) 312 | elif len(similar) < 1: 313 | argcount = 1 if eq == '=' else 0 314 | o = Option(None, long, argcount) 315 | options.append(o) 316 | if tokens.error is DocoptExit: 317 | o = Option(None, long, argcount, value if argcount else True) 318 | else: 319 | o = Option(similar[0].short, similar[0].long, 320 | similar[0].argcount, similar[0].value) 321 | if o.argcount == 0: 322 | if value is not None: 323 | raise tokens.error('%s must not have an argument' % o.long) 324 | else: 325 | if value is None: 326 | if tokens.current() in [None, '--']: 327 | raise tokens.error('%s requires argument' % o.long) 328 | value = tokens.move() 329 | if tokens.error is DocoptExit: 330 | o.value = value if value is not None else True 331 | return [o] 332 | 333 | 334 | def parse_shorts(tokens, options): 335 | """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" 336 | token = tokens.move() 337 | assert token.startswith('-') and not token.startswith('--') 338 | left = token.lstrip('-') 339 | parsed = [] 340 | while left != '': 341 | short, left = '-' + left[0], left[1:] 342 | similar = [o for o in options if o.short == short] 343 | if len(similar) > 1: 344 | raise tokens.error('%s is specified ambiguously %d times' % 345 | (short, len(similar))) 346 | elif len(similar) < 1: 347 | o = Option(short, None, 0) 348 | options.append(o) 349 | if tokens.error is DocoptExit: 350 | o = Option(short, None, 0, True) 351 | else: # why copying is necessary here? 352 | o = Option(short, similar[0].long, 353 | similar[0].argcount, similar[0].value) 354 | value = None 355 | if o.argcount != 0: 356 | if left == '': 357 | if tokens.current() in [None, '--']: 358 | raise tokens.error('%s requires argument' % short) 359 | value = tokens.move() 360 | else: 361 | value = left 362 | left = '' 363 | if tokens.error is DocoptExit: 364 | o.value = value if value is not None else True 365 | parsed.append(o) 366 | return parsed 367 | 368 | 369 | def parse_pattern(source, options): 370 | tokens = Tokens.from_pattern(source) 371 | result = parse_expr(tokens, options) 372 | if tokens.current() is not None: 373 | raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) 374 | return Required(*result) 375 | 376 | 377 | def parse_expr(tokens, options): 378 | """expr ::= seq ( '|' seq )* ;""" 379 | seq = parse_seq(tokens, options) 380 | if tokens.current() != '|': 381 | return seq 382 | result = [Required(*seq)] if len(seq) > 1 else seq 383 | while tokens.current() == '|': 384 | tokens.move() 385 | seq = parse_seq(tokens, options) 386 | result += [Required(*seq)] if len(seq) > 1 else seq 387 | return [Either(*result)] if len(result) > 1 else result 388 | 389 | 390 | def parse_seq(tokens, options): 391 | """seq ::= ( atom [ '...' ] )* ;""" 392 | result = [] 393 | while tokens.current() not in [None, ']', ')', '|']: 394 | atom = parse_atom(tokens, options) 395 | if tokens.current() == '...': 396 | atom = [OneOrMore(*atom)] 397 | tokens.move() 398 | result += atom 399 | return result 400 | 401 | 402 | def parse_atom(tokens, options): 403 | """atom ::= '(' expr ')' | '[' expr ']' | 'options' 404 | | long | shorts | argument | command ; 405 | """ 406 | token = tokens.current() 407 | result = [] 408 | if token in '([': 409 | tokens.move() 410 | matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] 411 | result = pattern(*parse_expr(tokens, options)) 412 | if tokens.move() != matching: 413 | raise tokens.error("unmatched '%s'" % token) 414 | return [result] 415 | elif token == 'options': 416 | tokens.move() 417 | return [OptionsShortcut()] 418 | elif token.startswith('--') and token != '--': 419 | return parse_long(tokens, options) 420 | elif token.startswith('-') and token not in ('-', '--'): 421 | return parse_shorts(tokens, options) 422 | elif token.startswith('<') and token.endswith('>') or token.isupper(): 423 | return [Argument(tokens.move())] 424 | else: 425 | return [Command(tokens.move())] 426 | 427 | 428 | def parse_argv(tokens, options, options_first=False): 429 | """Parse command-line argument vector. 430 | 431 | If options_first: 432 | argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; 433 | else: 434 | argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; 435 | 436 | """ 437 | parsed = [] 438 | while tokens.current() is not None: 439 | if tokens.current() == '--': 440 | return parsed + [Argument(None, v) for v in tokens] 441 | elif tokens.current().startswith('--'): 442 | parsed += parse_long(tokens, options) 443 | elif tokens.current().startswith('-') and tokens.current() != '-': 444 | parsed += parse_shorts(tokens, options) 445 | elif options_first: 446 | return parsed + [Argument(None, v) for v in tokens] 447 | else: 448 | parsed.append(Argument(None, tokens.move())) 449 | return parsed 450 | 451 | 452 | def parse_defaults(doc): 453 | defaults = [] 454 | for s in parse_section('options:', doc): 455 | # FIXME corner case "bla: options: --foo" 456 | _, _, s = s.partition(':') # get rid of "options:" 457 | split = re.split('\n[ \t]*(-\S+?)', '\n' + s)[1:] 458 | split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] 459 | options = [Option.parse(s) for s in split if s.startswith('-')] 460 | defaults += options 461 | return defaults 462 | 463 | 464 | def parse_section(name, source): 465 | pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', 466 | re.IGNORECASE | re.MULTILINE) 467 | return [s.strip() for s in pattern.findall(source)] 468 | 469 | 470 | def formal_usage(section): 471 | _, _, section = section.partition(':') # drop "usage:" 472 | pu = section.split() 473 | return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' 474 | 475 | 476 | def extras(help, version, options, doc): 477 | if help and any((o.name in ('-h', '--help')) and o.value for o in options): 478 | print(doc.strip("\n")) 479 | sys.exit() 480 | if version and any(o.name == '--version' and o.value for o in options): 481 | print(version) 482 | sys.exit() 483 | 484 | 485 | class Dict(dict): 486 | def __repr__(self): 487 | return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) 488 | 489 | 490 | def docopt(doc, argv=None, help=True, version=None, options_first=False): 491 | """Parse `argv` based on command-line interface described in `doc`. 492 | 493 | `docopt` creates your command-line interface based on its 494 | description that you pass as `doc`. Such description can contain 495 | --options, , commands, which could be 496 | [optional], (required), (mutually | exclusive) or repeated... 497 | 498 | Parameters 499 | ---------- 500 | doc : str 501 | Description of your command-line interface. 502 | argv : list of str, optional 503 | Argument vector to be parsed. sys.argv[1:] is used if not 504 | provided. 505 | help : bool (default: True) 506 | Set to False to disable automatic help on -h or --help 507 | options. 508 | version : any object 509 | If passed, the object will be printed if --version is in 510 | `argv`. 511 | options_first : bool (default: False) 512 | Set to True to require options precede positional arguments, 513 | i.e. to forbid options and positional arguments intermix. 514 | 515 | Returns 516 | ------- 517 | args : dict 518 | A dictionary, where keys are names of command-line elements 519 | such as e.g. "--verbose" and "", and values are the 520 | parsed values of those elements. 521 | 522 | Example 523 | ------- 524 | >>> from docopt import docopt 525 | >>> doc = ''' 526 | ... Usage: 527 | ... my_program tcp [--timeout=] 528 | ... my_program serial [--baud=] [--timeout=] 529 | ... my_program (-h | --help | --version) 530 | ... 531 | ... Options: 532 | ... -h, --help Show this screen and exit. 533 | ... --baud= Baudrate [default: 9600] 534 | ... ''' 535 | >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] 536 | >>> docopt(doc, argv) 537 | {'--baud': '9600', 538 | '--help': False, 539 | '--timeout': '30', 540 | '--version': False, 541 | '': '127.0.0.1', 542 | '': '80', 543 | 'serial': False, 544 | 'tcp': True} 545 | 546 | See also 547 | -------- 548 | * For video introduction see http://docopt.org 549 | * Full documentation is available in README.rst as well as online 550 | at https://github.com/docopt/docopt#readme 551 | 552 | """ 553 | argv = sys.argv[1:] if argv is None else argv 554 | 555 | usage_sections = parse_section('usage:', doc) 556 | if len(usage_sections) == 0: 557 | raise DocoptLanguageError('"usage:" (case-insensitive) not found.') 558 | if len(usage_sections) > 1: 559 | raise DocoptLanguageError('More than one "usage:" (case-insensitive).') 560 | DocoptExit.usage = usage_sections[0] 561 | 562 | options = parse_defaults(doc) 563 | pattern = parse_pattern(formal_usage(DocoptExit.usage), options) 564 | # [default] syntax for argument is disabled 565 | #for a in pattern.flat(Argument): 566 | # same_name = [d for d in arguments if d.name == a.name] 567 | # if same_name: 568 | # a.value = same_name[0].value 569 | argv = parse_argv(Tokens(argv), list(options), options_first) 570 | pattern_options = set(pattern.flat(Option)) 571 | for options_shortcut in pattern.flat(OptionsShortcut): 572 | doc_options = parse_defaults(doc) 573 | options_shortcut.children = list(set(doc_options) - pattern_options) 574 | #if any_options: 575 | # options_shortcut.children += [Option(o.short, o.long, o.argcount) 576 | # for o in argv if type(o) is Option] 577 | extras(help, version, argv, doc) 578 | matched, left, collected = pattern.fix().match(argv) 579 | if matched and left == []: # better error message if left? 580 | return Dict((a.name, a.value) for a in (pattern.flat() + collected)) 581 | raise DocoptExit() 582 | -------------------------------------------------------------------------------- /help/src/vendor/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | import codecs 13 | import json 14 | import mimetypes 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import string 20 | import unicodedata 21 | import urllib 22 | import urllib2 23 | import urlparse 24 | import zlib 25 | 26 | 27 | USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' 28 | 29 | # Valid characters for multipart form data boundaries 30 | BOUNDARY_CHARS = string.digits + string.ascii_letters 31 | 32 | # HTTP response codes 33 | RESPONSES = { 34 | 100: 'Continue', 35 | 101: 'Switching Protocols', 36 | 200: 'OK', 37 | 201: 'Created', 38 | 202: 'Accepted', 39 | 203: 'Non-Authoritative Information', 40 | 204: 'No Content', 41 | 205: 'Reset Content', 42 | 206: 'Partial Content', 43 | 300: 'Multiple Choices', 44 | 301: 'Moved Permanently', 45 | 302: 'Found', 46 | 303: 'See Other', 47 | 304: 'Not Modified', 48 | 305: 'Use Proxy', 49 | 307: 'Temporary Redirect', 50 | 400: 'Bad Request', 51 | 401: 'Unauthorized', 52 | 402: 'Payment Required', 53 | 403: 'Forbidden', 54 | 404: 'Not Found', 55 | 405: 'Method Not Allowed', 56 | 406: 'Not Acceptable', 57 | 407: 'Proxy Authentication Required', 58 | 408: 'Request Timeout', 59 | 409: 'Conflict', 60 | 410: 'Gone', 61 | 411: 'Length Required', 62 | 412: 'Precondition Failed', 63 | 413: 'Request Entity Too Large', 64 | 414: 'Request-URI Too Long', 65 | 415: 'Unsupported Media Type', 66 | 416: 'Requested Range Not Satisfiable', 67 | 417: 'Expectation Failed', 68 | 500: 'Internal Server Error', 69 | 501: 'Not Implemented', 70 | 502: 'Bad Gateway', 71 | 503: 'Service Unavailable', 72 | 504: 'Gateway Timeout', 73 | 505: 'HTTP Version Not Supported' 74 | } 75 | 76 | 77 | def str_dict(dic): 78 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 79 | 80 | :param dic: :class:`dict` of Unicode strings 81 | :returns: :class:`dict` 82 | 83 | """ 84 | if isinstance(dic, CaseInsensitiveDictionary): 85 | dic2 = CaseInsensitiveDictionary() 86 | else: 87 | dic2 = {} 88 | for k, v in dic.items(): 89 | if isinstance(k, unicode): 90 | k = k.encode('utf-8') 91 | if isinstance(v, unicode): 92 | v = v.encode('utf-8') 93 | dic2[k] = v 94 | return dic2 95 | 96 | 97 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 98 | """Prevent redirections.""" 99 | 100 | def redirect_request(self, *args): 101 | return None 102 | 103 | 104 | # Adapted from https://gist.github.com/babakness/3901174 105 | class CaseInsensitiveDictionary(dict): 106 | """Dictionary with caseless key search. 107 | 108 | Enables case insensitive searching while preserving case sensitivity 109 | when keys are listed, ie, via keys() or items() methods. 110 | 111 | Works by storing a lowercase version of the key as the new key and 112 | stores the original key-value pair as the key's value 113 | (values become dictionaries). 114 | 115 | """ 116 | 117 | def __init__(self, initval=None): 118 | """Create new case-insensitive dictionary.""" 119 | if isinstance(initval, dict): 120 | for key, value in initval.iteritems(): 121 | self.__setitem__(key, value) 122 | 123 | elif isinstance(initval, list): 124 | for (key, value) in initval: 125 | self.__setitem__(key, value) 126 | 127 | def __contains__(self, key): 128 | return dict.__contains__(self, key.lower()) 129 | 130 | def __getitem__(self, key): 131 | return dict.__getitem__(self, key.lower())['val'] 132 | 133 | def __setitem__(self, key, value): 134 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 135 | 136 | def get(self, key, default=None): 137 | try: 138 | v = dict.__getitem__(self, key.lower()) 139 | except KeyError: 140 | return default 141 | else: 142 | return v['val'] 143 | 144 | def update(self, other): 145 | for k, v in other.items(): 146 | self[k] = v 147 | 148 | def items(self): 149 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 150 | 151 | def keys(self): 152 | return [v['key'] for v in dict.itervalues(self)] 153 | 154 | def values(self): 155 | return [v['val'] for v in dict.itervalues(self)] 156 | 157 | def iteritems(self): 158 | for v in dict.itervalues(self): 159 | yield v['key'], v['val'] 160 | 161 | def iterkeys(self): 162 | for v in dict.itervalues(self): 163 | yield v['key'] 164 | 165 | def itervalues(self): 166 | for v in dict.itervalues(self): 167 | yield v['val'] 168 | 169 | 170 | class Response(object): 171 | """ 172 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 173 | 174 | Simplified version of the ``Response`` object in the ``requests`` library. 175 | 176 | >>> r = request('http://www.google.com') 177 | >>> r.status_code 178 | 200 179 | >>> r.encoding 180 | ISO-8859-1 181 | >>> r.content # bytes 182 | ... 183 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 184 | u' ...' 185 | >>> r.json() # content parsed as JSON 186 | 187 | """ 188 | 189 | def __init__(self, request, stream=False): 190 | """Call `request` with :mod:`urllib2` and process results. 191 | 192 | :param request: :class:`urllib2.Request` instance 193 | :param stream: Whether to stream response or retrieve it all at once 194 | :type stream: ``bool`` 195 | 196 | """ 197 | self.request = request 198 | self._stream = stream 199 | self.url = None 200 | self.raw = None 201 | self._encoding = None 202 | self.error = None 203 | self.status_code = None 204 | self.reason = None 205 | self.headers = CaseInsensitiveDictionary() 206 | self._content = None 207 | self._content_loaded = False 208 | self._gzipped = False 209 | 210 | # Execute query 211 | try: 212 | self.raw = urllib2.urlopen(request) 213 | except urllib2.HTTPError as err: 214 | self.error = err 215 | try: 216 | self.url = err.geturl() 217 | # sometimes (e.g. when authentication fails) 218 | # urllib can't get a URL from an HTTPError 219 | # This behaviour changes across Python versions, 220 | # so no test cover (it isn't important). 221 | except AttributeError: # pragma: no cover 222 | pass 223 | self.status_code = err.code 224 | else: 225 | self.status_code = self.raw.getcode() 226 | self.url = self.raw.geturl() 227 | self.reason = RESPONSES.get(self.status_code) 228 | 229 | # Parse additional info if request succeeded 230 | if not self.error: 231 | headers = self.raw.info() 232 | self.transfer_encoding = headers.getencoding() 233 | self.mimetype = headers.gettype() 234 | for key in headers.keys(): 235 | self.headers[key.lower()] = headers.get(key) 236 | 237 | # Is content gzipped? 238 | # Transfer-Encoding appears to not be used in the wild 239 | # (contrary to the HTTP standard), but no harm in testing 240 | # for it 241 | if ('gzip' in headers.get('content-encoding', '') or 242 | 'gzip' in headers.get('transfer-encoding', '')): 243 | self._gzipped = True 244 | 245 | @property 246 | def stream(self): 247 | """Whether response is streamed. 248 | 249 | Returns: 250 | bool: `True` if response is streamed. 251 | """ 252 | return self._stream 253 | 254 | @stream.setter 255 | def stream(self, value): 256 | if self._content_loaded: 257 | raise RuntimeError("`content` has already been read from " 258 | "this Response.") 259 | 260 | self._stream = value 261 | 262 | def json(self): 263 | """Decode response contents as JSON. 264 | 265 | :returns: object decoded from JSON 266 | :rtype: :class:`list` / :class:`dict` 267 | 268 | """ 269 | return json.loads(self.content, self.encoding or 'utf-8') 270 | 271 | @property 272 | def encoding(self): 273 | """Text encoding of document or ``None``. 274 | 275 | :returns: :class:`str` or ``None`` 276 | 277 | """ 278 | if not self._encoding: 279 | self._encoding = self._get_encoding() 280 | 281 | return self._encoding 282 | 283 | @property 284 | def content(self): 285 | """Raw content of response (i.e. bytes). 286 | 287 | :returns: Body of HTTP response 288 | :rtype: :class:`str` 289 | 290 | """ 291 | if not self._content: 292 | 293 | # Decompress gzipped content 294 | if self._gzipped: 295 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 296 | self._content = decoder.decompress(self.raw.read()) 297 | 298 | else: 299 | self._content = self.raw.read() 300 | 301 | self._content_loaded = True 302 | 303 | return self._content 304 | 305 | @property 306 | def text(self): 307 | """Unicode-decoded content of response body. 308 | 309 | If no encoding can be determined from HTTP headers or the content 310 | itself, the encoded response body will be returned instead. 311 | 312 | :returns: Body of HTTP response 313 | :rtype: :class:`unicode` or :class:`str` 314 | 315 | """ 316 | if self.encoding: 317 | return unicodedata.normalize('NFC', unicode(self.content, 318 | self.encoding)) 319 | return self.content 320 | 321 | def iter_content(self, chunk_size=4096, decode_unicode=False): 322 | """Iterate over response data. 323 | 324 | .. versionadded:: 1.6 325 | 326 | :param chunk_size: Number of bytes to read into memory 327 | :type chunk_size: ``int`` 328 | :param decode_unicode: Decode to Unicode using detected encoding 329 | :type decode_unicode: ``Boolean`` 330 | :returns: iterator 331 | 332 | """ 333 | if not self.stream: 334 | raise RuntimeError("You cannot call `iter_content` on a " 335 | "Response unless you passed `stream=True`" 336 | " to `get()`/`post()`/`request()`.") 337 | 338 | if self._content_loaded: 339 | raise RuntimeError( 340 | "`content` has already been read from this Response.") 341 | 342 | def decode_stream(iterator, r): 343 | 344 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') 345 | 346 | for chunk in iterator: 347 | data = decoder.decode(chunk) 348 | if data: 349 | yield data 350 | 351 | data = decoder.decode(b'', final=True) 352 | if data: # pragma: no cover 353 | yield data 354 | 355 | def generate(): 356 | 357 | if self._gzipped: 358 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 359 | 360 | while True: 361 | chunk = self.raw.read(chunk_size) 362 | if not chunk: 363 | break 364 | 365 | if self._gzipped: 366 | chunk = decoder.decompress(chunk) 367 | 368 | yield chunk 369 | 370 | chunks = generate() 371 | 372 | if decode_unicode and self.encoding: 373 | chunks = decode_stream(chunks, self) 374 | 375 | return chunks 376 | 377 | def save_to_path(self, filepath): 378 | """Save retrieved data to file at ``filepath``. 379 | 380 | .. versionadded: 1.9.6 381 | 382 | :param filepath: Path to save retrieved data. 383 | 384 | """ 385 | filepath = os.path.abspath(filepath) 386 | dirname = os.path.dirname(filepath) 387 | if not os.path.exists(dirname): 388 | os.makedirs(dirname) 389 | 390 | self.stream = True 391 | 392 | with open(filepath, 'wb') as fileobj: 393 | for data in self.iter_content(): 394 | fileobj.write(data) 395 | 396 | def raise_for_status(self): 397 | """Raise stored error if one occurred. 398 | 399 | error will be instance of :class:`urllib2.HTTPError` 400 | """ 401 | if self.error is not None: 402 | raise self.error 403 | return 404 | 405 | def _get_encoding(self): 406 | """Get encoding from HTTP headers or content. 407 | 408 | :returns: encoding or `None` 409 | :rtype: ``unicode`` or ``None`` 410 | 411 | """ 412 | headers = self.raw.info() 413 | encoding = None 414 | 415 | if headers.getparam('charset'): 416 | encoding = headers.getparam('charset') 417 | 418 | # HTTP Content-Type header 419 | for param in headers.getplist(): 420 | if param.startswith('charset='): 421 | encoding = param[8:] 422 | break 423 | 424 | if not self.stream: # Try sniffing response content 425 | # Encoding declared in document should override HTTP headers 426 | if self.mimetype == 'text/html': # sniff HTML headers 427 | m = re.search("""""", 428 | self.content) 429 | if m: 430 | encoding = m.group(1) 431 | 432 | elif ((self.mimetype.startswith('application/') or 433 | self.mimetype.startswith('text/')) and 434 | 'xml' in self.mimetype): 435 | m = re.search("""]*\?>""", 436 | self.content) 437 | if m: 438 | encoding = m.group(1) 439 | 440 | # Format defaults 441 | if self.mimetype == 'application/json' and not encoding: 442 | # The default encoding for JSON 443 | encoding = 'utf-8' 444 | 445 | elif self.mimetype == 'application/xml' and not encoding: 446 | # The default for 'application/xml' 447 | encoding = 'utf-8' 448 | 449 | if encoding: 450 | encoding = encoding.lower() 451 | 452 | return encoding 453 | 454 | 455 | def request(method, url, params=None, data=None, headers=None, cookies=None, 456 | files=None, auth=None, timeout=60, allow_redirects=False, 457 | stream=False): 458 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 459 | 460 | :param method: 'GET' or 'POST' 461 | :type method: ``unicode`` 462 | :param url: URL to open 463 | :type url: ``unicode`` 464 | :param params: mapping of URL parameters 465 | :type params: :class:`dict` 466 | :param data: mapping of form data ``{'field_name': 'value'}`` or 467 | :class:`str` 468 | :type data: :class:`dict` or :class:`str` 469 | :param headers: HTTP headers 470 | :type headers: :class:`dict` 471 | :param cookies: cookies to send to server 472 | :type cookies: :class:`dict` 473 | :param files: files to upload (see below). 474 | :type files: :class:`dict` 475 | :param auth: username, password 476 | :type auth: ``tuple`` 477 | :param timeout: connection timeout limit in seconds 478 | :type timeout: ``int`` 479 | :param allow_redirects: follow redirections 480 | :type allow_redirects: ``Boolean`` 481 | :param stream: Stream content instead of fetching it all at once. 482 | :type stream: ``bool`` 483 | :returns: :class:`Response` object 484 | 485 | 486 | The ``files`` argument is a dictionary:: 487 | 488 | {'fieldname' : { 'filename': 'blah.txt', 489 | 'content': '', 490 | 'mimetype': 'text/plain'} 491 | } 492 | 493 | * ``fieldname`` is the name of the field in the HTML form. 494 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 495 | be used to guess the mimetype, or ``application/octet-stream`` 496 | will be used. 497 | 498 | """ 499 | # TODO: cookies 500 | socket.setdefaulttimeout(timeout) 501 | 502 | # Default handlers 503 | openers = [] 504 | 505 | if not allow_redirects: 506 | openers.append(NoRedirectHandler()) 507 | 508 | if auth is not None: # Add authorisation handler 509 | username, password = auth 510 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 511 | password_manager.add_password(None, url, username, password) 512 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 513 | openers.append(auth_manager) 514 | 515 | # Install our custom chain of openers 516 | opener = urllib2.build_opener(*openers) 517 | urllib2.install_opener(opener) 518 | 519 | if not headers: 520 | headers = CaseInsensitiveDictionary() 521 | else: 522 | headers = CaseInsensitiveDictionary(headers) 523 | 524 | if 'user-agent' not in headers: 525 | headers['user-agent'] = USER_AGENT 526 | 527 | # Accept gzip-encoded content 528 | encodings = [s.strip() for s in 529 | headers.get('accept-encoding', '').split(',')] 530 | if 'gzip' not in encodings: 531 | encodings.append('gzip') 532 | 533 | headers['accept-encoding'] = ', '.join(encodings) 534 | 535 | # Force POST by providing an empty data string 536 | if method == 'POST' and not data: 537 | data = '' 538 | 539 | if files: 540 | if not data: 541 | data = {} 542 | new_headers, data = encode_multipart_formdata(data, files) 543 | headers.update(new_headers) 544 | elif data and isinstance(data, dict): 545 | data = urllib.urlencode(str_dict(data)) 546 | 547 | # Make sure everything is encoded text 548 | headers = str_dict(headers) 549 | 550 | if isinstance(url, unicode): 551 | url = url.encode('utf-8') 552 | 553 | if params: # GET args (POST args are handled in encode_multipart_formdata) 554 | 555 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 556 | 557 | if query: # Combine query string and `params` 558 | url_params = urlparse.parse_qs(query) 559 | # `params` take precedence over URL query string 560 | url_params.update(params) 561 | params = url_params 562 | 563 | query = urllib.urlencode(str_dict(params), doseq=True) 564 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 565 | 566 | req = urllib2.Request(url, data, headers) 567 | return Response(req, stream) 568 | 569 | 570 | def get(url, params=None, headers=None, cookies=None, auth=None, 571 | timeout=60, allow_redirects=True, stream=False): 572 | """Initiate a GET request. Arguments as for :func:`request`. 573 | 574 | :returns: :class:`Response` instance 575 | 576 | """ 577 | return request('GET', url, params, headers=headers, cookies=cookies, 578 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 579 | stream=stream) 580 | 581 | 582 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 583 | auth=None, timeout=60, allow_redirects=False, stream=False): 584 | """Initiate a POST request. Arguments as for :func:`request`. 585 | 586 | :returns: :class:`Response` instance 587 | 588 | """ 589 | return request('POST', url, params, data, headers, cookies, files, auth, 590 | timeout, allow_redirects, stream) 591 | 592 | 593 | def encode_multipart_formdata(fields, files): 594 | """Encode form data (``fields``) and ``files`` for POST request. 595 | 596 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 597 | :type fields: :class:`dict` 598 | :param files: dictionary of fieldnames/files elements for file data. 599 | See below for details. 600 | :type files: :class:`dict` of :class:`dicts` 601 | :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers 602 | :rtype: 2-tuple ``(dict, str)`` 603 | 604 | The ``files`` argument is a dictionary:: 605 | 606 | {'fieldname' : { 'filename': 'blah.txt', 607 | 'content': '', 608 | 'mimetype': 'text/plain'} 609 | } 610 | 611 | - ``fieldname`` is the name of the field in the HTML form. 612 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. 613 | 614 | """ 615 | def get_content_type(filename): 616 | """Return or guess mimetype of ``filename``. 617 | 618 | :param filename: filename of file 619 | :type filename: unicode/string 620 | :returns: mime-type, e.g. ``text/html`` 621 | :rtype: :class::class:`str` 622 | 623 | """ 624 | 625 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 626 | 627 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 628 | for i in range(30)) 629 | CRLF = '\r\n' 630 | output = [] 631 | 632 | # Normal form fields 633 | for (name, value) in fields.items(): 634 | if isinstance(name, unicode): 635 | name = name.encode('utf-8') 636 | if isinstance(value, unicode): 637 | value = value.encode('utf-8') 638 | output.append('--' + boundary) 639 | output.append('Content-Disposition: form-data; name="%s"' % name) 640 | output.append('') 641 | output.append(value) 642 | 643 | # Files to upload 644 | for name, d in files.items(): 645 | filename = d[u'filename'] 646 | content = d[u'content'] 647 | if u'mimetype' in d: 648 | mimetype = d[u'mimetype'] 649 | else: 650 | mimetype = get_content_type(filename) 651 | if isinstance(name, unicode): 652 | name = name.encode('utf-8') 653 | if isinstance(filename, unicode): 654 | filename = filename.encode('utf-8') 655 | if isinstance(mimetype, unicode): 656 | mimetype = mimetype.encode('utf-8') 657 | output.append('--' + boundary) 658 | output.append('Content-Disposition: form-data; ' 659 | 'name="%s"; filename="%s"' % (name, filename)) 660 | output.append('Content-Type: %s' % mimetype) 661 | output.append('') 662 | output.append(content) 663 | 664 | output.append('--' + boundary + '--') 665 | output.append('') 666 | body = CRLF.join(output) 667 | headers = { 668 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 669 | 'Content-Length': str(len(body)), 670 | } 671 | return (headers, body) 672 | --------------------------------------------------------------------------------