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' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0aedc8..a043ab06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +* 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. * 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..7ce61000 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -211,11 +211,13 @@ ispy(x::MyType) = true Py(x::MyType) = x.py ``` -## `@py` and `@pyconst` +## `@py`, `@pyconst`, `pyrepl` and `@pyrepl` ```@docs @py @pyconst +pyrepl +@pyrepl ``` ## Multi-threading 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/API/exports.jl b/src/API/exports.jl index 3d476182..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 @@ -82,6 +83,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/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 1d0773c0..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, @@ -119,6 +120,7 @@ import ..PythonCall: pypow, pyprint, pyrange, + pyrepl, pyrepr, pyrowlist, pyrshift, diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 10b05c8c..ee2156b7 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]))), + ), ) ), ) @@ -1427,6 +1430,127 @@ macro pyexec(arg) end end +""" + pyrepl(locals; [style=nothing]) + +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`. + +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). + +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) + 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 + if style === nothing + style = Utils.getpref_repl_style() + end + 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 + +""" + @pyrepl ... + +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). + +# 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...)))) +end + ### with """ 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