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