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
     # the list of results for Alfred
     for item in items:
     for item in items:
         on = item.get('on', False)
         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
     # Send the results to Alfred as XML
     wf.send_feedback()
     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
 # Workflow objects
 from .workflow import Workflow, manager
 from .workflow import Workflow, manager
-from .workflow3 import Workflow3
+from .workflow3 import Variables, Workflow3
 
 
 # Exceptions
 # Exceptions
 from .workflow import PasswordNotFound, KeychainError
 from .workflow import PasswordNotFound, KeychainError
@@ -64,9 +64,10 @@ __title__ = 'Alfred-Workflow'
 __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
 __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
 __author__ = 'Dean Jackson'
 __author__ = 'Dean Jackson'
 __licence__ = 'MIT'
 __licence__ = 'MIT'
-__copyright__ = 'Copyright 2014 Dean Jackson'
+__copyright__ = 'Copyright 2014-2019 Dean Jackson'
 
 
 __all__ = [
 __all__ = [
+    'Variables',
     'Workflow',
     'Workflow',
     'Workflow3',
     'Workflow3',
     'manager',
     'manager',

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

@@ -8,10 +8,18 @@
 # Created on 2014-04-06
 # 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
 from __future__ import print_function, unicode_literals
 
 
+import signal
 import sys
 import sys
 import os
 import os
 import subprocess
 import subprocess
@@ -31,6 +39,10 @@ def wf():
     return _wf
     return _wf
 
 
 
 
+def _log():
+    return wf().logger
+
+
 def _arg_cache(name):
 def _arg_cache(name):
     """Return path to pickle cache file for arguments.
     """Return path to pickle cache file for arguments.
 
 
@@ -40,7 +52,7 @@ def _arg_cache(name):
     :rtype: ``unicode`` filepath
     :rtype: ``unicode`` filepath
 
 
     """
     """
-    return wf().cachefile('{0}.argcache'.format(name))
+    return wf().cachefile(name + '.argcache')
 
 
 
 
 def _pid_file(name):
 def _pid_file(name):
@@ -52,7 +64,7 @@ def _pid_file(name):
     :rtype: ``unicode`` filepath
     :rtype: ``unicode`` filepath
 
 
     """
     """
-    return wf().cachefile('{0}.pid'.format(name))
+    return wf().cachefile(name + '.pid')
 
 
 
 
 def _process_exists(pid):
 def _process_exists(pid):
@@ -71,35 +83,49 @@ def _process_exists(pid):
     return True
     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)
     pidfile = _pid_file(name)
     if not os.path.exists(pidfile):
     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
     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
                 stderr='/dev/null'):  # pragma: no cover
     """Fork the current process into a background daemon.
     """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
     :param stdin: where to read input
     :type stdin: filepath
     :type stdin: filepath
     :param stdout: where to write stdout output
     :param stdout: where to write stdout output
@@ -108,25 +134,31 @@ def _background(stdin='/dev/null', stdout='/dev/null',
     :type stderr: filepath
     :type stderr: filepath
 
 
     """
     """
-    def _fork_and_exit_parent(errmsg):
+    def _fork_and_exit_parent(errmsg, wait=False, write=False):
         try:
         try:
             pid = os.fork()
             pid = os.fork()
             if pid > 0:
             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)
                 os._exit(0)
         except OSError as err:
         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
             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.
     # Decouple from parent environment.
     os.chdir(wf().workflowdir)
     os.chdir(wf().workflowdir)
     os.setsid()
     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!
     # Now I am a daemon!
     # Redirect standard file descriptors.
     # Redirect standard file descriptors.
@@ -141,15 +173,35 @@ def _background(stdin='/dev/null', stdout='/dev/null',
         os.dup2(se.fileno(), sys.stderr.fileno())
         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):
 def run_in_background(name, args, **kwargs):
     r"""Cache arguments then call this script again via :func:`subprocess.call`.
     r"""Cache arguments then call this script again via :func:`subprocess.call`.
 
 
-    :param name: name of task
-    :type name: ``unicode``
+    :param name: name of job
+    :type name: unicode
     :param args: arguments passed as first argument to :func:`subprocess.call`
     :param args: arguments passed as first argument to :func:`subprocess.call`
     :param \**kwargs: keyword arguments to :func:`subprocess.call`
     :param \**kwargs: keyword arguments to :func:`subprocess.call`
     :returns: exit code of sub-process
     :returns: exit code of sub-process
-    :rtype: ``int``
+    :rtype: int
 
 
     When you call this function, it caches its arguments and then calls
     When you call this function, it caches its arguments and then calls
     ``background.py`` in a subprocess. The Python subprocess will load the
     ``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):
     if is_running(name):
