Browse Source

Fix the problem of Alfred workflow. #484

oldj 5 years ago
parent
commit
631f85c2dc

+ 7 - 5
scripts/alfred/alfred.py → alfred/alfred.py

@@ -27,11 +27,13 @@ def main(wf):
     # the list of results for Alfred
     for item in items:
         on = item.get('on', False)
-        wf.add_item(title=item['title'],
-                    subtitle=get_subtitle(item),
-                    arg=item['id'],
-                    valid=True,
-                    icon='on.png' if on else 'off.png')
+        wf.add_item(
+            title=item.get('title', 'untitled'),
+            subtitle=get_subtitle(item),
+            arg=item.get('id'),
+            valid=True,
+            icon='on.png' if on else 'off.png',
+        )
 
     # Send the results to Alfred as XML
     wf.send_feedback()

BIN
alfred/icon.png


+ 124 - 0
alfred/info.plist

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+    <dict>
+        <key>bundleid</key>
+        <string>switchhosts.oldj.net</string>
+        <key>category</key>
+        <string>Productivity</string>
+        <key>connections</key>
+        <dict>
+            <key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
+            <array>
+                <dict>
+                    <key>destinationuid</key>
+                    <string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
+                    <key>modifiers</key>
+                    <integer>0</integer>
+                    <key>modifiersubtext</key>
+                    <string></string>
+                    <key>vitoclose</key>
+                    <false/>
+                </dict>
+            </array>
+        </dict>
+        <key>createdby</key>
+        <string>oldj</string>
+        <key>description</key>
+        <string>SwitchHosts quickly!</string>
+        <key>disabled</key>
+        <false/>
+        <key>name</key>
+        <string>SwitchHosts!</string>
+        <key>objects</key>
+        <array>
+            <dict>
+                <key>config</key>
+                <dict>
+                    <key>concurrently</key>
+                    <false/>
+                    <key>escaping</key>
+                    <integer>102</integer>
+                    <key>script</key>
+                    <string>curl 'http://127.0.0.1:50761/api/toggle?id={query}'</string>
+                    <key>scriptargtype</key>
+                    <integer>0</integer>
+                    <key>scriptfile</key>
+                    <string></string>
+                    <key>type</key>
+                    <integer>0</integer>
+                </dict>
+                <key>type</key>
+                <string>alfred.workflow.action.script</string>
+                <key>uid</key>
+                <string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
+                <key>version</key>
+                <integer>2</integer>
+            </dict>
+            <dict>
+                <key>config</key>
+                <dict>
+                    <key>alfredfiltersresults</key>
+                    <false/>
+                    <key>argumenttype</key>
+                    <integer>2</integer>
+                    <key>escaping</key>
+                    <integer>68</integer>
+                    <key>keyword</key>
+                    <string>swh</string>
+                    <key>queuedelaycustom</key>
+                    <integer>3</integer>
+                    <key>queuedelayimmediatelyinitially</key>
+                    <true/>
+                    <key>queuedelaymode</key>
+                    <integer>0</integer>
+                    <key>queuemode</key>
+                    <integer>1</integer>
+                    <key>runningsubtext</key>
+                    <string>loading...</string>
+                    <key>script</key>
+                    <string>python alfred.py</string>
+                    <key>scriptargtype</key>
+                    <integer>1</integer>
+                    <key>scriptfile</key>
+                    <string></string>
+                    <key>subtext</key>
+                    <string>Switch hosts quickly!</string>
+                    <key>title</key>
+                    <string>Show hosts..</string>
+                    <key>type</key>
+                    <integer>0</integer>
+                    <key>withspace</key>
+                    <false/>
+                </dict>
+                <key>type</key>
+                <string>alfred.workflow.input.scriptfilter</string>
+                <key>uid</key>
+                <string>E4D66445-FD72-47A2-9EE6-7232A2BADE29</string>
+                <key>version</key>
+                <integer>2</integer>
+            </dict>
+        </array>
+        <key>readme</key>
+        <string></string>
+        <key>uidata</key>
+        <dict>
+            <key>78D17FD5-9628-4901-A01A-511528D5FC14</key>
+            <dict>
+                <key>xpos</key>
+                <integer>340</integer>
+                <key>ypos</key>
+                <integer>30</integer>
+            </dict>
+            <key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
+            <dict>
+                <key>xpos</key>
+                <integer>120</integer>
+                <key>ypos</key>
+                <integer>30</integer>
+            </dict>
+        </dict>
+        <key>webaddress</key>
+        <string>https://oldj.github.io/SwitchHosts/</string>
+    </dict>
+</plist>

+ 0 - 0
scripts/alfred/off.png → alfred/off.png


+ 0 - 0
scripts/alfred/on.png → alfred/on.png


+ 0 - 0
scripts/alfred/workflow/Notify.tgz → alfred/workflow/Notify.tgz


+ 3 - 2
scripts/alfred/workflow/__init__.py → alfred/workflow/__init__.py

@@ -14,7 +14,7 @@ import os
 
 # Workflow objects
 from .workflow import Workflow, manager
-from .workflow3 import Workflow3
+from .workflow3 import Variables, Workflow3
 
 # Exceptions
 from .workflow import PasswordNotFound, KeychainError
@@ -64,9 +64,10 @@ __title__ = 'Alfred-Workflow'
 __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
 __author__ = 'Dean Jackson'
 __licence__ = 'MIT'
-__copyright__ = 'Copyright 2014 Dean Jackson'
+__copyright__ = 'Copyright 2014-2019 Dean Jackson'
 
 __all__ = [
+    'Variables',
     'Workflow',
     'Workflow3',
     'manager',

+ 104 - 56
scripts/alfred/workflow/background.py → alfred/workflow/background.py

@@ -8,10 +8,18 @@
 # Created on 2014-04-06
 #
 
-"""Run background tasks."""
+"""This module provides an API to run commands in background processes.
+
+Combine with the :ref:`caching API <caching-data>` to work from cached data
+while you fetch fresh data in the background.
+
+See :ref:`the User Manual <background-processes>` for more information
+and examples.
+"""
 
 from __future__ import print_function, unicode_literals
 
+import signal
 import sys
 import os
 import subprocess
@@ -31,6 +39,10 @@ def wf():
     return _wf
 
 
+def _log():
+    return wf().logger
+
+
 def _arg_cache(name):
     """Return path to pickle cache file for arguments.
 
@@ -40,7 +52,7 @@ def _arg_cache(name):
     :rtype: ``unicode`` filepath
 
     """
-    return wf().cachefile('{0}.argcache'.format(name))
+    return wf().cachefile(name + '.argcache')
 
 
 def _pid_file(name):
@@ -52,7 +64,7 @@ def _pid_file(name):
     :rtype: ``unicode`` filepath
 
     """
-    return wf().cachefile('{0}.pid'.format(name))
+    return wf().cachefile(name + '.pid')
 
 
 def _process_exists(pid):
@@ -71,35 +83,49 @@ def _process_exists(pid):
     return True
 
 
-def is_running(name):
-    """Test whether task is running under ``name``.
+def _job_pid(name):
+    """Get PID of job or `None` if job does not exist.
 
-    :param name: name of task
-    :type name: ``unicode``
-    :returns: ``True`` if task with name ``name`` is running, else ``False``
-    :rtype: ``Boolean``
+    Args:
+        name (str): Name of job.
 
+    Returns:
+        int: PID of job process (or `None` if job doesn't exist).
     """
     pidfile = _pid_file(name)
     if not os.path.exists(pidfile):
-        return False
+        return
 
-    with open(pidfile, 'rb') as file_obj:
-        pid = int(file_obj.read().strip())
+    with open(pidfile, 'rb') as fp:
+        pid = int(fp.read())
 
-    if _process_exists(pid):
-        return True
+        if _process_exists(pid):
+            return pid
 
-    elif os.path.exists(pidfile):
-        os.unlink(pidfile)
+    os.unlink(pidfile)
+
+
+def is_running(name):
+    """Test whether task ``name`` is currently running.
+
+    :param name: name of task
+    :type name: unicode
+    :returns: ``True`` if task with name ``name`` is running, else ``False``
+    :rtype: bool
+
+    """
+    if _job_pid(name) is not None:
+        return True
 
     return False
 
 
-def _background(stdin='/dev/null', stdout='/dev/null',
+def _background(pidfile, stdin='/dev/null', stdout='/dev/null',
                 stderr='/dev/null'):  # pragma: no cover
     """Fork the current process into a background daemon.
 
+    :param pidfile: file to write PID of daemon process to.
+    :type pidfile: filepath
     :param stdin: where to read input
     :type stdin: filepath
     :param stdout: where to write stdout output
@@ -108,25 +134,31 @@ def _background(stdin='/dev/null', stdout='/dev/null',
     :type stderr: filepath
 
     """
-    def _fork_and_exit_parent(errmsg):
+    def _fork_and_exit_parent(errmsg, wait=False, write=False):
         try:
             pid = os.fork()
             if pid > 0:
+                if write:  # write PID of child process to `pidfile`
+                    tmp = pidfile + '.tmp'
+                    with open(tmp, 'wb') as fp:
+                        fp.write(str(pid))
+                    os.rename(tmp, pidfile)
+                if wait:  # wait for child process to exit
+                    os.waitpid(pid, 0)
                 os._exit(0)
         except OSError as err:
-            wf().logger.critical('%s: (%d) %s', errmsg, err.errno,
-                                 err.strerror)
+            _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror)
             raise err
 
-    # Do first fork.
-    _fork_and_exit_parent('fork #1 failed')
+    # Do first fork and wait for second fork to finish.
+    _fork_and_exit_parent('fork #1 failed', wait=True)
 
     # Decouple from parent environment.
     os.chdir(wf().workflowdir)
     os.setsid()
 
-    # Do second fork.
-    _fork_and_exit_parent('fork #2 failed')
+    # Do second fork and write PID to pidfile.
+    _fork_and_exit_parent('fork #2 failed', write=True)
 
     # Now I am a daemon!
     # Redirect standard file descriptors.
@@ -141,15 +173,35 @@ def _background(stdin='/dev/null', stdout='/dev/null',
         os.dup2(se.fileno(), sys.stderr.fileno())
 
 
+def kill(name, sig=signal.SIGTERM):
+    """Send a signal to job ``name`` via :func:`os.kill`.
+
+    .. versionadded:: 1.29
+
+    Args:
+        name (str): Name of the job
+        sig (int, optional): Signal to send (default: SIGTERM)
+
+    Returns:
+        bool: `False` if job isn't running, `True` if signal was sent.
+    """
+    pid = _job_pid(name)
+    if pid is None:
+        return False
+
+    os.kill(pid, sig)
+    return True
+
+
 def run_in_background(name, args, **kwargs):
     r"""Cache arguments then call this script again via :func:`subprocess.call`.
 
-    :param name: name of task
-    :type name: ``unicode``
+    :param name: name of job
+    :type name: unicode
     :param args: arguments passed as first argument to :func:`subprocess.call`
     :param \**kwargs: keyword arguments to :func:`subprocess.call`
     :returns: exit code of sub-process
-    :rtype: ``int``
+    :rtype: int
 
     When you call this function, it caches its arguments and then calls
     ``background.py`` in a subprocess. The Python subprocess will load the
@@ -167,24 +219,26 @@ def run_in_background(name, args, **kwargs):
 
     """
     if is_running(name):
-        wf().logger.info('Task `{0}` is already running'.format(name))
+        _log().info('[%s] job already running', name)
         return
 
     argcache = _arg_cache(name)
 
     # Cache arguments
-    with open(argcache, 'wb') as file_obj:
-        pickle.dump({'args': args, 'kwargs': kwargs}, file_obj)
-        wf().logger.debug('Command arguments cached to `{0}`'.format(argcache))
+    with open(argcache, 'wb') as fp:
+        pickle.dump({'args': args, 'kwargs': kwargs}, fp)
+        _log().debug('[%s] command cached: %s', name, argcache)
 
     # Call this script
     cmd = ['/usr/bin/python', __file__, name]
-    wf().logger.debug('Calling {0!r} ...'.format(cmd))
+    _log().debug('[%s] passing job to background runner: %r', name, cmd)
     retcode = subprocess.call(cmd)
+
     if retcode:  # pragma: no cover
-        wf().logger.error('Failed to call task in background')
+        _log().error('[%s] background runner failed with %d', name, retcode)
     else:
-        wf().logger.debug('Executing task `{0}` in background...'.format(name))
+        _log().debug('[%s] background job started', name)
+
     return retcode
 
 
@@ -195,15 +249,21 @@ def main(wf):  # pragma: no cover
     :meth:`subprocess.call` with cached arguments.
 
     """
+    log = wf.logger
     name = wf.args[0]
     argcache = _arg_cache(name)
     if not os.path.exists(argcache):
-        wf.logger.critical('No arg cache found : {0!r}'.format(argcache))
-        return 1
+        msg = '[{0}] command cache not found: {1}'.format(name, argcache)
+        log.critical(msg)
+        raise IOError(msg)
+
+    # Fork to background and run command
+    pidfile = _pid_file(name)
+    _background(pidfile)
 
     # Load cached arguments
-    with open(argcache, 'rb') as file_obj:
-        data = pickle.load(file_obj)
+    with open(argcache, 'rb') as fp:
+        data = pickle.load(fp)
 
     # Cached arguments
     args = data['args']
@@ -212,30 +272,18 @@ def main(wf):  # pragma: no cover
     # Delete argument cache file
     os.unlink(argcache)
 
-    pidfile = _pid_file(name)
-
-    # Fork to background
-    _background()
-
-    # Write PID to file
-    with open(pidfile, 'wb') as file_obj:
-        file_obj.write('{0}'.format(os.getpid()))
-
-    # Run the command
     try:
-        wf.logger.debug('Task `{0}` running'.format(name))
-        wf.logger.debug('cmd : {0!r}'.format(args))
+        # Run the command
+        log.debug('[%s] running command: %r', name, args)
 
         retcode = subprocess.call(args, **kwargs)
 
         if retcode:
-            wf.logger.error('Command failed with [{0}] : {1!r}'.format(
-                            retcode, args))
-
+            log.error('[%s] command failed with status %d', name, retcode)
     finally:
-        if os.path.exists(pidfile):
-            os.unlink(pidfile)
-        wf.logger.debug('Task `{0}` finished'.format(name))
+        os.unlink(pidfile)
+
+    log.debug('[%s] job complete', name)
 
 
 if __name__ == '__main__':  # pragma: no cover

+ 46 - 75
scripts/alfred/workflow/notify.py → alfred/workflow/notify.py

@@ -11,9 +11,10 @@
 # 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.
+Post notifications via the macOS 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`.
 
@@ -60,10 +61,10 @@ SOUNDS = (
 
 
 def wf():
-    """Return `Workflow` object for this module.
+    """Return Workflow object for this module.
 
     Returns:
-        workflow.Workflow: `Workflow` object for current workflow.
+        workflow.Workflow: Workflow object for current workflow.
     """
     global _wf
     if _wf is None:
@@ -87,7 +88,7 @@ def notifier_program():
     """Return path to notifier applet executable.
 
     Returns:
-        unicode: Path to Notify.app `applet` executable.
+        unicode: Path to Notify.app ``applet`` executable.
     """
     return wf().datafile('Notify.app/Contents/MacOS/applet')
 
@@ -96,13 +97,13 @@ def notifier_icon_path():
     """Return path to icon file in installed Notify.app.
 
     Returns:
-        unicode: Path to `applet.icns` within the app bundle.
+        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.
+    """Extract ``Notify.app`` from the workflow to data directory.
 
     Changes the bundle ID of the installed app and gives it the
     workflow's icon.
@@ -111,13 +112,13 @@ def install_notifier():
     destdir = wf().datadir
     app_path = os.path.join(destdir, 'Notify.app')
     n = notifier_program()
-    log().debug("Installing Notify.app to %r ...", destdir)
+    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))
+    if not os.path.exists(n):  # pragma: nocover
+        raise RuntimeError('Notify.app could not be installed in ' + destdir)
 
     # Replace applet icon
     icon = notifier_icon_path()
