diff --git a/CHANGES.txt b/CHANGES.txt index a56a5903206..44fea4252ca 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -26,6 +26,9 @@ * Added site-wide configuation files. (:pull:`1978`) +* Added an automatic check to warn if there is an updated version of pip + available (:pull:`2049`). + * `wsgiref` and `argparse` (for >py26) are now excluded from `pip list` and `pip freeze` (:pull:`1606`, :pull:`1369`) diff --git a/pip/basecommand.py b/pip/basecommand.py index 8d7b4ca9989..e9223713a26 100644 --- a/pip/basecommand.py +++ b/pip/basecommand.py @@ -24,6 +24,7 @@ from pip.utils import appdirs, get_prog, normalize_path from pip.utils.deprecation import RemovedInPip8Warning from pip.utils.logging import IndentingFormatter +from pip.utils.outdated import pip_version_check __all__ = ['Command'] @@ -199,6 +200,11 @@ def main(self, args): ) sys.exit(VIRTUALENV_NOT_FOUND) + # Check if we're using the latest version of pip available + if not options.disable_pip_version_check: + with self._build_session(options) as session: + pip_version_check(session) + try: status = self.run(options, args) # FIXME: all commands should return an exit status diff --git a/pip/cmdoptions.py b/pip/cmdoptions.py index 8654a7397fc..96daabc2cba 100644 --- a/pip/cmdoptions.py +++ b/pip/cmdoptions.py @@ -390,6 +390,13 @@ def make(self): default=False, help="Don't clean up build directories.") +disable_pip_version_check = OptionMaker( + "--disable-pip-version-check", + dest="disable_pip_version_check", + action="store_true", + default=False, + help="Don't periodically check PyPI to determine whether a new version " + "of pip is available for download.") ########## # groups # @@ -417,6 +424,7 @@ def make(self): no_check_certificate, cache_dir, no_cache, + disable_pip_version_check, ] } diff --git a/pip/compat/__init__.py b/pip/compat/__init__.py index b203f7b10cd..1cfd0468c2a 100644 --- a/pip/compat/__init__.py +++ b/pip/compat/__init__.py @@ -1,6 +1,6 @@ """Stuff that differs in different Python versions and platform distributions.""" -from __future__ import absolute_import +from __future__ import absolute_import, division import os import imp @@ -46,6 +46,14 @@ def native_str(s, replace=False): return s +def total_seconds(td): + if hasattr(td, "total_seconds"): + return td.total_seconds() + else: + val = td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6 + return val / 10 ** 6 + + def get_path_uid(path): """ Return path's uid. diff --git a/pip/utils/__init__.py b/pip/utils/__init__.py index 1dfc40984f9..e62fe7e832b 100644 --- a/pip/utils/__init__.py +++ b/pip/utils/__init__.py @@ -17,7 +17,7 @@ from pip.compat import console_to_str, stdlib_pkgs from pip.locations import ( site_packages, user_site, running_under_virtualenv, virtualenv_no_global, - write_delete_marker_file + write_delete_marker_file, ) from pip._vendor import pkg_resources, six from pip._vendor.distlib import version diff --git a/pip/utils/outdated.py b/pip/utils/outdated.py new file mode 100644 index 00000000000..61fae4d431a --- /dev/null +++ b/pip/utils/outdated.py @@ -0,0 +1,131 @@ +from __future__ import absolute_import + +import datetime +import json +import logging +import os.path +import sys + +from pip._vendor import lockfile +from pip._vendor import pkg_resources + +from pip.compat import total_seconds +from pip.locations import USER_CACHE_DIR, running_under_virtualenv + + +SELFCHECK_DATE_FMT = "%Y-%m-%dT%H:%M:%SZ" + + +logger = logging.getLogger(__name__) + + +class VirtualenvSelfCheckState(object): + def __init__(self): + self.statefile_path = os.path.join(sys.prefix, "pip-selfcheck.json") + + # Load the existing state + try: + with open(self.statefile_path) as statefile: + self.state = json.load(statefile) + except (IOError, ValueError): + self.state = {} + + def save(self, pypi_version, current_time): + # Attempt to write out our version check file + with open(self.statefile_path, "w") as statefile: + json.dump( + { + "last_check": current_time.strftime(SELFCHECK_DATE_FMT), + "pypi_version": pypi_version, + }, + statefile, + sort_keys=True, + separators=(",", ":") + ) + + +class GlobalSelfCheckState(object): + def __init__(self): + self.statefile_path = os.path.join(USER_CACHE_DIR, "selfcheck.json") + + # Load the existing state + try: + with open(self.statefile_path) as statefile: + self.state = json.load(statefile)[sys.prefix] + except (IOError, ValueError, KeyError): + self.state = {} + + def save(self, pypi_version, current_time): + # Attempt to write out our version check file + with lockfile.LockFile(self.statefile_path): + with open(self.statefile_path) as statefile: + state = json.load(statefile) + + state[sys.prefix] = { + "last_check": current_time.strftime(SELFCHECK_DATE_FMT), + "pypi_version": pypi_version, + } + + with open(self.statefile_path, "w") as statefile: + json.dump(state, statefile, sort_keys=True, + separators=(",", ":")) + + +def load_selfcheck_statefile(): + if running_under_virtualenv(): + return VirtualenvSelfCheckState() + else: + return GlobalSelfCheckState() + + +def pip_version_check(session): + """Check for an update for pip. + + Limit the frequency of checks to once per week. State is stored either in + the active virtualenv or in the user's USER_CACHE_DIR keyed off the prefix + of the pip script path. + """ + import pip # imported here to prevent circular imports + pypi_version = None + + try: + state = load_selfcheck_statefile() + + current_time = datetime.datetime.utcnow() + # Determine if we need to refresh the state + if "last_check" in state.state and "pypi_version" in state.state: + last_check = datetime.datetime.strptime( + state.state["last_check"], + SELFCHECK_DATE_FMT + ) + if total_seconds(current_time - last_check) < 7 * 24 * 60 * 60: + pypi_version = state.state["pypi_version"] + + # Refresh the version if we need to or just see if we need to warn + if pypi_version is None: + resp = session.get( + "https://pypi.python.org/pypi/pip/json", + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + pypi_version = resp.json()["info"]["version"] + + # save that we've performed a check + state.save(pypi_version, current_time) + + pip_version = pkg_resources.parse_version(pip.__version__) + + # Determine if our pypi_version is older + if pip_version < pkg_resources.parse_version(pypi_version): + logger.warning( + "You are using pip version %s, however version %s is " + "available.\nYou should consider upgrading via the " + "'pip install --upgrade pip' command." % (pip.__version__, + pypi_version) + ) + + except Exception: + logger.debug( + "There was an error checking the latest version of pip", + exc_info=True, + ) diff --git a/tests/unit/test_basecommand.py b/tests/unit/test_basecommand.py index a5c7ab5edd7..3260ff83a00 100644 --- a/tests/unit/test_basecommand.py +++ b/tests/unit/test_basecommand.py @@ -11,6 +11,10 @@ def __init__(self, error=False): self.error = error super(FakeCommand, self).__init__() + def main(self, args): + args.append("--disable-pip-version-check") + return super(FakeCommand, self).main(args) + def run(self, options, args): logging.getLogger("pip.tests").info("fake") if self.error: diff --git a/tests/unit/test_unit_outdated.py b/tests/unit/test_unit_outdated.py new file mode 100644 index 00000000000..be4239b6649 --- /dev/null +++ b/tests/unit/test_unit_outdated.py @@ -0,0 +1,149 @@ +import sys +import datetime +import os +from contextlib import contextmanager + +import freezegun +import pytest +import pretend + +import pip +from pip._vendor import lockfile +from pip.utils import outdated + + +@pytest.mark.parametrize( + ['stored_time', 'newver', 'check', 'warn'], + [ + ('1970-01-01T10:00:00Z', '2.0', True, True), + ('1970-01-01T10:00:00Z', '1.0', True, False), + ('1970-01-06T10:00:00Z', '1.0', False, False), + ('1970-01-06T10:00:00Z', '2.0', False, True), + ] +) +def test_pip_version_check(monkeypatch, stored_time, newver, check, warn): + monkeypatch.setattr(pip, '__version__', '1.0') + + resp = pretend.stub( + raise_for_status=pretend.call_recorder(lambda: None), + json=pretend.call_recorder(lambda: {"info": {"version": newver}}), + ) + session = pretend.stub( + get=pretend.call_recorder(lambda u, headers=None: resp), + ) + + fake_state = pretend.stub( + state={"last_check": stored_time, 'pypi_version': '1.0'}, + save=pretend.call_recorder(lambda v, t: None), + ) + + monkeypatch.setattr( + outdated, 'load_selfcheck_statefile', lambda: fake_state + ) + + monkeypatch.setattr(outdated.logger, 'warning', + pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(outdated.logger, 'debug', + pretend.call_recorder(lambda s, exc_info=None: None)) + + with freezegun.freeze_time("1970-01-09 10:00:00", + ignore=["pip._vendor.six.moves"]): + outdated.pip_version_check(session) + + assert not outdated.logger.debug.calls + + if check: + assert session.get.calls == [pretend.call( + "https://pypi.python.org/pypi/pip/json", + headers={"Accept": "application/json"} + )] + assert fake_state.save.calls == [ + pretend.call(newver, datetime.datetime(1970, 1, 9, 10, 00, 00)), + ] + if warn: + assert len(outdated.logger.warning.calls) == 1 + else: + assert len(outdated.logger.warning.calls) == 0 + else: + assert session.get.calls == [] + assert fake_state.save.calls == [] + + +def test_virtualenv_state(monkeypatch): + CONTENT = '{"last_check": "1970-01-02T11:00:00Z", "pypi_version": "1.0"}' + fake_file = pretend.stub( + read=pretend.call_recorder(lambda: CONTENT), + write=pretend.call_recorder(lambda s: None), + ) + + @pretend.call_recorder + @contextmanager + def fake_open(filename, mode='r'): + yield fake_file + + monkeypatch.setattr(outdated, 'open', fake_open, raising=False) + + monkeypatch.setattr(outdated, 'running_under_virtualenv', + pretend.call_recorder(lambda: True)) + + monkeypatch.setattr(sys, 'prefix', 'virtually_env') + + state = outdated.load_selfcheck_statefile() + state.save('2.0', datetime.datetime.utcnow()) + + assert len(outdated.running_under_virtualenv.calls) == 1 + + expected_path = os.path.join('virtually_env', 'pip-selfcheck.json') + assert fake_open.calls == [ + pretend.call(expected_path), + pretend.call(expected_path, 'w'), + ] + + # json.dumps will call this a number of times + assert len(fake_file.write.calls) + + +def test_global_state(monkeypatch): + CONTENT = '''{"pip_prefix": {"last_check": "1970-01-02T11:00:00Z", + "pypi_version": "1.0"}}''' + fake_file = pretend.stub( + read=pretend.call_recorder(lambda: CONTENT), + write=pretend.call_recorder(lambda s: None), + ) + + @pretend.call_recorder + @contextmanager + def fake_open(filename, mode='r'): + yield fake_file + + monkeypatch.setattr(outdated, 'open', fake_open, raising=False) + + @pretend.call_recorder + @contextmanager + def fake_lock(filename): + yield + + monkeypatch.setattr(lockfile, 'LockFile', fake_lock) + + monkeypatch.setattr(outdated, 'running_under_virtualenv', + pretend.call_recorder(lambda: False)) + + monkeypatch.setattr(outdated, 'USER_CACHE_DIR', 'cache_dir') + monkeypatch.setattr(sys, 'prefix', 'pip_prefix') + + state = outdated.load_selfcheck_statefile() + state.save('2.0', datetime.datetime.utcnow()) + + assert len(outdated.running_under_virtualenv.calls) == 1 + + expected_path = os.path.join('cache_dir', 'selfcheck.json') + assert fake_lock.calls == [pretend.call(expected_path)] + + assert fake_open.calls == [ + pretend.call(expected_path), + pretend.call(expected_path), + pretend.call(expected_path, 'w'), + ] + + # json.dumps will call this a number of times + assert len(fake_file.write.calls) diff --git a/tox.ini b/tox.ini index 39644b483f4..9bfa005568a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = [testenv] deps = + https://github.com/dstufft/freezegun/archive/fix-pytest.zip#egg=freezegun pretend pytest pytest-capturelog