├── .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 | [](https://travis-ci.org/m1keil/alfred-vagrant-workflow) [](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 | 
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 |
--------------------------------------------------------------------------------