Skip to content
Open
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
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
the process uses its newer `libstdc++`.
- `julia --project=docs docs/make.jl` requires a valid Git `origin` so Documenter can infer
source links; it fails during `makedocs` in checkouts without one.
- `Documenter.HTML` discovers its default edit-link branch with `git remote show
origin`, which contacts the remote. Set `edit_link` explicitly for a local build
without that network lookup; v1's docs use `edit_link="v1"`. Disabling source
remotes alone does not prevent the HTML constructor's branch lookup.

The majority of tests live in the Julia package; Python tests cover functionality that cannot be exercised from Julia (e.g., JuliaCall-specific behavior). Run both suites—typically Julia first—in whichever order makes sense.

Expand All @@ -25,10 +29,43 @@ The majority of tests live in the Julia package; Python tests cover functionalit
`main`'s `ValueBase`/`AnyValue` hierarchy. Keep the collection wrapper when porting
`_jlwrap` changes. Wrapper modules load before `juliacall.Base` is assigned, so
constructors must look it up through `juliacall` when called.
- For numeric Python fixtures, use `pycomplex(real, imag)` when a Python complex
value is needed. Passing a Julia `Complex` directly to a Python constructor can
produce a Julia wrapper, which NumPy may treat as a sequence rather than a scalar.
- External-interpreter Julia CI jobs do not install NumPy/pandas development
dependencies. Keep synthetic conversion tests independent of them, and guard
library-specific checks with module availability (without hiding import errors
when a module is installed). Conda-backed jobs exercise those optional checks.
- The benchmark suite is shared across the compared revisions: `main` uses `pydel!`, while `v1` uses `unsafe_pydel`.
- `main` does not have `@pyregion`. Benchmark region wrappers must select a plain-body fallback before macro expansion; a runtime conditional around an unavailable macro still fails when loading the suite.
- Julia CI caches can preserve Conda's build metadata without its root environment. Rebuild `Conda` before building `PyCall` or running tests to recreate that directory.
- In restricted sandboxes, both suites need approval to write to the shared caches (`~/.julia` and `~/.cache/uv`).
- Julia also writes `~/.julia/logs/manifest_usage.toml` during dependency discovery,
including PythonCall startup through CondaPkg. Cache permissions must include
`~/.julia/logs`, in addition to compiled packages, artifacts and registries.
- Notebook tests start a Jupyter kernel using local sockets. A sandbox that blocks
sockets can run the ordinary Python tests but cannot run `--nbval` successfully.
- Loopback TCP permission alone may be insufficient for notebook tests: ZeroMQ
queries network interfaces via `getifaddrs`, which on Linux needs
`AF_NETLINK`/`NETLINK_ROUTE` sockets. In the tested sandbox, loopback binding
succeeded but creating a route Netlink socket failed with `EPERM`.
- Use the absolute installed Julia executable in a restricted sandbox to avoid
Juliaup's configuration lock. Installed channels may differ from those listed above.
- `JULIA_PKG_OFFLINE=true` lets JuliaPkg resolve the development Python test
environment using installed packages without refreshing registries or cloning
uncached package versions.
- `JULIA_CONDAPKG_ENV` must not point at an existing `.CondaPkg` directory.
To run Aqua against an installed interpreter without creating a fresh Conda
environment, use `JULIA_CONDAPKG_BACKEND=Null` and set `JULIA_PYTHONCALL_EXE`
to that interpreter's absolute path.
- `test/runtests.jl` infers optional development dependencies from the CondaPkg
backend. With the Null backend and an explicit Conda interpreter, PySide6 may
be installed even though `Setup.devdeps` is false, causing the GUI test to
incorrectly expect an import failure. Focused conversion tests are unaffected.
- Interrupted sandbox runs can leave a CondaPkg lock containing a PID from a
different PID namespace. A reused small PID can make later runs wait for that
lock indefinitely. Prefer an explicit installed Python/Null backend for
focused conversion tests; do not remove a lock without checking its owner.
- `pysrc/juliacall/juliapkg.json` is tracked; restore its original contents after using the development copy for Python tests.

