diff --git a/README.md b/README.md index aed83d4..5e933ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ -# Alfred-Viscosity # +Alfred-Viscosity +================ Manage your [Viscosity][viscosity] VPN connections from [Alfred][alfred]. +**Note:** Version 2 requires Alfred 3. + ![Alfred-Viscosity in action][demo] -## Usage ## +Usage +----- - `vpn []` — View and filter Viscosity VPN connections. - `↩` — Connect/disconnect selected connection. @@ -16,7 +20,8 @@ If you haven't entered a query, any active VPN connections will be shown at the If you are connected to multiple VPNs, an additional "Disconnect All" item will be shown first. -## Licence & thanks ## +Licence & thanks +---------------- This workflow is licensed under the [MIT Licence][mit]. diff --git a/TODO b/TODO new file mode 100644 index 0000000..f02faf4 --- /dev/null +++ b/TODO @@ -0,0 +1,9 @@ +Features: + +Bugs: + + + +___________________ +Archive: + - Notify user when an update is available. @done(17-04-01 17:41) @project(Features) diff --git a/Viscosity-1.1.alfredworkflow b/Viscosity-1.1.alfredworkflow deleted file mode 100644 index 4e4ef76..0000000 Binary files a/Viscosity-1.1.alfredworkflow and /dev/null differ diff --git a/Viscosity-2.0.alfredworkflow b/Viscosity-2.0.alfredworkflow new file mode 100644 index 0000000..daf652e Binary files /dev/null and b/Viscosity-2.0.alfredworkflow differ diff --git a/src/info.plist b/src/info.plist index 8644676..3e7dd81 100644 --- a/src/info.plist +++ b/src/info.plist @@ -15,6 +15,8 @@ 0 modifiersubtext + vitoclose + @@ -31,6 +33,8 @@ config + alfredfiltersresults + argumenttype 1 escaping @@ -48,7 +52,11 @@ runningsubtext Loading VPN information… script - /usr/bin/python viscosity.py list "{query}" + /usr/bin/python viscosity.py list "$1" + scriptargtype + 1 + scriptfile + subtext Manage VPN Connections title @@ -63,7 +71,7 @@ uid 2CD984EF-1CE0-4DFD-BC28-7AB81B9C9F38 version - 0 + 2 config @@ -73,7 +81,11 @@ escaping 0 script - /usr/bin/python viscosity.py {query} + /usr/bin/python viscosity.py $1 + scriptargtype + 1 + scriptfile + type 0 @@ -82,7 +94,7 @@ uid 3320B185-EABE-4399-98AB-D93EFA238B86 version - 0 + 2 readme @@ -96,15 +108,21 @@ Requires Viscosity app from https://www.sparklabs.com/viscosity/ 2CD984EF-1CE0-4DFD-BC28-7AB81B9C9F38 + xpos + 40 ypos - 10 + 40 3320B185-EABE-4399-98AB-D93EFA238B86 + xpos + 240 ypos - 10 + 40 + version + 2.0 webaddress diff --git a/src/scripts/connect_vpn.scpt b/src/scripts/connect_vpn.scpt index 8e79a2d..fe7c2c6 100644 Binary files a/src/scripts/connect_vpn.scpt and b/src/scripts/connect_vpn.scpt differ diff --git a/src/scripts/disconnect_vpn.scpt b/src/scripts/disconnect_vpn.scpt index fc0c098..b1533e2 100644 Binary files a/src/scripts/disconnect_vpn.scpt and b/src/scripts/disconnect_vpn.scpt differ diff --git a/src/scripts/get_connections.scpt b/src/scripts/get_connections.scpt index 9626d77..1e2be19 100644 Binary files a/src/scripts/get_connections.scpt and b/src/scripts/get_connections.scpt differ diff --git a/src/update-available.png b/src/update-available.png new file mode 100644 index 0000000..b551044 Binary files /dev/null and b/src/update-available.png differ diff --git a/src/viscosity.py b/src/viscosity.py index 8f01ba9..6afdf36 100644 --- a/src/viscosity.py +++ b/src/viscosity.py @@ -1,7 +1,7 @@ #!/usr/bin/python # encoding: utf-8 # -# Copyright © 2015 deanishe@deanishe.net +# Copyright (c) 2015 deanishe@deanishe.net # # MIT Licence. See http://opensource.org/licenses/MIT # @@ -21,7 +21,7 @@ -h, --help Show this message and exit. """ -from __future__ import print_function, unicode_literals, absolute_import +from __future__ import print_function, absolute_import from collections import namedtuple from operator import attrgetter @@ -30,10 +30,11 @@ import sys import docopt -from workflow import Workflow, ICON_WARNING +from workflow import Workflow3, ICON_WARNING log = None +ICON_UPDATE = 'update-available.png' UPDATE_SETTINGS = { 'github_slug': 'deanishe/alfred-viscosity' } @@ -44,12 +45,10 @@ def run_script(script_name, *args): """Return output of script `script_name`. - Script must reside in `./scripts` subdirectory and have extension `.scpt`. """ - script = wf.workflowfile('scripts/{0}.scpt'.format(script_name)) cmd = ['/usr/bin/osascript', script.encode('utf-8')] cmd += [a.encode('utf-8') for a in args] @@ -70,7 +69,6 @@ def _load_connections(): Returns a list of VPN tuples. """ - connections = [] output = run_script('get_connections').strip() for line in output.split('\n'): @@ -93,7 +91,8 @@ def _load_connections(): def load_connections(): """Return list of VPN connections. Cache list for 5 seconds.""" - return wf.cached_data('connections', _load_connections, max_age=5) + return wf.cached_data('connections', _load_connections, max_age=0, + session=True) def do_list(args): @@ -113,6 +112,14 @@ def do_list(args): # --------------------------------------------------------- # Display active connections at the top if there's no query + nouids = False + if wf.update_available: + nouids = True + wf.add_item('Update available!', + 'Action this item to update the workflow', + autocomplete='workflow:update', + valid=False, + icon=ICON_UPDATE) if not query: if len(active_connections) > 1: @@ -124,7 +131,7 @@ def do_list(args): ) for con in active_connections: - arg = 'disconnect {0}'.format(pipes.quote(con.name)) + arg = u'disconnect {0}'.format(pipes.quote(con.name)) wf.add_item( con.name, '↩ to disconnect', @@ -148,10 +155,10 @@ def do_list(args): if con.active: continue - arg = 'connect {0}'.format(pipes.quote(con.name)) + arg = u'connect {0}'.format(pipes.quote(con.name)) # Only add UID if there are no connected VPNs # to ensure connected VPNs are shown first - if connected: + if connected or nouids: uid = None else: uid = con.name @@ -181,7 +188,7 @@ def do_connect(args): name = args.get('') connections = filter_connections(False, name) for con in connections: - log.debug('Connecting `%s` ...', con.name) + log.debug(u'Connecting `%s` ...', con.name) run_script('connect_vpn', con.name) @@ -190,7 +197,7 @@ def do_disconnect(args): name = args.get('') connections = filter_connections(True, name) for con in connections: - log.debug('Disconnecting `%s` ...', con.name) + log.debug(u'Disconnecting `%s` ...', con.name) run_script('disconnect_vpn', con.name) @@ -212,6 +219,6 @@ def main(wf): if __name__ == '__main__': - wf = Workflow(update_settings=UPDATE_SETTINGS) + wf = Workflow3(update_settings=UPDATE_SETTINGS) log = wf.logger sys.exit(wf.run(main)) diff --git a/src/workflow/Notify.tgz b/src/workflow/Notify.tgz new file mode 100644 index 0000000..174e9a7 Binary files /dev/null and b/src/workflow/Notify.tgz differ diff --git a/src/workflow/__init__.py b/src/workflow/__init__.py index 5de1a96..632f1f5 100644 --- a/src/workflow/__init__.py +++ b/src/workflow/__init__.py @@ -8,22 +8,13 @@ # Created on 2014-02-15 # -""" -A Python helper library for `Alfred 2 `_ Workflow -authors. -""" +"""A helper library for `Alfred `_ workflows.""" import os -__title__ = 'Alfred-Workflow' -__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() -__author__ = 'Dean Jackson' -__licence__ = 'MIT' -__copyright__ = 'Copyright 2014 Dean Jackson' - - # Workflow objects from .workflow import Workflow, manager +from .workflow3 import Workflow3 # Exceptions from .workflow import PasswordNotFound, KeychainError @@ -68,8 +59,16 @@ MATCH_SUBSTRING, ) + +__title__ = 'Alfred-Workflow' +__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() +__author__ = 'Dean Jackson' +__licence__ = 'MIT' +__copyright__ = 'Copyright 2014 Dean Jackson' + __all__ = [ 'Workflow', + 'Workflow3', 'manager', 'PasswordNotFound', 'KeychainError', diff --git a/src/workflow/background.py b/src/workflow/background.py index bcfa74d..7bda3f5 100644 --- a/src/workflow/background.py +++ b/src/workflow/background.py @@ -1,16 +1,14 @@ #!/usr/bin/env python # encoding: utf-8 # -# Copyright © 2014 deanishe@deanishe.net +# Copyright (c) 2014 deanishe@deanishe.net # # MIT Licence. See http://opensource.org/licenses/MIT # # Created on 2014-04-06 # -""" -Run background tasks -""" +"""Run background tasks.""" from __future__ import print_function, unicode_literals @@ -34,7 +32,7 @@ def wf(): def _arg_cache(name): - """Return path to pickle cache file for arguments + """Return path to pickle cache file for arguments. :param name: name of task :type name: ``unicode`` @@ -42,12 +40,11 @@ def _arg_cache(name): :rtype: ``unicode`` filepath """ - return wf().cachefile('{0}.argcache'.format(name)) def _pid_file(name): - """Return path to PID file for ``name`` + """Return path to PID file for ``name``. :param name: name of task :type name: ``unicode`` @@ -55,19 +52,18 @@ def _pid_file(name): :rtype: ``unicode`` filepath """ - return wf().cachefile('{0}.pid'.format(name)) def _process_exists(pid): - """Check if a process with PID ``pid`` exists + """Check if a process with PID ``pid`` exists. :param pid: PID to check :type pid: ``int`` :returns: ``True`` if process exists, else ``False`` :rtype: ``Boolean`` - """ + """ try: os.kill(pid, 0) except OSError: # not running @@ -76,8 +72,7 @@ def _process_exists(pid): def is_running(name): - """ - Test whether task is running under ``name`` + """Test whether task is running under ``name``. :param name: name of task :type name: ``unicode`` @@ -113,34 +108,31 @@ def _background(stdin='/dev/null', stdout='/dev/null', :type stderr: filepath """ + def _fork_and_exit_parent(errmsg): + try: + pid = os.fork() + if pid > 0: + os._exit(0) + except OSError as err: + wf().logger.critical('%s: (%d) %s', errmsg, err.errno, + err.strerror) + raise err # Do first fork. - try: - pid = os.fork() - if pid > 0: - sys.exit(0) # Exit first parent. - except OSError as e: - wf().logger.critical("fork #1 failed: ({0:d}) {1}".format( - e.errno, e.strerror)) - sys.exit(1) + _fork_and_exit_parent('fork #1 failed') + # Decouple from parent environment. os.chdir(wf().workflowdir) - os.umask(0) os.setsid() + # Do second fork. - try: - pid = os.fork() - if pid > 0: - sys.exit(0) # Exit second parent. - except OSError as e: - wf().logger.critical("fork #2 failed: ({0:d}) {1}".format( - e.errno, e.strerror)) - sys.exit(1) + _fork_and_exit_parent('fork #2 failed') + # Now I am a daemon! # Redirect standard file descriptors. - si = file(stdin, 'r', 0) - so = file(stdout, 'a+', 0) - se = file(stderr, 'a+', 0) + si = open(stdin, 'r', 0) + so = open(stdout, 'a+', 0) + se = open(stderr, 'a+', 0) if hasattr(sys.stdin, 'fileno'): os.dup2(si.fileno(), sys.stdin.fileno()) if hasattr(sys.stdout, 'fileno'): @@ -150,8 +142,7 @@ def _background(stdin='/dev/null', stdout='/dev/null', def run_in_background(name, args, **kwargs): - """Pickle arguments to cache file, then call this script again via - :func:`subprocess.call`. + r"""Cache arguments then call this script again via :func:`subprocess.call`. :param name: name of task :type name: ``unicode`` @@ -175,7 +166,6 @@ def run_in_background(name, args, **kwargs): return immediately and will not run the specified command. """ - if is_running(name): wf().logger.info('Task `{0}` is already running'.format(name)) return @@ -199,12 +189,12 @@ def run_in_background(name, args, **kwargs): def main(wf): # pragma: no cover - """ + """Run command in a background process. + Load cached arguments, fork into background, then call - :meth:`subprocess.call` with cached arguments + :meth:`subprocess.call` with cached arguments. """ - name = wf.args[0] argcache = _arg_cache(name) if not os.path.exists(argcache): diff --git a/src/workflow/notify.py b/src/workflow/notify.py new file mode 100644 index 0000000..3ed1e5e --- /dev/null +++ b/src/workflow/notify.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2015 deanishe@deanishe.net +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2015-11-26 +# + +# TODO: Exclude this module from test and code coverage in py2.6 + +""" +Post notifications via the OS X Notification Center. This feature +is only available on Mountain Lion (10.8) and later. It will +silently fail on older systems. + +The main API is a single function, :func:`~workflow.notify.notify`. + +It works by copying a simple application to your workflow's data +directory. It replaces the application's icon with your workflow's +icon and then calls the application to post notifications. +""" + +from __future__ import print_function, unicode_literals + +import os +import plistlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import uuid + +import workflow + + +_wf = None +_log = None + + +#: Available system sounds from System Preferences > Sound > Sound Effects +SOUNDS = ( + 'Basso', + 'Blow', + 'Bottle', + 'Frog', + 'Funk', + 'Glass', + 'Hero', + 'Morse', + 'Ping', + 'Pop', + 'Purr', + 'Sosumi', + 'Submarine', + 'Tink', +) + + +def wf(): + """Return `Workflow` object for this module. + + Returns: + workflow.Workflow: `Workflow` object for current workflow. + """ + global _wf + if _wf is None: + _wf = workflow.Workflow() + return _wf + + +def log(): + """Return logger for this module. + + Returns: + logging.Logger: Logger for this module. + """ + global _log + if _log is None: + _log = wf().logger + return _log + + +def notifier_program(): + """Return path to notifier applet executable. + + Returns: + unicode: Path to Notify.app `applet` executable. + """ + return wf().datafile('Notify.app/Contents/MacOS/applet') + + +def notifier_icon_path(): + """Return path to icon file in installed Notify.app. + + Returns: + unicode: Path to `applet.icns` within the app bundle. + """ + return wf().datafile('Notify.app/Contents/Resources/applet.icns') + + +def install_notifier(): + """Extract `Notify.app` from the workflow to data directory. + + Changes the bundle ID of the installed app and gives it the + workflow's icon. + """ + archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') + destdir = wf().datadir + app_path = os.path.join(destdir, 'Notify.app') + n = notifier_program() + log().debug("Installing Notify.app to %r ...", destdir) + # z = zipfile.ZipFile(archive, 'r') + # z.extractall(destdir) + tgz = tarfile.open(archive, 'r:gz') + tgz.extractall(destdir) + assert os.path.exists(n), ( + "Notify.app could not be installed in {0!r}.".format(destdir)) + + # Replace applet icon + icon = notifier_icon_path() + workflow_icon = wf().workflowfile('icon.png') + if os.path.exists(icon): + os.unlink(icon) + + png_to_icns(workflow_icon, icon) + + # Set file icon + # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, + # none of this code will "work" on pre-10.8 systems. Let it run + # until I figure out a better way of excluding this module + # from coverage in py2.6. + if sys.version_info >= (2, 7): # pragma: no cover + from AppKit import NSWorkspace, NSImage + + ws = NSWorkspace.sharedWorkspace() + img = NSImage.alloc().init() + img.initWithContentsOfFile_(icon) + ws.setIcon_forFile_options_(img, app_path, 0) + + # Change bundle ID of installed app + ip_path = os.path.join(app_path, 'Contents/Info.plist') + bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) + data = plistlib.readPlist(ip_path) + log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) + data['CFBundleIdentifier'] = bundle_id + plistlib.writePlist(data, ip_path) + + +def validate_sound(sound): + """Coerce `sound` to valid sound name. + + Returns `None` for invalid sounds. Sound names can be found + in `System Preferences > Sound > Sound Effects`. + + Args: + sound (str): Name of system sound. + + Returns: + str: Proper name of sound or `None`. + """ + if not sound: + return None + + # Case-insensitive comparison of `sound` + if sound.lower() in [s.lower() for s in SOUNDS]: + # Title-case is correct for all system sounds as of OS X 10.11 + return sound.title() + return None + + +def notify(title='', text='', sound=None): + """Post notification via Notify.app helper. + + Args: + title (str, optional): Notification title. + text (str, optional): Notification body text. + sound (str, optional): Name of sound to play. + + Raises: + ValueError: Raised if both `title` and `text` are empty. + + Returns: + bool: `True` if notification was posted, else `False`. + """ + if title == text == '': + raise ValueError('Empty notification') + + sound = validate_sound(sound) or '' + + n = notifier_program() + + if not os.path.exists(n): + install_notifier() + + env = os.environ.copy() + enc = 'utf-8' + env['NOTIFY_TITLE'] = title.encode(enc) + env['NOTIFY_MESSAGE'] = text.encode(enc) + env['NOTIFY_SOUND'] = sound.encode(enc) + cmd = [n] + retcode = subprocess.call(cmd, env=env) + if retcode == 0: + return True + + log().error('Notify.app exited with status {0}.'.format(retcode)) + return False + + +def convert_image(inpath, outpath, size): + """Convert an image file using `sips`. + + Args: + inpath (str): Path of source file. + outpath (str): Path to destination file. + size (int): Width and height of destination image in pixels. + + Raises: + RuntimeError: Raised if `sips` exits with non-zero status. + """ + cmd = [ + b'sips', + b'-z', b'{0}'.format(size), b'{0}'.format(size), + inpath, + b'--out', outpath] + # log().debug(cmd) + with open(os.devnull, 'w') as pipe: + retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) + + if retcode != 0: + raise RuntimeError('sips exited with {0}'.format(retcode)) + + +def png_to_icns(png_path, icns_path): + """Convert PNG file to ICNS using `iconutil`. + + Create an iconset from the source PNG file. Generate PNG files + in each size required by OS X, then call `iconutil` to turn + them into a single ICNS file. + + Args: + png_path (str): Path to source PNG file. + icns_path (str): Path to destination ICNS file. + + Raises: + RuntimeError: Raised if `iconutil` or `sips` fail. + """ + tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) + + try: + iconset = os.path.join(tempdir, 'Icon.iconset') + + assert not os.path.exists(iconset), ( + "Iconset path already exists : {0!r}".format(iconset)) + os.makedirs(iconset) + + # Copy source icon to icon set and generate all the other + # sizes needed + configs = [] + for i in (16, 32, 128, 256, 512): + configs.append(('icon_{0}x{0}.png'.format(i), i)) + configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) + + shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) + shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) + + for name, size in configs: + outpath = os.path.join(iconset, name) + if os.path.exists(outpath): + continue + convert_image(png_path, outpath, size) + + cmd = [ + b'iconutil', + b'-c', b'icns', + b'-o', icns_path, + iconset] + + retcode = subprocess.call(cmd) + if retcode != 0: + raise RuntimeError("iconset exited with {0}".format(retcode)) + + assert os.path.exists(icns_path), ( + "Generated ICNS file not found : {0!r}".format(icns_path)) + finally: + try: + shutil.rmtree(tempdir) + except OSError: # pragma: no cover + pass + + +# def notify_native(title='', text='', sound=''): +# """Post notification via the native API (via pyobjc). + +# At least one of `title` or `text` must be specified. + +# This method will *always* show the Python launcher icon (i.e. the +# rocket with the snakes on it). + +# Args: +# title (str, optional): Notification title. +# text (str, optional): Notification body text. +# sound (str, optional): Name of sound to play. + +# """ + +# if title == text == '': +# raise ValueError('Empty notification') + +# import Foundation + +# sound = sound or Foundation.NSUserNotificationDefaultSoundName + +# n = Foundation.NSUserNotification.alloc().init() +# n.setTitle_(title) +# n.setInformativeText_(text) +# n.setSoundName_(sound) +# nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() +# nc.deliverNotification_(n) + + +if __name__ == '__main__': # pragma: nocover + # Simple command-line script to test module with + # This won't work on 2.6, as `argparse` isn't available + # by default. + import argparse + + from unicodedata import normalize + + def uni(s): + """Coerce `s` to normalised Unicode.""" + ustr = s.decode('utf-8') + return normalize('NFD', ustr) + + p = argparse.ArgumentParser() + p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") + p.add_argument('-l', '--list-sounds', help="Show available sounds.", + action='store_true') + p.add_argument('-t', '--title', + help="Notification title.", type=uni, + default='') + p.add_argument('-s', '--sound', type=uni, + help="Optional notification sound.", default='') + p.add_argument('text', type=uni, + help="Notification body text.", default='', nargs='?') + o = p.parse_args() + + # List available sounds + if o.list_sounds: + for sound in SOUNDS: + print(sound) + sys.exit(0) + + # Convert PNG to ICNS + if o.png: + icns = os.path.join( + os.path.dirname(o.png), + b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], + '.icns')) + + print('Converting {0!r} to {1!r} ...'.format(o.png, icns), + file=sys.stderr) + + assert not os.path.exists(icns), ( + "Destination file already exists : {0}".format(icns)) + + png_to_icns(o.png, icns) + sys.exit(0) + + # Post notification + if o.title == o.text == '': + print('ERROR: Empty notification.', file=sys.stderr) + sys.exit(1) + else: + notify(o.title, o.text, o.sound) diff --git a/src/workflow/update.py b/src/workflow/update.py index b946e79..468d024 100644 --- a/src/workflow/update.py +++ b/src/workflow/update.py @@ -1,16 +1,15 @@ #!/usr/bin/env python # encoding: utf-8 # -# Copyright © 2014 Fabio Niephaus , -# Dean Jackson +# Copyright (c) 2014 Fabio Niephaus , +# Dean Jackson # # MIT Licence. See http://opensource.org/licenses/MIT # # Created on 2014-08-16 # -""" -Self-updating from GitHub +"""Self-updating from GitHub. .. versionadded:: 1.9 @@ -42,6 +41,7 @@ def wf(): + """Lazy `Workflow` object.""" global _wf if _wf is None: _wf = workflow.Workflow() @@ -49,16 +49,37 @@ def wf(): class Version(object): - """Mostly semantic versioning + """Mostly semantic versioning. The main difference to proper :ref:`semantic versioning ` is that this implementation doesn't require a minor or patch version. + + Version strings may also be prefixed with "v", e.g.: + + >>> v = Version('v1.1.1') + >>> v.tuple + (1, 1, 1, '') + + >>> v = Version('2.0') + >>> v.tuple + (2, 0, 0, '') + + >>> Version('3.1-beta').tuple + (3, 1, 0, 'beta') + + >>> Version('1.0.1') > Version('0.0.1') + True """ #: Match version and pre-release/build information in version strings match_version = re.compile(r'([0-9\.]+)(.+)?').match def __init__(self, vstr): + """Create new `Version` object. + + Args: + vstr (basestring): Semantic version string. + """ self.vstr = vstr self.major = 0 self.minor = 0 @@ -101,7 +122,7 @@ def _parse(self, vstr): # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) def _parse_dotted_string(self, s): - """Parse string ``s`` into list of ints and strings""" + """Parse string ``s`` into list of ints and strings.""" parsed = [] parts = s.split('.') for p in parts: @@ -112,12 +133,11 @@ def _parse_dotted_string(self, s): @property def tuple(self): - """Return version number as a tuple of major, minor, patch, pre-release - """ - + """Version number as a tuple of major, minor, patch, pre-release.""" return (self.major, self.minor, self.patch, self.suffix) def __lt__(self, other): + """Implement comparison.""" if not isinstance(other, Version): raise ValueError('Not a Version instance: {0!r}'.format(other)) t = self.tuple[:3] @@ -135,27 +155,33 @@ def __lt__(self, other): return False def __eq__(self, other): + """Implement comparison.""" if not isinstance(other, Version): raise ValueError('Not a Version instance: {0!r}'.format(other)) return self.tuple == other.tuple def __ne__(self, other): + """Implement comparison.""" return not self.__eq__(other) def __gt__(self, other): + """Implement comparison.""" if not isinstance(other, Version): raise ValueError('Not a Version instance: {0!r}'.format(other)) return other.__lt__(self) def __le__(self, other): + """Implement comparison.""" if not isinstance(other, Version): raise ValueError('Not a Version instance: {0!r}'.format(other)) return not other.__lt__(self) def __ge__(self, other): + """Implement comparison.""" return not self.__lt__(other) def __str__(self): + """Return semantic version string.""" vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) if self.suffix: vstr += '-{0}'.format(self.suffix) @@ -164,28 +190,27 @@ def __str__(self): return vstr def __repr__(self): + """Return 'code' representation of `Version`.""" return "Version('{0}')".format(str(self)) def download_workflow(url): - """Download workflow at ``url`` to a local temporary file + """Download workflow at ``url`` to a local temporary file. :param url: URL to .alfredworkflow file in GitHub repo :returns: path to downloaded file """ - filename = url.split("/")[-1] if (not url.endswith('.alfredworkflow') or not filename.endswith('.alfredworkflow')): - raise ValueError('Attachment `{}` not a workflow'.format(filename)) + raise ValueError('Attachment `{0}` not a workflow'.format(filename)) local_path = os.path.join(tempfile.gettempdir(), filename) wf().logger.debug( - 'Downloading updated workflow from `{0}` to `{1}` ...'.format( - url, local_path)) + 'Downloading updated workflow from `%s` to `%s` ...', url, local_path) response = web.get(url) @@ -196,25 +221,80 @@ def download_workflow(url): def build_api_url(slug): - """Generate releases URL from GitHub slug + """Generate releases URL from GitHub slug. :param slug: Repo name in form ``username/repo`` :returns: URL to the API endpoint for the repo's releases - """ - + """ if len(slug.split('/')) != 2: raise ValueError('Invalid GitHub slug : {0}'.format(slug)) return RELEASES_BASE.format(slug) -def get_valid_releases(github_slug): - """Return list of all valid releases +def _validate_release(release): + """Return release for running version of Alfred.""" + alf3 = wf().alfred_version.major == 3 + + downloads = {'.alfredworkflow': [], '.alfred3workflow': []} + dl_count = 0 + version = release['tag_name'] + + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + if not url: # pragma: nocover + continue + + ext = os.path.splitext(url)[1].lower() + if ext not in downloads: + continue + + # Ignore Alfred 3-only files if Alfred 2 is running + if ext == '.alfred3workflow' and not alf3: + continue + + downloads[ext].append(url) + dl_count += 1 + + # download_urls.append(url) + + if dl_count == 0: + wf().logger.warning( + 'Invalid release %s : No workflow file', version) + return None + + for k in downloads: + if len(downloads[k]) > 1: + wf().logger.warning( + 'Invalid release %s : multiple %s files', version, k) + return None + + # Prefer .alfred3workflow file if there is one and Alfred 3 is + # running. + if alf3 and len(downloads['.alfred3workflow']): + download_url = downloads['.alfred3workflow'][0] + + else: + download_url = downloads['.alfredworkflow'][0] + + wf().logger.debug('Release `%s` : %s', version, download_url) + + return { + 'version': version, + 'download_url': download_url, + 'prerelease': release['prerelease'] + } + + +def get_valid_releases(github_slug, prereleases=False): + """Return list of all valid releases. :param github_slug: ``username/repo`` for workflow's GitHub repo + :param prereleases: Whether to include pre-releases. :returns: list of dicts. Each :class:`dict` has the form - ``{'version': '1.1', 'download_url': 'http://github.com/...'}`` + ``{'version': '1.1', 'download_url': 'http://github.com/...', + 'prerelease': False }`` A valid release is one that contains one ``.alfredworkflow`` file. @@ -223,55 +303,43 @@ def get_valid_releases(github_slug): ``v`` will be stripped. """ - api_url = build_api_url(github_slug) releases = [] - wf().logger.debug('Retrieving releases list from `{0}` ...'.format( - api_url)) + wf().logger.debug('Retrieving releases list from `%s` ...', api_url) def retrieve_releases(): wf().logger.info( - 'Retrieving releases for `{0}` ...'.format(github_slug)) + 'Retrieving releases for `%s` ...', github_slug) return web.get(api_url).json() slug = github_slug.replace('/', '-') for release in wf().cached_data('gh-releases-{0}'.format(slug), - retrieve_releases): - version = release['tag_name'] - download_urls = [] - for asset in release.get('assets', []): - url = asset.get('browser_download_url') - if not url or not url.endswith('.alfredworkflow'): - continue - download_urls.append(url) - - # Validate release - if release['prerelease']: - wf().logger.warning( - 'Invalid release {0} : pre-release detected'.format(version)) - continue - if not download_urls: - wf().logger.warning( - 'Invalid release {0} : No workflow file'.format(version)) + retrieve_releases): + + wf().logger.debug('Release : %r', release) + + release = _validate_release(release) + if release is None: + wf().logger.debug('Invalid release') continue - if len(download_urls) > 1: - wf().logger.warning( - 'Invalid release {0} : multiple workflow files'.format(version)) + + elif release['prerelease'] and not prereleases: + wf().logger.debug('Ignoring prerelease : %s', release['version']) continue - wf().logger.debug('Release `{0}` : {1}'.format(version, url)) - releases.append({'version': version, 'download_url': download_urls[0]}) + releases.append(release) return releases -def check_update(github_slug, current_version): - """Check whether a newer release is available on GitHub +def check_update(github_slug, current_version, prereleases=False): + """Check whether a newer release is available on GitHub. :param github_slug: ``username/repo`` for workflow's GitHub repo :param current_version: the currently installed version of the workflow. :ref:`Semantic versioning ` is required. + :param prereleases: Whether to include pre-releases. :type current_version: ``unicode`` :returns: ``True`` if an update is available, else ``False`` @@ -279,14 +347,12 @@ def check_update(github_slug, current_version): be cached. """ + releases = get_valid_releases(github_slug, prereleases) - releases = get_valid_releases(github_slug) - - wf().logger.info('{0} releases for {1}'.format(len(releases), - github_slug)) + wf().logger.info('%d releases for %s', len(releases), github_slug) if not len(releases): - raise ValueError('No valid releases for {0}'.format(github_slug)) + raise ValueError('No valid releases for %s', github_slug) # GitHub returns releases newest-first latest_release = releases[0] @@ -294,7 +360,7 @@ def check_update(github_slug, current_version): # (latest_version, download_url) = get_latest_release(releases) vr = Version(latest_release['version']) vl = Version(current_version) - wf().logger.debug('Latest : {0!r} Installed : {1!r}'.format(vr, vl)) + wf().logger.debug('Latest : %r Installed : %r', vr, vl) if vr > vl: wf().cache_data('__workflow_update_status', { @@ -311,21 +377,12 @@ def check_update(github_slug, current_version): return False -def install_update(github_slug, current_version): - """If a newer release is available, download and install it - - :param github_slug: ``username/repo`` for workflow's GitHub repo - :param current_version: the currently installed version of the - workflow. :ref:`Semantic versioning ` is required. - :type current_version: ``unicode`` - - If an update is available, it will be downloaded and installed. +def install_update(): + """If a newer release is available, download and install it. :returns: ``True`` if an update is installed, else ``False`` """ - # TODO: `github_slug` and `current_version` are both unusued. - update_data = wf().cached_data('__workflow_update_status', max_age=0) if not update_data or not update_data.get('available'): @@ -346,18 +403,26 @@ def install_update(github_slug, current_version): import sys def show_help(): - print('Usage : update.py (check|install) github_slug version') + """Print help message.""" + print('Usage : update.py (check|install) github_slug version ' + '[--prereleases]') sys.exit(1) - if len(sys.argv) != 4: + argv = sys.argv[:] + prereleases = '--prereleases' in argv + + if prereleases: + argv.remove('--prereleases') + + if len(argv) != 4: show_help() - action, github_slug, version = sys.argv[1:] + action, github_slug, version = argv[1:] if action not in ('check', 'install'): show_help() if action == 'check': - check_update(github_slug, version) + check_update(github_slug, version, prereleases) elif action == 'install': - install_update(github_slug, version) + install_update() diff --git a/src/workflow/version b/src/workflow/version index 9f76d37..581595d 100644 --- a/src/workflow/version +++ b/src/workflow/version @@ -1 +1 @@ -1.13 \ No newline at end of file +1.25 \ No newline at end of file diff --git a/src/workflow/web.py b/src/workflow/web.py index 153833b..748b199 100644 --- a/src/workflow/web.py +++ b/src/workflow/web.py @@ -7,11 +7,7 @@ # Created on 2014-02-15 # -""" -A lightweight HTTP library with a requests-like interface. -""" - -from __future__ import print_function +"""Lightweight HTTP library with a requests-like interface.""" import codecs import json @@ -28,7 +24,7 @@ import zlib -USER_AGENT = u'Alfred-Workflow/1.11 (http://www.deanishe.net)' +USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' # Valid characters for multipart form data boundaries BOUNDARY_CHARS = string.digits + string.ascii_letters @@ -79,7 +75,7 @@ def str_dict(dic): - """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str` + """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. :param dic: :class:`dict` of Unicode strings :returns: :class:`dict` @@ -99,7 +95,7 @@ def str_dict(dic): class NoRedirectHandler(urllib2.HTTPRedirectHandler): - """Prevent redirections""" + """Prevent redirections.""" def redirect_request(self, *args): return None @@ -107,9 +103,10 @@ def redirect_request(self, *args): # Adapted from https://gist.github.com/babakness/3901174 class CaseInsensitiveDictionary(dict): - """ - Dictionary that enables case insensitive searching while preserving - case sensitivity when keys are listed, ie, via keys() or items() methods. + """Dictionary with caseless key search. + + Enables case insensitive searching while preserving case sensitivity + when keys are listed, ie, via keys() or items() methods. Works by storing a lowercase version of the key as the new key and stores the original key-value pair as the key's value @@ -118,7 +115,7 @@ class CaseInsensitiveDictionary(dict): """ def __init__(self, initval=None): - + """Create new case-insensitive dictionary.""" if isinstance(initval, dict): for key, value in initval.iteritems(): self.__setitem__(key, value) @@ -174,7 +171,7 @@ class Response(object): """ Returned by :func:`request` / :func:`get` / :func:`post` functions. - A simplified version of the ``Response`` object in the ``requests`` library. + Simplified version of the ``Response`` object in the ``requests`` library. >>> r = request('http://www.google.com') >>> r.status_code @@ -189,14 +186,16 @@ class Response(object): """ - def __init__(self, request): + def __init__(self, request, stream=False): """Call `request` with :mod:`urllib2` and process results. :param request: :class:`urllib2.Request` instance + :param stream: Whether to stream response or retrieve it all at once + :type stream: ``bool`` """ - self.request = request + self._stream = stream self.url = None self.raw = None self._encoding = None @@ -205,6 +204,7 @@ def __init__(self, request): self.reason = None self.headers = CaseInsensitiveDictionary() self._content = None + self._content_loaded = False self._gzipped = False # Execute query @@ -242,6 +242,23 @@ def __init__(self, request): 'gzip' in headers.get('transfer-encoding', '')): self._gzipped = True + @property + def stream(self): + """Whether response is streamed. + + Returns: + bool: `True` if response is streamed. + """ + return self._stream + + @stream.setter + def stream(self, value): + if self._content_loaded: + raise RuntimeError("`content` has already been read from " + "this Response.") + + self._stream = value + def json(self): """Decode response contents as JSON. @@ -249,17 +266,15 @@ def json(self): :rtype: :class:`list` / :class:`dict` """ - return json.loads(self.content, self.encoding or 'utf-8') @property def encoding(self): - """Text encoding of document or ``None`` + """Text encoding of document or ``None``. :returns: :class:`str` or ``None`` """ - if not self._encoding: self._encoding = self._get_encoding() @@ -267,13 +282,12 @@ def encoding(self): @property def content(self): - """Raw content of response (i.e. bytes) + """Raw content of response (i.e. bytes). :returns: Body of HTTP response :rtype: :class:`str` """ - if not self._content: # Decompress gzipped content @@ -284,6 +298,8 @@ def content(self): else: self._content = self.raw.read() + self._content_loaded = True + return self._content @property @@ -297,7 +313,6 @@ def text(self): :rtype: :class:`unicode` or :class:`str` """ - if self.encoding: return unicodedata.normalize('NFC', unicode(self.content, self.encoding)) @@ -315,6 +330,14 @@ def iter_content(self, chunk_size=4096, decode_unicode=False): :returns: iterator """ + if not self.stream: + raise RuntimeError("You cannot call `iter_content` on a " + "Response unless you passed `stream=True`" + " to `get()`/`post()`/`request()`.") + + if self._content_loaded: + raise RuntimeError( + "`content` has already been read from this Response.") def decode_stream(iterator, r): @@ -326,8 +349,8 @@ def decode_stream(iterator, r): yield data data = decoder.decode(b'', final=True) - if data: - yield data # pragma: nocover + if data: # pragma: no cover + yield data def generate(): @@ -352,19 +375,20 @@ def generate(): return chunks def save_to_path(self, filepath): - """Save retrieved data to file at ``filepath`` + """Save retrieved data to file at ``filepath``. .. versionadded: 1.9.6 :param filepath: Path to save retrieved data. """ - filepath = os.path.abspath(filepath) dirname = os.path.dirname(filepath) if not os.path.exists(dirname): os.makedirs(dirname) + self.stream = True + with open(filepath, 'wb') as fileobj: for data in self.iter_content(): fileobj.write(data) @@ -374,7 +398,6 @@ def raise_for_status(self): error will be instance of :class:`urllib2.HTTPError` """ - if self.error is not None: raise self.error return @@ -386,7 +409,6 @@ def _get_encoding(self): :rtype: ``unicode`` or ``None`` """ - headers = self.raw.info() encoding = None @@ -399,20 +421,21 @@ def _get_encoding(self): encoding = param[8:] break - # Encoding declared in document should override HTTP headers - if self.mimetype == 'text/html': # sniff HTML headers - m = re.search("""""", - self.content) - if m: - encoding = m.group(1) - - elif ((self.mimetype.startswith('application/') or - self.mimetype.startswith('text/')) and - 'xml' in self.mimetype): - m = re.search("""]*\?>""", - self.content) - if m: - encoding = m.group(1) + if not self.stream: # Try sniffing response content + # Encoding declared in document should override HTTP headers + if self.mimetype == 'text/html': # sniff HTML headers + m = re.search("""""", + self.content) + if m: + encoding = m.group(1) + + elif ((self.mimetype.startswith('application/') or + self.mimetype.startswith('text/')) and + 'xml' in self.mimetype): + m = re.search("""]*\?>""", + self.content) + if m: + encoding = m.group(1) # Format defaults if self.mimetype == 'application/json' and not encoding: @@ -430,7 +453,8 @@ def _get_encoding(self): def request(method, url, params=None, data=None, headers=None, cookies=None, - files=None, auth=None, timeout=60, allow_redirects=False): + files=None, auth=None, timeout=60, allow_redirects=False, + stream=False): """Initiate an HTTP(S) request. Returns :class:`Response` object. :param method: 'GET' or 'POST' @@ -454,6 +478,8 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, :type timeout: ``int`` :param allow_redirects: follow redirections :type allow_redirects: ``Boolean`` + :param stream: Stream content instead of fetching it all at once. + :type stream: ``bool`` :returns: :class:`Response` object @@ -470,9 +496,7 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, will be used. """ - # TODO: cookies - # TODO: any way to force GET or POST? socket.setdefaulttimeout(timeout) # Default handlers @@ -508,6 +532,10 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, headers['accept-encoding'] = ', '.join(encodings) + # Force POST by providing an empty data string + if method == 'POST' and not data: + data = '' + if files: if not data: data = {} @@ -536,30 +564,30 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) req = urllib2.Request(url, data, headers) - return Response(req) + return Response(req, stream) def get(url, params=None, headers=None, cookies=None, auth=None, - timeout=60, allow_redirects=True): + timeout=60, allow_redirects=True, stream=False): """Initiate a GET request. Arguments as for :func:`request`. :returns: :class:`Response` instance """ - return request('GET', url, params, headers=headers, cookies=cookies, - auth=auth, timeout=timeout, allow_redirects=allow_redirects) + auth=auth, timeout=timeout, allow_redirects=allow_redirects, + stream=stream) def post(url, params=None, data=None, headers=None, cookies=None, files=None, - auth=None, timeout=60, allow_redirects=False): + auth=None, timeout=60, allow_redirects=False, stream=False): """Initiate a POST request. Arguments as for :func:`request`. :returns: :class:`Response` instance """ return request('POST', url, params, data, headers, cookies, files, auth, - timeout, allow_redirects) + timeout, allow_redirects, stream) def encode_multipart_formdata(fields, files): @@ -584,7 +612,6 @@ def encode_multipart_formdata(fields, files): - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. """ - def get_content_type(filename): """Return or guess mimetype of ``filename``. diff --git a/src/workflow/workflow.py b/src/workflow/workflow.py index fe65a81..d968ff7 100644 --- a/src/workflow/workflow.py +++ b/src/workflow/workflow.py @@ -7,8 +7,12 @@ # Created on 2014-02-15 # -""" -The :class:`Workflow` object is the main interface to this library. +"""The :class:`Workflow` object is the main interface to this library. + +:class:`Workflow` is targeted at Alfred 2. Use +:class:`~workflow.workflow3.Workflow3` if you want to use Alfred 3's new +features, such as :ref:`workflow variables ` or +more powerful modifiers. See :ref:`setup` in the :ref:`user-manual` for an example of how to set up your Python script to best utilise the :class:`Workflow` object. @@ -17,9 +21,11 @@ from __future__ import print_function, unicode_literals +import atexit import binascii from contextlib import contextmanager import cPickle +from copy import deepcopy import errno import json import logging @@ -445,17 +451,19 @@ class AcquisitionError(Exception): class KeychainError(Exception): - """Raised by methods :meth:`Workflow.save_password`, + """Raised for unknown Keychain errors. + + Raised by methods :meth:`Workflow.save_password`, :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` when ``security`` CLI app returns an unknown error code. - """ class PasswordNotFound(KeychainError): - """Raised by method :meth:`Workflow.get_password` when ``account`` - is unknown to the Keychain. + """Password not in Keychain. + Raised by method :meth:`Workflow.get_password` when ``account`` + is unknown to the Keychain. """ @@ -465,7 +473,6 @@ class PasswordExists(KeychainError): You should never receive this error: it is used internally by the :meth:`Workflow.save_password` method to know if it needs to delete the old password first (a Keychain implementation detail). - """ @@ -474,14 +481,14 @@ class PasswordExists(KeychainError): #################################################################### def isascii(text): - """Test if ``text`` contains only ASCII characters + """Test if ``text`` contains only ASCII characters. :param text: text to test for ASCII-ness :type text: ``unicode`` :returns: ``True`` if ``text`` contains only ASCII characters :rtype: ``Boolean`` - """ + """ try: text.encode('ascii') except UnicodeEncodeError: @@ -511,6 +518,7 @@ class SerializerManager(object): """ def __init__(self): + """Create new SerializerManager object.""" self._serializers = {} def register(self, name, serializer): @@ -528,7 +536,6 @@ def register(self, name, serializer): methods """ - # Basic validation getattr(serializer, 'load') getattr(serializer, 'dump') @@ -536,19 +543,18 @@ def register(self, name, serializer): self._serializers[name] = serializer def serializer(self, name): - """Return serializer object for ``name`` or ``None`` if no such - serializer is registered + """Return serializer object for ``name``. :param name: Name of serializer to return :type name: ``unicode`` or ``str`` - :returns: serializer object or ``None`` + :returns: serializer object or ``None`` if no such serializer + is registered. """ - return self._serializers.get(name) def unregister(self, name): - """Remove registered serializer with ``name`` + """Remove registered serializer with ``name``. Raises a :class:`ValueError` if there is no such registered serializer. @@ -558,9 +564,9 @@ def unregister(self, name): :returns: serializer object """ - if name not in self._serializers: - raise ValueError('No such serializer registered : {0}'.format(name)) + raise ValueError('No such serializer registered : {0}'.format( + name)) serializer = self._serializers[name] del self._serializers[name] @@ -569,7 +575,7 @@ def unregister(self, name): @property def serializers(self): - """Return names of registered serializers""" + """Return names of registered serializers.""" return sorted(self._serializers.keys()) @@ -596,7 +602,6 @@ def load(cls, file_obj): :rtype: object """ - return json.load(file_obj) @classmethod @@ -611,7 +616,6 @@ def dump(cls, obj, file_obj): :type file_obj: ``file`` object """ - return json.dump(obj, file_obj, indent=2, encoding='utf-8') @@ -637,7 +641,6 @@ def load(cls, file_obj): :rtype: object """ - return cPickle.load(file_obj) @classmethod @@ -652,7 +655,6 @@ def dump(cls, obj, file_obj): :type file_obj: ``file`` object """ - return cPickle.dump(obj, file_obj, protocol=-1) @@ -677,7 +679,6 @@ def load(cls, file_obj): :rtype: object """ - return pickle.load(file_obj) @classmethod @@ -692,7 +693,6 @@ def dump(cls, obj, file_obj): :type file_obj: ``file`` object """ - return pickle.dump(obj, file_obj, protocol=-1) @@ -704,8 +704,9 @@ def dump(cls, obj, file_obj): class Item(object): - """Represents a feedback item for Alfred. Generates Alfred-compliant - XML for a single item. + """Represents a feedback item for Alfred. + + Generates Alfred-compliant XML for a single item. You probably shouldn't use this class directly, but via :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` @@ -716,11 +717,8 @@ class Item(object): def __init__(self, title, subtitle='', modifier_subtitles=None, arg=None, autocomplete=None, valid=False, uid=None, icon=None, icontype=None, type=None, largetext=None, - copytext=None): - """Arguments the same as for :meth:`Workflow.add_item`. - - """ - + copytext=None, quicklookurl=None): + """Same arguments as :meth:`Workflow.add_item`.""" self.title = title self.subtitle = subtitle self.modifier_subtitles = modifier_subtitles or {} @@ -733,6 +731,7 @@ def __init__(self, title, subtitle='', modifier_subtitles=None, self.type = type self.largetext = largetext self.copytext = copytext + self.quicklookurl = quicklookurl @property def elem(self): @@ -742,7 +741,6 @@ def elem(self): instance for this :class:`Item` instance. """ - # Attributes on element attr = {} if self.valid: @@ -792,17 +790,22 @@ def elem(self): ET.SubElement(root, 'text', {'type': 'copy'}).text = self.copytext + if self.quicklookurl: + ET.SubElement(root, 'quicklookurl').text = self.quicklookurl + return root class LockFile(object): - """Context manager to create lock files""" + """Context manager to create lock files.""" def __init__(self, protected_path, timeout=0, delay=0.05): + """Create new :class:`LockFile` object.""" self.lockfile = protected_path + '.lock' self.timeout = timeout self.delay = delay self._locked = False + atexit.register(self.release) @property def locked(self): @@ -816,11 +819,14 @@ def acquire(self, blocking=True): ``False``. Otherwise, check every `self.delay` seconds until it acquires - lock or exceeds `self.timeout` and raises an exception. + lock or exceeds `self.timeout` and raises an `~AcquisitionError`. """ start = time.time() while True: + + self._validate_lockfile() + try: fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) with os.fdopen(fd, 'w') as fd: @@ -829,6 +835,7 @@ def acquire(self, blocking=True): except OSError as err: if err.errno != errno.EEXIST: # pragma: no cover raise + if self.timeout and (time.time() - start) >= self.timeout: raise AcquisitionError('Lock acquisition timed out.') if not blocking: @@ -838,10 +845,36 @@ def acquire(self, blocking=True): self._locked = True return True + def _validate_lockfile(self): + """Check existence and validity of lockfile. + + If the lockfile exists, but contains an invalid PID + or the PID of a non-existant process, it is removed. + + """ + try: + with open(self.lockfile) as fp: + s = fp.read() + except Exception: + return + + try: + pid = int(s) + except ValueError: + return self.release() + + from background import _process_exists + if not _process_exists(pid): + self.release() + def release(self): """Release the lock by deleting `self.lockfile`.""" self._locked = False - os.unlink(self.lockfile) + try: + os.unlink(self.lockfile) + except (OSError, IOError) as err: # pragma: no cover + if err.errno != 2: + raise err def __enter__(self): """Acquire lock.""" @@ -873,7 +906,6 @@ def atomic_writer(file_path, mode): succeeds. The data is first written to a temporary file. """ - temp_suffix = '.aw.temp' temp_file_path = file_path + temp_suffix with open(temp_file_path, mode) as file_obj: @@ -911,6 +943,7 @@ class uninterruptible(object): """ def __init__(self, func, class_name=''): + """Decorate `func`.""" self.func = func self._caught_signal = None @@ -919,6 +952,7 @@ def signal_handler(self, signum, frame): self._caught_signal = (signum, frame) def __call__(self, *args, **kwargs): + """Trap ``SIGTERM`` and call wrapped function.""" self._caught_signal = None # Register handler for SIGTERM, then call `self.func` self.old_signal_handler = signal.getsignal(signal.SIGTERM) @@ -938,6 +972,7 @@ def __call__(self, *args, **kwargs): sys.exit(0) def __get__(self, obj=None, klass=None): + """Decorator API.""" return self.__class__(self.func.__get__(obj, klass), klass.__name__) @@ -961,10 +996,11 @@ class Settings(dict): """ def __init__(self, filepath, defaults=None): - + """Create new :class:`Settings` object.""" super(Settings, self).__init__() self._filepath = filepath self._nosave = False + self._original = {} if os.path.exists(self._filepath): self._load() elif defaults: @@ -973,16 +1009,19 @@ def __init__(self, filepath, defaults=None): self.save() # save default settings def _load(self): - """Load cached settings from JSON file `self._filepath`""" - + """Load cached settings from JSON file `self._filepath`.""" self._nosave = True + d = {} with open(self._filepath, 'rb') as file_obj: for key, value in json.load(file_obj, encoding='utf-8').items(): - self[key] = value + d[key] = value + self.update(d) + self._original = deepcopy(d) self._nosave = False + @uninterruptible def save(self): - """Save settings to JSON file specified in ``self._filepath`` + """Save settings to JSON file specified in ``self._filepath``. If you're using this class via :attr:`Workflow.settings`, which you probably are, ``self._filepath`` will be ``settings.json`` @@ -991,8 +1030,9 @@ def save(self): if self._nosave: return data = {} - for key, value in self.items(): - data[key] = value + data.update(self) + # for key, value in self.items(): + # data[key] = value with LockFile(self._filepath): with atomic_writer(self._filepath, 'wb') as file_obj: json.dump(data, file_obj, sort_keys=True, indent=2, @@ -1000,10 +1040,13 @@ def save(self): # dict methods def __setitem__(self, key, value): - super(Settings, self).__setitem__(key, value) - self.save() + """Implement :class:`dict` interface.""" + if self._original.get(key) != value: + super(Settings, self).__setitem__(key, value) + self.save() def __delitem__(self, key): + """Implement :class:`dict` interface.""" super(Settings, self).__delitem__(key) self.save() @@ -1022,34 +1065,37 @@ def setdefault(self, key, value=None): class Workflow(object): """Create new :class:`Workflow` instance. - :param default_settings: default workflow settings. If no settings file - exists, :class:`Workflow.settings` will be pre-populated with - ``default_settings``. - :type default_settings: :class:`dict` - :param update_settings: settings for updating your workflow from GitHub. - This must be a :class:`dict` that contains ``github_slug`` and - ``version`` keys. ``github_slug`` is of the form ``username/repo`` - and ``version`` **must** correspond to the tag of a release. - See :ref:`updates` for more information. - :type update_settings: :class:`dict` - :param input_encoding: encoding of command line arguments - :type input_encoding: :class:`unicode` - :param normalization: normalisation to apply to CLI args. - See :meth:`Workflow.decode` for more details. - :type normalization: :class:`unicode` - :param capture_args: capture and act on ``workflow:*`` arguments. See - :ref:`Magic arguments ` for details. - :type capture_args: :class:`Boolean` - :param libraries: sequence of paths to directories containing - libraries. These paths will be prepended to ``sys.path``. - :type libraries: :class:`tuple` or :class:`list` - :param help_url: URL to webpage where a user can ask for help with - the workflow, report bugs, etc. This could be the GitHub repo - or a page on AlfredForum.com. If your workflow throws an error, - this URL will be displayed in the log and Alfred's debugger. It can - also be opened directly in a web browser with the ``workflow:help`` - :ref:`magic argument `. - :type help_url: :class:`unicode` or :class:`str` + :param default_settings: default workflow settings. If no settings file + exists, :class:`Workflow.settings` will be pre-populated with + ``default_settings``. + :type default_settings: :class:`dict` + :param update_settings: settings for updating your workflow from GitHub. + This must be a :class:`dict` that contains ``github_slug`` and + ``version`` keys. ``github_slug`` is of the form ``username/repo`` + and ``version`` **must** correspond to the tag of a release. The + boolean ``prereleases`` key is optional and if ``True`` will + override the :ref:`magic argument ` preference. + This is only recommended when the installed workflow is a pre-release. + See :ref:`updates` for more information. + :type update_settings: :class:`dict` + :param input_encoding: encoding of command line arguments + :type input_encoding: :class:`unicode` + :param normalization: normalisation to apply to CLI args. + See :meth:`Workflow.decode` for more details. + :type normalization: :class:`unicode` + :param capture_args: capture and act on ``workflow:*`` arguments. See + :ref:`Magic arguments ` for details. + :type capture_args: :class:`Boolean` + :param libraries: sequence of paths to directories containing + libraries. These paths will be prepended to ``sys.path``. + :type libraries: :class:`tuple` or :class:`list` + :param help_url: URL to webpage where a user can ask for help with + the workflow, report bugs, etc. This could be the GitHub repo + or a page on AlfredForum.com. If your workflow throws an error, + this URL will be displayed in the log and Alfred's debugger. It can + also be opened directly in a web browser with the ``workflow:help`` + :ref:`magic argument `. + :type help_url: :class:`unicode` or :class:`str` """ @@ -1061,7 +1107,7 @@ def __init__(self, default_settings=None, update_settings=None, input_encoding='utf-8', normalization='NFC', capture_args=True, libraries=None, help_url=None): - + """Create new :class:`Workflow` object.""" self._default_settings = default_settings or {} self._update_settings = update_settings or {} self._input_encoding = input_encoding @@ -1072,11 +1118,10 @@ def __init__(self, default_settings=None, update_settings=None, self._settings_path = None self._settings = None self._bundleid = None + self._debugging = None self._name = None self._cache_serializer = 'cpickle' self._data_serializer = 'cpickle' - # info.plist should be in the directory above this one - self._info_plist = self.workflowfile('info.plist') self._info = None self._info_loaded = False self._logger = None @@ -1114,9 +1159,15 @@ def __init__(self, default_settings=None, update_settings=None, # info.plist contents and alfred_* environment variables ---------- + @property + def alfred_version(self): + """Alfred version as :class:`~workflow.update.Version` object.""" + from update import Version + return Version(self.alfred_env.get('version')) + @property def alfred_env(self): - """Alfred's environmental variables minus the ``alfred_`` prefix. + """Dict of Alfred's environmental variables minus ``alfred_`` prefix. .. versionadded:: 1.7 @@ -1125,6 +1176,8 @@ def alfred_env(self): ============================ ========================================= Variable Description ============================ ========================================= + alfred_debug Set to ``1`` if Alfred's debugger is + open, otherwise unset. alfred_preferences Path to Alfred.alfredpreferences (where your workflows and settings are stored). @@ -1148,6 +1201,8 @@ def alfred_env(self): alfred_workflow_data Path to workflow's data directory alfred_workflow_name Name of current workflow alfred_workflow_uid UID of workflow + alfred_workflow_version The version number specified in the + workflow configuration sheet/info.plist ============================ ========================================= **Note:** all values are Unicode strings except ``version_build`` and @@ -1157,13 +1212,13 @@ def alfred_env(self): ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. """ - if self._alfred_env is not None: return self._alfred_env data = {} for key in ( + 'alfred_debug', 'alfred_preferences', 'alfred_preferences_localhash', 'alfred_theme', @@ -1175,12 +1230,14 @@ def alfred_env(self): 'alfred_workflow_cache', 'alfred_workflow_data', 'alfred_workflow_name', - 'alfred_workflow_uid'): + 'alfred_workflow_uid', + 'alfred_workflow_version'): value = os.getenv(key) if isinstance(value, str): - if key in ('alfred_version_build', 'alfred_theme_subtext'): + if key in ('alfred_debug', 'alfred_version_build', + 'alfred_theme_subtext'): value = int(value) else: value = self.decode(value) @@ -1194,7 +1251,6 @@ def alfred_env(self): @property def info(self): """:class:`dict` of ``info.plist`` contents.""" - if not self._info_loaded: self._load_info_plist() return self._info @@ -1207,7 +1263,6 @@ def bundleid(self): :rtype: ``unicode`` """ - if not self._bundleid: if self.alfred_env.get('workflow_bundleid'): self._bundleid = self.alfred_env.get('workflow_bundleid') @@ -1216,6 +1271,21 @@ def bundleid(self): return self._bundleid + @property + def debugging(self): + """Whether Alfred's debugger is open. + + :returns: ``True`` if Alfred's debugger is open. + :rtype: ``bool`` + + """ + if self._debugging is None: + if self.alfred_env.get('debug') == 1: + self._debugging = True + else: + self._debugging = False + return self._debugging + @property def name(self): """Workflow name from Alfred's environmental vars or ``info.plist``. @@ -1224,7 +1294,6 @@ def name(self): :rtype: ``unicode`` """ - if not self._name: if self.alfred_env.get('workflow_name'): self._name = self.decode(self.alfred_env.get('workflow_name')) @@ -1235,29 +1304,33 @@ def name(self): @property def version(self): - """Return the version of the workflow + """Return the version of the workflow. .. versionadded:: 1.9.10 - Get the version from the ``update_settings`` dict passed on - instantiation or the ``version`` file located in the workflow's - root directory. Return ``None`` if neither exist or - :class:`ValueError` if the version number is invalid (i.e. not - semantic). + Get the workflow version from environment variable, + the ``update_settings`` dict passed on + instantiation, the ``version`` file located in the workflow's + root directory or ``info.plist``. Return ``None`` if none + exists or :class:`ValueError` if the version number is invalid + (i.e. not semantic). :returns: Version of the workflow (not Alfred-Workflow) :rtype: :class:`~workflow.update.Version` object """ - if self._version is UNSET: version = None - # First check `update_settings` - if self._update_settings: + # environment variable has priority + if self.alfred_env.get('workflow_version'): + version = self.alfred_env['workflow_version'] + + # Try `update_settings` + elif self._update_settings: version = self._update_settings.get('version') - # Fallback to `version` file + # `version` file if not version: filepath = self.workflowfile('version') @@ -1265,6 +1338,10 @@ def version(self): with open(filepath, 'rb') as fileobj: version = fileobj.read() + # info.plist + if not version: + version = self.info.get('version') + if version: from update import Version version = Version(version) @@ -1293,7 +1370,6 @@ def args(self): See :ref:`Magic arguments ` for details. """ - msg = None args = [self.decode(arg) for arg in sys.argv[1:]] @@ -1316,28 +1392,34 @@ def args(self): def cachedir(self): """Path to workflow's cache directory. - The cache directory is a subdirectory of Alfred's own cache directory in - ``~/Library/Caches``. The full path is: + The cache directory is a subdirectory of Alfred's own cache directory + in ``~/Library/Caches``. The full path is: - ``~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/`` + ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` + + ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. :returns: full path to workflow's cache directory :rtype: ``unicode`` """ - if self.alfred_env.get('workflow_cache'): dirpath = self.alfred_env.get('workflow_cache') else: - dirpath = os.path.join( - os.path.expanduser( - '~/Library/Caches/com.runningwithcrayons.Alfred-2/' - 'Workflow Data/'), - self.bundleid) + dirpath = self._default_cachedir return self._create(dirpath) + @property + def _default_cachedir(self): + """Alfred 2's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-2/' + 'Workflow Data/'), + self.bundleid) + @property def datadir(self): """Path to workflow's data directory. @@ -1351,17 +1433,21 @@ def datadir(self): :rtype: ``unicode`` """ - if self.alfred_env.get('workflow_data'): dirpath = self.alfred_env.get('workflow_data') else: - dirpath = os.path.join(os.path.expanduser( - '~/Library/Application Support/Alfred 2/Workflow Data/'), - self.bundleid) + dirpath = self._default_datadir return self._create(dirpath) + @property + def _default_datadir(self): + """Alfred 2's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 2/Workflow Data/'), + self.bundleid) + @property def workflowdir(self): """Path to workflow's root directory (where ``info.plist`` is). @@ -1370,7 +1456,6 @@ def workflowdir(self): :rtype: ``unicode`` """ - if not self._workflowdir: # Try the working directory first, then the directory # the library is in. CWD will be the workflow root if @@ -1407,7 +1492,9 @@ def workflowdir(self): return self._workflowdir def cachefile(self, filename): - """Return full path to ``filename`` within your workflow's + """Path to ``filename`` in workflow's cache directory. + + Return absolute path to ``filename`` within your workflow's :attr:`cache directory `. :param filename: basename of file @@ -1416,11 +1503,12 @@ def cachefile(self, filename): :rtype: ``unicode`` """ - return os.path.join(self.cachedir, filename) def datafile(self, filename): - """Return full path to ``filename`` within your workflow's + """Path to ``filename`` in workflow's data directory. + + Return absolute path to ``filename`` within your workflow's :attr:`data directory `. :param filename: basename of file @@ -1429,12 +1517,10 @@ def datafile(self, filename): :rtype: ``unicode`` """ - return os.path.join(self.datadir, filename) def workflowfile(self, filename): - """Return full path to ``filename`` in workflow's root dir - (where ``info.plist`` is). + """Return full path to ``filename`` in workflow's root directory. :param filename: basename of file :type filename: ``unicode`` @@ -1442,31 +1528,30 @@ def workflowfile(self, filename): :rtype: ``unicode`` """ - return os.path.join(self.workflowdir, filename) @property def logfile(self): - """Return path to logfile + """Path to logfile. :returns: path to logfile within workflow's cache directory :rtype: ``unicode`` """ - return self.cachefile('%s.log' % self.bundleid) @property def logger(self): - """Create and return a logger that logs to both console and - a log file. + """Logger that logs to both console and a log file. + + If Alfred's debugger is open, log level will be ``DEBUG``, + else it will be ``INFO``. Use :meth:`open_log` to open the log file in Console. :returns: an initialised :class:`~logging.Logger` """ - if self._logger: return self._logger @@ -1474,25 +1559,28 @@ def logger(self): logger = logging.getLogger('workflow') if not len(logger.handlers): # Only add one set of handlers - logfile = logging.handlers.RotatingFileHandler( - self.logfile, - maxBytes=1024*1024, - backupCount=0) - - console = logging.StreamHandler() fmt = logging.Formatter( '%(asctime)s %(filename)s:%(lineno)s' ' %(levelname)-8s %(message)s', datefmt='%H:%M:%S') + logfile = logging.handlers.RotatingFileHandler( + self.logfile, + maxBytes=1024 * 1024, + backupCount=1) logfile.setFormatter(fmt) - console.setFormatter(fmt) - logger.addHandler(logfile) + + console = logging.StreamHandler() + console.setFormatter(fmt) logger.addHandler(console) - logger.setLevel(logging.DEBUG) + if self.debugging: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + self._logger = logger return self._logger @@ -1505,7 +1593,6 @@ def logger(self, logger): :type logger: `~logging.Logger` instance """ - self._logger = logger @property @@ -1516,7 +1603,6 @@ def settings_path(self): :rtype: ``unicode`` """ - if not self._settings_path: self._settings_path = self.datafile('settings.json') return self._settings_path @@ -1537,7 +1623,6 @@ def settings(self): :rtype: :class:`~workflow.workflow.Settings` instance """ - if not self._settings: self.logger.debug('Reading settings from `{0}` ...'.format( self.settings_path)) @@ -1560,7 +1645,6 @@ def cache_serializer(self): :rtype: ``unicode`` """ - return self._cache_serializer @cache_serializer.setter @@ -1580,7 +1664,6 @@ def cache_serializer(self, serializer_name): :type serializer_name: """ - if manager.serializer(serializer_name) is None: raise ValueError( 'Unknown serializer : `{0}`. Register your serializer ' @@ -1606,7 +1689,6 @@ def data_serializer(self): :rtype: ``unicode`` """ - return self._data_serializer @data_serializer.setter @@ -1625,7 +1707,6 @@ def data_serializer(self, serializer_name): :param serializer_name: Name of serializer to use by default. """ - if manager.serializer(serializer_name) is None: raise ValueError( 'Unknown serializer : `{0}`. Register your serializer ' @@ -1637,15 +1718,15 @@ def data_serializer(self, serializer_name): self._data_serializer = serializer_name def stored_data(self, name): - """Retrieve data from data directory. Returns ``None`` if there - are no data stored. + """Retrieve data from data directory. + + Returns ``None`` if there are no data stored under ``name``. .. versionadded:: 1.8 :param name: name of datastore """ - metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) if not os.path.exists(metadata_path): @@ -1701,7 +1782,6 @@ def store_data(self, name, data, serializer=None): :returns: data in datastore or ``None`` """ - # Ensure deletion is not interrupted by SIGTERM @uninterruptible def delete_paths(paths): @@ -1752,7 +1832,9 @@ def _store(): self.logger.debug('Stored data saved at : {0}'.format(data_path)) def cached_data(self, name, data_func=None, max_age=60): - """Retrieve data from cache or re-generate and re-cache data if + """Return cached data if younger than ``max_age`` seconds. + + Retrieve data from cache or re-generate and re-cache data if stale/non-existant. If ``max_age`` is 0, return cached data no matter how old. @@ -1765,7 +1847,6 @@ def cached_data(self, name, data_func=None, max_age=60): if ``data_func`` is not set """ - serializer = manager.serializer(self.cache_serializer) cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) @@ -1797,7 +1878,6 @@ def cache_data(self, name, data): the cache serializer """ - serializer = manager.serializer(self.cache_serializer) cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) @@ -1814,7 +1894,7 @@ def cache_data(self, name, data): self.logger.debug('Cached data saved at : %s', cache_path) def cached_data_fresh(self, name, max_age): - """Is data cached at `name` less than `max_age` old? + """Whether cache `name` is less than `max_age` seconds old. :param name: name of datastore :param max_age: maximum age of data in seconds @@ -1823,7 +1903,6 @@ def cached_data_fresh(self, name, max_age): ``False`` """ - age = self.cached_data_age(name) if not age: @@ -1832,8 +1911,7 @@ def cached_data_fresh(self, name, max_age): return age < max_age def cached_data_age(self, name): - """Return age of data cached at `name` in seconds or 0 if - cache doesn't exist + """Return age in seconds of cache `name` or 0 if cache doesn't exist. :param name: name of datastore :type name: ``unicode`` @@ -1841,7 +1919,6 @@ def cached_data_age(self, name): :rtype: ``int`` """ - cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) if not os.path.exists(cache_path): @@ -1902,7 +1979,7 @@ def filter(self, query, items, key=lambda x: x, ascending=False, 2. :const:`MATCH_CAPITALS` : The list of capital letters in item search key starts with ``query`` (``query`` may be lower-case). E.g., ``of`` would match ``OmniFocus``, - ``gc`` would match ``Google Chrome`` + ``gc`` would match ``Google Chrome``. 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on non-word characters (.,-,' etc.). Matches if ``query`` is one of these atoms (case-insensitive). @@ -1948,7 +2025,6 @@ def filter(self, query, items, key=lambda x: x, ascending=False, altered. """ - if not query: raise ValueError('Empty `query`') @@ -2008,12 +2084,11 @@ def filter(self, query, items, key=lambda x: x, ascending=False, return [t[0] for t in results] def _filter_item(self, value, query, match_on, fold_diacritics): - """Filter ``value`` against ``query`` using rules ``match_on`` + """Filter ``value`` against ``query`` using rules ``match_on``. :returns: ``(score, rule)`` """ - query = query.lower() if not isascii(query): @@ -2115,11 +2190,16 @@ def _search_for_query(self, query): self._search_pattern_cache[query] = search return search - def run(self, func): - """Call ``func`` to run your workflow + def run(self, func, text_errors=False): + """Call ``func`` to run your workflow. :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` instance) as first argument. + :param text_errors: Emit error messages in plain text, not in + Alfred's XML/JSON feedback format. Use this when you're not + running Alfred-Workflow in a Script Filter and would like + to pass the error message to, say, a notification. + :type text_errors: ``Boolean`` ``func`` will be called with :class:`Workflow` instance as first argument. @@ -2130,14 +2210,15 @@ def run(self, func): output to Alfred. """ - start = time.time() # Call workflow's entry function/method within a try-except block # to catch any errors and display an error message in Alfred try: + if self.version: - self.logger.debug('Workflow version : {0}'.format(self.version)) + self.logger.debug( + 'Workflow version : {0}'.format(self.version)) # Run update check if configured for self-updates. # This call has to go in the `run` try-except block, as it will @@ -2159,29 +2240,37 @@ def run(self, func): if self.help_url: self.logger.info( 'For assistance, see: {0}'.format(self.help_url)) + if not sys.stdout.isatty(): # Show error in Alfred - self._items = [] - if self._name: - name = self._name - elif self._bundleid: - name = self._bundleid - else: # pragma: no cover - name = os.path.dirname(__file__) - self.add_item("Error in workflow '%s'" % name, unicode(err), - icon=ICON_ERROR) - self.send_feedback() + if text_errors: + print(unicode(err).encode('utf-8'), end='') + else: + self._items = [] + if self._name: + name = self._name + elif self._bundleid: + name = self._bundleid + else: # pragma: no cover + name = os.path.dirname(__file__) + self.add_item("Error in workflow '%s'" % name, + unicode(err), + icon=ICON_ERROR) + self.send_feedback() return 1 + finally: self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format( - time.time() - start)) + time.time() - start)) + return 0 # Alfred feedback methods ------------------------------------------ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, autocomplete=None, valid=False, uid=None, icon=None, - icontype=None, type=None, largetext=None, copytext=None): - """Add an item to be output to Alfred + icontype=None, type=None, largetext=None, copytext=None, + quicklookurl=None): + """Add an item to be output to Alfred. :param title: Title shown in Alfred :type title: ``unicode`` @@ -2220,6 +2309,9 @@ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, :param copytext: Text to be copied to pasteboard if user presses CMD+C on item. :type copytext: ``unicode`` + :param quicklookurl: URL to be displayed using Alfred's Quick Look + feature (tapping ``SHIFT`` or ``⌘+Y`` on a result). + :type quicklookurl: ``unicode`` :returns: :class:`Item` instance See the :ref:`script-filter-results` section of the documentation @@ -2239,10 +2331,9 @@ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, edit it or do something with it other than send it to Alfred. """ - item = self.item_class(title, subtitle, modifier_subtitles, arg, autocomplete, valid, uid, icon, icontype, type, - largetext, copytext) + largetext, copytext, quicklookurl) self._items.append(item) return item @@ -2268,7 +2359,6 @@ def first_run(self): Raises a :class:`ValueError` if :attr:`version` isn't set. """ - if not self.version: raise ValueError('No workflow version set') @@ -2279,7 +2369,7 @@ def first_run(self): @property def last_version_run(self): - """Return version of last version to run (or ``None``) + """Return version of last version to run (or ``None``). .. versionadded:: 1.9.10 @@ -2287,7 +2377,6 @@ def last_version_run(self): or ``None`` """ - if self._last_version_run is UNSET: version = self.settings.get('__workflow_last_version') @@ -2303,7 +2392,7 @@ def last_version_run(self): return self._last_version_run def set_last_version(self, version=None): - """Set :attr:`last_version_run` to current version + """Set :attr:`last_version_run` to current version. .. versionadded:: 1.9.10 @@ -2313,7 +2402,6 @@ def set_last_version(self, version=None): :returns: ``True`` if version is saved, else ``False`` """ - if not version: if not self.version: self.logger.warning( @@ -2334,7 +2422,7 @@ def set_last_version(self, version=None): @property def update_available(self): - """Is an update available? + """Whether an update is available. .. versionadded:: 1.9 @@ -2344,7 +2432,6 @@ def update_available(self): :returns: ``True`` if an update is available, else ``False`` """ - update_data = self.cached_data('__workflow_update_status', max_age=0) self.logger.debug('update_data : {0}'.format(update_data)) @@ -2353,8 +2440,24 @@ def update_available(self): return update_data['available'] + @property + def prereleases(self): + """Whether workflow should update to pre-release versions. + + .. versionadded:: 1.16 + + :returns: ``True`` if pre-releases are enabled with the :ref:`magic + argument ` or the ``update_settings`` dict, else + ``False``. + + """ + if self._update_settings.get('prereleases'): + return True + + return self.settings.get('__workflow_prereleases') or False + def check_update(self, force=False): - """Call update script if it's time to check for a new release + """Call update script if it's time to check for a new release. .. versionadded:: 1.9 @@ -2368,7 +2471,6 @@ def check_update(self, force=False): :type force: ``Boolean`` """ - frequency = self._update_settings.get('frequency', DEFAULT_UPDATE_FREQUENCY) @@ -2393,6 +2495,9 @@ def check_update(self, force=False): cmd = ['/usr/bin/python', update_script, 'check', github_slug, version] + if self.prereleases: + cmd.append('--prereleases') + self.logger.info('Checking for update ...') run_in_background('__workflow_update_check', cmd) @@ -2401,7 +2506,7 @@ def check_update(self, force=False): self.logger.debug('Update check not due') def start_update(self): - """Check for update and download and install new workflow file + """Check for update and download and install new workflow file. .. versionadded:: 1.9 @@ -2412,14 +2517,13 @@ def start_update(self): installed, else ``False`` """ - import update github_slug = self._update_settings['github_slug'] # version = self._update_settings['version'] version = str(self.version) - if not update.check_update(github_slug, version): + if not update.check_update(github_slug, version, self.prereleases): return False from background import run_in_background @@ -2431,6 +2535,9 @@ def start_update(self): cmd = ['/usr/bin/python', update_script, 'install', github_slug, version] + if self.prereleases: + cmd.append('--prereleases') + self.logger.debug('Downloading update ...') run_in_background('__workflow_update_install', cmd) @@ -2481,8 +2588,9 @@ def save_password(self, account, password, service=None): self.logger.debug('save_password : %s:%s', service, account) def get_password(self, account, service=None): - """Retrieve the password saved at ``service/account``. Raise - :class:`PasswordNotFound` exception if password doesn't exist. + """Retrieve the password saved at ``service/account``. + + Raise :class:`PasswordNotFound` exception if password doesn't exist. :param account: name of the account the password is for, e.g. "Pinboard" @@ -2494,7 +2602,6 @@ def get_password(self, account, service=None): :rtype: ``unicode`` """ - if not service: service = self.bundleid @@ -2520,8 +2627,9 @@ def get_password(self, account, service=None): return password def delete_password(self, account, service=None): - """Delete the password stored at ``service/account``. Raises - :class:`PasswordNotFound` if account is unknown. + """Delete the password stored at ``service/account``. + + Raise :class:`PasswordNotFound` if account is unknown. :param account: name of the account the password is for, e.g. "Pinboard" @@ -2531,7 +2639,6 @@ def delete_password(self, account, service=None): :type service: ``unicode`` """ - if not service: service = self.bundleid @@ -2544,9 +2651,8 @@ def delete_password(self, account, service=None): #################################################################### def _register_default_magic(self): - """Register the built-in magic arguments""" + """Register the built-in magic arguments.""" # TODO: refactor & simplify - # Wrap callback and message with callable def callback(func, msg): def wrapper(): @@ -2601,6 +2707,14 @@ def update_off(): self.settings['__workflow_autoupdate'] = False return 'Auto update turned off' + def prereleases_on(): + self.settings['__workflow_prereleases'] = True + return 'Prerelease updates turned on' + + def prereleases_off(): + self.settings['__workflow_prereleases'] = False + return 'Prerelease updates turned off' + def do_update(): if self.start_update(): return 'Downloading and installing update ...' @@ -2609,6 +2723,8 @@ def do_update(): self.magic_arguments['autoupdate'] = update_on self.magic_arguments['noautoupdate'] = update_off + self.magic_arguments['prereleases'] = prereleases_on + self.magic_arguments['noprereleases'] = prereleases_off self.magic_arguments['update'] = do_update # Help @@ -2626,7 +2742,7 @@ def show_version(): return 'This workflow has no version number' def list_magic(): - """Display all available magic args in Alfred""" + """Display all available magic args in Alfred.""" isatty = sys.stderr.isatty() for name in sorted(self.magic_arguments.keys()): if name == 'magic': @@ -2675,21 +2791,18 @@ def clear_settings(self): self.logger.debug('Deleted : %r', self.settings_path) def reset(self): - """Delete :attr:`settings `, :attr:`cache ` - and :attr:`data ` + """Delete workflow settings, cache and data. - """ + File :attr:`settings ` and directories + :attr:`cache ` and :attr:`data ` are deleted. + """ self.clear_cache() self.clear_data() self.clear_settings() def open_log(self): - """Open workflows :attr:`logfile` in standard - application (usually Console.app). - - """ - + """Open :attr:`logfile` in default app (usually Console.app).""" subprocess.call(['open', self.logfile]) def open_cachedir(self): @@ -2706,12 +2819,11 @@ def open_workflowdir(self): def open_terminal(self): """Open a Terminal window at workflow's :attr:`workflowdir`.""" - subprocess.call(['open', '-a', 'Terminal', self.workflowdir]) def open_help(self): - """Open :attr:`help_url` in default browser""" + """Open :attr:`help_url` in default browser.""" subprocess.call(['open', self.help_url]) return 'Opening workflow help URL in browser' @@ -2748,7 +2860,6 @@ def decode(self, text, encoding=None, normalization=None): :class:`Workflow`. """ - encoding = encoding or self._input_encoding normalization = normalization or self._normalizsation if not isinstance(text, unicode): @@ -2796,15 +2907,15 @@ def dumbify_punctuation(self, text): return text def _delete_directory_contents(self, dirpath, filter_func): - """Delete all files in a directory + """Delete all files in a directory. :param dirpath: path to directory to clear :type dirpath: ``unicode`` or ``str`` :param filter_func function to determine whether a file shall be deleted or not. :type filter_func ``callable`` - """ + """ if os.path.exists(dirpath): for filename in os.listdir(dirpath): if not filter_func(filename): @@ -2817,15 +2928,13 @@ def _delete_directory_contents(self, dirpath, filter_func): self.logger.debug('Deleted : %r', path) def _load_info_plist(self): - """Load workflow info from ``info.plist`` - - """ - - self._info = plistlib.readPlist(self._info_plist) + """Load workflow info from ``info.plist``.""" + # info.plist should be in the directory above this one + self._info = plistlib.readPlist(self.workflowfile('info.plist')) self._info_loaded = True def _create(self, dirpath): - """Create directory `dirpath` if it doesn't exist + """Create directory `dirpath` if it doesn't exist. :param dirpath: path to directory :type dirpath: ``unicode`` @@ -2833,14 +2942,12 @@ def _create(self, dirpath): :rtype: ``unicode`` """ - if not os.path.exists(dirpath): os.makedirs(dirpath) return dirpath def _call_security(self, action, service, account, *args): - """Call the ``security`` CLI app that provides access to keychains. - + """Call ``security`` CLI program that provides access to keychains. May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` exceptions (the first two are subclasses of `KeychainError`). @@ -2863,17 +2970,16 @@ def _call_security(self, action, service, account, *args): :rtype: `tuple` (`int`, ``unicode``) """ - cmd = ['security', action, '-s', service, '-a', account] + list(args) p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8') - if retcode == 44: # password does not exist + stdout, _ = p.communicate() + if p.returncode == 44: # password does not exist raise PasswordNotFound() - elif retcode == 45: # password already exists + elif p.returncode == 45: # password already exists raise PasswordExists() - elif retcode > 0: - err = KeychainError('Unknown Keychain error : %s' % output) - err.retcode = retcode + elif p.returncode > 0: + err = KeychainError('Unknown Keychain error : %s' % stdout) + err.retcode = p.returncode raise err - return output + return stdout.strip().decode('utf-8') diff --git a/src/workflow/workflow3.py b/src/workflow/workflow3.py new file mode 100644 index 0000000..d4692ea --- /dev/null +++ b/src/workflow/workflow3.py @@ -0,0 +1,532 @@ +# encoding: utf-8 +# +# Copyright (c) 2016 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2016-06-25 +# + +""" +:class:`Workflow3` supports Alfred 3's new features. + +It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`. + +It supports setting :ref:`workflow-variables` and +:class:`the more advanced modifiers ` supported by Alfred 3. + +In order for the feedback mechanism to work correctly, it's important +to create :class:`Item3` and :class:`Modifier` objects via the +:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods +respectively. If you instantiate :class:`Item3` or :class:`Modifier` +objects directly, the current :class:`~workflow.workflow3.Workflow3` +object won't be aware of them, and they won't be sent to Alfred when +you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`. +""" + +from __future__ import print_function, unicode_literals, absolute_import + +import json +import os +import sys + +from .workflow import Workflow + + +class Modifier(object): + """Modify ``Item3`` values for when specified modifier keys are pressed. + + Valid modifiers (i.e. values for ``key``) are: + + * cmd + * alt + * shift + * ctrl + * fn + + Attributes: + arg (unicode): Arg to pass to following action. + key (unicode): Modifier key (see above). + subtitle (unicode): Override item subtitle. + valid (bool): Override item validity. + variables (dict): Workflow variables set by this modifier. + """ + + def __init__(self, key, subtitle=None, arg=None, valid=None): + """Create a new :class:`Modifier`. + + You probably don't want to use this class directly, but rather + use :meth:`Item3.add_modifier()` to add modifiers to results. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (unicode, optional): Override default subtitle. + arg (unicode, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + """ + self.key = key + self.subtitle = subtitle + self.arg = arg + self.valid = valid + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + @property + def obj(self): + """Modifier formatted for JSON serialization for Alfred 3. + + Returns: + dict: Modifier for serializing to JSON. + """ + o = {} + + if self.subtitle is not None: + o['subtitle'] = self.subtitle + + if self.arg is not None: + o['arg'] = self.arg + + if self.valid is not None: + o['valid'] = self.valid + + # Variables and config + if self.variables or self.config: + d = {} + if self.variables: + d['variables'] = self.variables + + if self.config: + d['config'] = self.config + + if self.arg is not None: + d['arg'] = self.arg + + o['arg'] = json.dumps({'alfredworkflow': d}) + + return o + + +class Item3(object): + """Represents a feedback item for Alfred 3. + + Generates Alfred-compliant JSON for a single item. + + You probably shouldn't use this class directly, but via + :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item` + for details of arguments. + """ + + def __init__(self, title, subtitle='', arg=None, autocomplete=None, + valid=False, uid=None, icon=None, icontype=None, + type=None, largetext=None, copytext=None, quicklookurl=None): + """Use same arguments as for :meth:`Workflow.add_item`. + + Argument ``subtitle_modifiers`` is not supported. + """ + self.title = title + self.subtitle = subtitle + self.arg = arg + self.autocomplete = autocomplete + self.valid = valid + self.uid = uid + self.icon = icon + self.icontype = icontype + self.type = type + self.quicklookurl = quicklookurl + self.largetext = largetext + self.copytext = copytext + + self.modifiers = {} + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + def add_modifier(self, key, subtitle=None, arg=None, valid=None): + """Add alternative values for a modifier key. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` + subtitle (unicode, optional): Override item subtitle. + arg (unicode, optional): Input for following action. + valid (bool, optional): Override item validity. + + Returns: + Modifier: Configured :class:`Modifier`. + """ + mod = Modifier(key, subtitle, arg, valid) + + for k in self.variables: + mod.setvar(k, self.variables[k]) + + self.modifiers[key] = mod + + return mod + + @property + def obj(self): + """Item formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred 3 feedback. + """ + # Basic values + o = {'title': self.title, + 'subtitle': self.subtitle, + 'valid': self.valid} + + icon = {} + + # Optional values + if self.arg is not None: + o['arg'] = self.arg + + if self.autocomplete is not None: + o['autocomplete'] = self.autocomplete + + if self.uid is not None: + o['uid'] = self.uid + + if self.type is not None: + o['type'] = self.type + + if self.quicklookurl is not None: + o['quicklookurl'] = self.quicklookurl + + # Largetype and copytext + text = self._text() + if text: + o['text'] = text + + icon = self._icon() + if icon: + o['icon'] = icon + + # Variables and config + js = self._vars_and_config() + if js: + o['arg'] = js + + # Modifiers + mods = self._modifiers() + if mods: + o['mods'] = mods + + return o + + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + """ + icon = {} + if self.icon is not None: + icon['path'] = self.icon + + if self.icontype is not None: + icon['type'] = self.icontype + + return icon + + def _text(self): + """Return `largetext` and `copytext` object for item. + + Returns: + dict: `text` mapping (may be empty) + """ + text = {} + if self.largetext is not None: + text['largetype'] = self.largetext + + if self.copytext is not None: + text['copy'] = self.copytext + + return text + + def _vars_and_config(self): + """Build `arg` including workflow variables and configuration. + + Returns: + str: JSON string value for `arg` (or `None`) + """ + if self.variables or self.config: + d = {} + if self.variables: + d['variables'] = self.variables + + if self.config: + d['config'] = self.config + + if self.arg is not None: + d['arg'] = self.arg + + return json.dumps({'alfredworkflow': d}) + + return None + + def _modifiers(self): + """Build `mods` dictionary for JSON feedback. + + Returns: + dict: Modifier mapping or `None`. + """ + if self.modifiers: + mods = {} + for k, mod in self.modifiers.items(): + mods[k] = mod.obj + + return mods + + return None + + +class Workflow3(Workflow): + """Workflow class that generates Alfred 3 feedback. + + Attributes: + item_class (class): Class used to generate feedback items. + variables (dict): Top level workflow variables. + """ + + item_class = Item3 + + def __init__(self, **kwargs): + """Create a new :class:`Workflow3` object. + + See :class:`~workflow.workflow.Workflow` for documentation. + """ + Workflow.__init__(self, **kwargs) + self.variables = {} + self._rerun = 0 + self._session_id = None + + @property + def _default_cachedir(self): + """Alfred 3's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-3/' + 'Workflow Data/'), + self.bundleid) + + @property + def _default_datadir(self): + """Alfred 3's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 3/Workflow Data/'), + self.bundleid) + + @property + def rerun(self): + """How often (in seconds) Alfred should re-run the Script Filter.""" + return self._rerun + + @rerun.setter + def rerun(self, seconds): + """Interval at which Alfred should re-run the Script Filter. + + Args: + seconds (int): Interval between runs. + """ + self._rerun = seconds + + @property + def session_id(self): + """A unique session ID every time the user uses the workflow. + + .. versionadded:: 1.25 + + The session ID persists while the user is using this workflow. + It expires when the user runs a different workflow or closes + Alfred. + + """ + if not self._session_id: + sid = os.getenv('_WF_SESSION_ID') + if not sid: + from uuid import uuid4 + sid = uuid4().hex + self.setvar('_WF_SESSION_ID', sid) + + self._session_id = sid + + return self._session_id + + def setvar(self, name, value): + """Set a "global" workflow variable. + + These variables are always passed to downstream workflow objects. + + If you have set :attr:`rerun`, these variables are also passed + back to the script when Alfred runs it again. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + def add_item(self, title, subtitle='', arg=None, autocomplete=None, + valid=False, uid=None, icon=None, icontype=None, + type=None, largetext=None, copytext=None, quicklookurl=None): + """Add an item to be output to Alfred. + + See :meth:`~workflow.workflow.Workflow.add_item` for the main + documentation. + + The key difference is that this method does not support the + ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` + method instead on the returned item instead. + + Returns: + Item3: Alfred feedback item. + """ + item = self.item_class(title, subtitle, arg, + autocomplete, valid, uid, icon, icontype, type, + largetext, copytext, quicklookurl) + + self._items.append(item) + return item + + def _mk_session_name(self, name): + """New cache name/key based on session ID.""" + return '_wfsess-{0}-{1}'.format(self.session_id, name) + + def cache_data(self, name, data, session=False): + """Cache API with session-scoped expiry. + + .. versionadded:: 1.25 + + Args: + name (str): Cache key + data (object): Data to cache + session (bool, optional): Whether to scope the cache + to the current session. + + ``name`` and ``data`` are as for the + :meth:`~workflow.workflow.Workflow.cache_data` on + :class:`~workflow.workflow.Workflow`. + + If ``session`` is ``True``, the ``name`` variable is prefixed + with :attr:`session_id`. + + """ + if session: + name = self._mk_session_name(name) + + return super(Workflow3, self).cache_data(name, data) + + def cached_data(self, name, data_func=None, max_age=60, session=False): + """Cache API with session-scoped expiry. + + .. versionadded:: 1.25 + + Args: + name (str): Cache key + data_func (callable): Callable that returns fresh data. It + is called if the cache has expired or doesn't exist. + max_age (int): Maximum allowable age of cache in seconds. + session (bool, optional): Whether to scope the cache + to the current session. + + ``name``, ``data_func`` and ``max_age`` are as for the + :meth:`~workflow.workflow.Workflow.cached_data` on + :class:`~workflow.workflow.Workflow`. + + If ``session`` is ``True``, the ``name`` variable is prefixed + with :attr:`session_id`. + + """ + if session: + name = self._mk_session_name(name) + + return super(Workflow3, self).cached_data(name, data_func, max_age) + + def clear_session_cache(self): + """Remove *all* session data from the cache. + + .. versionadded:: 1.25 + """ + def _is_session_file(filename): + return filename.startswith('_wfsess-') + + self.clear_cache(_is_session_file) + + @property + def obj(self): + """Feedback formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred 3 feedback. + """ + items = [] + for item in self._items: + items.append(item.obj) + + o = {'items': items} + if self.variables: + o['variables'] = self.variables + if self.rerun: + o['rerun'] = self.rerun + return o + + def send_feedback(self): + """Print stored items to console/Alfred as JSON.""" + json.dump(self.obj, sys.stdout) + sys.stdout.flush()