Skip to content

Commit 9136fb2

Browse files
committed
feat: Add async transformers and batching middleware
- Implement async transformers for image resizing, compression, and classification input conversion - Add request batching middleware for ClassifyRequest streaming - Add tests for async transformers and batching logic - Update dependencies: add anyio, brotli, numpy, pytest-cov - Update .gitignore for .env files - Add coverage reporting to CI workflow - Document async pipeline and correlation ID TODOs in README
1 parent ee1385b commit 9136fb2

23 files changed

Lines changed: 1572 additions & 38 deletions

.github/workflows/build_and_test.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,12 @@ jobs:
7272
7373
- name: Run tests
7474
run: |
75-
uv run pytest
75+
uv run pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=src tests/ | tee pytest-coverage.txt
76+
77+
- name: Pytest Coverage Comment
78+
uses: MishaKav/pytest-coverage-comment@v1.1.56
79+
with:
80+
junitxml-path: ./cov.xml
7681

7782
- name: Build package
7883
run: |

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
This is a Python library for interacting with the Athena API (Resolver Unknown
44
CSAM Detection).
55

6+
## TODO
7+
8+
### Async pipelines
9+
Make pipeline style invocation of the async interators such that we can
10+
11+
async read file -> async transform -> async classify -> async results
12+
13+
### Generate correlation IDs based on <something>
14+
Make do this with strategy pattern?
15+
616