## Meta instructions
Expand Down
35 changes: 35 additions & 0 deletions benchmark/convert-array-allocations.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Attribution only: run after timing trials, with the same environment/Python.
using PythonCall, Profile
array_value(::Val{T},x) where T = pyconvert(T,x,nothing)
label=isempty(ARGS) ? "after" : only(ARGS)
source_root=dirname(pathof(PythonCall))
np=pyimport("numpy")
array=pyimport("array")
cases=[("ndarray_native",Array,np.arange(1000)),
("ndarray_wrapper",PyArray,np.arange(1000)),
("buffer_native",Array,array.array("d",pylist(1:1000))),
("buffer_wrapper",PyArray,array.array("d",pylist(1:1000)))]
open(joinpath(@__DIR__,"convert-array-allocations-$label.txt"),"w") do io
println(io,"Julia: ",VERSION,"; PythonCall source: ",pathof(PythonCall))
for (name,T,x) in cases
f=let tag=Val(T), x=x
()->array_value(tag,x)
end
for _=1:3
f()
end
GC.gc()
Profile.Allocs.clear()
Profile.Allocs.@profile sample_rate=1.0 f()
println(io,"\n",name)
for a in Profile.Allocs.fetch().allocs
println(io,a.type,": ",a.size," bytes")
for frame in a.stacktrace
file=string(frame.file)
if occursin(source_root,file)
println(io," ",frame)
end
end
end
end
end
25 changes: 25 additions & 0 deletions benchmark/convert-dispatch.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using PythonCall, BenchmarkTools
const CV=PythonCall.Convert
label = isempty(ARGS) ? "" : "-" * only(ARGS)
x=pylist([pylist(1:5),pylist(6:10)])
scoped=pylist(1:10)
np=pyimport("numpy")
cases = [
("no_preference", ()->pyconvert(Any,x)),
("one_preference", ()->pyconvert(Any,x;prefer=("builtins:list"=>PyList,))),
("multiple_preferences", ()->pyconvert(Any,x;prefer=("builtins:dict"=>PyDict,"builtins:list"=>PyList))),
("unmatched_preferences", ()->pyconvert(Any,x;prefer=("builtins:dict"=>PyDict,))),
("nested_context", ()->pyconvert(PyConvertContext(;prefer=("builtins:list"=>PyList,)),Vector,x)),
("ndarray_native", let a=np.arange(1000); ()->pyconvert(Any,a) end),
("ndarray_wrapper", let a=np.arange(1000); ()->pyconvert(PyArray,a) end),
("bytes_explicit", let a=pybytes(pylist([1,2,3])); ()->pyconvert(Vector{UInt8},a) end),
("scope_filtered", ()->pyconvert(Vector{Int},scoped)),
]
open(joinpath(@__DIR__,"convert-dispatch$label.tsv"),"w") do io
println(io,"case\tns\tbytes\tallocs")
for (name,f) in cases
m=minimum(@benchmark $f() seconds=1.0 samples=5000)
println(io,"$name\t$(m.time)\t$(m.memory)\t$(m.allocs)")
end
end
# Cache misses/invalidation are measured with untimed setup in convert-expanded.jl.
184 changes: 184 additions & 0 deletions benchmark/convert-expanded.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Shared harness for the pre-redesign revision and current v1.
# julia --project=benchmark benchmark/convert-expanded.jl after
# Optional second argument "warm" skips unchanged cold-path experiments.
using PythonCall, BenchmarkTools, SHA
const ExpandedConvert = PythonCall.Convert
const EXPANDED_NEW_API = isdefined(PythonCall, :PyConvertContext)

expanded_decline1(T,x) = ExpandedConvert.pyconvert_unconverted()
expanded_decline2(T,x) = ExpandedConvert.pyconvert_unconverted()
expanded_decline3(T,x) = ExpandedConvert.pyconvert_unconverted()
expanded_success(T,x) = ExpandedConvert.pyconvert_return(42)
struct ExpandedDestination{I} end
struct ExpandedScopedDecliner{I} <: Function end
(::ExpandedScopedDecliner)(T,x) = ExpandedConvert.pyconvert_unconverted()

function expanded_register(source,T,f;scope=T)
if EXPANDED_NEW_API
pyconvert_add_rule(source,T,f;scope)
else
pyconvert_add_rule(source,T,f)
end
end
expanded_value(::Val{T},x) where T = pyconvert(T,x,nothing)
function expanded_case(name,::Type{T},x,valid) where T
tag=Val(T)
(;name, target=T, run=()->expanded_value(tag,x), valid)
end
function expanded_cache(T)
if EXPANDED_NEW_API
ExpandedConvert.pyconvert_rules_cache(typeof(PyConvertContext()),T)
else
ExpandedConvert.pyconvert_rules_cache(T)
end
end
function expanded_invalidate!()
if EXPANDED_NEW_API
ExpandedConvert.pyconvert_invalidate!()
else
foreach(empty!,values(ExpandedConvert.PYCONVERT_RULES_CACHE))
end
end

