Skip to content

Commit dec7bb4

Browse files
Fix Windows UnicodeEncodeError during build logging and post-render.
Set PYTHONUTF8 for Quarto subprocesses, reconfigure post-render stdio to UTF-8, and fall back to UTF-8 buffer writes when the build log hits narrow console encodings. Fixes #200 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d8540ae commit dec7bb4

5 files changed

Lines changed: 98 additions & 20 deletions

File tree

great_docs/_build_log.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,26 @@ def _should_use_color() -> bool:
125125
return False
126126

127127

128+
def _safe_stream_write(stream, text: str, *, flush: bool = True) -> None:
129+
"""Write *text* to *stream*, falling back to UTF-8 bytes on encode errors."""
130+
try:
131+
stream.write(text)
132+
if flush:
133+
stream.flush()
134+
except UnicodeEncodeError:
135+
buffer = getattr(stream, "buffer", None)
136+
if buffer is None:
137+
return
138+
try:
139+
buffer.write(text.encode("utf-8", errors="replace"))
140+
if flush:
141+
buffer.flush()
142+
except (BrokenPipeError, OSError):
143+
pass
144+
except (BrokenPipeError, OSError):
145+
pass
146+
147+
128148
# ---------------------------------------------------------------------------
129149
# Time formatting
130150
# ---------------------------------------------------------------------------
@@ -282,15 +302,13 @@ def update(self, current: int) -> None:
282302
return # throttle to 1 Hz
283303
self._last_draw_time = now
284304
line = self._render_bar(current)
285-
self.stream.write(f"\r{line}")
286-
self.stream.flush()
305+
_safe_stream_write(self.stream, f"\r{line}")
287306
else:
288307
# CI mode: emit at every 10 % boundary
289308
bucket = min(current * 100 // self.total, 100) // 10
290309
if bucket > self._last_pct_bucket:
291310
self._last_pct_bucket = bucket
292-
self.stream.write(self._render_bar_plain(current) + "\n")
293-
self.stream.flush()
311+
_safe_stream_write(self.stream, self._render_bar_plain(current) + "\n")
294312
except (BrokenPipeError, OSError):
295313
pass
296314

@@ -307,8 +325,7 @@ def finish(self, summary: str | None = None) -> None: # noqa: ARG002
307325
# Clear the in-place line
308326
try:
309327
width = shutil.get_terminal_size((80, 24)).columns
310-
self.stream.write("\r" + " " * width + "\r")
311-
self.stream.flush()
328+
_safe_stream_write(self.stream, "\r" + " " * width + "\r")
312329
except (BrokenPipeError, OSError):
313330
pass
314331

@@ -391,8 +408,7 @@ def update(self, idx: int, current: int) -> None:
391408
if bucket > self._ci_buckets[idx]:
392409
self._ci_buckets[idx] = bucket
393410
pct = bucket * 10
394-
self.stream.write(f" [..] {self._labels[idx]} {pct:>3d}%\n")
395-
self.stream.flush()
411+
_safe_stream_write(self.stream, f" [..] {self._labels[idx]} {pct:>3d}%\n")
396412

397413
def finish(self) -> None:
398414
"""Clear all progress lines. Safe to call more than once."""
@@ -403,12 +419,11 @@ def finish(self) -> None:
403419
try:
404420
width = shutil.get_terminal_size((80, 24)).columns
405421
# Move up N lines and clear each
406-
self.stream.write(f"\033[{self._n}A")
422+
_safe_stream_write(self.stream, f"\033[{self._n}A", flush=False)
407423
for _ in range(self._n):
408-
self.stream.write(" " * width + "\n")
424+
_safe_stream_write(self.stream, " " * width + "\n", flush=False)
409425
# Move back up to start
410-
self.stream.write(f"\033[{self._n}A\r")
411-
self.stream.flush()
426+
_safe_stream_write(self.stream, f"\033[{self._n}A\r")
412427
except (BrokenPipeError, OSError):
413428
pass
414429

@@ -417,7 +432,7 @@ def _redraw(self) -> None:
417432
try:
418433
if self._drawn:
419434
# Move cursor up to first slot line
420-
self.stream.write(f"\033[{self._n}A")
435+
_safe_stream_write(self.stream, f"\033[{self._n}A", flush=False)
421436

422437
lines = []
423438
width = shutil.get_terminal_size((80, 24)).columns
@@ -427,8 +442,7 @@ def _redraw(self) -> None:
427442
padding = max(0, width - _display_width(line))
428443
lines.append(line + " " * padding)
429444

430-
self.stream.write("\n".join(lines) + "\n")
431-
self.stream.flush()
445+
_safe_stream_write(self.stream, "\n".join(lines) + "\n")
432446
self._drawn = True
433447
except (BrokenPipeError, OSError):
434448
pass
@@ -505,11 +519,7 @@ def __init__(
505519
# -- internal helpers ---------------------------------------------------
506520

507521
def _write(self, text: str) -> None:
508-
try:
509-
self.stream.write(text + "\n")
510-
self.stream.flush()
511-
except (BrokenPipeError, OSError):
512-
pass # never crash the build for a logging failure
522+
_safe_stream_write(self.stream, text + "\n")
513523

514524
def _pad_rail(self, inner: str, inner_plain_len: int) -> str:
515525
"""Pad a step header *inner* string with ``━`` to fill the width."""

great_docs/assets/post-render.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@
55
import json
66
import os
77
import re
8+
import sys
9+
10+
11+
def _configure_stdio_for_unicode() -> None:
12+
"""Use UTF-8 for console output when the platform default is narrow (e.g. cp1252)."""
13+
for name in ("stdout", "stderr"):
14+
stream = getattr(sys, name, None)
15+
if stream is None:
16+
continue
17+
reconfigure = getattr(stream, "reconfigure", None)
18+
if reconfigure is None:
19+
continue
20+
try:
21+
reconfigure(encoding="utf-8", errors="replace")
22+
except (OSError, ValueError):
23+
pass
24+
25+
26+
_configure_stdio_for_unicode()
827

928
# Skip post-render processing during freeze-only renders
1029
if os.environ.get("GD_FREEZE_ONLY"):

great_docs/core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,9 @@ def _get_quarto_env(self) -> dict[str, str]:
13091309
else:
13101310
env["PYTHONPATH"] = new_pythonpath
13111311

1312+
# Ensure Python subprocesses (e.g. post-render.py via Quarto) use UTF-8 on Windows.
1313+
env.setdefault("PYTHONUTF8", "1")
1314+
13121315
return env
13131316

13141317
def _get_package_metadata(self) -> dict:

tests/test_build_log.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,35 @@ def flush(self):
17121712
log = BuildLog(stream=ErrorStream(), force_color=False, width=80)
17131713
log._write("test") # should not raise
17141714

1715+
def test_substep_survives_cp1252_stdout(self):
1716+
"""Issue #200: box-drawing substeps must not crash on cp1252 stdout."""
1717+
buffer = io.BytesIO()
1718+
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
1719+
log = BuildLog(stream=stream, force_color=False, width=80)
1720+
log.substep("SEO enhancements applied")
1721+
output = buffer.getvalue().decode("utf-8", errors="replace")
1722+
assert "SEO enhancements applied" in output
1723+
1724+
def test_footer_survives_cp1252_stdout(self):
1725+
"""Issue #200: celebration emoji in footer must not crash on cp1252 stdout."""
1726+
buffer = io.BytesIO()
1727+
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
1728+
log = BuildLog(stream=stream, force_color=False, width=80)
1729+
log.footer(site_path="out/index.html")
1730+
output = buffer.getvalue().decode("utf-8", errors="replace")
1731+
assert "Site ready" in output
1732+
assert "Build complete" in output
1733+
1734+
def test_post_render_stdio_reconfigure_allows_emoji(self):
1735+
"""Issue #200: post-render status prints must survive cp1252 stdout."""
1736+
buffer = io.BytesIO()
1737+
wrapper = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
1738+
wrapper.reconfigure(encoding="utf-8", errors="replace")
1739+
print("\n🔍 Applying SEO enhancements to HTML files...", file=wrapper)
1740+
wrapper.flush()
1741+
output = buffer.getvalue().decode("utf-8", errors="replace")
1742+
assert "SEO enhancements" in output
1743+
17151744
def test_progress_bar_survives_broken_pipe(self):
17161745
class BrokenStream:
17171746
def write(self, _s):

tests/test_great_docs.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,6 +2213,23 @@ def test_get_quarto_env_preserves_existing_env():
22132213
assert env["PATH"] == os.environ.get("PATH")
22142214

22152215

2216+
def test_get_quarto_env_sets_pythonutf8():
2217+
"""Issue #200: Quarto subprocess env enables UTF-8 mode on Windows."""
2218+
with tempfile.TemporaryDirectory() as tmp_dir:
2219+
docs = GreatDocs(project_path=tmp_dir)
2220+
env = docs._get_quarto_env()
2221+
assert env.get("PYTHONUTF8") == "1"
2222+
2223+
2224+
def test_get_quarto_env_preserves_existing_pythonutf8(monkeypatch):
2225+
"""Issue #200: do not override an explicit PYTHONUTF8 value."""
2226+
monkeypatch.setenv("PYTHONUTF8", "0")
2227+
with tempfile.TemporaryDirectory() as tmp_dir:
2228+
docs = GreatDocs(project_path=tmp_dir)
2229+
env = docs._get_quarto_env()
2230+
assert env["PYTHONUTF8"] == "0"
2231+
2232+
22162233
def test_detect_dynamic_mode_returns_true_for_simple_package():
22172234
"""Test that _detect_dynamic_mode returns True for packages without cyclic aliases."""
22182235

0 commit comments

Comments
 (0)