Skip to content

Commit bb58520

Browse files
committed
Add async E2E coverage
1 parent 924fffc commit bb58520

8 files changed

Lines changed: 149 additions & 39 deletions

File tree

tests/test_async_client.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ async def test_async_client_methods_and_context_manager(self):
274274
self.assertEqual(response.data["ok"], "ASSEMBLY_COMPLETED")
275275
self.assertEqual(response.data["assembly_id"], "abc123")
276276
self.assertEqual(response.status_code, 200)
277+
self.assertIs(type(response.headers), dict)
277278
self.assertEqual(response.headers["X-Async-Route"], "get_assembly")
278279

279280
response = await client.list_assemblies()
@@ -383,6 +384,18 @@ async def test_async_client_close_reopens_owned_session(self):
383384

384385
await client.close()
385386

387+
async def test_async_request_owned_sessions_trust_environment(self):
388+
session = _NeverOwnedSession()
389+
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
390+
391+
with mock.patch("aiohttp.ClientSession", return_value=session) as session_mock:
392+
ensured_session = await client.request._ensure_session()
393+
394+
self.assertIs(ensured_session, session)
395+
session_mock.assert_called_once_with(trust_env=True)
396+
397+
await client.close()
398+
386399
async def test_async_client_reopens_owned_session_when_session_is_closed(self):
387400
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
388401

@@ -521,7 +534,7 @@ async def test_async_assembly_wait_returns_plain_text_poll_response(self):
521534
def test_async_signed_smart_cdn_url_matches_sync_and_rejects_bad_types(self):
522535
async_client = AsyncTransloadit("test-key", "test-secret")
523536
sync_client = Transloadit("test-key", "test-secret")
524-
params = {"width": 100, "tags": ["a", "b"], "enabled": True, "skip": None}
537+
params = {"width": 100, "tags": ["a", "b"], "enabled": True, "flags": [True, False], "skip": None}
525538

