117 lines
3.7 KiB
Python
117 lines
3.7 KiB
Python
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)
|