diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0fd9e1f..db580f50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: matrix: arch: [x64] # x86 unsupported by MicroMamba os: [ubuntu-latest, windows-latest, macos-latest] - jlversion: ['1','1.9'] + jlversion: ['1','1.10'] pythonexe: ['@CondaPkg'] include: - arch: x64 @@ -66,7 +66,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - pyversion: ["3", "3.9"] + pyversion: ["3", "3.10"] juliaexe: ["@JuliaPkg"] include: - os: ubuntu-latest @@ -84,9 +84,15 @@ jobs: uses: actions/setup-python@v6 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) - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.pyversion }} @@ -94,7 +100,9 @@ jobs: id: setup_julia uses: julia-actions/setup-julia@v2 with: - version: '1' + # 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' - name: Set up test Julia project if: ${{ matrix.juliaexe == 'julia' }} diff --git a/CondaPkg.toml b/CondaPkg.toml index 8ebe709d..60d769c2 100644 --- a/CondaPkg.toml +++ b/CondaPkg.toml @@ -1,12 +1,17 @@ -[deps.libstdcxx] + +[deps.openssl] version = "<=julia" -[deps.libstdcxx-ng] +[deps.libstdcxx] version = "<=julia" -[deps.openssl] +[deps.libstdcxx-ng] version = "<=julia" [deps.python] build = "**cpython**" -version = ">=3.9,<4" +version = ">=3.10,<4" + +[dev.deps] +matplotlib = "" +pyside6 = "" diff --git a/Project.toml b/Project.toml index 547d665c..8dd63ae8 100644 --- a/Project.toml +++ b/Project.toml @@ -17,7 +17,7 @@ UnsafePointers = "e17b2a0c-0bdf-430a-bd0c-3a23cae4ff39" [compat] Aqua = "0 - 999" CategoricalArrays = "0.10, 1" -CondaPkg = "0.2.30" +CondaPkg = "0.2.33" Dates = "1" Libdl = "1" MacroTools = "0.5" @@ -29,7 +29,7 @@ Tables = "1" Test = "1" TestItemRunner = "0 - 999" UnsafePointers = "1" -julia = "1.9" +julia = "1.10" [extensions] PyCallExt = "PyCall" diff --git a/README.md b/README.md index 1b49cbf9..aa8e8fb3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Bringing [**Python®**](https://www.python.org/) and [**Julia**](https://juliala - Fast non-copying conversion of numeric arrays in either direction: modify Python arrays (e.g. `bytes`, `array.array`, `numpy.ndarray`) from Julia or Julia arrays from Python. - Helpful wrappers: interpret Python sequences, dictionaries, arrays, dataframes and IO streams as their Julia counterparts, and vice versa. - Beautiful stack-traces. -- Supports modern systems: tested on Windows, MacOS and Linux, 64-bit, Julia 1.9 upwards and Python 3.9 upwards. +- Supports modern systems: tested on Windows, MacOS and Linux, 64-bit, Julia 1.10 upwards and Python 3.10 upwards. ⭐ If you like this, a GitHub star would be lovely thank you. ⭐ diff --git a/docs/src/faq.md b/docs/src/faq.md index c36e4aab..a31dff7b 100644 --- a/docs/src/faq.md +++ b/docs/src/faq.md @@ -108,3 +108,11 @@ using PythonCall np = pyimport("numpy") ``` + +## What versions of Python and Julia do you support? + +Each release of PythonCall and JuliaCall will support and require: +- Any currently supported version of Python, [see here](https://devguide.python.org/versions/). Currently 3.10+. +- The current Julia LTS version and newer, [see here](https://julialang.org/downloads/#long_term_support_release). Currently 1.10+. + +Only the latest patch release within each minor version is supported. diff --git a/docs/src/index.md b/docs/src/index.md index 2dca5e6b..22efa41d 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -7,4 +7,4 @@ Bringing [**Python®**](https://www.python.org/) and [**Julia**](https://juliala - Fast non-copying conversion of numeric arrays in either direction: modify Python arrays (e.g. `bytes`, `array.array`, `numpy.ndarray`) from Julia or Julia arrays from Python. - Helpful wrappers: interpret Python sequences, dictionaries, arrays, dataframes and IO streams as their Julia counterparts, and vice versa. - Beautiful stack-traces. -- Works anywhere: tested on Windows, MacOS and Linux, 32- and 64-bit, Julia Julia 1.9 upwards and Python 3.9 upwards. +- Works anywhere: tested on Windows, MacOS and Linux, 32- and 64-bit, Julia Julia 1.10 upwards and Python 3.10 upwards. diff --git a/docs/src/juliacall-reference.md b/docs/src/juliacall-reference.md index b67d8b26..0aa8e6d7 100644 --- a/docs/src/juliacall-reference.md +++ b/docs/src/juliacall-reference.md @@ -158,13 +158,13 @@ This wraps any Julia `AbstractVector` value. It is a subclass of `juliacall.Arra `````@customdoc juliacall.DictValue - Class -This wraps any Julia `AbstractDict` value. It is a subclass of `collections.abc.Mapping` and +This wraps any Julia `AbstractDict` value. It is a subclass of `collections.abc.MutableMapping` and behaves similar to a Python `dict`. ````` `````@customdoc juliacall.SetValue - Class -This wraps any Julia `AbstractSet` value. It is a subclass of `collections.abc.Set` and +This wraps any Julia `AbstractSet` value. It is a subclass of `collections.abc.MutableSet` and behaves similar to a Python `set`. ````` diff --git a/docs/src/juliacall.md b/docs/src/juliacall.md index 4279e242..26182add 100644 --- a/docs/src/juliacall.md +++ b/docs/src/juliacall.md @@ -7,6 +7,11 @@ It's as simple as pip install juliacall ``` +If you prefer Conda, there is a community effort to also release this on conda-forge: +```bash +conda install conda-forge::pyjuliapkg +``` + Developers may wish to clone the repo (https://github.com/JuliaPy/PythonCall.jl) directly and pip install the module in editable mode. You should add `"dev":true, "path":"../.."` to `pysrc/juliacall/juliapkg.json` to ensure you use the development version of PythonCall @@ -140,6 +145,10 @@ be configured in two ways: ## [Multi-threading](@id py-multi-threading) +!!! warning + + Multi-threading support is experimental and can change without notice. + From v0.9.22, JuliaCall supports multi-threading in Julia and/or Python, with some caveats. diff --git a/docs/src/pythoncall.md b/docs/src/pythoncall.md index a4f191e8..5bc610ff 100644 --- a/docs/src/pythoncall.md +++ b/docs/src/pythoncall.md @@ -8,7 +8,7 @@ This package is in the general registry, so to install just type `]` in the Juli pkg> add PythonCall ``` -## Getting started +## [Getting started](@id py_getting_started) Import the module with: @@ -91,6 +91,52 @@ Python: ValueError('some error') With the functions introduced so far, you have access to the vast majority of Python's functionality. +## Executing Python scripts + +A common use case is calling multiple blocks of Python code from Julia interactively. This can be accomplished in PythonCall via the [@pyexec](@ref) macro. For example, the sentence parsing application in the [Getting started](@ref py_getting_started) section could be rewritten as: + +```julia-repl +julia> @pyexec """ + global re + import re + + def my_sentence(s): + words = re.findall("[a-zA-Z]+", s) + sentence = " ".join(words) + return sentence + """ => my_sentence +Python: + +julia> sentence = my_sentence("PythonCall.jl is very useful!") +Python: 'PythonCall jl is very useful' +``` + +Note the use of the `global` keyword to make the `re` package accessible in global scope, and the `=> my_sentence` syntax to create a Julia function named `my_sentence` that calls to the Python function of the same name. This syntax also supports calling to multiple functions and passing data back-and-forth: + +```julia-repl +julia> @pyexec (num=10) => """ + def add(a, b): + return a + b + + def subtract(a, b): + return a - b + + plusone = num + 1 + """ => (add, subtract, plusone::Float64) +(add = , subtract = , plusone = 11.0) + +julia> add(4, 3) +Python: 7 + +julia> subtract(4, 3) +Python: 1 + +julia> plusone +11.0 +``` + +Here we demonstrate passing a named variable, `num`, via the use of the `=>` syntax again, and returning named output, with the last element, `plusone`, being cast to a Julia object via the `::` syntax. See [@pyexec](@ref), [@pyeval](@ref), and their functional forms [pyexec](@ref) and [pyeval](@ref), for more. + ## Conversion between Julia and Python A Julia object can be converted to a Python one either explicitly (such as `Py(x)`) or @@ -369,6 +415,10 @@ See [Installing Python packages](@ref python-deps). ## [Multi-threading](@id jl-multi-threading) +!!! warning + + Multi-threading support is experimental and can change without notice. + From v0.9.22, PythonCall supports multi-threading in Julia and/or Python, with some caveats. diff --git a/docs/src/releasenotes.md b/docs/src/releasenotes.md index c4b029cb..dcbf7f75 100644 --- a/docs/src/releasenotes.md +++ b/docs/src/releasenotes.md @@ -1,5 +1,13 @@ # Release Notes +## Unreleased +* Minimum supported Python version is now 3.10. +* Minimum supported Julia version is now 1.10. +* Showing `Py` now respects the `compact` option - output is limited to a single line of + at most the display width. +* Support policy now documented in the FAQ. +* Bug fixes. + ## 0.9.28 (2025-09-17) * Added `NumpyDates`: NumPy-compatible DateTime64/TimeDelta64 types and units. * Added `pyconvert` rules for NumpyDates types. diff --git a/pyproject.toml b/pyproject.toml index 892cc148..c2775527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.9, <4" -dependencies = ["juliapkg >=0.1.17, <0.2"] +requires-python = ">=3.10, <4" +dependencies = ["juliapkg >=0.1.21, <0.2"] [dependency-groups] dev = [ diff --git a/pysrc/juliacall/juliapkg-dev.json b/pysrc/juliacall/juliapkg-dev.json index 144c0090..982c5f69 100644 --- a/pysrc/juliacall/juliapkg-dev.json +++ b/pysrc/juliacall/juliapkg-dev.json @@ -1,5 +1,5 @@ { - "julia": "~1.9, ^1.10.3", + "julia": "^1.10.3", "packages": { "PythonCall": { "uuid": "6099a3de-0909-46bc-b1f4-468b9a2dfc0d", diff --git a/pysrc/juliacall/juliapkg.json b/pysrc/juliacall/juliapkg.json index 5139c558..6357b1b8 100644 --- a/pysrc/juliacall/juliapkg.json +++ b/pysrc/juliacall/juliapkg.json @@ -1,5 +1,5 @@ { - "julia": "~1.9, ^1.10.3", + "julia": "^1.10.3", "packages": { "PythonCall": { "uuid": "6099a3de-0909-46bc-b1f4-468b9a2dfc0d", diff --git a/src/C/context.jl b/src/C/context.jl index 8d2714c5..9d1f5d30 100644 --- a/src/C/context.jl +++ b/src/C/context.jl @@ -29,6 +29,79 @@ function _atpyexit() return end + +function setup_onfixedthread() + channel_input = Channel(1) + channel_output = Channel(1) + islaunched = Ref(false) # use Ref to avoid closure boxing of variable + function launch_worker(tid) + islaunched[] && error("Cannot launch more than once: call setup_onfixedthread again if need be.") + islaunched[] = true + worker_task = Task() do + while true + f = take!(channel_input) + ret = try + Some(invokelatest(f)) + # invokelatest is necessary for development and interactive use. + # Otherwise, only a method f defined in a world prior to the call of + # launch_worker would work. + catch e + e, catch_backtrace() + end + put!(channel_output, ret) + end + end + # code adapted from set_task_tid! in StableTasks.jl, itself taken from Dagger.jl + worker_task.sticky = true + for _ in 1:100 + # try to fix the task id to tid, retrying up to 100 times + ret = ccall(:jl_set_task_tid, Cint, (Any, Cint), worker_task, tid-1) + if ret == 1 + break # success + elseif ret == 0 + yield() + else + error("Unexpected retcode from jl_set_task_tid: $ret") + end + end + if Threads.threadid(worker_task) != tid + error("Failed setting the thread ID to $tid.") + end + schedule(worker_task) + end + function onfixedthread(f) + put!(channel_input, f) + ret = take!(channel_output) + if ret isa Tuple + e, backtrace = ret + printstyled(stderr, "ERROR: "; color=:red, bold=true) + showerror(stderr, e) + Base.show_backtrace(stderr, backtrace) + println(stderr) + throw(e) # the stacktrace of the actual error is printed above + else + something(ret) + end + end + launch_worker, onfixedthread +end + +# launch_on_main_thread is used in init_context(), after which on_main_thread becomes usable +const launch_on_main_thread, on_main_thread = setup_onfixedthread() + +""" + on_main_thread(f) + +Execute `f()` on the main thread. + +!!! warning + The value returned by `on_main_thread(f)` cannot be type-inferred by the compiler: + if necessary, use explicit type annotations such as `on_main_thread(f)::T`, where `T` is + the expected return type. +""" +on_main_thread + + function init_context() CTX.is_embedded = hasproperty(Base.Main, :__PythonCall_libptr) @@ -236,10 +309,12 @@ function init_context() error("Cannot parse version from version string: $(repr(verstr))") end CTX.version = VersionNumber(vermatch.match) - v"3.9" ≤ CTX.version < v"4" || error( - "Only Python 3.9+ is supported, this is Python $(CTX.version) at $(CTX.exe_path===missing ? "unknown location" : CTX.exe_path).", + v"3.10" ≤ CTX.version < v"4" || error( + "Only Python 3.10+ is supported, this is Python $(CTX.version) at $(CTX.exe_path===missing ? "unknown location" : CTX.exe_path).", ) + launch_on_main_thread(Threads.threadid()) # makes on_main_thread usable + @debug "Initialized PythonCall.jl" CTX.is_embedded CTX.is_initialized CTX.exe_path CTX.lib_path CTX.lib_ptr CTX.pyprogname CTX.pyhome CTX.version return diff --git a/src/Compat/gui.jl b/src/Compat/gui.jl index ad7b1687..b0be9363 100644 --- a/src/Compat/gui.jl +++ b/src/Compat/gui.jl @@ -159,7 +159,7 @@ function init_gui() # add a hook to automatically call fix_qt_plugin_path() fixqthook = - Py(() -> (Core.CONFIG.auto_fix_qt_plugin_path && fix_qt_plugin_path(); nothing)) + Py(() -> (PythonCall.CONFIG.auto_fix_qt_plugin_path && fix_qt_plugin_path(); nothing)) pymodulehooks.add_hook("PyQt4", fixqthook) pymodulehooks.add_hook("PyQt5", fixqthook) pymodulehooks.add_hook("PySide", fixqthook) diff --git a/src/Core/Py.jl b/src/Core/Py.jl index 9faea130..8b26f036 100644 --- a/src/Core/Py.jl +++ b/src/Core/Py.jl @@ -168,16 +168,23 @@ function Base.show(io::IO, ::MIME"text/plain", o::Py) end hasprefix = (get(io, :typeinfo, Any) != Py)::Bool compact = get(io, :compact, false)::Bool + limit = get(io, :limit, true)::Bool + if compact + # compact should output a single line, which we force by replacing newline + # characters with spaces + str = replace(str, "\n" => " ") + end multiline = '\n' in str - prefix = - hasprefix ? - compact ? "Py:$(multiline ? '\n' : ' ')" : "Python:$(multiline ? '\n' : ' ')" : "" + prefix = !hasprefix ? "" : compact ? "Py: " : multiline ? "Python:\n" : "Python: " print(io, prefix) h, w = displaysize(io) - if get(io, :limit, true) + if limit + # limit: fit the printed text into the display size h, w = displaysize(io) h = max(h - 3, 5) # use 3 fewer lines to allow for the prompt, but always allow at least 5 lines if multiline + # multiline: we truncate each line to the width of the screen, and skip + # middle lines if there are too many for the height h -= 1 # for the prefix lines = split(str, '\n') function printlines(io, lines, w) @@ -218,7 +225,25 @@ function Base.show(io::IO, ::MIME"text/plain", o::Py) println(io) printlines(io, lines[end-h1+1:end], w) end + elseif compact + # compact: we print up to one screen width, skipping characters in the + # middle if the string is too long for the width + maxlen = w - length(prefix) + if length(str) ≤ maxlen + print(io, str) + else + gap = " ... " + gaplen = length(gap) + w0 = cld(maxlen - gaplen, 2) + i0 = nextind(str, 1, w0 - 1) + i1 = prevind(str, ncodeunits(str), maxlen - gaplen - w0 - 1) + print(io, str[begin:i0]) + printstyled(io, gap, color = :light_black) + print(io, str[i1:end]) + end else + # single-line: we print up to one screenfull, skipping characters in the + # middle if the string is too long. We skip a whole line of characters. maxlen = h * w - length(prefix) if length(str) ≤ maxlen print(io, str) @@ -257,25 +282,29 @@ Base.setproperty!(x::Py, k::Symbol, v) = pysetattr(x, string(k), v) Base.setproperty!(x::Py, k::String, v) = pysetattr(x, k, v) function Base.propertynames(x::Py, private::Bool = false) - # this follows the logic of rlcompleter.py - function classmembers(c) - r = pydir(c) - if pyhasattr(c, "__bases__") - for b in c.__bases__ - r = pyiadd(r, classmembers(b)) + properties = C.on_main_thread() do + # this follows the logic of rlcompleter.py + function classmembers(c) + r = pydir(c) + if pyhasattr(c, "__bases__") + for b in c.__bases__ + r = pyiadd(r, classmembers(b)) + end end + return r end - return r - end - words = pyset(pydir(x)) - words.discard("__builtins__") - if pyhasattr(x, "__class__") - words.add("__class__") - words.update(classmembers(x.__class__)) - end - words = map(pystr_asstring, words) + + words = pyset(pydir(x::Py)) + words.discard("__builtins__") + if pyhasattr(x, "__class__") + words.add("__class__") + words.update(classmembers(x.__class__)) + end + map(pystr_asstring, words) + end::Vector{String} # explicit type since on_main_thread() is type-unstable + # private || filter!(w->!startswith(w, "_"), words) - map(Symbol, words) + map(Symbol, properties) end Base.Bool(x::Py) = pytruth(x) diff --git a/src/Core/stdlib.jl b/src/Core/stdlib.jl index 98ddcce1..96c526c7 100644 --- a/src/Core/stdlib.jl +++ b/src/Core/stdlib.jl @@ -40,7 +40,7 @@ function init_stdlib() class JuliaCompatHooks: def __init__(self): self.hooks = {} - def find_module(self, name, path=None): + def find_spec(self, name, path=None, target=None): hs = self.hooks.get(name) if hs is not None: for h in hs: diff --git a/src/GIL/GIL.jl b/src/GIL/GIL.jl index 427a2791..f4b386ce 100644 --- a/src/GIL/GIL.jl +++ b/src/GIL/GIL.jl @@ -4,6 +4,10 @@ Handling the Python Global Interpreter Lock. See [`lock`](@ref), [`@lock`](@ref), [`unlock`](@ref) and [`@unlock`](@ref). + +!!! warning + + Multi-threading support is experimental and can change without notice. """ module GIL @@ -32,6 +36,10 @@ threads. Since the main Julia thread holds the GIL by default, you will need to [`unlock`](@ref) the GIL before using this function. See [`@lock`](@ref) for the macro form. + +!!! warning + + This function is experimental. Its semantics may be changed without notice. """ function lock(f) state = C.PyGILState_Ensure() @@ -52,6 +60,10 @@ threads. Since the main Julia thread holds the GIL by default, you will need to [`@unlock`](@ref) the GIL before using this function. The macro equivalent of [`lock`](@ref). + +!!! warning + + This macro is experimental. Its semantics may be changed without notice. """ macro lock(expr) quote @@ -74,6 +86,10 @@ Python code. That other thread can be a Julia thread, which must lock the GIL us [`lock`](@ref). See [`@unlock`](@ref) for the macro form. + +!!! warning + + This function is experimental. Its semantics may be changed without notice. """ function unlock(f) state = C.PyEval_SaveThread() @@ -94,6 +110,10 @@ Python code. That other thread can be a Julia thread, which must lock the GIL us [`@lock`](@ref). The macro equivalent of [`unlock`](@ref). + +!!! warning + + This macro is experimental. Its semantics may be changed without notice. """ macro unlock(expr) quote diff --git a/src/JlWrap/any.jl b/src/JlWrap/any.jl index 9685cc4b..96a6a3d2 100644 --- a/src/JlWrap/any.jl +++ b/src/JlWrap/any.jl @@ -28,9 +28,10 @@ function pyjlany_setattr(self, k_::Py, v_::Py) v = pyconvert(Any, v_) if self isa Module && !isdefined(self, k) # Fix for https://github.com/JuliaLang/julia/pull/54678 - Base.Core.eval(self, Expr(:global, k)) + @eval self (global $k = $v) + else + setproperty!(self, k, v) end - setproperty!(self, k, v) Py(nothing) end pyjl_handle_error_type(::typeof(pyjlany_setattr), self, exc) = pybuiltins.AttributeError diff --git a/src/Wrap/PyIO.jl b/src/Wrap/PyIO.jl index 62388df6..ea3f7de1 100644 --- a/src/Wrap/PyIO.jl +++ b/src/Wrap/PyIO.jl @@ -34,10 +34,51 @@ end # If obuf is non-empty, write it to the underlying stream. function putobuf(io::PyIO) if !isempty(io.obuf) - data = io.text ? pystr_fromUTF8(io.obuf) : pybytes(io.obuf) - pydel!(@py io.write(data)) - pydel!(data) - empty!(io.obuf) + if io.text + # Check if there is a partial character at the end of obuf and if so then + # do not write it. + # get the last character + nskip = 0 + n = length(io.obuf) + c = io.obuf[end] + if (c & 0xC0) == 0xC0 + # 11xxxxxx => buffer ends in a multi-byte char + nskip = 1 + elseif ((c & 0xC0) == 0x80) && (n > 1) + # 10xxxxxx => continuation char + # get the second to last character + c = io.obuf[end-1] + if (c & 0xE0) == 0xE0 + # 111xxxxx => buffer ends in a 3- or 4-byte char + nskip = 2 + elseif ((c & 0xC0) == 0x80) && (n > 2) + # 10xxxxxx => continuation char + # get the third to last character + c = io.obuf[end-2] + if (c & 0xF0) == 0xF0 + # 1111xxxx => buffer ends in a 4-byte char + nskip = 3 + end + end + end + if nskip == 0 + data = pystr_fromUTF8(io.obuf) + else + data = pystr_fromUTF8(view(io.obuf, 1:(n-nskip))) + end + pydel!(@py io.write(data)) + pydel!(data) + if nskip == 0 + empty!(io.obuf) + else + deleteat!(io.obuf, 1:(n-nskip)) + end + else + data = pybytes(io.obuf) + pydel!(@py io.write(data)) + pydel!(data) + empty!(io.obuf) + end end return end diff --git a/test/Compat.jl b/test/Compat.jl index 79037f29..00c93ac6 100644 --- a/test/Compat.jl +++ b/test/Compat.jl @@ -1,17 +1,28 @@ -@testitem "gui" begin +@testitem "gui" setup=[Setup] begin @testset "fix_qt_plugin_path" begin @test PythonCall.fix_qt_plugin_path() isa Bool # second time is a no-op @test PythonCall.fix_qt_plugin_path() === false end @testset "event_loop_on/off" begin - for g in [:pyqt4, :pyqt5, :pyside, :pyside2, :pyside6, :gtk, :gtk3, :wx] + @testset "$g" for g in [:pyqt4, :pyqt5, :pyside, :pyside2, :pyside6, :gtk, :gtk3, :wx] # TODO: actually test the various GUIs somehow? - @show g - @test_throws PyException PythonCall.event_loop_on(g) + if Setup.devdeps && g == :pyside6 + # pyside6 is installed as a dev dependency + # AND it's a dependency of matplotlib, which is also a dev dependency + @test PythonCall.event_loop_on(g) isa Timer + else + @test_throws PyException PythonCall.event_loop_on(g) + end @test PythonCall.event_loop_off(g) === nothing end end + @testset "matplotlib issue 676" begin + if Setup.devdeps + plt = pyimport("matplotlib.pyplot") + @test plt.get_backend() isa Py + end + end end @testitem "ipython" begin diff --git a/test/Core.jl b/test/Core.jl index 32b4d59f..8f2e428a 100644 --- a/test/Core.jl +++ b/test/Core.jl @@ -805,6 +805,9 @@ end @test sprint(show, MIME("text/plain"), Py(12)) == "Python: 12" # https://github.com/JuliaPy/PythonCall.jl/issues/522 @test sprint(show, MIME("text/plain"), PythonCall.pynew()) == "Python: NULL" + # test compact printing + @test sprint(show, MIME("text/plain"), Py(String('A':'Z')), context=(:compact=>true, :displaysize=>(50, 20))) == "Py: 'ABCDE ... WXYZ'" + @test sprint(show, MIME("text/plain"), Py(String('A':'Z')), context=(:compact=>true, :limit=>false, :displaysize=>(50, 20))) == "Py: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'" @test_throws MethodError sprint(show, MIME("text/html"), PythonCall.pynew()) end end @@ -827,3 +830,23 @@ end @test !isdir(tname) end end + +@testitem "propertynames" begin + x = pyint(7) + task = Threads.@spawn propertynames(x) + properties = propertynames(x) + @test :__init__ in properties + prop_task = fetch(task) + @test properties == prop_task +end + +@testitem "on_main_thread" begin + refid = PythonCall.C.on_main_thread() do; Threads.threadid(); end + tasks = [Threads.@spawn(PythonCall.C.on_main_thread() do; Threads.threadid(); end) for _ in 1:20] + @test all(t -> fetch(t) == refid, tasks) + @test_throws DivideError redirect_stderr(devnull) do + PythonCall.C.on_main_thread() do + throw(DivideError()) + end + end +end diff --git a/test/Wrap.jl b/test/Wrap.jl index c0af7ba2..9e23f8a3 100644 --- a/test/Wrap.jl +++ b/test/Wrap.jl @@ -270,6 +270,20 @@ end @test !isopen(b) @test !isopen(s) end + @testset "flush partial characters (issue 679)" begin + # In this example, "touché!" takes up 8 bytes, with 'é' taking 2. So when we + # make a PyIO with buflen=6, it tries to flush after 6 bytes. Previously this + # would try to create a string from those 6 bytes and fail with a + # UnicodeDecodeError because the final character is incomplete. This is now + # fixed by deferring printing of incomplete characters. + s0 = pyimport("io").StringIO() + s = PyIO(s0, buflen=6) + @test s.text + @test write(s, "touché!") == 8 + flush(s) + s0.seek(0) + @test pyeq(Bool, s0.read(), "touché!") + end end @testitem "PyIterable" begin diff --git a/test/runtests.jl b/test/runtests.jl index b9e874db..115648be 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,3 +1,11 @@ using TestItemRunner +@testmodule Setup begin + using PythonCall + # test if we are in CI + ci = get(ENV, "CI", "") == "true" + # test if we have all dev conda deps + devdeps = PythonCall.C.CTX.which == :CondaPkg +end + @run_package_tests