update.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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. from collections import defaultdict
  20. from functools import total_ordering
  21. import json
  22. import os
  23. import tempfile
  24. import re
  25. import subprocess
  26. import workflow
  27. import web
  28. # __all__ = []
  29. RELEASES_BASE = 'https://api.github.com/repos/{}/releases'
  30. match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search
  31. _wf = None
  32. def wf():
  33. """Lazy `Workflow` object."""
  34. global _wf
  35. if _wf is None:
  36. _wf = workflow.Workflow()
  37. return _wf
  38. @total_ordering
  39. class Download(object):
  40. """A workflow file that is available for download.
  41. .. versionadded: 1.37
  42. Attributes:
  43. url (str): URL of workflow file.
  44. filename (str): Filename of workflow file.
  45. version (Version): Semantic version of workflow.
  46. prerelease (bool): Whether version is a pre-release.
  47. alfred_version (Version): Minimum compatible version
  48. of Alfred.
  49. """
  50. @classmethod
  51. def from_dict(cls, d):
  52. """Create a `Download` from a `dict`."""
  53. return cls(url=d['url'], filename=d['filename'],
  54. version=Version(d['version']),
  55. prerelease=d['prerelease'])
  56. @classmethod
  57. def from_releases(cls, js):
  58. """Extract downloads from GitHub releases.
  59. Searches releases with semantic tags for assets with
  60. file extension .alfredworkflow or .alfredXworkflow where
  61. X is a number.
  62. Files are returned sorted by latest version first. Any
  63. releases containing multiple files with the same (workflow)
  64. extension are rejected as ambiguous.
  65. Args:
  66. js (str): JSON response from GitHub's releases endpoint.
  67. Returns:
  68. list: Sequence of `Download`.
  69. """
  70. releases = json.loads(js)
  71. downloads = []
  72. for release in releases:
  73. tag = release['tag_name']
  74. dupes = defaultdict(int)
  75. try:
  76. version = Version(tag)
  77. except ValueError as err:
  78. wf().logger.debug('ignored release: bad version "%s": %s',
  79. tag, err)
  80. continue
  81. dls = []
  82. for asset in release.get('assets', []):
  83. url = asset.get('browser_download_url')
  84. filename = os.path.basename(url)
  85. m = match_workflow(filename)
  86. if not m:
  87. wf().logger.debug('unwanted file: %s', filename)
  88. continue
  89. ext = m.group(0)
  90. dupes[ext] = dupes[ext] + 1
  91. dls.append(Download(url, filename, version,
  92. release['prerelease']))
  93. valid = True
  94. for ext, n in dupes.items():
  95. if n > 1:
  96. wf().logger.debug('ignored release "%s": multiple assets '
  97. 'with extension "%s"', tag, ext)
  98. valid = False
  99. break
  100. if valid:
  101. downloads.extend(dls)
  102. downloads.sort(reverse=True)
  103. return downloads
  104. def __init__(self, url, filename, version, prerelease=False):
  105. """Create a new Download.
  106. Args:
  107. url (str): URL of workflow file.
  108. filename (str): Filename of workflow file.
  109. version (Version): Version of workflow.
  110. prerelease (bool, optional): Whether version is
  111. pre-release. Defaults to False.
  112. """
  113. if isinstance(version, basestring):
  114. version = Version(version)
  115. self.url = url
  116. self.filename = filename
  117. self.version = version
  118. self.prerelease = prerelease
  119. @property
  120. def alfred_version(self):
  121. """Minimum Alfred version based on filename extension."""
  122. m = match_workflow(self.filename)
  123. if not m or not m.group(1):
  124. return Version('0')
  125. return Version(m.group(1))
  126. @property
  127. def dict(self):
  128. """Convert `Download` to `dict`."""
  129. return dict(url=self.url, filename=self.filename,
  130. version=str(self.version), prerelease=self.prerelease)
  131. def __str__(self):
  132. """Format `Download` for printing."""
  133. u = ('Download(url={dl.url!r}, '
  134. 'filename={dl.filename!r}, '
  135. 'version={dl.version!r}, '
  136. 'prerelease={dl.prerelease!r})'.format(dl=self))
  137. return u.encode('utf-8')
  138. def __repr__(self):
  139. """Code-like representation of `Download`."""
  140. return str(self)
  141. def __eq__(self, other):
  142. """Compare Downloads based on version numbers."""
  143. if self.url != other.url \
  144. or self.filename != other.filename \
  145. or self.version != other.version \
  146. or self.prerelease != other.prerelease:
  147. return False
  148. return True
  149. def __ne__(self, other):
  150. """Compare Downloads based on version numbers."""
  151. return not self.__eq__(other)
  152. def __lt__(self, other):
  153. """Compare Downloads based on version numbers."""
  154. if self.version != other.version:
  155. return self.version < other.version
  156. return self.alfred_version < other.alfred_version
  157. class Version(object):
  158. """Mostly semantic versioning.
  159. The main difference to proper :ref:`semantic versioning <semver>`
  160. is that this implementation doesn't require a minor or patch version.
  161. Version strings may also be prefixed with "v", e.g.:
  162. >>> v = Version('v1.1.1')
  163. >>> v.tuple
  164. (1, 1, 1, '')
  165. >>> v = Version('2.0')
  166. >>> v.tuple
  167. (2, 0, 0, '')
  168. >>> Version('3.1-beta').tuple
  169. (3, 1, 0, 'beta')
  170. >>> Version('1.0.1') > Version('0.0.1')
  171. True
  172. """
  173. #: Match version and pre-release/build information in version strings
  174. match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match
  175. def __init__(self, vstr):
  176. """Create new `Version` object.
  177. Args:
  178. vstr (basestring): Semantic version string.
  179. """
  180. if not vstr:
  181. raise ValueError('invalid version number: {!r}'.format(vstr))
  182. self.vstr = vstr
  183. self.major = 0
  184. self.minor = 0
  185. self.patch = 0
  186. self.suffix = ''
  187. self.build = ''
  188. self._parse(vstr)
  189. def _parse(self, vstr):
  190. if vstr.startswith('v'):
  191. m = self.match_version(vstr[1:])
  192. else:
  193. m = self.match_version(vstr)
  194. if not m:
  195. raise ValueError('invalid version number: ' + vstr)
  196. version, suffix = m.groups()
  197. parts = self._parse_dotted_string(version)
  198. self.major = parts.pop(0)
  199. if len(parts):
  200. self.minor = parts.pop(0)
  201. if len(parts):
  202. self.patch = parts.pop(0)
  203. if not len(parts) == 0:
  204. raise ValueError('version number too long: ' + vstr)
  205. if suffix:
  206. # Build info
  207. idx = suffix.find('+')
  208. if idx > -1:
  209. self.build = suffix[idx+1:]
  210. suffix = suffix[:idx]
  211. if suffix:
  212. if not suffix.startswith('-'):
  213. raise ValueError(
  214. 'suffix must start with - : ' + suffix)
  215. self.suffix = suffix[1:]
  216. def _parse_dotted_string(self, s):
  217. """Parse string ``s`` into list of ints and strings."""
  218. parsed = []
  219. parts = s.split('.')
  220. for p in parts:
  221. if p.isdigit():
  222. p = int(p)
  223. parsed.append(p)
  224. return parsed
  225. @property
  226. def tuple(self):
  227. """Version number as a tuple of major, minor, patch, pre-release."""
  228. return (self.major, self.minor, self.patch, self.suffix)
  229. def __lt__(self, other):
  230. """Implement comparison."""
  231. if not isinstance(other, Version):
  232. raise ValueError('not a Version instance: {0!r}'.format(other))
  233. t = self.tuple[:3]
  234. o = other.tuple[:3]
  235. if t < o:
  236. return True
  237. if t == o: # We need to compare suffixes
  238. if self.suffix and not other.suffix:
  239. return True
  240. if other.suffix and not self.suffix:
  241. return False
  242. return self._parse_dotted_string(self.suffix) \
  243. < self._parse_dotted_string(other.suffix)
  244. # t > o
  245. return False
  246. def __eq__(self, other):
  247. """Implement comparison."""
  248. if not isinstance(other, Version):
  249. raise ValueError('not a Version instance: {0!r}'.format(other))
  250. return self.tuple == other.tuple
  251. def __ne__(self, other):
  252. """Implement comparison."""
  253. return not self.__eq__(other)
  254. def __gt__(self, other):
  255. """Implement comparison."""
  256. if not isinstance(other, Version):
  257. raise ValueError('not a Version instance: {0!r}'.format(other))
  258. return other.__lt__(self)
  259. def __le__(self, other):
  260. """Implement comparison."""
  261. if not isinstance(other, Version):
  262. raise ValueError('not a Version instance: {0!r}'.format(other))
  263. return not other.__lt__(self)
  264. def __ge__(self, other):
  265. """Implement comparison."""
  266. return not self.__lt__(other)
  267. def __str__(self):
  268. """Return semantic version string."""
  269. vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
  270. if self.suffix:
  271. vstr = '{0}-{1}'.format(vstr, self.suffix)
  272. if self.build:
  273. vstr = '{0}+{1}'.format(vstr, self.build)
  274. return vstr
  275. def __repr__(self):
  276. """Return 'code' representation of `Version`."""
  277. return "Version('{0}')".format(str(self))
  278. def retrieve_download(dl):
  279. """Saves a download to a temporary file and returns path.
  280. .. versionadded: 1.37
  281. Args:
  282. url (unicode): URL to .alfredworkflow file in GitHub repo
  283. Returns:
  284. unicode: path to downloaded file
  285. """
  286. if not match_workflow(dl.filename):
  287. raise ValueError('attachment not a workflow: ' + dl.filename)
  288. path = os.path.join(tempfile.gettempdir(), dl.filename)
  289. wf().logger.debug('downloading update from '
  290. '%r to %r ...', dl.url, path)
  291. r = web.get(dl.url)
  292. r.raise_for_status()
  293. r.save_to_path(path)
  294. return path
  295. def build_api_url(repo):
  296. """Generate releases URL from GitHub repo.
  297. Args:
  298. repo (unicode): Repo name in form ``username/repo``
  299. Returns:
  300. unicode: URL to the API endpoint for the repo's releases
  301. """
  302. if len(repo.split('/')) != 2:
  303. raise ValueError('invalid GitHub repo: {!r}'.format(repo))
  304. return RELEASES_BASE.format(repo)
  305. def get_downloads(repo):
  306. """Load available ``Download``s for GitHub repo.
  307. .. versionadded: 1.37
  308. Args:
  309. repo (unicode): GitHub repo to load releases for.
  310. Returns:
  311. list: Sequence of `Download` contained in GitHub releases.
  312. """
  313. url = build_api_url(repo)
  314. def _fetch():
  315. wf().logger.info('retrieving releases for %r ...', repo)
  316. r = web.get(url)
  317. r.raise_for_status()
  318. return r.content
  319. key = 'github-releases-' + repo.replace('/', '-')
  320. js = wf().cached_data(key, _fetch, max_age=60)
  321. return Download.from_releases(js)
  322. def latest_download(dls, alfred_version=None, prereleases=False):
  323. """Return newest `Download`."""
  324. alfred_version = alfred_version or os.getenv('alfred_version')
  325. version = None
  326. if alfred_version:
  327. version = Version(alfred_version)
  328. dls.sort(reverse=True)
  329. for dl in dls:
  330. if dl.prerelease and not prereleases:
  331. wf().logger.debug('ignored prerelease: %s', dl.version)
  332. continue
  333. if version and dl.alfred_version > version:
  334. wf().logger.debug('ignored incompatible (%s > %s): %s',
  335. dl.alfred_version, version, dl.filename)
  336. continue
  337. wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename)
  338. return dl
  339. return None
  340. def check_update(repo, current_version, prereleases=False,
  341. alfred_version=None):
  342. """Check whether a newer release is available on GitHub.
  343. Args:
  344. repo (unicode): ``username/repo`` for workflow's GitHub repo
  345. current_version (unicode): the currently installed version of the
  346. workflow. :ref:`Semantic versioning <semver>` is required.
  347. prereleases (bool): Whether to include pre-releases.
  348. alfred_version (unicode): version of currently-running Alfred.
  349. if empty, defaults to ``$alfred_version`` environment variable.
  350. Returns:
  351. bool: ``True`` if an update is available, else ``False``
  352. If an update is available, its version number and download URL will
  353. be cached.
  354. """
  355. key = '__workflow_latest_version'
  356. # data stored when no update is available
  357. no_update = {
  358. 'available': False,
  359. 'download': None,
  360. 'version': None,
  361. }
  362. current = Version(current_version)
  363. dls = get_downloads(repo)
  364. if not len(dls):
  365. wf().logger.warning('no valid downloads for %s', repo)
  366. wf().cache_data(key, no_update)
  367. return False
  368. wf().logger.info('%d download(s) for %s', len(dls), repo)
  369. dl = latest_download(dls, alfred_version, prereleases)
  370. if not dl:
  371. wf().logger.warning('no compatible downloads for %s', repo)
  372. wf().cache_data(key, no_update)
  373. return False
  374. wf().logger.debug('latest=%r, installed=%r', dl.version, current)
  375. if dl.version > current:
  376. wf().cache_data(key, {
  377. 'version': str(dl.version),
  378. 'download': dl.dict,
  379. 'available': True,
  380. })
  381. return True
  382. wf().cache_data(key, no_update)
  383. return False
  384. def install_update():
  385. """If a newer release is available, download and install it.
  386. :returns: ``True`` if an update is installed, else ``False``
  387. """
  388. key = '__workflow_latest_version'
  389. # data stored when no update is available
  390. no_update = {
  391. 'available': False,
  392. 'download': None,
  393. 'version': None,
  394. }
  395. status = wf().cached_data(key, max_age=0)
  396. if not status or not status.get('available'):
  397. wf().logger.info('no update available')
  398. return False
  399. dl = status.get('download')
  400. if not dl:
  401. wf().logger.info('no download information')
  402. return False
  403. path = retrieve_download(Download.from_dict(dl))
  404. wf().logger.info('installing updated workflow ...')
  405. subprocess.call(['open', path]) # nosec
  406. wf().cache_data(key, no_update)
  407. return True
  408. if __name__ == '__main__': # pragma: nocover
  409. import sys
  410. prereleases = False
  411. def show_help(status=0):
  412. """Print help message."""
  413. print('usage: update.py (check|install) '
  414. '[--prereleases] <repo> <version>')
  415. sys.exit(status)
  416. argv = sys.argv[:]
  417. if '-h' in argv or '--help' in argv:
  418. show_help()
  419. if '--prereleases' in argv:
  420. argv.remove('--prereleases')
  421. prereleases = True
  422. if len(argv) != 4:
  423. show_help(1)
  424. action = argv[1]
  425. repo = argv[2]
  426. version = argv[3]
  427. try:
  428. if action == 'check':
  429. check_update(repo, version, prereleases)
  430. elif action == 'install':
  431. install_update()
  432. else:
  433. show_help(1)
  434. except Exception as err: # ensure traceback is in log file
  435. wf().logger.exception(err)
  436. raise err