526539
with mock.patch("time.time", return_value=1732550672.867):
527540
async_url = async_client.get_signed_smart_cdn_url(
@@ -569,6 +582,9 @@ def test_async_signed_smart_cdn_url_matches_sync_and_rejects_bad_types(self):
569582
self.assertIn("width=100", async_url)
570583
self.assertIn("tags=a", async_url)
571584
self.assertIn("tags=b", async_url)
585+
self.assertIn("enabled=true", async_url)
586+
self.assertIn("flags=true", async_url)
587+
self.assertIn("flags=false", async_url)
572588
self.assertIn("exp=1732550672867", explicit_async_url)
573589
self.assertNotIn("width=", bare_async_url)
574590
self.assertNotIn("skip=", async_url)
@@ -908,7 +924,7 @@ def uploader(self, **kwargs):
908924
post_mock.assert_awaited_once()
909925
self.assertEqual(calls, [])
910926

911-
async def test_async_assembly_resumable_response_without_upload_urls_skips_tus_upload(self):
927+
async def test_async_assembly_resumable_response_without_upload_urls_raises_before_tus_upload(self):
912928
calls = []
913929

914930
class _TusClient:
@@ -930,9 +946,9 @@ def uploader(self, **kwargs):
930946

931947
with mock.patch.object(client.request, "post", new=mock.AsyncMock(return_value=incomplete_response)) as post_mock:
932948
with mock.patch("transloadit.async_assembly.tus.TusClient", new=_TusClient):
933-
response = await assembly.create(resumable=True)
949+
with self.assertRaises(RuntimeError):
950+
await assembly.create(resumable=True)
934951

935-
self.assertIs(response, incomplete_response)
936952
post_mock.assert_awaited_once()
937953
self.assertEqual(calls, [])
938954

@@ -988,7 +1004,12 @@ def uploader(self, **kwargs):
9881004
response = await assembly.create(resumable=True)
9891005

9901006
self.assertEqual(response.data["ok"], "ASSEMBLY_COMPLETED")
991-
self.assertEqual(to_thread_mock.await_count, 1)
1007+
tus_upload_calls = [
1008+
call
1009+
for call in to_thread_mock.await_args_list
1010+
if getattr(call.args[0], "__name__", "") == "_do_tus_upload"
1011+
]
1012+
self.assertEqual(len(tus_upload_calls), 1)
9921013

9931014
create_request = next(
9941015
entry for entry in self.server.requests if entry["path"] == "/assemblies" and entry["method"] == "POST"
@@ -1232,6 +1253,25 @@ async def test_async_request_uses_connect_and_read_timeouts_for_uploads(self):
12321253
self.assertIsNone(timeout.sock_read)
12331254
self.assertEqual(session.calls[0][1]["data"]._fields[2][1]["Content-Type"], "image/jpeg")
12341255

1256+
async def test_async_request_payload_preserves_custom_auth_constraints(self):
1257+
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
1258+
1259+
payload = client.request._to_payload(
1260+
{
1261+
"auth": {
1262+
"max_size": 1024,
1263+
"referer": "https://example.com",
1264+
},
1265+
"foo": "bar",
1266+
}
1267+
)
1268+
1269+
params = json.loads(payload["params"])
1270+
self.assertEqual(params["auth"]["key"], "key")
1271+
self.assertIn("expires", params["auth"])
1272+
self.assertEqual(params["auth"]["max_size"], 1024)
1273+
self.assertEqual(params["auth"]["referer"], "https://example.com")
1274+
12351275
async def test_async_request_filters_none_and_lowercases_booleans_in_extra_data(self):
12361276
session = _RecordingSession({"ok": "ASSEMBLY_COMPLETED"})
12371277
client = AsyncTransloadit("key", "secret", service=self.server.base_url, session=session)

tests/test_e2e_upload.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
22
from pathlib import Path
3+
from unittest import IsolatedAsyncioTestCase
34

45
import pytest
56

7+
from transloadit.async_client import AsyncTransloadit
68
from transloadit.client import Transloadit
79

810

@@ -17,38 +19,39 @@ def _is_enabled():
1719
]
1820

1921

20-
def test_e2e_image_resize():
22+
def _get_e2e_credentials():
2123
key = os.getenv("TRANSLOADIT_KEY")
2224
secret = os.getenv("TRANSLOADIT_SECRET")
2325

2426
if not key or not secret:
2527
pytest.skip("TRANSLOADIT_KEY and TRANSLOADIT_SECRET must be set to run E2E tests")
2628

29+
return key, secret
30+
31+
32+
def _get_fixture_path():
2733
fixture_path = Path(__file__).resolve().parents[1] / "chameleon.jpg"
2834
if not fixture_path.exists():
2935
pytest.skip("chameleon.jpg fixture missing; run from repository root")
3036

31-
client = Transloadit(key, secret)
37+
return fixture_path
3238

33-
assembly = client.new_assembly()
3439

35-
with fixture_path.open("rb") as upload:
36-
assembly.add_file(upload)
37-
assembly.add_step(
38-
"resize",
39-
"/image/resize",
40-
{
41-
"use": ":original",
42-
"width": 128,
43-
"height": 128,
44-
"resize_strategy": "fit",
45-
"format": "png",
46-
},
47-
)
40+
def _add_resize_step(assembly):
41+
assembly.add_step(
42+
"resize",
43+
"/image/resize",
44+
{
45+
"use": ":original",
46+
"width": 128,
47+
"height": 128,
48+
"resize_strategy": "fit",
49+
"format": "png",
50+
},
51+
)
4852

49-
response = assembly.create(wait=True, resumable=False)
5053

51-
data = response.data
54+
def _assert_e2e_image_resize(data, fixture_path):
5255
assembly_ssl_url = data.get("assembly_ssl_url") or data.get("assembly_url")
5356
assembly_id = data.get("assembly_id")
5457
print(f"[python-sdk][e2e] Assembly URL: {assembly_ssl_url} (id={assembly_id})")
@@ -85,3 +88,34 @@ def test_e2e_image_resize():
8588
f"{width}x{height}, ssl_url={ssl_url}, basename={upload_info.get('basename')}, "
8689
f"filename={upload_info.get('name')}"
8790
)
91+
92+
93+
def test_e2e_image_resize():
94+
key, secret = _get_e2e_credentials()
95+
fixture_path = _get_fixture_path()
96+
client = Transloadit(key, secret)
97+
98+
assembly = client.new_assembly()
99+
100+
with fixture_path.open("rb") as upload:
101+
assembly.add_file(upload)
102+
_add_resize_step(assembly)
103+
response = assembly.create(wait=True, resumable=False)
104+
105+
_assert_e2e_image_resize(response.data, fixture_path)
106+
107+
108+
class TestAsyncE2EUpload(IsolatedAsyncioTestCase):
109+
async def test_e2e_image_resize(self):
110+
key, secret = _get_e2e_credentials()
111+
fixture_path = _get_fixture_path()
112+
113+
async with AsyncTransloadit(key, secret) as client:
114+
assembly = client.new_assembly()
115+
116+
with fixture_path.open("rb") as upload:
117+
assembly.add_file(upload)
118+
_add_resize_step(assembly)
119+
response = await assembly.create(wait=True, resumable=False)
120+
121+
_assert_e2e_image_resize(response.data, fixture_path)

