diff --git a/codebox.py b/codebox.py index b2e7012..473a7f3 100644 --- a/codebox.py +++ b/codebox.py @@ -8,7 +8,7 @@ from collections import Counter import difflib -__version__ = '0.2' +__version__ = '0.3' def main(wf): arg = ' '.join(wf.args) diff --git a/set_source.py b/set_source.py index 605c20b..d672b0b 100644 --- a/set_source.py +++ b/set_source.py @@ -1,5 +1,4 @@ -#!/usr/bin/python -# encoding: utf-8 +#!/usr/bin/env python import sys import os @@ -10,8 +9,8 @@ def main(wf): wf.add_item("To ensure your file is found, make sure it ends in '.cbxml'", icon="info.png") out, err = Popen(["mdfind","-name",".cbxml"], stdout=PIPE, stderr=PIPE).communicate() out = out.split("\n") - log.debug(out) for i in out: + i = i.decode("utf-8") wf.add_item(os.path.split(i)[1],i, arg=i, valid=True) wf.send_feedback() diff --git a/workflow/__init__.py b/workflow/__init__.py index 2c4f8c0..17636a4 100644 --- a/workflow/__init__.py +++ b/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-2017 Dean Jackson' +__copyright__ = 'Copyright 2014-2019 Dean Jackson' __all__ = [ 'Variables', diff --git a/workflow/__init__.pyc b/workflow/__init__.pyc index c0270d9..cf4f42a 100644 Binary files a/workflow/__init__.pyc and b/workflow/__init__.pyc differ diff --git a/workflow/background.py b/workflow/background.py index cd5400b..ba5c52a 100644 --- a/workflow/background.py +++ b/workflow/background.py @@ -8,8 +8,8 @@ # Created on 2014-04-06 # -""" -This module provides an API to run commands in background processes. +"""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. diff --git a/workflow/background.pyc b/workflow/background.pyc index 731a664..1f59780 100644 Binary files a/workflow/background.pyc and b/workflow/background.pyc differ diff --git a/workflow/notify.py b/workflow/notify.py index 4542c78..a4b7f40 100644 --- a/workflow/notify.py +++ b/workflow/notify.py @@ -11,9 +11,10 @@ # TODO: Exclude this module from test and code coverage in py2.6 """ -Post notifications via the macOS Notification Center. This feature -is only available on Mountain Lion (10.8) and later. It will -silently fail on older systems. +Post notifications via the macOS Notification Center. + +This feature is only available on Mountain Lion (10.8) and later. +It will silently fail on older systems. The main API is a single function, :func:`~workflow.notify.notify`. @@ -198,7 +199,7 @@ def notify(title='', text='', sound=None): env = os.environ.copy() enc = 'utf-8' env['NOTIFY_TITLE'] = title.encode(enc) - env['NOTIFY_MESSAGE'] = text.encode(enc) + env['NOTIFY_MESSAGE'] = text.encode(enc) env['NOTIFY_SOUND'] = sound.encode(enc) cmd = [n] retcode = subprocess.call(cmd, env=env) diff --git a/workflow/update.py b/workflow/update.py index 37569bb..6affc94 100644 --- a/workflow/update.py +++ b/workflow/update.py @@ -23,6 +23,9 @@ from __future__ import print_function, unicode_literals +from collections import defaultdict +from functools import total_ordering +import json import os import tempfile import re @@ -34,8 +37,8 @@ # __all__ = [] -RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' - +RELEASES_BASE = 'https://api.github.com/repos/{}/releases' +match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search _wf = None @@ -48,6 +51,153 @@ def wf(): return _wf +@total_ordering +class Download(object): + """A workflow file that is available for download. + + .. versionadded: 1.37 + + Attributes: + url (str): URL of workflow file. + filename (str): Filename of workflow file. + version (Version): Semantic version of workflow. + prerelease (bool): Whether version is a pre-release. + alfred_version (Version): Minimum compatible version + of Alfred. + + """ + + @classmethod + def from_dict(cls, d): + """Create a `Download` from a `dict`.""" + return cls(url=d['url'], filename=d['filename'], + version=Version(d['version']), + prerelease=d['prerelease']) + + @classmethod + def from_releases(cls, js): + """Extract downloads from GitHub releases. + + Searches releases with semantic tags for assets with + file extension .alfredworkflow or .alfredXworkflow where + X is a number. + + Files are returned sorted by latest version first. Any + releases containing multiple files with the same (workflow) + extension are rejected as ambiguous. + + Args: + js (str): JSON response from GitHub's releases endpoint. + + Returns: + list: Sequence of `Download`. + """ + releases = json.loads(js) + downloads = [] + for release in releases: + tag = release['tag_name'] + dupes = defaultdict(int) + try: + version = Version(tag) + except ValueError as err: + wf().logger.debug('ignored release: bad version "%s": %s', + tag, err) + continue + + dls = [] + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + filename = os.path.basename(url) + m = match_workflow(filename) + if not m: + wf().logger.debug('unwanted file: %s', filename) + continue + + ext = m.group(0) + dupes[ext] = dupes[ext] + 1 + dls.append(Download(url, filename, version, + release['prerelease'])) + + valid = True + for ext, n in dupes.items(): + if n > 1: + wf().logger.debug('ignored release "%s": multiple assets ' + 'with extension "%s"', tag, ext) + valid = False + break + + if valid: + downloads.extend(dls) + + downloads.sort(reverse=True) + return downloads + + def __init__(self, url, filename, version, prerelease=False): + """Create a new Download. + + Args: + url (str): URL of workflow file. + filename (str): Filename of workflow file. + version (Version): Version of workflow. + prerelease (bool, optional): Whether version is + pre-release. Defaults to False. + + """ + if isinstance(version, basestring): + version = Version(version) + + self.url = url + self.filename = filename + self.version = version + self.prerelease = prerelease + + @property + def alfred_version(self): + """Minimum Alfred version based on filename extension.""" + m = match_workflow(self.filename) + if not m or not m.group(1): + return Version('0') + return Version(m.group(1)) + + @property + def dict(self): + """Convert `Download` to `dict`.""" + return dict(url=self.url, filename=self.filename, + version=str(self.version), prerelease=self.prerelease) + + def __str__(self): + """Format `Download` for printing.""" + u = ('Download(url={dl.url!r}, ' + 'filename={dl.filename!r}, ' + 'version={dl.version!r}, ' + 'prerelease={dl.prerelease!r})'.format(dl=self)) + + return u.encode('utf-8') + + def __repr__(self): + """Code-like representation of `Download`.""" + return str(self) + + def __eq__(self, other): + """Compare Downloads based on version numbers.""" + if self.url != other.url \ + or self.filename != other.filename \ + or self.version != other.version \ + or self.prerelease != other.prerelease: + return False + return True + + def __ne__(self, other): + """Compare Downloads based on version numbers.""" + return not self.__eq__(other) + + def __lt__(self, other): + """Compare Downloads based on version numbers.""" + if self.version != other.version: + return self.version < other.version + return self.alfred_version < other.alfred_version + + class Version(object): """Mostly semantic versioning. @@ -72,7 +222,7 @@ class Version(object): """ #: Match version and pre-release/build information in version strings - match_version = re.compile(r'([0-9\.]+)(.+)?').match + match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match def __init__(self, vstr): """Create new `Version` object. @@ -80,6 +230,9 @@ def __init__(self, vstr): Args: vstr (basestring): Semantic version string. """ + if not vstr: + raise ValueError('invalid version number: {!r}'.format(vstr)) + self.vstr = vstr self.major = 0 self.minor = 0 @@ -94,7 +247,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: ' + vstr) version, suffix = m.groups() parts = self._parse_dotted_string(version) @@ -104,7 +257,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('version number too long: ' + vstr) if suffix: # Build info @@ -115,11 +268,9 @@ def _parse(self, vstr): if suffix: if not suffix.startswith('-'): raise ValueError( - 'suffix must start with - : {0}'.format(suffix)) + 'suffix must start with - : ' + 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 = [] @@ -148,8 +299,8 @@ def __lt__(self, other): return True if other.suffix and not self.suffix: return False - return (self._parse_dotted_string(self.suffix) < - self._parse_dotted_string(other.suffix)) + return self._parse_dotted_string(self.suffix) \ + < self._parse_dotted_string(other.suffix) # t > o return False @@ -193,183 +344,151 @@ def __repr__(self): return "Version('{0}')".format(str(self)) -def download_workflow(url): - """Download workflow at ``url`` to a local temporary file. +def retrieve_download(dl): + """Saves a download to a temporary file and returns path. - :param url: URL to .alfredworkflow file in GitHub repo - :returns: path to downloaded file + .. versionadded: 1.37 - """ - filename = url.split('/')[-1] - - if (not filename.endswith('.alfredworkflow') and - not filename.endswith('.alfred3workflow')): - raise ValueError('attachment not a workflow: {0}'.format(filename)) - - local_path = os.path.join(tempfile.gettempdir(), filename) - - wf().logger.debug( - 'downloading updated workflow from `%s` to `%s` ...', url, local_path) - - response = web.get(url) + Args: + url (unicode): URL to .alfredworkflow file in GitHub repo - 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 + Returns: + unicode: path to downloaded file """ - if len(slug.split('/')) != 2: - raise ValueError('invalid GitHub slug: {0}'.format(slug)) + if not match_workflow(dl.filename): + raise ValueError('attachment not a workflow: ' + dl.filename) - return RELEASES_BASE.format(slug) + path = os.path.join(tempfile.gettempdir(), dl.filename) + wf().logger.debug('downloading update from ' + '%r to %r ...', dl.url, path) + r = web.get(dl.url) + r.raise_for_status() -def _validate_release(release): - """Return release for running version of Alfred.""" - alf3 = wf().alfred_version.major == 3 + r.save_to_path(path) - downloads = {'.alfredworkflow': [], '.alfred3workflow': []} - dl_count = 0 - version = release['tag_name'] + return path - for asset in release.get('assets', []): - url = asset.get('browser_download_url') - if not url: # pragma: nocover - continue - ext = os.path.splitext(url)[1].lower() - if ext not in downloads: - continue - - # Ignore Alfred 3-only files if Alfred 2 is running - if ext == '.alfred3workflow' and not alf3: - continue - - downloads[ext].append(url) - dl_count += 1 - - # download_urls.append(url) +def build_api_url(repo): + """Generate releases URL from GitHub repo. - if dl_count == 0: - wf().logger.warning( - 'invalid release (no workflow file): %s', version) - return None + Args: + repo (unicode): Repo name in form ``username/repo`` - for k in downloads: - if len(downloads[k]) > 1: - wf().logger.warning( - 'invalid release (multiple %s files): %s', k, version) - return None + Returns: + unicode: URL to the API endpoint for the repo's releases - # Prefer .alfred3workflow file if there is one and Alfred 3 is - # running. - if alf3 and len(downloads['.alfred3workflow']): - download_url = downloads['.alfred3workflow'][0] - - else: - download_url = downloads['.alfredworkflow'][0] - - wf().logger.debug('release %s: %s', version, download_url) + """ + if len(repo.split('/')) != 2: + raise ValueError('invalid GitHub repo: {!r}'.format(repo)) - return { - 'version': version, - 'download_url': download_url, - 'prerelease': release['prerelease'] - } + return RELEASES_BASE.format(repo) -def get_valid_releases(github_slug, prereleases=False): - """Return list of all valid releases. +def get_downloads(repo): + """Load available ``Download``s for GitHub repo. - :param github_slug: ``username/repo`` for workflow's GitHub repo - :param prereleases: Whether to include pre-releases. - :returns: list of dicts. Each :class:`dict` has the form - ``{'version': '1.1', 'download_url': 'http://github.com/...', - 'prerelease': False }`` + .. versionadded: 1.37 + Args: + repo (unicode): GitHub repo to load releases for. - A valid release is one that contains one ``.alfredworkflow`` file. + Returns: + list: Sequence of `Download` contained in GitHub releases. + """ + url = build_api_url(repo) - If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading - ``v`` will be stripped. + def _fetch(): + wf().logger.info('retrieving releases for %r ...', repo) + r = web.get(url) + r.raise_for_status() + return r.content - """ - api_url = build_api_url(github_slug) - releases = [] + key = 'github-releases-' + repo.replace('/', '-') + js = wf().cached_data(key, _fetch, max_age=60) - wf().logger.debug('retrieving releases list: %s', api_url) + return Download.from_releases(js) - def retrieve_releases(): - wf().logger.info( - 'retrieving releases: %s', github_slug) - return web.get(api_url).json() - slug = github_slug.replace('/', '-') - for release in wf().cached_data('gh-releases-' + slug, retrieve_releases): +def latest_download(dls, alfred_version=None, prereleases=False): + """Return newest `Download`.""" + alfred_version = alfred_version or os.getenv('alfred_version') + version = None + if alfred_version: + version = Version(alfred_version) - release = _validate_release(release) - if release is None: - wf().logger.debug('invalid release: %r', release) + dls.sort(reverse=True) + for dl in dls: + if dl.prerelease and not prereleases: + wf().logger.debug('ignored prerelease: %s', dl.version) continue - - elif release['prerelease'] and not prereleases: - wf().logger.debug('ignoring prerelease: %s', release['version']) + if version and dl.alfred_version > version: + wf().logger.debug('ignored incompatible (%s > %s): %s', + dl.alfred_version, version, dl.filename) continue - wf().logger.debug('release: %r', release) + wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) + return dl - releases.append(release) + return None - return releases - -def check_update(github_slug, current_version, prereleases=False): +def check_update(repo, current_version, prereleases=False, + alfred_version=None): """Check whether a newer release is available on GitHub. - :param github_slug: ``username/repo`` for workflow's GitHub repo - :param current_version: the currently installed version of the - workflow. :ref:`Semantic versioning ` is required. - :param prereleases: Whether to include pre-releases. - :type current_version: ``unicode`` - :returns: ``True`` if an update is available, else ``False`` + Args: + repo (unicode): ``username/repo`` for workflow's GitHub repo + current_version (unicode): the currently installed version of the + workflow. :ref:`Semantic versioning ` is required. + prereleases (bool): Whether to include pre-releases. + alfred_version (unicode): version of currently-running Alfred. + if empty, defaults to ``$alfred_version`` environment variable. + + Returns: + bool: ``True`` if an update is available, else ``False`` If an update is available, its version number and download URL will be cached. """ - releases = get_valid_releases(github_slug, prereleases) + key = '__workflow_latest_version' + # data stored when no update is available + no_update = { + 'available': False, + 'download': None, + 'version': None, + } + current = Version(current_version) - if not len(releases): - raise ValueError('no valid releases for %s', github_slug) + dls = get_downloads(repo) + if not len(dls): + wf().logger.warning('no valid downloads for %s', repo) + wf().cache_data(key, no_update) + return False - wf().logger.info('%d releases for %s', len(releases), github_slug) + wf().logger.info('%d download(s) for %s', len(dls), repo) - # GitHub returns releases newest-first - latest_release = releases[0] + dl = latest_download(dls, alfred_version, prereleases) - # (latest_version, download_url) = get_latest_release(releases) - vr = Version(latest_release['version']) - vl = Version(current_version) - wf().logger.debug('latest=%r, installed=%r', vr, vl) - if vr > vl: + if not dl: + wf().logger.warning('no compatible downloads for %s', repo) + wf().cache_data(key, no_update) + return False - wf().cache_data('__workflow_update_status', { - 'version': latest_release['version'], - 'download_url': latest_release['download_url'], - 'available': True - }) + wf().logger.debug('latest=%r, installed=%r', dl.version, current) + if dl.version > current: + wf().cache_data(key, { + 'version': str(dl.version), + 'download': dl.dict, + 'available': True, + }) return True - wf().cache_data('__workflow_update_status', {'available': False}) + wf().cache_data(key, no_update) return False @@ -379,48 +498,68 @@ def install_update(): :returns: ``True`` if an update is installed, else ``False`` """ - update_data = wf().cached_data('__workflow_update_status', max_age=0) + key = '__workflow_latest_version' + # data stored when no update is available + no_update = { + 'available': False, + 'download': None, + 'version': None, + } + status = wf().cached_data(key, max_age=0) - if not update_data or not update_data.get('available'): + if not status or not status.get('available'): wf().logger.info('no update available') return False - local_file = download_workflow(update_data['download_url']) + dl = status.get('download') + if not dl: + wf().logger.info('no download information') + return False + + path = retrieve_download(Download.from_dict(dl)) wf().logger.info('installing updated workflow ...') - subprocess.call(['open', local_file]) + subprocess.call(['open', path]) - update_data['available'] = False - wf().cache_data('__workflow_update_status', update_data) + wf().cache_data(key, no_update) return True if __name__ == '__main__': # pragma: nocover import sys + prereleases = False + def show_help(status=0): """Print help message.""" - print('Usage : update.py (check|install) ' - '[--prereleases] ') + 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: + if '--prereleases' in argv: argv.remove('--prereleases') + prereleases = True if len(argv) != 4: show_help(1) - action, github_slug, version = argv[1:] + action = argv[1] + repo = argv[2] + version = argv[3] - if action == 'check': - check_update(github_slug, version, prereleases) - elif action == 'install': - install_update() - else: - show_help(1) + try: + + if action == 'check': + check_update(repo, version, prereleases) + elif action == 'install': + install_update() + else: + show_help(1) + + except Exception as err: # ensure traceback is in log file + wf().logger.exception(err) + raise err diff --git a/workflow/update.pyc b/workflow/update.pyc index 10519f6..9d6f950 100644 Binary files a/workflow/update.pyc and b/workflow/update.pyc differ diff --git a/workflow/version b/workflow/version index 476d2ce..673b6a6 100644 --- a/workflow/version +++ b/workflow/version @@ -1 +1 @@ -1.29 \ No newline at end of file +1.37.2 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py index d64bb6f..0781911 100644 --- a/workflow/web.py +++ b/workflow/web.py @@ -24,7 +24,7 @@ import zlib -USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' +USER_AGENT = u'Alfred-Workflow/1.36 (+http://www.deanishe.net/alfred-workflow)' # Valid characters for multipart form data boundaries BOUNDARY_CHARS = string.digits + string.ascii_letters @@ -100,6 +100,7 @@ class NoRedirectHandler(urllib2.HTTPRedirectHandler): """Prevent redirections.""" def redirect_request(self, *args): + """Ignore redirect.""" return None @@ -136,6 +137,7 @@ def __setitem__(self, key, value): return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) def get(self, key, default=None): + """Return value for case-insensitive key or default.""" try: v = dict.__getitem__(self, key.lower()) except KeyError: @@ -144,27 +146,34 @@ def get(self, key, default=None): return v['val'] def update(self, other): + """Update values from other ``dict``.""" for k, v in other.items(): self[k] = v def items(self): + """Return ``(key, value)`` pairs.""" return [(v['key'], v['val']) for v in dict.itervalues(self)] def keys(self): + """Return original keys.""" return [v['key'] for v in dict.itervalues(self)] def values(self): + """Return all values.""" return [v['val'] for v in dict.itervalues(self)] def iteritems(self): + """Iterate over ``(key, value)`` pairs.""" for v in dict.itervalues(self): yield v['key'], v['val'] def iterkeys(self): + """Iterate over original keys.""" for v in dict.itervalues(self): yield v['key'] def itervalues(self): + """Interate over values.""" for v in dict.itervalues(self): yield v['val'] @@ -240,8 +249,8 @@ def __init__(self, request, stream=False): # 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', '')): + if 'gzip' in headers.get('content-encoding', '') or \ + 'gzip' in headers.get('transfer-encoding', ''): self._gzipped = True @property @@ -250,6 +259,7 @@ def stream(self): Returns: bool: `True` if response is streamed. + """ return self._stream @@ -343,20 +353,18 @@ def iter_content(self, chunk_size=4096, decode_unicode=False): "`content` has already been read from this Response.") def decode_stream(iterator, r): - - decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') + dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') for chunk in iterator: - data = decoder.decode(chunk) + data = dec.decode(chunk) if data: yield data - data = decoder.decode(b'', final=True) + data = dec.decode(b'', final=True) if data: # pragma: no cover yield data def generate(): - if self._gzipped: decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) @@ -427,15 +435,15 @@ def _get_encoding(self): if not self.stream: # Try sniffing response content # Encoding declared in document should override HTTP headers if self.mimetype == 'text/html': # sniff HTML headers - m = re.search("""""", + m = re.search(r"""""", self.content) if m: encoding = m.group(1) - elif ((self.mimetype.startswith('application/') or - self.mimetype.startswith('text/')) and - 'xml' in self.mimetype): - m = re.search("""]*\?>""", + elif ((self.mimetype.startswith('application/') + or self.mimetype.startswith('text/')) + and 'xml' in self.mimetype): + m = re.search(r"""]*\?>""", self.content) if m: encoding = m.group(1) @@ -628,7 +636,6 @@ def get_content_type(filename): :rtype: str """ - return mimetypes.guess_type(filename)[0] or 'application/octet-stream' boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) diff --git a/workflow/web.pyc b/workflow/web.pyc index f846d00..ab69230 100644 Binary files a/workflow/web.pyc and b/workflow/web.pyc differ diff --git a/workflow/workflow.py b/workflow/workflow.py index 488ae3c..2a057b0 100644 --- a/workflow/workflow.py +++ b/workflow/workflow.py @@ -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,13 @@ except ImportError: # pragma: no cover import xml.etree.ElementTree as ET +# imported to maintain API +from util import AcquisitionError # noqa: F401 +from util import ( + atomic_writer, + LockFile, + uninterruptible, +) #: Sentinel for properties that haven't been set yet (that might #: correctly have the value ``None``) @@ -443,12 +446,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. @@ -799,205 +799,6 @@ def elem(self): return root -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 (int, 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. - - """ - - 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(str(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. - - .. 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 file_path: path of file to write to. - :type file_path: ``unicode`` - :param mode: sames as for :func:`open` - :type mode: string - - """ - 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 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 - 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. @@ -1031,13 +832,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 @@ -1050,13 +853,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 @@ -1090,9 +893,9 @@ class Workflow(object): 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. + ``Workflow`` is compatible with Alfred 2+. Subclass + :class:`~workflow.Workflow3` provides additional features, + only available in Alfred 3+, such as workflow variables. :param default_settings: default workflow settings. If no settings file exists, :class:`Workflow.settings` will be pre-populated with @@ -1163,8 +966,9 @@ def __init__(self, default_settings=None, update_settings=None, 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:`` + #: Prefix for all magic arguments. + #: The default value is ``workflow:`` so keyword + #: ``config`` would match user query ``workflow:config``. self.magic_prefix = 'workflow:' #: Mapping of available magic arguments. The built-in magic #: arguments are registered by default. To add your own magic arguments @@ -1248,31 +1052,30 @@ def alfred_env(self): data = {} for key in ( - 'alfred_debug', - 'alfred_preferences', - 'alfred_preferences_localhash', - 'alfred_theme', - 'alfred_theme_background', - 'alfred_theme_subtext', - 'alfred_version', - 'alfred_version_build', - 'alfred_workflow_bundleid', - 'alfred_workflow_cache', - 'alfred_workflow_data', - 'alfred_workflow_name', - 'alfred_workflow_uid', - 'alfred_workflow_version'): - - value = os.getenv(key) - - if isinstance(value, str): - if key in ('alfred_debug', 'alfred_version_build', - 'alfred_theme_subtext'): + 'debug', + 'preferences', + 'preferences_localhash', + 'theme', + 'theme_background', + 'theme_subtext', + 'version', + 'version_build', + 'workflow_bundleid', + 'workflow_cache', + 'workflow_data', + 'workflow_name', + 'workflow_uid', + 'workflow_version'): + + value = os.getenv('alfred_' + key, '') + + if value: + if key in ('debug', 'version_build', 'theme_subtext'): value = int(value) else: value = self.decode(value) - data[key[7:]] = value + data[key] = value self._alfred_env = data @@ -1309,12 +1112,7 @@ def debugging(self): :rtype: ``bool`` """ - if self._debugging is None: - if self.alfred_env.get('debug') == 1: - self._debugging = True - else: - self._debugging = False - return self._debugging + return self.alfred_env.get('debug') == 1 @property def name(self): @@ -1423,14 +1221,18 @@ def cachedir(self): """Path to workflow's cache directory. The cache directory is a subdirectory of Alfred's own cache directory - in ``~/Library/Caches``. The full path is: + in ``~/Library/Caches``. The full path is in Alfred 4+ is: + + ``~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/`` + + For earlier versions: ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` - ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. + where ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. - :returns: full path to workflow's cache directory - :rtype: ``unicode`` + Returns: + unicode: full path to workflow's cache directory """ if self.alfred_env.get('workflow_cache'): @@ -1455,12 +1257,18 @@ 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``. The full path for Alfred 4+ is: - ``~/Library/Application Support/Alfred 2/Workflow Data/`` + ``~/Library/Application Support/Alfred/Workflow Data/`` - :returns: full path to workflow data directory - :rtype: ``unicode`` + For earlier versions, the path is: + + ``~/Library/Application Support/Alfred X/Workflow Data/`` + + where ``Alfred X` is ``Alfred 2`` or ``Alfred 3``. + + Returns: + unicode: full path to workflow data directory """ if self.alfred_env.get('workflow_data'): @@ -1482,8 +1290,8 @@ def _default_datadir(self): def workflowdir(self): """Path to workflow's root directory (where ``info.plist`` is). - :returns: full path to workflow root directory - :rtype: ``unicode`` + Returns: + unicode: full path to workflow root directory """ if not self._workflowdir: @@ -1586,9 +1394,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' @@ -2239,6 +2050,9 @@ 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: @@ -2453,17 +2267,16 @@ def update_available(self): :returns: ``True`` if an update is available, else ``False`` """ + key = '__workflow_latest_version' # Create a new workflow object to ensure standard serialiser # is used (update.py is called without the user's settings) - update_data = Workflow().cached_data('__workflow_update_status', - max_age=0) - - self.logger.debug('update_data: %r', update_data) + status = Workflow().cached_data(key, max_age=0) - if not update_data or not update_data.get('available'): + # self.logger.debug('update status: %r', status) + if not status or not status.get('available'): return False - return update_data['available'] + return status['available'] @property def prereleases(self): @@ -2496,6 +2309,7 @@ def check_update(self, force=False): :type force: ``Boolean`` """ + key = '__workflow_latest_version' frequency = self._update_settings.get('frequency', DEFAULT_UPDATE_FREQUENCY) @@ -2504,10 +2318,9 @@ def check_update(self, force=False): return # Check for new version if it's time - if (force or not self.cached_data_fresh( - '__workflow_update_status', frequency * 86400)): + if (force or not self.cached_data_fresh(key, frequency * 86400)): - github_slug = self._update_settings['github_slug'] + repo = self._update_settings['github_slug'] # version = self._update_settings['version'] version = str(self.version) @@ -2517,8 +2330,7 @@ def check_update(self, force=False): update_script = os.path.join(os.path.dirname(__file__), b'update.py') - cmd = ['/usr/bin/python', update_script, 'check', github_slug, - version] + cmd = ['/usr/bin/python', update_script, 'check', repo, version] if self.prereleases: cmd.append('--prereleases') @@ -2544,11 +2356,11 @@ def start_update(self): """ import update - github_slug = self._update_settings['github_slug'] + repo = self._update_settings['github_slug'] # version = self._update_settings['version'] version = str(self.version) - if not update.check_update(github_slug, version, self.prereleases): + if not update.check_update(repo, version, self.prereleases): return False from background import run_in_background @@ -2557,8 +2369,7 @@ def start_update(self): update_script = os.path.join(os.path.dirname(__file__), b'update.py') - cmd = ['/usr/bin/python', update_script, 'install', github_slug, - version] + cmd = ['/usr/bin/python', update_script, 'install', repo, version] if self.prereleases: cmd.append('--prereleases') diff --git a/workflow/workflow.pyc b/workflow/workflow.pyc index fa1b39d..c5dd636 100644 Binary files a/workflow/workflow.pyc and b/workflow/workflow.pyc differ diff --git a/workflow/workflow3.py b/workflow/workflow3.py index e800b60..b92c4be 100644 --- a/workflow/workflow3.py +++ b/workflow/workflow3.py @@ -7,11 +7,11 @@ # Created on 2016-06-25 # -"""An Alfred 3-only version of :class:`~workflow.Workflow`. +"""An Alfred 3+ version of :class:`~workflow.Workflow`. -:class:`~workflow.Workflow3` supports Alfred 3's new features, such as +:class:`~workflow.Workflow3` supports new features, such as setting :ref:`workflow-variables` and -:class:`the more advanced modifiers ` supported by Alfred 3. +: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 @@ -29,7 +29,7 @@ import os import sys -from .workflow import Workflow +from .workflow import ICON_WARNING, Workflow class Variables(dict): @@ -250,7 +250,7 @@ def _icon(self): class Item3(object): - """Represents a feedback item for Alfred 3. + """Represents a feedback item for Alfred 3+. Generates Alfred-compliant JSON for a single item. @@ -334,8 +334,8 @@ def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, """ 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 @@ -447,10 +447,10 @@ def _modifiers(self): class Workflow3(Workflow): - """Workflow class that generates Alfred 3 feedback. + """Workflow class that generates Alfred 3+ feedback. - ``Workflow3`` is a subclass of :class:`~workflow.Workflow` and - most of its methods are documented there. + 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. @@ -476,18 +476,18 @@ def __init__(self, **kwargs): @property def _default_cachedir(self): - """Alfred 3's default cache directory.""" + """Alfred 4's default cache directory.""" return os.path.join( os.path.expanduser( - '~/Library/Caches/com.runningwithcrayons.Alfred-3/' + '~/Library/Caches/com.runningwithcrayons.Alfred/' 'Workflow Data/'), self.bundleid) @property def _default_datadir(self): - """Alfred 3's default data directory.""" + """Alfred 4's default data directory.""" return os.path.join(os.path.expanduser( - '~/Library/Application Support/Alfred 3/Workflow Data/'), + '~/Library/Application Support/Alfred/Workflow Data/'), self.bundleid) @property @@ -522,9 +522,11 @@ def session_id(self): return self._session_id - def setvar(self, name, value): + def setvar(self, name, value, persist=False): """Set a "global" workflow variable. + .. versionchanged:: 1.33 + These variables are always passed to downstream workflow objects. If you have set :attr:`rerun`, these variables are also passed @@ -533,9 +535,15 @@ def setvar(self, name, value): Args: name (unicode): Name of variable. value (unicode): Value of variable. + persist (bool, optional): Also save variable to ``info.plist``? """ self.variables[name] = value + if persist: + from .util import set_config + set_config(name, value, self.bundleid) + self.logger.debug('saved variable %r with value %r to info.plist', + name, value) def getvar(self, name, default=None): """Return value of workflow variable for ``name`` or ``default``. @@ -575,6 +583,9 @@ def add_item(self, title, subtitle='', arg=None, autocomplete=None, 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 @@ -678,6 +689,32 @@ 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)