Skip to content

Commit 4adeb0a

Browse files
Fix: support CPython free-threaded builds (3.14t) (#746)
* fix: support CPython free-threaded builds Co-authored-by: Miles Cranmer <[email protected]> * ci: test with python3.14t Co-authored-by: Miles Cranmer <[email protected]> * refactor: centralize free-threaded layout branching Co-authored-by: Miles Cranmer <[email protected]> * fix: make @ft macro hygienic * run freethreading tests as cases of existing jobs and include python tests * skip pycall tests on free-threaded python * simplify implementation of ft macro * add a comment for later * update changelog * extra care to ensure pythoncall uses the correct python executable --------- Co-authored-by: Miles Cranmer <[email protected]> Co-authored-by: Christopher Rowley <github.com/cjdoris>
1 parent 9de4d94 commit 4adeb0a

File tree

8 files changed

+145
-35
lines changed

8 files changed

+145
-35
lines changed

.github/workflows/tests.yml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212

1313
jobs:
1414
julia:
15-
name: Julia (${{ matrix.jlversion }}, ${{ matrix.os }}, ${{ matrix.pythonexe }})
15+
name: Julia (${{ matrix.jlversion }}, ${{ matrix.os }}, ${{ matrix.pythonexe }} ${{ matrix.pyversion}})
1616
runs-on: ${{ matrix.os }}
1717
strategy:
1818
fail-fast: false
@@ -21,11 +21,18 @@ jobs:
2121
os: [ubuntu-latest, windows-latest, macos-latest]
2222
jlversion: ['1','1.10']
2323
pythonexe: ['@CondaPkg']
24+
pyversion: ['3']
2425
include:
2526
- arch: x64
2627
os: ubuntu-latest
2728
jlversion: '1'
2829
pythonexe: python
30+
pyversion: '3'
31+
- arch: x64
32+
os: ubuntu-latest
33+
jlversion: '1'
34+
pythonexe: python
35+
pyversion: '3.14t'
2936

3037
steps:
3138
- uses: actions/checkout@v6
@@ -43,20 +50,27 @@ jobs:
4350
env:
4451
PYTHON: python
4552

53+
- name: Install Python
54+
id: setup-python
55+
uses: actions/setup-python@v6
56+
if: ${{ matrix.pythonexe == 'python' }}
57+
with:
58+
python-version: ${{ matrix.pyversion }}
59+
4660
- name: Build PyCall
4761
if: ${{ matrix.pythonexe == 'python' }}
4862
run: |
4963
julia --project=test -e 'import Pkg; Pkg.build("PyCall")'
5064
env:
51-
PYTHON: python
65+
PYTHON: ${{ steps.setup-python.outputs.python-path }}
5266

5367
- name: Run tests
5468
uses: julia-actions/julia-runtest@v1
5569
env:
5670
JULIA_DEBUG: PythonCall
5771
JULIA_NUM_THREADS: '2'
58-
PYTHON: python
59-
JULIA_PYTHONCALL_EXE: ${{ matrix.pythonexe }}
72+
PYTHON: ${{ steps.setup-python.outputs.python-path }}
73+
JULIA_PYTHONCALL_EXE: ${{ case(matrix.pythonexe == 'python', steps.setup-python.outputs.python-path, matrix.pythonexe) }}
6074

6175
- name: Process coverage
6276
uses: julia-actions/julia-processcoverage@v1
@@ -79,6 +93,9 @@ jobs:
7993
- os: ubuntu-latest
8094
pyversion: '3'
8195
juliaexe: julia
96+
- os: ubuntu-latest
97+
pyversion: '3.14t'
98+
juliaexe: '@JuliaPkg'
8299
env:
83100
MANUAL_TEST_PROJECT: /tmp/juliacall-test-project
84101
PYTHON_JULIACALL_THREADS: '2'

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
to the corresponding NumPy dtype, like `numpy.dtype(jl.Int)`.
66
* JuliaCall now launches Julia with 1 thread by default.
77
* Added options `trace_compile` and `trace_compile_timing` to JuliaCall.
8+
* Initial experimental support for free-threaded Python 3.14.
89
* Bug fixes.
910

1011
## 0.9.31 (2025-12-17)

src/C/consts.jl

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ end
124124
type::Ptr{Cvoid} = C_NULL # really is Ptr{PyObject} or Ptr{PyTypeObject} but Julia 1.3 and below get the layout incorrect when circular types are involved
125125
end
126126

127+
@kwdef struct PyMutex
128+
bits::Cuchar = 0
129+
end
130+
131+
@kwdef struct PyObjectFT
132+
tid::Csize_t = 0
133+
flags::Cushort = 0
134+
mutex::PyMutex = PyMutex()
135+
gc_bits::Cuchar = 0
136+
ref_local::Cuint = 0
137+
ref_shared::Py_ssize_t = 0
138+
type::Ptr{Cvoid} = C_NULL # really is Ptr{PyObject} or Ptr{PyTypeObject} but Julia 1.3 and below get the layout incorrect when circular types are involved
139+
end
140+
127141
const PyPtr = Ptr{PyObject}
128142
const PyNULL = PyPtr(0)
129143

@@ -139,6 +153,11 @@ Base.unsafe_convert(::Type{PyPtr}, o::PyObjectRef) = o.ptr
139153
size::Py_ssize_t = 0
140154
end
141155

156+
@kwdef struct PyVarObjectFT
157+
ob_base::PyObjectFT = PyObjectFT()
158+
size::Py_ssize_t = 0
159+
end
160+
142161
@kwdef struct PyMethodDef
143162
name::Cstring = C_NULL
144163
meth::Ptr{Cvoid} = C_NULL
@@ -249,6 +268,16 @@ end
249268
weakreflist::PyPtr = PyNULL
250269
end
251270

271+
@kwdef struct PyMemoryViewObjectFT
272+
ob_base::PyVarObjectFT = PyVarObjectFT()
273+
mbuf::PyPtr = PyNULL
274+
hash::Py_hash_t = 0
275+
flags::Cint = 0
276+
exports::Py_ssize_t = 0
277+
view::Py_buffer = Py_buffer()
278+
weakreflist::PyPtr = PyNULL
279+
end
280+
252281
@kwdef struct PyTypeObject
253282
ob_base::PyVarObject = PyVarObject()
254283
name::Cstring = C_NULL
@@ -327,6 +356,11 @@ end
327356
value::T
328357
end
329358

359+
@kwdef struct PySimpleObjectFT{T}
360+
ob_base::PyObjectFT = PyObjectFT()
361+
value::T
362+
end
363+
330364
@kwdef struct PyArrayInterface
331365
two::Cint = 0
332366
nd::Cint = 0

src/C/context.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A handle to a loaded instance of libpython, its interpreter, function pointers,
1717
pyhome_w::Any = missing
1818
which::Symbol = :unknown # :CondaPkg, :PyCall, :embedded or :unknown
1919
version::Union{VersionNumber,Missing} = missing
20+
is_free_threaded::Bool = false
2021
end
2122

2223
const CTX = Context()
@@ -312,10 +313,11 @@ function init_context()
312313
v"3.10" CTX.version < v"4" || error(
313314
"Only Python 3.10+ is supported, this is Python $(CTX.version) at $(CTX.exe_path===missing ? "unknown location" : CTX.exe_path).",
314315
)
316+
CTX.is_free_threaded = occursin("free-threading build", verstr)
315317

316318
launch_on_main_thread(Threads.threadid()) # makes on_main_thread usable
317319

318-
@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
320+
@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 CTX.is_free_threaded
319321

320322
return
321323
end

src/C/extras.jl

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,88 @@
11
asptr(x) = Base.unsafe_convert(PyPtr, x)
22

3-
Py_Type(x) = Base.GC.@preserve x PyPtr(UnsafePtr(asptr(x)).type[!])
3+
# Free-threaded CPython builds ("3.14t") currently have different C struct layouts,
4+
# but there is no stable ABI yet. To keep the code manageable, we centralize the
5+
# branching in a single macro that rewrites type names in the expression.
6+
const _FT_TYPE_REPLACEMENTS = Dict{Symbol,Symbol}(
7+
:PyObject => :PyObjectFT,
8+
:PyVarObject => :PyVarObjectFT,
9+
:PyMemoryViewObject => :PyMemoryViewObjectFT,
10+
:PySimpleObject => :PySimpleObjectFT,
11+
# Used from JlWrap/C.jl via `C.@ft`.
12+
:PyJuliaValueObject => :PyJuliaValueObjectFT,
13+
)
14+
15+
function _ft_transform(ex)
16+
if ex isa Symbol
17+
return get(_FT_TYPE_REPLACEMENTS, ex, ex)
18+
elseif ex isa QuoteNode
19+
return QuoteNode(_ft_transform(ex.value))
20+
elseif ex isa Expr
21+
return Expr(ex.head, map(_ft_transform, ex.args)...)
22+
else
23+
return ex
24+
end
25+
end
26+
27+
"""
28+
@ft expr
29+
30+
Evaluate `expr`, but when `CTX.is_free_threaded` is true (CPython "free-threaded"
31+
builds), rewrite internal type names like `PyObject` → `PyObjectFT` inside the
32+
expression.
33+
34+
This keeps free-threaded branching centralized, so we don't scatter `if
35+
CTX.is_free_threaded` throughout the code.
36+
"""
37+
macro ft(ex)
38+
ex_ft = _ft_transform(ex)
39+
m = @__MODULE__
40+
return esc(:($m.CTX.is_free_threaded ? $ex_ft : $ex))
41+
end
42+
43+
Py_Type(x) = Base.GC.@preserve x @ft PyPtr(UnsafePtr{PyObject}(asptr(x)).type[!])
444

545
PyObject_Type(x) = Base.GC.@preserve x (t = Py_Type(asptr(x)); Py_IncRef(t); t)
646

747
Py_TypeCheck(o, t) = Base.GC.@preserve o t PyType_IsSubtype(Py_Type(asptr(o)), asptr(t))
848
Py_TypeCheckFast(o, f::Integer) = Base.GC.@preserve o PyType_IsSubtypeFast(Py_Type(asptr(o)), f)
949

1050
PyType_IsSubtypeFast(t, f::Integer) =
11-
Base.GC.@preserve t Cint(!iszero(UnsafePtr{PyTypeObject}(asptr(t)).flags[] & f))
51+
Base.GC.@preserve t Cint(!iszero(PyType_GetFlags(asptr(t)) & f))
1252

13-
PyMemoryView_GET_BUFFER(m) = Base.GC.@preserve m Ptr{Py_buffer}(UnsafePtr{PyMemoryViewObject}(asptr(m)).view)
53+
PyMemoryView_GET_BUFFER(m) = Base.GC.@preserve m @ft Ptr{Py_buffer}(UnsafePtr{PyMemoryViewObject}(asptr(m)).view)
1454

1555
PyType_CheckBuffer(t) = Base.GC.@preserve t begin
16-
p = UnsafePtr{PyTypeObject}(asptr(t)).as_buffer[]
17-
return p != C_NULL && p.get[!] != C_NULL
56+
getbuf = PyType_GetSlot(asptr(t), Py_bf_getbuffer)
57+
return getbuf != C_NULL
1858
end
1959

2060
PyObject_CheckBuffer(o) = Base.GC.@preserve o PyType_CheckBuffer(Py_Type(asptr(o)))
2161

2262
PyObject_GetBuffer(_o, b, flags) = Base.GC.@preserve _o begin
2363
o = asptr(_o)
24-
p = UnsafePtr{PyTypeObject}(Py_Type(o)).as_buffer[]
25-
if p == C_NULL || p.get[!] == C_NULL
26-
PyErr_SetString(
27-
POINTERS.PyExc_TypeError,
28-
"a bytes-like object is required, not '$(String(UnsafePtr{PyTypeObject}(Py_Type(o)).name[]))'",
29-
)
64+
getbuf = PyType_GetSlot(Py_Type(o), Py_bf_getbuffer)
65+
if getbuf == C_NULL
66+
# TODO: we can drop this branch and just use PyType_GetName once we stop
67+
# supporting python 3.10
68+
msg = if CTX.is_free_threaded
69+
"a bytes-like object is required"
70+
else
71+
"a bytes-like object is required, not '$(String(UnsafePtr{PyTypeObject}(Py_Type(o)).name[]))'"
72+
end
73+
PyErr_SetString(POINTERS.PyExc_TypeError, msg)
3074
return Cint(-1)
3175
end
32-
return ccall(p.get[!], Cint, (PyPtr, Ptr{Py_buffer}, Cint), o, b, flags)
76+
return ccall(getbuf, Cint, (PyPtr, Ptr{Py_buffer}, Cint), o, b, flags)
3377
end
3478

3579
PyBuffer_Release(_b) = begin
3680
b = UnsafePtr(Base.unsafe_convert(Ptr{Py_buffer}, _b))
3781
o = b.obj[]
3882
o == C_NULL && return
39-
p = UnsafePtr{PyTypeObject}(Py_Type(o)).as_buffer[]
40-
if (p != C_NULL && p.release[!] != C_NULL)
41-
ccall(p.release[!], Cvoid, (PyPtr, Ptr{Py_buffer}), o, b)
83+
releasebuf = PyType_GetSlot(Py_Type(o), Py_bf_releasebuffer)
84+
if releasebuf != C_NULL
85+
ccall(releasebuf, Cvoid, (PyPtr, Ptr{Py_buffer}), o, b)
4286
end
4387
b.obj[] = C_NULL
4488
Py_DecRef(o)
@@ -65,7 +109,7 @@ function PyOS_RunInputHook()
65109
end
66110

67111
function PySimpleObject_GetValue(::Type{T}, o) where {T}
68-
Base.GC.@preserve o UnsafePtr{PySimpleObject{T}}(asptr(o)).value[!]
112+
Base.GC.@preserve o @ft UnsafePtr{PySimpleObject{T}}(asptr(o)).value[!]
69113
end
70114

71115
# FAST REFCOUNTING

src/C/pointers.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ const CAPI_FUNC_SIGS = Dict{Symbol,Pair{Tuple,Type}}(
7777
:PyType_Ready => (PyPtr,) => Cint,
7878
:PyType_GenericNew => (PyPtr, PyPtr, PyPtr) => PyPtr,
7979
:PyType_FromSpec => (Ptr{Cvoid},) => PyPtr,
80+
:PyType_GetFlags => (PyPtr,) => Culong,
81+
:PyType_GetSlot => (PyPtr, Cint) => Ptr{Cvoid},
8082
# MAPPING
8183
:PyMapping_HasKeyString => (PyPtr, Ptr{Cchar}) => Cint,
8284
:PyMapping_SetItemString => (PyPtr, Ptr{Cchar}, PyPtr) => Cint,

src/JlWrap/C.jl

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ using Serialization: serialize, deserialize
1212
weaklist::C.PyPtr = C_NULL
1313
end
1414

15+
@kwdef struct PyJuliaValueObjectFT
16+
ob_base::C.PyObjectFT = C.PyObjectFT()
17+
value::Int = 0
18+
weaklist::C.PyPtr = C_NULL
19+
end
20+
1521
const PyJuliaBase_Type = Ref(C.PyNULL)
1622

1723
# we store the actual julia values here
@@ -21,21 +27,24 @@ const PYJLVALUES = []
2127
const PYJLFREEVALUES = Int[]
2228

2329
function _pyjl_new(t::C.PyPtr, ::C.PyPtr, ::C.PyPtr)
24-
o = ccall(UnsafePtr{C.PyTypeObject}(t).alloc[!], C.PyPtr, (C.PyPtr, C.Py_ssize_t), t, 0)
30+
alloc = C.PyType_GetSlot(t, C.Py_tp_alloc)
31+
alloc == C_NULL && return C.PyNULL
32+
o = ccall(alloc, C.PyPtr, (C.PyPtr, C.Py_ssize_t), t, 0)
2533
o == C.PyNULL && return C.PyNULL
26-
UnsafePtr{PyJuliaValueObject}(o).weaklist[] = C.PyNULL
27-
UnsafePtr{PyJuliaValueObject}(o).value[] = 0
34+
C.@ft UnsafePtr{PyJuliaValueObject}(o).weaklist[] = C.PyNULL
35+
C.@ft UnsafePtr{PyJuliaValueObject}(o).value[] = 0
2836
return o
2937
end
3038

3139
function _pyjl_dealloc(o::C.PyPtr)
32-
idx = UnsafePtr{PyJuliaValueObject}(o).value[]
40+
idx = C.@ft UnsafePtr{PyJuliaValueObject}(o).value[]
3341
if idx != 0
3442
PYJLVALUES[idx] = nothing
3543
push!(PYJLFREEVALUES, idx)
3644
end
37-
UnsafePtr{PyJuliaValueObject}(o).weaklist[!] == C.PyNULL || C.PyObject_ClearWeakRefs(o)
38-
ccall(UnsafePtr{C.PyTypeObject}(C.Py_Type(o)).free[!], Cvoid, (C.PyPtr,), o)
45+
(C.@ft UnsafePtr{PyJuliaValueObject}(o).weaklist[!]) == C.PyNULL || C.PyObject_ClearWeakRefs(o)
46+
freeptr = C.PyType_GetSlot(C.Py_Type(o), C.Py_tp_free)
47+
freeptr == C_NULL || ccall(freeptr, Cvoid, (C.PyPtr,), o)
3948
nothing
4049
end
4150

@@ -319,7 +328,7 @@ function init_c()
319328
C.PyMemberDef(
320329
name = pointer(_pyjlbase_weaklistoffset_name),
321330
typ = C.Py_T_PYSSIZET,
322-
offset = fieldoffset(PyJuliaValueObject, 3),
331+
offset = (C.@ft fieldoffset(PyJuliaValueObject, 3)),
323332
flags = C.Py_READONLY,
324333
),
325334
C.PyMemberDef(), # NULL terminator
@@ -341,7 +350,7 @@ function init_c()
341350
# Create PyType_Spec
342351
_pyjlbase_spec[] = C.PyType_Spec(
343352
name = pointer(_pyjlbase_name),
344-
basicsize = sizeof(PyJuliaValueObject),
353+
basicsize = (C.@ft sizeof(PyJuliaValueObject)),
345354
flags = C.Py_TPFLAGS_BASETYPE | C.Py_TPFLAGS_HAVE_VERSION_TAG,
346355
slots = pointer(_pyjlbase_slots),
347356
)
@@ -358,13 +367,13 @@ function __init__()
358367
init_c()
359368
end
360369

361-
PyJuliaValue_IsNull(o) = Base.GC.@preserve o UnsafePtr{PyJuliaValueObject}(C.asptr(o)).value[] == 0
370+
PyJuliaValue_IsNull(o) = Base.GC.@preserve o (C.@ft UnsafePtr{PyJuliaValueObject}(C.asptr(o)).value[]) == 0
362371

363-
PyJuliaValue_GetValue(o) = Base.GC.@preserve o PYJLVALUES[UnsafePtr{PyJuliaValueObject}(C.asptr(o)).value[]]
372+
PyJuliaValue_GetValue(o) = Base.GC.@preserve o PYJLVALUES[(C.@ft UnsafePtr{PyJuliaValueObject}(C.asptr(o)).value[])]
364373

365374
PyJuliaValue_SetValue(_o, @nospecialize(v)) = Base.GC.@preserve _o begin
366375
o = C.asptr(_o)
367-
idx = UnsafePtr{PyJuliaValueObject}(o).value[]
376+
idx = C.@ft UnsafePtr{PyJuliaValueObject}(o).value[]
368377
if idx == 0
369378
if isempty(PYJLFREEVALUES)
370379
push!(PYJLVALUES, v)
@@ -373,7 +382,7 @@ PyJuliaValue_SetValue(_o, @nospecialize(v)) = Base.GC.@preserve _o begin
373382
idx = pop!(PYJLFREEVALUES)
374383
PYJLVALUES[idx] = v
375384
end
376-
UnsafePtr{PyJuliaValueObject}(o).value[] = idx
385+
C.@ft UnsafePtr{PyJuliaValueObject}(o).value[] = idx
377386
else
378387
PYJLVALUES[idx] = v
379388
end

test/Compat.jl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ end
5050
end
5151

5252
@testitem "PyCall.jl" begin
53-
if (get(ENV, "CI", "") != "") && (ENV["JULIA_PYTHONCALL_EXE"] == "python")
53+
if (get(ENV, "CI", "") != "") && (ENV["JULIA_PYTHONCALL_EXE"] == "python") && !PythonCall.C.CTX.is_free_threaded
5454
# Only run this test when we can guarantee PyCall and PythonCall are using the
5555
# same Python. Currently this only runs in CI, and if PythonCall is using the
56-
# system Python installation.
56+
# system Python installation. Also PyCall is not compatible with free-threaded
57+
# python so we skip this too.
5758
using PyCall
5859
# Check they are indeed using the same Python.
5960
@test Base.get_extension(PythonCall, :PyCallExt).SAME[]

0 commit comments

Comments
 (0)