This is an automated email from the ASF dual-hosted git repository.

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 4de1b4f89 feat(python): support free-threading (#3575)
4de1b4f89 is described below

commit 4de1b4f890f7570b0e4e94f32cbf7ebf3fb08421
Author: David Li <[email protected]>
AuthorDate: Tue Oct 14 16:41:53 2025 +0900

    feat(python): support free-threading (#3575)
    
    Closes https://github.com/apache/arrow-adbc/issues/2076.
    Closes https://github.com/apache/arrow-adbc/issues/3548.
---
 .github/workflows/packaging.yml                    |  9 ++++
 ci/scripts/python_wheel_unix_test.sh               | 38 +++++++++-----
 compose.yaml                                       |  7 +++
 .../adbc_driver_manager/_backward.pyx              |  1 +
 .../adbc_driver_manager/_lib.pyx                   |  1 +
 .../adbc_driver_manager/_reader.pyx                |  1 +
 python/adbc_driver_manager/pyproject.toml          |  2 +-
 .../_backward.pyx => tests/conftest.py}            | 30 +++--------
 python/adbc_driver_manager/tests/test_blocking.py  | 10 ++++
 python/adbc_driver_manager/tests/test_dbapi.py     | 25 ---------
 .../tests/test_dbapi_polars_nopyarrow.py           | 11 ++--
 python/adbc_driver_manager/tests/test_lowlevel.py  | 60 +++++++++++-----------
 .../_backward.pyx => tests/test_polars.py}         | 47 ++++++++---------
 13 files changed, 115 insertions(+), 127 deletions(-)

diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml
index 37b576ae6..0cb59b87a 100644
--- a/.github/workflows/packaging.yml
+++ b/.github/workflows/packaging.yml
@@ -735,6 +735,15 @@ jobs:
       #     pushd adbc
       #     env PYTHON=3.14 docker compose run python-wheel-manylinux-test
 
+      # TODO(lidavidm): once we support 3.14, only test 3.14t
+      - name: Test wheel 3.13t
+        env:
+          ARCH: ${{ matrix.arch }}
+          PLATFORM: ${{ matrix.platform }}
+        run: |
+          pushd adbc
+          env PYTHON=3.13t docker compose run 
python-wheel-manylinux-freethreaded-test
+
   python-macos:
     name: "Python ${{ matrix.arch }} macOS"
     runs-on: ${{ matrix.os }}
diff --git a/ci/scripts/python_wheel_unix_test.sh 
b/ci/scripts/python_wheel_unix_test.sh
index ffc345a0d..e279ef7bf 100755
--- a/ci/scripts/python_wheel_unix_test.sh
+++ b/ci/scripts/python_wheel_unix_test.sh
@@ -32,32 +32,44 @@ script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd 
)"
 source "${script_dir}/python_util.sh"
 
 echo "=== (${PYTHON_VERSION}) Installing wheels ==="
