Repository: PumasAI/QuartoNotebookRunner.jl (julia)

Issue:
Support notebook parameters
https://quarto.org/docs/computations/parameters.html

Pull request description:
Fixes #36 

Dicts are merged recursively, so one could use dicts as parameters and override only some of their values via CLI. I think that matches how we do merging in other places.

Example `parameters.qmd`:

````md
---
engine: julia
title: Parameters
params:
  a: 1
  b: [1, 2, 3]
---

a is `{julia} a` and b is `{julia} b`

```{julia}
a .+ b
```
````

Then `quarto render parameters.qmd --to=html -P a:2`:

<img width="835" alt="image" src="https://github.com/PumasAI/QuartoNotebookRunner.jl/assets/22495855/7fb893ab-19e4-4ef2-95af-274b5ad18642">

Patch:
diff --git a/src/server.jl b/src/server.jl
index d033adfc..b4a0a85f 100644
--- a/src/server.jl
+++ b/src/server.jl
@@ -171,6 +171,8 @@ function _extract_relevant_options(file_frontmatter::Dict, options::Dict)
 
     julia_default = get(file_frontmatter, "julia", nothing)
 
+    params_default = get(file_frontmatter, "params", Dict{String,Any}())
+
     if isempty(options)
         return _options_template(;
             fig_width = fig_width_default,
@@ -181,6 +183,7 @@ function _extract_relevant_options(file_frontmatter::Dict, options::Dict)
             pandoc_to = pandoc_to_default,
             julia = julia_default,
             daemon = daemon_default,
+            params = params_default,
         )
     else
         format = get(D, options, "format")
@@ -199,6 +202,13 @@ function _extract_relevant_options(file_frontmatter::Dict, options::Dict)
         julia = get(metadata, "julia", Dict())
         julia_merged = _recursive_merge(julia_default, julia)
 
+        # quarto stores params in two places currently, in `format.metadata.params` we have params specified in the front matter
+        # and in top-level `params` we have the parameters via command-line arguments.
+        # In case quarto decides to unify this behavior later, we probably can stop merging these on our side.
+        # Cf. https://github.com/quarto-dev/quarto-cli/issues/9197
+        params = get(metadata, "params", Dict())
+        cli_params = get(options, "params", Dict())
+        params_merged = _recursive_merge(params_default, params, cli_params)
 
         return _options_template(;
             fig_width,
@@ -209,6 +219,7 @@ function _extract_relevant_options(file_frontmatter::Dict, options::Dict)
             pandoc_to,
             julia = julia_merged,
             daemon,
+            params = params_merged,
         )
     end
 end
@@ -222,6 +233,7 @@ function _options_template(;
     pandoc_to,
     julia,
     daemon,
+    params,
 )
     D = Dict{String,Any}
     return D(
@@ -237,6 +249,7 @@ function _options_template(;
             "pandoc" => D("to" => pandoc_to),
             "metadata" => D("julia" => julia),
         ),
+        "params" => D(params),
     )
 end
 
@@ -578,6 +591,8 @@ function evaluate_raw_cells!(
     chunk_callback = (i, n, c) -> nothing,
 )
     refresh!(f, options)
+    evaluate_params!(f, options["params"])
+
     cells = []
 
     error_metadata = NamedTuple{(:kind, :file, :traceback),Tuple{Symbol,String,String}}[]
