bootstrap.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  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. # The global git config which should be applied by |git_postprocess|.
  32. GIT_GLOBAL_CONFIG = {
  33. 'core.autocrlf': 'false',
  34. 'core.filemode': 'false',
  35. 'core.preloadindex': 'true',
  36. 'core.fscache': 'true',
  37. }
  38. # Accumulated template parameters for generated stubs.
  39. class Template(
  40. collections.namedtuple('Template', (
  41. 'PYTHON3_BIN_RELDIR',
  42. 'PYTHON3_BIN_RELDIR_UNIX',
  43. 'GIT_BIN_ABSDIR',
  44. 'GIT_PROGRAM',
  45. ))):
  46. @classmethod
  47. def empty(cls):
  48. return cls(**{k: None for k in cls._fields})
  49. def maybe_install(self, name, dst_path):
  50. """Installs template |name| to |dst_path| if it has changed.
  51. This loads the template |name| from THIS_DIR, resolves template parameters,
  52. and installs it to |dst_path|. See `maybe_update` for more information.
  53. Args:
  54. name (str): The name of the template to install.
  55. dst_path (str): The destination filesystem path.
  56. Returns (bool): True if |dst_path| was updated, False otherwise.
  57. """
  58. template_path = os.path.join(THIS_DIR, name)
  59. with open(template_path, 'r', encoding='utf8') as fd:
  60. t = string.Template(fd.read())
  61. return maybe_update(t.safe_substitute(self._asdict()), dst_path)
  62. def maybe_update(content, dst_path):
  63. """Writes |content| to |dst_path| if |dst_path| does not already match.
  64. This function will ensure that there is a file at |dst_path| containing
  65. |content|. If |dst_path| already exists and contains |content|, no operation
  66. will be performed, preserving filesystem modification times and avoiding
  67. potential write contention.
  68. Args:
  69. content (str): The file content.
  70. dst_path (str): The destination filesystem path.
  71. Returns (bool): True if |dst_path| was updated, False otherwise.
  72. """
  73. # If the path already exists and matches the new content, refrain from
  74. # writing a new one.
  75. if os.path.exists(dst_path):
  76. with open(dst_path, 'r', encoding='utf-8') as fd:
  77. if fd.read() == content:
  78. return False
  79. logging.debug('Updating %r', dst_path)
  80. with open(dst_path, 'w', encoding='utf-8') as fd:
  81. fd.write(content)
  82. os.chmod(dst_path, 0o755)
  83. return True
  84. def maybe_copy(src_path, dst_path):
  85. """Writes the content of |src_path| to |dst_path| if needed.
  86. See `maybe_update` for more information.
  87. Args:
  88. src_path (str): The content source filesystem path.
  89. dst_path (str): The destination filesystem path.
  90. Returns (bool): True if |dst_path| was updated, False otherwise.
  91. """
  92. with open(src_path, 'r', encoding='utf-8') as fd:
  93. content = fd.read()
  94. return maybe_update(content, dst_path)
  95. def call_if_outdated(stamp_path, stamp_version, fn):
  96. """Invokes |fn| if the stamp at |stamp_path| doesn't match |stamp_version|.
  97. This can be used to keep a filesystem record of whether an operation has been
  98. performed. The record is stored at |stamp_path|. To invalidate a record,
  99. change the value of |stamp_version|.
  100. After |fn| completes successfully, |stamp_path| will be updated to match
  101. |stamp_version|, preventing the same update from happening in the future.
  102. Args:
  103. stamp_path (str): The filesystem path of the stamp file.
  104. stamp_version (str): The desired stamp version.
  105. fn (callable): A callable to invoke if the current stamp version doesn't
  106. match |stamp_version|.
  107. Returns (bool): True if an update occurred.
  108. """
  109. stamp_version = stamp_version.strip()
  110. if os.path.isfile(stamp_path):
  111. with open(stamp_path, 'r', encoding='utf-8') as fd:
  112. current_version = fd.read().strip()
  113. if current_version == stamp_version:
  114. return False
  115. fn()
  116. with open(stamp_path, 'w', encoding='utf-8') as fd:
  117. fd.write(stamp_version)
  118. return True
  119. def _in_use(path):
  120. """Checks if a Windows file is in use.
  121. When Windows is using an executable, it prevents other writers from
  122. modifying or deleting that executable. We can safely test for an in-use
  123. file by opening it in write mode and checking whether or not there was
  124. an error.
  125. Returns (bool): True if the file was in use, False if not.
  126. """
  127. try:
  128. with open(path, 'r+'):
  129. return False
  130. except IOError:
  131. return True
  132. def _toolchain_in_use(toolchain_path):
  133. """Returns (bool): True if a toolchain rooted at |path| is in use.
  134. """
  135. # Look for Python files that may be in use.
  136. for python_dir in (
  137. os.path.join(toolchain_path, 'python', 'bin'), # CIPD
  138. toolchain_path, # Legacy ZIP distributions.
  139. ):
  140. for component in (
  141. os.path.join(python_dir, 'python.exe'),
  142. os.path.join(python_dir, 'DLLs', 'unicodedata.pyd'),
  143. ):
  144. if os.path.isfile(component) and _in_use(component):
  145. return True
  146. # Look for Pytho:n 3 files that may be in use.
  147. python_dir = os.path.join(toolchain_path, 'python3', 'bin')
  148. for component in (
  149. os.path.join(python_dir, 'python3.exe'),
  150. os.path.join(python_dir, 'DLLs', 'unicodedata.pyd'),
  151. ):
  152. if os.path.isfile(component) and _in_use(component):
  153. return True
  154. return False
  155. def _check_call(argv, stdin_input=None, **kwargs):
  156. """Wrapper for subprocess.Popen that adds logging."""
  157. logging.info('running %r', argv)
  158. if stdin_input is not None:
  159. kwargs['stdin'] = subprocess.PIPE
  160. proc = subprocess.Popen(argv, **kwargs)
  161. stdout, stderr = proc.communicate(input=stdin_input)
  162. if proc.returncode:
  163. raise subprocess.CalledProcessError(proc.returncode, argv, None)
  164. return stdout, stderr
  165. def _safe_rmtree(path):
  166. if not os.path.exists(path):
  167. return
  168. def _make_writable_and_remove(path):
  169. st = os.stat(path)
  170. new_mode = st.st_mode | 0o200
  171. if st.st_mode == new_mode:
  172. return False
  173. try:
  174. os.chmod(path, new_mode)
  175. os.remove(path)
  176. return True
  177. except Exception:
  178. return False
  179. def _on_error(function, path, excinfo):
  180. if not _make_writable_and_remove(path):
  181. logging.warning('Failed to %s: %s (%s)', function, path, excinfo)
  182. shutil.rmtree(path, onerror=_on_error)
  183. def clean_up_old_installations(skip_dir):
  184. """Removes Python installations other than |skip_dir|.
  185. This includes an "in-use" check against the "python.exe" in a given
  186. directory to avoid removing Python executables that are currently running.
  187. We need this because our Python bootstrap may be run after (and by) other
  188. software that is using the bootstrapped Python!
  189. """
  190. root_contents = os.listdir(ROOT_DIR)
  191. for f in ('win_tools-*_bin', 'python27*_bin', 'git-*_bin',
  192. 'bootstrap-*_bin'):
  193. for entry in fnmatch.filter(root_contents, f):
  194. full_entry = os.path.join(ROOT_DIR, entry)
  195. if full_entry == skip_dir or not os.path.isdir(full_entry):
  196. continue
  197. logging.info('Cleaning up old installation %r', entry)
  198. if not _toolchain_in_use(full_entry):
  199. _safe_rmtree(full_entry)
  200. else:
  201. logging.info('Toolchain at %r is in-use; skipping', full_entry)
  202. def _within_depot_tools(path):
  203. """Returns whether the given path is within depot_tools."""
  204. try:
  205. return os.path.commonpath([os.path.abspath(path), ROOT_DIR]) == ROOT_DIR
  206. except ValueError:
  207. return False
  208. def _traverse_to_git_root(abspath):
  209. """Traverses up the path to the closest "git" directory (case-insensitive).
  210. Returns:
  211. The path to the directory with name "git" (case-insensitive), if it
  212. exists as an ancestor; otherwise, None.
  213. Examples:
  214. * "C:\Program Files\Git\cmd" -> "C:\Program Files\Git"
  215. * "C:\Program Files\Git\mingw64\bin" -> "C:\Program Files\Git"
  216. """
  217. head, tail = os.path.split(abspath)
  218. while tail:
  219. if tail.lower() == 'git':
  220. return os.path.join(head, tail)
  221. head, tail = os.path.split(head)
  222. return None
  223. def search_win_git_directory():
  224. """Searches for a git directory outside of depot_tools.
  225. As depot_tools will soon stop bundling Git for Windows, this function logs
  226. a warning if git has not yet been directly installed.
  227. """
  228. # Look for the git command in PATH outside of depot_tools.
  229. for p in os.environ.get('PATH', '').split(os.pathsep):
  230. if _within_depot_tools(p):
  231. continue
  232. for cmd in ('git.exe', 'git.bat'):
  233. if os.path.isfile(os.path.join(p, cmd)):
  234. git_root = _traverse_to_git_root(p)
  235. if git_root:
  236. return git_root
  237. # Log deprecation warning.
  238. logging.warning(
  239. 'depot_tools will soon stop bundling Git for Windows.\n'
  240. 'To prepare for this change, please install Git directly. See\n'
  241. 'https://chromium.googlesource.com/chromium/src/+/main/docs/windows_build_instructions.md#Install-git\n'
  242. )
  243. return None
  244. def git_get_mingw_dir(git_directory):
  245. """Returns (str) The "mingw" directory in a Git installation, or None."""
  246. for candidate in ('mingw64', 'mingw32'):
  247. mingw_dir = os.path.join(git_directory, candidate)
  248. if os.path.isdir(mingw_dir):
  249. return mingw_dir
  250. return None
  251. class GitConfigDict(collections.UserDict):
  252. """Custom dict to support mixed case sensitivity for Git config options.
  253. See the docs at: https://git-scm.com/docs/git-config#_syntax
  254. """
  255. @staticmethod
  256. def _to_case_compliant_key(config_key):
  257. parts = config_key.split('.')
  258. if len(parts) < 2:
  259. # The config key does not conform to the expected format.
  260. # Leave as-is.
  261. return config_key
  262. # Section headers are case-insensitive; set to lowercase for lookup
  263. # consistency.
  264. section = parts[0].lower()
  265. # Subsection headers are case-sensitive and allow '.'.
  266. subsection_parts = parts[1:-1]
  267. # Variable names are case-insensitive; again, set to lowercase for
  268. # lookup consistency.
  269. name = parts[-1].lower()
  270. return '.'.join([section] + subsection_parts + [name])
  271. def __setitem__(self, key, value):
  272. self.data[self._to_case_compliant_key(key)] = value
  273. def __getitem__(self, key):
  274. return self.data[self._to_case_compliant_key(key)]
  275. def get_git_global_config(git_path):
  276. """Helper to get all values from the global git config.
  277. Note: multivalued config variables (multivars) are not supported; only the
  278. last value for each multivar will be in the returned config.
  279. Returns:
  280. - dict of the current global git config.
  281. Raises:
  282. subprocess.CalledProcessError if there was an error reading the config.
  283. """
  284. try:
  285. # List all values in the global git config. Using the `-z` option allows
  286. # us to securely parse the output even if a value contains line breaks.
  287. # See docs at:
  288. # https://git-scm.com/docs/git-config#Documentation/git-config.txt--z
  289. stdout, _ = _check_call([git_path, 'config', 'list', '--global', '-z'],
  290. stdout=subprocess.PIPE,
  291. encoding='utf-8')
  292. except subprocess.CalledProcessError as e:
  293. raise e
  294. # Process all entries in the config.
  295. config = {}
  296. for line in stdout.split('\0'):
  297. entry = line.split('\n', 1)
  298. if len(entry) != 2:
  299. continue
  300. config[entry[0]] = entry[1]
  301. return GitConfigDict(config)
  302. def _win_git_bootstrap_config():
  303. """Bootstraps the global git config, if enabled by the user.
  304. To allow depot_tools to update your global git config, run:
  305. git config --global depot-tools.allowGlobalGitConfig true
  306. To prevent depot_tools updating your global git config and silence the
  307. warning, run:
  308. git config --global depot-tools.allowGlobalGitConfig false
  309. """
  310. git_bat_path = os.path.join(ROOT_DIR, 'git.bat')
  311. # Read the current global git config in its entirety.
  312. current_config = get_git_global_config(git_bat_path)
  313. # Get the current values for the settings which have defined values for
  314. # optimal Chromium development.
  315. config_keys = sorted(GIT_GLOBAL_CONFIG.keys())
  316. mismatching_keys = []
  317. for k in config_keys:
  318. if current_config.get(k) != GIT_GLOBAL_CONFIG.get(k):
  319. mismatching_keys.append(k)
  320. if not mismatching_keys:
  321. # Global git config already has the desired values. Nothing to do.
  322. return
  323. # Check whether the user has authorized depot_tools to update their global
  324. # git config.
  325. allow_global_key = 'depot-tools.allowGlobalGitConfig'
  326. allow_global = current_config.get(allow_global_key, '').lower()
  327. if allow_global in ('false', '0', 'no', 'off'):
  328. # The user has explicitly disabled this.
  329. return
  330. if allow_global not in ('true', '1', 'yes', 'on'):
  331. lines = [
  332. 'depot_tools recommends setting the following for',
  333. 'optimal Chromium development:',
  334. '',
  335. ] + [
  336. f'$ git config --global {k} {GIT_GLOBAL_CONFIG.get(k)}'
  337. for k in mismatching_keys
  338. ] + [
  339. '',
  340. 'You can silence this message by setting these recommended values.',
  341. '',
  342. 'You can allow depot_tools to automatically update your global',
  343. 'Git config to recommended settings by running:',
  344. f'$ git config --global {allow_global_key} true',
  345. '',
  346. 'To suppress this warning and silence future recommendations, run:',
  347. f'$ git config --global {allow_global_key} false',
  348. ]
  349. logging.warning('\n'.join(lines))
  350. return
  351. # Global git config changes have been authorized - do the necessary updates.
  352. for k in mismatching_keys:
  353. desired = GIT_GLOBAL_CONFIG.get(k)
  354. _check_call([git_bat_path, 'config', '--global', k, desired])
  355. # Clean up deprecated setting depot-tools.gitPostprocessVersion.
  356. postprocess_key = 'depot-tools.gitPostprocessVersion'
  357. if current_config.get(postprocess_key) != None:
  358. _check_call(
  359. [git_bat_path, 'config', 'unset', '--global', postprocess_key])
  360. def git_postprocess(template, add_docs):
  361. if add_docs:
  362. # Update depot_tools files for "git help <command>".
  363. mingw_dir = git_get_mingw_dir(template.GIT_BIN_ABSDIR)
  364. if mingw_dir:
  365. docsrc = os.path.join(ROOT_DIR, 'man', 'html')
  366. git_docs_dir = os.path.join(mingw_dir, 'share', 'doc', 'git-doc')
  367. for name in os.listdir(docsrc):
  368. maybe_copy(os.path.join(docsrc, name),
  369. os.path.join(git_docs_dir, name))
  370. else:
  371. logging.info('Could not find mingw directory for %r.',
  372. template.GIT_BIN_ABSDIR)
  373. # Create Git templates and configure its base layout.
  374. for stub_name, relpath in WIN_GIT_STUBS.items():
  375. stub_template = template._replace(GIT_PROGRAM=relpath)
  376. stub_template.maybe_install('git.template.bat',
  377. os.path.join(ROOT_DIR, stub_name))
  378. # Bootstrap the git global config.
  379. _win_git_bootstrap_config()
  380. def main(argv):
  381. parser = argparse.ArgumentParser()
  382. parser.add_argument('--verbose', action='store_true')
  383. parser.add_argument('--bootstrap-name',
  384. required=True,
  385. help='The directory of the Python installation.')
  386. parser.add_argument(
  387. '--use-system-git',
  388. action='store_true',
  389. help='Whether to prefer a direct git installation over a depot_tools '
  390. 'bundled git.')
  391. args = parser.parse_args(argv)
  392. logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARN)
  393. template = Template.empty()._replace(
  394. PYTHON3_BIN_RELDIR=os.path.join(args.bootstrap_name, 'python3', 'bin'),
  395. PYTHON3_BIN_RELDIR_UNIX=posixpath.join(args.bootstrap_name, 'python3',
  396. 'bin'))
  397. bootstrap_dir = os.path.join(ROOT_DIR, args.bootstrap_name)
  398. # Clean up any old Python and Git installations.
  399. clean_up_old_installations(bootstrap_dir)
  400. if IS_WIN:
  401. # Avoid messing with system git docs.
  402. add_docs = False
  403. git_dir = None
  404. if args.use_system_git:
  405. git_dir = search_win_git_directory()
  406. if not git_dir:
  407. # Either using system git was not enabled
  408. # or git was not found in PATH.
  409. # Fall back to depot_tools bundled git.
  410. git_dir = os.path.join(bootstrap_dir, 'git')
  411. add_docs = True
  412. template = template._replace(GIT_BIN_ABSDIR=git_dir)
  413. git_postprocess(template, add_docs)
  414. templates = [
  415. ('git-bash.template.sh', 'git-bash', ROOT_DIR),
  416. ('python3.bat', 'python3.bat', ROOT_DIR),
  417. ]
  418. for src_name, dst_name, dst_dir in templates:
  419. # Re-evaluate and regenerate our root templated files.
  420. template.maybe_install(src_name, os.path.join(dst_dir, dst_name))
  421. # Emit our Python bin depot-tools-relative directory. This is read by
  422. # python3.bat to identify the path of the current Python installation.
  423. #
  424. # We use this indirection so that upgrades can change this pointer to
  425. # redirect "python.bat" to a new Python installation. We can't just update
  426. # "python.bat" because batch file executions reload the batch file and seek
  427. # to the previous cursor in between every command, so changing the batch
  428. # file contents could invalidate any existing executions.
  429. #
  430. # The intention is that the batch file itself never needs to change when
  431. # switching Python versions.
  432. maybe_update(template.PYTHON3_BIN_RELDIR,
  433. os.path.join(ROOT_DIR, 'python3_bin_reldir.txt'))
  434. return 0
  435. if __name__ == '__main__':
  436. sys.exit(main(sys.argv[1:]))