we encoutered a crash due to a corruption in FCCS. Added error handling for that scenario which included a crashed list and modifying the export function. Need to test it on a known crashed folder
This commit is contained in:
@@ -58,6 +58,7 @@ Requires FCCS to be open with export destination already configured. Automates t
|
|||||||
- **Resumable** -- tracks completed drawers in `completed.txt`; safe to restart
|
- **Resumable** -- tracks completed drawers in `completed.txt`; safe to restart
|
||||||
- **Screenshots** -- captures failure states for diagnosis
|
- **Screenshots** -- captures failure states for diagnosis
|
||||||
- **Defensive** -- one bad drawer won't crash the entire run
|
- **Defensive** -- one bad drawer won't crash the entire run
|
||||||
|
- **Crash recovery** -- some documents (e.g. UltraTax "Diagnostics" files) crash FCCS's converter (`FileConversionEngine::convert() failed`), which aborts that drawer's export. The script detects the error dialog, screenshots and logs the crashing document, dismisses it, and records the drawer in `crashed.txt` so it's skipped on future runs instead of stalling. Handle crashed drawers manually (export them excluding the poison document); delete a line from `crashed.txt` to retry after fixing.
|
||||||
|
|
||||||
### Step 2b (optional): Verify Export Completeness
|
### Step 2b (optional): Verify Export Completeness
|
||||||
|
|
||||||
@@ -99,6 +100,6 @@ Exported filenames follow the format `{drawer_id}_{client_name}_{folder_name}_{c
|
|||||||
|
|
||||||
All scripts read from `config.ini` (or specify `--config path\to\config.ini`).
|
All scripts read from `config.ini` (or specify `--config path\to\config.ini`).
|
||||||
|
|
||||||
- **`[paths]`** -- backup_dir, export_dir, output_dir, drawer_id_file, completed_file, ignore_file, log_file, screenshot_dir, manifest_dir, folder_list
|
- **`[paths]`** -- backup_dir, export_dir, output_dir, drawer_id_file, completed_file, ignore_file, crashed_file, log_file, screenshot_dir, manifest_dir, folder_list
|
||||||
- **`[timeouts]`** -- nav_timeout, dialog_timeout, progress_appear, progress_finish, settle, confirm_timeout
|
- **`[timeouts]`** -- nav_timeout, dialog_timeout, progress_appear, progress_finish, settle, confirm_timeout
|
||||||
- **`[controls]`** -- FCCS window class names and button titles (rarely need changing)
|
- **`[controls]`** -- FCCS window class names and button titles (rarely need changing)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ output_dir = C:\Migration\Output
|
|||||||
drawer_id_file = C:\Migration\drawer_ids.txt
|
drawer_id_file = C:\Migration\drawer_ids.txt
|
||||||
completed_file = C:\Migration\completed.txt
|
completed_file = C:\Migration\completed.txt
|
||||||
ignore_file = C:\Migration\ignore.txt
|
ignore_file = C:\Migration\ignore.txt
|
||||||
|
crashed_file = C:\Migration\crashed.txt
|
||||||
log_file = C:\Migration\run_log.txt
|
log_file = C:\Migration\run_log.txt
|
||||||
screenshot_dir = C:\Migration\screenshots
|
screenshot_dir = C:\Migration\screenshots
|
||||||
manifest_dir = C:\Migration\manifests
|
manifest_dir = C:\Migration\manifests
|
||||||
@@ -33,3 +34,5 @@ progress_class = #32770
|
|||||||
saved_title = "Send to" file saved
|
saved_title = "Send to" file saved
|
||||||
saved_class = #32770
|
saved_class = #32770
|
||||||
saved_ok_title = OK
|
saved_ok_title = OK
|
||||||
|
error_title = FileCabinet CS
|
||||||
|
error_class = #32770
|
||||||
154
fccs_export.py
154
fccs_export.py
@@ -35,6 +35,16 @@ from pywinauto import timings
|
|||||||
from fccs_config import parse_args, load_config, make_logger, load_lines
|
from fccs_config import parse_args, load_config, make_logger, load_lines
|
||||||
|
|
||||||
|
|
||||||
|
# Per-drawer outcomes.
|
||||||
|
STATUS_SUCCESS = "success" # exported cleanly -> completed.txt
|
||||||
|
STATUS_CRASHED = "crashed" # FCCS converter crash -> crashed.txt (skip on rerun)
|
||||||
|
STATUS_FAILED = "failed" # navigation/timeout/other -> retried next run
|
||||||
|
|
||||||
|
# Substrings that mark the FCCS crash error dialog body (case-insensitive),
|
||||||
|
# used to distinguish a converter crash from other "FileCabinet CS" prompts.
|
||||||
|
_ERROR_SIGNATURES = ("failed", "access violation", "convert")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HELPERS
|
# HELPERS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -53,6 +63,12 @@ def mark_completed(path, drawer_id):
|
|||||||
f.write(drawer_id + "\n")
|
f.write(drawer_id + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def mark_crashed(path, drawer_id):
|
||||||
|
"""Append a drawer ID to the crashed log (skipped on future runs)."""
|
||||||
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(drawer_id + "\n")
|
||||||
|
|
||||||
|
|
||||||
def take_screenshot(window, drawer_id, tag, screenshot_dir, log):
|
def take_screenshot(window, drawer_id, tag, screenshot_dir, log):
|
||||||
"""Save a screenshot of a window for later diagnosis of a failure."""
|
"""Save a screenshot of a window for later diagnosis of a failure."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -256,6 +272,15 @@ def perform_export(dlg, drawer_id, ctrl, timeouts, log):
|
|||||||
|
|
||||||
def dismiss_any_dialog(app, ctrl, log):
|
def dismiss_any_dialog(app, ctrl, log):
|
||||||
"""Try to find and dismiss any stale dialogs left from a previous failure."""
|
"""Try to find and dismiss any stale dialogs left from a previous failure."""
|
||||||
|
# Dismiss a lingering converter-crash error dialog (OK)
|
||||||
|
w = _find_error_dialog(ctrl)
|
||||||
|
if w:
|
||||||
|
try:
|
||||||
|
_click_dialog_button(w, "OK", log)
|
||||||
|
log(" Dismissed stale crash error dialog.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Dismiss "Send to" file saved confirmation dialog (OK)
|
# Dismiss "Send to" file saved confirmation dialog (OK)
|
||||||
w = _find_desktop_dialog(ctrl["saved_title"])
|
w = _find_desktop_dialog(ctrl["saved_title"])
|
||||||
if w:
|
if w:
|
||||||
@@ -294,12 +319,52 @@ def _click_dialog_button(dialog, button_title, log):
|
|||||||
dlg.wait_not("visible", timeout=10)
|
dlg.wait_not("visible", timeout=10)
|
||||||
|
|
||||||
|
|
||||||
def wait_for_export(app, ctrl, timeouts, log):
|
def _find_error_dialog(ctrl):
|
||||||
|
"""Find the FCCS converter-crash error dialog, if present.
|
||||||
|
|
||||||
|
Matches a visible #32770 titled 'FileCabinet CS' whose body text carries a
|
||||||
|
crash signature (failed / access violation / convert), so we don't confuse
|
||||||
|
it with other 'FileCabinet CS' prompts. Returns the window or None.
|
||||||
"""
|
"""
|
||||||
Wait for the full export lifecycle to complete:
|
try:
|
||||||
1. 'Exporting Documents' progress dialog appears (export running)
|
windows = Desktop(backend="win32").windows()
|
||||||
2. Poll until '"Send to" file saved' confirmation dialog appears
|
except Exception:
|
||||||
3. Click OK to dismiss it
|
return None
|
||||||
|
for w in windows:
|
||||||
|
try:
|
||||||
|
if w.class_name() != ctrl["error_class"] or not w.is_visible():
|
||||||
|
continue
|
||||||
|
if ctrl["error_title"] not in w.window_text():
|
||||||
|
continue
|
||||||
|
body = " ".join(s.window_text()
|
||||||
|
for s in w.children(class_name="Static")).lower()
|
||||||
|
if any(sig in body for sig in _ERROR_SIGNATURES):
|
||||||
|
return w
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_progress_doc(app, ctrl):
|
||||||
|
"""Read the document name from the 'Exporting Documents' progress dialog."""
|
||||||
|
try:
|
||||||
|
progress = app.window(title=ctrl["progress_title"],
|
||||||
|
class_name=ctrl["progress_class"])
|
||||||
|
for st in progress.children(class_name="Static"):
|
||||||
|
t = st.window_text()
|
||||||
|
if t and "document" in t.lower():
|
||||||
|
return t.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "(unknown document)"
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_export(app, drawer_id, ctrl, timeouts, screenshot_dir, log):
|
||||||
|
"""
|
||||||
|
Wait for the export lifecycle to complete, watching for two outcomes:
|
||||||
|
- '"Send to" file saved' confirmation dialog -> STATUS_SUCCESS
|
||||||
|
- 'FileCabinet CS' converter-crash error -> STATUS_CRASHED
|
||||||
|
A drawer that shows neither before the deadline -> STATUS_FAILED.
|
||||||
"""
|
"""
|
||||||
progress = app.window(title=ctrl["progress_title"], class_name=ctrl["progress_class"])
|
progress = app.window(title=ctrl["progress_title"], class_name=ctrl["progress_class"])
|
||||||
|
|
||||||
@@ -310,20 +375,35 @@ def wait_for_export(app, ctrl, timeouts, log):
|
|||||||
log(" NOTE: progress dialog not seen; checking for confirmation dialog "
|
log(" NOTE: progress dialog not seen; checking for confirmation dialog "
|
||||||
"(drawer may have exported very quickly).")
|
"(drawer may have exported very quickly).")
|
||||||
|
|
||||||
# Poll the entire desktop for the confirmation dialog.
|
# Poll the entire desktop each cycle for either the success dialog or a
|
||||||
# app.window() can miss it — the dialog may not be owned by the app.
|
# converter-crash error. app.window() can miss dialogs it doesn't own.
|
||||||
# Use the exact saved_title to avoid matching the "Send To File Location" dialog.
|
|
||||||
deadline = time.time() + timeouts["progress_finish"]
|
deadline = time.time() + timeouts["progress_finish"]
|
||||||
saved_win = None
|
saved_win = None
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
|
# Read the current document BEFORE checking for the crash, so if the
|
||||||
|
# error dialog has already replaced the progress text we still logged it.
|
||||||
|
current_doc = _read_progress_doc(app, ctrl)
|
||||||
|
|
||||||
|
err_win = _find_error_dialog(ctrl)
|
||||||
|
if err_win is not None:
|
||||||
|
log(f" CRASH: FCCS converter failed on {current_doc}. "
|
||||||
|
f"This aborts the drawer export.")
|
||||||
|
take_screenshot(err_win, drawer_id, "crash", screenshot_dir, log)
|
||||||
|
try:
|
||||||
|
_click_dialog_button(err_win, "OK", log)
|
||||||
|
log(" Crash dialog dismissed; FCCS returned to home screen.")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" WARNING: could not dismiss crash dialog: {e}")
|
||||||
|
return STATUS_CRASHED
|
||||||
|
|
||||||
saved_win = _find_desktop_dialog(ctrl["saved_title"])
|
saved_win = _find_desktop_dialog(ctrl["saved_title"])
|
||||||
if saved_win is not None:
|
if saved_win is not None:
|
||||||
break
|
break
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
if saved_win is None:
|
if saved_win is None:
|
||||||
log(f" ERROR: confirmation dialog never appeared within {timeouts['progress_finish']}s.")
|
log(f" ERROR: no confirmation or crash within {timeouts['progress_finish']}s.")
|
||||||
return False
|
return STATUS_FAILED
|
||||||
|
|
||||||
log(f" Confirmation dialog found: {saved_win.window_text()!r}")
|
log(f" Confirmation dialog found: {saved_win.window_text()!r}")
|
||||||
|
|
||||||
@@ -335,12 +415,12 @@ def wait_for_export(app, ctrl, timeouts, log):
|
|||||||
log(" Export succeeded but dialog remains — will be dismissed next iteration.")
|
log(" Export succeeded but dialog remains — will be dismissed next iteration.")
|
||||||
|
|
||||||
log(" Export complete.")
|
log(" Export complete.")
|
||||||
return True
|
return STATUS_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts,
|
def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts,
|
||||||
screenshot_dir, manifest_dir, log):
|
screenshot_dir, manifest_dir, log):
|
||||||
"""Full per-drawer sequence. Returns True on success."""
|
"""Full per-drawer sequence. Returns a STATUS_* outcome."""
|
||||||
log(f"Drawer {drawer_id}: starting")
|
log(f"Drawer {drawer_id}: starting")
|
||||||
|
|
||||||
# 0. Dismiss any stale dialogs left from a previous failure
|
# 0. Dismiss any stale dialogs left from a previous failure
|
||||||
@@ -349,7 +429,7 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts,
|
|||||||
# 1. Navigate
|
# 1. Navigate
|
||||||
if not navigate_to_drawer(main, search_box, drawer_id, ctrl, timeouts, log):
|
if not navigate_to_drawer(main, search_box, drawer_id, ctrl, timeouts, log):
|
||||||
take_screenshot(main, drawer_id, "nav_fail", screenshot_dir, log)
|
take_screenshot(main, drawer_id, "nav_fail", screenshot_dir, log)
|
||||||
return False
|
return STATUS_FAILED
|
||||||
|
|
||||||
# 2. Open Send To File dialog
|
# 2. Open Send To File dialog
|
||||||
try:
|
try:
|
||||||
@@ -357,7 +437,7 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f" ERROR opening Send To File dialog: {e}")
|
log(f" ERROR opening Send To File dialog: {e}")
|
||||||
take_screenshot(main, drawer_id, "dialog_fail", screenshot_dir, log)
|
take_screenshot(main, drawer_id, "dialog_fail", screenshot_dir, log)
|
||||||
return False
|
return STATUS_FAILED
|
||||||
|
|
||||||
# 3 & 4. Select -> and OK (captures manifest before clicking OK)
|
# 3 & 4. Select -> and OK (captures manifest before clicking OK)
|
||||||
try:
|
try:
|
||||||
@@ -371,16 +451,18 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts,
|
|||||||
dlg.child_window(title="Cancel", class_name="Button").click()
|
dlg.child_window(title="Cancel", class_name="Button").click()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return STATUS_FAILED
|
||||||
|
|
||||||
# 5. Monitor progress
|
# 5. Monitor progress (success / crash / timeout)
|
||||||
ok = wait_for_export(app, ctrl, timeouts, log)
|
status = wait_for_export(app, drawer_id, ctrl, timeouts, screenshot_dir, log)
|
||||||
if not ok:
|
if status == STATUS_CRASHED:
|
||||||
|
return STATUS_CRASHED
|
||||||
|
if status != STATUS_SUCCESS:
|
||||||
take_screenshot(main, drawer_id, "progress_fail", screenshot_dir, log)
|
take_screenshot(main, drawer_id, "progress_fail", screenshot_dir, log)
|
||||||
return False
|
return STATUS_FAILED
|
||||||
|
|
||||||
time.sleep(timeouts["settle"])
|
time.sleep(timeouts["settle"])
|
||||||
return True
|
return STATUS_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -397,6 +479,7 @@ def main():
|
|||||||
"drawer_id_file": cfg.get("paths", "drawer_id_file"),
|
"drawer_id_file": cfg.get("paths", "drawer_id_file"),
|
||||||
"completed_file": cfg.get("paths", "completed_file"),
|
"completed_file": cfg.get("paths", "completed_file"),
|
||||||
"ignore_file": cfg.get("paths", "ignore_file"),
|
"ignore_file": cfg.get("paths", "ignore_file"),
|
||||||
|
"crashed_file": cfg.get("paths", "crashed_file"),
|
||||||
"screenshot_dir": cfg.get("paths", "screenshot_dir"),
|
"screenshot_dir": cfg.get("paths", "screenshot_dir"),
|
||||||
"manifest_dir": cfg.get("paths", "manifest_dir"),
|
"manifest_dir": cfg.get("paths", "manifest_dir"),
|
||||||
}
|
}
|
||||||
@@ -422,6 +505,8 @@ def main():
|
|||||||
"saved_title": cfg.get("controls", "saved_title"),
|
"saved_title": cfg.get("controls", "saved_title"),
|
||||||
"saved_class": cfg.get("controls", "saved_class"),
|
"saved_class": cfg.get("controls", "saved_class"),
|
||||||
"saved_ok_title": cfg.get("controls", "saved_ok_title"),
|
"saved_ok_title": cfg.get("controls", "saved_ok_title"),
|
||||||
|
"error_title": cfg.get("controls", "error_title"),
|
||||||
|
"error_class": cfg.get("controls", "error_class"),
|
||||||
}
|
}
|
||||||
|
|
||||||
log("=" * 60)
|
log("=" * 60)
|
||||||
@@ -435,11 +520,16 @@ def main():
|
|||||||
|
|
||||||
completed = load_completed(paths["completed_file"])
|
completed = load_completed(paths["completed_file"])
|
||||||
ignored = set(load_lines(paths["ignore_file"]))
|
ignored = set(load_lines(paths["ignore_file"]))
|
||||||
|
crashed = set(load_lines(paths["crashed_file"]))
|
||||||
|
|
||||||
pending = [d for d in all_ids if d not in completed and d not in ignored]
|
pending = [d for d in all_ids
|
||||||
|
if d not in completed
|
||||||
|
and d not in ignored
|
||||||
|
and d not in crashed]
|
||||||
log(f"Total drawers in list : {len(all_ids)}")
|
log(f"Total drawers in list : {len(all_ids)}")
|
||||||
log(f"Already completed : {len(completed)}")
|
log(f"Already completed : {len(completed)}")
|
||||||
log(f"Ignored (skipped) : {len(ignored & set(all_ids))}")
|
log(f"Ignored (skipped) : {len(ignored & set(all_ids))}")
|
||||||
|
log(f"Crashed (skipped) : {len(crashed & set(all_ids))}")
|
||||||
log(f"Remaining to process : {len(pending)}")
|
log(f"Remaining to process : {len(pending)}")
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
@@ -456,12 +546,14 @@ def main():
|
|||||||
|
|
||||||
success_count = 0
|
success_count = 0
|
||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
crash_count = 0
|
||||||
failed_ids = []
|
failed_ids = []
|
||||||
|
crashed_ids = []
|
||||||
|
|
||||||
for i, drawer_id in enumerate(pending, start=1):
|
for i, drawer_id in enumerate(pending, start=1):
|
||||||
log(f"--- [{i}/{len(pending)}] Drawer {drawer_id} ---")
|
log(f"--- [{i}/{len(pending)}] Drawer {drawer_id} ---")
|
||||||
try:
|
try:
|
||||||
ok = export_drawer(app, main_win, search_box, drawer_id, ctrl,
|
status = export_drawer(app, main_win, search_box, drawer_id, ctrl,
|
||||||
timeouts, paths["screenshot_dir"],
|
timeouts, paths["screenshot_dir"],
|
||||||
paths["manifest_dir"], log)
|
paths["manifest_dir"], log)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -471,13 +563,20 @@ def main():
|
|||||||
paths["screenshot_dir"], log)
|
paths["screenshot_dir"], log)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ok = False
|
status = STATUS_FAILED
|
||||||
|
|
||||||
if ok:
|
if status == STATUS_SUCCESS:
|
||||||
mark_completed(paths["completed_file"], drawer_id)
|
mark_completed(paths["completed_file"], drawer_id)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
log(f" Drawer {drawer_id} DONE ({success_count} ok / "
|
log(f" Drawer {drawer_id} DONE ({success_count} ok / "
|
||||||
f"{fail_count} failed so far)")
|
f"{fail_count} failed / {crash_count} crashed so far)")
|
||||||
|
elif status == STATUS_CRASHED:
|
||||||
|
mark_crashed(paths["crashed_file"], drawer_id)
|
||||||
|
crash_count += 1
|
||||||
|
crashed_ids.append(drawer_id)
|
||||||
|
log(f" Drawer {drawer_id} CRASHED - added to crashed list; will be "
|
||||||
|
f"skipped on re-run. Handle manually (export excluding the "
|
||||||
|
f"poison document).")
|
||||||
else:
|
else:
|
||||||
fail_count += 1
|
fail_count += 1
|
||||||
failed_ids.append(drawer_id)
|
failed_ids.append(drawer_id)
|
||||||
@@ -488,10 +587,15 @@ def main():
|
|||||||
log("Run finished.")
|
log("Run finished.")
|
||||||
log(f" Succeeded : {success_count}")
|
log(f" Succeeded : {success_count}")
|
||||||
log(f" Failed : {fail_count}")
|
log(f" Failed : {fail_count}")
|
||||||
|
log(f" Crashed : {crash_count}")
|
||||||
if failed_ids:
|
if failed_ids:
|
||||||
log(f" Failed drawer IDs: {', '.join(failed_ids)}")
|
log(f" Failed drawer IDs: {', '.join(failed_ids)}")
|
||||||
log(" These were NOT marked complete; re-running the script "
|
log(" These were NOT marked complete; re-running the script "
|
||||||
"will retry them.")
|
"will retry them.")
|
||||||
|
if crashed_ids:
|
||||||
|
log(f" Crashed drawer IDs: {', '.join(crashed_ids)}")
|
||||||
|
log(f" These were added to {paths['crashed_file']} and will be skipped "
|
||||||
|
"on re-run. Export them manually, excluding the crashing document.")
|
||||||
log("=" * 60)
|
log("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user