@@ -144,29 +145,29 @@ def install_notifier():
     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))
+    log().debug('changing bundle ID to %r', bundle_id)
     data['CFBundleIdentifier'] = bundle_id
     plistlib.writePlist(data, ip_path)
 
 
 def validate_sound(sound):
-    """Coerce `sound` to valid sound name.
+    """Coerce ``sound`` to valid sound name.
 
-    Returns `None` for invalid sounds. Sound names can be found
-    in `System Preferences > Sound > Sound Effects`.
+    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`.
+        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
+        # Title-case is correct for all system sounds as of macOS 10.11
         return sound.title()
     return None
 
@@ -180,10 +181,10 @@ def notify(title='', text='', sound=None):
         sound (str, optional): Name of sound to play.
 
     Raises:
-        ValueError: Raised if both `title` and `text` are empty.
+        ValueError: Raised if both ``title`` and ``text`` are empty.
 
     Returns:
-        bool: `True` if notification was posted, else `False`.
+        bool: ``True`` if notification was posted, else ``False``.
     """
     if title == text == '':
         raise ValueError('Empty notification')
@@ -198,7 +199,7 @@ def notify(title='', text='', sound=None):
     env = os.environ.copy()
     enc = 'utf-8'
     env['NOTIFY_TITLE'] = title.encode(enc)
-    env['NOTIFY_MESSAGE'] =  text.encode(enc)
+    env['NOTIFY_MESSAGE'] = text.encode(enc)
     env['NOTIFY_SOUND'] = sound.encode(enc)
     cmd = [n]
     retcode = subprocess.call(cmd, env=env)
@@ -210,7 +211,7 @@ def notify(title='', text='', sound=None):
 
 
 def convert_image(inpath, outpath, size):
-    """Convert an image file using `sips`.
+    """Convert an image file using ``sips``.
 
     Args:
         inpath (str): Path of source file.
@@ -218,11 +219,11 @@ def convert_image(inpath, outpath, size):
         size (int): Width and height of destination image in pixels.
 
     Raises:
-        RuntimeError: Raised if `sips` exits with non-zero status.
+        RuntimeError: Raised if ``sips`` exits with non-zero status.
     """
     cmd = [
         b'sips',
-        b'-z', b'{0}'.format(size), b'{0}'.format(size),
+        b'-z', str(size), str(size),
         inpath,
         b'--out', outpath]
     # log().debug(cmd)
@@ -230,14 +231,14 @@ def convert_image(inpath, outpath, size):
         retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
 
     if retcode != 0:
-        raise RuntimeError('sips exited with {0}'.format(retcode))
+        raise RuntimeError('sips exited with %d' % retcode)
 
 
 def png_to_icns(png_path, icns_path):
-    """Convert PNG file to ICNS using `iconutil`.
+    """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
+    in each size required by macOS, then call ``iconutil`` to turn
     them into a single ICNS file.
 
     Args:
@@ -245,15 +246,16 @@ def png_to_icns(png_path, icns_path):
         icns_path (str): Path to destination ICNS file.
 
     Raises:
-        RuntimeError: Raised if `iconutil` or `sips` fail.
+        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))
+        if os.path.exists(iconset):  # pragma: nocover
+            raise RuntimeError('iconset already exists: ' + iconset)
+
         os.makedirs(iconset)
 
         # Copy source icon to icon set and generate all the other
@@ -261,7 +263,7 @@ def png_to_icns(png_path, icns_path):
         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)))
+            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, '[email protected]'))
@@ -280,10 +282,11 @@ def png_to_icns(png_path, icns_path):
 
         retcode = subprocess.call(cmd)
         if retcode != 0:
-            raise RuntimeError("iconset exited with {0}".format(retcode))
+            raise RuntimeError('iconset exited with %d' % retcode)
 
-        assert os.path.exists(icns_path), (
-            "Generated ICNS file not found : {0!r}".format(icns_path))
+        if not os.path.exists(icns_path):  # pragma: nocover
+            raise ValueError(
+                'generated ICNS file not found: ' + repr(icns_path))
     finally:
         try:
             shutil.rmtree(tempdir)
@@ -291,36 +294,6 @@ def png_to_icns(png_path, icns_path):
             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
@@ -329,21 +302,20 @@ if __name__ == '__main__':  # pragma: nocover
 
     from unicodedata import normalize
 
-    def uni(s):
+    def ustr(s):
         """Coerce `s` to normalised Unicode."""
-        ustr = s.decode('utf-8')
-        return normalize('NFD', ustr)
+        return normalize('NFD', s.decode('utf-8'))
 
     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,
+                   help="Notification title.", type=ustr,
                    default='')
-    p.add_argument('-s', '--sound', type=uni,
+    p.add_argument('-s', '--sound', type=ustr,
                    help="Optional notification sound.", default='')
-    p.add_argument('text', type=uni,
+    p.add_argument('text', type=ustr,
                    help="Notification body text.", default='', nargs='?')
     o = p.parse_args()
 
@@ -357,21 +329,20 @@ if __name__ == '__main__':  # pragma: nocover
     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'))
+            os.path.splitext(os.path.basename(o.png))[0] + '.icns')
 
-        print('Converting {0!r} to {1!r} ...'.format(o.png, 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))
+        if os.path.exists(icns):
+            raise ValueError('destination file already exists: ' + icns)
 
         png_to_icns(o.png, icns)
         sys.exit(0)
 
     # Post notification
     if o.title == o.text == '':
-        print('ERROR: Empty notification.', file=sys.stderr)
+        print('ERROR: empty notification.', file=sys.stderr)
         sys.exit(1)
     else:
         notify(o.title, o.text, o.sound)

+ 565 - 0
alfred/workflow/update.py

@@ -0,0 +1,565 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Fabio Niephaus <[email protected]>,
+#       Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-08-16
+#
+
+"""Self-updating from GitHub.
+
+.. versionadded:: 1.9
+
+.. note::
+
+   This module is not intended to be used directly. Automatic updates
+   are controlled by the ``update_settings`` :class:`dict` passed to
+   :class:`~workflow.workflow.Workflow` objects.
+
+"""
+
+from __future__ import print_function, unicode_literals
+
+from collections import defaultdict
+from functools import total_ordering
+import json
+import os
+import tempfile
+import re
+import subprocess
+
+import workflow
+import web
+
+# __all__ = []
+
+
+RELEASES_BASE = 'https://api.github.com/repos/{}/releases'
+match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search
+
+_wf = None
+
+
+def wf():
+    """Lazy `Workflow` object."""
+    global _wf
+    if _wf is None:
+        _wf = workflow.Workflow()
+    return _wf
+
+
+@total_ordering
+class Download(object):
+    """A workflow file that is available for download.
+
+    .. versionadded: 1.37
+
+    Attributes:
+        url (str): URL of workflow file.
+        filename (str): Filename of workflow file.
+        version (Version): Semantic version of workflow.
+        prerelease (bool): Whether version is a pre-release.
+        alfred_version (Version): Minimum compatible version
+            of Alfred.
+
+    """
+
+    @classmethod
+    def from_dict(cls, d):
+        """Create a `Download` from a `dict`."""
+        return cls(url=d['url'], filename=d['filename'],
+                   version=Version(d['version']),
+                   prerelease=d['prerelease'])
+
+    @classmethod
+    def from_releases(cls, js):
+        """Extract downloads from GitHub releases.
+
+        Searches releases with semantic tags for assets with
+        file extension .alfredworkflow or .alfredXworkflow where
+        X is a number.
+
+        Files are returned sorted by latest version first. Any
+        releases containing multiple files with the same (workflow)
+        extension are rejected as ambiguous.
+
+        Args:
+            js (str): JSON response from GitHub's releases endpoint.
+
+        Returns:
+            list: Sequence of `Download`.
+        """
+        releases = json.loads(js)
+        downloads = []
+        for release in releases:
+            tag = release['tag_name']
+            dupes = defaultdict(int)
+            try:
+                version = Version(tag)
+            except ValueError as err:
+                wf().logger.debug('ignored release: bad version "%s": %s',
+                                  tag, err)
+                continue
+
+            dls = []
+            for asset in release.get('assets', []):
+                url = asset.get('browser_download_url')
+                filename = os.path.basename(url)
+                m = match_workflow(filename)
+                if not m:
+                    wf().logger.debug('unwanted file: %s', filename)
+                    continue
+
+                ext = m.group(0)
+                dupes[ext] = dupes[ext] + 1
+                dls.append(Download(url, filename, version,
+                                    release['prerelease']))
+
+            valid = True
+            for ext, n in dupes.items():
+                if n > 1:
+                    wf().logger.debug('ignored release "%s": multiple assets '
+                                      'with extension "%s"', tag, ext)
+                    valid = False
+                    break
+
+            if valid:
+                downloads.extend(dls)
+
+        downloads.sort(reverse=True)
+        return downloads
+
+    def __init__(self, url, filename, version, prerelease=False):
+        """Create a new Download.
+
+        Args:
+            url (str): URL of workflow file.
+            filename (str): Filename of workflow file.
+            version (Version): Version of workflow.
+            prerelease (bool, optional): Whether version is
+                pre-release. Defaults to False.
+
+        """
+        if isinstance(version, basestring):
+            version = Version(version)
+
+        self.url = url
+        self.filename = filename
+        self.version = version
+        self.prerelease = prerelease
+
+    @property
+    def alfred_version(self):
+        """Minimum Alfred version based on filename extension."""
+        m = match_workflow(self.filename)
+        if not m or not m.group(1):
+            return Version('0')
+        return Version(m.group(1))
+
+    @property
+    def dict(self):
+        """Convert `Download` to `dict`."""
+        return dict(url=self.url, filename=self.filename,
+                    version=str(self.version), prerelease=self.prerelease)
+
+    def __str__(self):
+        """Format `Download` for printing."""
+        u = ('Download(url={dl.url!r}, '
+             'filename={dl.filename!r}, '
+             'version={dl.version!r}, '
+             'prerelease={dl.prerelease!r})'.format(dl=self))
+
+        return u.encode('utf-8')
+
+    def __repr__(self):
+        """Code-like representation of `Download`."""
+        return str(self)
+
+    def __eq__(self, other):
+        """Compare Downloads based on version numbers."""
+        if self.url != other.url \
+                or self.filename != other.filename \
+                or self.version != other.version \
+                or self.prerelease != other.prerelease:
+            return False
+        return True
+
+    def __ne__(self, other):
+        """Compare Downloads based on version numbers."""
+        return not self.__eq__(other)
+
+    def __lt__(self, other):
+        """Compare Downloads based on version numbers."""
+        if self.version != other.version:
+            return self.version < other.version
+        return self.alfred_version < other.alfred_version
+
+
+class Version(object):
+    """Mostly semantic versioning.
+
+    The main difference to proper :ref:`semantic versioning <semver>`
+    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][0-9\.]*)(.+)?').match
+
+    def __init__(self, vstr):
+        """Create new `Version` object.
+
+        Args:
+            vstr (basestring): Semantic version string.
+        """
+        if not vstr:
+            raise ValueError('invalid version number: {!r}'.format(vstr))
+
+        self.vstr = vstr
+        self.major = 0
+        self.minor = 0
+        self.patch = 0
+        self.suffix = ''
+        self.build = ''
+        self._parse(vstr)
+
+    def _parse(self, vstr):
+        if vstr.startswith('v'):
+            m = self.match_version(vstr[1:])
+        else:
+            m = self.match_version(vstr)
+        if not m:
+            raise ValueError('invalid version number: ' + vstr)
+
+        version, suffix = m.groups()
+        parts = self._parse_dotted_string(version)
+        self.major = parts.pop(0)
+        if len(parts):
+            self.minor = parts.pop(0)
+        if len(parts):
+            self.patch = parts.pop(0)
+        if not len(parts) == 0:
+            raise ValueError('version number too long: ' + vstr)
+
+        if suffix:
+            # Build info
+            idx = suffix.find('+')
+            if idx > -1:
+                self.build = suffix[idx+1:]
+                suffix = suffix[:idx]
+            if suffix:
+                if not suffix.startswith('-'):
+                    raise ValueError(
+                        'suffix must start with - : ' + suffix)
+                self.suffix = suffix[1:]
+
+    def _parse_dotted_string(self, s):
+        """Parse string ``s`` into list of ints and strings."""
+        parsed = []
+        parts = s.split('.')
+        for p in parts:
+            if p.isdigit():
+                p = int(p)
+            parsed.append(p)
+        return parsed
+
+    @property
+    def tuple(self):
+        """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]
+        o = other.tuple[:3]
+        if t < o:
+            return True
+        if t == o:  # We need to compare suffixes
+            if self.suffix and not other.suffix:
+                return True
+            if other.suffix and not self.suffix:
+                return False
+            return self._parse_dotted_string(self.suffix) \
+                < self._parse_dotted_string(other.suffix)
+        # t > o
+        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}-{1}'.format(vstr, self.suffix)
+        if self.build:
+            vstr = '{0}+{1}'.format(vstr, self.build)
+        return vstr
+
+    def __repr__(self):
+        """Return 'code' representation of `Version`."""
+        return "Version('{0}')".format(str(self))
+
+
+def retrieve_download(dl):
+    """Saves a download to a temporary file and returns path.
+
+    .. versionadded: 1.37
+
+    Args:
+        url (unicode): URL to .alfredworkflow file in GitHub repo
+
+    Returns:
+        unicode: path to downloaded file
+
+    """
+    if not match_workflow(dl.filename):
+        raise ValueError('attachment not a workflow: ' + dl.filename)
+
+    path = os.path.join(tempfile.gettempdir(), dl.filename)
+    wf().logger.debug('downloading update from '
+                      '%r to %r ...', dl.url, path)
+
+    r = web.get(dl.url)
+    r.raise_for_status()
+
+    r.save_to_path(path)
+
+    return path
+
+
+def build_api_url(repo):
+    """Generate releases URL from GitHub repo.
+
+    Args:
+        repo (unicode): Repo name in form ``username/repo``
+
+    Returns:
+        unicode: URL to the API endpoint for the repo's releases
+
+    """
+    if len(repo.split('/')) != 2:
+        raise ValueError('invalid GitHub repo: {!r}'.format(repo))
+
+    return RELEASES_BASE.format(repo)
+
+
+def get_downloads(repo):
+    """Load available ``Download``s for GitHub repo.
+
+    .. versionadded: 1.37
+
+    Args:
+        repo (unicode): GitHub repo to load releases for.
+
+    Returns:
+        list: Sequence of `Download` contained in GitHub releases.
+    """
+    url = build_api_url(repo)
+
+    def _fetch():
+        wf().logger.info('retrieving releases for %r ...', repo)
+        r = web.get(url)
+        r.raise_for_status()
+        return r.content
+
+    key = 'github-releases-' + repo.replace('/', '-')
+    js = wf().cached_data(key, _fetch, max_age=60)
+
+    return Download.from_releases(js)
+
+
+def latest_download(dls, alfred_version=None, prereleases=False):
+    """Return newest `Download`."""
+    alfred_version = alfred_version or os.getenv('alfred_version')
+    version = None
+    if alfred_version:
+        version = Version(alfred_version)
+
+    dls.sort(reverse=True)
+    for dl in dls:
+        if dl.prerelease and not prereleases:
+            wf().logger.debug('ignored prerelease: %s', dl.version)
+            continue
+        if version and dl.alfred_version > version:
+            wf().logger.debug('ignored incompatible (%s > %s): %s',
+                              dl.alfred_version, version, dl.filename)
+            continue
+
+        wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename)
+        return dl
+
+    return None
+
+
+def check_update(repo, current_version, prereleases=False,
+                 alfred_version=None):
+    """Check whether a newer release is available on GitHub.
+
+    Args:
+        repo (unicode): ``username/repo`` for workflow's GitHub repo
+        current_version (unicode): the currently installed version of the
+            workflow. :ref:`Semantic versioning <semver>` is required.
+        prereleases (bool): Whether to include pre-releases.
+        alfred_version (unicode): version of currently-running Alfred.
+            if empty, defaults to ``$alfred_version`` environment variable.
+
+    Returns:
+        bool: ``True`` if an update is available, else ``False``
+
+    If an update is available, its version number and download URL will
+    be cached.
+
+    """
+    key = '__workflow_latest_version'
+    # data stored when no update is available
+    no_update = {
+        'available': False,
+        'download': None,
+        'version': None,
+    }
+    current = Version(current_version)
+
+    dls = get_downloads(repo)
+    if not len(dls):
+        wf().logger.warning('no valid downloads for %s', repo)
+        wf().cache_data(key, no_update)
+        return False
+
+    wf().logger.info('%d download(s) for %s', len(dls), repo)
+
+    dl = latest_download(dls, alfred_version, prereleases)
+
+    if not dl:
+        wf().logger.warning('no compatible downloads for %s', repo)
+        wf().cache_data(key, no_update)
+        return False
+
+    wf().logger.debug('latest=%r, installed=%r', dl.version, current)
+
+    if dl.version > current:
+        wf().cache_data(key, {
+            'version': str(dl.version),
+            'download': dl.dict,
+            'available': True,
+        })
+        return True
+
+    wf().cache_data(key, no_update)
+    return False
+
+
+def install_update():
+    """If a newer release is available, download and install it.
+
+    :returns: ``True`` if an update is installed, else ``False``
+
+    """
+    key = '__workflow_latest_version'
+    # data stored when no update is available
+    no_update = {
+        'available': False,
+        'download': None,
+        'version': None,
+    }
+    status = wf().cached_data(key, max_age=0)
+
+    if not status or not status.get('available'):
+        wf().logger.info('no update available')
+        return False
+
+    dl = status.get('download')
+    if not dl:
+        wf().logger.info('no download information')
+        return False
+
+    path = retrieve_download(Download.from_dict(dl))
+
+    wf().logger.info('installing updated workflow ...')
+    subprocess.call(['open', path])  # nosec
+
+    wf().cache_data(key, no_update)
+    return True
+
+
+if __name__ == '__main__':  # pragma: nocover
+    import sys
+
+    prereleases = False
+
+    def show_help(status=0):
+        """Print help message."""
+        print('usage: update.py (check|install) '
+              '[--prereleases] <repo> <version>')
+        sys.exit(status)
+
+    argv = sys.argv[:]
+    if '-h' in argv or '--help' in argv:
+        show_help()
+
+    if '--prereleases' in argv:
+        argv.remove('--prereleases')
+        prereleases = True
+
+    if len(argv) != 4:
+        show_help(1)
+
+    action = argv[1]
+    repo = argv[2]
+    version = argv[3]
+
+    try:
+
+        if action == 'check':
+            check_update(repo, version, prereleases)
+        elif action == 'install':
+            install_update()
+        else:
+            show_help(1)
+
+    except Exception as err:  # ensure traceback is in log file
+        wf().logger.exception(err)
+        raise err

+ 644 - 0
alfred/workflow/util.py

@@ -0,0 +1,644 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2017 Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2017-12-17
+#
+
+"""A selection of helper functions useful for building workflows."""
+
+from __future__ import print_function, absolute_import
+
+import atexit
+from collections import namedtuple
+from contextlib import contextmanager
+import errno
+import fcntl
+import functools
+import json
+import os
+import signal
+import subprocess
+import sys
+from threading import Event
+import time
+
+# JXA scripts to call Alfred's API via the Scripting Bridge
+# {app} is automatically replaced with "Alfred 3" or
+# "com.runningwithcrayons.Alfred" depending on version.
+#
+# Open Alfred in search (regular) mode
+JXA_SEARCH = 'Application({app}).search({arg});'
+# Open Alfred's File Actions on an argument
+JXA_ACTION = 'Application({app}).action({arg});'
+# Open Alfred's navigation mode at path
+JXA_BROWSE = 'Application({app}).browse({arg});'
+# Set the specified theme
+JXA_SET_THEME = 'Application({app}).setTheme({arg});'
+# Call an External Trigger
+JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});'
+# Save a variable to the workflow configuration sheet/info.plist
+JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});'
+# Delete a variable from the workflow configuration sheet/info.plist
+JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});'
+# Tell Alfred to reload a workflow from disk
+JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});'
+
+
+class AcquisitionError(Exception):
+    """Raised if a lock cannot be acquired."""
+
+
+AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
+"""Information about an installed application.
+
+Returned by :func:`appinfo`. All attributes are Unicode.
+
+.. py:attribute:: name
+
+    Name of the application, e.g. ``u'Safari'``.
+
+.. py:attribute:: path
+
+    Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
+
+.. py:attribute:: bundleid
+
+    Application's bundle ID, e.g. ``u'com.apple.Safari'``.
+
+"""
+
+
+def jxa_app_name():
+    """Return name of application to call currently running Alfred.
+
+    .. versionadded: 1.37
+
+    Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending
+    on which version of Alfred is running.
+
+    This name is suitable for use with ``Application(name)`` in JXA.
+
+    Returns:
+        unicode: Application name or ID.
+
+    """
+    if os.getenv('alfred_version', '').startswith('3'):
+        # Alfred 3
+        return u'Alfred 3'
+    # Alfred 4+
+    return u'com.runningwithcrayons.Alfred'
+
+
+def unicodify(s, encoding='utf-8', norm=None):
+    """Ensure string is Unicode.
+
+    .. versionadded:: 1.31
+
+    Decode encoded strings using ``encoding`` and normalise Unicode
+    to form ``norm`` if specified.
+
+    Args:
+        s (str): String to decode. May also be Unicode.
+        encoding (str, optional): Encoding to use on bytestrings.
+        norm (None, optional): Normalisation form to apply to Unicode string.
+
+    Returns:
+        unicode: Decoded, optionally normalised, Unicode string.
+
+    """
+    if not isinstance(s, unicode):
+        s = unicode(s, encoding)
+
+    if norm:
+        from unicodedata import normalize
+        s = normalize(norm, s)
+
+    return s
+
+
+def utf8ify(s):
+    """Ensure string is a bytestring.
+
+    .. versionadded:: 1.31
+
+    Returns `str` objects unchanced, encodes `unicode` objects to
+    UTF-8, and calls :func:`str` on anything else.
+
+    Args:
+        s (object): A Python object
+
+    Returns:
+        str: UTF-8 string or string representation of s.
+
+    """
+    if isinstance(s, str):
+        return s
+
+    if isinstance(s, unicode):
+        return s.encode('utf-8')
+
+    return str(s)
+
+
+def applescriptify(s):
+    """Escape string for insertion into an AppleScript string.
+
+    .. versionadded:: 1.31
+
+    Replaces ``"`` with `"& quote &"`. Use this function if you want
+    to insert a string into an AppleScript script:
+
+        >>> applescriptify('g "python" test')
+        'g " & quote & "python" & quote & "test'
+
+    Args:
+        s (unicode): Unicode string to escape.
+
+    Returns:
+        unicode: Escaped string.
+
+    """
+    return s.replace(u'"', u'" & quote & "')
+
+
+def run_command(cmd, **kwargs):
+    """Run a command and return the output.
+
+    .. versionadded:: 1.31
+
+    A thin wrapper around :func:`subprocess.check_output` that ensures
+    all arguments are encoded to UTF-8 first.
+
+    Args:
+        cmd (list): Command arguments to pass to :func:`~subprocess.check_output`.
+        **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`.
+
+    Returns:
+        str: Output returned by :func:`~subprocess.check_output`.
+
+    """
+    cmd = [utf8ify(s) for s in cmd]
+    return subprocess.check_output(cmd, **kwargs)
+
+
+def run_applescript(script, *args, **kwargs):
+    """Execute an AppleScript script and return its output.
+
+    .. versionadded:: 1.31
+
+    Run AppleScript either by filepath or code. If ``script`` is a valid
+    filepath, that script will be run, otherwise ``script`` is treated
+    as code.
+
+    Args:
+        script (str, optional): Filepath of script or code to run.
+        *args: Optional command-line arguments to pass to the script.
+        **kwargs: Pass ``lang`` to run a language other than AppleScript.
+            Any other keyword arguments are passed to :func:`run_command`.
+
+    Returns:
+        str: Output of run command.
+
+    """
+    lang = 'AppleScript'
+    if 'lang' in kwargs:
+        lang = kwargs['lang']
+        del kwargs['lang']
+
+    cmd = ['/usr/bin/osascript', '-l', lang]
+
+    if os.path.exists(script):
+        cmd += [script]
+    else:
+        cmd += ['-e', script]
+
+    cmd.extend(args)
+
+    return run_command(cmd, **kwargs)
+
+
+def run_jxa(script, *args):
+    """Execute a JXA script and return its output.
+
+    .. versionadded:: 1.31
+
+    Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
+
+    Args:
+        script (str): Filepath of script or code to run.
+        *args: Optional command-line arguments to pass to script.
+
+    Returns:
+        str: Output of script.
+
+    """
+    return run_applescript(script, *args, lang='JavaScript')
+
+
+def run_trigger(name, bundleid=None, arg=None):
+    """Call an Alfred External Trigger.
+
+    .. versionadded:: 1.31
+
+    If ``bundleid`` is not specified, the bundle ID of the calling
+    workflow is used.
+
+    Args:
+        name (str): Name of External Trigger to call.
+        bundleid (str, optional): Bundle ID of workflow trigger belongs to.
+        arg (str, optional): Argument to pass to trigger.
+
+    """
+    bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
+    appname = jxa_app_name()
+    opts = {'inWorkflow': bundleid}
+    if arg:
+        opts['withArgument'] = arg
+
+    script = JXA_TRIGGER.format(app=json.dumps(appname),
+                                arg=json.dumps(name),
+                                opts=json.dumps(opts, sort_keys=True))
+
+    run_applescript(script, lang='JavaScript')
+
+
+def set_theme(theme_name):
+    """Change Alfred's theme.
+
+    .. versionadded:: 1.39.0
+
+    Args:
+        theme_name (unicode): Name of theme Alfred should use.
+
+    """
+    appname = jxa_app_name()
+    script = JXA_SET_THEME.format(app=json.dumps(appname),
+                                  arg=json.dumps(theme_name))
+    run_applescript(script, lang='JavaScript')
+
+
+def set_config(name, value, bundleid=None, exportable=False):
+    """Set a workflow variable in ``info.plist``.
+
+    .. versionadded:: 1.33
+
+    If ``bundleid`` is not specified, the bundle ID of the calling
+    workflow is used.
+
+    Args:
+        name (str): Name of variable to set.
+        value (str): Value to set variable to.
+        bundleid (str, optional): Bundle ID of workflow variable belongs to.
+        exportable (bool, optional): Whether variable should be marked
+            as exportable (Don't Export checkbox).
+
+    """
+    bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
+    appname = jxa_app_name()
+    opts = {
+        'toValue': value,
+        'inWorkflow': bundleid,
+        'exportable': exportable,
+    }
+
+    script = JXA_SET_CONFIG.format(app=json.dumps(appname),
+                                   arg=json.dumps(name),
+                                   opts=json.dumps(opts, sort_keys=True))
+
+    run_applescript(script, lang='JavaScript')
+
+
+def unset_config(name, bundleid=None):
+    """Delete a workflow variable from ``info.plist``.
+
+    .. versionadded:: 1.33
+
+    If ``bundleid`` is not specified, the bundle ID of the calling
+    workflow is used.
+
+    Args:
+        name (str): Name of variable to delete.
+        bundleid (str, optional): Bundle ID of workflow variable belongs to.
+
+    """
+    bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
+    appname = jxa_app_name()
+    opts = {'inWorkflow': bundleid}
+
+    script = JXA_UNSET_CONFIG.format(app=json.dumps(appname),
+                                     arg=json.dumps(name),
+                                     opts=json.dumps(opts, sort_keys=True))
+
+    run_applescript(script, lang='JavaScript')
+
+
+def search_in_alfred(query=None):
+    """Open Alfred with given search query.
+
+    .. versionadded:: 1.39.0
+
+    Omit ``query`` to simply open Alfred's main window.
+
+    Args:
+        query (unicode, optional): Search query.
+
+    """
+    query = query or u''
+    appname = jxa_app_name()
+    script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query))
+    run_applescript(script, lang='JavaScript')
+
+
+def browse_in_alfred(path):
+    """Open Alfred's filesystem navigation mode at ``path``.
+
+    .. versionadded:: 1.39.0
+
+    Args:
+        path (unicode): File or directory path.
+
+    """
+    appname = jxa_app_name()
+    script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path))
+    run_applescript(script, lang='JavaScript')
+
+
+def action_in_alfred(paths):
+    """Action the give filepaths in Alfred.
+
+    .. versionadded:: 1.39.0
+
+    Args:
+        paths (list): Unicode paths to files/directories to action.
+
+    """
+    appname = jxa_app_name()
+    script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths))
+    run_applescript(script, lang='JavaScript')
+
+
+def reload_workflow(bundleid=None):
+    """Tell Alfred to reload a workflow from disk.
+
+    .. versionadded:: 1.39.0
+
+    If ``bundleid`` is not specified, the bundle ID of the calling
+    workflow is used.
+
+    Args:
+        bundleid (unicode, optional): Bundle ID of workflow to reload.
+
+    """
+    bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
+    appname = jxa_app_name()
+    script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname),
+                                        arg=json.dumps(bundleid))
+
+    run_applescript(script, lang='JavaScript')
+
+
+def appinfo(name):
+    """Get information about an installed application.
+
+    .. versionadded:: 1.31
+
+    Args:
+        name (str): Name of application to look up.
+
+    Returns:
+        AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
+
+    """
+    cmd = [
+        'mdfind',
+        '-onlyin', '/Applications',
+        '-onlyin', '/System/Applications',
+        '-onlyin', os.path.expanduser('~/Applications'),
+        '(kMDItemContentTypeTree == com.apple.application &&'
+        '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
+        .format(name)
+    ]
+
+    output = run_command(cmd).strip()
+    if not output:
+        return None
+
+    path = output.split('\n')[0]
+
+    cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
+    bid = run_command(cmd).strip()
+    if not bid:  # pragma: no cover
+        return None
+
+    return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
+
+
+@contextmanager
+def atomic_writer(fpath, mode):
+    """Atomic file writer.
+
+    .. versionadded:: 1.12
+
+    Context manager that ensures the file is only written if the write
+    succeeds. The data is first written to a temporary file.
+
+    :param fpath: path of file to write to.
+    :type fpath: ``unicode``
+    :param mode: sames as for :func:`open`
+    :type mode: string
+
+    """
+    suffix = '.{}.tmp'.format(os.getpid())
+    temppath = fpath + suffix
+    with open(temppath, mode) as fp:
+        try:
+            yield fp
+            os.rename(temppath, fpath)
+        finally:
+            try:
+                os.remove(temppath)
+            except (OSError, IOError):
+                pass
+
+
+class LockFile(object):
+    """Context manager to protect filepaths with lockfiles.
+
+    .. versionadded:: 1.13
+
+    Creates a lockfile alongside ``protected_path``. Other ``LockFile``
+    instances will refuse to lock the same path.
+
+    >>> path = '/path/to/file'
+    >>> with LockFile(path):
+    >>>     with open(path, 'wb') as fp:
+    >>>         fp.write(data)
+
+    Args:
+        protected_path (unicode): File to protect with a lockfile
+        timeout (float, optional): Raises an :class:`AcquisitionError`
+            if lock cannot be acquired within this number of seconds.
+            If ``timeout`` is 0 (the default), wait forever.
+        delay (float, optional): How often to check (in seconds) if
+            lock has been released.
+
+    Attributes:
+        delay (float): How often to check (in seconds) whether the lock
+            can be acquired.
+        lockfile (unicode): Path of the lockfile.
+        timeout (float): How long to wait to acquire the lock.
+
+    """
+
+    def __init__(self, protected_path, timeout=0.0, delay=0.05):
+        """Create new :class:`LockFile` object."""
+        self.lockfile = protected_path + '.lock'
+        self._lockfile = None
+        self.timeout = timeout
+        self.delay = delay
+        self._lock = Event()
+        atexit.register(self.release)
+
+    @property
+    def locked(self):
+        """``True`` if file is locked by this instance."""
+        return self._lock.is_set()
+
+    def acquire(self, blocking=True):
+        """Acquire the lock if possible.
+
+        If the lock is in use and ``blocking`` is ``False``, return
+        ``False``.
+
+        Otherwise, check every :attr:`delay` seconds until it acquires
+        lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
+
+        """
+        if self.locked and not blocking:
+            return False
+
+        start = time.time()
+        while True:
+            # Raise error if we've been waiting too long to acquire the lock
+            if self.timeout and (time.time() - start) >= self.timeout:
+                raise AcquisitionError('lock acquisition timed out')
+
+            # If already locked, wait then try again
+            if self.locked:
+                time.sleep(self.delay)
+                continue
+
+            # Create in append mode so we don't lose any contents
+            if self._lockfile is None:
+                self._lockfile = open(self.lockfile, 'a')
+
+            # Try to acquire the lock
+            try:
+                fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
+                self._lock.set()
+                break
+            except IOError as err:  # pragma: no cover
+                if err.errno not in (errno.EACCES, errno.EAGAIN):
+                    raise
+
+                # Don't try again
+                if not blocking:  # pragma: no cover
+                    return False
+
+                # Wait, then try again
+                time.sleep(self.delay)
+
+        return True
+
+    def release(self):
+        """Release the lock by deleting `self.lockfile`."""
+        if not self._lock.is_set():
+            return False
+
+        try:
+            fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
+        except IOError:  # pragma: no cover
+            pass
+        finally:
+            self._lock.clear()
+            self._lockfile = None
+            try:
+                os.unlink(self.lockfile)
+            except (IOError, OSError):  # pragma: no cover
+                pass
+
+            return True
+
+    def __enter__(self):
+        """Acquire lock."""
+        self.acquire()
+        return self
+
+    def __exit__(self, typ, value, traceback):
+        """Release lock."""
+        self.release()
+
+    def __del__(self):
+        """Clear up `self.lockfile`."""
+        self.release()  # pragma: no cover
+
+
+class uninterruptible(object):
+    """Decorator that postpones SIGTERM until wrapped function returns.
+
+    .. versionadded:: 1.12
+
+    .. important:: This decorator is NOT thread-safe.
+
+    As of version 2.7, Alfred allows Script Filters to be killed. If
+    your workflow is killed in the middle of critical code (e.g.
+    writing data to disk), this may corrupt your workflow's data.
+
+    Use this decorator to wrap critical functions that *must* complete.
+    If the script is killed while a wrapped function is executing,
+    the SIGTERM will be caught and handled after your function has
+    finished executing.
+
+    Alfred-Workflow uses this internally to ensure its settings, data
+    and cache writes complete.
+
+    """
+
+    def __init__(self, func, class_name=''):
+        """Decorate `func`."""
+        self.func = func
+        functools.update_wrapper(self, func)
+        self._caught_signal = None
+
+    def signal_handler(self, signum, frame):
+        """Called when process receives SIGTERM."""
+        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)
+        signal.signal(signal.SIGTERM, self.signal_handler)
+
+        self.func(*args, **kwargs)
+
+        # Restore old signal handler
+        signal.signal(signal.SIGTERM, self.old_signal_handler)
+
+        # Handle any signal caught during execution
+        if self._caught_signal is not None:
+            signum, frame = self._caught_signal
+            if callable(self.old_signal_handler):
+                self.old_signal_handler(signum, frame)
+            elif self.old_signal_handler == signal.SIG_DFL:
+                sys.exit(0)
+
+    def __get__(self, obj=None, klass=None):
+        """Decorator API."""
+        return self.__class__(self.func.__get__(obj, klass),
+                              klass.__name__)

+ 1 - 0
alfred/workflow/version

@@ -0,0 +1 @@
+1.39.0

+ 97 - 48
scripts/alfred/workflow/web.py → alfred/workflow/web.py

@@ -9,6 +9,8 @@
 
 """Lightweight HTTP library with a requests-like interface."""
 
+from __future__ import absolute_import, print_function
+
 import codecs
 import json
 import mimetypes
@@ -23,8 +25,10 @@ import urllib2
 import urlparse
 import zlib
 
+__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
 
-USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)'
+USER_AGENT = (u'Alfred-Workflow/' + __version__ +
+              ' (+http://www.deanishe.net/alfred-workflow)')
 
 # Valid characters for multipart form data boundaries
 BOUNDARY_CHARS = string.digits + string.ascii_letters
@@ -77,8 +81,10 @@ RESPONSES = {
 def str_dict(dic):
     """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
 
-    :param dic: :class:`dict` of Unicode strings
-    :returns: :class:`dict`
+    :param dic: Mapping of Unicode strings
+    :type dic: dict
+    :returns: Dictionary containing only UTF-8 strings
+    :rtype: dict
 
     """
     if isinstance(dic, CaseInsensitiveDictionary):
@@ -98,6 +104,7 @@ class NoRedirectHandler(urllib2.HTTPRedirectHandler):
     """Prevent redirections."""
 
     def redirect_request(self, *args):
+        """Ignore redirect."""
         return None
 
 
@@ -134,6 +141,7 @@ class CaseInsensitiveDictionary(dict):
         return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
 
     def get(self, key, default=None):
+        """Return value for case-insensitive key or default."""
         try:
             v = dict.__getitem__(self, key.lower())
         except KeyError:
@@ -142,31 +150,50 @@ class CaseInsensitiveDictionary(dict):
             return v['val']
 
     def update(self, other):
+        """Update values from other ``dict``."""
         for k, v in other.items():
             self[k] = v
 
     def items(self):
+        """Return ``(key, value)`` pairs."""
         return [(v['key'], v['val']) for v in dict.itervalues(self)]
 
     def keys(self):
+        """Return original keys."""
         return [v['key'] for v in dict.itervalues(self)]
 
     def values(self):
+        """Return all values."""
         return [v['val'] for v in dict.itervalues(self)]
 
     def iteritems(self):
+        """Iterate over ``(key, value)`` pairs."""
         for v in dict.itervalues(self):
             yield v['key'], v['val']
 
     def iterkeys(self):
+        """Iterate over original keys."""
         for v in dict.itervalues(self):
             yield v['key']
 
     def itervalues(self):
+        """Interate over values."""
         for v in dict.itervalues(self):
             yield v['val']
 
 
+class Request(urllib2.Request):
+    """Subclass of :class:`urllib2.Request` that supports custom methods."""
+
+    def __init__(self, *args, **kwargs):
+        """Create a new :class:`Request`."""
+        self._method = kwargs.pop('method', None)
+        urllib2.Request.__init__(self, *args, **kwargs)
+
+    def get_method(self):
+        return self._method.upper()
+
+
 class Response(object):
     """
     Returned by :func:`request` / :func:`get` / :func:`post` functions.
@@ -189,9 +216,9 @@ class Response(object):
     def __init__(self, request, stream=False):
         """Call `request` with :mod:`urllib2` and process results.
 
-        :param request: :class:`urllib2.Request` instance
+        :param request: :class:`Request` instance
         :param stream: Whether to stream response or retrieve it all at once
-        :type stream: ``bool``
+        :type stream: bool
 
         """
         self.request = request
@@ -238,8 +265,8 @@ class Response(object):
             # Transfer-Encoding appears to not be used in the wild
             # (contrary to the HTTP standard), but no harm in testing
             # for it
-            if ('gzip' in headers.get('content-encoding', '') or
-                    'gzip' in headers.get('transfer-encoding', '')):
+            if 'gzip' in headers.get('content-encoding', '') or \
+                    'gzip' in headers.get('transfer-encoding', ''):
                 self._gzipped = True
 
     @property
@@ -248,6 +275,7 @@ class Response(object):
 
         Returns:
             bool: `True` if response is streamed.
+
         """
         return self._stream
 
@@ -263,7 +291,7 @@ class Response(object):
         """Decode response contents as JSON.
 
         :returns: object decoded from JSON
-        :rtype: :class:`list` / :class:`dict`
+        :rtype: list, dict or unicode
 
         """
         return json.loads(self.content, self.encoding or 'utf-8')
@@ -272,7 +300,8 @@ class Response(object):
     def encoding(self):
         """Text encoding of document or ``None``.
 
-        :returns: :class:`str` or ``None``
+        :returns: Text encoding if found.
+        :rtype: str or ``None``
 
         """
         if not self._encoding:
@@ -285,7 +314,7 @@ class Response(object):
         """Raw content of response (i.e. bytes).
 
         :returns: Body of HTTP response
-        :rtype: :class:`str`
+        :rtype: str
 
         """
         if not self._content:
@@ -310,7 +339,7 @@ class Response(object):
         itself, the encoded response body will be returned instead.
 
         :returns: Body of HTTP response
-        :rtype: :class:`unicode` or :class:`str`
+        :rtype: unicode or str
 
         """
         if self.encoding:
@@ -324,9 +353,9 @@ class Response(object):
         .. versionadded:: 1.6
 
         :param chunk_size: Number of bytes to read into memory
-        :type chunk_size: ``int``
+        :type chunk_size: int
         :param decode_unicode: Decode to Unicode using detected encoding
-        :type decode_unicode: ``Boolean``
+        :type decode_unicode: bool
         :returns: iterator
 
         """
@@ -340,20 +369,18 @@ class Response(object):
                 "`content` has already been read from this Response.")
 
         def decode_stream(iterator, r):
-
-            decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace')
+            dec = codecs.getincrementaldecoder(r.encoding)(errors='replace')
 
             for chunk in iterator:
-                data = decoder.decode(chunk)
+                data = dec.decode(chunk)
                 if data:
                     yield data
 
-            data = decoder.decode(b'', final=True)
+            data = dec.decode(b'', final=True)
             if data:  # pragma: no cover
                 yield data
 
         def generate():
-
             if self._gzipped:
                 decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
 
@@ -406,7 +433,7 @@ class Response(object):
         """Get encoding from HTTP headers or content.
 
         :returns: encoding or `None`
-        :rtype: ``unicode`` or ``None``
+        :rtype: unicode or ``None``
 
         """
         headers = self.raw.info()
@@ -424,15 +451,15 @@ class Response(object):
         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("""<meta.+charset=["']{0,1}(.+?)["'].*>""",
+                m = re.search(r"""<meta.+charset=["']{0,1}(.+?)["'].*>""",
                               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("""<?xml.+encoding=["'](.+?)["'][^>]*\?>""",
+            elif ((self.mimetype.startswith('application/')
+                   or self.mimetype.startswith('text/'))
+                  and 'xml' in self.mimetype):
+                m = re.search(r"""<?xml.+encoding=["'](.+?)["'][^>]*\?>""",
                               self.content)
                 if m:
                     encoding = m.group(1)
@@ -458,29 +485,30 @@ def request(method, url, params=None, data=None, headers=None, cookies=None,
     """Initiate an HTTP(S) request. Returns :class:`Response` object.
 
     :param method: 'GET' or 'POST'
-    :type method: ``unicode``
+    :type method: unicode
     :param url: URL to open
-    :type url: ``unicode``
+    :type url: unicode
     :param params: mapping of URL parameters
-    :type params: :class:`dict`
+    :type params: dict
     :param data: mapping of form data ``{'field_name': 'value'}`` or
         :class:`str`
-    :type data: :class:`dict` or :class:`str`
+    :type data: dict or str
     :param headers: HTTP headers
-    :type headers: :class:`dict`
+    :type headers: dict
     :param cookies: cookies to send to server
-    :type cookies: :class:`dict`
+    :type cookies: dict
     :param files: files to upload (see below).
-    :type files: :class:`dict`
+    :type files: dict
     :param auth: username, password
-    :type auth: ``tuple``
+    :type auth: tuple
     :param timeout: connection timeout limit in seconds
-    :type timeout: ``int``
+    :type timeout: int
     :param allow_redirects: follow redirections
-    :type allow_redirects: ``Boolean``
+    :type allow_redirects: bool
     :param stream: Stream content instead of fetching it all at once.
-    :type stream: ``bool``
-    :returns: :class:`Response` object
+    :type stream: bool
+    :returns: Response object
+    :rtype: :class:`Response`
 
 
     The ``files`` argument is a dictionary::
@@ -532,10 +560,6 @@ 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 = {}
@@ -563,7 +587,7 @@ def request(method, url, params=None, data=None, headers=None, cookies=None,
         query = urllib.urlencode(str_dict(params), doseq=True)
         url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
 
-    req = urllib2.Request(url, data, headers)
+    req = Request(url, data, headers, method=method)
     return Response(req, stream)
 
 
@@ -579,6 +603,18 @@ def get(url, params=None, headers=None, cookies=None, auth=None,
                    stream=stream)
 
 
+def delete(url, params=None, data=None, headers=None, cookies=None, auth=None,
+           timeout=60, allow_redirects=True, stream=False):
+    """Initiate a DELETE request. Arguments as for :func:`request`.
+
+    :returns: :class:`Response` instance
+
+    """
+    return request('DELETE', url, params, data, headers=headers,
+                   cookies=cookies, 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, stream=False):
     """Initiate a POST request. Arguments as for :func:`request`.
@@ -590,15 +626,27 @@ def post(url, params=None, data=None, headers=None, cookies=None, files=None,
                    timeout, allow_redirects, stream)
 
 
+def put(url, params=None, data=None, headers=None, cookies=None, files=None,
+        auth=None, timeout=60, allow_redirects=False, stream=False):
+    """Initiate a PUT request. Arguments as for :func:`request`.
+
+    :returns: :class:`Response` instance
+
+    """
+    return request('PUT', url, params, data, headers, cookies, files, auth,
+                   timeout, allow_redirects, stream)
+
+
 def encode_multipart_formdata(fields, files):
     """Encode form data (``fields``) and ``files`` for POST request.
 
     :param fields: mapping of ``{name : value}`` pairs for normal form fields.
-    :type fields: :class:`dict`
+    :type fields: dict
     :param files: dictionary of fieldnames/files elements for file data.
                   See below for details.
-    :type files: :class:`dict` of :class:`dicts`
-    :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers
+    :type files: dict of :class:`dict`
+    :returns: ``(headers, body)`` ``headers`` is a
+        :class:`dict` of HTTP headers
     :rtype: 2-tuple ``(dict, str)``
 
     The ``files`` argument is a dictionary::
@@ -609,19 +657,20 @@ def encode_multipart_formdata(fields, files):
         }
 
     - ``fieldname`` is the name of the field in the HTML form.
-    - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used.
+    - ``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``.
 
         :param filename: filename of file
-        :type filename: unicode/string
+        :type filename: unicode/str
         :returns: mime-type, e.g. ``text/html``
-        :rtype: :class::class:`str`
+        :rtype: str
 
         """
-
         return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
 
     boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)

File diff suppressed because it is too large
+ 174 - 347
alfred/workflow/workflow.py


+ 300 - 108
scripts/alfred/workflow/workflow3.py → alfred/workflow/workflow3.py

@@ -7,21 +7,20 @@
 # 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`.
+"""An Alfred 3+ version of :class:`~workflow.Workflow`.
 
-It supports setting :ref:`workflow-variables` and
-:class:`the more advanced modifiers <Modifier>` supported by Alfred 3.
+:class:`~workflow.Workflow3` supports new features, such as
+setting :ref:`workflow-variables` and
+:class:`the more advanced modifiers <Modifier>` 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()`.
+objects directly, the current :class:`Workflow3` object won't be aware
+of them, and they won't be sent to Alfred when you call
+:meth:`Workflow3.send_feedback()`.
+
 """
 
 from __future__ import print_function, unicode_literals, absolute_import
@@ -30,44 +29,151 @@ import json
 import os
 import sys
 
-from .workflow import Workflow
+from .workflow import ICON_WARNING, Workflow
 
 
-class Modifier(object):
-    """Modify ``Item3`` values for when specified modifier keys are pressed.
+class Variables(dict):
+    """Workflow variables for Run Script actions.
+
+    .. versionadded: 1.26
+
+    This class allows you to set workflow variables from
+    Run Script actions.
+
+    It is a subclass of :class:`dict`.
+
+    >>> v = Variables(username='deanishe', password='hunter2')
+    >>> v.arg = u'output value'
+    >>> print(v)
+
+    See :ref:`variables-run-script` in the User Guide for more
+    information.
+
+    Args:
+        arg (unicode, optional): Main output/``{query}``.
+        **variables: Workflow variables to set.
+
+
+    Attributes:
+        arg (unicode): Output value (``{query}``).
+        config (dict): Configuration for downstream workflow element.
+
+    """
+
+    def __init__(self, arg=None, **variables):
+        """Create a new `Variables` object."""
+        self.arg = arg
+        self.config = {}
+        super(Variables, self).__init__(**variables)
+
+    @property
+    def obj(self):
+        """Return ``alfredworkflow`` `dict`."""
+        o = {}
+        if self:
+            d2 = {}
+            for k, v in self.items():
+                d2[k] = v
+            o['variables'] = d2
+
+        if self.config:
+            o['config'] = self.config
+
+        if self.arg is not None:
+            o['arg'] = self.arg
+
+        return {'alfredworkflow': o}
+
+    def __unicode__(self):
+        """Convert to ``alfredworkflow`` JSON object.
+
+        Returns:
+            unicode: ``alfredworkflow`` JSON object
+
+        """
+        if not self and not self.config:
+            if self.arg:
+                return self.arg
+            else:
+                return u''
+
+        return json.dumps(self.obj)
+
+    def __str__(self):
+        """Convert to ``alfredworkflow`` JSON object.
+
+        Returns:
+            str: UTF-8 encoded ``alfredworkflow`` JSON object
+
+        """
+        return unicode(self).encode('utf-8')
 
-    Valid modifiers (i.e. values for ``key``) are:
 
-     * cmd
-     * alt
-     * shift
-     * ctrl
-     * fn
+class Modifier(object):
+    """Modify :class:`Item3` arg/icon/variables when modifier key is pressed.
+
+    Don't use this class directly (as it won't be associated with any
+    :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
+    to add modifiers to results.
+
+    >>> it = wf.add_item('Title', 'Subtitle', valid=True)
+    >>> it.setvar('name', 'default')
+    >>> m = it.add_modifier('cmd')
+    >>> m.setvar('name', 'alternate')
+
+    See :ref:`workflow-variables` in the User Guide for more information
+    and :ref:`example usage <example-variables>`.
+
+    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.
+        icon (unicode, optional): Filepath/UTI of icon to use
+        icontype (unicode, optional): Type of icon. See
+            :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
+            for valid values.
 
     Attributes:
         arg (unicode): Arg to pass to following action.
+        config (dict): Configuration for a downstream element, such as
+            a File Filter.
+        icon (unicode): Filepath/UTI of icon.
+        icontype (unicode): Type of icon. See
+            :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
+            for valid values.
         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):
+    def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None,
+                 icontype=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.
+        Don't use this class directly (as it won't be associated with any
+        :class:`Item3`), 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.
+            icon (unicode, optional): Filepath/UTI of icon to use
+            icontype (unicode, optional): Type of icon. See
+                :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
+                for valid values.
+
         """
         self.key = key
         self.subtitle = subtitle
         self.arg = arg
         self.valid = valid
+        self.icon = icon
+        self.icontype = icontype
 
         self.config = {}
         self.variables = {}
@@ -78,6 +184,7 @@ class Modifier(object):
         Args:
             name (unicode): Name of variable.
             value (unicode): Value of variable.
+
         """
         self.variables[name] = value
 
@@ -90,6 +197,7 @@ class Modifier(object):
 
         Returns:
             unicode or ``default``: Value of variable if set or ``default``.
+
         """
         return self.variables.get(name, default)
 
@@ -99,6 +207,7 @@ class Modifier(object):
 
         Returns:
             dict: Modifier for serializing to JSON.
+
         """
         o = {}
 
@@ -111,44 +220,63 @@ class Modifier(object):
         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.variables:
+            o['variables'] = self.variables
 
-            if self.arg is not None:
-                d['arg'] = self.arg
+        if self.config:
+            o['config'] = self.config
 
-            o['arg'] = json.dumps({'alfredworkflow': d})
+        icon = self._icon()
+        if icon:
+            o['icon'] = icon
 
         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
+
 
 class Item3(object):
-    """Represents a feedback item for Alfred 3.
+    """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.
+    Don't use this class directly (as it then won't be associated with
+    any :class:`Workflow3 <workflow.Workflow3>` object), but rather use
+    :meth:`Workflow3.add_item() <workflow.Workflow3.add_item>`.
+    See :meth:`~workflow.Workflow3.add_item` for details of arguments.
+
     """
 
     def __init__(self, title, subtitle='', arg=None, autocomplete=None,
-                 valid=False, uid=None, icon=None, icontype=None,
+                 match=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`.
+        """Create a new :class:`Item3` object.
+
+        Use same arguments as for
+        :class:`Workflow.Item <workflow.Workflow.Item>`.
 
         Argument ``subtitle_modifiers`` is not supported.
+
         """
         self.title = title
         self.subtitle = subtitle
         self.arg = arg
         self.autocomplete = autocomplete
+        self.match = match
         self.valid = valid
         self.uid = uid
         self.icon = icon
@@ -182,10 +310,12 @@ class Item3(object):
 
         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):
+    def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None,
+                     icontype=None):
         """Add alternative values for a modifier key.
 
         Args:
@@ -193,14 +323,19 @@ class Item3(object):
             subtitle (unicode, optional): Override item subtitle.
             arg (unicode, optional): Input for following action.
             valid (bool, optional): Override item validity.
+            icon (unicode, optional): Filepath/UTI of icon.
+            icontype (unicode, optional): Type of icon.  See
+                :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
+                for valid values.
 
         Returns:
             Modifier: Configured :class:`Modifier`.
+
         """
-        mod = Modifier(key, subtitle, arg, valid)
+        mod = Modifier(key, subtitle, arg, valid, icon, icontype)
 
-        for k in self.variables:
-            mod.setvar(k, self.variables[k])
+        # Add Item variables to Modifier
+        mod.variables.update(self.variables)
 
         self.modifiers[key] = mod
 
@@ -212,13 +347,14 @@ class Item3(object):
 
         Returns:
             dict: Data suitable for Alfred 3 feedback.
-        """
-        # Basic values
-        o = {'title': self.title,
-             'subtitle': self.subtitle,
-             'valid': self.valid}
 
-        icon = {}
+        """
+        # Required values
+        o = {
+            'title': self.title,
+            'subtitle': self.subtitle,
+            'valid': self.valid,
+        }
 
         # Optional values
         if self.arg is not None:
@@ -227,6 +363,9 @@ class Item3(object):
         if self.autocomplete is not None:
             o['autocomplete'] = self.autocomplete
 
+        if self.match is not None:
+            o['match'] = self.match
+
         if self.uid is not None:
             o['uid'] = self.uid
 
@@ -236,6 +375,12 @@ class Item3(object):
         if self.quicklookurl is not None:
             o['quicklookurl'] = self.quicklookurl
 
+        if self.variables:
+            o['variables'] = self.variables
+
+        if self.config:
+            o['config'] = self.config
+
         # Largetype and copytext
         text = self._text()
         if text:
@@ -245,11 +390,6 @@ class Item3(object):
         if icon:
             o['icon'] = icon
 
-        # Variables and config
-        js = self._vars_and_config()
-        if js:
-            o['arg'] = js
-
         # Modifiers
         mods = self._modifiers()
         if mods:
@@ -262,6 +402,7 @@ class Item3(object):
 
         Returns:
             dict: Mapping for item `icon` (may be empty).
+
         """
         icon = {}
         if self.icon is not None:
@@ -277,6 +418,7 @@ class Item3(object):
 
         Returns:
             dict: `text` mapping (may be empty)
+
         """
         text = {}
         if self.largetext is not None:
@@ -287,32 +429,12 @@ class Item3(object):
 
         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 = {}
@@ -325,11 +447,15 @@ class Item3(object):
 
 
 class Workflow3(Workflow):
-    """Workflow class that generates Alfred 3 feedback.
+    """Workflow class that generates Alfred 3+ feedback.
+
+    It is a subclass of :class:`~workflow.Workflow` and most of its
+    methods are documented there.
 
     Attributes:
         item_class (class): Class used to generate feedback items.
         variables (dict): Top level workflow variables.
+
     """
 
     item_class = Item3
@@ -337,27 +463,31 @@ class Workflow3(Workflow):
     def __init__(self, **kwargs):
         """Create a new :class:`Workflow3` object.
 
-        See :class:`~workflow.workflow.Workflow` for documentation.
+        See :class:`~workflow.Workflow` for documentation.
+
         """
         Workflow.__init__(self, **kwargs)
         self.variables = {}
         self._rerun = 0
-        self._session_id = None
+        # Get session ID from environment if present
+        self._session_id = os.getenv('_WF_SESSION_ID') or None
+        if self._session_id:
+            self.setvar('_WF_SESSION_ID', self._session_id)
 
     @property
     def _default_cachedir(self):
-        """Alfred 3's default cache directory."""
+        """Alfred 4's default cache directory."""
         return os.path.join(
             os.path.expanduser(
-                '~/Library/Caches/com.runningwithcrayons.Alfred-3/'
+                '~/Library/Caches/com.runningwithcrayons.Alfred/'
                 'Workflow Data/'),
             self.bundleid)
 
     @property
     def _default_datadir(self):
-        """Alfred 3's default data directory."""
+        """Alfred 4's default data directory."""
         return os.path.join(os.path.expanduser(
-            '~/Library/Application Support/Alfred 3/Workflow Data/'),
+            '~/Library/Application Support/Alfred/Workflow Data/'),
             self.bundleid)
 
     @property
