251 lines
9.1 KiB
Python
251 lines
9.1 KiB
Python
#!/usr/bin/env python
|
|
|
|
"""Module that checks if there is an updated version of a package available."""
|
|
|
|
from __future__ import print_function
|
|
import json
|
|
import os
|
|
import pickle
|
|
import platform
|
|
import re
|
|
import requests
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from functools import wraps
|
|
from requests.status_codes import codes
|
|
from tempfile import gettempdir
|
|
|
|
__version__ = '0.16'
|
|
|
|
|
|
# http://bugs.python.org/issue7980
|
|
datetime.strptime('', '')
|
|
|
|
|
|
def cache_results(function):
|
|
"""Return decorated function that caches the results."""
|
|
def save_to_permacache():
|
|
"""Save the in-memory cache data to the permacache.
|
|
|
|
There is a race condition here between two processes updating at the
|
|
same time. It's perfectly acceptable to lose and/or corrupt the
|
|
permacache information as each process's in-memory cache will remain
|
|
in-tact.
|
|
|
|
"""
|
|
update_from_permacache()
|
|
try:
|
|
with open(filename, 'wb') as fp:
|
|
pickle.dump(cache, fp, pickle.HIGHEST_PROTOCOL)
|
|
except IOError:
|
|
pass # Ignore permacache saving exceptions
|
|
|
|
def update_from_permacache():
|
|
"""Attempt to update newer items from the permacache."""
|
|
try:
|
|
with open(filename, 'rb') as fp:
|
|
permacache = pickle.load(fp)
|
|
except Exception: # TODO: Handle specific exceptions
|
|
return # It's okay if it cannot load
|
|
for key, value in permacache.items():
|
|
if key not in cache or value[0] > cache[key][0]:
|
|
cache[key] = value
|
|
|
|
cache = {}
|
|
cache_expire_time = 3600
|
|
try:
|
|
filename = os.path.join(gettempdir(), 'update_checker_cache.pkl')
|
|
update_from_permacache()
|
|
except NotImplementedError:
|
|
filename = None
|
|
|
|
@wraps(function)
|
|
def wrapped(obj, package_name, package_version, **extra_data):
|
|
"""Return cached results if available."""
|
|
now = time.time()
|
|
key = (package_name, package_version)
|
|
if not obj.bypass_cache and key in cache: # Check the in-memory cache
|
|
cache_time, retval = cache[key]
|
|
if now - cache_time < cache_expire_time:
|
|
return retval
|
|
retval = function(obj, package_name, package_version, **extra_data)
|
|
cache[key] = now, retval
|
|
if filename:
|
|
save_to_permacache()
|
|
return retval
|
|
return wrapped
|
|
|
|
|
|
# This class must be defined before UpdateChecker in order to unpickle objects
|
|
# of this type
|
|
class UpdateResult(object):
|
|
|
|
"""Contains the information for a package that has an update."""
|
|
|
|
def __init__(self, package, running, available, release_date):
|
|
"""Initialize an UpdateResult instance."""
|
|
self.available_version = available
|
|
self.package_name = package
|
|
self.running_version = running
|
|
if release_date:
|
|
self.release_date = datetime.strptime(release_date,
|
|
'%Y-%m-%dT%H:%M:%S')
|
|
else:
|
|
self.release_date = None
|
|
|
|
def __str__(self):
|
|
"""Return a printable UpdateResult string."""
|
|
retval = ('Version {0} of {1} is outdated. Version {2} '
|
|
.format(self.running_version, self.package_name,
|
|
self.available_version))
|
|
if self.release_date:
|
|
retval += 'was released {0}.'.format(
|
|
pretty_date(self.release_date))
|
|
else:
|
|
retval += 'is available.'
|
|
return retval
|
|
|
|
|
|
class UpdateChecker(object):
|
|
|
|
"""A class to check for package updates."""
|
|
|
|
def __init__(self, url=None):
|
|
"""Store the URL to use for checking."""
|
|
self.bypass_cache = False
|
|
self.url = url if url \
|
|
else 'http://updatechecker.bryceboe.com/check'
|
|
|
|
@cache_results
|
|
def check(self, package_name, package_version, **extra_data):
|
|
"""Return a UpdateResult object if there is a newer version."""
|
|
data = extra_data
|
|
data['package_name'] = package_name
|
|
data['package_version'] = package_version
|
|
data['python_version'] = sys.version.split()[0]
|
|
data['platform'] = platform.platform(True) or 'Unspecified'
|
|
|
|
try:
|
|
headers = {'connection': 'close',
|
|
'content-type': 'application/json'}
|
|
response = requests.put(self.url, json.dumps(data), timeout=1,
|
|
headers=headers)
|
|
if response.status_code == codes.UNPROCESSABLE_ENTITY:
|
|
return 'update_checker does not support {!r}'.format(
|
|
package_name)
|
|
data = response.json()
|
|
except (requests.exceptions.RequestException, ValueError):
|
|
return None
|
|
|
|
if not data or not data.get('success') \
|
|
or (parse_version(package_version) >=
|
|
parse_version(data['data']['version'])):
|
|
return None
|
|
|
|
return UpdateResult(package_name, running=package_version,
|
|
available=data['data']['version'],
|
|
release_date=data['data']['upload_time'])
|
|
|
|
|
|
def pretty_date(the_datetime):
|
|
"""Attempt to return a human-readable time delta string."""
|
|
# Source modified from
|
|
# http://stackoverflow.com/a/5164027/176978
|
|
diff = datetime.utcnow() - the_datetime
|
|
if diff.days > 7 or diff.days < 0:
|
|
return the_datetime.strftime('%A %B %d, %Y')
|
|
elif diff.days == 1:
|
|
return '1 day ago'
|
|
elif diff.days > 1:
|
|
return '{0} days ago'.format(diff.days)
|
|
elif diff.seconds <= 1:
|
|
return 'just now'
|
|
elif diff.seconds < 60:
|
|
return '{0} seconds ago'.format(diff.seconds)
|
|
elif diff.seconds < 120:
|
|
return '1 minute ago'
|
|
elif diff.seconds < 3600:
|
|
return '{0} minutes ago'.format(int(round(diff.seconds / 60)))
|
|
elif diff.seconds < 7200:
|
|
return '1 hour ago'
|
|
else:
|
|
return '{0} hours ago'.format(int(round(diff.seconds / 3600)))
|
|
|
|
|
|
def update_check(package_name, package_version, bypass_cache=False, url=None,
|
|
**extra_data):
|
|
"""Convenience method that outputs to stdout if an update is available."""
|
|
checker = UpdateChecker(url)
|
|
checker.bypass_cache = bypass_cache
|
|
result = checker.check(package_name, package_version, **extra_data)
|
|
if result:
|
|
print(result)
|
|
|
|
|
|
# The following section of code is taken from setuptools pkg_resources.py (PSF
|
|
# license). Unfortunately importing pkg_resources to directly use the
|
|
# parse_version function results in some undesired side effects.
|
|
|
|
component_re = re.compile(r'(\d+ | [a-z]+ | \.| -)', re.VERBOSE)
|
|
replace = {'pre': 'c', 'preview': 'c', '-': 'final-', 'rc': 'c',
|
|
'dev': '@'}.get
|
|
|
|
|
|
def _parse_version_parts(s):
|
|
for part in component_re.split(s):
|
|
part = replace(part, part)
|
|
if not part or part == '.':
|
|
continue
|
|
if part[:1] in '0123456789':
|
|
yield part.zfill(8) # pad for numeric comparison
|
|
else:
|
|
yield '*'+part
|
|
|
|
yield '*final' # ensure that alpha/beta/candidate are before final
|
|
|
|
|
|
def parse_version(s):
|
|
"""Convert a version string to a chronologically-sortable key.
|
|
|
|
This is a rough cross between distutils' StrictVersion and LooseVersion;
|
|
if you give it versions that would work with StrictVersion, then it behaves
|
|
the same; otherwise it acts like a slightly-smarter LooseVersion. It is
|
|
*possible* to create pathological version coding schemes that will fool
|
|
this parser, but they should be very rare in practice.
|
|
|
|
The returned value will be a tuple of strings. Numeric portions of the
|
|
version are padded to 8 digits so they will compare numerically, but
|
|
without relying on how numbers compare relative to strings. Dots are
|
|
dropped, but dashes are retained. Trailing zeros between alpha segments
|
|
or dashes are suppressed, so that e.g. "2.4.0" is considered the same as
|
|
"2.4". Alphanumeric parts are lower-cased.
|
|
|
|
The algorithm assumes that strings like "-" and any alpha string that
|
|
alphabetically follows "final" represents a "patch level". So, "2.4-1"
|
|
is assumed to be a branch or patch of "2.4", and therefore "2.4.1" is
|
|
considered newer than "2.4-1", which in turn is newer than "2.4".
|
|
|
|
Strings like "a", "b", "c", "alpha", "beta", "candidate" and so on (that
|
|
come before "final" alphabetically) are assumed to be pre-release versions,
|
|
so that the version "2.4" is considered newer than "2.4a1".
|
|
|
|
Finally, to handle miscellaneous cases, the strings "pre", "preview", and
|
|
"rc" are treated as if they were "c", i.e. as though they were release
|
|
candidates, and therefore are not as new as a version string that does not
|
|
contain them, and "dev" is replaced with an '@' so that it sorts lower than
|
|
than any other pre-release tag.
|
|
|
|
"""
|
|
parts = []
|
|
for part in _parse_version_parts(s.lower()):
|
|
if part.startswith('*'):
|
|
if part < '*final': # remove '-' before a prerelease tag
|
|
while parts and parts[-1] == '*final-':
|
|
parts.pop()
|
|
# remove trailing zeros from each series of numeric parts
|
|
while parts and parts[-1] == '00000000':
|
|
parts.pop()
|
|
parts.append(part)
|
|
return tuple(parts)
|