Skip to content

Commit e414d49

Browse files
Merge pull request #10 from StructuralPython/features/cli-error-handling
feat: graceful CLI error handling for YAML and Python-block errors
2 parents fce4eeb + 3c317ba commit e414d49

9 files changed

Lines changed: 471 additions & 59 deletions

File tree

src/ymprint/blocks/python_block.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from reportlab.platypus import Table, KeepTogether
22
from . import register_block
33
from .code_block_styles import python_code_block
4+
from ..errors import PythonBlockError
45
from typing import Callable
56

67

@@ -19,7 +20,12 @@ def convert_python_block(block_key: str, block_value: dict, context: dict) -> li
1920
if namespace is not None:
2021
context['vars'][namespace] = {}
2122
local_namespace = context['vars'][namespace] if namespace is not None else context['vars']
22-
exec(source, globals=context['vars'], locals=local_namespace)
23+
try:
24+
exec(source, globals=context['vars'], locals=local_namespace)
25+
except Exception as e:
26+
# The author's own code raised — surface it as an authoring error that
27+
# keeps the source and traceback for a compact, actionable report.
28+
raise PythonBlockError(block_key, source, e) from e
2329
if block_value.get("echo", True):
2430
code_block = python_code_block(source, available_width * width_ratio, context, caption=caption, show_line_numbers=line_numbers)
2531
code_block.spaceBefore = space_around

src/ymprint/cli/config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33

44

55
def locate_config_file(cwd: Path) -> Optional[Path]:
6-
config_file = None
7-
for parent in cwd.parents:
8-
filenames = [path.name for path in parent.glob("*.ymprint.yml")]
9-
if filenames:
10-
config_file = filenames[0]
11-
return config_file
6+
"""Return the nearest ``*.ymprint.yml`` at or above ``cwd`` as a full path."""
7+
for parent in [cwd, *cwd.parents]:
8+
matches = sorted(parent.glob("*.ymprint.yml"))
9+
if matches:
10+
return matches[0]
11+
return None
1212

1313