tests/test_request.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import unittest
23
import urllib.parse
34

@@ -38,6 +39,23 @@ def test_post(self, mock):
3839
response = self.request.post("/foo", data={"foo": "bar"})
3940
self.assertEqual(response.data["ok"], "it works")
4041

42+
def test_payload_preserves_custom_auth_constraints(self):
43+
payload = self.request._to_payload(
44+
{
45+
"auth": {
46+
"max_size": 1024,
47+
"referer": "https://example.com",
48+
},
49+
"foo": "bar",
50+
}
51+
)
52+
53+
params = json.loads(payload["params"])
54+
self.assertEqual(params["auth"]["key"], "key")
55+
self.assertIn("expires", params["auth"])
56+
self.assertEqual(params["auth"]["max_size"], 1024)
57+
self.assertEqual(params["auth"]["referer"], "https://example.com")
58+
4159
@requests_mock.Mocker()
4260
def test_put(self, mock):
4361
url = f"{self.transloadit.service}/foo"

transloadit/async_assembly.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ async def create(self, wait=False, resumable=True, retries=3):
130130

131131
if resumable and self.files:
132132
if not assembly_url or not tus_url:
133-
return response
133+
raise RuntimeError("Resumable assembly response is missing upload URLs.")
134134
await self._do_tus_upload_async(assembly_url, tus_url, tus_retries)
135135

136136
if wait:

transloadit/async_client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from . import async_assembly, async_request, async_template
88

99

