Skip to content

Commit 6288fe5

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): move template build-context uploads onto pyqwest
build_api.upload_file uses a one-off pyqwest transport; Content-Length framing verified at the wire level for both the spooled sync archive and the async-iterator body (S3 rejects chunked encoding). The upload-file regression tests compare header names case-insensitively since hyper lowercases them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 249a147 commit 6288fe5

5 files changed

Lines changed: 51 additions & 18 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@e2b/python-sdk": minor
3+
---
4+
5+
Move template build-context uploads (to S3 presigned URLs) onto
6+
[`pyqwest`](https://pypi.org/project/pyqwest/) via its httpx-compatible
7+
transport adapter. Content-Length framing for the streamed archive body is
8+
preserved (S3 rejects chunked transfer encoding). The 1-hour upload timeout
9+
now bounds the entire upload rather than each socket operation, and
10+
`verify_ssl=False` on the client is no longer honored for uploads (pyqwest
11+
has no insecure-TLS option).

packages/python-sdk/e2b/template_async/build_api.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from typing import Callable, Optional, List, Union
55

66
import httpx
7+
from pyqwest import HTTPTransport
78

8-
from e2b.api import handle_api_exception
9+
from e2b.api import handle_api_exception, proxy_to_url
10+
from e2b.api.client_async import AsyncApiPyqwestTransport
911
from e2b.io_utils import aiter_io_chunks
1012
from e2b.api.client.api.templates import (
1113
post_v3_templates,
@@ -125,16 +127,23 @@ async def upload_file(
125127
try:
126128
size = os.fstat(tar_file.fileno()).st_size
127129

130+
# Through the pyqwest adapter the upload timeout is a
131+
# whole-request deadline for the entire transfer, not a per-write
132+
# bound as with the httpx transport this replaced.
128133
async with httpx.AsyncClient(
129134
timeout=httpx.Timeout(upload_timeout),
130-
verify=api_client._verify_ssl,
131135
follow_redirects=api_client._follow_redirects,
132-
proxy=getattr(api_client, "_proxy", None),
133-
http2=False,
136+
transport=AsyncApiPyqwestTransport(
137+
HTTPTransport(
138+
tls_include_system_certs=True,
139+
proxy=proxy_to_url(getattr(api_client, "_proxy", None)),
140+
)
141+
),
134142
) as client:
135143
# Stream the archive from disk via an async iterator. The
136144
# explicit Content-Length suppresses chunked transfer
137-
# encoding, which S3 presigned URLs reject.
145+
# encoding, which S3 presigned URLs reject; reqwest keeps the
146+
# Content-Length framing for the streamed body.
138147
response = await client.put(
139148
url,
140149
content=aiter_io_chunks(tar_file),

packages/python-sdk/e2b/template_sync/build_api.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from typing import Callable, Optional, List, Union
44

55
import httpx
6+
from pyqwest import SyncHTTPTransport
67

7-
from e2b.api import handle_api_exception
8+
from e2b.api import handle_api_exception, proxy_to_url
9+
from e2b.api.client_sync import ApiPyqwestTransport
810
from e2b.api.client.api.templates import (
911
post_v3_templates,
1012
get_templates_template_id_files_hash,
@@ -121,16 +123,23 @@ def upload_file(
121123
file_name, context_path, ignore_patterns, resolve_symlinks, gzip
122124
)
123125
try:
126+
# Through the pyqwest adapter the upload timeout is a
127+
# whole-request deadline for the entire transfer, not a per-write
128+
# bound as with the httpx transport this replaced.
124129
with httpx.Client(
125130
timeout=httpx.Timeout(upload_timeout),
126-
verify=api_client._verify_ssl,
127131
follow_redirects=api_client._follow_redirects,
128-
proxy=getattr(api_client, "_proxy", None),
129-
http2=False,
132+
transport=ApiPyqwestTransport(
133+
SyncHTTPTransport(
134+
tls_include_system_certs=True,
135+
proxy=proxy_to_url(getattr(api_client, "_proxy", None)),
136+
)
137+
),
130138
) as client:
131139
# httpx streams the archive from disk in chunks and sets
132140
# Content-Length from the file size—S3 presigned URLs reject
133-
# chunked transfer encoding.
141+
# chunked transfer encoding, and reqwest keeps the
142+
# Content-Length framing for the streamed body.
134143
response = client.put(url, content=tar_file)
135144
response.raise_for_status()
136145
finally:

packages/python-sdk/tests/async/template_async/test_upload_file.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ def _make_server():
2626

2727
class Handler(BaseHTTPRequestHandler):
2828
def do_PUT(self):
29-
state["headers"] = dict(self.headers)
29+
# hyper (pyqwest) sends lowercase header names where httpcore
30+
# title-cased them; compare case-insensitively.
31+
state["headers"] = {k.lower(): v for k, v in self.headers.items()}
3032
length = int(self.headers.get("Content-Length", 0))
3133
body = self.rfile.read(length) if length else b""
3234
state["body_length"] = len(body)
@@ -72,15 +74,15 @@ def get_async_httpx_client(self):
7274
thread.join(timeout=5)
7375

7476
assert state["headers"] is not None
75-
content_length = state["headers"].get("Content-Length")
77+
content_length = state["headers"].get("content-length")
7678
assert content_length is not None
7779
assert int(content_length) > 0
7880
assert int(content_length) == state["body_length"]
7981

80-
transfer_encoding = state["headers"].get("Transfer-Encoding")
82+
transfer_encoding = state["headers"].get("transfer-encoding")
8183
if transfer_encoding is not None:
8284
assert "chunked" not in transfer_encoding.lower()
83-
assert "Authorization" not in state["headers"]
85+
assert "authorization" not in state["headers"]
8486

8587

8688
async def _capture_upload_timeout(tmp_path, request_timeout=None):

packages/python-sdk/tests/sync/template_sync/test_upload_file.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ def _make_server():
2222

2323
class Handler(BaseHTTPRequestHandler):
2424
def do_PUT(self):
25-
state["headers"] = dict(self.headers)
25+
# hyper (pyqwest) sends lowercase header names where httpcore
26+
# title-cased them; compare case-insensitively.
27+
state["headers"] = {k.lower(): v for k, v in self.headers.items()}
2628
length = int(self.headers.get("Content-Length", 0))
2729
body = self.rfile.read(length) if length else b""
2830
state["body_length"] = len(body)
@@ -68,15 +70,15 @@ def get_httpx_client(self):
6870
thread.join(timeout=5)
6971

7072
assert state["headers"] is not None
71-
content_length = state["headers"].get("Content-Length")
73+
content_length = state["headers"].get("content-length")
7274
assert content_length is not None
7375
assert int(content_length) > 0
7476
assert int(content_length) == state["body_length"]
7577

76-
transfer_encoding = state["headers"].get("Transfer-Encoding")
78+
transfer_encoding = state["headers"].get("transfer-encoding")
7779
if transfer_encoding is not None:
7880
assert "chunked" not in transfer_encoding.lower()
79-
assert "Authorization" not in state["headers"]
81+
assert "authorization" not in state["headers"]
8082

8183

8284
def _capture_upload_timeout(tmp_path, request_timeout=None):

0 commit comments

Comments
 (0)