From 3b8a2288ac93e1c74d7e5074088d77da2e64dfe8 Mon Sep 17 00:00:00 2001 From: Abhishek Dwaraki Date: Fri, 6 May 2022 12:08:04 -0500 Subject: [PATCH] Dynamic Icon Set Update. Added in some easy code to dynamically pick the icon set based on a command-line argument. (AWP made some automated changes to the files, so committing them as is). --- README.md | 7 +- src/battery.sh | 42 +- src/icons/keyboard.png | Bin 0 -> 4162 bytes src/icons/mouse.png | Bin 0 -> 17109 bytes src/icons/trackpad.png | Bin 0 -> 9943 bytes workflow/Notify.tgz | Bin 0 -> 35556 bytes workflow/__init__.py | 108 ++ workflow/background.py | 290 +++++ workflow/notify.py | 348 +++++ workflow/update.py | 565 ++++++++ workflow/util.py | 644 +++++++++ workflow/version | 1 + workflow/web.py | 720 ++++++++++ workflow/workflow.py | 2820 ++++++++++++++++++++++++++++++++++++++++ workflow/workflow3.py | 734 +++++++++++ 15 files changed, 6259 insertions(+), 20 deletions(-) create mode 100644 src/icons/keyboard.png create mode 100644 src/icons/mouse.png create mode 100644 src/icons/trackpad.png create mode 100644 workflow/Notify.tgz create mode 100644 workflow/__init__.py create mode 100644 workflow/background.py create mode 100644 workflow/notify.py create mode 100644 workflow/update.py create mode 100644 workflow/util.py create mode 100644 workflow/version create mode 100644 workflow/web.py create mode 100644 workflow/workflow.py create mode 100644 workflow/workflow3.py diff --git a/README.md b/README.md index 847bcad..1630ef6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Battery -> For Apple M1 chip users: https://github.com/BenziAhamed/alfred-battery/issues/11#issue-812562822 - ![Logo](info.png) An Alfred workflow to display battery information of your Apple laptop. +Is activated by the keyword "battery". The new gradient icon set is the default, but if +you like the old set better, just use the optional keyword "reg", like thus: "battery reg" +and it will revert to the old set. + Gradient Icons: Thanks to their respective authors on FlatIcon. @@ -21,3 +23,4 @@ Clock icons created by redempticon Mouse icons created by Ilham Fitrotul Hayat Panel icons created by Pixel perfect Computer peripheral icons created by lakonicon +Macbook icons created by bqlqn diff --git a/src/battery.sh b/src/battery.sh index b9aca29..cc65878 100644 --- a/src/battery.sh +++ b/src/battery.sh @@ -1,7 +1,13 @@ -#1/usr/bin/env bash +#!/bin/bash ioreg -l -n AppleSmartBattery -r > info.txt +if [ "$1" = "reg" ]; then + ICON_SET="icons" +else + ICON_SET="gradient-icons" +fi + RAW_CURRENT_CAPACITY=$(cat info.txt | grep -e \"AppleRawCurrentCapacity\" | awk '{printf ("%i", $3)}') RAW_MAX_CAPACITY=$(cat info.txt | grep -e \"AppleRawMaxCapacity\" | awk '{printf ("%i", $3)}') # MAX_CAPACITY=$(cat info.txt | grep -e \"MaxCapacity\" | | awk -F',' '{printf ("%s", $45)}' | awk -F'=' '{printf ("%i", $2)}') @@ -28,13 +34,13 @@ fi TIME_INFO=n STATUS_INFO=Draining -BATT_ICON="gradient-icons/draining.png" +BATT_ICON="$ICON_SET/draining.png" if [ $CHARGING == Yes ]; then TIME_FULL=$(cat info.txt | grep -e \"AvgTimeToFull\" | tr '\n' ' | ' | awk '{printf("%i:%.2i", $3/60, $3%60)}') TIME_INFO=$(echo $TIME_FULL until full) STATUS_INFO=Charging - BATT_ICON="gradient-icons/charging.png" + BATT_ICON="$ICON_SET/charging.png" else FULLY_CHARGED=$(cat info.txt | grep -e \"FullyCharged\" | awk '{printf("%s",$3)}') EXTERNAL=$(cat info.txt | grep -e \"ExternalConnected\" | awk '{printf("%s",$3)}') @@ -42,20 +48,20 @@ else if [ $EXTERNAL == Yes ]; then TIME_INFO=$(echo On AC power) STATUS_INFO=$(echo Fully Charged) - BATT_ICON="gradient-icons/power.png" + BATT_ICON="$ICON_SET/power.png" else TIME_INFO=$(echo $TIME_LEFT) - BATT_ICON="gradient-icons/full.png" + BATT_ICON="$ICON_SET/full.png" fi else TIME_INFO=$(echo $TIME_LEFT) - BATT_ICON="gradient-icons/critical.png" + BATT_ICON="$ICON_SET/critical.png" if [ $CHARGE -gt 80 ]; then - BATT_ICON="gradient-icons/full.png" + BATT_ICON="$ICON_SET/full.png" elif [ $CHARGE -gt 50 ]; then - BATT_ICON="gradient-icons/medium.png" + BATT_ICON="$ICON_SET/medium.png" elif [ $CHARGE -gt 10 ]; then - BATT_ICON="gradient-icons/low.png" + BATT_ICON="$ICON_SET/low.png" fi fi fi @@ -69,7 +75,7 @@ let "day=(MANUFACTURE_DATE >> 32) & 0xFFFF" AGE=$(python3 -c "from datetime import date as D; d1=D.today(); d2=D($year, $month, $day); print ( (d1.year - d2.year)*12 + d1.month - d2.month )") -TRACKPAD_ICON="gradient-icons/trackpad.png" +TRACKPAD_ICON="$ICON_SET/trackpad.png" # trackpad TrackpadPercent=`ioreg -c AppleDeviceManagementHIDEventService | grep -se \"Magic Trackpad\" -A8 | grep -se \"BatteryPercent\" | sed 's/[a-z,A-Z, ,|,\",=]//g' | tail -1 | awk '{print $1}'` if [ ${#TrackpadPercent} = 0 ] @@ -80,7 +86,7 @@ else TrackpadTitle="$TrackpadPercent% $TrackpadSlug" fi -MOUSE_ICON="gradient-icons/mouse.png" +MOUSE_ICON="$ICON_SET/mouse.png" # mouse MousePercent=`ioreg -c AppleDeviceManagementHIDEventService | grep -se \"Magic Mouse\" -A8 | grep -se \"BatteryPercent\" | sed 's/[a-z,A-Z, ,|,\",=]//g' | tail -1 | awk '{print $1}'` if [ ${#MousePercent} = 0 ] @@ -91,7 +97,7 @@ else MouseTitle="$MousePercent% $MouseSlug" fi -KEYBOARD_ICON="gradient-icons/keyboard.png" +KEYBOARD_ICON="$ICON_SET/keyboard.png" # keyboard KeyboardPercent=`ioreg -c AppleDeviceManagementHIDEventService | grep -se \"Magic Keyboard\" -A8 | grep -se \"BatteryPercent\" | sed 's/[a-z,A-Z, ,|,\",=]//g' | tail -1 | awk '{print $1}'` if [ ${#KeyboardPercent} = 0 ] @@ -113,22 +119,22 @@ cat << EOB $TIME_INFO Time Left - gradient-icons/clock.png + $ICON_SET/clock.png ${TEMPERATURE} °C Temperature - gradient-icons/temp.png + $ICON_SET/temp.png $CYCLE_COUNT Charge Cycles Completed - gradient-icons/cycles.png + $ICON_SET/cycles.png $HEALTH% Health - gradient-icons/health.png + $ICON_SET/health.png $MouseTitle @@ -148,12 +154,12 @@ cat << EOB $SERIAL Serial - gradient-icons/serial.png + $ICON_SET/serial.png $AGE months Age - gradient-icons/age.png + $ICON_SET/age.png EOB diff --git a/src/icons/keyboard.png b/src/icons/keyboard.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0debd16b6f28e5298a4d3f9b802391a5ff213a GIT binary patch literal 4162 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 z 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 N#4T;rlAAV9{|~esNQ(df literal 0 HcmV?d00001 diff --git a/src/icons/trackpad.png b/src/icons/trackpad.png new file mode 100644 index 0000000000000000000000000000000000000000..74294211fba97833577ecc8b07b74453dba9bcf1 GIT binary patch literal 9943 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 literal 0 HcmV?d00001 diff --git a/workflow/__init__.py b/workflow/__init__.py new file mode 100644 index 0000000..17636a4 --- /dev/null +++ b/workflow/__init__.py @@ -0,0 +1,108 @@ +#!/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 Variables, 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-2019 Dean Jackson' + +__all__ = [ + 'Variables', + '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/background.py b/workflow/background.py new file mode 100644 index 0000000..c2bd735 --- /dev/null +++ b/workflow/background.py @@ -0,0 +1,290 @@ +#!/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 +# + +"""This module provides an API to run commands in background processes. + +Combine with the :ref:`caching API ` to work from cached data +while you fetch fresh data in the background. + +See :ref:`the User Manual ` for more information +and examples. +""" + +from __future__ import print_function, unicode_literals + +import signal +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 _log(): + return wf().logger + + +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(name + '.argcache') + + +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(name + '.pid') + + +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 _job_pid(name): + """Get PID of job or `None` if job does not exist. + + Args: + name (str): Name of job. + + Returns: + int: PID of job process (or `None` if job doesn't exist). + """ + pidfile = _pid_file(name) + if not os.path.exists(pidfile): + return + + with open(pidfile, 'rb') as fp: + pid = int(fp.read()) + + if _process_exists(pid): + return pid + + os.unlink(pidfile) + + +def is_running(name): + """Test whether task ``name`` is currently running. + + :param name: name of task + :type name: unicode + :returns: ``True`` if task with name ``name`` is running, else ``False`` + :rtype: bool + + """ + if _job_pid(name) is not None: + return True + + return False + + +def _background(pidfile, stdin='/dev/null', stdout='/dev/null', + stderr='/dev/null'): # pragma: no cover + """Fork the current process into a background daemon. + + :param pidfile: file to write PID of daemon process to. + :type pidfile: filepath + :param stdin: where to read input + :type stdin: filepath + :param stdout: where to write stdout output + :type stdout: filepath + :param stderr: where to write stderr output + :type stderr: filepath + + """ + def _fork_and_exit_parent(errmsg, wait=False, write=False): + try: + pid = os.fork() + if pid > 0: + if write: # write PID of child process to `pidfile` + tmp = pidfile + '.tmp' + with open(tmp, 'wb') as fp: + fp.write(str(pid)) + os.rename(tmp, pidfile) + if wait: # wait for child process to exit + os.waitpid(pid, 0) + os._exit(0) + except OSError as err: + _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) + raise err + + # Do first fork and wait for second fork to finish. + _fork_and_exit_parent('fork #1 failed', wait=True) + + # Decouple from parent environment. + os.chdir(wf().workflowdir) + os.setsid() + + # Do second fork and write PID to pidfile. + _fork_and_exit_parent('fork #2 failed', write=True) + + # Now I am a daemon! + # Redirect standard file descriptors. + si = open(stdin, 'r', 0) + so = open(stdout, 'a+', 0) + se = open(stderr, 'a+', 0) + if hasattr(sys.stdin, 'fileno'): + os.dup2(si.fileno(), sys.stdin.fileno()) + if hasattr(sys.stdout, 'fileno'): + os.dup2(so.fileno(), sys.stdout.fileno()) + if hasattr(sys.stderr, 'fileno'): + os.dup2(se.fileno(), sys.stderr.fileno()) + + +def kill(name, sig=signal.SIGTERM): + """Send a signal to job ``name`` via :func:`os.kill`. + + .. versionadded:: 1.29 + + Args: + name (str): Name of the job + sig (int, optional): Signal to send (default: SIGTERM) + + Returns: + bool: `False` if job isn't running, `True` if signal was sent. + """ + pid = _job_pid(name) + if pid is None: + return False + + os.kill(pid, sig) + return True + + +def run_in_background(name, args, **kwargs): + r"""Cache arguments then call this script again via :func:`subprocess.call`. + + :param name: name of job + :type name: unicode + :param args: arguments passed as first argument to :func:`subprocess.call` + :param \**kwargs: keyword arguments to :func:`subprocess.call` + :returns: exit code of sub-process + :rtype: int + + 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): + _log().info('[%s] job already running', name) + return + + argcache = _arg_cache(name) + + # Cache arguments + with open(argcache, 'wb') as fp: + pickle.dump({'args': args, 'kwargs': kwargs}, fp) + _log().debug('[%s] command cached: %s', name, argcache) + + # Call this script + cmd = ['/usr/bin/python', __file__, name] + _log().debug('[%s] passing job to background runner: %r', name, cmd) + retcode = subprocess.call(cmd) + + if retcode: # pragma: no cover + _log().error('[%s] background runner failed with %d', name, retcode) + else: + _log().debug('[%s] background job started', name) + + return retcode + + +def main(wf): # pragma: no cover + """Run command in a background process. + + Load cached arguments, fork into background, then call + :meth:`subprocess.call` with cached arguments. + + """ + log = wf.logger + name = wf.args[0] + argcache = _arg_cache(name) + if not os.path.exists(argcache): + msg = '[{0}] command cache not found: {1}'.format(name, argcache) + log.critical(msg) + raise IOError(msg) + + # Fork to background and run command + pidfile = _pid_file(name) + _background(pidfile) + + # Load cached arguments + with open(argcache, 'rb') as fp: + data = pickle.load(fp) + + # Cached arguments + args = data['args'] + kwargs = data['kwargs'] + + # Delete argument cache file + os.unlink(argcache) + + try: + # Run the command + log.debug('[%s] running command: %r', name, args) + + retcode = subprocess.call(args, **kwargs) + + if retcode: + log.error('[%s] command failed with status %d', name, retcode) + finally: + os.unlink(pidfile) + + log.debug('[%s] job complete', name) + + +if __name__ == '__main__': # pragma: no cover + wf().run(main) diff --git a/workflow/notify.py b/workflow/notify.py new file mode 100644 index 0000000..28ec0b9 --- /dev/null +++ b/workflow/notify.py @@ -0,0 +1,348 @@ +#!/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 macOS Notification Center. + +This feature is only available on Mountain Lion (10.8) and later. +It will silently fail on older systems. + +The main API is a single function, :func:`~workflow.notify.notify`. + +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) + if not os.path.exists(n): # pragma: nocover + raise RuntimeError('Notify.app could not be installed in ' + destdir) + + # Replace applet icon + icon = notifier_icon_path() + 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 %r', 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 macOS 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', str(size), str(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 %d' % 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 macOS, 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') + + if os.path.exists(iconset): # pragma: nocover + raise RuntimeError('iconset already exists: ' + iconset) + + os.makedirs(iconset) + + # Copy source icon to icon set and generate all the other + # 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 %d' % retcode) + + if not os.path.exists(icns_path): # pragma: nocover + raise ValueError( + 'generated ICNS file not found: ' + repr(icns_path)) + finally: + try: + shutil.rmtree(tempdir) + except OSError: # pragma: no cover + pass + + +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 ustr(s): + """Coerce `s` to normalised Unicode.""" + return normalize('NFD', s.decode('utf-8')) + + p = argparse.ArgumentParser() + p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") + p.add_argument('-l', '--list-sounds', help="Show available sounds.", + action='store_true') + p.add_argument('-t', '--title', + help="Notification title.", type=ustr, + default='') + p.add_argument('-s', '--sound', type=ustr, + help="Optional notification sound.", default='') + p.add_argument('text', type=ustr, + 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), + os.path.splitext(os.path.basename(o.png))[0] + '.icns') + + print('converting {0!r} to {1!r} ...'.format(o.png, icns), + file=sys.stderr) + + if os.path.exists(icns): + raise ValueError('destination file already exists: ' + icns) + + png_to_icns(o.png, icns) + sys.exit(0) + + # Post notification + if o.title == o.text == '': + print('ERROR: empty notification.', file=sys.stderr) + sys.exit(1) + else: + notify(o.title, o.text, o.sound) diff --git a/workflow/update.py b/workflow/update.py new file mode 100644 index 0000000..c039f7a --- /dev/null +++ b/workflow/update.py @@ -0,0 +1,565 @@ +#!/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 + +from collections import defaultdict +from functools import total_ordering +import json +import os +import tempfile +import re +import subprocess + +import workflow +import web + +# __all__ = [] + + +RELEASES_BASE = 'https://api.github.com/repos/{}/releases' +match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search + +_wf = None + + +def wf(): + """Lazy `Workflow` object.""" + global _wf + if _wf is None: + _wf = workflow.Workflow() + return _wf + + +@total_ordering +class Download(object): + """A workflow file that is available for download. + + .. versionadded: 1.37 + + Attributes: + url (str): URL of workflow file. + filename (str): Filename of workflow file. + version (Version): Semantic version of workflow. + prerelease (bool): Whether version is a pre-release. + alfred_version (Version): Minimum compatible version + of Alfred. + + """ + + @classmethod + def from_dict(cls, d): + """Create a `Download` from a `dict`.""" + return cls(url=d['url'], filename=d['filename'], + version=Version(d['version']), + prerelease=d['prerelease']) + + @classmethod + def from_releases(cls, js): + """Extract downloads from GitHub releases. + + Searches releases with semantic tags for assets with + file extension .alfredworkflow or .alfredXworkflow where + X is a number. + + Files are returned sorted by latest version first. Any + releases containing multiple files with the same (workflow) + extension are rejected as ambiguous. + + Args: + js (str): JSON response from GitHub's releases endpoint. + + Returns: + list: Sequence of `Download`. + """ + releases = json.loads(js) + downloads = [] + for release in releases: + tag = release['tag_name'] + dupes = defaultdict(int) + try: + version = Version(tag) + except ValueError as err: + wf().logger.debug('ignored release: bad version "%s": %s', + tag, err) + continue + + dls = [] + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + filename = os.path.basename(url) + m = match_workflow(filename) + if not m: + wf().logger.debug('unwanted file: %s', filename) + continue + + ext = m.group(0) + dupes[ext] = dupes[ext] + 1 + dls.append(Download(url, filename, version, + release['prerelease'])) + + valid = True + for ext, n in dupes.items(): + if n > 1: + wf().logger.debug('ignored release "%s": multiple assets ' + 'with extension "%s"', tag, ext) + valid = False + break + + if valid: + downloads.extend(dls) + + downloads.sort(reverse=True) + return downloads + + def __init__(self, url, filename, version, prerelease=False): + """Create a new Download. + + Args: + url (str): URL of workflow file. + filename (str): Filename of workflow file. + version (Version): Version of workflow. + prerelease (bool, optional): Whether version is + pre-release. Defaults to False. + + """ + if isinstance(version, basestring): + version = Version(version) + + self.url = url + self.filename = filename + self.version = version + self.prerelease = prerelease + + @property + def alfred_version(self): + """Minimum Alfred version based on filename extension.""" + m = match_workflow(self.filename) + if not m or not m.group(1): + return Version('0') + return Version(m.group(1)) + + @property + def dict(self): + """Convert `Download` to `dict`.""" + return dict(url=self.url, filename=self.filename, + version=str(self.version), prerelease=self.prerelease) + + def __str__(self): + """Format `Download` for printing.""" + u = ('Download(url={dl.url!r}, ' + 'filename={dl.filename!r}, ' + 'version={dl.version!r}, ' + 'prerelease={dl.prerelease!r})'.format(dl=self)) + + return u.encode('utf-8') + + def __repr__(self): + """Code-like representation of `Download`.""" + return str(self) + + def __eq__(self, other): + """Compare Downloads based on version numbers.""" + if self.url != other.url \ + or self.filename != other.filename \ + or self.version != other.version \ + or self.prerelease != other.prerelease: + return False + return True + + def __ne__(self, other): + """Compare Downloads based on version numbers.""" + return not self.__eq__(other) + + def __lt__(self, other): + """Compare Downloads based on version numbers.""" + if self.version != other.version: + return self.version < other.version + return self.alfred_version < other.alfred_version + + +class Version(object): + """Mostly semantic versioning. + + The main difference to proper :ref:`semantic versioning ` + is that this implementation doesn't require a minor or patch version. + + Version strings may also be prefixed with "v", e.g.: + + >>> v = Version('v1.1.1') + >>> v.tuple + (1, 1, 1, '') + + >>> v = Version('2.0') + >>> v.tuple + (2, 0, 0, '') + + >>> Version('3.1-beta').tuple + (3, 1, 0, 'beta') + + >>> Version('1.0.1') > Version('0.0.1') + True + """ + + #: Match version and pre-release/build information in version strings + match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match + + def __init__(self, vstr): + """Create new `Version` object. + + Args: + vstr (basestring): Semantic version string. + """ + if not vstr: + raise ValueError('invalid version number: {!r}'.format(vstr)) + + self.vstr = vstr + self.major = 0 + self.minor = 0 + self.patch = 0 + self.suffix = '' + self.build = '' + self._parse(vstr) + + def _parse(self, vstr): + if vstr.startswith('v'): + m = self.match_version(vstr[1:]) + else: + m = self.match_version(vstr) + if not m: + raise ValueError('invalid version number: ' + vstr) + + version, suffix = m.groups() + parts = self._parse_dotted_string(version) + self.major = parts.pop(0) + if len(parts): + self.minor = parts.pop(0) + if len(parts): + self.patch = parts.pop(0) + if not len(parts) == 0: + raise ValueError('version number too long: ' + vstr) + + if suffix: + # Build info + idx = suffix.find('+') + if idx > -1: + self.build = suffix[idx+1:] + suffix = suffix[:idx] + if suffix: + if not suffix.startswith('-'): + raise ValueError( + 'suffix must start with - : ' + suffix) + self.suffix = suffix[1:] + + def _parse_dotted_string(self, s): + """Parse string ``s`` into list of ints and strings.""" + parsed = [] + parts = s.split('.') + for p in parts: + if p.isdigit(): + p = int(p) + parsed.append(p) + return parsed + + @property + def tuple(self): + """Version number as a tuple of major, minor, patch, pre-release.""" + return (self.major, self.minor, self.patch, self.suffix) + + def __lt__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + t = self.tuple[:3] + o = other.tuple[:3] + if t < o: + return True + if t == o: # We need to compare suffixes + if self.suffix and not other.suffix: + return True + if other.suffix and not self.suffix: + return False + return self._parse_dotted_string(self.suffix) \ + < self._parse_dotted_string(other.suffix) + # t > o + return False + + def __eq__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return self.tuple == other.tuple + + def __ne__(self, other): + """Implement comparison.""" + return not self.__eq__(other) + + def __gt__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return other.__lt__(self) + + def __le__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return not other.__lt__(self) + + def __ge__(self, other): + """Implement comparison.""" + return not self.__lt__(other) + + def __str__(self): + """Return semantic version string.""" + vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) + if self.suffix: + vstr = '{0}-{1}'.format(vstr, self.suffix) + if self.build: + vstr = '{0}+{1}'.format(vstr, self.build) + return vstr + + def __repr__(self): + """Return 'code' representation of `Version`.""" + return "Version('{0}')".format(str(self)) + + +def retrieve_download(dl): + """Saves a download to a temporary file and returns path. + + .. versionadded: 1.37 + + Args: + url (unicode): URL to .alfredworkflow file in GitHub repo + + Returns: + unicode: path to downloaded file + + """ + if not match_workflow(dl.filename): + raise ValueError('attachment not a workflow: ' + dl.filename) + + path = os.path.join(tempfile.gettempdir(), dl.filename) + wf().logger.debug('downloading update from ' + '%r to %r ...', dl.url, path) + + r = web.get(dl.url) + r.raise_for_status() + + r.save_to_path(path) + + return path + + +def build_api_url(repo): + """Generate releases URL from GitHub repo. + + Args: + repo (unicode): Repo name in form ``username/repo`` + + Returns: + unicode: URL to the API endpoint for the repo's releases + + """ + if len(repo.split('/')) != 2: + raise ValueError('invalid GitHub repo: {!r}'.format(repo)) + + return RELEASES_BASE.format(repo) + + +def get_downloads(repo): + """Load available ``Download``s for GitHub repo. + + .. versionadded: 1.37 + + Args: + repo (unicode): GitHub repo to load releases for. + + Returns: + list: Sequence of `Download` contained in GitHub releases. + """ + url = build_api_url(repo) + + def _fetch(): + wf().logger.info('retrieving releases for %r ...', repo) + r = web.get(url) + r.raise_for_status() + return r.content + + key = 'github-releases-' + repo.replace('/', '-') + js = wf().cached_data(key, _fetch, max_age=60) + + return Download.from_releases(js) + + +def latest_download(dls, alfred_version=None, prereleases=False): + """Return newest `Download`.""" + alfred_version = alfred_version or os.getenv('alfred_version') + version = None + if alfred_version: + version = Version(alfred_version) + + dls.sort(reverse=True) + for dl in dls: + if dl.prerelease and not prereleases: + wf().logger.debug('ignored prerelease: %s', dl.version) + continue + if version and dl.alfred_version > version: + wf().logger.debug('ignored incompatible (%s > %s): %s', + dl.alfred_version, version, dl.filename) + continue + + wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) + return dl + + return None + + +def check_update(repo, current_version, prereleases=False, + alfred_version=None): + """Check whether a newer release is available on GitHub. + + Args: + repo (unicode): ``username/repo`` for workflow's GitHub repo + current_version (unicode): the currently installed version of the + workflow. :ref:`Semantic versioning ` is required. + prereleases (bool): Whether to include pre-releases. + alfred_version (unicode): version of currently-running Alfred. + if empty, defaults to ``$alfred_version`` environment variable. + + Returns: + bool: ``True`` if an update is available, else ``False`` + + If an update is available, its version number and download URL will + be cached. + + """ + key = '__workflow_latest_version' + # data stored when no update is available + no_update = { + 'available': False, + 'download': None, + 'version': None, + } + current = Version(current_version) + + dls = get_downloads(repo) + if not len(dls): + wf().logger.warning('no valid downloads for %s', repo) + wf().cache_data(key, no_update) + return False + + wf().logger.info('%d download(s) for %s', len(dls), repo) + + dl = latest_download(dls, alfred_version, prereleases) + + if not dl: + wf().logger.warning('no compatible downloads for %s', repo) + wf().cache_data(key, no_update) + return False + + wf().logger.debug('latest=%r, installed=%r', dl.version, current) + + if dl.version > current: + wf().cache_data(key, { + 'version': str(dl.version), + 'download': dl.dict, + 'available': True, + }) + return True + + wf().cache_data(key, no_update) + return False + + +def install_update(): + """If a newer release is available, download and install it. + + :returns: ``True`` if an update is installed, else ``False`` + + """ + key = '__workflow_latest_version' + # data stored when no update is available + no_update = { + 'available': False, + 'download': None, + 'version': None, + } + status = wf().cached_data(key, max_age=0) + + if not status or not status.get('available'): + wf().logger.info('no update available') + return False + + dl = status.get('download') + if not dl: + wf().logger.info('no download information') + return False + + path = retrieve_download(Download.from_dict(dl)) + + wf().logger.info('installing updated workflow ...') + subprocess.call(['open', path]) # nosec + + wf().cache_data(key, no_update) + return True + + +if __name__ == '__main__': # pragma: nocover + import sys + + prereleases = False + + def show_help(status=0): + """Print help message.""" + print('usage: update.py (check|install) ' + '[--prereleases] ') + sys.exit(status) + + argv = sys.argv[:] + if '-h' in argv or '--help' in argv: + show_help() + + if '--prereleases' in argv: + argv.remove('--prereleases') + prereleases = True + + if len(argv) != 4: + show_help(1) + + action = argv[1] + repo = argv[2] + version = argv[3] + + try: + + if action == 'check': + check_update(repo, version, prereleases) + elif action == 'install': + install_update() + else: + show_help(1) + + except Exception as err: # ensure traceback is in log file + wf().logger.exception(err) + raise err diff --git a/workflow/util.py b/workflow/util.py new file mode 100644 index 0000000..ab5e954 --- /dev/null +++ b/workflow/util.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2017 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2017-12-17 +# + +"""A selection of helper functions useful for building workflows.""" + +from __future__ import print_function, absolute_import + +import atexit +from collections import namedtuple +from contextlib import contextmanager +import errno +import fcntl +import functools +import json +import os +import signal +import subprocess +import sys +from threading import Event +import time + +# JXA scripts to call Alfred's API via the Scripting Bridge +# {app} is automatically replaced with "Alfred 3" or +# "com.runningwithcrayons.Alfred" depending on version. +# +# Open Alfred in search (regular) mode +JXA_SEARCH = 'Application({app}).search({arg});' +# Open Alfred's File Actions on an argument +JXA_ACTION = 'Application({app}).action({arg});' +# Open Alfred's navigation mode at path +JXA_BROWSE = 'Application({app}).browse({arg});' +# Set the specified theme +JXA_SET_THEME = 'Application({app}).setTheme({arg});' +# Call an External Trigger +JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});' +# Save a variable to the workflow configuration sheet/info.plist +JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});' +# Delete a variable from the workflow configuration sheet/info.plist +JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});' +# Tell Alfred to reload a workflow from disk +JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});' + + +class AcquisitionError(Exception): + """Raised if a lock cannot be acquired.""" + + +AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) +"""Information about an installed application. + +Returned by :func:`appinfo`. All attributes are Unicode. + +.. py:attribute:: name + + Name of the application, e.g. ``u'Safari'``. + +.. py:attribute:: path + + Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. + +.. py:attribute:: bundleid + + Application's bundle ID, e.g. ``u'com.apple.Safari'``. + +""" + + +def jxa_app_name(): + """Return name of application to call currently running Alfred. + + .. versionadded: 1.37 + + Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending + on which version of Alfred is running. + + This name is suitable for use with ``Application(name)`` in JXA. + + Returns: + unicode: Application name or ID. + + """ + if os.getenv('alfred_version', '').startswith('3'): + # Alfred 3 + return u'Alfred 3' + # Alfred 4+ + return u'com.runningwithcrayons.Alfred' + + +def unicodify(s, encoding='utf-8', norm=None): + """Ensure string is Unicode. + + .. versionadded:: 1.31 + + Decode encoded strings using ``encoding`` and normalise Unicode + to form ``norm`` if specified. + + Args: + s (str): String to decode. May also be Unicode. + encoding (str, optional): Encoding to use on bytestrings. + norm (None, optional): Normalisation form to apply to Unicode string. + + Returns: + unicode: Decoded, optionally normalised, Unicode string. + + """ + if not isinstance(s, unicode): + s = unicode(s, encoding) + + if norm: + from unicodedata import normalize + s = normalize(norm, s) + + return s + + +def utf8ify(s): + """Ensure string is a bytestring. + + .. versionadded:: 1.31 + + Returns `str` objects unchanced, encodes `unicode` objects to + UTF-8, and calls :func:`str` on anything else. + + Args: + s (object): A Python object + + Returns: + str: UTF-8 string or string representation of s. + + """ + if isinstance(s, str): + return s + + if isinstance(s, unicode): + return s.encode('utf-8') + + return str(s) + + +def applescriptify(s): + """Escape string for insertion into an AppleScript string. + + .. versionadded:: 1.31 + + Replaces ``"`` with `"& quote &"`. Use this function if you want + to insert a string into an AppleScript script: + + >>> applescriptify('g "python" test') + 'g " & quote & "python" & quote & "test' + + Args: + s (unicode): Unicode string to escape. + + Returns: + unicode: Escaped string. + + """ + return s.replace(u'"', u'" & quote & "') + + +def run_command(cmd, **kwargs): + """Run a command and return the output. + + .. versionadded:: 1.31 + + A thin wrapper around :func:`subprocess.check_output` that ensures + all arguments are encoded to UTF-8 first. + + Args: + cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. + **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. + + Returns: + str: Output returned by :func:`~subprocess.check_output`. + + """ + cmd = [utf8ify(s) for s in cmd] + return subprocess.check_output(cmd, **kwargs) + + +def run_applescript(script, *args, **kwargs): + """Execute an AppleScript script and return its output. + + .. versionadded:: 1.31 + + Run AppleScript either by filepath or code. If ``script`` is a valid + filepath, that script will be run, otherwise ``script`` is treated + as code. + + Args: + script (str, optional): Filepath of script or code to run. + *args: Optional command-line arguments to pass to the script. + **kwargs: Pass ``lang`` to run a language other than AppleScript. + Any other keyword arguments are passed to :func:`run_command`. + + Returns: + str: Output of run command. + + """ + lang = 'AppleScript' + if 'lang' in kwargs: + lang = kwargs['lang'] + del kwargs['lang'] + + cmd = ['/usr/bin/osascript', '-l', lang] + + if os.path.exists(script): + cmd += [script] + else: + cmd += ['-e', script] + + cmd.extend(args) + + return run_command(cmd, **kwargs) + + +def run_jxa(script, *args): + """Execute a JXA script and return its output. + + .. versionadded:: 1.31 + + Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. + + Args: + script (str): Filepath of script or code to run. + *args: Optional command-line arguments to pass to script. + + Returns: + str: Output of script. + + """ + return run_applescript(script, *args, lang='JavaScript') + + +def run_trigger(name, bundleid=None, arg=None): + """Call an Alfred External Trigger. + + .. versionadded:: 1.31 + + If ``bundleid`` is not specified, the bundle ID of the calling + workflow is used. + + Args: + name (str): Name of External Trigger to call. + bundleid (str, optional): Bundle ID of workflow trigger belongs to. + arg (str, optional): Argument to pass to trigger. + + """ + bundleid = bundleid or os.getenv('alfred_workflow_bundleid') + appname = jxa_app_name() + opts = {'inWorkflow': bundleid} + if arg: + opts['withArgument'] = arg + + script = JXA_TRIGGER.format(app=json.dumps(appname), + arg=json.dumps(name), + opts=json.dumps(opts, sort_keys=True)) + + run_applescript(script, lang='JavaScript') + + +def set_theme(theme_name): + """Change Alfred's theme. + + .. versionadded:: 1.39.0 + + Args: + theme_name (unicode): Name of theme Alfred should use. + + """ + appname = jxa_app_name() + script = JXA_SET_THEME.format(app=json.dumps(appname), + arg=json.dumps(theme_name)) + run_applescript(script, lang='JavaScript') + + +def set_config(name, value, bundleid=None, exportable=False): + """Set a workflow variable in ``info.plist``. + + .. versionadded:: 1.33 + + If ``bundleid`` is not specified, the bundle ID of the calling + workflow is used. + + Args: + name (str): Name of variable to set. + value (str): Value to set variable to. + bundleid (str, optional): Bundle ID of workflow variable belongs to. + exportable (bool, optional): Whether variable should be marked + as exportable (Don't Export checkbox). + + """ + bundleid = bundleid or os.getenv('alfred_workflow_bundleid') + appname = jxa_app_name() + opts = { + 'toValue': value, + 'inWorkflow': bundleid, + 'exportable': exportable, + } + + script = JXA_SET_CONFIG.format(app=json.dumps(appname), + arg=json.dumps(name), + opts=json.dumps(opts, sort_keys=True)) + + run_applescript(script, lang='JavaScript') + + +def unset_config(name, bundleid=None): + """Delete a workflow variable from ``info.plist``. + + .. versionadded:: 1.33 + + If ``bundleid`` is not specified, the bundle ID of the calling + workflow is used. + + Args: + name (str): Name of variable to delete. + bundleid (str, optional): Bundle ID of workflow variable belongs to. + + """ + bundleid = bundleid or os.getenv('alfred_workflow_bundleid') + appname = jxa_app_name() + opts = {'inWorkflow': bundleid} + + script = JXA_UNSET_CONFIG.format(app=json.dumps(appname), + arg=json.dumps(name), + opts=json.dumps(opts, sort_keys=True)) + + run_applescript(script, lang='JavaScript') + + +def search_in_alfred(query=None): + """Open Alfred with given search query. + + .. versionadded:: 1.39.0 + + Omit ``query`` to simply open Alfred's main window. + + Args: + query (unicode, optional): Search query. + + """ + query = query or u'' + appname = jxa_app_name() + script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) + run_applescript(script, lang='JavaScript') + + +def browse_in_alfred(path): + """Open Alfred's filesystem navigation mode at ``path``. + + .. versionadded:: 1.39.0 + + Args: + path (unicode): File or directory path. + + """ + appname = jxa_app_name() + script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) + run_applescript(script, lang='JavaScript') + + +def action_in_alfred(paths): + """Action the give filepaths in Alfred. + + .. versionadded:: 1.39.0 + + Args: + paths (list): Unicode paths to files/directories to action. + + """ + appname = jxa_app_name() + script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) + run_applescript(script, lang='JavaScript') + + +def reload_workflow(bundleid=None): + """Tell Alfred to reload a workflow from disk. + + .. versionadded:: 1.39.0 + + If ``bundleid`` is not specified, the bundle ID of the calling + workflow is used. + + Args: + bundleid (unicode, optional): Bundle ID of workflow to reload. + + """ + bundleid = bundleid or os.getenv('alfred_workflow_bundleid') + appname = jxa_app_name() + script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname), + arg=json.dumps(bundleid)) + + run_applescript(script, lang='JavaScript') + + +def appinfo(name): + """Get information about an installed application. + + .. versionadded:: 1.31 + + Args: + name (str): Name of application to look up. + + Returns: + AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. + + """ + cmd = [ + 'mdfind', + '-onlyin', '/Applications', + '-onlyin', '/System/Applications', + '-onlyin', os.path.expanduser('~/Applications'), + '(kMDItemContentTypeTree == com.apple.application &&' + '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' + .format(name) + ] + + output = run_command(cmd).strip() + if not output: + return None + + path = output.split('\n')[0] + + cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] + bid = run_command(cmd).strip() + if not bid: # pragma: no cover + return None + + return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) + + +@contextmanager +def atomic_writer(fpath, mode): + """Atomic file writer. + + .. versionadded:: 1.12 + + Context manager that ensures the file is only written if the write + succeeds. The data is first written to a temporary file. + + :param fpath: path of file to write to. + :type fpath: ``unicode`` + :param mode: sames as for :func:`open` + :type mode: string + + """ + suffix = '.{}.tmp'.format(os.getpid()) + temppath = fpath + suffix + with open(temppath, mode) as fp: + try: + yield fp + os.rename(temppath, fpath) + finally: + try: + os.remove(temppath) + except (OSError, IOError): + pass + + +class LockFile(object): + """Context manager to protect filepaths with lockfiles. + + .. versionadded:: 1.13 + + Creates a lockfile alongside ``protected_path``. Other ``LockFile`` + instances will refuse to lock the same path. + + >>> path = '/path/to/file' + >>> with LockFile(path): + >>> with open(path, 'wb') as fp: + >>> fp.write(data) + + Args: + protected_path (unicode): File to protect with a lockfile + timeout (float, optional): Raises an :class:`AcquisitionError` + if lock cannot be acquired within this number of seconds. + If ``timeout`` is 0 (the default), wait forever. + delay (float, optional): How often to check (in seconds) if + lock has been released. + + Attributes: + delay (float): How often to check (in seconds) whether the lock + can be acquired. + lockfile (unicode): Path of the lockfile. + timeout (float): How long to wait to acquire the lock. + + """ + + def __init__(self, protected_path, timeout=0.0, delay=0.05): + """Create new :class:`LockFile` object.""" + self.lockfile = protected_path + '.lock' + self._lockfile = None + self.timeout = timeout + self.delay = delay + self._lock = Event() + atexit.register(self.release) + + @property + def locked(self): + """``True`` if file is locked by this instance.""" + return self._lock.is_set() + + def acquire(self, blocking=True): + """Acquire the lock if possible. + + If the lock is in use and ``blocking`` is ``False``, return + ``False``. + + Otherwise, check every :attr:`delay` seconds until it acquires + lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. + + """ + if self.locked and not blocking: + return False + + start = time.time() + while True: + # Raise error if we've been waiting too long to acquire the lock + if self.timeout and (time.time() - start) >= self.timeout: + raise AcquisitionError('lock acquisition timed out') + + # If already locked, wait then try again + if self.locked: + time.sleep(self.delay) + continue + + # Create in append mode so we don't lose any contents + if self._lockfile is None: + self._lockfile = open(self.lockfile, 'a') + + # Try to acquire the lock + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._lock.set() + break + except IOError as err: # pragma: no cover + if err.errno not in (errno.EACCES, errno.EAGAIN): + raise + + # Don't try again + if not blocking: # pragma: no cover + return False + + # Wait, then try again + time.sleep(self.delay) + + return True + + def release(self): + """Release the lock by deleting `self.lockfile`.""" + if not self._lock.is_set(): + return False + + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_UN) + except IOError: # pragma: no cover + pass + finally: + self._lock.clear() + self._lockfile = None + try: + os.unlink(self.lockfile) + except (IOError, OSError): # pragma: no cover + pass + + return True + + def __enter__(self): + """Acquire lock.""" + self.acquire() + return self + + def __exit__(self, typ, value, traceback): + """Release lock.""" + self.release() + + def __del__(self): + """Clear up `self.lockfile`.""" + self.release() # pragma: no cover + + +class uninterruptible(object): + """Decorator that postpones SIGTERM until wrapped function returns. + + .. versionadded:: 1.12 + + .. important:: This decorator is NOT thread-safe. + + As of version 2.7, Alfred allows Script Filters to be killed. If + your workflow is killed in the middle of critical code (e.g. + writing data to disk), this may corrupt your workflow's data. + + Use this decorator to wrap critical functions that *must* complete. + If the script is killed while a wrapped function is executing, + the SIGTERM will be caught and handled after your function has + finished executing. + + Alfred-Workflow uses this internally to ensure its settings, data + and cache writes complete. + + """ + + def __init__(self, func, class_name=''): + """Decorate `func`.""" + self.func = func + functools.update_wrapper(self, func) + self._caught_signal = None + + def signal_handler(self, signum, frame): + """Called when process receives SIGTERM.""" + self._caught_signal = (signum, frame) + + def __call__(self, *args, **kwargs): + """Trap ``SIGTERM`` and call wrapped function.""" + self._caught_signal = None + # Register handler for SIGTERM, then call `self.func` + self.old_signal_handler = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, self.signal_handler) + + self.func(*args, **kwargs) + + # Restore old signal handler + signal.signal(signal.SIGTERM, self.old_signal_handler) + + # Handle any signal caught during execution + if self._caught_signal is not None: + signum, frame = self._caught_signal + if callable(self.old_signal_handler): + self.old_signal_handler(signum, frame) + elif self.old_signal_handler == signal.SIG_DFL: + sys.exit(0) + + def __get__(self, obj=None, klass=None): + """Decorator API.""" + return self.__class__(self.func.__get__(obj, klass), + klass.__name__) diff --git a/workflow/version b/workflow/version new file mode 100644 index 0000000..ebc91b4 --- /dev/null +++ b/workflow/version @@ -0,0 +1 @@ +1.40.0 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py new file mode 100644 index 0000000..83212a8 --- /dev/null +++ b/workflow/web.py @@ -0,0 +1,720 @@ +# 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.""" + +from __future__ import absolute_import, print_function + +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 + +__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() + +USER_AGENT = (u'Alfred-Workflow/' + __version__ + + ' (+http://www.deanishe.net/alfred-workflow)') + +# Valid characters for multipart form data boundaries +BOUNDARY_CHARS = string.digits + string.ascii_letters + +# 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: Mapping of Unicode strings + :type dic: dict + :returns: Dictionary containing only UTF-8 strings + :rtype: 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): + """Ignore redirect.""" + 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): + """Return value for case-insensitive key or default.""" + try: + v = dict.__getitem__(self, key.lower()) + except KeyError: + return default + else: + return v['val'] + + def update(self, other): + """Update values from other ``dict``.""" + for k, v in other.items(): + self[k] = v + + def items(self): + """Return ``(key, value)`` pairs.""" + return [(v['key'], v['val']) for v in dict.itervalues(self)] + + def keys(self): + """Return original keys.""" + return [v['key'] for v in dict.itervalues(self)] + + def values(self): + """Return all values.""" + return [v['val'] for v in dict.itervalues(self)] + + def iteritems(self): + """Iterate over ``(key, value)`` pairs.""" + for v in dict.itervalues(self): + yield v['key'], v['val'] + + def iterkeys(self): + """Iterate over original keys.""" + for v in dict.itervalues(self): + yield v['key'] + + def itervalues(self): + """Interate over values.""" + for v in dict.itervalues(self): + yield v['val'] + + +class Request(urllib2.Request): + """Subclass of :class:`urllib2.Request` that supports custom methods.""" + + def __init__(self, *args, **kwargs): + """Create a new :class:`Request`.""" + self._method = kwargs.pop('method', None) + urllib2.Request.__init__(self, *args, **kwargs) + + def get_method(self): + return self._method.upper() + + +class Response(object): + """ + Returned by :func:`request` / :func:`get` / :func:`post` functions. + + 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:`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: list, dict or unicode + + """ + return json.loads(self.content, self.encoding or 'utf-8') + + @property + def encoding(self): + """Text encoding of document or ``None``. + + :returns: Text encoding if found. + :rtype: 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: 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: unicode or 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: bool + :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): + dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') + + for chunk in iterator: + data = dec.decode(chunk) + if data: + yield data + + data = dec.decode(b'', final=True) + if data: # pragma: no cover + yield data + + def generate(): + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + + 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(r"""""", + 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(r"""]*\?>""", + 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: dict + :param data: mapping of form data ``{'field_name': 'value'}`` or + :class:`str` + :type data: dict or str + :param headers: HTTP headers + :type headers: dict + :param cookies: cookies to send to server + :type cookies: dict + :param files: files to upload (see below). + :type files: 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: bool + :param stream: Stream content instead of fetching it all at once. + :type stream: bool + :returns: Response object + :rtype: :class:`Response` + + + 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 = [urllib2.ProxyHandler(urllib2.getproxies())] + + 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) + + 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 = Request(url, data, headers, method=method) + 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 delete(url, params=None, data=None, headers=None, cookies=None, auth=None, + timeout=60, allow_redirects=True, stream=False): + """Initiate a DELETE request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('DELETE', url, params, data, headers=headers, + cookies=cookies, auth=auth, timeout=timeout, + allow_redirects=allow_redirects, stream=stream) + + +def post(url, params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=60, allow_redirects=False, stream=False): + """Initiate a POST request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('POST', url, params, data, headers, cookies, files, auth, + timeout, allow_redirects, stream) + + +def put(url, params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=60, allow_redirects=False, stream=False): + """Initiate a PUT request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('PUT', url, params, data, headers, cookies, files, auth, + timeout, allow_redirects, stream) + + +def encode_multipart_formdata(fields, files): + """Encode form data (``fields``) and ``files`` for POST request. + + :param fields: mapping of ``{name : value}`` pairs for normal form fields. + :type fields: dict + :param files: dictionary of fieldnames/files elements for file data. + See below for details. + :type files: dict of :class:`dict` + :returns: ``(headers, body)`` ``headers`` is a + :class:`dict` of HTTP headers + :rtype: 2-tuple ``(dict, str)`` + + The ``files`` argument is a dictionary:: + + {'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/str + :returns: mime-type, e.g. ``text/html`` + :rtype: 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/workflow.py b/workflow/workflow.py new file mode 100644 index 0000000..3935227 --- /dev/null +++ b/workflow/workflow.py @@ -0,0 +1,2820 @@ +# 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. + +:class:`Workflow` is targeted at Alfred 2. Use +:class:`~workflow.Workflow3` if you want to use Alfred 3's new +features, such as :ref:`workflow variables ` or +more powerful modifiers. + +See :ref:`setup` in the :ref:`user-manual` for an example of how to set +up your Python script to best utilise the :class:`Workflow` object. + +""" + +from __future__ import print_function, unicode_literals + +import binascii +import cPickle +from copy import deepcopy +import json +import logging +import logging.handlers +import os +import pickle +import plistlib +import re +import shutil +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 + +# imported to maintain API +from util import AcquisitionError # noqa: F401 +from util import ( + atomic_writer, + LockFile, + uninterruptible, +) + +#: 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 macOS 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 + + +#################################################################### +# Keychain access errors +#################################################################### + + +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): + """Password not in Keychain. + + 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 + :attr:`workflow.manager`. + + Use :meth:`register()` to register new (or replace + existing) serializers, which you can specify by name when calling + :class:`~workflow.Workflow` data storage methods. + + See :ref:`guide-serialization` and :ref:`guide-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): + """Same arguments as :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 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`.""" + data = {} + with LockFile(self._filepath, 0.5): + with open(self._filepath, 'rb') as fp: + data.update(json.load(fp)) + + self._original = deepcopy(data) + + self._nosave = True + self.update(data) + 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) + + with LockFile(self._filepath, 0.5): + with atomic_writer(self._filepath, 'wb') as fp: + json.dump(data, fp, sort_keys=True, indent=2, + encoding='utf-8') + + # dict methods + def __setitem__(self, key, value): + """Implement :class:`dict` interface.""" + if self._original.get(key) != value: + super(Settings, self).__setitem__(key, value) + self.save() + + def __delitem__(self, key): + """Implement :class:`dict` interface.""" + super(Settings, self).__delitem__(key) + self.save() + + 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): + """The ``Workflow`` object is the main interface to Alfred-Workflow. + + It provides APIs for accessing the Alfred/workflow environment, + storing & caching data, using Keychain, and generating Script + Filter feedback. + + ``Workflow`` is compatible with Alfred 2+. Subclass + :class:`~workflow.Workflow3` provides additional features, + only available in Alfred 3+, such as workflow variables. + + :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 releases. The only required key is ``github_slug``, + whose value must take the form of ``username/repo``. + If specified, ``Workflow`` will check the repo's releases + for updates. Your workflow must also have a semantic version + number. Please see the :ref:`User Manual ` and + `update API docs ` for more information. + :type update_settings: :class:`dict` + :param input_encoding: encoding of command line arguments. You + should probably leave this as the default (``utf-8``), which + is the encoding Alfred uses. + :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._debugging = 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 = {} + #: Prefix for all magic arguments. + #: The default value is ``workflow:`` so keyword + #: ``config`` would match user query ``workflow:config``. + 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): + """Dict of Alfred's environmental variables minus ``alfred_`` prefix. + + .. versionadded:: 1.7 + + The variables Alfred 2.4+ exports are: + + ============================ ========================================= + Variable Description + ============================ ========================================= + debug Set to ``1`` if Alfred's debugger is + open, otherwise unset. + preferences Path to Alfred.alfredpreferences + (where your workflows and settings are + stored). + preferences_localhash Machine-specific preferences are stored + in ``Alfred.alfredpreferences/preferences/local/`` + (see ``preferences`` above for + the path to ``Alfred.alfredpreferences``) + theme ID of selected theme + theme_background Background colour of selected theme in + format ``rgba(r,g,b,a)`` + theme_subtext Show result subtext. + ``0`` = Always, + ``1`` = Alternative actions only, + ``2`` = Selected result only, + ``3`` = Never + version Alfred version number, e.g. ``'2.4'`` + version_build Alfred build number, e.g. ``277`` + workflow_bundleid Bundle ID, e.g. + ``net.deanishe.alfred-mailto`` + workflow_cache Path to workflow's cache directory + workflow_data Path to workflow's data directory + workflow_name Name of current workflow + workflow_uid UID of workflow + 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 ( + 'debug', + 'preferences', + 'preferences_localhash', + 'theme', + 'theme_background', + 'theme_subtext', + 'version', + 'version_build', + 'workflow_bundleid', + 'workflow_cache', + 'workflow_data', + 'workflow_name', + 'workflow_uid', + 'workflow_version'): + + value = os.getenv('alfred_' + key, '') + + if value: + if key in ('debug', 'version_build', 'theme_subtext'): + value = int(value) + else: + value = self.decode(value) + + data[key] = 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 debugging(self): + """Whether Alfred's debugger is open. + + :returns: ``True`` if Alfred's debugger is open. + :rtype: ``bool`` + + """ + return self.alfred_env.get('debug') == 1 + + @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 in Alfred 4+ is: + + ``~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/`` + + For earlier versions: + + ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` + + where ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. + + Returns: + unicode: full path to workflow's cache directory + + """ + 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 for Alfred 4+ is: + + ``~/Library/Application Support/Alfred/Workflow Data/`` + + For earlier versions, the path is: + + ``~/Library/Application Support/Alfred X/Workflow Data/`` + + where ``Alfred X` is ``Alfred 2`` or ``Alfred 3``. + + Returns: + unicode: full path to workflow data directory + + """ + 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: + unicode: full path to workflow root directory + + """ + 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): + """Path to ``filename`` in workflow's cache directory. + + Return absolute path to ``filename`` within your workflow's + :attr:`cache directory `. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within cache directory + :rtype: ``unicode`` + + """ + return os.path.join(self.cachedir, filename) + + def datafile(self, filename): + """Path to ``filename`` in workflow's data directory. + + Return absolute path to ``filename`` within your workflow's + :attr:`data directory `. + + :param filename: basename of file + :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): + """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): + """Logger that logs to both console and a log file. + + If Alfred's debugger is open, log level will be ``DEBUG``, + else it will be ``INFO``. + + Use :meth:`open_log` to open the log file in Console. + + :returns: an initialised :class:`~logging.Logger` + + """ + if self._logger: + return self._logger + + # Initialise new logger and optionally handlers + logger = logging.getLogger('') + + # Only add one set of handlers + # Exclude from coverage, as pytest will have configured the + # root logger already + if not len(logger.handlers): # pragma: no cover + + 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) + + if self.debugging: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + 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:`guide-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 %s', 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: %s', 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: %s', 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 under ``name``. + + .. versionadded:: 1.8 + + :param name: name of datastore + + """ + metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) + + if not os.path.exists(metadata_path): + self.logger.debug('no data stored for `%s`', 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 `%s` stored as `%s`', 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: %s', 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: %s', 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: %s', 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('saved data: %s', data_path) + + def cached_data(self, name, data_func=None, max_age=60): + """Return cached data if younger than ``max_age`` seconds. + + Retrieve data from cache or re-generate and re-cache data if + stale/non-existant. If ``max_age`` is 0, return cached data no + matter how old. + + :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: %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: %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. + + If ``query`` is an empty string or contains only whitespace, + all items will match. + + :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 starts with ``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` + ``query`` is a substring of item search key (case-insensitive). + 8. :const:`MATCH_ALLCHARS` + 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: + return items + + # Remove preceding/trailing spaces + query = query.strip() + + if not query: + return items + + # 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, text_errors=False): + """Call ``func`` to run your workflow. + + :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` + instance) as first argument. + :param text_errors: Emit error messages in plain text, not in + Alfred's XML/JSON feedback format. Use this when you're not + running Alfred-Workflow in a Script Filter and would like + to pass the error message to, say, a notification. + :type text_errors: ``Boolean`` + + ``func`` will be called with :class:`Workflow` instance as first + argument. + + ``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() + + # Write to debugger to ensure "real" output starts on a new line + print('.', file=sys.stderr) + + # 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('---------- %s (%s) ----------', + self.name, self.version) + else: + self.logger.debug('---------- %s ----------', self.name) + + # 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: %s', self.help_url) + + if not sys.stdout.isatty(): # Show error in Alfred + if text_errors: + print(unicode(err).encode('utf-8'), end='') + else: + self._items = [] + if self._name: + name = self._name + elif self._bundleid: # pragma: no cover + 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('---------- finished in %0.3fs ----------', + 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 :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: %s', 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: %s', version) + + return True + + @property + def update_available(self): + """Whether an update is available. + + .. versionadded:: 1.9 + + See :ref:`guide-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`` + + """ + key = '__workflow_latest_version' + # Create a new workflow object to ensure standard serialiser + # is used (update.py is called without the user's settings) + status = Workflow().cached_data(key, max_age=0) + + # self.logger.debug('update status: %r', status) + if not status or not status.get('available'): + return False + + return status['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:`guide-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`` + + """ + key = '__workflow_latest_version' + 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(key, frequency * 86400)): + + repo = 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', repo, 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:`guide-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 + + repo = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + if not update.check_update(repo, 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', repo, 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 = 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 workflow settings, cache and data. + + File :attr:`settings ` and directories + :attr:`cache ` and :attr:`data ` are deleted. + + """ + self.clear_cache() + self.clear_data() + self.clear_settings() + + def open_log(self): + """Open :attr:`logfile` in default app (usually Console.app).""" + subprocess.call(['open', self.logfile]) # nosec + + def open_cachedir(self): + """Open the workflow's :attr:`cachedir` in Finder.""" + subprocess.call(['open', self.cachedir]) # nosec + + def open_datadir(self): + """Open the workflow's :attr:`datadir` in Finder.""" + subprocess.call(['open', self.datadir]) # nosec + + def open_workflowdir(self): + """Open the workflow's :attr:`workflowdir` in Finder.""" + subprocess.call(['open', self.workflowdir]) # nosec + + def open_terminal(self): + """Open a Terminal window at workflow's :attr:`workflowdir`.""" + subprocess.call(['open', '-a', 'Terminal', self.workflowdir]) # nosec + + def open_help(self): + """Open :attr:`help_url` in default browser.""" + subprocess.call(['open', self.help_url]) # nosec + + 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`). + + macOS, 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/workflow3.py b/workflow/workflow3.py new file mode 100644 index 0000000..23a7aae --- /dev/null +++ b/workflow/workflow3.py @@ -0,0 +1,734 @@ +# encoding: utf-8 +# +# Copyright (c) 2016 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2016-06-25 +# + +"""An Alfred 3+ version of :class:`~workflow.Workflow`. + +:class:`~workflow.Workflow3` supports new features, such as +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:`Workflow3` object won't be aware +of them, and they won't be sent to Alfred when you call +:meth:`Workflow3.send_feedback()`. + +""" + +from __future__ import print_function, unicode_literals, absolute_import + +import json +import os +import sys + +from .workflow import ICON_WARNING, Workflow + + +class Variables(dict): + """Workflow variables for Run Script actions. + + .. versionadded: 1.26 + + This class allows you to set workflow variables from + Run Script actions. + + It is a subclass of :class:`dict`. + + >>> v = Variables(username='deanishe', password='hunter2') + >>> v.arg = u'output value' + >>> print(v) + + See :ref:`variables-run-script` in the User Guide for more + information. + + Args: + arg (unicode or list, optional): Main output/``{query}``. + **variables: Workflow variables to set. + + In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a + :class:`list` or :class:`tuple`. + + Attributes: + arg (unicode or list): Output value (``{query}``). + In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a + :class:`list` or :class:`tuple`. + config (dict): Configuration for downstream workflow element. + + """ + + def __init__(self, arg=None, **variables): + """Create a new `Variables` object.""" + self.arg = arg + self.config = {} + super(Variables, self).__init__(**variables) + + @property + def obj(self): + """``alfredworkflow`` :class:`dict`.""" + o = {} + if self: + d2 = {} + for k, v in self.items(): + d2[k] = v + o['variables'] = d2 + + if self.config: + o['config'] = self.config + + if self.arg is not None: + o['arg'] = self.arg + + return {'alfredworkflow': o} + + def __unicode__(self): + """Convert to ``alfredworkflow`` JSON object. + + Returns: + unicode: ``alfredworkflow`` JSON object + + """ + if not self and not self.config: + if not self.arg: + return u'' + if isinstance(self.arg, unicode): + return self.arg + + return json.dumps(self.obj) + + def __str__(self): + """Convert to ``alfredworkflow`` JSON object. + + Returns: + str: UTF-8 encoded ``alfredworkflow`` JSON object + + """ + return unicode(self).encode('utf-8') + + +class Modifier(object): + """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. + + Don't use this class directly (as it won't be associated with any + :class:`Item3`), but rather use :meth:`Item3.add_modifier()` + to add modifiers to results. + + >>> it = wf.add_item('Title', 'Subtitle', valid=True) + >>> it.setvar('name', 'default') + >>> m = it.add_modifier('cmd') + >>> m.setvar('name', 'alternate') + + See :ref:`workflow-variables` in the User Guide for more information + and :ref:`example usage `. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (unicode, optional): Override default subtitle. + arg (unicode, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + icon (unicode, optional): Filepath/UTI of icon to use + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + Attributes: + arg (unicode): Arg to pass to following action. + config (dict): Configuration for a downstream element, such as + a File Filter. + icon (unicode): Filepath/UTI of icon. + icontype (unicode): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + key (unicode): Modifier key (see above). + subtitle (unicode): Override item subtitle. + valid (bool): Override item validity. + variables (dict): Workflow variables set by this modifier. + + """ + + def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, + icontype=None): + """Create a new :class:`Modifier`. + + Don't use this class directly (as it won't be associated with any + :class:`Item3`), but rather use :meth:`Item3.add_modifier()` + to add modifiers to results. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (unicode, optional): Override default subtitle. + arg (unicode, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + icon (unicode, optional): Filepath/UTI of icon to use + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + """ + self.key = key + self.subtitle = subtitle + self.arg = arg + self.valid = valid + self.icon = icon + self.icontype = icontype + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (unicode): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + unicode or ``default``: Value of variable if set or ``default``. + + """ + return self.variables.get(name, default) + + @property + def obj(self): + """Modifier formatted for JSON serialization for Alfred 3. + + Returns: + dict: Modifier for serializing to JSON. + + """ + o = {} + + if self.subtitle is not None: + o['subtitle'] = self.subtitle + + if self.arg is not None: + o['arg'] = self.arg + + if self.valid is not None: + o['valid'] = self.valid + + if self.variables: + o['variables'] = self.variables + + if self.config: + o['config'] = self.config + + icon = self._icon() + if icon: + o['icon'] = icon + + return o + + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + + """ + icon = {} + if self.icon is not None: + icon['path'] = self.icon + + if self.icontype is not None: + icon['type'] = self.icontype + + return icon + + +class Item3(object): + """Represents a feedback item for Alfred 3+. + + Generates Alfred-compliant JSON for a single item. + + Don't use this class directly (as it then won't be associated with + any :class:`Workflow3 ` object), but rather use + :meth:`Workflow3.add_item() `. + See :meth:`~workflow.Workflow3.add_item` for details of arguments. + + """ + + def __init__(self, title, subtitle='', arg=None, autocomplete=None, + match=None, valid=False, uid=None, icon=None, icontype=None, + type=None, largetext=None, copytext=None, quicklookurl=None): + """Create a new :class:`Item3` object. + + Use same arguments as for + :class:`Workflow.Item `. + + Argument ``subtitle_modifiers`` is not supported. + + """ + self.title = title + self.subtitle = subtitle + self.arg = arg + self.autocomplete = autocomplete + self.match = match + self.valid = valid + self.uid = uid + self.icon = icon + 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, icon=None, + icontype=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. + icon (unicode, optional): Filepath/UTI of icon. + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a + :class:`list` or :class:`tuple`. + + Returns: + Modifier: Configured :class:`Modifier`. + + """ + mod = Modifier(key, subtitle, arg, valid, icon, icontype) + + # Add Item variables to Modifier + mod.variables.update(self.variables) + + self.modifiers[key] = mod + + return mod + + @property + def obj(self): + """Item formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred 3 feedback. + + """ + # Required values + o = { + 'title': self.title, + 'subtitle': self.subtitle, + 'valid': self.valid, + } + + # Optional values + if self.arg is not None: + o['arg'] = self.arg + + if self.autocomplete is not None: + o['autocomplete'] = self.autocomplete + + if self.match is not None: + o['match'] = self.match + + if self.uid is not None: + o['uid'] = self.uid + + if self.type is not None: + o['type'] = self.type + + if self.quicklookurl is not None: + o['quicklookurl'] = self.quicklookurl + + if self.variables: + o['variables'] = self.variables + + if self.config: + o['config'] = self.config + + # Largetype and copytext + text = self._text() + if text: + o['text'] = text + + icon = self._icon() + if icon: + o['icon'] = icon + + # 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 _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. + + It is a subclass of :class:`~workflow.Workflow` and most of its + methods are documented there. + + Attributes: + item_class (class): Class used to generate feedback items. + variables (dict): Top level workflow variables. + + """ + + item_class = Item3 + + def __init__(self, **kwargs): + """Create a new :class:`Workflow3` object. + + See :class:`~workflow.Workflow` for documentation. + + """ + Workflow.__init__(self, **kwargs) + self.variables = {} + self._rerun = 0 + # Get session ID from environment if present + self._session_id = os.getenv('_WF_SESSION_ID') or None + if self._session_id: + self.setvar('_WF_SESSION_ID', self._session_id) + + @property + def _default_cachedir(self): + """Alfred 4's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred/' + 'Workflow Data/'), + self.bundleid) + + @property + def _default_datadir(self): + """Alfred 4's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred/Workflow Data/'), + self.bundleid) + + @property + def rerun(self): + """How often (in seconds) Alfred should re-run the Script Filter.""" + return self._rerun + + @rerun.setter + def rerun(self, seconds): + """Interval at which Alfred should re-run the Script Filter. + + Args: + seconds (int): Interval between runs. + """ + self._rerun = seconds + + @property + def session_id(self): + """A unique session ID every time the user uses the workflow. + + .. versionadded:: 1.25 + + The session ID persists while the user is using this workflow. + It expires when the user runs a different workflow or closes + Alfred. + + """ + if not self._session_id: + from uuid import uuid4 + self._session_id = uuid4().hex + self.setvar('_WF_SESSION_ID', self._session_id) + + return self._session_id + + def setvar(self, name, value, persist=False): + """Set a "global" workflow variable. + + .. versionchanged:: 1.33 + + These variables are always passed to downstream workflow objects. + + If you have set :attr:`rerun`, these variables are also passed + back to the script when Alfred runs it again. + + Args: + name (unicode): Name of variable. + value (unicode): Value of variable. + persist (bool, optional): Also save variable to ``info.plist``? + + """ + self.variables[name] = value + if persist: + from .util import set_config + set_config(name, value, self.bundleid) + self.logger.debug('saved variable %r with value %r to info.plist', + name, value) + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + 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, match=None): + """Add an item to be output to Alfred. + + Args: + match (unicode, optional): If you have "Alfred filters results" + turned on for your Script Filter, Alfred (version 3.5 and + above) will filter against this field, not ``title``. + + In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a + :class:`list` or :class:`tuple`. + + See :meth:`Workflow.add_item() ` for + the main documentation and other parameters. + + The key difference is that this method does not support the + ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` + method instead on the returned item instead. + + Returns: + Item3: Alfred feedback item. + + """ + item = self.item_class(title, subtitle, arg, autocomplete, + match, valid, uid, icon, icontype, type, + largetext, copytext, quicklookurl) + + # Add variables to child item + item.variables.update(self.variables) + + self._items.append(item) + return item + + @property + def _session_prefix(self): + """Filename prefix for current session.""" + return '_wfsess-{0}-'.format(self.session_id) + + def _mk_session_name(self, name): + """New cache name/key based on session ID.""" + return self._session_prefix + name + + def cache_data(self, name, data, session=False): + """Cache API with session-scoped expiry. + + .. versionadded:: 1.25 + + Args: + name (str): Cache key + data (object): Data to cache + session (bool, optional): Whether to scope the cache + to the current session. + + ``name`` and ``data`` are the same as for the + :meth:`~workflow.Workflow.cache_data` method on + :class:`~workflow.Workflow`. + + If ``session`` is ``True``, then ``name`` is prefixed + with :attr:`session_id`. + + """ + if session: + name = self._mk_session_name(name) + + return super(Workflow3, self).cache_data(name, data) + + def cached_data(self, name, data_func=None, max_age=60, session=False): + """Cache API with session-scoped expiry. + + .. versionadded:: 1.25 + + Args: + name (str): Cache key + data_func (callable): Callable that returns fresh data. It + is called if the cache has expired or doesn't exist. + max_age (int): Maximum allowable age of cache in seconds. + session (bool, optional): Whether to scope the cache + to the current session. + + ``name``, ``data_func`` and ``max_age`` are the same as for the + :meth:`~workflow.Workflow.cached_data` method on + :class:`~workflow.Workflow`. + + If ``session`` is ``True``, then ``name`` is prefixed + with :attr:`session_id`. + + """ + if session: + name = self._mk_session_name(name) + + return super(Workflow3, self).cached_data(name, data_func, max_age) + + def clear_session_cache(self, current=False): + """Remove session data from the cache. + + .. versionadded:: 1.25 + .. versionchanged:: 1.27 + + By default, data belonging to the current session won't be + deleted. Set ``current=True`` to also clear current session. + + Args: + current (bool, optional): If ``True``, also remove data for + current session. + + """ + def _is_session_file(filename): + if current: + return filename.startswith('_wfsess-') + return filename.startswith('_wfsess-') \ + and not filename.startswith(self._session_prefix) + + self.clear_cache(_is_session_file) + + @property + def obj(self): + """Feedback formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred 3 feedback. + + """ + items = [] + for item in self._items: + items.append(item.obj) + + o = {'items': items} + if self.variables: + o['variables'] = self.variables + if self.rerun: + o['rerun'] = self.rerun + return o + + def warn_empty(self, title, subtitle=u'', icon=None): + """Add a warning to feedback if there are no items. + + .. versionadded:: 1.31 + + Add a "warning" item to Alfred feedback if no other items + have been added. This is a handy shortcut to prevent Alfred + from showing its fallback searches, which is does if no + items are returned. + + Args: + title (unicode): Title of feedback item. + subtitle (unicode, optional): Subtitle of feedback item. + icon (str, optional): Icon for feedback item. If not + specified, ``ICON_WARNING`` is used. + + Returns: + Item3: Newly-created item. + + """ + if len(self._items): + return + + icon = icon or ICON_WARNING + return self.add_item(title, subtitle, icon=icon) + + def send_feedback(self): + """Print stored items to console/Alfred as JSON.""" + if self.debugging: + json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': ')) + else: + json.dump(self.obj, sys.stdout) + sys.stdout.flush()