-        wf().logger.info('Task `{0}` is already running'.format(name))
+        _log().info('[%s] job already running', name)
         return
         return
 
 
     argcache = _arg_cache(name)
     argcache = _arg_cache(name)
 
 
     # Cache arguments
     # 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
     # Call this script
     cmd = ['/usr/bin/python', __file__, name]
     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)
     retcode = subprocess.call(cmd)
+
     if retcode:  # pragma: no cover
     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:
     else:
-        wf().logger.debug('Executing task `{0}` in background...'.format(name))
+        _log().debug('[%s] background job started', name)
+
     return retcode
     return retcode
 
 
 
 
@@ -195,15 +249,21 @@ def main(wf):  # pragma: no cover
     :meth:`subprocess.call` with cached arguments.
     :meth:`subprocess.call` with cached arguments.
 
 
     """
     """
+    log = wf.logger
     name = wf.args[0]
     name = wf.args[0]
     argcache = _arg_cache(name)
     argcache = _arg_cache(name)
     if not os.path.exists(argcache):
     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
     # 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
     # Cached arguments
     args = data['args']
     args = data['args']
@@ -212,30 +272,18 @@ def main(wf):  # pragma: no cover
     # Delete argument cache file
     # Delete argument cache file
     os.unlink(argcache)
     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:
     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)
         retcode = subprocess.call(args, **kwargs)
 
 
         if retcode:
         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:
     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
 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
 # 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`.
 The main API is a single function, :func:`~workflow.notify.notify`.
 
 
