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, ) -> None: if self.running: raise RuntimeError("Un processus est déjà en cours") if log_path is not None: log_path.parent.mkdir(parents=True, exist_ok=True) self._log_file = log_path.open("wb") else: self._log_file = None 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 n’attend 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)