Skip to content

Commit e0e6c6b

Browse files
kesmit13claude
andcommitted
Add HTTP Data API support for tests
This commit enables running tests via HTTP Data API when USE_DATA_API=1 is set, while setup operations still use MySQL protocol for commands that aren't supported over HTTP. Changes: - singlestoredb/pytest.py: Added http_connection_url property, fixed port mapping (9000 for Data API, 8080 for Studio), suppressed Docker container ID output - singlestoredb/tests/conftest.py: Added USE_DATA_API environment variable support, sets SINGLESTOREDB_INIT_DB_URL for setup operations, added clear logging for Docker lifecycle and connection mode - singlestoredb/tests/utils.py: Modified load_sql to always use SINGLESTOREDB_INIT_DB_URL when set for setup operations like SET GLOBAL - singlestoredb/tests/test_plugin.py: Added skip marker for HTTP Data API mode since USE database doesn't work over HTTP - pyproject.toml: Added docker to test dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 71e84ee commit e0e6c6b

File tree

5 files changed

+304
-12
lines changed

5 files changed

+304
-12
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ vectorstore = ["singlestore-vectorstore>=0.1.2"]
4747
test = [
4848
"coverage",
4949
"dash",
50+
"docker",
5051
"fastapi",
5152
"ipython",
5253
"jupysql",

singlestoredb/pytest.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,39 @@ def node_name() -> Iterator[str]:
8686

8787

8888
class _TestContainerManager():
89-
"""Manages the setup and teardown of a SingleStoreDB Dev Container"""
89+
"""Manages the setup and teardown of a SingleStoreDB Dev Container
90+
91+
If SINGLESTOREDB_URL environment variable is set, the manager will use
92+
the existing server instead of starting a Docker container. This allows
93+
tests to run against either an existing server or an automatically
94+
managed Docker container.
95+
"""
9096

9197
def __init__(self) -> None:
98+
# Check if SINGLESTOREDB_URL is already set - if so, use existing server
99+
self.existing_url = os.environ.get('SINGLESTOREDB_URL')
100+
self.use_existing = self.existing_url is not None
101+
102+
if self.use_existing:
103+
logger.info('Using existing SingleStore server from SINGLESTOREDB_URL')
104+
self.url = self.existing_url
105+
# No need to initialize Docker-related attributes
106+
return
107+
108+
logger.info('SINGLESTOREDB_URL not set, will start Docker container')
109+
92110
# Generate unique container name using UUID and worker ID
93111
worker = os.environ.get('PYTEST_XDIST_WORKER', 'master')
94112
unique_id = uuid.uuid4().hex[:8]
95113
self.container_name = f'singlestoredb-test-{worker}-{unique_id}'
96114

97115
self.dev_image_name = 'ghcr.io/singlestore-labs/singlestoredb-dev'
98116

99-
assert 'SINGLESTORE_LICENSE' in os.environ, 'SINGLESTORE_LICENSE not set'
117+
# Use SINGLESTORE_LICENSE from environment, or empty string as fallback
118+
# Empty string works for the client SDK
119+
license = os.environ.get('SINGLESTORE_LICENSE', '')
120+
if not license:
121+
logger.info('SINGLESTORE_LICENSE not set, using empty string')
100122

101123
self.root_password = 'Q8r4D7yXR8oqn'
102124
self.environment_vars = {
@@ -111,12 +133,23 @@ def __init__(self) -> None:
111133
self.studio_port = _find_free_port()
112134
self.ports = [
113135
(self.mysql_port, '3306'), # External port -> Internal port
114-
(self.http_port, '8080'),
115-
(self.studio_port, '9000'),
136+
(self.studio_port, '8080'), # Studio
137+
(self.http_port, '9000'), # Data API
116138
]
117139

118140
self.url = f'root:{self.root_password}@127.0.0.1:{self.mysql_port}'
119141

142+
@property
143+
def http_connection_url(self) -> Optional[str]:
144+
"""HTTP connection URL for the SingleStoreDB server using Data API."""
145+
if self.use_existing:
146+
# If using existing server, HTTP URL not available from manager
147+
return None
148+
return (
149+
f'singlestoredb+http://root:{self.root_password}@'
150+
f'127.0.0.1:{self.http_port}'
151+
)
152+
120153
def _container_exists(self) -> bool:
121154
"""Check if a container with this name already exists."""
122155
try:
@@ -173,7 +206,11 @@ def start(self) -> None:
173206
env = {
174207
'SINGLESTORE_LICENSE': license,
175208
}
176-
subprocess.check_call(command, shell=True, env=env)
209+
# Capture output to avoid printing the container ID hash
210+
subprocess.check_call(
211+
command, shell=True, env=env,
212+
stdout=subprocess.DEVNULL,
213+
)
177214
except Exception as e:
178215
logger.exception(e)
179216
raise RuntimeError(
@@ -250,14 +287,22 @@ def stop(self) -> None:
250287
logger.info('Cleaning up SingleStore DB dev container')
251288
logger.debug('Stopping container')
252289
try:
253-
subprocess.check_call(f'docker stop {self.container_name}', shell=True)
290+
subprocess.check_call(
291+
f'docker stop {self.container_name}',
292+
shell=True,
293+
stdout=subprocess.DEVNULL,
294+
)
254295
except Exception as e:
255296
logger.exception(e)
256297
raise RuntimeError('Failed to stop container.') from e
257298

258299
logger.debug('Removing container')
259300
try:
260-
subprocess.check_call(f'docker rm {self.container_name}', shell=True)
301+
subprocess.check_call(
302+
f'docker rm {self.container_name}',
303+
shell=True,
304+
stdout=subprocess.DEVNULL,
305+
)
261306
except Exception as e:
262307
logger.exception(e)
263308
raise RuntimeError('Failed to stop container.') from e
@@ -267,13 +312,24 @@ def stop(self) -> None:
267312
def singlestoredb_test_container(
268313
execution_mode: ExecutionMode,
269314
) -> Iterator[_TestContainerManager]:
270-
"""Sets up and tears down the test container"""
315+
"""Sets up and tears down the test container
316+
317+
If SINGLESTOREDB_URL is set in the environment, uses the existing server
318+
and skips Docker container lifecycle management. Otherwise, automatically
319+
starts a Docker container for testing.
320+
"""
271321

272322
if not isinstance(execution_mode, ExecutionMode):
273323
raise TypeError(f"Invalid execution mode '{execution_mode}'")
274324

275325
container_manager = _TestContainerManager()
276326

327+
# If using existing server, skip all Docker lifecycle management
328+
if container_manager.use_existing:
329+
logger.info('Using existing server, skipping Docker container lifecycle')
330+
yield container_manager
331+
return
332+
277333
# In sequential operation do all the steps
278334
if execution_mode == ExecutionMode.SEQUENTIAL:
279335
logger.debug('Not distributed')

singlestoredb/tests/conftest.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env python
2+
"""Pytest configuration for singlestoredb tests
3+
4+
This module sets up automatic Docker container management for tests.
5+
It works with both pytest-style and unittest-style tests.
6+
7+
The conftest automatically:
8+
1. Checks if SINGLESTOREDB_URL is set in the environment
9+
2. If not set, starts a SingleStore Docker container
10+
3. Sets SINGLESTOREDB_URL for all tests to use
11+
4. Cleans up the container when tests complete
12+
13+
Environment Variables:
14+
- SINGLESTOREDB_URL: If set, tests will use this existing server instead
15+
of starting a Docker container. This allows testing
16+
against a specific server instance.
17+
- USE_DATA_API: If set to 1/true/on, tests will use HTTP Data API
18+
instead of MySQL protocol. When set, SINGLESTOREDB_URL
19+
will be set to the HTTP URL, and SINGLESTOREDB_INIT_DB_URL
20+
will be set to the MySQL URL for setup operations.
21+
- SINGLESTORE_LICENSE: Optional. License key for Docker container. If not
22+
set, an empty string is used as fallback.
23+
24+
Available Fixtures:
25+
- singlestoredb_test_container: Manages Docker container lifecycle
26+
- singlestoredb_connection: Provides a connection to the test server
27+
- singlestoredb_tempdb: Creates a temporary test database with cursor
28+
"""
29+
import logging
30+
import os
31+
from collections.abc import Iterator
32+
from typing import Optional
33+
34+
import pytest
35+
36+
from singlestoredb.pytest import _TestContainerManager
37+
from singlestoredb.pytest import execution_mode # noqa: F401
38+
from singlestoredb.pytest import ExecutionMode # noqa: F401
39+
from singlestoredb.pytest import name_allocator # noqa: F401
40+
from singlestoredb.pytest import node_name # noqa: F401
41+
from singlestoredb.pytest import singlestoredb_connection # noqa: F401
42+
from singlestoredb.pytest import singlestoredb_tempdb # noqa: F401
43+
44+
45+
logger = logging.getLogger(__name__)
46+
47+
# Global container manager instance
48+
_container_manager: Optional[_TestContainerManager] = None
49+
50+
51+
def pytest_configure(config: pytest.Config) -> None:
52+
"""
53+
Pytest hook that runs before test collection.
54+
55+
This ensures the Docker container is started (if needed) before any
56+
test modules are imported. Some test modules try to get connection
57+
parameters at import time, so we need the environment set up early.
58+
"""
59+
global _container_manager
60+
61+
# Prevent double initialization - pytest_configure can be called multiple times
62+
if _container_manager is not None:
63+
logger.debug('pytest_configure already called, skipping')
64+
return
65+
66+
if 'SINGLESTOREDB_URL' not in os.environ:
67+
print('\n' + '=' * 70)
68+
print('Starting SingleStoreDB Docker container...')
69+
print('This may take a moment...')
70+
print('=' * 70)
71+
logger.info('SINGLESTOREDB_URL not set, starting Docker container')
72+
73+
# Create and start the container
74+
_container_manager = _TestContainerManager()
75+
76+
if not _container_manager.use_existing:
77+
_container_manager.start()
78+
print(f'Container {_container_manager.container_name} started')
79+
print('Waiting for SingleStoreDB to be ready...')
80+
81+
# Wait for container to be ready
82+
try:
83+
conn = _container_manager.connect()
84+
conn.close()
85+
print('✓ SingleStoreDB is ready!')
86+
logger.info('Docker container is ready')
87+
except Exception as e:
88+
print(f'✗ Failed to connect to Docker container: {e}')
89+
logger.error(f'Failed to connect to Docker container: {e}')
90+
raise
91+
92+
# Set the environment variable for all tests
93+
# Check if USE_DATA_API is set to use HTTP connection
94+
if os.environ.get('USE_DATA_API', '0').lower() in ('1', 'true', 'on'):
95+
# Use HTTP URL for tests
96+
url = _container_manager.http_connection_url
97+
if url is None:
98+
raise RuntimeError(
99+
'Failed to get HTTP URL from container manager',
100+
)
101+
os.environ['SINGLESTOREDB_URL'] = url
102+
print('=' * 70)
103+
print('USE_DATA_API is enabled - using HTTP Data API for tests')
104+
print(f'Tests will connect via: {url}')
105+
print('=' * 70)
106+
logger.info('USE_DATA_API is enabled - using HTTP Data API for tests')
107+
logger.info(f'Tests will connect via: {url}')
108+
109+
# Also set INIT_DB_URL to MySQL URL for setup operations
110+
# (like SET GLOBAL) that don't work over HTTP
111+
mysql_url = _container_manager.url
112+
if mysql_url is None:
113+
raise RuntimeError(
114+
'Failed to get MySQL URL from container manager',
115+
)
116+
os.environ['SINGLESTOREDB_INIT_DB_URL'] = mysql_url
117+
print(f'Setup operations will use MySQL protocol: {mysql_url}')
118+
logger.info(
119+
f'Setup operations will use MySQL protocol: {mysql_url}',
120+
)
121+
else:
122+
url = _container_manager.url
123+
if url is None:
124+
raise RuntimeError(
125+
'Failed to get database URL from container manager',
126+
)
127+
os.environ['SINGLESTOREDB_URL'] = url
128+
print('=' * 70)
129+
print(f'Tests will connect via MySQL protocol: {url}')
130+
print('=' * 70)
131+
logger.info(f'Tests will connect via MySQL protocol: {url}')
132+
else:
133+
url = os.environ['SINGLESTOREDB_URL']
134+
logger.debug(f'Using existing SINGLESTOREDB_URL={url}')
135+
136+
137+
def pytest_unconfigure(config: pytest.Config) -> None:
138+
"""
139+
Pytest hook that runs after all tests complete.
140+
141+
Cleans up the Docker container if one was started.
142+
"""
143+
global _container_manager
144+
145+
if _container_manager is not None and not _container_manager.use_existing:
146+
print('\n' + '=' * 70)
147+
print('Cleaning up Docker container...')
148+
logger.info('Cleaning up Docker container')
149+
try:
150+
_container_manager.stop()
151+
print(f'✓ Container {_container_manager.container_name} stopped')
152+
print('=' * 70)
153+
logger.info('Docker container stopped')
154+
except Exception as e:
155+
print(f'✗ Failed to stop Docker container: {e}')
156+
print('=' * 70)
157+
logger.error(f'Failed to stop Docker container: {e}')
158+
159+
160+
@pytest.fixture(scope='session', autouse=True)
161+
def setup_test_environment() -> Iterator[None]:
162+
"""
163+
Automatically set up test environment for all tests.
164+
165+
This fixture ensures the test environment is ready. The actual container
166+
setup happens in pytest_configure hook to ensure it runs before test
167+
collection. Cleanup happens in pytest_unconfigure hook.
168+
169+
This fixture exists to ensure proper ordering but doesn't manage the
170+
container lifecycle itself.
171+
"""
172+
# The environment should already be set up by pytest_configure
173+
# This fixture just ensures proper test initialization order
174+
yield
175+
176+
# Clean up is handled by pytest_unconfigure
177+
178+
179+
@pytest.fixture(autouse=True)
180+
def protect_singlestoredb_url() -> Iterator[None]:
181+
"""
182+
Protect SINGLESTOREDB_URL and SINGLESTOREDB_INIT_DB_URL from corruption.
183+
184+
Some tests (like test_config.py) call reset_option() which resets all
185+
config options to their defaults. Since the 'host' option is registered
186+
with environ=['SINGLESTOREDB_HOST', 'SINGLESTOREDB_URL'], resetting it
187+
overwrites SINGLESTOREDB_URL with just '127.0.0.1' instead of the full
188+
connection string, breaking subsequent tests.
189+
190+
This fixture saves both URLs before each test and restores them
191+
after, ensuring they're not corrupted.
192+
"""
193+
# Save the current URLs
194+
saved_url = os.environ.get('SINGLESTOREDB_URL')
195+
saved_init_url = os.environ.get('SINGLESTOREDB_INIT_DB_URL')
196+
197+
yield
198+
199+
# Restore SINGLESTOREDB_URL if it was set and has been corrupted
200+
if saved_url is not None:
201+
current_url = os.environ.get('SINGLESTOREDB_URL')
202+
if current_url != saved_url:
203+
logger.debug(
204+
f'Restoring SINGLESTOREDB_URL from {current_url!r} to {saved_url!r}',
205+
)
206+
os.environ['SINGLESTOREDB_URL'] = saved_url
207+
208+
# Restore SINGLESTOREDB_INIT_DB_URL if it was set and has been corrupted
209+
if saved_init_url is not None:
210+
current_init_url = os.environ.get('SINGLESTOREDB_INIT_DB_URL')
211+
if current_init_url != saved_init_url:
212+
logger.debug(
213+
f'Restoring SINGLESTOREDB_INIT_DB_URL from '
214+
f'{current_init_url!r} to {saved_init_url!r}',
215+
)
216+
os.environ['SINGLESTOREDB_INIT_DB_URL'] = saved_init_url

singlestoredb/tests/test_plugin.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@
55
Each of these tests performs the same simple operation which
66
would fail if any other test had been run on the same database.
77
"""
8+
import os
9+
10+
import pytest
11+
812
from singlestoredb.connection import Cursor
913

1014
# pytest_plugins = ('singlestoredb.pytest',)
1115

16+
# Skip all tests in this module when using HTTP Data API
17+
# The singlestoredb_tempdb fixture uses 'USE database' which doesn't work with HTTP
18+
pytestmark = pytest.mark.skipif(
19+
os.environ.get('USE_DATA_API', '0').lower() in ('1', 'true', 'on'),
20+
reason='Plugin tests require MySQL protocol (USE database not supported via HTTP)',
21+
)
22+
1223
CREATE_TABLE_STATEMENT = 'create table test_dict (a text)'
1324

1425

0 commit comments

Comments
 (0)