Skip to content

Commit 09c4f53

Browse files
committed
use pytest to manage docker container startup for tests
1 parent 50c9fdd commit 09c4f53

File tree

6 files changed

+274
-41
lines changed

6 files changed

+274
-41
lines changed

.github/workflows/test.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ jobs:
3434
python-version: ${{matrix.py_ver}}
3535
- name: Integration test
3636
env:
37-
PY_VER: ${{matrix.py_ver}}
3837
MYSQL_VER: ${{matrix.mysql_ver}}
39-
# taking default variables set in docker-compose.yaml to sync with local test
4038
run: |
41-
export HOST_UID=$(id -u)
42-
docker compose --profile test up --quiet-pull --build --exit-code-from djtest djtest
39+
pip install -e ".[test]"
40+
pytest --cov-report term-missing --cov=datajoint tests

docker-compose.yaml

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
# HOST_UID=$(id -u) PY_VER=3.11 DJ_VERSION=$(grep -oP '\d+\.\d+\.\d+' datajoint/version.py) docker compose --profile test up --build --exit-code-from djtest djtest
1+
# Development environment with MySQL and MinIO services
2+
# To run tests: pytest --cov-report term-missing --cov=datajoint tests
23
services:
34
db:
45
image: datajoint/mysql:${MYSQL_VER:-8.0}
@@ -64,26 +65,3 @@ services:
6465
user: ${HOST_UID:-1000}:mambauser
6566
volumes:
6667
- .:/src
67-
djtest:
68-
extends:
69-
service: app
70-
profiles: ["test"]
71-
command:
72-
- sh
73-
- -c
74-
- |
75-
set -e
76-
pip install -q -e ".[test]"
77-
pip freeze | grep datajoint
78-
pytest --cov-report term-missing --cov=datajoint tests
79-
djtest-zarr:
80-
extends:
81-
service: app
82-
profiles: ["test"]
83-
command:
84-
- sh
85-
- -c
86-
- |
87-
set -e
88-
pip install -q -e ".[test]"
89-
pytest --cov-report term-missing --cov=datajoint tests/test_zarr.py

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ dependencies = [
2121
"faker",
2222
"urllib3",
2323
"setuptools",
24-
"zarr-python >=3.1.0",
24+
"zarr >=3.1.0",
2525
"typing-extensions>=4.15.0"
2626
]
27-
requires-python = ">=3.9,<4.0"
27+
requires-python = ">=3.11,<4.0"
2828
authors = [
2929
{name = "Dimitri Yatsenko", email = "[email protected]"},
3030
{name = "Thinh Nguyen", email = "[email protected]"},
@@ -80,11 +80,15 @@ Repository = "https://github.com/datajoint/datajoint-python"
8080
dj = "datajoint.cli:cli"
8181
datajoint = "datajoint.cli:cli"
8282

83-
[project.optional-dependencies]
83+
[dependency-groups]
8484
test = [
8585
"pytest",
8686
"pytest-cov",
87+
"docker",
88+
"requests",
8789
]
90+
91+
[project.optional-dependencies]
8892
dev = [
8993
"pre-commit",
9094
"black==24.2.0",

tests/conftest.py

Lines changed: 249 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import atexit
12
import json
3+
import logging
24
import os
35
import shutil
6+
import signal
7+
import time
48
from os import environ, remove
59
from pathlib import Path
610
from typing import Dict, List
711

812
import certifi
13+
import docker
914
import minio
1015
import networkx as nx
1116
import pytest
17+
import requests
1218
import urllib3
1319
from packaging import version
1420

@@ -24,6 +30,240 @@
2430
from . import schema_uuid as schema_uuid_module
2531

2632

33+
# Configure logging for container management
34+
logger = logging.getLogger(__name__)
35+
36+
37+
38+
39+
# Global container registry for cleanup
40+
_active_containers = set()
41+
_docker_client = None
42+
43+
44+
def _get_docker_client():
45+
"""Get or create docker client"""
46+
global _docker_client
47+
if _docker_client is None:
48+
_docker_client = docker.from_env()
49+
return _docker_client
50+
51+
52+
def _cleanup_containers():
53+
"""Clean up any remaining containers"""
54+
if _active_containers:
55+
logger.info(f"Emergency cleanup: {len(_active_containers)} containers to clean up")
56+
try:
57+
client = _get_docker_client()
58+
for container_id in list(_active_containers):
59+
try:
60+
container = client.containers.get(container_id)
61+
container.remove(force=True)
62+
logger.info(f"Emergency cleanup: removed container {container_id[:12]}")
63+
except docker.errors.NotFound:
64+
logger.debug(f"Container {container_id[:12]} already removed")
65+
except Exception as e:
66+
logger.error(f"Error cleaning up container {container_id[:12]}: {e}")
67+
finally:
68+
_active_containers.discard(container_id)
69+
except Exception as e:
70+
logger.error(f"Error during emergency cleanup: {e}")
71+
else:
72+
logger.debug("No containers to clean up")
73+
74+
75+
def _register_container(container):
76+
"""Register a container for cleanup"""
77+
_active_containers.add(container.id)
78+
logger.debug(f"Registered container {container.id[:12]} for cleanup")
79+
80+
81+
def _unregister_container(container):
82+
"""Unregister a container from cleanup"""
83+
_active_containers.discard(container.id)
84+
logger.debug(f"Unregistered container {container.id[:12]} from cleanup")
85+
86+
87+
# Register cleanup functions
88+
atexit.register(_cleanup_containers)
89+
90+
91+
def _signal_handler(signum, frame):
92+
"""Handle signals to ensure container cleanup"""
93+
logger.warning(f"Received signal {signum}, performing emergency container cleanup...")
94+
_cleanup_containers()
95+
96+
# Restore default signal handler and re-raise the signal
97+
# This allows pytest to handle the cancellation normally
98+
signal.signal(signum, signal.SIG_DFL)
99+
os.kill(os.getpid(), signum)
100+
101+
102+
# Register signal handlers for graceful cleanup, but only for non-interactive scenarios
103+
# In pytest, we'll rely on fixture teardown and atexit handlers primarily
104+
try:
105+
import pytest
106+
# If we're here, pytest is available, so only register SIGTERM (for CI/batch scenarios)
107+
signal.signal(signal.SIGTERM, _signal_handler)
108+
# Don't intercept SIGINT (Ctrl+C) to allow pytest's normal cancellation behavior
109+
except ImportError:
110+
# If pytest isn't available, register both handlers
111+
signal.signal(signal.SIGINT, _signal_handler)
112+
signal.signal(signal.SIGTERM, _signal_handler)
113+
114+
115+
@pytest.fixture(scope="session")
116+
def docker_client():
117+
"""Docker client for managing containers."""
118+
return _get_docker_client()
119+
120+
121+
@pytest.fixture(scope="session")
122+
def mysql_container(docker_client):
123+
"""Start MySQL container and wait for it to be healthy."""
124+
mysql_ver = os.environ.get("MYSQL_VER", "8.0")
125+
container_name = f"datajoint_test_mysql_{os.getpid()}"
126+
127+
logger.info(f"Starting MySQL container {container_name} with version {mysql_ver}")
128+
129+
# Remove existing container if it exists
130+
try:
131+
existing = docker_client.containers.get(container_name)
132+
logger.info(f"Removing existing MySQL container {container_name}")
133+
existing.remove(force=True)
134+
except docker.errors.NotFound:
135+
logger.debug(f"No existing MySQL container {container_name} found")
136+
137+
# Start MySQL container
138+
container = docker_client.containers.run(
139+
f"datajoint/mysql:{mysql_ver}",
140+
name=container_name,
141+
environment={
142+
"MYSQL_ROOT_PASSWORD": "password"
143+
},
144+
command="mysqld --default-authentication-plugin=mysql_native_password",
145+
ports={"3306/tcp": None}, # Let Docker assign random port
146+
detach=True,
147+
remove=True,
148+
healthcheck={
149+
"test": ["CMD", "mysqladmin", "ping", "-h", "localhost"],
150+
"timeout": 30000000000, # 30s in nanoseconds
151+
"retries": 5,
152+
"interval": 15000000000, # 15s in nanoseconds
153+
}
154+
)
155+
156+
# Register container for cleanup
157+
_register_container(container)
158+
logger.info(f"MySQL container {container_name} started with ID {container.id[:12]}")
159+
160+
# Wait for health check
161+
max_wait = 120 # 2 minutes
162+
start_time = time.time()
163+
logger.info(f"Waiting for MySQL container {container_name} to become healthy (max {max_wait}s)")
164+
165+
while time.time() - start_time < max_wait:
166+
container.reload()
167+
health_status = container.attrs["State"]["Health"]["Status"]
168+
logger.debug(f"MySQL container {container_name} health status: {health_status}")
169+
if health_status == "healthy":
170+
break
171+
time.sleep(2)
172+
else:
173+
logger.error(f"MySQL container {container_name} failed to become healthy within {max_wait}s")
174+
container.remove(force=True)
175+
raise RuntimeError("MySQL container failed to become healthy")
176+
177+
# Get the mapped port
178+
port_info = container.attrs["NetworkSettings"]["Ports"]["3306/tcp"]
179+
if port_info:
180+
host_port = port_info[0]["HostPort"]
181+
logger.info(f"MySQL container {container_name} is healthy and accessible on localhost:{host_port}")
182+
else:
183+
raise RuntimeError("Failed to get MySQL port mapping")
184+
185+
yield container, "localhost", int(host_port)
186+
187+
# Cleanup
188+
logger.info(f"Cleaning up MySQL container {container_name}")
189+
_unregister_container(container)
190+
container.remove(force=True)
191+
logger.info(f"MySQL container {container_name} removed")
192+
193+
194+
@pytest.fixture(scope="session")
195+
def minio_container(docker_client):
196+
"""Start MinIO container and wait for it to be healthy."""
197+
minio_ver = os.environ.get("MINIO_VER", "RELEASE.2025-02-28T09-55-16Z")
198+
container_name = f"datajoint_test_minio_{os.getpid()}"
199+
200+
logger.info(f"Starting MinIO container {container_name} with version {minio_ver}")
201+
202+
# Remove existing container if it exists
203+
try:
204+
existing = docker_client.containers.get(container_name)
205+
logger.info(f"Removing existing MinIO container {container_name}")
206+
existing.remove(force=True)
207+
except docker.errors.NotFound:
208+
logger.debug(f"No existing MinIO container {container_name} found")
209+
210+
# Start MinIO container
211+
container = docker_client.containers.run(
212+
f"minio/minio:{minio_ver}",
213+
name=container_name,
214+
environment={
215+
"MINIO_ACCESS_KEY": "datajoint",
216+
"MINIO_SECRET_KEY": "datajoint"
217+
},
218+
command=['server', '--address', ':9000', '/data'],
219+
ports={"9000/tcp": None}, # Let Docker assign random port
220+
detach=True,
221+
remove=True
222+
)
223+
224+
# Register container for cleanup
225+
_register_container(container)
226+
logger.info(f"MinIO container {container_name} started with ID {container.id[:12]}")
227+
228+
# Get the mapped port
229+
container.reload()
230+
port_info = container.attrs["NetworkSettings"]["Ports"]["9000/tcp"]
231+
if port_info:
232+
host_port = port_info[0]["HostPort"]
233+
logger.info(f"MinIO container {container_name} mapped to localhost:{host_port}")
234+
else:
235+
raise RuntimeError("Failed to get MinIO port mapping")
236+
237+
# Wait for MinIO to be ready
238+
minio_url = f"http://localhost:{host_port}"
239+
max_wait = 60
240+
start_time = time.time()
241+
logger.info(f"Waiting for MinIO container {container_name} to become ready (max {max_wait}s)")
242+
243+
while time.time() - start_time < max_wait:
244+
try:
245+
response = requests.get(f"{minio_url}/minio/health/live", timeout=5)
246+
if response.status_code == 200:
247+
logger.info(f"MinIO container {container_name} is ready and accessible at {minio_url}")
248+
break
249+
except requests.exceptions.RequestException:
250+
logger.debug(f"MinIO container {container_name} not ready yet, retrying...")
251+
pass
252+
time.sleep(2)
253+
else:
254+
logger.error(f"MinIO container {container_name} failed to become ready within {max_wait}s")
255+
container.remove(force=True)
256+
raise RuntimeError("MinIO container failed to become ready")
257+
258+
yield container, "localhost", int(host_port)
259+
260+
# Cleanup
261+
logger.info(f"Cleaning up MinIO container {container_name}")
262+
_unregister_container(container)
263+
container.remove(force=True)
264+
logger.info(f"MinIO container {container_name} removed")
265+
266+
27267
@pytest.fixture(scope="session")
28268
def prefix():
29269
return os.environ.get("DJ_TEST_DB_PREFIX", "djtest")
@@ -56,18 +296,20 @@ def enable_filepath_feature(monkeypatch):
56296

57297

58298
@pytest.fixture(scope="session")
59-
def db_creds_test() -> Dict:
299+
def db_creds_test(mysql_container) -> Dict:
300+
_, host, port = mysql_container
60301
return dict(
61-
host=os.getenv("DJ_TEST_HOST", "db"),
302+
host=f"{host}:{port}",
62303
user=os.getenv("DJ_TEST_USER", "datajoint"),
63304
password=os.getenv("DJ_TEST_PASSWORD", "datajoint"),
64305
)
65306

66307

67308
@pytest.fixture(scope="session")
68-
def db_creds_root() -> Dict:
309+
def db_creds_root(mysql_container) -> Dict:
310+
_, host, port = mysql_container
69311
return dict(
70-
host=os.getenv("DJ_HOST", "db"),
312+
host=f"{host}:{port}",
71313
user=os.getenv("DJ_USER", "root"),
72314
password=os.getenv("DJ_PASS", "password"),
73315
)
@@ -190,9 +432,10 @@ def connection_test(connection_root, prefix, db_creds_test):
190432

191433

192434
@pytest.fixture(scope="session")
193-
def s3_creds() -> Dict:
435+
def s3_creds(minio_container) -> Dict:
436+
_, host, port = minio_container
194437
return dict(
195-
endpoint=os.environ.get("S3_ENDPOINT", "minio:9000"),
438+
endpoint=f"{host}:{port}",
196439
access_key=os.environ.get("S3_ACCESS_KEY", "datajoint"),
197440
secret_key=os.environ.get("S3_SECRET_KEY", "datajoint"),
198441
bucket=os.environ.get("S3_BUCKET", "datajoint.test"),

tests/test_json.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@
77
import datajoint as dj
88
from datajoint.declare import declare
99

10-
if Version(dj.conn().query("select @@version;").fetchone()[0]) < Version("8.0.0"):
11-
pytest.skip("These tests require MySQL >= v8.0.0", allow_module_level=True)
10+
11+
def mysql_version_check(connection):
12+
"""Check if MySQL version is >= 8.0.0"""
13+
version_str = connection.query("select @@version;").fetchone()[0]
14+
if Version(version_str) < Version("8.0.0"):
15+
pytest.skip("These tests require MySQL >= v8.0.0")
16+
17+
18+
@pytest.fixture(scope="module", autouse=True)
19+
def check_mysql_version(connection_root):
20+
"""Automatically check MySQL version for all tests in this module"""
21+
mysql_version_check(connection_root)
1222

1323

1424
class Team(dj.Lookup):

0 commit comments

Comments
 (0)