diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5de4520 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Alfred_Workflow-1.21.1-py2.7.egg-info diff --git a/README.md b/README.md index eb902c0..e6bba94 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # alfred3-gmaps-place-search A simple workflow to convert clipboard text to an address returned by Google Maps Search + +### Installation + +grab the latest `.alfredworkflow` file here + +##### Planned Features + +Workflow script filter to return results to queries like + +`CVS near 1600 Pennsylvania Ave NW, Washington, DC 20500` as a list + +##### Tutorial Video + +I made this for my girlfriend, who I am introducing to programming, and thought why not share it. It shows me writing the script, obtaining an API key for Google Places, and using postman to submit and view results of HTTP GET requests, generating the python code, and creating the Alfred workflow. + + diff --git a/gmapsplacesearch.alfredworkflow b/gmapsplacesearch.alfredworkflow new file mode 100644 index 0000000..109bd70 Binary files /dev/null and b/gmapsplacesearch.alfredworkflow differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..e12b930 Binary files /dev/null and b/icon.png differ diff --git a/info.plist b/info.plist new file mode 100644 index 0000000..2c378a6 --- /dev/null +++ b/info.plist @@ -0,0 +1,188 @@ + + + + + bundleid + com.invious.gmaps.placesearch + connections + + 03DCD9A0-786E-460C-A734-DEB7F0C88D96 + + + destinationuid + 74355592-AF60-4FCA-AC56-EBF4476AD5DA + modifiers + 0 + modifiersubtext + + vitoclose + + + + 74355592-AF60-4FCA-AC56-EBF4476AD5DA + + + destinationuid + B51D4FB5-F97E-40E6-A135-6046DED947CD + modifiers + 0 + modifiersubtext + + vitoclose + + + + destinationuid + CF6B026C-8865-48DB-BFEF-75F77568B379 + modifiers + 0 + modifiersubtext + + vitoclose + + + + + createdby + Aymon Fournier + description + simple workflow to take selected or clipboard text and return an address + disabled + + name + Google Maps Place Search + objects + + + config + + lastpathcomponent + + onlyshowifquerypopulated + + removeextension + + text + Your address result has been copied to your clipboard. + title + Google Maps Places API Search + + type + alfred.workflow.output.notification + uid + B51D4FB5-F97E-40E6-A135-6046DED947CD + version + 1 + + + config + + argumenttype + 0 + keyword + gmapsearch + subtext + Ener query liek you would on GMaps + text + Search places API, get address + withspace + + + type + alfred.workflow.input.keyword + uid + 03DCD9A0-786E-460C-A734-DEB7F0C88D96 + version + 1 + + + config + + concurrently + + escaping + 68 + script + import requests + +url = "https://maps.googleapis.com/maps/api/place/textsearch/json" + +querystring = {"query": "{query}", + "key": "AIzaSyC0r4UxnX_COKvG10vK_NBjon-4CNKJeCI"} + +headers = { + 'cache-control': "no-cache", +} + +response = requests.request("GET", url, headers=headers, params=querystring) + +print response.json()['results'][0]['formatted_address'] + scriptargtype + 0 + scriptfile + + type + 3 + + type + alfred.workflow.action.script + uid + 74355592-AF60-4FCA-AC56-EBF4476AD5DA + version + 2 + + + config + + autopaste + + clipboardtext + {query} + transient + + + type + alfred.workflow.output.clipboard + uid + CF6B026C-8865-48DB-BFEF-75F77568B379 + version + 2 + + + readme + + uidata + + 03DCD9A0-786E-460C-A734-DEB7F0C88D96 + + xpos + 30 + ypos + 30 + + 74355592-AF60-4FCA-AC56-EBF4476AD5DA + + xpos + 250 + ypos + 30 + + B51D4FB5-F97E-40E6-A135-6046DED947CD + + xpos + 570 + ypos + 20 + + CF6B026C-8865-48DB-BFEF-75F77568B379 + + xpos + 560 + ypos + 150 + + + webaddress + invio.us + + diff --git a/script.py b/script.py new file mode 100644 index 0000000..220ac91 --- /dev/null +++ b/script.py @@ -0,0 +1,14 @@ +import requests + +url = "https://maps.googleapis.com/maps/api/place/textsearch/json" + +querystring = {"query": "Van Dyke Technology Group", + "key": "AIzaSyC0r4UxnX_COKvG10vK_NBjon-4CNKJeCI"} + +headers = { + 'cache-control': "no-cache", +} + +response = requests.request("GET", url, headers=headers, params=querystring) + +print response.json()['results'][0]['formatted_address'] diff --git a/workflow/Notify.tgz b/workflow/Notify.tgz new file mode 100644 index 0000000..174e9a7 Binary files /dev/null and b/workflow/Notify.tgz differ diff --git a/workflow/__init__.py b/workflow/__init__.py new file mode 100644 index 0000000..632f1f5 --- /dev/null +++ b/workflow/__init__.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +"""A helper library for `Alfred `_ workflows.""" + +import os + +# Workflow objects +from .workflow import Workflow, manager +from .workflow3 import Workflow3 + +# Exceptions +from .workflow import PasswordNotFound, KeychainError + +# Icons +from .workflow import ( + ICON_ACCOUNT, + ICON_BURN, + ICON_CLOCK, + ICON_COLOR, + ICON_COLOUR, + ICON_EJECT, + ICON_ERROR, + ICON_FAVORITE, + ICON_FAVOURITE, + ICON_GROUP, + ICON_HELP, + ICON_HOME, + ICON_INFO, + ICON_NETWORK, + ICON_NOTE, + ICON_SETTINGS, + ICON_SWIRL, + ICON_SWITCH, + ICON_SYNC, + ICON_TRASH, + ICON_USER, + ICON_WARNING, + ICON_WEB, +) + +# Filter matching rules +from .workflow import ( + MATCH_ALL, + MATCH_ALLCHARS, + MATCH_ATOM, + MATCH_CAPITALS, + MATCH_INITIALS, + MATCH_INITIALS_CONTAIN, + MATCH_INITIALS_STARTSWITH, + MATCH_STARTSWITH, + MATCH_SUBSTRING, +) + + +__title__ = 'Alfred-Workflow' +__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() +__author__ = 'Dean Jackson' +__licence__ = 'MIT' +__copyright__ = 'Copyright 2014 Dean Jackson' + +__all__ = [ + 'Workflow', + 'Workflow3', + 'manager', + 'PasswordNotFound', + 'KeychainError', + 'ICON_ACCOUNT', + 'ICON_BURN', + 'ICON_CLOCK', + 'ICON_COLOR', + 'ICON_COLOUR', + 'ICON_EJECT', + 'ICON_ERROR', + 'ICON_FAVORITE', + 'ICON_FAVOURITE', + 'ICON_GROUP', + 'ICON_HELP', + 'ICON_HOME', + 'ICON_INFO', + 'ICON_NETWORK', + 'ICON_NOTE', + 'ICON_SETTINGS', + 'ICON_SWIRL', + 'ICON_SWITCH', + 'ICON_SYNC', + 'ICON_TRASH', + 'ICON_USER', + 'ICON_WARNING', + 'ICON_WEB', + 'MATCH_ALL', + 'MATCH_ALLCHARS', + 'MATCH_ATOM', + 'MATCH_CAPITALS', + 'MATCH_INITIALS', + 'MATCH_INITIALS_CONTAIN', + 'MATCH_INITIALS_STARTSWITH', + 'MATCH_STARTSWITH', + 'MATCH_SUBSTRING', +] diff --git a/workflow/__init__.pyc b/workflow/__init__.pyc new file mode 100644 index 0000000..7c58600 Binary files /dev/null and b/workflow/__init__.pyc differ diff --git a/workflow/background.py b/workflow/background.py new file mode 100644 index 0000000..b3327ca --- /dev/null +++ b/workflow/background.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2014 deanishe@deanishe.net +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-04-06 +# + +""" +Run background tasks +""" + +from __future__ import print_function, unicode_literals + +import sys +import os +import subprocess +import pickle + +from workflow import Workflow + +__all__ = ['is_running', 'run_in_background'] + +_wf = None + + +def wf(): + global _wf + if _wf is None: + _wf = Workflow() + return _wf + + +def _arg_cache(name): + """Return path to pickle cache file for arguments + + :param name: name of task + :type name: ``unicode`` + :returns: Path to cache file + :rtype: ``unicode`` filepath + + """ + + return wf().cachefile('{0}.argcache'.format(name)) + + +def _pid_file(name): + """Return path to PID file for ``name`` + + :param name: name of task + :type name: ``unicode`` + :returns: Path to PID file for task + :rtype: ``unicode`` filepath + + """ + + return wf().cachefile('{0}.pid'.format(name)) + + +def _process_exists(pid): + """Check if a process with PID ``pid`` exists + + :param pid: PID to check + :type pid: ``int`` + :returns: ``True`` if process exists, else ``False`` + :rtype: ``Boolean`` + """ + + try: + os.kill(pid, 0) + except OSError: # not running + return False + return True + + +def is_running(name): + """ + Test whether task is running under ``name`` + + :param name: name of task + :type name: ``unicode`` + :returns: ``True`` if task with name ``name`` is running, else ``False`` + :rtype: ``Boolean`` + + """ + pidfile = _pid_file(name) + if not os.path.exists(pidfile): + return False + + with open(pidfile, 'rb') as file_obj: + pid = int(file_obj.read().strip()) + + if _process_exists(pid): + return True + + elif os.path.exists(pidfile): + os.unlink(pidfile) + + return False + + +def _background(stdin='/dev/null', stdout='/dev/null', + stderr='/dev/null'): # pragma: no cover + """Fork the current process into a background daemon. + + :param stdin: where to read input + :type stdin: filepath + :param stdout: where to write stdout output + :type stdout: filepath + :param stderr: where to write stderr output + :type stderr: filepath + + """ + + # Do first fork. + try: + pid = os.fork() + if pid > 0: + sys.exit(0) # Exit first parent. + except OSError as e: + wf().logger.critical("fork #1 failed: ({0:d}) {1}".format( + e.errno, e.strerror)) + sys.exit(1) + # Decouple from parent environment. + os.chdir(wf().workflowdir) + os.umask(0) + os.setsid() + # Do second fork. + try: + pid = os.fork() + if pid > 0: + sys.exit(0) # Exit second parent. + except OSError as e: + wf().logger.critical("fork #2 failed: ({0:d}) {1}".format( + e.errno, e.strerror)) + sys.exit(1) + # Now I am a daemon! + # Redirect standard file descriptors. + si = file(stdin, 'r', 0) + so = file(stdout, 'a+', 0) + se = file(stderr, 'a+', 0) + if hasattr(sys.stdin, 'fileno'): + os.dup2(si.fileno(), sys.stdin.fileno()) + if hasattr(sys.stdout, 'fileno'): + os.dup2(so.fileno(), sys.stdout.fileno()) + if hasattr(sys.stderr, 'fileno'): + os.dup2(se.fileno(), sys.stderr.fileno()) + + +def run_in_background(name, args, **kwargs): + """Pickle arguments to cache file, then call this script again via + :func:`subprocess.call`. + + :param name: name of task + :type name: ``unicode`` + :param args: arguments passed as first argument to :func:`subprocess.call` + :param \**kwargs: keyword arguments to :func:`subprocess.call` + :returns: exit code of sub-process + :rtype: ``int`` + + When you call this function, it caches its arguments and then calls + ``background.py`` in a subprocess. The Python subprocess will load the + cached arguments, fork into the background, and then run the command you + specified. + + This function will return as soon as the ``background.py`` subprocess has + forked, returning the exit code of *that* process (i.e. not of the command + you're trying to run). + + If that process fails, an error will be written to the log file. + + If a process is already running under the same name, this function will + return immediately and will not run the specified command. + + """ + + if is_running(name): + wf().logger.info('Task `{0}` is already running'.format(name)) + return + + argcache = _arg_cache(name) + + # Cache arguments + with open(argcache, 'wb') as file_obj: + pickle.dump({'args': args, 'kwargs': kwargs}, file_obj) + wf().logger.debug('Command arguments cached to `{0}`'.format(argcache)) + + # Call this script + cmd = ['/usr/bin/python', __file__, name] + wf().logger.debug('Calling {0!r} ...'.format(cmd)) + retcode = subprocess.call(cmd) + if retcode: # pragma: no cover + wf().logger.error('Failed to call task in background') + else: + wf().logger.debug('Executing task `{0}` in background...'.format(name)) + return retcode + + +def main(wf): # pragma: no cover + """ + Load cached arguments, fork into background, then call + :meth:`subprocess.call` with cached arguments + + """ + + name = wf.args[0] + argcache = _arg_cache(name) + if not os.path.exists(argcache): + wf.logger.critical('No arg cache found : {0!r}'.format(argcache)) + return 1 + + # Load cached arguments + with open(argcache, 'rb') as file_obj: + data = pickle.load(file_obj) + + # Cached arguments + args = data['args'] + kwargs = data['kwargs'] + + # Delete argument cache file + os.unlink(argcache) + + pidfile = _pid_file(name) + + # Fork to background + _background() + + # Write PID to file + with open(pidfile, 'wb') as file_obj: + file_obj.write('{0}'.format(os.getpid())) + + # Run the command + try: + wf.logger.debug('Task `{0}` running'.format(name)) + wf.logger.debug('cmd : {0!r}'.format(args)) + + retcode = subprocess.call(args, **kwargs) + + if retcode: + wf.logger.error('Command failed with [{0}] : {1!r}'.format( + retcode, args)) + + finally: + if os.path.exists(pidfile): + os.unlink(pidfile) + wf.logger.debug('Task `{0}` finished'.format(name)) + + +if __name__ == '__main__': # pragma: no cover + wf().run(main) diff --git a/workflow/background.pyc b/workflow/background.pyc new file mode 100644 index 0000000..923bc84 Binary files /dev/null and b/workflow/background.pyc differ diff --git a/workflow/notify.py b/workflow/notify.py new file mode 100644 index 0000000..3ed1e5e --- /dev/null +++ b/workflow/notify.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2015 deanishe@deanishe.net +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2015-11-26 +# + +# TODO: Exclude this module from test and code coverage in py2.6 + +""" +Post notifications via the OS X Notification Center. This feature +is only available on Mountain Lion (10.8) and later. It will +silently fail on older systems. + +The main API is a single function, :func:`~workflow.notify.notify`. + +It works by copying a simple application to your workflow's data +directory. It replaces the application's icon with your workflow's +icon and then calls the application to post notifications. +""" + +from __future__ import print_function, unicode_literals + +import os +import plistlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import uuid + +import workflow + + +_wf = None +_log = None + + +#: Available system sounds from System Preferences > Sound > Sound Effects +SOUNDS = ( + 'Basso', + 'Blow', + 'Bottle', + 'Frog', + 'Funk', + 'Glass', + 'Hero', + 'Morse', + 'Ping', + 'Pop', + 'Purr', + 'Sosumi', + 'Submarine', + 'Tink', +) + + +def wf(): + """Return `Workflow` object for this module. + + Returns: + workflow.Workflow: `Workflow` object for current workflow. + """ + global _wf + if _wf is None: + _wf = workflow.Workflow() + return _wf + + +def log(): + """Return logger for this module. + + Returns: + logging.Logger: Logger for this module. + """ + global _log + if _log is None: + _log = wf().logger + return _log + + +def notifier_program(): + """Return path to notifier applet executable. + + Returns: + unicode: Path to Notify.app `applet` executable. + """ + return wf().datafile('Notify.app/Contents/MacOS/applet') + + +def notifier_icon_path(): + """Return path to icon file in installed Notify.app. + + Returns: + unicode: Path to `applet.icns` within the app bundle. + """ + return wf().datafile('Notify.app/Contents/Resources/applet.icns') + + +def install_notifier(): + """Extract `Notify.app` from the workflow to data directory. + + Changes the bundle ID of the installed app and gives it the + workflow's icon. + """ + archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') + destdir = wf().datadir + app_path = os.path.join(destdir, 'Notify.app') + n = notifier_program() + log().debug("Installing Notify.app to %r ...", destdir) + # z = zipfile.ZipFile(archive, 'r') + # z.extractall(destdir) + tgz = tarfile.open(archive, 'r:gz') + tgz.extractall(destdir) + assert os.path.exists(n), ( + "Notify.app could not be installed in {0!r}.".format(destdir)) + + # Replace applet icon + icon = notifier_icon_path() + workflow_icon = wf().workflowfile('icon.png') + if os.path.exists(icon): + os.unlink(icon) + + png_to_icns(workflow_icon, icon) + + # Set file icon + # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, + # none of this code will "work" on pre-10.8 systems. Let it run + # until I figure out a better way of excluding this module + # from coverage in py2.6. + if sys.version_info >= (2, 7): # pragma: no cover + from AppKit import NSWorkspace, NSImage + + ws = NSWorkspace.sharedWorkspace() + img = NSImage.alloc().init() + img.initWithContentsOfFile_(icon) + ws.setIcon_forFile_options_(img, app_path, 0) + + # Change bundle ID of installed app + ip_path = os.path.join(app_path, 'Contents/Info.plist') + bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) + data = plistlib.readPlist(ip_path) + log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) + data['CFBundleIdentifier'] = bundle_id + plistlib.writePlist(data, ip_path) + + +def validate_sound(sound): + """Coerce `sound` to valid sound name. + + Returns `None` for invalid sounds. Sound names can be found + in `System Preferences > Sound > Sound Effects`. + + Args: + sound (str): Name of system sound. + + Returns: + str: Proper name of sound or `None`. + """ + if not sound: + return None + + # Case-insensitive comparison of `sound` + if sound.lower() in [s.lower() for s in SOUNDS]: + # Title-case is correct for all system sounds as of OS X 10.11 + return sound.title() + return None + + +def notify(title='', text='', sound=None): + """Post notification via Notify.app helper. + + Args: + title (str, optional): Notification title. + text (str, optional): Notification body text. + sound (str, optional): Name of sound to play. + + Raises: + ValueError: Raised if both `title` and `text` are empty. + + Returns: + bool: `True` if notification was posted, else `False`. + """ + if title == text == '': + raise ValueError('Empty notification') + + sound = validate_sound(sound) or '' + + n = notifier_program() + + if not os.path.exists(n): + install_notifier() + + env = os.environ.copy() + enc = 'utf-8' + env['NOTIFY_TITLE'] = title.encode(enc) + env['NOTIFY_MESSAGE'] = text.encode(enc) + env['NOTIFY_SOUND'] = sound.encode(enc) + cmd = [n] + retcode = subprocess.call(cmd, env=env) + if retcode == 0: + return True + + log().error('Notify.app exited with status {0}.'.format(retcode)) + return False + + +def convert_image(inpath, outpath, size): + """Convert an image file using `sips`. + + Args: + inpath (str): Path of source file. + outpath (str): Path to destination file. + size (int): Width and height of destination image in pixels. + + Raises: + RuntimeError: Raised if `sips` exits with non-zero status. + """ + cmd = [ + b'sips', + b'-z', b'{0}'.format(size), b'{0}'.format(size), + inpath, + b'--out', outpath] + # log().debug(cmd) + with open(os.devnull, 'w') as pipe: + retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) + + if retcode != 0: + raise RuntimeError('sips exited with {0}'.format(retcode)) + + +def png_to_icns(png_path, icns_path): + """Convert PNG file to ICNS using `iconutil`. + + Create an iconset from the source PNG file. Generate PNG files + in each size required by OS X, then call `iconutil` to turn + them into a single ICNS file. + + Args: + png_path (str): Path to source PNG file. + icns_path (str): Path to destination ICNS file. + + Raises: + RuntimeError: Raised if `iconutil` or `sips` fail. + """ + tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) + + try: + iconset = os.path.join(tempdir, 'Icon.iconset') + + assert not os.path.exists(iconset), ( + "Iconset path already exists : {0!r}".format(iconset)) + os.makedirs(iconset) + + # Copy source icon to icon set and generate all the other + # sizes needed + configs = [] + for i in (16, 32, 128, 256, 512): + configs.append(('icon_{0}x{0}.png'.format(i), i)) + configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) + + shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) + shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) + + for name, size in configs: + outpath = os.path.join(iconset, name) + if os.path.exists(outpath): + continue + convert_image(png_path, outpath, size) + + cmd = [ + b'iconutil', + b'-c', b'icns', + b'-o', icns_path, + iconset] + + retcode = subprocess.call(cmd) + if retcode != 0: + raise RuntimeError("iconset exited with {0}".format(retcode)) + + assert os.path.exists(icns_path), ( + "Generated ICNS file not found : {0!r}".format(icns_path)) + finally: + try: + shutil.rmtree(tempdir) + except OSError: # pragma: no cover + pass + + +# def notify_native(title='', text='', sound=''): +# """Post notification via the native API (via pyobjc). + +# At least one of `title` or `text` must be specified. + +# This method will *always* show the Python launcher icon (i.e. the +# rocket with the snakes on it). + +# Args: +# title (str, optional): Notification title. +# text (str, optional): Notification body text. +# sound (str, optional): Name of sound to play. + +# """ + +# if title == text == '': +# raise ValueError('Empty notification') + +# import Foundation + +# sound = sound or Foundation.NSUserNotificationDefaultSoundName + +# n = Foundation.NSUserNotification.alloc().init() +# n.setTitle_(title) +# n.setInformativeText_(text) +# n.setSoundName_(sound) +# nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() +# nc.deliverNotification_(n) + + +if __name__ == '__main__': # pragma: nocover + # Simple command-line script to test module with + # This won't work on 2.6, as `argparse` isn't available + # by default. + import argparse + + from unicodedata import normalize + + def uni(s): + """Coerce `s` to normalised Unicode.""" + ustr = s.decode('utf-8') + return normalize('NFD', ustr) + + p = argparse.ArgumentParser() + p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") + p.add_argument('-l', '--list-sounds', help="Show available sounds.", + action='store_true') + p.add_argument('-t', '--title', + help="Notification title.", type=uni, + default='') + p.add_argument('-s', '--sound', type=uni, + help="Optional notification sound.", default='') + p.add_argument('text', type=uni, + help="Notification body text.", default='', nargs='?') + o = p.parse_args() + + # List available sounds + if o.list_sounds: + for sound in SOUNDS: + print(sound) + sys.exit(0) + + # Convert PNG to ICNS + if o.png: + icns = os.path.join( + os.path.dirname(o.png), + b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], + '.icns')) + + print('Converting {0!r} to {1!r} ...'.format(o.png, icns), + file=sys.stderr) + + assert not os.path.exists(icns), ( + "Destination file already exists : {0}".format(icns)) + + png_to_icns(o.png, icns) + sys.exit(0) + + # Post notification + if o.title == o.text == '': + print('ERROR: Empty notification.', file=sys.stderr) + sys.exit(1) + else: + notify(o.title, o.text, o.sound) diff --git a/workflow/notify.pyc b/workflow/notify.pyc new file mode 100644 index 0000000..826cd0c Binary files /dev/null and b/workflow/notify.pyc differ diff --git a/workflow/update.py b/workflow/update.py new file mode 100644 index 0000000..468d024 --- /dev/null +++ b/workflow/update.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2014 Fabio Niephaus , +# Dean Jackson +# +# 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 ` + 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 ` 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() diff --git a/workflow/update.pyc b/workflow/update.pyc new file mode 100644 index 0000000..5a03201 Binary files /dev/null and b/workflow/update.pyc differ diff --git a/workflow/version b/workflow/version new file mode 100644 index 0000000..87967a7 --- /dev/null +++ b/workflow/version @@ -0,0 +1 @@ +1.21.1 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py new file mode 100644 index 0000000..748b199 --- /dev/null +++ b/workflow/web.py @@ -0,0 +1,671 @@ +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +"""Lightweight HTTP library with a requests-like interface.""" + +import codecs +import json +import mimetypes +import os +import random +import re +import socket +import string +import unicodedata +import urllib +import urllib2 +import urlparse +import zlib + + +USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' + +# Valid characters for multipart form data boundaries +BOUNDARY_CHARS = string.digits + string.ascii_letters + +# HTTP response codes +RESPONSES = { + 100: 'Continue', + 101: 'Switching Protocols', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported' +} + + +def str_dict(dic): + """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. + + :param dic: :class:`dict` of Unicode strings + :returns: :class:`dict` + + """ + if isinstance(dic, CaseInsensitiveDictionary): + dic2 = CaseInsensitiveDictionary() + else: + dic2 = {} + for k, v in dic.items(): + if isinstance(k, unicode): + k = k.encode('utf-8') + if isinstance(v, unicode): + v = v.encode('utf-8') + dic2[k] = v + return dic2 + + +class NoRedirectHandler(urllib2.HTTPRedirectHandler): + """Prevent redirections.""" + + def redirect_request(self, *args): + return None + + +# Adapted from https://gist.github.com/babakness/3901174 +class CaseInsensitiveDictionary(dict): + """Dictionary with caseless key search. + + Enables case insensitive searching while preserving case sensitivity + when keys are listed, ie, via keys() or items() methods. + + Works by storing a lowercase version of the key as the new key and + stores the original key-value pair as the key's value + (values become dictionaries). + + """ + + def __init__(self, initval=None): + """Create new case-insensitive dictionary.""" + if isinstance(initval, dict): + for key, value in initval.iteritems(): + self.__setitem__(key, value) + + elif isinstance(initval, list): + for (key, value) in initval: + self.__setitem__(key, value) + + def __contains__(self, key): + return dict.__contains__(self, key.lower()) + + def __getitem__(self, key): + return dict.__getitem__(self, key.lower())['val'] + + def __setitem__(self, key, value): + return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) + + def get(self, key, default=None): + try: + v = dict.__getitem__(self, key.lower()) + except KeyError: + return default + else: + return v['val'] + + def update(self, other): + for k, v in other.items(): + self[k] = v + + def items(self): + return [(v['key'], v['val']) for v in dict.itervalues(self)] + + def keys(self): + return [v['key'] for v in dict.itervalues(self)] + + def values(self): + return [v['val'] for v in dict.itervalues(self)] + + def iteritems(self): + for v in dict.itervalues(self): + yield v['key'], v['val'] + + def iterkeys(self): + for v in dict.itervalues(self): + yield v['key'] + + def itervalues(self): + for v in dict.itervalues(self): + yield v['val'] + + +class Response(object): + """ + Returned by :func:`request` / :func:`get` / :func:`post` functions. + + Simplified version of the ``Response`` object in the ``requests`` library. + + >>> r = request('http://www.google.com') + >>> r.status_code + 200 + >>> r.encoding + ISO-8859-1 + >>> r.content # bytes + ... + >>> r.text # unicode, decoded according to charset in HTTP header/meta tag + u' ...' + >>> r.json() # content parsed as JSON + + """ + + def __init__(self, request, stream=False): + """Call `request` with :mod:`urllib2` and process results. + + :param request: :class:`urllib2.Request` instance + :param stream: Whether to stream response or retrieve it all at once + :type stream: ``bool`` + + """ + self.request = request + self._stream = stream + self.url = None + self.raw = None + self._encoding = None + self.error = None + self.status_code = None + self.reason = None + self.headers = CaseInsensitiveDictionary() + self._content = None + self._content_loaded = False + self._gzipped = False + + # Execute query + try: + self.raw = urllib2.urlopen(request) + except urllib2.HTTPError as err: + self.error = err + try: + self.url = err.geturl() + # sometimes (e.g. when authentication fails) + # urllib can't get a URL from an HTTPError + # This behaviour changes across Python versions, + # so no test cover (it isn't important). + except AttributeError: # pragma: no cover + pass + self.status_code = err.code + else: + self.status_code = self.raw.getcode() + self.url = self.raw.geturl() + self.reason = RESPONSES.get(self.status_code) + + # Parse additional info if request succeeded + if not self.error: + headers = self.raw.info() + self.transfer_encoding = headers.getencoding() + self.mimetype = headers.gettype() + for key in headers.keys(): + self.headers[key.lower()] = headers.get(key) + + # Is content gzipped? + # Transfer-Encoding appears to not be used in the wild + # (contrary to the HTTP standard), but no harm in testing + # for it + if ('gzip' in headers.get('content-encoding', '') or + 'gzip' in headers.get('transfer-encoding', '')): + self._gzipped = True + + @property + def stream(self): + """Whether response is streamed. + + Returns: + bool: `True` if response is streamed. + """ + return self._stream + + @stream.setter + def stream(self, value): + if self._content_loaded: + raise RuntimeError("`content` has already been read from " + "this Response.") + + self._stream = value + + def json(self): + """Decode response contents as JSON. + + :returns: object decoded from JSON + :rtype: :class:`list` / :class:`dict` + + """ + return json.loads(self.content, self.encoding or 'utf-8') + + @property + def encoding(self): + """Text encoding of document or ``None``. + + :returns: :class:`str` or ``None`` + + """ + if not self._encoding: + self._encoding = self._get_encoding() + + return self._encoding + + @property + def content(self): + """Raw content of response (i.e. bytes). + + :returns: Body of HTTP response + :rtype: :class:`str` + + """ + if not self._content: + + # Decompress gzipped content + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + self._content = decoder.decompress(self.raw.read()) + + else: + self._content = self.raw.read() + + self._content_loaded = True + + return self._content + + @property + def text(self): + """Unicode-decoded content of response body. + + If no encoding can be determined from HTTP headers or the content + itself, the encoded response body will be returned instead. + + :returns: Body of HTTP response + :rtype: :class:`unicode` or :class:`str` + + """ + if self.encoding: + return unicodedata.normalize('NFC', unicode(self.content, + self.encoding)) + return self.content + + def iter_content(self, chunk_size=4096, decode_unicode=False): + """Iterate over response data. + + .. versionadded:: 1.6 + + :param chunk_size: Number of bytes to read into memory + :type chunk_size: ``int`` + :param decode_unicode: Decode to Unicode using detected encoding + :type decode_unicode: ``Boolean`` + :returns: iterator + + """ + if not self.stream: + raise RuntimeError("You cannot call `iter_content` on a " + "Response unless you passed `stream=True`" + " to `get()`/`post()`/`request()`.") + + if self._content_loaded: + raise RuntimeError( + "`content` has already been read from this Response.") + + def decode_stream(iterator, r): + + decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') + + for chunk in iterator: + data = decoder.decode(chunk) + if data: + yield data + + data = decoder.decode(b'', final=True) + if data: # pragma: no cover + yield data + + def generate(): + + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + + while True: + chunk = self.raw.read(chunk_size) + if not chunk: + break + + if self._gzipped: + chunk = decoder.decompress(chunk) + + yield chunk + + chunks = generate() + + if decode_unicode and self.encoding: + chunks = decode_stream(chunks, self) + + return chunks + + def save_to_path(self, filepath): + """Save retrieved data to file at ``filepath``. + + .. versionadded: 1.9.6 + + :param filepath: Path to save retrieved data. + + """ + filepath = os.path.abspath(filepath) + dirname = os.path.dirname(filepath) + if not os.path.exists(dirname): + os.makedirs(dirname) + + self.stream = True + + with open(filepath, 'wb') as fileobj: + for data in self.iter_content(): + fileobj.write(data) + + def raise_for_status(self): + """Raise stored error if one occurred. + + error will be instance of :class:`urllib2.HTTPError` + """ + if self.error is not None: + raise self.error + return + + def _get_encoding(self): + """Get encoding from HTTP headers or content. + + :returns: encoding or `None` + :rtype: ``unicode`` or ``None`` + + """ + headers = self.raw.info() + encoding = None + + if headers.getparam('charset'): + encoding = headers.getparam('charset') + + # HTTP Content-Type header + for param in headers.getplist(): + if param.startswith('charset='): + encoding = param[8:] + break + + if not self.stream: # Try sniffing response content + # Encoding declared in document should override HTTP headers + if self.mimetype == 'text/html': # sniff HTML headers + m = re.search("""""", + self.content) + if m: + encoding = m.group(1) + + elif ((self.mimetype.startswith('application/') or + self.mimetype.startswith('text/')) and + 'xml' in self.mimetype): + m = re.search("""]*\?>""", + self.content) + if m: + encoding = m.group(1) + + # Format defaults + if self.mimetype == 'application/json' and not encoding: + # The default encoding for JSON + encoding = 'utf-8' + + elif self.mimetype == 'application/xml' and not encoding: + # The default for 'application/xml' + encoding = 'utf-8' + + if encoding: + encoding = encoding.lower() + + return encoding + + +def request(method, url, params=None, data=None, headers=None, cookies=None, + files=None, auth=None, timeout=60, allow_redirects=False, + stream=False): + """Initiate an HTTP(S) request. Returns :class:`Response` object. + + :param method: 'GET' or 'POST' + :type method: ``unicode`` + :param url: URL to open + :type url: ``unicode`` + :param params: mapping of URL parameters + :type params: :class:`dict` + :param data: mapping of form data ``{'field_name': 'value'}`` or + :class:`str` + :type data: :class:`dict` or :class:`str` + :param headers: HTTP headers + :type headers: :class:`dict` + :param cookies: cookies to send to server + :type cookies: :class:`dict` + :param files: files to upload (see below). + :type files: :class:`dict` + :param auth: username, password + :type auth: ``tuple`` + :param timeout: connection timeout limit in seconds + :type timeout: ``int`` + :param allow_redirects: follow redirections + :type allow_redirects: ``Boolean`` + :param stream: Stream content instead of fetching it all at once. + :type stream: ``bool`` + :returns: :class:`Response` object + + + The ``files`` argument is a dictionary:: + + {'fieldname' : { 'filename': 'blah.txt', + 'content': '', + 'mimetype': 'text/plain'} + } + + * ``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. + + """ + # TODO: cookies + socket.setdefaulttimeout(timeout) + + # Default handlers + openers = [] + + if not allow_redirects: + openers.append(NoRedirectHandler()) + + if auth is not None: # Add authorisation handler + username, password = auth + password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(None, url, username, password) + auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) + openers.append(auth_manager) + + # Install our custom chain of openers + opener = urllib2.build_opener(*openers) + urllib2.install_opener(opener) + + if not headers: + headers = CaseInsensitiveDictionary() + else: + headers = CaseInsensitiveDictionary(headers) + + if 'user-agent' not in headers: + headers['user-agent'] = USER_AGENT + + # Accept gzip-encoded content + encodings = [s.strip() for s in + headers.get('accept-encoding', '').split(',')] + if 'gzip' not in encodings: + encodings.append('gzip') + + headers['accept-encoding'] = ', '.join(encodings) + + # Force POST by providing an empty data string + if method == 'POST' and not data: + data = '' + + if files: + if not data: + data = {} + new_headers, data = encode_multipart_formdata(data, files) + headers.update(new_headers) + elif data and isinstance(data, dict): + data = urllib.urlencode(str_dict(data)) + + # Make sure everything is encoded text + headers = str_dict(headers) + + if isinstance(url, unicode): + url = url.encode('utf-8') + + if params: # GET args (POST args are handled in encode_multipart_formdata) + + scheme, netloc, path, query, fragment = urlparse.urlsplit(url) + + if query: # Combine query string and `params` + url_params = urlparse.parse_qs(query) + # `params` take precedence over URL query string + url_params.update(params) + params = url_params + + query = urllib.urlencode(str_dict(params), doseq=True) + url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) + + req = urllib2.Request(url, data, headers) + return Response(req, stream) + + +def get(url, params=None, headers=None, cookies=None, auth=None, + timeout=60, allow_redirects=True, stream=False): + """Initiate a GET request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('GET', url, params, headers=headers, cookies=cookies, + auth=auth, timeout=timeout, allow_redirects=allow_redirects, + stream=stream) + + +def post(url, params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=60, allow_redirects=False, stream=False): + """Initiate a POST request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('POST', url, params, data, headers, cookies, files, auth, + timeout, allow_redirects, stream) + + +def encode_multipart_formdata(fields, files): + """Encode form data (``fields``) and ``files`` for POST request. + + :param fields: mapping of ``{name : value}`` pairs for normal form fields. + :type fields: :class:`dict` + :param files: dictionary of fieldnames/files elements for file data. + See below for details. + :type files: :class:`dict` of :class:`dicts` + :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers + :rtype: 2-tuple ``(dict, str)`` + + The ``files`` argument is a dictionary:: + + {'fieldname' : { 'filename': 'blah.txt', + 'content': '', + 'mimetype': 'text/plain'} + } + + - ``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. + + """ + def get_content_type(filename): + """Return or guess mimetype of ``filename``. + + :param filename: filename of file + :type filename: unicode/string + :returns: mime-type, e.g. ``text/html`` + :rtype: :class::class:`str` + + """ + + return mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) + for i in range(30)) + CRLF = '\r\n' + output = [] + + # Normal form fields + for (name, value) in fields.items(): + if isinstance(name, unicode): + name = name.encode('utf-8') + if isinstance(value, unicode): + value = value.encode('utf-8') + output.append('--' + boundary) + output.append('Content-Disposition: form-data; name="%s"' % name) + output.append('') + output.append(value) + + # Files to upload + for name, d in files.items(): + filename = d[u'filename'] + content = d[u'content'] + if u'mimetype' in d: + mimetype = d[u'mimetype'] + else: + mimetype = get_content_type(filename) + if isinstance(name, unicode): + name = name.encode('utf-8') + if isinstance(filename, unicode): + filename = filename.encode('utf-8') + if isinstance(mimetype, unicode): + mimetype = mimetype.encode('utf-8') + output.append('--' + boundary) + output.append('Content-Disposition: form-data; ' + 'name="%s"; filename="%s"' % (name, filename)) + output.append('Content-Type: %s' % mimetype) + output.append('') + output.append(content) + + output.append('--' + boundary + '--') + output.append('') + body = CRLF.join(output) + headers = { + 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, + 'Content-Length': str(len(body)), + } + return (headers, body) diff --git a/workflow/web.pyc b/workflow/web.pyc new file mode 100644 index 0000000..d9c59a5 Binary files /dev/null and b/workflow/web.pyc differ diff --git a/workflow/workflow.py b/workflow/workflow.py new file mode 100644 index 0000000..324845c --- /dev/null +++ b/workflow/workflow.py @@ -0,0 +1,2916 @@ +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +""" +The :class:`Workflow` object is the main interface to this library. + +See :ref:`setup` in the :ref:`user-manual` for an example of how to set +up your Python script to best utilise the :class:`Workflow` object. + +""" + +from __future__ import print_function, unicode_literals + +import binascii +from contextlib import contextmanager +import cPickle +from copy import deepcopy +import errno +import json +import logging +import logging.handlers +import os +import pickle +import plistlib +import re +import shutil +import signal +import string +import subprocess +import sys +import time +import unicodedata + +try: + import xml.etree.cElementTree as ET +except ImportError: # pragma: no cover + import xml.etree.ElementTree as ET + + +#: Sentinel for properties that haven't been set yet (that might +#: correctly have the value ``None``) +UNSET = object() + +#################################################################### +# Standard system icons +#################################################################### + +# These icons are default OS X icons. They are super-high quality, and +# will be familiar to users. +# This library uses `ICON_ERROR` when a workflow dies in flames, so +# in my own workflows, I use `ICON_WARNING` for less fatal errors +# (e.g. bad user input, no results etc.) + +# The system icons are all in this directory. There are many more than +# are listed here + +ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources' + +ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns') +ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns') +ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns') +ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns') +ICON_COLOUR = ICON_COLOR # Queen's English, if you please +ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns') +# Shown when a workflow throws an error +ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns') +ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns') +ICON_FAVOURITE = ICON_FAVORITE +ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns') +ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns') +ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns') +ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns') +ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns') +ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns') +ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns') +ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns') +ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns') +ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns') +ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns') +ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns') +ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns') +ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns') + +#################################################################### +# non-ASCII to ASCII diacritic folding. +# Used by `fold_to_ascii` method +#################################################################### + +ASCII_REPLACEMENTS = { + 'À': 'A', + 'Á': 'A', + 'Â': 'A', + 'Ã': 'A', + 'Ä': 'A', + 'Å': 'A', + 'Æ': 'AE', + 'Ç': 'C', + 'È': 'E', + 'É': 'E', + 'Ê': 'E', + 'Ë': 'E', + 'Ì': 'I', + 'Í': 'I', + 'Î': 'I', + 'Ï': 'I', + 'Ð': 'D', + 'Ñ': 'N', + 'Ò': 'O', + 'Ó': 'O', + 'Ô': 'O', + 'Õ': 'O', + 'Ö': 'O', + 'Ø': 'O', + 'Ù': 'U', + 'Ú': 'U', + 'Û': 'U', + 'Ü': 'U', + 'Ý': 'Y', + 'Þ': 'Th', + 'ß': 'ss', + 'à': 'a', + 'á': 'a', + 'â': 'a', + 'ã': 'a', + 'ä': 'a', + 'å': 'a', + 'æ': 'ae', + 'ç': 'c', + 'è': 'e', + 'é': 'e', + 'ê': 'e', + 'ë': 'e', + 'ì': 'i', + 'í': 'i', + 'î': 'i', + 'ï': 'i', + 'ð': 'd', + 'ñ': 'n', + 'ò': 'o', + 'ó': 'o', + 'ô': 'o', + 'õ': 'o', + 'ö': 'o', + 'ø': 'o', + 'ù': 'u', + 'ú': 'u', + 'û': 'u', + 'ü': 'u', + 'ý': 'y', + 'þ': 'th', + 'ÿ': 'y', + 'Ł': 'L', + 'ł': 'l', + 'Ń': 'N', + 'ń': 'n', + 'Ņ': 'N', + 'ņ': 'n', + 'Ň': 'N', + 'ň': 'n', + 'Ŋ': 'ng', + 'ŋ': 'NG', + 'Ō': 'O', + 'ō': 'o', + 'Ŏ': 'O', + 'ŏ': 'o', + 'Ő': 'O', + 'ő': 'o', + 'Œ': 'OE', + 'œ': 'oe', + 'Ŕ': 'R', + 'ŕ': 'r', + 'Ŗ': 'R', + 'ŗ': 'r', + 'Ř': 'R', + 'ř': 'r', + 'Ś': 'S', + 'ś': 's', + 'Ŝ': 'S', + 'ŝ': 's', + 'Ş': 'S', + 'ş': 's', + 'Š': 'S', + 'š': 's', + 'Ţ': 'T', + 'ţ': 't', + 'Ť': 'T', + 'ť': 't', + 'Ŧ': 'T', + 'ŧ': 't', + 'Ũ': 'U', + 'ũ': 'u', + 'Ū': 'U', + 'ū': 'u', + 'Ŭ': 'U', + 'ŭ': 'u', + 'Ů': 'U', + 'ů': 'u', + 'Ű': 'U', + 'ű': 'u', + 'Ŵ': 'W', + 'ŵ': 'w', + 'Ŷ': 'Y', + 'ŷ': 'y', + 'Ÿ': 'Y', + 'Ź': 'Z', + 'ź': 'z', + 'Ż': 'Z', + 'ż': 'z', + 'Ž': 'Z', + 'ž': 'z', + 'ſ': 's', + 'Α': 'A', + 'Β': 'B', + 'Γ': 'G', + 'Δ': 'D', + 'Ε': 'E', + 'Ζ': 'Z', + 'Η': 'E', + 'Θ': 'Th', + 'Ι': 'I', + 'Κ': 'K', + 'Λ': 'L', + 'Μ': 'M', + 'Ν': 'N', + 'Ξ': 'Ks', + 'Ο': 'O', + 'Π': 'P', + 'Ρ': 'R', + 'Σ': 'S', + 'Τ': 'T', + 'Υ': 'U', + 'Φ': 'Ph', + 'Χ': 'Kh', + 'Ψ': 'Ps', + 'Ω': 'O', + 'α': 'a', + 'β': 'b', + 'γ': 'g', + 'δ': 'd', + 'ε': 'e', + 'ζ': 'z', + 'η': 'e', + 'θ': 'th', + 'ι': 'i', + 'κ': 'k', + 'λ': 'l', + 'μ': 'm', + 'ν': 'n', + 'ξ': 'x', + 'ο': 'o', + 'π': 'p', + 'ρ': 'r', + 'ς': 's', + 'σ': 's', + 'τ': 't', + 'υ': 'u', + 'φ': 'ph', + 'χ': 'kh', + 'ψ': 'ps', + 'ω': 'o', + 'А': 'A', + 'Б': 'B', + 'В': 'V', + 'Г': 'G', + 'Д': 'D', + 'Е': 'E', + 'Ж': 'Zh', + 'З': 'Z', + 'И': 'I', + 'Й': 'I', + 'К': 'K', + 'Л': 'L', + 'М': 'M', + 'Н': 'N', + 'О': 'O', + 'П': 'P', + 'Р': 'R', + 'С': 'S', + 'Т': 'T', + 'У': 'U', + 'Ф': 'F', + 'Х': 'Kh', + 'Ц': 'Ts', + 'Ч': 'Ch', + 'Ш': 'Sh', + 'Щ': 'Shch', + 'Ъ': "'", + 'Ы': 'Y', + 'Ь': "'", + 'Э': 'E', + 'Ю': 'Iu', + 'Я': 'Ia', + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'е': 'e', + 'ж': 'zh', + 'з': 'z', + 'и': 'i', + 'й': 'i', + 'к': 'k', + 'л': 'l', + 'м': 'm', + 'н': 'n', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'у': 'u', + 'ф': 'f', + 'х': 'kh', + 'ц': 'ts', + 'ч': 'ch', + 'ш': 'sh', + 'щ': 'shch', + 'ъ': "'", + 'ы': 'y', + 'ь': "'", + 'э': 'e', + 'ю': 'iu', + 'я': 'ia', + # 'ᴀ': '', + # 'ᴁ': '', + # 'ᴂ': '', + # 'ᴃ': '', + # 'ᴄ': '', + # 'ᴅ': '', + # 'ᴆ': '', + # 'ᴇ': '', + # 'ᴈ': '', + # 'ᴉ': '', + # 'ᴊ': '', + # 'ᴋ': '', + # 'ᴌ': '', + # 'ᴍ': '', + # 'ᴎ': '', + # 'ᴏ': '', + # 'ᴐ': '', + # 'ᴑ': '', + # 'ᴒ': '', + # 'ᴓ': '', + # 'ᴔ': '', + # 'ᴕ': '', + # 'ᴖ': '', + # 'ᴗ': '', + # 'ᴘ': '', + # 'ᴙ': '', + # 'ᴚ': '', + # 'ᴛ': '', + # 'ᴜ': '', + # 'ᴝ': '', + # 'ᴞ': '', + # 'ᴟ': '', + # 'ᴠ': '', + # 'ᴡ': '', + # 'ᴢ': '', + # 'ᴣ': '', + # 'ᴤ': '', + # 'ᴥ': '', + 'ᴦ': 'G', + 'ᴧ': 'L', + 'ᴨ': 'P', + 'ᴩ': 'R', + 'ᴪ': 'PS', + 'ẞ': 'Ss', + 'Ỳ': 'Y', + 'ỳ': 'y', + 'Ỵ': 'Y', + 'ỵ': 'y', + 'Ỹ': 'Y', + 'ỹ': 'y', +} + +#################################################################### +# Smart-to-dumb punctuation mapping +#################################################################### + +DUMB_PUNCTUATION = { + '‘': "'", + '’': "'", + '‚': "'", + '“': '"', + '”': '"', + '„': '"', + '–': '-', + '—': '-' +} + + +#################################################################### +# Used by `Workflow.filter` +#################################################################### + +# Anchor characters in a name +#: Characters that indicate the beginning of a "word" in CamelCase +INITIALS = string.ascii_uppercase + string.digits + +#: Split on non-letters, numbers +split_on_delimiters = re.compile('[^a-zA-Z0-9]').split + +# Match filter flags +#: Match items that start with ``query`` +MATCH_STARTSWITH = 1 +#: Match items whose capital letters start with ``query`` +MATCH_CAPITALS = 2 +#: Match items with a component "word" that matches ``query`` +MATCH_ATOM = 4 +#: Match items whose initials (based on atoms) start with ``query`` +MATCH_INITIALS_STARTSWITH = 8 +#: Match items whose initials (based on atoms) contain ``query`` +MATCH_INITIALS_CONTAIN = 16 +#: Combination of :const:`MATCH_INITIALS_STARTSWITH` and +#: :const:`MATCH_INITIALS_CONTAIN` +MATCH_INITIALS = 24 +#: Match items if ``query`` is a substring +MATCH_SUBSTRING = 32 +#: Match items if all characters in ``query`` appear in the item in order +MATCH_ALLCHARS = 64 +#: Combination of all other ``MATCH_*`` constants +MATCH_ALL = 127 + + +#################################################################### +# Used by `Workflow.check_update` +#################################################################### + +# Number of days to wait between checking for updates to the workflow +DEFAULT_UPDATE_FREQUENCY = 1 + + +#################################################################### +# Lockfile and Keychain access errors +#################################################################### + +class AcquisitionError(Exception): + """Raised if a lock cannot be acquired.""" + + +class KeychainError(Exception): + """Raised for unknown Keychain errors. + + Raised by methods :meth:`Workflow.save_password`, + :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` + when ``security`` CLI app returns an unknown error code. + """ + + +class PasswordNotFound(KeychainError): + """Raised by method :meth:`Workflow.get_password` when ``account`` + is unknown to the Keychain. + """ + + +class PasswordExists(KeychainError): + """Raised when trying to overwrite an existing account password. + + You should never receive this error: it is used internally + by the :meth:`Workflow.save_password` method to know if it needs + to delete the old password first (a Keychain implementation detail). + """ + + +#################################################################### +# Helper functions +#################################################################### + +def isascii(text): + """Test if ``text`` contains only ASCII characters. + + :param text: text to test for ASCII-ness + :type text: ``unicode`` + :returns: ``True`` if ``text`` contains only ASCII characters + :rtype: ``Boolean`` + """ + + try: + text.encode('ascii') + except UnicodeEncodeError: + return False + return True + + +#################################################################### +# Implementation classes +#################################################################### + +class SerializerManager(object): + """Contains registered serializers. + + .. versionadded:: 1.8 + + A configured instance of this class is available at + ``workflow.manager``. + + Use :meth:`register()` to register new (or replace + existing) serializers, which you can specify by name when calling + :class:`Workflow` data storage methods. + + See :ref:`manual-serialization` and :ref:`manual-persistent-data` + for further information. + + """ + + def __init__(self): + """Create new SerializerManager object.""" + self._serializers = {} + + def register(self, name, serializer): + """Register ``serializer`` object under ``name``. + + Raises :class:`AttributeError` if ``serializer`` in invalid. + + .. note:: + + ``name`` will be used as the file extension of the saved files. + + :param name: Name to register ``serializer`` under + :type name: ``unicode`` or ``str`` + :param serializer: object with ``load()`` and ``dump()`` + methods + + """ + + # Basic validation + getattr(serializer, 'load') + getattr(serializer, 'dump') + + self._serializers[name] = serializer + + def serializer(self, name): + """Return serializer object for ``name``. + + :param name: Name of serializer to return + :type name: ``unicode`` or ``str`` + :returns: serializer object or ``None`` if no such serializer + is registered. + + """ + + return self._serializers.get(name) + + def unregister(self, name): + """Remove registered serializer with ``name``. + + Raises a :class:`ValueError` if there is no such registered + serializer. + + :param name: Name of serializer to remove + :type name: ``unicode`` or ``str`` + :returns: serializer object + + """ + + if name not in self._serializers: + raise ValueError('No such serializer registered : {0}'.format( + name)) + + serializer = self._serializers[name] + del self._serializers[name] + + return serializer + + @property + def serializers(self): + """Return names of registered serializers.""" + return sorted(self._serializers.keys()) + + +class JSONSerializer(object): + """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``. + + .. versionadded:: 1.8 + + Use this serializer if you need readable data files. JSON doesn't + support Python objects as well as ``cPickle``/``pickle``, so be + careful which data you try to serialize as JSON. + + """ + + @classmethod + def load(cls, file_obj): + """Load serialized object from open JSON file. + + .. versionadded:: 1.8 + + :param file_obj: file handle + :type file_obj: ``file`` object + :returns: object loaded from JSON file + :rtype: object + + """ + + return json.load(file_obj) + + @classmethod + def dump(cls, obj, file_obj): + """Serialize object ``obj`` to open JSON file. + + .. versionadded:: 1.8 + + :param obj: Python object to serialize + :type obj: JSON-serializable data structure + :param file_obj: file handle + :type file_obj: ``file`` object + + """ + + return json.dump(obj, file_obj, indent=2, encoding='utf-8') + + +class CPickleSerializer(object): + """Wrapper around :mod:`cPickle`. Sets ``protocol``. + + .. versionadded:: 1.8 + + This is the default serializer and the best combination of speed and + flexibility. + + """ + + @classmethod + def load(cls, file_obj): + """Load serialized object from open pickle file. + + .. versionadded:: 1.8 + + :param file_obj: file handle + :type file_obj: ``file`` object + :returns: object loaded from pickle file + :rtype: object + + """ + + return cPickle.load(file_obj) + + @classmethod + def dump(cls, obj, file_obj): + """Serialize object ``obj`` to open pickle file. + + .. versionadded:: 1.8 + + :param obj: Python object to serialize + :type obj: Python object + :param file_obj: file handle + :type file_obj: ``file`` object + + """ + + return cPickle.dump(obj, file_obj, protocol=-1) + + +class PickleSerializer(object): + """Wrapper around :mod:`pickle`. Sets ``protocol``. + + .. versionadded:: 1.8 + + Use this serializer if you need to add custom pickling. + + """ + + @classmethod + def load(cls, file_obj): + """Load serialized object from open pickle file. + + .. versionadded:: 1.8 + + :param file_obj: file handle + :type file_obj: ``file`` object + :returns: object loaded from pickle file + :rtype: object + + """ + + return pickle.load(file_obj) + + @classmethod + def dump(cls, obj, file_obj): + """Serialize object ``obj`` to open pickle file. + + .. versionadded:: 1.8 + + :param obj: Python object to serialize + :type obj: Python object + :param file_obj: file handle + :type file_obj: ``file`` object + + """ + + return pickle.dump(obj, file_obj, protocol=-1) + + +# Set up default manager and register built-in serializers +manager = SerializerManager() +manager.register('cpickle', CPickleSerializer) +manager.register('pickle', PickleSerializer) +manager.register('json', JSONSerializer) + + +class Item(object): + """Represents a feedback item for Alfred. + + Generates Alfred-compliant XML for a single item. + + You probably shouldn't use this class directly, but via + :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` + for details of arguments. + + """ + + def __init__(self, title, subtitle='', modifier_subtitles=None, + arg=None, autocomplete=None, valid=False, uid=None, + icon=None, icontype=None, type=None, largetext=None, + copytext=None, quicklookurl=None): + """Arguments the same as for :meth:`Workflow.add_item`. + + """ + + self.title = title + self.subtitle = subtitle + self.modifier_subtitles = modifier_subtitles or {} + self.arg = arg + self.autocomplete = autocomplete + self.valid = valid + self.uid = uid + self.icon = icon + self.icontype = icontype + self.type = type + self.largetext = largetext + self.copytext = copytext + self.quicklookurl = quicklookurl + + @property + def elem(self): + """Create and return feedback item for Alfred. + + :returns: :class:`ElementTree.Element ` + instance for this :class:`Item` instance. + + """ + + # Attributes on element + attr = {} + if self.valid: + attr['valid'] = 'yes' + else: + attr['valid'] = 'no' + # Allow empty string for autocomplete. This is a useful value, + # as TABing the result will revert the query back to just the + # keyword + if self.autocomplete is not None: + attr['autocomplete'] = self.autocomplete + + # Optional attributes + for name in ('uid', 'type'): + value = getattr(self, name, None) + if value: + attr[name] = value + + root = ET.Element('item', attr) + ET.SubElement(root, 'title').text = self.title + ET.SubElement(root, 'subtitle').text = self.subtitle + + # Add modifier subtitles + for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'): + if mod in self.modifier_subtitles: + ET.SubElement(root, 'subtitle', + {'mod': mod}).text = self.modifier_subtitles[mod] + + # Add arg as element instead of attribute on , as it's more + # flexible (newlines aren't allowed in attributes) + if self.arg: + ET.SubElement(root, 'arg').text = self.arg + + # Add icon if there is one + if self.icon: + if self.icontype: + attr = dict(type=self.icontype) + else: + attr = {} + ET.SubElement(root, 'icon', attr).text = self.icon + + if self.largetext: + ET.SubElement(root, 'text', + {'type': 'largetype'}).text = self.largetext + + if self.copytext: + ET.SubElement(root, 'text', + {'type': 'copy'}).text = self.copytext + + if self.quicklookurl: + ET.SubElement(root, 'quicklookurl').text = self.quicklookurl + + return root + + +class LockFile(object): + """Context manager to create lock files.""" + + def __init__(self, protected_path, timeout=0, delay=0.05): + """Create new :class:`LockFile` object.""" + self.lockfile = protected_path + '.lock' + self.timeout = timeout + self.delay = delay + self._locked = False + + @property + def locked(self): + """`True` if file is locked by this instance.""" + return self._locked + + 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 `self.delay` seconds until it acquires + lock or exceeds `self.timeout` and raises an exception. + + """ + start = time.time() + while True: + try: + fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) + with os.fdopen(fd, 'w') as fd: + fd.write('{0}'.format(os.getpid())) + break + except OSError as err: + if err.errno != errno.EEXIST: # pragma: no cover + raise + if self.timeout and (time.time() - start) >= self.timeout: + raise AcquisitionError('Lock acquisition timed out.') + if not blocking: + return False + time.sleep(self.delay) + + self._locked = True + return True + + def release(self): + """Release the lock by deleting `self.lockfile`.""" + self._locked = False + os.unlink(self.lockfile) + + 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`.""" + if self._locked: # pragma: no cover + self.release() + + +@contextmanager +def atomic_writer(file_path, mode): + """Atomic file writer. + + :param file_path: path of file to write to. + :type file_path: ``unicode`` + :param mode: sames as for `func:open` + :type mode: string + + .. 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. + + """ + temp_suffix = '.aw.temp' + temp_file_path = file_path + temp_suffix + with open(temp_file_path, mode) as file_obj: + try: + yield file_obj + os.rename(temp_file_path, file_path) + finally: + try: + os.remove(temp_file_path) + except (OSError, IOError): + pass + + +class uninterruptible(object): + """Decorator that postpones SIGTERM until wrapped function is complete. + + .. versionadded:: 1.12 + + Since 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. + + .. important:: + + This decorator is NOT thread-safe. + + """ + + def __init__(self, func, class_name=''): + self.func = 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): + 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): + return self.__class__(self.func.__get__(obj, klass), + klass.__name__) + + +class Settings(dict): + """A dictionary that saves itself when changed. + + Dictionary keys & values will be saved as a JSON file + at ``filepath``. If the file does not exist, the dictionary + (and settings file) will be initialised with ``defaults``. + + :param filepath: where to save the settings + :type filepath: :class:`unicode` + :param defaults: dict of default settings + :type defaults: :class:`dict` + + + An appropriate instance is provided by :class:`Workflow` instances at + :attr:`Workflow.settings`. + + """ + + def __init__(self, filepath, defaults=None): + """Create new :class:`Settings` object.""" + + super(Settings, self).__init__() + self._filepath = filepath + self._nosave = False + self._original = {} + if os.path.exists(self._filepath): + self._load() + elif defaults: + for key, val in defaults.items(): + self[key] = val + self.save() # save default settings + + def _load(self): + """Load cached settings from JSON file `self._filepath`.""" + self._nosave = True + d = {} + with open(self._filepath, 'rb') as file_obj: + for key, value in json.load(file_obj, encoding='utf-8').items(): + d[key] = value + self.update(d) + self._original = deepcopy(d) + self._nosave = False + + @uninterruptible + def save(self): + """Save settings to JSON file specified in ``self._filepath``. + + If you're using this class via :attr:`Workflow.settings`, which + you probably are, ``self._filepath`` will be ``settings.json`` + in your workflow's data directory (see :attr:`~Workflow.datadir`). + """ + if self._nosave: + return + data = {} + data.update(self) + # for key, value in self.items(): + # data[key] = value + with LockFile(self._filepath): + with atomic_writer(self._filepath, 'wb') as file_obj: + json.dump(data, file_obj, sort_keys=True, indent=2, + encoding='utf-8') + + # dict methods + def __setitem__(self, key, value): + if self._original.get(key) != value: + super(Settings, self).__setitem__(key, value) + self.save() + + def __delitem__(self, key): + super(Settings, self).__delitem__(key) + self.save() + + def update(self, *args, **kwargs): + """Override :class:`dict` method to save on update.""" + super(Settings, self).update(*args, **kwargs) + self.save() + + def setdefault(self, key, value=None): + """Override :class:`dict` method to save on update.""" + ret = super(Settings, self).setdefault(key, value) + self.save() + return ret + + +class Workflow(object): + """Create new :class:`Workflow` instance. + + :param default_settings: default workflow settings. If no settings file + exists, :class:`Workflow.settings` will be pre-populated with + ``default_settings``. + :type default_settings: :class:`dict` + :param update_settings: settings for updating your workflow from GitHub. + This must be a :class:`dict` that contains ``github_slug`` and + ``version`` keys. ``github_slug`` is of the form ``username/repo`` + and ``version`` **must** correspond to the tag of a release. The + boolean ``prereleases`` key is optional and if ``True`` will + override the :ref:`magic argument ` preference. + This is only recommended when the installed workflow is a pre-release. + See :ref:`updates` for more information. + :type update_settings: :class:`dict` + :param input_encoding: encoding of command line arguments + :type input_encoding: :class:`unicode` + :param normalization: normalisation to apply to CLI args. + See :meth:`Workflow.decode` for more details. + :type normalization: :class:`unicode` + :param capture_args: capture and act on ``workflow:*`` arguments. See + :ref:`Magic arguments ` for details. + :type capture_args: :class:`Boolean` + :param libraries: sequence of paths to directories containing + libraries. These paths will be prepended to ``sys.path``. + :type libraries: :class:`tuple` or :class:`list` + :param help_url: URL to webpage where a user can ask for help with + the workflow, report bugs, etc. This could be the GitHub repo + or a page on AlfredForum.com. If your workflow throws an error, + this URL will be displayed in the log and Alfred's debugger. It can + also be opened directly in a web browser with the ``workflow:help`` + :ref:`magic argument `. + :type help_url: :class:`unicode` or :class:`str` + + """ + + # Which class to use to generate feedback items. You probably + # won't want to change this + item_class = Item + + def __init__(self, default_settings=None, update_settings=None, + input_encoding='utf-8', normalization='NFC', + capture_args=True, libraries=None, + help_url=None): + """Create new :class:`Workflow` object.""" + self._default_settings = default_settings or {} + self._update_settings = update_settings or {} + self._input_encoding = input_encoding + self._normalizsation = normalization + self._capture_args = capture_args + self.help_url = help_url + self._workflowdir = None + self._settings_path = None + self._settings = None + self._bundleid = None + self._name = None + self._cache_serializer = 'cpickle' + self._data_serializer = 'cpickle' + self._info = None + self._info_loaded = False + self._logger = None + self._items = [] + self._alfred_env = None + # Version number of the workflow + self._version = UNSET + # Version from last workflow run + self._last_version_run = UNSET + # Cache for regex patterns created for filter keys + self._search_pattern_cache = {} + # Magic arguments + #: The prefix for all magic arguments. Default is ``workflow:`` + self.magic_prefix = 'workflow:' + #: Mapping of available magic arguments. The built-in magic + #: arguments are registered by default. To add your own magic arguments + #: (or override built-ins), add a key:value pair where the key is + #: what the user should enter (prefixed with :attr:`magic_prefix`) + #: and the value is a callable that will be called when the argument + #: is entered. If you would like to display a message in Alfred, the + #: function should return a ``unicode`` string. + #: + #: By default, the magic arguments documented + #: :ref:`here ` are registered. + self.magic_arguments = {} + + self._register_default_magic() + + if libraries: + sys.path = libraries + sys.path + + #################################################################### + # API methods + #################################################################### + + # info.plist contents and alfred_* environment variables ---------- + + @property + def alfred_version(self): + """Alfred version as :class:`~workflow.update.Version` object.""" + from update import Version + return Version(self.alfred_env.get('version')) + + @property + def alfred_env(self): + """Alfred's environmental variables minus the ``alfred_`` prefix. + + .. versionadded:: 1.7 + + The variables Alfred 2.4+ exports are: + + ============================ ========================================= + Variable Description + ============================ ========================================= + alfred_preferences Path to Alfred.alfredpreferences + (where your workflows and settings are + stored). + alfred_preferences_localhash Machine-specific preferences are stored + in ``Alfred.alfredpreferences/preferences/local/`` + (see ``alfred_preferences`` above for + the path to ``Alfred.alfredpreferences``) + alfred_theme ID of selected theme + alfred_theme_background Background colour of selected theme in + format ``rgba(r,g,b,a)`` + alfred_theme_subtext Show result subtext. + ``0`` = Always, + ``1`` = Alternative actions only, + ``2`` = Selected result only, + ``3`` = Never + alfred_version Alfred version number, e.g. ``'2.4'`` + alfred_version_build Alfred build number, e.g. ``277`` + alfred_workflow_bundleid Bundle ID, e.g. + ``net.deanishe.alfred-mailto`` + alfred_workflow_cache Path to workflow's cache directory + alfred_workflow_data Path to workflow's data directory + alfred_workflow_name Name of current workflow + alfred_workflow_uid UID of workflow + alfred_workflow_version The version number specified in the + workflow configuration sheet/info.plist + ============================ ========================================= + + **Note:** all values are Unicode strings except ``version_build`` and + ``theme_subtext``, which are integers. + + :returns: ``dict`` of Alfred's environmental variables without the + ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. + + """ + if self._alfred_env is not None: + return self._alfred_env + + data = {} + + for key in ( + 'alfred_preferences', + 'alfred_preferences_localhash', + 'alfred_theme', + 'alfred_theme_background', + 'alfred_theme_subtext', + 'alfred_version', + 'alfred_version_build', + 'alfred_workflow_bundleid', + 'alfred_workflow_cache', + 'alfred_workflow_data', + 'alfred_workflow_name', + 'alfred_workflow_uid', + 'alfred_workflow_version'): + + value = os.getenv(key) + + if isinstance(value, str): + if key in ('alfred_version_build', 'alfred_theme_subtext'): + value = int(value) + else: + value = self.decode(value) + + data[key[7:]] = value + + self._alfred_env = data + + return self._alfred_env + + @property + def info(self): + """:class:`dict` of ``info.plist`` contents.""" + if not self._info_loaded: + self._load_info_plist() + return self._info + + @property + def bundleid(self): + """Workflow bundle ID from environmental vars or ``info.plist``. + + :returns: bundle ID + :rtype: ``unicode`` + + """ + if not self._bundleid: + if self.alfred_env.get('workflow_bundleid'): + self._bundleid = self.alfred_env.get('workflow_bundleid') + else: + self._bundleid = unicode(self.info['bundleid'], 'utf-8') + + return self._bundleid + + @property + def name(self): + """Workflow name from Alfred's environmental vars or ``info.plist``. + + :returns: workflow name + :rtype: ``unicode`` + + """ + if not self._name: + if self.alfred_env.get('workflow_name'): + self._name = self.decode(self.alfred_env.get('workflow_name')) + else: + self._name = self.decode(self.info['name']) + + return self._name + + @property + def version(self): + """Return the version of the workflow. + + .. versionadded:: 1.9.10 + + Get the workflow version from environment variable, + the ``update_settings`` dict passed on + instantiation, the ``version`` file located in the workflow's + root directory or ``info.plist``. Return ``None`` if none + exists or :class:`ValueError` if the version number is invalid + (i.e. not semantic). + + :returns: Version of the workflow (not Alfred-Workflow) + :rtype: :class:`~workflow.update.Version` object + + """ + if self._version is UNSET: + + version = None + # environment variable has priority + if self.alfred_env.get('workflow_version'): + version = self.alfred_env['workflow_version'] + + # Try `update_settings` + elif self._update_settings: + version = self._update_settings.get('version') + + # `version` file + if not version: + filepath = self.workflowfile('version') + + if os.path.exists(filepath): + with open(filepath, 'rb') as fileobj: + version = fileobj.read() + + # info.plist + if not version: + version = self.info.get('version') + + if version: + from update import Version + version = Version(version) + + self._version = version + + return self._version + + # Workflow utility methods ----------------------------------------- + + @property + def args(self): + """Return command line args as normalised unicode. + + Args are decoded and normalised via :meth:`~Workflow.decode`. + + The encoding and normalisation are the ``input_encoding`` and + ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` + and ``NFC`` are the defaults). + + If :class:`Workflow` is called with ``capture_args=True`` + (the default), :class:`Workflow` will look for certain + ``workflow:*`` args and, if found, perform the corresponding + actions and exit the workflow. + + See :ref:`Magic arguments ` for details. + + """ + msg = None + args = [self.decode(arg) for arg in sys.argv[1:]] + + # Handle magic args + if len(args) and self._capture_args: + for name in self.magic_arguments: + key = '{0}{1}'.format(self.magic_prefix, name) + if key in args: + msg = self.magic_arguments[name]() + + if msg: + self.logger.debug(msg) + if not sys.stdout.isatty(): # Show message in Alfred + self.add_item(msg, valid=False, icon=ICON_INFO) + self.send_feedback() + sys.exit(0) + return args + + @property + def cachedir(self): + """Path to workflow's cache directory. + + The cache directory is a subdirectory of Alfred's own cache directory + in ``~/Library/Caches``. The full path is: + + ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` + + ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. + + :returns: full path to workflow's cache directory + :rtype: ``unicode`` + + """ + if self.alfred_env.get('workflow_cache'): + dirpath = self.alfred_env.get('workflow_cache') + + else: + dirpath = self._default_cachedir + + return self._create(dirpath) + + @property + def _default_cachedir(self): + """Alfred 2's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-2/' + 'Workflow Data/'), + self.bundleid) + + @property + def datadir(self): + """Path to workflow's data directory. + + The data directory is a subdirectory of Alfred's own data directory in + ``~/Library/Application Support``. The full path is: + + ``~/Library/Application Support/Alfred 2/Workflow Data/`` + + :returns: full path to workflow data directory + :rtype: ``unicode`` + + """ + if self.alfred_env.get('workflow_data'): + dirpath = self.alfred_env.get('workflow_data') + + else: + dirpath = self._default_datadir + + return self._create(dirpath) + + @property + def _default_datadir(self): + """Alfred 2's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 2/Workflow Data/'), + self.bundleid) + + @property + def workflowdir(self): + """Path to workflow's root directory (where ``info.plist`` is). + + :returns: full path to workflow root directory + :rtype: ``unicode`` + + """ + if not self._workflowdir: + # Try the working directory first, then the directory + # the library is in. CWD will be the workflow root if + # a workflow is being run in Alfred + candidates = [ + os.path.abspath(os.getcwdu()), + os.path.dirname(os.path.abspath(os.path.dirname(__file__)))] + + # climb the directory tree until we find `info.plist` + for dirpath in candidates: + + # Ensure directory path is Unicode + dirpath = self.decode(dirpath) + + while True: + if os.path.exists(os.path.join(dirpath, 'info.plist')): + self._workflowdir = dirpath + break + + elif dirpath == '/': + # no `info.plist` found + break + + # Check the parent directory + dirpath = os.path.dirname(dirpath) + + # No need to check other candidates + if self._workflowdir: + break + + if not self._workflowdir: + raise IOError("'info.plist' not found in directory tree") + + return self._workflowdir + + def cachefile(self, filename): + """Return full path to ``filename`` within your workflow's + :attr:`cache directory `. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within cache directory + :rtype: ``unicode`` + + """ + return os.path.join(self.cachedir, filename) + + def datafile(self, filename): + """Return full path to ``filename`` within your workflow's + :attr:`data directory `. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within data directory + :rtype: ``unicode`` + + """ + return os.path.join(self.datadir, filename) + + def workflowfile(self, filename): + """Return full path to ``filename`` in workflow's root directory. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within data directory + :rtype: ``unicode`` + + """ + return os.path.join(self.workflowdir, filename) + + @property + def logfile(self): + """Return path to logfile. + + :returns: path to logfile within workflow's cache directory + :rtype: ``unicode`` + + """ + return self.cachefile('%s.log' % self.bundleid) + + @property + def logger(self): + """Create and return a logger that logs to both console and a log file. + + Use :meth:`open_log` to open the log file in Console. + + :returns: an initialised :class:`~logging.Logger` + + """ + if self._logger: + return self._logger + + # Initialise new logger and optionally handlers + logger = logging.getLogger('workflow') + + if not len(logger.handlers): # Only add one set of handlers + + fmt = logging.Formatter( + '%(asctime)s %(filename)s:%(lineno)s' + ' %(levelname)-8s %(message)s', + datefmt='%H:%M:%S') + + logfile = logging.handlers.RotatingFileHandler( + self.logfile, + maxBytes=1024*1024, + backupCount=1) + logfile.setFormatter(fmt) + logger.addHandler(logfile) + + console = logging.StreamHandler() + console.setFormatter(fmt) + logger.addHandler(console) + + logger.setLevel(logging.DEBUG) + self._logger = logger + + return self._logger + + @logger.setter + def logger(self, logger): + """Set a custom logger. + + :param logger: The logger to use + :type logger: `~logging.Logger` instance + + """ + self._logger = logger + + @property + def settings_path(self): + """Path to settings file within workflow's data directory. + + :returns: path to ``settings.json`` file + :rtype: ``unicode`` + + """ + if not self._settings_path: + self._settings_path = self.datafile('settings.json') + return self._settings_path + + @property + def settings(self): + """Return a dictionary subclass that saves itself when changed. + + See :ref:`manual-settings` in the :ref:`user-manual` for more + information on how to use :attr:`settings` and **important + limitations** on what it can do. + + :returns: :class:`~workflow.workflow.Settings` instance + initialised from the data in JSON file at + :attr:`settings_path` or if that doesn't exist, with the + ``default_settings`` :class:`dict` passed to + :class:`Workflow` on instantiation. + :rtype: :class:`~workflow.workflow.Settings` instance + + """ + if not self._settings: + self.logger.debug('Reading settings from `{0}` ...'.format( + self.settings_path)) + self._settings = Settings(self.settings_path, + self._default_settings) + return self._settings + + @property + def cache_serializer(self): + """Name of default cache serializer. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`cache_data()` and + :meth:`cached_data()` + + See :class:`SerializerManager` for details. + + :returns: serializer name + :rtype: ``unicode`` + + """ + + return self._cache_serializer + + @cache_serializer.setter + def cache_serializer(self, serializer_name): + """Set the default cache serialization format. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`cache_data()` and + :meth:`cached_data()` + + The specified serializer must already by registered with the + :class:`SerializerManager` at `~workflow.workflow.manager`, + otherwise a :class:`ValueError` will be raised. + + :param serializer_name: Name of default serializer to use. + :type serializer_name: + + """ + if manager.serializer(serializer_name) is None: + raise ValueError( + 'Unknown serializer : `{0}`. Register your serializer ' + 'with `manager` first.'.format(serializer_name)) + + self.logger.debug( + 'default cache serializer set to `{0}`'.format(serializer_name)) + + self._cache_serializer = serializer_name + + @property + def data_serializer(self): + """Name of default data serializer. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`store_data()` and + :meth:`stored_data()` + + See :class:`SerializerManager` for details. + + :returns: serializer name + :rtype: ``unicode`` + + """ + + return self._data_serializer + + @data_serializer.setter + def data_serializer(self, serializer_name): + """Set the default cache serialization format. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`store_data()` and + :meth:`stored_data()` + + The specified serializer must already by registered with the + :class:`SerializerManager` at `~workflow.workflow.manager`, + otherwise a :class:`ValueError` will be raised. + + :param serializer_name: Name of serializer to use by default. + + """ + + if manager.serializer(serializer_name) is None: + raise ValueError( + 'Unknown serializer : `{0}`. Register your serializer ' + 'with `manager` first.'.format(serializer_name)) + + self.logger.debug( + 'default data serializer set to `{0}`'.format(serializer_name)) + + self._data_serializer = serializer_name + + def stored_data(self, name): + """Retrieve data from data directory. Returns ``None`` if there + are no data stored. + + .. versionadded:: 1.8 + + :param name: name of datastore + + """ + metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) + + if not os.path.exists(metadata_path): + self.logger.debug('No data stored for `{0}`'.format(name)) + return None + + with open(metadata_path, 'rb') as file_obj: + serializer_name = file_obj.read().strip() + + serializer = manager.serializer(serializer_name) + + if serializer is None: + raise ValueError( + 'Unknown serializer `{0}`. Register a corresponding ' + 'serializer with `manager.register()` ' + 'to load this data.'.format(serializer_name)) + + self.logger.debug('Data `{0}` stored in `{1}` format'.format( + name, serializer_name)) + + filename = '{0}.{1}'.format(name, serializer_name) + data_path = self.datafile(filename) + + if not os.path.exists(data_path): + self.logger.debug('No data stored for `{0}`'.format(name)) + if os.path.exists(metadata_path): + os.unlink(metadata_path) + + return None + + with open(data_path, 'rb') as file_obj: + data = serializer.load(file_obj) + + self.logger.debug('Stored data loaded from : {0}'.format(data_path)) + + return data + + def store_data(self, name, data, serializer=None): + """Save data to data directory. + + .. versionadded:: 1.8 + + If ``data`` is ``None``, the datastore will be deleted. + + Note that the datastore does NOT support mutliple threads. + + :param name: name of datastore + :param data: object(s) to store. **Note:** some serializers + can only handled certain types of data. + :param serializer: name of serializer to use. If no serializer + is specified, the default will be used. See + :class:`SerializerManager` for more information. + :returns: data in datastore or ``None`` + + """ + # Ensure deletion is not interrupted by SIGTERM + @uninterruptible + def delete_paths(paths): + """Clear one or more data stores""" + for path in paths: + if os.path.exists(path): + os.unlink(path) + self.logger.debug('Deleted data file : {0}'.format(path)) + + serializer_name = serializer or self.data_serializer + + # In order for `stored_data()` to be able to load data stored with + # an arbitrary serializer, yet still have meaningful file extensions, + # the format (i.e. extension) is saved to an accompanying file + metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) + filename = '{0}.{1}'.format(name, serializer_name) + data_path = self.datafile(filename) + + if data_path == self.settings_path: + raise ValueError( + 'Cannot save data to' + + '`{0}` with format `{1}`. '.format(name, serializer_name) + + "This would overwrite Alfred-Workflow's settings file.") + + serializer = manager.serializer(serializer_name) + + if serializer is None: + raise ValueError( + 'Invalid serializer `{0}`. Register your serializer with ' + '`manager.register()` first.'.format(serializer_name)) + + if data is None: # Delete cached data + delete_paths((metadata_path, data_path)) + return + + # Ensure write is not interrupted by SIGTERM + @uninterruptible + def _store(): + # Save file extension + with atomic_writer(metadata_path, 'wb') as file_obj: + file_obj.write(serializer_name) + + with atomic_writer(data_path, 'wb') as file_obj: + serializer.dump(data, file_obj) + + _store() + + self.logger.debug('Stored data saved at : {0}'.format(data_path)) + + def cached_data(self, name, data_func=None, max_age=60): + """Retrieve data from cache or re-generate and re-cache data if + stale/non-existant. If ``max_age`` is 0, return cached data no + matter how old. + + :param name: name of datastore + :param data_func: function to (re-)generate data. + :type data_func: ``callable`` + :param max_age: maximum age of cached data in seconds + :type max_age: ``int`` + :returns: cached data, return value of ``data_func`` or ``None`` + if ``data_func`` is not set + + """ + serializer = manager.serializer(self.cache_serializer) + + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + age = self.cached_data_age(name) + + if (age < max_age or max_age == 0) and os.path.exists(cache_path): + + with open(cache_path, 'rb') as file_obj: + self.logger.debug('Loading cached data from : %s', + cache_path) + return serializer.load(file_obj) + + if not data_func: + return None + + data = data_func() + self.cache_data(name, data) + + return data + + def cache_data(self, name, data): + """Save ``data`` to cache under ``name``. + + If ``data`` is ``None``, the corresponding cache file will be + deleted. + + :param name: name of datastore + :param data: data to store. This may be any object supported by + the cache serializer + + """ + serializer = manager.serializer(self.cache_serializer) + + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + + if data is None: + if os.path.exists(cache_path): + os.unlink(cache_path) + self.logger.debug('Deleted cache file : %s', cache_path) + return + + with atomic_writer(cache_path, 'wb') as file_obj: + serializer.dump(data, file_obj) + + self.logger.debug('Cached data saved at : %s', cache_path) + + def cached_data_fresh(self, name, max_age): + """Whether cache `name` is less than `max_age` seconds old. + + :param name: name of datastore + :param max_age: maximum age of data in seconds + :type max_age: ``int`` + :returns: ``True`` if data is less than ``max_age`` old, else + ``False`` + + """ + age = self.cached_data_age(name) + + if not age: + return False + + return age < max_age + + def cached_data_age(self, name): + """Return age in seconds of cache `name` or 0 if cache doesn't exist. + + :param name: name of datastore + :type name: ``unicode`` + :returns: age of datastore in seconds + :rtype: ``int`` + + """ + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + + if not os.path.exists(cache_path): + return 0 + + return time.time() - os.stat(cache_path).st_mtime + + def filter(self, query, items, key=lambda x: x, ascending=False, + include_score=False, min_score=0, max_results=0, + match_on=MATCH_ALL, fold_diacritics=True): + """Fuzzy search filter. Returns list of ``items`` that match ``query``. + + ``query`` is case-insensitive. Any item that does not contain the + entirety of ``query`` is rejected. + + .. warning:: + + If ``query`` is an empty string or contains only whitespace, + a :class:`ValueError` will be raised. + + :param query: query to test items against + :type query: ``unicode`` + :param items: iterable of items to test + :type items: ``list`` or ``tuple`` + :param key: function to get comparison key from ``items``. + Must return a ``unicode`` string. The default simply returns + the item. + :type key: ``callable`` + :param ascending: set to ``True`` to get worst matches first + :type ascending: ``Boolean`` + :param include_score: Useful for debugging the scoring algorithm. + If ``True``, results will be a list of tuples + ``(item, score, rule)``. + :type include_score: ``Boolean`` + :param min_score: If non-zero, ignore results with a score lower + than this. + :type min_score: ``int`` + :param max_results: If non-zero, prune results list to this length. + :type max_results: ``int`` + :param match_on: Filter option flags. Bitwise-combined list of + ``MATCH_*`` constants (see below). + :type match_on: ``int`` + :param fold_diacritics: Convert search keys to ASCII-only + characters if ``query`` only contains ASCII characters. + :type fold_diacritics: ``Boolean`` + :returns: list of ``items`` matching ``query`` or list of + ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. + ``rule`` is the ``MATCH_*`` rule that matched the item. + :rtype: ``list`` + + **Matching rules** + + By default, :meth:`filter` uses all of the following flags (i.e. + :const:`MATCH_ALL`). The tests are always run in the given order: + + 1. :const:`MATCH_STARTSWITH` : Item search key startswith + ``query``(case-insensitive). + 2. :const:`MATCH_CAPITALS` : The list of capital letters in item + search key starts with ``query`` (``query`` may be + lower-case). E.g., ``of`` would match ``OmniFocus``, + ``gc`` would match ``Google Chrome``. + 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on + non-word characters (.,-,' etc.). Matches if ``query`` is + one of these atoms (case-insensitive). + 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first + characters of the above-described "atoms" (case-insensitive). + 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of + the above-described initials. + 6. :const:`MATCH_INITIALS` : Combination of (4) and (5). + 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring + of item search key (case-insensitive). + 8. :const:`MATCH_ALLCHARS` : Matches if all characters in + ``query`` appear in item search key in the same order + (case-insensitive). + 9. :const:`MATCH_ALL` : Combination of all the above. + + + :const:`MATCH_ALLCHARS` is considerably slower than the other + tests and provides much less accurate results. + + **Examples:** + + To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst + matches and is expensive to run), use + ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. + + To match only on capitals, use ``match_on=MATCH_CAPITALS``. + + To match only on startswith and substring, use + ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. + + **Diacritic folding** + + .. versionadded:: 1.3 + + If ``fold_diacritics`` is ``True`` (the default), and ``query`` + contains only ASCII characters, non-ASCII characters in search keys + will be converted to ASCII equivalents (e.g. **ü** -> **u**, + **ß** -> **ss**, **é** -> **e**). + + See :const:`ASCII_REPLACEMENTS` for all replacements. + + If ``query`` contains non-ASCII characters, search keys will not be + altered. + + """ + if not query: + raise ValueError('Empty `query`') + + # Remove preceding/trailing spaces + query = query.strip() + + if not query: + raise ValueError('`query` contains only whitespace') + + # Use user override if there is one + fold_diacritics = self.settings.get('__workflow_diacritic_folding', + fold_diacritics) + + results = [] + + for item in items: + skip = False + score = 0 + words = [s.strip() for s in query.split(' ')] + value = key(item).strip() + if value == '': + continue + for word in words: + if word == '': + continue + s, rule = self._filter_item(value, word, match_on, + fold_diacritics) + + if not s: # Skip items that don't match part of the query + skip = True + score += s + + if skip: + continue + + if score: + # use "reversed" `score` (i.e. highest becomes lowest) and + # `value` as sort key. This means items with the same score + # will be sorted in alphabetical not reverse alphabetical order + results.append(((100.0 / score, value.lower(), score), + (item, score, rule))) + + # sort on keys, then discard the keys + results.sort(reverse=ascending) + results = [t[1] for t in results] + + if min_score: + results = [r for r in results if r[1] > min_score] + + if max_results and len(results) > max_results: + results = results[:max_results] + + # return list of ``(item, score, rule)`` + if include_score: + return results + # just return list of items + return [t[0] for t in results] + + def _filter_item(self, value, query, match_on, fold_diacritics): + """Filter ``value`` against ``query`` using rules ``match_on``. + + :returns: ``(score, rule)`` + + """ + query = query.lower() + + if not isascii(query): + fold_diacritics = False + + if fold_diacritics: + value = self.fold_to_ascii(value) + + # pre-filter any items that do not contain all characters + # of ``query`` to save on running several more expensive tests + if not set(query) <= set(value.lower()): + + return (0, None) + + # item starts with query + if match_on & MATCH_STARTSWITH and value.lower().startswith(query): + score = 100.0 - (len(value) / len(query)) + + return (score, MATCH_STARTSWITH) + + # query matches capitalised letters in item, + # e.g. of = OmniFocus + if match_on & MATCH_CAPITALS: + initials = ''.join([c for c in value if c in INITIALS]) + if initials.lower().startswith(query): + score = 100.0 - (len(initials) / len(query)) + + return (score, MATCH_CAPITALS) + + # split the item into "atoms", i.e. words separated by + # spaces or other non-word characters + if (match_on & MATCH_ATOM or + match_on & MATCH_INITIALS_CONTAIN or + match_on & MATCH_INITIALS_STARTSWITH): + atoms = [s.lower() for s in split_on_delimiters(value)] + # print('atoms : %s --> %s' % (value, atoms)) + # initials of the atoms + initials = ''.join([s[0] for s in atoms if s]) + + if match_on & MATCH_ATOM: + # is `query` one of the atoms in item? + # similar to substring, but scores more highly, as it's + # a word within the item + if query in atoms: + score = 100.0 - (len(value) / len(query)) + + return (score, MATCH_ATOM) + + # `query` matches start (or all) of the initials of the + # atoms, e.g. ``himym`` matches "How I Met Your Mother" + # *and* "how i met your mother" (the ``capitals`` rule only + # matches the former) + if (match_on & MATCH_INITIALS_STARTSWITH and + initials.startswith(query)): + score = 100.0 - (len(initials) / len(query)) + + return (score, MATCH_INITIALS_STARTSWITH) + + # `query` is a substring of initials, e.g. ``doh`` matches + # "The Dukes of Hazzard" + elif (match_on & MATCH_INITIALS_CONTAIN and + query in initials): + score = 95.0 - (len(initials) / len(query)) + + return (score, MATCH_INITIALS_CONTAIN) + + # `query` is a substring of item + if match_on & MATCH_SUBSTRING and query in value.lower(): + score = 90.0 - (len(value) / len(query)) + + return (score, MATCH_SUBSTRING) + + # finally, assign a score based on how close together the + # characters in `query` are in item. + if match_on & MATCH_ALLCHARS: + search = self._search_for_query(query) + match = search(value) + if match: + score = 100.0 / ((1 + match.start()) * + (match.end() - match.start() + 1)) + + return (score, MATCH_ALLCHARS) + + # Nothing matched + return (0, None) + + def _search_for_query(self, query): + if query in self._search_pattern_cache: + return self._search_pattern_cache[query] + + # Build pattern: include all characters + pattern = [] + for c in query: + # pattern.append('[^{0}]*{0}'.format(re.escape(c))) + pattern.append('.*?{0}'.format(re.escape(c))) + pattern = ''.join(pattern) + search = re.compile(pattern, re.IGNORECASE).search + + self._search_pattern_cache[query] = search + return search + + def run(self, func): + """Call ``func`` to run your workflow. + + :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` + instance) as first argument. + + ``func`` will be called with :class:`Workflow` instance as first + argument. + + ``func`` should be the main entry point to your workflow. + + Any exceptions raised will be logged and an error message will be + output to Alfred. + + """ + start = time.time() + + # Call workflow's entry function/method within a try-except block + # to catch any errors and display an error message in Alfred + try: + + if self.version: + self.logger.debug( + 'Workflow version : {0}'.format(self.version)) + + # Run update check if configured for self-updates. + # This call has to go in the `run` try-except block, as it will + # initialise `self.settings`, which will raise an exception + # if `settings.json` isn't valid. + + if self._update_settings: + self.check_update() + + # Run workflow's entry function/method + func(self) + + # Set last version run to current version after a successful + # run + self.set_last_version() + + except Exception as err: + self.logger.exception(err) + if self.help_url: + self.logger.info( + 'For assistance, see: {0}'.format(self.help_url)) + + if not sys.stdout.isatty(): # Show error in Alfred + self._items = [] + if self._name: + name = self._name + elif self._bundleid: + name = self._bundleid + else: # pragma: no cover + name = os.path.dirname(__file__) + self.add_item("Error in workflow '%s'" % name, unicode(err), + icon=ICON_ERROR) + self.send_feedback() + return 1 + + finally: + self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format( + time.time() - start)) + + return 0 + + # Alfred feedback methods ------------------------------------------ + + def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, + autocomplete=None, valid=False, uid=None, icon=None, + icontype=None, type=None, largetext=None, copytext=None, + quicklookurl=None): + """Add an item to be output to Alfred. + + :param title: Title shown in Alfred + :type title: ``unicode`` + :param subtitle: Subtitle shown in Alfred + :type subtitle: ``unicode`` + :param modifier_subtitles: Subtitles shown when modifier + (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase + keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` + :type modifier_subtitles: ``dict`` + :param arg: Argument passed by Alfred as ``{query}`` when item is + actioned + :type arg: ``unicode`` + :param autocomplete: Text expanded in Alfred when item is TABbed + :type autocomplete: ``unicode`` + :param valid: Whether or not item can be actioned + :type valid: ``Boolean`` + :param uid: Used by Alfred to remember/sort items + :type uid: ``unicode`` + :param icon: Filename of icon to use + :type icon: ``unicode`` + :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` + or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype + such as ``'public.folder'``. Use ``'fileicon'`` when you wish to + use the icon of the file specified as ``icon``, e.g. + ``icon='/Applications/Safari.app', icontype='fileicon'``. + Leave as `None` if ``icon`` points to an actual + icon file. + :type icontype: ``unicode`` + :param type: Result type. Currently only ``'file'`` is supported + (by Alfred). This will tell Alfred to enable file actions for + this item. + :type type: ``unicode`` + :param largetext: Text to be displayed in Alfred's large text box + if user presses CMD+L on item. + :type largetext: ``unicode`` + :param copytext: Text to be copied to pasteboard if user presses + CMD+C on item. + :type copytext: ``unicode`` + :param quicklookurl: URL to be displayed using Alfred's Quick Look + feature (tapping ``SHIFT`` or ``⌘+Y`` on a result). + :type quicklookurl: ``unicode`` + :returns: :class:`Item` instance + + See the :ref:`script-filter-results` section of the documentation + for a detailed description of what the various parameters do and how + they interact with one another. + + See :ref:`icons` for a list of the supported system icons. + + .. note:: + + Although this method returns an :class:`Item` instance, you don't + need to hold onto it or worry about it. All generated :class:`Item` + instances are also collected internally and sent to Alfred when + :meth:`send_feedback` is called. + + The generated :class:`Item` is only returned in case you want to + edit it or do something with it other than send it to Alfred. + + """ + item = self.item_class(title, subtitle, modifier_subtitles, arg, + autocomplete, valid, uid, icon, icontype, type, + largetext, copytext, quicklookurl) + self._items.append(item) + return item + + def send_feedback(self): + """Print stored items to console/Alfred as XML.""" + root = ET.Element('items') + for item in self._items: + root.append(item.elem) + sys.stdout.write('\n') + sys.stdout.write(ET.tostring(root).encode('utf-8')) + sys.stdout.flush() + + #################################################################### + # Updating methods + #################################################################### + + @property + def first_run(self): + """Return ``True`` if it's the first time this version has run. + + .. versionadded:: 1.9.10 + + Raises a :class:`ValueError` if :attr:`version` isn't set. + + """ + + if not self.version: + raise ValueError('No workflow version set') + + if not self.last_version_run: + return True + + return self.version != self.last_version_run + + @property + def last_version_run(self): + """Return version of last version to run (or ``None``). + + .. versionadded:: 1.9.10 + + :returns: :class:`~workflow.update.Version` instance + or ``None`` + + """ + if self._last_version_run is UNSET: + + version = self.settings.get('__workflow_last_version') + if version: + from update import Version + version = Version(version) + + self._last_version_run = version + + self.logger.debug('Last run version : {0}'.format( + self._last_version_run)) + + return self._last_version_run + + def set_last_version(self, version=None): + """Set :attr:`last_version_run` to current version. + + .. versionadded:: 1.9.10 + + :param version: version to store (default is current version) + :type version: :class:`~workflow.update.Version` instance + or ``unicode`` + :returns: ``True`` if version is saved, else ``False`` + + """ + if not version: + if not self.version: + self.logger.warning( + "Can't save last version: workflow has no version") + return False + + version = self.version + + if isinstance(version, basestring): + from update import Version + version = Version(version) + + self.settings['__workflow_last_version'] = str(version) + + self.logger.debug('Set last run version : {0}'.format(version)) + + return True + + @property + def update_available(self): + """Whether an update is available. + + .. versionadded:: 1.9 + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :returns: ``True`` if an update is available, else ``False`` + + """ + update_data = self.cached_data('__workflow_update_status', max_age=0) + self.logger.debug('update_data : {0}'.format(update_data)) + + if not update_data or not update_data.get('available'): + return False + + return update_data['available'] + + @property + def prereleases(self): + """Whether workflow should update to pre-release versions. + + .. versionadded:: 1.16 + + :returns: ``True`` if pre-releases are enabled with the :ref:`magic + argument ` or the ``update_settings`` dict, else + ``False``. + + """ + if self._update_settings.get('prereleases'): + return True + + return self.settings.get('__workflow_prereleases') or False + + def check_update(self, force=False): + """Call update script if it's time to check for a new release. + + .. versionadded:: 1.9 + + The update script will be run in the background, so it won't + interfere in the execution of your workflow. + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :param force: Force update check + :type force: ``Boolean`` + + """ + frequency = self._update_settings.get('frequency', + DEFAULT_UPDATE_FREQUENCY) + + if not force and not self.settings.get('__workflow_autoupdate', True): + self.logger.debug('Auto update turned off by user') + return + + # Check for new version if it's time + if (force or not self.cached_data_fresh( + '__workflow_update_status', frequency * 86400)): + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + from background import run_in_background + + # update.py is adjacent to this file + update_script = os.path.join(os.path.dirname(__file__), + b'update.py') + + cmd = ['/usr/bin/python', update_script, 'check', github_slug, + version] + + if self.prereleases: + cmd.append('--prereleases') + + self.logger.info('Checking for update ...') + + run_in_background('__workflow_update_check', cmd) + + else: + self.logger.debug('Update check not due') + + def start_update(self): + """Check for update and download and install new workflow file + + .. versionadded:: 1.9 + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :returns: ``True`` if an update is available and will be + installed, else ``False`` + + """ + import update + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + if not update.check_update(github_slug, version, self.prereleases): + return False + + from background import run_in_background + + # update.py is adjacent to this file + update_script = os.path.join(os.path.dirname(__file__), + b'update.py') + + cmd = ['/usr/bin/python', update_script, 'install', github_slug, + version] + + if self.prereleases: + cmd.append('--prereleases') + + self.logger.debug('Downloading update ...') + run_in_background('__workflow_update_install', cmd) + + return True + + #################################################################### + # Keychain password storage methods + #################################################################### + + def save_password(self, account, password, service=None): + """Save account credentials. + + If the account exists, the old password will first be deleted + (Keychain throws an error otherwise). + + If something goes wrong, a :class:`KeychainError` exception will + be raised. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param password: the password to secure + :type password: ``unicode`` + :param service: Name of the service. By default, this is the + workflow's bundle ID + :type service: ``unicode`` + + """ + if not service: + service = self.bundleid + + try: + self._call_security('add-generic-password', service, account, + '-w', password) + self.logger.debug('Saved password : %s:%s', service, account) + + except PasswordExists: + self.logger.debug('Password exists : %s:%s', service, account) + current_password = self.get_password(account, service) + + if current_password == password: + self.logger.debug('Password unchanged') + + else: + self.delete_password(account, service) + self._call_security('add-generic-password', service, + account, '-w', password) + self.logger.debug('save_password : %s:%s', service, account) + + def get_password(self, account, service=None): + """Retrieve the password saved at ``service/account``. + + Raise :class:`PasswordNotFound` exception if password doesn't exist. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param service: Name of the service. By default, this is the workflow's + bundle ID + :type service: ``unicode`` + :returns: account password + :rtype: ``unicode`` + + """ + if not service: + service = self.bundleid + + output = self._call_security('find-generic-password', service, + account, '-g') + + # Parsing of `security` output is adapted from python-keyring + # by Jason R. Coombs + # https://pypi.python.org/pypi/keyring + m = re.search( + r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', + output) + + if m: + groups = m.groupdict() + h = groups.get('hex') + password = groups.get('pw') + if h: + password = unicode(binascii.unhexlify(h), 'utf-8') + + self.logger.debug('Got password : %s:%s', service, account) + + return password + + def delete_password(self, account, service=None): + """Delete the password stored at ``service/account``. + + Raise :class:`PasswordNotFound` if account is unknown. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param service: Name of the service. By default, this is the workflow's + bundle ID + :type service: ``unicode`` + + """ + if not service: + service = self.bundleid + + self._call_security('delete-generic-password', service, account) + + self.logger.debug('Deleted password : %s:%s', service, account) + + #################################################################### + # Methods for workflow:* magic args + #################################################################### + + def _register_default_magic(self): + """Register the built-in magic arguments.""" + # TODO: refactor & simplify + # Wrap callback and message with callable + def callback(func, msg): + def wrapper(): + func() + return msg + + return wrapper + + self.magic_arguments['delcache'] = callback(self.clear_cache, + 'Deleted workflow cache') + self.magic_arguments['deldata'] = callback(self.clear_data, + 'Deleted workflow data') + self.magic_arguments['delsettings'] = callback( + self.clear_settings, 'Deleted workflow settings') + self.magic_arguments['reset'] = callback(self.reset, + 'Reset workflow') + self.magic_arguments['openlog'] = callback(self.open_log, + 'Opening workflow log file') + self.magic_arguments['opencache'] = callback( + self.open_cachedir, 'Opening workflow cache directory') + self.magic_arguments['opendata'] = callback( + self.open_datadir, 'Opening workflow data directory') + self.magic_arguments['openworkflow'] = callback( + self.open_workflowdir, 'Opening workflow directory') + self.magic_arguments['openterm'] = callback( + self.open_terminal, 'Opening workflow root directory in Terminal') + + # Diacritic folding + def fold_on(): + self.settings['__workflow_diacritic_folding'] = True + return 'Diacritics will always be folded' + + def fold_off(): + self.settings['__workflow_diacritic_folding'] = False + return 'Diacritics will never be folded' + + def fold_default(): + if '__workflow_diacritic_folding' in self.settings: + del self.settings['__workflow_diacritic_folding'] + return 'Diacritics folding reset' + + self.magic_arguments['foldingon'] = fold_on + self.magic_arguments['foldingoff'] = fold_off + self.magic_arguments['foldingdefault'] = fold_default + + # Updates + def update_on(): + self.settings['__workflow_autoupdate'] = True + return 'Auto update turned on' + + def update_off(): + self.settings['__workflow_autoupdate'] = False + return 'Auto update turned off' + + def prereleases_on(): + self.settings['__workflow_prereleases'] = True + return 'Prerelease updates turned on' + + def prereleases_off(): + self.settings['__workflow_prereleases'] = False + return 'Prerelease updates turned off' + + def do_update(): + if self.start_update(): + return 'Downloading and installing update ...' + else: + return 'No update available' + + self.magic_arguments['autoupdate'] = update_on + self.magic_arguments['noautoupdate'] = update_off + self.magic_arguments['prereleases'] = prereleases_on + self.magic_arguments['noprereleases'] = prereleases_off + self.magic_arguments['update'] = do_update + + # Help + def do_help(): + if self.help_url: + self.open_help() + return 'Opening workflow help URL in browser' + else: + return 'Workflow has no help URL' + + def show_version(): + if self.version: + return 'Version: {0}'.format(self.version) + else: + return 'This workflow has no version number' + + def list_magic(): + """Display all available magic args in Alfred""" + isatty = sys.stderr.isatty() + for name in sorted(self.magic_arguments.keys()): + if name == 'magic': + continue + arg = '{0}{1}'.format(self.magic_prefix, name) + self.logger.debug(arg) + + if not isatty: + self.add_item(arg, icon=ICON_INFO) + + if not isatty: + self.send_feedback() + + self.magic_arguments['help'] = do_help + self.magic_arguments['magic'] = list_magic + self.magic_arguments['version'] = show_version + + def clear_cache(self, filter_func=lambda f: True): + """Delete all files in workflow's :attr:`cachedir`. + + :param filter_func: Callable to determine whether a file should be + deleted or not. ``filter_func`` is called with the filename + of each file in the data directory. If it returns ``True``, + the file will be deleted. + By default, *all* files will be deleted. + :type filter_func: ``callable`` + """ + self._delete_directory_contents(self.cachedir, filter_func) + + def clear_data(self, filter_func=lambda f: True): + """Delete all files in workflow's :attr:`datadir`. + + :param filter_func: Callable to determine whether a file should be + deleted or not. ``filter_func`` is called with the filename + of each file in the data directory. If it returns ``True``, + the file will be deleted. + By default, *all* files will be deleted. + :type filter_func: ``callable`` + """ + self._delete_directory_contents(self.datadir, filter_func) + + def clear_settings(self): + """Delete workflow's :attr:`settings_path`.""" + if os.path.exists(self.settings_path): + os.unlink(self.settings_path) + self.logger.debug('Deleted : %r', self.settings_path) + + def reset(self): + """Delete :attr:`settings `, :attr:`cache ` + and :attr:`data ` + + """ + self.clear_cache() + self.clear_data() + self.clear_settings() + + def open_log(self): + """Open :attr:`logfile` in default app (usually Console.app).""" + subprocess.call(['open', self.logfile]) + + def open_cachedir(self): + """Open the workflow's :attr:`cachedir` in Finder.""" + subprocess.call(['open', self.cachedir]) + + def open_datadir(self): + """Open the workflow's :attr:`datadir` in Finder.""" + subprocess.call(['open', self.datadir]) + + def open_workflowdir(self): + """Open the workflow's :attr:`workflowdir` in Finder.""" + subprocess.call(['open', self.workflowdir]) + + def open_terminal(self): + """Open a Terminal window at workflow's :attr:`workflowdir`.""" + + subprocess.call(['open', '-a', 'Terminal', + self.workflowdir]) + + def open_help(self): + """Open :attr:`help_url` in default browser.""" + subprocess.call(['open', self.help_url]) + + return 'Opening workflow help URL in browser' + + #################################################################### + # Helper methods + #################################################################### + + def decode(self, text, encoding=None, normalization=None): + """Return ``text`` as normalised unicode. + + If ``encoding`` and/or ``normalization`` is ``None``, the + ``input_encoding``and ``normalization`` parameters passed to + :class:`Workflow` are used. + + :param text: string + :type text: encoded or Unicode string. If ``text`` is already a + Unicode string, it will only be normalised. + :param encoding: The text encoding to use to decode ``text`` to + Unicode. + :type encoding: ``unicode`` or ``None`` + :param normalization: The nomalisation form to apply to ``text``. + :type normalization: ``unicode`` or ``None`` + :returns: decoded and normalised ``unicode`` + + :class:`Workflow` uses "NFC" normalisation by default. This is the + standard for Python and will work well with data from the web (via + :mod:`~workflow.web` or :mod:`json`). + + OS X, on the other hand, uses "NFD" normalisation (nearly), so data + coming from the system (e.g. via :mod:`subprocess` or + :func:`os.listdir`/:mod:`os.path`) may not match. You should either + normalise this data, too, or change the default normalisation used by + :class:`Workflow`. + + """ + encoding = encoding or self._input_encoding + normalization = normalization or self._normalizsation + if not isinstance(text, unicode): + text = unicode(text, encoding) + return unicodedata.normalize(normalization, text) + + def fold_to_ascii(self, text): + """Convert non-ASCII characters to closest ASCII equivalent. + + .. versionadded:: 1.3 + + .. note:: This only works for a subset of European languages. + + :param text: text to convert + :type text: ``unicode`` + :returns: text containing only ASCII characters + :rtype: ``unicode`` + + """ + if isascii(text): + return text + text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text]) + return unicode(unicodedata.normalize('NFKD', + text).encode('ascii', 'ignore')) + + def dumbify_punctuation(self, text): + """Convert non-ASCII punctuation to closest ASCII equivalent. + + This method replaces "smart" quotes and n- or m-dashes with their + workaday ASCII equivalents. This method is currently not used + internally, but exists as a helper method for workflow authors. + + .. versionadded: 1.9.7 + + :param text: text to convert + :type text: ``unicode`` + :returns: text with only ASCII punctuation + :rtype: ``unicode`` + + """ + if isascii(text): + return text + + text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text]) + return text + + def _delete_directory_contents(self, dirpath, filter_func): + """Delete all files in a directory. + + :param dirpath: path to directory to clear + :type dirpath: ``unicode`` or ``str`` + :param filter_func function to determine whether a file shall be + deleted or not. + :type filter_func ``callable`` + + """ + if os.path.exists(dirpath): + for filename in os.listdir(dirpath): + if not filter_func(filename): + continue + path = os.path.join(dirpath, filename) + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.unlink(path) + self.logger.debug('Deleted : %r', path) + + def _load_info_plist(self): + """Load workflow info from ``info.plist``.""" + # info.plist should be in the directory above this one + self._info = plistlib.readPlist(self.workflowfile('info.plist')) + self._info_loaded = True + + def _create(self, dirpath): + """Create directory `dirpath` if it doesn't exist. + + :param dirpath: path to directory + :type dirpath: ``unicode`` + :returns: ``dirpath`` argument + :rtype: ``unicode`` + + """ + if not os.path.exists(dirpath): + os.makedirs(dirpath) + return dirpath + + def _call_security(self, action, service, account, *args): + """Call ``security`` CLI program that provides access to keychains. + + May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` + exceptions (the first two are subclasses of `KeychainError`). + + :param action: The ``security`` action to call, e.g. + ``add-generic-password`` + :type action: ``unicode`` + :param service: Name of the service. + :type service: ``unicode`` + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param password: the password to secure + :type password: ``unicode`` + :param *args: list of command line arguments to be passed to + ``security`` + :type *args: `list` or `tuple` + :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a + ``unicode`` string. + :rtype: `tuple` (`int`, ``unicode``) + + """ + cmd = ['security', action, '-s', service, '-a', account] + list(args) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + stdout, _ = p.communicate() + if p.returncode == 44: # password does not exist + raise PasswordNotFound() + elif p.returncode == 45: # password already exists + raise PasswordExists() + elif p.returncode > 0: + err = KeychainError('Unknown Keychain error : %s' % stdout) + err.retcode = p.returncode + raise err + return stdout.strip().decode('utf-8') diff --git a/workflow/workflow.pyc b/workflow/workflow.pyc new file mode 100644 index 0000000..e35d03b Binary files /dev/null and b/workflow/workflow.pyc differ diff --git a/workflow/workflow3.py b/workflow/workflow3.py new file mode 100644 index 0000000..d3f2205 --- /dev/null +++ b/workflow/workflow3.py @@ -0,0 +1,418 @@ +# encoding: utf-8 +# +# Copyright (c) 2016 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2016-06-25 +# + +""" +:class:`Workflow3` supports Alfred 3's new features. + +It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`. + +It supports setting :ref:`workflow-variables` and +:class:`the more advanced modifiers ` supported by Alfred 3. + +In order for the feedback mechanism to work correctly, it's important +to create :class:`Item3` and :class:`Modifier` objects via the +:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods +respectively. If you instantiate :class:`Item3` or :class:`Modifier` +objects directly, the current :class:`~workflow.workflow3.Workflow3` +object won't be aware of them, and they won't be sent to Alfred when +you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`. +""" + +from __future__ import print_function, unicode_literals, absolute_import + +import json +import os +import sys + +from .workflow import Workflow + + +class Modifier(object): + """Modify ``Item3`` values for when specified modifier keys are pressed. + + Valid modifiers (i.e. values for ``key``) are: + + * cmd + * alt + * shift + * ctrl + * fn + + Attributes: + arg (unicode): Arg to pass to following action. + key (unicode): Modifier key (see above). + subtitle (unicode): Override item subtitle. + valid (bool): Override item validity. + variables (dict): Workflow variables set by this modifier. + + """ + + def __init__(self, key, subtitle=None, arg=None, valid=None): + """Create a new :class:`Modifier`. + + You probably don't want to use this class directly, but rather + use :meth:`Item3.add_modifier()` to add modifiers to results. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (unicode, optional): Override default subtitle. + arg (unicode, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + """ + self.key = key + self.subtitle = subtitle + self.arg = arg + self.valid = valid + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + @property + def obj(self): + """Return modifier formatted for JSON serialization for Alfred 3. + + Returns: + dict: Modifier for serializing to JSON. + """ + o = {} + + if self.subtitle is not None: + o['subtitle'] = self.subtitle + + if self.arg is not None: + o['arg'] = self.arg + + if self.valid is not None: + o['valid'] = self.valid + + # Variables and config + if self.variables or self.config: + d = {} + if self.variables: + d['variables'] = self.variables + + if self.config: + d['config'] = self.config + + if self.arg is not None: + d['arg'] = self.arg + + o['arg'] = json.dumps({'alfredworkflow': d}) + + return o + + +class Item3(object): + """Represents a feedback item for Alfred 3. + + Generates Alfred-compliant JSON for a single item. + + You probably shouldn't use this class directly, but via + :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item` + for details of arguments. + + """ + + def __init__(self, title, subtitle='', arg=None, autocomplete=None, + valid=False, uid=None, icon=None, icontype=None, + type=None, largetext=None, copytext=None, quicklookurl=None): + """Use same arguments as for :meth:`Workflow.add_item`. + + Argument ``subtitle_modifiers`` is not supported. + + """ + self.title = title + self.subtitle = subtitle + self.arg = arg + self.autocomplete = autocomplete + self.valid = valid + self.uid = uid + self.icon = icon + self.icontype = icontype + self.type = type + self.quicklookurl = quicklookurl + self.largetext = largetext + self.copytext = copytext + + self.modifiers = {} + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + def add_modifier(self, key, subtitle=None, arg=None, valid=None): + """Add alternative values for a modifier key. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` + subtitle (unicode, optional): Override item subtitle. + arg (unicode, optional): Input for following action. + valid (bool, optional): Override item validity. + + Returns: + Modifier: Configured :class:`Modifier`. + """ + mod = Modifier(key, subtitle, arg, valid) + + for k in self.variables: + mod.setvar(k, self.variables[k]) + + self.modifiers[key] = mod + + return mod + + @property + def obj(self): + """Return Modifier formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred 3 feedback. + """ + # Basic values + o = {'title': self.title, + 'subtitle': self.subtitle, + 'valid': self.valid} + + icon = {} + + # Optional values + if self.arg is not None: + o['arg'] = self.arg + + if self.autocomplete is not None: + o['autocomplete'] = self.autocomplete + + if self.uid is not None: + o['uid'] = self.uid + + if self.type is not None: + o['type'] = self.type + + if self.quicklookurl is not None: + o['quicklookurl'] = self.quicklookurl + + # Largetype and copytext + text = self._text() + if text: + o['text'] = text + + icon = self._icon() + if icon: + o['icon'] = icon + + # Variables and config + js = self._vars_and_config() + if js: + o['arg'] = js + + # Modifiers + mods = self._modifiers() + if mods: + o['mods'] = mods + + return o + + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + """ + icon = {} + if self.icon is not None: + icon['path'] = self.icon + + if self.icontype is not None: + icon['type'] = self.icontype + + return icon + + def _text(self): + """Return `largetext` and `copytext` object for item. + + Returns: + dict: `text` mapping (may be empty) + + """ + text = {} + if self.largetext is not None: + text['largetype'] = self.largetext + + if self.copytext is not None: + text['copy'] = self.copytext + + return text + + def _vars_and_config(self): + """Build `arg` including workflow variables and configuration. + + Returns: + str: JSON string value for `arg` (or `None`) + + """ + if self.variables or self.config: + d = {} + if self.variables: + d['variables'] = self.variables + + if self.config: + d['config'] = self.config + + if self.arg is not None: + d['arg'] = self.arg + + return json.dumps({'alfredworkflow': d}) + + return None + + def _modifiers(self): + """Build `mods` dictionary for JSON feedback. + + Returns: + dict: Modifier mapping or `None`. + + """ + if self.modifiers: + mods = {} + for k, mod in self.modifiers.items(): + mods[k] = mod.obj + + return mods + + return None + + +class Workflow3(Workflow): + """Workflow class that generates Alfred 3 feedback.""" + + item_class = Item3 + + def __init__(self, **kwargs): + """Create a new :class:`Workflow3` object. + + See :class:`~workflow.workflow.Workflow` for documentation. + """ + Workflow.__init__(self, **kwargs) + self.variables = {} + + @property + def _default_cachedir(self): + """Alfred 3's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-3/' + 'Workflow Data/'), + self.bundleid) + + @property + def _default_datadir(self): + """Alfred 3's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 3/Workflow Data/'), + self.bundleid) + + def setvar(self, name, value): + """Set a workflow variable that will be inherited by all new items. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + """ + return self.variables.get(name, default) + + def add_item(self, title, subtitle='', arg=None, autocomplete=None, + valid=False, uid=None, icon=None, icontype=None, + type=None, largetext=None, copytext=None, quicklookurl=None): + """Add an item to be output to Alfred. + + See :meth:`~workflow.workflow.Workflow.add_item` for the main + documentation. + + The key difference is that this method does not support the + ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` + method instead on the returned item instead. + + Returns: + Item3: Alfred feedback item. + + """ + item = self.item_class(title, subtitle, arg, + autocomplete, valid, uid, icon, icontype, type, + largetext, copytext, quicklookurl) + + for k in self.variables: + item.setvar(k, self.variables[k]) + + self._items.append(item) + return item + + def send_feedback(self): + """Print stored items to console/Alfred as JSON.""" + items = [] + for item in self._items: + items.append(item.obj) + + json.dump({'items': items}, sys.stdout) + sys.stdout.flush() diff --git a/workflow/workflow3.pyc b/workflow/workflow3.pyc new file mode 100644 index 0000000..52b28e3 Binary files /dev/null and b/workflow/workflow3.pyc differ