186 lines
5.1 KiB
Python
186 lines
5.1 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import shlex
|
|||
|
|
import shutil
|
|||
|
|
import socket
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
from PySide6.QtCore import QObject, QProcess, QTimer, Signal, Slot, Property
|
|||
|
|
|
|||
|
|
|
|||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_python_invocation() -> tuple[str, list[str]]:
|
|||
|
|
"""Return (program, prefix_args) to invoke Python reliably.
|
|||
|
|
|
|||
|
|
On Windows, prefer the repo-local venv; otherwise prefer the `py -3` launcher
|
|||
|
|
when present so we don’t depend on `python.exe` being on PATH.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
venv_py = REPO_ROOT / ".venv" / ("Scripts" if os.name == "nt" else "bin") / (
|
|||
|
|
"python.exe" if os.name == "nt" else "python"
|
|||
|
|
)
|
|||
|
|
if venv_py.exists():
|
|||
|
|
return (str(venv_py), [])
|
|||
|
|
|
|||
|
|
if os.name == "nt" and shutil.which("py"):
|
|||
|
|
return ("py", ["-3"])
|
|||
|
|
|
|||
|
|
if shutil.which("python3"):
|
|||
|
|
return ("python3", [])
|
|||
|
|
|
|||
|
|
return (sys.executable or "python", [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_python() -> str:
|
|||
|
|
program, prefix = resolve_python_invocation()
|
|||
|
|
if prefix:
|
|||
|
|
# Best-effort string representation (mostly for display)
|
|||
|
|
return " ".join([program] + prefix)
|
|||
|
|
return program
|
|||
|
|
|
|||
|
|
|
|||
|
|
def can_connect(host: str, port: int, timeout_s: float = 0.8) -> bool:
|
|||
|
|
try:
|
|||
|
|
with socket.create_connection((host, port), timeout=timeout_s):
|
|||
|
|
return True
|
|||
|
|
except OSError:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _pick_terminal_command() -> Optional[list[str]]:
|
|||
|
|
if os.name == "nt":
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
candidates: list[list[str]] = []
|
|||
|
|
# Debian/Ubuntu alternative system
|
|||
|
|
candidates.append(["x-terminal-emulator", "-e"])
|
|||
|
|
candidates.append(["gnome-terminal", "--"])
|
|||
|
|
candidates.append(["konsole", "-e"])
|
|||
|
|
candidates.append(["xfce4-terminal", "-e"])
|
|||
|
|
candidates.append(["xterm", "-e"])
|
|||
|
|
|
|||
|
|
for cmd in candidates:
|
|||
|
|
if shutil.which(cmd[0]):
|
|||
|
|
return cmd
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def spawn_in_terminal(argv: list[str]) -> bool:
|
|||
|
|
"""Best-effort external terminal launcher.
|
|||
|
|
|
|||
|
|
Returns True if a terminal was spawned, False otherwise.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
if os.name == "nt":
|
|||
|
|
# Use cmd.exe window, keep it open (/k)
|
|||
|
|
# Build a single string command for cmd.
|
|||
|
|
cmdline = " ".join(shlex.quote(a) for a in argv)
|
|||
|
|
subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", cmdline], shell=False)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
term = _pick_terminal_command()
|
|||
|
|
if not term:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
subprocess.Popen(term + argv, cwd=str(REPO_ROOT))
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProcessRunner(QObject):
|
|||
|
|
runningChanged = Signal()
|
|||
|
|
exitCodeChanged = Signal()
|
|||
|
|
outputAppended = Signal(str)
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__()
|
|||
|
|
self._proc = QProcess(self)
|
|||
|
|
self._proc.setProcessChannelMode(QProcess.MergedChannels)
|
|||
|
|
self._proc.readyReadStandardOutput.connect(self._on_ready)
|
|||
|
|
self._proc.finished.connect(self._on_finished)
|
|||
|
|
self._exit_code: int = -1
|
|||
|
|
|
|||
|
|
@Property(bool, notify=runningChanged)
|
|||
|
|
def running(self) -> bool:
|
|||
|
|
return self._proc.state() != QProcess.NotRunning
|
|||
|
|
|
|||
|
|
@Property(int, notify=exitCodeChanged)
|
|||
|
|
def exitCode(self) -> int:
|
|||
|
|
return self._exit_code
|
|||
|
|
|
|||
|
|
@Slot(str, list)
|
|||
|
|
def start(self, program: str, arguments: list) -> None:
|
|||
|
|
if self.running:
|
|||
|
|
return
|
|||
|
|
self._exit_code = -1
|
|||
|
|
self.exitCodeChanged.emit()
|
|||
|
|
self._proc.setProgram(program)
|
|||
|
|
self._proc.setArguments([str(a) for a in arguments])
|
|||
|
|
self._proc.setWorkingDirectory(str(REPO_ROOT))
|
|||
|
|
self._proc.start()
|
|||
|
|
self.runningChanged.emit()
|
|||
|
|
|
|||
|
|
@Slot()
|
|||
|
|
def stop(self) -> None:
|
|||
|
|
if not self.running:
|
|||
|
|
return
|
|||
|
|
self._proc.terminate()
|
|||
|
|
if not self._proc.waitForFinished(1500):
|
|||
|
|
self._proc.kill()
|
|||
|
|
self.runningChanged.emit()
|
|||
|
|
|
|||
|
|
def _on_ready(self) -> None:
|
|||
|
|
data = bytes(self._proc.readAllStandardOutput()).decode("utf-8", errors="replace")
|
|||
|
|
if data:
|
|||
|
|
self.outputAppended.emit(data)
|
|||
|
|
|
|||
|
|
def _on_finished(self, exit_code: int, _status) -> None:
|
|||
|
|
self._exit_code = int(exit_code)
|
|||
|
|
self.exitCodeChanged.emit()
|
|||
|
|
self.runningChanged.emit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConnectionMonitor(QObject):
|
|||
|
|
hostChanged = Signal()
|
|||
|
|
connectedChanged = Signal()
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__()
|
|||
|
|
self._host = ""
|
|||
|
|
self._connected = False
|
|||
|
|
self._timer = QTimer(self)
|
|||
|
|
self._timer.setInterval(1000)
|
|||
|
|
self._timer.timeout.connect(self._poll)
|
|||
|
|
self._timer.start()
|
|||
|
|
|
|||
|
|
@Property(str, notify=hostChanged)
|
|||
|
|
def host(self) -> str:
|
|||
|
|
return self._host
|
|||
|
|
|
|||
|
|
@host.setter
|
|||
|
|
def host(self, value: str) -> None:
|
|||
|
|
value = (value or "").strip()
|
|||
|
|
if value == self._host:
|
|||
|
|
return
|
|||
|
|
self._host = value
|
|||
|
|
self.hostChanged.emit()
|
|||
|
|
self._poll()
|
|||
|
|
|
|||
|
|
@Property(bool, notify=connectedChanged)
|
|||
|
|
def connected(self) -> bool:
|
|||
|
|
return self._connected
|
|||
|
|
|
|||
|
|
def _poll(self) -> None:
|
|||
|
|
host = self._host
|
|||
|
|
connected = False
|
|||
|
|
if host:
|
|||
|
|
connected = can_connect(host, 22)
|
|||
|
|
if connected != self._connected:
|
|||
|
|
self._connected = connected
|
|||
|
|
self.connectedChanged.emit()
|