Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion IPython/core/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,9 @@ def _dir_hist_default(self) -> list[Path]:
# execution count.
output_hist = Dict()
# The text/plain repr of outputs.
output_hist_reprs: dict[int, str] = Dict() # type: ignore [assignment]
output_hist_reprs: typing.Dict[int, str] = Dict() # type: ignore [assignment]
output_mime_bundles = Dict() # Maps execution_count to MIME bundles
exceptions = Dict() # Maps execution_count to exception tracebacks

# The number of the current session in the history database
session_number: int = Integer() # type: ignore [assignment]
Expand Down Expand Up @@ -749,6 +751,9 @@ def reset(self, new_session: bool = True) -> None:
"""Clear the session history, releasing all object references, and
optionally open a new session."""
self.output_hist.clear()
self.output_mime_bundles.clear()
self.exceptions.clear()

# The directory history can't be completely empty
self.dir_hist[:] = [Path.cwd()]

Expand Down
6 changes: 3 additions & 3 deletions IPython/core/historyapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ def start(self):
print("There are already at most %d entries in the history database." % self.keep)
print("Not doing anything. Use --keep= argument to keep fewer entries")
return

print("Trimming history to the most recent %d entries." % self.keep)

inputs.pop() # Remove the extra element we got to check the length.
inputs.reverse()
if inputs:
Expand All @@ -71,7 +71,7 @@ def start(self):
sessions = list(con.execute('SELECT session, start, end, num_cmds, remark FROM '
'sessions WHERE session >= ?', (first_session,)))
con.close()

# Create the new history database.
new_hist_file = profile_dir / "history.sqlite.new"
i = 0
Expand Down
49 changes: 48 additions & 1 deletion IPython/core/interactiveshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -3200,6 +3200,11 @@ async def run_cell_async(

def error_before_exec(value):
if store_history:
if self.history_manager:
# Store formatted traceback and error details
self.history_manager.exceptions[self.execution_count] = (
self.format_exception_for_storage(value)
)
self.execution_count += 1
result.error_before_exec = value
self.last_execution_succeeded = False
Expand Down Expand Up @@ -3309,12 +3314,54 @@ def error_before_exec(value):
assert self.history_manager is not None
# Write output to the database. Does nothing unless
# history output logging is enabled.
self.history_manager.store_output(self.execution_count)
hm = self.history_manager
hm.store_output(self.execution_count)
exec_count = self.execution_count

if result.result:
# Format the result into a MIME bundle
try:
mime_obj = (
result.result[0]
if isinstance(result.result, list)
else result.result
)
Copy link
Member

@krassowski krassowski Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single block of code may produce multiple outputs:

image

Currently the display pub objects are ignored:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To capture the result passed to display() we might want to also populate output_mime_bundles in:

for mime, handler in handlers.items():
if mime in data:
handler(data[mime], metadata.get(mime, None))
return
if "text/plain" in data:
print(data["text/plain"])

I am not yet 100% sure if this is the best place, but seems fine for first iteration.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm facing a slight issue while capturing mime_bundles here.

When only display is called, the MIME output is shown before execution_count is updated. However, when both an output and display are present, the output appears first, then execution_count is updated, and only after that is the MIME output displayed.

How can we determine whether the MIME bundle should be associated with the current or previous execution_count in such cases?

In the screenshot below, in both cases <Figure size ...> and 1, self.shell.execution_count is 5.

Screenshot from 2025-02-28 21-35-56

mime_fig = mime_obj.get_figure()
mime_bundle, _ = self.display_formatter.format(mime_fig)
except:
# In case formatting fails, fallback to text/plain
mime_bundle = {"text/plain": repr(result.result)}
hm.output_mime_bundles[exec_count] = mime_bundle
if result.error_in_exec:
# Store formatted traceback and error details
hm.exceptions[exec_count] = self.format_exception_for_storage(
result.error_in_exec
)

# Each cell is a *single* input, regardless of how many lines it has
self.execution_count += 1

return result

def format_exception_for_storage(self, exception):
"""
Format an exception's traceback and details for storage in exceptions.
"""
etype = type(exception)
evalue = exception
tb = exception.__traceback__

if isinstance(exception, SyntaxError):
# Use SyntaxTB for syntax errors
stb = self.SyntaxTB.structured_traceback(etype, evalue, tb)
else:
# Use InteractiveTB for other exceptions, skipping IPython's internal frame
stb = self.InteractiveTB.structured_traceback(
etype, evalue, tb, tb_offset=1
)

return {"ename": etype.__name__, "evalue": str(evalue), "traceback": stb}

def transform_cell(self, raw_cell):
"""Transform an input cell before parsing it.

Expand Down
45 changes: 36 additions & 9 deletions IPython/core/magics/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class MagicsDisplay:
def __init__(self, magics_manager, ignore=None):
self.ignore = ignore if ignore else []
self.magics_manager = magics_manager

def _lsmagic(self):
"""The main implementation of the %lsmagic"""
mesc = magic_escapes['line']
Expand All @@ -39,13 +39,13 @@ def _lsmagic(self):

def _repr_pretty_(self, p, cycle):
p.text(self._lsmagic())

def __repr__(self):
return self.__str__()

def __str__(self):
return self._lsmagic()

def _jsonable(self):
"""turn magics dict into jsonable dict of the same structure

Expand All @@ -62,10 +62,10 @@ def _jsonable(self):
classname = obj.__self__.__class__.__name__
except AttributeError:
classname = 'Other'

d[name] = classname
return magic_dict

def _repr_json_(self):
return self._jsonable()

Expand Down Expand Up @@ -561,13 +561,40 @@ def notebook(self, s):

cells = []
hist = list(self.shell.history_manager.get_range())
output_mime_bundles = self.shell.history_manager.output_mime_bundles
exceptions = self.shell.history_manager.exceptions

if(len(hist)<=1):
raise ValueError('History is empty, cannot export')
for session, execution_count, source in hist[:-1]:
cells.append(v4.new_code_cell(
execution_count=execution_count,
source=source
))
cell = v4.new_code_cell(execution_count=execution_count, source=source)
# Check if this execution_count is in exceptions (current session)
if execution_count in output_mime_bundles:
mime_bundle = output_mime_bundles[execution_count]
for mime_type, data in mime_bundle.items():
if mime_type == "text/plain":
cell.outputs.append(
v4.new_output(
"execute_result",
data={mime_type: data},
execution_count=execution_count,
)
)
else:
cell.outputs.append(
v4.new_output(
"display_data",
data={mime_type: data},
)
)

# Check if this execution_count is in exceptions (current session)
if execution_count in exceptions:
cell.outputs.append(
v4.new_output("error", **exceptions[execution_count])
)
cells.append(cell)

nb = v4.new_notebook(cells=cells)
with io.open(outfname, "w", encoding="utf-8") as f:
write(nb, f, version=4)
Expand Down
Loading