@@ -386,19 +516,17 @@ class Workflow3(Workflow):
 
         """
         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
+            from uuid import uuid4
+            self._session_id = uuid4().hex
+            self.setvar('_WF_SESSION_ID', self._session_id)
 
         return self._session_id
 
-    def setvar(self, name, value):
+    def setvar(self, name, value, persist=False):
         """Set a "global" workflow variable.
 
+        .. versionchanged:: 1.33
+
         These variables are always passed to downstream workflow objects.
 
         If you have set :attr:`rerun`, these variables are also passed
@@ -407,8 +535,15 @@ class Workflow3(Workflow):
         Args:
             name (unicode): Name of variable.
             value (unicode): Value of variable.
+            persist (bool, optional): Also save variable to ``info.plist``?
+
         """
         self.variables[name] = value
+        if persist:
+            from .util import set_config
+            set_config(name, value, self.bundleid)
+            self.logger.debug('saved variable %r with value %r to info.plist',
+                              name, value)
 
     def getvar(self, name, default=None):
         """Return value of workflow variable for ``name`` or ``default``.
@@ -419,16 +554,22 @@ class Workflow3(Workflow):
 
         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):
+                 valid=False, uid=None, icon=None, icontype=None, type=None,
+                 largetext=None, copytext=None, quicklookurl=None, match=None):
         """Add an item to be output to Alfred.
 
-        See :meth:`~workflow.workflow.Workflow.add_item` for the main
-        documentation.
+        Args:
+            match (unicode, optional): If you have "Alfred filters results"
+                turned on for your Script Filter, Alfred (version 3.5 and
+                above) will filter against this field, not ``title``.
+
+        See :meth:`Workflow.add_item() <workflow.Workflow.add_item>` for
+        the main documentation and other parameters.
 
         The key difference is that this method does not support the
         ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
@@ -436,17 +577,26 @@ class Workflow3(Workflow):
 
         Returns:
             Item3: Alfred feedback item.
+
         """
