172 lines
7.9 KiB
Python
172 lines
7.9 KiB
Python
"""Drive the Run tab's 7-box checkpoint tracker.
|
|
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
|
|
'stuck' (box cp overran its max time)."""
|
|
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
|
|
|
|
def _on_automation_line(self, line, level):
|
|
"""Called (from the worker thread) for every printed line."""
|
|
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
|
|
# (raw line still stored in the DB log below).
|
|
if "[CPSTATE]" in line:
|
|
try:
|
|
kind, cp = line.split("[CPSTATE]", 1)[1].split()
|
|
self._cpstate(kind, cp)
|
|
# Feed the parent-side stall watchdog. The automation runs in a
|
|
# CHILD process, so the in-process performer.WD_STATE the old
|
|
# watchdog read is ALWAYS empty here (it never fired — a stuck
|
|
# checkpoint never restarted). Track the active checkpoint + its
|
|
# start time from the STREAMED [CPSTATE] line instead.
|
|
if kind == "active":
|
|
self._wd_cp = int(cp)
|
|
self._wd_since = time.time()
|
|
except Exception:
|
|
pass
|
|
low = line.lower()
|
|
ltype = "info"
|
|
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
|
|
ltype = "error"
|
|
elif "warning" in low or "[warn" in low:
|
|
ltype = "warn"
|
|
elif "processed successfully" in low or "[all done]" in low or "success" in low:
|
|
ltype = "success"
|
|
|
|
# The FULL raw technical line always goes to the per-batch DB log AND to
|
|
# the DB-independent file log (the file works even when the DB is down).
|
|
if self._batch_log is not None:
|
|
self._batch_log.log(ltype, line)
|
|
if self._run_log_file is not None:
|
|
try:
|
|
self._run_log_file.write(line + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
# The on-screen log gets the plain-English translation only; raw lines
|
|
# stay off the screen — except untranslated ERRORS, which are shown
|
|
# as-is (a failure must never be invisible to the user).
|
|
shown = friendly_log_line(line)
|
|
if shown is not None:
|
|
if shown[0] != self._last_friendly: # collapse repeated polling lines
|
|
self._log(shown[0], shown[1])
|
|
self._last_friendly = shown[0]
|
|
elif ltype == "error" and not _is_error_noise(low):
|
|
# An untranslated real error still shows (a failure must never be
|
|
# invisible) — but raw traceback/COM noise is filtered out.
|
|
self._log(line, "error")
|
|
self._last_friendly = None
|
|
|
|
# advance the progress bar / step list on known markers
|
|
for marker, step_idx, pct in STEP_MARKERS:
|
|
if marker in low:
|
|
if step_idx > self._cur_step:
|
|
self._cur_step = step_idx
|
|
if pct > self._max_pct:
|
|
self._max_pct = pct
|
|
self._step(STEPS[self._cur_step], self._max_pct)
|
|
break
|
|
|
|
def _resolve_input_path(self, p):
|
|
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
|
|
get a bare filename. Resolve it against the known input folders so the
|
|
automation always receives a real, absolute path."""
|
|
if p and os.path.isabs(p) and os.path.exists(p):
|
|
return p
|
|
base = os.path.basename(p) if p else ""
|
|
for folder in (INPUT_DIR, _ROOT):
|
|
cand = os.path.join(folder, base) if base else ""
|
|
if cand and os.path.exists(cand):
|
|
return cand
|
|
return p # unresolved — caller will report a clear error
|
|
|
|
def _run_multi_subprocess(self, excel_path, emp):
|
|
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
|
|
no Qt) and stream its stdout back through this run's log pipeline.
|
|
|
|
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
|
|
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
|
|
for cross-process UIA; forcing MTA dead-locks against the Qt main
|
|
thread's UIA object). The reference tool works because it is a separate
|
|
process with no Qt — this reproduces that. Every child stdout line is
|
|
re-printed here so it flows through _StreamToBridge → _on_automation_line
|
|
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
|
|
per-batch DB log, file log). Returns the run_multi summary dict.
|
|
"""
|
|
import subprocess
|
|
import time as _time
|
|
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__))))
|
|
|
|
# The child writes ALL its output to THIS file and we tail it live. A
|
|
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
|
|
# (v1.1.5: nothing streamed back), but a line-buffered file always
|
|
# flushes per line and can be read while the process runs.
|
|
try:
|
|
os.makedirs(LOGS_DIR, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
child_log = os.path.join(
|
|
LOGS_DIR,
|
|
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
f"_{self.batch_id or 0}.log")
|
|
try:
|
|
open(child_log, "w", encoding="utf-8").close() # start empty
|
|
except Exception:
|
|
pass
|
|
|
|
if getattr(sys, "frozen", False):
|
|
cmd = [sys.executable, "--run-automation"]
|
|
else:
|
|
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
|
|
"--run-automation"]
|
|
cmd += ["--excel", str(excel_path),
|
|
"--batch", str(self.batch_id or ""),
|
|
"--emp", str(emp or ""),
|
|
"--log", child_log,
|
|
# Parent-death watchdog: the child exits itself the instant this
|
|
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
|
|
# run) so automation can never keep driving Tebra orphaned.
|
|
"--parent-pid", str(os.getpid())]
|
|
|
|
env = dict(os.environ)
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
|
|
if sys.platform.startswith("win") else 0
|
|
|
|
print(f"[Automation] Launching automation in its own process "
|
|
f"(reliable UIA) — batch {self.batch_id} …")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
cwd=proj_root, env=env, creationflags=creationflags)
|
|
except Exception as e:
|
|
print(f"[Automation] Could not launch automation process: {e}")
|
|
return {"total": 0, "ok": [],
|
|
"failed": [("Automation", f"launch failed: {e}")],
|
|
"stopped": False}
|
|
self._auto_proc = proc
|
|
|
|
summary = {}
|
|
got_summary = False
|
|
|
|
def _handle(line):
|
|
nonlocal summary, got_summary
|
|
if line.startswith("__WF_SUMMARY__"):
|
|
try:
|
|
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
|
|
got_summary = True
|
|
except Exception:
|
|
pass
|
|
elif line.strip():
|
|
print(line) # → _StreamToBridge tee → _on_automation_line
|
|
|
|
# Tail the child log until the process exits, then drain the rest.
|
|
try:
|
|
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
|
|
while True:
|
|
pos = f.tell()
|
|
line = f.readline()
|
|
if line and line.endswith("\n"):
|
|
_handle(line.rstrip("\r\n"))
|
|
elif line: # partial line (no newline yet)
|
|
if proc.poll() is not None:
|
|
_handle(line.rstrip("\r\n"))
|