Coverage for pycommons / processes / shell.py: 98%

92 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-24 03:11 +0000

1r""" 

2The tool for invoking shell commands. 

3 

4>>> Command(("echo", "123"), stdout=STREAM_CAPTURE).execute(False) 

5('123\n', None) 

6""" 

7 

8import subprocess # nosec 

9from dataclasses import dataclass 

10from os import getcwd 

11from typing import Callable, Final, Iterable, Mapping 

12 

13from pycommons.io.console import logger 

14from pycommons.io.path import UTF8, Path, directory_path 

15from pycommons.types import check_int_range, type_error 

16 

17#: ignore the given stream 

18STREAM_IGNORE: Final[int] = 0 

19#: forward given stream to the same stream of this process 

20STREAM_FORWARD: Final[int] = 1 

21#: capture the given stream 

22STREAM_CAPTURE: Final[int] = 2 

23 

24 

25#: the stream mode to string converter 

26_SM: Final[Callable[[int], str]] = { 

27 STREAM_IGNORE: " ignored", 

28 STREAM_FORWARD: " forwarded", 

29 STREAM_CAPTURE: " captured", 

30}.get 

31 

32 

33@dataclass(frozen=True, init=False, order=False, eq=False) 

34class Command: 

35 """ 

36 A class that represents a command that can be executed. 

37 

38 >>> c = Command("test") 

39 >>> c.command 

40 ('test',) 

41 >>> c.working_dir.is_dir() 

42 True 

43 >>> c.timeout 

44 3600 

45 

46 >>> d = Command(("test", "b")) 

47 >>> d.command 

48 ('test', 'b') 

49 >>> d.working_dir == c.working_dir 

50 True 

51 >>> d.timeout == c.timeout 

52 True 

53 

54 >>> e = Command(("", "test", " b", " ")) 

55 >>> e.command == d.command 

56 True 

57 >>> e.working_dir == c.working_dir 

58 True 

59 >>> e.timeout == c.timeout 

60 True 

61 

62 >>> try: 

63 ... Command(1) 

64 ... except TypeError as te: 

65 ... print(str(te)[:50]) 

66 command should be an instance of any in {str, typi 

67 

68 >>> try: 

69 ... Command([1]) 

70 ... except TypeError as te: 

71 ... print(te) 

72 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

73 

74 >>> try: 

75 ... Command(["x", 1]) 

76 ... except TypeError as te: 

77 ... print(te) 

78 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

79 

80 >>> try: 

81 ... Command([]) 

82 ... except ValueError as ve: 

83 ... print(ve) 

84 Invalid command []. 

85 

86 >>> try: 

87 ... Command([""]) 

88 ... except ValueError as ve: 

89 ... print(ve) 

90 Invalid command ['']. 

91 

92 >>> try: 

93 ... Command("") 

94 ... except ValueError as ve: 

95 ... print(ve) 

96 Invalid command ['']. 

97 

98 >>> Command("x", working_dir=Path(__file__).up(1)).command 

99 ('x',) 

100 

101 >>> try: 

102 ... Command("x", working_dir=1) 

103 ... except TypeError as te: 

104 ... print(te) 

105 descriptor '__len__' requires a 'str' object but received a 'int' 

106 

107 >>> try: 

108 ... Command("x", working_dir=Path(__file__)) 

109 ... except ValueError as ve: 

110 ... print(str(ve)[-30:]) 

111 does not identify a directory. 

112 

113 >>> Command("x", timeout=23).timeout 

114 23 

115 

116 >>> try: 

117 ... Command("x", timeout=1.2) 

118 ... except TypeError as te: 

119 ... print(te) 

120 timeout should be an instance of int but is float, namely 1.2. 

121 

122 >>> try: 

123 ... Command("x", timeout=None) 

124 ... except TypeError as te: 

125 ... print(te) 

126 timeout should be an instance of int but is None. 

127 

128 >>> try: 

129 ... Command("x", timeout=0) 

130 ... except ValueError as ve: 

131 ... print(ve) 

132 timeout=0 is invalid, must be in 1..1000000. 

133 

134 >>> try: 

135 ... Command("x", timeout=1_000_001) 

136 ... except ValueError as ve: 

137 ... print(ve) 

138 timeout=1000001 is invalid, must be in 1..1000000. 

139 

140 >>> try: 

141 ... Command("x", stdin=1_000_001) 

142 ... except TypeError as te: 

143 ... print(str(te)[:49]) 

144 stdin should be an instance of any in {None, str} 

145 

146 >>> sxx = str(Command("x", env={"A": "B", "C": "D"})) 

147 >>> sxx[sxx.index("with "):sxx.index("with ") + 30] 

148 'with <env> no stdin, stdout ig' 

149 

150 >>> try: 

151 ... Command("x", env={"A": "B", "C": 1}) 

152 ... except TypeError as te: 

153 ... print(str(te)) 

154 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

155 

156 >>> try: 

157 ... Command("x", env=1) 

158 ... except TypeError as te: 

159 ... print(str(te)[:-20]) 

160 env should be an instance of any in {typing.Iterable, typing.Mapping} b 

161 

162 >>> str(Command("x", env=dict()))[0:10] 

163 "('x',) in " 

164 """ 

