Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
- "3.12"
- "3.13"
- "3.14"
- "3.15"

steps:
- uses: actions/checkout@v7
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,15 @@ 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"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.15"
driver:
- mysql
- https
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion singlestoredb/apps/_uvicorn_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have another PR that is going to refactor this part of the code, so I'm holding off on approving this PR until that is done. get_event_loop is no longer used in the refactored code.

self._startup_future = asyncio.get_running_loop().create_future()

async def startup(self, sockets: Optional[List[socket.socket]] = None) -> None:
try:
Expand Down
28 changes: 25 additions & 3 deletions singlestoredb/functions/ext/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
mbUSC marked this conversation as resolved.
Resolved
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:
Expand Down
91 changes: 91 additions & 0 deletions singlestoredb/tests/test_asgi_env_file.py
Original file line number Diff line number Diff line change
@@ -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()