#!/usr/bin/env python # encoding: utf-8 # # Copyright (c) 2014 Fabio Niephaus <fabio.niephaus@gmail.com>, # Dean Jackson <deanishe@deanishe.net> # # MIT Licence. See http://opensource.org/licenses/MIT # # Created on 2014-08-16 # """Self-updating from GitHub. .. versionadded:: 1.9 .. note:: This module is not intended to be used directly. Automatic updates are controlled by the ``update_settings`` :class:`dict` passed to :class:`~workflow.workflow.Workflow` objects. """ from __future__ import print_function, unicode_literals from collections import defaultdict from functools import total_ordering import json import os import tempfile import re import subprocess import workflow import web # __all__ = [] RELEASES_BASE = 'https://api.github.com/repos/{}/releases' match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search _wf = None def wf(): """Lazy `Workflow` object.""" global _wf if _wf is None: _wf = workflow.Workflow() return _wf @total_ordering class Download(object): """A workflow file that is available for download. .. versionadded: 1.37 Attributes: url (str): URL of workflow file. filename (str): Filename of workflow file. version (Version): Semantic version of workflow. prerelease (bool): Whether version is a pre-release. alfred_version (Version): Minimum compatible version of Alfred. """ @classmethod def from_dict(cls, d): """Create a `Download` from a `dict`.""" return cls(url=d['url'], filename=d['filename'], version=Version(d['version']), prerelease=d['prerelease']) @classmethod def from_releases(cls, js): """Extract downloads from GitHub releases. Searches releases with semantic tags for assets with file extension .alfredworkflow or .alfredXworkflow where X is a number. Files are returned sorted by latest version first. Any releases containing multiple files with the same (workflow) extension are rejected as ambiguous. Args: js (str): JSON response from GitHub's releases endpoint. Returns: list: Sequence of `Download`. """ releases = json.loads(js) downloads = [] for release in releases: tag = release['tag_name'] dupes = defaultdict(int) try: version = Version(tag) except ValueError as err: wf().logger.debug('ignored release: bad version "%s": %s', tag, err) continue dls = [] for asset in release.get('assets', []): url = asset.get('browser_download_url') filename = os.path.basename(url) m = match_workflow(filename) if not m: wf().logger.debug('unwanted file: %s', filename) continue ext = m.group(0) dupes[ext] = dupes[ext] + 1 dls.append(Download(url, filename, version, release['prerelease'])) valid = True for ext, n in dupes.items(): if n > 1: wf().logger.debug('ignored release "%s": multiple assets ' 'with extension "%s"', tag, ext) valid = False break if valid: downloads.extend(dls) downloads.sort(reverse=True) return downloads def __init__(self, url, filename, version, prerelease=False): """Create a new Download. Args: url (str): URL of workflow file. filename (str): Filename of workflow file. version (Version): Version of workflow. prerelease (bool, optional): Whether version is pre-release. Defaults to False. """ if isinstance(version, basestring): version = Version(version) self.url = url self.filename = filename self.version = version self.prerelease = prerelease @property def alfred_version(self): """Minimum Alfred version based on filename extension.""" m = match_workflow(self.filename) if not m or not m.group(1): return Version('0') return Version(m.group(1)) @property def dict(self): """Convert `Download` to `dict`.""" return dict(url=self.url, filename=self.filename, version=str(self.version), prerelease=self.prerelease) def __str__(self): """Format `Download` for printing.""" u = ('Download(url={dl.url!r}, ' 'filename={dl.filename!r}, ' 'version={dl.version!r}, ' 'prerelease={dl.prerelease!r})'.format(dl=self)) return u.encode('utf-8') def __repr__(self): """Code-like representation of `Download`.""" return str(self) def __eq__(self, other): """Compare Downloads based on version numbers.""" if self.url != other.url \ or self.filename != other.filename \ or self.version != other.version \ or self.prerelease != other.prerelease: return False return True def __ne__(self, other): """Compare Downloads based on version numbers.""" return not self.__eq__(other) def __lt__(self, other): """Compare Downloads based on version numbers.""" if self.version != other.version: return self.version < other.version return self.alfred_version < other.alfred_version class Version(object): """Mostly semantic versioning. The main difference to proper :ref:`semantic versioning <semver>` is that this implementation doesn't require a minor or patch version. Version strings may also be prefixed with "v", e.g.: >>> v = Version('v1.1.1') >>> v.tuple (1, 1, 1, '') >>> v = Version('2.0') >>> v.tuple (2, 0, 0, '') >>> Version('3.1-beta').tuple (3, 1, 0, 'beta') >>> Version('1.0.1') > Version('0.0.1') True """ #: Match version and pre-release/build information in version strings match_version = re.compile(r'([0-9\.]+)(.+)?').match def __init__(self, vstr): """Create new `Version` object. Args: vstr (basestring): Semantic version string. """ if not vstr: raise ValueError('invalid version number: {!r}'.format(vstr)) self.vstr = vstr self.major = 0 self.minor = 0 self.patch = 0 self.suffix = '' self.build = '' self._parse(vstr) def _parse(self, vstr): if vstr.startswith('v'): m = self.match_version(vstr[1:]) else: m = self.match_version(vstr) if not m: raise ValueError('invalid version number: {!r}'.format(vstr)) version, suffix = m.groups() parts = self._parse_dotted_string(version) self.major = parts.pop(0) if len(parts): self.minor = parts.pop(0) if len(parts): self.patch = parts.pop(0) if not len(parts) == 0: raise ValueError('version number too long: {!r}'.format(vstr)) if suffix: # Build info idx = suffix.find('+') if idx > -1: self.build = suffix[idx+1:] suffix = suffix[:idx] if suffix: if not suffix.startswith('-'): raise ValueError( 'suffix must start with - : {0}'.format(suffix)) self.suffix = suffix[1:] # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) def _parse_dotted_string(self, s): """Parse string ``s`` into list of ints and strings.""" parsed = [] parts = s.split('.') for p in parts: if p.isdigit(): p = int(p) parsed.append(p) return parsed @property def tuple(self): """Version number as a tuple of major, minor, patch, pre-release.""" return (self.major, self.minor, self.patch, self.suffix) def __lt__(self, other): """Implement comparison.""" if not isinstance(other, Version): raise ValueError('not a Version instance: {0!r}'.format(other)) t = self.tuple[:3] o = other.tuple[:3] if t < o: return True if t == o: # We need to compare suffixes if self.suffix and not other.suffix: return True if other.suffix and not self.suffix: return False return self._parse_dotted_string(self.suffix) \ < self._parse_dotted_string(other.suffix) # t > o return False def __eq__(self, other): """Implement comparison.""" if not isinstance(other, Version): raise ValueError('not a Version instance: {0!r}'.format(other)) return self.tuple == other.tuple def __ne__(self, other): """Implement comparison.""" return not self.__eq__(other) def __gt__(self, other): """Implement comparison.""" if not isinstance(other, Version): raise ValueError('not a Version instance: {0!r}'.format(other)) return other.__lt__(self) def __le__(self, other): """Implement comparison.""" if not isinstance(other, Version): raise ValueError('not a Version instance: {0!r}'.format(other)) return not other.__lt__(self) def __ge__(self, other): """Implement comparison.""" return not self.__lt__(other) def __str__(self): """Return semantic version string.""" vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) if self.suffix: vstr = '{0}-{1}'.format(vstr, self.suffix) if self.build: vstr = '{0}+{1}'.format(vstr, self.build) return vstr def __repr__(self): """Return 'code' representation of `Version`.""" return "Version('{0}')".format(str(self)) def retrieve_download(dl): """Saves a download to a temporary file and returns path. .. versionadded: 1.37 Args: url (unicode): URL to .alfredworkflow file in GitHub repo Returns: unicode: path to downloaded file """ if not match_workflow(dl.filename): raise ValueError('attachment not a workflow: ' + dl.filename) path = os.path.join(tempfile.gettempdir(), dl.filename) wf().logger.debug('downloading update from ' '%r to %r ...', dl.url, path) r = web.get(dl.url) r.raise_for_status() r.save_to_path(path) return path def build_api_url(repo): """Generate releases URL from GitHub repo. Args: repo (unicode): Repo name in form ``username/repo`` Returns: unicode: URL to the API endpoint for the repo's releases """ if len(repo.split('/')) != 2: raise ValueError('invalid GitHub repo: {!r}'.format(repo)) return RELEASES_BASE.format(repo) def get_downloads(repo): """Load available ``Download``s for GitHub repo. .. versionadded: 1.37 Args: repo (unicode): GitHub repo to load releases for. Returns: list: Sequence of `Download` contained in GitHub releases. """ url = build_api_url(repo) def _fetch(): wf().logger.info('retrieving releases for %r ...', repo) r = web.get(url) r.raise_for_status() return r.content key = 'github-releases-' + repo.replace('/', '-') js = wf().cached_data(key, _fetch, max_age=60) return Download.from_releases(js) def latest_download(dls, alfred_version=None, prereleases=False): """Return newest `Download`.""" alfred_version = alfred_version or os.getenv('alfred_version') version = None if alfred_version: version = Version(alfred_version) dls.sort(reverse=True) for dl in dls: if dl.prerelease and not prereleases: wf().logger.debug('ignored prerelease: %s', dl.version) continue if version and dl.alfred_version > version: wf().logger.debug('ignored incompatible (%s > %s): %s', dl.alfred_version, version, dl.filename) continue wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) return dl return None def check_update(repo, current_version, prereleases=False, alfred_version=None): """Check whether a newer release is available on GitHub. Args: repo (unicode): ``username/repo`` for workflow's GitHub repo current_version (unicode): the currently installed version of the workflow. :ref:`Semantic versioning <semver>` is required. prereleases (bool): Whether to include pre-releases. alfred_version (unicode): version of currently-running Alfred. if empty, defaults to ``$alfred_version`` environment variable. Returns: bool: ``True`` if an update is available, else ``False`` If an update is available, its version number and download URL will be cached. """ key = '__workflow_latest_version' # data stored when no update is available no_update = { 'available': False, 'download': None, 'version': None, } current = Version(current_version) dls = get_downloads(repo) if not len(dls): wf().logger.warning('no valid downloads for %s', repo) wf().cache_data(key, no_update) return False wf().logger.info('%d download(s) for %s', len(dls), repo) dl = latest_download(dls, alfred_version, prereleases) if not dl: wf().logger.warning('no compatible downloads for %s', repo) wf().cache_data(key, no_update) return False wf().logger.debug('latest=%r, installed=%r', dl.version, current) if dl.version > current: wf().cache_data(key, { 'version': str(dl.version), 'download': dl.dict, 'available': True, }) return True wf().cache_data(key, no_update) return False def install_update(): """If a newer release is available, download and install it. :returns: ``True`` if an update is installed, else ``False`` """ key = '__workflow_latest_version' # data stored when no update is available no_update = { 'available': False, 'download': None, 'version': None, } status = wf().cached_data(key, max_age=0) if not status or not status.get('available'): wf().logger.info('no update available') return False dl = status.get('download') if not dl: wf().logger.info('no download information') return False path = retrieve_download(Download.from_dict(dl)) wf().logger.info('installing updated workflow ...') subprocess.call(['open', path]) 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] <repo> <version>') sys.exit(status) argv = sys.argv[:] if '-h' in argv or '--help' in argv: show_help() if '--prereleases' in argv: argv.remove('--prereleases') prereleases = True if len(argv) != 4: show_help(1) action = argv[1] repo = argv[2] version = argv[3] try: if action == 'check': check_update(repo, version, prereleases) elif action == 'install': install_update() else: show_help(1) except Exception as err: # ensure traceback is in log file wf().logger.exception(err) raise err