Coverage for vibefpmgui/console.py: 88%

28 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Starting console programs (git, fpm, fortitude, fprettify…) from the app 

2without a console window flashing up on Windows (no Qt). 

3 

4A GUI app on Windows has no console; a console program it starts with 

5Python's subprocess gets a new one, shown for as long as the program runs, 

6unless it's started with CREATE_NO_WINDOW. (QProcess does that by itself 

7when the app has no console.) 

8""" 

9 

10from __future__ import annotations 

11 

12import os 

13import subprocess 

14import sys 

15import tempfile 

16from pathlib import Path 

17 

18# subprocess's keyword arguments for no console window: **NO_WINDOW in each call. 

19NO_WINDOW: dict = {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} 

20 

21 

22def gui_python() -> str: 

23 """The Python to start another window of the app with: on Windows its 

24 pythonw.exe (python.exe would open a console window beside it, there 

25 for as long as the window is).""" 

26 if sys.platform == "win32": 

27 windowless = Path(sys.executable).with_name("pythonw.exe") 

28 if windowless.is_file(): 

29 return str(windowless) 

30 return sys.executable 

31 

32 

33def run(args: list[str], timeout: float, limit: int | None = None, env: dict | None = None, 

34 cwd: str | None = None) -> tuple[int, bytes | None, str]: 

35 """Run a program to its end, without a console window: (exit code, its 

36 output, or None if over ``limit`` bytes, its error message). Raises 

37 OSError if it can't be started, TimeoutError after ``timeout`` seconds 

38 (it's killed). 

39 

40 Its output goes to temporary files, not pipes, and only it is waited for 

41 (without Python's lock: the window goes on): a pipe is only done when 

42 every process holding it has closed it, and a process started meanwhile 

43 by another thread (the Terminal's shell, fortls, fpm) could hold it too, 

44 for as long as it runs.""" 

45 with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: 

46 process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=out, stderr=err, env=env, cwd=cwd, 

47 **NO_WINDOW) 

48 try: 

49 code = process.wait(timeout=timeout) 

50 except subprocess.TimeoutExpired: 

51 process.kill() 

52 process.wait() 

53 raise TimeoutError(f"{Path(args[0]).name} took over {timeout:.0f} s") from None 

54 size = out.seek(0, os.SEEK_END) 

55 out.seek(0) 

56 err.seek(0) 

57 output = None if limit is not None and size > limit else out.read() 

58 message = err.read().decode("utf-8", "replace").strip() 

59 return code, output, message 

60