Skip to content

Commit a6e57fb

Browse files
committed
feat: add tokens namespace
1 parent 3ecb931 commit a6e57fb

File tree

9 files changed

+219
-2
lines changed

9 files changed

+219
-2
lines changed

decart/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
QueueSubmitError,
1111
QueueStatusError,
1212
QueueResultError,
13+
TokenCreateError,
1314
)
1415
from .models import models, ModelDefinition
1516
from .types import FileInput, ModelState, Prompt
@@ -20,6 +21,10 @@
2021
JobStatusResponse,
2122
QueueJobResult,
2223
)
24+
from .tokens import (
25+
TokensClient,
26+
CreateTokenResponse,
27+
)
2328

2429
try:
2530
from .realtime import (
@@ -59,6 +64,9 @@
5964
"JobSubmitResponse",
6065
"JobStatusResponse",
6166
"QueueJobResult",
67+
"TokensClient",
68+
"CreateTokenResponse",
69+
"TokenCreateError",
6270
]
6371

6472
if REALTIME_AVAILABLE:

decart/client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .models import ImageModelDefinition, _MODELS
77
from .process.request import send_request
88
from .queue.client import QueueClient
9+
from .tokens.client import TokensClient
910

1011
try:
1112
from .realtime.client import RealtimeClient
@@ -66,6 +67,7 @@ def __init__(
6667
self.integration = integration
6768
self._session: Optional[aiohttp.ClientSession] = None
6869
self._queue: Optional[QueueClient] = None
70+
self._tokens: Optional[TokensClient] = None
6971

7072
@property
7173
def queue(self) -> QueueClient:
@@ -91,6 +93,28 @@ def queue(self) -> QueueClient:
9193
self._queue = QueueClient(self)
9294
return self._queue
9395

96+
@property
97+
def tokens(self) -> TokensClient:
98+
"""
99+
Client for creating client tokens.
100+
Client tokens are short-lived API keys safe for client-side use.
101+
102+
Example:
103+
```python
104+
# Server-side: Create a client token
105+
server_client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
106+
token = await server_client.tokens.create()
107+
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
108+
109+
# Client-side: Use the client token
110+
client = DecartClient(api_key=token.api_key)
111+
realtime_client = await client.realtime.connect(...)
112+
```
113+
"""
114+
if self._tokens is None:
115+
self._tokens = TokensClient(self)
116+
return self._tokens
117+
94118
async def _get_session(self) -> aiohttp.ClientSession:
95119
"""Get or create the aiohttp session."""
96120
if self._session is None or self._session.closed:

decart/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,9 @@ class QueueResultError(DecartSDKError):
8282
"""Raised when getting queue job result fails."""
8383

8484
pass
85+
86+
87+
class TokenCreateError(DecartSDKError):
88+
"""Raised when token creation fails."""
89+
90+
pass

decart/tokens/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .client import TokensClient
2+
from .types import CreateTokenResponse
3+
4+
__all__ = ["TokensClient", "CreateTokenResponse"]

decart/tokens/client.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import TYPE_CHECKING
2+
3+
import aiohttp
4+
5+
from ..errors import TokenCreateError
6+
from .._user_agent import build_user_agent
7+
from .types import CreateTokenResponse
8+
9+
if TYPE_CHECKING:
10+
from ..client import DecartClient
11+
12+
13+
class TokensClient:
14+
"""
15+
Client for creating client tokens.
16+
Client tokens are short-lived API keys safe for client-side use.
17+
18+
Example:
19+
```python
20+
# Server-side: Create a client token
21+
server_client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
22+
token = await server_client.tokens.create()
23+
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
24+
25+
# Client-side: Use the client token
26+
client = DecartClient(api_key=token.api_key)
27+
realtime_client = await client.realtime.connect(...)
28+
```
29+
"""
30+
31+
def __init__(self, parent: "DecartClient") -> None:
32+
self._parent = parent
33+
34+
async def _get_session(self) -> aiohttp.ClientSession:
35+
return await self._parent._get_session()
36+
37+
async def create(self) -> CreateTokenResponse:
38+
"""
39+
Create a client token.
40+
41+
Returns:
42+
A short-lived API key safe for client-side use.
43+
44+
Example:
45+
```python
46+
token = await client.tokens.create()
47+
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
48+
```
49+
50+
Raises:
51+
TokenCreateError: If token creation fails (401, 403, etc.)
52+
"""
53+
session = await self._get_session()
54+
endpoint = f"{self._parent.base_url}/v1/client/tokens"
55+
56+
async with session.post(
57+
endpoint,
58+
headers={
59+
"X-API-KEY": self._parent.api_key,
60+
"User-Agent": build_user_agent(self._parent.integration),
61+
},
62+
) as response:
63+
if not response.ok:
64+
error_text = await response.text()
65+
raise TokenCreateError(
66+
f"Failed to create token: {response.status} - {error_text}",
67+
data={"status": response.status},
68+
)
69+
data = await response.json()
70+
return CreateTokenResponse(
71+
api_key=data["apiKey"],
72+
expires_at=data["expiresAt"],
73+
)

decart/tokens/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pydantic import BaseModel
2+
3+
4+
class CreateTokenResponse(BaseModel):
5+
"""Response from creating a client token."""
6+
7+
api_key: str
8+
expires_at: str

examples/create_token.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import asyncio
2+
import os
3+
from decart import DecartClient
4+
5+
6+
async def main() -> None:
7+
# Server-side: Create client token using API key
8+
async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client:
9+
print("Creating client token...")
10+
11+
token = await server_client.tokens.create()
12+
13+
print("Token created successfully:")
14+
print(f" API Key: {token.api_key[:10]}...")
15+
print(f" Expires At: {token.expires_at}")
16+
17+
# Client-side: Use the client token
18+
# In a real app, you would send token.api_key to the frontend
19+
_client = DecartClient(api_key=token.api_key)
20+
21+
print("Client created with client token.")
22+
print("This token can now be used for realtime connections.")
23+
24+
25+
if __name__ == "__main__":
26+
asyncio.run(main())

tests/test_tokens.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for the tokens API."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, patch, MagicMock
5+
from decart import DecartClient, TokenCreateError
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_create_token() -> None:
10+
"""Creates a client token successfully."""
11+
client = DecartClient(api_key="test-api-key")
12+
13+
mock_response = AsyncMock()
14+
mock_response.ok = True
15+
mock_response.json = AsyncMock(
16+
return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"}
17+
)
18+
19+
mock_session = MagicMock()
20+
mock_session.post = MagicMock(
21+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
22+
)
23+
24+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
25+
result = await client.tokens.create()
26+
27+
assert result.api_key == "ek_test123"
28+
assert result.expires_at == "2024-12-15T12:10:00Z"
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_create_token_401_error() -> None:
33+
"""Handles 401 error."""
34+
client = DecartClient(api_key="test-api-key")
35+
36+
mock_response = AsyncMock()
37+
mock_response.ok = False
38+
mock_response.status = 401
39+
mock_response.text = AsyncMock(return_value="Invalid API key")
40+
41+
mock_session = MagicMock()
42+
mock_session.post = MagicMock(
43+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
44+
)
45+
46+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
47+
with pytest.raises(TokenCreateError, match="Failed to create token"):
48+
await client.tokens.create()
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_create_token_403_error() -> None:
53+
"""Handles 403 error."""
54+
client = DecartClient(api_key="test-api-key")
55+
56+
mock_response = AsyncMock()
57+
mock_response.ok = False
58+
mock_response.status = 403
59+
mock_response.text = AsyncMock(return_value="Cannot create token from client token")
60+
61+
mock_session = MagicMock()
62+
mock_session.post = MagicMock(
63+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
64+
)
65+
66+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
67+
with pytest.raises(TokenCreateError, match="Failed to create token"):
68+
await client.tokens.create()

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)