bootstrap.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. # Copyright 2016 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. import argparse
  5. import collections
  6. import contextlib
  7. import fnmatch
  8. import hashlib
  9. import logging
  10. import os
  11. import platform
  12. import posixpath
  13. import shutil
  14. import string
  15. import subprocess
  16. import sys
  17. import tempfile
  18. THIS_DIR = os.path.abspath(os.path.dirname(__file__))
  19. ROOT_DIR = os.path.abspath(os.path.join(THIS_DIR, '..'))
  20. DEVNULL = open(os.devnull, 'w')
  21. IS_WIN = sys.platform.startswith('win')
  22. BAT_EXT = '.bat' if IS_WIN else ''
  23. # Top-level stubs to generate that fall through to executables within the Git
  24. # directory.
  25. WIN_GIT_STUBS = {
  26. 'git.bat': 'cmd\\git.exe',
  27. 'gitk.bat': 'cmd\\gitk.exe',
  28. 'ssh.bat': 'usr\\bin\\ssh.exe',
  29. 'ssh-keygen.bat': 'usr\\bin\\ssh-keygen.exe',
  30. }
  31. # Accumulated template parameters for generated stubs.
  32. class Template(
  33. collections.namedtuple('Template', (
  34. 'PYTHON3_BIN_RELDIR',
  35. 'PYTHON3_BIN_RELDIR_UNIX',
  36. 'GIT_BIN_ABSDIR',
  37. 'GIT_PROGRAM',
  38. ))):
  39. @classmethod
  40. def empty(cls):
  41. return cls(**{k: None for k in cls._fields})
  42. def maybe_install(self, name, dst_path):
  43. """Installs template |name| to |dst_path| if it has changed.
  44. This loads the template |name| from THIS_DIR, resolves template parameters,
  45. and installs it to |dst_path|. See `maybe_update` for more information.
  46. Args:
  47. name (str): The name of the template to install.
  48. dst_path (str): The destination filesystem path.
  49. Returns (bool): True if |dst_path| was updated, False otherwise.
  50. """
  51. template_path = os.path.join(THIS_DIR, name)
  52. with open(template_path, 'r', encoding='utf8') as fd:
  53. t = string.Template(fd.read())
  54. return maybe_update(t.safe_substitute(self._asdict()), dst_path)
  55. def maybe_update(content, dst_path):
  56. """Writes |content| to |dst_path| if |dst_path| does not already match.
  57. This function will ensure that there is a file at |dst_path| containing
  58. |content|. If |dst_path| already exists and contains |content|, no operation
  59. will be performed, preserving filesystem modification times and avoiding
  60. potential write contention.
  61. Args:
  62. content (str): The file content.
  63. dst_path (str): The destination filesystem path.
  64. Returns (bool): True if |dst_path| was updated, False otherwise.
  65. """
  66. # If the path already exists and matches the new content, refrain from
  67. # writing a new one.
  68. if os.path.exists(dst_path):
  69. with open(dst_path, 'r', encoding='utf-8') as fd:
  70. if fd.read() == content:
  71. return False
  72. logging.debug('Updating %r', dst_path)
  73. with open(dst_path, 'w', encoding='utf-8') as fd:
  74. fd.write(content)
  75. os.chmod(dst_path, 0o755)
  76. return True
  77. def maybe_copy(src_path, dst_path):
  78. """Writes the content of |src_path| to |dst_path| if needed.
  79. See `maybe_update` for more information.
  80. Args:
  81. src_path (str): The content source filesystem path.
  82. dst_path (str): The destination filesystem path.
  83. Returns (bool): True if |dst_path| was updated, False otherwise.
  84. """
  85. with open(src_path, 'r', encoding='utf-8') as fd:
  86. content = fd.read()
  87. return maybe_update(content, dst_path)
  88. def call_if_outdated(stamp_path, stamp_version, fn):
  89. """Invokes |fn| if the stamp at |stamp_path| doesn't match |stamp_version|.
  90. This can be used to keep a filesystem record of whether an operation has been
  91. performed. The record is stored at |stamp_path|. To invalidate a record,
  92. change the value of |stamp_version|.
  93. After |fn| completes successfully, |stamp_path| will be updated to match
  94. |stamp_version|, preventing the same update from happening in the future.
  95. Args:
  96. stamp_path (str): The filesystem path of the stamp file.
  97. stamp_version (str): The desired stamp version.
  98. fn (callable): A callable to invoke if the current stamp version doesn't
  99. match |stamp_version|.
  100. Returns (bool): True if an update occurred.
  101. """
  102. stamp_version = stamp_version.strip()
  103. if os.path.isfile(stamp_path):
  104. with open(stamp_path, 'r', encoding='utf-8') as fd:
  105. current_version = fd.read().strip()
  106. if current_version == stamp_version:
  107. return False
  108. fn()
  109. with open(stamp_path, 'w', encoding='utf-8') as fd:
  110. fd.write(stamp_version)
  111. return True
  112. def _in_use(path):
  113. """Checks if a Windows file is in use.
  114. When Windows is using an executable, it prevents other writers from
  115. modifying or deleting that executable. We can safely test for an in-use
  116. file by opening it in write mode and checking whether or not there was
  117. an error.
  118. Returns (bool): True if the file was in use, False if not.
  119. """
  120. try:
  121. with open(path, 'r+'):
  122. return False
  123. except IOError:
  124. return True
  125. def _toolchain_in_use(toolchain_path):
  126. """Returns (bool): True if a toolchain rooted at |path| is in use.
  127. """
  128. # Look for Python files that may be in use.
  129. for python_dir in (
  130. os.path.join(toolchain_path, 'python', 'bin'), # CIPD
  131. toolchain_path, # Legacy ZIP distributions.
  132. ):
  133. for component in (
  134. os.path.join(python_dir, 'python.exe'),
  135. os.path.join(python_dir, 'DLLs', 'unicodedata.pyd'),
  136. ):
  137. if os.path.isfile(component) and _in_use(component):
  138. return True
  139. # Look for Pytho:n 3 files that may be in use.
  140. python_dir = os.path.join(toolchain_path, 'python3', 'bin')
  141. for component in (
  142. os.path.join(python_dir, 'python3.exe'),
  143. os.path.join(python_dir, 'DLLs', 'unicodedata.pyd'),
  144. ):
  145. if os.path.isfile(component) and _in_use(component):
  146. return True
  147. return False
  148. def _check_call(argv, stdin_input=None, **kwargs):
  149. """Wrapper for subprocess.check_call that adds logging."""
  150. logging.info('running %r', argv)
  151. if stdin_input is not None:
  152. kwargs['stdin'] = subprocess.PIPE
  153. proc = subprocess.Popen(argv, **kwargs)
  154. proc.communicate(input=stdin_input)
  155. if proc.returncode:
  156. raise subprocess.CalledProcessError(proc.returncode, argv, None)
  157. def _safe_rmtree(path):
  158. if not os.path.exists(path):
  159. return
  160. def _make_writable_and_remove(path):
  161. st = os.stat(path)
  162. new_mode = st.st_mode | 0o200
  163. if st.st_mode == new_mode:
  164. return False
  165. try:
  166. os.chmod(path, new_mode)
  167. os.remove(path)
  168. return True
  169. except Exception:
  170. return False
  171. def _on_error(function, path, excinfo):
  172. if not _make_writable_and_remove(path):
  173. logging.warning('Failed to %s: %s (%s)', function, path, excinfo)
  174. shutil.rmtree(path, onerror=_on_error)
  175. def clean_up_old_installations(skip_dir):
  176. """Removes Python installations other than |skip_dir|.
  177. This includes an "in-use" check against the "python.exe" in a given
  178. directory to avoid removing Python executables that are currently running.
  179. We need this because our Python bootstrap may be run after (and by) other
  180. software that is using the bootstrapped Python!
  181. """
  182. root_contents = os.listdir(ROOT_DIR)
  183. for f in ('win_tools-*_bin', 'python27*_bin', 'git-*_bin',
  184. 'bootstrap-*_bin'):
  185. for entry in fnmatch.filter(root_contents, f):
  186. full_entry = os.path.join(ROOT_DIR, entry)
  187. if full_entry == skip_dir or not os.path.isdir(full_entry):
  188. continue
  189. logging.info('Cleaning up old installation %r', entry)
  190. if not _toolchain_in_use(full_entry):
  191. _safe_rmtree(full_entry)
  192. else:
  193. logging.info('Toolchain at %r is in-use; skipping', full_entry)
  194. # Version of "git_postprocess" system configuration (see |git_postprocess|).
  195. GIT_POSTPROCESS_VERSION = '2'
  196. def _within_depot_tools(path):
  197. """Returns whether the given path is within depot_tools."""
  198. return os.path.commonpath([os.path.abspath(path), ROOT_DIR]) == ROOT_DIR
  199. def _traverse_to_git_root(abspath):
  200. """Traverses up the path to the closest "git" directory (case-insensitive).
  201. Returns:
  202. The path to the directory with name "git" (case-insensitive), if it
  203. exists as an ancestor; otherwise, None.
  204. Examples:
  205. * "C:\Program Files\Git\cmd" -> "C:\Program Files\Git"
  206. * "C:\Program Files\Git\mingw64\bin" -> "C:\Program Files\Git"
  207. """
  208. head, tail = os.path.split(abspath)
  209. while tail:
  210. if tail.lower() == 'git':
  211. return os.path.join(head, tail)
  212. head, tail = os.path.split(head)
  213. return None
  214. def search_win_git_directory():
  215. """Searches for a git directory outside of depot_tools.
  216. As depot_tools will soon stop bundling Git for Windows, this function logs
  217. a warning if git has not yet been directly installed.
  218. """
  219. # Look for the git command in PATH outside of depot_tools.
  220. for p in os.environ.get('PATH', '').split(os.pathsep):
  221. if _within_depot_tools(p):
  222. continue
  223. for cmd in ('git.exe', 'git.bat'):
  224. if os.path.isfile(os.path.join(p, cmd)):
  225. git_root = _traverse_to_git_root(p)
  226. if git_root:
  227. return git_root
  228. # Log deprecation warning.
  229. logging.warning(
  230. 'depot_tools will soon stop bundling Git for Windows.\n'
  231. 'To prepare for this change, please install Git directly. See\n'
  232. 'https://chromium.googlesource.com/chromium/src/+/main/docs/windows_build_instructions.md#Install-git\n'
  233. )
  234. return None
  235. def git_get_mingw_dir(git_directory):
  236. """Returns (str) The "mingw" directory in a Git installation, or None."""
  237. for candidate in ('mingw64', 'mingw32'):
  238. mingw_dir = os.path.join(git_directory, candidate)
  239. if os.path.isdir(mingw_dir):
  240. return mingw_dir
  241. return None
  242. def git_postprocess(template, bootstrap_git_dir, add_docs):
  243. if add_docs:
  244. # Update depot_tools files for "git help <command>".
  245. mingw_dir = git_get_mingw_dir(template.GIT_BIN_ABSDIR)
  246. if mingw_dir:
  247. docsrc = os.path.join(ROOT_DIR, 'man', 'html')
  248. git_docs_dir = os.path.join(mingw_dir, 'share', 'doc', 'git-doc')
  249. for name in os.listdir(docsrc):
  250. maybe_copy(os.path.join(docsrc, name),
  251. os.path.join(git_docs_dir, name))
  252. else:
  253. logging.info('Could not find mingw directory for %r.',
  254. template.GIT_BIN_ABSDIR)
  255. # Create Git templates and configure its base layout.
  256. for stub_name, relpath in WIN_GIT_STUBS.items():
  257. stub_template = template._replace(GIT_PROGRAM=relpath)
  258. stub_template.maybe_install('git.template.bat',
  259. os.path.join(ROOT_DIR, stub_name))
  260. # Set-up our system configuration environment. The following set of
  261. # parameters is versioned by "GIT_POSTPROCESS_VERSION". If they change,
  262. # update "GIT_POSTPROCESS_VERSION" accordingly.
  263. def configure_git_system():
  264. git_bat_path = os.path.join(ROOT_DIR, 'git.bat')
  265. _check_call(
  266. [git_bat_path, 'config', '--system', 'core.autocrlf', 'false'])
  267. _check_call(
  268. [git_bat_path, 'config', '--system', 'core.filemode', 'false'])
  269. _check_call(
  270. [git_bat_path, 'config', '--system', 'core.preloadindex', 'true'])
  271. _check_call(
  272. [git_bat_path, 'config', '--system', 'core.fscache', 'true'])
  273. _check_call(
  274. [git_bat_path, 'config', '--system', 'protocol.version', '2'])
  275. os.makedirs(bootstrap_git_dir, exist_ok=True)
  276. call_if_outdated(os.path.join(bootstrap_git_dir, '.git_postprocess'),
  277. GIT_POSTPROCESS_VERSION, configure_git_system)
  278. def main(argv):
  279. parser = argparse.ArgumentParser()
  280. parser.add_argument('--verbose', action='store_true')
  281. parser.add_argument('--bootstrap-name',
  282. required=True,
  283. help='The directory of the Python installation.')
  284. args = parser.parse_args(argv)
  285. logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARN)
  286. template = Template.empty()._replace(
  287. PYTHON3_BIN_RELDIR=os.path.join(args.bootstrap_name, 'python3', 'bin'),
  288. PYTHON3_BIN_RELDIR_UNIX=posixpath.join(args.bootstrap_name, 'python3',
  289. 'bin'))
  290. bootstrap_dir = os.path.join(ROOT_DIR, args.bootstrap_name)
  291. # Clean up any old Python and Git installations.
  292. clean_up_old_installations(bootstrap_dir)
  293. if IS_WIN:
  294. bootstrap_git_dir = os.path.join(bootstrap_dir, 'git')
  295. # Avoid messing with system git docs.
  296. add_docs = False
  297. git_dir = search_win_git_directory()
  298. if not git_dir:
  299. # git not found in PATH - fall back to depot_tools bundled git.
  300. git_dir = bootstrap_git_dir
  301. add_docs = True
  302. template = template._replace(GIT_BIN_ABSDIR=git_dir)
  303. git_postprocess(template, bootstrap_git_dir, add_docs)
  304. templates = [
  305. ('git-bash.template.sh', 'git-bash', ROOT_DIR),
  306. ('python3.bat', 'python3.bat', ROOT_DIR),
  307. ]
  308. for src_name, dst_name, dst_dir in templates:
  309. # Re-evaluate and regenerate our root templated files.
  310. template.maybe_install(src_name, os.path.join(dst_dir, dst_name))
  311. # Emit our Python bin depot-tools-relative directory. This is read by
  312. # python3.bat to identify the path of the current Python installation.
  313. #
  314. # We use this indirection so that upgrades can change this pointer to
  315. # redirect "python.bat" to a new Python installation. We can't just update
  316. # "python.bat" because batch file executions reload the batch file and seek
  317. # to the previous cursor in between every command, so changing the batch
  318. # file contents could invalidate any existing executions.
  319. #
  320. # The intention is that the batch file itself never needs to change when
  321. # switching Python versions.
  322. maybe_update(template.PYTHON3_BIN_RELDIR,
  323. os.path.join(ROOT_DIR, 'python3_bin_reldir.txt'))
  324. return 0
  325. if __name__ == '__main__':
  326. sys.exit(main(sys.argv[1:]))