From 92da4ebd6741078936c28d623865e8f0e2370910 Mon Sep 17 00:00:00 2001 From: Connor Ferster Date: Fri, 14 Aug 2026 03:05:27 +0000 Subject: [PATCH 1/2] feat: graceful CLI error handling for YAML and Python-block errors Author mistakes no longer crash the CLI with a raw traceback. YAML syntax errors and exceptions raised inside `_py` blocks are now caught and presented as friendly, actionable reports that make clear the problem is in the authored document, not an ymprint bug. - Add YmprintAuthoringError hierarchy (YamlSyntaxError, PythonBlockError) - Raise these at the parse (yaml_loader) and exec (python_block) sites - error_display: compact top-and-bottom traceback truncation; maps `` exec frames back to the author's block source and highlights the failing line - convert: wrap render, show a red error panel and exit 1 on authoring errors - live: redesigned two-part rich Panel (watch list + throbber over a status area) with a state-coloured border; reload failures show the error in-place instead of killing the session, cleared on the next save - throbber: red error-explosion burst on failed reload - Fix latent live bugs: undefined CONFIG_FILENAMES and locate_config_file now returns the nearest config as a full path - Remove a stray debug print that corrupted the live display - Tests for YAML/Python-block errors, convert exit code, and truncation Co-Authored-By: Claude Opus 4.8 --- src/ymprint/blocks/python_block.py | 8 +- src/ymprint/cli/config.py | 12 +-- src/ymprint/cli/error_display.py | 117 ++++++++++++++++++++++ src/ymprint/cli/main.py | 150 +++++++++++++++++++---------- src/ymprint/cli/throbber.py | 5 + src/ymprint/errors.py | 60 ++++++++++++ src/ymprint/report_reader.py | 1 - src/ymprint/yaml_loader.py | 12 ++- tests/test_cli_errors.py | 103 ++++++++++++++++++++ 9 files changed, 409 insertions(+), 59 deletions(-) create mode 100644 src/ymprint/cli/error_display.py create mode 100644 src/ymprint/errors.py create mode 100644 tests/test_cli_errors.py diff --git a/src/ymprint/blocks/python_block.py b/src/ymprint/blocks/python_block.py index ed97117..f83f81e 100644 --- a/src/ymprint/blocks/python_block.py +++ b/src/ymprint/blocks/python_block.py @@ -1,6 +1,7 @@ from reportlab.platypus import Table, KeepTogether from . import register_block from .code_block_styles import python_code_block +from ..errors import PythonBlockError from typing import Callable @@ -19,7 +20,12 @@ def convert_python_block(block_key: str, block_value: dict, context: dict) -> li if namespace is not None: context['vars'][namespace] = {} local_namespace = context['vars'][namespace] if namespace is not None else context['vars'] - exec(source, globals=context['vars'], locals=local_namespace) + try: + exec(source, globals=context['vars'], locals=local_namespace) + except Exception as e: + # The author's own code raised — surface it as an authoring error that + # keeps the source and traceback for a compact, actionable report. + raise PythonBlockError(block_key, source, e) from e if block_value.get("echo", True): code_block = python_code_block(source, available_width * width_ratio, context, caption=caption, show_line_numbers=line_numbers) code_block.spaceBefore = space_around diff --git a/src/ymprint/cli/config.py b/src/ymprint/cli/config.py index 1e8efa4..087024c 100644 --- a/src/ymprint/cli/config.py +++ b/src/ymprint/cli/config.py @@ -3,11 +3,11 @@ def locate_config_file(cwd: Path) -> Optional[Path]: - config_file = None - for parent in cwd.parents: - filenames = [path.name for path in parent.glob("*.ymprint.yml")] - if filenames: - config_file = filenames[0] - return config_file + """Return the nearest ``*.ymprint.yml`` at or above ``cwd`` as a full path.""" + for parent in [cwd, *cwd.parents]: + matches = sorted(parent.glob("*.ymprint.yml")) + if matches: + return matches[0] + return None diff --git a/src/ymprint/cli/error_display.py b/src/ymprint/cli/error_display.py new file mode 100644 index 0000000..8315aef --- /dev/null +++ b/src/ymprint/cli/error_display.py @@ -0,0 +1,117 @@ +"""Render :class:`YmprintAuthoringError` instances as compact, friendly output. + +Python-block tracebacks are truncated to their *top and bottom*: the first few +frames (the calling context — which block triggered the failure) and the last +few frames (the exact line that blew up), with the middle collapsed. This keeps +even a deep stack readable while still telling the author both *where their +document caused it* and *what to fix*. +""" +from __future__ import annotations + +import traceback +from typing import Optional + +from rich.console import Group, RenderableType +from rich.text import Text + +from ..errors import PythonBlockError, YamlSyntaxError, YmprintAuthoringError + +# How many stack frames to keep from each end before collapsing the middle. +HEAD_FRAMES = 2 +TAIL_FRAMES = 3 + + +def format_authoring_error(exc: YmprintAuthoringError) -> RenderableType: + """Return a rich renderable describing an authoring error.""" + if isinstance(exc, YamlSyntaxError): + return _format_yaml_error(exc) + if isinstance(exc, PythonBlockError): + return _format_python_error(exc) + return Text(str(exc), style="red") + + +def _format_yaml_error(exc: YamlSyntaxError) -> RenderableType: + location = exc.filepath.name + if exc.line is not None: + location += f", line {exc.line}, column {exc.column}" + + parts: list[RenderableType] = [ + Text(f"YAML syntax error in {location}", style="bold red") + ] + if exc.problem: + parts.append(Text(exc.problem, style="red")) + if exc.snippet: + parts.append(Text(exc.snippet, style="yellow")) + parts.append(Text("Fix the YAML above, then save to reload.", style="dim italic")) + return Group(*parts) + + +def _format_python_error(exc: PythonBlockError) -> RenderableType: + original = exc.original + frames = traceback.extract_tb(original.__traceback__) + source_lines = exc.source.splitlines() + + parts: list[RenderableType] = [ + Text(f"Error in Python block '{exc.block_key}'", style="bold red") + ] + + failing = _failing_source_line(frames, source_lines) + if failing is not None: + parts.append(failing) + + parts.append(Text("Traceback (most relevant frames):", style="dim")) + parts.extend(_compact_frames(frames, source_lines)) + parts.append( + Text(f"{type(original).__name__}: {original}", style="bold red") + ) + return Group(*parts) + + +def _frame_source(frame: traceback.FrameSummary, source_lines: list[str]) -> Optional[str]: + """Text of the frame's line, mapping exec'd `` frames to the block.""" + if frame.line: + return frame.line.strip() + if frame.filename == "" and 1 <= frame.lineno <= len(source_lines): + return source_lines[frame.lineno - 1].strip() + return None + + +def _render_frame(frame: traceback.FrameSummary, source_lines: list[str]) -> Text: + where = "your Python block" if frame.filename == "" else frame.filename + text = Text(" ") + text.append(where, style="cyan") + text.append(f", line {frame.lineno}, in {frame.name}", style="dim") + line = _frame_source(frame, source_lines) + if line: + text.append("\n ") + text.append(line, style="white") + return text + + +def _compact_frames( + frames: list[traceback.FrameSummary], source_lines: list[str] +) -> list[RenderableType]: + if len(frames) <= HEAD_FRAMES + TAIL_FRAMES: + return [_render_frame(f, source_lines) for f in frames] + + hidden = len(frames) - HEAD_FRAMES - TAIL_FRAMES + head = [_render_frame(f, source_lines) for f in frames[:HEAD_FRAMES]] + tail = [_render_frame(f, source_lines) for f in frames[-TAIL_FRAMES:]] + marker = Text(f" … {hidden} frame(s) hidden …", style="dim italic") + return [*head, marker, *tail] + + +def _failing_source_line( + frames: list[traceback.FrameSummary], source_lines: list[str] +) -> Optional[Text]: + """Highlight the author's own line that raised (the deepest `` frame).""" + string_frames = [f for f in frames if f.filename == ""] + if not string_frames: + return None + lineno = string_frames[-1].lineno + if not (1 <= lineno <= len(source_lines)): + return None + text = Text() + text.append(f"→ line {lineno}: ", style="bold yellow") + text.append(source_lines[lineno - 1].strip(), style="yellow") + return text diff --git a/src/ymprint/cli/main.py b/src/ymprint/cli/main.py index 68f2da3..bf48553 100644 --- a/src/ymprint/cli/main.py +++ b/src/ymprint/cli/main.py @@ -1,18 +1,23 @@ import subprocess import time +from datetime import datetime from typing import Optional, Annotated from pathlib import Path from .throbber import ThrobberState, FPS +from .error_display import format_authoring_error +from ..errors import YmprintAuthoringError +from rich import box from rich.text import Text -from rich.console import Console +from rich.console import Console, Group, RenderableType from rich.live import Live +from rich.panel import Panel +from rich.rule import Rule import typer from typer import Typer from ..report_reader import load_report from .config import locate_config_file from .okular import ensure_okular -from ..config.config_loaders import load_config_directory app = Typer(name='ymp', no_args_is_help=True) @@ -39,11 +44,42 @@ def changed(self) -> bool: return False -def build_display(state: ThrobberState, status: str) -> Text: - bar = state.render() - label = Text(f"\n {status}", style="dim white") - bar.append_text(label) - return bar +def build_live_panel( + state: ThrobberState, + watchers: list[FileWatcher], + lower: RenderableType, + border_style: str, +) -> Panel: + """Assemble the two-part live panel: watch list + throbber over a status area.""" + header = Text() + header.append("👁 ", style="bold") + header.append("YMPrint live", style="bold cyan") + header.append(" · hot-reloading", style="dim") + + files = Text() + for watcher in watchers: + files.append(" • ", style="dim") + files.append(f"{watcher.path.name}\n", style="cyan") + files.append(" ", style="dim") + files.append(str(watchers[0].path.resolve().parent), style="dim") + + upper = Group(header, Text(), files, Text(), state.render()) + body = Group(upper, Rule(style=border_style), lower) + + return Panel( + body, + title="[bold]✨ ymprint ✨[/bold]", + subtitle="[dim]Ctrl+C to quit[/dim]", + border_style=border_style, + box=box.ROUNDED, + padding=(1, 2), + ) + + +def _resolve_config(config_file: Optional[str], source: Path) -> Optional[Path]: + if config_file is not None: + return Path(config_file) + return locate_config_file(source.resolve().parent) @app.command( @@ -52,21 +88,35 @@ def build_display(state: ThrobberState, status: str) -> Text: no_args_is_help=True ) def convert( - src: str, - dest: str | None = None, + src: str, + dest: str | None = None, config_dir: str | None = None ): source = Path(src) destination = Path(dest) if dest is not None else None if destination is None: destination = source.parent / f"{source.stem}.pdf" - # Identify config files and content files to watch here - # ensure_demo_file() - if config_dir is None: - config_dir = locate_config_file(Path.cwd()) - load_report(source, destination, config_dir) + config_path = _resolve_config(config_dir, source) console = Console() + + try: + load_report(source, destination, config_path) + except YmprintAuthoringError as exc: + # This is a problem in the author's document, not an ymprint crash. Make + # that explicit and show the actionable, compact error. + console.print( + Panel( + format_authoring_error(exc), + title="[bold red]ymprint convert — error in your document[/bold red]", + subtitle="[dim]this is an error in the file you authored, not an ymprint bug[/dim]", + border_style="red", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + raise typer.Exit(code=1) + console.print( f"✍️ .... 📝 ... PDF created: {destination.resolve()}" ) @@ -88,20 +138,12 @@ def live( destination = source.parent / f"{source.stem}.pdf" else: destination = Path(dest) - # Identify config files and content files to watch here - # ensure_demo_file() - if config_file is None: - config_dir = locate_config_file(Path.cwd()) - - file_watchers = [FileWatcher(Path(source))] - if config_dir is not None: - config_dir = Path(config_dir) - for filename in CONFIG_FILENAMES: - if (config_dir / filename).exists(): - file_watchers.append(FileWatcher(config_dir / filename)) - else: - config_dir = source.parent - print(file_watchers) + + config_path = _resolve_config(config_file, source) + + file_watchers = [FileWatcher(source)] + if config_path is not None and config_path.is_file(): + file_watchers.append(FileWatcher(config_path)) console = Console() @@ -112,19 +154,29 @@ def live( raise typer.Exit(code=1) state = ThrobberState() - # watcher = FileWatcher(WATCH_FILE) frame_time = 1.0 / FPS - console.print( - f"\n[bold cyan]YMPrint live mode[/bold cyan] " - f"[dim]watching [white]{str(source)}[/white] — " - f"Ctrl+C to quit[/dim]\n" + def render() -> tuple[RenderableType, str]: + """Attempt a render; return the (lower panel, border colour) to show.""" + try: + load_report(source, destination, config_path) + except YmprintAuthoringError as exc: + state.trigger_error_explosion() + return format_authoring_error(exc), "red" + names = ", ".join(w.path.name for w in file_watchers) + stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return Text(f"✓ Reloaded {names} at {stamp}", style="green"), "green" + + # Initial render before opening the viewer. + lower, border = render() + okular_sub = subprocess.Popen( + [*okular_cmd, str(destination)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) - load_report(source, destination, config_dir) - okular_sub = subprocess.Popen([*okular_cmd, str(destination)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - status = "" + with Live( - build_display(state, status), + build_live_panel(state, file_watchers, lower, border), console=console, refresh_per_second=FPS, transient=False, @@ -132,22 +184,22 @@ def live( try: while True: t0 = time.monotonic() - for watcher in file_watchers: - if watcher.changed(): - state.trigger_explosion() - status = f"change detected in {str(watcher.path)}!" - load_report(source, destination, config_dir) - break - elif not state.explosions: - status = f"watching {(str(file_watchers))} …" + changed = next((w for w in file_watchers if w.changed()), None) + if changed is not None: + # A save clears any prior error immediately and shows the + # reload in progress before we attempt it. + state.trigger_explosion() + lower = Text(f"⟳ reloading ({changed.path.name}) …", style="yellow") + border = "yellow" + live.update(build_live_panel(state, file_watchers, lower, border)) + lower, border = render() state.advance() - live.update(build_display(state, status)) + live.update(build_live_panel(state, file_watchers, lower, border)) elapsed = time.monotonic() - t0 - sleep = max(0.0, frame_time - elapsed) - time.sleep(sleep) + time.sleep(max(0.0, frame_time - elapsed)) except KeyboardInterrupt: - console.print("\n[dim]Live mode ended.[/dim]\n") \ No newline at end of file + console.print("\n[dim]Live mode ended.[/dim]\n") diff --git a/src/ymprint/cli/throbber.py b/src/ymprint/cli/throbber.py index babe839..f27e2af 100644 --- a/src/ymprint/cli/throbber.py +++ b/src/ymprint/cli/throbber.py @@ -97,6 +97,11 @@ def trigger_explosion(self): colour = self.next_explosion_colour() self.explosions.append(Explosion(origin=origin_cell, colour=colour)) + def trigger_error_explosion(self): + """A vivid red burst used to signal a failed reload.""" + origin_cell = self.pos * (self.width - 1) + self.explosions.append(Explosion(origin=origin_cell, colour=(255, 40, 40))) + def advance(self): # Move the white wave head self.pos += self.direction * WAVE_SPEED diff --git a/src/ymprint/errors.py b/src/ymprint/errors.py new file mode 100644 index 0000000..c5fb653 --- /dev/null +++ b/src/ymprint/errors.py @@ -0,0 +1,60 @@ +"""Exception types for ymprint. + +These represent problems in the *author's document* — a YAML syntax mistake or +an exception raised by the author's own Python (`_py`) block — as opposed to a +bug inside ymprint itself. The CLI catches :class:`YmprintAuthoringError` and +presents it in a friendly, compact way; anything else is a genuine ymprint bug +and is allowed to propagate as a normal traceback. +""" +from __future__ import annotations + +import pathlib +from typing import Optional + + +class YmprintAuthoringError(Exception): + """Base class for errors caused by the author's document, not by ymprint.""" + + +class YamlSyntaxError(YmprintAuthoringError): + """A YAML file could not be parsed. + + Wraps a ``ruamel.yaml`` error, pulling out the line/column and a source + snippet where available so the author can jump straight to the problem. + """ + + def __init__(self, filepath: str | pathlib.Path, original: Exception): + self.filepath = pathlib.Path(filepath) + self.original = original + self.problem = str(getattr(original, "problem", "") or "").strip() + self.line: Optional[int] = None + self.column: Optional[int] = None + self.snippet: Optional[str] = None + + # ruamel's MarkedYAMLError exposes a `problem_mark` with 0-based + # line/column and a `get_snippet()` helper that renders a caret. + mark = getattr(original, "problem_mark", None) + if mark is not None: + self.line = mark.line + 1 + self.column = mark.column + 1 + try: + self.snippet = mark.get_snippet() + except Exception: + self.snippet = None + + super().__init__(self.problem or str(original)) + + +class PythonBlockError(YmprintAuthoringError): + """An author's Python (`_py`) block raised an exception during execution. + + Retains the block key and the author's source so the failing line can be + shown, plus the original exception (with its traceback) for a compact + top-and-bottom stack rendering. + """ + + def __init__(self, block_key: str, source: str, original: BaseException): + self.block_key = block_key + self.source = source + self.original = original + super().__init__(f"{type(original).__name__}: {original}") diff --git a/src/ymprint/report_reader.py b/src/ymprint/report_reader.py index 5ba8ebc..9a44fc9 100644 --- a/src/ymprint/report_reader.py +++ b/src/ymprint/report_reader.py @@ -56,7 +56,6 @@ def load_report(source_yaml: str | pathlib.Path, destination_pdf: str | pathlib. destination_pdf, report_config_path, ) - print(f"{doc_data=}") story = build_story(source_data, context) rl_doc, page_template_map = doctemplate.build(destination_pdf) rl_report_buffer = BytesIO() diff --git a/src/ymprint/yaml_loader.py b/src/ymprint/yaml_loader.py index 11390a7..da4997f 100644 --- a/src/ymprint/yaml_loader.py +++ b/src/ymprint/yaml_loader.py @@ -1,7 +1,10 @@ import collections from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError import pathlib +from .errors import YamlSyntaxError + yaml = YAML(typ='safe') def load_yaml(filepath: str | pathlib.Path) -> dict: @@ -9,5 +12,10 @@ def load_yaml(filepath: str | pathlib.Path) -> dict: Reads the Yaml document and returns the dict """ with open(filepath) as file: - data = yaml.load(file) - return data \ No newline at end of file + try: + data = yaml.load(file) + except YAMLError as e: + # Re-raise as an authoring error so the CLI can report *which* file + # and *where* the syntax problem is, instead of crashing. + raise YamlSyntaxError(filepath, e) from e + return data diff --git a/tests/test_cli_errors.py b/tests/test_cli_errors.py new file mode 100644 index 0000000..50b35c4 --- /dev/null +++ b/tests/test_cli_errors.py @@ -0,0 +1,103 @@ +"""Tests for graceful CLI error handling of authoring mistakes.""" +import pathlib +import traceback + +import pytest +from typer.testing import CliRunner + +from ymprint import yaml_loader +from ymprint.report_reader import load_report +from ymprint.errors import YamlSyntaxError, PythonBlockError, YmprintAuthoringError +from ymprint.blocks.python_block import convert_python_block +from ymprint.cli.error_display import format_authoring_error, _compact_frames +from ymprint.cli.main import app + +TEST_DATA = pathlib.Path(__file__).parent / "test-data" +runner = CliRunner() + + +# ── YAML syntax errors ──────────────────────────────────────────────────────── + +def test_bad_yaml_raises_authoring_error(tmp_path): + bad = tmp_path / "broken.yml" + bad.write_text("title:\n - item\n bad_indent: : oops\n") + with pytest.raises(YamlSyntaxError) as info: + yaml_loader.load_yaml(bad) + err = info.value + assert isinstance(err, YmprintAuthoringError) + assert err.filepath == bad + assert err.line is not None # line/column extracted from ruamel mark + + +def test_convert_reports_yaml_error_and_exits_nonzero(tmp_path): + bad = tmp_path / "broken.yml" + bad.write_text("title: [unclosed\n") + result = runner.invoke(app, ["convert", str(bad)]) + assert result.exit_code == 1 + assert "error in your document" in result.stdout + # The friendly panel names the file, not a raw Python traceback. + assert "Traceback (most recent call last)" not in result.stdout + + +# ── Python block errors ─────────────────────────────────────────────────────── + +def _run_py_block(source: str): + context = { + "vars": {}, + "frames": {"all_pages": {"width": 400}}, + "styles": {"ymprint": type("S", (), {"body": type("B", (), {"spacing": 1.1, "size": 10})()})()}, + } + return convert_python_block("_py", {"source": source, "echo": False}, context) + + +def test_python_block_error_wraps_original(tmp_path): + with pytest.raises(PythonBlockError) as info: + _run_py_block("x = 1\nraise ValueError('boom from author')\n") + err = info.value + assert isinstance(err, YmprintAuthoringError) + assert err.block_key == "_py" + assert isinstance(err.original, ValueError) + assert "raise ValueError" in err.source + + +def test_python_block_error_display_points_to_line(): + try: + _run_py_block("a = 1\nb = a / 0\n") + except PythonBlockError as err: + rendered = format_authoring_error(err) + from rich.console import Console + console = Console(width=100) + with console.capture() as cap: + console.print(rendered) + text = cap.get() + assert "ZeroDivisionError" in text + assert "line 2" in text # the failing author line + assert "your Python block" in text + else: + pytest.fail("expected PythonBlockError") + + +# ── Top-and-bottom truncation ───────────────────────────────────────────────── + +def test_compact_frames_truncates_deep_stacks(): + # Build a synthetic deep traceback. + def recurse(n): + if n == 0: + raise RuntimeError("deep") + recurse(n - 1) + + try: + recurse(20) + except RuntimeError as e: + frames = traceback.extract_tb(e.__traceback__) + + rendered = _compact_frames(frames, []) + from rich.console import Console + console = Console(width=100) + with console.capture() as cap: + for part in rendered: + console.print(part) + text = cap.get() + assert "frame(s) hidden" in text + # Fewer rendered items than total frames (head + marker + tail). + assert len(rendered) < len(frames) From 3c317baf5dc5ed11d39c7c76a81ce0c241a9ce94 Mon Sep 17 00:00:00 2001 From: Connor Ferster Date: Fri, 14 Aug 2026 03:26:46 +0000 Subject: [PATCH 2/2] feat: clearer reporting for SyntaxErrors in _py blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SyntaxError fails at compile time, so exec() produces no `` traceback frame — the previous formatter could only show the internal exec frame with no author context. Handle SyntaxError specially: - Use the exception's own .lineno/.text to show the offending code line - Detect the common cause of a `_py` block collapsing to a single line (writing `source:` instead of `source: |`, which folds the code) and emit an actionable hint to use a YAML block scalar - Clean up the final message (drop the redundant "(, line N)") Co-Authored-By: Claude Opus 4.8 --- src/ymprint/cli/error_display.py | 57 +++++++++++++++++++++++++------- tests/test_cli_errors.py | 29 ++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/ymprint/cli/error_display.py b/src/ymprint/cli/error_display.py index 8315aef..97825b9 100644 --- a/src/ymprint/cli/error_display.py +++ b/src/ymprint/cli/error_display.py @@ -48,25 +48,58 @@ def _format_yaml_error(exc: YamlSyntaxError) -> RenderableType: def _format_python_error(exc: PythonBlockError) -> RenderableType: original = exc.original - frames = traceback.extract_tb(original.__traceback__) - source_lines = exc.source.splitlines() - parts: list[RenderableType] = [ Text(f"Error in Python block '{exc.block_key}'", style="bold red") ] - failing = _failing_source_line(frames, source_lines) - if failing is not None: - parts.append(failing) - - parts.append(Text("Traceback (most relevant frames):", style="dim")) - parts.extend(_compact_frames(frames, source_lines)) - parts.append( - Text(f"{type(original).__name__}: {original}", style="bold red") - ) + if isinstance(original, SyntaxError): + # A SyntaxError fails at compile time, so there is no `` frame in + # the traceback. Use the exception's own line/offset instead, and detect + # the common cause: forgetting the `|` block scalar, which folds the code + # into a single line. + parts.extend(_syntax_error_parts(exc, original)) + message = original.msg + else: + frames = traceback.extract_tb(original.__traceback__) + source_lines = exc.source.splitlines() + failing = _failing_source_line(frames, source_lines) + if failing is not None: + parts.append(failing) + parts.append(Text("Traceback (most relevant frames):", style="dim")) + parts.extend(_compact_frames(frames, source_lines)) + message = str(original) + + parts.append(Text(f"{type(original).__name__}: {message}", style="bold red")) return Group(*parts) +def _syntax_error_parts( + exc: PythonBlockError, original: SyntaxError +) -> list[RenderableType]: + parts: list[RenderableType] = [] + single_line = "\n" not in exc.source.strip() + text = (original.text or "").rstrip("\n") + lineno = original.lineno or 1 + + if text: + line = Text() + line.append(f"→ line {lineno}: ", style="bold yellow") + line.append(text.strip(), style="yellow") + parts.append(line) + + if single_line: + # The code collapsed onto one line — almost always a missing block scalar. + parts.append( + Text( + "Hint: this block parsed as a single line. If the code was meant " + "to span multiple lines, use a YAML block scalar — write " + "'source: |' and indent the code beneath it.", + style="yellow", + ) + ) + return parts + + def _frame_source(frame: traceback.FrameSummary, source_lines: list[str]) -> Optional[str]: """Text of the frame's line, mapping exec'd `` frames to the block.""" if frame.line: diff --git a/tests/test_cli_errors.py b/tests/test_cli_errors.py index 50b35c4..d58ebb8 100644 --- a/tests/test_cli_errors.py +++ b/tests/test_cli_errors.py @@ -77,6 +77,35 @@ def test_python_block_error_display_points_to_line(): pytest.fail("expected PythonBlockError") +# ── SyntaxError from a `_py` block ──────────────────────────────────────────── + +def _render(exc): + from rich.console import Console + console = Console(width=100) + with console.capture() as cap: + console.print(format_authoring_error(exc)) + return cap.get() + + +def test_folded_source_gets_block_scalar_hint(): + # Simulates `source:` (no `|`): YAML folds the code onto a single line. + folded = "from math import pi a = pi b = a * 2" + with pytest.raises(PythonBlockError) as info: + _run_py_block(folded) + text = _render(info.value) + assert "SyntaxError" in text + assert "source: |" in text # actionable hint about the block scalar + + +def test_multiline_syntax_error_has_no_folding_hint(): + with pytest.raises(PythonBlockError) as info: + _run_py_block("x = 1\ny = (1 +\n") + text = _render(info.value) + assert "SyntaxError" in text + assert "line 2" in text + assert "source: |" not in text # multi-line source: not a folding mistake + + # ── Top-and-bottom truncation ───────────────────────────────────────────────── def test_compact_frames_truncates_deep_stacks():