| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565 |
- #!/usr/bin/env python
- # encoding: utf-8
- #
- # Copyright (c) 2014 Fabio Niephaus <[email protected]>,
- # Dean Jackson <[email protected]>
- #
- # 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][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: ' + 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: ' + 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 - : ' + suffix)
- self.suffix = suffix[1:]
- 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]) # nosec
- 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
|