stateful_shell.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. # Copyright (c) 2022 Project CHIP Authors
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import os
  15. import shlex
  16. import subprocess
  17. import sys
  18. import tempfile
  19. import time
  20. from typing import Dict, Optional
  21. import constants
  22. _ENV_FILENAME = ".shell_env"
  23. _OUTPUT_FILENAME = ".shell_output"
  24. _HERE = os.path.dirname(os.path.abspath(__file__))
  25. _TEE_WAIT_TIMEOUT = 3
  26. _ENV_EXCLUDE_SET = {"PS1"}
  27. TermColors = constants.TermColors
  28. class StatefulShell:
  29. """A Shell that tracks state changes of the environment.
  30. Attributes:
  31. env: Env variables passed to command. It gets updated after every command.
  32. cwd: Current working directory of shell.
  33. """
  34. def __init__(self) -> None:
  35. if sys.platform == "linux" or sys.platform == "linux2":
  36. self.shell_app = '/bin/bash'
  37. elif sys.platform == "darwin":
  38. self.shell_app = '/bin/zsh'
  39. elif sys.platform == "win32":
  40. print('Windows is currently not supported. Use Linux or MacOS platforms')
  41. exit(1)
  42. self.env: Dict[str, str] = os.environ.copy()
  43. self.cwd: str = self.env["PWD"]
  44. def print_env(self) -> None:
  45. """Print environment variables in commandline friendly format for export.
  46. The purpose of this function is to output the env variables in such a way
  47. that a user can copy the env variables and paste them in their terminal to
  48. quickly recreate the environment state.
  49. """
  50. for env_var in self.env:
  51. quoted_value = shlex.quote(self.env[env_var])
  52. if env_var:
  53. print(f"export {env_var}={quoted_value}")
  54. def run_cmd(
  55. self, cmd: str, *,
  56. raise_on_returncode=True,
  57. return_cmd_output=False,
  58. ) -> Optional[str]:
  59. """Runs a command and updates environment.
  60. Args:
  61. cmd: Command to execute.
  62. This does not support commands that run in the background e.g. `<cmd> &`
  63. raise_on_returncode: Whether to raise an error if the return code is nonzero.
  64. return_cmd_output: Whether to return the command output.
  65. If enabled, the text piped to screen won't be colorized due to output
  66. being passed through `tee`.
  67. Raises:
  68. RuntimeError: If raise_on_returncode is set and nonzero return code is given.
  69. Returns:
  70. Output of command if return_cmd_output set to True.
  71. """
  72. with tempfile.TemporaryDirectory(dir=os.path.dirname(_HERE)) as temp_dir:
  73. envfile_path: str = os.path.join(temp_dir, _ENV_FILENAME)
  74. cmd_output_path: str = os.path.join(temp_dir, _OUTPUT_FILENAME)
  75. env_dict = {}
  76. # Set OLDPWD at beginning because opening the shell clears this. This handles 'cd -'.
  77. # env -0 prints the env variables separated by null characters for easy parsing.
  78. if return_cmd_output:
  79. # Piping won't work here because piping will affect how environment variables
  80. # are propagated. This solution uses tee without piping to preserve env variables.
  81. redirect = f" > >(tee \"{cmd_output_path}\") 2>&1 " # include stderr
  82. else:
  83. redirect = ""
  84. # TODO: Use env -0 when `macos-latest` refers to macos-12 in github actions.
  85. # env -0 is ideal because it will support cases where an env variable that has newline
  86. # characters. The flag "-0" is requires MacOS 12 which is still in beta in Github Actions.
  87. # The less ideal `env` command is used by itself, with the caveat that newline chars
  88. # are unsupported in env variables.
  89. save_env_cmd = f"env > {envfile_path}"
  90. command_with_state = (
  91. f"OLDPWD={self.env.get('OLDPWD', '')}; {cmd} {redirect}; RETCODE=$?; "
  92. f"{save_env_cmd}; exit $RETCODE")
  93. try:
  94. with subprocess.Popen(
  95. [command_with_state],
  96. env=self.env, cwd=self.cwd,
  97. shell=True, executable=self.shell_app
  98. ) as proc:
  99. returncode = proc.wait()
  100. except Exception:
  101. print("Error.")
  102. print(f"Cmd:\n{command_with_state}")
  103. print(f"Envs:\n{self.env}")
  104. raise
  105. # Load env state from envfile.
  106. with open(envfile_path, encoding="latin1") as f:
  107. # TODO: Split on null char after updating to env -0 - requires MacOS 12.
  108. env_entries = f.read().split("\n")
  109. for entry in env_entries:
  110. parts = entry.split("=")
  111. if parts[0] in _ENV_EXCLUDE_SET:
  112. continue
  113. # Handle case where an env variable contains text with '='.
  114. env_dict[parts[0]] = "=".join(parts[1:])
  115. self.env = env_dict
  116. self.cwd = self.env["PWD"]
  117. if raise_on_returncode and returncode != 0:
  118. raise RuntimeError(
  119. "Error. Nonzero return code."
  120. f"\nReturncode: {returncode}"
  121. f"\nCmd: {cmd}")
  122. if return_cmd_output:
  123. # Poll for file due to give 'tee' time to close.
  124. # This is necessary because 'tee' waits for all subshells to finish before writing.
  125. start_time = time.time()
  126. while time.time() - start_time < _TEE_WAIT_TIMEOUT:
  127. if os.path.isfile(cmd_output_path):
  128. with open(cmd_output_path, encoding="latin1") as f:
  129. output = f.read()
  130. if output: # Ensure that file has been written to.
  131. break
  132. time.sleep(0.1)
  133. else:
  134. raise TimeoutError(
  135. f"Error. Output file: {cmd_output_path} not created within "
  136. f"the alloted time of: {_TEE_WAIT_TIMEOUT}s"
  137. )
  138. return output