self_outdated_check.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import datetime
  2. import functools
  3. import hashlib
  4. import json
  5. import logging
  6. import optparse
  7. import os.path
  8. import sys
  9. from dataclasses import dataclass
  10. from typing import Any, Callable, Dict, Optional
  11. from pip._vendor.packaging.version import parse as parse_version
  12. from pip._vendor.rich.console import Group
  13. from pip._vendor.rich.markup import escape
  14. from pip._vendor.rich.text import Text
  15. from pip._internal.index.collector import LinkCollector
  16. from pip._internal.index.package_finder import PackageFinder
  17. from pip._internal.metadata import get_default_environment
  18. from pip._internal.metadata.base import DistributionVersion
  19. from pip._internal.models.selection_prefs import SelectionPreferences
  20. from pip._internal.network.session import PipSession
  21. from pip._internal.utils.compat import WINDOWS
  22. from pip._internal.utils.entrypoints import (
  23. get_best_invocation_for_this_pip,
  24. get_best_invocation_for_this_python,
  25. )
  26. from pip._internal.utils.filesystem import adjacent_tmp_file, check_path_owner, replace
  27. from pip._internal.utils.misc import ensure_dir
  28. _DATE_FMT = "%Y-%m-%dT%H:%M:%SZ"
  29. logger = logging.getLogger(__name__)
  30. def _get_statefile_name(key: str) -> str:
  31. key_bytes = key.encode()
  32. name = hashlib.sha224(key_bytes).hexdigest()
  33. return name
  34. class SelfCheckState:
  35. def __init__(self, cache_dir: str) -> None:
  36. self._state: Dict[str, Any] = {}
  37. self._statefile_path = None
  38. # Try to load the existing state
  39. if cache_dir:
  40. self._statefile_path = os.path.join(
  41. cache_dir, "selfcheck", _get_statefile_name(self.key)
  42. )
  43. try:
  44. with open(self._statefile_path, encoding="utf-8") as statefile:
  45. self._state = json.load(statefile)
  46. except (OSError, ValueError, KeyError):
  47. # Explicitly suppressing exceptions, since we don't want to
  48. # error out if the cache file is invalid.
  49. pass
  50. @property
  51. def key(self) -> str:
  52. return sys.prefix
  53. def get(self, current_time: datetime.datetime) -> Optional[str]:
  54. """Check if we have a not-outdated version loaded already."""
  55. if not self._state:
  56. return None
  57. if "last_check" not in self._state:
  58. return None
  59. if "pypi_version" not in self._state:
  60. return None
  61. seven_days_in_seconds = 7 * 24 * 60 * 60
  62. # Determine if we need to refresh the state
  63. last_check = datetime.datetime.strptime(self._state["last_check"], _DATE_FMT)
  64. seconds_since_last_check = (current_time - last_check).total_seconds()
  65. if seconds_since_last_check > seven_days_in_seconds:
  66. return None
  67. return self._state["pypi_version"]
  68. def set(self, pypi_version: str, current_time: datetime.datetime) -> None:
  69. # If we do not have a path to cache in, don't bother saving.
  70. if not self._statefile_path:
  71. return
  72. # Check to make sure that we own the directory
  73. if not check_path_owner(os.path.dirname(self._statefile_path)):
  74. return
  75. # Now that we've ensured the directory is owned by this user, we'll go
  76. # ahead and make sure that all our directories are created.
  77. ensure_dir(os.path.dirname(self._statefile_path))
  78. state = {
  79. # Include the key so it's easy to tell which pip wrote the
  80. # file.
  81. "key": self.key,
  82. "last_check": current_time.strftime(_DATE_FMT),
  83. "pypi_version": pypi_version,
  84. }
  85. text = json.dumps(state, sort_keys=True, separators=(",", ":"))
  86. with adjacent_tmp_file(self._statefile_path) as f:
  87. f.write(text.encode())
  88. try:
  89. # Since we have a prefix-specific state file, we can just
  90. # overwrite whatever is there, no need to check.
  91. replace(f.name, self._statefile_path)
  92. except OSError:
  93. # Best effort.
  94. pass
  95. @dataclass
  96. class UpgradePrompt:
  97. old: str
  98. new: str
  99. def __rich__(self) -> Group:
  100. if WINDOWS:
  101. pip_cmd = f"{get_best_invocation_for_this_python()} -m pip"
  102. else:
  103. pip_cmd = get_best_invocation_for_this_pip()
  104. notice = "[bold][[reset][blue]notice[reset][bold]][reset]"
  105. return Group(
  106. Text(),
  107. Text.from_markup(
  108. f"{notice} A new release of pip is available: "
  109. f"[red]{self.old}[reset] -> [green]{self.new}[reset]"
  110. ),
  111. Text.from_markup(
  112. f"{notice} To update, run: "
  113. f"[green]{escape(pip_cmd)} install --upgrade pip"
  114. ),
  115. )
  116. def was_installed_by_pip(pkg: str) -> bool:
  117. """Checks whether pkg was installed by pip
  118. This is used not to display the upgrade message when pip is in fact
  119. installed by system package manager, such as dnf on Fedora.
  120. """
  121. dist = get_default_environment().get_distribution(pkg)
  122. return dist is not None and "pip" == dist.installer
  123. def _get_current_remote_pip_version(
  124. session: PipSession, options: optparse.Values
  125. ) -> Optional[str]:
  126. # Lets use PackageFinder to see what the latest pip version is
  127. link_collector = LinkCollector.create(
  128. session,
  129. options=options,
  130. suppress_no_index=True,
  131. )
  132. # Pass allow_yanked=False so we don't suggest upgrading to a
  133. # yanked version.
  134. selection_prefs = SelectionPreferences(
  135. allow_yanked=False,
  136. allow_all_prereleases=False, # Explicitly set to False
  137. )
  138. finder = PackageFinder.create(
  139. link_collector=link_collector,
  140. selection_prefs=selection_prefs,
  141. )
  142. best_candidate = finder.find_best_candidate("pip").best_candidate
  143. if best_candidate is None:
  144. return None
  145. return str(best_candidate.version)
  146. def _self_version_check_logic(
  147. *,
  148. state: SelfCheckState,
  149. current_time: datetime.datetime,
  150. local_version: DistributionVersion,
  151. get_remote_version: Callable[[], Optional[str]],
  152. ) -> Optional[UpgradePrompt]:
  153. remote_version_str = state.get(current_time)
  154. if remote_version_str is None:
  155. remote_version_str = get_remote_version()
  156. if remote_version_str is None:
  157. logger.debug("No remote pip version found")
  158. return None
  159. state.set(remote_version_str, current_time)
  160. remote_version = parse_version(remote_version_str)
  161. logger.debug("Remote version of pip: %s", remote_version)
  162. logger.debug("Local version of pip: %s", local_version)
  163. pip_installed_by_pip = was_installed_by_pip("pip")
  164. logger.debug("Was pip installed by pip? %s", pip_installed_by_pip)
  165. if not pip_installed_by_pip:
  166. return None # Only suggest upgrade if pip is installed by pip.
  167. local_version_is_older = (
  168. local_version < remote_version
  169. and local_version.base_version != remote_version.base_version
  170. )
  171. if local_version_is_older:
  172. return UpgradePrompt(old=str(local_version), new=remote_version_str)
  173. return None
  174. def pip_self_version_check(session: PipSession, options: optparse.Values) -> None:
  175. """Check for an update for pip.
  176. Limit the frequency of checks to once per week. State is stored either in
  177. the active virtualenv or in the user's USER_CACHE_DIR keyed off the prefix
  178. of the pip script path.
  179. """
  180. installed_dist = get_default_environment().get_distribution("pip")
  181. if not installed_dist:
  182. return
  183. try:
  184. upgrade_prompt = _self_version_check_logic(
  185. state=SelfCheckState(cache_dir=options.cache_dir),
  186. current_time=datetime.datetime.utcnow(),
  187. local_version=installed_dist.version,
  188. get_remote_version=functools.partial(
  189. _get_current_remote_pip_version, session, options
  190. ),
  191. )
  192. if upgrade_prompt is not None:
  193. logger.warning("[present-rich] %s", upgrade_prompt)
  194. except Exception:
  195. logger.warning("There was an error checking the latest version of pip.")
  196. logger.debug("See below for error", exc_info=True)