Skip to content

Commit b20c851

Browse files
committed
feat: adds oauth credential helper, better streaming support
1 parent 7daef2b commit b20c851

27 files changed

Lines changed: 1875 additions & 272 deletions

README.md

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

6+
## Authentication
7+
8+
The Athena client supports two authentication methods:
9+
10+
### Static Token Authentication
11+
```python
12+
from athena_client.client.channel import create_channel
13+
14+
# Use a pre-existing authentication token
15+
channel = create_channel(host="your-host", auth_token="your-token")
16+
```
17+
18+
### OAuth Credential Helper (Recommended)
19+
The credential helper automatically handles OAuth token acquisition and refresh:
20+
21+
```python
22+
import asyncio
23+
from athena_client.client.channel import CredentialHelper, create_channel_with_credentials
24+
25+
async def main():
26+
# Create credential helper with OAuth settings
27+
credential_helper = CredentialHelper(
28+
client_id="your-oauth-client-id",
29+
client_secret="your-oauth-client-secret",
30+
auth_url="https://crispthinking.auth0.com/oauth/token", # Optional, this is default
31+
audience="crisp-athena-dev" # Optional, this is default
32+
)
33+
34+
# Create channel with automatic OAuth handling
35+
channel = await create_channel_with_credentials(
36+
host="your-host",
37+
credential_helper=credential_helper
38+
)
39+
40+
asyncio.run(main())
41+
```
42+
43+
#### Environment Variables
44+
For the OAuth example to work, set these environment variables:
45+
```bash
46+
export OAUTH_CLIENT_ID="your-client-id"
47+
export OAUTH_CLIENT_SECRET="your-client-secret"
48+
export ATHENA_HOST="your-athena-host"
49+
```
50+
51+
#### OAuth Features
52+
- **Automatic token refresh**: Tokens are automatically refreshed when they expire
53+
- **Thread-safe**: Multiple concurrent requests will safely share cached tokens
54+
- **Error handling**: Comprehensive error handling for OAuth failures
55+
- **Configurable**: Custom OAuth endpoints and audiences supported
56+
57+
See `examples/oauth_example.py` for a complete working example.
58+
59+
## Examples
60+
61+
- `examples/example.py` - Basic classification example with static token
62+
- `examples/oauth_example.py` - OAuth authentication with credential helper
63+
- `examples/create_image.py` - Image generation utilities
64+
665
## TODO
766

867
### Async pipelines

examples/create_image.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,26 @@ def create_random_image(width: int = 640, height: int = 480) -> bytes:
5656
return img_byte_arr.getvalue()
5757

5858

59-
async def iter_images(max_images: int | None = None) -> AsyncIterator[bytes]:
59+
async def iter_images(
60+
max_images: int | None = None, counter: list[int] | None = None
61+
) -> AsyncIterator[bytes]:
6062
"""Generate random test images.
6163
6264
Args:
6365
max_images: Maximum number of images to generate. If None, generates
64-
infinitely.
66+
infinitely.
67+
counter: Optional list with single integer to track number of images
68+
sent.
69+
The first element will be incremented for each image generated.
6570
6671
Yields:
6772
JPEG image bytes with random content
6873
6974
"""
7075
count = 0
7176
while max_images is None or count < max_images:
72-
yield create_random_image()
77+
img = create_random_image()
78+
if counter is not None:
79+
counter[0] = counter[0] + 1
80+
yield img
7381
count += 1

