Skip to content

Commit 015dfca

Browse files
test: multi format images test
1 parent b5612cb commit 015dfca

5 files changed

Lines changed: 166 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ docs/_build/
1414

1515
# Generated protobuf code
1616
src/resolver_athena_client/generated/
17+
18+
# Generated functional test images
19+
tests/test_support/images/*

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,19 @@ bash scripts/compile_proto.sh
123123
```
124124

125125
### Functional Tests
126-
Functional tests require an Athena environment to run against. You can set up
127-
the environment variables in a `.env` file in the root of the repository, or in
128-
your shell environment:
126+
Functional tests require an Athena environment to run against.
127+
128+
#### Pre-Requisites
129+
You will need:
130+
- An Athena host URL.
131+
- An OAuth client ID and secret with access to the Athena environment.
132+
- An affiliate with Athena enabled.
133+
- `imagemagick` installed on your system and on your path at `magick`.
134+
135+
136+
#### Preparing your environment
137+
You can set up the environment variables in a `.env` file in the root of the
138+
repository, or in your shell environment:
129139

130140
You must set the following variables:
131141
```

tests/functional/conftest.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
import os
2+
import shutil
3+
import subprocess
24
import uuid
5+
from pathlib import Path
36

47
import pytest
58
import pytest_asyncio
69
from dotenv import load_dotenv
710

811
from resolver_athena_client.client.athena_options import AthenaOptions
912
from resolver_athena_client.client.channel import CredentialHelper
13+
from tests.utils.image_generation import create_test_image
14+
15+
IMAGEMAGICK_FORMATS = [
16+
"gif",
17+
"bmp",
18+
"dib",
19+
"png",
20+
"webp",
21+
"pbm",
22+
"pgm",
23+
"ppm",
24+
"pxm",
25+
"pnm",
26+
"sr",
27+
"ras",
28+
"tiff",
29+
"pic",
30+
]
1031

1132

1233
def get_required_env_var(name: str) -> str:
@@ -67,3 +88,58 @@ def athena_options() -> AthenaOptions:
6788
keepalive_interval=30.0, # Longer intervals for persistent streams
6889
affiliate="Crisp",
6990
)
91+
92+
93+
@pytest.fixture
94+
def formatted_images() -> list[tuple[bytes, str]]:
95+
if (magick_path := shutil.which("magick")) is None:
96+
msg = (
97+
"ImageMagick 'magick' command not found - cannot run "
98+
"multi-format test"
99+
)
100+
raise AssertionError(msg)
101+
102+
images = []
103+
this_dir = Path(__file__).resolve()
104+
image_dir = this_dir.parent / "../test_support/images/"
105+
106+
if not image_dir.exists():
107+
image_dir.mkdir(parents=True)
108+
if not image_dir.is_dir():
109+
msg = f"Image directory {image_dir} is not a directory"
110+
raise AssertionError(msg)
111+
112+
base_image_format = "png"
113+
base_image = create_test_image(448, 448, img_format=base_image_format)
114+
base_image_path = image_dir / "base_image.png"
115+
with base_image_path.open("wb") as f:
116+
f.write(base_image)
117+
118+
for img_format in IMAGEMAGICK_FORMATS:
119+
if img_format == base_image_format:
120+
continue # base image is already generated.
121+
122+
converted_image_path = image_dir / f"test_image.{img_format}"
123+
if converted_image_path.exists():
124+
continue # already generated - probably from previous run.
125+
126+
cmd = f'magick "{base_image_path}" "{converted_image_path}"'
127+
subprocess.run( # noqa: S603 - false positive :(
128+
[magick_path, str(base_image_path), str(converted_image_path)],
129+
check=True,
130+
shell=False,
131+
)
132+
133+
if not converted_image_path.exists():
134+
msg = f"Failed to create {img_format} image with command: {cmd}"
135+
raise AssertionError(msg)
136+
137+
for path in image_dir.iterdir():
138+
if path.is_file():
139+
with path.open("rb") as f:
140+
img_bytes = f.read()
141+
images.append((img_bytes, path.suffix.lstrip(".")))
142+
143+
raw_uint8 = create_test_image(448, 448, img_format="raw_uint8")
144+
images.append((raw_uint8, "raw_uint8"))
145+
return images
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import pytest
2+
3+
from resolver_athena_client.client.athena_client import AthenaClient
4+
from resolver_athena_client.client.athena_options import AthenaOptions
5+
from resolver_athena_client.client.channel import (
6+
CredentialHelper,
7+
create_channel_with_credentials,
8+
)
9+
from resolver_athena_client.client.models import ImageData
10+
11+
12+
@pytest.mark.asyncio
13+
@pytest.mark.functional
14+
async def test_classsify_single_multi_format(
15+
athena_options: AthenaOptions,
16+
credential_helper: CredentialHelper,
17+
formatted_images: list[tuple[bytes, str]],
18+
) -> None:
19+
# Create gRPC channel with credentials
20+
channel = await create_channel_with_credentials(
21+
athena_options.host, credential_helper
22+
)
23+
24+
failed_formats: list[tuple[str, Exception]] = []
25+
26+
async with AthenaClient(channel, athena_options) as client:
27+
for img_bytes, img_format in formatted_images:
28+
try:
29+
# Create a unique test image for each iteration
30+
image_data = ImageData(img_bytes)
31+
32+
# Classify with auto-generated correlation ID
33+
result = await client.classify_single(image_data)
34+
35+
if result.error.code:
36+
msg = f"Image Result Error: {result.error.message}"
37+
pytest.fail(msg)
38+
39+
except Exception as e: # noqa: BLE001, PERF203 - its a test.
40+
failed_formats.append((img_format, e))
41+
42+
if failed_formats:
43+
failure_messages = ", ".join(
44+
[f"{fmt}: {err}" for fmt, err in failed_formats]
45+
)
46+
msg = f"Classification failed for formats: {failure_messages}"
47+
raise AssertionError(msg)

tests/utils/image_generation.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ def _get_cached_image(
2525
return _image_cache[key]
2626

2727

28-
def create_random_image(width: int = 160, height: int = 120) -> bytes:
28+
def create_random_image(
29+
width: int = 160, height: int = 120, img_format: str = "PNG"
30+
) -> bytes:
2931
"""Create a minimal random image optimized for maximum speed.
3032
3133
Args:
@@ -55,9 +57,24 @@ def create_random_image(width: int = 160, height: int = 120) -> bytes:
5557
x2, y2 = (width * 3) // 4, (height * 3) // 4
5658
draw.rectangle([x1, y1, x2, y2], fill=accent_color)
5759

60+
if img_format.upper() == "RAW_UINT8":
61+
# Return raw bytes
62+
r_bytes = []
63+
g_bytes = []
64+
b_bytes = []
65+
for y in range(height):
66+
for x in range(width):
67+
pixel = image.getpixel((x, y))
68+
assert pixel is not None
69+
assert isinstance(pixel, tuple)
70+
r_bytes.append(pixel[0])
71+
g_bytes.append(pixel[1])
72+
b_bytes.append(pixel[2])
73+
return bytes(r_bytes + g_bytes + b_bytes)
74+
5875
# Convert to PNG bytes
5976
buffer = io.BytesIO()
60-
image.save(buffer, format="PNG")
77+
image.save(buffer, format=img_format)
6178
return buffer.getvalue()
6279

6380

@@ -142,20 +159,25 @@ async def iter_images(
142159

143160

144161
def create_test_image(
145-
width: int = 160, height: int = 120, seed: int | None = None
162+
width: int = 160,
163+
height: int = 120,
164+
seed: int | None = None,
165+
img_format: str = "PNG",
146166
) -> bytes:
147167
"""Create a test image with specified dimensions and optional seed.
148168
149169
Args:
150170
width: Width of the test image in pixels (default: 160)
151171
height: Height of the test image in pixels (default: 120)
152172
seed: Optional seed for reproducible image generation
173+
img_format: Image format (default: PNG). Other formats like JPEG are
174+
also supported.
153175
154176
Returns:
155-
PNG image bytes
177+
image bytes in specified format (default: PNG)
156178
157179
"""
158180
if seed is not None:
159181
_rng.seed(seed)
160182

161-
return create_random_image(width, height)
183+
return create_random_image(width, height, img_format)

0 commit comments

Comments
 (0)