Coverage for pyTooling/CLIAbstraction/__init__.py: 71%

234 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ ___ _ _ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| | |_ _| / \ | |__ ___| |_ _ __ __ _ ___| |_(_) ___ _ __ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | | | / _ \ | '_ \/ __| __| '__/ _` |/ __| __| |/ _ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |___| |___ | | / ___ \| |_) \__ \ |_| | | (_| | (__| |_| | (_) | | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_____|___/_/ \_\_.__/|___/\__|_| \__,_|\___|\__|_|\___/|_| |_| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

15# Copyright 2014-2016 Technische Universität Dresden - Germany, Chair of VLSI-Design, Diagnostics and Architecture # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33Basic abstraction layer for executables. 

34 

35.. seealso:: 

36 

37 :mod:`pyTooling.Attributes.ArgParse` 

38 |rarr| The other direction: describing the command line a program accepts. 

39 :mod:`pyTooling.TerminalUI` 

40 |rarr| Writing the program's own messages to the terminal. 

41 :mod:`pyTooling.Platform` 

42 |rarr| Deciding which executable name and path style the current platform uses. 

43""" 

44from __future__ import annotations 

45 

46# __keywords__ = ["abstract", "executable", "cli", "cli arguments"] 

47 

48from os import environ as os_environ 

49from pathlib import Path 

50from platform import system 

51from shutil import which as shutil_which 

52from subprocess import Popen as Subprocess_Popen, PIPE as Subprocess_Pipe, STDOUT as Subprocess_StdOut, TimeoutExpired 

53from typing import Optional as Nullable, ClassVar, Iterator, Generator, Any, Mapping, Iterable 

54 

55from pyTooling.Decorators import export, readonly 

56from pyTooling.MetaClasses import ExtendedType 

57from pyTooling.Exceptions import ToolingException, PlatformNotSupportedException 

58from pyTooling.Common import getFullyQualifiedName 

59from pyTooling.Attributes import Attribute 

60from pyTooling.CLIAbstraction.Argument import CommandLineArgument 

61from pyTooling.CLIAbstraction.Argument import NamedAndValuedArgument, ValuedArgument, PathArgument, PathListArgument, NamedTupledArgument 

62from pyTooling.CLIAbstraction.ValuedFlag import ValuedFlag 

63from pyTooling.Platform import Platform 

64 

65 

66@export 

67class CLIAbstractionException(ToolingException): 

68 """Base-exception of all exceptions raised by :mod:`pyTooling.CLIAbstraction`.""" 

69 

70 

71@export 

72class DryRunException(CLIAbstractionException): 

73 """This exception is raised if an executable is launched while in dry-run mode.""" 

74 

75 

76@export 

77class CLIArgument(Attribute): 

78 """An attribute to annotate nested classes as an CLI argument.""" 

79 

80 

81@export 

82class Environment(metaclass=ExtendedType, slots=True): 

83 """ 

84 A class describing the environment of an executable. 

85 

86 .. topic:: Content of the environment 

87 

88 * Environment variables 

89 """ 

90 _variables: dict[str, str] #: Dictionary of active environment variables. 

91 

92 # TODO: derive environment from existing environment object. 

93 def __init__( 

94 self, *, 

95 environment: Nullable[Environment] = None, 

96 newVariables: Nullable[Mapping[str, str]] = None, 

97 addVariables: Nullable[Mapping[str, str]] = None, 

98 delVariables: Nullable[Iterable[str]] = None 

99 ) -> None: 

100 """ 

101 Initializes an environment class managing. 

102 

103 .. topic:: Algorithm 

104 

105 1. Create a new dictionary of environment variables (name-value pairs) from either: 

106 

107 * an existing :class:`Environment` instance. 

108 * current executable's environment by reading environment variables from :func:`os.environ`. 

109 * a dictionary of name-value pairs. 

110 

111 2. Remove variables from environment. 

112 3. Add new or update existing variables. 

113 

114 :param environment: Optional, existing Environment instance to derive a new environment. 

115 :param newVariables: Optional, dictionary of new environment variables. |br| 

116 If ``None``, read current environment variables from :func:`os.environ`. 

117 :param addVariables: Optional, dictionary of variables to be added or modified in the environment. 

118 :param delVariables: Optional, list of variable names to be removed from the environment. 

119 """ 

120 if environment is not None: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 newVariables = environment._variables 

122 elif newVariables is None: 

123 newVariables = os_environ 

124 

125 self._variables = {name: value for name, value in newVariables.items()} 

126 

127 if delVariables is not None: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 for variableName in delVariables: 

129 del self._variables[variableName] 

130 

131 if addVariables is not None: 

132 self._variables.update(addVariables) 

133 

134 def __len__(self) -> len: 

135 """ 

136 Returns the number of set environment variables. 

137 

138 :returns: Number of environment variables. 

139 """ 

140 return len(self._variables) 

141 

142 def __contains__(self, name: str) -> bool: 

143 """ 

144 Checks if the variable is set in the environment. 

145 

146 :param name: The variable name to check. 

147 :returns: ``True``, if the variable is set in the environment. 

148 """ 

149 return name in self._variables 

150 

151 def __getitem__(self, name: str) -> str: 

152 """ 

153 Access an environment variable in the environment by name. 

154 

155 :param name: Name of the environment variable. 

156 :returns: The environment variable's value. 

157 :raises KeyError: If Variable name is not set in the environment. 

158 """ 

159 return self._variables[name] 

160 

161 def __setitem__(self, name: str, value: str) -> None: 

162 """ 

163 Add or set an environment variable in the environment by name. 

164 

165 :param name: Name of the environment variable. 

166 :param value: Value of the environment variable to be set. 

167 """ 

168 self._variables[name] = value 

169 

170 def __delitem__(self, name: str) -> None: 

171 """ 

172 Remove an environment variable from the environment by name. 

173 

174 :param name: The name of the environment variable to remove. 

175 :raises KeyError: If name doesn't exist in the environment. 

176 """ 

177 del self._variables[name] 

178 

179 

180@export 

181class Program(metaclass=ExtendedType, slots=True): 

182 """ 

183 Represent a simple command line interface (CLI) executable (program or script). 

184 

185 CLI options are collected in a ``__cliOptions__`` dictionary. 

186 """ 

187 

188 _platform: str #: Current platform the executable runs on (Linux, Windows, ...) 

189 _executableNames: ClassVar[dict[str, str]] #: Dictionary of platform specific executable names. 

190 _executablePath: Path #: The path to the executable (binary, script, ...). 

191 _dryRun: bool #: True, if program shall run in *dry-run mode*. 

192 __cliOptions__: ClassVar[dict[type[CommandLineArgument], int]] #: List of all possible CLI options. 

193 __cliParameters__: dict[type[CommandLineArgument], CommandLineArgument] #: List of all CLI parameters. 

194 

195 def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: 

196 """ 

197 Whenever a subclass is derived from :class:``Program``, all nested classes declared within ``Program`` and which are 

198 marked with attribute ``CLIArgument`` are collected and then listed in the ``__cliOptions__`` dictionary. 

199 

200 :param args: Any positional arguments. 

201 :param kwargs: Any keyword arguments. 

202 """ 

203 super().__init_subclass__(*args, **kwargs) 

204 

205 # register all available CLI options (nested classes marked with attribute 'CLIArgument') 

206 options: dict[type[CommandLineArgument], int] = { 

207 option: order 

208 for order, option in enumerate(CLIArgument.GetClasses(scope=cls)) 

209 } 

210 cls.__cliOptions__ = options 

211 

212 def __init__( 

213 self, 

214 executablePath: Nullable[Path] = None, 

215 binaryDirectoryPath: Nullable[Path] = None, 

216 dryRun: bool = False 

217 ) -> None: 

218 """ 

219 Initializes a program instance. 

220 

221 .. todo:: Document algorithm 

222 

223 :param executablePath: Optional, path to the executable. 

224 :param binaryDirectoryPath: Optional, path to the executable's directory. 

225 :param dryRun: Optional, ``True``, when the program should run in dryrun mode. 

226 :raises TypeError: If parameter 'executablePath' is not of type :class:`~pathlib.Path`. 

227 :raises CLIAbstractionException: If the executable doesn't exist at the given path. 

228 """ 

229 self._platform = system() 

230 self._dryRun = dryRun 

231 

232 if executablePath is not None: 

233 if isinstance(executablePath, Path): 

234 if not executablePath.exists(): 

235 if dryRun: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true

236 self.LogDryRun(f"File check for '{executablePath}' failed. [SKIPPING]") 

237 else: 

238 raise CLIAbstractionException(f"Program '{executablePath}' not found.") from FileNotFoundError(executablePath) 

239 else: 

240 ex = TypeError(f"Parameter 'executablePath' is not of type 'Path'.") 

241 ex.add_note(f"Got type '{getFullyQualifiedName(executablePath)}'.") 

242 raise ex 

243 elif binaryDirectoryPath is not None: 

244 if isinstance(binaryDirectoryPath, Path): 

245 if not binaryDirectoryPath.exists(): 

246 if dryRun: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 self.LogDryRun(f"Directory check for '{binaryDirectoryPath}' failed. [SKIPPING]") 

248 else: 

249 raise CLIAbstractionException(f"Binary directory '{binaryDirectoryPath}' not found.") from FileNotFoundError(binaryDirectoryPath) 

250 

251 try: 

252 executablePath = binaryDirectoryPath / self.__class__._executableNames[self._platform] 

253 except KeyError: 

254 raise CLIAbstractionException(f"Program is not supported on platform '{self._platform}'.") from PlatformNotSupportedException(self._platform) 

255 

256 if not executablePath.exists(): 

257 if dryRun: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 self.LogDryRun(f"File check for '{executablePath}' failed. [SKIPPING]") 

259 else: 

260 raise CLIAbstractionException(f"Program '{executablePath}' not found.") from FileNotFoundError(executablePath) 

261 else: 

262 ex = TypeError(f"Parameter 'binaryDirectoryPath' is not of type 'Path'.") 

263 ex.add_note(f"Got type '{getFullyQualifiedName(binaryDirectoryPath)}'.") 

264 raise ex 

265 else: 

266 try: 

267 executablePath = Path(self._executableNames[self._platform]) 

268 except KeyError: 

269 raise CLIAbstractionException(f"Program is not supported on platform '{self._platform}'.") from PlatformNotSupportedException(self._platform) 

270 

271 resolvedExecutable = shutil_which(str(executablePath)) 

272 if dryRun: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 if resolvedExecutable is None: 

274 pass 

275 # XXX: log executable not found in PATH 

276 # self.LogDryRun(f"Which '{executablePath}' failed. [SKIPPING]") 

277 else: 

278 fullExecutablePath = Path(resolvedExecutable) 

279 if not fullExecutablePath.exists(): 

280 pass 

281 # XXX: log executable not found 

282 # self.LogDryRun(f"File check for '{fullExecutablePath}' failed. [SKIPPING]") 

283 else: 

284 if resolvedExecutable is None: 

285 raise CLIAbstractionException(f"Program could not be found in PATH.") from FileNotFoundError(executablePath) 

286 

287 fullExecutablePath = Path(resolvedExecutable) 

288 if not fullExecutablePath.exists(): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise CLIAbstractionException(f"Program '{fullExecutablePath}' not found.") from FileNotFoundError(fullExecutablePath) 

290 

291 # TODO: log found executable in PATH 

292 # TODO: check if found executable has execute permissions 

293 # raise ValueError(f"Neither parameter 'executablePath' nor 'binaryDirectoryPath' was set.") 

294 

295 self._executablePath = executablePath 

296 self.__cliParameters__ = {} 

297 

298 @staticmethod 

299 def _NeedsParameterInitialization(key: type) -> bool: 

300 """ 

301 Check if an argument class needs a value when it is set. 

302 

303 :param key: Class of the command line argument. 

304 :returns: ``True``, if the argument carries a value. 

305 """ 

306 return issubclass(key, (ValuedFlag, ValuedArgument, NamedAndValuedArgument, NamedTupledArgument, PathArgument, PathListArgument)) 

307 

308 def __getitem__(self, key: type[CommandLineArgument]) -> CommandLineArgument: 

309 """ 

310 Access to a CLI parameter by CLI option, which is already used. 

311 

312 :param key: Class of the command line argument to read. 

313 :returns: The command line argument object registered for that class. 

314 :raises TypeError: If the key is not a subclass of :class:`~pyTooling.CLIAbstraction.Argument.CommandLineArgument`. 

315 """ 

316 if not issubclass(key, CommandLineArgument): 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true

317 ex = TypeError(f"Key '{key}' is not a subclass of 'CommandLineArgument'.") 

318 ex.add_note(f"Got type '{getFullyQualifiedName(key)}'.") 

319 raise ex 

320 

321 # TODO: is nested check 

322 return self.__cliParameters__[key] 

323 

324 def __setitem__(self, key: type[CommandLineArgument], value: CommandLineArgument) -> None: 

325 """ 

326 Set a command line argument of this program by its argument class. 

327 

328 :param key: Class of the command line argument to set. 

329 :param value: Value of that argument; ignored for arguments needing no value. 

330 :raises TypeError: If the key is not a subclass of :class:`~pyTooling.CLIAbstraction.Argument.CommandLineArgument`. 

331 :raises KeyError: If the argument isn't allowed on this program, or was set before. 

332 """ 

333 if not issubclass(key, CommandLineArgument): 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 ex = TypeError(f"Key '{key}' is not a subclass of 'CommandLineArgument'.") 

335 ex.add_note(f"Got type '{getFullyQualifiedName(key)}'.") 

336 raise ex 

337 elif key not in self.__cliOptions__: 

338 raise KeyError(f"Option '{key}' is not allowed on executable '{self.__class__.__name__}'") 

339 elif key in self.__cliParameters__: 

340 raise KeyError(f"Option '{key}' is already set to a value.") 

341 

342 if self._NeedsParameterInitialization(key): 

343 self.__cliParameters__[key] = key(value) 

344 else: 

345 self.__cliParameters__[key] = key() 

346 

347 @readonly 

348 def Path(self) -> Path: 

349 """ 

350 Read-only property to access the program's path. 

351 

352 :returns: The program's path. 

353 """ 

354 return self._executablePath 

355 

356 def ToArgumentList(self) -> list[str]: 

357 """ 

358 Convert a program and used CLI options to a list of CLI argument strings in correct order and with escaping. 

359 

360 :returns: List of CLI arguments 

361 :raises TypeError: If an argument is neither a string nor a sequence of strings. |br| 

362 An argument's :meth:`~pyTooling.CLIAbstraction.Argument.CommandLineArgument.AsArgument` has to 

363 return a string, a tuple or a list. 

364 """ 

365 result: list[str] = [] 

366 

367 result.append(str(self._executablePath)) 

368 

369 def predicate(item: tuple[type[CommandLineArgument], int]) -> int: 

370 """ 

371 Nested function used as sort key. 

372 

373 :param item: Pair of an argument class and its argument object. 

374 :returns: The position the argument class was registered at, so the command line keeps the declared order. 

375 """ 

376 return self.__cliOptions__[item[0]] 

377 

378 for key, value in sorted(self.__cliParameters__.items(), key=predicate): 

379 param = value.AsArgument() 

380 if isinstance(param, str): 

381 result.append(param) 

382 elif isinstance(param, (tuple, list)): 382 ↛ 385line 382 didn't jump to line 385 because the condition on line 382 was always true

383 result += param 

384 else: 

385 ex = TypeError(f"Argument '{key.__name__}' was rendered to neither a string nor a sequence of strings.") 

386 ex.add_note(f"Got type '{getFullyQualifiedName(param)}'.") 

387 ex.add_note("'AsArgument()' has to return a 'str', a 'tuple' or a 'list'.") 

388 raise ex 

389 

390 return result 

391 

392 def __repr__(self) -> str: 

393 """ 

394 Returns the string representation as coma-separated list of double-quoted CLI argument strings within square brackets. 

395 

396 Example: :pycode:`["arg1", "arg2"]` 

397 

398 :returns: Coma-separated list of CLI arguments with double-quotes. 

399 """ 

400 return "[" + ", ".join([f"\"{item}\"" for item in self.ToArgumentList()]) + "]" # WORKAROUND: Python <3.12 

401 # return f"[{", ".join([f"\"{item}\"" for item in self.ToArgumentList()])}]" 

402 

403 def __str__(self) -> str: 

404 """ 

405 Returns the string representation as space-separated list of double-quoted CLI argument strings. 

406 

407 Example: :pycode:`"arg1" "arg2"` 

408 

409 :returns: Space-separated list of CLI arguments with double-quotes. 

410 """ 

411 return " ".join([f"\"{item}\"" for item in self.ToArgumentList()]) 

412 

413 

414@export 

415class Executable(Program): # (ILogable): 

416 """Represent a CLI executable derived from :class:`Program`, that adds an abstraction of :class:`subprocess.Popen`.""" 

417 

418 _BOUNDARY: ClassVar[str] = "====== BOUNDARY pyTooling.CLIAbstraction BOUNDARY ======" #: Marker line printed between the program's own output and the executable's output. 

419 

420 _workingDirectory: Nullable[Path] #: Path to the working directory 

421 _environment: Nullable[Environment] #: Environment to use when executing. 

422 _process: Nullable[Subprocess_Popen[str]] #: Reference to the running process. 

423 _exitCode: Nullable[int] #: The child's process exit code. 

424 _killed: Nullable[bool] #: True, if the child-process got killed (e.g. by a timeout). 

425 _iterator: Nullable[Iterator[str]] #: Iterator for reading STDOUT. 

426 

427 def __init__( 

428 self, 

429 executablePath: Nullable[Path] = None, 

430 binaryDirectoryPath: Nullable[Path] = None, 

431 workingDirectory: Nullable[Path] = None, 

432 environment: Nullable[Environment] = None, 

433 dryRun: bool = False 

434 ) -> None: 

435 """ 

436 Initializes an executable instance. 

437 

438 :param executablePath: Optional, path to the executable. 

439 :param binaryDirectoryPath: Optional, path to the executable's directory. 

440 :param workingDirectory: Optional, path to the working directory. 

441 :param environment: Optional, environment that should be setup when launching the executable. 

442 :param dryRun: Optional, ``True``, when the program should run in dryrun mode. 

443 """ 

444 super().__init__(executablePath, binaryDirectoryPath, dryRun) 

445 

446 self._workingDirectory = None 

447 self._environment = environment 

448 self._process = None 

449 self._exitCode = None 

450 self._killed = None 

451 self._iterator = None 

452 

453 def StartProcess(self, environment: Nullable[Environment] = None) -> None: 

454 """ 

455 Start the executable as a child-process. 

456 

457 :param environment: Optional, environment that should be setup when launching the executable. |br| 

458 If ``None``, the :attr:`_environment` is used. 

459 :raises CLIAbstractionException: When an :exc:`OSError` occurs while launching the child-process. 

460 """ 

461 if self._dryRun: 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true

462 self.LogDryRun(f"Start process: {self!r}") 

463 return 

464 

465 if environment is not None: 465 ↛ 467line 465 didn't jump to line 467 because the condition on line 465 was always true

466 envVariables = environment._variables 

467 elif self._environment is not None: 

468 envVariables = self._environment._variables 

469 else: 

470 envVariables = None 

471 

472 # FIXME: verbose log start process 

473 # FIXME: debug log - parameter list 

474 try: 

475 self._process = Subprocess_Popen( 

476 self.ToArgumentList(), 

477 stdin=Subprocess_Pipe, 

478 stdout=Subprocess_Pipe, 

479 stderr=Subprocess_StdOut, 

480 cwd=self._workingDirectory, 

481 env=envVariables, 

482 universal_newlines=True, 

483 bufsize=256 

484 ) 

485 

486 except OSError as ex: 

487 raise CLIAbstractionException(f"Error while launching a process for '{self._executablePath}'.") from ex 

488 

489 def Send(self, line: str, end: str = "\n") -> None: 

490 """ 

491 Send a string to STDIN of the running child-process. 

492 

493 :param line: Line to send. 

494 :param end: Optional, line end character. 

495 :raises CLIAbstractionException: If the child-process was not started, or has no standard input. 

496 :raises CLIAbstractionException: When any error occurs while sending data to the child-process. 

497 """ 

498 if self._process is None or self._process.stdin is None: 

499 raise CLIAbstractionException( 

500 f"The child-process '{self._executablePath}' was not started, or has no standard input." 

501 ) 

502 

503 try: 

504 self._process.stdin.write(line + end) 

505 self._process.stdin.flush() 

506 except Exception as ex: 

507 raise CLIAbstractionException(f"Error while sending data to the child-process '{self._executablePath}'.") from ex 

508 

509 # This is TCL specific ... 

510 # def SendBoundary(self): 

511 # self.Send("puts \"{0}\"".format(self._pyIPCMI_BOUNDARY)) 

512 

513 def GetLineReader(self) -> Generator[str, None, None]: 

514 """ 

515 Return a line-reader for STDOUT. 

516 

517 :returns: A generator object to read from STDOUT line-by-line. 

518 :raises DryRunException: In case dryrun mode is active. 

519 :raises CLIAbstractionException: When any error occurs while reading outputs from the child-process. 

520 """ 

521 if self._dryRun: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true

522 raise DryRunException(f"Can't read from the child-process '{self._executablePath}' in dry-run mode.") 

523 

524 if self._process is None or self._process.stdout is None: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 raise CLIAbstractionException( 

526 f"The child-process '{self._executablePath}' was not started, or has no standard output." 

527 ) 

528 

529 try: 

530 for line in iter(self._process.stdout.readline, ""): # FIXME: can it be improved? 

531 yield line[:-1] 

532 except Exception as ex: 

533 raise CLIAbstractionException(f"Error while reading from the child-process '{self._executablePath}'.") from ex 

534 # finally: 

535 # self._process.terminate() 

536 

537 def Wait(self, timeout: Nullable[float] = None, kill: bool = False) -> Nullable[int]: 

538 """ 

539 Wait on the child-process with an optional timeout. 

540 

541 When the timeout period exceeds, the child-process can be forcefully terminated. 

542 

543 :param timeout: Optional, timeout in seconds. |br| 

544 Default: infinitely wait on the child-process. 

545 :param kill: Optional, if ``True``, terminate (kill) the child-process if it didn't terminate by 

546 itself within the timeout period. 

547 :returns: ``None`` when the child-process is still running, otherwise the exit code. 

548 :raises CLIAbstractionException: When the child-process is not started yet. 

549 

550 .. topic:: Usecases 

551 

552 :pycode:`executable.Wait()` 

553 Infinitely wait on the child-process. When the child-process terminates by itself, the exit code is returned. 

554 

555 This is a blocking call. 

556 

557 :pycode:`executable.Wait(timeout=5.4)` 

558 Wait for a specified time on the child-process' termination. If it terminated by itself within the specified 

559 timeout period, the exit code is returned; otherwise ``None``. 

560 

561 Thus :pycode:`.Wait(timeout=0.0)` returning ``None`` indicates a running process. 

562 

563 :pycode:`executable.Wait(timeout=20.0, kill=True)` 

564 Wait for a specified time on the child-process' termination. If it terminated by itself within the specified 

565 timeout period, the exit code is returned; otherwise the child-process gets killed and it's exit code is returned. 

566 

567 :pycode:`executable.Wait(timeout=0.0, kill=True)` 

568 Kill immediately. 

569 

570 .. seealso:: 

571 

572 :meth:`Terminate` - Terminate the child-process. 

573 """ 

574 if self._process is None: 

575 raise CLIAbstractionException(f"Process not yet started.") 

576 

577 try: 

578 self._exitCode = self._process.wait(timeout=timeout) 

579 except TimeoutExpired: 

580 # when timed out, the process isn't terminated/killed automatically 

581 if kill: 

582 self._killed = True 

583 self._process.terminate() 

584 # After killing, wait to clean up the "zombie" process 

585 self._exitCode = self._process.wait() 

586 

587 return self._exitCode 

588 

589 def Terminate(self) -> Nullable[int]: 

590 """ 

591 Terminate the child-process. 

592 

593 :returns: The child-process' exit code. 

594 :raises CLIAbstractionException: When the child-process is not started yet. 

595 

596 .. seealso:: 

597 

598 :meth:`Wait` - Wait on the child-process with an optional timeout. 

599 """ 

600 return self.Wait(timeout=0.0, kill=True) 

601 

602 @readonly 

603 def ExitCode(self) -> int: 

604 """ 

605 Read-only property accessing the child-process' exit code. 

606 

607 :returns: Child-process' exit code or ``None`` if it's still running. 

608 

609 .. seealso:: 

610 

611 :meth:`Wait` 

612 |rarr| Wait on the child-process with an optional timeout. 

613 :meth:`Terminate` 

614 |rarr| Terminate the child-process. 

615 """ 

616 return self._exitCode 

617 

618 # This is TCL specific 

619 # def ReadUntilBoundary(self, indent=0): 

620 # __indent = " " * indent 

621 # if self._iterator is None: 

622 # self._iterator = iter(self.GetReader()) 

623 # 

624 # for line in self._iterator: 

625 # print(__indent + line) 

626 # if self._pyIPCMI_BOUNDARY in line: 

627 # break 

628 # self.LogDebug("Quartus II is ready") 

629 

630 

631@export 

632class OutputFilteredExecutable(Executable): 

633 """Represent a CLI executable derived from :class:`Executable`, whose outputs are filtered.""" 

634 _hasOutput: bool #: ``True``, if the executable wrote any output. 

635 _hasWarnings: bool #: ``True``, if the output filter classified a line as a warning. 

636 _hasErrors: bool #: ``True``, if the output filter classified a line as an error. 

637 _hasFatals: bool #: ``True``, if the output filter classified a line as a fatal error. 

638 

639 def __init__(self, platform: Platform, dryrun: bool, executablePath: Path) -> None: #, environment=None, logger=None) -> None: 

640 """ 

641 Initialize an executable whose output is filtered, with all filter results cleared. 

642 

643 :param platform: Platform the executable is called on. 

644 :param dryrun: If ``True``, the executable is not started, only the command line is assembled. 

645 :param executablePath: Optional, path to the executable. 

646 """ 

647 super().__init__(platform, dryrun, executablePath) #, environment=environment, logger=logger) 

648 

649 self._hasOutput = False 

650 self._hasWarnings = False 

651 self._hasErrors = False 

652 self._hasFatals = False 

653 

654 @readonly 

655 def HasWarnings(self) -> bool: 

656 # TODO: update doc-string 

657 """ 

658 Check if warnings were found while processing the output stream. 

659 

660 :returns: ``True``, if at least one warning was found. 

661 """ 

662 return self._hasWarnings 

663 

664 @readonly 

665 def HasErrors(self) -> bool: 

666 # TODO: update doc-string 

667 """ 

668 Check if errors were found while processing the output stream. 

669 

670 :returns: ``True``, if at least one error was found. 

671 """ 

672 return self._hasErrors 

673 

674 @readonly 

675 def HasFatals(self) -> bool: 

676 # TODO: update doc-string 

677 """ 

678 Check if fatal errors were found while processing the output stream. 

679 

680 :returns: ``True``, if at least one fatal error was found. 

681 """ 

682 return self._hasErrors