diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd20fdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +*.pyc diff --git a/com.whyliam.workflows.youdao.alfredworkflow b/com.whyliam.workflows.youdao.alfredworkflow new file mode 100644 index 0000000..0c1afdb Binary files /dev/null and b/com.whyliam.workflows.youdao.alfredworkflow differ diff --git a/icon.png b/icon.png new file mode 100755 index 0000000..bbaeb96 Binary files /dev/null and b/icon.png differ diff --git a/icon_basic.png b/icon_basic.png new file mode 100755 index 0000000..8b54148 Binary files /dev/null and b/icon_basic.png differ diff --git a/icon_web.png b/icon_web.png new file mode 100755 index 0000000..31d785c Binary files /dev/null and b/icon_web.png differ diff --git a/info.plist b/info.plist new file mode 100755 index 0000000..b8beebb --- /dev/null +++ b/info.plist @@ -0,0 +1,106 @@ + + + + + bundleid + com.whyliam.workflows.youdao + category + Tools + connections + + 91C343E7-50D8-4B0D-9034-1C16C20DA8D4 + + + destinationuid + E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B + modifiers + 0 + modifiersubtext + + + + + createdby + whyliam + description + 使用有道翻译你想知道的单词和语句 + disabled + + name + Youdao + objects + + + config + + argumenttype + 0 + escaping + 127 + keyword + yd + queuedelaycustom + 3 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + 正在获取中... + script + /usr/bin/python youdao.py "{query}" + subtext + 使用有道翻译你想知道的单词和语句 {query} + title + 有道翻译 + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 91C343E7-50D8-4B0D-9034-1C16C20DA8D4 + version + 0 + + + config + + plusspaces + + url + {query} + utf8 + + + type + alfred.workflow.action.openurl + uid + E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B + version + 0 + + + readme + + uidata + + 91C343E7-50D8-4B0D-9034-1C16C20DA8D4 + + ypos + 10 + + E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B + + ypos + 10 + + + webaddress + youdao.com + + diff --git a/workflow/__init__.py b/workflow/__init__.py new file mode 100755 index 0000000..5de1a96 --- /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 Python helper library for `Alfred 2 `_ Workflow +authors. +""" + +import os + +__title__ = 'Alfred-Workflow' +__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() +__author__ = 'Dean Jackson' +__licence__ = 'MIT' +__copyright__ = 'Copyright 2014 Dean Jackson' + + +# Workflow objects +from .workflow import Workflow, manager + +# 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, +) + +__all__ = [ + 'Workflow', + 'manager', + 'PasswordNotFound', + 'KeychainError', + 'ICON_ACCOUNT', + 'ICON_BURN', + 'ICON_CLOCK', + 'ICON_COLOR', + 'ICON_COLOUR', + 'ICON_EJECT', + 'ICON_ERROR', + 'ICON_FAVORITE', + 'ICON_FAVOURITE', + 'ICON_GROUP', + 'ICON_HELP', + 'ICON_HOME', + 'ICON_INFO', + 'ICON_NETWORK', + 'ICON_NOTE', + 'ICON_SETTINGS', + 'ICON_SWIRL', + 'ICON_SWITCH', + 'ICON_SYNC', + 'ICON_TRASH', + 'ICON_USER', + 'ICON_WARNING', + 'ICON_WEB', + 'MATCH_ALL', + 'MATCH_ALLCHARS', + 'MATCH_ATOM', + 'MATCH_CAPITALS', + 'MATCH_INITIALS', + 'MATCH_INITIALS_CONTAIN', + 'MATCH_INITIALS_STARTSWITH', + 'MATCH_STARTSWITH', + 'MATCH_SUBSTRING', +] diff --git a/workflow/background.py b/workflow/background.py new file mode 100755 index 0000000..bcfa74d --- /dev/null +++ b/workflow/background.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright © 2014 deanishe@deanishe.net +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-04-06 +# + +""" +Run background tasks +""" + +from __future__ import print_function, unicode_literals + +import sys +import os +import subprocess +import pickle + +from workflow import Workflow + +__all__ = ['is_running', 'run_in_background'] + +_wf = None + + +def wf(): + global _wf + if _wf is None: + _wf = Workflow() + return _wf + + +def _arg_cache(name): + """Return path to pickle cache file for arguments + + :param name: name of task + :type name: ``unicode`` + :returns: Path to cache file + :rtype: ``unicode`` filepath + + """ + + return wf().cachefile('{0}.argcache'.format(name)) + + +def _pid_file(name): + """Return path to PID file for ``name`` + + :param name: name of task + :type name: ``unicode`` + :returns: Path to PID file for task + :rtype: ``unicode`` filepath + + """ + + return wf().cachefile('{0}.pid'.format(name)) + + +def _process_exists(pid): + """Check if a process with PID ``pid`` exists + + :param pid: PID to check + :type pid: ``int`` + :returns: ``True`` if process exists, else ``False`` + :rtype: ``Boolean`` + """ + + try: + os.kill(pid, 0) + except OSError: # not running + return False + return True + + +def is_running(name): + """ + Test whether task is running under ``name`` + + :param name: name of task + :type name: ``unicode`` + :returns: ``True`` if task with name ``name`` is running, else ``False`` + :rtype: ``Boolean`` + + """ + pidfile = _pid_file(name) + if not os.path.exists(pidfile): + return False + + with open(pidfile, 'rb') as file_obj: + pid = int(file_obj.read().strip()) + + if _process_exists(pid): + return True + + elif os.path.exists(pidfile): + os.unlink(pidfile) + + return False + + +def _background(stdin='/dev/null', stdout='/dev/null', + stderr='/dev/null'): # pragma: no cover + """Fork the current process into a background daemon. + + :param stdin: where to read input + :type stdin: filepath + :param stdout: where to write stdout output + :type stdout: filepath + :param stderr: where to write stderr output + :type stderr: filepath + + """ + + # Do first fork. + try: + pid = os.fork() + if pid > 0: + sys.exit(0) # Exit first parent. + except OSError as e: + wf().logger.critical("fork #1 failed: ({0:d}) {1}".format( + e.errno, e.strerror)) + sys.exit(1) + # Decouple from parent environment. + os.chdir(wf().workflowdir) + os.umask(0) + os.setsid() + # Do second fork. + try: + pid = os.fork() + if pid > 0: + sys.exit(0) # Exit second parent. + except OSError as e: + wf().logger.critical("fork #2 failed: ({0:d}) {1}".format( + e.errno, e.strerror)) + sys.exit(1) + # Now I am a daemon! + # Redirect standard file descriptors. + si = file(stdin, 'r', 0) + so = file(stdout, 'a+', 0) + se = file(stderr, 'a+', 0) + if hasattr(sys.stdin, 'fileno'): + os.dup2(si.fileno(), sys.stdin.fileno()) + if hasattr(sys.stdout, 'fileno'): + os.dup2(so.fileno(), sys.stdout.fileno()) + if hasattr(sys.stderr, 'fileno'): + os.dup2(se.fileno(), sys.stderr.fileno()) + + +def run_in_background(name, args, **kwargs): + """Pickle arguments to cache file, then call this script again via + :func:`subprocess.call`. + + :param name: name of task + :type name: ``unicode`` + :param args: arguments passed as first argument to :func:`subprocess.call` + :param \**kwargs: keyword arguments to :func:`subprocess.call` + :returns: exit code of sub-process + :rtype: ``int`` + + When you call this function, it caches its arguments and then calls + ``background.py`` in a subprocess. The Python subprocess will load the + cached arguments, fork into the background, and then run the command you + specified. + + This function will return as soon as the ``background.py`` subprocess has + forked, returning the exit code of *that* process (i.e. not of the command + you're trying to run). + + If that process fails, an error will be written to the log file. + + If a process is already running under the same name, this function will + return immediately and will not run the specified command. + + """ + + if is_running(name): + wf().logger.info('Task `{0}` is already running'.format(name)) + return + + argcache = _arg_cache(name) + + # Cache arguments + with open(argcache, 'wb') as file_obj: + pickle.dump({'args': args, 'kwargs': kwargs}, file_obj) + wf().logger.debug('Command arguments cached to `{0}`'.format(argcache)) + + # Call this script + cmd = ['/usr/bin/python', __file__, name] + wf().logger.debug('Calling {0!r} ...'.format(cmd)) + retcode = subprocess.call(cmd) + if retcode: # pragma: no cover + wf().logger.error('Failed to call task in background') + else: + wf().logger.debug('Executing task `{0}` in background...'.format(name)) + return retcode + + +def main(wf): # pragma: no cover + """ + Load cached arguments, fork into background, then call + :meth:`subprocess.call` with cached arguments + + """ + + name = wf.args[0] + argcache = _arg_cache(name) + if not os.path.exists(argcache): + wf.logger.critical('No arg cache found : {0!r}'.format(argcache)) + return 1 + + # Load cached arguments + with open(argcache, 'rb') as file_obj: + data = pickle.load(file_obj) + + # Cached arguments + args = data['args'] + kwargs = data['kwargs'] + + # Delete argument cache file + os.unlink(argcache) + + pidfile = _pid_file(name) + + # Fork to background + _background() + + # Write PID to file + with open(pidfile, 'wb') as file_obj: + file_obj.write('{0}'.format(os.getpid())) + + # Run the command + try: + wf.logger.debug('Task `{0}` running'.format(name)) + wf.logger.debug('cmd : {0!r}'.format(args)) + + retcode = subprocess.call(args, **kwargs) + + if retcode: + wf.logger.error('Command failed with [{0}] : {1!r}'.format( + retcode, args)) + + finally: + if os.path.exists(pidfile): + os.unlink(pidfile) + wf.logger.debug('Task `{0}` finished'.format(name)) + + +if __name__ == '__main__': # pragma: no cover + wf().run(main) diff --git a/workflow/update.py b/workflow/update.py new file mode 100755 index 0000000..b946e79 --- /dev/null +++ b/workflow/update.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright © 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(): + 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. + """ + + #: Match version and pre-release/build information in version strings + match_version = re.compile(r'([0-9\.]+)(.+)?').match + + def __init__(self, vstr): + self.vstr = vstr + self.major = 0 + self.minor = 0 + self.patch = 0 + self.suffix = '' + self.build = '' + self._parse(vstr) + + def _parse(self, vstr): + if vstr.startswith('v'): + m = self.match_version(vstr[1:]) + else: + m = self.match_version(vstr) + if not m: + raise ValueError('Invalid version number: {0}'.format(vstr)) + + version, suffix = m.groups() + parts = self._parse_dotted_string(version) + self.major = parts.pop(0) + if len(parts): + self.minor = parts.pop(0) + if len(parts): + self.patch = parts.pop(0) + if not len(parts) == 0: + raise ValueError('Invalid version (too long) : {0}'.format(vstr)) + + if suffix: + # Build info + idx = suffix.find('+') + if idx > -1: + self.build = suffix[idx+1:] + suffix = suffix[:idx] + if suffix: + if not suffix.startswith('-'): + raise ValueError( + 'Invalid suffix : `{0}`. Must start with `-`'.format( + suffix)) + self.suffix = suffix[1:] + + # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) + + def _parse_dotted_string(self, s): + """Parse string ``s`` into list of ints and strings""" + parsed = [] + parts = s.split('.') + for p in parts: + if p.isdigit(): + p = int(p) + parsed.append(p) + return parsed + + @property + def tuple(self): + """Return version number as a tuple of major, minor, patch, pre-release + """ + + return (self.major, self.minor, self.patch, self.suffix) + + def __lt__(self, other): + 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): + if not isinstance(other, Version): + raise ValueError('Not a Version instance: {0!r}'.format(other)) + return self.tuple == other.tuple + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + if not isinstance(other, Version): + raise ValueError('Not a Version instance: {0!r}'.format(other)) + return other.__lt__(self) + + def __le__(self, other): + if not isinstance(other, Version): + raise ValueError('Not a Version instance: {0!r}'.format(other)) + return not other.__lt__(self) + + def __ge__(self, other): + return not self.__lt__(other) + + def __str__(self): + vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) + if self.suffix: + vstr += '-{0}'.format(self.suffix) + if self.build: + vstr += '+{0}'.format(self.build) + return vstr + + def __repr__(self): + return "Version('{0}')".format(str(self)) + + +def download_workflow(url): + """Download workflow at ``url`` to a local temporary file + + :param url: URL to .alfredworkflow file in GitHub repo + :returns: path to downloaded file + + """ + + filename = url.split("/")[-1] + + if (not url.endswith('.alfredworkflow') or + not filename.endswith('.alfredworkflow')): + raise ValueError('Attachment `{}` not a workflow'.format(filename)) + + local_path = os.path.join(tempfile.gettempdir(), filename) + + wf().logger.debug( + 'Downloading updated workflow from `{0}` to `{1}` ...'.format( + 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 get_valid_releases(github_slug): + """Return list of all valid releases + + :param github_slug: ``username/repo`` for workflow's GitHub repo + :returns: list of dicts. Each :class:`dict` has the form + ``{'version': '1.1', 'download_url': 'http://github.com/...'}`` + + + A valid release is one that contains one ``.alfredworkflow`` file. + + If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading + ``v`` will be stripped. + + """ + + api_url = build_api_url(github_slug) + releases = [] + + wf().logger.debug('Retrieving releases list from `{0}` ...'.format( + api_url)) + + def retrieve_releases(): + wf().logger.info( + 'Retrieving releases for `{0}` ...'.format(github_slug)) + return web.get(api_url).json() + + slug = github_slug.replace('/', '-') + for release in wf().cached_data('gh-releases-{0}'.format(slug), + retrieve_releases): + version = release['tag_name'] + download_urls = [] + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + if not url or not url.endswith('.alfredworkflow'): + continue + download_urls.append(url) + + # Validate release + if release['prerelease']: + wf().logger.warning( + 'Invalid release {0} : pre-release detected'.format(version)) + continue + if not download_urls: + wf().logger.warning( + 'Invalid release {0} : No workflow file'.format(version)) + continue + if len(download_urls) > 1: + wf().logger.warning( + 'Invalid release {0} : multiple workflow files'.format(version)) + continue + + wf().logger.debug('Release `{0}` : {1}'.format(version, url)) + releases.append({'version': version, 'download_url': download_urls[0]}) + + return releases + + +def check_update(github_slug, current_version): + """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. + :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) + + wf().logger.info('{0} releases for {1}'.format(len(releases), + github_slug)) + + if not len(releases): + raise ValueError('No valid releases for {0}'.format(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 : {0!r} Installed : {1!r}'.format(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(github_slug, current_version): + """If a newer release is available, download and install it + + :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. + :type current_version: ``unicode`` + + If an update is available, it will be downloaded and installed. + + :returns: ``True`` if an update is installed, else ``False`` + + """ + # TODO: `github_slug` and `current_version` are both unusued. + + update_data = wf().cached_data('__workflow_update_status', max_age=0) + + if not update_data or not update_data.get('available'): + wf().logger.info('No update available') + return False + + local_file = download_workflow(update_data['download_url']) + + wf().logger.info('Installing updated workflow ...') + subprocess.call(['open', local_file]) + + update_data['available'] = False + wf().cache_data('__workflow_update_status', update_data) + return True + + +if __name__ == '__main__': # pragma: nocover + import sys + + def show_help(): + print('Usage : update.py (check|install) github_slug version') + sys.exit(1) + + if len(sys.argv) != 4: + show_help() + + action, github_slug, version = sys.argv[1:] + + if action not in ('check', 'install'): + show_help() + + if action == 'check': + check_update(github_slug, version) + elif action == 'install': + install_update(github_slug, version) diff --git a/workflow/version b/workflow/version new file mode 100755 index 0000000..b0f61c5 --- /dev/null +++ b/workflow/version @@ -0,0 +1 @@ +1.11.1 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py new file mode 100755 index 0000000..b67bbcb --- /dev/null +++ b/workflow/web.py @@ -0,0 +1,633 @@ +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +""" +A lightweight HTTP library with a requests-like interface. +""" + +from __future__ import print_function + +import codecs +import json +import mimetypes +import os +import random +import re +import socket +import string +import unicodedata +import urllib +import urllib2 +import zlib + + +USER_AGENT = u'Alfred-Workflow/1.11 (http://www.deanishe.net)' + +# Valid characters for multipart form data boundaries +BOUNDARY_CHARS = string.digits + string.ascii_letters + +# HTTP response codes +RESPONSES = { + 100: 'Continue', + 101: 'Switching Protocols', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported' +} + + +def str_dict(dic): + """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str` + + :param dic: :class:`dict` of Unicode strings + :returns: :class:`dict` + + """ + if isinstance(dic, CaseInsensitiveDictionary): + dic2 = CaseInsensitiveDictionary() + else: + dic2 = {} + for k, v in dic.items(): + if isinstance(k, unicode): + k = k.encode('utf-8') + if isinstance(v, unicode): + v = v.encode('utf-8') + dic2[k] = v + return dic2 + + +class NoRedirectHandler(urllib2.HTTPRedirectHandler): + """Prevent redirections""" + + def redirect_request(self, *args): + return None + + +# Adapted from https://gist.github.com/babakness/3901174 +class CaseInsensitiveDictionary(dict): + """ + Dictionary that 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): + + 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. + + A 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): + """Call `request` with :mod:`urllib2` and process results. + + :param request: :class:`urllib2.Request` instance + + """ + + self.request = request + 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._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 + + def json(self): + """Decode response contents as JSON. + + :returns: object decoded from JSON + :rtype: :class:`list` / :class:`dict` + + """ + + return json.loads(self.content, self.encoding or 'utf-8') + + @property + def encoding(self): + """Text encoding of document or ``None`` + + :returns: :class:`str` or ``None`` + + """ + + if not self._encoding: + self._encoding = self._get_encoding() + + return self._encoding + + @property + def content(self): + """Raw content of response (i.e. bytes) + + :returns: Body of HTTP response + :rtype: :class:`str` + + """ + + if not self._content: + + # Decompress gzipped content + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + self._content = decoder.decompress(self.raw.read()) + + else: + self._content = self.raw.read() + + return self._content + + @property + def text(self): + """Unicode-decoded content of response body. + + If no encoding can be determined from HTTP headers or the content + itself, the encoded response body will be returned instead. + + :returns: Body of HTTP response + :rtype: :class:`unicode` or :class:`str` + + """ + + if self.encoding: + return unicodedata.normalize('NFC', unicode(self.content, + self.encoding)) + return self.content + + def iter_content(self, chunk_size=4096, decode_unicode=False): + """Iterate over response data. + + .. versionadded:: 1.6 + + :param chunk_size: Number of bytes to read into memory + :type chunk_size: ``int`` + :param decode_unicode: Decode to Unicode using detected encoding + :type decode_unicode: ``Boolean`` + :returns: iterator + + """ + + 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: + yield data # pragma: nocover + + 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) + + 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 + + # 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): + """Initiate an HTTP(S) request. Returns :class:`Response` object. + + :param method: 'GET' or 'POST' + :type method: ``unicode`` + :param url: URL to open + :type url: ``unicode`` + :param params: mapping of URL parameters + :type params: :class:`dict` + :param data: mapping of form data ``{'field_name': 'value'}`` or + :class:`str` + :type data: :class:`dict` or :class:`str` + :param headers: HTTP headers + :type headers: :class:`dict` + :param cookies: cookies to send to server + :type cookies: :class:`dict` + :param files: files to upload (see below). + :type files: :class:`dict` + :param auth: username, password + :type auth: ``tuple`` + :param timeout: connection timeout limit in seconds + :type timeout: ``int`` + :param allow_redirects: follow redirections + :type allow_redirects: ``Boolean`` + :returns: :class:`Response` object + + + The ``files`` argument is a dictionary:: + + {'fieldname' : { 'filename': 'blah.txt', + 'content': '', + 'mimetype': 'text/plain'} + } + + * ``fieldname`` is the name of the field in the HTML form. + * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will + be used to guess the mimetype, or ``application/octet-stream`` + will be used. + + """ + + # TODO: cookies + # TODO: any way to force GET or POST? + 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) + + 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) + url = url + '?' + urllib.urlencode(str_dict(params)) + + req = urllib2.Request(url, data, headers) + return Response(req) + + +def get(url, params=None, headers=None, cookies=None, auth=None, + timeout=60, allow_redirects=True): + """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) + + +def post(url, params=None, data=None, headers=None, cookies=None, files=None, + auth=None, timeout=60, allow_redirects=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) + + +def encode_multipart_formdata(fields, files): + """Encode form data (``fields``) and ``files`` for POST request. + + :param fields: mapping of ``{name : value}`` pairs for normal form fields. + :type fields: :class:`dict` + :param files: dictionary of fieldnames/files elements for file data. + See below for details. + :type files: :class:`dict` of :class:`dicts` + :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers + :rtype: 2-tuple ``(dict, str)`` + + The ``files`` argument is a dictionary:: + + {'fieldname' : { 'filename': 'blah.txt', + 'content': '', + 'mimetype': 'text/plain'} + } + + - ``fieldname`` is the name of the field in the HTML form. + - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. + + """ + + def get_content_type(filename): + """Return or guess mimetype of ``filename``. + + :param filename: filename of file + :type filename: unicode/string + :returns: mime-type, e.g. ``text/html`` + :rtype: :class::class:`str` + + """ + + return mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) + for i in range(30)) + CRLF = '\r\n' + output = [] + + # Normal form fields + for (name, value) in fields.items(): + if isinstance(name, unicode): + name = name.encode('utf-8') + if isinstance(value, unicode): + value = value.encode('utf-8') + output.append('--' + boundary) + output.append('Content-Disposition: form-data; name="%s"' % name) + output.append('') + output.append(value) + + # Files to upload + for name, d in files.items(): + filename = d[u'filename'] + content = d[u'content'] + if u'mimetype' in d: + mimetype = d[u'mimetype'] + else: + mimetype = get_content_type(filename) + if isinstance(name, unicode): + name = name.encode('utf-8') + if isinstance(filename, unicode): + filename = filename.encode('utf-8') + if isinstance(mimetype, unicode): + mimetype = mimetype.encode('utf-8') + output.append('--' + boundary) + output.append('Content-Disposition: form-data; ' + 'name="%s"; filename="%s"' % (name, filename)) + output.append('Content-Type: %s' % mimetype) + output.append('') + output.append(content) + + output.append('--' + boundary + '--') + output.append('') + body = CRLF.join(output) + headers = { + 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, + 'Content-Length': str(len(body)), + } + return (headers, body) diff --git a/workflow/workflow.py b/workflow/workflow.py new file mode 100755 index 0000000..14c1d87 --- /dev/null +++ b/workflow/workflow.py @@ -0,0 +1,2711 @@ +# encoding: utf-8 +# +# Copyright (c) 2014 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-02-15 +# + +""" +The :class:`Workflow` object is the main interface to this library. + +See :ref:`setup` in the :ref:`user-manual` for an example of how to set +up your Python script to best utilise the :class:`Workflow` object. + +""" + +from __future__ import print_function, unicode_literals + +import binascii +import os +import sys +import string +import re +import plistlib +import subprocess +import unicodedata +import shutil +import json +import cPickle +import pickle +import time +import logging +import logging.handlers +try: + import xml.etree.cElementTree as ET +except ImportError: # pragma: no cover + import xml.etree.ElementTree as ET + + +#: Sentinel for properties that haven't been set yet (that might +#: correctly have the value ``None``) +UNSET = object() + +#################################################################### +# Standard system icons +#################################################################### + +# These icons are default OS X icons. They are super-high quality, and +# will be familiar to users. +# This library uses `ICON_ERROR` when a workflow dies in flames, so +# in my own workflows, I use `ICON_WARNING` for less fatal errors +# (e.g. bad user input, no results etc.) + +# The system icons are all in this directory. There are many more than +# are listed here + +ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources' + +ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns') +ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns') +ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns') +ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns') +ICON_COLOUR = ICON_COLOR # Queen's English, if you please +ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns') +# Shown when a workflow throws an error +ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns') +ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns') +ICON_FAVOURITE = ICON_FAVORITE +ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns') +ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns') +ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns') +ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns') +ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns') +ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns') +ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns') +ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns') +ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns') +ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns') +ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns') +ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns') +ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns') +ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns') + +#################################################################### +# non-ASCII to ASCII diacritic folding. +# Used by `fold_to_ascii` method +#################################################################### + +ASCII_REPLACEMENTS = { + 'À': 'A', + 'Á': 'A', + 'Â': 'A', + 'Ã': 'A', + 'Ä': 'A', + 'Å': 'A', + 'Æ': 'AE', + 'Ç': 'C', + 'È': 'E', + 'É': 'E', + 'Ê': 'E', + 'Ë': 'E', + 'Ì': 'I', + 'Í': 'I', + 'Î': 'I', + 'Ï': 'I', + 'Ð': 'D', + 'Ñ': 'N', + 'Ò': 'O', + 'Ó': 'O', + 'Ô': 'O', + 'Õ': 'O', + 'Ö': 'O', + 'Ø': 'O', + 'Ù': 'U', + 'Ú': 'U', + 'Û': 'U', + 'Ü': 'U', + 'Ý': 'Y', + 'Þ': 'Th', + 'ß': 'ss', + 'à': 'a', + 'á': 'a', + 'â': 'a', + 'ã': 'a', + 'ä': 'a', + 'å': 'a', + 'æ': 'ae', + 'ç': 'c', + 'è': 'e', + 'é': 'e', + 'ê': 'e', + 'ë': 'e', + 'ì': 'i', + 'í': 'i', + 'î': 'i', + 'ï': 'i', + 'ð': 'd', + 'ñ': 'n', + 'ò': 'o', + 'ó': 'o', + 'ô': 'o', + 'õ': 'o', + 'ö': 'o', + 'ø': 'o', + 'ù': 'u', + 'ú': 'u', + 'û': 'u', + 'ü': 'u', + 'ý': 'y', + 'þ': 'th', + 'ÿ': 'y', + 'Ł': 'L', + 'ł': 'l', + 'Ń': 'N', + 'ń': 'n', + 'Ņ': 'N', + 'ņ': 'n', + 'Ň': 'N', + 'ň': 'n', + 'Ŋ': 'ng', + 'ŋ': 'NG', + 'Ō': 'O', + 'ō': 'o', + 'Ŏ': 'O', + 'ŏ': 'o', + 'Ő': 'O', + 'ő': 'o', + 'Œ': 'OE', + 'œ': 'oe', + 'Ŕ': 'R', + 'ŕ': 'r', + 'Ŗ': 'R', + 'ŗ': 'r', + 'Ř': 'R', + 'ř': 'r', + 'Ś': 'S', + 'ś': 's', + 'Ŝ': 'S', + 'ŝ': 's', + 'Ş': 'S', + 'ş': 's', + 'Š': 'S', + 'š': 's', + 'Ţ': 'T', + 'ţ': 't', + 'Ť': 'T', + 'ť': 't', + 'Ŧ': 'T', + 'ŧ': 't', + 'Ũ': 'U', + 'ũ': 'u', + 'Ū': 'U', + 'ū': 'u', + 'Ŭ': 'U', + 'ŭ': 'u', + 'Ů': 'U', + 'ů': 'u', + 'Ű': 'U', + 'ű': 'u', + 'Ŵ': 'W', + 'ŵ': 'w', + 'Ŷ': 'Y', + 'ŷ': 'y', + 'Ÿ': 'Y', + 'Ź': 'Z', + 'ź': 'z', + 'Ż': 'Z', + 'ż': 'z', + 'Ž': 'Z', + 'ž': 'z', + 'ſ': 's', + 'Α': 'A', + 'Β': 'B', + 'Γ': 'G', + 'Δ': 'D', + 'Ε': 'E', + 'Ζ': 'Z', + 'Η': 'E', + 'Θ': 'Th', + 'Ι': 'I', + 'Κ': 'K', + 'Λ': 'L', + 'Μ': 'M', + 'Ν': 'N', + 'Ξ': 'Ks', + 'Ο': 'O', + 'Π': 'P', + 'Ρ': 'R', + 'Σ': 'S', + 'Τ': 'T', + 'Υ': 'U', + 'Φ': 'Ph', + 'Χ': 'Kh', + 'Ψ': 'Ps', + 'Ω': 'O', + 'α': 'a', + 'β': 'b', + 'γ': 'g', + 'δ': 'd', + 'ε': 'e', + 'ζ': 'z', + 'η': 'e', + 'θ': 'th', + 'ι': 'i', + 'κ': 'k', + 'λ': 'l', + 'μ': 'm', + 'ν': 'n', + 'ξ': 'x', + 'ο': 'o', + 'π': 'p', + 'ρ': 'r', + 'ς': 's', + 'σ': 's', + 'τ': 't', + 'υ': 'u', + 'φ': 'ph', + 'χ': 'kh', + 'ψ': 'ps', + 'ω': 'o', + 'А': 'A', + 'Б': 'B', + 'В': 'V', + 'Г': 'G', + 'Д': 'D', + 'Е': 'E', + 'Ж': 'Zh', + 'З': 'Z', + 'И': 'I', + 'Й': 'I', + 'К': 'K', + 'Л': 'L', + 'М': 'M', + 'Н': 'N', + 'О': 'O', + 'П': 'P', + 'Р': 'R', + 'С': 'S', + 'Т': 'T', + 'У': 'U', + 'Ф': 'F', + 'Х': 'Kh', + 'Ц': 'Ts', + 'Ч': 'Ch', + 'Ш': 'Sh', + 'Щ': 'Shch', + 'Ъ': "'", + 'Ы': 'Y', + 'Ь': "'", + 'Э': 'E', + 'Ю': 'Iu', + 'Я': 'Ia', + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'е': 'e', + 'ж': 'zh', + 'з': 'z', + 'и': 'i', + 'й': 'i', + 'к': 'k', + 'л': 'l', + 'м': 'm', + 'н': 'n', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'у': 'u', + 'ф': 'f', + 'х': 'kh', + 'ц': 'ts', + 'ч': 'ch', + 'ш': 'sh', + 'щ': 'shch', + 'ъ': "'", + 'ы': 'y', + 'ь': "'", + 'э': 'e', + 'ю': 'iu', + 'я': 'ia', + # 'ᴀ': '', + # 'ᴁ': '', + # 'ᴂ': '', + # 'ᴃ': '', + # 'ᴄ': '', + # 'ᴅ': '', + # 'ᴆ': '', + # 'ᴇ': '', + # 'ᴈ': '', + # 'ᴉ': '', + # 'ᴊ': '', + # 'ᴋ': '', + # 'ᴌ': '', + # 'ᴍ': '', + # 'ᴎ': '', + # 'ᴏ': '', + # 'ᴐ': '', + # 'ᴑ': '', + # 'ᴒ': '', + # 'ᴓ': '', + # 'ᴔ': '', + # 'ᴕ': '', + # 'ᴖ': '', + # 'ᴗ': '', + # 'ᴘ': '', + # 'ᴙ': '', + # 'ᴚ': '', + # 'ᴛ': '', + # 'ᴜ': '', + # 'ᴝ': '', + # 'ᴞ': '', + # 'ᴟ': '', + # 'ᴠ': '', + # 'ᴡ': '', + # 'ᴢ': '', + # 'ᴣ': '', + # 'ᴤ': '', + # 'ᴥ': '', + 'ᴦ': 'G', + 'ᴧ': 'L', + 'ᴨ': 'P', + 'ᴩ': 'R', + 'ᴪ': 'PS', + 'ẞ': 'Ss', + 'Ỳ': 'Y', + 'ỳ': 'y', + 'Ỵ': 'Y', + 'ỵ': 'y', + 'Ỹ': 'Y', + 'ỹ': 'y', +} + +#################################################################### +# Smart-to-dumb punctuation mapping +#################################################################### + +DUMB_PUNCTUATION = { + '‘': "'", + '’': "'", + '‚': "'", + '“': '"', + '”': '"', + '„': '"', + '–': '-', + '—': '-' +} + + +#################################################################### +# Used by `Workflow.filter` +#################################################################### + +# Anchor characters in a name +#: Characters that indicate the beginning of a "word" in CamelCase +INITIALS = string.ascii_uppercase + string.digits + +#: Split on non-letters, numbers +split_on_delimiters = re.compile('[^a-zA-Z0-9]').split + +# Match filter flags +#: Match items that start with ``query`` +MATCH_STARTSWITH = 1 +#: Match items whose capital letters start with ``query`` +MATCH_CAPITALS = 2 +#: Match items with a component "word" that matches ``query`` +MATCH_ATOM = 4 +#: Match items whose initials (based on atoms) start with ``query`` +MATCH_INITIALS_STARTSWITH = 8 +#: Match items whose initials (based on atoms) contain ``query`` +MATCH_INITIALS_CONTAIN = 16 +#: Combination of :const:`MATCH_INITIALS_STARTSWITH` and +#: :const:`MATCH_INITIALS_CONTAIN` +MATCH_INITIALS = 24 +#: Match items if ``query`` is a substring +MATCH_SUBSTRING = 32 +#: Match items if all characters in ``query`` appear in the item in order +MATCH_ALLCHARS = 64 +#: Combination of all other ``MATCH_*`` constants +MATCH_ALL = 127 + + +#################################################################### +# Used by `Workflow.check_update` +#################################################################### + +# Number of days to wait between checking for updates to the workflow +DEFAULT_UPDATE_FREQUENCY = 1 + + +#################################################################### +# Keychain access errors +#################################################################### + +class KeychainError(Exception): + """Raised by methods :meth:`Workflow.save_password`, + :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` + when ``security`` CLI app returns an unknown error code. + + """ + + +class PasswordNotFound(KeychainError): + """Raised by method :meth:`Workflow.get_password` when ``account`` + is unknown to the Keychain. + + """ + + +class PasswordExists(KeychainError): + """Raised when trying to overwrite an existing account password. + + You should never receive this error: it is used internally + by the :meth:`Workflow.save_password` method to know if it needs + to delete the old password first (a Keychain implementation detail). + + """ + + +#################################################################### +# Helper functions +#################################################################### + +def isascii(text): + """Test if ``text`` contains only ASCII characters + + :param text: text to test for ASCII-ness + :type text: ``unicode`` + :returns: ``True`` if ``text`` contains only ASCII characters + :rtype: ``Boolean`` + """ + + try: + text.encode('ascii') + except UnicodeEncodeError: + return False + return True + + +#################################################################### +# Implementation classes +#################################################################### + +class SerializerManager(object): + """Contains registered serializers. + + .. versionadded:: 1.8 + + A configured instance of this class is available at + ``workflow.manager``. + + Use :meth:`register()` to register new (or replace + existing) serializers, which you can specify by name when calling + :class:`Workflow` data storage methods. + + See :ref:`manual-serialization` and :ref:`manual-persistent-data` + for further information. + + """ + + def __init__(self): + 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`` or ``None`` if no such + serializer is registered + + :param name: Name of serializer to return + :type name: ``unicode`` or ``str`` + :returns: serializer object or ``None`` + + """ + + 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): + """Arguments the same as for :meth:`Workflow.add_item`. + + """ + + self.title = title + self.subtitle = subtitle + self.modifier_subtitles = modifier_subtitles or {} + self.arg = arg + self.autocomplete = autocomplete + self.valid = valid + self.uid = uid + self.icon = icon + self.icontype = icontype + self.type = type + self.largetext = largetext + self.copytext = copytext + + @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 + + 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): + + super(Settings, self).__init__() + self._filepath = filepath + self._nosave = False + if os.path.exists(self._filepath): + self._load() + elif defaults: + for key, val in defaults.items(): + self[key] = val + self.save() # save default settings + + def _load(self): + """Load cached settings from JSON file `self._filepath`""" + + self._nosave = True + with open(self._filepath, 'rb') as file_obj: + for key, value in json.load(file_obj, encoding='utf-8').items(): + self[key] = value + self._nosave = False + + 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 = {} + for key, value in self.items(): + data[key] = value + with open(self._filepath, 'wb') as file_obj: + json.dump(data, file_obj, sort_keys=True, indent=2, + encoding='utf-8') + + # dict methods + def __setitem__(self, key, value): + super(Settings, self).__setitem__(key, value) + self.save() + + def __delitem__(self, key): + super(Settings, self).__delitem__(key) + self.save() + + def update(self, *args, **kwargs): + """Override :class:`dict` method to save on update.""" + super(Settings, self).update(*args, **kwargs) + self.save() + + def setdefault(self, key, value=None): + """Override :class:`dict` method to save on update.""" + ret = super(Settings, self).setdefault(key, value) + self.save() + return ret + + +class Workflow(object): + """Create new :class:`Workflow` instance. + + :param default_settings: default workflow settings. If no settings file + exists, :class:`Workflow.settings` will be pre-populated with + ``default_settings``. + :type default_settings: :class:`dict` + :param update_settings: settings for updating your workflow from GitHub. + This must be a :class:`dict` that contains ``github_slug`` and + ``version`` keys. ``github_slug`` is of the form ``username/repo`` + and ``version`` **must** correspond to the tag of a release. + See :ref:`updates` for more information. + :type update_settings: :class:`dict` + :param input_encoding: encoding of command line arguments + :type input_encoding: :class:`unicode` + :param normalization: normalisation to apply to CLI args. + See :meth:`Workflow.decode` for more details. + :type normalization: :class:`unicode` + :param capture_args: capture and act on ``workflow:*`` arguments. See + :ref:`Magic arguments ` for details. + :type capture_args: :class:`Boolean` + :param libraries: sequence of paths to directories containing + libraries. These paths will be prepended to ``sys.path``. + :type libraries: :class:`tuple` or :class:`list` + :param help_url: URL to webpage where a user can ask for help with + the workflow, report bugs, etc. This could be the GitHub repo + or a page on AlfredForum.com. If your workflow throws an error, + this URL will be displayed in the log and Alfred's debugger. It can + also be opened directly in a web browser with the ``workflow:help`` + :ref:`magic argument `. + :type help_url: :class:`unicode` or :class:`str` + + """ + + # Which class to use to generate feedback items. You probably + # won't want to change this + item_class = Item + + def __init__(self, default_settings=None, update_settings=None, + input_encoding='utf-8', normalization='NFC', + capture_args=True, libraries=None, + help_url=None): + + self._default_settings = default_settings or {} + self._update_settings = update_settings or {} + self._input_encoding = input_encoding + self._normalizsation = normalization + self._capture_args = capture_args + self.help_url = help_url + self._workflowdir = None + self._settings_path = None + self._settings = None + self._bundleid = None + self._name = None + self._cache_serializer = 'cpickle' + self._data_serializer = 'cpickle' + # info.plist should be in the directory above this one + self._info_plist = self.workflowfile('info.plist') + 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_env(self): + """Alfred's environmental variables minus the ``alfred_`` prefix. + + .. versionadded:: 1.7 + + The variables Alfred 2.4+ exports are: + + ============================ ========================================= + Variable Description + ============================ ========================================= + alfred_preferences Path to Alfred.alfredpreferences + (where your workflows and settings are + stored). + alfred_preferences_localhash Machine-specific preferences are stored + in ``Alfred.alfredpreferences/preferences/local/`` + (see ``alfred_preferences`` above for + the path to ``Alfred.alfredpreferences``) + alfred_theme ID of selected theme + alfred_theme_background Background colour of selected theme in + format ``rgba(r,g,b,a)`` + alfred_theme_subtext Show result subtext. + ``0`` = Always, + ``1`` = Alternative actions only, + ``2`` = Selected result only, + ``3`` = Never + alfred_version Alfred version number, e.g. ``'2.4'`` + alfred_version_build Alfred build number, e.g. ``277`` + alfred_workflow_bundleid Bundle ID, e.g. + ``net.deanishe.alfred-mailto`` + alfred_workflow_cache Path to workflow's cache directory + alfred_workflow_data Path to workflow's data directory + alfred_workflow_name Name of current workflow + alfred_workflow_uid UID of workflow + ============================ ========================================= + + **Note:** all values are Unicode strings except ``version_build`` and + ``theme_subtext``, which are integers. + + :returns: ``dict`` of Alfred's environmental variables without the + ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. + + """ + + if self._alfred_env is not None: + return self._alfred_env + + data = {} + + for key in ( + 'alfred_preferences', + 'alfred_preferences_localhash', + 'alfred_theme', + 'alfred_theme_background', + 'alfred_theme_subtext', + 'alfred_version', + 'alfred_version_build', + 'alfred_workflow_bundleid', + 'alfred_workflow_cache', + 'alfred_workflow_data', + 'alfred_workflow_name', + 'alfred_workflow_uid'): + + value = os.getenv(key) + + if isinstance(value, str): + if key in ('alfred_version_build', 'alfred_theme_subtext'): + value = int(value) + else: + value = self.decode(value) + + data[key[7:]] = value + + self._alfred_env = data + + return self._alfred_env + + @property + def info(self): + """:class:`dict` of ``info.plist`` contents.""" + + if not self._info_loaded: + self._load_info_plist() + return self._info + + @property + def bundleid(self): + """Workflow bundle ID from environmental vars or ``info.plist``. + + :returns: bundle ID + :rtype: ``unicode`` + + """ + + if not self._bundleid: + if self.alfred_env.get('workflow_bundleid'): + self._bundleid = self.alfred_env.get('workflow_bundleid') + else: + self._bundleid = unicode(self.info['bundleid'], 'utf-8') + + return self._bundleid + + @property + def name(self): + """Workflow name from Alfred's environmental vars or ``info.plist``. + + :returns: workflow name + :rtype: ``unicode`` + + """ + + if not self._name: + if self.alfred_env.get('workflow_name'): + self._name = self.decode(self.alfred_env.get('workflow_name')) + else: + self._name = self.decode(self.info['name']) + + return self._name + + @property + def version(self): + """Return the version of the workflow + + .. versionadded:: 1.9.10 + + Get the version from the ``update_settings`` dict passed on + instantiation or the ``version`` file located in the workflow's + root directory. Return ``None`` if neither exist 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 + # First check `update_settings` + if self._update_settings: + version = self._update_settings.get('version') + + # Fallback to `version` file + if not version: + filepath = self.workflowfile('version') + + if os.path.exists(filepath): + with open(filepath, 'rb') as fileobj: + version = fileobj.read() + + 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-2/Workflow Data/`` + + :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 = os.path.join( + os.path.expanduser( + '~/Library/Caches/com.runningwithcrayons.Alfred-2/' + 'Workflow Data/'), + self.bundleid) + + return self._create(dirpath) + + @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 = os.path.join(os.path.expanduser( + '~/Library/Application Support/Alfred 2/Workflow Data/'), + self.bundleid) + + return self._create(dirpath) + + @property + def workflowdir(self): + """Path to workflow's root directory (where ``info.plist`` is). + + :returns: full path to workflow root directory + :rtype: ``unicode`` + + """ + + if not self._workflowdir: + # Try the working directory first, then the directory + # the library is in. CWD will be the workflow root if + # a workflow is being run in Alfred + candidates = [ + os.path.abspath(os.getcwdu()), + os.path.dirname(os.path.abspath(os.path.dirname(__file__)))] + + # climb the directory tree until we find `info.plist` + for dirpath in candidates: + + # Ensure directory path is Unicode + dirpath = self.decode(dirpath) + + while True: + if os.path.exists(os.path.join(dirpath, 'info.plist')): + self._workflowdir = dirpath + break + + elif dirpath == '/': + # no `info.plist` found + break + + # Check the parent directory + dirpath = os.path.dirname(dirpath) + + # No need to check other candidates + if self._workflowdir: + break + + if not self._workflowdir: + raise IOError("'info.plist' not found in directory tree") + + return self._workflowdir + + def cachefile(self, filename): + """Return full path to ``filename`` within your workflow's + :attr:`cache directory `. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within cache directory + :rtype: ``unicode`` + + """ + + return os.path.join(self.cachedir, filename) + + def datafile(self, filename): + """Return full path to ``filename`` within your workflow's + :attr:`data directory `. + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within data directory + :rtype: ``unicode`` + + """ + + return os.path.join(self.datadir, filename) + + def workflowfile(self, filename): + """Return full path to ``filename`` in workflow's root dir + (where ``info.plist`` is). + + :param filename: basename of file + :type filename: ``unicode`` + :returns: full path to file within data directory + :rtype: ``unicode`` + + """ + + return os.path.join(self.workflowdir, filename) + + @property + def logfile(self): + """Return path to logfile + + :returns: path to logfile within workflow's cache directory + :rtype: ``unicode`` + + """ + + return self.cachefile('%s.log' % self.bundleid) + + @property + def logger(self): + """Create and return a logger that logs to both console and + a log file. + + Use :meth:`open_log` to open the log file in Console. + + :returns: an initialised :class:`~logging.Logger` + + """ + + if self._logger: + return self._logger + + # Initialise new logger and optionally handlers + logger = logging.getLogger('workflow') + + if not len(logger.handlers): # Only add one set of handlers + logfile = logging.handlers.RotatingFileHandler( + self.logfile, + maxBytes=1024*1024, + backupCount=0) + + console = logging.StreamHandler() + + fmt = logging.Formatter( + '%(asctime)s %(filename)s:%(lineno)s' + ' %(levelname)-8s %(message)s', + datefmt='%H:%M:%S') + + logfile.setFormatter(fmt) + console.setFormatter(fmt) + + logger.addHandler(logfile) + logger.addHandler(console) + + logger.setLevel(logging.DEBUG) + self._logger = logger + + return self._logger + + @logger.setter + def logger(self, logger): + """Set a custom logger. + + :param logger: The logger to use + :type logger: `~logging.Logger` instance + + """ + + self._logger = logger + + @property + def settings_path(self): + """Path to settings file within workflow's data directory. + + :returns: path to ``settings.json`` file + :rtype: ``unicode`` + + """ + + if not self._settings_path: + self._settings_path = self.datafile('settings.json') + return self._settings_path + + @property + def settings(self): + """Return a dictionary subclass that saves itself when changed. + + See :ref:`manual-settings` in the :ref:`user-manual` for more + information on how to use :attr:`settings` and **important + limitations** on what it can do. + + :returns: :class:`~workflow.workflow.Settings` instance + initialised from the data in JSON file at + :attr:`settings_path` or if that doesn't exist, with the + ``default_settings`` :class:`dict` passed to + :class:`Workflow` on instantiation. + :rtype: :class:`~workflow.workflow.Settings` instance + + """ + + if not self._settings: + self.logger.debug('Reading settings from `{0}` ...'.format( + self.settings_path)) + self._settings = Settings(self.settings_path, + self._default_settings) + return self._settings + + @property + def cache_serializer(self): + """Name of default cache serializer. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`cache_data()` and + :meth:`cached_data()` + + See :class:`SerializerManager` for details. + + :returns: serializer name + :rtype: ``unicode`` + + """ + + return self._cache_serializer + + @cache_serializer.setter + def cache_serializer(self, serializer_name): + """Set the default cache serialization format. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`cache_data()` and + :meth:`cached_data()` + + The specified serializer must already by registered with the + :class:`SerializerManager` at `~workflow.workflow.manager`, + otherwise a :class:`ValueError` will be raised. + + :param serializer_name: Name of default serializer to use. + :type serializer_name: + + """ + + if manager.serializer(serializer_name) is None: + raise ValueError( + 'Unknown serializer : `{0}`. Register your serializer ' + 'with `manager` first.'.format(serializer_name)) + + self.logger.debug( + 'default cache serializer set to `{0}`'.format(serializer_name)) + + self._cache_serializer = serializer_name + + @property + def data_serializer(self): + """Name of default data serializer. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`store_data()` and + :meth:`stored_data()` + + See :class:`SerializerManager` for details. + + :returns: serializer name + :rtype: ``unicode`` + + """ + + return self._data_serializer + + @data_serializer.setter + def data_serializer(self, serializer_name): + """Set the default cache serialization format. + + .. versionadded:: 1.8 + + This serializer is used by :meth:`store_data()` and + :meth:`stored_data()` + + The specified serializer must already by registered with the + :class:`SerializerManager` at `~workflow.workflow.manager`, + otherwise a :class:`ValueError` will be raised. + + :param serializer_name: Name of serializer to use by default. + + """ + + if manager.serializer(serializer_name) is None: + raise ValueError( + 'Unknown serializer : `{0}`. Register your serializer ' + 'with `manager` first.'.format(serializer_name)) + + self.logger.debug( + 'default data serializer set to `{0}`'.format(serializer_name)) + + self._data_serializer = serializer_name + + def stored_data(self, name): + """Retrieve data from data directory. Returns ``None`` if there + are no data stored. + + .. versionadded:: 1.8 + + :param name: name of datastore + + """ + + metadata_path = self.datafile('.{0}.alfred-workflow'.format(name)) + + if not os.path.exists(metadata_path): + self.logger.debug('No data stored for `{0}`'.format(name)) + return None + + with open(metadata_path, 'rb') as file_obj: + serializer_name = file_obj.read().strip() + + serializer = manager.serializer(serializer_name) + + if serializer is None: + raise ValueError( + 'Unknown serializer `{0}`. Register a corresponding serializer ' + 'with `manager.register()` to load this data.'.format( + serializer_name)) + + self.logger.debug('Data `{0}` stored in `{1}` format'.format( + name, serializer_name)) + + filename = '{0}.{1}'.format(name, serializer_name) + data_path = self.datafile(filename) + + if not os.path.exists(data_path): + self.logger.debug('No data stored for `{0}`'.format(name)) + if os.path.exists(metadata_path): + os.unlink(metadata_path) + + return None + + with open(data_path, 'rb') as file_obj: + data = serializer.load(file_obj) + + self.logger.debug('Stored data loaded from : {0}'.format(data_path)) + + return data + + def store_data(self, name, data, serializer=None): + """Save data to data directory. + + .. versionadded:: 1.8 + + If ``data`` is ``None``, the datastore will be deleted. + + :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`` + + """ + + 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 + for path in (metadata_path, data_path): + if os.path.exists(path): + os.unlink(path) + self.logger.debug('Deleted data file : {0}'.format(path)) + + return + + # Save file extension + with open(metadata_path, 'wb') as file_obj: + file_obj.write(serializer_name) + + with open(data_path, 'wb') as file_obj: + serializer.dump(data, file_obj) + + self.logger.debug('Stored data saved at : {0}'.format(data_path)) + + def cached_data(self, name, data_func=None, max_age=60): + """Retrieve data from cache or re-generate and re-cache data if + stale/non-existant. If ``max_age`` is 0, return cached data no + matter how old. + + :param name: name of datastore + :param data_func: function to (re-)generate data. + :type data_func: ``callable`` + :param max_age: maximum age of cached data in seconds + :type max_age: ``int`` + :returns: cached data, return value of ``data_func`` or ``None`` + if ``data_func`` is not set + + """ + + serializer = manager.serializer(self.cache_serializer) + + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + age = self.cached_data_age(name) + + if (age < max_age or max_age == 0) and os.path.exists(cache_path): + + with open(cache_path, 'rb') as file_obj: + self.logger.debug('Loading cached data from : %s', + cache_path) + return serializer.load(file_obj) + + if not data_func: + return None + + data = data_func() + self.cache_data(name, data) + + return data + + def cache_data(self, name, data): + """Save ``data`` to cache under ``name``. + + If ``data`` is ``None``, the corresponding cache file will be + deleted. + + :param name: name of datastore + :param data: data to store. This may be any object supported by + the cache serializer + + """ + + serializer = manager.serializer(self.cache_serializer) + + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + + if data is None: + if os.path.exists(cache_path): + os.unlink(cache_path) + self.logger.debug('Deleted cache file : %s', cache_path) + return + + with open(cache_path, 'wb') as file_obj: + serializer.dump(data, file_obj) + + self.logger.debug('Cached data saved at : %s', cache_path) + + def cached_data_fresh(self, name, max_age): + """Is data cached at `name` less than `max_age` 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 of data cached at `name` in seconds or 0 if + cache doesn't exist + + :param name: name of datastore + :type name: ``unicode`` + :returns: age of datastore in seconds + :rtype: ``int`` + + """ + + cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer)) + + if not os.path.exists(cache_path): + return 0 + + return time.time() - os.stat(cache_path).st_mtime + + def filter(self, query, items, key=lambda x: x, ascending=False, + include_score=False, min_score=0, max_results=0, + match_on=MATCH_ALL, fold_diacritics=True): + """Fuzzy search filter. Returns list of ``items`` that match ``query``. + + ``query`` is case-insensitive. Any item that does not contain the + entirety of ``query`` is rejected. + + .. warning:: + + If ``query`` is an empty string or contains only whitespace, + a :class:`ValueError` will be raised. + + :param query: query to test items against + :type query: ``unicode`` + :param items: iterable of items to test + :type items: ``list`` or ``tuple`` + :param key: function to get comparison key from ``items``. + Must return a ``unicode`` string. The default simply returns + the item. + :type key: ``callable`` + :param ascending: set to ``True`` to get worst matches first + :type ascending: ``Boolean`` + :param include_score: Useful for debugging the scoring algorithm. + If ``True``, results will be a list of tuples + ``(item, score, rule)``. + :type include_score: ``Boolean`` + :param min_score: If non-zero, ignore results with a score lower + than this. + :type min_score: ``int`` + :param max_results: If non-zero, prune results list to this length. + :type max_results: ``int`` + :param match_on: Filter option flags. Bitwise-combined list of + ``MATCH_*`` constants (see below). + :type match_on: ``int`` + :param fold_diacritics: Convert search keys to ASCII-only + characters if ``query`` only contains ASCII characters. + :type fold_diacritics: ``Boolean`` + :returns: list of ``items`` matching ``query`` or list of + ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. + ``rule`` is the ``MATCH_*`` rule that matched the item. + :rtype: ``list`` + + **Matching rules** + + By default, :meth:`filter` uses all of the following flags (i.e. + :const:`MATCH_ALL`). The tests are always run in the given order: + + 1. :const:`MATCH_STARTSWITH` : Item search key startswith + ``query``(case-insensitive). + 2. :const:`MATCH_CAPITALS` : The list of capital letters in item + search key starts with ``query`` (``query`` may be + lower-case). E.g., ``of`` would match ``OmniFocus``, + ``gc`` would match ``Google Chrome`` + 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on + non-word characters (.,-,' etc.). Matches if ``query`` is + one of these atoms (case-insensitive). + 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first + characters of the above-described "atoms" (case-insensitive). + 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of + the above-described initials. + 6. :const:`MATCH_INITIALS` : Combination of (4) and (5). + 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring + of item search key (case-insensitive). + 8. :const:`MATCH_ALLCHARS` : Matches if all characters in + ``query`` appear in item search key in the same order + (case-insensitive). + 9. :const:`MATCH_ALL` : Combination of all the above. + + + :const:`MATCH_ALLCHARS` is considerably slower than the other + tests and provides much less accurate results. + + **Examples:** + + To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst + matches and is expensive to run), use + ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. + + To match only on capitals, use ``match_on=MATCH_CAPITALS``. + + To match only on startswith and substring, use + ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. + + **Diacritic folding** + + .. versionadded:: 1.3 + + If ``fold_diacritics`` is ``True`` (the default), and ``query`` + contains only ASCII characters, non-ASCII characters in search keys + will be converted to ASCII equivalents (e.g. **ü** -> **u**, + **ß** -> **ss**, **é** -> **e**). + + See :const:`ASCII_REPLACEMENTS` for all replacements. + + If ``query`` contains non-ASCII characters, search keys will not be + altered. + + """ + + if not query: + raise ValueError('Empty `query`') + + # Remove preceding/trailing spaces + query = query.strip() + + if not query: + raise ValueError('`query` contains only whitespace') + + # Use user override if there is one + fold_diacritics = self.settings.get('__workflow_diacritic_folding', + fold_diacritics) + + results = [] + + for item in items: + skip = False + score = 0 + words = [s.strip() for s in query.split(' ')] + value = key(item).strip() + if value == '': + continue + for word in words: + if word == '': + continue + s, rule = self._filter_item(value, word, match_on, + fold_diacritics) + + if not s: # Skip items that don't match part of the query + skip = True + score += s + + if skip: + continue + + if score: + # use "reversed" `score` (i.e. highest becomes lowest) and + # `value` as sort key. This means items with the same score + # will be sorted in alphabetical not reverse alphabetical order + results.append(((100.0 / score, value.lower(), score), + (item, score, rule))) + + # sort on keys, then discard the keys + results.sort(reverse=ascending) + results = [t[1] for t in results] + + if min_score: + results = [r for r in results if r[1] > min_score] + + if max_results and len(results) > max_results: + results = results[:max_results] + + # return list of ``(item, score, rule)`` + if include_score: + return results + # just return list of items + return [t[0] for t in results] + + def _filter_item(self, value, query, match_on, fold_diacritics): + """Filter ``value`` against ``query`` using rules ``match_on`` + + :returns: ``(score, rule)`` + + """ + + query = query.lower() + + if not isascii(query): + fold_diacritics = False + + if fold_diacritics: + value = self.fold_to_ascii(value) + + # pre-filter any items that do not contain all characters + # of ``query`` to save on running several more expensive tests + if not set(query) <= set(value.lower()): + + return (0, None) + + # item starts with query + if match_on & MATCH_STARTSWITH and value.lower().startswith(query): + score = 100.0 - (len(value) / len(query)) + + return (score, MATCH_STARTSWITH) + + # query matches capitalised letters in item, + # e.g. of = OmniFocus + if match_on & MATCH_CAPITALS: + initials = ''.join([c for c in value if c in INITIALS]) + if initials.lower().startswith(query): + score = 100.0 - (len(initials) / len(query)) + + return (score, MATCH_CAPITALS) + + # split the item into "atoms", i.e. words separated by + # spaces or other non-word characters + if (match_on & MATCH_ATOM or + match_on & MATCH_INITIALS_CONTAIN or + match_on & MATCH_INITIALS_STARTSWITH): + atoms = [s.lower() for s in split_on_delimiters(value)] + # print('atoms : %s --> %s' % (value, atoms)) + # initials of the atoms + initials = ''.join([s[0] for s in atoms if s]) + + if match_on & MATCH_ATOM: + # is `query` one of the atoms in item? + # similar to substring, but scores more highly, as it's + # a word within the item + if query in atoms: + score = 100.0 - (len(value) / len(query)) + + return (score, MATCH_ATOM) + + # `query` matches start (or all) of the initials of the + # atoms, e.g. ``himym`` matches "How I Met Your Mother" + # *and* "how i met your mother" (the ``capitals`` rule only + # matches the former) + if (match_on & MATCH_INITIALS_STARTSWITH and + initials.startswith(query)): + score = 100.0 - (len(initials) / len(query)) + + return (score, MATCH_INITIALS_STARTSWITH) + + # `query` is a substring of initials, e.g. ``doh`` matches + # "The Dukes of Hazzard" + elif (match_on & MATCH_INITIALS_CONTAIN and + query in initials): + score = 95.0 - (len(initials) / len(query)) + + return (score, MATCH_INITIALS_CONTAIN) + + # `query` is a substring of item + if match_on & MATCH_SUBSTRING and query in value.lower(): + score = 90.0 - (len(value) / len(query)) + + return (score, MATCH_SUBSTRING) + + # finally, assign a score based on how close together the + # characters in `query` are in item. + if match_on & MATCH_ALLCHARS: + search = self._search_for_query(query) + match = search(value) + if match: + score = 100.0 / ((1 + match.start()) * + (match.end() - match.start() + 1)) + + return (score, MATCH_ALLCHARS) + + # Nothing matched + return (0, None) + + def _search_for_query(self, query): + if query in self._search_pattern_cache: + return self._search_pattern_cache[query] + + # Build pattern: include all characters + pattern = [] + for c in query: + # pattern.append('[^{0}]*{0}'.format(re.escape(c))) + pattern.append('.*?{0}'.format(re.escape(c))) + pattern = ''.join(pattern) + search = re.compile(pattern, re.IGNORECASE).search + + self._search_pattern_cache[query] = search + return search + + def run(self, func): + """Call ``func`` to run your workflow + + :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` + instance) as first argument. + + ``func`` will be called with :class:`Workflow` instance as first + argument. + + ``func`` should be the main entry point to your workflow. + + Any exceptions raised will be logged and an error message will be + output to Alfred. + + """ + + start = time.time() + + # Call workflow's entry function/method within a try-except block + # to catch any errors and display an error message in Alfred + try: + if self.version: + self.logger.debug('Workflow version : {0}'.format(self.version)) + + # Run update check if configured for self-updates. + # This call has to go in the `run` try-except block, as it will + # initialise `self.settings`, which will raise an exception + # if `settings.json` isn't valid. + + if self._update_settings: + self.check_update() + + # Run workflow's entry function/method + func(self) + + # Set last version run to current version after a successful + # run + self.set_last_version() + + except Exception as err: + self.logger.exception(err) + if self.help_url: + self.logger.info( + 'For assistance, see: {0}'.format(self.help_url)) + if not sys.stdout.isatty(): # Show error in Alfred + self._items = [] + if self._name: + name = self._name + elif self._bundleid: + name = self._bundleid + else: # pragma: no cover + name = os.path.dirname(__file__) + self.add_item("Error in workflow '%s'" % name, unicode(err), + icon=ICON_ERROR) + self.send_feedback() + return 1 + finally: + self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format( + time.time() - start)) + return 0 + + # Alfred feedback methods ------------------------------------------ + + def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, + autocomplete=None, valid=False, uid=None, icon=None, + icontype=None, type=None, largetext=None, copytext=None): + """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`` + :returns: :class:`Item` instance + + See the :ref:`script-filter-results` section of the documentation + for a detailed description of what the various parameters do and how + they interact with one another. + + See :ref:`icons` for a list of the supported system icons. + + .. note:: + + Although this method returns an :class:`Item` instance, you don't + need to hold onto it or worry about it. All generated :class:`Item` + instances are also collected internally and sent to Alfred when + :meth:`send_feedback` is called. + + The generated :class:`Item` is only returned in case you want to + edit it or do something with it other than send it to Alfred. + + """ + + item = self.item_class(title, subtitle, modifier_subtitles, arg, + autocomplete, valid, uid, icon, icontype, type, + largetext, copytext) + self._items.append(item) + return item + + def send_feedback(self): + """Print stored items to console/Alfred as XML.""" + root = ET.Element('items') + for item in self._items: + root.append(item.elem) + sys.stdout.write('\n') + sys.stdout.write(ET.tostring(root).encode('utf-8')) + sys.stdout.flush() + + #################################################################### + # Updating methods + #################################################################### + + @property + def first_run(self): + """Return ``True`` if it's the first time this version has run. + + .. versionadded:: 1.9.10 + + Raises a :class:`ValueError` if :attr:`version` isn't set. + + """ + + if not self.version: + raise ValueError('No workflow version set') + + if not self.last_version_run: + return True + + return self.version != self.last_version_run + + @property + def last_version_run(self): + """Return version of last version to run (or ``None``) + + .. versionadded:: 1.9.10 + + :returns: :class:`~workflow.update.Version` instance + or ``None`` + + """ + + if self._last_version_run is UNSET: + + version = self.settings.get('__workflow_last_version') + if version: + from update import Version + version = Version(version) + + self._last_version_run = version + + self.logger.debug('Last run version : {0}'.format( + self._last_version_run)) + + return self._last_version_run + + def set_last_version(self, version=None): + """Set :attr:`last_version_run` to current version + + .. versionadded:: 1.9.10 + + :param version: version to store (default is current version) + :type version: :class:`~workflow.update.Version` instance + or ``unicode`` + :returns: ``True`` if version is saved, else ``False`` + + """ + + if not version: + if not self.version: + self.logger.warning( + "Can't save last version: workflow has no version") + return False + + version = self.version + + if isinstance(version, basestring): + from update import Version + version = Version(version) + + self.settings['__workflow_last_version'] = str(version) + + self.logger.debug('Set last run version : {0}'.format(version)) + + return True + + @property + def update_available(self): + """Is an update available? + + .. versionadded:: 1.9 + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :returns: ``True`` if an update is available, else ``False`` + + """ + + update_data = self.cached_data('__workflow_update_status', max_age=0) + self.logger.debug('update_data : {0}'.format(update_data)) + + if not update_data or not update_data.get('available'): + return False + + return update_data['available'] + + def check_update(self, force=False): + """Call update script if it's time to check for a new release + + .. versionadded:: 1.9 + + The update script will be run in the background, so it won't + interfere in the execution of your workflow. + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :param force: Force update check + :type force: ``Boolean`` + + """ + + frequency = self._update_settings.get('frequency', + DEFAULT_UPDATE_FREQUENCY) + + if not force and not self.settings.get('__workflow_autoupdate', True): + self.logger.debug('Auto update turned off by user') + return + + # Check for new version if it's time + if (force or not self.cached_data_fresh( + '__workflow_update_status', frequency * 86400)): + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + from background import run_in_background + + # update.py is adjacent to this file + update_script = os.path.join(os.path.dirname(__file__), + b'update.py') + + cmd = ['/usr/bin/python', update_script, 'check', github_slug, + version] + + self.logger.info('Checking for update ...') + + run_in_background('__workflow_update_check', cmd) + + else: + self.logger.debug('Update check not due') + + def start_update(self): + """Check for update and download and install new workflow file + + .. versionadded:: 1.9 + + See :ref:`manual-updates` in the :ref:`user-manual` for detailed + information on how to enable your workflow to update itself. + + :returns: ``True`` if an update is available and will be + installed, else ``False`` + + """ + + import update + + github_slug = self._update_settings['github_slug'] + # version = self._update_settings['version'] + version = str(self.version) + + if not update.check_update(github_slug, version): + 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] + + 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``. Raises + :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 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['update'] = do_update + + # Help + def do_help(): + if self.help_url: + self.open_help() + return 'Opening workflow help URL in browser' + else: + return 'Workflow has no help URL' + + def show_version(): + if self.version: + return 'Version: {0}'.format(self.version) + else: + return 'This workflow has no version number' + + def list_magic(): + """Display all available magic args in Alfred""" + isatty = sys.stderr.isatty() + for name in sorted(self.magic_arguments.keys()): + if name == 'magic': + continue + arg = '{0}{1}'.format(self.magic_prefix, name) + self.logger.debug(arg) + + if not isatty: + self.add_item(arg, icon=ICON_INFO) + + if not isatty: + self.send_feedback() + + self.magic_arguments['help'] = do_help + self.magic_arguments['magic'] = list_magic + self.magic_arguments['version'] = show_version + + def clear_cache(self, filter_func=lambda f: True): + """Delete all files in workflow's :attr:`cachedir`. + + :param filter_func: Callable to determine whether a file should be + deleted or not. ``filter_func`` is called with the filename + of each file in the data directory. If it returns ``True``, + the file will be deleted. + By default, *all* files will be deleted. + :type filter_func: ``callable`` + """ + self._delete_directory_contents(self.cachedir, filter_func) + + def clear_data(self, filter_func=lambda f: True): + """Delete all files in workflow's :attr:`datadir`. + + :param filter_func: Callable to determine whether a file should be + deleted or not. ``filter_func`` is called with the filename + of each file in the data directory. If it returns ``True``, + the file will be deleted. + By default, *all* files will be deleted. + :type filter_func: ``callable`` + """ + self._delete_directory_contents(self.datadir, filter_func) + + def clear_settings(self): + """Delete workflow's :attr:`settings_path`.""" + if os.path.exists(self.settings_path): + os.unlink(self.settings_path) + self.logger.debug('Deleted : %r', self.settings_path) + + def reset(self): + """Delete :attr:`settings `, :attr:`cache ` + and :attr:`data ` + + """ + + self.clear_cache() + self.clear_data() + self.clear_settings() + + def open_log(self): + """Open workflows :attr:`logfile` in standard + application (usually Console.app). + + """ + + subprocess.call(['open', self.logfile]) + + def open_cachedir(self): + """Open the workflow's :attr:`cachedir` in Finder.""" + subprocess.call(['open', self.cachedir]) + + def open_datadir(self): + """Open the workflow's :attr:`datadir` in Finder.""" + subprocess.call(['open', self.datadir]) + + def open_workflowdir(self): + """Open the workflow's :attr:`workflowdir` in Finder.""" + subprocess.call(['open', self.workflowdir]) + + def open_terminal(self): + """Open a Terminal window at workflow's :attr:`workflowdir`.""" + + subprocess.call(['open', '-a', 'Terminal', + self.workflowdir]) + + def open_help(self): + """Open :attr:`help_url` in default browser""" + subprocess.call(['open', self.help_url]) + + return 'Opening workflow help URL in browser' + + #################################################################### + # Helper methods + #################################################################### + + def decode(self, text, encoding=None, normalization=None): + """Return ``text`` as normalised unicode. + + If ``encoding`` and/or ``normalization`` is ``None``, the + ``input_encoding``and ``normalization`` parameters passed to + :class:`Workflow` are used. + + :param text: string + :type text: encoded or Unicode string. If ``text`` is already a + Unicode string, it will only be normalised. + :param encoding: The text encoding to use to decode ``text`` to + Unicode. + :type encoding: ``unicode`` or ``None`` + :param normalization: The nomalisation form to apply to ``text``. + :type normalization: ``unicode`` or ``None`` + :returns: decoded and normalised ``unicode`` + + :class:`Workflow` uses "NFC" normalisation by default. This is the + standard for Python and will work well with data from the web (via + :mod:`~workflow.web` or :mod:`json`). + + OS X, on the other hand, uses "NFD" normalisation (nearly), so data + coming from the system (e.g. via :mod:`subprocess` or + :func:`os.listdir`/:mod:`os.path`) may not match. You should either + normalise this data, too, or change the default normalisation used by + :class:`Workflow`. + + """ + + encoding = encoding or self._input_encoding + normalization = normalization or self._normalizsation + if not isinstance(text, unicode): + text = unicode(text, encoding) + return unicodedata.normalize(normalization, text) + + def fold_to_ascii(self, text): + """Convert non-ASCII characters to closest ASCII equivalent. + + .. versionadded:: 1.3 + + .. note:: This only works for a subset of European languages. + + :param text: text to convert + :type text: ``unicode`` + :returns: text containing only ASCII characters + :rtype: ``unicode`` + + """ + if isascii(text): + return text + text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text]) + return unicode(unicodedata.normalize('NFKD', + text).encode('ascii', 'ignore')) + + def dumbify_punctuation(self, text): + """Convert non-ASCII punctuation to closest ASCII equivalent. + + This method replaces "smart" quotes and n- or m-dashes with their + workaday ASCII equivalents. This method is currently not used + internally, but exists as a helper method for workflow authors. + + .. versionadded: 1.9.7 + + :param text: text to convert + :type text: ``unicode`` + :returns: text with only ASCII punctuation + :rtype: ``unicode`` + + """ + if isascii(text): + return text + + text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text]) + return text + + def _delete_directory_contents(self, dirpath, filter_func): + """Delete all files in a directory + + :param dirpath: path to directory to clear + :type dirpath: ``unicode`` or ``str`` + :param filter_func function to determine whether a file shall be + deleted or not. + :type filter_func ``callable`` + """ + + if os.path.exists(dirpath): + for filename in os.listdir(dirpath): + if not filter_func(filename): + continue + path = os.path.join(dirpath, filename) + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.unlink(path) + self.logger.debug('Deleted : %r', path) + + def _load_info_plist(self): + """Load workflow info from ``info.plist`` + + """ + + self._info = plistlib.readPlist(self._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 the ``security`` CLI app 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) + retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8') + if retcode == 44: # password does not exist + raise PasswordNotFound() + elif retcode == 45: # password already exists + raise PasswordExists() + elif retcode > 0: + err = KeychainError('Unknown Keychain error : %s' % output) + err.retcode = retcode + raise err + return output diff --git a/youdao.py b/youdao.py new file mode 100755 index 0000000..dd0ef61 --- /dev/null +++ b/youdao.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +import re +import urllib +from workflow import Workflow, ICON_WEB, web +import sys + +reload(sys) +sys.setdefaultencoding('utf8') + +apikey = 1331254833 +keyfrom = 'whyliam' +ICON_DEFAULT = 'icon.png' +ICON_BASIC = 'icon_basic.png' +ICON_WEB = 'icon_web.png' + +def get_web_data(query): + query = urllib.quote(query) + url = 'http://fanyi.youdao.com/openapi.do?keyfrom=' + keyfrom + \ + '&key=' + str(apikey) + \ + '&type=data&doctype=json&version=1.1&q=' + query + return web.get(url).json() + + +def main(wf): + + query = wf.args[0].strip() + + if not query: + wf.add_item('有道翻译') + wf.send_feedback() + return 0 + + s = get_web_data(query) + # '翻译结果' + title = s["translation"] + title = ''.join(title).encode("UTF-8") + url = u'http://dict.youdao.com/search?q=' + query + + if title != query: + subtitle = '翻译结果' + wf.add_item( + title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_DEFAULT) + + # '简明释意' + if u'basic' in s.keys(): + for be in range(len(s["basic"]["explains"])): + title = s["basic"]["explains"][be] + subtitle = '简明释意' + wf.add_item( + title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_BASIC) + + # '网络翻译' + if u'web' in s.keys(): + for w in range(len(s["web"])): + title = s["web"][w]["key"] + title = ''.join(title).encode("UTF-8") + subtitle = '网络翻译: ' + s["web"][w]["key"] + wf.add_item( + title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_WEB) + + else: + title = '有道也翻译不出来了' + subtitle = '尝试一下去网站搜索' + wf.add_item( + title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_DEFAULT) + + wf.send_feedback() + +if __name__ == '__main__': + wf = Workflow() + sys.exit(wf.run(main))