Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PythonCall #185

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/QuartoNotebookWorker/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5"
PlotlyJS = "f0f68f2c-4968-5e81-91da-67840de0976a"
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
RCall = "6f49c342-dc21-5d91-9882-a32aef131414"
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
SymPyCore = "458b697b-88f0-4a86-b56b-78b75cfb3531"
Expand All @@ -35,6 +36,7 @@ QuartoNotebookWorkerMakieExt = "Makie"
QuartoNotebookWorkerPlotlyBaseExt = "PlotlyBase"
QuartoNotebookWorkerPlotlyJSExt = "PlotlyJS"
QuartoNotebookWorkerPlotsExt = "Plots"
QuartoNotebookWorkerPythonCallExt = "PythonCall"
QuartoNotebookWorkerRCallExt = "RCall"
QuartoNotebookWorkerReviseExt = "Revise"
QuartoNotebookWorkerSymPyCoreExt = "SymPyCore"
Expand Down
17 changes: 17 additions & 0 deletions src/QuartoNotebookWorker/ext/QuartoNotebookWorkerPythonCallExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module QuartoNotebookWorkerPythonCallExt

import QuartoNotebookWorker
import PythonCall

function __init__()
if ccall(:jl_generating_output, Cint, ()) == 0
#RCall_temp_files_ref[] = mktempdir()
#configure()
#QuartoNotebookWorker.add_package_loading_hook!(configure)
#QuartoNotebookWorker.add_package_refresh_hook!(refresh)
#QuartoNotebookWorker.add_post_eval_hook!(display_plots)
#QuartoNotebookWorker.add_post_error_hook!(cleanup_temp_files)
end
end

