├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── requirements-test.txt ├── screenshots ├── demo.gif ├── global-status.jpg ├── machine-actions.png └── notifications.jpg ├── src ├── __init__.py ├── commons.py ├── execute.py ├── icon.png ├── icons │ ├── LICENSE.txt │ ├── actions │ │ ├── destroy.png │ │ ├── halt.png │ │ ├── provision.png │ │ ├── rdp.png │ │ ├── resume.png │ │ ├── ssh.png │ │ ├── suspend.png │ │ └── up.png │ ├── providers │ │ ├── vagrant.png │ │ ├── virtualbox.png │ │ └── vmware-fusion.png │ ├── states │ │ ├── vagrant.missing.png │ │ ├── vagrant.paused.png │ │ ├── vagrant.running.png │ │ ├── vagrant.stopped.png │ │ ├── vagrant.unexpected.png │ │ ├── virtualbox.missing.png │ │ ├── virtualbox.paused.png │ │ ├── virtualbox.running.png │ │ ├── virtualbox.stopped.png │ │ ├── virtualbox.unexpected.png │ │ ├── vmware_fusion.missing.png │ │ ├── vmware_fusion.paused.png │ │ ├── vmware_fusion.running.png │ │ ├── vmware_fusion.stopped.png │ │ └── vmware_fusion.unexpected.png │ └── templates │ │ ├── not-created.png │ │ ├── paused.png │ │ ├── running.png │ │ ├── stopped.png │ │ └── unexpected.png ├── info.plist ├── properties.py ├── vagrant.py ├── vagrantup.py ├── version └── workflow │ ├── Notify.tgz │ ├── __init__.py │ ├── background.py │ ├── notify.py │ ├── update.py │ ├── version │ ├── web.py │ └── workflow.py ├── tests ├── __init__.py ├── test_commons.py └── test_main.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = src/* 3 | omit = 4 | src/workflow/* 5 | 6 | [report] 7 | show_missing = true 8 | exclude_lines = 9 | if __name__ == .__main__.: 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.pyc 3 | *.iml 4 | .tox/ 5 | .coverage 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | env: 4 | - TOX_ENV=py27 5 | - TOX_ENV=flake8 6 | install: 7 | - pip install tox==1.8.1 8 | - pip install coveralls 9 | script: 10 | - tox -e $TOX_ENV 11 | after_success: 12 | - coveralls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2016 Michael Sverdlik 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | **I'm not an active Vagrant user and no longer maintaining this project.** 3 | 4 | 5 | # VagrantUP 6 | [![Build Status](https://travis-ci.org/m1keil/alfred-vagrant-workflow.svg?branch=master)](https://travis-ci.org/m1keil/alfred-vagrant-workflow) [![Coverage Status](https://coveralls.io/repos/m1keil/alfred-vagrant-workflow/badge.svg)](https://coveralls.io/r/m1keil/alfred-vagrant-workflow) 7 | 8 | A workflow for [Alfred2/3](http://www.alfredapp.com) which provides quick control over [Vagrant](vagrantup.com). 9 | 10 | ## Functionality 11 | * List existing Vagrant environments 12 | * Filter environments by name, path or id 13 | * Execute actions on single machines or the whole environment 14 | 15 | ## Demo: 16 | ![Screenshot](screenshots/demo.gif?raw=true "Vagrant global-status") 17 | 18 | ## Downloads & Install 19 | Download this workflow from [GitHub](https://github.com/m1keil/alfred-vagrant-workflow/releases) (recommended) or from [Packal](http://www.packal.org/workflow/vagrantup). 20 | 21 | Install by double clicking the downloaded file. 22 | 23 | For additional instructions about installing workflows, check [Alfred's support](http://support.alfredapp.com/workflows:installing). 24 | 25 | ## Usage 26 | #### List Vagrant environments 27 | To list all existing Vagrant environments, use keyword `vagrant`. 28 | 29 | #### Filtering list 30 | You can filter the list by `machine name`,`environment path` or `machine id`. 31 | 32 | Filtering is done with [fuzzy search](http://en.wikipedia.org/wiki/Approximate_string_matching). 33 | 34 | #### Executing actions 35 | To execute Vagrant commands directly from Alfred just choose the machine and press Enter (or Tab). You will get a list of possible actions for the chosen machine. 36 | 37 | Avaliable actions will vary depending on machine's state. For example, if machine is stopped, you cannot run provision. 38 | 39 | It also possible to run commands on [multi machine Vagrantfile](https://docs.vagrantup.com/v2/multi-machine/index.html). Choose any action and hold the Command key while pressing enter. 40 | 41 | **NOTE:** RDP & SSH actions will use Alfred's default terminal app which is configured in Alfred's setting. 42 | 43 | ## Settings 44 | Because environment variables are not propagated into OS X applications, the workflow comes with its own defaults: 45 | 46 | - **Vagrant Index**: the file where Vagrant stores it's internal state (`~/.vagrant.d/data/machine-index/index` by default) 47 | - **PATH**: the PATH variable specifies a set of directories where executable programs are located. It is where `vagrant` or `VBoxManage` executables are expected to be found (`/usr/bin:/usr/local/bin` by default) 48 | 49 | If your setup is different, you should adjust these settings in the config file. 50 | 51 | To open the config file - open Alfred and type `vagrant workflow:settings`. 52 | 53 | In case you wish to reset setting to default type `vagrant workflow:delsettings`. 54 | 55 | ## Requirements 56 | 1. Python 2.7 (Installed in OS X by default since 10.7) 57 | 2. Alfred 2/3 58 | 3. Vagrant (Preferebly 1.7+) 59 | 60 | ## Troubleshooting 61 | In case something isn't right, check the logs by typing `vagrant workflow:openlog`. 62 | I assume most of the issues will occur due to path variables. 63 | 64 | Feel free to submit bug reports in the issue tracker, please include as much details as possible with logs. 65 | 66 | ## Special Thanks 67 | Special thanks to **Deanishe** and his awesome [alfred-workflow](http://www.deanishe.net/alfred-workflow/index.html) library which does most of the heavy lifting here. 68 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | mock==2.0.0 2 | nose==1.3.7 3 | flake8==2.5.4 4 | tox==2.3.1 5 | coverage==4.1.0 6 | -------------------------------------------------------------------------------- /screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/screenshots/demo.gif -------------------------------------------------------------------------------- /screenshots/global-status.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/screenshots/global-status.jpg -------------------------------------------------------------------------------- /screenshots/machine-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/screenshots/machine-actions.png -------------------------------------------------------------------------------- /screenshots/notifications.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/screenshots/notifications.jpg -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/__init__.py -------------------------------------------------------------------------------- /src/commons.py: -------------------------------------------------------------------------------- 1 | import os 2 | from workflow import Workflow 3 | from subprocess import call 4 | 5 | logger = Workflow().logger 6 | 7 | 8 | def external_trigger(name, argument): 9 | """ 10 | Call to external trigger in Alfred. 11 | 12 | This utilize apple script functionality to trigger in Alfred. 13 | 14 | 15 | Args: 16 | name (str): Name of the trigger. 17 | argument: Argument to the trigger. 18 | 19 | Returns: 20 | int: Return code from osascript exec 21 | """ 22 | major_version = os.environ['alfred_version'].split('.')[0] 23 | 24 | osascript = 'tell application "Alfred {version}" to run trigger ' \ 25 | '"{name}" in workflow "{uuid}" with argument "{arg}"' \ 26 | .format(version=major_version, 27 | name=name, 28 | uuid=os.environ['alfred_workflow_bundleid'], 29 | arg=argument) 30 | 31 | cmd = ['/usr/bin/osascript', '-e', osascript] 32 | logger.debug('Sending notification: {0}'.format(cmd)) 33 | return call(cmd) 34 | 35 | 36 | def send_notification(msg): 37 | """ 38 | Trigger notification with msg as content. 39 | 40 | Args: 41 | msg (str): Notification message. 42 | """ 43 | external_trigger('send_notification', msg) 44 | 45 | 46 | def run_vagrant(arg): 47 | """ 48 | Trigger running Vagrant in terminal. 49 | 50 | Args: 51 | arg (str): Vagrant command line arguments in one string. 52 | """ 53 | external_trigger('run_vagrant', arg) 54 | 55 | 56 | def opensettings(workflow_settings): 57 | """ 58 | Open settings.json file with system's default editor 59 | Args: 60 | workflow_settings: settings.json file path 61 | """ 62 | call(['/usr/bin/open', workflow_settings]) 63 | -------------------------------------------------------------------------------- /src/execute.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from subprocess import Popen, PIPE, STDOUT 3 | 4 | from workflow import Workflow 5 | from commons import send_notification 6 | 7 | 8 | def spawn_process(action, flags=None, machine_name=None): 9 | """ 10 | Spaws subprocess. 11 | 12 | Args: 13 | action (str): Vagrant action. 14 | flags (optional): An iterable of vagrant command flags. 15 | machine_name (optional[str]): Vagrant machine name. 16 | 17 | Returns: 18 | Popen: Popen instance 19 | """ 20 | command = ['vagrant', action] 21 | 22 | if flags: 23 | command += ['-{0}'.format(flag) for flag in flags] 24 | 25 | if machine_name: 26 | command.append(machine_name) 27 | 28 | logger.debug('Calling: %s', command) 29 | return Popen(command, stdout=PIPE, stderr=STDOUT) 30 | 31 | 32 | def parse_process_output(process): 33 | """ 34 | Logs subprocess's stdout. 35 | 36 | Args: 37 | process (Popen): the process the log output from. 38 | """ 39 | while True: 40 | line = process.stdout.readline() 41 | if not line: 42 | break 43 | logger.debug(line.strip()) 44 | 45 | 46 | def parse_arguments(): 47 | """ 48 | Parse command line argument and return argparse.Namespace object. 49 | 50 | Returns: 51 | argparse.Namespace: Parsed arguments Namespace object. 52 | """ 53 | 54 | description = """ 55 | This will run Vagrant executable. 56 | Works under the assumption that the PATH & HOME variables are set 57 | correctly and that CWD is relevant Vagrant environmnet directory. 58 | """ 59 | 60 | parser = ArgumentParser(description=description) 61 | 62 | parser.add_argument('-a', '--action', 63 | required=True, 64 | help='Vagrant action to execute') 65 | 66 | parser.add_argument('-f', '--flags', 67 | nargs='?', 68 | action='append', 69 | default=[], 70 | help='Vagrant command flags') 71 | 72 | parser.add_argument('-n', '--name', help='Vagrant machine name') 73 | 74 | return parser.parse_args() 75 | 76 | 77 | def main(): 78 | """ 79 | Main program entry point. Parse command line, execute subprocess and 80 | send notification at the end. 81 | """ 82 | args = parse_arguments() 83 | 84 | process = spawn_process(action=args.action, 85 | flags=args.flags, 86 | machine_name=args.name) 87 | parse_process_output(process) 88 | 89 | return_code = process.wait() 90 | logger.debug('Return code: %s', return_code) 91 | message = 'finished succesfully' if return_code == 0 else 'failed' 92 | 93 | send_notification('{0} {1}'.format(args.action, message)) 94 | 95 | 96 | if __name__ == '__main__': 97 | logger = Workflow().logger 98 | main() 99 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icon.png -------------------------------------------------------------------------------- /src/icons/LICENSE.txt: -------------------------------------------------------------------------------- 1 | All png files under icons/actions and icons/template folders are licensed under 2 | Creative Commons Attribution 3.0 License and were taken from http://www.fatcow.com/free-icons. 3 | 4 | Virtualbox logo under icons/providers/virtualbox.png is licensed and distributed under GPLv2 license. 5 | 6 | VMWare Fusion logo under icons/providers/vmware-fusion.png is public domain. 7 | 8 | Vagrant logo under icons/providers/vagrant.png is licensed under 9 | Creative Commons Attribution ShareAlike 3.0 Unported (CC BY-SA 3.0) 10 | 11 | 12 | All png files under icons/states folder are mix of the the logos in the providers and templates folders. -------------------------------------------------------------------------------- /src/icons/actions/destroy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/destroy.png -------------------------------------------------------------------------------- /src/icons/actions/halt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/halt.png -------------------------------------------------------------------------------- /src/icons/actions/provision.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/provision.png -------------------------------------------------------------------------------- /src/icons/actions/rdp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/rdp.png -------------------------------------------------------------------------------- /src/icons/actions/resume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/resume.png -------------------------------------------------------------------------------- /src/icons/actions/ssh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/ssh.png -------------------------------------------------------------------------------- /src/icons/actions/suspend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/suspend.png -------------------------------------------------------------------------------- /src/icons/actions/up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/actions/up.png -------------------------------------------------------------------------------- /src/icons/providers/vagrant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/providers/vagrant.png -------------------------------------------------------------------------------- /src/icons/providers/virtualbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/providers/virtualbox.png -------------------------------------------------------------------------------- /src/icons/providers/vmware-fusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/providers/vmware-fusion.png -------------------------------------------------------------------------------- /src/icons/states/vagrant.missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vagrant.missing.png -------------------------------------------------------------------------------- /src/icons/states/vagrant.paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vagrant.paused.png -------------------------------------------------------------------------------- /src/icons/states/vagrant.running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vagrant.running.png -------------------------------------------------------------------------------- /src/icons/states/vagrant.stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vagrant.stopped.png -------------------------------------------------------------------------------- /src/icons/states/vagrant.unexpected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vagrant.unexpected.png -------------------------------------------------------------------------------- /src/icons/states/virtualbox.missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/virtualbox.missing.png -------------------------------------------------------------------------------- /src/icons/states/virtualbox.paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/virtualbox.paused.png -------------------------------------------------------------------------------- /src/icons/states/virtualbox.running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/virtualbox.running.png -------------------------------------------------------------------------------- /src/icons/states/virtualbox.stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/virtualbox.stopped.png -------------------------------------------------------------------------------- /src/icons/states/virtualbox.unexpected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/virtualbox.unexpected.png -------------------------------------------------------------------------------- /src/icons/states/vmware_fusion.missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vmware_fusion.missing.png -------------------------------------------------------------------------------- /src/icons/states/vmware_fusion.paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vmware_fusion.paused.png -------------------------------------------------------------------------------- /src/icons/states/vmware_fusion.running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vmware_fusion.running.png -------------------------------------------------------------------------------- /src/icons/states/vmware_fusion.stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vmware_fusion.stopped.png -------------------------------------------------------------------------------- /src/icons/states/vmware_fusion.unexpected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/states/vmware_fusion.unexpected.png -------------------------------------------------------------------------------- /src/icons/templates/not-created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/templates/not-created.png -------------------------------------------------------------------------------- /src/icons/templates/paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/templates/paused.png -------------------------------------------------------------------------------- /src/icons/templates/running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/templates/running.png -------------------------------------------------------------------------------- /src/icons/templates/stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/templates/stopped.png -------------------------------------------------------------------------------- /src/icons/templates/unexpected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/icons/templates/unexpected.png -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.sverdlik.michael 7 | connections 8 | 9 | 51FBDBDC-00D2-47AF-8220-033DF6DDE63C 10 | 11 | 12 | destinationuid 13 | 0C730F61-1CAC-4DE8-80D3-334F826497C8 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | destinationuid 23 | E3FAB9B5-2CD6-4138-941D-2C0616C8D7F4 24 | modifiers 25 | 1048576 26 | modifiersubtext 27 | 28 | vitoclose 29 | 30 | 31 | 32 | A61FF101-9C27-41CB-88B3-BDDCF17EEFC5 33 | 34 | 35 | destinationuid 36 | BBFC6352-C982-4965-978B-CE120119E021 37 | modifiers 38 | 0 39 | modifiersubtext 40 | 41 | vitoclose 42 | 43 | 44 | 45 | A7439BA6-7126-4350-9302-E969C163AA89 46 | 47 | 48 | destinationuid 49 | 2CB3B3C6-06C2-45BD-9010-D5A83EC4440B 50 | modifiers 51 | 0 52 | modifiersubtext 53 | 54 | vitoclose 55 | 56 | 57 | 58 | 59 | createdby 60 | Michael Sverdlik 61 | description 62 | List and control Vagrant environments with Alfred2/3 63 | disabled 64 | 65 | name 66 | VagrantUP 67 | objects 68 | 69 | 70 | config 71 | 72 | concurrently 73 | 74 | escaping 75 | 118 76 | script 77 | /usr/bin/python vagrantup.py --machine {query} 78 | scriptargtype 79 | 0 80 | scriptfile 81 | 82 | type 83 | 0 84 | 85 | type 86 | alfred.workflow.action.script 87 | uid 88 | 0C730F61-1CAC-4DE8-80D3-334F826497C8 89 | version 90 | 2 91 | 92 | 93 | config 94 | 95 | alfredfiltersresults 96 | 97 | argumenttype 98 | 1 99 | escaping 100 | 118 101 | keyword 102 | vagrant 103 | queuedelaycustom 104 | 3 105 | queuedelayimmediatelyinitially 106 | 107 | queuedelaymode 108 | 0 109 | queuemode 110 | 1 111 | runningsubtext 112 | Loading.. 113 | script 114 | /usr/bin/python vagrantup.py --list {query} 115 | scriptargtype 116 | 0 117 | scriptfile 118 | 119 | subtext 120 | Show Vagrant environments 121 | title 122 | VagrantUP 123 | type 124 | 0 125 | withspace 126 | 127 | 128 | type 129 | alfred.workflow.input.scriptfilter 130 | uid 131 | 51FBDBDC-00D2-47AF-8220-033DF6DDE63C 132 | version 133 | 2 134 | 135 | 136 | config 137 | 138 | concurrently 139 | 140 | escaping 141 | 118 142 | script 143 | /usr/bin/python vagrantup.py --env {query} 144 | scriptargtype 145 | 0 146 | scriptfile 147 | 148 | type 149 | 0 150 | 151 | type 152 | alfred.workflow.action.script 153 | uid 154 | E3FAB9B5-2CD6-4138-941D-2C0616C8D7F4 155 | version 156 | 2 157 | 158 | 159 | config 160 | 161 | triggerid 162 | send_notification 163 | 164 | type 165 | alfred.workflow.trigger.external 166 | uid 167 | A61FF101-9C27-41CB-88B3-BDDCF17EEFC5 168 | version 169 | 1 170 | 171 | 172 | config 173 | 174 | lastpathcomponent 175 | 176 | onlyshowifquerypopulated 177 | 178 | removeextension 179 | 180 | text 181 | {query} 182 | title 183 | VagrantUP 184 | 185 | type 186 | alfred.workflow.output.notification 187 | uid 188 | BBFC6352-C982-4965-978B-CE120119E021 189 | version 190 | 1 191 | 192 | 193 | config 194 | 195 | escaping 196 | 0 197 | script 198 | clear; vagrant {query} 199 | 200 | type 201 | alfred.workflow.action.terminalcommand 202 | uid 203 | 2CB3B3C6-06C2-45BD-9010-D5A83EC4440B 204 | version 205 | 1 206 | 207 | 208 | config 209 | 210 | triggerid 211 | run_vagrant 212 | 213 | type 214 | alfred.workflow.trigger.external 215 | uid 216 | A7439BA6-7126-4350-9302-E969C163AA89 217 | version 218 | 1 219 | 220 | 221 | readme 222 | A workflow for Alfred2/3 which provides quick control over Vagrant. 223 | uidata 224 | 225 | 0C730F61-1CAC-4DE8-80D3-334F826497C8 226 | 227 | xpos 228 | 700 229 | ypos 230 | 20 231 | 232 | 2CB3B3C6-06C2-45BD-9010-D5A83EC4440B 233 | 234 | xpos 235 | 500 236 | ypos 237 | 480 238 | 239 | 51FBDBDC-00D2-47AF-8220-033DF6DDE63C 240 | 241 | xpos 242 | 300 243 | ypos 244 | 130 245 | 246 | A61FF101-9C27-41CB-88B3-BDDCF17EEFC5 247 | 248 | xpos 249 | 100 250 | ypos 251 | 360 252 | 253 | A7439BA6-7126-4350-9302-E969C163AA89 254 | 255 | xpos 256 | 100 257 | ypos 258 | 480 259 | 260 | BBFC6352-C982-4965-978B-CE120119E021 261 | 262 | xpos 263 | 700 264 | ypos 265 | 360 266 | 267 | E3FAB9B5-2CD6-4138-941D-2C0616C8D7F4 268 | 269 | xpos 270 | 700 271 | ypos 272 | 180 273 | 274 | 275 | version 276 | 2.0.3 277 | webaddress 278 | https://github.com/m1keil/alfred-vagrant-workflow 279 | 280 | 281 | -------------------------------------------------------------------------------- /src/properties.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Dict contains all Vagrant actions this workflow supports and metadata: 4 | # --- 5 | # 'desc': description field showed in alfred 6 | # 'flags': additional command line flags for vagrant command (without hyphen) 7 | # 'state': machine state for which this action needs to be showed 8 | # 'confirm': flag to mark if action needs to be confirmed before execution 9 | actions = { 10 | 'up': { 11 | 'desc': 'Starts and provisions the vagrant environment', 12 | 'state': ['paused', 'stopped', 'unexpected'], 13 | }, 14 | 'halt': { 15 | 'desc': 'Stops the machine', 16 | 'state': ['running', 'paused'], 17 | }, 18 | 'resume': { 19 | 'desc': 'Resume a suspended machine', 20 | 'state': ['paused'], 21 | }, 22 | 'suspend': { 23 | 'desc': 'Suspends the machine', 24 | 'state': ['running'], 25 | }, 26 | 'provision': { 27 | 'desc': 'Provisions the machine', 28 | 'state': ['running'], 29 | }, 30 | 'rdp': { 31 | 'desc': 'Connects to machine via RDP', 32 | 'state': ['running'], 33 | }, 34 | 'ssh': { 35 | 'desc': 'Connects to machine via SSH', 36 | 'state': ['running'], 37 | }, 38 | 'destroy': { 39 | 'desc': 'Stops and deletes all traces of the machine', 40 | 'flags': ['f'], 41 | 'state': ['running', 'paused', 'stopped', 'unexpected'], 42 | 'confirm': True, 43 | } 44 | } 45 | 46 | # Normalization dictionary 47 | states = { 48 | ('running', 'up', 'on'): 'running', 49 | ('paused', 'suspended', 'saved'): 'paused', 50 | ('stopped', 'poweroff', 'not running', 'down'): 'stopped', 51 | 'not created': 'missing', 52 | } 53 | 54 | # Modifiers 55 | modifiers = { 56 | 'cmd': 'Run command on the whole environment' 57 | } 58 | 59 | # Default paths used by the workflow 60 | # - VAGRANT_HOME is the path where vagrant stores it's state. 61 | # By default it's ~/.vagrant.d however it's user configurable 62 | # - PATH contains the PATH variable which will be set at subprocess call 63 | path = { 64 | 'INDEX': os.path.expanduser('~/.vagrant.d/data/machine-index/index'), 65 | 'VAR': '/usr/bin:/usr/local/bin:/sbin' 66 | } 67 | -------------------------------------------------------------------------------- /src/vagrant.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from commons import run_vagrant 5 | from properties import actions, states 6 | 7 | from workflow import Workflow 8 | from workflow.background import run_in_background 9 | 10 | ICONS_STATES_PATH = os.path.join(Workflow().workflowdir, 'icons', 'states') 11 | ICONS_ACTION_PATH = os.path.join(Workflow().workflowdir, 'icons', 'actions') 12 | 13 | logger = Workflow().logger 14 | 15 | 16 | class Machine: 17 | """ 18 | A Vagrant machine. 19 | """ 20 | def __init__(self, **kwargs): 21 | self.key = kwargs['key'] 22 | self.name = kwargs['name'] 23 | self.provider = kwargs['provider'] 24 | self.state = kwargs['state'] 25 | self.vagrantfile_path = kwargs['vagrantfile_path'] 26 | self.normalized_state = self.normalize_state(self.state) 27 | 28 | @property 29 | def actions(self): 30 | def f(state): 31 | return self.normalized_state in state 32 | 33 | return [Action(action) for action, props in actions.iteritems() 34 | if f(props['state'])] 35 | 36 | @property 37 | def icon(self): 38 | icon = os.path.join(ICONS_STATES_PATH, '{0}.{1}.png' 39 | .format(self.provider, self.normalized_state)) 40 | default = os.path.join(ICONS_STATES_PATH, 'vagrant.{0}.png' 41 | .format(self.normalized_state)) 42 | 43 | if os.path.isfile(icon): 44 | return icon 45 | elif os.path.isfile(default): 46 | return default 47 | else: 48 | return None 49 | 50 | @staticmethod 51 | def normalize_state(state): 52 | for states_tup, output in states.iteritems(): 53 | if state in states_tup: 54 | return output 55 | 56 | return 'unexpected' 57 | 58 | def run(self, action, env=False): 59 | action = Action(action) 60 | if not filter(lambda x: x.name == action.name, self.actions): 61 | raise Exception('Action {0} not found. Instance changed state?' 62 | .format(action)) 63 | 64 | task_name = 'exec{0}'.format(hash(self.vagrantfile_path)) 65 | 66 | if action.name in ('rdp', 'ssh'): 67 | run_vagrant('{action} {machine_id}' 68 | .format(action=action.name, machine_id=self.key)) 69 | return 70 | 71 | cur_dir = os.path.dirname(os.path.realpath(__file__)) 72 | exec_path = os.path.join(cur_dir, 'execute.py') 73 | cmd = ['python', exec_path, '--action', action.name] 74 | 75 | if action.flags: 76 | cmd += ['-f'] + action.flags 77 | if not env: 78 | cmd += ['--name', self.name] 79 | logger.debug('Running in background: %s', cmd) 80 | 81 | new_env = os.environ.copy() 82 | new_env['HOME'] = os.path.expanduser('~') 83 | new_env['PATH'] = Workflow().settings['PATH']['VAR'] 84 | run_in_background(task_name, 85 | cmd, 86 | env=new_env, 87 | cwd=self.vagrantfile_path) 88 | 89 | def __call__(self, query=None): 90 | if not query: 91 | return self.actions 92 | return Workflow().filter(query=query, 93 | items=self.actions, 94 | key=lambda x: x.name) 95 | 96 | 97 | class Action: 98 | """ 99 | A Vagrant machine action. 100 | """ 101 | def __init__(self, action): 102 | if action not in actions: 103 | raise Exception('Unknown action type %s' % action) 104 | 105 | self.name = action 106 | self.description = actions[action].get('desc', '') 107 | self.flags = actions[action].get('flags', None) 108 | self.dir_action = actions[action].get('dir_action', True) 109 | self.confirm = actions[action].get('confirm', False) 110 | 111 | @property 112 | def icon(self): 113 | icon = os.path.join(ICONS_ACTION_PATH, '{0}.png'.format(self.name)) 114 | if os.path.isfile(icon): 115 | return icon 116 | else: 117 | return None 118 | 119 | 120 | class Index: 121 | """ 122 | A Vagrant Index. 123 | """ 124 | @staticmethod 125 | def parse_v1_machines(mdict): 126 | return {key: Machine(key=key, **val) for key, val in mdict.iteritems()} 127 | 128 | def __init__(self, fh): 129 | content = json.load(fh) 130 | 131 | self.version = content['version'] 132 | if self.version == 1: 133 | self.machines = self.parse_v1_machines(content['machines']) 134 | # TODO: naive assumption that length of 8 is enough 135 | self.test = {key[0:8]: key for key in self.machines.iterkeys()} 136 | else: 137 | raise ValueError('Vagrant index version {0} is not supported' 138 | .format(self.version)) 139 | 140 | def __iter__(self): 141 | return self.machines.iteritems() 142 | 143 | def __call__(self, query=None): 144 | if not query: 145 | return self.__iter__() 146 | 147 | def key(x): 148 | return x[1].name + x[1].vagrantfile_path + x[1].key 149 | 150 | return Workflow().filter(query=query, 151 | items=self.__iter__(), 152 | key=key) 153 | 154 | def __getitem__(self, machine_id): 155 | if machine_id in self.machines: 156 | return self.machines[machine_id] 157 | elif machine_id in self.test: 158 | return self.machines[self.test[machine_id]] 159 | else: 160 | raise ValueError('Machine {0} not found'.format(machine_id)) 161 | -------------------------------------------------------------------------------- /src/vagrantup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # coding=utf-8 3 | from __future__ import unicode_literals 4 | 5 | import sys 6 | from argparse import ArgumentParser 7 | 8 | from vagrant import Index 9 | from properties import modifiers, path 10 | from commons import opensettings 11 | from workflow import Workflow, ICON_WARNING, ICON_INFO 12 | from workflow.background import is_running 13 | 14 | 15 | logger = None 16 | SEP = '►' 17 | 18 | WORKFLOW_URL = 'https://github.com/m1keil/alfred-vagrant-workflow' 19 | 20 | 21 | def add_warning(wf, machine_id, action): 22 | """ 23 | Add warning item to Workflow object. 24 | 25 | Args: 26 | wf (Workflow): Workflow object. 27 | machine_id (unicode): Vagrant's machine ID. 28 | action (unicode): Vagrant's action. 29 | """ 30 | wf.add_item(title='Are you sure?', 31 | subtitle='This action is not recoverable', 32 | modifier_subtitles=modifiers, 33 | arg='{action} {mid}'.format(mid=machine_id, action=action), 34 | icon=ICON_WARNING, 35 | valid=True) 36 | 37 | 38 | def add_machines(wf, query=None): 39 | """ 40 | Add machine items to Workflow object. If query provided, filter out items 41 | by fuzzy searching with the query string. 42 | 43 | Args: 44 | wf (Workflow): Workflow object. 45 | query (optional[unicode]): Fuzzy search query. 46 | """ 47 | with open(wf.settings['PATH']['INDEX']) as fh: 48 | vi = Index(fh) 49 | 50 | for machine_id, machine in vi(query): 51 | autocomplete = '{mid} {sep} '.format(mid=machine_id[0:8], sep=SEP) 52 | wf.add_item(title=machine.name, 53 | subtitle=machine.vagrantfile_path, 54 | autocomplete=autocomplete, 55 | icon=machine.icon, 56 | valid=False) 57 | 58 | 59 | def add_actions(wf, machine_id, query=None): 60 | """ 61 | Add action items to Workflow object. If query is provided, filter out 62 | items by fuzzy searching with the query string. 63 | 64 | Args: 65 | wf (Workflow): Workflow object. 66 | machine_id (unicode): Vagrant's machine ID. 67 | query (optional[unicode]): Fuzzy search query. 68 | """ 69 | with open(wf.settings['PATH']['INDEX']) as fh: 70 | vi = Index(fh) 71 | 72 | machine = vi[machine_id] 73 | task_name = 'exec{0}'.format(hash(machine.vagrantfile_path)) 74 | 75 | if is_running(task_name): 76 | subtitle = 'A task already running on {env_path} environemnt' \ 77 | ''.format(env_path=machine.vagrantfile_path) 78 | wf.add_item(title='Please wait..', 79 | subtitle=subtitle, 80 | icon=ICON_INFO, 81 | valid=False) 82 | else: 83 | for action in machine(query): 84 | autocomplete = '{mid} {sep} {action}'.format(mid=machine_id, 85 | sep=SEP, 86 | action=action.name) 87 | if action.confirm: 88 | autocomplete += ' {sep} '.format(sep=SEP) 89 | 90 | wf.add_item(title=action.name, 91 | subtitle=action.description, 92 | modifier_subtitles=modifiers, 93 | autocomplete=autocomplete, 94 | arg='{action} {mid}'.format(mid=machine_id, 95 | action=action.name), 96 | icon=action.icon, 97 | valid=not action.confirm) 98 | 99 | 100 | def do_list(wf, args): 101 | """ 102 | Depanding on the arguments, will run the appropriate functions to add 103 | machine, action or warning item(s) to Workflow object. 104 | 105 | Args: 106 | wf (Workflow): Workflow object. 107 | args (list): List of arguments. 108 | """ 109 | def _safe_get(l, i): 110 | try: 111 | return l[i] 112 | except IndexError: 113 | return None 114 | 115 | count = args.count(SEP) 116 | if count == 0: 117 | add_machines(wf, _safe_get(args, 0)) 118 | elif count == 1: 119 | add_actions(wf, args[0], _safe_get(args, 2)) 120 | else: 121 | add_warning(wf, args[0], _safe_get(args, 2)) 122 | 123 | 124 | def do_execute(wf, args, env=False): 125 | """ 126 | Executes Vagrant's commands. If env is True, execute command on the entire 127 | Vagrant environemnt. 128 | 129 | Args: 130 | wf (Workflow): Workflow object. 131 | args (list): A list in the form of [action, machine_id]. 132 | env (bool): If True, execute comman on the entire environment. Else 133 | execute only on the machine. 134 | """ 135 | action, machine_id = args 136 | with open(wf.settings['PATH']['INDEX']) as fh: 137 | vi = Index(fh) 138 | machine = vi[machine_id] 139 | machine.run(action, env) 140 | 141 | 142 | def parse_args(args): 143 | """ 144 | Parse command line argument and return parsed Namespace object. 145 | 146 | Args: 147 | args (list): List of arguments. 148 | 149 | Returns: 150 | argparse.Namespace: Parsed arguments Namespace object. 151 | """ 152 | parser = ArgumentParser() 153 | group = parser.add_mutually_exclusive_group(required=True) 154 | group.add_argument('--list', 155 | nargs='*', 156 | metavar='QUERY', 157 | help='List Vagrant machines and actions. ' 158 | 'If %(metavar)s is provided, will filter results ' 159 | 'by fuzzy searching.') 160 | group.add_argument('--machine', 161 | nargs=2, 162 | metavar=('COMMAND', 'ID'), 163 | help='Execute command on specific machine.') 164 | group.add_argument('--env', 165 | nargs=2, 166 | metavar=('COMMAND', 'ID'), 167 | help='Execute command on entire environment.') 168 | 169 | return parser.parse_args(args) 170 | 171 | 172 | def main(wf): 173 | """ 174 | Main program entry point. 175 | 176 | Args: 177 | wf (Workflow): Workflow object. 178 | """ 179 | args = parse_args(wf.args) 180 | if args.machine: 181 | do_execute(wf, args.machine) 182 | elif args.env: 183 | do_execute(wf, args.env, env=True) 184 | else: 185 | do_list(wf, args.list) 186 | wf.send_feedback() 187 | 188 | if __name__ == '__main__': 189 | wf = Workflow(help_url=WORKFLOW_URL, default_settings={'PATH': path}) 190 | 191 | wf.magic_arguments['settings'] = lambda: opensettings(wf.settings_path) 192 | 193 | logger = wf.logger 194 | sys.exit(wf.run(main)) 195 | -------------------------------------------------------------------------------- /src/version: -------------------------------------------------------------------------------- 1 | 2.0.3 -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /src/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """ 12 | A Python helper library for `Alfred 2 `_ Workflow 13 | authors. 14 | """ 15 | 16 | import os 17 | 18 | __title__ = 'Alfred-Workflow' 19 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 20 | __author__ = 'Dean Jackson' 21 | __licence__ = 'MIT' 22 | __copyright__ = 'Copyright 2014 Dean Jackson' 23 | 24 | 25 | # Workflow objects 26 | from .workflow import Workflow, manager 27 | 28 | # Exceptions 29 | from .workflow import PasswordNotFound, KeychainError 30 | 31 | # Icons 32 | from .workflow import ( 33 | ICON_ACCOUNT, 34 | ICON_BURN, 35 | ICON_CLOCK, 36 | ICON_COLOR, 37 | ICON_COLOUR, 38 | ICON_EJECT, 39 | ICON_ERROR, 40 | ICON_FAVORITE, 41 | ICON_FAVOURITE, 42 | ICON_GROUP, 43 | ICON_HELP, 44 | ICON_HOME, 45 | ICON_INFO, 46 | ICON_NETWORK, 47 | ICON_NOTE, 48 | ICON_SETTINGS, 49 | ICON_SWIRL, 50 | ICON_SWITCH, 51 | ICON_SYNC, 52 | ICON_TRASH, 53 | ICON_USER, 54 | ICON_WARNING, 55 | ICON_WEB, 56 | ) 57 | 58 | # Filter matching rules 59 | from .workflow import ( 60 | MATCH_ALL, 61 | MATCH_ALLCHARS, 62 | MATCH_ATOM, 63 | MATCH_CAPITALS, 64 | MATCH_INITIALS, 65 | MATCH_INITIALS_CONTAIN, 66 | MATCH_INITIALS_STARTSWITH, 67 | MATCH_STARTSWITH, 68 | MATCH_SUBSTRING, 69 | ) 70 | 71 | __all__ = [ 72 | 'Workflow', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """ 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 | -------------------------------------------------------------------------------- /src/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the 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 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """ 13 | Self-updating from GitHub 14 | 15 | .. versionadded:: 1.9 16 | 17 | .. note:: 18 | 19 | This module is not intended to be used directly. Automatic updates 20 | are controlled by the ``update_settings`` :class:`dict` passed to 21 | :class:`~workflow.workflow.Workflow` objects. 22 | 23 | """ 24 | 25 | from __future__ import print_function, unicode_literals 26 | 27 | import os 28 | import tempfile 29 | import re 30 | import subprocess 31 | 32 | import workflow 33 | import web 34 | 35 | # __all__ = [] 36 | 37 | 38 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' 39 | 40 | 41 | _wf = None 42 | 43 | 44 | def wf(): 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 | 58 | #: Match version and pre-release/build information in version strings 59 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 60 | 61 | def __init__(self, vstr): 62 | self.vstr = vstr 63 | self.major = 0 64 | self.minor = 0 65 | self.patch = 0 66 | self.suffix = '' 67 | self.build = '' 68 | self._parse(vstr) 69 | 70 | def _parse(self, vstr): 71 | if vstr.startswith('v'): 72 | m = self.match_version(vstr[1:]) 73 | else: 74 | m = self.match_version(vstr) 75 | if not m: 76 | raise ValueError('Invalid version number: {0}'.format(vstr)) 77 | 78 | version, suffix = m.groups() 79 | parts = self._parse_dotted_string(version) 80 | self.major = parts.pop(0) 81 | if len(parts): 82 | self.minor = parts.pop(0) 83 | if len(parts): 84 | self.patch = parts.pop(0) 85 | if not len(parts) == 0: 86 | raise ValueError('Invalid version (too long) : {0}'.format(vstr)) 87 | 88 | if suffix: 89 | # Build info 90 | idx = suffix.find('+') 91 | if idx > -1: 92 | self.build = suffix[idx+1:] 93 | suffix = suffix[:idx] 94 | if suffix: 95 | if not suffix.startswith('-'): 96 | raise ValueError( 97 | 'Invalid suffix : `{0}`. Must start with `-`'.format( 98 | suffix)) 99 | self.suffix = suffix[1:] 100 | 101 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 102 | 103 | def _parse_dotted_string(self, s): 104 | """Parse string ``s`` into list of ints and strings""" 105 | parsed = [] 106 | parts = s.split('.') 107 | for p in parts: 108 | if p.isdigit(): 109 | p = int(p) 110 | parsed.append(p) 111 | return parsed 112 | 113 | @property 114 | def tuple(self): 115 | """Version number as a tuple of major, minor, patch, pre-release""" 116 | 117 | return (self.major, self.minor, self.patch, self.suffix) 118 | 119 | def __lt__(self, other): 120 | if not isinstance(other, Version): 121 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 122 | t = self.tuple[:3] 123 | o = other.tuple[:3] 124 | if t < o: 125 | return True 126 | if t == o: # We need to compare suffixes 127 | if self.suffix and not other.suffix: 128 | return True 129 | if other.suffix and not self.suffix: 130 | return False 131 | return (self._parse_dotted_string(self.suffix) < 132 | self._parse_dotted_string(other.suffix)) 133 | # t > o 134 | return False 135 | 136 | def __eq__(self, other): 137 | if not isinstance(other, Version): 138 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 139 | return self.tuple == other.tuple 140 | 141 | def __ne__(self, other): 142 | return not self.__eq__(other) 143 | 144 | def __gt__(self, other): 145 | if not isinstance(other, Version): 146 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 147 | return other.__lt__(self) 148 | 149 | def __le__(self, other): 150 | if not isinstance(other, Version): 151 | raise ValueError('Not a Version instance: {0!r}'.format(other)) 152 | return not other.__lt__(self) 153 | 154 | def __ge__(self, other): 155 | return not self.__lt__(other) 156 | 157 | def __str__(self): 158 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 159 | if self.suffix: 160 | vstr += '-{0}'.format(self.suffix) 161 | if self.build: 162 | vstr += '+{0}'.format(self.build) 163 | return vstr 164 | 165 | def __repr__(self): 166 | return "Version('{0}')".format(str(self)) 167 | 168 | 169 | def download_workflow(url): 170 | """Download workflow at ``url`` to a local temporary file 171 | 172 | :param url: URL to .alfredworkflow file in GitHub repo 173 | :returns: path to downloaded file 174 | 175 | """ 176 | 177 | filename = url.split("/")[-1] 178 | 179 | if (not url.endswith('.alfredworkflow') or 180 | not filename.endswith('.alfredworkflow')): 181 | raise ValueError('Attachment `{0}` not a workflow'.format(filename)) 182 | 183 | local_path = os.path.join(tempfile.gettempdir(), filename) 184 | 185 | wf().logger.debug( 186 | 'Downloading updated workflow from `{0}` to `{1}` ...'.format( 187 | url, local_path)) 188 | 189 | response = web.get(url) 190 | 191 | with open(local_path, 'wb') as output: 192 | output.write(response.content) 193 | 194 | return local_path 195 | 196 | 197 | def build_api_url(slug): 198 | """Generate releases URL from GitHub slug 199 | 200 | :param slug: Repo name in form ``username/repo`` 201 | :returns: URL to the API endpoint for the repo's releases 202 | 203 | """ 204 | 205 | if len(slug.split('/')) != 2: 206 | raise ValueError('Invalid GitHub slug : {0}'.format(slug)) 207 | 208 | return RELEASES_BASE.format(slug) 209 | 210 | 211 | def get_valid_releases(github_slug, prereleases=False): 212 | """Return list of all valid releases 213 | 214 | :param github_slug: ``username/repo`` for workflow's GitHub repo 215 | :param prereleases: Whether to include pre-releases. 216 | :returns: list of dicts. Each :class:`dict` has the form 217 | ``{'version': '1.1', 'download_url': 'http://github.com/...', 218 | 'prerelease': False }`` 219 | 220 | 221 | A valid release is one that contains one ``.alfredworkflow`` file. 222 | 223 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading 224 | ``v`` will be stripped. 225 | 226 | """ 227 | 228 | api_url = build_api_url(github_slug) 229 | releases = [] 230 | 231 | wf().logger.debug('Retrieving releases list from `{0}` ...'.format( 232 | api_url)) 233 | 234 | def retrieve_releases(): 235 | wf().logger.info( 236 | 'Retrieving releases for `{0}` ...'.format(github_slug)) 237 | return web.get(api_url).json() 238 | 239 | slug = github_slug.replace('/', '-') 240 | for release in wf().cached_data('gh-releases-{0}'.format(slug), 241 | retrieve_releases): 242 | version = release['tag_name'] 243 | download_urls = [] 244 | for asset in release.get('assets', []): 245 | url = asset.get('browser_download_url') 246 | if not url or not url.endswith('.alfredworkflow'): 247 | continue 248 | download_urls.append(url) 249 | 250 | # Validate release 251 | if release['prerelease'] and not prereleases: 252 | wf().logger.warning( 253 | 'Invalid release {0} : pre-release detected'.format(version)) 254 | continue 255 | if not download_urls: 256 | wf().logger.warning( 257 | 'Invalid release {0} : No workflow file'.format(version)) 258 | continue 259 | if len(download_urls) > 1: 260 | wf().logger.warning( 261 | 'Invalid release {0} : multiple workflow files'.format(version)) 262 | continue 263 | 264 | wf().logger.debug('Release `{0}` : {1}'.format(version, url)) 265 | releases.append({ 266 | 'version': version, 267 | 'download_url': download_urls[0], 268 | 'prerelease': release['prerelease'] 269 | }) 270 | 271 | return releases 272 | 273 | 274 | def check_update(github_slug, current_version, prereleases=False): 275 | """Check whether a newer release is available on GitHub 276 | 277 | :param github_slug: ``username/repo`` for workflow's GitHub repo 278 | :param current_version: the currently installed version of the 279 | workflow. :ref:`Semantic versioning ` is required. 280 | :param prereleases: Whether to include pre-releases. 281 | :type current_version: ``unicode`` 282 | :returns: ``True`` if an update is available, else ``False`` 283 | 284 | If an update is available, its version number and download URL will 285 | be cached. 286 | 287 | """ 288 | 289 | releases = get_valid_releases(github_slug, prereleases) 290 | 291 | wf().logger.info('{0} releases for {1}'.format(len(releases), 292 | github_slug)) 293 | 294 | if not len(releases): 295 | raise ValueError('No valid releases for {0}'.format(github_slug)) 296 | 297 | # GitHub returns releases newest-first 298 | latest_release = releases[0] 299 | 300 | # (latest_version, download_url) = get_latest_release(releases) 301 | vr = Version(latest_release['version']) 302 | vl = Version(current_version) 303 | wf().logger.debug('Latest : {0!r} Installed : {1!r}'.format(vr, vl)) 304 | if vr > vl: 305 | 306 | wf().cache_data('__workflow_update_status', { 307 | 'version': latest_release['version'], 308 | 'download_url': latest_release['download_url'], 309 | 'available': True 310 | }) 311 | 312 | return True 313 | 314 | wf().cache_data('__workflow_update_status', { 315 | 'available': False 316 | }) 317 | return False 318 | 319 | 320 | def install_update(github_slug, current_version): 321 | """If a newer release is available, download and install it 322 | 323 | :param github_slug: ``username/repo`` for workflow's GitHub repo 324 | :param current_version: the currently installed version of the 325 | workflow. :ref:`Semantic versioning ` is required. 326 | :type current_version: ``unicode`` 327 | 328 | If an update is available, it will be downloaded and installed. 329 | 330 | :returns: ``True`` if an update is installed, else ``False`` 331 | 332 | """ 333 | # TODO: `github_slug` and `current_version` are both unusued. 334 | 335 | update_data = wf().cached_data('__workflow_update_status', max_age=0) 336 | 337 | if not update_data or not update_data.get('available'): 338 | wf().logger.info('No update available') 339 | return False 340 | 341 | local_file = download_workflow(update_data['download_url']) 342 | 343 | wf().logger.info('Installing updated workflow ...') 344 | subprocess.call(['open', local_file]) 345 | 346 | update_data['available'] = False 347 | wf().cache_data('__workflow_update_status', update_data) 348 | return True 349 | 350 | 351 | if __name__ == '__main__': # pragma: nocover 352 | import sys 353 | 354 | def show_help(): 355 | print('Usage : update.py (check|install) github_slug version [--prereleases]') 356 | sys.exit(1) 357 | 358 | argv = sys.argv[:] 359 | prereleases = '--prereleases' in argv 360 | 361 | if prereleases: 362 | argv.remove('--prereleases') 363 | 364 | if len(argv) != 4: 365 | show_help() 366 | 367 | action, github_slug, version = argv[1:] 368 | 369 | if action not in ('check', 'install'): 370 | show_help() 371 | 372 | if action == 'check': 373 | check_update(github_slug, version, prereleases) 374 | elif action == 'install': 375 | install_update(github_slug, version) 376 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.17.2 -------------------------------------------------------------------------------- /src/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """ 11 | A lightweight HTTP library with a requests-like interface. 12 | """ 13 | 14 | from __future__ import print_function 15 | 16 | import codecs 17 | import json 18 | import mimetypes 19 | import os 20 | import random 21 | import re 22 | import socket 23 | import string 24 | import unicodedata 25 | import urllib 26 | import urllib2 27 | import urlparse 28 | import zlib 29 | 30 | 31 | USER_AGENT = u'Alfred-Workflow/1.17 (+http://www.deanishe.net/alfred-workflow)' 32 | 33 | # Valid characters for multipart form data boundaries 34 | BOUNDARY_CHARS = string.digits + string.ascii_letters 35 | 36 | # HTTP response codes 37 | RESPONSES = { 38 | 100: 'Continue', 39 | 101: 'Switching Protocols', 40 | 200: 'OK', 41 | 201: 'Created', 42 | 202: 'Accepted', 43 | 203: 'Non-Authoritative Information', 44 | 204: 'No Content', 45 | 205: 'Reset Content', 46 | 206: 'Partial Content', 47 | 300: 'Multiple Choices', 48 | 301: 'Moved Permanently', 49 | 302: 'Found', 50 | 303: 'See Other', 51 | 304: 'Not Modified', 52 | 305: 'Use Proxy', 53 | 307: 'Temporary Redirect', 54 | 400: 'Bad Request', 55 | 401: 'Unauthorized', 56 | 402: 'Payment Required', 57 | 403: 'Forbidden', 58 | 404: 'Not Found', 59 | 405: 'Method Not Allowed', 60 | 406: 'Not Acceptable', 61 | 407: 'Proxy Authentication Required', 62 | 408: 'Request Timeout', 63 | 409: 'Conflict', 64 | 410: 'Gone', 65 | 411: 'Length Required', 66 | 412: 'Precondition Failed', 67 | 413: 'Request Entity Too Large', 68 | 414: 'Request-URI Too Long', 69 | 415: 'Unsupported Media Type', 70 | 416: 'Requested Range Not Satisfiable', 71 | 417: 'Expectation Failed', 72 | 500: 'Internal Server Error', 73 | 501: 'Not Implemented', 74 | 502: 'Bad Gateway', 75 | 503: 'Service Unavailable', 76 | 504: 'Gateway Timeout', 77 | 505: 'HTTP Version Not Supported' 78 | } 79 | 80 | 81 | def str_dict(dic): 82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str` 83 | 84 | :param dic: :class:`dict` of Unicode strings 85 | :returns: :class:`dict` 86 | 87 | """ 88 | if isinstance(dic, CaseInsensitiveDictionary): 89 | dic2 = CaseInsensitiveDictionary() 90 | else: 91 | dic2 = {} 92 | for k, v in dic.items(): 93 | if isinstance(k, unicode): 94 | k = k.encode('utf-8') 95 | if isinstance(v, unicode): 96 | v = v.encode('utf-8') 97 | dic2[k] = v 98 | return dic2 99 | 100 | 101 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 102 | """Prevent redirections""" 103 | 104 | def redirect_request(self, *args): 105 | return None 106 | 107 | 108 | # Adapted from https://gist.github.com/babakness/3901174 109 | class CaseInsensitiveDictionary(dict): 110 | """ 111 | Dictionary that enables case insensitive searching while preserving 112 | case sensitivity when keys are listed, ie, via keys() or items() methods. 113 | 114 | Works by storing a lowercase version of the key as the new key and 115 | stores the original key-value pair as the key's value 116 | (values become dictionaries). 117 | 118 | """ 119 | 120 | def __init__(self, initval=None): 121 | 122 | if isinstance(initval, dict): 123 | for key, value in initval.iteritems(): 124 | self.__setitem__(key, value) 125 | 126 | elif isinstance(initval, list): 127 | for (key, value) in initval: 128 | self.__setitem__(key, value) 129 | 130 | def __contains__(self, key): 131 | return dict.__contains__(self, key.lower()) 132 | 133 | def __getitem__(self, key): 134 | return dict.__getitem__(self, key.lower())['val'] 135 | 136 | def __setitem__(self, key, value): 137 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 138 | 139 | def get(self, key, default=None): 140 | try: 141 | v = dict.__getitem__(self, key.lower()) 142 | except KeyError: 143 | return default 144 | else: 145 | return v['val'] 146 | 147 | def update(self, other): 148 | for k, v in other.items(): 149 | self[k] = v 150 | 151 | def items(self): 152 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 153 | 154 | def keys(self): 155 | return [v['key'] for v in dict.itervalues(self)] 156 | 157 | def values(self): 158 | return [v['val'] for v in dict.itervalues(self)] 159 | 160 | def iteritems(self): 161 | for v in dict.itervalues(self): 162 | yield v['key'], v['val'] 163 | 164 | def iterkeys(self): 165 | for v in dict.itervalues(self): 166 | yield v['key'] 167 | 168 | def itervalues(self): 169 | for v in dict.itervalues(self): 170 | yield v['val'] 171 | 172 | 173 | class Response(object): 174 | """ 175 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 176 | 177 | A simplified version of the ``Response`` object in the ``requests`` library. 178 | 179 | >>> r = request('http://www.google.com') 180 | >>> r.status_code 181 | 200 182 | >>> r.encoding 183 | ISO-8859-1 184 | >>> r.content # bytes 185 | ... 186 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 187 | u' ...' 188 | >>> r.json() # content parsed as JSON 189 | 190 | """ 191 | 192 | def __init__(self, request, stream=False): 193 | """Call `request` with :mod:`urllib2` and process results. 194 | 195 | :param request: :class:`urllib2.Request` instance 196 | :param stream: Whether to stream response or retrieve it all at once 197 | :type stream: ``bool`` 198 | 199 | """ 200 | 201 | self.request = request 202 | self._stream = stream 203 | self.url = None 204 | self.raw = None 205 | self._encoding = None 206 | self.error = None 207 | self.status_code = None 208 | self.reason = None 209 | self.headers = CaseInsensitiveDictionary() 210 | self._content = None 211 | self._content_loaded = False 212 | self._gzipped = False 213 | 214 | # Execute query 215 | try: 216 | self.raw = urllib2.urlopen(request) 217 | except urllib2.HTTPError as err: 218 | self.error = err 219 | try: 220 | self.url = err.geturl() 221 | # sometimes (e.g. when authentication fails) 222 | # urllib can't get a URL from an HTTPError 223 | # This behaviour changes across Python versions, 224 | # so no test cover (it isn't important). 225 | except AttributeError: # pragma: no cover 226 | pass 227 | self.status_code = err.code 228 | else: 229 | self.status_code = self.raw.getcode() 230 | self.url = self.raw.geturl() 231 | self.reason = RESPONSES.get(self.status_code) 232 | 233 | # Parse additional info if request succeeded 234 | if not self.error: 235 | headers = self.raw.info() 236 | self.transfer_encoding = headers.getencoding() 237 | self.mimetype = headers.gettype() 238 | for key in headers.keys(): 239 | self.headers[key.lower()] = headers.get(key) 240 | 241 | # Is content gzipped? 242 | # Transfer-Encoding appears to not be used in the wild 243 | # (contrary to the HTTP standard), but no harm in testing 244 | # for it 245 | if ('gzip' in headers.get('content-encoding', '') or 246 | 'gzip' in headers.get('transfer-encoding', '')): 247 | self._gzipped = True 248 | 249 | @property 250 | def stream(self): 251 | return self._stream 252 | 253 | @stream.setter 254 | def stream(self, value): 255 | if self._content_loaded: 256 | raise RuntimeError("`content` has already been read from " 257 | "this Response.") 258 | 259 | self._stream = value 260 | 261 | def json(self): 262 | """Decode response contents as JSON. 263 | 264 | :returns: object decoded from JSON 265 | :rtype: :class:`list` / :class:`dict` 266 | 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 | 279 | if not self._encoding: 280 | self._encoding = self._get_encoding() 281 | 282 | return self._encoding 283 | 284 | @property 285 | def content(self): 286 | """Raw content of response (i.e. bytes) 287 | 288 | :returns: Body of HTTP response 289 | :rtype: :class:`str` 290 | 291 | """ 292 | 293 | if not self._content: 294 | 295 | # Decompress gzipped content 296 | if self._gzipped: 297 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 298 | self._content = decoder.decompress(self.raw.read()) 299 | 300 | else: 301 | self._content = self.raw.read() 302 | 303 | self._content_loaded = True 304 | 305 | return self._content 306 | 307 | @property 308 | def text(self): 309 | """Unicode-decoded content of response body. 310 | 311 | If no encoding can be determined from HTTP headers or the content 312 | itself, the encoded response body will be returned instead. 313 | 314 | :returns: Body of HTTP response 315 | :rtype: :class:`unicode` or :class:`str` 316 | 317 | """ 318 | 319 | if self.encoding: 320 | return unicodedata.normalize('NFC', unicode(self.content, 321 | self.encoding)) 322 | return self.content 323 | 324 | def iter_content(self, chunk_size=4096, decode_unicode=False): 325 | """Iterate over response data. 326 | 327 | .. versionadded:: 1.6 328 | 329 | :param chunk_size: Number of bytes to read into memory 330 | :type chunk_size: ``int`` 331 | :param decode_unicode: Decode to Unicode using detected encoding 332 | :type decode_unicode: ``Boolean`` 333 | :returns: iterator 334 | 335 | """ 336 | 337 | if not self.stream: 338 | raise RuntimeError("You cannot call `iter_content` on a " 339 | "Response unless you passed `stream=True`" 340 | " to `get()`/`post()`/`request()`.") 341 | 342 | if self._content_loaded: 343 | raise RuntimeError( 344 | "`content` has already been read from this Response.") 345 | 346 | def decode_stream(iterator, r): 347 | 348 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') 349 | 350 | for chunk in iterator: 351 | data = decoder.decode(chunk) 352 | if data: 353 | yield data 354 | 355 | data = decoder.decode(b'', final=True) 356 | if data: # pragma: no cover 357 | yield data 358 | 359 | def generate(): 360 | 361 | if self._gzipped: 362 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 363 | 364 | while True: 365 | chunk = self.raw.read(chunk_size) 366 | if not chunk: 367 | break 368 | 369 | if self._gzipped: 370 | chunk = decoder.decompress(chunk) 371 | 372 | yield chunk 373 | 374 | chunks = generate() 375 | 376 | if decode_unicode and self.encoding: 377 | chunks = decode_stream(chunks, self) 378 | 379 | return chunks 380 | 381 | def save_to_path(self, filepath): 382 | """Save retrieved data to file at ``filepath`` 383 | 384 | .. versionadded: 1.9.6 385 | 386 | :param filepath: Path to save retrieved data. 387 | 388 | """ 389 | 390 | filepath = os.path.abspath(filepath) 391 | dirname = os.path.dirname(filepath) 392 | if not os.path.exists(dirname): 393 | os.makedirs(dirname) 394 | 395 | self.stream = True 396 | 397 | with open(filepath, 'wb') as fileobj: 398 | for data in self.iter_content(): 399 | fileobj.write(data) 400 | 401 | def raise_for_status(self): 402 | """Raise stored error if one occurred. 403 | 404 | error will be instance of :class:`urllib2.HTTPError` 405 | """ 406 | 407 | if self.error is not None: 408 | raise self.error 409 | return 410 | 411 | def _get_encoding(self): 412 | """Get encoding from HTTP headers or content. 413 | 414 | :returns: encoding or `None` 415 | :rtype: ``unicode`` or ``None`` 416 | 417 | """ 418 | 419 | headers = self.raw.info() 420 | encoding = None 421 | 422 | if headers.getparam('charset'): 423 | encoding = headers.getparam('charset') 424 | 425 | # HTTP Content-Type header 426 | for param in headers.getplist(): 427 | if param.startswith('charset='): 428 | encoding = param[8:] 429 | break 430 | 431 | if not self.stream: # Try sniffing response content 432 | # Encoding declared in document should override HTTP headers 433 | if self.mimetype == 'text/html': # sniff HTML headers 434 | m = re.search("""""", 435 | self.content) 436 | if m: 437 | encoding = m.group(1) 438 | print('sniffed HTML encoding=%r' % encoding) 439 | 440 | elif ((self.mimetype.startswith('application/') or 441 | self.mimetype.startswith('text/')) and 442 | 'xml' in self.mimetype): 443 | m = re.search("""]*\?>""", 444 | self.content) 445 | if m: 446 | encoding = m.group(1) 447 | 448 | # Format defaults 449 | if self.mimetype == 'application/json' and not encoding: 450 | # The default encoding for JSON 451 | encoding = 'utf-8' 452 | 453 | elif self.mimetype == 'application/xml' and not encoding: 454 | # The default for 'application/xml' 455 | encoding = 'utf-8' 456 | 457 | if encoding: 458 | encoding = encoding.lower() 459 | 460 | return encoding 461 | 462 | 463 | def request(method, url, params=None, data=None, headers=None, cookies=None, 464 | files=None, auth=None, timeout=60, allow_redirects=False, 465 | stream=False): 466 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 467 | 468 | :param method: 'GET' or 'POST' 469 | :type method: ``unicode`` 470 | :param url: URL to open 471 | :type url: ``unicode`` 472 | :param params: mapping of URL parameters 473 | :type params: :class:`dict` 474 | :param data: mapping of form data ``{'field_name': 'value'}`` or 475 | :class:`str` 476 | :type data: :class:`dict` or :class:`str` 477 | :param headers: HTTP headers 478 | :type headers: :class:`dict` 479 | :param cookies: cookies to send to server 480 | :type cookies: :class:`dict` 481 | :param files: files to upload (see below). 482 | :type files: :class:`dict` 483 | :param auth: username, password 484 | :type auth: ``tuple`` 485 | :param timeout: connection timeout limit in seconds 486 | :type timeout: ``int`` 487 | :param allow_redirects: follow redirections 488 | :type allow_redirects: ``Boolean`` 489 | :param stream: Stream content instead of fetching it all at once. 490 | :type stream: ``bool`` 491 | :returns: :class:`Response` object 492 | 493 | 494 | The ``files`` argument is a dictionary:: 495 | 496 | {'fieldname' : { 'filename': 'blah.txt', 497 | 'content': '', 498 | 'mimetype': 'text/plain'} 499 | } 500 | 501 | * ``fieldname`` is the name of the field in the HTML form. 502 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 503 | be used to guess the mimetype, or ``application/octet-stream`` 504 | will be used. 505 | 506 | """ 507 | 508 | # TODO: cookies 509 | socket.setdefaulttimeout(timeout) 510 | 511 | # Default handlers 512 | openers = [] 513 | 514 | if not allow_redirects: 515 | openers.append(NoRedirectHandler()) 516 | 517 | if auth is not None: # Add authorisation handler 518 | username, password = auth 519 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 520 | password_manager.add_password(None, url, username, password) 521 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 522 | openers.append(auth_manager) 523 | 524 | # Install our custom chain of openers 525 | opener = urllib2.build_opener(*openers) 526 | urllib2.install_opener(opener) 527 | 528 | if not headers: 529 | headers = CaseInsensitiveDictionary() 530 | else: 531 | headers = CaseInsensitiveDictionary(headers) 532 | 533 | if 'user-agent' not in headers: 534 | headers['user-agent'] = USER_AGENT 535 | 536 | # Accept gzip-encoded content 537 | encodings = [s.strip() for s in 538 | headers.get('accept-encoding', '').split(',')] 539 | if 'gzip' not in encodings: 540 | encodings.append('gzip') 541 | 542 | headers['accept-encoding'] = ', '.join(encodings) 543 | 544 | # Force POST by providing an empty data string 545 | if method == 'POST' and not data: 546 | data = '' 547 | 548 | if files: 549 | if not data: 550 | data = {} 551 | new_headers, data = encode_multipart_formdata(data, files) 552 | headers.update(new_headers) 553 | elif data and isinstance(data, dict): 554 | data = urllib.urlencode(str_dict(data)) 555 | 556 | # Make sure everything is encoded text 557 | headers = str_dict(headers) 558 | 559 | if isinstance(url, unicode): 560 | url = url.encode('utf-8') 561 | 562 | if params: # GET args (POST args are handled in encode_multipart_formdata) 563 | 564 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 565 | 566 | if query: # Combine query string and `params` 567 | url_params = urlparse.parse_qs(query) 568 | # `params` take precedence over URL query string 569 | url_params.update(params) 570 | params = url_params 571 | 572 | query = urllib.urlencode(str_dict(params), doseq=True) 573 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 574 | 575 | req = urllib2.Request(url, data, headers) 576 | return Response(req, stream) 577 | 578 | 579 | def get(url, params=None, headers=None, cookies=None, auth=None, 580 | timeout=60, allow_redirects=True, stream=False): 581 | """Initiate a GET request. Arguments as for :func:`request`. 582 | 583 | :returns: :class:`Response` instance 584 | 585 | """ 586 | 587 | return request('GET', url, params, headers=headers, cookies=cookies, 588 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 589 | stream=stream) 590 | 591 | 592 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 593 | auth=None, timeout=60, allow_redirects=False, stream=False): 594 | """Initiate a POST request. Arguments as for :func:`request`. 595 | 596 | :returns: :class:`Response` instance 597 | 598 | """ 599 | return request('POST', url, params, data, headers, cookies, files, auth, 600 | timeout, allow_redirects, stream) 601 | 602 | 603 | def encode_multipart_formdata(fields, files): 604 | """Encode form data (``fields``) and ``files`` for POST request. 605 | 606 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 607 | :type fields: :class:`dict` 608 | :param files: dictionary of fieldnames/files elements for file data. 609 | See below for details. 610 | :type files: :class:`dict` of :class:`dicts` 611 | :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers 612 | :rtype: 2-tuple ``(dict, str)`` 613 | 614 | The ``files`` argument is a dictionary:: 615 | 616 | {'fieldname' : { 'filename': 'blah.txt', 617 | 'content': '', 618 | 'mimetype': 'text/plain'} 619 | } 620 | 621 | - ``fieldname`` is the name of the field in the HTML form. 622 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. 623 | 624 | """ 625 | 626 | def get_content_type(filename): 627 | """Return or guess mimetype of ``filename``. 628 | 629 | :param filename: filename of file 630 | :type filename: unicode/string 631 | :returns: mime-type, e.g. ``text/html`` 632 | :rtype: :class::class:`str` 633 | 634 | """ 635 | 636 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 637 | 638 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 639 | for i in range(30)) 640 | CRLF = '\r\n' 641 | output = [] 642 | 643 | # Normal form fields 644 | for (name, value) in fields.items(): 645 | if isinstance(name, unicode): 646 | name = name.encode('utf-8') 647 | if isinstance(value, unicode): 648 | value = value.encode('utf-8') 649 | output.append('--' + boundary) 650 | output.append('Content-Disposition: form-data; name="%s"' % name) 651 | output.append('') 652 | output.append(value) 653 | 654 | # Files to upload 655 | for name, d in files.items(): 656 | filename = d[u'filename'] 657 | content = d[u'content'] 658 | if u'mimetype' in d: 659 | mimetype = d[u'mimetype'] 660 | else: 661 | mimetype = get_content_type(filename) 662 | if isinstance(name, unicode): 663 | name = name.encode('utf-8') 664 | if isinstance(filename, unicode): 665 | filename = filename.encode('utf-8') 666 | if isinstance(mimetype, unicode): 667 | mimetype = mimetype.encode('utf-8') 668 | output.append('--' + boundary) 669 | output.append('Content-Disposition: form-data; ' 670 | 'name="%s"; filename="%s"' % (name, filename)) 671 | output.append('Content-Type: %s' % mimetype) 672 | output.append('') 673 | output.append(content) 674 | 675 | output.append('--' + boundary + '--') 676 | output.append('') 677 | body = CRLF.join(output) 678 | headers = { 679 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 680 | 'Content-Length': str(len(body)), 681 | } 682 | return (headers, body) 683 | -------------------------------------------------------------------------------- /src/workflow/workflow.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """ 11 | The :class:`Workflow` object is the main interface to this library. 12 | 13 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set 14 | up your Python script to best utilise the :class:`Workflow` object. 15 | 16 | """ 17 | 18 | from __future__ import print_function, unicode_literals 19 | 20 | import binascii 21 | from contextlib import contextmanager 22 | import cPickle 23 | from copy import deepcopy 24 | import errno 25 | import json 26 | import logging 27 | import logging.handlers 28 | import os 29 | import pickle 30 | import plistlib 31 | import re 32 | import shutil 33 | import signal 34 | import string 35 | import subprocess 36 | import sys 37 | import time 38 | import unicodedata 39 | 40 | try: 41 | import xml.etree.cElementTree as ET 42 | except ImportError: # pragma: no cover 43 | import xml.etree.ElementTree as ET 44 | 45 | 46 | #: Sentinel for properties that haven't been set yet (that might 47 | #: correctly have the value ``None``) 48 | UNSET = object() 49 | 50 | #################################################################### 51 | # Standard system icons 52 | #################################################################### 53 | 54 | # These icons are default OS X icons. They are super-high quality, and 55 | # will be familiar to users. 56 | # This library uses `ICON_ERROR` when a workflow dies in flames, so 57 | # in my own workflows, I use `ICON_WARNING` for less fatal errors 58 | # (e.g. bad user input, no results etc.) 59 | 60 | # The system icons are all in this directory. There are many more than 61 | # are listed here 62 | 63 | ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources' 64 | 65 | ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns') 66 | ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns') 67 | ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns') 68 | ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns') 69 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please 70 | ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns') 71 | # Shown when a workflow throws an error 72 | ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns') 73 | ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns') 74 | ICON_FAVOURITE = ICON_FAVORITE 75 | ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns') 76 | ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns') 77 | ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns') 78 | ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns') 79 | ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns') 80 | ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns') 81 | ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns') 82 | ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns') 83 | ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns') 84 | ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns') 85 | ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns') 86 | ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns') 87 | ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns') 88 | ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns') 89 | 90 | #################################################################### 91 | # non-ASCII to ASCII diacritic folding. 92 | # Used by `fold_to_ascii` method 93 | #################################################################### 94 | 95 | ASCII_REPLACEMENTS = { 96 | 'À': 'A', 97 | 'Á': 'A', 98 | 'Â': 'A', 99 | 'Ã': 'A', 100 | 'Ä': 'A', 101 | 'Å': 'A', 102 | 'Æ': 'AE', 103 | 'Ç': 'C', 104 | 'È': 'E', 105 | 'É': 'E', 106 | 'Ê': 'E', 107 | 'Ë': 'E', 108 | 'Ì': 'I', 109 | 'Í': 'I', 110 | 'Î': 'I', 111 | 'Ï': 'I', 112 | 'Ð': 'D', 113 | 'Ñ': 'N', 114 | 'Ò': 'O', 115 | 'Ó': 'O', 116 | 'Ô': 'O', 117 | 'Õ': 'O', 118 | 'Ö': 'O', 119 | 'Ø': 'O', 120 | 'Ù': 'U', 121 | 'Ú': 'U', 122 | 'Û': 'U', 123 | 'Ü': 'U', 124 | 'Ý': 'Y', 125 | 'Þ': 'Th', 126 | 'ß': 'ss', 127 | 'à': 'a', 128 | 'á': 'a', 129 | 'â': 'a', 130 | 'ã': 'a', 131 | 'ä': 'a', 132 | 'å': 'a', 133 | 'æ': 'ae', 134 | 'ç': 'c', 135 | 'è': 'e', 136 | 'é': 'e', 137 | 'ê': 'e', 138 | 'ë': 'e', 139 | 'ì': 'i', 140 | 'í': 'i', 141 | 'î': 'i', 142 | 'ï': 'i', 143 | 'ð': 'd', 144 | 'ñ': 'n', 145 | 'ò': 'o', 146 | 'ó': 'o', 147 | 'ô': 'o', 148 | 'õ': 'o', 149 | 'ö': 'o', 150 | 'ø': 'o', 151 | 'ù': 'u', 152 | 'ú': 'u', 153 | 'û': 'u', 154 | 'ü': 'u', 155 | 'ý': 'y', 156 | 'þ': 'th', 157 | 'ÿ': 'y', 158 | 'Ł': 'L', 159 | 'ł': 'l', 160 | 'Ń': 'N', 161 | 'ń': 'n', 162 | 'Ņ': 'N', 163 | 'ņ': 'n', 164 | 'Ň': 'N', 165 | 'ň': 'n', 166 | 'Ŋ': 'ng', 167 | 'ŋ': 'NG', 168 | 'Ō': 'O', 169 | 'ō': 'o', 170 | 'Ŏ': 'O', 171 | 'ŏ': 'o', 172 | 'Ő': 'O', 173 | 'ő': 'o', 174 | 'Œ': 'OE', 175 | 'œ': 'oe', 176 | 'Ŕ': 'R', 177 | 'ŕ': 'r', 178 | 'Ŗ': 'R', 179 | 'ŗ': 'r', 180 | 'Ř': 'R', 181 | 'ř': 'r', 182 | 'Ś': 'S', 183 | 'ś': 's', 184 | 'Ŝ': 'S', 185 | 'ŝ': 's', 186 | 'Ş': 'S', 187 | 'ş': 's', 188 | 'Š': 'S', 189 | 'š': 's', 190 | 'Ţ': 'T', 191 | 'ţ': 't', 192 | 'Ť': 'T', 193 | 'ť': 't', 194 | 'Ŧ': 'T', 195 | 'ŧ': 't', 196 | 'Ũ': 'U', 197 | 'ũ': 'u', 198 | 'Ū': 'U', 199 | 'ū': 'u', 200 | 'Ŭ': 'U', 201 | 'ŭ': 'u', 202 | 'Ů': 'U', 203 | 'ů': 'u', 204 | 'Ű': 'U', 205 | 'ű': 'u', 206 | 'Ŵ': 'W', 207 | 'ŵ': 'w', 208 | 'Ŷ': 'Y', 209 | 'ŷ': 'y', 210 | 'Ÿ': 'Y', 211 | 'Ź': 'Z', 212 | 'ź': 'z', 213 | 'Ż': 'Z', 214 | 'ż': 'z', 215 | 'Ž': 'Z', 216 | 'ž': 'z', 217 | 'ſ': 's', 218 | 'Α': 'A', 219 | 'Β': 'B', 220 | 'Γ': 'G', 221 | 'Δ': 'D', 222 | 'Ε': 'E', 223 | 'Ζ': 'Z', 224 | 'Η': 'E', 225 | 'Θ': 'Th', 226 | 'Ι': 'I', 227 | 'Κ': 'K', 228 | 'Λ': 'L', 229 | 'Μ': 'M', 230 | 'Ν': 'N', 231 | 'Ξ': 'Ks', 232 | 'Ο': 'O', 233 | 'Π': 'P', 234 | 'Ρ': 'R', 235 | 'Σ': 'S', 236 | 'Τ': 'T', 237 | 'Υ': 'U', 238 | 'Φ': 'Ph', 239 | 'Χ': 'Kh', 240 | 'Ψ': 'Ps', 241 | 'Ω': 'O', 242 | 'α': 'a', 243 | 'β': 'b', 244 | 'γ': 'g', 245 | 'δ': 'd', 246 | 'ε': 'e', 247 | 'ζ': 'z', 248 | 'η': 'e', 249 | 'θ': 'th', 250 | 'ι': 'i', 251 | 'κ': 'k', 252 | 'λ': 'l', 253 | 'μ': 'm', 254 | 'ν': 'n', 255 | 'ξ': 'x', 256 | 'ο': 'o', 257 | 'π': 'p', 258 | 'ρ': 'r', 259 | 'ς': 's', 260 | 'σ': 's', 261 | 'τ': 't', 262 | 'υ': 'u', 263 | 'φ': 'ph', 264 | 'χ': 'kh', 265 | 'ψ': 'ps', 266 | 'ω': 'o', 267 | 'А': 'A', 268 | 'Б': 'B', 269 | 'В': 'V', 270 | 'Г': 'G', 271 | 'Д': 'D', 272 | 'Е': 'E', 273 | 'Ж': 'Zh', 274 | 'З': 'Z', 275 | 'И': 'I', 276 | 'Й': 'I', 277 | 'К': 'K', 278 | 'Л': 'L', 279 | 'М': 'M', 280 | 'Н': 'N', 281 | 'О': 'O', 282 | 'П': 'P', 283 | 'Р': 'R', 284 | 'С': 'S', 285 | 'Т': 'T', 286 | 'У': 'U', 287 | 'Ф': 'F', 288 | 'Х': 'Kh', 289 | 'Ц': 'Ts', 290 | 'Ч': 'Ch', 291 | 'Ш': 'Sh', 292 | 'Щ': 'Shch', 293 | 'Ъ': "'", 294 | 'Ы': 'Y', 295 | 'Ь': "'", 296 | 'Э': 'E', 297 | 'Ю': 'Iu', 298 | 'Я': 'Ia', 299 | 'а': 'a', 300 | 'б': 'b', 301 | 'в': 'v', 302 | 'г': 'g', 303 | 'д': 'd', 304 | 'е': 'e', 305 | 'ж': 'zh', 306 | 'з': 'z', 307 | 'и': 'i', 308 | 'й': 'i', 309 | 'к': 'k', 310 | 'л': 'l', 311 | 'м': 'm', 312 | 'н': 'n', 313 | 'о': 'o', 314 | 'п': 'p', 315 | 'р': 'r', 316 | 'с': 's', 317 | 'т': 't', 318 | 'у': 'u', 319 | 'ф': 'f', 320 | 'х': 'kh', 321 | 'ц': 'ts', 322 | 'ч': 'ch', 323 | 'ш': 'sh', 324 | 'щ': 'shch', 325 | 'ъ': "'", 326 | 'ы': 'y', 327 | 'ь': "'", 328 | 'э': 'e', 329 | 'ю': 'iu', 330 | 'я': 'ia', 331 | # 'ᴀ': '', 332 | # 'ᴁ': '', 333 | # 'ᴂ': '', 334 | # 'ᴃ': '', 335 | # 'ᴄ': '', 336 | # 'ᴅ': '', 337 | # 'ᴆ': '', 338 | # 'ᴇ': '', 339 | # 'ᴈ': '', 340 | # 'ᴉ': '', 341 | # 'ᴊ': '', 342 | # 'ᴋ': '', 343 | # 'ᴌ': '', 344 | # 'ᴍ': '', 345 | # 'ᴎ': '', 346 | # 'ᴏ': '', 347 | # 'ᴐ': '', 348 | # 'ᴑ': '', 349 | # 'ᴒ': '', 350 | # 'ᴓ': '', 351 | # 'ᴔ': '', 352 | # 'ᴕ': '', 353 | # 'ᴖ': '', 354 | # 'ᴗ': '', 355 | # 'ᴘ': '', 356 | # 'ᴙ': '', 357 | # 'ᴚ': '', 358 | # 'ᴛ': '', 359 | # 'ᴜ': '', 360 | # 'ᴝ': '', 361 | # 'ᴞ': '', 362 | # 'ᴟ': '', 363 | # 'ᴠ': '', 364 | # 'ᴡ': '', 365 | # 'ᴢ': '', 366 | # 'ᴣ': '', 367 | # 'ᴤ': '', 368 | # 'ᴥ': '', 369 | 'ᴦ': 'G', 370 | 'ᴧ': 'L', 371 | 'ᴨ': 'P', 372 | 'ᴩ': 'R', 373 | 'ᴪ': 'PS', 374 | 'ẞ': 'Ss', 375 | 'Ỳ': 'Y', 376 | 'ỳ': 'y', 377 | 'Ỵ': 'Y', 378 | 'ỵ': 'y', 379 | 'Ỹ': 'Y', 380 | 'ỹ': 'y', 381 | } 382 | 383 | #################################################################### 384 | # Smart-to-dumb punctuation mapping 385 | #################################################################### 386 | 387 | DUMB_PUNCTUATION = { 388 | '‘': "'", 389 | '’': "'", 390 | '‚': "'", 391 | '“': '"', 392 | '”': '"', 393 | '„': '"', 394 | '–': '-', 395 | '—': '-' 396 | } 397 | 398 | 399 | #################################################################### 400 | # Used by `Workflow.filter` 401 | #################################################################### 402 | 403 | # Anchor characters in a name 404 | #: Characters that indicate the beginning of a "word" in CamelCase 405 | INITIALS = string.ascii_uppercase + string.digits 406 | 407 | #: Split on non-letters, numbers 408 | split_on_delimiters = re.compile('[^a-zA-Z0-9]').split 409 | 410 | # Match filter flags 411 | #: Match items that start with ``query`` 412 | MATCH_STARTSWITH = 1 413 | #: Match items whose capital letters start with ``query`` 414 | MATCH_CAPITALS = 2 415 | #: Match items with a component "word" that matches ``query`` 416 | MATCH_ATOM = 4 417 | #: Match items whose initials (based on atoms) start with ``query`` 418 | MATCH_INITIALS_STARTSWITH = 8 419 | #: Match items whose initials (based on atoms) contain ``query`` 420 | MATCH_INITIALS_CONTAIN = 16 421 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and 422 | #: :const:`MATCH_INITIALS_CONTAIN` 423 | MATCH_INITIALS = 24 424 | #: Match items if ``query`` is a substring 425 | MATCH_SUBSTRING = 32 426 | #: Match items if all characters in ``query`` appear in the item in order 427 | MATCH_ALLCHARS = 64 428 | #: Combination of all other ``MATCH_*`` constants 429 | MATCH_ALL = 127 430 | 431 | 432 | #################################################################### 433 | # Used by `Workflow.check_update` 434 | #################################################################### 435 | 436 | # Number of days to wait between checking for updates to the workflow 437 | DEFAULT_UPDATE_FREQUENCY = 1 438 | 439 | 440 | #################################################################### 441 | # Lockfile and Keychain access errors 442 | #################################################################### 443 | 444 | class AcquisitionError(Exception): 445 | """Raised if a lock cannot be acquired.""" 446 | 447 | 448 | class KeychainError(Exception): 449 | """Raised for unknown Keychain errors. 450 | 451 | Raised by methods :meth:`Workflow.save_password`, 452 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` 453 | when ``security`` CLI app returns an unknown error code. 454 | """ 455 | 456 | 457 | class PasswordNotFound(KeychainError): 458 | """Raised by method :meth:`Workflow.get_password` when ``account`` 459 | is unknown to the Keychain. 460 | """ 461 | 462 | 463 | class PasswordExists(KeychainError): 464 | """Raised when trying to overwrite an existing account password. 465 | 466 | You should never receive this error: it is used internally 467 | by the :meth:`Workflow.save_password` method to know if it needs 468 | to delete the old password first (a Keychain implementation detail). 469 | """ 470 | 471 | 472 | #################################################################### 473 | # Helper functions 474 | #################################################################### 475 | 476 | def isascii(text): 477 | """Test if ``text`` contains only ASCII characters. 478 | 479 | :param text: text to test for ASCII-ness 480 | :type text: ``unicode`` 481 | :returns: ``True`` if ``text`` contains only ASCII characters 482 | :rtype: ``Boolean`` 483 | """ 484 | 485 | try: 486 | text.encode('ascii') 487 | except UnicodeEncodeError: 488 | return False 489 | return True 490 | 491 | 492 | #################################################################### 493 | # Implementation classes 494 | #################################################################### 495 | 496 | class SerializerManager(object): 497 | """Contains registered serializers. 498 | 499 | .. versionadded:: 1.8 500 | 501 | A configured instance of this class is available at 502 | ``workflow.manager``. 503 | 504 | Use :meth:`register()` to register new (or replace 505 | existing) serializers, which you can specify by name when calling 506 | :class:`Workflow` data storage methods. 507 | 508 | See :ref:`manual-serialization` and :ref:`manual-persistent-data` 509 | for further information. 510 | 511 | """ 512 | 513 | def __init__(self): 514 | """Create new SerializerManager object.""" 515 | self._serializers = {} 516 | 517 | def register(self, name, serializer): 518 | """Register ``serializer`` object under ``name``. 519 | 520 | Raises :class:`AttributeError` if ``serializer`` in invalid. 521 | 522 | .. note:: 523 | 524 | ``name`` will be used as the file extension of the saved files. 525 | 526 | :param name: Name to register ``serializer`` under 527 | :type name: ``unicode`` or ``str`` 528 | :param serializer: object with ``load()`` and ``dump()`` 529 | methods 530 | 531 | """ 532 | 533 | # Basic validation 534 | getattr(serializer, 'load') 535 | getattr(serializer, 'dump') 536 | 537 | self._serializers[name] = serializer 538 | 539 | def serializer(self, name): 540 | """Return serializer object for ``name``. 541 | 542 | :param name: Name of serializer to return 543 | :type name: ``unicode`` or ``str`` 544 | :returns: serializer object or ``None`` if no such serializer 545 | is registered. 546 | 547 | """ 548 | 549 | return self._serializers.get(name) 550 | 551 | def unregister(self, name): 552 | """Remove registered serializer with ``name``. 553 | 554 | Raises a :class:`ValueError` if there is no such registered 555 | serializer. 556 | 557 | :param name: Name of serializer to remove 558 | :type name: ``unicode`` or ``str`` 559 | :returns: serializer object 560 | 561 | """ 562 | 563 | if name not in self._serializers: 564 | raise ValueError('No such serializer registered : {0}'.format( 565 | name)) 566 | 567 | serializer = self._serializers[name] 568 | del self._serializers[name] 569 | 570 | return serializer 571 | 572 | @property 573 | def serializers(self): 574 | """Return names of registered serializers.""" 575 | return sorted(self._serializers.keys()) 576 | 577 | 578 | class JSONSerializer(object): 579 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``. 580 | 581 | .. versionadded:: 1.8 582 | 583 | Use this serializer if you need readable data files. JSON doesn't 584 | support Python objects as well as ``cPickle``/``pickle``, so be 585 | careful which data you try to serialize as JSON. 586 | 587 | """ 588 | 589 | @classmethod 590 | def load(cls, file_obj): 591 | """Load serialized object from open JSON file. 592 | 593 | .. versionadded:: 1.8 594 | 595 | :param file_obj: file handle 596 | :type file_obj: ``file`` object 597 | :returns: object loaded from JSON file 598 | :rtype: object 599 | 600 | """ 601 | 602 | return json.load(file_obj) 603 | 604 | @classmethod 605 | def dump(cls, obj, file_obj): 606 | """Serialize object ``obj`` to open JSON file. 607 | 608 | .. versionadded:: 1.8 609 | 610 | :param obj: Python object to serialize 611 | :type obj: JSON-serializable data structure 612 | :param file_obj: file handle 613 | :type file_obj: ``file`` object 614 | 615 | """ 616 | 617 | return json.dump(obj, file_obj, indent=2, encoding='utf-8') 618 | 619 | 620 | class CPickleSerializer(object): 621 | """Wrapper around :mod:`cPickle`. Sets ``protocol``. 622 | 623 | .. versionadded:: 1.8 624 | 625 | This is the default serializer and the best combination of speed and 626 | flexibility. 627 | 628 | """ 629 | 630 | @classmethod 631 | def load(cls, file_obj): 632 | """Load serialized object from open pickle file. 633 | 634 | .. versionadded:: 1.8 635 | 636 | :param file_obj: file handle 637 | :type file_obj: ``file`` object 638 | :returns: object loaded from pickle file 639 | :rtype: object 640 | 641 | """ 642 | 643 | return cPickle.load(file_obj) 644 | 645 | @classmethod 646 | def dump(cls, obj, file_obj): 647 | """Serialize object ``obj`` to open pickle file. 648 | 649 | .. versionadded:: 1.8 650 | 651 | :param obj: Python object to serialize 652 | :type obj: Python object 653 | :param file_obj: file handle 654 | :type file_obj: ``file`` object 655 | 656 | """ 657 | 658 | return cPickle.dump(obj, file_obj, protocol=-1) 659 | 660 | 661 | class PickleSerializer(object): 662 | """Wrapper around :mod:`pickle`. Sets ``protocol``. 663 | 664 | .. versionadded:: 1.8 665 | 666 | Use this serializer if you need to add custom pickling. 667 | 668 | """ 669 | 670 | @classmethod 671 | def load(cls, file_obj): 672 | """Load serialized object from open pickle file. 673 | 674 | .. versionadded:: 1.8 675 | 676 | :param file_obj: file handle 677 | :type file_obj: ``file`` object 678 | :returns: object loaded from pickle file 679 | :rtype: object 680 | 681 | """ 682 | 683 | return pickle.load(file_obj) 684 | 685 | @classmethod 686 | def dump(cls, obj, file_obj): 687 | """Serialize object ``obj`` to open pickle file. 688 | 689 | .. versionadded:: 1.8 690 | 691 | :param obj: Python object to serialize 692 | :type obj: Python object 693 | :param file_obj: file handle 694 | :type file_obj: ``file`` object 695 | 696 | """ 697 | 698 | return pickle.dump(obj, file_obj, protocol=-1) 699 | 700 | 701 | # Set up default manager and register built-in serializers 702 | manager = SerializerManager() 703 | manager.register('cpickle', CPickleSerializer) 704 | manager.register('pickle', PickleSerializer) 705 | manager.register('json', JSONSerializer) 706 | 707 | 708 | class Item(object): 709 | """Represents a feedback item for Alfred. 710 | 711 | Generates Alfred-compliant XML for a single item. 712 | 713 | You probably shouldn't use this class directly, but via 714 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` 715 | for details of arguments. 716 | 717 | """ 718 | 719 | def __init__(self, title, subtitle='', modifier_subtitles=None, 720 | arg=None, autocomplete=None, valid=False, uid=None, 721 | icon=None, icontype=None, type=None, largetext=None, 722 | copytext=None): 723 | """Arguments the same as for :meth:`Workflow.add_item`. 724 | 725 | """ 726 | 727 | self.title = title 728 | self.subtitle = subtitle 729 | self.modifier_subtitles = modifier_subtitles or {} 730 | self.arg = arg 731 | self.autocomplete = autocomplete 732 | self.valid = valid 733 | self.uid = uid 734 | self.icon = icon 735 | self.icontype = icontype 736 | self.type = type 737 | self.largetext = largetext 738 | self.copytext = copytext 739 | 740 | @property 741 | def elem(self): 742 | """Create and return feedback item for Alfred. 743 | 744 | :returns: :class:`ElementTree.Element ` 745 | instance for this :class:`Item` instance. 746 | 747 | """ 748 | 749 | # Attributes on element 750 | attr = {} 751 | if self.valid: 752 | attr['valid'] = 'yes' 753 | else: 754 | attr['valid'] = 'no' 755 | # Allow empty string for autocomplete. This is a useful value, 756 | # as TABing the result will revert the query back to just the 757 | # keyword 758 | if self.autocomplete is not None: 759 | attr['autocomplete'] = self.autocomplete 760 | 761 | # Optional attributes 762 | for name in ('uid', 'type'): 763 | value = getattr(self, name, None) 764 | if value: 765 | attr[name] = value 766 | 767 | root = ET.Element('item', attr) 768 | ET.SubElement(root, 'title').text = self.title 769 | ET.SubElement(root, 'subtitle').text = self.subtitle 770 | 771 | # Add modifier subtitles 772 | for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'): 773 | if mod in self.modifier_subtitles: 774 | ET.SubElement(root, 'subtitle', 775 | {'mod': mod}).text = self.modifier_subtitles[mod] 776 | 777 | # Add arg as element instead of attribute on , as it's more 778 | # flexible (newlines aren't allowed in attributes) 779 | if self.arg: 780 | ET.SubElement(root, 'arg').text = self.arg 781 | 782 | # Add icon if there is one 783 | if self.icon: 784 | if self.icontype: 785 | attr = dict(type=self.icontype) 786 | else: 787 | attr = {} 788 | ET.SubElement(root, 'icon', attr).text = self.icon 789 | 790 | if self.largetext: 791 | ET.SubElement(root, 'text', 792 | {'type': 'largetype'}).text = self.largetext 793 | 794 | if self.copytext: 795 | ET.SubElement(root, 'text', 796 | {'type': 'copy'}).text = self.copytext 797 | 798 | return root 799 | 800 | 801 | class LockFile(object): 802 | """Context manager to create lock files.""" 803 | 804 | def __init__(self, protected_path, timeout=0, delay=0.05): 805 | """Create new :class:`LockFile` object.""" 806 | self.lockfile = protected_path + '.lock' 807 | self.timeout = timeout 808 | self.delay = delay 809 | self._locked = False 810 | 811 | @property 812 | def locked(self): 813 | """`True` if file is locked by this instance.""" 814 | return self._locked 815 | 816 | def acquire(self, blocking=True): 817 | """Acquire the lock if possible. 818 | 819 | If the lock is in use and ``blocking`` is ``False``, return 820 | ``False``. 821 | 822 | Otherwise, check every `self.delay` seconds until it acquires 823 | lock or exceeds `self.timeout` and raises an exception. 824 | 825 | """ 826 | start = time.time() 827 | while True: 828 | try: 829 | fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) 830 | with os.fdopen(fd, 'w') as fd: 831 | fd.write('{0}'.format(os.getpid())) 832 | break 833 | except OSError as err: 834 | if err.errno != errno.EEXIST: # pragma: no cover 835 | raise 836 | if self.timeout and (time.time() - start) >= self.timeout: 837 | raise AcquisitionError('Lock acquisition timed out.') 838 | if not blocking: 839 | return False 840 | time.sleep(self.delay) 841 | 842 | self._locked = True 843 | return True 844 | 845 | def release(self): 846 | """Release the lock by deleting `self.lockfile`.""" 847 | self._locked = False 848 | os.unlink(self.lockfile) 849 | 850 | def __enter__(self): 851 | """Acquire lock.""" 852 | self.acquire() 853 | return self 854 | 855 | def __exit__(self, typ, value, traceback): 856 | """Release lock.""" 857 | self.release() 858 | 859 | def __del__(self): 860 | """Clear up `self.lockfile`.""" 861 | if self._locked: # pragma: no cover 862 | self.release() 863 | 864 | 865 | @contextmanager 866 | def atomic_writer(file_path, mode): 867 | """Atomic file writer. 868 | 869 | :param file_path: path of file to write to. 870 | :type file_path: ``unicode`` 871 | :param mode: sames as for `func:open` 872 | :type mode: string 873 | 874 | .. versionadded:: 1.12 875 | 876 | Context manager that ensures the file is only written if the write 877 | succeeds. The data is first written to a temporary file. 878 | 879 | """ 880 | 881 | temp_suffix = '.aw.temp' 882 | temp_file_path = file_path + temp_suffix 883 | with open(temp_file_path, mode) as file_obj: 884 | try: 885 | yield file_obj 886 | os.rename(temp_file_path, file_path) 887 | finally: 888 | try: 889 | os.remove(temp_file_path) 890 | except (OSError, IOError): 891 | pass 892 | 893 | 894 | class uninterruptible(object): 895 | """Decorator that postpones SIGTERM until wrapped function is complete. 896 | 897 | .. versionadded:: 1.12 898 | 899 | Since version 2.7, Alfred allows Script Filters to be killed. If 900 | your workflow is killed in the middle of critical code (e.g. 901 | writing data to disk), this may corrupt your workflow's data. 902 | 903 | Use this decorator to wrap critical functions that *must* complete. 904 | If the script is killed while a wrapped function is executing, 905 | the SIGTERM will be caught and handled after your function has 906 | finished executing. 907 | 908 | Alfred-Workflow uses this internally to ensure its settings, data 909 | and cache writes complete. 910 | 911 | .. important:: 912 | 913 | This decorator is NOT thread-safe. 914 | 915 | """ 916 | 917 | def __init__(self, func, class_name=''): 918 | self.func = func 919 | self._caught_signal = None 920 | 921 | def signal_handler(self, signum, frame): 922 | """Called when process receives SIGTERM.""" 923 | self._caught_signal = (signum, frame) 924 | 925 | def __call__(self, *args, **kwargs): 926 | self._caught_signal = None 927 | # Register handler for SIGTERM, then call `self.func` 928 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 929 | signal.signal(signal.SIGTERM, self.signal_handler) 930 | 931 | self.func(*args, **kwargs) 932 | 933 | # Restore old signal handler 934 | signal.signal(signal.SIGTERM, self.old_signal_handler) 935 | 936 | # Handle any signal caught during execution 937 | if self._caught_signal is not None: 938 | signum, frame = self._caught_signal 939 | if callable(self.old_signal_handler): 940 | self.old_signal_handler(signum, frame) 941 | elif self.old_signal_handler == signal.SIG_DFL: 942 | sys.exit(0) 943 | 944 | def __get__(self, obj=None, klass=None): 945 | return self.__class__(self.func.__get__(obj, klass), 946 | klass.__name__) 947 | 948 | 949 | class Settings(dict): 950 | """A dictionary that saves itself when changed. 951 | 952 | Dictionary keys & values will be saved as a JSON file 953 | at ``filepath``. If the file does not exist, the dictionary 954 | (and settings file) will be initialised with ``defaults``. 955 | 956 | :param filepath: where to save the settings 957 | :type filepath: :class:`unicode` 958 | :param defaults: dict of default settings 959 | :type defaults: :class:`dict` 960 | 961 | 962 | An appropriate instance is provided by :class:`Workflow` instances at 963 | :attr:`Workflow.settings`. 964 | 965 | """ 966 | 967 | def __init__(self, filepath, defaults=None): 968 | 969 | super(Settings, self).__init__() 970 | self._filepath = filepath 971 | self._nosave = False 972 | self._original = {} 973 | if os.path.exists(self._filepath): 974 | self._load() 975 | elif defaults: 976 | for key, val in defaults.items(): 977 | self[key] = val 978 | self.save() # save default settings 979 | 980 | def _load(self): 981 | """Load cached settings from JSON file `self._filepath`""" 982 | 983 | self._nosave = True 984 | d = {} 985 | with open(self._filepath, 'rb') as file_obj: 986 | for key, value in json.load(file_obj, encoding='utf-8').items(): 987 | d[key] = value 988 | self.update(d) 989 | self._original = deepcopy(d) 990 | self._nosave = False 991 | 992 | @uninterruptible 993 | def save(self): 994 | """Save settings to JSON file specified in ``self._filepath`` 995 | 996 | If you're using this class via :attr:`Workflow.settings`, which 997 | you probably are, ``self._filepath`` will be ``settings.json`` 998 | in your workflow's data directory (see :attr:`~Workflow.datadir`). 999 | """ 1000 | if self._nosave: 1001 | return 1002 | data = {} 1003 | data.update(self) 1004 | # for key, value in self.items(): 1005 | # data[key] = value 1006 | with LockFile(self._filepath): 1007 | with atomic_writer(self._filepath, 'wb') as file_obj: 1008 | json.dump(data, file_obj, sort_keys=True, indent=2, 1009 | encoding='utf-8') 1010 | 1011 | # dict methods 1012 | def __setitem__(self, key, value): 1013 | if self._original.get(key) != value: 1014 | super(Settings, self).__setitem__(key, value) 1015 | self.save() 1016 | 1017 | def __delitem__(self, key): 1018 | super(Settings, self).__delitem__(key) 1019 | self.save() 1020 | 1021 | def update(self, *args, **kwargs): 1022 | """Override :class:`dict` method to save on update.""" 1023 | super(Settings, self).update(*args, **kwargs) 1024 | self.save() 1025 | 1026 | def setdefault(self, key, value=None): 1027 | """Override :class:`dict` method to save on update.""" 1028 | ret = super(Settings, self).setdefault(key, value) 1029 | self.save() 1030 | return ret 1031 | 1032 | 1033 | class Workflow(object): 1034 | """Create new :class:`Workflow` instance. 1035 | 1036 | :param default_settings: default workflow settings. If no settings file 1037 | exists, :class:`Workflow.settings` will be pre-populated with 1038 | ``default_settings``. 1039 | :type default_settings: :class:`dict` 1040 | :param update_settings: settings for updating your workflow from GitHub. 1041 | This must be a :class:`dict` that contains ``github_slug`` and 1042 | ``version`` keys. ``github_slug`` is of the form ``username/repo`` 1043 | and ``version`` **must** correspond to the tag of a release. The 1044 | boolean ``prereleases`` key is optional and if ``True`` will 1045 | override the :ref:`magic argument ` preference. 1046 | This is only recommended when the installed workflow is a pre-release. 1047 | See :ref:`updates` for more information. 1048 | :type update_settings: :class:`dict` 1049 | :param input_encoding: encoding of command line arguments 1050 | :type input_encoding: :class:`unicode` 1051 | :param normalization: normalisation to apply to CLI args. 1052 | See :meth:`Workflow.decode` for more details. 1053 | :type normalization: :class:`unicode` 1054 | :param capture_args: capture and act on ``workflow:*`` arguments. See 1055 | :ref:`Magic arguments ` for details. 1056 | :type capture_args: :class:`Boolean` 1057 | :param libraries: sequence of paths to directories containing 1058 | libraries. These paths will be prepended to ``sys.path``. 1059 | :type libraries: :class:`tuple` or :class:`list` 1060 | :param help_url: URL to webpage where a user can ask for help with 1061 | the workflow, report bugs, etc. This could be the GitHub repo 1062 | or a page on AlfredForum.com. If your workflow throws an error, 1063 | this URL will be displayed in the log and Alfred's debugger. It can 1064 | also be opened directly in a web browser with the ``workflow:help`` 1065 | :ref:`magic argument `. 1066 | :type help_url: :class:`unicode` or :class:`str` 1067 | 1068 | """ 1069 | 1070 | # Which class to use to generate feedback items. You probably 1071 | # won't want to change this 1072 | item_class = Item 1073 | 1074 | def __init__(self, default_settings=None, update_settings=None, 1075 | input_encoding='utf-8', normalization='NFC', 1076 | capture_args=True, libraries=None, 1077 | help_url=None): 1078 | 1079 | self._default_settings = default_settings or {} 1080 | self._update_settings = update_settings or {} 1081 | self._input_encoding = input_encoding 1082 | self._normalizsation = normalization 1083 | self._capture_args = capture_args 1084 | self.help_url = help_url 1085 | self._workflowdir = None 1086 | self._settings_path = None 1087 | self._settings = None 1088 | self._bundleid = None 1089 | self._name = None 1090 | self._cache_serializer = 'cpickle' 1091 | self._data_serializer = 'cpickle' 1092 | # info.plist should be in the directory above this one 1093 | self._info_plist = self.workflowfile('info.plist') 1094 | self._info = None 1095 | self._info_loaded = False 1096 | self._logger = None 1097 | self._items = [] 1098 | self._alfred_env = None 1099 | # Version number of the workflow 1100 | self._version = UNSET 1101 | # Version from last workflow run 1102 | self._last_version_run = UNSET 1103 | # Cache for regex patterns created for filter keys 1104 | self._search_pattern_cache = {} 1105 | # Magic arguments 1106 | #: The prefix for all magic arguments. Default is ``workflow:`` 1107 | self.magic_prefix = 'workflow:' 1108 | #: Mapping of available magic arguments. The built-in magic 1109 | #: arguments are registered by default. To add your own magic arguments 1110 | #: (or override built-ins), add a key:value pair where the key is 1111 | #: what the user should enter (prefixed with :attr:`magic_prefix`) 1112 | #: and the value is a callable that will be called when the argument 1113 | #: is entered. If you would like to display a message in Alfred, the 1114 | #: function should return a ``unicode`` string. 1115 | #: 1116 | #: By default, the magic arguments documented 1117 | #: :ref:`here ` are registered. 1118 | self.magic_arguments = {} 1119 | 1120 | self._register_default_magic() 1121 | 1122 | if libraries: 1123 | sys.path = libraries + sys.path 1124 | 1125 | #################################################################### 1126 | # API methods 1127 | #################################################################### 1128 | 1129 | # info.plist contents and alfred_* environment variables ---------- 1130 | 1131 | @property 1132 | def alfred_env(self): 1133 | """Alfred's environmental variables minus the ``alfred_`` prefix. 1134 | 1135 | .. versionadded:: 1.7 1136 | 1137 | The variables Alfred 2.4+ exports are: 1138 | 1139 | ============================ ========================================= 1140 | Variable Description 1141 | ============================ ========================================= 1142 | alfred_preferences Path to Alfred.alfredpreferences 1143 | (where your workflows and settings are 1144 | stored). 1145 | alfred_preferences_localhash Machine-specific preferences are stored 1146 | in ``Alfred.alfredpreferences/preferences/local/`` 1147 | (see ``alfred_preferences`` above for 1148 | the path to ``Alfred.alfredpreferences``) 1149 | alfred_theme ID of selected theme 1150 | alfred_theme_background Background colour of selected theme in 1151 | format ``rgba(r,g,b,a)`` 1152 | alfred_theme_subtext Show result subtext. 1153 | ``0`` = Always, 1154 | ``1`` = Alternative actions only, 1155 | ``2`` = Selected result only, 1156 | ``3`` = Never 1157 | alfred_version Alfred version number, e.g. ``'2.4'`` 1158 | alfred_version_build Alfred build number, e.g. ``277`` 1159 | alfred_workflow_bundleid Bundle ID, e.g. 1160 | ``net.deanishe.alfred-mailto`` 1161 | alfred_workflow_cache Path to workflow's cache directory 1162 | alfred_workflow_data Path to workflow's data directory 1163 | alfred_workflow_name Name of current workflow 1164 | alfred_workflow_uid UID of workflow 1165 | ============================ ========================================= 1166 | 1167 | **Note:** all values are Unicode strings except ``version_build`` and 1168 | ``theme_subtext``, which are integers. 1169 | 1170 | :returns: ``dict`` of Alfred's environmental variables without the 1171 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. 1172 | 1173 | """ 1174 | 1175 | if self._alfred_env is not None: 1176 | return self._alfred_env 1177 | 1178 | data = {} 1179 | 1180 | for key in ( 1181 | 'alfred_preferences', 1182 | 'alfred_preferences_localhash', 1183 | 'alfred_theme', 1184 | 'alfred_theme_background', 1185 | 'alfred_theme_subtext', 1186 | 'alfred_version', 1187 | 'alfred_version_build', 1188 | 'alfred_workflow_bundleid', 1189 | 'alfred_workflow_cache', 1190 | 'alfred_workflow_data', 1191 | 'alfred_workflow_name', 1192 | 'alfred_workflow_uid'): 1193 | 1194 | value = os.getenv(key) 1195 | 1196 | if isinstance(value, str): 1197 | if key in ('alfred_version_build', 'alfred_theme_subtext'): 1198 | value = int(value) 1199 | else: 1200 | value = self.decode(value) 1201 | 1202 | data[key[7:]] = value 1203 | 1204 | self._alfred_env = data 1205 | 1206 | return self._alfred_env 1207 | 1208 | @property 1209 | def info(self): 1210 | """:class:`dict` of ``info.plist`` contents.""" 1211 | 1212 | if not self._info_loaded: 1213 | self._load_info_plist() 1214 | return self._info 1215 | 1216 | @property 1217 | def bundleid(self): 1218 | """Workflow bundle ID from environmental vars or ``info.plist``. 1219 | 1220 | :returns: bundle ID 1221 | :rtype: ``unicode`` 1222 | 1223 | """ 1224 | 1225 | if not self._bundleid: 1226 | if self.alfred_env.get('workflow_bundleid'): 1227 | self._bundleid = self.alfred_env.get('workflow_bundleid') 1228 | else: 1229 | self._bundleid = unicode(self.info['bundleid'], 'utf-8') 1230 | 1231 | return self._bundleid 1232 | 1233 | @property 1234 | def name(self): 1235 | """Workflow name from Alfred's environmental vars or ``info.plist``. 1236 | 1237 | :returns: workflow name 1238 | :rtype: ``unicode`` 1239 | 1240 | """ 1241 | 1242 | if not self._name: 1243 | if self.alfred_env.get('workflow_name'): 1244 | self._name = self.decode(self.alfred_env.get('workflow_name')) 1245 | else: 1246 | self._name = self.decode(self.info['name']) 1247 | 1248 | return self._name 1249 | 1250 | @property 1251 | def version(self): 1252 | """Return the version of the workflow 1253 | 1254 | .. versionadded:: 1.9.10 1255 | 1256 | Get the version from the ``update_settings`` dict passed on 1257 | instantiation or the ``version`` file located in the workflow's 1258 | root directory. Return ``None`` if neither exist or 1259 | :class:`ValueError` if the version number is invalid (i.e. not 1260 | semantic). 1261 | 1262 | :returns: Version of the workflow (not Alfred-Workflow) 1263 | :rtype: :class:`~workflow.update.Version` object 1264 | 1265 | """ 1266 | 1267 | if self._version is UNSET: 1268 | 1269 | version = None 1270 | # First check `update_settings` 1271 | if self._update_settings: 1272 | version = self._update_settings.get('version') 1273 | 1274 | # Fallback to `version` file 1275 | if not version: 1276 | filepath = self.workflowfile('version') 1277 | 1278 | if os.path.exists(filepath): 1279 | with open(filepath, 'rb') as fileobj: 1280 | version = fileobj.read() 1281 | 1282 | if version: 1283 | from update import Version 1284 | version = Version(version) 1285 | 1286 | self._version = version 1287 | 1288 | return self._version 1289 | 1290 | # Workflow utility methods ----------------------------------------- 1291 | 1292 | @property 1293 | def args(self): 1294 | """Return command line args as normalised unicode. 1295 | 1296 | Args are decoded and normalised via :meth:`~Workflow.decode`. 1297 | 1298 | The encoding and normalisation are the ``input_encoding`` and 1299 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` 1300 | and ``NFC`` are the defaults). 1301 | 1302 | If :class:`Workflow` is called with ``capture_args=True`` 1303 | (the default), :class:`Workflow` will look for certain 1304 | ``workflow:*`` args and, if found, perform the corresponding 1305 | actions and exit the workflow. 1306 | 1307 | See :ref:`Magic arguments ` for details. 1308 | 1309 | """ 1310 | 1311 | msg = None 1312 | args = [self.decode(arg) for arg in sys.argv[1:]] 1313 | 1314 | # Handle magic args 1315 | if len(args) and self._capture_args: 1316 | for name in self.magic_arguments: 1317 | key = '{0}{1}'.format(self.magic_prefix, name) 1318 | if key in args: 1319 | msg = self.magic_arguments[name]() 1320 | 1321 | if msg: 1322 | self.logger.debug(msg) 1323 | if not sys.stdout.isatty(): # Show message in Alfred 1324 | self.add_item(msg, valid=False, icon=ICON_INFO) 1325 | self.send_feedback() 1326 | sys.exit(0) 1327 | return args 1328 | 1329 | @property 1330 | def cachedir(self): 1331 | """Path to workflow's cache directory. 1332 | 1333 | The cache directory is a subdirectory of Alfred's own cache directory in 1334 | ``~/Library/Caches``. The full path is: 1335 | 1336 | ``~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/`` 1337 | 1338 | :returns: full path to workflow's cache directory 1339 | :rtype: ``unicode`` 1340 | 1341 | """ 1342 | 1343 | if self.alfred_env.get('workflow_cache'): 1344 | dirpath = self.alfred_env.get('workflow_cache') 1345 | 1346 | else: 1347 | dirpath = os.path.join( 1348 | os.path.expanduser( 1349 | '~/Library/Caches/com.runningwithcrayons.Alfred-2/' 1350 | 'Workflow Data/'), 1351 | self.bundleid) 1352 | 1353 | return self._create(dirpath) 1354 | 1355 | @property 1356 | def datadir(self): 1357 | """Path to workflow's data directory. 1358 | 1359 | The data directory is a subdirectory of Alfred's own data directory in 1360 | ``~/Library/Application Support``. The full path is: 1361 | 1362 | ``~/Library/Application Support/Alfred 2/Workflow Data/`` 1363 | 1364 | :returns: full path to workflow data directory 1365 | :rtype: ``unicode`` 1366 | 1367 | """ 1368 | 1369 | if self.alfred_env.get('workflow_data'): 1370 | dirpath = self.alfred_env.get('workflow_data') 1371 | 1372 | else: 1373 | dirpath = os.path.join(os.path.expanduser( 1374 | '~/Library/Application Support/Alfred 2/Workflow Data/'), 1375 | self.bundleid) 1376 | 1377 | return self._create(dirpath) 1378 | 1379 | @property 1380 | def workflowdir(self): 1381 | """Path to workflow's root directory (where ``info.plist`` is). 1382 | 1383 | :returns: full path to workflow root directory 1384 | :rtype: ``unicode`` 1385 | 1386 | """ 1387 | 1388 | if not self._workflowdir: 1389 | # Try the working directory first, then the directory 1390 | # the library is in. CWD will be the workflow root if 1391 | # a workflow is being run in Alfred 1392 | candidates = [ 1393 | os.path.abspath(os.getcwdu()), 1394 | os.path.dirname(os.path.abspath(os.path.dirname(__file__)))] 1395 | 1396 | # climb the directory tree until we find `info.plist` 1397 | for dirpath in candidates: 1398 | 1399 | # Ensure directory path is Unicode 1400 | dirpath = self.decode(dirpath) 1401 | 1402 | while True: 1403 | if os.path.exists(os.path.join(dirpath, 'info.plist')): 1404 | self._workflowdir = dirpath 1405 | break 1406 | 1407 | elif dirpath == '/': 1408 | # no `info.plist` found 1409 | break 1410 | 1411 | # Check the parent directory 1412 | dirpath = os.path.dirname(dirpath) 1413 | 1414 | # No need to check other candidates 1415 | if self._workflowdir: 1416 | break 1417 | 1418 | if not self._workflowdir: 1419 | raise IOError("'info.plist' not found in directory tree") 1420 | 1421 | return self._workflowdir 1422 | 1423 | def cachefile(self, filename): 1424 | """Return full path to ``filename`` within your workflow's 1425 | :attr:`cache directory `. 1426 | 1427 | :param filename: basename of file 1428 | :type filename: ``unicode`` 1429 | :returns: full path to file within cache directory 1430 | :rtype: ``unicode`` 1431 | 1432 | """ 1433 | 1434 | return os.path.join(self.cachedir, filename) 1435 | 1436 | def datafile(self, filename): 1437 | """Return full path to ``filename`` within your workflow's 1438 | :attr:`data directory `. 1439 | 1440 | :param filename: basename of file 1441 | :type filename: ``unicode`` 1442 | :returns: full path to file within data directory 1443 | :rtype: ``unicode`` 1444 | 1445 | """ 1446 | 1447 | return os.path.join(self.datadir, filename) 1448 | 1449 | def workflowfile(self, filename): 1450 | """Return full path to ``filename`` in workflow's root dir 1451 | (where ``info.plist`` is). 1452 | 1453 | :param filename: basename of file 1454 | :type filename: ``unicode`` 1455 | :returns: full path to file within data directory 1456 | :rtype: ``unicode`` 1457 | 1458 | """ 1459 | 1460 | return os.path.join(self.workflowdir, filename) 1461 | 1462 | @property 1463 | def logfile(self): 1464 | """Return path to logfile 1465 | 1466 | :returns: path to logfile within workflow's cache directory 1467 | :rtype: ``unicode`` 1468 | 1469 | """ 1470 | 1471 | return self.cachefile('%s.log' % self.bundleid) 1472 | 1473 | @property 1474 | def logger(self): 1475 | """Create and return a logger that logs to both console and 1476 | a log file. 1477 | 1478 | Use :meth:`open_log` to open the log file in Console. 1479 | 1480 | :returns: an initialised :class:`~logging.Logger` 1481 | 1482 | """ 1483 | 1484 | if self._logger: 1485 | return self._logger 1486 | 1487 | # Initialise new logger and optionally handlers 1488 | logger = logging.getLogger('workflow') 1489 | 1490 | if not len(logger.handlers): # Only add one set of handlers 1491 | 1492 | fmt = logging.Formatter( 1493 | '%(asctime)s %(filename)s:%(lineno)s' 1494 | ' %(levelname)-8s %(message)s', 1495 | datefmt='%H:%M:%S') 1496 | 1497 | logfile = logging.handlers.RotatingFileHandler( 1498 | self.logfile, 1499 | maxBytes=1024*1024, 1500 | backupCount=1) 1501 | logfile.setFormatter(fmt) 1502 | logger.addHandler(logfile) 1503 | 1504 | # console = logging.StreamHandler() 1505 | # console.setFormatter(fmt) 1506 | # logger.addHandler(console) 1507 | 1508 | logger.setLevel(logging.DEBUG) 1509 | self._logger = logger 1510 | 1511 | return self._logger 1512 | 1513 | @logger.setter 1514 | def logger(self, logger): 1515 | """Set a custom logger. 1516 | 1517 | :param logger: The logger to use 1518 | :type logger: `~logging.Logger` instance 1519 | 1520 | """ 1521 | 1522 | self._logger = logger 1523 | 1524 | @property 1525 | def settings_path(self): 1526 | """Path to settings file within workflow's data directory. 1527 | 1528 | :returns: path to ``settings.json`` file 1529 | :rtype: ``unicode`` 1530 | 1531 | """ 1532 | 1533 | if not self._settings_path: 1534 | self._settings_path = self.datafile('settings.json') 1535 | return self._settings_path 1536 | 1537 | @property 1538 | def settings(self): 1539 | """Return a dictionary subclass that saves itself when changed. 1540 | 1541 | See :ref:`manual-settings` in the :ref:`user-manual` for more 1542 | information on how to use :attr:`settings` and **important 1543 | limitations** on what it can do. 1544 | 1545 | :returns: :class:`~workflow.workflow.Settings` instance 1546 | initialised from the data in JSON file at 1547 | :attr:`settings_path` or if that doesn't exist, with the 1548 | ``default_settings`` :class:`dict` passed to 1549 | :class:`Workflow` on instantiation. 1550 | :rtype: :class:`~workflow.workflow.Settings` instance 1551 | 1552 | """ 1553 | 1554 | if not self._settings: 1555 | self.logger.debug('Reading settings from `{0}` ...'.format( 1556 | self.settings_path)) 1557 | self._settings = Settings(self.settings_path, 1558 | self._default_settings) 1559 | return self._settings 1560 | 1561 | @property 1562 | def cache_serializer(self): 1563 | """Name of default cache serializer. 1564 | 1565 | .. versionadded:: 1.8 1566 | 1567 | This serializer is used by :meth:`cache_data()` and 1568 | :meth:`cached_data()` 1569 | 1570 | See :class:`SerializerManager` for details. 1571 | 1572 | :returns: serializer name 1573 | :rtype: ``unicode`` 1574 | 1575 | """ 1576 | 1577 | return self._cache_serializer 1578 | 1579 | @cache_serializer.setter 1580 | def cache_serializer(self, serializer_name): 1581 | """Set the default cache serialization format. 1582 | 1583 | .. versionadded:: 1.8 1584 | 1585 | This serializer is used by :meth:`cache_data()` and 1586 | :meth:`cached_data()` 1587 | 1588 | The specified serializer must already by registered with the 1589 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1590 | otherwise a :class:`ValueError` will be raised. 1591 | 1592 | :param serializer_name: Name of default serializer to use. 1593 | :type serializer_name: 1594 | 1595 | """ 1596 | 1597 | if manager.serializer(serializer_name) is None: 1598 | raise ValueError( 1599 | 'Unknown serializer : `{0}`. Register your serializer ' 1600 | 'with `manager` first.'.format(serializer_name)) 1601 | 1602 | self.logger.debug( 1603 | 'default cache serializer set to `{0}`'.format(serializer_name)) 1604 | 1605 | self._cache_serializer = serializer_name 1606 | 1607 | @property 1608 | def data_serializer(self): 1609 | """Name of default data serializer. 1610 | 1611 | .. versionadded:: 1.8 1612 | 1613 | This serializer is used by :meth:`store_data()` and 1614 | :meth:`stored_data()` 1615 | 1616 | See :class:`SerializerManager` for details. 1617 | 1618 | :returns: serializer name 1619 | :rtype: ``unicode`` 1620 | 1621 | """ 1622 | 1623 | return self._data_serializer 1624 | 1625 | @data_serializer.setter 1626 | def data_serializer(self, serializer_name): 1627 | """Set the default cache serialization format. 1628 | 1629 | .. versionadded:: 1.8 1630 | 1631 | This serializer is used by :meth:`store_data()` and 1632 | :meth:`stored_data()` 1633 | 1634 | The specified serializer must already by registered with the 1635 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1636 | otherwise a :class:`ValueError` will be raised. 1637 | 1638 | :param serializer_name: Name of serializer to use by default. 1639 | 1640 | """ 1641 | 1642 | if manager.serializer(serializer_name) is None: 1643 | raise ValueError( 1644 | 'Unknown serializer : `{0}`. Register your serializer ' 1645 | 'with `manager` first.'.format(serializer_name)) 1646 | 1647 | self.logger.debug( 1648 | 'default data serializer set to `{0}`'.format(serializer_name)) 1649 | 1650 | self._data_serializer = serializer_name 1651 | 1652 | def stored_data(self, name): 1653 | """Retrieve data from data directory. Returns ``None`` if there 1654 | are no data stored. 1655 | 1656 | .. versionadded:: 1.8 1657 | 1658 | :param name: name of datastore 1659 | 1660 | """ 1661 | 1662 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) 1663 | 1664 | if not os.path.exists(metadata_path): 1665 | self.logger.debug('No data stored for `{0}`'.format(name)) 1666 | return None 1667 | 1668 | with open(metadata_path, 'rb') as file_obj: 1669 | serializer_name = file_obj.read().strip() 1670 | 1671 | serializer = manager.serializer(serializer_name) 1672 | 1673 | if serializer is None: 1674 | raise ValueError( 1675 | 'Unknown serializer `{0}`. Register a corresponding ' 1676 | 'serializer with `manager.register()` ' 1677 | 'to load this data.'.format(serializer_name)) 1678 | 1679 | self.logger.debug('Data `{0}` stored in `{1}` format'.format( 1680 | name, serializer_name)) 1681 | 1682 | filename = '{0}.{1}'.format(name, serializer_name) 1683 | data_path = self.datafile(filename) 1684 | 1685 | if not os.path.exists(data_path): 1686 | self.logger.debug('No data stored for `{0}`'.format(name)) 1687 | if os.path.exists(metadata_path): 1688 | os.unlink(metadata_path) 1689 | 1690 | return None 1691 | 1692 | with open(data_path, 'rb') as file_obj: 1693 | data = serializer.load(file_obj) 1694 | 1695 | self.logger.debug('Stored data loaded from : {0}'.format(data_path)) 1696 | 1697 | return data 1698 | 1699 | def store_data(self, name, data, serializer=None): 1700 | """Save data to data directory. 1701 | 1702 | .. versionadded:: 1.8 1703 | 1704 | If ``data`` is ``None``, the datastore will be deleted. 1705 | 1706 | Note that the datastore does NOT support mutliple threads. 1707 | 1708 | :param name: name of datastore 1709 | :param data: object(s) to store. **Note:** some serializers 1710 | can only handled certain types of data. 1711 | :param serializer: name of serializer to use. If no serializer 1712 | is specified, the default will be used. See 1713 | :class:`SerializerManager` for more information. 1714 | :returns: data in datastore or ``None`` 1715 | 1716 | """ 1717 | 1718 | # Ensure deletion is not interrupted by SIGTERM 1719 | @uninterruptible 1720 | def delete_paths(paths): 1721 | """Clear one or more data stores""" 1722 | for path in paths: 1723 | if os.path.exists(path): 1724 | os.unlink(path) 1725 | self.logger.debug('Deleted data file : {0}'.format(path)) 1726 | 1727 | serializer_name = serializer or self.data_serializer 1728 | 1729 | # In order for `stored_data()` to be able to load data stored with 1730 | # an arbitrary serializer, yet still have meaningful file extensions, 1731 | # the format (i.e. extension) is saved to an accompanying file 1732 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) 1733 | filename = '{0}.{1}'.format(name, serializer_name) 1734 | data_path = self.datafile(filename) 1735 | 1736 | if data_path == self.settings_path: 1737 | raise ValueError( 1738 | 'Cannot save data to' + 1739 | '`{0}` with format `{1}`. '.format(name, serializer_name) + 1740 | "This would overwrite Alfred-Workflow's settings file.") 1741 | 1742 | serializer = manager.serializer(serializer_name) 1743 | 1744 | if serializer is None: 1745 | raise ValueError( 1746 | 'Invalid serializer `{0}`. Register your serializer with ' 1747 | '`manager.register()` first.'.format(serializer_name)) 1748 | 1749 | if data is None: # Delete cached data 1750 | delete_paths((metadata_path, data_path)) 1751 | return 1752 | 1753 | # Ensure write is not interrupted by SIGTERM 1754 | @uninterruptible 1755 | def _store(): 1756 | # Save file extension 1757 | with atomic_writer(metadata_path, 'wb') as file_obj: 1758 | file_obj.write(serializer_name) 1759 | 1760 | with atomic_writer(data_path, 'wb') as file_obj: 1761 | serializer.dump(data, file_obj) 1762 | 1763 | _store() 1764 | 1765 | self.logger.debug('Stored data saved at : {0}'.format(data_path)) 1766 | 1767 | def cached_data(self, name, data_func=None, max_age=60): 1768 | """Retrieve data from cache or re-generate and re-cache data if 1769 | stale/non-existant. If ``max_age`` is 0, return cached data no 1770 | matter how old. 1771 | 1772 | :param name: name of datastore 1773 | :param data_func: function to (re-)generate data. 1774 | :type data_func: ``callable`` 1775 | :param max_age: maximum age of cached data in seconds 1776 | :type max_age: ``int`` 1777 | :returns: cached data, return value of ``data_func`` or ``None`` 1778 | if ``data_func`` is not set 1779 | 1780 | """ 1781 | 1782 | serializer = manager.serializer(self.cache_serializer) 1783 | 1784 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1785 | age = self.cached_data_age(name) 1786 | 1787 | if (age < max_age or max_age == 0) and os.path.exists(cache_path): 1788 | 1789 | with open(cache_path, 'rb') as file_obj: 1790 | self.logger.debug('Loading cached data from : %s', 1791 | cache_path) 1792 | return serializer.load(file_obj) 1793 | 1794 | if not data_func: 1795 | return None 1796 | 1797 | data = data_func() 1798 | self.cache_data(name, data) 1799 | 1800 | return data 1801 | 1802 | def cache_data(self, name, data): 1803 | """Save ``data`` to cache under ``name``. 1804 | 1805 | If ``data`` is ``None``, the corresponding cache file will be 1806 | deleted. 1807 | 1808 | :param name: name of datastore 1809 | :param data: data to store. This may be any object supported by 1810 | the cache serializer 1811 | 1812 | """ 1813 | 1814 | serializer = manager.serializer(self.cache_serializer) 1815 | 1816 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1817 | 1818 | if data is None: 1819 | if os.path.exists(cache_path): 1820 | os.unlink(cache_path) 1821 | self.logger.debug('Deleted cache file : %s', cache_path) 1822 | return 1823 | 1824 | with atomic_writer(cache_path, 'wb') as file_obj: 1825 | serializer.dump(data, file_obj) 1826 | 1827 | self.logger.debug('Cached data saved at : %s', cache_path) 1828 | 1829 | def cached_data_fresh(self, name, max_age): 1830 | """Is data cached at `name` less than `max_age` old? 1831 | 1832 | :param name: name of datastore 1833 | :param max_age: maximum age of data in seconds 1834 | :type max_age: ``int`` 1835 | :returns: ``True`` if data is less than ``max_age`` old, else 1836 | ``False`` 1837 | 1838 | """ 1839 | 1840 | age = self.cached_data_age(name) 1841 | 1842 | if not age: 1843 | return False 1844 | 1845 | return age < max_age 1846 | 1847 | def cached_data_age(self, name): 1848 | """Return age of data cached at `name` in seconds or 0 if 1849 | cache doesn't exist 1850 | 1851 | :param name: name of datastore 1852 | :type name: ``unicode`` 1853 | :returns: age of datastore in seconds 1854 | :rtype: ``int`` 1855 | 1856 | """ 1857 | 1858 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) 1859 | 1860 | if not os.path.exists(cache_path): 1861 | return 0 1862 | 1863 | return time.time() - os.stat(cache_path).st_mtime 1864 | 1865 | def filter(self, query, items, key=lambda x: x, ascending=False, 1866 | include_score=False, min_score=0, max_results=0, 1867 | match_on=MATCH_ALL, fold_diacritics=True): 1868 | """Fuzzy search filter. Returns list of ``items`` that match ``query``. 1869 | 1870 | ``query`` is case-insensitive. Any item that does not contain the 1871 | entirety of ``query`` is rejected. 1872 | 1873 | .. warning:: 1874 | 1875 | If ``query`` is an empty string or contains only whitespace, 1876 | a :class:`ValueError` will be raised. 1877 | 1878 | :param query: query to test items against 1879 | :type query: ``unicode`` 1880 | :param items: iterable of items to test 1881 | :type items: ``list`` or ``tuple`` 1882 | :param key: function to get comparison key from ``items``. 1883 | Must return a ``unicode`` string. The default simply returns 1884 | the item. 1885 | :type key: ``callable`` 1886 | :param ascending: set to ``True`` to get worst matches first 1887 | :type ascending: ``Boolean`` 1888 | :param include_score: Useful for debugging the scoring algorithm. 1889 | If ``True``, results will be a list of tuples 1890 | ``(item, score, rule)``. 1891 | :type include_score: ``Boolean`` 1892 | :param min_score: If non-zero, ignore results with a score lower 1893 | than this. 1894 | :type min_score: ``int`` 1895 | :param max_results: If non-zero, prune results list to this length. 1896 | :type max_results: ``int`` 1897 | :param match_on: Filter option flags. Bitwise-combined list of 1898 | ``MATCH_*`` constants (see below). 1899 | :type match_on: ``int`` 1900 | :param fold_diacritics: Convert search keys to ASCII-only 1901 | characters if ``query`` only contains ASCII characters. 1902 | :type fold_diacritics: ``Boolean`` 1903 | :returns: list of ``items`` matching ``query`` or list of 1904 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. 1905 | ``rule`` is the ``MATCH_*`` rule that matched the item. 1906 | :rtype: ``list`` 1907 | 1908 | **Matching rules** 1909 | 1910 | By default, :meth:`filter` uses all of the following flags (i.e. 1911 | :const:`MATCH_ALL`). The tests are always run in the given order: 1912 | 1913 | 1. :const:`MATCH_STARTSWITH` : Item search key startswith 1914 | ``query``(case-insensitive). 1915 | 2. :const:`MATCH_CAPITALS` : The list of capital letters in item 1916 | search key starts with ``query`` (``query`` may be 1917 | lower-case). E.g., ``of`` would match ``OmniFocus``, 1918 | ``gc`` would match ``Google Chrome`` 1919 | 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on 1920 | non-word characters (.,-,' etc.). Matches if ``query`` is 1921 | one of these atoms (case-insensitive). 1922 | 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first 1923 | characters of the above-described "atoms" (case-insensitive). 1924 | 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of 1925 | the above-described initials. 1926 | 6. :const:`MATCH_INITIALS` : Combination of (4) and (5). 1927 | 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring 1928 | of item search key (case-insensitive). 1929 | 8. :const:`MATCH_ALLCHARS` : Matches if all characters in 1930 | ``query`` appear in item search key in the same order 1931 | (case-insensitive). 1932 | 9. :const:`MATCH_ALL` : Combination of all the above. 1933 | 1934 | 1935 | :const:`MATCH_ALLCHARS` is considerably slower than the other 1936 | tests and provides much less accurate results. 1937 | 1938 | **Examples:** 1939 | 1940 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst 1941 | matches and is expensive to run), use 1942 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. 1943 | 1944 | To match only on capitals, use ``match_on=MATCH_CAPITALS``. 1945 | 1946 | To match only on startswith and substring, use 1947 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. 1948 | 1949 | **Diacritic folding** 1950 | 1951 | .. versionadded:: 1.3 1952 | 1953 | If ``fold_diacritics`` is ``True`` (the default), and ``query`` 1954 | contains only ASCII characters, non-ASCII characters in search keys 1955 | will be converted to ASCII equivalents (e.g. **ü** -> **u**, 1956 | **ß** -> **ss**, **é** -> **e**). 1957 | 1958 | See :const:`ASCII_REPLACEMENTS` for all replacements. 1959 | 1960 | If ``query`` contains non-ASCII characters, search keys will not be 1961 | altered. 1962 | 1963 | """ 1964 | 1965 | if not query: 1966 | raise ValueError('Empty `query`') 1967 | 1968 | # Remove preceding/trailing spaces 1969 | query = query.strip() 1970 | 1971 | if not query: 1972 | raise ValueError('`query` contains only whitespace') 1973 | 1974 | # Use user override if there is one 1975 | fold_diacritics = self.settings.get('__workflow_diacritic_folding', 1976 | fold_diacritics) 1977 | 1978 | results = [] 1979 | 1980 | for item in items: 1981 | skip = False 1982 | score = 0 1983 | words = [s.strip() for s in query.split(' ')] 1984 | value = key(item).strip() 1985 | if value == '': 1986 | continue 1987 | for word in words: 1988 | if word == '': 1989 | continue 1990 | s, rule = self._filter_item(value, word, match_on, 1991 | fold_diacritics) 1992 | 1993 | if not s: # Skip items that don't match part of the query 1994 | skip = True 1995 | score += s 1996 | 1997 | if skip: 1998 | continue 1999 | 2000 | if score: 2001 | # use "reversed" `score` (i.e. highest becomes lowest) and 2002 | # `value` as sort key. This means items with the same score 2003 | # will be sorted in alphabetical not reverse alphabetical order 2004 | results.append(((100.0 / score, value.lower(), score), 2005 | (item, score, rule))) 2006 | 2007 | # sort on keys, then discard the keys 2008 | results.sort(reverse=ascending) 2009 | results = [t[1] for t in results] 2010 | 2011 | if min_score: 2012 | results = [r for r in results if r[1] > min_score] 2013 | 2014 | if max_results and len(results) > max_results: 2015 | results = results[:max_results] 2016 | 2017 | # return list of ``(item, score, rule)`` 2018 | if include_score: 2019 | return results 2020 | # just return list of items 2021 | return [t[0] for t in results] 2022 | 2023 | def _filter_item(self, value, query, match_on, fold_diacritics): 2024 | """Filter ``value`` against ``query`` using rules ``match_on`` 2025 | 2026 | :returns: ``(score, rule)`` 2027 | 2028 | """ 2029 | 2030 | query = query.lower() 2031 | 2032 | if not isascii(query): 2033 | fold_diacritics = False 2034 | 2035 | if fold_diacritics: 2036 | value = self.fold_to_ascii(value) 2037 | 2038 | # pre-filter any items that do not contain all characters 2039 | # of ``query`` to save on running several more expensive tests 2040 | if not set(query) <= set(value.lower()): 2041 | 2042 | return (0, None) 2043 | 2044 | # item starts with query 2045 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query): 2046 | score = 100.0 - (len(value) / len(query)) 2047 | 2048 | return (score, MATCH_STARTSWITH) 2049 | 2050 | # query matches capitalised letters in item, 2051 | # e.g. of = OmniFocus 2052 | if match_on & MATCH_CAPITALS: 2053 | initials = ''.join([c for c in value if c in INITIALS]) 2054 | if initials.lower().startswith(query): 2055 | score = 100.0 - (len(initials) / len(query)) 2056 | 2057 | return (score, MATCH_CAPITALS) 2058 | 2059 | # split the item into "atoms", i.e. words separated by 2060 | # spaces or other non-word characters 2061 | if (match_on & MATCH_ATOM or 2062 | match_on & MATCH_INITIALS_CONTAIN or 2063 | match_on & MATCH_INITIALS_STARTSWITH): 2064 | atoms = [s.lower() for s in split_on_delimiters(value)] 2065 | # print('atoms : %s --> %s' % (value, atoms)) 2066 | # initials of the atoms 2067 | initials = ''.join([s[0] for s in atoms if s]) 2068 | 2069 | if match_on & MATCH_ATOM: 2070 | # is `query` one of the atoms in item? 2071 | # similar to substring, but scores more highly, as it's 2072 | # a word within the item 2073 | if query in atoms: 2074 | score = 100.0 - (len(value) / len(query)) 2075 | 2076 | return (score, MATCH_ATOM) 2077 | 2078 | # `query` matches start (or all) of the initials of the 2079 | # atoms, e.g. ``himym`` matches "How I Met Your Mother" 2080 | # *and* "how i met your mother" (the ``capitals`` rule only 2081 | # matches the former) 2082 | if (match_on & MATCH_INITIALS_STARTSWITH and 2083 | initials.startswith(query)): 2084 | score = 100.0 - (len(initials) / len(query)) 2085 | 2086 | return (score, MATCH_INITIALS_STARTSWITH) 2087 | 2088 | # `query` is a substring of initials, e.g. ``doh`` matches 2089 | # "The Dukes of Hazzard" 2090 | elif (match_on & MATCH_INITIALS_CONTAIN and 2091 | query in initials): 2092 | score = 95.0 - (len(initials) / len(query)) 2093 | 2094 | return (score, MATCH_INITIALS_CONTAIN) 2095 | 2096 | # `query` is a substring of item 2097 | if match_on & MATCH_SUBSTRING and query in value.lower(): 2098 | score = 90.0 - (len(value) / len(query)) 2099 | 2100 | return (score, MATCH_SUBSTRING) 2101 | 2102 | # finally, assign a score based on how close together the 2103 | # characters in `query` are in item. 2104 | if match_on & MATCH_ALLCHARS: 2105 | search = self._search_for_query(query) 2106 | match = search(value) 2107 | if match: 2108 | score = 100.0 / ((1 + match.start()) * 2109 | (match.end() - match.start() + 1)) 2110 | 2111 | return (score, MATCH_ALLCHARS) 2112 | 2113 | # Nothing matched 2114 | return (0, None) 2115 | 2116 | def _search_for_query(self, query): 2117 | if query in self._search_pattern_cache: 2118 | return self._search_pattern_cache[query] 2119 | 2120 | # Build pattern: include all characters 2121 | pattern = [] 2122 | for c in query: 2123 | # pattern.append('[^{0}]*{0}'.format(re.escape(c))) 2124 | pattern.append('.*?{0}'.format(re.escape(c))) 2125 | pattern = ''.join(pattern) 2126 | search = re.compile(pattern, re.IGNORECASE).search 2127 | 2128 | self._search_pattern_cache[query] = search 2129 | return search 2130 | 2131 | def run(self, func): 2132 | """Call ``func`` to run your workflow 2133 | 2134 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` 2135 | instance) as first argument. 2136 | 2137 | ``func`` will be called with :class:`Workflow` instance as first 2138 | argument. 2139 | 2140 | ``func`` should be the main entry point to your workflow. 2141 | 2142 | Any exceptions raised will be logged and an error message will be 2143 | output to Alfred. 2144 | 2145 | """ 2146 | 2147 | start = time.time() 2148 | 2149 | # Call workflow's entry function/method within a try-except block 2150 | # to catch any errors and display an error message in Alfred 2151 | try: 2152 | if self.version: 2153 | msg = 'Workflow version : {0}'.format(self.version) 2154 | self.logger.debug(msg) 2155 | print(msg, file=sys.stderr) 2156 | 2157 | print('The debug log can be found in the log file:\n{0}'.format( 2158 | self.logfile).encode('utf-8'), file=sys.stderr) 2159 | 2160 | # Run update check if configured for self-updates. 2161 | # This call has to go in the `run` try-except block, as it will 2162 | # initialise `self.settings`, which will raise an exception 2163 | # if `settings.json` isn't valid. 2164 | 2165 | if self._update_settings: 2166 | self.check_update() 2167 | 2168 | # Run workflow's entry function/method 2169 | func(self) 2170 | 2171 | # Set last version run to current version after a successful 2172 | # run 2173 | self.set_last_version() 2174 | 2175 | except Exception as err: 2176 | self.logger.exception(err) 2177 | if self.help_url: 2178 | msg = 'For assistance, see: {0}'.format(self.help_url) 2179 | self.logger.info(msg) 2180 | print(msg, file=sys.stderr) 2181 | 2182 | if not sys.stdout.isatty(): # Show error in Alfred 2183 | self._items = [] 2184 | if self._name: 2185 | name = self._name 2186 | elif self._bundleid: 2187 | name = self._bundleid 2188 | else: # pragma: no cover 2189 | name = os.path.dirname(__file__) 2190 | self.add_item("Error in workflow '%s'" % name, unicode(err), 2191 | icon=ICON_ERROR) 2192 | self.send_feedback() 2193 | return 1 2194 | finally: 2195 | msg = 'Workflow finished in {0:0.3f} seconds.'.format( 2196 | time.time() - start) 2197 | self.logger.debug(msg) 2198 | print(msg, file=sys.stderr) 2199 | return 0 2200 | 2201 | # Alfred feedback methods ------------------------------------------ 2202 | 2203 | def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, 2204 | autocomplete=None, valid=False, uid=None, icon=None, 2205 | icontype=None, type=None, largetext=None, copytext=None): 2206 | """Add an item to be output to Alfred 2207 | 2208 | :param title: Title shown in Alfred 2209 | :type title: ``unicode`` 2210 | :param subtitle: Subtitle shown in Alfred 2211 | :type subtitle: ``unicode`` 2212 | :param modifier_subtitles: Subtitles shown when modifier 2213 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase 2214 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` 2215 | :type modifier_subtitles: ``dict`` 2216 | :param arg: Argument passed by Alfred as ``{query}`` when item is 2217 | actioned 2218 | :type arg: ``unicode`` 2219 | :param autocomplete: Text expanded in Alfred when item is TABbed 2220 | :type autocomplete: ``unicode`` 2221 | :param valid: Whether or not item can be actioned 2222 | :type valid: ``Boolean`` 2223 | :param uid: Used by Alfred to remember/sort items 2224 | :type uid: ``unicode`` 2225 | :param icon: Filename of icon to use 2226 | :type icon: ``unicode`` 2227 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` 2228 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype 2229 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to 2230 | use the icon of the file specified as ``icon``, e.g. 2231 | ``icon='/Applications/Safari.app', icontype='fileicon'``. 2232 | Leave as `None` if ``icon`` points to an actual 2233 | icon file. 2234 | :type icontype: ``unicode`` 2235 | :param type: Result type. Currently only ``'file'`` is supported 2236 | (by Alfred). This will tell Alfred to enable file actions for 2237 | this item. 2238 | :type type: ``unicode`` 2239 | :param largetext: Text to be displayed in Alfred's large text box 2240 | if user presses CMD+L on item. 2241 | :type largetext: ``unicode`` 2242 | :param copytext: Text to be copied to pasteboard if user presses 2243 | CMD+C on item. 2244 | :type copytext: ``unicode`` 2245 | :returns: :class:`Item` instance 2246 | 2247 | See the :ref:`script-filter-results` section of the documentation 2248 | for a detailed description of what the various parameters do and how 2249 | they interact with one another. 2250 | 2251 | See :ref:`icons` for a list of the supported system icons. 2252 | 2253 | .. note:: 2254 | 2255 | Although this method returns an :class:`Item` instance, you don't 2256 | need to hold onto it or worry about it. All generated :class:`Item` 2257 | instances are also collected internally and sent to Alfred when 2258 | :meth:`send_feedback` is called. 2259 | 2260 | The generated :class:`Item` is only returned in case you want to 2261 | edit it or do something with it other than send it to Alfred. 2262 | 2263 | """ 2264 | 2265 | item = self.item_class(title, subtitle, modifier_subtitles, arg, 2266 | autocomplete, valid, uid, icon, icontype, type, 2267 | largetext, copytext) 2268 | self._items.append(item) 2269 | return item 2270 | 2271 | def send_feedback(self): 2272 | """Print stored items to console/Alfred as XML.""" 2273 | root = ET.Element('items') 2274 | for item in self._items: 2275 | root.append(item.elem) 2276 | sys.stdout.write('\n') 2277 | sys.stdout.write(ET.tostring(root).encode('utf-8')) 2278 | sys.stdout.flush() 2279 | 2280 | #################################################################### 2281 | # Updating methods 2282 | #################################################################### 2283 | 2284 | @property 2285 | def first_run(self): 2286 | """Return ``True`` if it's the first time this version has run. 2287 | 2288 | .. versionadded:: 1.9.10 2289 | 2290 | Raises a :class:`ValueError` if :attr:`version` isn't set. 2291 | 2292 | """ 2293 | 2294 | if not self.version: 2295 | raise ValueError('No workflow version set') 2296 | 2297 | if not self.last_version_run: 2298 | return True 2299 | 2300 | return self.version != self.last_version_run 2301 | 2302 | @property 2303 | def last_version_run(self): 2304 | """Return version of last version to run (or ``None``) 2305 | 2306 | .. versionadded:: 1.9.10 2307 | 2308 | :returns: :class:`~workflow.update.Version` instance 2309 | or ``None`` 2310 | 2311 | """ 2312 | 2313 | if self._last_version_run is UNSET: 2314 | 2315 | version = self.settings.get('__workflow_last_version') 2316 | if version: 2317 | from update import Version 2318 | version = Version(version) 2319 | 2320 | self._last_version_run = version 2321 | 2322 | self.logger.debug('Last run version : {0}'.format( 2323 | self._last_version_run)) 2324 | 2325 | return self._last_version_run 2326 | 2327 | def set_last_version(self, version=None): 2328 | """Set :attr:`last_version_run` to current version 2329 | 2330 | .. versionadded:: 1.9.10 2331 | 2332 | :param version: version to store (default is current version) 2333 | :type version: :class:`~workflow.update.Version` instance 2334 | or ``unicode`` 2335 | :returns: ``True`` if version is saved, else ``False`` 2336 | 2337 | """ 2338 | 2339 | if not version: 2340 | if not self.version: 2341 | self.logger.warning( 2342 | "Can't save last version: workflow has no version") 2343 | return False 2344 | 2345 | version = self.version 2346 | 2347 | if isinstance(version, basestring): 2348 | from update import Version 2349 | version = Version(version) 2350 | 2351 | self.settings['__workflow_last_version'] = str(version) 2352 | 2353 | self.logger.debug('Set last run version : {0}'.format(version)) 2354 | 2355 | return True 2356 | 2357 | @property 2358 | def update_available(self): 2359 | """Is an update available? 2360 | 2361 | .. versionadded:: 1.9 2362 | 2363 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2364 | information on how to enable your workflow to update itself. 2365 | 2366 | :returns: ``True`` if an update is available, else ``False`` 2367 | 2368 | """ 2369 | 2370 | update_data = self.cached_data('__workflow_update_status', max_age=0) 2371 | self.logger.debug('update_data : {0}'.format(update_data)) 2372 | 2373 | if not update_data or not update_data.get('available'): 2374 | return False 2375 | 2376 | return update_data['available'] 2377 | 2378 | @property 2379 | def prereleases(self): 2380 | """Should the workflow update to a newer pre-release version if 2381 | available? 2382 | 2383 | .. versionadded:: 1.16 2384 | 2385 | :returns: ``True`` if pre-releases are enabled with the :ref:`magic 2386 | argument ` or the ``update_settings`` dict, else 2387 | ``False`` 2388 | 2389 | """ 2390 | if self._update_settings.get('prereleases'): 2391 | return True 2392 | 2393 | return self.settings.get('__workflow_prereleases') or False 2394 | 2395 | def check_update(self, force=False): 2396 | """Call update script if it's time to check for a new release 2397 | 2398 | .. versionadded:: 1.9 2399 | 2400 | The update script will be run in the background, so it won't 2401 | interfere in the execution of your workflow. 2402 | 2403 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2404 | information on how to enable your workflow to update itself. 2405 | 2406 | :param force: Force update check 2407 | :type force: ``Boolean`` 2408 | 2409 | """ 2410 | 2411 | frequency = self._update_settings.get('frequency', 2412 | DEFAULT_UPDATE_FREQUENCY) 2413 | 2414 | if not force and not self.settings.get('__workflow_autoupdate', True): 2415 | self.logger.debug('Auto update turned off by user') 2416 | return 2417 | 2418 | # Check for new version if it's time 2419 | if (force or not self.cached_data_fresh( 2420 | '__workflow_update_status', frequency * 86400)): 2421 | 2422 | github_slug = self._update_settings['github_slug'] 2423 | # version = self._update_settings['version'] 2424 | version = str(self.version) 2425 | 2426 | from background import run_in_background 2427 | 2428 | # update.py is adjacent to this file 2429 | update_script = os.path.join(os.path.dirname(__file__), 2430 | b'update.py') 2431 | 2432 | cmd = ['/usr/bin/python', update_script, 'check', github_slug, 2433 | version] 2434 | 2435 | if self.prereleases: 2436 | cmd.append('--prereleases') 2437 | 2438 | self.logger.info('Checking for update ...') 2439 | 2440 | run_in_background('__workflow_update_check', cmd) 2441 | 2442 | else: 2443 | self.logger.debug('Update check not due') 2444 | 2445 | def start_update(self): 2446 | """Check for update and download and install new workflow file 2447 | 2448 | .. versionadded:: 1.9 2449 | 2450 | See :ref:`manual-updates` in the :ref:`user-manual` for detailed 2451 | information on how to enable your workflow to update itself. 2452 | 2453 | :returns: ``True`` if an update is available and will be 2454 | installed, else ``False`` 2455 | 2456 | """ 2457 | 2458 | import update 2459 | 2460 | github_slug = self._update_settings['github_slug'] 2461 | # version = self._update_settings['version'] 2462 | version = str(self.version) 2463 | 2464 | if not update.check_update(github_slug, version, self.prereleases): 2465 | return False 2466 | 2467 | from background import run_in_background 2468 | 2469 | # update.py is adjacent to this file 2470 | update_script = os.path.join(os.path.dirname(__file__), 2471 | b'update.py') 2472 | 2473 | cmd = ['/usr/bin/python', update_script, 'install', github_slug, 2474 | version] 2475 | 2476 | if self.prereleases: 2477 | cmd.append('--prereleases') 2478 | 2479 | self.logger.debug('Downloading update ...') 2480 | run_in_background('__workflow_update_install', cmd) 2481 | 2482 | return True 2483 | 2484 | #################################################################### 2485 | # Keychain password storage methods 2486 | #################################################################### 2487 | 2488 | def save_password(self, account, password, service=None): 2489 | """Save account credentials. 2490 | 2491 | If the account exists, the old password will first be deleted 2492 | (Keychain throws an error otherwise). 2493 | 2494 | If something goes wrong, a :class:`KeychainError` exception will 2495 | be raised. 2496 | 2497 | :param account: name of the account the password is for, e.g. 2498 | "Pinboard" 2499 | :type account: ``unicode`` 2500 | :param password: the password to secure 2501 | :type password: ``unicode`` 2502 | :param service: Name of the service. By default, this is the 2503 | workflow's bundle ID 2504 | :type service: ``unicode`` 2505 | 2506 | """ 2507 | if not service: 2508 | service = self.bundleid 2509 | 2510 | try: 2511 | self._call_security('add-generic-password', service, account, 2512 | '-w', password) 2513 | self.logger.debug('Saved password : %s:%s', service, account) 2514 | 2515 | except PasswordExists: 2516 | self.logger.debug('Password exists : %s:%s', service, account) 2517 | current_password = self.get_password(account, service) 2518 | 2519 | if current_password == password: 2520 | self.logger.debug('Password unchanged') 2521 | 2522 | else: 2523 | self.delete_password(account, service) 2524 | self._call_security('add-generic-password', service, 2525 | account, '-w', password) 2526 | self.logger.debug('save_password : %s:%s', service, account) 2527 | 2528 | def get_password(self, account, service=None): 2529 | """Retrieve the password saved at ``service/account``. Raise 2530 | :class:`PasswordNotFound` exception if password doesn't exist. 2531 | 2532 | :param account: name of the account the password is for, e.g. 2533 | "Pinboard" 2534 | :type account: ``unicode`` 2535 | :param service: Name of the service. By default, this is the workflow's 2536 | bundle ID 2537 | :type service: ``unicode`` 2538 | :returns: account password 2539 | :rtype: ``unicode`` 2540 | 2541 | """ 2542 | 2543 | if not service: 2544 | service = self.bundleid 2545 | 2546 | output = self._call_security('find-generic-password', service, 2547 | account, '-g') 2548 | 2549 | # Parsing of `security` output is adapted from python-keyring 2550 | # by Jason R. Coombs 2551 | # https://pypi.python.org/pypi/keyring 2552 | m = re.search( 2553 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', 2554 | output) 2555 | 2556 | if m: 2557 | groups = m.groupdict() 2558 | h = groups.get('hex') 2559 | password = groups.get('pw') 2560 | if h: 2561 | password = unicode(binascii.unhexlify(h), 'utf-8') 2562 | 2563 | self.logger.debug('Got password : %s:%s', service, account) 2564 | 2565 | return password 2566 | 2567 | def delete_password(self, account, service=None): 2568 | """Delete the password stored at ``service/account``. Raises 2569 | :class:`PasswordNotFound` if account is unknown. 2570 | 2571 | :param account: name of the account the password is for, e.g. 2572 | "Pinboard" 2573 | :type account: ``unicode`` 2574 | :param service: Name of the service. By default, this is the workflow's 2575 | bundle ID 2576 | :type service: ``unicode`` 2577 | 2578 | """ 2579 | 2580 | if not service: 2581 | service = self.bundleid 2582 | 2583 | self._call_security('delete-generic-password', service, account) 2584 | 2585 | self.logger.debug('Deleted password : %s:%s', service, account) 2586 | 2587 | #################################################################### 2588 | # Methods for workflow:* magic args 2589 | #################################################################### 2590 | 2591 | def _register_default_magic(self): 2592 | """Register the built-in magic arguments""" 2593 | # TODO: refactor & simplify 2594 | 2595 | # Wrap callback and message with callable 2596 | def callback(func, msg): 2597 | def wrapper(): 2598 | func() 2599 | return msg 2600 | 2601 | return wrapper 2602 | 2603 | self.magic_arguments['delcache'] = callback(self.clear_cache, 2604 | 'Deleted workflow cache') 2605 | self.magic_arguments['deldata'] = callback(self.clear_data, 2606 | 'Deleted workflow data') 2607 | self.magic_arguments['delsettings'] = callback( 2608 | self.clear_settings, 'Deleted workflow settings') 2609 | self.magic_arguments['reset'] = callback(self.reset, 2610 | 'Reset workflow') 2611 | self.magic_arguments['openlog'] = callback(self.open_log, 2612 | 'Opening workflow log file') 2613 | self.magic_arguments['opencache'] = callback( 2614 | self.open_cachedir, 'Opening workflow cache directory') 2615 | self.magic_arguments['opendata'] = callback( 2616 | self.open_datadir, 'Opening workflow data directory') 2617 | self.magic_arguments['openworkflow'] = callback( 2618 | self.open_workflowdir, 'Opening workflow directory') 2619 | self.magic_arguments['openterm'] = callback( 2620 | self.open_terminal, 'Opening workflow root directory in Terminal') 2621 | 2622 | # Diacritic folding 2623 | def fold_on(): 2624 | self.settings['__workflow_diacritic_folding'] = True 2625 | return 'Diacritics will always be folded' 2626 | 2627 | def fold_off(): 2628 | self.settings['__workflow_diacritic_folding'] = False 2629 | return 'Diacritics will never be folded' 2630 | 2631 | def fold_default(): 2632 | if '__workflow_diacritic_folding' in self.settings: 2633 | del self.settings['__workflow_diacritic_folding'] 2634 | return 'Diacritics folding reset' 2635 | 2636 | self.magic_arguments['foldingon'] = fold_on 2637 | self.magic_arguments['foldingoff'] = fold_off 2638 | self.magic_arguments['foldingdefault'] = fold_default 2639 | 2640 | # Updates 2641 | def update_on(): 2642 | self.settings['__workflow_autoupdate'] = True 2643 | return 'Auto update turned on' 2644 | 2645 | def update_off(): 2646 | self.settings['__workflow_autoupdate'] = False 2647 | return 'Auto update turned off' 2648 | 2649 | def prereleases_on(): 2650 | self.settings['__workflow_prereleases'] = True 2651 | return 'Prerelease updates turned on' 2652 | 2653 | def prereleases_off(): 2654 | self.settings['__workflow_prereleases'] = False 2655 | return 'Prerelease updates turned off' 2656 | 2657 | def do_update(): 2658 | if self.start_update(): 2659 | return 'Downloading and installing update ...' 2660 | else: 2661 | return 'No update available' 2662 | 2663 | self.magic_arguments['autoupdate'] = update_on 2664 | self.magic_arguments['noautoupdate'] = update_off 2665 | self.magic_arguments['prereleases'] = prereleases_on 2666 | self.magic_arguments['noprereleases'] = prereleases_off 2667 | self.magic_arguments['update'] = do_update 2668 | 2669 | # Help 2670 | def do_help(): 2671 | if self.help_url: 2672 | self.open_help() 2673 | return 'Opening workflow help URL in browser' 2674 | else: 2675 | return 'Workflow has no help URL' 2676 | 2677 | def show_version(): 2678 | if self.version: 2679 | return 'Version: {0}'.format(self.version) 2680 | else: 2681 | return 'This workflow has no version number' 2682 | 2683 | def list_magic(): 2684 | """Display all available magic args in Alfred""" 2685 | isatty = sys.stderr.isatty() 2686 | for name in sorted(self.magic_arguments.keys()): 2687 | if name == 'magic': 2688 | continue 2689 | arg = '{0}{1}'.format(self.magic_prefix, name) 2690 | self.logger.debug(arg) 2691 | 2692 | if not isatty: 2693 | self.add_item(arg, icon=ICON_INFO) 2694 | 2695 | if not isatty: 2696 | self.send_feedback() 2697 | 2698 | self.magic_arguments['help'] = do_help 2699 | self.magic_arguments['magic'] = list_magic 2700 | self.magic_arguments['version'] = show_version 2701 | 2702 | def clear_cache(self, filter_func=lambda f: True): 2703 | """Delete all files in workflow's :attr:`cachedir`. 2704 | 2705 | :param filter_func: Callable to determine whether a file should be 2706 | deleted or not. ``filter_func`` is called with the filename 2707 | of each file in the data directory. If it returns ``True``, 2708 | the file will be deleted. 2709 | By default, *all* files will be deleted. 2710 | :type filter_func: ``callable`` 2711 | """ 2712 | self._delete_directory_contents(self.cachedir, filter_func) 2713 | 2714 | def clear_data(self, filter_func=lambda f: True): 2715 | """Delete all files in workflow's :attr:`datadir`. 2716 | 2717 | :param filter_func: Callable to determine whether a file should be 2718 | deleted or not. ``filter_func`` is called with the filename 2719 | of each file in the data directory. If it returns ``True``, 2720 | the file will be deleted. 2721 | By default, *all* files will be deleted. 2722 | :type filter_func: ``callable`` 2723 | """ 2724 | self._delete_directory_contents(self.datadir, filter_func) 2725 | 2726 | def clear_settings(self): 2727 | """Delete workflow's :attr:`settings_path`.""" 2728 | if os.path.exists(self.settings_path): 2729 | os.unlink(self.settings_path) 2730 | self.logger.debug('Deleted : %r', self.settings_path) 2731 | 2732 | def reset(self): 2733 | """Delete :attr:`settings `, :attr:`cache ` 2734 | and :attr:`data ` 2735 | 2736 | """ 2737 | 2738 | self.clear_cache() 2739 | self.clear_data() 2740 | self.clear_settings() 2741 | 2742 | def open_log(self): 2743 | """Open workflows :attr:`logfile` in standard 2744 | application (usually Console.app). 2745 | 2746 | """ 2747 | 2748 | subprocess.call(['open', self.logfile]) 2749 | 2750 | def open_cachedir(self): 2751 | """Open the workflow's :attr:`cachedir` in Finder.""" 2752 | subprocess.call(['open', self.cachedir]) 2753 | 2754 | def open_datadir(self): 2755 | """Open the workflow's :attr:`datadir` in Finder.""" 2756 | subprocess.call(['open', self.datadir]) 2757 | 2758 | def open_workflowdir(self): 2759 | """Open the workflow's :attr:`workflowdir` in Finder.""" 2760 | subprocess.call(['open', self.workflowdir]) 2761 | 2762 | def open_terminal(self): 2763 | """Open a Terminal window at workflow's :attr:`workflowdir`.""" 2764 | 2765 | subprocess.call(['open', '-a', 'Terminal', 2766 | self.workflowdir]) 2767 | 2768 | def open_help(self): 2769 | """Open :attr:`help_url` in default browser""" 2770 | subprocess.call(['open', self.help_url]) 2771 | 2772 | return 'Opening workflow help URL in browser' 2773 | 2774 | #################################################################### 2775 | # Helper methods 2776 | #################################################################### 2777 | 2778 | def decode(self, text, encoding=None, normalization=None): 2779 | """Return ``text`` as normalised unicode. 2780 | 2781 | If ``encoding`` and/or ``normalization`` is ``None``, the 2782 | ``input_encoding``and ``normalization`` parameters passed to 2783 | :class:`Workflow` are used. 2784 | 2785 | :param text: string 2786 | :type text: encoded or Unicode string. If ``text`` is already a 2787 | Unicode string, it will only be normalised. 2788 | :param encoding: The text encoding to use to decode ``text`` to 2789 | Unicode. 2790 | :type encoding: ``unicode`` or ``None`` 2791 | :param normalization: The nomalisation form to apply to ``text``. 2792 | :type normalization: ``unicode`` or ``None`` 2793 | :returns: decoded and normalised ``unicode`` 2794 | 2795 | :class:`Workflow` uses "NFC" normalisation by default. This is the 2796 | standard for Python and will work well with data from the web (via 2797 | :mod:`~workflow.web` or :mod:`json`). 2798 | 2799 | OS X, on the other hand, uses "NFD" normalisation (nearly), so data 2800 | coming from the system (e.g. via :mod:`subprocess` or 2801 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either 2802 | normalise this data, too, or change the default normalisation used by 2803 | :class:`Workflow`. 2804 | 2805 | """ 2806 | 2807 | encoding = encoding or self._input_encoding 2808 | normalization = normalization or self._normalizsation 2809 | if not isinstance(text, unicode): 2810 | text = unicode(text, encoding) 2811 | return unicodedata.normalize(normalization, text) 2812 | 2813 | def fold_to_ascii(self, text): 2814 | """Convert non-ASCII characters to closest ASCII equivalent. 2815 | 2816 | .. versionadded:: 1.3 2817 | 2818 | .. note:: This only works for a subset of European languages. 2819 | 2820 | :param text: text to convert 2821 | :type text: ``unicode`` 2822 | :returns: text containing only ASCII characters 2823 | :rtype: ``unicode`` 2824 | 2825 | """ 2826 | if isascii(text): 2827 | return text 2828 | text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text]) 2829 | return unicode(unicodedata.normalize('NFKD', 2830 | text).encode('ascii', 'ignore')) 2831 | 2832 | def dumbify_punctuation(self, text): 2833 | """Convert non-ASCII punctuation to closest ASCII equivalent. 2834 | 2835 | This method replaces "smart" quotes and n- or m-dashes with their 2836 | workaday ASCII equivalents. This method is currently not used 2837 | internally, but exists as a helper method for workflow authors. 2838 | 2839 | .. versionadded: 1.9.7 2840 | 2841 | :param text: text to convert 2842 | :type text: ``unicode`` 2843 | :returns: text with only ASCII punctuation 2844 | :rtype: ``unicode`` 2845 | 2846 | """ 2847 | if isascii(text): 2848 | return text 2849 | 2850 | text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text]) 2851 | return text 2852 | 2853 | def _delete_directory_contents(self, dirpath, filter_func): 2854 | """Delete all files in a directory 2855 | 2856 | :param dirpath: path to directory to clear 2857 | :type dirpath: ``unicode`` or ``str`` 2858 | :param filter_func function to determine whether a file shall be 2859 | deleted or not. 2860 | :type filter_func ``callable`` 2861 | """ 2862 | 2863 | if os.path.exists(dirpath): 2864 | for filename in os.listdir(dirpath): 2865 | if not filter_func(filename): 2866 | continue 2867 | path = os.path.join(dirpath, filename) 2868 | if os.path.isdir(path): 2869 | shutil.rmtree(path) 2870 | else: 2871 | os.unlink(path) 2872 | self.logger.debug('Deleted : %r', path) 2873 | 2874 | def _load_info_plist(self): 2875 | """Load workflow info from ``info.plist`` 2876 | 2877 | """ 2878 | 2879 | self._info = plistlib.readPlist(self._info_plist) 2880 | self._info_loaded = True 2881 | 2882 | def _create(self, dirpath): 2883 | """Create directory `dirpath` if it doesn't exist 2884 | 2885 | :param dirpath: path to directory 2886 | :type dirpath: ``unicode`` 2887 | :returns: ``dirpath`` argument 2888 | :rtype: ``unicode`` 2889 | 2890 | """ 2891 | 2892 | if not os.path.exists(dirpath): 2893 | os.makedirs(dirpath) 2894 | return dirpath 2895 | 2896 | def _call_security(self, action, service, account, *args): 2897 | """Call the ``security`` CLI app that provides access to keychains. 2898 | 2899 | 2900 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` 2901 | exceptions (the first two are subclasses of `KeychainError`). 2902 | 2903 | :param action: The ``security`` action to call, e.g. 2904 | ``add-generic-password`` 2905 | :type action: ``unicode`` 2906 | :param service: Name of the service. 2907 | :type service: ``unicode`` 2908 | :param account: name of the account the password is for, e.g. 2909 | "Pinboard" 2910 | :type account: ``unicode`` 2911 | :param password: the password to secure 2912 | :type password: ``unicode`` 2913 | :param *args: list of command line arguments to be passed to 2914 | ``security`` 2915 | :type *args: `list` or `tuple` 2916 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a 2917 | ``unicode`` string. 2918 | :rtype: `tuple` (`int`, ``unicode``) 2919 | 2920 | """ 2921 | 2922 | cmd = ['security', action, '-s', service, '-a', account] + list(args) 2923 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, 2924 | stderr=subprocess.STDOUT) 2925 | retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8') 2926 | if retcode == 44: # password does not exist 2927 | raise PasswordNotFound() 2928 | elif retcode == 45: # password already exists 2929 | raise PasswordExists() 2930 | elif retcode > 0: 2931 | err = KeychainError('Unknown Keychain error : %s' % output) 2932 | err.retcode = retcode 2933 | raise err 2934 | return output 2935 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1keil/alfred-vagrant-workflow/f64dc891c686ace6f2ebd424ee596e1e1afba291/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_commons.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | import unittest 3 | import commons 4 | 5 | 6 | class Test(unittest.TestCase): 7 | @patch('commons.call') 8 | def test_external_trigger(self, mocked_call): 9 | with patch.dict('os.environ', {'alfred_version': '3.0', 10 | 'alfred_workflow_bundleid': 'a.b.c'}): 11 | commons.external_trigger('name', 'argument') 12 | mocked_call.assert_called_once() 13 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mock 3 | import string 4 | import random 5 | import unittest 6 | import tempfile 7 | from copy import deepcopy 8 | from json import dump 9 | 10 | import vagrantup 11 | import commons 12 | import workflow 13 | 14 | 15 | class Test(unittest.TestCase): 16 | def test_validate_version(self): 17 | self.assertTrue(vagrantup.validate_version(1) is None) 18 | self.assertRaises(Exception, vagrantup.validate_version, 0) 19 | 20 | def test_normalize_state(self): 21 | for states, output in commons.states.items(): 22 | for state in states: 23 | self.assertEqual(vagrantup.normalize_state(state), output) 24 | self.assertEqual(vagrantup.normalize_state('bla'), 'unexpected') 25 | 26 | def test_get_state_icon(self): 27 | providers = ['virtualbox', 'vmware_fusion'] 28 | states = ['not created', 'paused', 'running', 'stopped', 'unknown'] 29 | 30 | for provider in providers: 31 | for state in states: 32 | icon_path = vagrantup.get_state_icon(state, provider) 33 | icon_name = os.path.basename(icon_path) 34 | self.assertFalse(icon_path is None) 35 | self.assertEqual(icon_name.split('.')[-3], provider) 36 | self.assertTrue(os.path.isfile(icon_path)) 37 | 38 | for state in states: 39 | icon_path = vagrantup.get_state_icon('unknown', state) 40 | self.assertFalse(icon_path is None) 41 | self.assertTrue(os.path.isfile(icon_path)) 42 | 43 | vagrantup.ICONS_STATES_PATH = os.getcwd() 44 | self.assertTrue(vagrantup.get_state_icon('x', 'y') is None) 45 | 46 | def test_get_action_icon(self): 47 | actions = ['destroy', 'halt', 'provision', 48 | 'rdp', 'resume', 'ssh', 'suspend', 'up'] 49 | for action in actions: 50 | icon_path = vagrantup.get_action_icon(action) 51 | self.assertFalse(icon_path is None) 52 | self.assertTrue(os.path.isfile(icon_path)) 53 | 54 | self.assertTrue(vagrantup.get_action_icon('x') is None) 55 | 56 | def test_list_machines(self): 57 | wf = workflow.Workflow() 58 | machines = generate_index()['machines'] 59 | vagrantup.list_machines(machines, wf) 60 | for item in wf._items: 61 | mid, vagrantfile_path = item.arg.split(' ') 62 | meta = machines[mid] 63 | self.assertTrue(mid in machines.keys()) 64 | self.assertTrue(item.uid in machines.keys()) 65 | self.assertEqual(item.title, meta['name']) 66 | self.assertEqual(item.subtitle, meta['vagrantfile_path']) 67 | self.assertEqual(vagrantfile_path, meta['vagrantfile_path']) 68 | self.assertEqual(item.valid, True) 69 | self.assertFalse(item.icon, None) 70 | 71 | def test_empty_machine_index(self): 72 | wf = workflow.Workflow() 73 | old_get = vagrantup.get_machine_data 74 | vagrantup.get_machine_data = lambda: generate_index(0)['machines'] 75 | vagrantup.do_list([], wf) 76 | vagrantup.get_machine_data = old_get 77 | self.assertEqual(len(wf._items), 1) 78 | self.assertEqual(wf._items[0].valid, False) 79 | 80 | def test_get_search_key(self): 81 | machine = generate_machine().items()[0] 82 | meta = machine[1] 83 | self.assertEqual(' '.join([meta['name'], 84 | meta['vagrantfile_path'], 85 | meta['provider']]), 86 | vagrantup.get_search_key(machine)) 87 | 88 | def test_show_warning(self): 89 | mock_wf = mock.MagicMock() 90 | vagrantup.show_warning('title', 'subtitle', mock_wf) 91 | mock_wf.add_item.assert_called_once_with(title='title', 92 | subtitle='subtitle', 93 | icon=vagrantup.ICON_WARNING, 94 | valid=False) 95 | 96 | @mock.patch('vagrantup.logger') 97 | @mock.patch('vagrantup.run_alfred') 98 | def test_do_set(self, mock_run_alfred, moch): 99 | mock_wf = mock.MagicMock() 100 | mock_args = [] 101 | vagrantup.do_set(mock_args, mock_wf, 'test') 102 | mock_wf.cache_data.assert_called_once_with('id', mock_args) 103 | mock_run_alfred.assert_called_once_with(':vagrant-id ') 104 | 105 | 106 | class TestVagrantHome(unittest.TestCase): 107 | def setUp(self): 108 | self.original_environ = deepcopy(os.environ) 109 | self.index_content = generate_index() 110 | self.vagrant_home = create_vagrant_home(self.index_content) 111 | 112 | def tearDown(self): 113 | os.environ = self.original_environ 114 | 115 | def test_get_machine_data_with_env(self): 116 | os.environ['VAGRANT_HOME'] = self.vagrant_home 117 | index_data = vagrantup.get_machine_data() 118 | self.assertEqual(self.index_content['machines'], index_data) 119 | 120 | def test_get_machine_data(self): 121 | vagrantup.VAGRANT_HOME = self.vagrant_home 122 | index_data = vagrantup.get_machine_data() 123 | self.assertEqual(self.index_content['machines'], index_data) 124 | 125 | 126 | class TestCommandLine(unittest.TestCase): 127 | def setUp(self): 128 | parser = vagrantup.get_parser() 129 | self.parser = parser 130 | 131 | def test_with_empty_args(self): 132 | self.assertRaises(SystemExit, self.parser.parse_args, []) 133 | 134 | def test_with_help_args(self): 135 | self.assertRaises(SystemExit, self.parser.parse_args, ['--help']) 136 | 137 | 138 | def create_vagrant_home(index_content): 139 | vagrant_home = tempfile.mkdtemp() 140 | path, file_name = os.path.split(vagrantup.VAGRANT_INDEX) 141 | os.makedirs(os.path.join(vagrant_home, path)) 142 | full_path = os.path.join(vagrant_home, path, file_name) 143 | with open(full_path, 'w') as f: 144 | dump(index_content, f) 145 | return vagrant_home 146 | 147 | 148 | def generate_index(machine_number=3): 149 | index = { 150 | 'version': 1, 151 | 'machines': {} 152 | } 153 | for _ in range(machine_number): 154 | index['machines'].update(generate_machine()) 155 | return index 156 | 157 | 158 | def id_generator(size=32, chars=string.ascii_lowercase + string.digits): 159 | return ''.join(random.choice(chars) for _ in range(size)) 160 | 161 | 162 | def generate_machine(): 163 | return { 164 | id_generator(): { 165 | 'extra_data': { 166 | 'box': { 167 | 'name': 'ubuntu/trusty64', 168 | 'provider': 'virtualbox', 169 | 'version': '1.0', 170 | }, 171 | }, 172 | 'local_data_path': '/tmp/file/.vagrant', 173 | 'name': 'default', 174 | 'provider': 'virtualbox', 175 | 'state': 'running', 176 | 'updated_at': None, 177 | 'vagrantfile_name': None, 178 | 'vagrantfile_path': '/tmp/file', 179 | } 180 | } 181 | 182 | if __name__ == '__main__': 183 | unittest.main() 184 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,flake8 3 | skipsdist=True 4 | [testenv] 5 | deps= 6 | mock==1.0.1 7 | nose==1.3.6 8 | nose-cov==1.6 9 | coverage==3.7.1 10 | commands=nosetests --with-cov --cov-config .coveragerc 11 | [testenv:flake8] 12 | deps= 13 | flake8==2.4.0 14 | commands=flake8 --exclude=workflow,argparse.py 15 | --------------------------------------------------------------------------------