10+
def _stringify_url_param(value: Union[str, int, float, bool]) -> str:
11+
if isinstance(value, bool):
12+
return "true" if value else "false"
13+
return str(value)
14+
15+
1016
class AsyncTransloadit:
1117
"""
1218
Asynchronous client interface to the Transloadit API.
@@ -133,9 +139,9 @@ def get_signed_smart_cdn_url(
133139
if v is None:
134140
continue
135141
elif isinstance(v, (str, int, float, bool)):
136-
params.append((k, str(v)))
142+
params.append((k, _stringify_url_param(v)))
137143
elif isinstance(v, (list, tuple)):
138-
params.append((k, [str(vv) for vv in v]))
144+
params.append((k, [_stringify_url_param(vv) for vv in v]))
139145
else:
140146
raise ValueError(
141147
f"URL parameter values must be strings, numbers, booleans, arrays, or None. Got {type(v)} for {k}"

transloadit/async_request.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ async def _ensure_session(self):
5959
return self._session
6060
async with self._get_session_lock():
6161
if self._session is None:
62-
self._session = aiohttp.ClientSession()
62+
self._session = aiohttp.ClientSession(trust_env=True)
6363
self._owns_session = True
6464
elif self._session.closed:
6565
if self._owns_session:
66-
self._session = aiohttp.ClientSession()
66+
self._session = aiohttp.ClientSession(trust_env=True)
6767
else:
6868
raise RuntimeError("Injected aiohttp session is closed.")
6969
return self._session
@@ -75,10 +75,12 @@ async def aclose(self):
7575
self._session = None
7676

7777
def _timeout(self, files=False):
78+
# Large uploads can legitimately wait longer than TIMEOUT for the first response byte.
79+
sock_read = None if files else TIMEOUT
7880
return aiohttp.ClientTimeout(
7981
total=None,
8082
sock_connect=TIMEOUT,
81-
sock_read=None if files else TIMEOUT,
83+
sock_read=sock_read,
8284
)
8385

8486
def _normalize_payload(self, data):
@@ -115,7 +117,7 @@ async def get(self, path, params=None):
115117
return Response(
116118
data=await self._read_response_data(response),
117119
status_code=response.status,
118-
headers=response.headers,
120+
headers=dict(response.headers),
119121
)
120122

121123
async def post(self, path, data=None, extra_data=None, files=None):
@@ -149,7 +151,7 @@ async def post(self, path, data=None, extra_data=None, files=None):
149151
return Response(
150152
data=await self._read_response_data(response),
151153
status_code=response.status,
152-
headers=response.headers,
154+
headers=dict(response.headers),
153155
)
154156

155157
async def put(self, path, data=None):
@@ -167,7 +169,7 @@ async def put(self, path, data=None):
167169
return Response(
168170
data=await self._read_response_data(response),
169171
status_code=response.status,
170-
headers=response.headers,
172+
headers=dict(response.headers),
171173
)
172174

173175
async def delete(self, path, data=None):
@@ -185,16 +187,18 @@ async def delete(self, path, data=None):
185187
return Response(
186188
data=await self._read_response_data(response),
187189
status_code=response.status,
188-
headers=response.headers,
190+
headers=dict(response.headers),
189191
)
190192

191193
def _to_payload(self, data):
192194
data = copy.deepcopy(data or {})
193195
expiry = datetime.now(timezone.utc) + timedelta(seconds=self.transloadit.duration)
194-
data["auth"] = {
196+
auth = data.get("auth") if isinstance(data.get("auth"), dict) else {}
197+
auth.update({
195198
"key": self.transloadit.auth_key,
196199
"expires": expiry.strftime("%Y/%m/%d %H:%M:%S+00:00"),
197-
}
200+
})
201+
data["auth"] = auth
198202
json_data = json.dumps(data)
199203
return {"params": json_data, "signature": self._sign_data(json_data)}
200204

transloadit/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
from requests import Response
1313

1414

15+
def _stringify_url_param(value: Union[str, int, float, bool]) -> str:
16+
if isinstance(value, bool):
17+
return "true" if value else "false"
18+
return str(value)
19+
20+
1521
class Transloadit:
1622
"""
1723
This class serves as a client interface to the Transloadit API.
@@ -210,9 +216,9 @@ def get_signed_smart_cdn_url(
210216
if v is None:
211217
continue # Skip None values
212218
elif isinstance(v, (str, int, float, bool)):
213-
params.append((k, str(v)))
219+
params.append((k, _stringify_url_param(v)))
214220
elif isinstance(v, (list, tuple)):
215-
params.append((k, [str(vv) for vv in v]))
221+
params.append((k, [_stringify_url_param(vv) for vv in v]))
216222
else:
217223
raise ValueError(f"URL parameter values must be strings, numbers, booleans, arrays, or None. Got {type(v)} for {k}")
218224

transloadit/request.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,12 @@ def delete(self, path, data=None):
114114
def _to_payload(self, data):
115115
data = copy.deepcopy(data or {})
116116
expiry = datetime.now(timezone.utc) + timedelta(seconds=self.transloadit.duration)
117-
data["auth"] = {
117+
auth = data.get("auth") if isinstance(data.get("auth"), dict) else {}
118+
auth.update({
118119
"key": self.transloadit.auth_key,
119120
"expires": expiry.strftime("%Y/%m/%d %H:%M:%S+00:00"),
120-
}
121+
})
122+
data["auth"] = auth
121123
json_data = json.dumps(data)
122124
return {"params": json_data, "signature": self._sign_data(json_data)}
123125

0 commit comments

Comments
 (0)