Skip to content

Commit 52b78a6

Browse files
MarkDaoustcopybara-github
authored andcommitted
feat: Add destination parameter to client.files.download to support streaming downloads to disk or file-like objects
PiperOrigin-RevId: 970840644
1 parent 2cc99a9 commit 52b78a6

4 files changed

Lines changed: 920 additions & 102 deletions

File tree

google/genai/_api_client.py

Lines changed: 231 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import sys
3535
import threading
3636
import time
37-
from typing import Any, AsyncIterator, Iterator, Optional, TYPE_CHECKING, Tuple, Union
37+
from typing import Any, AsyncIterator, Iterator, Optional, TYPE_CHECKING, Tuple, Union, cast, overload
3838
from urllib.parse import urlparse
3939
from urllib.parse import urlunparse
4040
import warnings
@@ -638,6 +638,9 @@ def __del__(self) -> None:
638638
class BaseApiClient:
639639
"""Client for calling HTTP APIs sending and receiving JSON."""
640640

641+
vertexai: Optional[bool] = None
642+
custom_base_url: Optional[str] = None
643+
641644
def __init__(
642645
self,
643646
vertexai: Optional[bool] = None,
@@ -1962,21 +1965,55 @@ def _upload_fd(
19621965
raise ValueError('Failed to upload file: Upload status is not finalized.')
19631966
return HttpResponse(response.headers, response_stream=[response.text])
19641967

1968+
@overload
19651969
def download_file(
19661970
self,
19671971
path: str,
19681972
*,
19691973
http_options: Optional[HttpOptionsOrDict] = None,
1970-
) -> Union[Any, bytes]:
1974+
destination: None = None,
1975+
chunk_size: int = 1024 * 1024,
1976+
) -> bytes:
1977+
...
1978+
1979+
@overload
1980+
def download_file(
1981+
self,
1982+
path: str,
1983+
*,
1984+
http_options: Optional[HttpOptionsOrDict] = None,
1985+
destination: Union[str, os.PathLike[str], io.IOBase],
1986+
chunk_size: int = 1024 * 1024,
1987+
) -> None:
1988+
...
1989+
1990+
def download_file(
1991+
self,
1992+
path: str,
1993+
*,
1994+
http_options: Optional[HttpOptionsOrDict] = None,
1995+
destination: Optional[Union[str, os.PathLike[str], io.IOBase]] = None,
1996+
chunk_size: int = 1024 * 1024,
1997+
) -> Optional[bytes]:
19711998
"""Downloads the file data.
19721999
19732000
Args:
19742001
path: The request path with query params.
19752002
http_options: The http options to use for the request.
2003+
destination: Optional local file path or writable stream.
2004+
chunk_size: The chunk size in bytes to stream.
19762005
1977-
returns:
1978-
The file bytes
2006+
Returns:
2007+
The file bytes if destination is None, otherwise None.
19792008
"""
2009+
if destination is not None and not isinstance(
2010+
destination, (str, os.PathLike)
2011+
) and not hasattr(destination, 'write'):
2012+
raise ValueError(
2013+
f'Unsupported destination type: {type(destination)}. '
2014+
'Expected str, os.PathLike, or a writable file-like object.'
2015+
)
2016+
19802017
http_request = self._build_request(
19812018
'get', path=path, request_dict={}, http_options=http_options
19822019
)
@@ -1988,18 +2025,86 @@ def download_file(
19882025
else:
19892026
data = http_request.data
19902027

1991-
response = self._httpx_client.request( # type: ignore[union-attr]
1992-
method=http_request.method,
1993-
url=http_request.url,
1994-
headers=http_request.headers,
1995-
content=data,
1996-
timeout=http_request.timeout,
1997-
)
2028+
def _write_chunks(chunks: Iterator[bytes]) -> None:
2029+
if isinstance(destination, (str, os.PathLike)):
2030+
with open(destination, 'wb') as f:
2031+
for chunk in chunks:
2032+
f.write(chunk)
2033+
elif destination is not None and hasattr(destination, 'write'):
2034+
for chunk in chunks:
2035+
destination.write(chunk)
19982036

1999-
errors.APIError.raise_for_response(response)
2000-
return HttpResponse(
2001-
response.headers, byte_stream=[response.read()]
2002-
).byte_stream[0]
2037+
if self._use_google_auth_sync():
2038+
url = str(http_request.url)
2039+
if self._authorized_session is None:
2040+
from google.auth.transport.requests import AuthorizedSession # pylint: disable=g-import-not-at-top
2041+
2042+
self._authorized_session = AuthorizedSession( # type: ignore[no-untyped-call]
2043+
self._credentials,
2044+
max_refresh_attempts=1,
2045+
)
2046+
client_cert_source = mtls.default_client_cert_source() # type: ignore[no-untyped-call]
2047+
self._authorized_session.configure_mtls_channel(
2048+
client_cert_source
2049+
) # type: ignore[no-untyped-call]
2050+
if self._authorized_session._is_mtls and 'googleapis.com' in url:
2051+
if 'sandbox' in url:
2052+
url = url.replace(
2053+
'sandbox.googleapis.com', 'mtls.sandbox.googleapis.com'
2054+
)
2055+
else:
2056+
url = url.replace('googleapis.com', 'mtls.googleapis.com')
2057+
if destination is not None:
2058+
response = self._authorized_session.request( # type: ignore[no-untyped-call]
2059+
method=http_request.method.upper(),
2060+
url=url,
2061+
data=data,
2062+
headers=http_request.headers,
2063+
timeout=http_request.timeout,
2064+
stream=True,
2065+
)
2066+
try:
2067+
errors.APIError.raise_for_response(response)
2068+
_write_chunks(response.iter_content(chunk_size=chunk_size))
2069+
finally:
2070+
response.close()
2071+
return None
2072+
else:
2073+
response = self._authorized_session.request( # type: ignore[no-untyped-call]
2074+
method=http_request.method.upper(),
2075+
url=url,
2076+
data=data,
2077+
headers=http_request.headers,
2078+
timeout=http_request.timeout,
2079+
)
2080+
errors.APIError.raise_for_response(response)
2081+
return cast(bytes, response.content)
2082+
else:
2083+
if destination is not None:
2084+
httpx_request = self._httpx_client.build_request( # type: ignore[union-attr]
2085+
method=http_request.method,
2086+
url=http_request.url,
2087+
content=data,
2088+
headers=http_request.headers,
2089+
timeout=http_request.timeout,
2090+
)
2091+
response = self._httpx_client.send(httpx_request, stream=True) # type: ignore[union-attr, arg-type]
2092+
try:
2093+
errors.APIError.raise_for_response(response)
2094+
_write_chunks(response.iter_bytes(chunk_size=chunk_size))
2095+
finally:
2096+
response.close()
2097+
return None
2098+
else:
2099+
response = self._httpx_client.request( # type: ignore[union-attr]
2100+
method=http_request.method,
2101+
url=http_request.url,
2102+
content=data,
2103+
headers=http_request.headers,
2104+
timeout=http_request.timeout,
2105+
)
2106+
errors.APIError.raise_for_response(response)
2107+
return cast(bytes, response.read())
20032108

20042109
async def async_upload_file(
20052110
self,
@@ -2237,21 +2342,55 @@ async def _async_upload_fd(
22372342
client_response.headers, response_stream=[client_response.text]
22382343
)
22392344

2345+
@overload
22402346
async def async_download_file(
22412347
self,
22422348
path: str,
22432349
*,
22442350
http_options: Optional[HttpOptionsOrDict] = None,
2245-
) -> Union[Any, bytes]:
2246-
"""Downloads the file data.
2351+
destination: None = None,
2352+
chunk_size: int = 1024 * 1024,
2353+
) -> bytes:
2354+
...
2355+
2356+
@overload
2357+
async def async_download_file(
2358+
self,
2359+
path: str,
2360+
*,
2361+
http_options: Optional[HttpOptionsOrDict] = None,
2362+
destination: Union[str, os.PathLike[str], io.IOBase],
2363+
chunk_size: int = 1024 * 1024,
2364+
) -> None:
2365+
...
2366+
2367+
async def async_download_file(
2368+
self,
2369+
path: str,
2370+
*,
2371+
http_options: Optional[HttpOptionsOrDict] = None,
2372+
destination: Optional[Union[str, os.PathLike[str], io.IOBase]] = None,
2373+
chunk_size: int = 1024 * 1024,
2374+
) -> Optional[bytes]:
2375+
"""Downloads the file data asynchronously.
22472376
22482377
Args:
22492378
path: The request path with query params.
22502379
http_options: The http options to use for the request.
2380+
destination: Optional local file path or writable stream.
2381+
chunk_size: The chunk size in bytes to stream.
22512382
2252-
returns:
2253-
The file bytes
2383+
Returns:
2384+
The file bytes if destination is None, otherwise None.
22542385
"""
2386+
if destination is not None and not isinstance(
2387+
destination, (str, os.PathLike)
2388+
) and not hasattr(destination, 'write'):
2389+
raise ValueError(
2390+
f'Unsupported destination type: {type(destination)}. '
2391+
'Expected str, os.PathLike, or a writable file-like object.'
2392+
)
2393+
22552394
http_request = self._build_request(
22562395
'get', path=path, request_dict={}, http_options=http_options
22572396
)
@@ -2263,34 +2402,89 @@ async def async_download_file(
22632402
else:
22642403
data = http_request.data
22652404

2405+
async def _write_chunks(chunks: AsyncIterator[bytes]) -> None:
2406+
if isinstance(destination, (str, os.PathLike)):
2407+
with open(destination, 'wb') as f:
2408+
async for chunk in chunks:
2409+
f.write(chunk)
2410+
elif destination is not None and hasattr(destination, 'write'):
2411+
async for chunk in chunks:
2412+
res = destination.write(chunk)
2413+
if inspect.isawaitable(res):
2414+
await res
2415+
22662416
if self._use_aiohttp():
22672417
session = await self._get_aiohttp_session() # type: ignore[assignment]
2418+
url = http_request.url
2419+
if self._use_google_auth_async():
2420+
client_cert_source = mtls.default_client_cert_source() # type: ignore[no-untyped-call]
2421+
await session.configure_mtls_channel( # type: ignore[union-attr]
2422+
client_cert_source
2423+
)
2424+
if session._is_mtls and 'googleapis.com' in url: # type: ignore[union-attr]
2425+
if 'sandbox' in url:
2426+
url = url.replace(
2427+
'sandbox.googleapis.com', 'mtls.sandbox.googleapis.com'
2428+
)
2429+
else:
2430+
url = url.replace('googleapis.com', 'mtls.googleapis.com')
22682431
response = await session.request( # type: ignore[union-attr]
22692432
method=http_request.method,
2270-
url=http_request.url,
2433+
url=url,
22712434
headers=http_request.headers,
22722435
data=data,
22732436
timeout=aiohttp.ClientTimeout(total=http_request.timeout),
2437+
**self._async_client_session_request_args,
22742438
)
2275-
await errors.APIError.raise_for_async_response(response)
2276-
2277-
return HttpResponse(
2278-
response.headers, byte_stream=[await response.read()]
2279-
).byte_stream[0]
2439+
if destination is not None:
2440+
try:
2441+
await errors.APIError.raise_for_async_response(response)
2442+
if hasattr(response, '_response'):
2443+
raw_response = response._response
2444+
else:
2445+
raw_response = response
2446+
await _write_chunks(raw_response.content.iter_chunked(chunk_size))
2447+
finally:
2448+
response.close()
2449+
return None
2450+
else:
2451+
try:
2452+
await errors.APIError.raise_for_async_response(response)
2453+
return cast(bytes, await response.read())
2454+
finally:
2455+
response.close()
22802456
else:
22812457
# aiohttp is not available. Fall back to httpx.
2282-
client_response = await self._async_httpx_client.request( # type: ignore[union-attr]
2283-
method=http_request.method,
2284-
url=http_request.url,
2285-
headers=http_request.headers,
2286-
content=data,
2287-
timeout=http_request.timeout,
2288-
)
2289-
await errors.APIError.raise_for_async_response(client_response)
2290-
2291-
return HttpResponse(
2292-
client_response.headers, byte_stream=[client_response.read()]
2293-
).byte_stream[0]
2458+
if destination is not None:
2459+
httpx_request = self._async_httpx_client.build_request( # type: ignore[union-attr]
2460+
method=http_request.method,
2461+
url=http_request.url,
2462+
content=data,
2463+
headers=http_request.headers,
2464+
timeout=http_request.timeout,
2465+
)
2466+
client_response = await self._async_httpx_client.send( # type: ignore[union-attr]
2467+
httpx_request, # type: ignore[arg-type]
2468+
stream=True,
2469+
)
2470+
try:
2471+
await errors.APIError.raise_for_async_response(client_response)
2472+
await _write_chunks(
2473+
client_response.aiter_bytes(chunk_size=chunk_size)
2474+
)
2475+
finally:
2476+
await client_response.aclose()
2477+
return None
2478+
else:
2479+
client_response = await self._async_httpx_client.request( # type: ignore[union-attr]
2480+
method=http_request.method,
2481+
url=http_request.url,
2482+
headers=http_request.headers,
2483+
content=data,
2484+
timeout=http_request.timeout,
2485+
)
2486+
await errors.APIError.raise_for_async_response(client_response)
2487+
return cast(bytes, client_response.read())
22942488

22952489
# This method does nothing in the real api client. It is used in the
22962490
# replay_api_client to verify the response from the SDK method matches the

0 commit comments

Comments
 (0)