diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/13B27ECF-46AA-426A-A93B-8F4085F2F630.png b/13B27ECF-46AA-426A-A93B-8F4085F2F630.png new file mode 100644 index 0000000..47d8d69 Binary files /dev/null and b/13B27ECF-46AA-426A-A93B-8F4085F2F630.png differ diff --git a/8256C27F-E95A-4E03-B205-674155C0E420.png b/8256C27F-E95A-4E03-B205-674155C0E420.png new file mode 100644 index 0000000..47d8d69 Binary files /dev/null and b/8256C27F-E95A-4E03-B205-674155C0E420.png differ diff --git a/README.md b/README.md index 95b3d43..220e0b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ # semaphoreci-workflow An Alfred3 Workflow for SemaphoreCI + +# Usage + +Configure auth token + +1. Run `ci-auth`. +1. Press Enter. +1. Paste your `auth_token` you retrieve from https://semaphoreci.com/users/edit + +Search your repos + +1. Type `ci`, optionally followed by the name of the project you are looking for +1. Press `cmd`+`Enter` to open the relative SemaphoreCI page or... +1. Press `Enter` to see the list of builds and servers and their status. + +![Demo](http://g.recordit.co/9LhgHy3Om6.gif) + +## Contributing + +Bug reports and pull requests are welcome on GitHub. +This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to +the [Contributor Covenant](http://contributor-covenant.org) code of conduct. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Copyright + +Coypright 2018 [Renuo AG](https://www.renuo.ch/) \ No newline at end of file diff --git a/auth.py b/auth.py new file mode 100755 index 0000000..fd46678 --- /dev/null +++ b/auth.py @@ -0,0 +1,17 @@ +#!/usr/bin/python +# encoding: utf-8 + +import sys +import os +import re + +from workflow import Workflow3, web + +def main(wf): + query = wf.args[0] + wf.save_password('semaphoreci-auth-token', query) + wf.send_feedback() + +if __name__ == '__main__': + wf = Workflow3() + sys.exit(wf.run(main)) \ No newline at end of file diff --git a/failed.png b/failed.png new file mode 100644 index 0000000..cc8dee6 Binary files /dev/null and b/failed.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..47d8d69 Binary files /dev/null and b/icon.png differ diff --git a/info.plist b/info.plist new file mode 100644 index 0000000..07c40cb --- /dev/null +++ b/info.plist @@ -0,0 +1,294 @@ + + + + + bundleid + + connections + + 13B27ECF-46AA-426A-A93B-8F4085F2F630 + + + destinationuid + 8256C27F-E95A-4E03-B205-674155C0E420 + modifiers + 0 + modifiersubtext + + vitoclose + + + + destinationuid + 74BFD9A4-96CF-46AA-9A9D-0F4544164A86 + modifiers + 1048576 + modifiersubtext + + vitoclose + + + + 643EA774-BE5C-465A-9B51-814C38B96E04 + + + destinationuid + 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117 + modifiers + 0 + modifiersubtext + + vitoclose + + + + 8256C27F-E95A-4E03-B205-674155C0E420 + + + destinationuid + 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9 + modifiers + 0 + modifiersubtext + + vitoclose + + + + + createdby + Alessandro Rodi + description + Lists SemaphoreCI projects and status + disabled + + name + SemaphoreCI + objects + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttrimmode + 0 + argumenttype + 2 + escaping + 0 + queuedelaycustom + 3 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + + script + + scriptargtype + 1 + scriptfile + semaphore.py + subtext + + title + App actions + type + 8 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 8256C27F-E95A-4E03-B205-674155C0E420 + version + 2 + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 2 + argumenttrimmode + 0 + argumenttype + 1 + escaping + 0 + keyword + ci + queuedelaycustom + 3 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + Loading the projects list... + script + + scriptargtype + 1 + scriptfile + semaphore.py + subtext + + title + Semaphore Projects List + type + 8 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 13B27ECF-46AA-426A-A93B-8F4085F2F630 + version + 2 + + + config + + browser + + spaces + + url + {query} + utf8 + + + type + alfred.workflow.action.openurl + uid + 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9 + version + 1 + + + config + + browser + + spaces + + url + {var:html_url} + utf8 + + + type + alfred.workflow.action.openurl + uid + 74BFD9A4-96CF-46AA-9A9D-0F4544164A86 + version + 1 + + + config + + argumenttype + 0 + keyword + ci-auth + subtext + + text + Set your auth key + withspace + + + type + alfred.workflow.input.keyword + uid + 643EA774-BE5C-465A-9B51-814C38B96E04 + version + 1 + + + config + + concurrently + + escaping + 0 + script + + scriptargtype + 1 + scriptfile + auth.py + type + 8 + + type + alfred.workflow.action.script + uid + 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117 + version + 2 + + + readme + + uidata + + 13B27ECF-46AA-426A-A93B-8F4085F2F630 + + xpos + 110 + ypos + 120 + + 643EA774-BE5C-465A-9B51-814C38B96E04 + + xpos + 210 + ypos + 470 + + 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117 + + xpos + 520 + ypos + 500 + + 74BFD9A4-96CF-46AA-9A9D-0F4544164A86 + + xpos + 460 + ypos + 350 + + 8256C27F-E95A-4E03-B205-674155C0E420 + + xpos + 460 + ypos + 70 + + 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9 + + xpos + 760 + ypos + 250 + + + webaddress + + + diff --git a/passed.png b/passed.png new file mode 100644 index 0000000..0263208 Binary files /dev/null and b/passed.png differ diff --git a/pending.png b/pending.png new file mode 100644 index 0000000..7d38b96 Binary files /dev/null and b/pending.png differ diff --git a/semaphore.png b/semaphore.png new file mode 100644 index 0000000..47d8d69 Binary files /dev/null and b/semaphore.png differ diff --git a/semaphore.py b/semaphore.py new file mode 100755 index 0000000..74ce200 --- /dev/null +++ b/semaphore.py @@ -0,0 +1,71 @@ +#!/usr/bin/python +# encoding: utf-8 + +import sys +import os +import re +import json + +from workflow import Workflow3, ICON_WEB, ICON_WARNING, web, Variables, PasswordNotFound +from workflow.background import run_in_background, is_running + +ICON_SEMAPHORE = '%s/semaphore.png' %(os.path.dirname(os.path.abspath(__file__))) + +def main(wf): + args = wf.args + + auth_token = None + try: + auth_token = wf.get_password('semaphoreci-auth-token') + except PasswordNotFound: # API key has not yet been set + wf.add_item('No API key set.', + 'Please use ci-auth to set your SemaphoreCI auth key.', + arg='https://semaphoreci.com/users/edit', + valid=False, + icon=ICON_WARNING) + wf.send_feedback() + return 0 + query = None + + if not wf.cached_data_fresh('projects', 10): + run_in_background('update',['/usr/bin/python', wf.workflowfile('update_projects.py')]) + projects = sorted(wf.cached_data('projects', max_age=0), key=lambda project: project['updated_at'], reverse=True) + + if projects: + if (len(args) == 0): + for project in projects: + arg = project['name'] + item = wf.add_item(project['name'], '', arg=arg, valid=True, icon=ICON_SEMAPHORE) + item.setvar('html_url', project['html_url']) + else: + query = args[0] + project = next( (p for p in projects if p['name'] == query), None) + if project is not None: + branches = sorted(project['branches'], key=lambda branch: branch['started_at'], reverse=True)[:3] + for branch in branches: + subtitle = '%s by %s' %(branch['commit']['message'], branch['commit']['author_name']) + icon = '%s/%s.png' %(os.path.dirname(os.path.abspath(__file__)),branch['result']) + wf.add_item(branch['branch_name'], subtitle, arg=branch['build_url'], + valid=True, icon=icon) + + servers = project['servers'] + for server in filter(None, servers): + subtitle = '%s by %s' %(server['commit']['message'], server['commit']['author_name']) + icon = '%s/server_%s.png' %(os.path.dirname(os.path.abspath(__file__)),branch['result']) + wf.add_item(server['server_name'], subtitle, arg=server['server_html_url'], + valid=True, icon=icon) + if len(branches) > 0: + github_url = branches[0]['commit']['url'] + if 'github.com' in github_url: + pr_url = re.sub(r'/commit/.*','/pulls', github_url) + wf.add_item('Open GitHub Pull Requests', pr_url, arg=pr_url, + valid=True, icon=ICON_WEB) + wf.add_item('Open on SemaphoreCI', project['html_url'], arg=project['html_url'], + valid=True, icon=ICON_WEB) + + wf.send_feedback() + + +if __name__ == '__main__': + wf = Workflow3() + sys.exit(wf.run(main)) \ No newline at end of file diff --git a/server_failed.png b/server_failed.png new file mode 100644 index 0000000..504ffbb Binary files /dev/null and b/server_failed.png differ diff --git a/server_passed.png b/server_passed.png new file mode 100644 index 0000000..852309b Binary files /dev/null and b/server_passed.png differ diff --git a/server_stopped.png b/server_stopped.png new file mode 100644 index 0000000..504ffbb Binary files /dev/null and b/server_stopped.png differ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8128165 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[install] +prefix= diff --git a/stopped.png b/stopped.png new file mode 100644 index 0000000..f6cb238 Binary files /dev/null and b/stopped.png differ diff --git a/update_projects.py b/update_projects.py new file mode 100755 index 0000000..751d6fe --- /dev/null +++ b/update_projects.py @@ -0,0 +1,30 @@ +#!/usr/bin/python +# encoding: utf-8 + +import sys +import os +import re + +from workflow import Workflow3, web + +def get_projects_list(auth_token): + alfred_projects = [] + url = 'https://semaphoreci.com/api/v1/projects' + params = dict(auth_token=auth_token) + r = web.get(url, params) + r.raise_for_status() + alfred_projects = r.json() + return sorted(alfred_projects, key=lambda project: project['name'].lower()) + +def main(wf): + + def wrapper(): + auth_token = wf.get_password('semaphoreci-auth-token') + return get_projects_list(auth_token) + + wf.cached_data('projects', wrapper) + wf.send_feedback() + +if __name__ == '__main__': + wf = Workflow3() + sys.exit(wf.run(main)) \ No newline at end of file diff --git a/workflow/.alfredversionchecked b/workflow/.alfredversionchecked new file mode 100644 index 0000000..e69de29 diff --git a/workflow/Notify.tgz b/workflow/Notify.tgz new file mode 100644 index 0000000..174e9a7 Binary files /dev/null and b/workflow/Notify.tgz differ diff --git a/workflow/__init__.py b/workflow/__init__.py new file mode 100644 index 0000000..2c4f8c0 --- /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-2017 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/__init__.pyc b/workflow/__init__.pyc new file mode 100644 index 0000000..fcf47bb Binary files /dev/null and b/workflow/__init__.pyc differ diff --git a/workflow/background.py b/workflow/background.py new file mode 100644 index 0000000..cd5400b --- /dev/null +++ b/workflow/background.py @@ -0,0 +1,293 @@ +#!/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 + + try: + os.unlink(pidfile) + except Exception: # pragma: no cover + pass + + +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/background.pyc b/workflow/background.pyc new file mode 100644 index 0000000..baab8b3 Binary files /dev/null and b/workflow/background.pyc differ diff --git a/workflow/notify.py b/workflow/notify.py new file mode 100644 index 0000000..4542c78 --- /dev/null +++ b/workflow/notify.py @@ -0,0 +1,345 @@ +#!/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) + assert os.path.exists(n), \ + 'Notify.app could not be installed in %s' % 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') + + assert not os.path.exists(iconset), \ + '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) + + assert os.path.exists(icns_path), \ + '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) + + assert not os.path.exists(icns), \ + '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/notify.pyc b/workflow/notify.pyc new file mode 100644 index 0000000..dbba7e5 Binary files /dev/null and b/workflow/notify.pyc differ diff --git a/workflow/update.py b/workflow/update.py new file mode 100644 index 0000000..44bd1a8 --- /dev/null +++ b/workflow/update.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2014 Fabio Niephaus , +# Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-08-16 +# + +"""Self-updating from GitHub. + +.. versionadded:: 1.9 + +.. note:: + + This module is not intended to be used directly. Automatic updates + are controlled by the ``update_settings`` :class:`dict` passed to + :class:`~workflow.workflow.Workflow` objects. + +""" + +from __future__ import print_function, unicode_literals + +import os +import tempfile +import re +import subprocess + +import workflow +import web + +# __all__ = [] + + +RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' + + +_wf = None + + +def wf(): + """Lazy `Workflow` object.""" + global _wf + if _wf is None: + _wf = workflow.Workflow() + return _wf + + +class Version(object): + """Mostly semantic versioning. + + The main difference to proper :ref:`semantic versioning ` + is that this implementation doesn't require a minor or patch version. + + Version strings may also be prefixed with "v", e.g.: + + >>> v = Version('v1.1.1') + >>> v.tuple + (1, 1, 1, '') + + >>> v = Version('2.0') + >>> v.tuple + (2, 0, 0, '') + + >>> Version('3.1-beta').tuple + (3, 1, 0, 'beta') + + >>> Version('1.0.1') > Version('0.0.1') + True + """ + + #: Match version and pre-release/build information in version strings + match_version = re.compile(r'([0-9\.]+)(.+)?').match + + def __init__(self, vstr): + """Create new `Version` object. + + Args: + vstr (basestring): Semantic version string. + """ + self.vstr = vstr + self.major = 0 + self.minor = 0 + self.patch = 0 + self.suffix = '' + self.build = '' + self._parse(vstr) + + def _parse(self, vstr): + if vstr.startswith('v'): + m = self.match_version(vstr[1:]) + else: + m = self.match_version(vstr) + if not m: + raise ValueError('invalid version number: {0}'.format(vstr)) + + version, suffix = m.groups() + parts = self._parse_dotted_string(version) + self.major = parts.pop(0) + if len(parts): + self.minor = parts.pop(0) + if len(parts): + self.patch = parts.pop(0) + if not len(parts) == 0: + raise ValueError('invalid version (too long) : {0}'.format(vstr)) + + if suffix: + # Build info + idx = suffix.find('+') + if idx > -1: + self.build = suffix[idx+1:] + suffix = suffix[:idx] + if suffix: + if not suffix.startswith('-'): + raise ValueError( + 'suffix must start with - : {0}'.format(suffix)) + self.suffix = suffix[1:] + + # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) + + def _parse_dotted_string(self, s): + """Parse string ``s`` into list of ints and strings.""" + parsed = [] + parts = s.split('.') + for p in parts: + if p.isdigit(): + p = int(p) + parsed.append(p) + return parsed + + @property + def tuple(self): + """Version number as a tuple of major, minor, patch, pre-release.""" + return (self.major, self.minor, self.patch, self.suffix) + + def __lt__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + t = self.tuple[:3] + o = other.tuple[:3] + if t < o: + return True + if t == o: # We need to compare suffixes + if self.suffix and not other.suffix: + return True + if other.suffix and not self.suffix: + return False + return (self._parse_dotted_string(self.suffix) < + self._parse_dotted_string(other.suffix)) + # t > o + return False + + def __eq__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return self.tuple == other.tuple + + def __ne__(self, other): + """Implement comparison.""" + return not self.__eq__(other) + + def __gt__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return other.__lt__(self) + + def __le__(self, other): + """Implement comparison.""" + if not isinstance(other, Version): + raise ValueError('not a Version instance: {0!r}'.format(other)) + return not other.__lt__(self) + + def __ge__(self, other): + """Implement comparison.""" + return not self.__lt__(other) + + def __str__(self): + """Return semantic version string.""" + vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) + if self.suffix: + vstr = '{0}-{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 download_workflow(url): + """Download workflow at ``url`` to a local temporary file. + + :param url: URL to .alfredworkflow file in GitHub repo + :returns: path to downloaded file + + """ + filename = url.split('/')[-1] + + if (not filename.endswith('.alfredworkflow') and + not filename.endswith('.alfred3workflow')): + raise ValueError('attachment not a workflow: {0}'.format(filename)) + + local_path = os.path.join(tempfile.gettempdir(), filename) + + wf().logger.debug( + 'downloading updated workflow from `%s` to `%s` ...', url, local_path) + + response = web.get(url) + + with open(local_path, 'wb') as output: + output.write(response.content) + + return local_path + + +def build_api_url(slug): + """Generate releases URL from GitHub slug. + + :param slug: Repo name in form ``username/repo`` + :returns: URL to the API endpoint for the repo's releases + + """ + if len(slug.split('/')) != 2: + raise ValueError('invalid GitHub slug: {0}'.format(slug)) + + return RELEASES_BASE.format(slug) + + +def _validate_release(release): + """Return release for running version of Alfred.""" + alf3 = wf().alfred_version.major == 3 + + downloads = {'.alfredworkflow': [], '.alfred3workflow': []} + dl_count = 0 + version = release['tag_name'] + + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + if not url: # pragma: nocover + continue + + ext = os.path.splitext(url)[1].lower() + if ext not in downloads: + continue + + # Ignore Alfred 3-only files if Alfred 2 is running + if ext == '.alfred3workflow' and not alf3: + continue + + downloads[ext].append(url) + dl_count += 1 + + # download_urls.append(url) + + if dl_count == 0: + wf().logger.warning( + 'invalid release (no workflow file): %s', version) + return None + + for k in downloads: + if len(downloads[k]) > 1: + wf().logger.warning( + 'invalid release (multiple %s files): %s', k, version) + return None + + # Prefer .alfred3workflow file if there is one and Alfred 3 is + # running. + if alf3 and len(downloads['.alfred3workflow']): + download_url = downloads['.alfred3workflow'][0] + + else: + download_url = downloads['.alfredworkflow'][0] + + wf().logger.debug('release %s: %s', version, download_url) + + return { + 'version': version, + 'download_url': download_url, + 'prerelease': release['prerelease'] + } + + +def get_valid_releases(github_slug, prereleases=False): + """Return list of all valid releases. + + :param github_slug: ``username/repo`` for workflow's GitHub repo + :param prereleases: Whether to include pre-releases. + :returns: list of dicts. Each :class:`dict` has the form + ``{'version': '1.1', 'download_url': 'http://github.com/...', + 'prerelease': False }`` + + + A valid release is one that contains one ``.alfredworkflow`` file. + + If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading + ``v`` will be stripped. + + """ + api_url = build_api_url(github_slug) + releases = [] + + wf().logger.debug('retrieving releases list: %s', api_url) + + def retrieve_releases(): + wf().logger.info( + 'retrieving releases: %s', github_slug) + return web.get(api_url).json() + + slug = github_slug.replace('/', '-') + for release in wf().cached_data('gh-releases-' + slug, retrieve_releases): + + release = _validate_release(release) + if release is None: + wf().logger.debug('invalid release: %r', release) + continue + + elif release['prerelease'] and not prereleases: + wf().logger.debug('ignoring prerelease: %s', release['version']) + continue + + wf().logger.debug('release: %r', release) + + releases.append(release) + + return releases + + +def check_update(github_slug, current_version, prereleases=False): + """Check whether a newer release is available on GitHub. + + :param github_slug: ``username/repo`` for workflow's GitHub repo + :param current_version: the currently installed version of the + workflow. :ref:`Semantic versioning ` is required. + :param prereleases: Whether to include pre-releases. + :type current_version: ``unicode`` + :returns: ``True`` if an update is available, else ``False`` + + If an update is available, its version number and download URL will + be cached. + + """ + releases = get_valid_releases(github_slug, prereleases) + + if not len(releases): + wf().logger.warning('no valid releases for %s', github_slug) + wf().cache_data('__workflow_update_status', {'available': False}) + return False + + wf().logger.info('%d releases for %s', len(releases), github_slug) + + # GitHub returns releases newest-first + latest_release = releases[0] + + # (latest_version, download_url) = get_latest_release(releases) + vr = Version(latest_release['version']) + vl = Version(current_version) + wf().logger.debug('latest=%r, installed=%r', vr, vl) + if vr > vl: + + wf().cache_data('__workflow_update_status', { + 'version': latest_release['version'], + 'download_url': latest_release['download_url'], + 'available': True + }) + + return True + + wf().cache_data('__workflow_update_status', {'available': False}) + return False + + +def install_update(): + """If a newer release is available, download and install it. + + :returns: ``True`` if an update is installed, else ``False`` + + """ + update_data = wf().cached_data('__workflow_update_status', max_age=0) + + if not update_data or not update_data.get('available'): + wf().logger.info('no update available') + return False + + local_file = download_workflow(update_data['download_url']) + + wf().logger.info('installing updated workflow ...') + subprocess.call(['open', local_file]) + + update_data['available'] = False + wf().cache_data('__workflow_update_status', update_data) + return True + + +if __name__ == '__main__': # pragma: nocover + import sys + + def show_help(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() + + prereleases = '--prereleases' in argv + + if prereleases: + argv.remove('--prereleases') + + if len(argv) != 4: + show_help(1) + + action, github_slug, version = argv[1:] + + try: + + if action == 'check': + check_update(github_slug, 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/update.pyc b/workflow/update.pyc new file mode 100644 index 0000000..3f84809 Binary files /dev/null and b/workflow/update.pyc differ diff --git a/workflow/util.py b/workflow/util.py new file mode 100644 index 0000000..257654e --- /dev/null +++ b/workflow/util.py @@ -0,0 +1,533 @@ +#!/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 os +import signal +import subprocess +import sys +from threading import Event +import time + +# AppleScript to call an External Trigger in Alfred +AS_TRIGGER = """ +tell application "Alfred 3" +run trigger "{name}" in workflow "{bundleid}" {arg} +end tell +""" + +# AppleScript to save a variable in info.plist +AS_CONFIG_SET = """ +tell application "Alfred 3" +set configuration "{name}" to value "{value}" in workflow "{bundleid}" {export} +end tell +""" + +# AppleScript to remove a variable from info.plist +AS_CONFIG_UNSET = """ +tell application "Alfred 3" +remove configuration "{name}" in workflow "{bundleid}" +end tell +""" + + +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 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: + >>> script = 'tell application "Alfred 3" to search "{}"' + >>> query = 'g "python" test' + >>> script.format(applescriptify(query)) + 'tell application "Alfred 3" to search "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 ``check_output``. + **kwargs: Keyword arguments to pass to ``check_output``. + + Returns: + str: Output returned by ``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. + + Returns: + str: Output of run command. + + """ + cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')] + + if os.path.exists(script): + cmd += [script] + else: + cmd += ['-e', script] + + cmd.extend(args) + + return run_command(cmd) + + +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, reads the bundle ID of the current + workflow from Alfred's environment variables. + + 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. + + """ + if not bundleid: + bundleid = os.getenv('alfred_workflow_bundleid') + + if arg: + arg = 'with argument "{}"'.format(applescriptify(arg)) + else: + arg = '' + + script = AS_TRIGGER.format(name=name, bundleid=bundleid, + arg=arg) + + run_applescript(script) + + +def set_config(name, value, bundleid=None, exportable=False): + """Set a workflow variable in ``info.plist``. + + .. versionadded:: 1.33 + + 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). + + """ + if not bundleid: + bundleid = os.getenv('alfred_workflow_bundleid') + + name = applescriptify(name) + value = applescriptify(value) + bundleid = applescriptify(bundleid) + + if exportable: + export = 'exportable true' + else: + export = 'exportable false' + + script = AS_CONFIG_SET.format(name=name, bundleid=bundleid, + value=value, export=export) + + run_applescript(script) + + +def unset_config(name, bundleid=None): + """Delete a workflow variable from ``info.plist``. + + .. versionadded:: 1.33 + + Args: + name (str): Name of variable to delete. + bundleid (str, optional): Bundle ID of workflow variable belongs to. + + """ + if not bundleid: + bundleid = os.getenv('alfred_workflow_bundleid') + + name = applescriptify(name) + bundleid = applescriptify(bundleid) + + script = AS_CONFIG_UNSET.format(name=name, bundleid=bundleid) + + run_applescript(script) + + +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', 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/util.pyc b/workflow/util.pyc new file mode 100644 index 0000000..2a1b087 Binary files /dev/null and b/workflow/util.pyc differ diff --git a/workflow/version b/workflow/version new file mode 100644 index 0000000..1caa5ab --- /dev/null +++ b/workflow/version @@ -0,0 +1 @@ +1.36 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py new file mode 100644 index 0000000..d64bb6f --- /dev/null +++ b/workflow/web.py @@ -0,0 +1,678 @@ +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +"""Lightweight HTTP library with a requests-like interface.""" + +import codecs +import json +import mimetypes +import os +import random +import re +import socket +import string +import unicodedata +import urllib +import urllib2 +import urlparse +import zlib + + +USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' + +# Valid characters for multipart form data boundaries +BOUNDARY_CHARS = string.digits + string.ascii_letters + +# HTTP response codes +RESPONSES = { + 100: 'Continue', + 101: 'Switching Protocols', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported' +} + + +def str_dict(dic): + """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. + + :param dic: 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): + return None + + +# Adapted from https://gist.github.com/babakness/3901174 +class CaseInsensitiveDictionary(dict): + """Dictionary with caseless key search. + + Enables case insensitive searching while preserving case sensitivity + when keys are listed, ie, via keys() or items() methods. + + Works by storing a lowercase version of the key as the new key and + stores the original key-value pair as the key's value + (values become dictionaries). + + """ + + def __init__(self, initval=None): + """Create new case-insensitive dictionary.""" + if isinstance(initval, dict): + for key, value in initval.iteritems(): + self.__setitem__(key, value) + + elif isinstance(initval, list): + for (key, value) in initval: + self.__setitem__(key, value) + + def __contains__(self, key): + return dict.__contains__(self, key.lower()) + + def __getitem__(self, key): + return dict.__getitem__(self, key.lower())['val'] + + def __setitem__(self, key, value): + return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) + + def get(self, key, default=None): + try: + v = dict.__getitem__(self, key.lower()) + except KeyError: + return default + else: + return v['val'] + + def update(self, other): + for k, v in other.items(): + self[k] = v + + def items(self): + return [(v['key'], v['val']) for v in dict.itervalues(self)] + + def keys(self): + return [v['key'] for v in dict.itervalues(self)] + + def values(self): + return [v['val'] for v in dict.itervalues(self)] + + def iteritems(self): + for v in dict.itervalues(self): + yield v['key'], v['val'] + + def iterkeys(self): + for v in dict.itervalues(self): + yield v['key'] + + def itervalues(self): + for v in dict.itervalues(self): + yield v['val'] + + +class Response(object): + """ + Returned by :func:`request` / :func:`get` / :func:`post` functions. + + Simplified version of the ``Response`` object in the ``requests`` library. + + >>> r = request('http://www.google.com') + >>> r.status_code + 200 + >>> r.encoding + ISO-8859-1 + >>> r.content # bytes + ... + >>> r.text # unicode, decoded according to charset in HTTP header/meta tag + u' ...' + >>> r.json() # content parsed as JSON + + """ + + def __init__(self, request, stream=False): + """Call `request` with :mod:`urllib2` and process results. + + :param request: :class:`urllib2.Request` instance + :param stream: Whether to stream response or retrieve it all at once + :type stream: bool + + """ + self.request = request + self._stream = stream + self.url = None + self.raw = None + self._encoding = None + self.error = None + self.status_code = None + self.reason = None + self.headers = CaseInsensitiveDictionary() + self._content = None + self._content_loaded = False + self._gzipped = False + + # Execute query + try: + self.raw = urllib2.urlopen(request) + except urllib2.HTTPError as err: + self.error = err + try: + self.url = err.geturl() + # sometimes (e.g. when authentication fails) + # urllib can't get a URL from an HTTPError + # This behaviour changes across Python versions, + # so no test cover (it isn't important). + except AttributeError: # pragma: no cover + pass + self.status_code = err.code + else: + self.status_code = self.raw.getcode() + self.url = self.raw.geturl() + self.reason = RESPONSES.get(self.status_code) + + # Parse additional info if request succeeded + if not self.error: + headers = self.raw.info() + self.transfer_encoding = headers.getencoding() + self.mimetype = headers.gettype() + for key in headers.keys(): + self.headers[key.lower()] = headers.get(key) + + # Is content gzipped? + # Transfer-Encoding appears to not be used in the wild + # (contrary to the HTTP standard), but no harm in testing + # for it + if ('gzip' in headers.get('content-encoding', '') or + 'gzip' in headers.get('transfer-encoding', '')): + self._gzipped = True + + @property + def stream(self): + """Whether response is streamed. + + Returns: + bool: `True` if response is streamed. + """ + return self._stream + + @stream.setter + def stream(self, value): + if self._content_loaded: + raise RuntimeError("`content` has already been read from " + "this Response.") + + self._stream = value + + def json(self): + """Decode response contents as JSON. + + :returns: object decoded from JSON + :rtype: 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): + + decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') + + for chunk in iterator: + data = decoder.decode(chunk) + if data: + yield data + + data = decoder.decode(b'', final=True) + if data: # pragma: no cover + yield data + + def generate(): + + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + + while True: + chunk = self.raw.read(chunk_size) + if not chunk: + break + + if self._gzipped: + chunk = decoder.decompress(chunk) + + yield chunk + + chunks = generate() + + if decode_unicode and self.encoding: + chunks = decode_stream(chunks, self) + + return chunks + + def save_to_path(self, filepath): + """Save retrieved data to file at ``filepath``. + + .. versionadded: 1.9.6 + + :param filepath: Path to save retrieved data. + + """ + filepath = os.path.abspath(filepath) + dirname = os.path.dirname(filepath) + if not os.path.exists(dirname): + os.makedirs(dirname) + + self.stream = True + + with open(filepath, 'wb') as fileobj: + for data in self.iter_content(): + fileobj.write(data) + + def raise_for_status(self): + """Raise stored error if one occurred. + + error will be instance of :class:`urllib2.HTTPError` + """ + if self.error is not None: + raise self.error + return + + def _get_encoding(self): + """Get encoding from HTTP headers or content. + + :returns: encoding or `None` + :rtype: unicode or ``None`` + + """ + headers = self.raw.info() + encoding = None + + if headers.getparam('charset'): + encoding = headers.getparam('charset') + + # HTTP Content-Type header + for param in headers.getplist(): + if param.startswith('charset='): + encoding = param[8:] + break + + if not self.stream: # Try sniffing response content + # Encoding declared in document should override HTTP headers + if self.mimetype == 'text/html': # sniff HTML headers + m = re.search("""""", + self.content) + if m: + encoding = m.group(1) + + elif ((self.mimetype.startswith('application/') or + self.mimetype.startswith('text/')) and + 'xml' in self.mimetype): + m = re.search("""]*\?>""", + self.content) + if m: + encoding = m.group(1) + + # Format defaults + if self.mimetype == 'application/json' and not encoding: + # The default encoding for JSON + encoding = 'utf-8' + + elif self.mimetype == 'application/xml' and not encoding: + # The default for 'application/xml' + encoding = 'utf-8' + + if encoding: + encoding = encoding.lower() + + return encoding + + +def request(method, url, params=None, data=None, headers=None, cookies=None, + files=None, auth=None, timeout=60, allow_redirects=False, + stream=False): + """Initiate an HTTP(S) request. Returns :class:`Response` object. + + :param method: 'GET' or 'POST' + :type method: unicode + :param url: URL to open + :type url: unicode + :param params: mapping of URL parameters + :type params: 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 = [] + + if not allow_redirects: + openers.append(NoRedirectHandler()) + + if auth is not None: # Add authorisation handler + username, password = auth + password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(None, url, username, password) + auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) + openers.append(auth_manager) + + # Install our custom chain of openers + opener = urllib2.build_opener(*openers) + urllib2.install_opener(opener) + + if not headers: + headers = CaseInsensitiveDictionary() + else: + headers = CaseInsensitiveDictionary(headers) + + if 'user-agent' not in headers: + headers['user-agent'] = USER_AGENT + + # Accept gzip-encoded content + encodings = [s.strip() for s in + headers.get('accept-encoding', '').split(',')] + if 'gzip' not in encodings: + encodings.append('gzip') + + headers['accept-encoding'] = ', '.join(encodings) + + # Force POST by providing an empty data string + if method == 'POST' and not data: + data = '' + + if files: + if not data: + data = {} + new_headers, data = encode_multipart_formdata(data, files) + headers.update(new_headers) + elif data and isinstance(data, dict): + data = urllib.urlencode(str_dict(data)) + + # Make sure everything is encoded text + headers = str_dict(headers) + + if isinstance(url, unicode): + url = url.encode('utf-8') + + if params: # GET args (POST args are handled in encode_multipart_formdata) + + scheme, netloc, path, query, fragment = urlparse.urlsplit(url) + + if query: # Combine query string and `params` + url_params = urlparse.parse_qs(query) + # `params` take precedence over URL query string + url_params.update(params) + params = url_params + + query = urllib.urlencode(str_dict(params), doseq=True) + url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) + + req = urllib2.Request(url, data, headers) + return Response(req, stream) + + +def get(url, params=None, headers=None, cookies=None, auth=None, + timeout=60, allow_redirects=True, stream=False): + """Initiate a GET request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('GET', url, params, headers=headers, cookies=cookies, + auth=auth, timeout=timeout, allow_redirects=allow_redirects, + stream=stream) + + +def post(url, params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=60, allow_redirects=False, stream=False): + """Initiate a POST request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request('POST', url, params, data, headers, cookies, files, auth, + timeout, allow_redirects, stream) + + +def encode_multipart_formdata(fields, files): + """Encode form data (``fields``) and ``files`` for POST request. + + :param fields: mapping of ``{name : value}`` pairs for normal form fields. + :type fields: 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/web.pyc b/workflow/web.pyc new file mode 100644 index 0000000..84a87c1 Binary files /dev/null and b/workflow/web.pyc differ diff --git a/workflow/workflow.py b/workflow/workflow.py new file mode 100644 index 0000000..c2c1616 --- /dev/null +++ b/workflow/workflow.py @@ -0,0 +1,2818 @@ +# 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 + +from util import ( + AcquisitionError, # imported to maintain API + 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 both Alfred 2 and 3. The + :class:`~workflow.Workflow3` subclass provides additional, + Alfred 3-only features, 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 = {} + # Magic arguments + #: The prefix for all magic arguments. Default is ``workflow:`` + self.magic_prefix = 'workflow:' + #: Mapping of available magic arguments. The built-in magic + #: arguments are registered by default. To add your own magic arguments + #: (or override built-ins), add a key:value pair where the key is + #: what the user should enter (prefixed with :attr:`magic_prefix`) + #: and the value is a callable that will be called when the argument + #: is entered. If you would like to display a message in Alfred, the + #: function should return a ``unicode`` string. + #: + #: By default, the magic arguments documented + #: :ref:`here ` are registered. + self.magic_arguments = {} + + self._register_default_magic() + + if libraries: + sys.path = libraries + sys.path + + #################################################################### + # API methods + #################################################################### + + # info.plist contents and alfred_* environment variables ---------- + + @property + def alfred_version(self): + """Alfred version as :class:`~workflow.update.Version` object.""" + from update import Version + return Version(self.alfred_env.get('version')) + + @property + def alfred_env(self): + """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 ( + 'alfred_debug', + 'alfred_preferences', + 'alfred_preferences_localhash', + 'alfred_theme', + 'alfred_theme_background', + 'alfred_theme_subtext', + 'alfred_version', + 'alfred_version_build', + 'alfred_workflow_bundleid', + 'alfred_workflow_cache', + 'alfred_workflow_data', + 'alfred_workflow_name', + 'alfred_workflow_uid', + 'alfred_workflow_version'): + + value = os.getenv(key) + + if isinstance(value, str): + if key in ('alfred_debug', 'alfred_version_build', + 'alfred_theme_subtext'): + value = int(value) + else: + value = self.decode(value) + + data[key[7:]] = value + + self._alfred_env = data + + return self._alfred_env + + @property + def info(self): + """:class:`dict` of ``info.plist`` contents.""" + if not self._info_loaded: + self._load_info_plist() + return self._info + + @property + def bundleid(self): + """Workflow bundle ID from environmental vars or ``info.plist``. + + :returns: bundle ID + :rtype: ``unicode`` + + """ + if not self._bundleid: + if self.alfred_env.get('workflow_bundleid'): + self._bundleid = self.alfred_env.get('workflow_bundleid') + else: + self._bundleid = unicode(self.info['bundleid'], 'utf-8') + + return self._bundleid + + @property + def debugging(self): + """Whether Alfred's debugger is open. + + :returns: ``True`` if Alfred's debugger is open. + :rtype: ``bool`` + + """ + if self._debugging is None: + if self.alfred_env.get('debug') == 1: + self._debugging = True + else: + self._debugging = False + return self._debugging + + @property + def name(self): + """Workflow name from Alfred's environmental vars or ``info.plist``. + + :returns: workflow name + :rtype: ``unicode`` + + """ + if not self._name: + if self.alfred_env.get('workflow_name'): + self._name = self.decode(self.alfred_env.get('workflow_name')) + else: + self._name = self.decode(self.info['name']) + + return self._name + + @property + def version(self): + """Return the version of the workflow. + + .. versionadded:: 1.9.10 + + Get the workflow version from environment variable, + the ``update_settings`` dict passed on + instantiation, the ``version`` file located in the workflow's + root directory or ``info.plist``. Return ``None`` if none + exists or :class:`ValueError` if the version number is invalid + (i.e. not semantic). + + :returns: Version of the workflow (not Alfred-Workflow) + :rtype: :class:`~workflow.update.Version` object + + """ + if self._version is UNSET: + + version = None + # environment variable has priority + if self.alfred_env.get('workflow_version'): + version = self.alfred_env['workflow_version'] + + # Try `update_settings` + elif self._update_settings: + version = self._update_settings.get('version') + + # `version` file + if not version: + filepath = self.workflowfile('version') + + if os.path.exists(filepath): + with open(filepath, 'rb') as fileobj: + version = fileobj.read() + + # info.plist + if not version: + version = self.info.get('version') + + if version: + from update import Version + version = Version(version) + + self._version = version + + return self._version + + # Workflow utility methods ----------------------------------------- + + @property + def args(self): + """Return command line args as normalised unicode. + + Args are decoded and normalised via :meth:`~Workflow.decode`. + + The encoding and normalisation are the ``input_encoding`` and + ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` + and ``NFC`` are the defaults). + + If :class:`Workflow` is called with ``capture_args=True`` + (the default), :class:`Workflow` will look for certain + ``workflow:*`` args and, if found, perform the corresponding + actions and exit the workflow. + + See :ref:`Magic arguments ` for details. + + """ + msg = None + args = [self.decode(arg) for arg in sys.argv[1:]] + + # Handle magic args + if len(args) and self._capture_args: + for name in self.magic_arguments: + key = '{0}{1}'.format(self.magic_prefix, name) + if key in args: + msg = self.magic_arguments[name]() + + if msg: + self.logger.debug(msg) + if not sys.stdout.isatty(): # Show message in Alfred + self.add_item(msg, valid=False, icon=ICON_INFO) + self.send_feedback() + sys.exit(0) + return args + + @property + def cachedir(self): + """Path to workflow's cache directory. + + The cache directory is a subdirectory of Alfred's own cache directory + in ``~/Library/Caches``. The full path is: + + ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` + + ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. + + :returns: full path to workflow's cache directory + :rtype: ``unicode`` + + """ + if self.alfred_env.get('workflow_cache'): + dirpath = self.alfred_env.get('workflow_cache') + + else: + dirpath = self._default_cachedir + + return self._create(dirpath) + + @property + def _default_cachedir(self): + """Alfred 2's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-2/' + 'Workflow Data/'), + self.bundleid) + + @property + def datadir(self): + """Path to workflow's data directory. + + The data directory is a subdirectory of Alfred's own data directory in + ``~/Library/Application Support``. The full path is: + + ``~/Library/Application Support/Alfred 2/Workflow Data/`` + + :returns: full path to workflow data directory + :rtype: ``unicode`` + + """ + if self.alfred_env.get('workflow_data'): + dirpath = self.alfred_env.get('workflow_data') + + else: + dirpath = self._default_datadir + + return self._create(dirpath) + + @property + def _default_datadir(self): + """Alfred 2's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 2/Workflow Data/'), + self.bundleid) + + @property + def workflowdir(self): + """Path to workflow's root directory (where ``info.plist`` is). + + :returns: full path to workflow root directory + :rtype: ``unicode`` + + """ + if not self._workflowdir: + # Try the working directory first, then the directory + # the library is in. CWD will be the workflow root if + # a workflow is being run in Alfred + candidates = [ + os.path.abspath(os.getcwdu()), + os.path.dirname(os.path.abspath(os.path.dirname(__file__)))] + + # climb the directory tree until we find `info.plist` + for dirpath in candidates: + + # Ensure directory path is Unicode + dirpath = self.decode(dirpath) + + while True: + if os.path.exists(os.path.join(dirpath, 'info.plist')): + self._workflowdir = dirpath + break + + elif dirpath == '/': + # no `info.plist` found + break + + # Check the parent directory + dirpath = os.path.dirname(dirpath) + + # No need to check other candidates + if self._workflowdir: + break + + if not self._workflowdir: + raise IOError("'info.plist' not found in directory tree") + + return self._workflowdir + + def cachefile(self, filename): + """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`` + + """ + # Create a new workflow object to ensure standard serialiser + # is used (update.py is called without the user's settings) + update_data = Workflow().cached_data('__workflow_update_status', + max_age=0) + + self.logger.debug('update_data: %r', update_data) + + if not update_data or not update_data.get('available'): + return False + + return update_data['available'] + + @property + def prereleases(self): + """Whether workflow should update to pre-release versions. + + .. versionadded:: 1.16 + + :returns: ``True`` if pre-releases are enabled with the :ref:`magic + argument ` or the ``update_settings`` dict, else + ``False``. + + """ + if self._update_settings.get('prereleases'): + return True + + return self.settings.get('__workflow_prereleases') or False + + def check_update(self, force=False): + """Call update script if it's time to check for a new release. + + .. versionadded:: 1.9 + + The update script will be run in the background, so it won't + interfere in the execution of your workflow. + + See :ref:`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`` + + """ + frequency = self._update_settings.get('frequency', + DEFAULT_UPDATE_FREQUENCY) + + if not force and not self.settings.get('__workflow_autoupdate', True): + self.logger.debug('Auto update turned off by user') + return + + # Check for new version if it's time + if (force or not self.cached_data_fresh( + '__workflow_update_status', frequency * 86400)): + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + from background import run_in_background + + # update.py is adjacent to this file + update_script = os.path.join(os.path.dirname(__file__), + b'update.py') + + cmd = ['/usr/bin/python', update_script, 'check', github_slug, + version] + + if self.prereleases: + cmd.append('--prereleases') + + self.logger.info('checking for update ...') + + run_in_background('__workflow_update_check', cmd) + + else: + self.logger.debug('update check not due') + + def start_update(self): + """Check for update and download and install new workflow file. + + .. versionadded:: 1.9 + + See :ref:`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 + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + if not update.check_update(github_slug, version, self.prereleases): + return False + + from background import run_in_background + + # update.py is adjacent to this file + update_script = os.path.join(os.path.dirname(__file__), + b'update.py') + + cmd = ['/usr/bin/python', update_script, 'install', github_slug, + version] + + if self.prereleases: + cmd.append('--prereleases') + + self.logger.debug('downloading update ...') + run_in_background('__workflow_update_install', cmd) + + return True + + #################################################################### + # Keychain password storage methods + #################################################################### + + def save_password(self, account, password, service=None): + """Save account credentials. + + If the account exists, the old password will first be deleted + (Keychain throws an error otherwise). + + If something goes wrong, a :class:`KeychainError` exception will + be raised. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param password: the password to secure + :type password: ``unicode`` + :param service: Name of the service. By default, this is the + workflow's bundle ID + :type service: ``unicode`` + + """ + if not service: + service = self.bundleid + + try: + self._call_security('add-generic-password', service, account, + '-w', password) + self.logger.debug('saved password : %s:%s', service, account) + + except PasswordExists: + self.logger.debug('password exists : %s:%s', service, account) + current_password = self.get_password(account, service) + + if current_password == password: + self.logger.debug('password unchanged') + + else: + self.delete_password(account, service) + self._call_security('add-generic-password', service, + account, '-w', password) + self.logger.debug('save_password : %s:%s', service, account) + + def get_password(self, account, service=None): + """Retrieve the password saved at ``service/account``. + + Raise :class:`PasswordNotFound` exception if password doesn't exist. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param service: Name of the service. By default, this is the workflow's + bundle ID + :type service: ``unicode`` + :returns: account password + :rtype: ``unicode`` + + """ + if not service: + service = self.bundleid + + output = self._call_security('find-generic-password', service, + account, '-g') + + # Parsing of `security` output is adapted from python-keyring + # by Jason R. Coombs + # https://pypi.python.org/pypi/keyring + m = re.search( + r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', + output) + + if m: + groups = m.groupdict() + h = groups.get('hex') + password = groups.get('pw') + if h: + password = unicode(binascii.unhexlify(h), 'utf-8') + + self.logger.debug('got password : %s:%s', service, account) + + return password + + def delete_password(self, account, service=None): + """Delete the password stored at ``service/account``. + + Raise :class:`PasswordNotFound` if account is unknown. + + :param account: name of the account the password is for, e.g. + "Pinboard" + :type account: ``unicode`` + :param service: Name of the service. By default, this is the workflow's + bundle ID + :type service: ``unicode`` + + """ + if not service: + service = self.bundleid + + self._call_security('delete-generic-password', service, account) + + self.logger.debug('deleted password : %s:%s', service, account) + + #################################################################### + # Methods for workflow:* magic args + #################################################################### + + def _register_default_magic(self): + """Register the built-in magic arguments.""" + # TODO: refactor & simplify + # Wrap callback and message with callable + def callback(func, msg): + def wrapper(): + func() + return msg + + return wrapper + + self.magic_arguments['delcache'] = callback(self.clear_cache, + 'Deleted workflow cache') + self.magic_arguments['deldata'] = callback(self.clear_data, + 'Deleted workflow data') + self.magic_arguments['delsettings'] = callback( + self.clear_settings, 'Deleted workflow settings') + self.magic_arguments['reset'] = callback(self.reset, + 'Reset workflow') + self.magic_arguments['openlog'] = callback(self.open_log, + 'Opening workflow log file') + self.magic_arguments['opencache'] = callback( + self.open_cachedir, 'Opening workflow cache directory') + self.magic_arguments['opendata'] = callback( + self.open_datadir, 'Opening workflow data directory') + self.magic_arguments['openworkflow'] = callback( + self.open_workflowdir, 'Opening workflow directory') + self.magic_arguments['openterm'] = callback( + self.open_terminal, 'Opening workflow root directory in Terminal') + + # Diacritic folding + def fold_on(): + self.settings['__workflow_diacritic_folding'] = True + return 'Diacritics will always be folded' + + def fold_off(): + self.settings['__workflow_diacritic_folding'] = False + return 'Diacritics will never be folded' + + def fold_default(): + if '__workflow_diacritic_folding' in self.settings: + del self.settings['__workflow_diacritic_folding'] + return 'Diacritics folding reset' + + self.magic_arguments['foldingon'] = fold_on + self.magic_arguments['foldingoff'] = fold_off + self.magic_arguments['foldingdefault'] = fold_default + + # Updates + def update_on(): + self.settings['__workflow_autoupdate'] = True + return 'Auto update turned on' + + def update_off(): + self.settings['__workflow_autoupdate'] = False + return 'Auto update turned off' + + def prereleases_on(): + self.settings['__workflow_prereleases'] = True + return 'Prerelease updates turned on' + + def prereleases_off(): + self.settings['__workflow_prereleases'] = False + return 'Prerelease updates turned off' + + def do_update(): + if self.start_update(): + return 'Downloading and installing update ...' + else: + return 'No update available' + + self.magic_arguments['autoupdate'] = update_on + self.magic_arguments['noautoupdate'] = update_off + self.magic_arguments['prereleases'] = prereleases_on + self.magic_arguments['noprereleases'] = prereleases_off + self.magic_arguments['update'] = do_update + + # Help + def do_help(): + if self.help_url: + self.open_help() + return 'Opening workflow help URL in browser' + else: + return 'Workflow has no help URL' + + def show_version(): + if self.version: + return 'Version: {0}'.format(self.version) + else: + return 'This workflow has no version number' + + def list_magic(): + """Display all available magic args in Alfred.""" + isatty = sys.stderr.isatty() + for name in sorted(self.magic_arguments.keys()): + if name == 'magic': + continue + arg = 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]) + + def open_cachedir(self): + """Open the workflow's :attr:`cachedir` in Finder.""" + subprocess.call(['open', self.cachedir]) + + def open_datadir(self): + """Open the workflow's :attr:`datadir` in Finder.""" + subprocess.call(['open', self.datadir]) + + def open_workflowdir(self): + """Open the workflow's :attr:`workflowdir` in Finder.""" + subprocess.call(['open', self.workflowdir]) + + def open_terminal(self): + """Open a Terminal window at workflow's :attr:`workflowdir`.""" + subprocess.call(['open', '-a', 'Terminal', + self.workflowdir]) + + def open_help(self): + """Open :attr:`help_url` in default browser.""" + subprocess.call(['open', self.help_url]) + + return 'Opening workflow help URL in browser' + + #################################################################### + # Helper methods + #################################################################### + + def decode(self, text, encoding=None, normalization=None): + """Return ``text`` as normalised unicode. + + If ``encoding`` and/or ``normalization`` is ``None``, the + ``input_encoding``and ``normalization`` parameters passed to + :class:`Workflow` are used. + + :param text: string + :type text: encoded or Unicode string. If ``text`` is already a + Unicode string, it will only be normalised. + :param encoding: The text encoding to use to decode ``text`` to + Unicode. + :type encoding: ``unicode`` or ``None`` + :param normalization: The nomalisation form to apply to ``text``. + :type normalization: ``unicode`` or ``None`` + :returns: decoded and normalised ``unicode`` + + :class:`Workflow` uses "NFC" normalisation by default. This is the + standard for Python and will work well with data from the web (via + :mod:`~workflow.web` or :mod:`json`). + + 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/workflow.pyc b/workflow/workflow.pyc new file mode 100644 index 0000000..6c6fcf1 Binary files /dev/null and b/workflow/workflow.pyc differ diff --git a/workflow/workflow3.py b/workflow/workflow3.py new file mode 100644 index 0000000..a6c07c9 --- /dev/null +++ b/workflow/workflow3.py @@ -0,0 +1,720 @@ +# encoding: utf-8 +# +# Copyright (c) 2016 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2016-06-25 +# + +"""An Alfred 3-only version of :class:`~workflow.Workflow`. + +:class:`~workflow.Workflow3` supports Alfred 3's 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, optional): Main output/``{query}``. + **variables: Workflow variables to set. + + + Attributes: + arg (unicode): Output value (``{query}``). + config (dict): Configuration for downstream workflow element. + + """ + + def __init__(self, arg=None, **variables): + """Create a new `Variables` object.""" + self.arg = arg + self.config = {} + super(Variables, self).__init__(**variables) + + @property + def obj(self): + """Return ``alfredworkflow`` `dict`.""" + o = {} + if self: + d2 = {} + for k, v in self.items(): + d2[k] = v + o['variables'] = d2 + + if self.config: + o['config'] = self.config + + if self.arg is not None: + o['arg'] = self.arg + + return {'alfredworkflow': o} + + def __unicode__(self): + """Convert to ``alfredworkflow`` JSON object. + + Returns: + unicode: ``alfredworkflow`` JSON object + + """ + if not self and not self.config: + if self.arg: + return self.arg + else: + return u'' + + return json.dumps(self.obj) + + def __str__(self): + """Convert to ``alfredworkflow`` JSON object. + + Returns: + str: UTF-8 encoded ``alfredworkflow`` JSON object + + """ + return unicode(self).encode('utf-8') + + +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. + + 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 3's default cache directory.""" + return os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-3/' + 'Workflow Data/'), + self.bundleid) + + @property + def _default_datadir(self): + """Alfred 3's default data directory.""" + return os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 3/Workflow Data/'), + self.bundleid) + + @property + def rerun(self): + """How often (in seconds) Alfred should re-run the Script Filter.""" + return self._rerun + + @rerun.setter + def rerun(self, seconds): + """Interval at which Alfred should re-run the Script Filter. + + Args: + seconds (int): Interval between runs. + """ + self._rerun = seconds + + @property + def session_id(self): + """A unique session ID every time the user uses the workflow. + + .. versionadded:: 1.25 + + The session ID persists while the user is using this workflow. + It expires when the user runs a different workflow or closes + Alfred. + + """ + if not self._session_id: + 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``. + + 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.""" + json.dump(self.obj, sys.stdout) + sys.stdout.flush() diff --git a/workflow/workflow3.pyc b/workflow/workflow3.pyc new file mode 100644 index 0000000..5ae3425 Binary files /dev/null and b/workflow/workflow3.pyc differ diff --git a/workflow/workflow4.pyc b/workflow/workflow4.pyc new file mode 100644 index 0000000..5ae3425 Binary files /dev/null and b/workflow/workflow4.pyc differ