From 31e47b8646cb276c37fc0d75bd6ed9d9fba979b8 Mon Sep 17 00:00:00 2001 From: Fabio Niephaus Date: Sun, 14 Jan 2018 23:24:51 +0100 Subject: [PATCH] Update Alfred-Workflow dependency --- Makefile | 2 +- src/version | 2 +- src/workflow/__init__.py | 2 +- src/workflow/background.py | 161 ++++++++----- src/workflow/notify.py | 112 ++++----- src/workflow/update.py | 82 ++++--- src/workflow/util.py | 457 +++++++++++++++++++++++++++++++++++++ src/workflow/version | 2 +- src/workflow/web.py | 63 ++--- src/workflow/workflow.py | 403 +++++++++----------------------- src/workflow/workflow3.py | 304 +++++++++++++++++------- 11 files changed, 1012 insertions(+), 578 deletions(-) create mode 100644 src/workflow/util.py diff --git a/Makefile b/Makefile index a277910..212aa77 100644 --- a/Makefile +++ b/Makefile @@ -9,4 +9,4 @@ clean: update-lib: /usr/bin/python -m pip install --target src --upgrade Alfred-Workflow - rm -rf src/Alfred_Workflow-*.dist-info/ \ No newline at end of file + rm -rf src/Alfred_Workflow-*.*info/ diff --git a/src/version b/src/version index f467a21..5593dc1 100644 --- a/src/version +++ b/src/version @@ -1 +1 @@ -v6.7 +v7.0 \ No newline at end of file diff --git a/src/workflow/__init__.py b/src/workflow/__init__.py index 3069e51..2c4f8c0 100644 --- a/src/workflow/__init__.py +++ b/src/workflow/__init__.py @@ -64,7 +64,7 @@ __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() __author__ = 'Dean Jackson' __licence__ = 'MIT' -__copyright__ = 'Copyright 2014 Dean Jackson' +__copyright__ = 'Copyright 2014-2017 Dean Jackson' __all__ = [ 'Variables', diff --git a/src/workflow/background.py b/src/workflow/background.py index 7bda3f5..cd5400b 100644 --- a/src/workflow/background.py +++ b/src/workflow/background.py @@ -8,10 +8,18 @@ # Created on 2014-04-06 # -"""Run background tasks.""" +""" +This module provides an API to run commands in background processes. +Combine with the :ref:`caching API ` to work from cached data +while you fetch fresh data in the background. + +See :ref:`the User Manual ` for more information +and examples. +""" from __future__ import print_function, unicode_literals +import signal import sys import os import subprocess @@ -31,6 +39,10 @@ def wf(): return _wf +def _log(): + return wf().logger + + def _arg_cache(name): """Return path to pickle cache file for arguments. @@ -40,7 +52,7 @@ def _arg_cache(name): :rtype: ``unicode`` filepath """ - return wf().cachefile('{0}.argcache'.format(name)) + return wf().cachefile(name + '.argcache') def _pid_file(name): @@ -52,7 +64,7 @@ def _pid_file(name): :rtype: ``unicode`` filepath """ - return wf().cachefile('{0}.pid'.format(name)) + return wf().cachefile(name + '.pid') def _process_exists(pid): @@ -71,35 +83,52 @@ def _process_exists(pid): return True -def is_running(name): - """Test whether task is running under ``name``. +def _job_pid(name): + """Get PID of job or `None` if job does not exist. - :param name: name of task - :type name: ``unicode`` - :returns: ``True`` if task with name ``name`` is running, else ``False`` - :rtype: ``Boolean`` + Args: + name (str): Name of job. + Returns: + int: PID of job process (or `None` if job doesn't exist). """ pidfile = _pid_file(name) if not os.path.exists(pidfile): - return False + return - with open(pidfile, 'rb') as file_obj: - pid = int(file_obj.read().strip()) + with open(pidfile, 'rb') as fp: + pid = int(fp.read()) - if _process_exists(pid): - return True + if _process_exists(pid): + return pid - elif os.path.exists(pidfile): + try: os.unlink(pidfile) + except Exception: # pragma: no cover + pass + + +def is_running(name): + """Test whether task ``name`` is currently running. + + :param name: name of task + :type name: unicode + :returns: ``True`` if task with name ``name`` is running, else ``False`` + :rtype: bool + + """ + if _job_pid(name) is not None: + return True return False -def _background(stdin='/dev/null', stdout='/dev/null', +def _background(pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): # pragma: no cover """Fork the current process into a background daemon. + :param pidfile: file to write PID of daemon process to. + :type pidfile: filepath :param stdin: where to read input :type stdin: filepath :param stdout: where to write stdout output @@ -108,25 +137,31 @@ def _background(stdin='/dev/null', stdout='/dev/null', :type stderr: filepath """ - def _fork_and_exit_parent(errmsg): + def _fork_and_exit_parent(errmsg, wait=False, write=False): try: pid = os.fork() if pid > 0: + if write: # write PID of child process to `pidfile` + tmp = pidfile + '.tmp' + with open(tmp, 'wb') as fp: + fp.write(str(pid)) + os.rename(tmp, pidfile) + if wait: # wait for child process to exit + os.waitpid(pid, 0) os._exit(0) except OSError as err: - wf().logger.critical('%s: (%d) %s', errmsg, err.errno, - err.strerror) + _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) raise err - # Do first fork. - _fork_and_exit_parent('fork #1 failed') + # Do first fork and wait for second fork to finish. + _fork_and_exit_parent('fork #1 failed', wait=True) # Decouple from parent environment. os.chdir(wf().workflowdir) os.setsid() - # Do second fork. - _fork_and_exit_parent('fork #2 failed') + # Do second fork and write PID to pidfile. + _fork_and_exit_parent('fork #2 failed', write=True) # Now I am a daemon! # Redirect standard file descriptors. @@ -141,15 +176,35 @@ def _fork_and_exit_parent(errmsg): os.dup2(se.fileno(), sys.stderr.fileno()) +def kill(name, sig=signal.SIGTERM): + """Send a signal to job ``name`` via :func:`os.kill`. + + .. versionadded:: 1.29 + + Args: + name (str): Name of the job + sig (int, optional): Signal to send (default: SIGTERM) + + Returns: + bool: `False` if job isn't running, `True` if signal was sent. + """ + pid = _job_pid(name) + if pid is None: + return False + + os.kill(pid, sig) + return True + + def run_in_background(name, args, **kwargs): r"""Cache arguments then call this script again via :func:`subprocess.call`. - :param name: name of task - :type name: ``unicode`` + :param name: name of job + :type name: unicode :param args: arguments passed as first argument to :func:`subprocess.call` :param \**kwargs: keyword arguments to :func:`subprocess.call` :returns: exit code of sub-process - :rtype: ``int`` + :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 @@ -167,24 +222,26 @@ def run_in_background(name, args, **kwargs): """ if is_running(name): - wf().logger.info('Task `{0}` is already running'.format(name)) + _log().info('[%s] job already running', 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)) + with open(argcache, 'wb') as fp: + pickle.dump({'args': args, 'kwargs': kwargs}, fp) + _log().debug('[%s] command cached: %s', name, argcache) # Call this script cmd = ['/usr/bin/python', __file__, name] - wf().logger.debug('Calling {0!r} ...'.format(cmd)) + _log().debug('[%s] passing job to background runner: %r', name, cmd) retcode = subprocess.call(cmd) + if retcode: # pragma: no cover - wf().logger.error('Failed to call task in background') + _log().error('[%s] background runner failed with %d', name, retcode) else: - wf().logger.debug('Executing task `{0}` in background...'.format(name)) + _log().debug('[%s] background job started', name) + return retcode @@ -195,15 +252,21 @@ def main(wf): # pragma: no cover :meth:`subprocess.call` with cached arguments. """ + log = wf.logger 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 + msg = '[{0}] command cache not found: {1}'.format(name, argcache) + log.critical(msg) + raise IOError(msg) + + # Fork to background and run command + pidfile = _pid_file(name) + _background(pidfile) # Load cached arguments - with open(argcache, 'rb') as file_obj: - data = pickle.load(file_obj) + with open(argcache, 'rb') as fp: + data = pickle.load(fp) # Cached arguments args = data['args'] @@ -212,30 +275,18 @@ def main(wf): # pragma: no cover # 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)) + # Run the command + log.debug('[%s] running command: %r', name, args) retcode = subprocess.call(args, **kwargs) if retcode: - wf.logger.error('Command failed with [{0}] : {1!r}'.format( - retcode, args)) - + log.error('[%s] command failed with status %d', name, retcode) finally: - if os.path.exists(pidfile): - os.unlink(pidfile) - wf.logger.debug('Task `{0}` finished'.format(name)) + os.unlink(pidfile) + + log.debug('[%s] job complete', name) if __name__ == '__main__': # pragma: no cover diff --git a/src/workflow/notify.py b/src/workflow/notify.py index 3ed1e5e..4542c78 100644 --- a/src/workflow/notify.py +++ b/src/workflow/notify.py @@ -11,7 +11,7 @@ # TODO: Exclude this module from test and code coverage in py2.6 """ -Post notifications via the OS X Notification Center. This feature +Post notifications via the macOS Notification Center. This feature is only available on Mountain Lion (10.8) and later. It will silently fail on older systems. @@ -60,10 +60,10 @@ def wf(): - """Return `Workflow` object for this module. + """Return Workflow object for this module. Returns: - workflow.Workflow: `Workflow` object for current workflow. + workflow.Workflow: Workflow object for current workflow. """ global _wf if _wf is None: @@ -87,7 +87,7 @@ def notifier_program(): """Return path to notifier applet executable. Returns: - unicode: Path to Notify.app `applet` executable. + unicode: Path to Notify.app ``applet`` executable. """ return wf().datafile('Notify.app/Contents/MacOS/applet') @@ -96,13 +96,13 @@ def notifier_icon_path(): """Return path to icon file in installed Notify.app. Returns: - unicode: Path to `applet.icns` within the app bundle. + unicode: Path to ``applet.icns`` within the app bundle. """ return wf().datafile('Notify.app/Contents/Resources/applet.icns') def install_notifier(): - """Extract `Notify.app` from the workflow to data directory. + """Extract ``Notify.app`` from the workflow to data directory. Changes the bundle ID of the installed app and gives it the workflow's icon. @@ -111,13 +111,13 @@ def install_notifier(): destdir = wf().datadir app_path = os.path.join(destdir, 'Notify.app') n = notifier_program() - log().debug("Installing Notify.app to %r ...", destdir) + log().debug('installing Notify.app to %r ...', destdir) # z = zipfile.ZipFile(archive, 'r') # z.extractall(destdir) tgz = tarfile.open(archive, 'r:gz') tgz.extractall(destdir) - assert os.path.exists(n), ( - "Notify.app could not be installed in {0!r}.".format(destdir)) + assert os.path.exists(n), \ + 'Notify.app could not be installed in %s' % destdir # Replace applet icon icon = notifier_icon_path() @@ -144,29 +144,29 @@ def install_notifier(): ip_path = os.path.join(app_path, 'Contents/Info.plist') bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) data = plistlib.readPlist(ip_path) - log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) + log().debug('changing bundle ID to %r', bundle_id) data['CFBundleIdentifier'] = bundle_id plistlib.writePlist(data, ip_path) def validate_sound(sound): - """Coerce `sound` to valid sound name. + """Coerce ``sound`` to valid sound name. - Returns `None` for invalid sounds. Sound names can be found - in `System Preferences > Sound > Sound Effects`. + Returns ``None`` for invalid sounds. Sound names can be found + in ``System Preferences > Sound > Sound Effects``. Args: sound (str): Name of system sound. Returns: - str: Proper name of sound or `None`. + str: Proper name of sound or ``None``. """ if not sound: return None # Case-insensitive comparison of `sound` if sound.lower() in [s.lower() for s in SOUNDS]: - # Title-case is correct for all system sounds as of OS X 10.11 + # Title-case is correct for all system sounds as of macOS 10.11 return sound.title() return None @@ -180,10 +180,10 @@ def notify(title='', text='', sound=None): sound (str, optional): Name of sound to play. Raises: - ValueError: Raised if both `title` and `text` are empty. + ValueError: Raised if both ``title`` and ``text`` are empty. Returns: - bool: `True` if notification was posted, else `False`. + bool: ``True`` if notification was posted, else ``False``. """ if title == text == '': raise ValueError('Empty notification') @@ -210,7 +210,7 @@ def notify(title='', text='', sound=None): def convert_image(inpath, outpath, size): - """Convert an image file using `sips`. + """Convert an image file using ``sips``. Args: inpath (str): Path of source file. @@ -218,11 +218,11 @@ def convert_image(inpath, outpath, size): size (int): Width and height of destination image in pixels. Raises: - RuntimeError: Raised if `sips` exits with non-zero status. + RuntimeError: Raised if ``sips`` exits with non-zero status. """ cmd = [ b'sips', - b'-z', b'{0}'.format(size), b'{0}'.format(size), + b'-z', str(size), str(size), inpath, b'--out', outpath] # log().debug(cmd) @@ -230,14 +230,14 @@ def convert_image(inpath, outpath, size): retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) if retcode != 0: - raise RuntimeError('sips exited with {0}'.format(retcode)) + raise RuntimeError('sips exited with %d' % retcode) def png_to_icns(png_path, icns_path): - """Convert PNG file to ICNS using `iconutil`. + """Convert PNG file to ICNS using ``iconutil``. Create an iconset from the source PNG file. Generate PNG files - in each size required by OS X, then call `iconutil` to turn + in each size required by macOS, then call ``iconutil`` to turn them into a single ICNS file. Args: @@ -245,15 +245,15 @@ def png_to_icns(png_path, icns_path): icns_path (str): Path to destination ICNS file. Raises: - RuntimeError: Raised if `iconutil` or `sips` fail. + RuntimeError: Raised if ``iconutil`` or ``sips`` fail. """ tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) try: iconset = os.path.join(tempdir, 'Icon.iconset') - assert not os.path.exists(iconset), ( - "Iconset path already exists : {0!r}".format(iconset)) + assert not os.path.exists(iconset), \ + 'iconset already exists: ' + iconset os.makedirs(iconset) # Copy source icon to icon set and generate all the other @@ -261,7 +261,7 @@ def png_to_icns(png_path, icns_path): configs = [] for i in (16, 32, 128, 256, 512): configs.append(('icon_{0}x{0}.png'.format(i), i)) - configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) + configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) @@ -280,10 +280,10 @@ def png_to_icns(png_path, icns_path): retcode = subprocess.call(cmd) if retcode != 0: - raise RuntimeError("iconset exited with {0}".format(retcode)) + raise RuntimeError('iconset exited with %d' % retcode) - assert os.path.exists(icns_path), ( - "Generated ICNS file not found : {0!r}".format(icns_path)) + assert os.path.exists(icns_path), \ + 'generated ICNS file not found: ' + repr(icns_path) finally: try: shutil.rmtree(tempdir) @@ -291,36 +291,6 @@ def png_to_icns(png_path, icns_path): pass -# def notify_native(title='', text='', sound=''): -# """Post notification via the native API (via pyobjc). - -# At least one of `title` or `text` must be specified. - -# This method will *always* show the Python launcher icon (i.e. the -# rocket with the snakes on it). - -# Args: -# title (str, optional): Notification title. -# text (str, optional): Notification body text. -# sound (str, optional): Name of sound to play. - -# """ - -# if title == text == '': -# raise ValueError('Empty notification') - -# import Foundation - -# sound = sound or Foundation.NSUserNotificationDefaultSoundName - -# n = Foundation.NSUserNotification.alloc().init() -# n.setTitle_(title) -# n.setInformativeText_(text) -# n.setSoundName_(sound) -# nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() -# nc.deliverNotification_(n) - - if __name__ == '__main__': # pragma: nocover # Simple command-line script to test module with # This won't work on 2.6, as `argparse` isn't available @@ -329,21 +299,20 @@ def png_to_icns(png_path, icns_path): from unicodedata import normalize - def uni(s): + def ustr(s): """Coerce `s` to normalised Unicode.""" - ustr = s.decode('utf-8') - return normalize('NFD', ustr) + return normalize('NFD', s.decode('utf-8')) p = argparse.ArgumentParser() p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") p.add_argument('-l', '--list-sounds', help="Show available sounds.", action='store_true') p.add_argument('-t', '--title', - help="Notification title.", type=uni, + help="Notification title.", type=ustr, default='') - p.add_argument('-s', '--sound', type=uni, + p.add_argument('-s', '--sound', type=ustr, help="Optional notification sound.", default='') - p.add_argument('text', type=uni, + p.add_argument('text', type=ustr, help="Notification body text.", default='', nargs='?') o = p.parse_args() @@ -357,21 +326,20 @@ def uni(s): if o.png: icns = os.path.join( os.path.dirname(o.png), - b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], - '.icns')) + os.path.splitext(os.path.basename(o.png))[0] + '.icns') - print('Converting {0!r} to {1!r} ...'.format(o.png, icns), + print('converting {0!r} to {1!r} ...'.format(o.png, icns), file=sys.stderr) - assert not os.path.exists(icns), ( - "Destination file already exists : {0}".format(icns)) + assert not os.path.exists(icns), \ + 'destination file already exists: ' + icns png_to_icns(o.png, icns) sys.exit(0) # Post notification if o.title == o.text == '': - print('ERROR: Empty notification.', file=sys.stderr) + print('ERROR: empty notification.', file=sys.stderr) sys.exit(1) else: notify(o.title, o.text, o.sound) diff --git a/src/workflow/update.py b/src/workflow/update.py index bb8e9da..37569bb 100644 --- a/src/workflow/update.py +++ b/src/workflow/update.py @@ -94,7 +94,7 @@ def _parse(self, vstr): else: m = self.match_version(vstr) if not m: - raise ValueError('Invalid version number: {0}'.format(vstr)) + raise ValueError('invalid version number: {0}'.format(vstr)) version, suffix = m.groups() parts = self._parse_dotted_string(version) @@ -104,7 +104,7 @@ def _parse(self, vstr): if len(parts): self.patch = parts.pop(0) if not len(parts) == 0: - raise ValueError('Invalid version (too long) : {0}'.format(vstr)) + raise ValueError('invalid version (too long) : {0}'.format(vstr)) if suffix: # Build info @@ -115,8 +115,7 @@ def _parse(self, vstr): if suffix: if not suffix.startswith('-'): raise ValueError( - 'Invalid suffix : `{0}`. Must start with `-`'.format( - suffix)) + 'suffix must start with - : {0}'.format(suffix)) self.suffix = suffix[1:] # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) @@ -139,7 +138,7 @@ def tuple(self): def __lt__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError('Not a Version instance: {0!r}'.format(other)) + raise ValueError('not a Version instance: {0!r}'.format(other)) t = self.tuple[:3] o = other.tuple[:3] if t < o: @@ -157,7 +156,7 @@ def __lt__(self, other): def __eq__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError('Not a Version instance: {0!r}'.format(other)) + raise ValueError('not a Version instance: {0!r}'.format(other)) return self.tuple == other.tuple def __ne__(self, other): @@ -167,13 +166,13 @@ def __ne__(self, other): def __gt__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError('Not a Version instance: {0!r}'.format(other)) + raise ValueError('not a Version instance: {0!r}'.format(other)) return other.__lt__(self) def __le__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError('Not a Version instance: {0!r}'.format(other)) + raise ValueError('not a Version instance: {0!r}'.format(other)) return not other.__lt__(self) def __ge__(self, other): @@ -184,9 +183,9 @@ def __str__(self): """Return semantic version string.""" vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) if self.suffix: - vstr += '-{0}'.format(self.suffix) + vstr = '{0}-{1}'.format(vstr, self.suffix) if self.build: - vstr += '+{0}'.format(self.build) + vstr = '{0}+{1}'.format(vstr, self.build) return vstr def __repr__(self): @@ -201,16 +200,16 @@ def download_workflow(url): :returns: path to downloaded file """ - filename = url.split("/")[-1] + filename = url.split('/')[-1] if (not filename.endswith('.alfredworkflow') and not filename.endswith('.alfred3workflow')): - raise ValueError('Attachment `{0}` not a workflow'.format(filename)) + raise ValueError('attachment not a workflow: {0}'.format(filename)) local_path = os.path.join(tempfile.gettempdir(), filename) wf().logger.debug( - 'Downloading updated workflow from `%s` to `%s` ...', url, local_path) + 'downloading updated workflow from `%s` to `%s` ...', url, local_path) response = web.get(url) @@ -228,7 +227,7 @@ def build_api_url(slug): """ if len(slug.split('/')) != 2: - raise ValueError('Invalid GitHub slug : {0}'.format(slug)) + raise ValueError('invalid GitHub slug: {0}'.format(slug)) return RELEASES_BASE.format(slug) @@ -261,13 +260,13 @@ def _validate_release(release): if dl_count == 0: wf().logger.warning( - 'Invalid release %s : No workflow file', version) + 'invalid release (no workflow file): %s', version) return None for k in downloads: if len(downloads[k]) > 1: wf().logger.warning( - 'Invalid release %s : multiple %s files', version, k) + 'invalid release (multiple %s files): %s', k, version) return None # Prefer .alfred3workflow file if there is one and Alfred 3 is @@ -278,7 +277,7 @@ def _validate_release(release): else: download_url = downloads['.alfredworkflow'][0] - wf().logger.debug('Release `%s` : %s', version, download_url) + wf().logger.debug('release %s: %s', version, download_url) return { 'version': version, @@ -306,28 +305,27 @@ def get_valid_releases(github_slug, prereleases=False): api_url = build_api_url(github_slug) releases = [] - wf().logger.debug('Retrieving releases list from `%s` ...', api_url) + wf().logger.debug('retrieving releases list: %s', api_url) def retrieve_releases(): wf().logger.info( - 'Retrieving releases for `%s` ...', github_slug) + 'retrieving releases: %s', github_slug) return web.get(api_url).json() slug = github_slug.replace('/', '-') - for release in wf().cached_data('gh-releases-{0}'.format(slug), - retrieve_releases): - - wf().logger.debug('Release : %r', release) + for release in wf().cached_data('gh-releases-' + slug, retrieve_releases): release = _validate_release(release) if release is None: - wf().logger.debug('Invalid release') + wf().logger.debug('invalid release: %r', release) continue elif release['prerelease'] and not prereleases: - wf().logger.debug('Ignoring prerelease : %s', release['version']) + wf().logger.debug('ignoring prerelease: %s', release['version']) continue + wf().logger.debug('release: %r', release) + releases.append(release) return releases @@ -349,10 +347,10 @@ def check_update(github_slug, current_version, prereleases=False): """ releases = get_valid_releases(github_slug, prereleases) - wf().logger.info('%d releases for %s', len(releases), github_slug) - if not len(releases): - raise ValueError('No valid releases for %s', github_slug) + raise ValueError('no valid releases for %s', github_slug) + + wf().logger.info('%d releases for %s', len(releases), github_slug) # GitHub returns releases newest-first latest_release = releases[0] @@ -360,7 +358,7 @@ def check_update(github_slug, current_version, prereleases=False): # (latest_version, download_url) = get_latest_release(releases) vr = Version(latest_release['version']) vl = Version(current_version) - wf().logger.debug('Latest : %r Installed : %r', vr, vl) + wf().logger.debug('latest=%r, installed=%r', vr, vl) if vr > vl: wf().cache_data('__workflow_update_status', { @@ -371,9 +369,7 @@ def check_update(github_slug, current_version, prereleases=False): return True - wf().cache_data('__workflow_update_status', { - 'available': False - }) + wf().cache_data('__workflow_update_status', {'available': False}) return False @@ -386,12 +382,12 @@ def install_update(): 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') + wf().logger.info('no update available') return False local_file = download_workflow(update_data['download_url']) - wf().logger.info('Installing updated workflow ...') + wf().logger.info('installing updated workflow ...') subprocess.call(['open', local_file]) update_data['available'] = False @@ -402,27 +398,29 @@ def install_update(): if __name__ == '__main__': # pragma: nocover import sys - def show_help(): + def show_help(status=0): """Print help message.""" - print('Usage : update.py (check|install) github_slug version ' - '[--prereleases]') - sys.exit(1) + print('Usage : update.py (check|install) ' + '[--prereleases] ') + sys.exit(status) argv = sys.argv[:] + if '-h' in argv or '--help' in argv: + show_help() + prereleases = '--prereleases' in argv if prereleases: argv.remove('--prereleases') if len(argv) != 4: - show_help() + show_help(1) action, github_slug, version = argv[1:] - if action not in ('check', 'install'): - show_help() - if action == 'check': check_update(github_slug, version, prereleases) elif action == 'install': install_update() + else: + show_help(1) diff --git a/src/workflow/util.py b/src/workflow/util.py new file mode 100644 index 0000000..f00ac36 --- /dev/null +++ b/src/workflow/util.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2017 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2017-12-17 +# + +"""A selection of helper functions useful for building workflows.""" + +from __future__ import print_function, absolute_import + +import atexit +from collections import namedtuple +from contextlib import contextmanager +import errno +import fcntl +import functools +import os +import signal +import subprocess +import sys +from threading import Event +import time + +# AppleScript to call an External Trigger in Alfred +AS_TRIGGER = """ +tell application "Alfred 3" +run trigger "{name}" in workflow "{bundleid}" {arg} +end tell +""" + + +class AcquisitionError(Exception): + """Raised if a lock cannot be acquired.""" + + +AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) +"""Information about an installed application. + +Returned by :func:`appinfo`. All attributes are Unicode. + +.. py:attribute:: name + + Name of the application, e.g. ``u'Safari'``. + +.. py:attribute:: path + + Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. + +.. py:attribute:: bundleid + + Application's bundle ID, e.g. ``u'com.apple.Safari'``. +""" + + +def unicodify(s, encoding='utf-8', norm=None): + """Ensure string is Unicode. + + .. versionadded:: 1.31 + + Decode encoded strings using ``encoding`` and normalise Unicode + to form ``norm`` if specified. + + Args: + s (str): String to decode. May also be Unicode. + encoding (str, optional): Encoding to use on bytestrings. + norm (None, optional): Normalisation form to apply to Unicode string. + + Returns: + unicode: Decoded, optionally normalised, Unicode string. + + """ + if not isinstance(s, unicode): + s = unicode(s, encoding) + + if norm: + from unicodedata import normalize + s = normalize(norm, s) + + return s + + +def utf8ify(s): + """Ensure string is a bytestring. + + .. versionadded:: 1.31 + + Returns `str` objects unchanced, encodes `unicode` objects to + UTF-8, and calls :func:`str` on anything else. + + Args: + s (object): A Python object + + Returns: + str: UTF-8 string or string representation of s. + """ + if isinstance(s, str): + return s + + if isinstance(s, unicode): + return s.encode('utf-8') + + return str(s) + + +def applescriptify(s): + """Escape string for insertion into an AppleScript string. + + .. versionadded:: 1.31 + + Replaces ``"`` with `"& quote &"`. Use this function if you want + + to insert a string into an AppleScript script: + >>> script = 'tell application "Alfred 3" to search "{}"' + >>> query = 'g "python" test' + >>> script.format(applescriptify(query)) + 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"' + + Args: + s (unicode): Unicode string to escape. + + Returns: + unicode: Escaped string + """ + return s.replace(u'"', u'" & quote & "') + + +def run_command(cmd, **kwargs): + """Run a command and return the output. + + .. versionadded:: 1.31 + + A thin wrapper around :func:`subprocess.check_output` that ensures + all arguments are encoded to UTF-8 first. + + Args: + cmd (list): Command arguments to pass to ``check_output``. + **kwargs: Keyword arguments to pass to ``check_output``. + + Returns: + str: Output returned by ``check_output``. + """ + cmd = [utf8ify(s) for s in cmd] + return subprocess.check_output(cmd, **kwargs) + + +def run_applescript(script, *args, **kwargs): + """Execute an AppleScript script and return its output. + + .. versionadded:: 1.31 + + Run AppleScript either by filepath or code. If ``script`` is a valid + filepath, that script will be run, otherwise ``script`` is treated + as code. + + Args: + script (str, optional): Filepath of script or code to run. + *args: Optional command-line arguments to pass to the script. + **kwargs: Pass ``lang`` to run a language other than AppleScript. + + Returns: + str: Output of run command. + + """ + cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')] + + if os.path.exists(script): + cmd += [script] + else: + cmd += ['-e', script] + + cmd.extend(args) + + return run_command(cmd) + + +def run_jxa(script, *args): + """Execute a JXA script and return its output. + + .. versionadded:: 1.31 + + Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. + + Args: + script (str): Filepath of script or code to run. + *args: Optional command-line arguments to pass to script. + + Returns: + str: Output of script. + """ + return run_applescript(script, *args, lang='JavaScript') + + +def run_trigger(name, bundleid=None, arg=None): + """Call an Alfred External Trigger. + + .. versionadded:: 1.31 + + If ``bundleid`` is not specified, reads the bundle ID of the current + workflow from Alfred's environment variables. + + Args: + name (str): Name of External Trigger to call. + bundleid (str, optional): Bundle ID of workflow trigger belongs to. + arg (str, optional): Argument to pass to trigger. + """ + if not bundleid: + bundleid = os.getenv('alfred_workflow_bundleid') + + if arg: + arg = 'with argument "{}"'.format(applescriptify(arg)) + else: + arg = '' + + script = AS_TRIGGER.format(name=name, bundleid=bundleid, + arg=arg) + + run_applescript(script) + + +def appinfo(name): + """Get information about an installed application. + + .. versionadded:: 1.31 + + Args: + name (str): Name of application to look up. + + Returns: + AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. + """ + cmd = ['mdfind', '-onlyin', '/', + '(kMDItemContentTypeTree == com.apple.application &&' + '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' + .format(name)] + + path = run_command(cmd).strip() + if not path: + return None + + cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] + bid = run_command(cmd).strip() + if not bid: # pragma: no cover + return None + + return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) + + +@contextmanager +def atomic_writer(fpath, mode): + """Atomic file writer. + + .. versionadded:: 1.12 + + Context manager that ensures the file is only written if the write + succeeds. The data is first written to a temporary file. + + :param fpath: path of file to write to. + :type fpath: ``unicode`` + :param mode: sames as for :func:`open` + :type mode: string + + """ + suffix = '.{}.tmp'.format(os.getpid()) + temppath = fpath + suffix + with open(temppath, mode) as fp: + try: + yield fp + os.rename(temppath, fpath) + finally: + try: + os.remove(temppath) + except (OSError, IOError): + pass + + +class LockFile(object): + """Context manager to protect filepaths with lockfiles. + + .. versionadded:: 1.13 + + Creates a lockfile alongside ``protected_path``. Other ``LockFile`` + instances will refuse to lock the same path. + + >>> path = '/path/to/file' + >>> with LockFile(path): + >>> with open(path, 'wb') as fp: + >>> fp.write(data) + + Args: + protected_path (unicode): File to protect with a lockfile + timeout (float, optional): Raises an :class:`AcquisitionError` + if lock cannot be acquired within this number of seconds. + If ``timeout`` is 0 (the default), wait forever. + delay (float, optional): How often to check (in seconds) if + lock has been released. + + Attributes: + delay (float): How often to check (in seconds) whether the lock + can be acquired. + lockfile (unicode): Path of the lockfile. + timeout (float): How long to wait to acquire the lock. + + """ + + def __init__(self, protected_path, timeout=0.0, delay=0.05): + """Create new :class:`LockFile` object.""" + self.lockfile = protected_path + '.lock' + self._lockfile = None + self.timeout = timeout + self.delay = delay + self._lock = Event() + atexit.register(self.release) + + @property + def locked(self): + """``True`` if file is locked by this instance.""" + return self._lock.is_set() + + def acquire(self, blocking=True): + """Acquire the lock if possible. + + If the lock is in use and ``blocking`` is ``False``, return + ``False``. + + Otherwise, check every :attr:`delay` seconds until it acquires + lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. + + """ + if self.locked and not blocking: + return False + + start = time.time() + while True: + + # Raise error if we've been waiting too long to acquire the lock + if self.timeout and (time.time() - start) >= self.timeout: + raise AcquisitionError('lock acquisition timed out') + + # If already locked, wait then try again + if self.locked: + time.sleep(self.delay) + continue + + # Create in append mode so we don't lose any contents + if self._lockfile is None: + self._lockfile = open(self.lockfile, 'a') + + # Try to acquire the lock + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._lock.set() + break + except IOError as err: # pragma: no cover + if err.errno not in (errno.EACCES, errno.EAGAIN): + raise + + # Don't try again + if not blocking: # pragma: no cover + return False + + # Wait, then try again + time.sleep(self.delay) + + return True + + def release(self): + """Release the lock by deleting `self.lockfile`.""" + if not self._lock.is_set(): + return False + + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_UN) + except IOError: # pragma: no cover + pass + finally: + self._lock.clear() + self._lockfile = None + try: + os.unlink(self.lockfile) + except (IOError, OSError): # pragma: no cover + pass + + return True + + def __enter__(self): + """Acquire lock.""" + self.acquire() + return self + + def __exit__(self, typ, value, traceback): + """Release lock.""" + self.release() + + def __del__(self): + """Clear up `self.lockfile`.""" + self.release() # pragma: no cover + + +class uninterruptible(object): + """Decorator that postpones SIGTERM until wrapped function returns. + + .. versionadded:: 1.12 + + .. important:: This decorator is NOT thread-safe. + + As of version 2.7, Alfred allows Script Filters to be killed. If + your workflow is killed in the middle of critical code (e.g. + writing data to disk), this may corrupt your workflow's data. + + Use this decorator to wrap critical functions that *must* complete. + If the script is killed while a wrapped function is executing, + the SIGTERM will be caught and handled after your function has + finished executing. + + Alfred-Workflow uses this internally to ensure its settings, data + and cache writes complete. + + """ + + def __init__(self, func, class_name=''): + """Decorate `func`.""" + self.func = func + functools.update_wrapper(self, func) + self._caught_signal = None + + def signal_handler(self, signum, frame): + """Called when process receives SIGTERM.""" + self._caught_signal = (signum, frame) + + def __call__(self, *args, **kwargs): + """Trap ``SIGTERM`` and call wrapped function.""" + self._caught_signal = None + # Register handler for SIGTERM, then call `self.func` + self.old_signal_handler = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, self.signal_handler) + + self.func(*args, **kwargs) + + # Restore old signal handler + signal.signal(signal.SIGTERM, self.old_signal_handler) + + # Handle any signal caught during execution + if self._caught_signal is not None: + signum, frame = self._caught_signal + if callable(self.old_signal_handler): + self.old_signal_handler(signum, frame) + elif self.old_signal_handler == signal.SIG_DFL: + sys.exit(0) + + def __get__(self, obj=None, klass=None): + """Decorator API.""" + return self.__class__(self.func.__get__(obj, klass), + klass.__name__) diff --git a/src/workflow/version b/src/workflow/version index c8d3893..6de2df8 100644 --- a/src/workflow/version +++ b/src/workflow/version @@ -1 +1 @@ -1.26 \ No newline at end of file +1.32 \ No newline at end of file diff --git a/src/workflow/web.py b/src/workflow/web.py index 748b199..d64bb6f 100644 --- a/src/workflow/web.py +++ b/src/workflow/web.py @@ -77,8 +77,10 @@ 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` + :param dic: Mapping of Unicode strings + :type dic: dict + :returns: Dictionary containing only UTF-8 strings + :rtype: dict """ if isinstance(dic, CaseInsensitiveDictionary): @@ -191,7 +193,7 @@ def __init__(self, request, stream=False): :param request: :class:`urllib2.Request` instance :param stream: Whether to stream response or retrieve it all at once - :type stream: ``bool`` + :type stream: bool """ self.request = request @@ -263,7 +265,7 @@ def json(self): """Decode response contents as JSON. :returns: object decoded from JSON - :rtype: :class:`list` / :class:`dict` + :rtype: list, dict or unicode """ return json.loads(self.content, self.encoding or 'utf-8') @@ -272,7 +274,8 @@ def json(self): def encoding(self): """Text encoding of document or ``None``. - :returns: :class:`str` or ``None`` + :returns: Text encoding if found. + :rtype: str or ``None`` """ if not self._encoding: @@ -285,7 +288,7 @@ def content(self): """Raw content of response (i.e. bytes). :returns: Body of HTTP response - :rtype: :class:`str` + :rtype: str """ if not self._content: @@ -310,7 +313,7 @@ def text(self): itself, the encoded response body will be returned instead. :returns: Body of HTTP response - :rtype: :class:`unicode` or :class:`str` + :rtype: unicode or str """ if self.encoding: @@ -324,9 +327,9 @@ def iter_content(self, chunk_size=4096, decode_unicode=False): .. versionadded:: 1.6 :param chunk_size: Number of bytes to read into memory - :type chunk_size: ``int`` + :type chunk_size: int :param decode_unicode: Decode to Unicode using detected encoding - :type decode_unicode: ``Boolean`` + :type decode_unicode: bool :returns: iterator """ @@ -406,7 +409,7 @@ def _get_encoding(self): """Get encoding from HTTP headers or content. :returns: encoding or `None` - :rtype: ``unicode`` or ``None`` + :rtype: unicode or ``None`` """ headers = self.raw.info() @@ -458,29 +461,30 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, """Initiate an HTTP(S) request. Returns :class:`Response` object. :param method: 'GET' or 'POST' - :type method: ``unicode`` + :type method: unicode :param url: URL to open - :type url: ``unicode`` + :type url: unicode :param params: mapping of URL parameters - :type params: :class:`dict` + :type params: dict :param data: mapping of form data ``{'field_name': 'value'}`` or :class:`str` - :type data: :class:`dict` or :class:`str` + :type data: dict or str :param headers: HTTP headers - :type headers: :class:`dict` + :type headers: dict :param cookies: cookies to send to server - :type cookies: :class:`dict` + :type cookies: dict :param files: files to upload (see below). - :type files: :class:`dict` + :type files: dict :param auth: username, password - :type auth: ``tuple`` + :type auth: tuple :param timeout: connection timeout limit in seconds - :type timeout: ``int`` + :type timeout: int :param allow_redirects: follow redirections - :type allow_redirects: ``Boolean`` + :type allow_redirects: bool :param stream: Stream content instead of fetching it all at once. - :type stream: ``bool`` - :returns: :class:`Response` object + :type stream: bool + :returns: Response object + :rtype: :class:`Response` The ``files`` argument is a dictionary:: @@ -594,11 +598,12 @@ 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` + :type fields: 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 + :type files: dict of :class:`dict` + :returns: ``(headers, body)`` ``headers`` is a + :class:`dict` of HTTP headers :rtype: 2-tuple ``(dict, str)`` The ``files`` argument is a dictionary:: @@ -609,16 +614,18 @@ def encode_multipart_formdata(fields, files): } - ``fieldname`` is the name of the field in the HTML form. - - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used. + - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will + be used to guess the mimetype, or ``application/octet-stream`` + will be used. """ def get_content_type(filename): """Return or guess mimetype of ``filename``. :param filename: filename of file - :type filename: unicode/string + :type filename: unicode/str :returns: mime-type, e.g. ``text/html`` - :rtype: :class::class:`str` + :rtype: str """ diff --git a/src/workflow/workflow.py b/src/workflow/workflow.py index 4fd8db4..c2c1616 100644 --- a/src/workflow/workflow.py +++ b/src/workflow/workflow.py @@ -10,7 +10,7 @@ """The :class:`Workflow` object is the main interface to this library. :class:`Workflow` is targeted at Alfred 2. Use -:class:`~workflow.workflow3.Workflow3` if you want to use Alfred 3's new +:class:`~workflow.Workflow3` if you want to use Alfred 3's new features, such as :ref:`workflow variables ` or more powerful modifiers. @@ -21,12 +21,9 @@ from __future__ import print_function, unicode_literals -import atexit import binascii -from contextlib import contextmanager import cPickle from copy import deepcopy -import errno import json import logging import logging.handlers @@ -35,7 +32,6 @@ import plistlib import re import shutil -import signal import string import subprocess import sys @@ -47,6 +43,12 @@ except ImportError: # pragma: no cover import xml.etree.ElementTree as ET +from util import ( + AcquisitionError, # imported to maintain API + atomic_writer, + LockFile, + uninterruptible, +) #: Sentinel for properties that haven't been set yet (that might #: correctly have the value ``None``) @@ -56,7 +58,7 @@ # Standard system icons #################################################################### -# These icons are default OS X icons. They are super-high quality, and +# These icons are default macOS icons. They are super-high quality, and # will be familiar to users. # This library uses `ICON_ERROR` when a workflow dies in flames, so # in my own workflows, I use `ICON_WARNING` for less fatal errors @@ -443,12 +445,9 @@ #################################################################### -# Lockfile and Keychain access errors +# Keychain access errors #################################################################### -class AcquisitionError(Exception): - """Raised if a lock cannot be acquired.""" - class KeychainError(Exception): """Raised for unknown Keychain errors. @@ -456,6 +455,7 @@ 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. + """ @@ -464,6 +464,7 @@ class PasswordNotFound(KeychainError): Raised by method :meth:`Workflow.get_password` when ``account`` is unknown to the Keychain. + """ @@ -473,6 +474,7 @@ class PasswordExists(KeychainError): 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). + """ @@ -506,13 +508,13 @@ class SerializerManager(object): .. versionadded:: 1.8 A configured instance of this class is available at - ``workflow.manager``. + :attr:`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. + :class:`~workflow.Workflow` data storage methods. - See :ref:`manual-serialization` and :ref:`manual-persistent-data` + See :ref:`guide-serialization` and :ref:`guide-persistent-data` for further information. """ @@ -796,187 +798,6 @@ def elem(self): return root -class LockFile(object): - """Context manager to create lock files.""" - - def __init__(self, protected_path, timeout=0, delay=0.05): - """Create new :class:`LockFile` object.""" - self.lockfile = protected_path + '.lock' - self.timeout = timeout - self.delay = delay - self._locked = False - atexit.register(self.release) - - @property - def locked(self): - """`True` if file is locked by this instance.""" - return self._locked - - def acquire(self, blocking=True): - """Acquire the lock if possible. - - If the lock is in use and ``blocking`` is ``False``, return - ``False``. - - Otherwise, check every `self.delay` seconds until it acquires - lock or exceeds `self.timeout` and raises an `~AcquisitionError`. - - """ - start = time.time() - while True: - - self._validate_lockfile() - - try: - fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) - with os.fdopen(fd, 'w') as fd: - fd.write('{0}'.format(os.getpid())) - break - except OSError as err: - if err.errno != errno.EEXIST: # pragma: no cover - raise - - if self.timeout and (time.time() - start) >= self.timeout: - raise AcquisitionError('Lock acquisition timed out.') - if not blocking: - return False - time.sleep(self.delay) - - self._locked = True - return True - - def _validate_lockfile(self): - """Check existence and validity of lockfile. - - If the lockfile exists, but contains an invalid PID - or the PID of a non-existant process, it is removed. - - """ - try: - with open(self.lockfile) as fp: - s = fp.read() - except Exception: - return - - try: - pid = int(s) - except ValueError: - return self.release() - - from background import _process_exists - if not _process_exists(pid): - self.release() - - def release(self): - """Release the lock by deleting `self.lockfile`.""" - self._locked = False - try: - os.unlink(self.lockfile) - except (OSError, IOError) as err: # pragma: no cover - if err.errno != 2: - raise err - - def __enter__(self): - """Acquire lock.""" - self.acquire() - return self - - def __exit__(self, typ, value, traceback): - """Release lock.""" - self.release() - - def __del__(self): - """Clear up `self.lockfile`.""" - if self._locked: # pragma: no cover - self.release() - - -@contextmanager -def atomic_writer(file_path, mode): - """Atomic file writer. - - :param file_path: path of file to write to. - :type file_path: ``unicode`` - :param mode: sames as for `func:open` - :type mode: string - - .. versionadded:: 1.12 - - Context manager that ensures the file is only written if the write - succeeds. The data is first written to a temporary file. - - """ - temp_suffix = '.aw.temp' - temp_file_path = file_path + temp_suffix - with open(temp_file_path, mode) as file_obj: - try: - yield file_obj - os.rename(temp_file_path, file_path) - finally: - try: - os.remove(temp_file_path) - except (OSError, IOError): - pass - - -class uninterruptible(object): - """Decorator that postpones SIGTERM until wrapped function is complete. - - .. versionadded:: 1.12 - - Since version 2.7, Alfred allows Script Filters to be killed. If - your workflow is killed in the middle of critical code (e.g. - writing data to disk), this may corrupt your workflow's data. - - Use this decorator to wrap critical functions that *must* complete. - If the script is killed while a wrapped function is executing, - the SIGTERM will be caught and handled after your function has - finished executing. - - Alfred-Workflow uses this internally to ensure its settings, data - and cache writes complete. - - .. important:: - - This decorator is NOT thread-safe. - - """ - - def __init__(self, func, class_name=''): - """Decorate `func`.""" - self.func = func - self._caught_signal = None - - def signal_handler(self, signum, frame): - """Called when process receives SIGTERM.""" - self._caught_signal = (signum, frame) - - def __call__(self, *args, **kwargs): - """Trap ``SIGTERM`` and call wrapped function.""" - self._caught_signal = None - # Register handler for SIGTERM, then call `self.func` - self.old_signal_handler = signal.getsignal(signal.SIGTERM) - signal.signal(signal.SIGTERM, self.signal_handler) - - self.func(*args, **kwargs) - - # Restore old signal handler - signal.signal(signal.SIGTERM, self.old_signal_handler) - - # Handle any signal caught during execution - if self._caught_signal is not None: - signum, frame = self._caught_signal - if callable(self.old_signal_handler): - self.old_signal_handler(signum, frame) - elif self.old_signal_handler == signal.SIG_DFL: - sys.exit(0) - - def __get__(self, obj=None, klass=None): - """Decorator API.""" - return self.__class__(self.func.__get__(obj, klass), - klass.__name__) - - class Settings(dict): """A dictionary that saves itself when changed. @@ -1010,13 +831,15 @@ def __init__(self, filepath, defaults=None): def _load(self): """Load cached settings from JSON file `self._filepath`.""" + data = {} + with LockFile(self._filepath, 0.5): + with open(self._filepath, 'rb') as fp: + data.update(json.load(fp)) + + self._original = deepcopy(data) + self._nosave = True - d = {} - with open(self._filepath, 'rb') as file_obj: - for key, value in json.load(file_obj, encoding='utf-8').items(): - d[key] = value - self.update(d) - self._original = deepcopy(d) + self.update(data) self._nosave = False @uninterruptible @@ -1029,13 +852,13 @@ def save(self): """ if self._nosave: return + data = {} data.update(self) - # for key, value in self.items(): - # data[key] = value - with LockFile(self._filepath): - with atomic_writer(self._filepath, 'wb') as file_obj: - json.dump(data, file_obj, sort_keys=True, indent=2, + + with LockFile(self._filepath, 0.5): + with atomic_writer(self._filepath, 'wb') as fp: + json.dump(data, fp, sort_keys=True, indent=2, encoding='utf-8') # dict methods @@ -1063,27 +886,36 @@ def setdefault(self, key, value=None): class Workflow(object): - """Create new :class:`Workflow` instance. + """The ``Workflow`` object is the main interface to Alfred-Workflow. + + It provides APIs for accessing the Alfred/workflow environment, + storing & caching data, using Keychain, and generating Script + Filter feedback. + + ``Workflow`` is compatible with both Alfred 2 and 3. The + :class:`~workflow.Workflow3` subclass provides additional, + Alfred 3-only features, such as workflow variables. :param default_settings: default workflow settings. If no settings file exists, :class:`Workflow.settings` will be pre-populated with ``default_settings``. :type default_settings: :class:`dict` - :param update_settings: settings for updating your workflow from GitHub. - This must be a :class:`dict` that contains ``github_slug`` and - ``version`` keys. ``github_slug`` is of the form ``username/repo`` - and ``version`` **must** correspond to the tag of a release. The - boolean ``prereleases`` key is optional and if ``True`` will - override the :ref:`magic argument ` preference. - This is only recommended when the installed workflow is a pre-release. - See :ref:`updates` for more information. + :param update_settings: settings for updating your workflow from + GitHub releases. The only required key is ``github_slug``, + whose value must take the form of ``username/repo``. + If specified, ``Workflow`` will check the repo's releases + for updates. Your workflow must also have a semantic version + number. Please see the :ref:`User Manual ` and + `update API docs ` for more information. :type update_settings: :class:`dict` - :param input_encoding: encoding of command line arguments + :param input_encoding: encoding of command line arguments. You + should probably leave this as the default (``utf-8``), which + is the encoding Alfred uses. :type input_encoding: :class:`unicode` :param normalization: normalisation to apply to CLI args. See :meth:`Workflow.decode` for more details. :type normalization: :class:`unicode` - :param capture_args: capture and act on ``workflow:*`` arguments. See + :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 @@ -1176,32 +1008,32 @@ def alfred_env(self): ============================ ========================================= Variable Description ============================ ========================================= - alfred_debug Set to ``1`` if Alfred's debugger is + debug Set to ``1`` if Alfred's debugger is open, otherwise unset. - alfred_preferences Path to Alfred.alfredpreferences + preferences Path to Alfred.alfredpreferences (where your workflows and settings are stored). - alfred_preferences_localhash Machine-specific preferences are stored + preferences_localhash Machine-specific preferences are stored in ``Alfred.alfredpreferences/preferences/local/`` - (see ``alfred_preferences`` above for + (see ``preferences`` above for the path to ``Alfred.alfredpreferences``) - alfred_theme ID of selected theme - alfred_theme_background Background colour of selected theme in + theme ID of selected theme + theme_background Background colour of selected theme in format ``rgba(r,g,b,a)`` - alfred_theme_subtext Show result subtext. + 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. + version Alfred version number, e.g. ``'2.4'`` + version_build Alfred build number, e.g. ``277`` + workflow_bundleid Bundle ID, e.g. ``net.deanishe.alfred-mailto`` - alfred_workflow_cache Path to workflow's cache directory - alfred_workflow_data Path to workflow's data directory - alfred_workflow_name Name of current workflow - alfred_workflow_uid UID of workflow - alfred_workflow_version The version number specified in the + workflow_cache Path to workflow's cache directory + workflow_data Path to workflow's data directory + workflow_name Name of current workflow + workflow_uid UID of workflow + workflow_version The version number specified in the workflow configuration sheet/info.plist ============================ ========================================= @@ -1556,9 +1388,12 @@ def logger(self): return self._logger # Initialise new logger and optionally handlers - logger = logging.getLogger('workflow') + logger = logging.getLogger('') - if not len(logger.handlers): # Only add one set of handlers + # Only add one set of handlers + # Exclude from coverage, as pytest will have configured the + # root logger already + if not len(logger.handlers): # pragma: no cover fmt = logging.Formatter( '%(asctime)s %(filename)s:%(lineno)s' @@ -1611,7 +1446,7 @@ def settings_path(self): def settings(self): """Return a dictionary subclass that saves itself when changed. - See :ref:`manual-settings` in the :ref:`user-manual` for more + See :ref:`guide-settings` in the :ref:`user-manual` for more information on how to use :attr:`settings` and **important limitations** on what it can do. @@ -1624,8 +1459,7 @@ def settings(self): """ if not self._settings: - self.logger.debug('Reading settings from `{0}` ...'.format( - self.settings_path)) + self.logger.debug('reading settings from %s', self.settings_path) self._settings = Settings(self.settings_path, self._default_settings) return self._settings @@ -1669,8 +1503,7 @@ def cache_serializer(self, serializer_name): '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.logger.debug('default cache serializer: %s', serializer_name) self._cache_serializer = serializer_name @@ -1712,8 +1545,7 @@ def data_serializer(self, serializer_name): '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.logger.debug('default data serializer: %s', serializer_name) self._data_serializer = serializer_name @@ -1730,7 +1562,7 @@ def stored_data(self, name): 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)) + self.logger.debug('no data stored for `%s`', name) return None with open(metadata_path, 'rb') as file_obj: @@ -1744,14 +1576,13 @@ def stored_data(self, name): 'serializer with `manager.register()` ' 'to load this data.'.format(serializer_name)) - self.logger.debug('Data `{0}` stored in `{1}` format'.format( - name, serializer_name)) + self.logger.debug('data `%s` stored as `%s`', name, serializer_name) filename = '{0}.{1}'.format(name, serializer_name) data_path = self.datafile(filename) if not os.path.exists(data_path): - self.logger.debug('No data stored for `{0}`'.format(name)) + self.logger.debug('no data stored: %s', name) if os.path.exists(metadata_path): os.unlink(metadata_path) @@ -1760,7 +1591,7 @@ def stored_data(self, name): with open(data_path, 'rb') as file_obj: data = serializer.load(file_obj) - self.logger.debug('Stored data loaded from : {0}'.format(data_path)) + self.logger.debug('stored data loaded: %s', data_path) return data @@ -1789,7 +1620,7 @@ def delete_paths(paths): for path in paths: if os.path.exists(path): os.unlink(path) - self.logger.debug('Deleted data file : {0}'.format(path)) + self.logger.debug('deleted data file: %s', path) serializer_name = serializer or self.data_serializer @@ -1829,7 +1660,7 @@ def _store(): _store() - self.logger.debug('Stored data saved at : {0}'.format(data_path)) + self.logger.debug('saved data: %s', data_path) def cached_data(self, name, data_func=None, max_age=60): """Return cached data if younger than ``max_age`` seconds. @@ -1855,8 +1686,7 @@ def cached_data(self, name, data_func=None, max_age=60): 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) + self.logger.debug('loading cached data: %s', cache_path) return serializer.load(file_obj) if not data_func: @@ -1885,13 +1715,13 @@ def cache_data(self, name, data): if data is None: if os.path.exists(cache_path): os.unlink(cache_path) - self.logger.debug('Deleted cache file : %s', cache_path) + self.logger.debug('deleted cache file: %s', cache_path) return with atomic_writer(cache_path, 'wb') as file_obj: serializer.dump(data, file_obj) - self.logger.debug('Cached data saved at : %s', cache_path) + self.logger.debug('cached data: %s', cache_path) def cached_data_fresh(self, name, max_age): """Whether cache `name` is less than `max_age` seconds old. @@ -1934,10 +1764,8 @@ def filter(self, query, items, key=lambda x: x, ascending=False, ``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. + If ``query`` is an empty string or contains only whitespace, + all items will match. :param query: query to test items against :type query: ``unicode`` @@ -2030,13 +1858,13 @@ def filter(self, query, items, key=lambda x: x, ascending=False, """ if not query: - raise ValueError('Empty `query`') + return items # Remove preceding/trailing spaces query = query.strip() if not query: - raise ValueError('`query` contains only whitespace') + return items # Use user override if there is one fold_diacritics = self.settings.get('__workflow_diacritic_folding', @@ -2216,19 +2044,22 @@ def run(self, func, text_errors=False): """ start = time.time() + # Write to debugger to ensure "real" output starts on a new line + print('.', file=sys.stderr) + # Call workflow's entry function/method within a try-except block # to catch any errors and display an error message in Alfred try: - if self.version: - self.logger.debug( - 'Workflow version : {0}'.format(self.version)) + self.logger.debug('---------- %s (%s) ----------', + self.name, self.version) + else: + self.logger.debug('---------- %s ----------', self.name) # Run update check if configured for self-updates. # This call has to go in the `run` try-except block, as it will # initialise `self.settings`, which will raise an exception # if `settings.json` isn't valid. - if self._update_settings: self.check_update() @@ -2242,8 +2073,7 @@ def run(self, func, text_errors=False): except Exception as err: self.logger.exception(err) if self.help_url: - self.logger.info( - 'For assistance, see: {0}'.format(self.help_url)) + self.logger.info('for assistance, see: %s', self.help_url) if not sys.stdout.isatty(): # Show error in Alfred if text_errors: @@ -2252,7 +2082,7 @@ def run(self, func, text_errors=False): self._items = [] if self._name: name = self._name - elif self._bundleid: + elif self._bundleid: # pragma: no cover name = self._bundleid else: # pragma: no cover name = os.path.dirname(__file__) @@ -2263,8 +2093,8 @@ def run(self, func, text_errors=False): return 1 finally: - self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format( - time.time() - start)) + self.logger.debug('---------- finished in %0.3fs ----------', + time.time() - start) return 0 @@ -2318,10 +2148,6 @@ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, :type quicklookurl: ``unicode`` :returns: :class:`Item` instance - See the :ref:`script-filter-results` section of the documentation - for a detailed description of what the various parameters do and how - they interact with one another. - See :ref:`icons` for a list of the supported system icons. .. note:: @@ -2390,8 +2216,7 @@ def last_version_run(self): self._last_version_run = version - self.logger.debug('Last run version : {0}'.format( - self._last_version_run)) + self.logger.debug('last run version: %s', self._last_version_run) return self._last_version_run @@ -2420,7 +2245,7 @@ def set_last_version(self, version=None): self.settings['__workflow_last_version'] = str(version) - self.logger.debug('Set last run version : {0}'.format(version)) + self.logger.debug('set last run version: %s', version) return True @@ -2430,7 +2255,7 @@ def update_available(self): .. versionadded:: 1.9 - See :ref:`manual-updates` in the :ref:`user-manual` for detailed + See :ref:`guide-updates` in the :ref:`user-manual` for detailed information on how to enable your workflow to update itself. :returns: ``True`` if an update is available, else ``False`` @@ -2441,7 +2266,7 @@ def update_available(self): update_data = Workflow().cached_data('__workflow_update_status', max_age=0) - self.logger.debug('update_data : {0}'.format(update_data)) + self.logger.debug('update_data: %r', update_data) if not update_data or not update_data.get('available'): return False @@ -2472,7 +2297,7 @@ def check_update(self, force=False): 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 + See :ref:`guide-updates` in the :ref:`user-manual` for detailed information on how to enable your workflow to update itself. :param force: Force update check @@ -2506,19 +2331,19 @@ def check_update(self, force=False): if self.prereleases: cmd.append('--prereleases') - self.logger.info('Checking for update ...') + self.logger.info('checking for update ...') run_in_background('__workflow_update_check', cmd) else: - self.logger.debug('Update check not due') + 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 + See :ref:`guide-updates` in the :ref:`user-manual` for detailed information on how to enable your workflow to update itself. :returns: ``True`` if an update is available and will be @@ -2546,7 +2371,7 @@ def start_update(self): if self.prereleases: cmd.append('--prereleases') - self.logger.debug('Downloading update ...') + self.logger.debug('downloading update ...') run_in_background('__workflow_update_install', cmd) return True @@ -2580,14 +2405,14 @@ def save_password(self, account, password, service=None): try: self._call_security('add-generic-password', service, account, '-w', password) - self.logger.debug('Saved password : %s:%s', service, account) + self.logger.debug('saved password : %s:%s', service, account) except PasswordExists: - self.logger.debug('Password exists : %s:%s', service, account) + 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') + self.logger.debug('password unchanged') else: self.delete_password(account, service) @@ -2630,7 +2455,7 @@ def get_password(self, account, service=None): if h: password = unicode(binascii.unhexlify(h), 'utf-8') - self.logger.debug('Got password : %s:%s', service, account) + self.logger.debug('got password : %s:%s', service, account) return password @@ -2652,7 +2477,7 @@ def delete_password(self, account, service=None): self._call_security('delete-generic-password', service, account) - self.logger.debug('Deleted password : %s:%s', service, account) + self.logger.debug('deleted password : %s:%s', service, account) #################################################################### # Methods for workflow:* magic args @@ -2755,7 +2580,7 @@ def list_magic(): for name in sorted(self.magic_arguments.keys()): if name == 'magic': continue - arg = '{0}{1}'.format(self.magic_prefix, name) + arg = self.magic_prefix + name self.logger.debug(arg) if not isatty: @@ -2796,7 +2621,7 @@ 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) + self.logger.debug('deleted : %r', self.settings_path) def reset(self): """Delete workflow settings, cache and data. @@ -2861,7 +2686,7 @@ def decode(self, text, encoding=None, normalization=None): 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 + macOS, on the other hand, uses "NFD" normalisation (nearly), so data coming from the system (e.g. via :mod:`subprocess` or :func:`os.listdir`/:mod:`os.path`) may not match. You should either normalise this data, too, or change the default normalisation used by @@ -2933,7 +2758,7 @@ def _delete_directory_contents(self, dirpath, filter_func): shutil.rmtree(path) else: os.unlink(path) - self.logger.debug('Deleted : %r', path) + self.logger.debug('deleted : %r', path) def _load_info_plist(self): """Load workflow info from ``info.plist``.""" diff --git a/src/workflow/workflow3.py b/src/workflow/workflow3.py index beee0ef..3ffd95b 100644 --- a/src/workflow/workflow3.py +++ b/src/workflow/workflow3.py @@ -7,21 +7,20 @@ # Created on 2016-06-25 # -""" -:class:`Workflow3` supports Alfred 3's new features. - -It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`. +"""An Alfred 3-only version of :class:`~workflow.Workflow`. -It supports setting :ref:`workflow-variables` and +:class:`~workflow.Workflow3` supports Alfred 3's new features, such as +setting :ref:`workflow-variables` and :class:`the more advanced modifiers ` supported by Alfred 3. In order for the feedback mechanism to work correctly, it's important to create :class:`Item3` and :class:`Modifier` objects via the :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods respectively. If you instantiate :class:`Item3` or :class:`Modifier` -objects directly, the current :class:`~workflow.workflow3.Workflow3` -object won't be aware of them, and they won't be sent to Alfred when -you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`. +objects directly, the current :class:`Workflow3` object won't be aware +of them, and they won't be sent to Alfred when you call +:meth:`Workflow3.send_feedback()`. + """ from __future__ import print_function, unicode_literals, absolute_import @@ -30,7 +29,7 @@ import os import sys -from .workflow import Workflow +from .workflow import ICON_WARNING, Workflow class Variables(dict): @@ -41,12 +40,20 @@ class Variables(dict): This class allows you to set workflow variables from Run Script actions. - It is a subclass of `dict`. + It is a subclass of :class:`dict`. >>> v = Variables(username='deanishe', password='hunter2') >>> v.arg = u'output value' >>> print(v) + See :ref:`variables-run-script` in the User Guide for more + information. + + Args: + arg (unicode, optional): Main output/``{query}``. + **variables: Workflow variables to set. + + Attributes: arg (unicode): Output value (``{query}``). config (dict): Configuration for downstream workflow element. @@ -54,13 +61,7 @@ class Variables(dict): """ def __init__(self, arg=None, **variables): - """Create a new `Variables` object. - - Args: - arg (unicode, optional): Main output/``{query}``. - **variables: Workflow variables to set. - - """ + """Create a new `Variables` object.""" self.arg = arg self.config = {} super(Variables, self).__init__(**variables) @@ -88,6 +89,7 @@ def __unicode__(self): Returns: unicode: ``alfredworkflow`` JSON object + """ if not self and not self.config: if self.arg: @@ -102,45 +104,76 @@ def __str__(self): Returns: str: UTF-8 encoded ``alfredworkflow`` JSON object + """ return unicode(self).encode('utf-8') class Modifier(object): - """Modify ``Item3`` values for when specified modifier keys are pressed. - - Valid modifiers (i.e. values for ``key``) are: - - * cmd - * alt - * shift - * ctrl - * fn + """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. + + Don't use this class directly (as it won't be associated with any + :class:`Item3`), but rather use :meth:`Item3.add_modifier()` + to add modifiers to results. + + >>> it = wf.add_item('Title', 'Subtitle', valid=True) + >>> it.setvar('name', 'default') + >>> m = it.add_modifier('cmd') + >>> m.setvar('name', 'alternate') + + See :ref:`workflow-variables` in the User Guide for more information + and :ref:`example usage `. + + Args: + key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (unicode, optional): Override default subtitle. + arg (unicode, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + icon (unicode, optional): Filepath/UTI of icon to use + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. Attributes: arg (unicode): Arg to pass to following action. + config (dict): Configuration for a downstream element, such as + a File Filter. + icon (unicode): Filepath/UTI of icon. + icontype (unicode): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. key (unicode): Modifier key (see above). subtitle (unicode): Override item subtitle. valid (bool): Override item validity. variables (dict): Workflow variables set by this modifier. + """ - def __init__(self, key, subtitle=None, arg=None, valid=None): + def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, + icontype=None): """Create a new :class:`Modifier`. - You probably don't want to use this class directly, but rather - use :meth:`Item3.add_modifier()` to add modifiers to results. + Don't use this class directly (as it won't be associated with any + :class:`Item3`), but rather use :meth:`Item3.add_modifier()` + to add modifiers to results. Args: key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. subtitle (unicode, optional): Override default subtitle. arg (unicode, optional): Argument to pass for this modifier. valid (bool, optional): Override item's validity. + icon (unicode, optional): Filepath/UTI of icon to use + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + """ self.key = key self.subtitle = subtitle self.arg = arg self.valid = valid + self.icon = icon + self.icontype = icontype self.config = {} self.variables = {} @@ -151,6 +184,7 @@ def setvar(self, name, value): Args: name (unicode): Name of variable. value (unicode): Value of variable. + """ self.variables[name] = value @@ -163,6 +197,7 @@ def getvar(self, name, default=None): Returns: unicode or ``default``: Value of variable if set or ``default``. + """ return self.variables.get(name, default) @@ -172,6 +207,7 @@ def obj(self): Returns: dict: Modifier for serializing to JSON. + """ o = {} @@ -184,44 +220,63 @@ def obj(self): if self.valid is not None: o['valid'] = self.valid - # Variables and config - if self.variables or self.config: - d = {} - if self.variables: - d['variables'] = self.variables - - if self.config: - d['config'] = self.config + if self.variables: + o['variables'] = self.variables - if self.arg is not None: - d['arg'] = self.arg + if self.config: + o['config'] = self.config - o['arg'] = json.dumps({'alfredworkflow': d}) + icon = self._icon() + if icon: + o['icon'] = icon return o + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + + """ + icon = {} + if self.icon is not None: + icon['path'] = self.icon + + if self.icontype is not None: + icon['type'] = self.icontype + + return icon + class Item3(object): """Represents a feedback item for Alfred 3. Generates Alfred-compliant JSON for a single item. - You probably shouldn't use this class directly, but via - :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item` - for details of arguments. + Don't use this class directly (as it then won't be associated with + any :class:`Workflow3 ` object), but rather use + :meth:`Workflow3.add_item() `. + See :meth:`~workflow.Workflow3.add_item` for details of arguments. + """ def __init__(self, title, subtitle='', arg=None, autocomplete=None, - valid=False, uid=None, icon=None, icontype=None, + match=None, valid=False, uid=None, icon=None, icontype=None, type=None, largetext=None, copytext=None, quicklookurl=None): - """Use same arguments as for :meth:`Workflow.add_item`. + """Create a new :class:`Item3` object. + + Use same arguments as for + :class:`Workflow.Item `. Argument ``subtitle_modifiers`` is not supported. + """ self.title = title self.subtitle = subtitle self.arg = arg self.autocomplete = autocomplete + self.match = match self.valid = valid self.uid = uid self.icon = icon @@ -255,10 +310,12 @@ def getvar(self, name, default=None): Returns: unicode or ``default``: Value of variable if set or ``default``. + """ return self.variables.get(name, default) - def add_modifier(self, key, subtitle=None, arg=None, valid=None): + def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, + icontype=None): """Add alternative values for a modifier key. Args: @@ -266,14 +323,19 @@ def add_modifier(self, key, subtitle=None, arg=None, valid=None): subtitle (unicode, optional): Override item subtitle. arg (unicode, optional): Input for following action. valid (bool, optional): Override item validity. + icon (unicode, optional): Filepath/UTI of icon. + icontype (unicode, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. Returns: Modifier: Configured :class:`Modifier`. + """ - mod = Modifier(key, subtitle, arg, valid) + mod = Modifier(key, subtitle, arg, valid, icon, icontype) - for k in self.variables: - mod.setvar(k, self.variables[k]) + # Add Item variables to Modifier + mod.variables.update(self.variables) self.modifiers[key] = mod @@ -285,26 +347,25 @@ def obj(self): Returns: dict: Data suitable for Alfred 3 feedback. + """ # Required values - o = {'title': self.title, - 'subtitle': self.subtitle, - 'valid': self.valid} - - icon = {} + o = { + 'title': self.title, + 'subtitle': self.subtitle, + 'valid': self.valid, + } # Optional values - - # arg & variables - v = Variables(self.arg, **self.variables) - v.config = self.config - arg = unicode(v) - if arg: - o['arg'] = arg + if self.arg is not None: + o['arg'] = self.arg if self.autocomplete is not None: o['autocomplete'] = self.autocomplete + if self.match is not None: + o['match'] = self.match + if self.uid is not None: o['uid'] = self.uid @@ -314,6 +375,12 @@ def obj(self): if self.quicklookurl is not None: o['quicklookurl'] = self.quicklookurl + if self.variables: + o['variables'] = self.variables + + if self.config: + o['config'] = self.config + # Largetype and copytext text = self._text() if text: @@ -335,6 +402,7 @@ def _icon(self): Returns: dict: Mapping for item `icon` (may be empty). + """ icon = {} if self.icon is not None: @@ -350,6 +418,7 @@ def _text(self): Returns: dict: `text` mapping (may be empty) + """ text = {} if self.largetext is not None: @@ -365,6 +434,7 @@ def _modifiers(self): Returns: dict: Modifier mapping or `None`. + """ if self.modifiers: mods = {} @@ -379,9 +449,13 @@ def _modifiers(self): class Workflow3(Workflow): """Workflow class that generates Alfred 3 feedback. + It is a subclass of :class:`~workflow.Workflow` and most of its + methods are documented there. + Attributes: item_class (class): Class used to generate feedback items. variables (dict): Top level workflow variables. + """ item_class = Item3 @@ -389,12 +463,16 @@ class Workflow3(Workflow): def __init__(self, **kwargs): """Create a new :class:`Workflow3` object. - See :class:`~workflow.workflow.Workflow` for documentation. + See :class:`~workflow.Workflow` for documentation. + """ Workflow.__init__(self, **kwargs) self.variables = {} self._rerun = 0 - self._session_id = None + # Get session ID from environment if present + self._session_id = os.getenv('_WF_SESSION_ID') or None + if self._session_id: + self.setvar('_WF_SESSION_ID', self._session_id) @property def _default_cachedir(self): @@ -438,13 +516,9 @@ def session_id(self): """ if not self._session_id: - sid = os.getenv('_WF_SESSION_ID') - if not sid: - from uuid import uuid4 - sid = uuid4().hex - self.setvar('_WF_SESSION_ID', sid) - - self._session_id = sid + from uuid import uuid4 + self._session_id = uuid4().hex + self.setvar('_WF_SESSION_ID', self._session_id) return self._session_id @@ -459,6 +533,7 @@ def setvar(self, name, value): Args: name (unicode): Name of variable. value (unicode): Value of variable. + """ self.variables[name] = value @@ -471,16 +546,22 @@ def getvar(self, name, default=None): Returns: unicode or ``default``: Value of variable if set or ``default``. + """ return self.variables.get(name, default) def add_item(self, title, subtitle='', arg=None, autocomplete=None, - valid=False, uid=None, icon=None, icontype=None, - type=None, largetext=None, copytext=None, quicklookurl=None): + valid=False, uid=None, icon=None, icontype=None, type=None, + largetext=None, copytext=None, quicklookurl=None, match=None): """Add an item to be output to Alfred. - See :meth:`~workflow.workflow.Workflow.add_item` for the main - documentation. + Args: + match (unicode, optional): If you have "Alfred filters results" + turned on for your Script Filter, Alfred (version 3.5 and + above) will filter against this field, not ``title``. + + See :meth:`Workflow.add_item() ` for + the main documentation and other parameters. The key difference is that this method does not support the ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` @@ -488,17 +569,26 @@ def add_item(self, title, subtitle='', arg=None, autocomplete=None, Returns: Item3: Alfred feedback item. + """ - item = self.item_class(title, subtitle, arg, - autocomplete, valid, uid, icon, icontype, type, + item = self.item_class(title, subtitle, arg, autocomplete, + match, valid, uid, icon, icontype, type, largetext, copytext, quicklookurl) + # Add variables to child item + item.variables.update(self.variables) + self._items.append(item) return item + @property + def _session_prefix(self): + """Filename prefix for current session.""" + return '_wfsess-{0}-'.format(self.session_id) + def _mk_session_name(self, name): """New cache name/key based on session ID.""" - return '_wfsess-{0}-{1}'.format(self.session_id, name) + return self._session_prefix + name def cache_data(self, name, data, session=False): """Cache API with session-scoped expiry. @@ -511,11 +601,11 @@ def cache_data(self, name, data, session=False): session (bool, optional): Whether to scope the cache to the current session. - ``name`` and ``data`` are as for the - :meth:`~workflow.workflow.Workflow.cache_data` on - :class:`~workflow.workflow.Workflow`. + ``name`` and ``data`` are the same as for the + :meth:`~workflow.Workflow.cache_data` method on + :class:`~workflow.Workflow`. - If ``session`` is ``True``, the ``name`` variable is prefixed + If ``session`` is ``True``, then ``name`` is prefixed with :attr:`session_id`. """ @@ -537,11 +627,11 @@ def cached_data(self, name, data_func=None, max_age=60, session=False): session (bool, optional): Whether to scope the cache to the current session. - ``name``, ``data_func`` and ``max_age`` are as for the - :meth:`~workflow.workflow.Workflow.cached_data` on - :class:`~workflow.workflow.Workflow`. + ``name``, ``data_func`` and ``max_age`` are the same as for the + :meth:`~workflow.Workflow.cached_data` method on + :class:`~workflow.Workflow`. - If ``session`` is ``True``, the ``name`` variable is prefixed + If ``session`` is ``True``, then ``name`` is prefixed with :attr:`session_id`. """ @@ -550,13 +640,25 @@ def cached_data(self, name, data_func=None, max_age=60, session=False): return super(Workflow3, self).cached_data(name, data_func, max_age) - def clear_session_cache(self): - """Remove *all* session data from the cache. + def clear_session_cache(self, current=False): + """Remove session data from the cache. .. versionadded:: 1.25 + .. versionchanged:: 1.27 + + By default, data belonging to the current session won't be + deleted. Set ``current=True`` to also clear current session. + + Args: + current (bool, optional): If ``True``, also remove data for + current session. + """ def _is_session_file(filename): - return filename.startswith('_wfsess-') + if current: + return filename.startswith('_wfsess-') + return filename.startswith('_wfsess-') \ + and not filename.startswith(self._session_prefix) self.clear_cache(_is_session_file) @@ -566,6 +668,7 @@ def obj(self): Returns: dict: Data suitable for Alfred 3 feedback. + """ items = [] for item in self._items: @@ -578,6 +681,31 @@ def obj(self): o['rerun'] = self.rerun return o + def warn_empty(self, title, subtitle=u'', icon=None): + """Add a warning to feedback if there are no items. + + .. versionadded:: 1.31 + + Add a "warning" item to Alfred feedback if no other items + have been added. This is a handy shortcut to prevent Alfred + from showing its fallback searches, which is does if no + items are returned. + + Args: + title (unicode): Title of feedback item. + subtitle (unicode, optional): Subtitle of feedback item. + icon (str, optional): Icon for feedback item. If not + specified, ``ICON_WARNING`` is used. + + Returns: + Item3: Newly-created item. + """ + if len(self._items): + return + + icon = icon or ICON_WARNING + return self.add_item(title, subtitle, icon=icon) + def send_feedback(self): """Print stored items to console/Alfred as JSON.""" json.dump(self.obj, sys.stdout)