This commit is contained in:
2026-08-20 11:39:14 +02:00
parent f7067d8585
commit 7c366a9ca4
11 changed files with 1362 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import os
import queue
import signal
import subprocess
import threading
from pathlib import Path
from typing import Any
class ProcessRunner:
"""Run one subprocess and expose thread-safe events for the Tk loop."""
def __init__(self) -> None:
self.events: queue.Queue[tuple[str, Any]] = queue.Queue()
self.process: subprocess.Popen[bytes] | None = None
self._interrupted = False
self._log_file = None
@property
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def start(
self,
command: list[str],
cwd: Path,
environment: dict[str, str],
log_path: Path,
) -> None:
if self.running:
raise RuntimeError("Un processus est déjà en cours")
log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = log_path.open("wb")
self._interrupted = False
kwargs: dict[str, Any] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs["start_new_session"] = True
self.process = subprocess.Popen(
command,
cwd=cwd,
env=environment,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
**kwargs,
)
thread = threading.Thread(target=self._read_process, daemon=True)
thread.start()
def _read_process(self) -> None:
process = self.process
if process is None or process.stdout is None:
return
return_code = -1
try:
while True:
chunk = process.stdout.read(4096)
if not chunk:
break
if self._log_file:
self._log_file.write(chunk)
self._log_file.flush()
self.events.put(("output", chunk.decode("utf-8", errors="replace")))
return_code = process.wait()
except (OSError, ValueError) as exc: # pragma: no cover - defensive reporting
self.events.put(("runner_error", str(exc)))
return_code = process.wait()
finally:
if self._log_file:
self._log_file.close()
self._log_file = None
process.stdout.close()
if process.stdin:
process.stdin.close()
self.events.put(("finished", (return_code, self._interrupted)))
def send_input(self, text: str) -> None:
if not self.running or not self.process or not self.process.stdin:
raise RuntimeError("Aucun processus nattend de saisie")
data = (text + "\n").encode("utf-8")
self.process.stdin.write(data)
self.process.stdin.flush()
self.events.put(("input_echo", text + "\n"))
def interrupt(self) -> None:
if not self.running or not self.process:
return
self._interrupted = True
if os.name == "nt":
self.process.send_signal(signal.CTRL_BREAK_EVENT)
else:
os.killpg(self.process.pid, signal.SIGINT)
def force_stop(self) -> None:
if not self.running or not self.process:
return
self._interrupted = True
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(self.process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
os.killpg(self.process.pid, signal.SIGKILL)