label=isempty(ARGS) ? "after" : first(ARGS)
warm_only=length(ARGS)>=2 && ARGS[2]=="warm"
ns=pyimport("types").ModuleType("convert_expanded_benchmark")
pyimport("sys").modules["convert_expanded_benchmark"]=ns
pyexec("""
import collections.abc
import array
import numpy
class Success: pass
class Declines: pass
class ThreeDeclines: pass
class Scoped: pass
class Preference: pass
class VirtualMapping: pass
collections.abc.Mapping.register(VirtualMapping)
class InterfaceOnly:
def __init__(self, owner): self.owner = owner
@property
def __array_interface__(self): return self.owner.__array_interface__
class ArrayOnly:
def __init__(self, owner): self.owner = owner
def __array__(self): return self.owner
""",ns.__dict__)
prefix="convert_expanded_benchmark:"
expanded_register(prefix*"Success",Int,expanded_success;scope=Any)
expanded_register(prefix*"Declines",Int,expanded_decline1;scope=Any)
expanded_register(prefix*"Declines",Int,expanded_success;scope=Any)
for f in (expanded_decline1,expanded_decline2,expanded_decline3,expanded_success)
expanded_register(prefix*"ThreeDeclines",Int,f;scope=Any)
end
for i=1:16
# These rules legitimately decline for this value. Old v1 has no scope
# filtering and must try them; the redesign excludes their owned scopes.
expanded_register(prefix*"Scoped",Any,ExpandedScopedDecliner{i}();scope=ExpandedDestination{i})
end
expanded_register(prefix*"Scoped",Int,expanded_success;scope=Any)
expanded_register(prefix*"Preference",String,expanded_decline1;scope=Any)
expanded_register(prefix*"Preference",Int,expanded_success;scope=Any)
expanded_register("collections.abc:Mapping",Int,expanded_success)

buffer=ns.array.array("d",pylist(1:1000))
owner=ns.numpy.arange(1000)
interface=ns.InterfaceOnly(owner)
arrayonly=ns.ArrayOnly(owner)
@assert PythonCall.C.PyType_CheckBuffer(pytype(buffer))
@assert !any(pyhasattr(pytype(buffer),a) for a in ("__array_struct__","__array_interface__","__array__"))
@assert !PythonCall.C.PyType_CheckBuffer(pytype(interface))
@assert !PythonCall.C.PyType_CheckBuffer(pytype(arrayonly))
if EXPANDED_NEW_API
eligible=filter(r->r.eligible,pyconvert_rules(Array,buffer))
@assert first(eligible).source=="<buffer>"
else
eligible=filter(r->typeintersect(r.type,Array)!==Union{},
ExpandedConvert._pyconvert_get_rules(pytype(buffer)))
@assert first(eligible).func===PythonCall.Wrap.pyconvert_rule_array
end
validint=x->x===42
validarray=x->x isa Array && size(x)==(1000,) && eltype(x)===Int64 && first(x)==0 && last(x)==999
validbuffer=x->x isa Array && size(x)==(1000,) && eltype(x)===Float64 && first(x)==1.0 && last(x)==1000.0
validwrapper=x->x isa PyArray && size(x)==(1000,) && first(x)==0 && last(x)==999
validbufferwrapper=x->x isa PyArray && size(x)==(1000,) && first(x)==1.0 && last(x)==1000.0
cases=[
expanded_case("single_rule",Int,ns.Success(),validint),
expanded_case("decline_then_success",Int,ns.Declines(),validint),
expanded_case("three_declines_then_success",Int,ns.ThreeDeclines(),validint),
expanded_case("sixteen_scoped_decliners",Int,ns.Scoped(),validint),
expanded_case("virtual_mapping_abc",Int,ns.VirtualMapping(),validint),
expanded_case("buffer_only_native",Array,buffer,validbuffer),
expanded_case("buffer_only_wrapper",PyArray,buffer,validbufferwrapper),
expanded_case("interface_only_native",Array,interface,validarray),
expanded_case("array_method_only_native",Array,arrayonly,validarray),
expanded_case("ndarray_native",Array,owner,validarray),
expanded_case("ndarray_wrapper",PyArray,owner,validwrapper),
]
# Verify the dispatch fixtures themselves, outside the measured regions.
for (i,expected) in enumerate(EXPANDED_NEW_API ? (1,2,4,1) : (1,2,4,17))
case=cases[i]
# The first fixtures are distinct Python classes, each with the indicated
# number of eligible rules. This also detects accidental rule deduplication.
t=(ns.Success,ns.Declines,ns.ThreeDeclines,ns.Scoped)[i]
if EXPANDED_NEW_API
@assert count(r->r.eligible,pyconvert_rules(Int,t()))==expected case.name
else
@assert length(ExpandedConvert.pyconvert_get_rules(Int,t))==expected case.name
end
end
preference_names=("two_matching_first_success","two_matching_first_declines",
"two_matching_decline_then_ordinary")
preference_cases=if EXPANDED_NEW_API
x=ns.Preference()
source=prefix*"Preference"
[(;name=preference_names[1],run=()->pyconvert(Any,x;prefer=(source=>Int,source=>String))),
(;name=preference_names[2],run=()->pyconvert(Any,x;prefer=(source=>String,source=>Int))),
(;name=preference_names[3],run=()->pyconvert(Any,x;prefer=(source=>String,source=>Float64)))]
else
[]
end