src/ymprint/cli/error_display.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Render :class:`YmprintAuthoringError` instances as compact, friendly output.
2+
3+
Python-block tracebacks are truncated to their *top and bottom*: the first few
4+
frames (the calling context — which block triggered the failure) and the last
5+
few frames (the exact line that blew up), with the middle collapsed. This keeps
6+
even a deep stack readable while still telling the author both *where their
7+
document caused it* and *what to fix*.
8+
"""
9+
from __future__ import annotations
10+
11+
import traceback
12+
from typing import Optional
13+
14+
from rich.console import Group, RenderableType
15+
from rich.text import Text
16+
17+
from ..errors import PythonBlockError, YamlSyntaxError, YmprintAuthoringError
18+
19+
# How many stack frames to keep from each end before collapsing the middle.
20+
HEAD_FRAMES = 2
21+
TAIL_FRAMES = 3
22+
23+
24+
def format_authoring_error(exc: YmprintAuthoringError) -> RenderableType:
25+
"""Return a rich renderable describing an authoring error."""
26+
if isinstance(exc, YamlSyntaxError):
27+
return _format_yaml_error(exc)
28+
if isinstance(exc, PythonBlockError):
29+
return _format_python_error(exc)
30+
return Text(str(exc), style="red")
31+
32+
33+
def _format_yaml_error(exc: YamlSyntaxError) -> RenderableType:
34+
location = exc.filepath.name
35+
if exc.line is not None:
36+
location += f", line {exc.line}, column {exc.column}"
37+
38+
parts: list[RenderableType] = [
39+
Text(f"YAML syntax error in {location}", style="bold red")
40+
]
41+
if exc.problem:
42+
parts.append(Text(exc.problem, style="red"))
43+
if exc.snippet:
44+
parts.append(Text(exc.snippet, style="yellow"))
45+
parts.append(Text("Fix the YAML above, then save to reload.", style="dim italic"))
46+
return Group(*parts)
47+
48+
49+
def _format_python_error(exc: PythonBlockError) -> RenderableType:
50+
original = exc.original
51+
parts: list[RenderableType] = [
52+
Text(f"Error in Python block '{exc.block_key}'", style="bold red")
53+
]
54+
55+
if isinstance(original, SyntaxError):
56+
# A SyntaxError fails at compile time, so there is no `<string>` frame in
57+
# the traceback. Use the exception's own line/offset instead, and detect
58+
# the common cause: forgetting the `|` block scalar, which folds the code
59+
# into a single line.
60+
parts.extend(_syntax_error_parts(exc, original))
61+
message = original.msg
62+
else:
63+
frames = traceback.extract_tb(original.__traceback__)
64+
source_lines = exc.source.splitlines()
65+
failing = _failing_source_line(frames, source_lines)
66+
if failing is not None:
67+
parts.append(failing)
68+
parts.append(Text("Traceback (most relevant frames):", style="dim"))
69+
parts.extend(_compact_frames(frames, source_lines))
70+
message = str(original)
71+
72+
parts.append(Text(f"{type(original).__name__}: {message}", style="bold red"))
73+
return Group(*parts)
74+
75+
76+
def _syntax_error_parts(
77+
exc: PythonBlockError, original: SyntaxError
78+
) -> list[RenderableType]:
79+
parts: list[RenderableType] = []
80+
single_line = "\n" not in exc.source.strip()
81+
text = (original.text or "").rstrip("\n")
82+
lineno = original.lineno or 1
83+
84+
if text:
85+
line = Text()
86+
line.append(f"→ line {lineno}: ", style="bold yellow")
87+
line.append(text.strip(), style="yellow")
88+
parts.append(line)
89+
90+
if single_line:
91+
# The code collapsed onto one line — almost always a missing block scalar.
92+
parts.append(
93+
Text(
94+
"Hint: this block parsed as a single line. If the code was meant "
95+
"to span multiple lines, use a YAML block scalar — write "
96+
"'source: |' and indent the code beneath it.",
97+
style="yellow",
98+
)
99+
)
100+
return parts
101+
102+
103+
def _frame_source(frame: traceback.FrameSummary, source_lines: list[str]) -> Optional[str]:
104+
"""Text of the frame's line, mapping exec'd `<string>` frames to the block."""
105+
if frame.line:
106+
return frame.line.strip()
107+
if frame.filename == "<string>" and 1 <= frame.lineno <= len(source_lines):
108+
return source_lines[frame.lineno - 1].strip()
109+
return None
110+
111+
112+
def _render_frame(frame: traceback.FrameSummary, source_lines: list[str]) -> Text:
113+
where = "your Python block" if frame.filename == "<string>" else frame.filename
114+
text = Text(" ")
115+
text.append(where, style="cyan")
116+
text.append(f", line {frame.lineno}, in {frame.name}", style="dim")
117+
line = _frame_source(frame, source_lines)
118+
if line:
119+
text.append("\n ")
120+
text.append(line, style="white")
121+
return text
122+
123+
124+
def _compact_frames(
125+
frames: list[traceback.FrameSummary], source_lines: list[str]
126+
) -> list[RenderableType]:
127+
if len(frames) <= HEAD_FRAMES + TAIL_FRAMES:
128+
return [_render_frame(f, source_lines) for f in frames]
129+
130+
hidden = len(frames) - HEAD_FRAMES - TAIL_FRAMES
131+
head = [_render_frame(f, source_lines) for f in frames[:HEAD_FRAMES]]
132+
tail = [_render_frame(f, source_lines) for f in frames[-TAIL_FRAMES:]]
133+
marker = Text(f" … {hidden} frame(s) hidden …", style="dim italic")
134+
return [*head, marker, *tail]
135+
136+
137+
def _failing_source_line(
138+
frames: list[traceback.FrameSummary], source_lines: list[str]
139+
) -> Optional[Text]:
140+
"""Highlight the author's own line that raised (the deepest `<string>` frame)."""
141+
string_frames = [f for f in frames if f.filename == "<string>"]
142+
if not string_frames:
143+
return None
144+
lineno = string_frames[-1].lineno
145+
if not (1 <= lineno <= len(source_lines)):
146+
return None
147+
text = Text()
148+
text.append(f"→ line {lineno}: ", style="bold yellow")
149+
text.append(source_lines[lineno - 1].strip(), style="yellow")
150+
return text

0 commit comments

Comments
 (0)