From f0b75ddde300b9156f2beff9996c747fd3f2b986 Mon Sep 17 00:00:00 2001 From: XXXXRT666 <157766680+XXXXRT666@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:46:48 +0100 Subject: [PATCH 1/4] Freeze bound types on Python 3.15 --- python/src/array.cpp | 53 +++++++++++++++++++++-------------- python/src/device.cpp | 17 ++++++----- python/src/distributed.cpp | 3 +- python/src/export.cpp | 3 +- python/src/print.cpp | 6 ++-- python/src/random.cpp | 7 +++-- python/src/stream.cpp | 17 +++++++---- python/src/transforms.cpp | 3 +- python/tests/test_bindings.py | 48 +++++++++++++++++++++++++++++++ 9 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 python/tests/test_bindings.py diff --git a/python/src/array.cpp b/python/src/array.cpp index beb8974ff9..91cbf4656f 100644 --- a/python/src/array.cpp +++ b/python/src/array.cpp @@ -129,9 +129,10 @@ void init_array(nb::module_& m) { return nb::isinstance(other) && t == nb::cast(other); }) - .def("__hash__", [](const mx::Dtype& t) { - return static_cast(t.val()); - }); + .def( + "__hash__", + [](const mx::Dtype& t) { return static_cast(t.val()); }) + .freeze(); m.attr("bool_") = nb::cast(mx::bool_); m.attr("uint8") = nb::cast(mx::uint8); @@ -229,13 +230,16 @@ void init_array(nb::module_& m) { &mx::finfo::smallest_normal, R"pbdoc(The smallest positive normal number.)pbdoc") .def_ro("dtype", &mx::finfo::dtype, R"pbdoc(The :obj:`Dtype`.)pbdoc") - .def("__repr__", [](const mx::finfo& f) { - std::ostringstream os; - os << "finfo(" - << "min=" << f.min << ", max=" << f.max << ", dtype=" << f.dtype - << ")"; - return os.str(); - }); + .def( + "__repr__", + [](const mx::finfo& f) { + std::ostringstream os; + os << "finfo(" + << "min=" << f.min << ", max=" << f.max << ", dtype=" << f.dtype + << ")"; + return os.str(); + }) + .freeze(); nb::class_( m, @@ -253,13 +257,16 @@ void init_array(nb::module_& m) { &mx::iinfo::max, R"pbdoc(The largest representable number.)pbdoc") .def_ro("dtype", &mx::iinfo::dtype, R"pbdoc(The :obj:`Dtype`.)pbdoc") - .def("__repr__", [](const mx::iinfo& i) { - std::ostringstream os; - os << "iinfo(" - << "min=" << i.min << ", max=" << i.max << ", dtype=" << i.dtype - << ")"; - return os.str(); - }); + .def( + "__repr__", + [](const mx::iinfo& i) { + std::ostringstream os; + os << "iinfo(" + << "min=" << i.min << ", max=" << i.max << ", dtype=" << i.dtype + << ")"; + return os.str(); + }) + .freeze(); nb::class_( m, @@ -274,7 +281,8 @@ void init_array(nb::module_& m) { .def("multiply", &ArrayAt::multiply, "value"_a) .def("divide", &ArrayAt::divide, "value"_a) .def("maximum", &ArrayAt::maximum, "value"_a) - .def("minimum", &ArrayAt::minimum, "value"_a); + .def("minimum", &ArrayAt::minimum, "value"_a) + .freeze(); nb::class_( m, @@ -283,7 +291,8 @@ void init_array(nb::module_& m) { Any Python object which has an ``__mlx__array__`` method that returns an :obj:`array`. )pbdoc") - .def(nb::init_implicit()); + .def(nb::init_implicit()) + .freeze(); nb::class_( m, @@ -292,7 +301,8 @@ void init_array(nb::module_& m) { A helper object to iterate over the 1st dimension of an array. )pbdoc") .def("__next__", &ArrayPythonIterator::next) - .def("__iter__", [](const ArrayPythonIterator& it) { return it; }); + .def("__iter__", [](const ArrayPythonIterator& it) { return it; }) + .freeze(); // Install buffer protocol functions PyType_Slot array_slots[] = { @@ -1582,5 +1592,6 @@ void init_array(nb::module_& m) { "dtype"_a, nb::kw_only(), "stream"_a = nb::none(), - "See :func:`view`."); + "See :func:`view`.") + .freeze(); } diff --git a/python/src/device.cpp b/python/src/device.cpp index 83d32d1cb6..1f654f5989 100644 --- a/python/src/device.cpp +++ b/python/src/device.cpp @@ -43,13 +43,16 @@ void init_device(nb::module_& m) { os << d; return os.str(); }) - .def("__eq__", [](const mx::Device& d, const nb::object& other) { - if (!nb::isinstance(other) && - !nb::isinstance(other)) { - return false; - } - return d == nb::cast(other); - }); + .def( + "__eq__", + [](const mx::Device& d, const nb::object& other) { + if (!nb::isinstance(other) && + !nb::isinstance(other)) { + return false; + } + return d == nb::cast(other); + }) + .freeze(); nb::implicitly_convertible(); diff --git a/python/src/distributed.cpp b/python/src/distributed.cpp index 0ad91c6e48..0d1e341f92 100644 --- a/python/src/distributed.cpp +++ b/python/src/distributed.cpp @@ -51,7 +51,8 @@ void init_distributed(nb::module_& parent_module) { color (int): A value to group processes into subgroups. key (int, optional): A key to optionally change the rank ordering of the processes. - )pbdoc"); + )pbdoc") + .freeze(); m.def( "is_available", diff --git a/python/src/export.cpp b/python/src/export.cpp index 7748f87b14..1becd22bcc 100644 --- a/python/src/export.cpp +++ b/python/src/export.cpp @@ -298,7 +298,8 @@ void init_export(nb::module_& m) { auto [args_, kwargs_] = validate_and_extract_inputs(args, kwargs, "[export_function]"); exporter(args_, kwargs_); - }); + }) + .freeze(); m.def( "exporter", diff --git a/python/src/print.cpp b/python/src/print.cpp index 6c08d88d3f..685860be85 100644 --- a/python/src/print.cpp +++ b/python/src/print.cpp @@ -33,7 +33,8 @@ void init_print(nb::module_& m) { // Expose printing options to Python: allow setting global precision. nb::class_(m, "PrintOptions") .def(nb::init(), "precision"_a = -1) - .def_rw("precision", &mx::PrintOptions::precision); + .def_rw("precision", &mx::PrintOptions::precision) + .freeze(); m.def( "set_printoptions", @@ -64,7 +65,8 @@ void init_print(nb::module_& m) { nb::class_(m, "_PrintOptionsContext") .def(nb::init()) .def("__enter__", &PrintOptionsContext::enter) - .def("__exit__", &PrintOptionsContext::exit); + .def("__exit__", &PrintOptionsContext::exit) + .freeze(); m.def( "printoptions", diff --git a/python/src/random.cpp b/python/src/random.cpp index 10b82b8921..082f153765 100644 --- a/python/src/random.cpp +++ b/python/src/random.cpp @@ -107,9 +107,10 @@ void init_random(nb::module_& parent_module) { return default_key().state()[0]; }, "index"_a) - .def("__iter__", [](const RandomState&) { - return nb::iter(default_key().state()); - }); + .def( + "__iter__", + [](const RandomState&) { return nb::iter(default_key().state()); }) + .freeze(); m.def("__getattr__", [&](nb::handle key) -> nb::object { // Create random.state lazily to avoid initializing device during import. diff --git a/python/src/stream.cpp b/python/src/stream.cpp index 2ddb99179b..1ce7a4e962 100644 --- a/python/src/stream.cpp +++ b/python/src/stream.cpp @@ -73,10 +73,13 @@ void init_stream(nb::module_& m) { os << s; return os.str(); }) - .def("__eq__", [](const mx::Stream& s, const nb::object& other) { - return nb::isinstance(other) && - s == nb::cast(other); - }); + .def( + "__eq__", + [](const mx::Stream& s, const nb::object& other) { + return nb::isinstance(other) && + s == nb::cast(other); + }) + .freeze(); nb::class_( m, @@ -97,7 +100,8 @@ void init_stream(nb::module_& m) { [](const mx::ThreadLocalStream& s, const nb::object& other) { return nb::isinstance(other) && s == nb::cast(other); - }); + }) + .freeze(); nb::implicitly_convertible(); @@ -179,7 +183,8 @@ void init_stream(nb::module_& m) { const std::optional& traceback) { scm.exit(); }, "exc_type"_a = nb::none(), "exc_value"_a = nb::none(), - "traceback"_a = nb::none()); + "traceback"_a = nb::none()) + .freeze(); m.def( "stream", [](mx::StreamOrDevice s) { return PyStreamContext(s); }, diff --git a/python/src/transforms.cpp b/python/src/transforms.cpp index e33cde9bce..7252c8b9f8 100644 --- a/python/src/transforms.cpp +++ b/python/src/transforms.cpp @@ -1170,7 +1170,8 @@ void init_transforms(nb::module_& m) { return a pytree with the vectorization axes of each output. If some outputs are no longer vectorized, then their vectorization axis should be ``None``. - )pbdoc"); + )pbdoc") + .freeze(); m.def( "eval", diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py new file mode 100644 index 0000000000..0cef3e77c8 --- /dev/null +++ b/python/tests/test_bindings.py @@ -0,0 +1,48 @@ +# Copyright © 2026 Apple Inc. + +import sys +import unittest + +import mlx.core as mx +import mlx_tests + + +class TestBindings(mlx_tests.MLXTestCase): + @unittest.skipUnless(sys.version_info >= (3, 15), "requires Python 3.15") + def test_frozen_types(self): + types = ( + mx.array, + mx.Dtype, + mx.finfo, + mx.iinfo, + mx.ArrayAt, + mx.ArrayLike, + mx.ArrayIterator, + mx.Device, + mx.Stream, + mx.ThreadLocalStream, + mx.StreamContext, + mx.PrintOptions, + mx.custom_function, + mx.FunctionExporter, + mx.distributed.Group, + ) + for bound_type in types: + with self.subTest(type=bound_type): + with self.assertRaises(TypeError): + bound_type._test_attribute = None + with self.assertRaises(TypeError): + bound_type.__doc__ = "modified" + with self.assertRaises(TypeError): + del bound_type.__doc__ + + def test_array_subclass(self): + class Array(mx.array): + def total(self): + return self.sum().item() + + self.assertEqual(Array([1, 2, 3]).total(), 6) + + +if __name__ == "__main__": + mlx_tests.MLXTestRunner() From adacd80ba436e66ec7d901e336c2a2dc8ab8d943 Mon Sep 17 00:00:00 2001 From: XXXXRT666 <157766680+XXXXRT666@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:10:07 +0100 Subject: [PATCH 2/4] Update nanobind to 3.1.0 --- CMakeLists.txt | 2 +- docs/src/dev/extensions.rst | 2 +- examples/extensions/CMakeLists.txt | 4 ++-- python/src/CMakeLists.txt | 1 - python/tests/test_bindings.py | 15 +++++++++++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 01b329b2f2..3f2330483d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -396,7 +396,7 @@ if(MLX_BUILD_PYTHON_BINDINGS) FetchContent_Declare( nanobind GIT_REPOSITORY https://github.com/wjakob/nanobind.git - GIT_TAG v3.0.1 + GIT_TAG v3.1.0 GIT_SHALLOW TRUE EXCLUDE_FROM_ALL) FetchContent_MakeAvailable(nanobind) diff --git a/docs/src/dev/extensions.rst b/docs/src/dev/extensions.rst index 5f5fab6429..9876bde48a 100644 --- a/docs/src/dev/extensions.rst +++ b/docs/src/dev/extensions.rst @@ -679,7 +679,7 @@ Finally, we build the nanobind_ bindings nanobind_add_module( _ext - NB_STATIC STABLE_ABI LTO NOMINSIZE + NB_STATIC FREE_THREADED LTO NOMINSIZE NB_DOMAIN mlx ${CMAKE_CURRENT_LIST_DIR}/bindings.cpp ) diff --git a/examples/extensions/CMakeLists.txt b/examples/extensions/CMakeLists.txt index 014f712404..4438153650 100644 --- a/examples/extensions/CMakeLists.txt +++ b/examples/extensions/CMakeLists.txt @@ -23,7 +23,7 @@ find_package( FetchContent_Declare( nanobind GIT_REPOSITORY https://github.com/wjakob/nanobind.git - GIT_TAG v3.0.1 + GIT_TAG v3.1.0 GIT_SHALLOW TRUE EXCLUDE_FROM_ALL) FetchContent_MakeAvailable(nanobind) @@ -73,7 +73,7 @@ endif() nanobind_add_module( _ext NB_STATIC - STABLE_ABI + FREE_THREADED LTO NOMINSIZE NB_DOMAIN diff --git a/python/src/CMakeLists.txt b/python/src/CMakeLists.txt index 0798add410..d5cda4deef 100644 --- a/python/src/CMakeLists.txt +++ b/python/src/CMakeLists.txt @@ -1,7 +1,6 @@ nanobind_add_module( core NB_STATIC - STABLE_ABI FREE_THREADED LTO NOMINSIZE diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py index 0cef3e77c8..741694f4a3 100644 --- a/python/tests/test_bindings.py +++ b/python/tests/test_bindings.py @@ -1,5 +1,6 @@ # Copyright © 2026 Apple Inc. +import subprocess import sys import unittest @@ -43,6 +44,20 @@ def total(self): self.assertEqual(Array([1, 2, 3]).total(), 6) + @unittest.skipUnless(sys.version_info >= (3, 13), "requires Python 3.13") + def test_import_preserves_gil_state(self): + subprocess.run( + [ + sys.executable, + "-c", + "import sys; " + "before = sys._is_gil_enabled(); " + "import mlx.core; " + "assert sys._is_gil_enabled() == before", + ], + check=True, + ) + if __name__ == "__main__": mlx_tests.MLXTestRunner() From 3ac3a4fb575440f39530b67853b6c528aba91b52 Mon Sep 17 00:00:00 2001 From: XXXXRT666 <157766680+XXXXRT666@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:06:29 +0100 Subject: [PATCH 3/4] Fix Python object lifetimes in free-threaded builds Let garbage-collected wrappers own C++ callbacks so compiled caches and captured objects are released. Keep the random state sentinel alive without a Python reference-count operation during static destruction. --- python/src/mlx_func.cpp | 103 ++++++++-------------------------- python/src/mlx_func.h | 30 ++++++---- python/src/random.cpp | 8 +-- python/src/transforms.cpp | 8 ++- python/tests/test_bindings.py | 53 +++++++++++++++++ 5 files changed, 104 insertions(+), 98 deletions(-) diff --git a/python/src/mlx_func.cpp b/python/src/mlx_func.cpp index 9955e134f9..9f84f5c797 100644 --- a/python/src/mlx_func.cpp +++ b/python/src/mlx_func.cpp @@ -2,17 +2,11 @@ #include "python/src/mlx_func.h" -#include - -// A garbage collected function which wraps nb::cpp_function -// See https://github.com/wjakob/nanobind/discussions/919 +// The wrapper owns callbacks because free-threaded nanobind functions are +// immortal struct gc_func { - PyObject_HEAD - // Vector call implementation that forwards calls to nanobind - PyObject* (*vectorcall)(PyObject*, PyObject* const*, size_t, PyObject*); - // The nanobind wrapper func - PyObject* func; + std::unique_ptr func; // The original wrapped func PyObject* orig_func; @@ -22,97 +16,48 @@ struct gc_func { int gc_func_tp_traverse(PyObject* self, visitproc visit, void* arg) { Py_VISIT(Py_TYPE(self)); - gc_func* w = (gc_func*)self; - Py_VISIT(w->func); - for (auto d : w->deps) { - Py_VISIT(d); + if (nb::inst_ready(self)) { + for (auto d : nb::inst_ptr(self)->deps) { + Py_VISIT(d); + } } return 0; -}; +} int gc_func_tp_clear(PyObject* self) { - gc_func* w = (gc_func*)self; - Py_CLEAR(w->func); + auto* w = nb::inst_ptr(self); + w->orig_func = nullptr; + w->deps.clear(); + w->func.reset(); return 0; } -PyObject* gc_func_get_doc(PyObject* self, void*) { - return PyObject_GetAttrString(((gc_func*)self)->func, "__doc__"); -} - -PyObject* gc_func_get_sig(PyObject* self, void*) { - return PyObject_GetAttrString(((gc_func*)self)->func, "__nb_signature__"); -} - -PyObject* gc_func_vectorcall( - PyObject* self, - PyObject* const* args, - size_t nargs, - PyObject* kwnames) { - return PyObject_Vectorcall(((gc_func*)self)->func, args, nargs, kwnames); -} - -void gc_func_dealloc(PyObject* self) { - PyObject_GC_UnTrack(self); - Py_XDECREF(((gc_func*)self)->func); - PyObject_GC_Del(self); -} - -static PyMemberDef gc_func_members[] = { - {"__vectorcalloffset__", - T_PYSSIZET, - (Py_ssize_t)offsetof(gc_func, vectorcall), - READONLY, - nullptr}, - {nullptr, 0, 0, 0, nullptr}}; - -static PyGetSetDef gc_func_getset[] = { - {"__doc__", gc_func_get_doc, nullptr, nullptr, nullptr}, - {"__nb_signature__", gc_func_get_sig, nullptr, nullptr, nullptr}, - {nullptr, nullptr, nullptr, nullptr, nullptr}}; - static PyObject* gc_func_getattro(PyObject* self, PyObject* name_) { - gc_func* w = (gc_func*)self; - return PyObject_GenericGetAttr(w->orig_func, name_); + return PyObject_GenericGetAttr(nb::inst_ptr(self)->orig_func, name_); } // Table of custom type slots we want to install PyType_Slot gc_func_slots[] = { {Py_tp_traverse, (void*)gc_func_tp_traverse}, {Py_tp_clear, (void*)gc_func_tp_clear}, - {Py_tp_getset, (void*)gc_func_getset}, {Py_tp_getattro, (void*)gc_func_getattro}, - {Py_tp_members, (void*)gc_func_members}, - {Py_tp_call, (void*)PyVectorcall_Call}, - {Py_tp_dealloc, (void*)gc_func_dealloc}, {0, 0}}; -static PyType_Spec gc_func_spec = { - /* .name = */ "mlx.gc_func", - /* .basicsize = */ (int)sizeof(gc_func), - /* .itemsize = */ 0, - /* .flags = */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | - Py_TPFLAGS_HAVE_VECTORCALL, - /* .slots = */ gc_func_slots}; - -static PyTypeObject* gc_func_tp = nullptr; - nb::callable mlx_func( - nb::object func, + std::unique_ptr func, const nb::callable& orig_func, std::vector deps) { - gc_func* r = (gc_func*)PyType_GenericAlloc(gc_func_tp, 0); - r->func = func.inc_ref().ptr(); - r->orig_func = orig_func.ptr(); - deps.push_back(r->orig_func); - r->deps = std::move(deps); - r->vectorcall = gc_func_vectorcall; - return nb::steal((PyObject*)r); + deps.push_back(orig_func.ptr()); + return nb::borrow( + nb::cast(gc_func{std::move(func), orig_func.ptr(), std::move(deps)})); } void init_mlx_func(nb::module_& m) { - gc_func_tp = (PyTypeObject*)PyType_FromSpec(&gc_func_spec); - if (!gc_func_tp) { - nb::raise("Could not register MLX function type."); - } + nb::class_(m, "_gc_func", nb::type_slots(gc_func_slots)) + .def( + "__call__", + [](gc_func& self, nb::args args, nb::kwargs kwargs) { + return (*self.func)(args, kwargs); + }) + .freeze(); } diff --git a/python/src/mlx_func.h b/python/src/mlx_func.h index 79c8376dee..ffda53e329 100644 --- a/python/src/mlx_func.h +++ b/python/src/mlx_func.h @@ -2,30 +2,38 @@ #pragma once +#include +#include #include #include -#include namespace nb = nanobind; using namespace nb::literals; +struct PyFunction { + virtual ~PyFunction() = default; + virtual nb::object operator()(nb::args& args, nb::kwargs& kwargs) = 0; +}; + nb::callable mlx_func( - nb::object func, + std::unique_ptr func, const nb::callable& orig_func, std::vector deps); template nb::callable mlx_func(F func, const nb::callable& orig_func, Deps&&... deps) { - return mlx_func( - nb::cpp_function(std::move(func)), - orig_func, - std::vector{deps.ptr()...}); -} + struct Callback : PyFunction { + F func; + + explicit Callback(F func) : func(std::move(func)) {} -template -nb::callable -mlx_func(nb::object func, const nb::callable& orig_func, Deps&&... deps) { + nb::object operator()(nb::args& args, nb::kwargs& kwargs) override { + return nb::cast(func(args, kwargs)); + } + }; + std::unique_ptr callback = + std::make_unique(std::move(func)); return mlx_func( - std::move(func), orig_func, std::vector{deps.ptr()...}); + std::move(callback), orig_func, std::vector{deps.ptr()...}); } diff --git a/python/src/random.cpp b/python/src/random.cpp index 082f153765..8710a8d701 100644 --- a/python/src/random.cpp +++ b/python/src/random.cpp @@ -74,13 +74,9 @@ void reset_random_state() { class RandomState {}; nb::object random_state_sentinel() { - static nb::object sentinel = []() { - auto sentinel = nb::cast(RandomState{}); - sentinel.inc_ref(); - return sentinel; - }(); + static nb::handle sentinel = nb::cast(RandomState{}).release(); - return sentinel; + return nb::borrow(sentinel); } mx::array random_state_key() { diff --git a/python/src/transforms.cpp b/python/src/transforms.cpp index 7252c8b9f8..7b99364739 100644 --- a/python/src/transforms.cpp +++ b/python/src/transforms.cpp @@ -310,7 +310,11 @@ auto py_vmap( const nb::callable& fun, const nb::object& in_axes, const nb::object& out_axes) { - return [fun, in_axes, out_axes](const nb::args& args) { + return [fun, in_axes, out_axes]( + const nb::args& args, const nb::kwargs& kwargs) { + if (kwargs.size()) { + throw nb::type_error("[vmap] Keyword arguments are not supported."); + } auto axes_to_flat_tree = [](const nb::object& tree, const nb::object& axes, bool output_axes) { @@ -1472,7 +1476,7 @@ void init_transforms(nb::module_& m) { const nb::object& outputs, bool shapeless) { return mlx_func( - nb::cpp_function(PyCompiledFun{fun, inputs, outputs, shapeless}), + PyCompiledFun{fun, inputs, outputs, shapeless}, fun, inputs, outputs); diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py index 741694f4a3..742a4a3934 100644 --- a/python/tests/test_bindings.py +++ b/python/tests/test_bindings.py @@ -1,8 +1,11 @@ # Copyright © 2026 Apple Inc. +import gc import subprocess import sys +import textwrap import unittest +import weakref import mlx.core as mx import mlx_tests @@ -44,6 +47,56 @@ def total(self): self.assertEqual(Array([1, 2, 3]).total(), 6) + def test_transform_releases_captures(self): + for transform in ( + mx.compile, + mx.grad, + mx.value_and_grad, + mx.vmap, + mx.checkpoint, + ): + for cycle in (False, True): + with self.subTest(transform=transform, cycle=cycle): + captured = mx.array(2.0) + ref = weakref.ref(captured) + + def fun(x, captured=captured): + return (x * captured).sum() + + fn = transform(fun) + if cycle: + fun.wrapped = fn + result = fn(mx.ones((2, 3))) + mx.eval(result) + del result, fn, fun, captured + gc.collect() + self.assertIsNone(ref()) + + def test_random_state_created_in_thread(self): + code = textwrap.dedent(""" + import threading + import mlx.core as mx + + results = [] + + def worker(): + mx.set_default_device(mx.cpu) + state = mx.random.state + results.append(state is mx.random.state) + mx.clear_streams() + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + assert results == [True] + """) + subprocess.run([sys.executable, "-c", code], check=True) + + def test_vmap_rejects_keywords(self): + fn = mx.vmap(lambda x: x) + with self.assertRaises(TypeError): + fn(x=mx.array([1.0])) + @unittest.skipUnless(sys.version_info >= (3, 13), "requires Python 3.13") def test_import_preserves_gil_state(self): subprocess.run( From ba2866d9f5b0dc1573d221cd1cbae3819fadb1e7 Mon Sep 17 00:00:00 2001 From: XXXXRT666 <157766680+XXXXRT666@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:28:53 +0100 Subject: [PATCH 4/4] Build wheels for Python 3.15 and 3.15t --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0445da0361..21de175b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: matrix: os: ['Linux', 'Windows'] arch: ['x86_64', 'aarch64'] - python-version: &pyver ['3.10', '3.11', '3.12', '3.13', '3.13t', '3.14', '3.14t'] + python-version: &pyver ['3.10', '3.11', '3.12', '3.13', '3.13t', '3.14', '3.14t', '3.15', '3.15t'] # There is no cp310 binary for Windows on arm. exclude: - os: 'Windows'