update.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. #!/usr/bin/env python
  2. # encoding: utf-8
  3. #
  4. # Copyright (c) 2014 Fabio Niephaus <[email protected]>,
  5. # Dean Jackson <[email protected]>
  6. #
  7. # MIT Licence. See http://opensource.org/licenses/MIT
  8. #
  9. # Created on 2014-08-16
  10. #
  11. """Self-updating from GitHub.
  12. .. versionadded:: 1.9
  13. .. note::
  14. This module is not intended to be used directly. Automatic updates
  15. are controlled by the ``update_settings`` :class:`dict` passed to
  16. :class:`~workflow.workflow.Workflow` objects.
  17. """
  18. from __future__ import print_function, unicode_literals
  19. import os
  20. import tempfile
  21. import re
  22. import subprocess
  23. import workflow
  24. import web
  25. # __all__ = []
  26. RELEASES_BASE = 'https://api.github.com/repos/{0}/releases'
  27. _wf = None
  28. def wf():
  29. """Lazy `Workflow` object."""
  30. global _wf
  31. if _wf is None:
  32. _wf = workflow.Workflow()
  33. return _wf
  34. class Version(object):
  35. """Mostly semantic versioning.
  36. The main difference to proper :ref:`semantic versioning <semver>`
  37. is that this implementation doesn't require a minor or patch version.
  38. Version strings may also be prefixed with "v", e.g.:
  39. >>> v = Version('v1.1.1')
  40. >>> v.tuple
  41. (1, 1, 1, '')
  42. >>> v = Version('2.0')
  43. >>> v.tuple
  44. (2, 0, 0, '')
  45. >>> Version('3.1-beta').tuple
  46. (3, 1, 0, 'beta')
  47. >>> Version('1.0.1') > Version('0.0.1')
  48. True
  49. """
  50. #: Match version and pre-release/build information in version strings
  51. match_version = re.compile(r'([0-9\.]+)(.+)?').match
  52. def __init__(self, vstr):
  53. """Create new `Version` object.
  54. Args:
  55. vstr (basestring): Semantic version string.
  56. """
  57. self.vstr = vstr
  58. self.major = 0
  59. self.minor = 0
  60. self.patch = 0
  61. self.suffix = ''
  62. self.build = ''
  63. self._parse(vstr)
  64. def _parse(self, vstr):
  65. if vstr.startswith('v'):
  66. m = self.match_version(vstr[1:])
  67. else:
  68. m = self.match_version(vstr)
  69. if not m:
  70. raise ValueError('Invalid version number: {0}'.format(vstr))
  71. version, suffix = m.groups()
  72. parts = self._parse_dotted_string(version)
  73. self.major = parts.pop(0)
  74. if len(parts):
  75. self.minor = parts.pop(0)
  76. if len(parts):
  77. self.patch = parts.pop(0)
  78. if not len(parts) == 0:
  79. raise ValueError('Invalid version (too long) : {0}'.format(vstr))
  80. if suffix:
  81. # Build info
  82. idx = suffix.find('+')
  83. if idx > -1:
  84. self.build = suffix[idx+1:]
  85. suffix = suffix[:idx]
  86. if suffix:
  87. if not suffix.startswith('-'):
  88. raise ValueError(
  89. 'Invalid suffix : `{0}`. Must start with `-`'.format(
  90. suffix))
  91. self.suffix = suffix[1:]
  92. # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self)))
  93. def _parse_dotted_string(self, s):
  94. """Parse string ``s`` into list of ints and strings."""
  95. parsed = []
  96. parts = s.split('.')
  97. for p in parts:
  98. if p.isdigit():
  99. p = int(p)
  100. parsed.append(p)
  101. return parsed
  102. @property
  103. def tuple(self):
  104. """Version number as a tuple of major, minor, patch, pre-release."""
  105. return (self.major, self.minor, self.patch, self.suffix)
  106. def __lt__(self, other):
  107. """Implement comparison."""
  108. if not isinstance(other, Version):
  109. raise ValueError('Not a Version instance: {0!r}'.format(other))
  110. t = self.tuple[:3]
  111. o = other.tuple[:3]
  112. if t < o:
  113. return True
  114. if t == o: # We need to compare suffixes
  115. if self.suffix and not other.suffix:
  116. return True
  117. if other.suffix and not self.suffix:
  118. return False
  119. return (self._parse_dotted_string(self.suffix) <
  120. self._parse_dotted_string(other.suffix))
  121. # t > o
  122. return False
  123. def __eq__(self, other):
  124. """Implement comparison."""
  125. if not isinstance(other, Version):
  126. raise ValueError('Not a Version instance: {0!r}'.format(other))
  127. return self.tuple == other.tuple
  128. def __ne__(self, other):
  129. """Implement comparison."""
  130. return not self.__eq__(other)
  131. def __gt__(self, other):
  132. """Implement comparison."""
  133. if not isinstance(other, Version):
  134. raise ValueError('Not a Version instance: {0!r}'.format(other))
  135. return other.__lt__(self)
  136. def __le__(self, other):
  137. """Implement comparison."""
  138. if not isinstance(other, Version):
  139. raise ValueError('Not a Version instance: {0!r}'.format(other))
  140. return not other.__lt__(self)
  141. def __ge__(self, other):
  142. """Implement comparison."""
  143. return not self.__lt__(other)
  144. def __str__(self):
  145. """Return semantic version string."""
  146. vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
  147. if self.suffix:
  148. vstr += '-{0}'.format(self.suffix)
  149. if self.build:
  150. vstr += '+{0}'.format(self.build)
  151. return vstr
  152. def __repr__(self):
  153. """Return 'code' representation of `Version`."""
  154. return "Version('{0}')".format(str(self))
  155. def download_workflow(url):
  156. """Download workflow at ``url`` to a local temporary file.
  157. :param url: URL to .alfredworkflow file in GitHub repo
  158. :returns: path to downloaded file
  159. """
  160. filename = url.split("/")[-1]
  161. if (not url.endswith('.alfredworkflow') or
  162. not filename.endswith('.alfredworkflow')):
  163. raise ValueError('Attachment `{0}` not a workflow'.format(filename))
  164. local_path = os.path.join(tempfile.gettempdir(), filename)
  165. wf().logger.debug(
  166. 'Downloading updated workflow from `%s` to `%s` ...', url, local_path)
  167. response = web.get(url)
  168. with open(local_path, 'wb') as output:
  169. output.write(response.content)
  170. return local_path
  171. def build_api_url(slug):
  172. """Generate releases URL from GitHub slug.
  173. :param slug: Repo name in form ``username/repo``
  174. :returns: URL to the API endpoint for the repo's releases
  175. """
  176. if len(slug.split('/')) != 2:
  177. raise ValueError('Invalid GitHub slug : {0}'.format(slug))
  178. return RELEASES_BASE.format(slug)
  179. def _validate_release(release):
  180. """Return release for running version of Alfred."""
  181. alf3 = wf().alfred_version.major == 3
  182. downloads = {'.alfredworkflow': [], '.alfred3workflow': []}
  183. dl_count = 0
  184. version = release['tag_name']
  185. for asset in release.get('assets', []):
  186. url = asset.get('browser_download_url')
  187. if not url: # pragma: nocover
  188. continue
  189. ext = os.path.splitext(url)[1].lower()
  190. if ext not in downloads:
  191. continue
  192. # Ignore Alfred 3-only files if Alfred 2 is running
  193. if ext == '.alfred3workflow' and not alf3:
  194. continue
  195. downloads[ext].append(url)
  196. dl_count += 1
  197. # download_urls.append(url)
  198. if dl_count == 0:
  199. wf().logger.warning(
  200. 'Invalid release %s : No workflow file', version)
  201. return None
  202. for k in downloads:
  203. if len(downloads[k]) > 1:
  204. wf().logger.warning(
  205. 'Invalid release %s : multiple %s files', version, k)
  206. return None
  207. # Prefer .alfred3workflow file if there is one and Alfred 3 is
  208. # running.
  209. if alf3 and len(downloads['.alfred3workflow']):
  210. download_url = downloads['.alfred3workflow'][0]
  211. else:
  212. download_url = downloads['.alfredworkflow'][0]
  213. wf().logger.debug('Release `%s` : %s', version, download_url)
  214. return {
  215. 'version': version,
  216. 'download_url': download_url,
  217. 'prerelease': release['prerelease']
  218. }
  219. def get_valid_releases(github_slug, prereleases=False):
  220. """Return list of all valid releases.
  221. :param github_slug: ``username/repo`` for workflow's GitHub repo
  222. :param prereleases: Whether to include pre-releases.
  223. :returns: list of dicts. Each :class:`dict` has the form
  224. ``{'version': '1.1', 'download_url': 'http://github.com/...',
  225. 'prerelease': False }``
  226. A valid release is one that contains one ``.alfredworkflow`` file.
  227. If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading
  228. ``v`` will be stripped.
  229. """
  230. api_url = build_api_url(github_slug)
  231. releases = []
  232. wf().logger.debug('Retrieving releases list from `%s` ...', api_url)
  233. def retrieve_releases():
  234. wf().logger.info(
  235. 'Retrieving releases for `%s` ...', github_slug)
  236. return web.get(api_url).json()
  237. slug = github_slug.replace('/', '-')
  238. for release in wf().cached_data('gh-releases-{0}'.format(slug),
  239. retrieve_releases):
  240. wf().logger.debug('Release : %r', release)
  241. release = _validate_release(release)
  242. if release is None:
  243. wf().logger.debug('Invalid release')
  244. continue
  245. elif release['prerelease'] and not prereleases:
  246. wf().logger.debug('Ignoring prerelease : %s', release['version'])
  247. continue
  248. releases.append(release)
  249. return releases
  250. def check_update(github_slug, current_version, prereleases=False):
  251. """Check whether a newer release is available on GitHub.
  252. :param github_slug: ``username/repo`` for workflow's GitHub repo
  253. :param current_version: the currently installed version of the
  254. workflow. :ref:`Semantic versioning <semver>` is required.
  255. :param prereleases: Whether to include pre-releases.
  256. :type current_version: ``unicode``
  257. :returns: ``True`` if an update is available, else ``False``
  258. If an update is available, its version number and download URL will
  259. be cached.
  260. """
  261. releases = get_valid_releases(github_slug, prereleases)
  262. wf().logger.info('%d releases for %s', len(releases), github_slug)
  263. if not len(releases):
  264. raise ValueError('No valid releases for %s', github_slug)
  265. # GitHub returns releases newest-first
  266. latest_release = releases[0]
  267. # (latest_version, download_url) = get_latest_release(releases)
  268. vr = Version(latest_release['version'])
  269. vl = Version(current_version)
  270. wf().logger.debug('Latest : %r Installed : %r', vr, vl)
  271. if vr > vl:
  272. wf().cache_data('__workflow_update_status', {
  273. 'version': latest_release['version'],
  274. 'download_url': latest_release['download_url'],
  275. 'available': True
  276. })
  277. return True
  278. wf().cache_data('__workflow_update_status', {
  279. 'available': False
  280. })
  281. return False
  282. def install_update():
  283. """If a newer release is available, download and install it.
  284. :returns: ``True`` if an update is installed, else ``False``
  285. """
  286. update_data = wf().cached_data('__workflow_update_status', max_age=0)
  287. if not update_data or not update_data.get('available'):
  288. wf().logger.info('No update available')
  289. return False
  290. local_file = download_workflow(update_data['download_url'])
  291. wf().logger.info('Installing updated workflow ...')
  292. subprocess.call(['open', local_file])
  293. update_data['available'] = False
  294. wf().cache_data('__workflow_update_status', update_data)
  295. return True
  296. if __name__ == '__main__': # pragma: nocover
  297. import sys
  298. def show_help():
  299. """Print help message."""
  300. print('Usage : update.py (check|install) github_slug version '
  301. '[--prereleases]')
  302. sys.exit(1)
  303. argv = sys.argv[:]
  304. prereleases = '--prereleases' in argv
  305. if prereleases:
  306. argv.remove('--prereleases')
  307. if len(argv) != 4:
  308. show_help()
  309. action, github_slug, version = argv[1:]
  310. if action not in ('check', 'install'):
  311. show_help()
  312. if action == 'check':
  313. check_update(github_slug, version, prereleases)
  314. elif action == 'install':
  315. install_update()