diff --git a/oracletrace/compare.py b/oracletrace/compare.py index 1656235..15eeb2f 100644 --- a/oracletrace/compare.py +++ b/oracletrace/compare.py @@ -1,8 +1,10 @@ -from .tracer import TracerData, FunctionData +from .tracer import TracerData, FunctionData, _safe_char from rich import print from typing import Any, Dict, List, Set, Optional from dataclasses import dataclass +_ARROW = _safe_char("→", "->") + @dataclass class RegressionData: name: str @@ -59,7 +61,7 @@ def compare_traces( print( f"{name}\n" - f" total_time: {old_time:.4f}s → {new_time:.4f}s " + f" total_time: {old_time:.4f}s {_ARROW} {new_time:.4f}s " f"[{color}]({percent:+.2f}%)[/]\n" ) if not show_only_regressions or percent > threshold else ... diff --git a/oracletrace/tracer.py b/oracletrace/tracer.py index 7297725..cfff533 100644 --- a/oracletrace/tracer.py +++ b/oracletrace/tracer.py @@ -18,6 +18,21 @@ ProfileEvent: TypeAlias = Literal["call", "return", "c_call", "c_return", "c_exception"] ProfileFunction: TypeAlias = Callable[[FrameType, ProfileEvent, Any], object] + +def _safe_char(preferred: str, fallback: str) -> str: + # Use the unicode char only when stdout can actually encode it — + # piped output on Windows is often cp1252, where rich raises + # UnicodeEncodeError and the whole run crashes after tracing finished + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + try: + preferred.encode(enc) + return preferred + except (UnicodeEncodeError, LookupError): + return fallback + + +_CYCLE = _safe_char("↻", "@") + @dataclass class TracerMetadata: root_path: str @@ -271,7 +286,7 @@ def add_nodes(parent_node: Tree, parent_key: str, current_path: set[str]) -> Non _total_time = self._func_time[child_key] # Detect recursion to prevent infinite loops in the tree if child_key in current_path: - parent_node.add(f"[red]↻ {child_key}[/] ({count}x)") + parent_node.add(f"[red]{_CYCLE} {child_key}[/] ({count}x)") continue node_text = f"{child_key} [dim]({count}x, {_total_time:.4f}s)[/]" diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 0000000..dcef7f3 --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,66 @@ +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from oracletrace.tracer import _safe_char + +REPO_ROOT = Path(__file__).resolve().parents[1] + +RECURSIVE_SCRIPT = """\ +def rec(n): + if n: + rec(n - 1) + +def main(): + rec(3) + +main() +""" + + +class _FakeStdout: + def __init__(self, encoding): + self.encoding = encoding + + +def test_safe_char_keeps_unicode_when_encodable(monkeypatch): + monkeypatch.setattr(sys, "stdout", _FakeStdout("utf-8")) + assert _safe_char("↻", "@") == "↻" + + +def test_safe_char_falls_back_on_cp1252(monkeypatch): + monkeypatch.setattr(sys, "stdout", _FakeStdout("cp1252")) + assert _safe_char("↻", "@") == "@" + assert _safe_char("→", "->") == "->" + + +def test_safe_char_survives_missing_encoding(monkeypatch): + monkeypatch.setattr(sys, "stdout", _FakeStdout(None)) + assert _safe_char("→", "->") == "→" # falls back to utf-8 + + +def test_piped_cp1252_output_does_not_crash(): + # Piped output on Windows often defaults to cp1252, which cannot encode + # the recursion marker in the call tree; the run then dies with + # UnicodeEncodeError AFTER tracing succeeded. Forced via PYTHONIOENCODING + # so the regression is caught on any platform. + env = { + **os.environ, + "PYTHONPATH": str(REPO_ROOT), + "PYTHONIOENCODING": "cp1252", + } + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + script = Path(tmp) / "script.py" + script.write_text(RECURSIVE_SCRIPT, encoding="utf-8") + result = subprocess.run( + [sys.executable, "-m", "oracletrace", "script.py"], + capture_output=True, + text=True, + cwd=tmp, + env=env, + ) + assert result.returncode == 0, result.stderr + assert "UnicodeEncodeError" not in result.stderr + assert "rec" in result.stdout