The Gaudi Framework  master (53935a5b)
Loading...
Searching...
No Matches
SubprocessBaseTest.py
Go to the documentation of this file.
11import os
12import re
13import select
14import shutil
15import signal
16import subprocess
17import threading
18from collections.abc import Callable
19from datetime import datetime
20from pathlib import Path
21from string import Template
22
23import pytest
24
26 ExceededStreamError,
27 FixtureResult,
28 ProcessTimeoutError,
29)
30from GaudiTesting.utils import file_path_for_class, kill_tree, which
31
32STDOUT_LIMIT = int(os.environ.get("GAUDI_TEST_STDOUT_LIMIT", 1024**2 * 100))
33
34
36 """
37 A base class for running and managing subprocess executions within a test framework.
38 It provides mechanisms for setting up the environment, preparing commands,
39 and handling subprocess output and errors.
40 """
41
42 command: list[str] = None
43 reference: str = None
44 environment: list[str] = None
45 timeout: int = 600
46 returncode: int = 0
47 popen_kwargs: dict = {}
48 # By default record environment variables that are likely to affect job execution
49 env_to_record: list[str] = [
50 r"^(.*PATH|BINARY_TAG|CMTCONFIG|PWD|TMPDIR|DISPLAY|LC_[A-Z]+|LANGUAGE|.*ROOT|.*OPTS|GAUDI_ENV_TO_RECORD(_\d+)?)$"
51 ]
52
53 @property
54 def cwd(self) -> Path | None:
55 cwd = self.popen_kwargs.get("cwd")
56 return Path(cwd) if cwd else None
57
58 @classmethod
59 def resolve_path(cls, path: Path | str) -> str:
60 """
61 Resolve the given path to an absolute path,
62 expanding environment variables.
63 If path looks relative and does not point to anything
64 it is not modified.
65 """
66 if isinstance(path, Path):
67 path = str(path)
68 path = os.path.expandvars(path)
69
70 # handle the special case "path/to/file:some_suffix"
71 suffix = ""
72 if ":" in path:
73 path, suffix = path.rsplit(":", 1)
74 suffix = f":{suffix}"
75
76 if not os.path.isabs(path):
77 base_dir = file_path_for_class(cls).parent
78 possible_path = str((base_dir / path).resolve())
79 if os.path.exists(possible_path):
80 path = possible_path
81 return path + suffix
82
83 @classmethod
84 def update_env(cls, env: dict[str, str]) -> None:
85 if cls.environment:
86 for item in cls.environment:
87 key, value = item.split("=", 1)
88 env[key] = cls.expand_vars_from(value, env)
89
90 @classmethod
91 def _prepare_environment(cls) -> dict[str, str]:
92 env = dict(os.environ)
93 cls.update_env(env)
94 return env
95
96 @staticmethod
97 def expand_vars_from(value: str, env: dict[str, str]) -> str:
98 return Template(value).safe_substitute(env)
99
100 @staticmethod
101 def unset_vars(env: dict[str, str], vars_to_unset: list[str]) -> None:
102 for var in vars_to_unset:
103 env.pop(var, None)
104
105 @classmethod
106 def _determine_program(cls, prog: str) -> str:
107 if not any(prog.lower().endswith(ext) for ext in [".exe", ".py", ".bat"]):
108 prog += ".exe"
109 return which(prog) or cls.resolve_path(prog)
110
111 @classmethod
112 def _prepare_command(cls, tmp_path=Path()) -> list[str]:
113 """
114 Prepare the command to be executed, resolving paths for each part.
115 """
116 command = [cls._determine_program(cls.command[0])]
117 for part in cls.command[1:]:
118 if not part.startswith("-"): # do not try to expand options
119 command.append(cls.resolve_path(part))
120 else:
121 command.append(part)
122 return command
123
124 @classmethod
125 def _handle_timeout(cls, proc: subprocess.Popen) -> str:
126 """
127 Handle a process timeout by collecting and returning the stack trace.
128 """
129 stack_trace = cls._collect_stack_trace(proc)
130 cls._terminate_process(proc)
131 return stack_trace
132
133 @staticmethod
134 def _collect_stack_trace(proc: subprocess.Popen) -> str:
135 """
136 Collect stack trace from a running process using the available debugger.
137 Uses lldb on macOS and gdb on Linux. Returns a message if no debugger
138 is available.
139 """
140 import sys
141
142 if sys.platform == "darwin":
143 # Use lldb on macOS
144 if not shutil.which("lldb"):
145 return "(lldb not available for stack trace collection)"
146 cmd = [
147 "lldb",
148 "-p",
149 str(proc.pid),
150 "-o",
151 "bt all",
152 "-o",
153 "quit",
154 ]
155 else:
156 # Use gdb on Linux
157 if not shutil.which("gdb"):
158 return "(gdb not available for stack trace collection)"
159 cmd = [
160 "gdb",
161 "--pid",
162 str(proc.pid),
163 "--batch",
164 "--eval-command=thread apply all backtrace",
165 ]
166 debugger = subprocess.Popen(
167 cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
168 )
169 return debugger.communicate()[0].decode("utf-8", errors="backslashreplace")
170
171 @staticmethod
172 def _terminate_process(proc: subprocess.Popen) -> None:
173 kill_tree(proc.pid, signal.SIGTERM)
174 proc.wait(60)
175 if proc.poll() is None:
176 kill_tree(proc.pid, signal.SIGKILL)
177
178 @classmethod
179 def _prepare_execution(cls, tmp_path=None):
180 command = cls._prepare_command(tmp_path=tmp_path)
181 env = cls._prepare_environment()
182 os.makedirs(cls.popen_kwargs.get("cwd", tmp_path), exist_ok=True)
183 return command, env
184
185 @classmethod
186 def run_program(cls, tmp_path=None) -> FixtureResult:
187 """
188 Run the specified program and capture its output.
189 """
190 start_time = datetime.now()
191 command, env = cls._prepare_execution(tmp_path=tmp_path)
192
193 proc = subprocess.Popen(
194 command,
195 stdout=subprocess.PIPE,
196 stderr=subprocess.PIPE,
197 env=env,
198 **cls.popen_kwargs,
199 )
200
201 stdout_chunks, stderr_chunks = [], []
202 stdout = stderr = ""
203 exceeded_stream = stack_trace = run_exception = None
204 streams = {
205 proc.stdout.fileno(): (stdout_chunks, "stdout"),
206 proc.stderr.fileno(): (stderr_chunks, "stderr"),
207 }
208
209 def read_output():
210 nonlocal stdout, stderr, exceeded_stream
211 open_streams = dict(streams)
212 while open_streams and not exceeded_stream:
213 # once the process has exited, only collect what is still in the pipes
214 # (up to the pipe capacity, which a slow reader leaves behind), without
215 # waiting for descendants that may keep them open
216 exited = proc.poll() is not None
217 readable, _, _ = select.select(
218 open_streams.keys(), [], [], 0 if exited else cls.timeout
219 )
220 if exited and not readable:
221 break
222 for fileno in readable:
223 data = os.read(fileno, 65536)
224 if not data: # end of file: the write end is closed
225 del open_streams[fileno]
226 continue
227 chunks, stream_name = streams[fileno]
228 chunks.append(data)
229 if sum(len(chunk) for chunk in chunks) > STDOUT_LIMIT:
230 exceeded_stream = stream_name
231 break
232
233 if not exceeded_stream:
234 proc.wait() # both pipes can be closed while the process still runs
235 stdout = b"".join(stdout_chunks)
236 stderr = b"".join(stderr_chunks)
237
238 thread = threading.Thread(target=read_output)
239 thread.start()
240 thread.join(cls.timeout)
241
242 if thread.is_alive():
243 stack_trace = cls._handle_timeout(proc)
244 run_exception = ProcessTimeoutError("Process timed out", stack_trace)
245 elif exceeded_stream:
246 run_exception = ExceededStreamError(
247 "Stream exceeded size limit", exceeded_stream
248 )
249
250 end_time = datetime.now()
251
252 completed_process = subprocess.CompletedProcess(
253 args=command,
254 returncode=proc.returncode,
255 stdout=stdout,
256 stderr=stderr,
257 )
258
259 # Only record environment variables that match at least one
260 # of the regular expressions in env_to_record.
261 # If env_to_record is empty or None, do not record any env variable
262 env_to_record = list(cls.env_to_record) if cls.env_to_record else []
263 # extend env_to_record with the regular expressions in environment variables
264 # named GAUDI_ENV_TO_RECORD, GAUDI_ENV_TO_RECORD_1, GAUDI_ENV_TO_RECORD_2, ...
265 env_to_record.extend(
266 env[name] for name in env if re.match(r"GAUDI_ENV_TO_RECORD(_\d+)?", name)
267 )
268 env = {
269 key: value
270 for key, value in env.items()
271 if any(re.match(exp, key) for exp in env_to_record)
272 }
273 return FixtureResult(
274 completed_process=completed_process,
275 start_time=start_time,
276 end_time=end_time,
277 run_exception=run_exception,
278 command=cls.command,
279 expanded_command=command,
280 env=env,
281 cwd=cls.popen_kwargs["cwd"],
282 )
283
284 @classmethod
285 def run_program_for_dbg(cls, tmp_path):
286 tmp_path = cls.popen_kwargs.get("cwd", tmp_path)
287 command, env = cls._prepare_execution(tmp_path=tmp_path)
288 print("Running the command: ", command)
289 subprocess.run(
290 command,
291 env=env,
292 cwd=tmp_path,
293 **cls.popen_kwargs,
294 )
295
296 @pytest.mark.do_not_collect_source
298 self,
299 record_property: Callable[[str, str], None],
300 fixture_result: FixtureResult,
301 reference_path: Path | None,
302 ) -> None:
303 """
304 Record properties and handle any failures during fixture setup.
305 """
306 for key, value in fixture_result.to_dict().items():
307 if value is not None:
308 record_property(key, value)
309 if reference_path:
310 record_property("reference_file", str(reference_path))
311
312 if fixture_result.run_exception:
313 pytest.fail(f"{fixture_result.run_exception}")
314
315 @pytest.mark.do_not_collect_source
316 def test_returncode(self, returncode: int) -> None:
317 """
318 Test that the return code matches the expected value.
319 """
320 assert returncode == self.returncode
str expand_vars_from(str value, dict[str, str] env)
None unset_vars(dict[str, str] env, list[str] vars_to_unset)
None test_fixture_setup(self, Callable[[str, str], None] record_property, FixtureResult fixture_result, Path|None reference_path)