examples/example.py

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,80 +21,106 @@ async def run_example(
2121
options: AthenaOptions,
2222
auth_token: str,
2323
max_test_images: int | None = None,
24-
) -> None:
25-
"""Run an example classification with the given options.
24+
) -> tuple[int, int]:
25+
"""Run example classification with the given options.
2626
2727
Args:
2828
logger: Logger instance for output
2929
options: Configuration options for the Athena client
3030
auth_token: Authentication token for the API
3131
max_test_images: Maximum number of test images to generate
32-
(None for infinite)
32+
33+
Returns:
34+
Number of requests sent and responses received
3335
3436
"""
3537
channel = create_channel(options.host, auth_token)
3638

39+
sent_counter = [0] # Use list to allow mutation in closure
40+
received_count = 0
41+
3742
async with AthenaClient(channel, options) as client:
38-
# Start image classification
39-
results = client.classify_images(iter_images(max_test_images))
40-
logger.info("Starting classification...")
43+
results = client.classify_images(
44+
iter_images(max_test_images, sent_counter)
45+
)
4146

42-
# Initialize rate tracking
4347
start_time = time.time()
44-
response_count = 0
4548

4649
try:
4750
async for result in results:
48-
response_count += 1
49-
current_time = time.time()
50-
elapsed = current_time - start_time
51+
received_count += len(result.outputs)
5152

52-
if response_count % 10 == 0:
53-
rate = response_count / elapsed
53+
if received_count % 10 == 0:
54+
elapsed = time.time() - start_time
55+
rate = received_count / elapsed if elapsed > 0 else 0
5456
logger.info(
55-
"Processed %d responses (%.1f/sec)",
56-
response_count,
57+
"Sent %d requests, received %d responses (%.1f/sec)",
58+
sent_counter[0],
59+
received_count,
5760
rate,
5861
)
5962

60-
logger.debug("Got result: %s", result)
63+
for output in result.outputs:
64+
classifications = {
65+
c.label: round(c.weight, 3)
66+
for c in output.classifications
67+
}
68+
logger.debug(
69+
"Result [%s]: %s",
70+
output.correlation_id[:8],
71+
classifications,
72+
)
73+
6174
except Exception:
6275
logger.exception("Error during classification")
63-
if response_count == 0:
64-
# Re-raise if we didn't process any responses
76+
if received_count == 0:
6577
raise
6678
finally:
67-
# Calculate final statistics after loop completes
68-
if response_count > 0:
69-
duration = time.time() - start_time
70-
avg_rate = response_count / duration
79+
duration = time.time() - start_time
80+
if received_count > 0:
81+
avg_rate = received_count / duration if duration > 0 else 0
7182
logger.info(
72-
"\nFinal: %d responses in %.1f seconds (%.1f/sec)",
73-
response_count,
83+
"Completed: sent=%d received=%d in %.1fs (%.1f/sec)",
84+
sent_counter[0],
85+
received_count,
7486
duration,
7587
avg_rate,
7688
)
7789

90+
if options.timeout and duration >= options.timeout * 0.95:
91+
logger.info(
92+
"Stream reached maximum duration: %.1fs (limit: %.1fs)",
93+
duration,
94+
options.timeout,
95+
)
96+
elif options.timeout:
97+
logger.info(
98+
"Stream completed naturally in %.1fs (max: %.1fs)",
99+
duration,
100+
options.timeout,
101+
)
102+
103+
return (sent_counter[0], received_count)
104+
78105

79106
async def main() -> int:
80-
"""Run examples showing both authenticated and unauthenticated usage."""
107+
"""Run the classification example."""
81108
logger = logging.getLogger(__name__)
82109
load_dotenv()
83110

84-
# Set maximum number of test images to generate (None for infinite)
85-
max_test_images = None
111+
# Configuration
112+
max_test_images = 100
86113

87-
# Example with authenticated channel
88114
auth_token = os.getenv("ATHENA_AUTH_TOKEN")
89115
if not auth_token:
90-
logger.error("ATHENA_AUTH_TOKEN not set.")
116+
logger.error("ATHENA_AUTH_TOKEN not set")
91117
return 1
92118

93-
logger.info("Running example with secure authenticated channel")
94119
host = os.getenv("ATHENA_HOST", "localhost")
95-
channel = create_channel(host, auth_token)
120+
logger.info("Connecting to %s", host)
96121

97-
# Get the first available deployment using context manager
122+
# Get available deployment
123+
channel = create_channel(host, auth_token)
98124
async with DeploymentSelector(channel) as deployment_selector:
99125
deployments = await deployment_selector.list_deployments()
100126

@@ -105,15 +131,25 @@ async def main() -> int:
105131
deployment_id = deployments.deployments[0].deployment_id
106132
logger.info("Using deployment: %s", deployment_id)
107133

108-
athena_options = AthenaOptions(
134+
# Run classification with persistent connection
135+
options = AthenaOptions(
109136
host=host,
110137
resize_images=True,
111138
deployment_id=deployment_id,
112139
compress_images=True,
140+
timeout=120.0, # Maximum duration, not forced timeout
141+
keepalive_interval=30.0, # Longer intervals for persistent streams
142+
)
143+
144+
sent, received = await run_example(
145+
logger, options, auth_token, max_test_images
113146
)
114-
await run_example(logger, athena_options, auth_token, max_test_images)
115147

116-
return 0
148+
if sent == received:
149+
logger.info("Success: %d requests processed", sent)
150+
return 0
151+
logger.warning("Incomplete: %d sent, %d received", sent, received)
152+
return 1
117153

118154

119155
if __name__ == "__main__":

0 commit comments

Comments
 (0)