diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index eada96749..4f341d4de 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -23,6 +23,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 f70799f91..29253bcb6 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -70,9 +70,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" @@ -80,6 +78,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" driver: - mysql - https @@ -141,7 +140,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/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..7a8bbc379 100755 --- a/singlestoredb/functions/ext/asgi.py +++ b/singlestoredb/functions/ext/asgi.py @@ -2405,9 +2405,31 @@ 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 + 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: + raise RuntimeError( + f'environment file could not be imported: {envs[0]}', + ) functions = [mod] if defaults: 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()