Skip to content

Commit 4bc5a98

Browse files
authored
Merge pull request #12238 from quarto-dev/jupyter/display-error
Add error handling for IPython display errors in notebook cell execution
2 parents d9f1872 + a0efdf9 commit 4bc5a98

File tree

13 files changed

+329
-38
lines changed

13 files changed

+329
-38
lines changed

news/changelog-1.7.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ All changes included in 1.7:
140140

141141
- ([#12114](https://github.com/quarto-dev/quarto-cli/issues/12114)): `JUPYTERCACHE` environment variable from [Jupyter cache CLI](https://jupyter-cache.readthedocs.io/en/latest/using/cli.html) is now respected by Quarto when `cache: true` is used. This environment variable allows to change the path of the cache directory.
142142
- ([#12374](https://github.com/quarto-dev/quarto-cli/issues/12374)): Detect language properly in Jupyter notebooks that lack the `language` field in their `kernelspec`s.
143+
- ([#12228](https://github.com/quarto-dev/quarto-cli/issues/12228)): `quarto render` will now fails if errors are detected at IPython display level. Setting `error: true` globally or at cell level will keep the error to show in output and not stop the rendering.
143144

144145
## Other Fixes and Improvements
145146

@@ -160,6 +161,7 @@ All changes included in 1.7:
160161
- ([#11803](https://github.com/quarto-dev/quarto-cli/pull/11803)): Added a new CLI command `quarto call`. First users of this interface are the new `quarto call engine julia ...` subcommands.
161162
- ([#12338](https://github.com/quarto-dev/quarto-cli/issues/12338)): Add an additional workaround for the SCSS parser used in color variable extraction.
162163
- ([#12369](https://github.com/quarto-dev/quarto-cli/pull/12369)): `quarto preview` correctly throws a YAML validation error when a `format` key does not conform.
164+
- ([#12238](https://gijit.com/quarto-dev/quarto-cli/issues/12238)): Very long error (e.g. in Jupyter Notenook with backtrace) are now no more truncated in the console.
163165

164166
## Languages
165167

src/core/log.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface LogMessageOptions {
3737
indent?: number;
3838
format?: (line: string) => string;
3939
colorize?: boolean;
40+
stripAnsiCode?: boolean;
4041
}
4142

4243
// deno-lint-ignore no-explicit-any
@@ -156,9 +157,23 @@ export class StdErrOutputHandler extends BaseHandler {
156157
return msg;
157158
}
158159
log(msg: string): void {
159-
Deno.stderr.writeSync(
160-
new TextEncoder().encode(msg),
161-
);
160+
const encoder = new TextEncoder();
161+
const data = encoder.encode(msg);
162+
163+
let bytesWritten = 0;
164+
while (bytesWritten < data.length) {
165+
// Write the remaining portion of the buffer
166+
const remaining = data.subarray(bytesWritten);
167+
const written = Deno.stderr.writeSync(remaining);
168+
169+
// If we wrote 0 bytes, something is wrong - avoid infinite loop
170+
if (written === 0) {
171+
// Could add fallback handling here if needed
172+
break;
173+
}
174+
175+
bytesWritten += written;
176+
}
162177
}
163178
}
164179

@@ -215,6 +230,7 @@ export class LogFileHandler extends FileHandler {
215230
...logRecord.args[0] as LogMessageOptions,
216231
bold: false,
217232
dim: false,
233+
stripAnsiCode: true,
218234
format: undefined,
219235
};
220236
let msg = applyMsgOptions(logRecord.msg, options);
@@ -403,7 +419,9 @@ function applyMsgOptions(msg: string, options: LogMessageOptions) {
403419
if (options.format) {
404420
msg = options.format(msg);
405421
}
406-
422+
if (options.stripAnsiCode) {
423+
msg = colors.stripAnsiCode(msg);
424+
}
407425
return msg;
408426
}
409427

src/resources/jupyter/jupyter.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from log import log_init, log, log_error, trace
2121
from notebook import notebook_execute, RestartKernel
22+
from nbclient.exceptions import CellExecutionError
2223

2324
import asyncio
2425
if sys.platform == 'win32':
@@ -226,21 +227,22 @@ def run_server_subprocess(options, status):
226227
# run a notebook directly (not a server)
227228
def run_notebook(options, status):
228229

229-
# run notebook w/ some special exception handling. note that we don't
230+
# run notebook w/ some special exception handling. note that we don't
230231
# log exceptions here b/c they are considered normal course of execution
231232
# for errors that occur in notebook cells
232-
try:
233+
try:
234+
trace('Running notebook_execute')
233235
notebook_execute(options, status)
234236
except Exception as e:
237+
trace(f'run_notebook caught exception: {type(e).__name__}')
235238
# CellExecutionError for execution at the terminal includes a bunch
236239
# of extra stack frames internal to this script. remove them
237240
msg = str(e)
238241
kCellExecutionError = "nbclient.exceptions.CellExecutionError: "
239242
loc = msg.find(kCellExecutionError)
240243
if loc != -1:
241-
msg = msg[loc + len(kCellExecutionError)]
242-
sys.stderr.write("\n\n" + msg + "\n")
243-
sys.stderr.flush()
244+
msg = msg[loc + len(kCellExecutionError):]
245+
status("\n\n" + msg + "\n")
244246
sys.exit(1)
245247

246248

src/resources/jupyter/log.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ def log(level, msg):
3737
def log(level, msg):
3838
logging.getLogger().log(level, msg)
3939

40-
def log_error(msg, exc_info = False):
41-
logging.getLogger().log(logging.ERROR, msg, exc_info = exc_info, stack_info = not exc_info)
40+
def log_error(msg, exc_info = False, stack_info = None):
41+
if stack_info is None:
42+
stack_info = not exc_info
43+
logging.getLogger().log(logging.ERROR, msg, exc_info = exc_info, stack_info = stack_info)
4244

4345
def trace(msg):
4446
prev_frame = inspect.stack()[1]

src/resources/jupyter/notebook.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,9 +517,11 @@ def cell_execute(client, cell, index, execution_count, eval_default, store_histo
517517

518518
# check options for eval and error
519519
eval = cell_options.get('eval', eval_default)
520-
allow_errors = cell_options.get('error', False)
520+
allow_errors = cell_options.get('error')
521521

522522
trace(f"cell_execute with eval={eval}")
523+
if (allow_errors == True):
524+
trace(f"cell_execute with allow_errors={allow_errors}")
523525

524526
# execute if eval is active
525527
if eval == True:
@@ -553,6 +555,27 @@ def cell_execute(client, cell, index, execution_count, eval_default, store_histo
553555
if len(cell["metadata"]["tags"]) == 0:
554556
del cell["metadata"]["tags"]
555557

558+
# Check for display errors in output (respecting both global and cell settings)
559+
cell_allows_errors = allow_errors if allow_errors is not None else client.allow_errors
560+
if not cell_allows_errors:
561+
trace("Cell does not allow errors: checking for uncaught errors")
562+
for output in cell.outputs:
563+
if output.get('output_type') == 'error':
564+
trace(" Uncaught error found in output")
565+
from nbclient.exceptions import CellExecutionError
566+
error_name = output.get('ename', 'UnnamedError')
567+
error_value = output.get('evalue', '')
568+
traceback = output.get('traceback', [])
569+
# Use same error raising mechanism as nbclient
570+
raise CellExecutionError.from_cell_and_msg(
571+
cell,
572+
{
573+
'ename': 'UncaughtCellError:' + error_name,
574+
'evalue': error_value,
575+
'traceback': traceback
576+
}
577+
)
578+
556579
# return cell
557580
return cell
558581

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.quarto_ipynb
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
title: test
3+
format: html
4+
_quarto:
5+
tests:
6+
html:
7+
shouldError: default
8+
postRenderCleanup:
9+
- '${input_stem}.quarto_ipynb'
10+
---
11+
12+
With default setting, this document should error at rendering because of an exception at IPython.display level.
13+
14+
By default `nbconvert` does not throw exception for error thrown by IPython display, on purpose as document output is still valid as there are other representation.
15+
16+
```{python}
17+
# First cell - create an object with a buggy _repr_markdown_ method
18+
class BuggyDisplay:
19+
def __init__(self):
20+
self.data = "This works fine"
21+
22+
def _repr_html_(self):
23+
# HTML representation used for `format: html`
24+
return "<b>HTML fallback:</b> " + self.data
25+
26+
def _repr_markdown_(self):
27+
# This error happens during display, not execution
28+
# even if the markdown reprensentation is not used
29+
raise ValueError("Display phase error!")
30+
31+
def __repr__(self):
32+
# This ensures the object has a string representation
33+
return self.data
34+
35+
# Create the object
36+
buggy = BuggyDisplay()
37+
```
38+
39+
```{python}
40+
buggy
41+
```
42+
43+
```{python}
44+
print("Execution continued despite display error")
45+
```
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
title: test
3+
format: html
4+
_quarto:
5+
tests:
6+
html:
7+
ensureHtmlElementContents:
8+
selectors: ['div.cell-output-error']
9+
matches: ['ValueError\: Display phase error for HTML']
10+
noMatches: []
11+
---
12+
13+
With `error: true` in cell, this document should not error at rendering and Exception at IPython.display level should be shown in output.
14+
15+
By default `nbconvert` does not throw exception for error thrown by IPython display, on purpose as document output is still valid as there are other representation.
16+
17+
```{python}
18+
# First cell - create an object with a buggy _repr_html_ method
19+
class BuggyDisplay:
20+
def __init__(self):
21+
self.data = "This works fine"
22+
23+
def _repr_html_(self):
24+
# This error happens during display, not execution
25+
raise ValueError("Display phase error for HTML!")
26+
27+
def _repr_markdown_(self):
28+
# Markdown representation as fallback when HTML fails
29+
return "**Markdown fallback:** " + self.data
30+
31+
def __repr__(self):
32+
# This ensures the object has a string representation
33+
return self.data
34+
35+
# Create the object
36+
buggy = BuggyDisplay()
37+
```
38+
39+
```{python}
40+
#| error: true
41+
buggy
42+
```
43+
44+
```{python}
45+
print("Execution continued despite display error")
46+
```
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
title: test
3+
format: html
4+
execute:
5+
error: true
6+
_quarto:
7+
tests:
8+
html:
9+
shouldError: default
10+
postRenderCleanup:
11+
- '${input_stem}.quarto_ipynb'
12+
---
13+
14+
With `error: true`, and `error: false` at cell level, this document should error at rendering.
15+
16+
By default `nbconvert` does not throw exception for error thrown by IPython display, on purpose as document output is still valid as there are other representation.
17+
18+
```{python}
19+
class BuggyDisplay:
20+
def __init__(self):
21+
self.data = "This works fine"
22+
23+
def _repr_html_(self):
24+
# HTML representation used for `format: html`
25+
return "<b>HTML fallback:</b> " + self.data
26+
27+
def _repr_markdown_(self):
28+
# This error happens during display, not execution
29+
# even if the markdown reprensentation is not used
30+
raise ValueError("Display phase error for Markdown!")
31+
32+
def __repr__(self):
33+
# This ensures the object has a string representation
34+
return self.data
35+
36+
# Create the object
37+
buggy = BuggyDisplay()
38+
```
39+
40+
```{python}
41+
#| error: false
42+
buggy
43+
```
44+
45+
```{python}
46+
print("Execution continued despite display error")
47+
```
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
title: test
3+
format: html
4+
execute:
5+
error: true
6+
_quarto:
7+
tests:
8+
html:
9+
ensureHtmlElementContents:
10+
selectors: ['div.cell-output-error']
11+
matches: ['ValueError\: Display phase error for Markdown']
12+
---
13+
14+
With `error: true` this document should not error at rendering and Exception at IPython.display level should be shown in output.
15+
16+
By default `nbconvert` does not throw exception for error thrown by IPython display, on purpose as document output is still valid as there are other representation.
17+
18+
```{python}
19+
# First cell - create an object with a buggy _repr_markdown_ method
20+
class BuggyDisplay:
21+
def __init__(self):
22+
self.data = "This works fine"
23+
24+
def _repr_html_(self):
25+
# HTML representation used for `format: html`
26+
return "<b>HTML fallback:</b> " + self.data
27+
28+
def _repr_markdown_(self):
29+
# This error happens during display, not execution
30+
# even if the markdown reprensentation is not used
31+
raise ValueError("Display phase error for Markdown!")
32+
33+
def __repr__(self):
34+
# This ensures the object has a string representation
35+
return self.data
36+
37+
# Create the object
38+
buggy = BuggyDisplay()
39+
```
40+
41+
```{python}
42+
buggy
43+
```
44+
45+
```{python}
46+
print("Execution continued despite display error")
47+
```

0 commit comments

Comments
 (0)