165 

166 #: the command line. 

167 command: tuple[str, ...] 

168 #: the working directory 

169 working_dir: Path 

170 #: the timeout in seconds, after which the process will be terminated 

171 timeout: int 

172 #: the data to be written to stdin 

173 stdin: str | None 

174 #: how to handle the standard output stream 

175 stdout: int 

176 #: how to handle the standard error stream 

177 stderr: int 

178 #: the environment variables to pass to the new process, if any 

179 env: tuple[tuple[str, str], ...] | None 

180 

181 def __init__(self, command: str | Iterable[str], 

182 working_dir: str | None = None, 

183 timeout: int | None = 3600, 

184 stdin: str | None = None, 

185 stdout: int = STREAM_IGNORE, 

186 stderr: int = STREAM_IGNORE, 

187 env: Mapping[str, str] | Iterable[tuple[ 

188 str, str]] | None = None) -> None: 

189 """ 

190 Create the command. 

191 

192 :param command: the command string or iterable 

193 :param working_dir: the working directory 

194 :param timeout: the timeout 

195 :param stdin: a string to be written to stdin, or `None` 

196 :param stdout: how to handle the standard output stream 

197 :param stderr: how to handle the standard error stream 

198 """ 

199 if isinstance(command, str): 

200 command = [command] 

201 elif not isinstance(command, Iterable): 

202 raise type_error(command, "command", (str, Iterable)) 

203 object.__setattr__(self, "command", tuple( 

204 s for s in map(str.strip, command) if str.__len__(s) > 0)) 

205 if tuple.__len__(self.command) <= 0: 

206 raise ValueError(f"Invalid command {command!r}.") 

207 

208 object.__setattr__(self, "working_dir", directory_path( 

209 getcwd() if working_dir is None else working_dir)) 

210 

211 object.__setattr__(self, "timeout", check_int_range( 

212 timeout, "timeout", 1, 1_000_000)) 

213 

214 if (stdin is not None) and (not isinstance(stdin, str)): 

215 raise type_error(stdin, "stdin", (str, None)) 

216 object.__setattr__(self, "stdin", stdin) 

217 

218 object.__setattr__(self, "stdout", check_int_range( 

219 stdout, "stdout", 0, 2)) 

220 object.__setattr__(self, "stderr", check_int_range( 

221 stderr, "stderr", 0, 2)) 

222 

223 the_env: tuple[tuple[str, str], ...] | None = None 

224 if env is not None: 

225 if isinstance(env, Mapping): 

226 env = env.items() 

227 elif not isinstance(env, Iterable): 

228 raise type_error(env, "env", (Mapping, Iterable)) 

229 the_env = tuple(sorted((str.strip(k), str.strip(v)) 

230 for k, v in env)) 

231 if tuple.__len__(the_env) <= 0: 

232 the_env = None 

233 object.__setattr__(self, "env", the_env) 

234 

235 def __str__(self) -> str: 

236 """ 

237 Get the string representation of this command. 

238 

239 This string includes most of the important information, but it will 

240 never include the environment variables. These variables may contain 

241 security sensitive stuff, so they are not printed. Instead. if an 

242 environment is specified, this will just be printed as `<env>`. 

243 

244 :returns: A string representing this command 

245 

246 >>> str(Command("a"))[-50:] 

247 ' with no stdin, stdout ignored, and stderr ignored' 

248 >>> str(Command("x"))[:11] 

249 "('x',) in '" 

250 >>> "with 3 chars of stdin" in str(Command("x", stdin="123")) 

251 True 

252 >>> str(Command("a", env={"y": "x"}))[-65:] 

253 'for 3600s with <env> no stdin, stdout ignored, and stderr ignored' 

254 """ 

255 si: str = "no" if self.stdin is None \ 

256 else f"{str.__len__(self.stdin)} chars of" 

257 ev: str = "" if self.env is None else "<env> " 

258 return (f"{self.command!r} in {self.working_dir!r} for {self.timeout}" 

259 f"s with {ev}{si} stdin, stdout{_SM(self.stdout)}, and " 

260 f"stderr{_SM(self.stderr)}") 

261 

262 def execute(self, log_call: bool = True) -> tuple[str | None, str | None]: 