+
+PYTHON_TAG=cp$(python -c "import sysconfig; 
print(sysconfig.get_python_version().replace('.', ''))")
+PYTHON_FLAGS=$(python -c "import sysconfig; 
print(sysconfig.get_config_var('abiflags'))")
+
 for component in ${COMPONENTS}; do
     if [[ "${component}" = "adbc_driver_manager" ]]; then
-        PYTHON_TAG=cp$(python -c "import sysconfig; 
print(sysconfig.get_python_version().replace('.', ''))")
-        # We only want cp313-cp313 and not cp313-cp313t (for example)
-        PYTHON_TAG="${PYTHON_TAG}-${PYTHON_TAG}"
+        # Don't pick up cp313-cp313t when we wanted cp313-cp313 or vice versa 
(for example)
+        WHEEL_TAG="${PYTHON_TAG}-${PYTHON_TAG}${PYTHON_FLAGS}"
     else
-        PYTHON_TAG=py3-none
+        WHEEL_TAG=py3-none
     fi
 
     if [[ -d ${source_dir}/python/${component}/repaired_wheels/ ]]; then
-        pip install --no-deps --force-reinstall \
-            
${source_dir}/python/${component}/repaired_wheels/*-${PYTHON_TAG}-*.whl
+        python -m pip install --no-deps --force-reinstall \
+            
${source_dir}/python/${component}/repaired_wheels/*-${WHEEL_TAG}-*.whl
     elif [[ -d ${source_dir}/python/${component}/dist/ ]]; then
-        pip install --no-deps --force-reinstall \
-            ${source_dir}/python/${component}/dist/*-${PYTHON_TAG}-*.whl
+        python -m pip install --no-deps --force-reinstall \
+            ${source_dir}/python/${component}/dist/*-${WHEEL_TAG}-*.whl
     else
         echo "NOTE: assuming wheels are already installed"
     fi
 done
-pip install importlib-resources pytest pyarrow pandas polars protobuf
 
+python -m pip install importlib-resources pytest pyarrow pandas protobuf
+if [[ -z "${PYTHON_FLAGS}" ]]; then
+    # polars does not support freethreading and will try to build from source
+    python -m pip install polars
+fi
 
 echo "=== (${PYTHON_VERSION}) Testing wheels ==="
 test_packages
 
-echo "=== (${PYTHON_VERSION}) Testing wheels (no PyArrow) ==="
-pip uninstall -y pyarrow
-export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} -k pyarrowless"
-test_packages_pyarrowless
+if [[ -z "${PYTHON_FLAGS}" ]]; then
+    echo "=== (${PYTHON_VERSION}) Testing wheels (no PyArrow) ==="
+    python -m pip uninstall -y pyarrow
+    export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} -k pyarrowless"
+    test_packages_pyarrowless
+else
+    echo "Freethreading build, skipping pyarrowless tests"
+    echo "(polars does not yet support freethreading)"
+fi
diff --git a/compose.yaml b/compose.yaml
index b020c4b9a..1f8388b25 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -209,6 +209,13 @@ services:
       - .:/adbc:delegated
     command: /adbc/ci/scripts/python_wheel_unix_test.sh /adbc
 
+  python-wheel-manylinux-freethreaded-test:
+    image: ghcr.io/astral-sh/uv:trixie-slim
+    platform: ${PLATFORM}
+    volumes:
+      - .:/adbc:delegated
+    command: bash -c 'uv venv --python ${PYTHON} && source 
./.venv/bin/activate && python -m ensurepip && PYTHON_GIL=0 
/adbc/ci/scripts/python_wheel_unix_test.sh /adbc'
+
   ###################### Test database environments 
############################
 
   dremio:
diff --git a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx 
b/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
index 0527d1c4f..d5ac9f8c0 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
+++ b/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
@@ -16,6 +16,7 @@
 # under the License.
 
 # cython: language_level = 3
+# cython: freethreading_compatible=True
 
 """
 For debugging, install crash handlers that print a backtrace.
diff --git a/python/adbc_driver_manager/adbc_driver_manager/_lib.pyx 
b/python/adbc_driver_manager/adbc_driver_manager/_lib.pyx
index a34613b24..ac98e813f 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/_lib.pyx
+++ b/python/adbc_driver_manager/adbc_driver_manager/_lib.pyx
@@ -16,6 +16,7 @@
 # under the License.
 
 # cython: language_level = 3
+# cython: freethreading_compatible=True
 
 """Low-level ADBC API."""
 
diff --git a/python/adbc_driver_manager/adbc_driver_manager/_reader.pyx 
b/python/adbc_driver_manager/adbc_driver_manager/_reader.pyx
index c3ea39554..43c5f9870 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/_reader.pyx
+++ b/python/adbc_driver_manager/adbc_driver_manager/_reader.pyx
@@ -16,6 +16,7 @@
 # under the License.
 
 # cython: language_level = 3
+# cython: freethreading_compatible=True
 
 import pyarrow
 from cython.operator cimport dereference as deref
diff --git a/python/adbc_driver_manager/pyproject.toml 
b/python/adbc_driver_manager/pyproject.toml
index 704707926..32a9d42b4 100644
--- a/python/adbc_driver_manager/pyproject.toml
+++ b/python/adbc_driver_manager/pyproject.toml
@@ -35,7 +35,7 @@ homepage = "https://arrow.apache.org/adbc/";
 repository = "https://github.com/apache/arrow-adbc";
 
 [build-system]
-requires = ["Cython", "setuptools >= 77.0.0"]
+requires = ["Cython >= 3.1.0", "setuptools >= 77.0.0"]
 build-backend = "setuptools.build_meta"
 
 [tool.cibuildwheel]
diff --git a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx 
b/python/adbc_driver_manager/tests/conftest.py
similarity index 59%
copy from python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
copy to python/adbc_driver_manager/tests/conftest.py
index 0527d1c4f..e08ad3134 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
+++ b/python/adbc_driver_manager/tests/conftest.py
@@ -15,30 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# cython: language_level = 3
+import typing
 
-"""
-For debugging, install crash handlers that print a backtrace.
-"""
+import pytest
 
-import threading
+from adbc_driver_manager import dbapi
 
-cdef extern from "backward.hpp" nogil:
-    cdef struct CSignalHandling"backward::SignalHandling":
-        pass
 
-
-cdef class _SignalHandling:
-    cdef CSignalHandling _c_signal_handler
-
-
-_CRASH_HANDLER = None
-_CRASH_HANDLER_LOCK = threading.Lock()
-
-
-def _install_crash_handler():
-    global _CRASH_HANDLER
-    with _CRASH_HANDLER_LOCK:
-        if _CRASH_HANDLER:
-            return
-        _CRASH_HANDLER = _SignalHandling()
[email protected]
+def sqlite() -> typing.Generator[dbapi.Connection, None, None]:
+    with dbapi.connect(driver="adbc_driver_sqlite") as conn:
+        yield conn
diff --git a/python/adbc_driver_manager/tests/test_blocking.py 
b/python/adbc_driver_manager/tests/test_blocking.py
index ea7007736..820104c20 100644
--- a/python/adbc_driver_manager/tests/test_blocking.py
+++ b/python/adbc_driver_manager/tests/test_blocking.py
@@ -24,6 +24,7 @@ having to send the signal, so this tests the handler itself 
instead.
 
 import os
 import signal
+import sys
 import threading
 import time
 
@@ -97,6 +98,15 @@ def test_blocking_raise():
 def test_cancel_raise():
     event = threading.Event()
 
+    def _blocking(event):
+        _send_sigint()
+        event.wait()
+        # Under freethreaded python, _blocking ends before _cancel finishes
+        # and raises the exception, so the exception ends up getting thrown
+        # away; sleep a bit to prevent that
+        if hasattr(sys, "_is_gil_enabled") and not getattr(sys, 
"_is_gil_enabled")():
+            time.sleep(5)
+
     def _cancel():
         event.set()
         raise ValueError("expected error")
diff --git a/python/adbc_driver_manager/tests/test_dbapi.py 
b/python/adbc_driver_manager/tests/test_dbapi.py
index 72699b13e..72ae18f9e 100644
--- a/python/adbc_driver_manager/tests/test_dbapi.py
+++ b/python/adbc_driver_manager/tests/test_dbapi.py
@@ -18,8 +18,6 @@
 import pathlib
 
 import pandas
-import polars
-import polars.testing
 import pyarrow
 import pyarrow.dataset
 import pytest
@@ -28,13 +26,6 @@ from pandas.testing import assert_frame_equal
 from adbc_driver_manager import dbapi
 
 
[email protected]
-def sqlite():
-    """Dynamically load the SQLite driver."""
-    with dbapi.connect(driver="adbc_driver_sqlite") as conn:
-        yield conn
-
-
 def test_type_objects():
     assert dbapi.NUMBER == pyarrow.int64()
     assert pyarrow.int64() == dbapi.NUMBER
@@ -281,22 +272,6 @@ def test_query_fetch_df(sqlite):
         )
 
 
[email protected]
-def test_query_fetch_polars(sqlite):
-    with sqlite.cursor() as cur:
-        cur.execute("SELECT 1, 'foo' AS foo, 2.0")
-        polars.testing.assert_frame_equal(
-            cur.fetch_polars(),
-            polars.DataFrame(
-                {
-                    "1": [1],
-                    "foo": ["foo"],
-                    "2.0": [2.0],
-                }
-            ),
-        )
-
-
 @pytest.mark.sqlite
 @pytest.mark.parametrize(
     "parameters",
diff --git a/python/adbc_driver_manager/tests/test_dbapi_polars_nopyarrow.py 
b/python/adbc_driver_manager/tests/test_dbapi_polars_nopyarrow.py
index da86fd3a1..7bf7476f2 100644
--- a/python/adbc_driver_manager/tests/test_dbapi_polars_nopyarrow.py
+++ b/python/adbc_driver_manager/tests/test_dbapi_polars_nopyarrow.py
@@ -18,12 +18,13 @@
 import os
 import typing
 
-import polars
-import polars.testing
 import pytest
 
 from adbc_driver_manager import dbapi
 
+polars = pytest.importorskip("polars")
+polars.testing = pytest.importorskip("polars.testing")
+
 pytestmark = pytest.mark.pyarrowless
 
 
@@ -39,12 +40,6 @@ def no_pyarrow() -> None:
         pytest.skip("Skipping because pyarrow is installed")
 
 
[email protected]
-def sqlite() -> typing.Generator[dbapi.Connection, None, None]:
-    with dbapi.connect(driver="adbc_driver_sqlite") as conn:
-        yield conn
-
-
 @pytest.mark.parametrize(
     "data",
     [
diff --git a/python/adbc_driver_manager/tests/test_lowlevel.py 
b/python/adbc_driver_manager/tests/test_lowlevel.py
index e1cb503dc..74a28bca3 100644
--- a/python/adbc_driver_manager/tests/test_lowlevel.py
+++ b/python/adbc_driver_manager/tests/test_lowlevel.py
@@ -24,7 +24,7 @@ import adbc_driver_manager
 
 
 @pytest.fixture
-def sqlite():
+def sqlite_raw():
     """Dynamically load the SQLite driver."""
     with adbc_driver_manager.AdbcDatabase(driver="adbc_driver_sqlite") as db:
         with adbc_driver_manager.AdbcConnection(db) as conn:
@@ -99,8 +99,8 @@ def test_error_mapping():
 
 
 @pytest.mark.sqlite
-def test_database_set_options(sqlite):
-    db, _ = sqlite
+def test_database_set_options(sqlite_raw):
+    db, _ = sqlite_raw
     with pytest.raises(
         adbc_driver_manager.NotSupportedError,
         match="Unknown database option foo='bar'",
@@ -115,8 +115,8 @@ def test_database_set_options(sqlite):
 
 
 @pytest.mark.sqlite
-def test_connection_get_info(sqlite):
-    _, conn = sqlite
+def test_connection_get_info(sqlite_raw):
+    _, conn = sqlite_raw
     codes = [
         adbc_driver_manager.AdbcInfoCode.VENDOR_NAME,
         adbc_driver_manager.AdbcInfoCode.VENDOR_VERSION.value,
@@ -139,8 +139,8 @@ def test_connection_get_info(sqlite):
 
 
 @pytest.mark.sqlite
-def test_connection_get_objects(sqlite):
-    _, conn = sqlite
+def test_connection_get_objects(sqlite_raw):
+    _, conn = sqlite_raw
     data = pyarrow.record_batch(
         [
             [1, 2, 3, 4],
@@ -168,8 +168,8 @@ def test_connection_get_objects(sqlite):
 
 
 @pytest.mark.sqlite
-def test_connection_get_table_schema(sqlite):
-    _, conn = sqlite
+def test_connection_get_table_schema(sqlite_raw):
+    _, conn = sqlite_raw
     data = pyarrow.record_batch(
         [
             [1, 2, 3, 4],
@@ -187,23 +187,23 @@ def test_connection_get_table_schema(sqlite):
 
 
 @pytest.mark.sqlite
-def test_connection_get_table_types(sqlite):
-    _, conn = sqlite
+def test_connection_get_table_types(sqlite_raw):
+    _, conn = sqlite_raw
     handle = conn.get_table_types()
     table = _import(handle).read_all()
     assert "table" in table[0].to_pylist()
 
 
 @pytest.mark.sqlite
-def test_connection_read_partition(sqlite):
-    _, conn = sqlite
+def test_connection_read_partition(sqlite_raw):
+    _, conn = sqlite_raw
     with pytest.raises(adbc_driver_manager.NotSupportedError):
         conn.read_partition(b"")
 
 
 @pytest.mark.sqlite
-def test_connection_set_options(sqlite):
-    _, conn = sqlite
+def test_connection_set_options(sqlite_raw):
+    _, conn = sqlite_raw
     with pytest.raises(
         adbc_driver_manager.NotSupportedError,
         match="Unknown connection option foo='bar'",
@@ -218,8 +218,8 @@ def test_connection_set_options(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_query(sqlite):
-    _, conn = sqlite
+def test_statement_query(sqlite_raw):
+    _, conn = sqlite_raw
     with adbc_driver_manager.AdbcStatement(conn) as stmt:
         stmt.set_sql_query("SELECT 1")
         handle, _ = stmt.execute_query()
@@ -228,8 +228,8 @@ def test_statement_query(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_prepared(sqlite):
-    _, conn = sqlite
+def test_statement_prepared(sqlite_raw):
+    _, conn = sqlite_raw
     with adbc_driver_manager.AdbcStatement(conn) as stmt:
         stmt.set_sql_query("SELECT ?")
         stmt.prepare()
@@ -241,8 +241,8 @@ def test_statement_prepared(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_ingest(sqlite):
-    _, conn = sqlite
+def test_statement_ingest(sqlite_raw):
+    _, conn = sqlite_raw
     data = pyarrow.record_batch(
         [
             [1, 2, 3, 4],
@@ -262,8 +262,8 @@ def test_statement_ingest(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_adbc_prepare(sqlite):
-    _, conn = sqlite
+def test_statement_adbc_prepare(sqlite_raw):
+    _, conn = sqlite_raw
     with adbc_driver_manager.AdbcStatement(conn) as stmt:
         stmt.set_sql_query("SELECT 1")
         stmt.prepare()
@@ -282,8 +282,8 @@ def test_statement_adbc_prepare(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_autocommit(sqlite):
-    _, conn = sqlite
+def test_statement_autocommit(sqlite_raw):
+    _, conn = sqlite_raw
 
     # Autocommit enabled by default
     with pytest.raises(adbc_driver_manager.ProgrammingError) as errholder:
@@ -356,8 +356,8 @@ def test_statement_autocommit(sqlite):
 
 
 @pytest.mark.sqlite
-def test_statement_set_options(sqlite):
-    _, conn = sqlite
+def test_statement_set_options(sqlite_raw):
+    _, conn = sqlite_raw
 
     with adbc_driver_manager.AdbcStatement(conn) as stmt:
         with pytest.raises(
@@ -374,7 +374,7 @@ def test_statement_set_options(sqlite):
 
 
 @pytest.mark.sqlite
-def test_child_tracking(sqlite):
+def test_child_tracking(sqlite_raw):
     with adbc_driver_manager.AdbcDatabase(driver="adbc_driver_sqlite") as db:
         with adbc_driver_manager.AdbcConnection(db) as conn:
             with adbc_driver_manager.AdbcStatement(conn):
@@ -395,8 +395,8 @@ def test_child_tracking(sqlite):
 
 
 @pytest.mark.sqlite
-def test_pycapsule(sqlite):
-    _, conn = sqlite
+def test_pycapsule(sqlite_raw):
+    _, conn = sqlite_raw
     handle = conn.get_table_types()
     with pyarrow.RecordBatchReader._import_from_c_capsule(
         handle.__arrow_c_stream__()
diff --git a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx 
b/python/adbc_driver_manager/tests/test_polars.py
similarity index 59%
copy from python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
copy to python/adbc_driver_manager/tests/test_polars.py
index 0527d1c4f..a48740fa8 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/_backward.pyx
+++ b/python/adbc_driver_manager/tests/test_polars.py
@@ -15,30 +15,23 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# cython: language_level = 3
-
-"""
-For debugging, install crash handlers that print a backtrace.
-"""
-
-import threading
-
-cdef extern from "backward.hpp" nogil:
-    cdef struct CSignalHandling"backward::SignalHandling":
-        pass
-
-
-cdef class _SignalHandling:
-    cdef CSignalHandling _c_signal_handler
-
-
-_CRASH_HANDLER = None
-_CRASH_HANDLER_LOCK = threading.Lock()
-
-
-def _install_crash_handler():
-    global _CRASH_HANDLER
-    with _CRASH_HANDLER_LOCK:
-        if _CRASH_HANDLER:
-            return
-        _CRASH_HANDLER = _SignalHandling()
+import pytest
+
+polars = pytest.importorskip("polars")
+polars.testing = pytest.importorskip("polars.testing")
+
+
[email protected]
+def test_query_fetch_polars(sqlite):
+    with sqlite.cursor() as cur:
+        cur.execute("SELECT 1, 'foo' AS foo, 2.0")
+        polars.testing.assert_frame_equal(
+            cur.fetch_polars(),
+            polars.DataFrame(
+                {
+                    "1": [1],
+                    "foo": ["foo"],
+                    "2.0": [2.0],
+                }
+            ),
+        )

Reply via email to