16
16
import shlex
17
17
import subprocess
18
18
import sys
19
+ import time
19
20
from typing import Dict , Optional
20
21
21
22
import constants
22
23
23
24
_ENV_FILENAME = ".shell_env"
24
25
_OUTPUT_FILENAME = ".shell_output"
25
26
_HERE = os .path .dirname (os .path .abspath (__file__ ))
27
+ _TEE_WAIT_TIMEOUT = 3
26
28
27
29
TermColors = constants .TermColors
28
30
29
31
30
32
class StatefulShell :
31
- """A Shell that tracks state changes of the environment."""
33
+ """A Shell that tracks state changes of the environment.
34
+
35
+ Attributes:
36
+ env: Env variables passed to command. It gets updated after every command.
37
+ cwd: Current working directory of shell.
38
+ """
32
39
33
40
def __init__ (self ) -> None :
34
41
if sys .platform == "linux" or sys .platform == "linux2" :
@@ -44,8 +51,8 @@ def __init__(self) -> None:
44
51
45
52
# This file holds the env after running a command. This is a better approach
46
53
# than writing to stdout because commands could redirect the stdout.
47
- self .envfile_path : str = os .path .join (_HERE , _ENV_FILENAME )
48
- self .cmd_output_path : str = os .path .join (_HERE , _OUTPUT_FILENAME )
54
+ self ._envfile_path : str = os .path .join (_HERE , _ENV_FILENAME )
55
+ self ._cmd_output_path : str = os .path .join (_HERE , _OUTPUT_FILENAME )
49
56
50
57
def print_env (self ) -> None :
51
58
"""Print environment variables in commandline friendly format for export.
@@ -87,13 +94,25 @@ def run_cmd(
87
94
if return_cmd_output :
88
95
# Piping won't work here because piping will affect how environment variables
89
96
# are propagated. This solution uses tee without piping to preserve env variables.
90
- redirect = f" > >(tee \" { self .cmd_output_path } \" ) 2>&1 " # include stderr
97
+ redirect = f" > >(tee \" { self ._cmd_output_path } \" ) 2>&1 " # include stderr
98
+
99
+ # Delete the file before running the command so we can later check if the file
100
+ # exists as a signal that tee has finished writing to the file.
101
+ if os .path .isfile (self ._cmd_output_path ):
102
+ os .remove (self ._cmd_output_path )
91
103
else :
92
104
redirect = ""
93
105
106
+ # TODO: Use env -0 when `macos-latest` refers to macos-12 in github actions.
107
+ # env -0 is ideal because it will support cases where an env variable that has newline
108
+ # characters. The flag "-0" is requires MacOS 12 which is still in beta in Github Actions.
109
+ # The less ideal `env` command is used by itself, with the caveat that newline chars
110
+ # are unsupported in env variables.
111
+ save_env_cmd = f"env > { self ._envfile_path } "
112
+
94
113
command_with_state = (
95
- f"OLDPWD={ self .env .get ('OLDPWD' , '' )} ; { cmd } { redirect } ; RETCODE=$?;"
96
- f" env -0 > { self . envfile_path } ; exit $RETCODE" )
114
+ f"OLDPWD={ self .env .get ('OLDPWD' , '' )} ; { cmd } { redirect } ; RETCODE=$?; "
115
+ f"{ save_env_cmd } ; exit $RETCODE" )
97
116
with subprocess .Popen (
98
117
[command_with_state ],
99
118
env = self .env , cwd = self .cwd ,
@@ -102,9 +121,9 @@ def run_cmd(
102
121
returncode = proc .wait ()
103
122
104
123
# Load env state from envfile.
105
- with open (self .envfile_path , encoding = "latin1" ) as f :
106
- # Split on null char because we use env -0.
107
- env_entries = f .read ().split ("\0 " )
124
+ with open (self ._envfile_path , encoding = "latin1" ) as f :
125
+ # TODO: Split on null char after updating to env -0 - requires MacOS 12 .
126
+ env_entries = f .read ().split ("\n " )
108
127
for entry in env_entries :
109
128
parts = entry .split ("=" )
110
129
# Handle case where an env variable contains text with '='.
@@ -119,6 +138,21 @@ def run_cmd(
119
138
f"\n Cmd: { cmd } " )
120
139
121
140
if return_cmd_output :
122
- with open (self .cmd_output_path , encoding = "latin1" ) as f :
123
- output = f .read ()
141
+ # Poll for file due to give 'tee' time to close.
142
+ # This is necessary because 'tee' waits for all subshells to finish before writing.
143
+ start_time = time .time ()
144
+ while time .time () - start_time < _TEE_WAIT_TIMEOUT :
145
+ try :
146
+ with open (self ._cmd_output_path , encoding = "latin1" ) as f :
147
+ output = f .read ()
148
+ break
149
+ except FileNotFoundError :
150
+ pass
151
+ time .sleep (0.1 )
152
+ else :
153
+ raise TimeoutError (
154
+ f"Error. Output file: { self ._cmd_output_path } not created within "
155
+ f"the alloted time of: { _TEE_WAIT_TIMEOUT } s"
156
+ )
157
+
124
158
return output
0 commit comments