end
93 changes: 90 additions & 3 deletions src/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
terminal_line = 1
code_cells = false
for (node, enter) in ast
if enter && (is_julia_toplevel(node) || is_r_toplevel(node))
if enter &&
(is_julia_toplevel(node) || is_python_toplevel(node) || is_r_toplevel(node))
code_cells = true
line = node.sourcepos[1][1]
md = join(source_lines[terminal_line:(line-1)], "\n")
Expand All @@ -341,6 +342,7 @@ function raw_markdown_chunks_from_string(path::String, markdown::String)
end
language =
is_julia_toplevel(node) ? :julia :
is_python_toplevel(node) ? :python :
is_r_toplevel(node) ? :r : error("Unhandled code block language")
push!(
raw_chunks,
Expand Down Expand Up @@ -775,6 +777,28 @@ function evaluate_raw_cells!(
# We also need to hide the real code cell in this case, which contains possible formatting
# settings in its YAML front-matter and which can therefore not be omitted entirely.
cell_options["echo"] = false
elseif chunk.language === :python
# Same reasoning as :r
push!(
cells,
(;
id = string(
expand_cell ? string(nth, "_", mth) : string(nth),
"_code_prefix",
),
cell_type = :markdown,
metadata = (;),
source = process_cell_source(
"""
```python
$(strip_cell_options(chunk.source))
```
""",
Dict(),
),
),
)
cell_options["echo"] = false
end

source = expand_cell ? remote.code : chunk.source
Expand All @@ -793,16 +817,18 @@ function evaluate_raw_cells!(
end
end
elseif chunk.type === :markdown
marker = r"{(?:julia|r)} "
marker = r"{(?:julia|r|python)} "
source = chunk.source
if contains(chunk.source, r"`{(?:julia|r)} ")
if contains(chunk.source, r"`{(?:julia|r|python)} ")
parser = Parser()
for (node, enter) in parser(chunk.source)
if enter && node.t isa CommonMark.Code
if startswith(node.literal, marker)
source_code = replace(node.literal, marker => "")
if startswith(node.literal, "{r}")
source_code = wrap_with_r_boilerplate(source_code)
elseif startswith(node.literal, "{python}")
source_code = wrap_with_python_boilerplate(source_code)
end
expr = :(render(
$(source_code),
Expand Down Expand Up @@ -907,6 +933,60 @@ function strip_cell_options(source::AbstractString)
join(lines[keep_from:end])
end

function wrap_with_python_boilerplate(code)
"""
@isdefined(PythonCall) && PythonCall isa Module && Base.PkgId(PythonCall).uuid == Base.UUID("6099a3de-0909-46bc-b1f4-468b9a2dfc0d") || error("PythonCall must be imported to execute Python code cells with QuartoNotebookRunner")
let
code = "$code"

ast = PythonCall.pyimport("ast")
tree = ast.parse(code)

body = tree.body

result = nothing
if body !== nothing
for (i, node) in enumerate(body)
nodecode = PythonCall.pyconvert(String, ast.unparse(node))
if i < length(body)
PythonCall.pyexec(nodecode, Main.Notebook)
else
eval_allowed_nodes = (
ast.Expression, # A wrapper for expressions in eval context
ast.Expr,
ast.BinOp, # Binary operations like 1 + 1
ast.BoolOp, # Boolean operations like "and", "or"
ast.Call, # Function call like my_func()
ast.Compare, # Comparisons like a > b
ast.Constant, # Constants like numbers, strings (Python 3.8+)
ast.Dict, # Dictionary literals
ast.List, # List literals
ast.Name, # Variable names
ast.Set, # Set literals
ast.Tuple, # Tuple literals
ast.UnaryOp, # Unary operations like -1
ast.Lambda # Lambda functions
)
if any(t -> PythonCall.pyisinstance(node, t), eval_allowed_nodes)
result = PythonCall.pyeval(Any, nodecode, Main.Notebook)
else
PythonCall.pyexec(nodecode, Main.Notebook)
if PythonCall.pyisinstance(node, ast.Assign)
for target in node.targets
# TODO: how to know whether it's a single value or a one-element tuple?
# currently throwing away results 2 to n
result = PythonCall.pyeval(Any, ast.unparse(target), Main.Notebook)
end
end
end
end
end
end
result
end
"""
end
Comment on lines +936 to +988
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this be defined in a reuseable function within the worker package so that it doesn't have to be reparsed/reevaluated on every single cell?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah right now this is just prototype code I wanted to show, I'm sure most bits can be factored out


function wrap_with_r_boilerplate(code)
"""
@isdefined(RCall) && RCall isa Module && Base.PkgId(RCall).uuid == Base.UUID("6f49c342-dc21-5d91-9882-a32aef131414") || error("RCall must be imported to execute R code cells with QuartoNotebookRunner")
Expand All @@ -921,6 +1001,8 @@ function transform_source(chunk)
chunk.source
elseif chunk.language === :r
wrap_with_r_boilerplate(chunk.source)
elseif chunk.language === :python
wrap_with_python_boilerplate(chunk.source)
else
error("Unhandled code chunk language $(chunk.language)")
end
Expand Down Expand Up @@ -1064,6 +1146,11 @@ is_julia_toplevel(node) =
node.t.info == "{julia}" &&
node.parent.t isa CommonMark.Document

is_python_toplevel(node) =
node.t isa CommonMark.CodeBlock &&
node.t.info == "{python}" &&
node.parent.t isa CommonMark.Document

is_r_toplevel(node) =
node.t isa CommonMark.CodeBlock &&
node.t.info == "{r}" &&
Expand Down
20 changes: 20 additions & 0 deletions test/examples/integrations/PythonCall.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: PythonCall integration
julia:
exeflags: ["--project=PythonCall"]
---

This should fail since we didn't import PythonCall yet.

```{python}
import re
```

```{julia}
using PythonCall
```

```{python}
import re
words = re.findall("[a-zA-Z]+", "PythonCall.jl is very useful!")
```
2 changes: 2 additions & 0 deletions test/examples/integrations/PythonCall/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[deps]
PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
10 changes: 10 additions & 0 deletions test/testsets/integrations/PythonCall.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
include("../../utilities/prelude.jl")

test_example(joinpath(@__DIR__, "../../examples/integrations/PythonCall.qmd")) do json
cells = json["cells"]

@test occursin("PythonCall must be imported", cells[3]["outputs"][1]["traceback"][1])

@test cells[8]["data"]["outputs"][1]["text/plain"] ==
"Python: ['PythonCall', 'jl', 'is', 'very', 'useful']"
end
Loading