Skip to content

Commit dc978f1

Browse files
authored
Fix %notebook magic creating multiple display_data outputs for single widgets (ipython#15001)
## Fixes ipython#14989 ### Description The `%notebook` magic was incorrectly creating separate `display_data` outputs for each MIME type from a single widget, resulting in duplicate plots and widgets in exported notebooks. The fix collects all MIME types from a single `display_data` output into one data dictionary, ensuring that widgets with multiple representations (`text/plain`, `text/html`, `image/png`, etc.) are exported as a single `display_data` entry in the notebook. *Before* ```json { "outputs": [ { "output_type": "display_data", "data": {"text/plain": "test"}, "metadata": {} }, { "output_type": "display_data", "data": {"text/html": "<div>test</div>"}, "metadata": {} } ] } ``` *After* ```json { "outputs": [ { "output_type": "display_data", "data": { "text/plain": "test", "text/html": "<div>test</div>" }, "metadata": {} } ] } ``` This resolves the multiple plots issue and makes the exported notebook structure match the expected format where all MIME types for a single output are grouped together.
2 parents 9cbf5f2 + 02805df commit dc978f1

2 files changed

Lines changed: 95 additions & 24 deletions

File tree

IPython/core/magics/basic.py

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -568,35 +568,58 @@ def notebook(self, s):
568568

569569
if(len(hist)<=1):
570570
raise ValueError('History is empty, cannot export')
571+
571572
for session, execution_count, source in hist[:-1]:
572573
cell = v4.new_code_cell(execution_count=execution_count, source=source)
574+
573575
for output in outputs[execution_count]:
574-
for mime_type, data in output.bundle.items():
575-
if output.output_type == "out_stream":
576-
text = data if isinstance(data, list) else [data]
577-
cell.outputs.append(v4.new_output("stream", text=text))
578-
elif output.output_type == "err_stream":
579-
text = data if isinstance(data, list) else [data]
580-
err_output = v4.new_output("stream", text=text)
581-
err_output.name = "stderr"
582-
cell.outputs.append(err_output)
583-
elif output.output_type == "execute_result":
584-
cell.outputs.append(
585-
v4.new_output(
586-
"execute_result",
587-
data={mime_type: data},
588-
execution_count=execution_count,
589-
)
576+
if output.output_type in {"out_stream", "err_stream"}:
577+
text_data = []
578+
for mime_type, data in output.bundle.items():
579+
if isinstance(data, list):
580+
text_data.extend(data)
581+
else:
582+
text_data.append(data)
583+
full_text = "".join(text_data)
584+
# Replace literal \n with actual newlines
585+
full_text = full_text.replace("\\n", "\n")
586+
normalized_text = []
587+
lines = full_text.split("\n")
588+
for i, line in enumerate(lines):
589+
if i < len(lines) - 1:
590+
normalized_text.append(line + "\n")
591+
elif line: # Last line only if it's not empty
592+
normalized_text.append(line + "\n")
593+
stream_output = v4.new_output("stream", text=normalized_text)
594+
if output.output_type == "err_stream":
595+
stream_output.name = "stderr"
596+
cell.outputs.append(stream_output)
597+
598+
elif output.output_type == "execute_result":
599+
data_dict = {}
600+
for mime_type, data in output.bundle.items():
601+
data_dict[mime_type] = data
602+
cell.outputs.append(
603+
v4.new_output(
604+
"execute_result",
605+
data=data_dict,
606+
execution_count=execution_count,
590607
)
591-
elif output.output_type == "display_data":
592-
cell.outputs.append(
593-
v4.new_output(
594-
"display_data",
595-
data={mime_type: data},
596-
)
608+
)
609+
610+
elif output.output_type == "display_data":
611+
# Collect all MIME types for this display_data into a single output
612+
data_dict = {}
613+
for mime_type, data in output.bundle.items():
614+
data_dict[mime_type] = data
615+
cell.outputs.append(
616+
v4.new_output(
617+
"display_data",
618+
data=data_dict,
597619
)
598-
else:
599-
raise ValueError(f"Unknown output type: {output.output_type}")
620+
)
621+
else:
622+
raise ValueError(f"Unknown output type: {output.output_type}")
600623

601624
# Check if this execution_count is in exceptions (current session)
602625
if execution_count in exceptions:

tests/test_magic.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
register_line_magic,
3636
)
3737
from IPython.core.magics import code, execution, logging, osm, script
38+
from IPython.core.history import HistoryOutput
3839
from IPython.testing import decorators as dec
3940
from IPython.testing import tools as tt
4041
from IPython.utils.io import capture_output
@@ -1094,6 +1095,53 @@ def test_notebook_export_json_with_output():
10941095
_ip.colors = "nocolor"
10951096

10961097

1098+
def test_notebook_export_single_display():
1099+
"""Test that multiple MIME types create a single display_data output, not multiple."""
1100+
pytest.importorskip("nbformat")
1101+
1102+
_ip = get_ipython()
1103+
orig_outputs = _ip.history_manager.outputs.copy()
1104+
orig_execution_count = _ip.execution_count
1105+
_ip.history_manager.reset()
1106+
1107+
try:
1108+
execution_count = _ip.execution_count = 1
1109+
_ip.run_cell("'test'", store_history=True, silent=False)
1110+
1111+
# Mock display output with multiple MIME types
1112+
test_display_history = HistoryOutput(
1113+
output_type="display_data",
1114+
bundle={"text/plain": "test", "text/html": "<div>test</div>"},
1115+
)
1116+
_ip.history_manager.outputs[execution_count] = [test_display_history]
1117+
1118+
with TemporaryDirectory() as td:
1119+
outfile = f"{td}/test.ipynb"
1120+
_ip.run_cell(f"%notebook {outfile}", store_history=True, silent=False)
1121+
1122+
# Verify single display_data output with both MIME types
1123+
with open(outfile, "r") as f:
1124+
nb = json.load(f)
1125+
1126+
cell = nb["cells"][0]
1127+
display_outputs = [
1128+
out for out in cell["outputs"] if out["output_type"] == "display_data"
1129+
]
1130+
1131+
assert (
1132+
len(display_outputs) == 1
1133+
), f"Expected 1 display_data output, got {len(display_outputs)}"
1134+
1135+
output_data = display_outputs[0]["data"]
1136+
assert set(output_data.keys()) == {"text/plain", "text/html"}
1137+
assert output_data["text/plain"] == ["test"]
1138+
assert output_data["text/html"] == ["<div>test</div>"]
1139+
1140+
finally:
1141+
_ip.history_manager.outputs = orig_outputs
1142+
_ip.execution_count = orig_execution_count
1143+
1144+
10971145
class TestEnv(TestCase):
10981146
def test_env(self):
10991147
env = _ip.run_line_magic("env", "")

0 commit comments

Comments
 (0)