Skip to content

Commit 49fe65d

Browse files
authored
test: Set up minio-based testing, replace moto (#553)
* Add docker and minio dev deps * Set up minio conftest * set up conftest * Fix minio conftest * fix tests in test_s3 * passing tests! * remove moto * Remove old conftest
1 parent eb7155e commit 49fe65d

File tree

5 files changed

+447
-638
lines changed

5 files changed

+447
-638
lines changed

pyproject.toml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dev-dependencies = [
1313
"arro3-core>=0.4.2",
1414
"azure-identity>=1.21.0",
1515
"boto3>=1.38.21",
16+
"docker>=7.1.0",
1617
"fastapi>=0.115.12", # used in example but added here for pyright CI
1718
"fsspec>=2024.10.0",
1819
"google-auth>=2.38.0",
@@ -22,12 +23,12 @@ dev-dependencies = [
2223
"maturin-import-hook>=0.2.0",
2324
"maturin>=1.7.4",
2425
"mike>=2.1.3",
26+
"minio>=7.2.16",
2527
"mkdocs-material[imaging]>=9.6.3",
2628
"mkdocs-redirects>=1.2.2",
2729
"mkdocs>=1.6.1",
2830
"mkdocstrings-python>=1.13.0",
2931
"mkdocstrings>=0.27.0",
30-
"moto[s3,server]>=5.1.1",
3132
"mypy>=1.15.0",
3233
"obspec>=0.1.0",
3334
"pip>=24.2",
@@ -93,14 +94,10 @@ ignore = [
9394
]
9495

9596
[tool.pyright]
96-
exclude = [
97-
"**/__pycache__",
98-
"examples",
99-
".venv",
100-
]
97+
exclude = ["**/__pycache__", "examples", ".venv"]
10198
executionEnvironments = [
102-
{ root = "./", extraPaths = ["./obstore/python"] }, # Tests.
103-
{ root = "./obstore/python" }
99+
{ root = "./", extraPaths = ["./obstore/python"] }, # Tests.
100+
{ root = "./obstore/python" },
104101
]
105102

106103
[tool.pytest.ini_options]

tests/conftest.py

Lines changed: 125 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,147 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
import socket
4+
import time
5+
import warnings
6+
from typing import TYPE_CHECKING, Any
47

5-
import boto3
8+
import docker
69
import pytest
710
import requests
8-
from botocore import UNSIGNED
9-
from botocore.client import Config
10-
from moto.moto_server.threaded_moto_server import ThreadedMotoServer
11+
from minio import Minio
12+
from requests.exceptions import RequestException
1113

1214
from obstore.store import S3Store
1315

1416
if TYPE_CHECKING:
15-
from obstore.store import S3Config
17+
from collections.abc import Generator
1618

17-
TEST_BUCKET_NAME = "test"
19+
from obstore.store import ClientConfig, S3Config
20+
21+
TEST_BUCKET_NAME = "test-bucket"
22+
23+
24+
def find_available_port() -> int:
25+
"""Find a free port on localhost.
26+
27+
Note that this is susceptible to race conditions.
28+
"""
29+
# https://stackoverflow.com/a/36331860
30+
31+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
32+
# Bind to a free port provided by the host.
33+
s.bind(("", 0))
34+
35+
# Return the port number assigned.
36+
return s.getsockname()[1]
1837

1938

20-
# See docs here: https://docs.getmoto.org/en/latest/docs/server_mode.html
2139
@pytest.fixture(scope="session")
22-
def moto_server_uri():
23-
"""Fixture to run a mocked AWS server for testing."""
24-
# Note: pass `port=0` to get a random free port.
25-
server = ThreadedMotoServer(ip_address="localhost", port=0)
26-
server.start()
27-
if hasattr(server, "get_host_and_port"):
28-
host, port = server.get_host_and_port()
29-
else:
30-
s = server._server
31-
assert s is not None
32-
# An AF_INET6 socket address has 4 components.
33-
host, port = s.server_address[:2]
34-
uri = f"http://{host}:{port}"
35-
yield uri
36-
server.stop()
40+
def minio_config() -> Generator[tuple[S3Config, ClientConfig], Any, None]:
41+
warnings.warn(
42+
"Creating Docker client...",
43+
UserWarning,
44+
stacklevel=1,
45+
)
46+
docker_client = docker.from_env()
47+
warnings.warn(
48+
"Finished creating Docker client...",
49+
UserWarning,
50+
stacklevel=1,
51+
)
3752

53+
username = "minioadmin"
54+
password = "minioadmin" # noqa: S105
55+
port = find_available_port()
56+
console_port = find_available_port()
3857

39-
@pytest.fixture
40-
def s3(moto_server_uri: str):
41-
client = boto3.client(
42-
"s3",
43-
config=Config(signature_version=UNSIGNED),
44-
region_name="us-east-1",
45-
endpoint_url=moto_server_uri,
58+
print(f"Using ports: {port=}, {console_port=}") # noqa: T201
59+
print( # noqa: T201
60+
f"Log on to MinIO console at http://localhost:{console_port} with "
61+
f"{username=} and {password=}",
4662
)
47-
client.create_bucket(Bucket=TEST_BUCKET_NAME, ACL="public-read")
48-
client.put_object(Bucket=TEST_BUCKET_NAME, Key="afile", Body=b"hello world")
49-
yield moto_server_uri
50-
objects = client.list_objects_v2(Bucket=TEST_BUCKET_NAME)
51-
for name in objects.get("Contents", []):
52-
key = name.get("Key")
53-
assert key is not None
54-
client.delete_object(Bucket=TEST_BUCKET_NAME, Key=key)
55-
requests.post(f"{moto_server_uri}/moto-api/reset", timeout=30)
5663

64+
warnings.warn(
65+
"Starting MinIO container...",
66+
UserWarning,
67+
stacklevel=1,
68+
)
69+
minio_container = docker_client.containers.run(
70+
"quay.io/minio/minio",
71+
"server /data --console-address :9001",
72+
detach=True,
73+
ports={
74+
"9000/tcp": port,
75+
"9001/tcp": console_port,
76+
},
77+
environment={
78+
"MINIO_ROOT_USER": username,
79+
"MINIO_ROOT_PASSWORD": password,
80+
},
81+
)
82+
warnings.warn(
83+
"Finished starting MinIO container...",
84+
UserWarning,
85+
stacklevel=1,
86+
)
5787

58-
@pytest.fixture
59-
def s3_store(s3: str):
60-
return S3Store.from_url(
61-
f"s3://{TEST_BUCKET_NAME}/",
62-
endpoint=s3,
63-
region="us-east-1",
64-
skip_signature=True,
65-
client_options={"allow_http": True},
88+
# Wait for MinIO to be ready
89+
endpoint = f"http://localhost:{port}"
90+
wait_for_minio(endpoint, timeout=30)
91+
92+
minio_client = Minio(
93+
f"localhost:{port}",
94+
access_key=username,
95+
secret_key=password,
96+
secure=False,
6697
)
98+
minio_client.make_bucket(TEST_BUCKET_NAME)
99+
100+
s3_config: S3Config = {
101+
"bucket": TEST_BUCKET_NAME,
102+
"endpoint": endpoint,
103+
"access_key_id": username,
104+
"secret_access_key": password,
105+
"virtual_hosted_style_request": False,
106+
}
107+
client_options: ClientConfig = {"allow_http": True}
108+
109+
yield (s3_config, client_options)
110+
111+
minio_container.stop()
112+
minio_container.remove()
67113

68114

69115
@pytest.fixture
70-
def s3_store_config(s3: str) -> S3Config:
71-
return {
72-
"endpoint": s3,
73-
"region": "us-east-1",
74-
"skip_signature": True,
75-
}
116+
def minio_bucket(
117+
minio_config: tuple[S3Config, ClientConfig],
118+
) -> Generator[tuple[S3Config, ClientConfig], Any, None]:
119+
yield minio_config
120+
121+
# Remove all files from bucket
122+
store = S3Store(config=minio_config[0], client_options=minio_config[1])
123+
objects = store.list().collect()
124+
paths = [obj["path"] for obj in objects]
125+
store.delete(paths)
126+
127+
128+
@pytest.fixture
129+
def minio_store(minio_bucket: tuple[S3Config, ClientConfig]) -> S3Store:
130+
"""Create an S3Store configured for MinIO integration testing."""
131+
return S3Store(config=minio_bucket[0], client_options=minio_bucket[1])
132+
133+
134+
def wait_for_minio(endpoint: str, timeout: int):
135+
start_time = time.time()
136+
while time.time() - start_time < timeout:
137+
try:
138+
# MinIO health check endpoint
139+
response = requests.get(f"{endpoint}/minio/health/live", timeout=2)
140+
if response.status_code == 200:
141+
return
142+
except RequestException:
143+
pass
144+
time.sleep(0.5)
145+
146+
exc_str = f"MinIO failed to start within {timeout} seconds"
147+
raise TimeoutError(exc_str)

tests/store/test_s3.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import pytest
88

9-
import obstore as obs
109
from obstore.exceptions import BaseError, UnauthenticatedError
1110
from obstore.store import S3Store, from_url
1211

@@ -16,8 +15,10 @@
1615
reason="Moto doesn't seem to support Python 3.9",
1716
)
1817
@pytest.mark.asyncio
19-
async def test_list_async(s3_store: S3Store):
20-
list_result = await obs.list(s3_store).collect_async()
18+
async def test_list_async(minio_store: S3Store):
19+
await minio_store.put_async("afile", b"hello world")
20+
21+
list_result = await minio_store.list().collect_async()
2122
assert any("afile" in x["path"] for x in list_result)
2223

2324

@@ -26,8 +27,10 @@ async def test_list_async(s3_store: S3Store):
2627
reason="Moto doesn't seem to support Python 3.9",
2728
)
2829
@pytest.mark.asyncio
29-
async def test_get_async(s3_store: S3Store):
30-
resp = await obs.get_async(s3_store, "afile")
30+
async def test_get_async(minio_store: S3Store):
31+
await minio_store.put_async("afile", b"hello world")
32+
33+
resp = await minio_store.get_async("afile")
3134
buf = await resp.bytes_async()
3235
assert buf == b"hello world"
3336

@@ -78,7 +81,7 @@ async def test_from_url():
7881
region="us-west-2",
7982
skip_signature=True,
8083
)
81-
_meta = await obs.head_async(store, "2024-01-01_performance_fixed_tiles.parquet")
84+
_meta = await store.head_async("2024-01-01_performance_fixed_tiles.parquet")
8285

8386

8487
def test_pickle():
@@ -88,7 +91,7 @@ def test_pickle():
8891
skip_signature=True,
8992
)
9093
restored = pickle.loads(pickle.dumps(store))
91-
_objects = next(obs.list(restored))
94+
_objects = next(restored.list())
9295

9396

9497
def test_config_round_trip():
@@ -120,7 +123,7 @@ def credential_provider():
120123

121124
store = S3Store("bucket", credential_provider=credential_provider) # type: ignore
122125
with pytest.raises(UnauthenticatedError):
123-
obs.list(store).collect()
126+
store.list().collect()
124127

125128

126129
@pytest.mark.asyncio
@@ -130,7 +133,7 @@ async def credential_provider():
130133

131134
store = S3Store("bucket", credential_provider=credential_provider) # type: ignore
132135
with pytest.raises(UnauthenticatedError):
133-
await obs.list(store).collect_async()
136+
await store.list().collect_async()
134137

135138

136139
def test_eq():

0 commit comments

Comments
 (0)