spawn.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. """Utilities for spawning processes.
  10. The functions in this module are a convenient high-level interface to the
  11. operations provided by the standard [`subprocess`][subprocess] module.
  12. [subprocess]: https://docs.python.org/3/library/subprocess.html
  13. """
  14. import math
  15. import subprocess
  16. import sys
  17. import time
  18. from collections.abc import Callable, Sequence
  19. from pathlib import Path
  20. from typing import IO, TypeVar
  21. from materialize import ui
  22. CalledProcessError = subprocess.CalledProcessError
  23. # NOTE(benesch): Please think twice before adding additional parameters to this
  24. # method! It is meant to serve 95% of callers with a small ands understandable
  25. # set of parameters. If your needs are niche, consider calling `subprocess.run`
  26. # directly rather than adding a one-off parameter here.
  27. def runv(
  28. args: Sequence[Path | str],
  29. *,
  30. cwd: Path | None = None,
  31. env: dict[str, str] | None = None,
  32. stdin: None | int | IO[bytes] | bytes = None,
  33. stdout: None | int | IO[bytes] = None,
  34. stderr: None | int | IO[bytes] = None,
  35. ) -> subprocess.CompletedProcess:
  36. """Verbosely run a subprocess.
  37. A description of the subprocess will be written to stdout before the
  38. subprocess is executed.
  39. Args:
  40. args: A list of strings or paths describing the program to run and
  41. the arguments to pass to it.
  42. cwd: An optional directory to change into before executing the process.
  43. env: A replacement environment with which to launch the process. If
  44. unspecified, the current process's environment is used. Replacement
  45. occurs wholesale, so use a construction like
  46. `env=dict(os.environ, KEY=VAL, ...)` to instead amend the existing
  47. environment.
  48. stdin: An optional IO handle or byte string to use as the process's
  49. stdin stream.
  50. stdout: An optional IO handle to use as the process's stdout stream.
  51. stderr: An optional IO handle to use as the process's stderr stream.
  52. Raises:
  53. OSError: The process cannot be executed, e.g. because the specified
  54. program does not exist.
  55. CalledProcessError: The process exited with a non-zero exit status.
  56. """
  57. print("$", ui.shell_quote(args), file=sys.stderr)
  58. input = None
  59. if isinstance(stdin, bytes):
  60. input = stdin
  61. stdin = None
  62. return subprocess.run(
  63. args,
  64. cwd=cwd,
  65. env=env,
  66. input=input,
  67. stdin=stdin,
  68. stdout=stdout,
  69. stderr=stderr,
  70. check=True,
  71. )
  72. def capture(
  73. args: Sequence[Path | str],
  74. *,
  75. cwd: Path | None = None,
  76. env: dict[str, str] | None = None,
  77. stdin: None | int | IO[bytes] | str = None,
  78. stderr: None | int | IO[bytes] = None,
  79. ) -> str:
  80. """Capture the output of a subprocess.
  81. Args:
  82. args: A list of strings or paths describing the program to run and
  83. the arguments to pass to it.
  84. cwd: An optional directory to change into before executing the process.
  85. env: A replacement environment with which to launch the process. If
  86. unspecified, the current process's environment is used. Replacement
  87. occurs wholesale, so use a construction like
  88. `env=dict(os.environ, KEY=VAL, ...)` to instead amend the existing
  89. environment.
  90. stdin: An optional IO handle, byte string or string to use as the process's
  91. stdin stream.
  92. stderr: An optional IO handle to use as the process's stderr stream.
  93. Returns:
  94. output: The verbatim output of the process as a string. Note that
  95. trailing whitespace is preserved.
  96. Raises:
  97. OSError: The process cannot be executed, e.g. because the specified
  98. program does not exist.
  99. CalledProcessError: The process exited with a non-zero exit status.
  100. .. tip:: Many programs produce output with a trailing newline.
  101. You may want to call `strip()` on the output to remove any trailing
  102. whitespace.
  103. """
  104. input = None
  105. if isinstance(stdin, str):
  106. input = stdin
  107. stdin = None
  108. return subprocess.check_output(
  109. args, cwd=cwd, env=env, input=input, stdin=stdin, stderr=stderr, text=True
  110. )
  111. def run_and_get_return_code(
  112. args: Sequence[Path | str],
  113. *,
  114. cwd: Path | None = None,
  115. env: dict[str, str] | None = None,
  116. ) -> int:
  117. """Run a subprocess and return the return code."""
  118. try:
  119. capture(args, cwd=cwd, env=env, stderr=subprocess.DEVNULL)
  120. return 0
  121. except CalledProcessError as e:
  122. return e.returncode
  123. T = TypeVar("T") # Generic type variable
  124. def run_with_retries(fn: Callable[[], T], max_duration: int = 60) -> T:
  125. """Retry a function until it doesn't raise a `CalledProcessError`, uses
  126. exponential backoff until `max_duration` is reached."""
  127. for retry in range(math.ceil(math.log2(max_duration))):
  128. try:
  129. return fn()
  130. except subprocess.CalledProcessError as e:
  131. sleep_time = 2**retry
  132. print(f"Failed: {e}, retrying in {sleep_time}s")
  133. time.sleep(sleep_time)
  134. return fn()