diff --git a/README.md b/README.md index a57eb4b..6986eeb 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,130 @@ This is achieved by using Julia's native package extension mechanism. You can find all the current package integrations in the `src/QuartoNotebookWorker/ext` folder. Typically this is done via adding function hooks within the `__init__` method of the extension that run at different points during notebook execution. + +### Package Extensions + +As discussed above `QuartoNotebookWorker` is implemented as a full Julia package +rather than just a `Module` loaded into the worker processes. This allows for +any package to extend the functionality provided by the worker via Julia's +[package extension mechanism][package-extensions]. For example, given a package +called `PackageName` you could create a new package extension in +`PackageName/ext/PackageNameQuartoNotebookWorkerExt.jl` with contents + +[package-extensions]: https://pkgdocs.julialang.org/v1/creating-packages/#Conditional-loading-of-code-in-packages-(Extensions) + +```julia +module PackageNameQuartoNotebookWorkerExt + +import PackageName +import QuartoNotebookWorker + +# ... Extension code here ... + +end +``` + +and update the `Project.toml` file for the `PackageName` package to include the +following extension configuration: + +```toml +[weakdeps] +QuartoNotebookWorker = "38328d9c-a911-4051-bc06-3f7f556ffeda" + +[extensions] +PackageNameQuartoNotebookWorkerExt = "QuartoNotebookWorker" +``` + +With these additions whenever `PackageName` is loaded into a `.qmd` file that is +being run with `engine: julia` the extension code in the +`PackageNameQuartoNotebookWorkerExt` module will be loaded. Below are the +available interfaces that are can be extended. + +#### `expand` + +The `expand` function is used to inform `QuartoNotebookWorker` that a specific +Julia type should not be rendered and instead should be converted into a series +of notebook cells that are themselves evaluated and rendered. This allows for +notebooks to generate a dynamic number of cells based on runtime information +computed within the notebook rather than just the static cells of the original +notebook source. + +The below example shows how to create a `Replicate` type that will be expanded +into `n` cells of the same value. + +```julia +module PackageNameQuartoNotebookWorkerExt + +import PackageName +import QuartoNotebookWorker + +function QuartoNotebookWorker.expand(r::PackageName.Replicate) + # Return a list of notebook `Cell`s to be rendered. + return [QuartoNotebookWorker.Cell(r.value) for _ in 1:r.n] +end + +end +``` + +Where `PackageName` itself defines the `Replicate` type as + +```julia +module PackageName + +export Replicate + +struct Replicate + value + n::Int +end + +end +``` + +The `Cell` type takes a value, which can be any Julia type. If it is a +`Function` then the result of the `Cell` will be the result of calling the +`value()`, including any printing to `stdout` and `stderr` that may occur during +the call. If it is any other type then the result of the `Cell` will be the +value itself. + +> [!NOTE] +> +> To return a `Function` itself as the output of the `Cell` you can wrap it +> with `Returns(func)`, which will then not call `func`. + +Optional `code` keyword allows fake source code for the cell to be set, which +will be rendered by `quarto`. Note that the source code is never parsed or +evaluated. Additionally the `options` keyword allows for defining cell options +that will be passed to `quarto` to control cell rendering such as captions, +layout, etc. + +Within a `.qmd` file you can then use the `Replicate` type as follows: + +````qmd +```{julia} +using PackageName +``` + +Generate two cells that each output `"Hello"` as their returned value. + +```{julia} +Replicate("Hello", 2) +``` + +Next we generate three cells that each push the current `DateTime` to a shared +`state` vector, print `"World"` to `stdout` and then return the entire `state` +for rendering. The `echo: false` option is used to suppress the output of the +original cell itself. + +```{julia} +#| echo: false +import Dates +let state = [] + Replicate(3) do + push!(state, Dates.now()) + println("World") + return state + end +end +``` +```` diff --git a/src/QuartoNotebookWorker/src/QuartoNotebookWorker.jl b/src/QuartoNotebookWorker/src/QuartoNotebookWorker.jl index 11766c9..395e208 100644 --- a/src/QuartoNotebookWorker/src/QuartoNotebookWorker.jl +++ b/src/QuartoNotebookWorker/src/QuartoNotebookWorker.jl @@ -1,5 +1,11 @@ module QuartoNotebookWorker +# Exports: + +export Cell +export expand + + walk(x, _, outer) = outer(x) walk(x::Expr, inner, outer) = outer(Expr(x.head, map(inner, x.args)...)) postwalk(f, x) = walk(x, x -> postwalk(f, x), f) @@ -83,6 +89,7 @@ include("InlineDisplay.jl") include("NotebookState.jl") include("NotebookInclude.jl") include("refresh.jl") +include("cell_expansion.jl") include("render.jl") include("utilities.jl") include("ojs_define.jl") diff --git a/src/QuartoNotebookWorker/src/cell_expansion.jl b/src/QuartoNotebookWorker/src/cell_expansion.jl new file mode 100644 index 0000000..b9c4dc7 --- /dev/null +++ b/src/QuartoNotebookWorker/src/cell_expansion.jl @@ -0,0 +1,86 @@ +""" + struct Cell + Cell(content; code = nothing, options = Dict()) + +`content` is either a callable object, which should have a 0-argument method +which, when called, represents the evaluation of the cell, or any other +non-callable object, which will simply be treated as the output value of the +cell. Should you wish to use a callable object as the output of a cell, rather +than calling it, you can wrap it in a `Returns` object. + +`code` is the mock source code of the cell, and is not parsed or evaluated, but +will be rendered in the final output generated by `quarto`. When `code` is not +provided then the code block is hidden with `echo: false`. `options` represents +the cell options for the mock cell and will impact the rendering of the final +output. +""" +struct Cell + thunk::Base.Callable + code::String + options::Dict{String,Any} + + function Cell(object; code::Union{String,Nothing} = nothing, options::Dict = Dict()) + thunk = _make_thunk(object) + if isnothing(code) + options["echo"] = false + code = "" + end + new(thunk, code, options) + end +end + +_make_thunk(c::Base.Callable) = c +# Returns is only available on 1.7 and upwards. +_make_thunk(other) = @static @isdefined(Returns) ? Returns(other) : () -> other + +""" + expand(object::T) -> Vector{Cell} + +Define the vector of `Cell`s that an object of type `T` should expand to. See +`Cell` documentation for more details on `Cell` creation. + +This function is meant to be extended by 3rd party packages using Julia's +package extension mechanism. For example, within the `Project.toml` of a package +called `ExamplePackage` add the following: + +```toml +[weakdeps] +QuartoNotebookWorker = "38328d9c-a911-4051-bc06-3f7f556ffeda" + +[extensions] +ExamplePackageQuartoNotebookWorkerExt = "QuartoNotebookWorker" +``` + +Then create a file `ext/ExamplePackageQuartoNotebookWorkerExt.jl` with the the contents + +```julia +module ExamplePackageQuartoNotebookWorkerExt + +import ExamplePackage +import QuartoNotebookWorker + +function QuartoNotebookWorker.expand(obj::ExamplePackage.ExampleType) + return [ + QuartoNotebookWorker.Cell("This is the cell result."; code = "# Mock code goes here."), + QuartoNotebookWorker.Cell("This is a second cell."), + ] +end + +end +``` +""" +expand(@nospecialize(_)) = nothing + +struct CellExpansionError <: Exception + message::String +end + +_is_expanded(@nospecialize(_), ::Nothing) = false +_is_expanded(@nospecialize(_), ::Vector{Cell}) = true +function _is_expanded(@nospecialize(original), @nospecialize(result)) + throw( + CellExpansionError( + "invalid cell expansion result for `expand(::$(typeof(original)))`. Expected the result to be a `Vector{Cell}`, got `$(typeof(result))`.", + ), + ) +end diff --git a/src/QuartoNotebookWorker/src/render.jl b/src/QuartoNotebookWorker/src/render.jl index 0e8b17d..8b10639 100644 --- a/src/QuartoNotebookWorker/src/render.jl +++ b/src/QuartoNotebookWorker/src/render.jl @@ -4,9 +4,16 @@ function render( line::Integer, cell_options::AbstractDict = Dict{String,Any}(), ) - return Base.@invokelatest( + # This records whether the outermost cell is an expandable cell, which we + # then return to the server so that it can decide whether to treat the cell + # results it gets back as an expansion or not. We can't decide this + # statically since expansion depends on whether the runtime type of the cell + # output is `is_expandable` or not. Recursive calls to `_render_thunk` don't + # matter to the server, it's just the outermost cell that matters. + is_expansion_ref = Ref(false) + result = Base.@invokelatest( collect( - _render_thunk(code, cell_options) do + _render_thunk(code, cell_options, is_expansion_ref) do Base.@invokelatest include_str( NotebookState.notebook_module(), code; @@ -16,6 +23,7 @@ function render( end, ) ) + return (result, is_expansion_ref[]) end # Recursively render cell thunks. This might be an `include_str` call, @@ -26,133 +34,53 @@ function _render_thunk( thunk::Base.Callable, code::AbstractString, cell_options::AbstractDict = Dict{String,Any}(), + is_expansion_ref::Ref{Bool} = Ref(false), ) captured, display_results = with_inline_display(thunk, cell_options) - if get(cell_options, "expand", false) === true - if captured.error + + # Attempt to expand the cell. This requires the cell result to have a method + # defined for the `QuartoNotebookWorker.expand` function. We only attempt to + # run expansion if the cell didn't error. Cell expansion can itself error, + # so we need to catch that and return an error cell if that's the case. + expansion = nothing + is_expansion = false + if !captured.error + try + expansion = expand(captured.value) + is_expansion = _is_expanded(captured.value, expansion) + catch error + backtrace = catch_backtrace() return ((; code = "", # an expanded cell that errored can't have returned code cell_options = Dict{String,Any}(), # or options results = Dict{String,@NamedTuple{error::Bool, data::Vector{UInt8}}}(), display_results, output = captured.output, - error = string(typeof(captured.value)), + error = string(typeof(error)), backtrace = collect( - eachline( - IOBuffer( - clean_bt_str( - captured.error, - captured.backtrace, - captured.value, - ), - ), - ), + eachline(IOBuffer(clean_bt_str(true, backtrace, error))), ), ),) - else - function invalid_return_value_cell( - errmsg; - code = "", - cell_options = Dict{String,Any}(), - ) - return ((; - code, - cell_options, - results = Dict{String,@NamedTuple{error::Bool, data::Vector{UInt8}}}(), - display_results, - output = captured.output, - error = "Invalid return value for expanded cell", - backtrace = collect(eachline(IOBuffer(errmsg))), - ),) - end + end + # Track in this side-channel whether the cell is an expansion or not. + is_expansion_ref[] = is_expansion + end - if !(Base.@invokelatest Base.isiterable(typeof(captured.value))) - return invalid_return_value_cell( - """ - Return value of a cell with `expand: true` is not iterable. - The returned value must iterate objects that each have a `thunk` - property which contains a function that returns the cell output. - Instead, the returned value was: - $(repr(captured.value)) - """, + if is_expansion + # A cell expansion with `expand` might itself also contain + # cells that expand to multiple cells, so we need to flatten + # the results to a single list of cells before passing back + # to the server. Cell expansion is recursive. + return _flatmap(expansion) do cell + wrapped = function () + return QuartoNotebookWorker.Packages.IOCapture.capture( + cell.thunk; + rethrow = InterruptException, + color = true, ) end - - # A cell expansion with `expand` might itself also contain - # cells that expand to multiple cells, so we need to flatten - # the results to a single list of cells before passing back - # to the server. Cell expansion is recursive. - return _flatmap(enumerate(captured.value)) do (i, cell) - - code = _getproperty(cell, :code, "") - options = _getproperty(Dict{String,Any}, cell, :options) - - if !(code isa String) - return invalid_return_value_cell( - """ - While iterating over the elements of the return value of a cell with - `expand: true`, a value was found at position $i which has a `code` property - that is not of the expected type `String`. The value was: - $(repr(cell.code)) - """, - ) - end - - if !(options isa Dict{String}) - return invalid_return_value_cell( - """ - While iterating over the elements of the return value of a cell with - `expand: true`, a value was found at position $i which has a `options` property - that is not of the expected type `Dict{String}`. The value was: - $(repr(cell.options)) - """; - code, - ) - end - - if !hasproperty(cell, :thunk) - return invalid_return_value_cell( - """ - While iterating over the elements of the return value of a cell with - `expand: true`, a value was found at position $i which does not have a - `thunk` property. Every object in the iterator returned from an expanded - cell must have a property `thunk` with a function that returns - the output of the cell. - The object without a `thunk` property was: - $(repr(cell)) - """; - code, - cell_options = options, - ) - end - - if !(cell.thunk isa Base.Callable) - return invalid_return_value_cell( - """ - While iterating over the elements of the return value of a cell with - `expand: true` a value was found at position $i which has a `thunk` - property that is not a function of type `Base.Callable`. - Every object in the iterator returned from an expanded - cell must have a property `thunk` with a function that returns - the output of the cell. Instead, the returned value was: - $(repr(cell.thunk)) - """; - code, - cell_options = options, - ) - end - - wrapped = function () - return QuartoNotebookWorker.Packages.IOCapture.capture( - cell.thunk; - rethrow = InterruptException, - color = true, - ) - end - - # **The recursive call:** - return Base.@invokelatest _render_thunk(wrapped, code, options) - end + # **The recursive call:** + return Base.@invokelatest _render_thunk(wrapped, cell.code, cell.options) end else results = Base.@invokelatest render_mimetypes( diff --git a/src/server.jl b/src/server.jl index 518b656..4b771bd 100644 --- a/src/server.jl +++ b/src/server.jl @@ -612,12 +612,9 @@ function evaluate_raw_cells!( @maybe_progress showprogress "$header" for (nth, chunk) in enumerate(chunks) if chunk.type === :code - expand_cell = get(chunk.cell_options, "expand", false) === true - # When we're not evaluating the code, or when there is an `expand` - # cell output then we immediately splice in the cell code. The - # results of evaluating an `expand` cell are added later on and are - # not considered direct outputs of this cell. - if !chunk.evaluate || expand_cell + if !chunk.evaluate + # Cells that are not evaluated are not executed, but they are + # still included in the notebook. push!( cells, (; @@ -626,12 +623,10 @@ function evaluate_raw_cells!( metadata = (;), source = process_cell_source(chunk.source), outputs = [], - execution_count = chunk.evaluate ? 1 : 0, + execution_count = 0, ), ) - end - - if chunk.evaluate + else chunk_callback(ith_chunk_to_evaluate, chunks_to_evaluate, chunk) ith_chunk_to_evaluate += 1 @@ -646,7 +641,26 @@ function evaluate_raw_cells!( $(chunk.cell_options), )) - for (mth, remote) in enumerate(Malt.remote_eval_fetch(f.worker, expr)) + worker_results, expand_cell = Malt.remote_eval_fetch(f.worker, expr) + + # When the result of the cell evaluation is a cell expansion + # then we insert the original cell contents before the expanded + # cells as a mock cell similar to if it has `eval: false` set. + if expand_cell + push!( + cells, + (; + id = string(nth), + cell_type = chunk.type, + metadata = (;), + source = process_cell_source(chunk.source), + outputs = [], + execution_count = 1, + ), + ) + end + + for (mth, remote) in enumerate(worker_results) outputs = [] processed = process_results(remote.results) @@ -800,7 +814,10 @@ function evaluate_raw_cells!( # There should only ever be a single result from an # inline evaluation since you can't pass cell # options and so `expand` will always be `false`. - remote = only(Malt.remote_eval_fetch(f.worker, expr)) + worker_results, expand_cell = + Malt.remote_eval_fetch(f.worker, expr) + expand_cell && error("inline code cells cannot be expanded") + remote = only(worker_results) if !isnothing(remote.error) # file location is not straightforward to determine with inline literals, but just printing the (presumably short) # code back instead of a location should be quite helpful diff --git a/test/examples/cell_expansion.qmd b/test/examples/cell_expansion.qmd index ab166cb..2bec5fd 100644 --- a/test/examples/cell_expansion.qmd +++ b/test/examples/cell_expansion.qmd @@ -2,68 +2,66 @@ title: Cell Expansion --- +```{julia} +import QuartoNotebookWorker: Cell, expand +``` + +```{julia} +struct CustomStruct + content::Any + + function CustomStruct(content) + new([ + Cell( + n.thunk; + code = get(n, :code, nothing), + options = get(n, :options, Dict()), + ) for n in content + ]) + end +end +expand(cs::CustomStruct) = cs.content +``` + ```{julia} #| echo: false -#| expand: true -[(; - thunk = function () - println("print call") - display("display call") - "return value" - end, - code = """ - # Fake code goes here. - """, - options = Dict("layout-ncol" => 2), -)] +CustomStruct([ + (; + thunk = function () + println("print call") + display("display call") + "return value" + end, + code = """ + # Fake code goes here. + """, + options = Dict("layout-ncol" => 2), + ), +]) ``` ```{julia} -#| expand: true -[ +CustomStruct([ (; thunk = function () - return [ + return CustomStruct([ (; thunk = function () - return [(; thunk = () -> 1, options = Dict("layout-ncol" => 1))] + return CustomStruct([(; thunk = () -> 1, options = Dict("layout-ncol" => 1))]) end, - options = Dict("expand" => true), ), (; thunk = function () - return [(; thunk = () -> (display(2); 2)), (; thunk = () -> 3)] + return CustomStruct([(; thunk = () -> (display(2); 2)), (; thunk = () -> 3)]) end, - options = Dict("expand" => true), ), - ] + ]) end, - options = Dict("expand" => true), ), (; thunk = () -> 4), (; thunk = () -> println("## Header"), options = Dict("output" => "asis", "echo" => false), ), -] -``` - -```{julia} -#| expand: true - -# test if newly defined structs can be returned as iterators (requires certain -# invokelatest calls to be present) -struct QuartoCell - thunk::Base.Callable - options::Dict{String,Any} - code::String -end - -Base.iterate(c::QuartoCell) = Base.iterate(cells(c)) -Base.iterate(c::QuartoCell, state) = Base.iterate(cells(c), state) -Base.IteratorSize(c::QuartoCell) = Base.SizeUnknown() - -cells(q::QuartoCell) = (q,) - -QuartoCell(() -> 123, Dict(), "") +]) ``` diff --git a/test/examples/cell_expansion_errors.qmd b/test/examples/cell_expansion_errors.qmd index d085797..e901972 100644 --- a/test/examples/cell_expansion_errors.qmd +++ b/test/examples/cell_expansion_errors.qmd @@ -5,58 +5,41 @@ execute: --- ```{julia} -#| expand: true - -"a" + "b" +import QuartoNotebookWorker: Cell, expand ``` ```{julia} -#| expand: true +struct CustomStruct + content::Any +end +expand(cs::CustomStruct) = [ + Cell(c.thunk; code = get(c, :code, nothing), options = get(c, :options, Dict())) for + c in cs.content +] +``` -[ +```{julia} +CustomStruct([ (; thunk = () -> "no problem here"), (; thunk = function () - return [ + return CustomStruct([ (; thunk = function () - return [(; thunk = () -> error("a nested thunk error"))] + return CustomStruct([(; thunk = () -> error("a nested thunk error"))]) end, - options = Dict("expand" => true), ), - ] + ]) end, - options = Dict("expand" => true), ), -] +]) ``` ```{julia} -#| expand: true - -[(; thunk = "not a function")] -``` - -```{julia} -#| expand: true - -:not_an_iterable +struct BrokenStruct end +expand(bs::BrokenStruct) = "broken" ``` ```{julia} -#| expand: true - -[(; no_thunk_here = "")] -``` - -```{julia} -#| expand: true - -[(; thunk = () -> 123, code = :invalid_code)] -``` - -```{julia} -#| expand: true - -[(; thunk = () -> 123, options = Dict(:invalid => "options"))] +BrokenStruct() ``` diff --git a/test/testsets/cell_expansion.jl b/test/testsets/cell_expansion.jl index 8b0f70f..98c0bd3 100644 --- a/test/testsets/cell_expansion.jl +++ b/test/testsets/cell_expansion.jl @@ -1,7 +1,8 @@ include("../utilities/prelude.jl") test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json - @test length(json["cells"]) == 13 + cells = json["cells"] + @test length(cells) == 14 cell = json["cells"][1] @test cell["cell_type"] == "markdown" @@ -9,12 +10,12 @@ test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json @test contains(cell["source"][1], "\n") @test !contains(cell["source"][end], "\n") - cell = json["cells"][2] + cell = json["cells"][6] @test cell["cell_type"] == "code" @test cell["execution_count"] == 1 @test isempty(cell["outputs"]) - cell = json["cells"][3] + cell = json["cells"][7] @test cell["cell_type"] == "code" @test cell["execution_count"] == 1 output = cell["outputs"][1] @@ -34,41 +35,41 @@ test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json @test source[1] == "#| layout-ncol: 2\n" @test source[2] == "# Fake code goes here." - cell = json["cells"][5] + cell = json["cells"][9] @test cell["cell_type"] == "code" @test cell["execution_count"] == 1 @test isempty(cell["outputs"]) - cell = json["cells"][6] - @test cell["id"] == "4_1" + cell = json["cells"][10] + @test cell["id"] == "8_1" source = cell["source"] @test any(line -> contains(line, "#| layout-ncol: 1"), source) @test length(cell["outputs"]) == 1 @test cell["outputs"][1]["output_type"] == "execute_result" @test cell["outputs"][1]["data"]["text/plain"] == "1" - cell = json["cells"][7] - @test cell["id"] == "4_2" + cell = json["cells"][11] + @test cell["id"] == "8_2" @test length(cell["outputs"]) == 2 @test cell["outputs"][1]["output_type"] == "display_data" @test cell["outputs"][1]["data"]["text/plain"] == "2" @test cell["outputs"][2]["output_type"] == "execute_result" @test cell["outputs"][2]["data"]["text/plain"] == "2" - cell = json["cells"][8] - @test cell["id"] == "4_3" + cell = json["cells"][12] + @test cell["id"] == "8_3" @test length(cell["outputs"]) == 1 @test cell["outputs"][1]["output_type"] == "execute_result" @test cell["outputs"][1]["data"]["text/plain"] == "3" - cell = json["cells"][9] - @test cell["id"] == "4_4" + cell = json["cells"][13] + @test cell["id"] == "8_4" @test length(cell["outputs"]) == 1 @test cell["outputs"][1]["output_type"] == "execute_result" @test cell["outputs"][1]["data"]["text/plain"] == "4" - cell = json["cells"][10] - @test cell["id"] == "4_5" + cell = json["cells"][14] + @test cell["id"] == "8_5" @test cell["cell_type"] == "code" @test cell["execution_count"] == 1 @test cell["outputs"][1]["output_type"] == "stream" @@ -77,43 +78,17 @@ test_example(joinpath(@__DIR__, "../examples/cell_expansion.qmd")) do json source = cell["source"] @test any(line -> contains(line, "#| output: \"asis\""), source) @test any(line -> contains(line, "#| echo: false"), source) - - cell = json["cells"][13] - @test cell["outputs"][1]["data"]["text/plain"] == "123" end test_example(joinpath(@__DIR__, "../examples/cell_expansion_errors.qmd")) do json cells = json["cells"] - @test any(x -> occursin("MethodError", x), cells[3]["outputs"][]["traceback"]) - - @test cells[6]["outputs"][]["data"]["text/plain"] == "\"no problem here\"" - - @test any(x -> occursin("a nested thunk error", x), cells[7]["outputs"][]["traceback"]) - - cell = cells[10] - @test cell["outputs"][]["ename"] == "Invalid return value for expanded cell" - @test any( - x -> occursin("not a function of type `Base.Callable`", x), - cell["outputs"][]["traceback"], - ) - - cell = cells[13] - @test cell["outputs"][]["ename"] == "Invalid return value for expanded cell" - @test any(x -> occursin("is not iterable", x), cell["outputs"][]["traceback"]) + cell = cells[8] + @test any(x -> occursin("a nested thunk error", x), cell["outputs"][]["traceback"]) - cell = cells[16] - @test cell["outputs"][]["ename"] == "Invalid return value for expanded cell" + cell = cells[12] @test any( - x -> occursin("must have a property `thunk`", x), + x -> occursin("invalid cell expansion result", x), cell["outputs"][]["traceback"], ) - - cell = cells[19] - @test cell["outputs"][]["ename"] == "Invalid return value for expanded cell" - @test any(x -> occursin("`code` property", x), cell["outputs"][]["traceback"]) - - cell = cells[22] - @test cell["outputs"][]["ename"] == "Invalid return value for expanded cell" - @test any(x -> occursin("`options` property", x), cell["outputs"][]["traceback"]) end