@@ -60,10 +61,10 @@ SOUNDS = (
 
 
 
 
 def wf():
 def wf():
-    """Return `Workflow` object for this module.
+    """Return Workflow object for this module.
 
 
     Returns:
     Returns:
-        workflow.Workflow: `Workflow` object for current workflow.
+        workflow.Workflow: Workflow object for current workflow.
     """
     """
     global _wf
     global _wf
     if _wf is None:
     if _wf is None:
@@ -87,7 +88,7 @@ def notifier_program():
     """Return path to notifier applet executable.
     """Return path to notifier applet executable.
 
 
     Returns:
     Returns:
-        unicode: Path to Notify.app `applet` executable.
+        unicode: Path to Notify.app ``applet`` executable.
     """
     """
     return wf().datafile('Notify.app/Contents/MacOS/applet')
     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.
     """Return path to icon file in installed Notify.app.
 
 
     Returns:
     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')
     return wf().datafile('Notify.app/Contents/Resources/applet.icns')
 
 
 
 
 def install_notifier():
 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
     Changes the bundle ID of the installed app and gives it the
     workflow's icon.
     workflow's icon.
@@ -111,13 +112,13 @@ def install_notifier():
     destdir = wf().datadir
     destdir = wf().datadir
     app_path = os.path.join(destdir, 'Notify.app')
     app_path = os.path.join(destdir, 'Notify.app')
     n = notifier_program()
     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 = zipfile.ZipFile(archive, 'r')
     # z.extractall(destdir)
     # z.extractall(destdir)
     tgz = tarfile.open(archive, 'r:gz')
     tgz = tarfile.open(archive, 'r:gz')
     tgz.extractall(destdir)
     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
     # Replace applet icon
     icon = notifier_icon_path()
     icon = notifier_icon_path()
@@ -144,29 +145,29 @@ def install_notifier():
     ip_path = os.path.join(app_path, 'Contents/Info.plist')
     ip_path = os.path.join(app_path, 'Contents/Info.plist')
     bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
     bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
     data = plistlib.readPlist(ip_path)
     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
     data['CFBundleIdentifier'] = bundle_id
     plistlib.writePlist(data, ip_path)
     plistlib.writePlist(data, ip_path)
 
 
 
 
 def validate_sound(sound):
 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:
     Args:
         sound (str): Name of system sound.
         sound (str): Name of system sound.
 
 
     Returns:
     Returns:
-        str: Proper name of sound or `None`.
+        str: Proper name of sound or ``None``.
     """
     """
     if not sound:
     if not sound:
         return None
         return None
 
 
     # Case-insensitive comparison of `sound`
     # Case-insensitive comparison of `sound`
     if sound.lower() in [s.lower() for s in SOUNDS]:
     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 sound.title()
     return None
     return None
 
 
@@ -180,10 +181,10 @@ def notify(title='', text='', sound=None):
         sound (str, optional): Name of sound to play.
         sound (str, optional): Name of sound to play.
 
 
     Raises:
     Raises:
-        ValueError: Raised if both `title` and `text` are empty.
+        ValueError: Raised if both ``title`` and ``text`` are empty.
 
 
     Returns:
     Returns:
-        bool: `True` if notification was posted, else `False`.
+        bool: ``True`` if notification was posted, else ``False``.
     """
     """
     if title == text == '':
     if title == text == '':
         raise ValueError('Empty notification')
         raise ValueError('Empty notification')
@@ -198,7 +199,7 @@ def notify(title='', text='', sound=None):
     env = os.environ.copy()
     env = os.environ.copy()
     enc = 'utf-8'
     enc = 'utf-8'
     env['NOTIFY_TITLE'] = title.encode(enc)
     env['NOTIFY_TITLE'] = title.encode(enc)
-    env['NOTIFY_MESSAGE'] =  text.encode(enc)
+    env['NOTIFY_MESSAGE'] = text.encode(enc)
     env['NOTIFY_SOUND'] = sound.encode(enc)
     env['NOTIFY_SOUND'] = sound.encode(enc)
     cmd = [n]
     cmd = [n]
     retcode = subprocess.call(cmd, env=env)
     retcode = subprocess.call(cmd, env=env)
@@ -210,7 +211,7 @@ def notify(title='', text='', sound=None):
 
 
 
 
 def convert_image(inpath, outpath, size):
 def convert_image(inpath, outpath, size):
-    """Convert an image file using `sips`.
+    """Convert an image file using ``sips``.
 
 
     Args:
     Args:
         inpath (str): Path of source file.
         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.
         size (int): Width and height of destination image in pixels.
 
 
     Raises:
     Raises:
-        RuntimeError: Raised if `sips` exits with non-zero status.
+        RuntimeError: Raised if ``sips`` exits with non-zero status.
     """
     """
     cmd = [
     cmd = [
         b'sips',
         b'sips',
-        b'-z', b'{0}'.format(size), b'{0}'.format(size),
+        b'-z', str(size), str(size),
         inpath,
         inpath,
         b'--out', outpath]
         b'--out', outpath]
     # log().debug(cmd)
     # log().debug(cmd)
@@ -230,14 +231,14 @@ def convert_image(inpath, outpath, size):
         retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
         retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
 
 
     if retcode != 0:
     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):
 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
     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.
     them into a single ICNS file.
 
 
     Args:
     Args:
@@ -245,15 +246,16 @@ def png_to_icns(png_path, icns_path):
         icns_path (str): Path to destination ICNS file.
         icns_path (str): Path to destination ICNS file.
 
 
     Raises:
     Raises:
-        RuntimeError: Raised if `iconutil` or `sips` fail.
+        RuntimeError: Raised if ``iconutil`` or ``sips`` fail.
     """
     """
     tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
     tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
 
 
     try:
     try:
         iconset = os.path.join(tempdir, 'Icon.iconset')
         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)
         os.makedirs(iconset)
 
 
         # Copy source icon to icon set and generate all the other
         # Copy source icon to icon set and generate all the other
@@ -261,7 +263,7 @@ def png_to_icns(png_path, icns_path):
         configs = []
         configs = []
         for i in (16, 32, 128, 256, 512):
         for i in (16, 32, 128, 256, 512):
             configs.append(('icon_{0}x{0}.png'.format(i), i))
             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, 'icon_256x256.png'))
         shutil.copy(png_path, os.path.join(iconset, '[email protected]'))
         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)
         retcode = subprocess.call(cmd)
         if retcode != 0:
         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:
     finally:
         try:
         try:
             shutil.rmtree(tempdir)
             shutil.rmtree(tempdir)
@@ -291,36 +294,6 @@ def png_to_icns(png_path, icns_path):
             pass
             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
 if __name__ == '__main__':  # pragma: nocover
     # Simple command-line script to test module with
     # Simple command-line script to test module with
     # This won't work on 2.6, as `argparse` isn't available
     # 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
     from unicodedata import normalize
 
 
-    def uni(s):
+    def ustr(s):
         """Coerce `s` to normalised Unicode."""
         """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 = argparse.ArgumentParser()
     p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
     p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
     p.add_argument('-l', '--list-sounds', help="Show available sounds.",
     p.add_argument('-l', '--list-sounds', help="Show available sounds.",
                    action='store_true')
                    action='store_true')
     p.add_argument('-t', '--title',
     p.add_argument('-t', '--title',
-                   help="Notification title.", type=uni,
+                   help="Notification title.", type=ustr,
                    default='')
                    default='')
-    p.add_argument('-s', '--sound', type=uni,
+    p.add_argument('-s', '--sound', type=ustr,
                    help="Optional notification sound.", default='')
                    help="Optional notification sound.", default='')
-    p.add_argument('text', type=uni,
+    p.add_argument('text', type=ustr,
                    help="Notification body text.", default='', nargs='?')
                    help="Notification body text.", default='', nargs='?')
     o = p.parse_args()
     o = p.parse_args()
 
 
@@ -357,21 +329,20 @@ if __name__ == '__main__':  # pragma: nocover
     if o.png:
     if o.png:
         icns = os.path.join(
         icns = os.path.join(
             os.path.dirname(o.png),
             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)
               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)
         png_to_icns(o.png, icns)
         sys.exit(0)
         sys.exit(0)
 
 
     # Post notification
     # Post notification
     if o.title == o.text == '':
     if o.title == o.text == '':
-        print('ERROR: Empty notification.', file=sys.stderr)
+        print('ERROR: empty notification.', file=sys.stderr)
         sys.exit(1)
         sys.exit(1)
     else:
     else:
         notify(o.title, o.text, o.sound)
         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."""
 """Lightweight HTTP library with a requests-like interface."""
 
 
+from __future__ import absolute_import, print_function
+
 import codecs
 import codecs
 import json
 import json
 import mimetypes
 import mimetypes
@@ -23,8 +25,10 @@ import urllib2
 import urlparse
 import urlparse
 import zlib
 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
 # Valid characters for multipart form data boundaries
 BOUNDARY_CHARS = string.digits + string.ascii_letters
 BOUNDARY_CHARS = string.digits + string.ascii_letters
@@ -77,8 +81,10 @@ RESPONSES = {
 def str_dict(dic):
 def str_dict(dic):
     """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
     """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
 
 
-    :param dic: :class:`dict` of Unicode strings
-    :returns: :class:`dict`
+    :param dic: Mapping of Unicode strings
+    :type dic: dict
+    :returns: Dictionary containing only UTF-8 strings
+    :rtype: dict
 
 
     """
     """
     if isinstance(dic, CaseInsensitiveDictionary):
     if isinstance(dic, CaseInsensitiveDictionary):
@@ -98,6 +104,7 @@ class NoRedirectHandler(urllib2.HTTPRedirectHandler):
     """Prevent redirections."""
     """Prevent redirections."""
 
 
     def redirect_request(self, *args):
     def redirect_request(self, *args):
+        """Ignore redirect."""
         return None
         return None
 
 
 
 
@@ -134,6 +141,7 @@ class CaseInsensitiveDictionary(dict):
         return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
         return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
 
 
     def get(self, key, default=None):
     def get(self, key, default=None):
+        """Return value for case-insensitive key or default."""
         try:
         try:
             v = dict.__getitem__(self, key.lower())
             v = dict.__getitem__(self, key.lower())
         except KeyError:
         except KeyError:
@@ -142,31 +150,50 @@ class CaseInsensitiveDictionary(dict):
             return v['val']
             return v['val']
 
 
     def update(self, other):
     def update(self, other):
+        """Update values from other ``dict``."""
         for k, v in other.items():
         for k, v in other.items():
             self[k] = v
             self[k] = v
 
 
     def items(self):
     def items(self):
+        """Return ``(key, value)`` pairs."""
         return [(v['key'], v['val']) for v in dict.itervalues(self)]
         return [(v['key'], v['val']) for v in dict.itervalues(self)]
 
 
     def keys(self):
     def keys(self):
+        """Return original keys."""
         return [v['key'] for v in dict.itervalues(self)]
         return [v['key'] for v in dict.itervalues(self)]
 
 
     def values(self):
     def values(self):
+        """Return all values."""
         return [v['val'] for v in dict.itervalues(self)]
         return [v['val'] for v in dict.itervalues(self)]
 
 
     def iteritems(self):
     def iteritems(self):
+        """Iterate over ``(key, value)`` pairs."""
         for v in dict.itervalues(self):
         for v in dict.itervalues(self):
             yield v['key'], v['val']
             yield v['key'], v['val']
 
 
     def iterkeys(self):
     def iterkeys(self):
+        """Iterate over original keys."""
         for v in dict.itervalues(self):
         for v in dict.itervalues(self):
             yield v['key']
             yield v['key']
 
 
     def itervalues(self):
     def itervalues(self):
+        """Interate over values."""
         for v in dict.itervalues(self):
         for v in dict.itervalues(self):
             yield v['val']
             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):
 class Response(object):
     """
     """
     Returned by :func:`request` / :func:`get` / :func:`post` functions.
     Returned by :func:`request` / :func:`get` / :func:`post` functions.
@@ -189,9 +216,9 @@ class Response(object):
     def __init__(self, request, stream=False):
     def __init__(self, request, stream=False):
         """Call `request` with :mod:`urllib2` and process results.
         """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
         :param stream: Whether to stream response or retrieve it all at once
-        :type stream: ``bool``
+        :type stream: bool
 
 
         """
         """
         self.request = request
         self.request = request
@@ -238,8 +265,8 @@ class Response(object):
             # Transfer-Encoding appears to not be used in the wild
             # Transfer-Encoding appears to not be used in the wild
             # (contrary to the HTTP standard), but no harm in testing
             # (contrary to the HTTP standard), but no harm in testing
             # for it
             # 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
                 self._gzipped = True
 
 
     @property
     @property
@@ -248,6 +275,7 @@ class Response(object):
 
 
         Returns:
         Returns:
             bool: `True` if response is streamed.
             bool: `True` if response is streamed.
+
         """
         """
         return self._stream
         return self._stream
 
 
@@ -263,7 +291,7 @@ class Response(object):
         """Decode response contents as JSON.
         """Decode response contents as JSON.
 
 
         :returns: object decoded from 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')
         return json.loads(self.content, self.encoding or 'utf-8')
@@ -272,7 +300,8 @@ class Response(object):
     def encoding(self):
     def encoding(self):
         """Text encoding of document or ``None``.
         """Text encoding of document or ``None``.
 
 
-        :returns: :class:`str` or ``None``
+        :returns: Text encoding if found.
+        :rtype: str or ``None``
 
 
         """
         """
         if not self._encoding:
         if not self._encoding:
@@ -285,7 +314,7 @@ class Response(object):
         """Raw content of response (i.e. bytes).
         """Raw content of response (i.e. bytes).
 
 
         :returns: Body of HTTP response
         :returns: Body of HTTP response
-        :rtype: :class:`str`
+        :rtype: str
 
 
         """
         """
         if not self._content:
         if not self._content:
@@ -310,7 +339,7 @@ class Response(object):
         itself, the encoded response body will be returned instead.
         itself, the encoded response body will be returned instead.
 
 
         :returns: Body of HTTP response
         :returns: Body of HTTP response
-        :rtype: :class:`unicode` or :class:`str`
+        :rtype: unicode or str
 
 
         """
         """
         if self.encoding:
         if self.encoding:
@@ -324,9 +353,9 @@ class Response(object):
         .. versionadded:: 1.6
         .. versionadded:: 1.6
 
 
         :param chunk_size: Number of bytes to read into memory
         :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
         :param decode_unicode: Decode to Unicode using detected encoding
-        :type decode_unicode: ``Boolean``
+        :type decode_unicode: bool
         :returns: iterator
         :returns: iterator
 
 
         """
         """
@@ -340,20 +369,18 @@ class Response(object):
                 "`content` has already been read from this Response.")
                 "`content` has already been read from this Response.")
 
 
         def decode_stream(iterator, r):
         def decode_stream(iterator, r):
-
-            decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace')
+            dec = codecs.getincrementaldecoder(r.encoding)(errors='replace')
 
 
             for chunk in iterator:
             for chunk in iterator:
-                data = decoder.decode(chunk)
+                data = dec.decode(chunk)
                 if data:
                 if data:
                     yield data
                     yield data
 
 
-            data = decoder.decode(b'', final=True)
+            data = dec.decode(b'', final=True)
             if data:  # pragma: no cover
             if data:  # pragma: no cover
                 yield data
                 yield data
 
 
         def generate():
         def generate():
-
             if self._gzipped:
             if self._gzipped:
                 decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
                 decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
 
 
@@ -406,7 +433,7 @@ class Response(object):
         """Get encoding from HTTP headers or content.
         """Get encoding from HTTP headers or content.
 
 
         :returns: encoding or `None`
         :returns: encoding or `None`
-        :rtype: ``unicode`` or ``None``
+        :rtype: unicode or ``None``
 
 
         """
         """
         headers = self.raw.info()
         headers = self.raw.info()
@@ -424,15 +451,15 @@ class Response(object):
         if not self.stream:  # Try sniffing response content
         if not self.stream:  # Try sniffing response content
             # Encoding declared in document should override HTTP headers
             # Encoding declared in document should override HTTP headers
             if self.mimetype == 'text/html':  # sniff HTML 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)
                               self.content)
                 if m:
                 if m:
                     encoding = m.group(1)
                     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)
                               self.content)
                 if m:
                 if m:
                     encoding = m.group(1)
                     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.
     """Initiate an HTTP(S) request. Returns :class:`Response` object.
 
 
     :param method: 'GET' or 'POST'
     :param method: 'GET' or 'POST'
-    :type method: ``unicode``
+    :type method: unicode
     :param url: URL to open
     :param url: URL to open
-    :type url: ``unicode``
+    :type url: unicode
     :param params: mapping of URL parameters
     :param params: mapping of URL parameters
-    :type params: :class:`dict`
+    :type params: dict
     :param data: mapping of form data ``{'field_name': 'value'}`` or
     :param data: mapping of form data ``{'field_name': 'value'}`` or
         :class:`str`
         :class:`str`
-    :type data: :class:`dict` or :class:`str`
+    :type data: dict or str
     :param headers: HTTP headers
     :param headers: HTTP headers
-    :type headers: :class:`dict`
+    :type headers: dict
     :param cookies: cookies to send to server
     :param cookies: cookies to send to server
-    :type cookies: :class:`dict`
+    :type cookies: dict
     :param files: files to upload (see below).
     :param files: files to upload (see below).
-    :type files: :class:`dict`
+    :type files: dict
     :param auth: username, password
     :param auth: username, password
-    :type auth: ``tuple``
+    :type auth: tuple
     :param timeout: connection timeout limit in seconds
     :param timeout: connection timeout limit in seconds
-    :type timeout: ``int``
+    :type timeout: int
     :param allow_redirects: follow redirections
     :param allow_redirects: follow redirections
-    :type allow_redirects: ``Boolean``
+    :type allow_redirects: bool
     :param stream: Stream content instead of fetching it all at once.
     :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::
     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)
     headers['accept-encoding'] = ', '.join(encodings)
 
 
