Skip to content

pip version check redux #2049

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 18, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
6 changes: 6 additions & 0 deletions pip/basecommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions pip/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down Expand Up @@ -417,6 +424,7 @@ def make(self):
no_check_certificate,
cache_dir,
no_cache,
disable_pip_version_check,
]
}

Expand Down
10 changes: 9 additions & 1 deletion pip/compat/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pip/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions pip/utils/outdated.py
Original file line number Diff line number Diff line change
@@ -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,
)
4 changes: 4 additions & 0 deletions tests/unit/test_basecommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/test_unit_outdated.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ envlist =

[testenv]
deps =
https://github.com/dstufft/freezegun/archive/fix-pytest.zip#egg=freezegun
pretend
pytest
pytest-capturelog
Expand Down