717
## Development
818
This package uses [uv](https://docs.astral.sh/uv/) to manage its packages.

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ readme = "README.md"
88
authors = [{ name = "Resolver Global", email = "opensource@kroll.com" }]
99
requires-python = ">=3.10"
1010
dependencies = [
11+
"anyio>=4.10.0",
12+
"brotli>=1.1.0",
1113
"grpcio-tools>=1.74.0",
14+
"numpy>=2.2.6",
1215
"pillow>=11.3.0",
13-
"pydantic>=2.11.7",
14-
"types-protobuf>=6.30.2.20250703",
1516
]
1617
[dependency-groups]
1718
dev = [
@@ -20,6 +21,7 @@ dev = [
2021
"pyright>=1.1.403",
2122
"pytest>=8.4.1",
2223
"pytest-asyncio>=1.1.0",
24+
"pytest-cov>=6.2.1",
2325
"ruff>=0.12.7",
2426
"types-protobuf>=6.30.2.20250703",
2527
]
@@ -42,3 +44,5 @@ ignore = ["COM812", "D213", "D211", "D203", "S101"]
4244

4345
[tool.pyright]
4446
exclude = ["src/athena_client/generated/*", ".venv"]
47+
venvPath = "."
48+
venv = ".venv"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Athena Client.
2+
3+
This module provides a client for interacting with the Athena API.
4+
"""
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""The Athena Client Class."""
2+
3+
import types
4+
from collections.abc import AsyncIterator
5+
6+
import grpc
7+
8+
from athena_client.client.athena_options import AthenaOptions
9+
from athena_client.client.exceptions import AthenaError
10+
from athena_client.client.transformers.brotli_compressor import BrotliCompressor
11+
from athena_client.client.transformers.classification_input import (
12+
ClassificationInputTransformer,
13+
)
14+
from athena_client.client.transformers.image_resizer import ImageResizer
15+
from athena_client.client.transformers.request_batcher import RequestBatcher
16+
from athena_client.generated.athena.athena_pb2 import (
17+
ClassifyRequest,
18+
ClassifyResponse,
19+
RequestEncoding,
20+
)
21+
from athena_client.grpc_wrappers.classifier_service import (
22+
ClassifierServiceClient,
23+
)
24+
25+
26+
class AthenaClient:
27+
"""The Athena Client Class.
28+
29+
This class provides coroutine methods for interacting with the
30+
Athena service.
31+
32+
Attributes:
33+
options (AthenaOptions): Configuration options for the Athena client.
34+
35+
"""
36+
37+
def __init__(
38+
self, channel: grpc.aio.Channel, options: AthenaOptions
39+
) -> None:
40+
"""Initialize the Athena Client.
41+
42+
Args:
43+
channel (grpc.aio.Channel): The gRPC channel to use for
44+
communication.
45+
options (AthenaOptions): Configuration options for the
46+
Athena client.
47+
48+
"""
49+
self.options = options
50+
self.channel = channel
51+
self.classifier = ClassifierServiceClient(self.channel)
52+
self.deployment_id = options.deployment_id
53+
self.max_batch_size = options.max_batch_size
54+
55+
async def classify_images(
56+
self, images: AsyncIterator[bytes]
57+
) -> AsyncIterator[ClassifyResponse]:
58+
"""Classify an image using the Athena service.
59+
60+
Args:
61+
images (AsyncIterator[Image]): The images to classify in an
62+
asynchronous iterator.
63+
64+
Yields:
65+
ClassifyResponse: The classification responses.
66+
67+
"""
68+
# Build middleware pipeline
69+
image_stream = images
70+
71+
if self.options.resize_images:
72+
image_stream = ImageResizer(image_stream)
73+
74+
if self.options.compress_images:
75+
image_stream = BrotliCompressor(image_stream)
76+
77+
input_transformer = ClassificationInputTransformer(
78+
image_stream,
79+
deployment_id=self.options.deployment_id,
80+
affiliate=self.options.affiliate,
81+
request_encoding=RequestEncoding.REQUEST_ENCODING_BROTLI,
82+
)
83+
84+
request_batcher = RequestBatcher(
85+
input_transformer,
86+
deployment_id=self.deployment_id,
87+
max_batch_size=self.max_batch_size,
88+
)
89+
90+
async for response in self.classifier.classify(request_batcher):
91+
yield response
92+
93+
async def close(self) -> None:
94+
"""Close the client and GRPC channel."""
95+
await self.channel.close()
96+
97+
async def __aenter__(self) -> "AthenaClient":
98+
"""Context manager entrypoint.
99+
100+
Registers the client with the server by sending a
101+
classification request. With a deployment ID.
102+
"""
103+
104+
# Register the client with the server, just send one deployment ID.
105+
async def init_request() -> AsyncIterator[ClassifyRequest]:
106+
yield ClassifyRequest(deployment_id=self.deployment_id)
107+
108+
responses = self.classifier.classify(init_request())
109+
async for response in responses:
110+
if response.global_error:
111+
raise AthenaError(response.global_error.message)
112+
113+
return self
114+
115+
async def __aexit__(
116+
self,
117+
exc_type: type[BaseException] | None,
118+
exc_val: BaseException | None,
119+
exc_tb: types.TracebackType | None,
120+
) -> None:
121+
"""Context manager exit."""
122+
await self.close()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Options object for the Athena client."""
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class AthenaOptions:
8+
"""Options for the Athena client."""
9+
10+
host: str = "localhost"
11+
resize_images: bool = False
12+
compress_images: bool = True
13+
deployment_id: str = "default"
14+
affiliate: str = "default"
15+
correlation_id: str = "default"
16+
max_batch_size: int = 100
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Channel creation utilities for the Athena client."""
2+
3+
import grpc
4+
from grpc.aio import Channel
5+
6+
7+
def create_channel(host: str, auth_token: str) -> Channel:
8+
"""Create a gRPC channel with optional authentication.
9+
10+
Args:
11+
host: The host address to connect to
12+
auth_token: Optional authentication token. If provided, creates a secure
13+
channel with token-based authentication. If not provided, creates an
14+
insecure channel.
15+
16+
Returns:
17+
A gRPC channel (either secure or insecure)
18+
19+
"""
20+
# Create credentials with token authentication
21+
credentials = grpc.composite_channel_credentials(
22+
grpc.ssl_channel_credentials(),
23+
grpc.access_token_call_credentials(auth_token),
24+
)
25+
26+
return grpc.aio.secure_channel(host, credentials)

src/athena_client/client/consts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Constants for the athena_client's image properties."""
2+
3+
# Athena's classifier expects images to be 448x448 pixels.
4+
EXPECTED_WIDTH = 448
5+
EXPECTED_HEIGHT = 448
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Deployment selector for the Athena client."""
2+
3+
import logging
4+
5+
import grpc
6+
7+
from athena_client.generated.athena.athena_pb2 import ListDeploymentsResponse
8+
from athena_client.grpc_wrappers.classifier_service import (
9+
ClassifierServiceClient,
10+
)
11+
12+
13+
class DeploymentSelector:
14+
"""A controller for selecting deployments from the Athena service.
15+
16+
This class provides functionality to list available deployments for use
17+
with the Athena client.
18+
19+
Attributes:
20+
classifier (ClassifierServiceClient): The classifier service client used
21+
to communicate with the Athena service.
22+
23+
"""
24+
25+
def __init__(self, channel: grpc.aio.Channel) -> None:
26+
"""Initialize the deployment selector.
27+
28+
Args:
29+
channel (grpc.aio.Channel): Channel with which to communicate with
30+
the Athena service.
31+
32+
"""
33+
self.logger = logging.getLogger(__name__)
34+
self.classifier = ClassifierServiceClient(channel)
35+
36+
async def list_deployments(self) -> ListDeploymentsResponse:
37+
"""Retrieve a list of all active deployments.
38+
39+
Returns:
40+
ListDeploymentsResponse: Response containing the list of
41+
deployments.
42+
43+
"""
44+
self.logger.debug("Retrieving list of deployments from server")
45+
response = await self.classifier.list_deployments()
46+
47+
if not response.deployments:
48+
self.logger.error("No deployments available from server")
49+
else:
50+
self.logger.debug(
51+
"Retrieved %d deployments: %s",
52+
len(response.deployments),
53+
", ".join(
54+
[
55+
deployment.deployment_id
56+
for deployment in response.deployments
57+
]
58+
),
59+
)
60+
61+
return response
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Base classes for all Athena exceptions."""
2+
3+
4+
class AthenaError(Exception):
5+
"""Base class for all Athena exceptions."""
6+
7+
8+
class InvalidRequestError(AthenaError):
9+
"""Raised when the request is invalid."""

0 commit comments

Comments
 (0)