From d15b9611c2163936f17d242c888d5f59f1312ae0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 27 Oct 2026 21:32:44 -0700 Subject: [PATCH 1/2] Add Python 3.15 support Python 3.15 removed zipimport.zipimporter.load_module(), which the external function server used to load a zipped environment file. Load it with find_spec()/exec_module() instead. Those methods only exist from Python 3.10, so 3.9 keeps using load_module(); that branch can be removed when 3.9 support is dropped. Use asyncio.get_running_loop() in AwaitableUvicornServer rather than get_event_loop(). Both callers already create it inside a running event loop, so behaviour is unchanged. Add 3.15 to the smoke-test and pre-commit matrices. The cibuildwheel upgrade needed to build and test wheels on 3.15 is in #147. Tested locally on 3.15.0 against SingleStore 9.1.1 (management tests excluded): 967 passed, 19 skipped, 1 failed, the same results as 3.14. The failure, TestServerProcessMode::test_process_mode_worker_handles_request, also fails on 3.14 on macOS and is unrelated to this change. --- .github/workflows/pre-commit.yml | 1 + .github/workflows/smoke-test.yml | 5 ++--- singlestoredb/apps/_uvicorn_util.py | 2 +- singlestoredb/functions/ext/asgi.py | 22 +++++++++++++++++++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 1bf0ed870..ed13f0132 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,6 +18,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 87140ffe2..aa33e1ca9 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -60,9 +60,7 @@ jobs: os: - ubuntu-24.04 # The floor in pyproject.toml (requires-python >=3.9) up to the newest - # final release. 3.15 is absent deliberately: as of 2026-09-18 it is at - # rc2 (GA 2026-10-01), and setup-python needs allow-prereleases plus an - # explicit "3.15.0-rc.2" to install it. Add a bare "3.15" once it ships. + # final release. python-version: - "3.9" - "3.10" @@ -70,6 +68,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" driver: - mysql - https diff --git a/singlestoredb/apps/_uvicorn_util.py b/singlestoredb/apps/_uvicorn_util.py index 9131e7ea6..45fb4e6c6 100644 --- a/singlestoredb/apps/_uvicorn_util.py +++ b/singlestoredb/apps/_uvicorn_util.py @@ -17,7 +17,7 @@ class AwaitableUvicornServer(uvicorn.Server): def __init__(self, config: 'uvicorn.Config') -> None: super().__init__(config) - self._startup_future = asyncio.get_event_loop().create_future() + self._startup_future = asyncio.get_running_loop().create_future() async def startup(self, sockets: Optional[List[socket.socket]] = None) -> None: try: diff --git a/singlestoredb/functions/ext/asgi.py b/singlestoredb/functions/ext/asgi.py index 94d95e31e..d3eb266ee 100755 --- a/singlestoredb/functions/ext/asgi.py +++ b/singlestoredb/functions/ext/asgi.py @@ -2405,9 +2405,25 @@ def main(argv: Optional[List[str]] = None) -> None: # Load zip file as a module modname = os.path.splitext(os.path.basename(envs[0]))[0] zi = zipimport.zipimporter(envs[0]) - mod = zi.load_module(modname) - if mod is None: - raise RuntimeError(f'environment file could not be imported: {envs[0]}') + # zipimporter.load_module() was removed in Python 3.15, and + # find_spec()/exec_module() only exist from Python 3.10. The else + # branch is only for Python 3.9 compatibility and can be dropped + # as soon as Python 3.9 support is dropped. + if sys.version_info >= (3, 10): + spec = zi.find_spec(modname) + if spec is None or spec.loader is None: + raise RuntimeError( + f'environment file could not be imported: {envs[0]}', + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[modname] = mod + spec.loader.exec_module(mod) + else: + mod = zi.load_module(modname) + if mod is None: + raise RuntimeError( + f'environment file could not be imported: {envs[0]}', + ) functions = [mod] if defaults: From 6ccbc33dcb616bc2db44438d1a45b620167f9783 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:10:55 -0700 Subject: [PATCH 2/2] Clean up failed environment imports and test the zip loader If the module in a zipped environment file raised while importing, main() left the half-initialized module in sys.modules, and a later import in the same process would return it without retrying. Remove the entry and re-raise, as load_module() did. Add test_asgi_env_file, which runs a zip environment through main() with uvicorn mocked out and checks that its functions are found, and that a failed import leaves nothing in sys.modules. The smoke-test job runs it on every Python version, 3.9 through 3.15, since the loader takes a different path on 3.9 than on 3.10+. --- .github/workflows/smoke-test.yml | 6 +- singlestoredb/functions/ext/asgi.py | 8 +- singlestoredb/tests/test_asgi_env_file.py | 91 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 singlestoredb/tests/test_asgi_env_file.py diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index aa33e1ca9..b3e103145 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -130,7 +130,11 @@ jobs: - name: Run tests if: ${{ matrix.driver != 'https' }} - run: pytest -v --pyargs singlestoredb.tests.test_basics + # test_asgi_env_file needs no database; it is here, and not in the + # https step, to run once on each Python version. It covers the zip + # loader in asgi.main(), which differs between 3.9 and 3.10+ and broke + # on 3.15. + run: pytest -v --pyargs singlestoredb.tests.test_basics singlestoredb.tests.test_asgi_env_file env: PYTHONPATH: ${{ github.workspace }} # CLUSTER_PASSWORD goes into the userinfo half of a URL here, so it diff --git a/singlestoredb/functions/ext/asgi.py b/singlestoredb/functions/ext/asgi.py index d3eb266ee..7a8bbc379 100755 --- a/singlestoredb/functions/ext/asgi.py +++ b/singlestoredb/functions/ext/asgi.py @@ -2417,7 +2417,13 @@ def main(argv: Optional[List[str]] = None) -> None: ) mod = importlib.util.module_from_spec(spec) sys.modules[modname] = mod - spec.loader.exec_module(mod) + try: + spec.loader.exec_module(mod) + except BaseException: + # Match load_module(): don't leave a half-initialized + # module behind for a later import to pick up + sys.modules.pop(modname, None) + raise else: mod = zi.load_module(modname) if mod is None: diff --git a/singlestoredb/tests/test_asgi_env_file.py b/singlestoredb/tests/test_asgi_env_file.py new file mode 100644 index 000000000..b5ac85372 --- /dev/null +++ b/singlestoredb/tests/test_asgi_env_file.py @@ -0,0 +1,91 @@ +"""Tests for loading a zipped environment file through ``asgi.main()``.""" +import os +import sys +import tempfile +import textwrap +import unittest +import uuid +import zipfile +from types import ModuleType +from typing import Optional +from unittest.mock import MagicMock +from unittest.mock import patch + +from ..functions.ext import asgi + + +GOOD_MODULE = textwrap.dedent(''' + from singlestoredb.functions import udf + + @udf + def env_file_double(x: int) -> int: + return x * 2 +''') + +FAILING_MODULE = textwrap.dedent(''' + raise RuntimeError('environment init failed') +''') + + +class TestEnvironmentFile(unittest.TestCase): + """``main()`` imports the module inside a zip passed as the function source. + + Python 3.15 removed ``zipimporter.load_module()``, which this path used + to rely on. uvicorn is mocked out, so no server is started. + """ + + def setUp(self) -> None: + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + # A unique name per test, so no module cached in sys.modules from + # an earlier test can satisfy the import + self.modname = f'envfile_{uuid.uuid4().hex}' + self.addCleanup(sys.modules.pop, self.modname, None) + self.cached_module: Optional[ModuleType] = None + + def _make_env(self, source: str) -> str: + path = os.path.join(self.tmpdir.name, f'{self.modname}.zip') + with zipfile.ZipFile(path, 'w') as zf: + zf.writestr(f'{self.modname}/__init__.py', source) + return path + + def _run_main(self, env_path: str) -> MagicMock: + """Run ``main()`` on the environment file and return the mocked uvicorn. + + What ``sys.modules`` holds for the environment module is saved to + ``self.cached_module`` before ``patch.dict`` exits, since restoring + ``sys.modules`` would otherwise drop the entry. It is saved even when + ``main()`` raises. + + """ + uvicorn = MagicMock() + with patch.dict(sys.modules, {'uvicorn': uvicorn}): + try: + # An empty --db skips registering the functions with a database + asgi.main(['--db', '', env_path]) + finally: + self.cached_module = sys.modules.get(self.modname) + return uvicorn + + def test_module_is_loaded_and_functions_found(self) -> None: + uvicorn = self._run_main(self._make_env(GOOD_MODULE)) + + uvicorn.run.assert_called_once() + app = uvicorn.run.call_args.args[0] + names = [info['signature']['name'] for _, info in app.endpoints.values()] + self.assertEqual(names, ['env_file_double']) + + mod = self.cached_module + assert mod is not None + self.assertEqual(mod.env_file_double(21), 42) + self.assertTrue(str(mod.__file__).startswith(self.tmpdir.name)) + + def test_failed_import_is_not_cached(self) -> None: + with self.assertRaisesRegex(RuntimeError, 'environment init failed'): + self._run_main(self._make_env(FAILING_MODULE)) + + self.assertIsNone(self.cached_module) + + +if __name__ == '__main__': + unittest.main()