open(joinpath(@__DIR__,"convert-expanded-$label.tsv"),"w") do io
println(io,"case\tmode\tstatus\tmin_ns\tmedian_ns\tbytes\tallocs")
for case in cases
f=case.run
@assert case.valid(f()) case.name
cache=expanded_cache(case.target)
for mode in (warm_only ? ("warm",) : ("warm","selection_miss","invalidated"))
trial=if mode=="warm"
@benchmark $f() seconds=1.0 samples=5000
elseif mode=="selection_miss"
@benchmark $f() setup=(empty!($cache)) seconds=0.4 samples=200 evals=1
else
@benchmark $f() setup=(expanded_invalidate!()) seconds=0.4 samples=40 evals=1
end
m,d=minimum(trial),median(trial)
println(io,"$(case.name)\t$mode\tmeasured\t$(m.time)\t$(d.time)\t$(m.memory)\t$(m.allocs)")
end
flush(io)
end
for case in preference_cases
f=case.run
@assert f()===42 case.name
trial=@benchmark $f() seconds=1.0 samples=5000
m,d=minimum(trial),median(trial)
println(io,"$(case.name)\twarm\tmeasured\t$(m.time)\t$(d.time)\t$(m.memory)\t$(m.allocs)")
end
if !EXPANDED_NEW_API
for name in preference_names
println(io,"$name\twarm\tunsupported\tNA\tNA\tNA\tNA")
end
end
end
open(joinpath(@__DIR__,"convert-expanded-$label-environment.txt"),"w") do io
println(io,"Julia: ",VERSION)
println(io,"Python: ",pyconvert(String,pyimport("sys").version))
println(io,"Python executable: ",pyconvert(String,pyimport("sys").executable))
println(io,"NumPy: ",pyconvert(String,ns.numpy.__version__))
println(io,"BenchmarkTools: ",pkgversion(BenchmarkTools))
println(io,"PythonCall source: ",pathof(PythonCall))
println(io,"New API: ",EXPANDED_NEW_API)
for file in ("Convert/pyconvert.jl","Convert/rules.jl","Wrap/PyArray.jl")
println(io,file," SHA256: ",bytes2hex(sha256(read(joinpath(dirname(pathof(PythonCall)),file)))))
end
end
63 changes: 63 additions & 0 deletions benchmark/convert-final.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Same harness on original v1 and the final working tree. No new-API dependency.
# julia --project=benchmark benchmark/convert-final.jl after-r1
using PythonCall, BenchmarkTools, SHA