-        item = self.item_class(title, subtitle, arg,
-                               autocomplete, valid, uid, icon, icontype, type,
+        item = self.item_class(title, subtitle, arg, autocomplete,
+                               match, valid, uid, icon, icontype, type,
                                largetext, copytext, quicklookurl)
 
+        # Add variables to child item
+        item.variables.update(self.variables)
+
         self._items.append(item)
         return item
 
+    @property
+    def _session_prefix(self):
+        """Filename prefix for current session."""
+        return '_wfsess-{0}-'.format(self.session_id)
+
     def _mk_session_name(self, name):
         """New cache name/key based on session ID."""
-        return '_wfsess-{0}-{1}'.format(self.session_id, name)
+        return self._session_prefix + name
 
     def cache_data(self, name, data, session=False):
         """Cache API with session-scoped expiry.
@@ -459,11 +609,11 @@ class Workflow3(Workflow):
             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`.
+        ``name`` and ``data`` are the same as for the
+        :meth:`~workflow.Workflow.cache_data` method on
+        :class:`~workflow.Workflow`.
 
-        If ``session`` is ``True``, the ``name`` variable is prefixed
+        If ``session`` is ``True``, then ``name`` is prefixed
         with :attr:`session_id`.
 
         """
@@ -485,11 +635,11 @@ class Workflow3(Workflow):
             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`.
+        ``name``, ``data_func`` and ``max_age`` are the same as for the
+        :meth:`~workflow.Workflow.cached_data` method on
+        :class:`~workflow.Workflow`.
 
-        If ``session`` is ``True``, the ``name`` variable is prefixed
+        If ``session`` is ``True``, then ``name`` is prefixed
         with :attr:`session_id`.
 
         """
@@ -498,13 +648,25 @@ class Workflow3(Workflow):
 
         return super(Workflow3, self).cached_data(name, data_func, max_age)
 
-    def clear_session_cache(self):
-        """Remove *all* session data from the cache.
+    def clear_session_cache(self, current=False):
+        """Remove session data from the cache.
 
         .. versionadded:: 1.25
+        .. versionchanged:: 1.27
+
+        By default, data belonging to the current session won't be
+        deleted. Set ``current=True`` to also clear current session.
+
+        Args:
+            current (bool, optional): If ``True``, also remove data for
+                current session.
+
         """
         def _is_session_file(filename):
-            return filename.startswith('_wfsess-')
+            if current:
+                return filename.startswith('_wfsess-')
+            return filename.startswith('_wfsess-') \
+                and not filename.startswith(self._session_prefix)
 
         self.clear_cache(_is_session_file)
 
@@ -514,6 +676,7 @@ class Workflow3(Workflow):
 
         Returns:
             dict: Data suitable for Alfred 3 feedback.
+
         """
         items = []
         for item in self._items:
@@ -526,7 +689,36 @@ class Workflow3(Workflow):
             o['rerun'] = self.rerun
         return o
 
+    def warn_empty(self, title, subtitle=u'', icon=None):
+        """Add a warning to feedback if there are no items.
+
+        .. versionadded:: 1.31
+
+        Add a "warning" item to Alfred feedback if no other items
+        have been added. This is a handy shortcut to prevent Alfred
+        from showing its fallback searches, which is does if no
+        items are returned.
+
+        Args:
+            title (unicode): Title of feedback item.
+            subtitle (unicode, optional): Subtitle of feedback item.
+            icon (str, optional): Icon for feedback item. If not
+                specified, ``ICON_WARNING`` is used.
+
+        Returns:
+            Item3: Newly-created item.
+
+        """
+        if len(self._items):
+            return
+
+        icon = icon or ICON_WARNING
+        return self.add_item(title, subtitle, icon=icon)
+
     def send_feedback(self):
         """Print stored items to console/Alfred as JSON."""
-        json.dump(self.obj, sys.stdout)
+        if self.debugging:
+            json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': '))
+        else:
+            json.dump(self.obj, sys.stdout)
         sys.stdout.flush()

BIN
scripts/alfred/icon.png


+ 0 - 124
scripts/alfred/info.plist

@@ -1,124 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-<dict>
-	<key>bundleid</key>
-	<string>switchhosts.oldj.net</string>
-	<key>category</key>
-	<string>Productivity</string>
-	<key>connections</key>
-	<dict>
-		<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
-		<array>
-			<dict>
-				<key>destinationuid</key>
-				<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
-				<key>modifiers</key>
-				<integer>0</integer>
-				<key>modifiersubtext</key>
-				<string></string>
-				<key>vitoclose</key>
-				<false/>
-			</dict>
-		</array>
-	</dict>
-	<key>createdby</key>
-	<string>oldj</string>
-	<key>description</key>
-	<string>SwitchHosts quickly!</string>
-	<key>disabled</key>
-	<false/>
-	<key>name</key>
-	<string>SwitchHosts!</string>
-	<key>objects</key>
-	<array>
-		<dict>
-			<key>config</key>
-			<dict>
-				<key>concurrently</key>
-				<false/>
-				<key>escaping</key>
-				<integer>102</integer>
-				<key>script</key>
-				<string>curl 'http://127.0.0.1:50761/api/toggle?id={query}'</string>
-				<key>scriptargtype</key>
-				<integer>0</integer>
-				<key>scriptfile</key>
-				<string></string>
-				<key>type</key>
-				<integer>0</integer>
-			</dict>
-			<key>type</key>
-			<string>alfred.workflow.action.script</string>
-			<key>uid</key>
-			<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
-			<key>version</key>
-			<integer>2</integer>
-		</dict>
-		<dict>
-			<key>config</key>
-			<dict>
-				<key>alfredfiltersresults</key>
-				<false/>
-				<key>argumenttype</key>
-				<integer>2</integer>
-				<key>escaping</key>
-				<integer>68</integer>
-				<key>keyword</key>
-				<string>swh</string>
-				<key>queuedelaycustom</key>
-				<integer>3</integer>
-				<key>queuedelayimmediatelyinitially</key>
-				<true/>
-				<key>queuedelaymode</key>
-				<integer>0</integer>
-				<key>queuemode</key>
-				<integer>1</integer>
-				<key>runningsubtext</key>
-				<string>loading...</string>
-				<key>script</key>
-				<string>python alfred.py</string>
-				<key>scriptargtype</key>
-				<integer>1</integer>
-				<key>scriptfile</key>
-				<string></string>
-				<key>subtext</key>
-				<string>Switch hosts quickly!</string>
-				<key>title</key>
-				<string>Show hosts..</string>
-				<key>type</key>
-				<integer>0</integer>
-				<key>withspace</key>
-				<false/>
-			</dict>
-			<key>type</key>
-			<string>alfred.workflow.input.scriptfilter</string>
-			<key>uid</key>
-			<string>E4D66445-FD72-47A2-9EE6-7232A2BADE29</string>
-			<key>version</key>
-			<integer>2</integer>
-		</dict>
-	</array>
-	<key>readme</key>
-	<string></string>
-	<key>uidata</key>
-	<dict>
-		<key>78D17FD5-9628-4901-A01A-511528D5FC14</key>
-		<dict>
-			<key>xpos</key>
-			<integer>340</integer>
-			<key>ypos</key>
-			<integer>30</integer>
-		</dict>
-		<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
-		<dict>
-			<key>xpos</key>
-			<integer>120</integer>
-			<key>ypos</key>
-			<integer>30</integer>
-		</dict>
-	</dict>
-	<key>webaddress</key>
-	<string>https://oldj.github.io/SwitchHosts/</string>
-</dict>
-</plist>

+ 0 - 428
scripts/alfred/workflow/update.py

@@ -1,428 +0,0 @@
-#!/usr/bin/env python
-# encoding: utf-8
-#
-# Copyright (c) 2014 Fabio Niephaus <[email protected]>,
-#       Dean Jackson <[email protected]>
-#
-# MIT Licence. See http://opensource.org/licenses/MIT
-#
-# Created on 2014-08-16
-#
-
-"""Self-updating from GitHub.
-
-.. versionadded:: 1.9
-
-.. note::
-
-   This module is not intended to be used directly. Automatic updates
-   are controlled by the ``update_settings`` :class:`dict` passed to
-   :class:`~workflow.workflow.Workflow` objects.
-
-"""
-
-from __future__ import print_function, unicode_literals
-
-import os
-import tempfile
-import re
-import subprocess
-
-import workflow
-import web
-
-# __all__ = []
-
-
-RELEASES_BASE = 'https://api.github.com/repos/{0}/releases'
-
-
-_wf = None
-
-
-def wf():
-    """Lazy `Workflow` object."""
-    global _wf
-    if _wf is None:
-        _wf = workflow.Workflow()
-    return _wf
-
-
-class Version(object):
-    """Mostly semantic versioning.
-
-    The main difference to proper :ref:`semantic versioning <semver>`
-    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
-        self.patch = 0
-        self.suffix = ''
-        self.build = ''
-        self._parse(vstr)
-
-    def _parse(self, vstr):
-        if vstr.startswith('v'):
-            m = self.match_version(vstr[1:])
-        else:
-            m = self.match_version(vstr)
-        if not m:
-            raise ValueError('Invalid version number: {0}'.format(vstr))
-
-        version, suffix = m.groups()
-        parts = self._parse_dotted_string(version)
-        self.major = parts.pop(0)
-        if len(parts):
-            self.minor = parts.pop(0)
-        if len(parts):
-            self.patch = parts.pop(0)
-        if not len(parts) == 0:
-            raise ValueError('Invalid version (too long) : {0}'.format(vstr))
-
-        if suffix:
-            # Build info
-            idx = suffix.find('+')
-            if idx > -1:
-                self.build = suffix[idx+1:]
-                suffix = suffix[:idx]
-            if suffix:
-                if not suffix.startswith('-'):
-                    raise ValueError(
-                        'Invalid suffix : `{0}`. Must start with `-`'.format(
-                            suffix))
-                self.suffix = suffix[1:]
-
-        # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self)))
-
-    def _parse_dotted_string(self, s):
-        """Parse string ``s`` into list of ints and strings."""
-        parsed = []
-        parts = s.split('.')
-        for p in parts:
-            if p.isdigit():
-                p = int(p)
-            parsed.append(p)
-        return parsed
-
-    @property
-    def tuple(self):
-        """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]
-        o = other.tuple[:3]
-        if t < o:
-            return True
-        if t == o:  # We need to compare suffixes
-            if self.suffix and not other.suffix:
-                return True
-            if other.suffix and not self.suffix:
-                return False
-            return (self._parse_dotted_string(self.suffix) <
-                    self._parse_dotted_string(other.suffix))
-        # t > o
-        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)
-        if self.build:
-            vstr += '+{0}'.format(self.build)
-        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.
-
-    :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 `{0}` not a workflow'.format(filename))
-
-    local_path = os.path.join(tempfile.gettempdir(), filename)
-
-    wf().logger.debug(
-        'Downloading updated workflow from `%s` to `%s` ...', url, local_path)
-
-    response = web.get(url)
-
-    with open(local_path, 'wb') as output:
-        output.write(response.content)
-
-    return local_path
-
-
-def build_api_url(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 _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/...',
-        'prerelease': False }``
-
-
-    A valid release is one that contains one ``.alfredworkflow`` file.
-
-    If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading
-    ``v`` will be stripped.
-
-    """
-    api_url = build_api_url(github_slug)
-    releases = []
-
-    wf().logger.debug('Retrieving releases list from `%s` ...', api_url)
-
-    def retrieve_releases():
-        wf().logger.info(
-            '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):
-
-        wf().logger.debug('Release : %r', release)
-
-        release = _validate_release(release)
-        if release is None:
-            wf().logger.debug('Invalid release')
-            continue
-
-        elif release['prerelease'] and not prereleases:
-            wf().logger.debug('Ignoring prerelease : %s', release['version'])
-            continue
-
-        releases.append(release)
-
-    return releases
-
-
-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 <semver>` is required.
-    :param prereleases: Whether to include pre-releases.
-    :type current_version: ``unicode``
-    :returns: ``True`` if an update is available, else ``False``
-
-    If an update is available, its version number and download URL will
-    be cached.
-
-    """
-    releases = get_valid_releases(github_slug, prereleases)
-
-    wf().logger.info('%d releases for %s', len(releases), github_slug)
-
-    if not len(releases):
-        raise ValueError('No valid releases for %s', github_slug)
-
-    # GitHub returns releases newest-first
-    latest_release = releases[0]
-
-    # (latest_version, download_url) = get_latest_release(releases)
-    vr = Version(latest_release['version'])
-    vl = Version(current_version)
-    wf().logger.debug('Latest : %r Installed : %r', vr, vl)
-    if vr > vl:
-
-        wf().cache_data('__workflow_update_status', {
-            'version': latest_release['version'],
-            'download_url': latest_release['download_url'],
-            'available': True
-        })
-
-        return True
-
-    wf().cache_data('__workflow_update_status', {
-        'available': False
-    })
-    return False
-
-
-def install_update():
-    """If a newer release is available, download and install it.
-
-    :returns: ``True`` if an update is installed, else ``False``
-
-    """
-    update_data = wf().cached_data('__workflow_update_status', max_age=0)
-
-    if not update_data or not update_data.get('available'):
-        wf().logger.info('No update available')
-        return False
-
-    local_file = download_workflow(update_data['download_url'])
-
-    wf().logger.info('Installing updated workflow ...')
-    subprocess.call(['open', local_file])
-
-    update_data['available'] = False
-    wf().cache_data('__workflow_update_status', update_data)
-    return True
-
-
-if __name__ == '__main__':  # pragma: nocover
-    import sys
-
-    def show_help():
-        """Print help message."""
-        print('Usage : update.py (check|install) github_slug version '
-              '[--prereleases]')
-        sys.exit(1)
-
-    argv = sys.argv[:]
-    prereleases = '--prereleases' in argv
-
-    if prereleases:
-        argv.remove('--prereleases')
-
-    if len(argv) != 4:
-        show_help()
-
-    action, github_slug, version = argv[1:]
-
-    if action not in ('check', 'install'):
-        show_help()
-
-    if action == 'check':
-        check_update(github_slug, version, prereleases)
-    elif action == 'install':
-        install_update()

+ 0 - 1
scripts/alfred/workflow/version

@@ -1 +0,0 @@
-1.25.1

Some files were not shown because too many files changed in this diff