periodic_update.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. """Periodically update bundled versions."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import os
  6. import ssl
  7. import sys
  8. from datetime import datetime, timedelta, timezone
  9. from itertools import groupby
  10. from pathlib import Path
  11. from shutil import copy2
  12. from subprocess import DEVNULL, Popen
  13. from textwrap import dedent
  14. from threading import Thread
  15. from typing import TYPE_CHECKING
  16. from urllib.error import URLError
  17. from urllib.request import urlopen
  18. from virtualenv.app_data import AppDataDiskFolder
  19. from virtualenv.seed.wheels.embed import BUNDLE_SUPPORT
  20. from virtualenv.seed.wheels.util import Wheel
  21. from virtualenv.util.subprocess import CREATE_NO_WINDOW
  22. if TYPE_CHECKING:
  23. from collections.abc import Generator
  24. from virtualenv.app_data.base import AppData
  25. LOGGER = logging.getLogger(__name__)
  26. GRACE_PERIOD_CI = timedelta(hours=1) # prevent version switch in the middle of a CI run
  27. GRACE_PERIOD_MINOR = timedelta(days=28)
  28. UPDATE_PERIOD = timedelta(days=14)
  29. UPDATE_ABORTED_DELAY = timedelta(hours=1)
  30. def periodic_update( # noqa: PLR0913
  31. distribution: str,
  32. of_version: str | None,
  33. for_py_version: str,
  34. wheel: Wheel | None,
  35. search_dirs: list[Path],
  36. app_data: AppData,
  37. do_periodic_update: bool,
  38. env: dict[str, str],
  39. ) -> Wheel | None:
  40. if do_periodic_update:
  41. handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env)
  42. now = datetime.now(tz=timezone.utc)
  43. def _update_wheel(ver: NewVersion) -> Wheel:
  44. updated_wheel = Wheel(app_data.house / ver.filename)
  45. LOGGER.debug("using %supdated wheel %s", "periodically " if updated_wheel else "", updated_wheel)
  46. return updated_wheel
  47. u_log = UpdateLog.from_app_data(app_data, distribution, for_py_version)
  48. if of_version is None:
  49. for _, group in groupby(u_log.versions, key=lambda v: v.wheel.version_tuple[0:2]):
  50. # use only latest patch version per minor, earlier assumed to be buggy
  51. all_patches = list(group)
  52. ignore_grace_period_minor = any(version for version in all_patches if version.use(now))
  53. for version in all_patches:
  54. if wheel is not None and Path(version.filename).name == wheel.name:
  55. return wheel
  56. if version.use(now, ignore_grace_period_minor):
  57. return _update_wheel(version)
  58. else:
  59. for version in u_log.versions:
  60. if version.wheel.version == of_version:
  61. return _update_wheel(version)
  62. return wheel
  63. def handle_auto_update( # noqa: PLR0913
  64. distribution: str,
  65. for_py_version: str,
  66. wheel: Wheel | None,
  67. search_dirs: list[Path],
  68. app_data: AppData,
  69. env: dict[str, str],
  70. ) -> None:
  71. embed_update_log = app_data.embed_update_log(distribution, for_py_version)
  72. u_log = UpdateLog.from_dict(embed_update_log.read())
  73. if u_log.needs_update:
  74. u_log.periodic = True
  75. u_log.started = datetime.now(tz=timezone.utc)
  76. embed_update_log.write(u_log.to_dict())
  77. trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, periodic=True, env=env)
  78. def add_wheel_to_update_log(wheel: Wheel, for_py_version: str, app_data: AppData) -> None:
  79. embed_update_log = app_data.embed_update_log(wheel.distribution, for_py_version)
  80. LOGGER.debug("adding %s information to %s", wheel.name, embed_update_log.file) # ty: ignore[unresolved-attribute]
  81. u_log = UpdateLog.from_dict(embed_update_log.read())
  82. if any(version.filename == wheel.name for version in u_log.versions):
  83. LOGGER.warning("%s already present in %s", wheel.name, embed_update_log.file) # ty: ignore[unresolved-attribute]
  84. return
  85. # we don't need a release date for sources other than "periodic"
  86. version = NewVersion(wheel.name, datetime.now(tz=timezone.utc), None, "download")
  87. u_log.versions.append(version) # always write at the end for proper updates
  88. embed_update_log.write(u_log.to_dict())
  89. DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ"
  90. def dump_datetime(value: datetime | None) -> str | None:
  91. return None if value is None else value.strftime(DATETIME_FMT)
  92. def load_datetime(value: str | None) -> datetime | None:
  93. return None if value is None else datetime.strptime(value, DATETIME_FMT).replace(tzinfo=timezone.utc)
  94. class NewVersion: # noqa: PLW1641
  95. def __init__(self, filename: str, found_date: datetime, release_date: datetime | None, source: str) -> None:
  96. self.filename = filename
  97. self.found_date = found_date
  98. self.release_date = release_date
  99. self.source = source
  100. @classmethod
  101. def from_dict(cls, dictionary: dict[str, str | None]) -> NewVersion:
  102. return cls(
  103. filename=dictionary["filename"], # ty: ignore[invalid-argument-type]
  104. found_date=load_datetime(dictionary["found_date"]), # ty: ignore[invalid-argument-type]
  105. release_date=load_datetime(dictionary["release_date"]),
  106. source=dictionary["source"], # ty: ignore[invalid-argument-type]
  107. )
  108. def to_dict(self) -> dict[str, str | None]:
  109. return {
  110. "filename": self.filename,
  111. "release_date": dump_datetime(self.release_date),
  112. "found_date": dump_datetime(self.found_date),
  113. "source": self.source,
  114. }
  115. def use(self, now: datetime, ignore_grace_period_minor: bool = False, ignore_grace_period_ci: bool = False) -> bool: # noqa: FBT002
  116. if self.source == "manual":
  117. return True
  118. if self.source == "periodic" and (self.found_date < now - GRACE_PERIOD_CI or ignore_grace_period_ci):
  119. if not ignore_grace_period_minor:
  120. compare_from = self.release_date or self.found_date
  121. return now - compare_from >= GRACE_PERIOD_MINOR
  122. return True
  123. return False
  124. def __repr__(self) -> str:
  125. return (
  126. f"{self.__class__.__name__}(filename={self.filename}), found_date={self.found_date}, "
  127. f"release_date={self.release_date}, source={self.source})"
  128. )
  129. def __eq__(self, other: object) -> bool:
  130. return type(self) == type(other) and all( # noqa: E721
  131. getattr(self, k) == getattr(other, k) for k in ["filename", "release_date", "found_date", "source"]
  132. )
  133. def __ne__(self, other: object) -> bool:
  134. return not (self == other)
  135. @property
  136. def wheel(self) -> Wheel:
  137. return Wheel(Path(self.filename))
  138. class UpdateLog:
  139. def __init__(
  140. self, started: datetime | None, completed: datetime | None, versions: list[NewVersion], periodic: bool | None
  141. ) -> None:
  142. self.started = started
  143. self.completed = completed
  144. self.versions = versions
  145. self.periodic = periodic
  146. @classmethod
  147. def from_dict(cls, dictionary: dict[str, object] | None) -> UpdateLog:
  148. if dictionary is None:
  149. dictionary = {}
  150. return cls(
  151. load_datetime(dictionary.get("started")), # ty: ignore[invalid-argument-type]
  152. load_datetime(dictionary.get("completed")), # ty: ignore[invalid-argument-type]
  153. [NewVersion.from_dict(v) for v in dictionary.get("versions", [])], # ty: ignore[not-iterable]
  154. dictionary.get("periodic"), # ty: ignore[invalid-argument-type]
  155. )
  156. @classmethod
  157. def from_app_data(cls, app_data: AppData, distribution: str, for_py_version: str) -> UpdateLog:
  158. raw_json = app_data.embed_update_log(distribution, for_py_version).read()
  159. return cls.from_dict(raw_json)
  160. def to_dict(self) -> dict[str, object]:
  161. return {
  162. "started": dump_datetime(self.started),
  163. "completed": dump_datetime(self.completed),
  164. "periodic": self.periodic,
  165. "versions": [r.to_dict() for r in self.versions],
  166. }
  167. @property
  168. def needs_update(self) -> bool:
  169. now = datetime.now(tz=timezone.utc)
  170. if self.completed is None: # never completed
  171. return self._check_start(now)
  172. if now - self.completed <= UPDATE_PERIOD:
  173. return False
  174. return self._check_start(now)
  175. def _check_start(self, now: datetime) -> bool:
  176. return self.started is None or now - self.started > UPDATE_ABORTED_DELAY
  177. def trigger_update( # noqa: PLR0913
  178. distribution: str,
  179. for_py_version: str,
  180. wheel: Wheel | None,
  181. search_dirs: list[Path],
  182. app_data: AppData,
  183. env: dict[str, str],
  184. periodic: bool,
  185. ) -> None:
  186. wheel_path = None if wheel is None else str(wheel.path)
  187. cmd = [
  188. sys.executable,
  189. "-c",
  190. dedent(
  191. """
  192. from virtualenv.report import setup_report, MAX_LEVEL
  193. from virtualenv.seed.wheels.periodic_update import do_update
  194. setup_report(MAX_LEVEL, show_pid=True)
  195. do_update({!r}, {!r}, {!r}, {!r}, {!r}, {!r})
  196. """,
  197. )
  198. .strip()
  199. .format(distribution, for_py_version, wheel_path, str(app_data), [str(p) for p in search_dirs], periodic),
  200. ]
  201. debug = env.get("_VIRTUALENV_PERIODIC_UPDATE_INLINE") == "1"
  202. pipe = None if debug else DEVNULL
  203. kwargs = {"stdout": pipe, "stderr": pipe}
  204. if not debug and sys.platform == "win32":
  205. kwargs["creationflags"] = CREATE_NO_WINDOW
  206. process = Popen(cmd, **kwargs) # ty: ignore[no-matching-overload]
  207. LOGGER.info(
  208. "triggered periodic upgrade of %s%s (for python %s) via background process having PID %d",
  209. distribution,
  210. "" if wheel is None else f"=={wheel.version}",
  211. for_py_version,
  212. process.pid,
  213. )
  214. if debug:
  215. process.communicate() # on purpose not called to make it a background process
  216. else:
  217. # set the returncode here -> no ResourceWarning on main process exit if the subprocess still runs
  218. process.returncode = 0
  219. def do_update( # noqa: PLR0913
  220. distribution: str,
  221. for_py_version: str,
  222. embed_filename: str | None,
  223. app_data: str | AppData,
  224. search_dirs: list[str] | list[Path],
  225. periodic: bool,
  226. ) -> list[NewVersion] | None:
  227. versions = None
  228. try:
  229. versions = _run_do_update(app_data, distribution, embed_filename, for_py_version, periodic, search_dirs)
  230. finally:
  231. LOGGER.debug("done %s %s with %s", distribution, for_py_version, versions)
  232. return versions
  233. def _run_do_update( # noqa: C901, PLR0913
  234. app_data: str | AppData,
  235. distribution: str,
  236. embed_filename: str | None,
  237. for_py_version: str,
  238. periodic: bool,
  239. search_dirs: list[str] | list[Path],
  240. ) -> list[NewVersion]:
  241. from virtualenv.seed.wheels import acquire # noqa: PLC0415
  242. wheel_filename = None if embed_filename is None else Path(embed_filename)
  243. embed_version = None if wheel_filename is None else Wheel(wheel_filename).version_tuple
  244. app_data = AppDataDiskFolder(app_data) if isinstance(app_data, str) else app_data
  245. search_dirs = [Path(p) if isinstance(p, str) else p for p in search_dirs]
  246. wheelhouse = app_data.house
  247. embed_update_log = app_data.embed_update_log(distribution, for_py_version)
  248. u_log = UpdateLog.from_dict(embed_update_log.read())
  249. now = datetime.now(tz=timezone.utc)
  250. update_versions, other_versions = [], []
  251. for version in u_log.versions:
  252. if version.source in {"periodic", "manual"}:
  253. update_versions.append(version)
  254. else:
  255. other_versions.append(version)
  256. if periodic:
  257. source = "periodic"
  258. else:
  259. source = "manual"
  260. # mark the most recent one as source "manual"
  261. if update_versions:
  262. update_versions[0].source = source
  263. if wheel_filename is not None:
  264. dest = wheelhouse / wheel_filename.name
  265. if not dest.exists():
  266. copy2(str(wheel_filename), str(wheelhouse))
  267. last, last_version, versions, filenames = None, None, [], set()
  268. while last is None or not last.use(now, ignore_grace_period_ci=True):
  269. download_time = datetime.now(tz=timezone.utc)
  270. dest = acquire.download_wheel(
  271. distribution=distribution,
  272. version_spec=None if last_version is None else f"<{last_version}",
  273. for_py_version=for_py_version,
  274. search_dirs=search_dirs,
  275. app_data=app_data,
  276. to_folder=wheelhouse,
  277. env=os.environ, # ty: ignore[invalid-argument-type]
  278. )
  279. if dest is None or (update_versions and update_versions[0].filename == dest.name):
  280. break
  281. release_date = release_date_for_wheel_path(dest.path)
  282. last = NewVersion(filename=dest.path.name, release_date=release_date, found_date=download_time, source=source)
  283. LOGGER.info("detected %s in %s", last, datetime.now(tz=timezone.utc) - download_time)
  284. versions.append(last)
  285. filenames.add(last.filename)
  286. last_wheel = last.wheel
  287. last_version = last_wheel.version
  288. if embed_version is not None and embed_version >= last_wheel.version_tuple:
  289. break # stop download if we reach the embed version
  290. u_log.periodic = periodic
  291. if not u_log.periodic:
  292. u_log.started = now
  293. # update other_versions by removing version we just found
  294. other_versions = [version for version in other_versions if version.filename not in filenames]
  295. u_log.versions = versions + update_versions + other_versions
  296. u_log.completed = datetime.now(tz=timezone.utc)
  297. embed_update_log.write(u_log.to_dict())
  298. return versions
  299. def release_date_for_wheel_path(dest: Path) -> datetime | None:
  300. wheel = Wheel(dest)
  301. # the most accurate is to ask PyPi - e.g. https://pypi.org/pypi/pip/json,
  302. # see https://warehouse.pypa.io/api-reference/json/ for more details
  303. content = _pypi_get_distribution_info_cached(wheel.distribution)
  304. if content is not None:
  305. try:
  306. upload_time = content["releases"][wheel.version][0]["upload_time"] # ty: ignore[not-subscriptable]
  307. return datetime.strptime(upload_time, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
  308. except Exception as exception: # noqa: BLE001
  309. LOGGER.error("could not load release date %s because %r", content, exception) # noqa: TRY400
  310. return None
  311. def _request_context() -> Generator[ssl.SSLContext | None, None, None]:
  312. yield None
  313. # fallback to non verified HTTPS (the information we request is not sensitive, so fallback)
  314. yield ssl._create_unverified_context() # noqa: S323, SLF001
  315. _PYPI_CACHE = {}
  316. def _pypi_get_distribution_info_cached(distribution: str) -> dict[str, object] | None:
  317. if distribution not in _PYPI_CACHE:
  318. _PYPI_CACHE[distribution] = _pypi_get_distribution_info(distribution)
  319. return _PYPI_CACHE[distribution]
  320. def _pypi_get_distribution_info(distribution: str) -> dict[str, object] | None:
  321. content, url = None, f"https://pypi.org/pypi/{distribution}/json"
  322. try:
  323. for context in _request_context():
  324. try:
  325. with urlopen(url, context=context) as file_handler:
  326. content = json.load(file_handler)
  327. break
  328. except URLError as exception:
  329. LOGGER.error("failed to access %s because %r", url, exception) # noqa: TRY400
  330. except Exception as exception: # noqa: BLE001
  331. LOGGER.error("failed to access %s because %r", url, exception) # noqa: TRY400
  332. return content
  333. def manual_upgrade(app_data: AppData, env: dict[str, str]) -> None:
  334. threads = []
  335. for for_py_version, distribution_to_package in BUNDLE_SUPPORT.items():
  336. # load extra search dir for the given for_py
  337. for distribution in distribution_to_package:
  338. thread = Thread(target=_run_manual_upgrade, args=(app_data, distribution, for_py_version, env))
  339. thread.start()
  340. threads.append(thread)
  341. for thread in threads:
  342. thread.join()
  343. def _run_manual_upgrade(app_data: AppData, distribution: str, for_py_version: str, env: dict[str, str]) -> None:
  344. start = datetime.now(tz=timezone.utc)
  345. from .bundle import from_bundle # noqa: PLC0415
  346. current = from_bundle(
  347. distribution=distribution,
  348. version=None,
  349. for_py_version=for_py_version,
  350. search_dirs=[],
  351. app_data=app_data,
  352. do_periodic_update=False,
  353. env=env,
  354. )
  355. LOGGER.warning(
  356. "upgrade %s for python %s with current %s",
  357. distribution,
  358. for_py_version,
  359. "" if current is None else current.name,
  360. )
  361. versions = do_update(
  362. distribution=distribution,
  363. for_py_version=for_py_version,
  364. embed_filename=current.path, # ty: ignore[invalid-argument-type, unresolved-attribute]
  365. app_data=app_data,
  366. search_dirs=[],
  367. periodic=False,
  368. )
  369. args = [
  370. distribution,
  371. for_py_version,
  372. datetime.now(tz=timezone.utc) - start,
  373. ]
  374. if versions:
  375. args.append("\n".join(f"\t{v}" for v in versions))
  376. ver_update = "new entries found:\n%s" if versions else "no new versions found"
  377. msg = f"upgraded %s for python %s in %s {ver_update}"
  378. LOGGER.warning(msg, *args)
  379. __all__ = [
  380. "NewVersion",
  381. "UpdateLog",
  382. "add_wheel_to_update_log",
  383. "do_update",
  384. "dump_datetime",
  385. "load_datetime",
  386. "manual_upgrade",
  387. "periodic_update",
  388. "release_date_for_wheel_path",
  389. "trigger_update",
  390. ]