-    # Force POST by providing an empty data string
-    if method == 'POST' and not data:
-        data = ''
-
     if files:
     if files:
         if not data:
         if not data:
             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)
         query = urllib.urlencode(str_dict(params), doseq=True)
         url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
         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)
     return Response(req, stream)
 
 
 
 
@@ -579,6 +603,18 @@ def get(url, params=None, headers=None, cookies=None, auth=None,
                    stream=stream)
                    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,
 def post(url, params=None, data=None, headers=None, cookies=None, files=None,
          auth=None, timeout=60, allow_redirects=False, stream=False):
          auth=None, timeout=60, allow_redirects=False, stream=False):
     """Initiate a POST request. Arguments as for :func:`request`.
     """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)
                    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):
 def encode_multipart_formdata(fields, files):
     """Encode form data (``fields``) and ``files`` for POST request.
     """Encode form data (``fields``) and ``files`` for POST request.
 
 
     :param fields: mapping of ``{name : value}`` pairs for normal form fields.
     :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.
     :param files: dictionary of fieldnames/files elements for file data.
                   See below for details.
                   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)``
     :rtype: 2-tuple ``(dict, str)``
 
 
     The ``files`` argument is a dictionary::
     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.
     - ``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):
     def get_content_type(filename):
         """Return or guess mimetype of ``filename``.
         """Return or guess mimetype of ``filename``.
 
 
         :param filename: filename of file
         :param filename: filename of file
