iripau 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iripau/subprocess.py ADDED
@@ -0,0 +1,526 @@
1
+ """
2
+ A wrapper of the subprocess module
3
+
4
+ This module relies on the following system utilities being installed:
5
+ * bash
6
+ * kill
7
+ * pstree
8
+ * sudo
9
+ * tee
10
+ """
11
+
12
+ import io
13
+ import os
14
+ import re
15
+ import sys
16
+ import shlex
17
+ import psutil
18
+ import subprocess
19
+
20
+ from subprocess import DEVNULL
21
+ from subprocess import PIPE
22
+ from subprocess import STDOUT
23
+ from subprocess import CompletedProcess
24
+ from subprocess import TimeoutExpired
25
+ from subprocess import SubprocessError # noqa: F401
26
+ from subprocess import CalledProcessError
27
+
28
+ from time import time
29
+ from typing import Union, Iterable, Callable
30
+ from tempfile import SpooledTemporaryFile
31
+ from contextlib import contextmanager, nullcontext
32
+
33
+ FILE = -4
34
+ GLOBAL_ECHO = False
35
+ GLOBAL_STDOUTS = set()
36
+ GLOBAL_STDERRS = set()
37
+ GLOBAL_PROMPTS = set()
38
+
39
+
40
+ TeeStream = Union[io.IOBase, Callable[[], io.IOBase]]
41
+
42
+
43
+ class PipeFile(SpooledTemporaryFile):
44
+ """ A file to be used as stdin, stdout and stderr in Popen to avoid dead lock
45
+ when the process output is too long using PIPE.
46
+
47
+ If used as stdin, the content should be written before spawning the process.
48
+ """
49
+
50
+ def __init__(self, content=None, encoding=None, errors=None, text=None):
51
+ super().__init__(
52
+ mode="w+t" if text else "w+b",
53
+ encoding=encoding,
54
+ errors=errors
55
+ )
56
+
57
+ if content:
58
+ self.write(content)
59
+ self.seek(0)
60
+
61
+ def read_all(self):
62
+ self.seek(0)
63
+ return self.read()
64
+
65
+
66
+ class Tee(subprocess.Popen):
67
+ """ A subprocess to send real-time input to several file descriptors """
68
+
69
+ def __init__(self, input, fds, output=None, encoding=None, errors=None, text=None):
70
+ if output is STDOUT:
71
+ raise ValueError("output cannot be STDOUT")
72
+
73
+ if output is None:
74
+ output = DEVNULL
75
+
76
+ fds = normalize_outerr_fds(fds)
77
+ if 1 in fds:
78
+ if output != DEVNULL:
79
+ fds.add(2)
80
+ stdout = None
81
+ stderr = output
82
+ elif 2 in fds:
83
+ stdout = output
84
+ stderr = None
85
+ else:
86
+ stdout = output
87
+ stderr = DEVNULL
88
+
89
+ fds.discard(1)
90
+ super().__init__(
91
+ stdin=input, stdout=stdout, stderr=stderr, pass_fds=fds - {2},
92
+ encoding=encoding, errors=errors, text=text, **self.get_kwargs(fds)
93
+ )
94
+
95
+ self.output = self.stdout if self.stderr is None else self.stderr
96
+
97
+ @staticmethod
98
+ def get_cmd(fds):
99
+ return ["tee", "-a"] + [f"/dev/fd/{fd}" for fd in fds]
100
+
101
+ if os.access("/dev/fd/2", os.W_OK):
102
+ @classmethod
103
+ def get_kwargs(cls, fds):
104
+ return {"args": cls.get_cmd(fds)}
105
+ else:
106
+ @classmethod
107
+ def get_kwargs(cls, fds):
108
+ if 2 in fds:
109
+ return {
110
+ "args": " ".join(cls.get_cmd(fds - {2})) + " >(cat >&2)",
111
+ "shell": True
112
+ }
113
+ return {"args": cls.get_cmd(fds)}
114
+
115
+ def communicate(self, *args, **kwargs):
116
+ stdout, stderr = super().communicate(*args, **kwargs)
117
+ return stdout if stderr is None else stderr
118
+
119
+
120
+ class Popen(subprocess.Popen):
121
+ """ A subprocess.Popen that can send its stdout and stderr to several files
122
+ in real-time keeping the ability of capturing its output.
123
+ """
124
+
125
+ def __init__(
126
+ self, args, *, cwd=None, env=None, encoding=None, errors=None, text=None,
127
+ stdout_tees: Iterable[TeeStream] = [], add_global_stdout_tees=True,
128
+ stderr_tees: Iterable[TeeStream] = [], add_global_stderr_tees=True,
129
+ prompt_tees: Iterable[TeeStream] = [], add_global_prompt_tees=True,
130
+ echo=None, alias=None, comment=None, **kwargs
131
+ ):
132
+ stdout = kwargs.get("stdout")
133
+ stderr = kwargs.get("stderr")
134
+
135
+ stdout_tees, stderr_tees, prompt_tees = self._get_tee_sets(
136
+ stdout_tees, add_global_stdout_tees,
137
+ stderr_tees, add_global_stderr_tees,
138
+ prompt_tees, add_global_prompt_tees,
139
+ echo, stdout, stderr
140
+ )
141
+
142
+ if stderr is STDOUT:
143
+ err2out = True
144
+ stderr_tees = set()
145
+ else:
146
+ err2out = False
147
+
148
+ stdout_fds = {tee.fileno() for tee in stdout_tees}
149
+ stderr_fds = {tee.fileno() for tee in stderr_tees}
150
+ prompt_fds = {tee.fileno() for tee in prompt_tees}
151
+
152
+ if stdout_fds:
153
+ kwargs["stdout"] = PIPE
154
+
155
+ if stderr_fds:
156
+ kwargs["stderr"] = PIPE
157
+
158
+ if prompt_fds:
159
+ stream_prompts(prompt_fds, alias or args, cwd, env, err2out, comment)
160
+
161
+ super().__init__(args, cwd=cwd, env=env,
162
+ encoding=encoding, errors=errors, text=text, **kwargs)
163
+
164
+ self.original_stdout = self.stdout
165
+ self.original_stderr = self.stderr
166
+
167
+ stdout_process = stderr_process = self
168
+ if stdout_fds:
169
+ stdout_process = Tee(self.stdout, stdout_fds, stdout, encoding, errors, text)
170
+ self.stdout = stdout_process.output
171
+ if stderr_fds:
172
+ stderr_process = Tee(self.stderr, stderr_fds, stderr, encoding, errors, text)
173
+ self.stderr = stderr_process.output
174
+
175
+ self.stdout_process = stdout_process
176
+ self.stderr_process = stderr_process
177
+
178
+ @staticmethod
179
+ def _get_tee_files(tees: Iterable[TeeStream]):
180
+ return set(callable(tee) and tee() or tee for tee in tees)
181
+
182
+ @classmethod
183
+ def _get_tee_sets(
184
+ cls,
185
+ stdout_tees, add_global_stdout_tees,
186
+ stderr_tees, add_global_stderr_tees,
187
+ prompt_tees, add_global_prompt_tees,
188
+ echo, stdout, stderr
189
+ ):
190
+ stdout_tees = set(stdout_tees)
191
+ stderr_tees = set(stderr_tees)
192
+ prompt_tees = set(prompt_tees)
193
+
194
+ if add_global_prompt_tees:
195
+ prompt_tees.update(GLOBAL_PROMPTS)
196
+ if add_global_stdout_tees:
197
+ stdout_tees.update(GLOBAL_STDOUTS)
198
+ if add_global_stderr_tees:
199
+ stderr_tees.update(GLOBAL_STDERRS)
200
+
201
+ if echo is None:
202
+ echo = GLOBAL_ECHO
203
+
204
+ if echo:
205
+ prompt_tees.add(sys.stdout)
206
+ stdout_tees.add(sys.stdout)
207
+ stderr_tees.add(sys.stderr)
208
+
209
+ if stdout is None:
210
+ if stdout_tees == {sys.stdout}:
211
+ stdout_tees.clear() # tee process not needed for stdout
212
+ if stdout_tees:
213
+ stdout_tees.add(sys.stdout)
214
+
215
+ if stderr is None:
216
+ if stderr_tees == {sys.stderr}:
217
+ stderr_tees.clear() # tee process not needed for stderr
218
+ if stderr_tees:
219
+ stderr_tees.add(sys.stderr)
220
+
221
+ return (
222
+ cls._get_tee_files(stdout_tees),
223
+ cls._get_tee_files(stderr_tees),
224
+ cls._get_tee_files(prompt_tees)
225
+ )
226
+
227
+ def get_pids(self):
228
+ """ Return the pid for all of the processes in the tree """
229
+ output = run(
230
+ ["pstree", "-p", str(self.pid)],
231
+ stdin=DEVNULL,
232
+ stdout=PIPE,
233
+ stderr=DEVNULL,
234
+ text=True,
235
+ check=True
236
+ )
237
+
238
+ return re.findall("\\((\\d+)\\)", output.stdout)[::-1]
239
+
240
+ def terminate_tree(self):
241
+ run(
242
+ ["sudo", "kill"] + self.get_pids(),
243
+ stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL,
244
+ )
245
+
246
+ def kill_tree(self):
247
+ run(
248
+ ["sudo", "kill", "-9"] + self.get_pids(),
249
+ stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL,
250
+ )
251
+
252
+ def end_tree(self, sigterm_timeout):
253
+ """ Try to gracefully terminate the process tree,
254
+ kill it after 'sigterm_timeout' seconds
255
+ """
256
+ if sigterm_timeout:
257
+ self.terminate_tree()
258
+ try:
259
+ self.communicate(timeout=sigterm_timeout)
260
+ except: # noqa: E722
261
+ self.kill_tree()
262
+ else:
263
+ self.kill_tree()
264
+
265
+ def poll(self):
266
+ processes = {self.stderr_process, self.stdout_process} - {self}
267
+ if all(process.poll() is not None for process in processes):
268
+ return super().poll()
269
+
270
+ def wait(self, timeout=None):
271
+ processes = {self.stderr_process, self.stdout_process} - {self}
272
+ for process in processes:
273
+ process.wait(timeout)
274
+ timeout = None
275
+ return super().wait(timeout)
276
+
277
+ @contextmanager
278
+ def _streams_restored(self):
279
+ self.stdout = self.original_stdout
280
+ self.stderr = self.original_stderr
281
+ try:
282
+ yield
283
+ finally:
284
+ self.stdout = self.stdout_process.stdout
285
+ self.stderr = self.stderr_process.stderr
286
+
287
+ @contextmanager
288
+ def _stdin_none(self):
289
+ original_stdin = self.stdin
290
+ self.stdin = None
291
+ try:
292
+ yield
293
+ finally:
294
+ self.stdin = original_stdin
295
+
296
+ def _communicate_tees(self, timeout):
297
+ stdout = stderr = None
298
+ if self.stdout_process is not self:
299
+ stdout = self.stdout_process.communicate(timeout=timeout)
300
+ timeout = None
301
+ if self.stderr_process is not self:
302
+ stderr = self.stderr_process.communicate(timeout=timeout)
303
+ timeout = None
304
+ return stdout, stderr, timeout
305
+
306
+ def _communicate_all(self, timeout):
307
+ stdout, stderr, timeout = self._communicate_tees(timeout)
308
+ main_stdout, main_stderr = super().communicate(timeout=timeout)
309
+ return (
310
+ main_stdout if self.stdout_process is self else stdout,
311
+ main_stderr if self.stderr_process is self else stderr
312
+ )
313
+
314
+ @property
315
+ def _any_communication_started(self):
316
+ return (
317
+ self.stdout_process._communication_started or
318
+ self.stderr_process._communication_started or
319
+ self._communication_started
320
+ )
321
+
322
+ def communicate(self, input=None, timeout=None):
323
+ if self._any_communication_started and input:
324
+ raise ValueError("Cannot send input after starting communication")
325
+
326
+ with self._streams_restored():
327
+ if self.stdin and input:
328
+ self._stdin_write(input)
329
+ with self._stdin_none():
330
+ return self._communicate_all(timeout)
331
+ return self._communicate_all(timeout)
332
+
333
+
334
+ def normalize_outerr_fds(fds: Iterable[int]):
335
+ """ Return fds as a set but using 1 and 2 for stdout and stderr file
336
+ descriptors in case we are being redirected
337
+ """
338
+ out_fd = sys.stdout.fileno() # This might not always be 1
339
+ err_fd = sys.stderr.fileno() # This might not always be 2
340
+ fds = set(fds)
341
+ if out_fd in fds:
342
+ fds.remove(out_fd)
343
+ fds.add(1)
344
+ if err_fd in fds:
345
+ fds.remove(err_fd)
346
+ fds.add(2)
347
+ return fds
348
+
349
+
350
+ def quote(cmd: Iterable[str]):
351
+ """ Convert the command tokens into a single string that could be pasted into
352
+ the shell to execute the original command
353
+ """
354
+ return " ".join(map(shlex.quote, cmd))
355
+
356
+
357
+ def shellify(cmd: Union[str, Iterable[str]], err2out=False, comment=None):
358
+ """ Quote command if needed and optionally add extra strings to express
359
+ stderr being redirected to stdout and a comment
360
+ """
361
+ if not isinstance(cmd, str):
362
+ cmd = quote(cmd)
363
+ if err2out:
364
+ cmd += " 2>&1"
365
+ if comment:
366
+ cmd += f" # {comment}"
367
+ return cmd
368
+
369
+
370
+ # If bash is installed and supports prompt expansion
371
+ if subprocess.run(
372
+ ["bash", "-c", "echo ${0@P}"],
373
+ stdin=DEVNULL,
374
+ stdout=DEVNULL,
375
+ stderr=DEVNULL
376
+ ).returncode == 0:
377
+ HOME = os.path.expanduser("~")
378
+ PS1, PS2 = subprocess.run(
379
+ ["bash", "-ic", "echo \"$PS1\"; echo \"$PS2\""],
380
+ text=True,
381
+ stdin=DEVNULL,
382
+ stdout=PIPE,
383
+ stderr=DEVNULL
384
+ ).stdout.splitlines()[-2:]
385
+
386
+ def stream_prompts(fds: Iterable[str], cmd, cwd=None, env=None, err2out=False, comment=None):
387
+ """ Write shell prompt and command into file descriptors fds """
388
+ fds = normalize_outerr_fds(fds)
389
+ custom_env = {"CPS1": PS1, "CPS2": PS2}
390
+ custom_env.update(env or {})
391
+ custom_env.setdefault("HOME", HOME)
392
+ script = (
393
+ "(\n"
394
+ " IFS= read -r \"line\"\n"
395
+ " echo \"${CPS1@P}${line}\"\n"
396
+ " while IFS= read line; do\n"
397
+ " echo \"${CPS2@P}${line}\"\n"
398
+ " done\n"
399
+ ") | " + quote(Tee.get_cmd(fds - {1}))
400
+ )
401
+ subprocess.run(
402
+ ["bash", "-c", script],
403
+ text=True,
404
+ input=shellify(cmd, err2out, comment),
405
+ stdout=None if 1 in fds else DEVNULL,
406
+ stderr=None if 2 in fds else DEVNULL,
407
+ pass_fds=fds - {1, 2},
408
+ cwd=cwd,
409
+ env=custom_env,
410
+ check=True
411
+ )
412
+ else: # Use hard-coded PS1 and PS2 strings
413
+ def stream_prompts(fds: Iterable[str], cmd, cwd=None, env=None, err2out=False, comment=None):
414
+ """ Write shell prompt and command into file descriptors fds """
415
+ cmd = shellify(cmd, err2out, comment) + "\n"
416
+ input = "$ " + "> ".join(cmd.splitlines(keepends=True))
417
+ with Tee(PIPE, fds, DEVNULL, text=True) as tee:
418
+ tee.communicate(input=input)
419
+
420
+
421
+ def set_global_echo(value):
422
+ global GLOBAL_ECHO
423
+ GLOBAL_ECHO = bool(value)
424
+
425
+
426
+ def set_global_stdout_files(*files: TeeStream):
427
+ global GLOBAL_STDOUTS
428
+ GLOBAL_STDOUTS = set(*files)
429
+
430
+
431
+ def set_global_stderr_files(*files: TeeStream):
432
+ global GLOBAL_STDERRS
433
+ GLOBAL_STDERRS = set(*files)
434
+
435
+
436
+ def set_global_prompt_files(*files: TeeStream):
437
+ global GLOBAL_PROMPTS
438
+ GLOBAL_PROMPTS = set(*files)
439
+
440
+
441
+ def _output_context(kwargs, key, encoding, errors, text):
442
+ """ Create a PipeFile, store it in kwargs[key] and return it if it is FILE.
443
+ Just return a nullcontext otherwise.
444
+ """
445
+ if kwargs.get(key) is FILE:
446
+ kwargs[key] = PipeFile(encoding=encoding, errors=errors, text=text)
447
+ return kwargs[key]
448
+ return nullcontext()
449
+
450
+
451
+ def run(
452
+ args, *, input=None, capture_output=False, timeout=None, check=False,
453
+ encoding=None, errors=None, text=None, sigterm_timeout=10, **kwargs
454
+ ):
455
+ """ A subprocess.run that instantiates this module's Popen """
456
+ if input is not None:
457
+ if kwargs.get("stdin") is not None:
458
+ raise ValueError("stdin and input arguments may not both be used.")
459
+ kwargs["stdin"] = PIPE
460
+
461
+ if capture_output:
462
+ if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
463
+ raise ValueError("stdout and stderr arguments may not be used with capture_output.")
464
+ kwargs["stdout"] = FILE
465
+ kwargs["stderr"] = FILE
466
+
467
+ comment = f"timeout={timeout}" if timeout else None
468
+ with (
469
+ _output_context(kwargs, "stdout", encoding, errors, text) as stdout_file,
470
+ _output_context(kwargs, "stderr", encoding, errors, text) as stderr_file,
471
+ Popen(args, encoding=encoding, errors=errors, text=text,
472
+ comment=comment, **kwargs) as process
473
+ ):
474
+ start = psutil.Process(process.pid).create_time()
475
+ try:
476
+ stdout, stderr = process.communicate(input, timeout=timeout)
477
+ except TimeoutExpired:
478
+ process.end_tree(sigterm_timeout)
479
+ process.wait()
480
+ raise
481
+ except: # noqa: E722
482
+ process.kill_tree()
483
+ raise
484
+ finally:
485
+ end = time()
486
+ returncode = process.poll()
487
+
488
+ if stdout_file:
489
+ stdout = stdout_file.read_all()
490
+ if stderr_file:
491
+ stderr = stderr_file.read_all()
492
+
493
+ if check and returncode:
494
+ raise CalledProcessError(returncode, process.args, output=stdout, stderr=stderr)
495
+
496
+ output = CompletedProcess(process.args, returncode, stdout, stderr)
497
+ output.time = end - start
498
+ return output
499
+
500
+
501
+ def call(*args, **kwargs):
502
+ return run(*args, **kwargs).returncode
503
+
504
+
505
+ def check_call(*args, **kwargs):
506
+ kwargs["check"] = True
507
+ return call(*args, **kwargs)
508
+
509
+
510
+ def check_output(*args, **kwargs):
511
+ kwargs["check"] = True
512
+ kwargs.setdefault("stdout", PIPE)
513
+ return run(*args, **kwargs).stdout
514
+
515
+
516
+ def getoutput(*args, **kwargs):
517
+ return getstatusoutput(*args, **kwargs)[1]
518
+
519
+
520
+ def getstatusoutput(*args, **kwargs):
521
+ kwargs["stdout"] = PIPE
522
+ kwargs["stderr"] = STDOUT
523
+ kwargs["shell"] = True
524
+ kwargs["text"] = True
525
+ output = run(*args, **kwargs)
526
+ return (output.returncode, output.stdout)