@@ -825,6 +840,25 @@ function evaluate_raw_cells!(
     return cells
 end
 
+function evaluate_params!(f, params::Dict{String})
+    invalid_param_keys = filter(!Base.isidentifier, keys(params))
+    if !isempty(invalid_param_keys)
+        plu = length(invalid_param_keys) > 1
+        throw(
+            ArgumentError(
+                "Found parameter key$(plu ? "s that are not " : " that is not a ") valid Julia identifier$(plu ? "s" : ""): $(join((repr(k) for k in invalid_param_keys), ", ", " and ")).",
+            ),
+        )
+    end
+
+    exprs = map(collect(pairs(params))) do (key, value)
+        :(@eval getfield(Main, :Notebook) $(Symbol(key::String)) = $value)
+    end
+    expr = Expr(:block, exprs...)
+    Malt.remote_eval_fetch(f.worker, expr)
+    return
+end
+
 # All but the last line of a cell should contain a newline character to end it.
 # The optional `cell_options` argument is a dictionary of cell attributes which
 # are written into the processed cell source when the cell is the result of an


Test patch:
diff --git a/test/examples/parameters.qmd b/test/examples/parameters.qmd
new file mode 100644
index 00000000..4979e1bb
--- /dev/null
+++ b/test/examples/parameters.qmd
@@ -0,0 +1,43 @@
+---
+params:
+    a: 1
+    b: 2.0
+    c: some string
+    d: "some other string"
+    e: ["string", "array"]
+    f: [1, 2, 3]
+    g: {a: 1, b: 2}
+---
+
+```{julia}
+a
+```
+
+```{julia}
+b
+```
+
+```{julia}
+c
+```
+
+```{julia}
+d
+```
+
+```{julia}
+print(repr(e))
+```
+
+```{julia}
+print(repr(f))
+```
+
+```{julia}
+g["a"]
+```
+
+```{julia}
+g["b"]
+```
+
diff --git a/test/testsets/parameters.jl b/test/testsets/parameters.jl
new file mode 100644
index 00000000..2ca47670
--- /dev/null
+++ b/test/testsets/parameters.jl
@@ -0,0 +1,53 @@
+include("../utilities/prelude.jl")
+
+test_example(joinpath(@__DIR__, "../examples/parameters.qmd")) do json
+    cells = json["cells"]
+
+    @test cells[2]["outputs"][1]["data"]["text/plain"] == "1"
+    @test cells[4]["outputs"][1]["data"]["text/plain"] == "2.0"
+    @test cells[6]["outputs"][1]["data"]["text/plain"] == "\"some string\""
+    @test cells[8]["outputs"][1]["data"]["text/plain"] == "\"some other string\""
+    @test cells[10]["outputs"][1]["text"] == "[\"string\", \"array\"]"
+    @test cells[12]["outputs"][1]["text"] == "[1, 2, 3]"
+    @test cells[14]["outputs"][1]["data"]["text/plain"] == "1"
+    @test cells[16]["outputs"][1]["data"]["text/plain"] == "2"
+end
+
+@testset "parameters via options" begin
+    s = Server()
+    options = Dict{String,Any}(
+        "params" => Dict("a" => 7, "c" => "cli override"),
+        "format" => Dict(
+            "metadata" =>
+                Dict("params" => Dict("a" => 5, "b" => 6.0, "g" => Dict("a" => 4))),
+        ),
+    )
+    json = QuartoNotebookRunner.run!(
+        s,
+        joinpath(@__DIR__, "../examples/parameters.qmd");
+        options,
+    )
+
+    cells = json.cells
+
+    @test cells[2].outputs[1].data["text/plain"] == "7"
+    @test cells[4].outputs[1].data["text/plain"] == "6.0"
+    @test cells[6].outputs[1].data["text/plain"] == "\"cli override\""
+    @test cells[8].outputs[1].data["text/plain"] == "\"some other string\""
+    @test cells[10].outputs[1].text == "[\"string\", \"array\"]"
+    @test cells[12].outputs[1].text == "[1, 2, 3]"
+    @test cells[14].outputs[1].data["text/plain"] == "4"
+    @test cells[16].outputs[1].data["text/plain"] == "2"
+end
+
+@testset "Invalid parameters" begin
+    s = Server()
+    options = Dict{String,Any}("params" => Dict("invalid identifier" => 7))
+    @test_throws ArgumentError(
+        "Found parameter key that is not a  valid Julia identifier: \"invalid identifier\".",
+    ) QuartoNotebookRunner.run!(
+        s,
+        joinpath(@__DIR__, "../examples/parameters.qmd");
+        options,
+    )
+end