From 668275beb8f5d1a5455c232b017efce106568566 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 19 Mar 2026 14:18:38 +0200 Subject: [PATCH 1/2] tests: detect stale Cython .so files at test startup Add a pytest_configure hook in tests/conftest.py that compares mtime of each compiled .so against its .py source and warns when the source is newer. This prevents silently testing stale compiled code after editing a Cython-compiled module without rebuilding. Also document the rebuild requirement in CONTRIBUTING.rst. --- CONTRIBUTING.rst | 10 ++++++++ tests/conftest.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/conftest.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8b8fc0e791..21c09fada0 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -40,6 +40,16 @@ When modifying driver files, rebuilding Cython modules is often necessary. Without caching, each such rebuild may take over a minute. Caching usually brings it down to about 2-3 seconds. +**Important:** After modifying any ``.py`` file under ``cassandra/`` that is +Cython-compiled (such as ``query.py``, ``protocol.py``, ``cluster.py``, etc.), +you **must** rebuild extensions before running tests:: + + python setup.py build_ext --inplace + +Without rebuilding, Python will load the stale compiled extension (``.so`` / ``.pyd``) +instead of your modified ``.py`` source, and your changes will not actually be tested. +The test suite will emit a warning if it detects this situation. + Building the Docs ================= diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..e79f4afe71 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +# Copyright ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import glob +import importlib.machinery +import os +import warnings + +# Directory containing the Cython-compiled driver modules. +_CASSANDRA_DIR = os.path.join(os.path.dirname(__file__), os.pardir, "cassandra") + + +def pytest_configure(config): + """Warn when a compiled Cython extension is older than its .py source. + + Python's import system prefers compiled extensions (.so / .pyd) over pure + Python (.py) files. If a developer edits a .py file without rebuilding + the Cython extensions (``python setup.py build_ext --inplace``), the tests + will silently run the *old* compiled code, masking any regressions in the + Python source. + + This hook detects such staleness at test-session startup so the developer + is alerted immediately. + """ + seen = set() + stale = [] + # Use the current interpreter's extension suffixes so we only check + # extensions that would actually be loaded (correct ABI tag), and + # handle both .so (POSIX) and .pyd (Windows) automatically. + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + for ext_path in glob.glob(os.path.join(_CASSANDRA_DIR, f"*{suffix}")): + module_name = os.path.basename(ext_path).split(".")[0] + if module_name in seen: + continue + py_path = os.path.join(_CASSANDRA_DIR, module_name + ".py") + if os.path.exists(py_path) and os.path.getmtime(py_path) > os.path.getmtime( + ext_path + ): + seen.add(module_name) + stale.append((module_name, ext_path, py_path)) + + if stale: + names = ", ".join(m for m, _, _ in stale) + warnings.warn( + f"Stale Cython extension(s) detected: {names}. " + f"The .py source is newer than the compiled extension — tests " + f"will run the OLD compiled code, not your latest changes. " + f"Rebuild with: python setup.py build_ext --inplace", + stacklevel=1, + ) From c1afe54065ac81b7704ec19fdaa25f311594e67b Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Fri, 20 Mar 2026 19:36:56 +0200 Subject: [PATCH 2/2] Address CoPilot review: iterate .py sources instead of globbing extensions Rewrite the stale-extension scan to iterate over .py source files and check for the first matching compiled extension per EXTENSION_SUFFIXES order. This mirrors Python's import machinery and avoids false positives from ABI-tagged extensions built for other Python versions that the glob approach could pick up. Also removes the now-unused glob import. --- tests/conftest.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e79f4afe71..b6bf42677d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import glob import importlib.machinery import os import warnings @@ -33,22 +32,28 @@ def pytest_configure(config): This hook detects such staleness at test-session startup so the developer is alerted immediately. """ - seen = set() stale = [] - # Use the current interpreter's extension suffixes so we only check - # extensions that would actually be loaded (correct ABI tag), and - # handle both .so (POSIX) and .pyd (Windows) automatically. - for suffix in importlib.machinery.EXTENSION_SUFFIXES: - for ext_path in glob.glob(os.path.join(_CASSANDRA_DIR, f"*{suffix}")): - module_name = os.path.basename(ext_path).split(".")[0] - if module_name in seen: + # Iterate over .py sources and, for each module, look for the first + # existing compiled extension in EXTENSION_SUFFIXES order. This mirrors + # how Python's import machinery selects an extension module, and avoids + # globbing patterns like "*{suffix}" that can pick up ABI-tagged + # extensions built for other Python versions. + if os.path.isdir(_CASSANDRA_DIR): + for entry in os.listdir(_CASSANDRA_DIR): + if not entry.endswith(".py"): continue - py_path = os.path.join(_CASSANDRA_DIR, module_name + ".py") - if os.path.exists(py_path) and os.path.getmtime(py_path) > os.path.getmtime( - ext_path - ): - seen.add(module_name) - stale.append((module_name, ext_path, py_path)) + module_name, _ = os.path.splitext(entry) + py_path = os.path.join(_CASSANDRA_DIR, entry) + # For this module, find the first extension file Python would load. + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + ext_path = os.path.join(_CASSANDRA_DIR, module_name + suffix) + if not os.path.exists(ext_path): + continue + if os.path.getmtime(py_path) > os.path.getmtime(ext_path): + stale.append((module_name, ext_path, py_path)) + # Only consider the first matching suffix; this is the one + # the import system would actually use. + break if stale: names = ", ".join(m for m, _, _ in stale)