-        :type filename: unicode/string
+        :type filename: unicode/str
         :returns: mime-type, e.g. ``text/html``
         :returns: mime-type, e.g. ``text/html``
-        :rtype: :class::class:`str`
+        :rtype: str
 
 
         """
         """
-
         return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
         return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
 
 
     boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
     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
 # 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
 In order for the feedback mechanism to work correctly, it's important
 to create :class:`Item3` and :class:`Modifier` objects via the
 to create :class:`Item3` and :class:`Modifier` objects via the
 :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
 :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
 respectively. If you instantiate :class:`Item3` or :class:`Modifier`
 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
 from __future__ import print_function, unicode_literals, absolute_import
@@ -30,44 +29,151 @@ import json
 import os
 import os
 import sys
 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:
     Attributes:
         arg (unicode): Arg to pass to following action.
         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).
         key (unicode): Modifier key (see above).
         subtitle (unicode): Override item subtitle.
         subtitle (unicode): Override item subtitle.
         valid (bool): Override item validity.
         valid (bool): Override item validity.
         variables (dict): Workflow variables set by this modifier.
         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`.
         """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:
         Args:
             key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
             key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
             subtitle (unicode, optional): Override default subtitle.
             subtitle (unicode, optional): Override default subtitle.
             arg (unicode, optional): Argument to pass for this modifier.
             arg (unicode, optional): Argument to pass for this modifier.
             valid (bool, optional): Override item's validity.
             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.key = key
         self.subtitle = subtitle
         self.subtitle = subtitle
         self.arg = arg
         self.arg = arg
         self.valid = valid
         self.valid = valid
+        self.icon = icon
+        self.icontype = icontype
 
 
         self.config = {}
         self.config = {}
         self.variables = {}
         self.variables = {}
@@ -78,6 +184,7 @@ class Modifier(object):
         Args:
         Args:
             name (unicode): Name of variable.
             name (unicode): Name of variable.
             value (unicode): Value of variable.
             value (unicode): Value of variable.
+
         """
         """
         self.variables[name] = value
         self.variables[name] = value
 
 
@@ -90,6 +197,7 @@ class Modifier(object):
 
 
         Returns:
         Returns:
             unicode or ``default``: Value of variable if set or ``default``.
             unicode or ``default``: Value of variable if set or ``default``.
+
         """
         """
         return self.variables.get(name, default)
         return self.variables.get(name, default)
 
 
@@ -99,6 +207,7 @@ class Modifier(object):
 
 
         Returns:
         Returns:
             dict: Modifier for serializing to JSON.
             dict: Modifier for serializing to JSON.
+
         """
         """
         o = {}
         o = {}
 
 
@@ -111,44 +220,63 @@ class Modifier(object):
         if self.valid is not None:
         if self.valid is not None:
             o['valid'] = self.valid
             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
         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):
 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.
     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,
     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):
                  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.
         Argument ``subtitle_modifiers`` is not supported.
+
         """
         """
         self.title = title
         self.title = title
         self.subtitle = subtitle
         self.subtitle = subtitle
         self.arg = arg
         self.arg = arg
         self.autocomplete = autocomplete
         self.autocomplete = autocomplete
+        self.match = match
         self.valid = valid
         self.valid = valid
         self.uid = uid
         self.uid = uid
         self.icon = icon
         self.icon = icon
@@ -182,10 +310,12 @@ class Item3(object):
 
 
         Returns:
         Returns:
             unicode or ``default``: Value of variable if set or ``default``.
             unicode or ``default``: Value of variable if set or ``default``.
+
         """
         """
         return self.variables.get(name, 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.
         """Add alternative values for a modifier key.
 
 
         Args:
         Args:
@@ -193,14 +323,19 @@ class Item3(object):
             subtitle (unicode, optional): Override item subtitle.
             subtitle (unicode, optional): Override item subtitle.
             arg (unicode, optional): Input for following action.
             arg (unicode, optional): Input for following action.
             valid (bool, optional): Override item validity.
             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:
         Returns:
             Modifier: Configured :class:`Modifier`.
             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
         self.modifiers[key] = mod
 
 
@@ -212,13 +347,14 @@ class Item3(object):
 
 
         Returns:
         Returns:
             dict: Data suitable for Alfred 3 feedback.
             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
         # Optional values
         if self.arg is not None:
         if self.arg is not None:
@@ -227,6 +363,9 @@ class Item3(object):
         if self.autocomplete is not None:
         if self.autocomplete is not None:
             o['autocomplete'] = self.autocomplete
             o['autocomplete'] = self.autocomplete
 
 
+        if self.match is not None:
+            o['match'] = self.match
+
         if self.uid is not None:
         if self.uid is not None:
             o['uid'] = self.uid
             o['uid'] = self.uid
 
 
@@ -236,6 +375,12 @@ class Item3(object):
         if self.quicklookurl is not None:
         if self.quicklookurl is not None:
             o['quicklookurl'] = self.quicklookurl
             o['quicklookurl'] = self.quicklookurl
 
 
+        if self.variables:
+            o['variables'] = self.variables
+
+        if self.config:
+            o['config'] = self.config
+
         # Largetype and copytext
         # Largetype and copytext
         text = self._text()
         text = self._text()
         if text:
         if text:
@@ -245,11 +390,6 @@ class Item3(object):
         if icon:
         if icon:
             o['icon'] = icon
             o['icon'] = icon
 
 
-        # Variables and config
-        js = self._vars_and_config()
-        if js:
-            o['arg'] = js
-
         # Modifiers
         # Modifiers
         mods = self._modifiers()
         mods = self._modifiers()
         if mods:
         if mods:
@@ -262,6 +402,7 @@ class Item3(object):
 
 
         Returns:
         Returns:
             dict: Mapping for item `icon` (may be empty).
             dict: Mapping for item `icon` (may be empty).
+
         """
         """
         icon = {}
         icon = {}
         if self.icon is not None:
         if self.icon is not None:
@@ -277,6 +418,7 @@ class Item3(object):
 
 
         Returns:
         Returns:
             dict: `text` mapping (may be empty)
             dict: `text` mapping (may be empty)
+
         """
         """
         text = {}
         text = {}
         if self.largetext is not None:
         if self.largetext is not None:
@@ -287,32 +429,12 @@ class Item3(object):
 
 
         return text
         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):
     def _modifiers(self):
         """Build `mods` dictionary for JSON feedback.
         """Build `mods` dictionary for JSON feedback.
 
 
         Returns:
         Returns:
             dict: Modifier mapping or `None`.
             dict: Modifier mapping or `None`.
+
         """
         """
         if self.modifiers:
         if self.modifiers:
             mods = {}
             mods = {}
@@ -325,11 +447,15 @@ class Item3(object):
 
 
 
 
 class Workflow3(Workflow):
 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:
     Attributes:
         item_class (class): Class used to generate feedback items.
         item_class (class): Class used to generate feedback items.
         variables (dict): Top level workflow variables.
         variables (dict): Top level workflow variables.
+
     """
     """
 
 
     item_class = Item3
     item_class = Item3
@@ -337,27 +463,31 @@ class Workflow3(Workflow):
     def __init__(self, **kwargs):
     def __init__(self, **kwargs):
         """Create a new :class:`Workflow3` object.
         """Create a new :class:`Workflow3` object.
 
 
-        See :class:`~workflow.workflow.Workflow` for documentation.
+        See :class:`~workflow.Workflow` for documentation.
+
         """
         """
         Workflow.__init__(self, **kwargs)
         Workflow.__init__(self, **kwargs)
         self.variables = {}
         self.variables = {}
         self._rerun = 0
         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
     @property
     def _default_cachedir(self):
     def _default_cachedir(self):
-        """Alfred 3's default cache directory."""
+        """Alfred 4's default cache directory."""
         return os.path.join(
         return os.path.join(
             os.path.expanduser(
             os.path.expanduser(
-                '~/Library/Caches/com.runningwithcrayons.Alfred-3/'
+                '~/Library/Caches/com.runningwithcrayons.Alfred/'
                 'Workflow Data/'),
                 'Workflow Data/'),
             self.bundleid)
             self.bundleid)
 
 
     @property
     @property
     def _default_datadir(self):
     def _default_datadir(self):
-        """Alfred 3's default data directory."""
+        """Alfred 4's default data directory."""
         return os.path.join(os.path.expanduser(
         return os.path.join(os.path.expanduser(
-            '~/Library/Application Support/Alfred 3/Workflow Data/'),
+            '~/Library/Application Support/Alfred/Workflow Data/'),
             self.bundleid)
             self.bundleid)
 
 
     @property
     @property
@@ -386,19 +516,17 @@ class Workflow3(Workflow):
 
 
         """
         """
         if not self._session_id:
         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
         return self._session_id
 
 
-    def setvar(self, name, value):
+    def setvar(self, name, value, persist=False):
         """Set a "global" workflow variable.
         """Set a "global" workflow variable.
 
 
+        .. versionchanged:: 1.33
+
         These variables are always passed to downstream workflow objects.
         These variables are always passed to downstream workflow objects.
 
 
         If you have set :attr:`rerun`, these variables are also passed
         If you have set :attr:`rerun`, these variables are also passed
@@ -407,8 +535,15 @@ class Workflow3(Workflow):
         Args:
         Args:
             name (unicode): Name of variable.
             name (unicode): Name of variable.
             value (unicode): Value of variable.
             value (unicode): Value of variable.
+            persist (bool, optional): Also save variable to ``info.plist``?
+
         """
         """
         self.variables[name] = value
         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):
     def getvar(self, name, default=None):
         """Return value of workflow variable for ``name`` or ``default``.
         """Return value of workflow variable for ``name`` or ``default``.
@@ -419,16 +554,22 @@ class Workflow3(Workflow):
 
 
         Returns:
         Returns:
             unicode or ``default``: Value of variable if set or ``default``.
             unicode or ``default``: Value of variable if set or ``default``.
+
         """
         """
         return self.variables.get(name, default)
         return self.variables.get(name, default)
 
 
     def add_item(self, title, subtitle='', arg=None, autocomplete=None,
     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.
         """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
         The key difference is that this method does not support the
         ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
         ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
@@ -436,17 +577,26 @@ class Workflow3(Workflow):
 
 
         Returns:
         Returns:
             Item3: Alfred feedback item.
             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)
                                largetext, copytext, quicklookurl)
 
 
+        # Add variables to child item
+        item.variables.update(self.variables)
+
         self._items.append(item)
         self._items.append(item)
         return 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):
     def _mk_session_name(self, name):
         """New cache name/key based on session ID."""
         """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):
     def cache_data(self, name, data, session=False):
         """Cache API with session-scoped expiry.
         """Cache API with session-scoped expiry.
@@ -459,11 +609,11 @@ class Workflow3(Workflow):
             session (bool, optional): Whether to scope the cache
             session (bool, optional): Whether to scope the cache
                 to the current session.
                 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`.
         with :attr:`session_id`.
 
 
         """
         """
@@ -485,11 +635,11 @@ class Workflow3(Workflow):
             session (bool, optional): Whether to scope the cache
             session (bool, optional): Whether to scope the cache
                 to the current session.
                 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`.
         with :attr:`session_id`.
 
 
         """
         """
@@ -498,13 +648,25 @@ class Workflow3(Workflow):
 
 
         return super(Workflow3, self).cached_data(name, data_func, max_age)
         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
         .. 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):
         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)
         self.clear_cache(_is_session_file)
 
 
@@ -514,6 +676,7 @@ class Workflow3(Workflow):
 
 
         Returns:
         Returns:
             dict: Data suitable for Alfred 3 feedback.
             dict: Data suitable for Alfred 3 feedback.
+
         """
         """
         items = []
         items = []
         for item in self._items:
         for item in self._items:
@@ -526,7 +689,36 @@ class Workflow3(Workflow):
             o['rerun'] = self.rerun
             o['rerun'] = self.rerun
         return o
         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):
     def send_feedback(self):
         """Print stored items to console/Alfred as JSON."""
         """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()
         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