final_value(::Val{T}, x) where T = pyconvert(T,x,nothing)
label = isempty(ARGS) ? "after-r1" : only(ARGS)
newapi = isdefined(PythonCall,:PyConvertContext)
fixtures = [
("int",Int,pyint(10000),x->x===10000),
("integer",Integer,pyint(42),x->x===42),
("bigint",BigInt,pyint(big(2)^200),x->x==big(2)^200),
("float",Float64,pyfloat(1.5),x->x===1.5),
("bool",Bool,pybool(true),x->x===true),
("string",String,pystr("hello"),x->x=="hello"),
("none",Nothing,pybuiltins.None,isnothing),
("failure",Int,pystr("oops"),isnothing),
("list10",Vector,pylist(1:10),x->x==collect(1:10)),
("list10000",Vector,pylist(1:10000),x->x==collect(1:10000)),
("list10000_concrete",Vector{Int},pylist(1:10000),x->x==collect(1:10000)),
("mixed_numeric",Vector,pylist([pyint(1),pyfloat(2.5)]),x->x==[1,2.5]),
("heterogeneous",Vector,pylist([pyint(1),pystr("two"),pybuiltins.None]),x->isequal(x,Any[1,"two",nothing])),
("nested_concrete",Vector{Vector{Int}},pylist([pylist(1:5),pylist(6:10)]),x->x==[collect(1:5),collect(6:10)]),
# Any intentionally changes from wrapping to native recursive conversion.
("nested_any",Any,pylist([pylist(1:5),pylist(6:10)]),x->newapi ? x isa Vector{Vector{Int}} : x isa PyList),
("dict",Dict,pydict("a"=>1,"b"=>2.5),x->x==Dict("a"=>1,"b"=>2.5)),
("dict_concrete",Dict{String,Float64},pydict("a"=>1,"b"=>2.5),x->x==Dict("a"=>1.0,"b"=>2.5)),
("empty_list",Vector,pylist(),x->x isa Vector && isempty(x)),
("empty_dict",Dict,pydict(),x->x isa Dict && isempty(x)),
("wrapper",PyList,pylist(1:10),x->x isa PyList && length(x)==10),
("iterator",Any,pyiter(pylist(1:10)),x->x isa PyIterable),
]
open(joinpath(@__DIR__,"convert-final-$label.tsv"),"w") do io
println(io,"case\tmode\tmin_ns\tmedian_ns\tbytes\tallocs")
for (i,(name,T,x,valid)) in enumerate(fixtures)
tag=Val(T)
@assert valid(final_value(tag,x)) name
modes=i<=8 ? ("typed","runtime_target") : ("typed",)
for mode in modes
trial = if mode=="typed"
@benchmark final_value($tag,$x) seconds=1.0 samples=5000
else
@benchmark pyconvert($T,$x,nothing) seconds=1.0 samples=5000
end
m,d=minimum(trial),median(trial)
println(io,"$name\t$mode\t$(m.time)\t$(d.time)\t$(m.memory)\t$(m.allocs)")
end
flush(io)
end
end
open(joinpath(@__DIR__,"convert-final-$label-environment.txt"),"w") do io
println(io,"Julia: ",VERSION)
println(io,"Python: ",pyconvert(String,pyimport("sys").version))
println(io,"Python executable: ",pyconvert(String,pyimport("sys").executable))
println(io,"BenchmarkTools: ",pkgversion(BenchmarkTools))
println(io,"PythonCall source: ",pathof(PythonCall))
println(io,"New API: ",newapi)
for file in ("Convert/pyconvert.jl","Convert/rules.jl","Wrap/PyArray.jl")
println(io,file," SHA256: ",bytes2hex(sha256(read(joinpath(dirname(pathof(PythonCall)),file)))))
end
for (name,T,x,_) in fixtures
println(io,name," result type: ",typeof(final_value(Val(T),x)))
end
end
24 changes: 24 additions & 0 deletions benchmark/convert-redesign.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Run with julia --project=benchmark benchmark/convert-redesign.jl baseline|redesign
using PythonCall, BenchmarkTools
label = isempty(ARGS) ? "redesign" : ARGS[1]
const CV = PythonCall.Convert
cases = [
("int", Int, pyint(42)), ("integer", Integer, pyint(42)),
("bigint", BigInt, pyint(big(2)^200)), ("float", Float64, pyfloat(1.5)),
("bool", Bool, pybool(true)), ("string", String, pystr("hello")),
("none", Nothing, pybuiltins.None), ("failure", Int, pystr("oops")),
("list10", Vector, pylist(1:10)), ("list10000", Vector, pylist(1:10000)),
("mixed", Vector, pylist([1, 2.5, "three", nothing])),
("nested", Any, pylist([pylist(1:5), pylist(6:10)])),
("dict", Dict, pydict("a"=>1, "b"=>2.5)),
("wrapper", PyList, pylist(1:10)),
("iterator", Any, pyiter(pylist(1:10))),
]
open(joinpath(@__DIR__, "convert-$label.tsv"), "w") do io
println(io, "case\tns\tbytes\tallocs")
for (name, T, x) in cases
trial = @benchmark pyconvert($T, $x, nothing) seconds=0.3 samples=1000
m = minimum(trial)
println(io, "$name\t$(m.time)\t$(m.memory)\t$(m.allocs)")
end
end
Loading
Loading