From fee74c935960146227f88eb1ca5956c50179363a Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 14:07:40 +0100 Subject: [PATCH 1/9] add pyrepl --- CHANGELOG.md | 3 +++ docs/src/pythoncall-reference.md | 3 ++- src/API/exports.jl | 1 + src/API/functions.jl | 1 + src/Core/Core.jl | 1 + src/Core/builtins.jl | 21 +++++++++++++++++++++ 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0aedc8..6ca0eb21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Unreleased +* Add `pyrepl` to launch a Python REPL. + ## 0.9.35 (2026-06-08) * Add option `lib` to JuliaCall. Setting this will skip the discovery subprocess. * Add support for using a system image in `juliacall` that has `PythonCall` baked in. diff --git a/docs/src/pythoncall-reference.md b/docs/src/pythoncall-reference.md index f09d5f12..1bbce0c7 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -211,11 +211,12 @@ ispy(x::MyType) = true Py(x::MyType) = x.py ``` -## `@py` and `@pyconst` +## `@py`, `@pyconst` and `pyrepl` ```@docs @py @pyconst +pyrepl ``` ## Multi-threading diff --git a/src/API/exports.jl b/src/API/exports.jl index 3d476182..019d0119 100644 --- a/src/API/exports.jl +++ b/src/API/exports.jl @@ -82,6 +82,7 @@ export pypos export pypow export pyprint export pyrange +export pyrepl export pyrepr export pyrowlist export pyrshift diff --git a/src/API/functions.jl b/src/API/functions.jl index ab1258c8..cc96a141 100644 --- a/src/API/functions.jl +++ b/src/API/functions.jl @@ -86,6 +86,7 @@ function pypos end function pypow end function pyprint end function pyrange end +function pyrepl end function pyrepr end function pyrowlist end function pyrshift end diff --git a/src/Core/Core.jl b/src/Core/Core.jl index 1d0773c0..637ee47a 100644 --- a/src/Core/Core.jl +++ b/src/Core/Core.jl @@ -119,6 +119,7 @@ import ..PythonCall: pypow, pyprint, pyrange, + pyrepl, pyrepr, pyrowlist, pyrshift, diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 10b05c8c..b3f65222 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1427,6 +1427,27 @@ macro pyexec(arg) end end +""" + pyrepl(locals) + +Run a Python REPL, for interacting directly with Python. + +Runs in a scope defined by `locals`. As with [`pyeval`](@ref), if you pass a module, +then a persistent scope for that module is used. Otherwise you must pass a Python +`dict`. +""" +function pyrepl(locals) + if ispy(locals) + locals = Py(locals) + elseif locals isa Module + locals = get!(pydict, MODULE_GLOBALS, locals) + else + error("locals must be a Module or a Python dict") + end + pyimport("code").interact(banner="", exitmsg="", var"local"=locals) + return +end + ### with """ From 93e358bc421b1146242a669d7ac10b34501212eb Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:02:50 +0100 Subject: [PATCH 2/9] add pyrepl style kwarg with support for ipython, bpython and ptpython --- src/Core/builtins.jl | 57 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index b3f65222..99852a78 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1428,15 +1428,26 @@ macro pyexec(arg) end """ - pyrepl(locals) + pyrepl(locals; style=:code) Run a Python REPL, for interacting directly with Python. Runs in a scope defined by `locals`. As with [`pyeval`](@ref), if you pass a module, then a persistent scope for that module is used. Otherwise you must pass a Python `dict`. + +The `style` keyword argument selects the REPL implementation: +- `:code` (default): Standard library `code.interact()` +- `:ipython`: IPython REPL via `IPython.embed()` (requires `IPython` to be installed). +- `:bpython`: bpython REPL via `bpython.embed()` (requires `bpython` to be installed). +- `:ptpython`: ptpython REPL via `ptpython.embed()` (requires `ptpython` to be installed). + +Examples: +- `pyrepl(Main)` is usually sufficient at the Julia REPL. +- `pyrepl(@__MODULE__)` to use the scope of the current module. +- `pyrepl(pydict())` to use a temporary scope. """ -function pyrepl(locals) +function pyrepl(locals; style=:code) if ispy(locals) locals = Py(locals) elseif locals isa Module @@ -1444,7 +1455,47 @@ function pyrepl(locals) else error("locals must be a Module or a Python dict") end - pyimport("code").interact(banner="", exitmsg="", var"local"=locals) + sys = pyimport("sys") + ps1 = pygetattr(sys, "ps1", nothing) + ps2 = pygetattr(sys, "ps2", nothing) + try + if style == :code + pyimport("code").interact(banner="", exitmsg="", var"local"=locals) + elseif style == :ipython + config = pyimport("traitlets.config").Config() + config.InteractiveShell.banner1 = "" + config.InteractiveShell.banner2 = "" + config.InteractiveShell.enable_tip = false + locid = "$(@__FILE__):$(@__LINE__)" + mod = pyimport("sys").__class__("temp") + pyimport("IPython.terminal.embed").InteractiveShellEmbed( + _init_location_id=locid, + config=config, + )( + user_ns=locals, + local_ns=locals, + var"module"=mod, + _call_location_id=locid, + compile_flags=0, + ) + elseif style == :bpython + pyimport("bpython").embed(locals_=locals) + elseif style == :ptpython + pyimport("ptpython").embed(globals=locals) + else + error("Unknown REPL style: $style. Supported styles are :code, :ipython, :bpython, :ptpython") + end + finally + for (k, v) in (("ps1", ps1), ("ps2", ps2)) + if v === nothing + if pyhasattr(sys, k) + pydelattr(sys, k) + end + else + pysetattr(sys, k, v) + end + end + end return end From 9429eb2363f5ffc2b2c94de76f113bda44b5061f Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:12:04 +0100 Subject: [PATCH 3/9] add repl_style preference --- docs/src/pythoncall.md | 1 + src/Core/builtins.jl | 12 +++++++++--- src/Utils/Utils.jl | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/src/pythoncall.md b/docs/src/pythoncall.md index 7c604b9a..4b30bb47 100644 --- a/docs/src/pythoncall.md +++ b/docs/src/pythoncall.md @@ -289,6 +289,7 @@ variables. | `exe` | `JULIA_PYTHONCALL_EXE` | Path to the Python executable, or special values (see below). | | `lib` | `JULIA_PYTHONCALL_LIB` | Path to the Python library (usually inferred automatically). | | `pickle` | `JULIA_PYTHONCALL_PICKLE` | Pickle module to use for serialization (`pickle` or `dill`). | +| `repl_style` | `JULIA_PYTHONCALL_REPL_STYLE` | Style of the Python REPL in [`pyrepl`](@ref). | The easiest way to set these preferences is with the [`PreferenceTools`](https://github.com/cjdoris/PreferenceTools.jl) diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 99852a78..51266f02 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1428,7 +1428,7 @@ macro pyexec(arg) end """ - pyrepl(locals; style=:code) + pyrepl(locals; style=nothing) Run a Python REPL, for interacting directly with Python. @@ -1437,17 +1437,20 @@ then a persistent scope for that module is used. Otherwise you must pass a Pytho `dict`. The `style` keyword argument selects the REPL implementation: -- `:code` (default): Standard library `code.interact()` +- `:code` (default): Standard library `code.interact()`. - `:ipython`: IPython REPL via `IPython.embed()` (requires `IPython` to be installed). - `:bpython`: bpython REPL via `bpython.embed()` (requires `bpython` to be installed). - `:ptpython`: ptpython REPL via `ptpython.embed()` (requires `ptpython` to be installed). +The default style is `:code`, which is always available. You can set +[the `repl_style` preference](@ref pythoncall-config) to override this default. + Examples: - `pyrepl(Main)` is usually sufficient at the Julia REPL. - `pyrepl(@__MODULE__)` to use the scope of the current module. - `pyrepl(pydict())` to use a temporary scope. """ -function pyrepl(locals; style=:code) +function pyrepl(locals; style=nothing) if ispy(locals) locals = Py(locals) elseif locals isa Module @@ -1455,6 +1458,9 @@ function pyrepl(locals; style=:code) else error("locals must be a Module or a Python dict") end + if style === nothing + style = Utils.getpref_repl_style() + end sys = pyimport("sys") ps1 = pygetattr(sys, "ps1", nothing) ps2 = pygetattr(sys, "ps2", nothing) diff --git a/src/Utils/Utils.jl b/src/Utils/Utils.jl index a26b3198..c9f67aa1 100644 --- a/src/Utils/Utils.jl +++ b/src/Utils/Utils.jl @@ -17,6 +17,20 @@ checkpref(::Type{String}, x::AbstractString) = convert(String, x) getpref_exe() = getpref(String, "exe", "JULIA_PYTHONCALL_EXE", "") getpref_lib() = getpref(String, "lib", "JULIA_PYTHONCALL_LIB", nothing) getpref_pickle() = getpref(String, "pickle", "JULIA_PYTHONCALL_PICKLE", "pickle") +function getpref_repl_style() + pref = getpref(String, "repl_style", "JULIA_PYTHONCALL_REPL_STYLE", "code") + if pref == "code" + :code + elseif pref == "ipython" + :ipython + elseif pref == "bpython" + :bpython + elseif pref == "ptpython" + :ptpython + else + error("invalid repl_style preference, expecting `code`, `ipython`, `bpython` or `ptpython`") + end +end function explode_union(T) @nospecialize T From 66f051c118178cdf1de303e1abd0b1b01049e870 Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:24:20 +0100 Subject: [PATCH 4/9] adds pyrepl macro --- docs/src/pythoncall-reference.md | 3 ++- src/API/exports.jl | 1 + src/API/macros.jl | 1 + src/Core/Core.jl | 1 + src/Core/builtins.jl | 19 ++++++++++++++----- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/src/pythoncall-reference.md b/docs/src/pythoncall-reference.md index 1bbce0c7..7ce61000 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -211,12 +211,13 @@ ispy(x::MyType) = true Py(x::MyType) = x.py ``` -## `@py`, `@pyconst` and `pyrepl` +## `@py`, `@pyconst`, `pyrepl` and `@pyrepl` ```@docs @py @pyconst pyrepl +@pyrepl ``` ## Multi-threading diff --git a/src/API/exports.jl b/src/API/exports.jl index 019d0119..59c38d75 100644 --- a/src/API/exports.jl +++ b/src/API/exports.jl @@ -1,6 +1,7 @@ # Core export @py export @pyconst +export @pyrepl export @pyeval export @pyexec export ispy diff --git a/src/API/macros.jl b/src/API/macros.jl index c74d191c..42f0f9bb 100644 --- a/src/API/macros.jl +++ b/src/API/macros.jl @@ -1,5 +1,6 @@ # Core macro pyconst end +macro pyrepl end macro pyeval end macro pyexec end diff --git a/src/Core/Core.jl b/src/Core/Core.jl index 637ee47a..454ca3d7 100644 --- a/src/Core/Core.jl +++ b/src/Core/Core.jl @@ -32,6 +32,7 @@ using Markdown: Markdown import ..PythonCall: @pyconst, + @pyrepl, @pyeval, @pyexec, getptr, diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 51266f02..a8055578 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1428,7 +1428,7 @@ macro pyexec(arg) end """ - pyrepl(locals; style=nothing) + pyrepl(locals; [style=nothing]) Run a Python REPL, for interacting directly with Python. @@ -1445,10 +1445,7 @@ The `style` keyword argument selects the REPL implementation: The default style is `:code`, which is always available. You can set [the `repl_style` preference](@ref pythoncall-config) to override this default. -Examples: -- `pyrepl(Main)` is usually sufficient at the Julia REPL. -- `pyrepl(@__MODULE__)` to use the scope of the current module. -- `pyrepl(pydict())` to use a temporary scope. +For most uses, [`@pyrepl`](@ref) is preferred. It is equivalent to `pyrepl(@__MODULE__)`. """ function pyrepl(locals; style=nothing) if ispy(locals) @@ -1505,6 +1502,18 @@ function pyrepl(locals; style=nothing) return end +""" + @pyrepl ... + +Shorthand for [`pyrepl(mod; ...)`](@ref `pyrepl`) where `mod` is the calling module. + +This starts an interactive Python REPL, whose local scope is a persistent scope for the +Julia module this is called from. +""" +macro pyrepl(args...) + esc(:($pyrepl($__module__; $(args...)))) +end + ### with """ From e824798b04a7d5a3d7abda33a3aed1a039b65ee8 Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:31:16 +0100 Subject: [PATCH 5/9] return values from pyexec can come from globals --- src/Core/builtins.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index a8055578..8627d446 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1199,7 +1199,10 @@ _pyexec_ans(::Type{Nothing}, globals, locals) = nothing :( $v = pyconvert( $(types.parameters[i]), - pygetitem(locals, $(string(names[i]))), + @something( + pygetitem(locals, $(string(names[i])), nothing), + pygetitem(globals, $(string(names[i]))), + ), ) ), ) From 0141d4efada9478f93f71ece1ef1c8a93f2670e2 Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:35:46 +0100 Subject: [PATCH 6/9] update docstrings --- src/Core/builtins.jl | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 8627d446..de0a3b3c 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1435,6 +1435,8 @@ end Run a Python REPL, for interacting directly with Python. +Press Ctrl-D to terminate the REPL and return to Julia. + Runs in a scope defined by `locals`. As with [`pyeval`](@ref), if you pass a module, then a persistent scope for that module is used. Otherwise you must pass a Python `dict`. @@ -1449,6 +1451,9 @@ The default style is `:code`, which is always available. You can set [the `repl_style` preference](@ref pythoncall-config) to override this default. For most uses, [`@pyrepl`](@ref) is preferred. It is equivalent to `pyrepl(@__MODULE__)`. + +You can use `pyeval("varname", locals)` to retrieve a variable computed during the REPL +session. """ function pyrepl(locals; style=nothing) if ispy(locals) @@ -1508,10 +1513,16 @@ end """ @pyrepl ... -Shorthand for [`pyrepl(mod; ...)`](@ref `pyrepl`) where `mod` is the calling module. +Run a Python REPL, for interacting directly with Python. + +Press Ctrl-D to terminate the REPL and return to Julia. + +Runs in a persistent scope tied to the Julia module that this was called from, the same +as for [`@pyeval`](@ref) and [`@pyexec`](@ref). + +Equivalent to `pyrepl(@__MODULE__; ...)`. Keyword arguments are as for [`pyrepl`](@ref). -This starts an interactive Python REPL, whose local scope is a persistent scope for the -Julia module this is called from. +You can use `@pyeval "varname"` to retrieve a variable computed during the REPL session. """ macro pyrepl(args...) esc(:($pyrepl($__module__; $(args...)))) From d6906145f759a9576de1a8e189ada6adddbfa992 Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:45:05 +0100 Subject: [PATCH 7/9] update examples --- src/Core/builtins.jl | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index de0a3b3c..ee2156b7 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1522,7 +1522,30 @@ as for [`@pyeval`](@ref) and [`@pyexec`](@ref). Equivalent to `pyrepl(@__MODULE__; ...)`. Keyword arguments are as for [`pyrepl`](@ref). -You can use `@pyeval "varname"` to retrieve a variable computed during the REPL session. +# Examples + +Launch a REPL and use `@pyeval` to retrieve a value. + +```julia-repl +julia> @pyrepl +>>> x = 12 +>>> # press Ctrl-D to quit + +julia> @pyeval "x" +Python: 12 +``` + +Launch a REPL and directly set a value in the Main module. + +```julia-repl +julia> @pyrepl +>>> from juliacall import Main as jl +>>> jl.x = 123 +>>> # press Ctrl-D to quit + +julia> x +123 +``` """ macro pyrepl(args...) esc(:($pyrepl($__module__; $(args...)))) From 838f20979b99985d6d56bae72e958d960e712efc Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:48:15 +0100 Subject: [PATCH 8/9] update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca0eb21..a043ab06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog ## Unreleased -* Add `pyrepl` to launch a Python REPL. +* Add `pyrepl` and `@pyrepl` to launch a Python REPL. +* Add `repl_style` preference. ## 0.9.35 (2026-06-08) * Add option `lib` to JuliaCall. Setting this will skip the discovery subprocess. From 71af6571452677b79cd9faec4aa940cfccabd6a3 Mon Sep 17 00:00:00 2001 From: Christopher Rowley Date: Sat, 20 Jun 2026 15:57:30 +0100 Subject: [PATCH 9/9] remove openssl restriction --- .github/workflows/tests.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 92fe57bd..40c43e50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -105,11 +105,13 @@ jobs: with: python-version: ${{ matrix.pyversion }} - - name: Check Python OpenSSL version (see setup_julia) - shell: python - run: | - import ssl - assert ssl.OPENSSL_VERSION_INFO < (3, 5) + # We previously had to restrict to Julia 1.11 because Julia 1.12 requires OpenSSL + # 3.5 but the GitHub runners only ship Python with OpenSSL 3.0. + # - name: Check Python OpenSSL version (see setup_julia) + # shell: python + # run: | + # import ssl + # assert ssl.OPENSSL_VERSION_INFO < (3, 5) - name: Set up uv uses: astral-sh/setup-uv@v7 @@ -120,9 +122,7 @@ jobs: id: setup_julia uses: julia-actions/setup-julia@v3 with: - # Python in the GitHub runners ships with OpenSSL 3.0. Julia 1.12 requires - # OpenSSL 3.5. Therefore juliapkg requires Julia 1.11 or lower. - version: '1.11' + version: '1' - name: Set up test Julia project if: ${{ matrix.juliaexe == 'julia' }}