"""Run tab: PyQt6 window + bridge for the page in run_tab.html. python run_tab/run_tab.py open the Run tab python run_tab/run_tab.py --selfcheck run the fake bot through every ending, save screenshots The same file is also the bot's process: the window starts `run_tab.py --run-bot ...` (or `.exe --run-bot ...` when frozen). That branch loads no Qt (ADR-0002). """ import json import os import sys import threading from datetime import datetime from pathlib import Path # ---- CONFIG: the rpa-run-tab skill fills these in ------------------------------------ TITLE = "Run automation" SUBTITLE = "Upload the input Excel and start the bot" BOT = "bot.py" # "path/to/bot.py" (from the project root) or "package.module:function" HAS_INPUT = True # False hides the input card; Trigger is ready straight away INPUT_HINT = "Excel workbook with the input sheets" CHECKPOINTS = [ # (name, seconds before it counts as stuck; 0 = no limit) ("Sign in", 300), ("Process items", 0), ("Finish up", 300), ] SAMPLE_SHEETS = {} # {"Sheet name": ["Column", ...]}; empty hides the Sample input button KILL_ALSO = [] # extra .exe names to kill with the bot, e.g. ["chromedriver.exe"] APP_ID = "RunTab" # QSettings name and the page's dark-mode key # --------------------------------------------------------------------------------------- FROZEN = getattr(sys, "frozen", False) HERE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) ROOT = Path(sys.executable).parent if FROZEN else Path(__file__).resolve().parent.parent LOGS = ROOT / "logs" def _arg(flag, default=""): return sys.argv[sys.argv.index(flag) + 1] if flag in sys.argv else default def _watch_parent(ppid): """Exit the bot the moment the window's process dies, so it never runs orphaned.""" try: if sys.platform.startswith("win"): import ctypes k32 = ctypes.windll.kernel32 h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE if h: k32.WaitForSingleObject(h, 0xFFFFFFFF) else: import time while os.getppid() == ppid: time.sleep(1) except Exception: return # can't watch: keep running os._exit(1) def run_bot_child(): """The bot's process: all output goes to --log, the bot runs, its result becomes the summary line.""" log = open(_arg("--log"), "a", encoding="utf-8", buffering=1) sys.stdout = sys.stderr = log threading.Thread(target=_watch_parent, args=(int(_arg("--parent-pid", "0")),), daemon=True).start() excel, bot = _arg("--excel"), _arg("--bot", BOT) sys.path.insert(0, str(ROOT)) os.chdir(ROOT) if bot.endswith(".py"): import runpy sys.argv = [bot] + ([excel] if excel else []) sys.path.insert(0, str((ROOT / bot).parent)) runpy.run_path(str(ROOT / bot), run_name="__main__") # a plain script prints its own summary else: import importlib mod, func = bot.split(":") sys.path.insert(0, str(HERE)) result = getattr(importlib.import_module(mod), func)(*([excel] if excel else [])) if isinstance(result, dict): print("__RUN_SUMMARY__" + json.dumps(result)) log.flush() os._exit(0) if "--run-bot" in sys.argv: run_bot_child() # ---- everything below runs only in the window's process -------------------------------- sys.path.insert(0, str(HERE)) from PyQt6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot # noqa: E402 from PyQt6.QtWebChannel import QWebChannel # noqa: E402 from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402 from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox # noqa: E402 import runner # noqa: E402 SEARCH_DIRS = [ROOT, ROOT / "input", Path.home() / "Downloads", Path.home() / "Desktop", Path.home() / "Documents"] def page_html(): cfg = {"title": TITLE, "sub": SUBTITLE, "input": HAS_INPUT, "inputHint": INPUT_HINT, "sample": bool(SAMPLE_SHEETS), "darkKey": APP_ID + "_dark", "checkpoints": [{"name": n, "max": m} for n, m in CHECKPOINTS]} return (HERE / "run_tab.html").read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg)) def bot_cmd(excel, log, bot=BOT): me = [sys.executable] if FROZEN else [sys.executable, str(Path(__file__).resolve())] return me + ["--run-bot", "--bot", bot, "--excel", excel or "", "--log", str(log), "--parent-pid", str(os.getpid())] def build_sample(path, sheets): """Headers-only sample workbook with one fake row. Never real data (R13).""" from openpyxl import Workbook wb = Workbook() wb.remove(wb.active) for name, cols in sheets.items(): ws = wb.create_sheet(name[:31]) ws.append(cols) ws.append([f"Example {c}".strip() for c in cols]) wb.save(path) def read_sheet_rows(path, sheet, cap=200): """(header, rows, more): first `cap` non-empty rows as text, Excel date serials as m/d/yyyy.""" from datetime import date, timedelta from openpyxl import load_workbook def cell(c): if c is None: return "" if isinstance(c, datetime): c = c.date() if isinstance(c, date): return f"{c.month}/{c.day}/{c.year}" if isinstance(c, (int, float)) and not isinstance(c, bool) and 36526 <= c <= 73050: # 2000..2099 d = date(1899, 12, 30) + timedelta(days=int(c)) return f"{d.month}/{d.day}/{d.year}" return str(c) wb = load_workbook(path, read_only=True, data_only=True) try: ws = wb[sheet] if sheet in wb.sheetnames else wb.active rows, more = [], False for r in ws.iter_rows(values_only=True): cells = [cell(c) for c in r] if not any(x.strip() for x in cells): continue if len(rows) >= cap: more = True break rows.append(cells) finally: wb.close() return (rows[0] if rows else []), rows[1:], more class Bridge(QObject): """Exposed to the page as pyBridge.""" js_message = pyqtSignal(str) def __init__(self, window, bot=BOT, checkpoints=CHECKPOINTS): super().__init__() self.window, self.bot, self.checkpoints = window, bot, checkpoints self.run = None @property def running(self): return self.run is not None and self.run.thread.is_alive() def send(self, event, data): self.js_message.emit(json.dumps({"event": event, "data": data})) def _file(self, path): sheets = [] try: from openpyxl import load_workbook wb = load_workbook(path, read_only=True) sheets = list(wb.sheetnames) wb.close() except Exception as e: self.send("log", {"msg": f"Could not open the workbook: {e}", "type": "warn"}) self.send("file_selected", {"path": path, "name": os.path.basename(path), "sheets": sheets}) @pyqtSlot() def browse_file(self): path, _ = QFileDialog.getOpenFileName(self.window, "Select input Excel file", str(Path.home()), "Excel files (*.xlsx *.xlsm *.xls)") if path: self._file(path) @pyqtSlot(str) def process_dropped_path(self, path): # The page only knows the dropped file's name, so look for it in the usual folders. if not os.path.isabs(path): path = next((str(d / path) for d in SEARCH_DIRS if (d / path).is_file()), "") if path and os.path.isfile(path): self._file(path) else: self.send("file_selected", {"path": "", "name": os.path.basename(path) or "file", "sheets": []}) @pyqtSlot(str, str) def read_sheet(self, path, sheet): try: header, rows, more = read_sheet_rows(path, sheet) self.send("sheet_data", {"sheet": sheet, "header": header, "rows": rows, "total": len(rows), "more": more}) except Exception as e: self.send("sheet_data", {"sheet": sheet, "error": str(e), "header": [], "rows": [], "total": 0}) @pyqtSlot() def download_sample_input(self): path, _ = QFileDialog.getSaveFileName(self.window, "Save sample input", str(Path.home() / f"{APP_ID}_sample_input.xlsx"), "Excel files (*.xlsx)") if path: build_sample(path, SAMPLE_SHEETS) self.send("log", {"msg": f"Sample saved: {path}", "type": "success"}) @pyqtSlot(str) def start_automation(self, excel): if self.running: # double-run guard return LOGS.mkdir(exist_ok=True) log = LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log" self.run = runner.Run(bot_cmd(excel if HAS_INPUT else "", log, self.bot), log, self.checkpoints, self.send, KILL_ALSO, cwd=str(ROOT)).start() @pyqtSlot(bool) def set_dark(self, on): QSettings("RPA", APP_ID).setValue("dark", on) class RunTabWindow(QMainWindow): def __init__(self, bot=BOT, checkpoints=CHECKPOINTS): super().__init__() self.setWindowTitle(TITLE) self.setMinimumSize(940, 620) self.resize(1320, 820) self.view = QWebEngineView() self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # no Reload mid-run self.bridge = Bridge(self, bot, checkpoints) self.channel = QWebChannel() self.channel.registerObject("pyBridge", self.bridge) self.view.page().setWebChannel(self.channel) self.bridge.js_message.connect(self._push) self.view.loadFinished.connect(self._loaded) self.view.setHtml(page_html(), QUrl("qrc:///")) self.setCentralWidget(self.view) def _push(self, s): self.view.page().runJavaScript(f"window.__pyMsg && window.__pyMsg({json.dumps(s)})") def _loaded(self, ok): if ok: on = QSettings("RPA", APP_ID).value("dark", False, type=bool) self.bridge.send("apply_dark", {"on": on}) def closeEvent(self, event): if self.bridge.running: ans = QMessageBox.question( self, f"Close {TITLE}?", "The automation is still running.\n\nClosing will STOP it now. Close anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if ans != QMessageBox.StandardButton.Yes: event.ignore() return self.bridge.run.kill() event.accept() def selfcheck(): """Fake bot through every ending with the real runner, then window screenshots of a green run (light), an amber run (dark) and a stuck run (light), saved to logs/ (R14). Never runs the real bot.""" import time fake = str((HERE / "fake_bot.py").relative_to(ROOT)) os.environ["FAKE_CPS"] = str(len(CHECKPOINTS)) quick = [(n, 2 if i == 1 else 0) for i, (n, _) in enumerate(CHECKPOINTS)] # checkpoint 2 sticks after 2 s want = {"complete": "done", "partial": "partial", "stuck": "error", "crash": "error"} LOGS.mkdir(exist_ok=True) for mode, state in want.items(): os.environ["FAKE_MODE"] = mode events = [] log = LOGS / f"selfcheck_{mode}.log" r = runner.Run(bot_cmd("", log, fake), log, quick, lambda e, d: events.append((e, d)), cwd=str(ROOT)).start() r.thread.join(60) end = events[-1][1] if events and events[-1][0] == "status" else {} assert end.get("state") == state, f"{mode}: expected {state}, got {end} (log: {log})" if mode == "stuck": assert ("cpstate", {"kind": "stuck", "cp": 2}) in events, f"stuck box missing: {events}" print(f"ok {mode:<8} -> {end['state']}: {end['msg']}") # The window: one green run in light, one amber run in dark, saved as PNGs to look at. app = QApplication.instance() or QApplication(sys.argv) w = RunTabWindow(fake, quick) w.resize(1280, 900) w.show() shots = [("complete", False), ("partial", True), ("stuck", False)] def wait_for_end(then): def poll(): if w.bridge.running or w.bridge.run is None: QTimer.singleShot(200, poll) else: QTimer.singleShot(800, then) poll() def next_shot(): if not shots: app.quit() return mode, dark = shots.pop(0) os.environ["FAKE_MODE"] = mode w.bridge.run = None w.bridge.send("apply_dark", {"on": dark}) w.view.page().runJavaScript("fileReady=true; filePath=''; doRun();") def save(): out = LOGS / f"selfcheck_{mode}_{'dark' if dark else 'light'}.png" w.view.grab().save(str(out)) print(f"ok screenshot {out}") next_shot() def scrolled_save(): w.view.page().runJavaScript("document.getElementById('prog-wrap').scrollIntoView()") QTimer.singleShot(400, save) QTimer.singleShot(300, lambda: wait_for_end(scrolled_save)) w.view.loadFinished.connect(lambda ok: QTimer.singleShot(500, next_shot)) QTimer.singleShot(90_000, app.quit) # never hang t = time.monotonic() app.exec() print(f"selfcheck done in {time.monotonic() - t:.0f}s") if __name__ == "__main__": if "--selfcheck" in sys.argv: selfcheck() sys.exit(0) app = QApplication(sys.argv) app.setApplicationName(TITLE) w = RunTabWindow() w.show() sys.exit(app.exec())