1#!/usr/bin/env python 2 3"""Module that checks if there is an updated version of a package available.""" 4 5from __future__ import print_function 6import json 7import os 8import pickle 9import platform 10import re 11import requests 12import sys 13import time 14from datetime import datetime 15from functools import wraps 16from requests.status_codes import codes 17from tempfile import gettempdir 18 19__version__ = '0.16' 20 21 22# http://bugs.python.org/issue7980 23datetime.strptime('', '') 24 25 26def cache_results(function): 27 """Return decorated function that caches the results.""" 28 def save_to_permacache(): 29 """Save the in-memory cache data to the permacache. 30 31 There is a race condition here between two processes updating at the 32 same time. It's perfectly acceptable to lose and/or corrupt the 33 permacache information as each process's in-memory cache will remain 34 in-tact. 35 36 """ 37 update_from_permacache() 38 try: 39 with open(filename, 'wb') as fp: 40 pickle.dump(cache, fp, pickle.HIGHEST_PROTOCOL) 41 except IOError: 42 pass # Ignore permacache saving exceptions 43 44 def update_from_permacache(): 45 """Attempt to update newer items from the permacache.""" 46 try: 47 with open(filename, 'rb') as fp: 48 permacache = pickle.load(fp) 49 except Exception: # TODO: Handle specific exceptions 50 return # It's okay if it cannot load 51 for key, value in permacache.items(): 52 if key not in cache or value[0] > cache[key][0]: 53 cache[key] = value 54 55 cache = {} 56 cache_expire_time = 3600 57 try: 58 filename = os.path.join(gettempdir(), 'update_checker_cache.pkl') 59 update_from_permacache() 60 except NotImplementedError: 61 filename = None 62 63 @wraps(function) 64 def wrapped(obj, package_name, package_version, **extra_data): 65 """Return cached results if available.""" 66 now = time.time() 67 key = (package_name, package_version) 68 if not obj.bypass_cache and key in cache: # Check the in-memory cache 69 cache_time, retval = cache[key] 70 if now - cache_time < cache_expire_time: 71 return retval 72 retval = function(obj, package_name, package_version, **extra_data) 73 cache[key] = now, retval 74 if filename: 75 save_to_permacache() 76 return retval 77 return wrapped 78 79 80# This class must be defined before UpdateChecker in order to unpickle objects 81# of this type 82class UpdateResult(object): 83 84 """Contains the information for a package that has an update.""" 85 86 def __init__(self, package, running, available, release_date): 87 """Initialize an UpdateResult instance.""" 88 self.available_version = available 89 self.package_name = package 90 self.running_version = running 91 if release_date: 92 self.release_date = datetime.strptime(release_date, 93 '%Y-%m-%dT%H:%M:%S') 94 else: 95 self.release_date = None 96 97 def __str__(self): 98 """Return a printable UpdateResult string.""" 99 retval = ('Version {0} of {1} is outdated. Version {2} ' 100 .format(self.running_version, self.package_name, 101 self.available_version)) 102 if self.release_date: 103 retval += 'was released {0}.'.format( 104 pretty_date(self.release_date)) 105 else: 106 retval += 'is available.' 107 return retval 108 109 110class UpdateChecker(object): 111 112 """A class to check for package updates.""" 113 114 def __init__(self, url=None): 115 """Store the URL to use for checking.""" 116 self.bypass_cache = False 117 self.url = url if url \ 118 else 'http://updatechecker.bryceboe.com/check' 119 120 @cache_results 121 def check(self, package_name, package_version, **extra_data): 122 """Return a UpdateResult object if there is a newer version.""" 123 data = extra_data 124 data['package_name'] = package_name 125 data['package_version'] = package_version 126 data['python_version'] = sys.version.split()[0] 127 data['platform'] = platform.platform(True) or 'Unspecified' 128 129 try: 130 headers = {'connection': 'close', 131 'content-type': 'application/json'} 132 response = requests.put(self.url, json.dumps(data), timeout=1, 133 headers=headers) 134 if response.status_code == codes.UNPROCESSABLE_ENTITY: 135 return 'update_checker does not support {!r}'.format( 136 package_name) 137 data = response.json() 138 except (requests.exceptions.RequestException, ValueError): 139 return None 140 141 if not data or not data.get('success') \ 142 or (parse_version(package_version) >= 143 parse_version(data['data']['version'])): 144 return None 145 146 return UpdateResult(package_name, running=package_version, 147 available=data['data']['version'], 148 release_date=data['data']['upload_time']) 149 150 151def pretty_date(the_datetime): 152 """Attempt to return a human-readable time delta string.""" 153 # Source modified from 154 # http://stackoverflow.com/a/5164027/176978 155 diff = datetime.utcnow() - the_datetime 156 if diff.days > 7 or diff.days < 0: 157 return the_datetime.strftime('%A %B %d, %Y') 158 elif diff.days == 1: 159 return '1 day ago' 160 elif diff.days > 1: 161 return '{0} days ago'.format(diff.days) 162 elif diff.seconds <= 1: 163 return 'just now' 164 elif diff.seconds < 60: 165 return '{0} seconds ago'.format(diff.seconds) 166 elif diff.seconds < 120: 167 return '1 minute ago' 168 elif diff.seconds < 3600: 169 return '{0} minutes ago'.format(int(round(diff.seconds / 60))) 170 elif diff.seconds < 7200: 171 return '1 hour ago' 172 else: 173 return '{0} hours ago'.format(int(round(diff.seconds / 3600))) 174 175 176def update_check(package_name, package_version, bypass_cache=False, url=None, 177 **extra_data): 178 """Convenience method that outputs to stdout if an update is available.""" 179 checker = UpdateChecker(url) 180 checker.bypass_cache = bypass_cache 181 result = checker.check(package_name, package_version, **extra_data) 182 if result: 183 print(result) 184 185 186# The following section of code is taken from setuptools pkg_resources.py (PSF 187# license). Unfortunately importing pkg_resources to directly use the 188# parse_version function results in some undesired side effects. 189 190component_re = re.compile(r'(\d+ | [a-z]+ | \.| -)', re.VERBOSE) 191replace = {'pre': 'c', 'preview': 'c', '-': 'final-', 'rc': 'c', 192 'dev': '@'}.get 193 194 195def _parse_version_parts(s): 196 for part in component_re.split(s): 197 part = replace(part, part) 198 if not part or part == '.': 199 continue 200 if part[:1] in '0123456789': 201 yield part.zfill(8) # pad for numeric comparison 202 else: 203 yield '*'+part 204 205 yield '*final' # ensure that alpha/beta/candidate are before final 206 207 208def parse_version(s): 209 """Convert a version string to a chronologically-sortable key. 210 211 This is a rough cross between distutils' StrictVersion and LooseVersion; 212 if you give it versions that would work with StrictVersion, then it behaves 213 the same; otherwise it acts like a slightly-smarter LooseVersion. It is 214 *possible* to create pathological version coding schemes that will fool 215 this parser, but they should be very rare in practice. 216 217 The returned value will be a tuple of strings. Numeric portions of the 218 version are padded to 8 digits so they will compare numerically, but 219 without relying on how numbers compare relative to strings. Dots are 220 dropped, but dashes are retained. Trailing zeros between alpha segments 221 or dashes are suppressed, so that e.g. "2.4.0" is considered the same as 222 "2.4". Alphanumeric parts are lower-cased. 223 224 The algorithm assumes that strings like "-" and any alpha string that 225 alphabetically follows "final" represents a "patch level". So, "2.4-1" 226 is assumed to be a branch or patch of "2.4", and therefore "2.4.1" is 227 considered newer than "2.4-1", which in turn is newer than "2.4". 228 229 Strings like "a", "b", "c", "alpha", "beta", "candidate" and so on (that 230 come before "final" alphabetically) are assumed to be pre-release versions, 231 so that the version "2.4" is considered newer than "2.4a1". 232 233 Finally, to handle miscellaneous cases, the strings "pre", "preview", and 234 "rc" are treated as if they were "c", i.e. as though they were release 235 candidates, and therefore are not as new as a version string that does not 236 contain them, and "dev" is replaced with an '@' so that it sorts lower than 237 than any other pre-release tag. 238 239 """ 240 parts = [] 241 for part in _parse_version_parts(s.lower()): 242 if part.startswith('*'): 243 if part < '*final': # remove '-' before a prerelease tag 244 while parts and parts[-1] == '*final-': 245 parts.pop() 246 # remove trailing zeros from each series of numeric parts 247 while parts and parts[-1] == '00000000': 248 parts.pop() 249 parts.append(part) 250 return tuple(parts) 251