263 r""" 

264 Execute the given process. 

265 

266 :param log_call: should the call be logged? If `True`, the 

267 string representation of the :class:`Command` will be 

268 written to the `logger`, otherwise nothing is logged. 

269 Note: The environment, if any, will not be printed for security 

270 reasons. 

271 :returns: a tuple with the standard output and standard error, which 

272 are only not `None` if they were supposed to be captured 

273 :raises TypeError: if any argument has the wrong type 

274 :raises ValueError: if execution of the process failed 

275 

276 >>> Command(("echo", "123"), stdout=STREAM_CAPTURE).execute(False) 

277 ('123\n', None) 

278 

279 >>> Command(("echo", "", "123"), stdout=STREAM_CAPTURE).execute(False) 

280 ('123\n', None) 

281 

282 >>> from contextlib import redirect_stdout 

283 >>> with redirect_stdout(None): 

284 ... s = Command(("echo", "123"), stdout=STREAM_CAPTURE).execute() 

285 >>> print(s) 

286 ('123\n', None) 

287 

288 >>> Command("cat", stdin="test", stdout=STREAM_CAPTURE).execute(False) 

289 ('test', None) 

290 

291 >>> Command("cat", stdin="test").execute(False) 

292 (None, None) 

293 

294 >>> try: 

295 ... with redirect_stdout(None): 

296 ... Command(("ping", "blabla!")).execute(True) 

297 ... except ValueError as ve: 

298 ... ss = str(ve) 

299 ... print(ss[:20] + " ... " + ss[-22:]) 

300 ('ping', 'blabla!') ... yields return code 2. 

301 

302 >>> try: 

303 ... with redirect_stdout(None): 

304 ... Command(("ping", "www.example.com", "-i 20"), 

305 ... timeout=1).execute(True) 

306 ... except ValueError as ve: 

307 ... print("timed out after" in str(ve)) 

308 True 

309 

310 >>> try: 

311 ... Command("x").execute(None) 

312 ... except TypeError as te: 

313 ... print(te) 

314 log_call should be an instance of bool but is None. 

315 

316 >>> try: 

317 ... Command("x").execute(1) 

318 ... except TypeError as te: 

319 ... print(te) 

320 log_call should be an instance of bool but is int, namely 1. 

321 

322 >>> with redirect_stdout(None): 

323 ... r = Command(("echo", "1"), stderr=STREAM_CAPTURE).execute( 

324 ... True) 

325 >>> r 

326 (None, '') 

327 

328 >>> with redirect_stdout(None): 

329 ... r = Command(("printenv", ), 

330 ... stdout=STREAM_CAPTURE, 

331 ... env={"BLA": "XX"}).execute(True) 

332 >>> r 

333 ('BLA=XX\n', None) 

334 """ 

335 if not isinstance(log_call, bool): 

336 raise type_error(log_call, "log_call", bool) 

337 message: Final[str] = str(self) 

338 if log_call: 

339 logger(f"Now invoking {message}.") 

340 

341 arguments: Final[dict[str, str | Iterable[str] | bool | int]] = { 

342 "args": self.command, 

343 "check": False, 

344 "text": True, 

345 "timeout": self.timeout, 

346 "cwd": self.working_dir, 

347 "errors": "strict", 

348 "encoding": UTF8, 

349 } 

350 

351 if self.stdin is not None: 

352 arguments["input"] = self.stdin 

353 

354 arguments["stdout"] = 1 if self.stdout == STREAM_FORWARD else ( 

355 subprocess.PIPE if self.stdout == STREAM_CAPTURE 

356 else subprocess.DEVNULL) 

357 arguments["stderr"] = 2 if self.stderr == STREAM_FORWARD else ( 

358 subprocess.PIPE if self.stderr == STREAM_CAPTURE 

359 else subprocess.DEVNULL) 

360 

361 if self.env is not None: 

362 arguments["env"] = {t[0]: t[1] for t in self.env} 

363 

364 try: 

365 # noqa # nosemgrep # pylint: disable=W1510 # type: ignore 

366 ret: Final[subprocess.CompletedProcess] = \ 

367 subprocess.run(**arguments) # type: ignore # nosec # noqa 

368 except (TimeoutError, subprocess.TimeoutExpired) as toe: 

369 if log_call: 

370 logger(f"Failed executing {self} with timeout {toe}.") 

371 raise ValueError(f"{message} timed out: {toe}.") from toe 

372 

373 returncode: Final[int] = ret.returncode 

374 if returncode != 0: 

375 if log_call: 

376 logger(f"Failed executing {self}: got return" 

377 f" code {returncode}.") 

378 raise ValueError(f"{message} yields return code {returncode}.") 

379 

380 stdout: str | None = None 

381 if self.stdout == STREAM_CAPTURE: 

382 stdout = ret.stdout 

383 if not isinstance(stdout, str): 

384 raise type_error(stdout, f"stdout of {self}", stdout) 

385 

386 stderr: str | None = None 

387 if self.stderr == STREAM_CAPTURE: 

388 stderr = ret.stderr 

389 if not isinstance(stderr, str): 

390 raise type_error(stderr, f"stderr of {self}", stderr) 

391 

392 if log_call: 

393 capture: str = "" 

394 if stdout is not None: 

395 capture = f", captured {str.__len__(stdout)} chars of stdout" 

396 if stderr is not None: 

397 capture = f"{capture} and " if str.__len__(capture) > 0 \ 

398 else ", captured " 

399 capture = f"{capture}{str.__len__(stderr)} chars of stderr" 

400 logger(f"Finished executing {self} with return code 0{capture}.") 

401 

402 return stdout, stderr