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