diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index d0b47a6f4de..d56d24aa04c 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -29,6 +29,9 @@ runs: requirements_dev.txt requirements_doc.txt requirements.txt + .github/workflows/requirements_dev.txt + .github/workflows/requirements_doc.txt + .github/workflows/requirements.txt - name: Setup system dependencies uses: ./.github/actions/setup diff --git a/.github/workflows/requirements_dev.txt b/.github/workflows/requirements_dev.txt index 0cb58570423..ed7adc2edec 100644 --- a/.github/workflows/requirements_dev.txt +++ b/.github/workflows/requirements_dev.txt @@ -4,9 +4,7 @@ conan packaging setuptools setuptools-scm -# TODO: The SWIG 4.4.X is incompatible with Basilisk. Pin to SWIG 4.3.1 until -# Basilisk is updated to support SWIG 4.4.X -swig<=4.3.1,>=4.2.1 +swig libclang pytest diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index e64a5061049..15f7b2583e8 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -10,11 +10,11 @@ Basilisk Known Issues Version |release| ----------------- -- When building from source on Python 3.13 using SWIG 4.4.0, a build failure may occur - if ``pyLimitedAPI`` is set to an ABI lower than Python 3.13 (e.g., ``0x03080000``). - SWIG 4.4.0 introduces a new C-API codepath for Python 3.13 that expects newer - definition macros which are not present when targeting older ``abi3`` compatibility. As such, when building - Basilisk with Python 3.13 or above, we automatically default to using the newer cp313 ABI. +- SWIG 4.4.0 caused Basilisk build failures in some Python 3.13+ source-build configurations. + The development dependency range now excludes SWIG 4.4.0, and SWIG 4.4.1 has been verified to build + successfully. If source builds fail with SWIG 4.4.0 or emit ``builtin type swigvarlink has no __module__ attribute`` + warnings, upgrade to SWIG 4.4.1 or newer. + Version 2.10.0 (April 2, 2026) ------------------------------ diff --git a/docs/source/Support/bskReleaseNotesSnippets/1354-swig-pytest-cleanup.rst b/docs/source/Support/bskReleaseNotesSnippets/1354-swig-pytest-cleanup.rst new file mode 100644 index 00000000000..ff5156376d1 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/1354-swig-pytest-cleanup.rst @@ -0,0 +1,2 @@ +- Updated SWIG dependency support to allow compatible latest SWIG releases while excluding SWIG 4.4.0 due to compile regressions observed during testing. If you are getting lots of ``builtin type swigvarlink has no __module__ attribute`` warnings upgrade to SWIG 4.4.1 +- Cleaned up pytest resource handling so parallel test runs with pytest-xdist and pytest-rerunfailures no longer report unclosed socket warnings. diff --git a/pyproject.toml b/pyproject.toml index b7b0b24b590..764b064ef48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ requires = [ # Requirements for building Basilisk through conanfile "conan>=2.0.5,<=2.23.0", "cmake>=3.26,<4.0", - "swig>=4.2.1,<=4.3.1", # Known to work with https://github.com/nightlark/swig-pypi/pull/120 + "swig>=4.2.1,!=4.4.0,<5", # Support Basilisk's established SWIG baseline while avoiding the known-bad 4.4.0 release. "libclang>=15.0.6.1,<=18.1.1" # Required by generatePayloadMetaJson.py (message code generation) ] @@ -105,8 +105,3 @@ markers = [ "scenarioTest: mark a test as a tutorial scenario test.", "ciSkip: mark a test that should be skipped on the CI test runs", ] -filterwarnings = [ - "ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning", - "ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning", - "ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning", -] diff --git a/requirements_dev.txt b/requirements_dev.txt index e2a2a870336..8a9fa3539e2 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,7 +4,7 @@ conan>=2.0.5,<=2.23.0 packaging>=24,<26 setuptools>=70.1.0,<=80.9.0 setuptools-scm>=8.0,<=9.2.2 -swig<=4.3.1,>=4.2.1 +swig>=4.2.1,!=4.4.0,<5 libclang>=15.0.6.1,<=18.1.1 pytest>=8.3.5,<=9.0.1 diff --git a/setup.py b/setup.py index e38034abcf9..c18164af6b9 100644 --- a/setup.py +++ b/setup.py @@ -88,7 +88,7 @@ def finalize_options(self) -> None: # Set limited ABI compatibility by default, targeting the minimum required Python version. # See https://docs.python.org/3/c-api/stable.html - # NOTE: Swig 4.2.1 or higher is required, see https://github.com/swig/swig/pull/2727 + # NOTE: Swig 4.2.1 or higher is required. Newer 4.4.x releases avoid deprecated wrapper warnings on newer Python. min_version = next(self.distribution.python_requires.filter([f"3.{i}" for i in range(2, 100)])).replace(".", "") wheel_py_limited = f"cp{min_version}" bdist_wheel = self.reinitialize_command("bdist_wheel", py_limited_api=wheel_py_limited) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cbbc3859208..cc4d0de7600 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -71,7 +71,7 @@ if(PY_LIMITED_API AND NOT PY_LIMITED_API STREQUAL "") # compatibility (basically, we can build a single Python wheel to # support all future Python versions). # See https://docs.python.org/3/c-api/stable.html - # NOTE: Swig 4.2.0 is required, see https://github.com/swig/swig/pull/2727 + # NOTE: Swig 4.2.0 is required. Swig 4.4.x avoids the deprecated wrapper warnings on newer Python releases. add_definitions("-DPy_LIMITED_API=${PY_LIMITED_API}") # Support for current Python version # Force libraries to link to the Stable ABI module instead. diff --git a/src/conftest.py b/src/conftest.py index bddf57f87a5..1674a335908 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -21,19 +21,12 @@ import shutil import subprocess import sys -import warnings from datetime import date import matplotlib as mpl import matplotlib.pyplot as plt import pytest -warnings.filterwarnings( - "ignore", - message="builtin type swigvarlink has no __module__ attribute", - category=DeprecationWarning, -) - SHOW_PLOTS_REMOVAL_DATE = date(2027, 2, 12) SHOW_PLOTS_DEPRECATION_MESSAGE = ( "The pytest option '--show_plots' is deprecated and will be removed after February 12, 2027." @@ -42,6 +35,39 @@ "The pytest option '--show_plots' has been deprecated for a year and will be removed shortly." ) + +def _patch_rerunfailures_socket_cleanup(): + """ + Close pytest-rerunfailures server-side sockets when xdist is active. + + pytest-rerunfailures 16.1 opens localhost sockets to coordinate reruns + between xdist workers, but accepted server connections are not closed by + the plugin. This narrow patch preserves the plugin handler while ensuring + each accepted connection is closed when the handler exits. + """ + try: + import pytest_rerunfailures + except ImportError: + return + + server_status_db = getattr(pytest_rerunfailures, "ServerStatusDB", None) + if server_status_db is None: + return + if getattr(server_status_db, "_bsk_socket_cleanup_patched", False): + return + + original_run_connection = server_status_db.run_connection + + def run_connection_with_socket_close(self, conn): + with conn: + return original_run_connection(self, conn) + + server_status_db.run_connection = run_connection_with_socket_close + server_status_db._bsk_socket_cleanup_patched = True + + +_patch_rerunfailures_socket_cleanup() + filename = inspect.getframeinfo(inspect.currentframe()).filename path = os.path.dirname(os.path.abspath(filename)) print(path) @@ -78,6 +104,17 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): terminalreporter.write_line(message, **{terminal_color: True}, bold=True) +def pytest_unconfigure(config): + failures_db = getattr(config, "failures_db", None) + socket_handle = getattr(failures_db, "sock", None) + if socket_handle is None: + return + try: + socket_handle.close() + except OSError: + pass + + @pytest.fixture(scope="module") def show_plots(request): return request.config.getoption("--show_plots") @@ -116,4 +153,5 @@ def reset_matplotlib_state(): quit() if 'pytest-html' in installed_packages: - exec(open(path + "/reportconf.py").read(), globals()) + with open(path + "/reportconf.py") as reportConfig: + exec(reportConfig.read(), globals()) diff --git a/src/pytest.ini b/src/pytest.ini index eaf99cf50fe..5049969648d 100644 --- a/src/pytest.ini +++ b/src/pytest.ini @@ -4,8 +4,3 @@ markers = slowtest: mark a test as a slow unit test. scenarioTest: mark a test as a tutorial scenario test. ciSkip: mark a test that should be skipped on the CI test runs - -filterwarnings = - ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning - ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning - ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning diff --git a/src/simulation/environment/spaceWeatherData/spaceWeatherData.i b/src/simulation/environment/spaceWeatherData/spaceWeatherData.i index b7d1196206c..11b55f2a25d 100644 --- a/src/simulation/environment/spaceWeatherData/spaceWeatherData.i +++ b/src/simulation/environment/spaceWeatherData/spaceWeatherData.i @@ -25,26 +25,6 @@ #include "spaceWeatherData.h" %} -%pythonbegin %{ -import warnings as _warnings - -_warnings.filterwarnings( - "ignore", - message=r"builtin type SwigPyPacked has no __module__ attribute", - category=DeprecationWarning, -) -_warnings.filterwarnings( - "ignore", - message=r"builtin type SwigPyObject has no __module__ attribute", - category=DeprecationWarning, -) -_warnings.filterwarnings( - "ignore", - message=r"builtin type swigvarlink has no __module__ attribute", - category=DeprecationWarning, -) -%} - %pythoncode %{ from Basilisk.architecture.swig_common_model import * %} diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceThreadSafety.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceThreadSafety.py index e85a2fa621f..d2cb66e272e 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceThreadSafety.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceThreadSafety.py @@ -232,6 +232,59 @@ def _runTestWithTimeout(resultQueue, numWorkers, iterationsPerWorker): False, ) ) + finally: + _closeQueue(resultQueue) + + +def _closeQueue(resultQueue): + """ + Close a multiprocessing queue and wait for its feeder thread. + + Parameters + ---------- + resultQueue : multiprocessing.Queue + Queue to close after all expected data has been read or written. + """ + try: + resultQueue.close() + except (OSError, ValueError): + pass + try: + resultQueue.join_thread() + except (AssertionError, OSError, ValueError): + pass + + +def _closeProcess(testProcess): + """ + Release multiprocessing process resources after the process exits. + + Parameters + ---------- + testProcess : multiprocessing.Process + Process object to close after it has been joined. + """ + try: + testProcess.close() + except (OSError, ValueError): + pass + + +def _terminateProcess(testProcess): + """ + Stop a multiprocessing process and wait for shutdown. + + Parameters + ---------- + testProcess : multiprocessing.Process + Process object to terminate. + """ + testProcess.terminate() + shutdownTimeout = 1 # [s] + testProcess.join(shutdownTimeout) + if testProcess.is_alive(): + os.kill(testProcess.pid, 9) + testProcess.join(shutdownTimeout) @pytest.mark.flaky(reruns=3) @@ -260,34 +313,37 @@ def testSpiceThreadSafety(numWorkers, iterationsPerWorker): target=_runTestWithTimeout, args=(resultQueue, numWorkers, iterationsPerWorker), ) - testProcess.start() + try: + testProcess.start() - timeoutSeconds = 60 - testProcess.join(timeoutSeconds) + timeoutSeconds = 60 # [s] + testProcess.join(timeoutSeconds) - if testProcess.is_alive(): - # Hard timeout: kill the worker process and fail the test - testProcess.terminate() - testProcess.join(1) if testProcess.is_alive(): - os.kill(testProcess.pid, 9) - pytest.fail(f"Thread safety test timed out after {timeoutSeconds} seconds") - - try: - results, success = resultQueue.get(block=False) - - if isinstance(results, dict) and "error" in results: + # Hard timeout: kill the worker process and fail the test + _terminateProcess(testProcess) pytest.fail( - "Thread safety test failed with error: " - f"{results['error']}\n{results.get('traceback')}" + f"Thread safety test timed out after {timeoutSeconds} seconds" ) - assert success, "Thread safety test reported thread-safety issues" - assert results["failedIterations"] == 0, ( - "Some iterations failed in the thread-safety test" - ) - except queue.Empty: - pytest.fail("Thread safety test completed but did not return any results") + try: + results, success = resultQueue.get(block=False) + + if isinstance(results, dict) and "error" in results: + pytest.fail( + "Thread safety test failed with error: " + f"{results['error']}\n{results.get('traceback')}" + ) + + assert success, "Thread safety test reported thread-safety issues" + assert results["failedIterations"] == 0, ( + "Some iterations failed in the thread-safety test" + ) + except queue.Empty: + pytest.fail("Thread safety test completed but did not return any results") + finally: + _closeQueue(resultQueue) + _closeProcess(testProcess) if __name__ == "__main__": @@ -307,28 +363,29 @@ def testSpiceThreadSafety(numWorkers, iterationsPerWorker): target=_runTestWithTimeout, args=(resultQueue, numWorkers, iterationsPerWorker), ) - testProcess.start() + try: + testProcess.start() - timeoutSeconds = 60 - testProcess.join(timeoutSeconds) + timeoutSeconds = 60 # [s] + testProcess.join(timeoutSeconds) - if testProcess.is_alive(): - testProcess.terminate() - testProcess.join(1) if testProcess.is_alive(): - os.kill(testProcess.pid, 9) - print(f"ERROR: Thread safety test timed out after {timeoutSeconds} seconds") - sys.exit(2) + _terminateProcess(testProcess) + print(f"ERROR: Thread safety test timed out after {timeoutSeconds} seconds") + sys.exit(2) - try: - results, success = resultQueue.get(block=False) + try: + results, success = resultQueue.get(block=False) - if isinstance(results, dict) and "error" in results: - print(f"ERROR: Thread safety test failed with error: {results['error']}") - print(results.get("traceback")) - sys.exit(1) + if isinstance(results, dict) and "error" in results: + print(f"ERROR: Thread safety test failed with error: {results['error']}") + print(results.get("traceback")) + sys.exit(1) - sys.exit(0 if success else 1) - except queue.Empty: - print("ERROR: Thread safety test completed but did not return results") - sys.exit(1) + sys.exit(0 if success else 1) + except queue.Empty: + print("ERROR: Thread safety test completed but did not return results") + sys.exit(1) + finally: + _closeQueue(resultQueue) + _closeProcess(testProcess)