From fbe2c47ed42721ae806aa0e4c6bd43318e929e4d Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 30 Aug 2026 18:28:39 -0500 Subject: [PATCH] Force UTF-8 on clicker output so Windows stops failing runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run died with "'charmap' codec can't encode character '▶'" at step 1. Python picks the console code page for stdout, and under PM2 - where stdout is a pipe rather than a console - that is cp1252 on Windows. cp1252 handles the em dashes in these files but not the run markers, so the first one raised UnicodeEncodeError from inside run_steps and the runner reported it as a step failure rather than an output problem. Reconfigure stdout and stderr to UTF-8 at the top of each entry point, with errors="replace" as a backstop for streams that cannot be reconfigured. Fixing the encoding once beats stripping the glyphs from ~30 call sites, and covers manual runs in a plain console too. Co-Authored-By: Claude Opus 5 --- clicker/clicker.py | 17 +++++++++++++++++ clicker/diagnose.py | 3 +++ clicker/runner.py | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/clicker/clicker.py b/clicker/clicker.py index 9a4838b..91bd7dd 100644 --- a/clicker/clicker.py +++ b/clicker/clicker.py @@ -31,6 +31,22 @@ import urllib.request import actions import focus + +def force_utf8_output() -> None: + """Make stdout/stderr accept the status glyphs this tool prints. + + Windows picks the console code page for stdout, and under PM2 (where stdout + is a pipe, not a console) that is cp1252. It can encode em dashes but not + the run markers, so the first one raised UnicodeEncodeError mid-run and the + runner reported it as a step failure. errors="replace" is a backstop for any + stream that cannot be reconfigured at all. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + DEFAULT_API = "http://localhost:3000" POLL_INTERVAL = 0.25 @@ -176,6 +192,7 @@ def describe(found: dict, factor: float) -> str: def main() -> int: + force_utf8_output() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("action", choices=["locate", "click", "type"], help="locate = measure only; click = measure then click; " diff --git a/clicker/diagnose.py b/clicker/diagnose.py index 221a887..b2aaf73 100644 --- a/clicker/diagnose.py +++ b/clicker/diagnose.py @@ -15,6 +15,8 @@ Nothing is clicked. The cursor is moved and put back where it started. import sys import time +from clicker import force_utf8_output + def line(label: str, value: str) -> None: print(f" {label:<22} {value}") @@ -125,6 +127,7 @@ def check_foreground() -> None: def main() -> int: + force_utf8_output() print("AutoFirmer clicker diagnostics") check_platform() check_dpi() diff --git a/clicker/runner.py b/clicker/runner.py index 0932389..cab2288 100644 --- a/clicker/runner.py +++ b/clicker/runner.py @@ -25,7 +25,7 @@ import time import actions import focus -from clicker import Dashboard, DashboardError, NotFoundError +from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output POLL_SECONDS = 1.0 HEARTBEAT_SECONDS = 2.0 @@ -362,6 +362,7 @@ def finish(dash, run_id, error): def main() -> int: + force_utf8_output() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")