diff --git a/README.md b/README.md index 6cca3cd0a9..f2388d4255 100644 --- a/README.md +++ b/README.md @@ -250,37 +250,37 @@ client = genai.Client( ) ``` -### HTTP client option: httpx2 +### HTTP client option: httpx -By default, the SDK uses `httpx` for its HTTP transport. You can opt in to -[`httpx2`](https://github.com/pydantic/httpx2) by installing `google-genai[httpx2]` +By default, the SDK uses [`httpx2`](https://github.com/pydantic/httpx2) for its HTTP transport. You can opt in to +legacy `httpx` by installing `google-genai[httpx]` and setting the `GOOGLE_GENAI_HTTP_CLIENT` environment variable: ```bash -export GOOGLE_GENAI_HTTP_CLIENT=httpx2 +export GOOGLE_GENAI_HTTP_CLIENT=httpx ``` -Alternatively, you can directly inject an `httpx2.Client` or `httpx2.AsyncClient` +Alternatively, you can directly inject an `httpx.Client` or `httpx.AsyncClient` via `HttpOptions`: ```python -import httpx2 +import httpx from google import genai from google.genai import types client = genai.Client( http_options=types.HttpOptions( - httpx_client=httpx2.Client(), - httpx_async_client=httpx2.AsyncClient(), + httpx_client=httpx.Client(), + httpx_async_client=httpx.AsyncClient(), ) ) ``` ### Faster async client option: Aiohttp -By default we use httpx for both sync and async client implementations. In order +By default we use httpx2 for both sync and async client implementations. In order to have faster performance, you may install `google-genai[aiohttp]`. In Gen AI -SDK we configure `trust_env=True` to match with the default behavior of httpx. +SDK we configure `trust_env=True` to match with the default behavior of httpx2. Additional args of `aiohttp.ClientSession.request()` ([see `_RequestOptions` args](https://github.com/aio-libs/aiohttp/blob/v3.12.13/aiohttp/client.py#L170)) can be passed through the following way: @@ -295,7 +295,7 @@ client=Client(..., http_options=http_options) ### Proxy -Both httpx and aiohttp libraries use `urllib.request.getproxies` from +Both httpx2 and aiohttp libraries use `urllib.request.getproxies` from environment variables. Before client initialization, you may set proxy (and optional `SSL_CERT_FILE`) by setting the environment variables: @@ -304,8 +304,8 @@ export HTTPS_PROXY='http://username:password@proxy_uri:port' export SSL_CERT_FILE='client.pem' ``` -If you need `socks5` proxy, httpx [supports](https://www.python-httpx.org/advanced/proxies/#socks) `socks5` proxy if you pass it via -args to `httpx.Client()`. You may install `httpx[socks]` to use it. +If you need `socks5` proxy, httpx2 [supports](https://www.python-httpx.org/advanced/proxies/#socks) `socks5` proxy if you pass it via +args to `httpx2.Client()`. You may install `httpx2[socks]` to use it. Then, you can pass it through the following way: ```python diff --git a/docs/source/index.rst b/docs/source/index.rst index a285fcf808..2d8be4b0dc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -218,9 +218,9 @@ To set the API version to `v1alpha` for the Gemini Developer API: Faster async client option: Aiohttp ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -By default we use httpx for both sync and async client implementations. In order +By default we use httpx2 for both sync and async client implementations. In order to have faster performance, you may install `google-genai[aiohttp]`. In Gen AI -SDK we configure `trust_env=True` to match with the default behavior of httpx. +SDK we configure `trust_env=True` to match with the default behavior of httpx2. Additional args of `aiohttp.ClientSession.request()` (`see _RequestOptions args `_) can be passed through the following way: @@ -237,7 +237,7 @@ can be passed through the following way: Proxy ^^^^^^^ -Both httpx and aiohttp libraries use `urllib.request.getproxies` from +Both httpx2 and aiohttp libraries use `urllib.request.getproxies` from environment variables. Before client initialization, you may set proxy (and optional SSL_CERT_FILE) by setting the environment variables: @@ -248,9 +248,9 @@ optional SSL_CERT_FILE) by setting the environment variables: export SSL_CERT_FILE='client.pem' -If you need `socks5` proxy, httpx `supports `_ -`socks5` proxy if you pass it via args to httpx.Client(). You may install -`httpx[socks]` to use it. Then you can pass it through the following way: +If you need `socks5` proxy, httpx2 `supports `_ +`socks5` proxy if you pass it via args to httpx2.Client(). You may install +`httpx2[socks]` to use it. Then you can pass it through the following way: .. code:: python diff --git a/google/genai/_api_client.py b/google/genai/_api_client.py index 36d7a2638d..ca4878f4ae 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -809,13 +809,19 @@ def __del__(self) -> None: AsyncHttpx2Client = None # type: ignore[assignment,misc] +# The HTTP client backend used when GOOGLE_GENAI_HTTP_CLIENT is unset or 'auto'. +# If it is not installed, the other backend is used instead. +_DEFAULT_HTTP_CLIENT_BACKEND = 'httpx2' + + def _get_http_client_backend() -> str: """Determines whether to use 'httpx' or 'httpx2' as the default HTTP client. Can be controlled via the GOOGLE_GENAI_HTTP_CLIENT environment variable: - 'httpx2': Use httpx2 (raises ImportError if not installed). - 'httpx': Use httpx (raises ImportError if not installed). - - 'auto' or unset: Default to httpx if installed, otherwise fall back to httpx2. + - 'auto' or unset: Use `_DEFAULT_HTTP_CLIENT_BACKEND` if installed, + otherwise fall back to the other backend. """ env = os.environ.get('GOOGLE_GENAI_HTTP_CLIENT', '').lower().strip() if env == 'httpx2': @@ -837,14 +843,14 @@ def _get_http_client_backend() -> str: 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT=%r; falling back to default.', env ) - if httpx is not None: - return 'httpx' - # httpx is a required dependency so it _should_ always be present in a - # standard install, but if a user has uninstalled httpx in an environment - # that only has httpx2 installed, fall back to httpx2 before raising + installed = {'httpx': httpx is not None, 'httpx2': httpx2 is not None} + fallback = 'httpx' if _DEFAULT_HTTP_CLIENT_BACKEND == 'httpx2' else 'httpx2' + # Either backend may be missing (httpx2 is an optional extra, and a user may + # have uninstalled httpx), so fall back to the other one before raising # ImportError. - if httpx2 is not None: - return 'httpx2' + for backend in (_DEFAULT_HTTP_CLIENT_BACKEND, fallback): + if installed[backend]: + return backend raise ImportError( 'Neither httpx nor httpx2 is installed. Please install httpx2 or httpx.' ) @@ -1377,19 +1383,70 @@ def _ensure_httpx_ssl_ctx( else None ) + default_backend = ( + _get_http_client_backend() + if (client_cls is None or async_client_cls is None) + else None + ) + if client_cls is not None: + sync_cls = client_cls + elif default_backend == 'httpx2' and SyncHttpx2Client is not None: + sync_cls = SyncHttpx2Client + else: + sync_cls = ( + SyncHttpxClient + if SyncHttpxClient is not None + else (httpx.Client if httpx is not None else None) + ) + + if async_client_cls is not None: + async_cls = async_client_cls + elif default_backend == 'httpx2' and AsyncHttpx2Client is not None: + async_cls = AsyncHttpx2Client + else: + async_cls = ( + AsyncHttpxClient + if AsyncHttpxClient is not None + else (httpx.AsyncClient if httpx is not None else None) + ) + + use_httpx2 = httpx2 is not None and ( + ( + sync_cls is not None + and isinstance(getattr(httpx2, 'Client', None), type) + and issubclass(sync_cls, httpx2.Client) + ) + or ( + async_cls is not None + and isinstance(getattr(httpx2, 'AsyncClient', None), type) + and issubclass(async_cls, httpx2.AsyncClient) + ) + ) + if ctx is None: - # Initialize the SSL context for the httpx client. + # Initialize the SSL context for the httpx/httpx2 client. # Unlike requests, the httpx package does not automatically pull in the # environment variables SSL_CERT_FILE or SSL_CERT_DIR. They need to be - # enabled explicitly. + # enabled explicitly. When using httpx2 without SSL_CERT_FILE or + # SSL_CERT_DIR, delegate to httpx2.create_ssl_context(), which uses + # truststore (OS native trust store). if vertexai: ctx = _get_client_cert_ssl_ctx() if ctx is None: - ctx = ssl.create_default_context( - cafile=os.environ.get('SSL_CERT_FILE', certifi.where()), - capath=os.environ.get('SSL_CERT_DIR'), - ) + if use_httpx2: + if os.environ.get('SSL_CERT_FILE') or os.environ.get('SSL_CERT_DIR'): + ctx = ssl.create_default_context( + cafile=os.environ.get('SSL_CERT_FILE'), + capath=os.environ.get('SSL_CERT_DIR'), + ) + else: + ctx = httpx2.create_ssl_context() + else: + ctx = ssl.create_default_context( + cafile=os.environ.get('SSL_CERT_FILE', certifi.where()), + capath=os.environ.get('SSL_CERT_DIR'), + ) def _maybe_set( raw_args: Optional[_common.StringDict], @@ -1432,17 +1489,6 @@ def _maybe_set( break return args - sync_cls = client_cls or ( - SyncHttpxClient - if SyncHttpxClient is not None - else (httpx.Client if httpx is not None else None) - ) - async_cls = async_client_cls or ( - AsyncHttpxClient - if AsyncHttpxClient is not None - else (httpx.AsyncClient if httpx is not None else None) - ) - return ( _maybe_set(args, ctx, sync_cls), _maybe_set(async_args, ctx, async_cls), diff --git a/google/genai/_gaos/_hooks/adapters.py b/google/genai/_gaos/_hooks/adapters.py index 5a9c41c70e..a2809297d4 100644 --- a/google/genai/_gaos/_hooks/adapters.py +++ b/google/genai/_gaos/_hooks/adapters.py @@ -34,7 +34,10 @@ BeforeRequestHook, ) import asyncio -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional, Tuple, Union # ============================================================================ diff --git a/google/genai/_gaos/_hooks/asyncsdkhooks.py b/google/genai/_gaos/_hooks/asyncsdkhooks.py index ad81b1d067..bfcdff468e 100644 --- a/google/genai/_gaos/_hooks/asyncsdkhooks.py +++ b/google/genai/_gaos/_hooks/asyncsdkhooks.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from .asynctypes import ( AsyncBeforeRequestHook, AsyncAfterSuccessHook, diff --git a/google/genai/_gaos/_hooks/asynctypes.py b/google/genai/_gaos/_hooks/asynctypes.py index 0869abee1f..a2464421b8 100644 --- a/google/genai/_gaos/_hooks/asynctypes.py +++ b/google/genai/_gaos/_hooks/asynctypes.py @@ -24,7 +24,10 @@ BeforeRequestContext, ) from abc import ABC, abstractmethod -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional, Tuple, Union diff --git a/google/genai/_gaos/_hooks/google_genai_auth.py b/google/genai/_gaos/_hooks/google_genai_auth.py index 7ec30b9d87..989ddfb753 100644 --- a/google/genai/_gaos/_hooks/google_genai_auth.py +++ b/google/genai/_gaos/_hooks/google_genai_auth.py @@ -21,7 +21,10 @@ from typing import Any, Callable, Mapping, Optional, Union, cast -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from .. import types, utils from .types import BeforeRequestContext, BeforeRequestHook diff --git a/google/genai/_gaos/_hooks/sdkhooks.py b/google/genai/_gaos/_hooks/sdkhooks.py index 5dc2b7bc32..dcb716b345 100644 --- a/google/genai/_gaos/_hooks/sdkhooks.py +++ b/google/genai/_gaos/_hooks/sdkhooks.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from .types import ( SDKInitHook, BeforeRequestContext, diff --git a/google/genai/_gaos/_hooks/types.py b/google/genai/_gaos/_hooks/types.py index fdee868291..a1336373b8 100644 --- a/google/genai/_gaos/_hooks/types.py +++ b/google/genai/_gaos/_hooks/types.py @@ -19,7 +19,10 @@ from ..sdkconfiguration import SDKConfiguration from abc import ABC, abstractmethod -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union diff --git a/google/genai/_gaos/_internal.py b/google/genai/_gaos/_internal.py index abd8077f0f..2d65147fa8 100644 --- a/google/genai/_gaos/_internal.py +++ b/google/genai/_gaos/_internal.py @@ -22,7 +22,10 @@ from .basesdk import AsyncBaseSDK, BaseSDK from .types import OptionalNullable, UNSET from .utils import get_security_from_env, response_helpers -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/agents.py b/google/genai/_gaos/agents.py index 1118eeed94..784af73a8b 100644 --- a/google/genai/_gaos/agents.py +++ b/google/genai/_gaos/agents.py @@ -24,7 +24,10 @@ from .types.agents import agent as agents_agent, agenttool as agents_agenttool from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/basesdk.py b/google/genai/_gaos/basesdk.py index 0306654c11..8d77ec76c9 100644 --- a/google/genai/_gaos/basesdk.py +++ b/google/genai/_gaos/basesdk.py @@ -26,7 +26,12 @@ ) from .sdkconfiguration import SDKConfiguration from .utils import RetryConfig, SerializedRequestBody, get_body_content -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx +from typing import Any, Callable, List, Mapping, Optional, Tuple +from urllib.parse import parse_qs, urlparse try: import httpx2 @@ -38,8 +43,14 @@ if httpx2 is None else (httpx.Timeout, httpx2.Timeout) ) -from typing import Any, Callable, List, Mapping, Optional, Tuple -from urllib.parse import parse_qs, urlparse + + +def _use_client_default(client: Any) -> Any: + if httpx2 is not None and isinstance( + client, (httpx2.Client, httpx2.AsyncClient) + ): + return httpx2.USE_CLIENT_DEFAULT + return httpx.USE_CLIENT_DEFAULT class BaseSDK: @@ -234,7 +245,9 @@ def _build_request_with_client( data=serialized_request_body.data, files=serialized_request_body.files, headers=headers, - timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT, + timeout=( + timeout if timeout is not None else _use_client_default(client) + ), ) def do_request( @@ -501,7 +514,9 @@ def _build_request_with_client( data=serialized_request_body.data, files=serialized_request_body.files, headers=headers, - timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT, + timeout=( + timeout if timeout is not None else _use_client_default(client) + ), ) async def do_request_async( diff --git a/google/genai/_gaos/credentials.py b/google/genai/_gaos/credentials.py index f132a9d5dd..58c69fefa3 100644 --- a/google/genai/_gaos/credentials.py +++ b/google/genai/_gaos/credentials.py @@ -23,7 +23,10 @@ from .types import BaseModel, OptionalNullable, UNSET, credentials, interactions from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, List, Literal, Mapping, Optional, Union, cast, overload diff --git a/google/genai/_gaos/environments.py b/google/genai/_gaos/environments.py index 92846eb14b..ab216003f5 100644 --- a/google/genai/_gaos/environments.py +++ b/google/genai/_gaos/environments.py @@ -30,7 +30,10 @@ from .types.interactions import source as interactions_source from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/errors/cancelinteractionbyid.py b/google/genai/_gaos/errors/cancelinteractionbyid.py index 8fe786cbfd..f106e86049 100644 --- a/google/genai/_gaos/errors/cancelinteractionbyid.py +++ b/google/genai/_gaos/errors/cancelinteractionbyid.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/createinteraction.py b/google/genai/_gaos/errors/createinteraction.py index 776c4ed298..7bdb6ce903 100644 --- a/google/genai/_gaos/errors/createinteraction.py +++ b/google/genai/_gaos/errors/createinteraction.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/deleteinteraction.py b/google/genai/_gaos/errors/deleteinteraction.py index 05f2536ef9..1468644024 100644 --- a/google/genai/_gaos/errors/deleteinteraction.py +++ b/google/genai/_gaos/errors/deleteinteraction.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/genaidefaulterror.py b/google/genai/_gaos/errors/genaidefaulterror.py index 318be10505..6094a2729d 100644 --- a/google/genai/_gaos/errors/genaidefaulterror.py +++ b/google/genai/_gaos/errors/genaidefaulterror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional from dataclasses import dataclass diff --git a/google/genai/_gaos/errors/genaierror.py b/google/genai/_gaos/errors/genaierror.py index 516f13a5dd..4fdf49a5ab 100644 --- a/google/genai/_gaos/errors/genaierror.py +++ b/google/genai/_gaos/errors/genaierror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional from dataclasses import dataclass, field diff --git a/google/genai/_gaos/errors/getinteractionbyid.py b/google/genai/_gaos/errors/getinteractionbyid.py index a29d02716d..deea47d7aa 100644 --- a/google/genai/_gaos/errors/getinteractionbyid.py +++ b/google/genai/_gaos/errors/getinteractionbyid.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/responsevalidationerror.py b/google/genai/_gaos/errors/responsevalidationerror.py index 24fe08c107..d9aa158c0c 100644 --- a/google/genai/_gaos/errors/responsevalidationerror.py +++ b/google/genai/_gaos/errors/responsevalidationerror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional from dataclasses import dataclass diff --git a/google/genai/_gaos/files.py b/google/genai/_gaos/files.py index 6155254a35..693ea60aa4 100644 --- a/google/genai/_gaos/files.py +++ b/google/genai/_gaos/files.py @@ -23,7 +23,10 @@ from .types import OptionalNullable, UNSET, environments from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/google_genai.py b/google/genai/_gaos/google_genai.py index 95c9fdf7c8..fc2c221219 100644 --- a/google/genai/_gaos/google_genai.py +++ b/google/genai/_gaos/google_genai.py @@ -31,7 +31,10 @@ import mimetypes import os -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from ._hooks.google_genai_auth import ( GOOGLE_GENAI_API_REVISION as _GOOGLE_GENAI_API_REVISION, diff --git a/google/genai/_gaos/httpclient.py b/google/genai/_gaos/httpclient.py index 0e1a8dcd74..c3ed9fe352 100644 --- a/google/genai/_gaos/httpclient.py +++ b/google/genai/_gaos/httpclient.py @@ -20,7 +20,10 @@ # pyright: reportReturnType = false import asyncio from typing_extensions import Protocol, runtime_checkable -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Optional, Union diff --git a/google/genai/_gaos/interactions.py b/google/genai/_gaos/interactions.py index d8951ad442..1aa4df8a5e 100644 --- a/google/genai/_gaos/interactions.py +++ b/google/genai/_gaos/interactions.py @@ -24,7 +24,10 @@ from .utils import get_security_from_env, response_helpers from .utils.eventstreaming import AsyncStream, Stream from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Dict, List, Literal, Mapping, Optional, Union, cast, overload diff --git a/google/genai/_gaos/lib/compat_errors.py b/google/genai/_gaos/lib/compat_errors.py index 7bd524927e..358521972b 100644 --- a/google/genai/_gaos/lib/compat_errors.py +++ b/google/genai/_gaos/lib/compat_errors.py @@ -34,7 +34,10 @@ except ImportError: httpx2 = None -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx _HTTPX_TIMEOUT_ERRORS = ( (httpx.TimeoutException,) diff --git a/google/genai/_gaos/sdk.py b/google/genai/_gaos/sdk.py index 2f493dcf71..61920a08c0 100644 --- a/google/genai/_gaos/sdk.py +++ b/google/genai/_gaos/sdk.py @@ -31,7 +31,10 @@ from .types import OptionalNullable, UNSET from .utils.logger import Logger, get_default_logger from .utils.retries import RetryConfig -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx import importlib import importlib.util import sys diff --git a/google/genai/_gaos/triggers.py b/google/genai/_gaos/triggers.py index df24c69207..3840d6f618 100644 --- a/google/genai/_gaos/triggers.py +++ b/google/genai/_gaos/triggers.py @@ -27,7 +27,10 @@ from .types.triggers import triggerupdate as triggers_triggerupdate from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/utils/eventstreaming.py b/google/genai/_gaos/utils/eventstreaming.py index 423689b29a..6fc316c114 100644 --- a/google/genai/_gaos/utils/eventstreaming.py +++ b/google/genai/_gaos/utils/eventstreaming.py @@ -31,7 +31,10 @@ AsyncGenerator, Tuple, ) -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx T = TypeVar("T") diff --git a/google/genai/_gaos/utils/headers.py b/google/genai/_gaos/utils/headers.py index b95606938a..f4b326aa14 100644 --- a/google/genai/_gaos/utils/headers.py +++ b/google/genai/_gaos/utils/headers.py @@ -23,7 +23,10 @@ List, Optional, ) -from httpx import Headers +try: + from httpx import Headers +except ImportError: + from httpx2 import Headers from pydantic import BaseModel from pydantic.fields import FieldInfo diff --git a/google/genai/_gaos/utils/logger.py b/google/genai/_gaos/utils/logger.py index 1085e0b046..65f61771a0 100644 --- a/google/genai/_gaos/utils/logger.py +++ b/google/genai/_gaos/utils/logger.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx import logging import os from typing import Any, Protocol diff --git a/google/genai/_gaos/utils/response_helpers.py b/google/genai/_gaos/utils/response_helpers.py index bbb844d39a..c3a2f6c654 100644 --- a/google/genai/_gaos/utils/response_helpers.py +++ b/google/genai/_gaos/utils/response_helpers.py @@ -46,7 +46,10 @@ overload, ) -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from pydantic import BaseModel from typing_extensions import TypeAliasType diff --git a/google/genai/_gaos/utils/retries.py b/google/genai/_gaos/utils/retries.py index 1dc0099962..a2bb0879d7 100644 --- a/google/genai/_gaos/utils/retries.py +++ b/google/genai/_gaos/utils/retries.py @@ -24,7 +24,10 @@ from email.utils import parsedate_to_datetime from typing import List, Optional -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx class BackoffStrategy: diff --git a/google/genai/_gaos/utils/serializers.py b/google/genai/_gaos/utils/serializers.py index 99747b4f68..dde9c1a0f1 100644 --- a/google/genai/_gaos/utils/serializers.py +++ b/google/genai/_gaos/utils/serializers.py @@ -25,7 +25,10 @@ import typing_extensions from typing_extensions import get_origin -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from pydantic import ConfigDict, create_model from pydantic_core import from_json diff --git a/google/genai/_gaos/utils/unmarshal_json_response.py b/google/genai/_gaos/utils/unmarshal_json_response.py index 4ba1a154be..744af872cf 100644 --- a/google/genai/_gaos/utils/unmarshal_json_response.py +++ b/google/genai/_gaos/utils/unmarshal_json_response.py @@ -19,7 +19,10 @@ from typing import Any, Optional, Type, TypeVar, overload -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from .serializers import unmarshal_json, construct_unvalidated from .. import errors diff --git a/google/genai/_gaos/utils/values.py b/google/genai/_gaos/utils/values.py index cc953332d6..63fe86570e 100644 --- a/google/genai/_gaos/utils/values.py +++ b/google/genai/_gaos/utils/values.py @@ -24,7 +24,10 @@ import os from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast -from httpx import Response +try: + from httpx import Response +except ImportError: + from httpx2 import Response from pydantic import BaseModel from pydantic.fields import FieldInfo diff --git a/google/genai/_gaos/voices.py b/google/genai/_gaos/voices.py index 454f466f56..3ed2411f90 100644 --- a/google/genai/_gaos/voices.py +++ b/google/genai/_gaos/voices.py @@ -24,7 +24,10 @@ from .types.voices import voice_input as voices_voice_input from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/webhooks.py b/google/genai/_gaos/webhooks.py index 145a392590..5017450e57 100644 --- a/google/genai/_gaos/webhooks.py +++ b/google/genai/_gaos/webhooks.py @@ -29,7 +29,10 @@ ) from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/tests/client/test_async_stream.py b/google/genai/tests/client/test_async_stream.py index 71b626b9f7..43b57eb033 100644 --- a/google/genai/tests/client/test_async_stream.py +++ b/google/genai/tests/client/test_async_stream.py @@ -32,13 +32,16 @@ AIOHTTP_NOT_INSTALLED = True aiohttp = mock.MagicMock() -import httpx - from ... import _api_client as api_client from ... import Client from ... import errors from ... import types +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + EVENT_STREAM_DATA_WITH_ERROR = [ b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}', @@ -387,7 +390,7 @@ def mock_response(chunks): ) -@patch('httpx.Client.send') +@patch.object(httpx.Client, 'send') def test_error_event_in_streamed_responses_bad_json(mock_send_method): with_bad_json = [ b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}', @@ -412,7 +415,7 @@ def test_error_event_in_streamed_responses_bad_json(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch.object(httpx.Client, 'send') def test_error_event_in_streamed_responses(mock_send_method): mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR) @@ -432,7 +435,7 @@ def test_error_event_in_streamed_responses(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch.object(httpx.Client, 'send') def test_error_event_in_generate_content_stream(mock_send_method): mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR) @@ -464,8 +467,12 @@ def test_error_event_in_generate_content_stream(mock_send_method): async def _async_httpx_response(_): + async def _aiter(): + for chunk in EVENT_STREAM_DATA_WITH_ERROR: + yield chunk + mock_stream = MagicMock(spec=httpx.AsyncByteStream) - mock_stream.__aiter__.return_value = EVENT_STREAM_DATA_WITH_ERROR + mock_stream.__aiter__.side_effect = _aiter mock_stream.aclose = AsyncMock() return httpx.Response( status_code=200, @@ -473,7 +480,7 @@ async def _async_httpx_response(_): ) -@patch('httpx.AsyncBaseTransport') +@patch.object(httpx, 'AsyncBaseTransport') @pytest.mark.asyncio async def test_error_event_in_streamed_responses_async(mock_transport): client = api_client.BaseApiClient( @@ -502,7 +509,7 @@ async def test_error_event_in_streamed_responses_async(mock_transport): await anext(resp) -@patch('httpx.AsyncBaseTransport') +@patch.object(httpx, 'AsyncBaseTransport') @pytest.mark.asyncio async def test_error_event_in_generate_content_stream_async(mock_transport): client = Client( diff --git a/google/genai/tests/client/test_client_initialization.py b/google/genai/tests/client/test_client_initialization.py index 5df241a7a1..d36706b272 100644 --- a/google/genai/tests/client/test_client_initialization.py +++ b/google/genai/tests/client/test_client_initialization.py @@ -28,7 +28,6 @@ import certifi import google.auth from google.auth import credentials -import httpx import pytest import requests @@ -38,6 +37,15 @@ from ... import Client from ... import types +if api_client._get_http_client_backend() == "httpx2": + import httpx2 as httpx # pylint: disable=g-import-not-at-top + _SyncHttpxClient = api_client.SyncHttpx2Client + _AsyncHttpxClient = api_client.AsyncHttpx2Client +else: + import httpx # pylint: disable=g-import-not-at-top + _SyncHttpxClient = api_client.SyncHttpxClient + _AsyncHttpxClient = api_client.AsyncHttpxClient + try: import aiohttp @@ -1550,7 +1558,7 @@ def refresh_side_effect(request): text='{"candidates": [{"content": {"parts": [{"text": "response"}]}}]}', ) mock_request = mock.Mock(return_value=mock_httpx_response) - monkeypatch.setattr(api_client.SyncHttpxClient, "send", mock_request) + monkeypatch.setattr(_SyncHttpxClient, "send", mock_request) # Reset credentials to test initialization to ensure the sync lock is tested. client._api_client._credentials = None @@ -2161,7 +2169,7 @@ def test_sync_httpx_uses_mtls_endpoint_with_client_cert(mock_client_cert): assert not client._api_client._use_google_auth_sync() mock_send = mock.Mock(return_value=httpx.Response(200, text="{}")) - with mock.patch.object(api_client.SyncHttpxClient, "send", mock_send): + with mock.patch.object(_SyncHttpxClient, "send", mock_send): client._api_client.request("post", "models/gemini:generateContent", {}) assert mock_send.call_args[0][0].url.host == ( @@ -2177,7 +2185,7 @@ async def test_async_httpx_uses_mtls_endpoint_with_client_cert( client = _mtls_test_client() mock_request = mock.AsyncMock(return_value=httpx.Response(200, text="{}")) - with mock.patch.object(api_client.AsyncHttpxClient, "request", mock_request): + with mock.patch.object(_AsyncHttpxClient, "request", mock_request): await client._api_client.async_request( "post", "models/gemini:generateContent", {} ) @@ -2195,7 +2203,7 @@ async def test_async_httpx_download_uses_mtls_endpoint_with_client_cert( client = _mtls_test_client() mock_request = mock.AsyncMock(return_value=httpx.Response(200, content=b"")) - with mock.patch.object(api_client.AsyncHttpxClient, "request", mock_request): + with mock.patch.object(_AsyncHttpxClient, "request", mock_request): await client._api_client.async_download_file("files/abc:download") assert mock_request.call_args.kwargs["url"].startswith( diff --git a/google/genai/tests/client/test_http_options.py b/google/genai/tests/client/test_http_options.py index c5c08de54b..2dec9790b3 100644 --- a/google/genai/tests/client/test_http_options.py +++ b/google/genai/tests/client/test_http_options.py @@ -18,12 +18,16 @@ from unittest import mock -import httpx import pytest from ... import _api_client from ... import types +if _api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + def test_patch_http_options_with_copies_all_fields(): patch_options = types.HttpOptions( diff --git a/google/genai/tests/client/test_httpx2_client.py b/google/genai/tests/client/test_httpx2_client.py index dbb1480022..83cf571736 100644 --- a/google/genai/tests/client/test_httpx2_client.py +++ b/google/genai/tests/client/test_httpx2_client.py @@ -34,8 +34,10 @@ try: import httpx2 + import truststore except ImportError: httpx2 = None + truststore = None # Mark all tests in this module to be skipped if httpx2 is not available pytestmark = pytest.mark.skipif( @@ -166,20 +168,44 @@ def test_httpx2_response_flows_through_sync_stream(): assert chunks == ['{"first": 1}', '{"second": 2}'] -def test_default_client_uses_httpx(monkeypatch): - monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) +def _client_classes(backend): target_api_client = getattr(api_client, 'public_api_client', api_client) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpxClient + if backend == 'httpx2': + return ( + httpx2.Client, + httpx2.AsyncClient, + target_api_client.SyncHttpx2Client, + target_api_client.AsyncHttpx2Client, + ) + return ( + httpx.Client, + httpx.AsyncClient, + target_api_client.SyncHttpxClient, + target_api_client.AsyncHttpxClient, ) - assert isinstance(client._api_client._async_httpx_client, httpx.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpxClient + + +def _set_default_backend(monkeypatch, backend): + target_api_client = getattr(api_client, 'public_api_client', api_client) + monkeypatch.setattr( + target_api_client, '_DEFAULT_HTTP_CLIENT_BACKEND', backend ) +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_default_client_uses_default_backend(monkeypatch, default_backend): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + _set_default_backend(monkeypatch, default_backend) + sync_cls, async_cls, sdk_sync_cls, sdk_async_cls = _client_classes( + default_backend + ) + client = Client(api_key='fake_api_key') + assert isinstance(client._api_client._httpx_client, sync_cls) + assert isinstance(client._api_client._httpx_client, sdk_sync_cls) + assert isinstance(client._api_client._async_httpx_client, async_cls) + assert isinstance(client._api_client._async_httpx_client, sdk_async_cls) + + def test_env_var_override_to_httpx(monkeypatch): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') target_api_client = getattr(api_client, 'public_api_client', api_client) @@ -194,6 +220,7 @@ def test_env_var_override_to_httpx(monkeypatch): ) + def test_env_var_override_to_httpx2(monkeypatch): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') target_api_client = getattr(api_client, 'public_api_client', api_client) @@ -208,19 +235,27 @@ def test_env_var_override_to_httpx2(monkeypatch): ) -def test_env_var_auto(monkeypatch): + + +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_env_var_auto(monkeypatch, default_backend): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'auto') - target_api_client = getattr(api_client, 'public_api_client', api_client) + _set_default_backend(monkeypatch, default_backend) + sync_cls, *_ = _client_classes(default_backend) client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) + assert isinstance(client._api_client._httpx_client, sync_cls) -def test_env_var_unrecognized_falls_back_to_default(monkeypatch, caplog): +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_env_var_unrecognized_falls_back_to_default( + monkeypatch, caplog, default_backend +): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'unknown_client') - target_api_client = getattr(api_client, 'public_api_client', api_client) + _set_default_backend(monkeypatch, default_backend) + sync_cls, *_ = _client_classes(default_backend) with caplog.at_level('WARNING'): client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) + assert isinstance(client._api_client._httpx_client, sync_cls) assert 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT' in caplog.text @@ -232,6 +267,7 @@ def test_env_var_httpx2_missing_raises(monkeypatch): Client(api_key='fake_api_key') + def test_env_var_httpx_missing_raises(monkeypatch): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') target_api_client = getattr(api_client, 'public_api_client', api_client) @@ -240,19 +276,36 @@ def test_env_var_httpx_missing_raises(monkeypatch): Client(api_key='fake_api_key') -def test_default_falls_back_to_httpx2_when_httpx_missing(monkeypatch): + + +@pytest.mark.parametrize( + 'default_backend,fallback_backend', + [('httpx', 'httpx2'), ('httpx2', 'httpx')], +) +def test_default_falls_back_when_default_backend_missing( + monkeypatch, default_backend, fallback_backend +): monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) target_api_client = getattr(api_client, 'public_api_client', api_client) - monkeypatch.setattr(target_api_client, 'httpx', None) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx2.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpx2Client - ) - assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpx2Client + _set_default_backend(monkeypatch, default_backend) + monkeypatch.setattr(target_api_client, default_backend, None) + sync_cls, async_cls, sdk_sync_cls, sdk_async_cls = _client_classes( + fallback_backend ) + client = Client(api_key='fake_api_key') + assert isinstance(client._api_client._httpx_client, sync_cls) + assert isinstance(client._api_client._httpx_client, sdk_sync_cls) + assert isinstance(client._api_client._async_httpx_client, async_cls) + assert isinstance(client._api_client._async_httpx_client, sdk_async_cls) + + +def test_default_raises_when_no_backend_installed(monkeypatch): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + target_api_client = getattr(api_client, 'public_api_client', api_client) + monkeypatch.setattr(target_api_client, 'httpx', None) + monkeypatch.setattr(target_api_client, 'httpx2', None) + with pytest.raises(ImportError, match='Neither httpx nor httpx2'): + target_api_client._get_http_client_backend() # High-level feature tests: end-to-end generate_content and streaming with httpx2 @@ -866,6 +919,21 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert attempts == 2 +def test_ensure_httpx2_ssl_ctx_default_uses_truststore(monkeypatch): + monkeypatch.delenv('SSL_CERT_FILE', raising=False) + monkeypatch.delenv('SSL_CERT_DIR', raising=False) + client_args, async_client_args = ( + api_client.BaseApiClient._ensure_httpx_ssl_ctx( + HttpOptions(), + vertexai=False, + client_cls=api_client.SyncHttpx2Client, + async_client_cls=api_client.AsyncHttpx2Client, + ) + ) + assert isinstance(client_args['verify'], truststore.SSLContext) + assert isinstance(async_client_args['verify'], truststore.SSLContext) + + def test_ensure_httpx2_ssl_ctx_custom_verify(): custom_ctx = ssl.create_default_context() client_args, async_client_args = ( diff --git a/google/genai/tests/client/test_retries.py b/google/genai/tests/client/test_retries.py index 6332f2091f..a70244da67 100644 --- a/google/genai/tests/client/test_retries.py +++ b/google/genai/tests/client/test_retries.py @@ -36,13 +36,17 @@ AsyncAuthorizedSession = mock.MagicMock() from google.oauth2 import credentials -import httpx import tenacity from ... import _api_client as api_client from ... import errors from ... import types +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + requires_aiohttp = pytest.mark.skipif( AIOHTTP_NOT_INSTALLED, reason="aiohttp is not installed, skipping test." diff --git a/google/genai/tests/imports/test_no_optional_imports.py b/google/genai/tests/imports/test_no_optional_imports.py index a65a3d4a90..2fe658d6c9 100644 --- a/google/genai/tests/imports/test_no_optional_imports.py +++ b/google/genai/tests/imports/test_no_optional_imports.py @@ -13,7 +13,9 @@ # limitations under the License. # +import asyncio import os +import sys import pytest IS_NOT_GITHUB_ACTIONS = os.getenv('GITHUB_ACTIONS') != 'true' @@ -22,7 +24,99 @@ @pytest.mark.skipif(IS_NOT_GITHUB_ACTIONS, reason='This test is only run on GitHub Actions.') def test_library_can_be_imported_without_optional_dependencies(): - """Tests that the library can be imported without optional dependencies. - """ + """Tests that the library works without optional dependencies (including httpx).""" + with pytest.raises(ImportError): + import httpx # pylint: disable=g-import-not-at-top,unused-import + assert 'httpx' not in sys.modules + from google import genai + from google.genai import errors + from google.genai import interactions from google.genai import types + import httpx2 + + assert interactions is not None + + def handler(request: httpx2.Request) -> httpx2.Response: + path = request.url.path + if path.endswith(':streamGenerateContent'): + return httpx2.Response( + status_code=200, + content=( + b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' + b'data: {"candidates": [{"content": {"parts": [{"text": "world"}], "role": "model"}}]}\n\n' + ), + headers={'content-type': 'text/event-stream'}, + ) + if path.endswith('/models/bad-model:generateContent'): + return httpx2.Response( + status_code=400, + json={'error': {'code': 400, 'message': 'Bad request', 'status': 'INVALID_ARGUMENT'}}, + ) + if '/interactions/' in path: + return httpx2.Response( + status_code=200, + headers={'content-type': 'application/json'}, + content=b'{"id": "test-id", "status": "completed"}', + ) + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': {'parts': [{'text': 'ok'}], 'role': 'model'}, + 'finishReason': 'STOP', + }] + }, + ) + + transport = httpx2.MockTransport(handler) + client = genai.Client( + api_key='test-api-key', + http_options=types.HttpOptions( + client_args={'transport': transport}, + async_client_args={'transport': transport}, + ), + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + + # Sync generate_content, streaming, error handling, and interactions. + resp = client.models.generate_content(model='gemini-2.5-flash', contents='hi') + assert resp.text == 'ok' + + chunks = list( + client.models.generate_content_stream( + model='gemini-2.5-flash', contents='hi' + ) + ) + assert ''.join(c.text for c in chunks) == 'Hello world' + + with pytest.raises(errors.ClientError): + client.models.generate_content(model='bad-model', contents='hi') + + interaction = client.interactions.get(id='test-id') + assert interaction.status == 'completed' + + # Async generate_content, streaming, error handling, and interactions. + async def _run_async() -> None: + async_resp = await client.aio.models.generate_content( + model='gemini-2.5-flash', contents='hi' + ) + assert async_resp.text == 'ok' + + async_chunks = [ + c + async for c in await client.aio.models.generate_content_stream( + model='gemini-2.5-flash', contents='hi' + ) + ] + assert ''.join(c.text for c in async_chunks) == 'Hello world' + + with pytest.raises(errors.ClientError): + await client.aio.models.generate_content(model='bad-model', contents='hi') + + async_interaction = await client.aio.interactions.get(id='test-id') + assert async_interaction.status == 'completed' + + asyncio.run(_run_async()) + assert 'httpx' not in sys.modules diff --git a/google/genai/tests/interactions/test_auth.py b/google/genai/tests/interactions/test_auth.py index 3873dbde5e..be37fe9267 100644 --- a/google/genai/tests/interactions/test_auth.py +++ b/google/genai/tests/interactions/test_auth.py @@ -17,14 +17,20 @@ """Tests for Interactions API.""" from ... import Client +from ... import _api_client from ... import _base_url from unittest import mock import pytest -from httpx import Request, Response -from ..._api_client import AsyncHttpxClient, BaseApiClient -from httpx import Client as HTTPClient +from ..._api_client import BaseApiClient import os +if _api_client._get_http_client_backend() == "httpx2": + from httpx2 import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpx2Client +else: + from httpx import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpxClient + ENV_VARS = [ "GOOGLE_CLOUD_PROJECT", "GEMINI_API_KEY", @@ -101,9 +107,6 @@ def test_interactions_gemini_extra_headers(monkeypatch): def test_interactions_vertex_auth_header(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -135,9 +138,6 @@ def test_interactions_vertex_auth_header(): for key, value in headers.items()) def test_interactions_vertex_key_no_auth_header(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() client = Client(vertexai=True, api_key='test-api-key') @@ -171,7 +171,7 @@ def test_interactions_vertex_url(): with ( mock.patch.object(BaseApiClient, "_access_token", return_value="fake-token") as mock_access_token, - mock.patch("httpx.Client.send") as mock_send, + mock.patch.object(HTTPClient, "send") as mock_send, ): mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') client.interactions.create( @@ -183,9 +183,6 @@ def test_interactions_vertex_url(): assert str(request.url) == 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/us-central1/interactions' def test_interactions_vertex_auth_refresh_on_retry(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -223,9 +220,6 @@ def my_send(req, *args, **kwargs): def test_interactions_vertex_extra_headers_override(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -326,9 +320,6 @@ async def test_async_interactions_gemini_extra_headers(monkeypatch): @pytest.mark.asyncio async def test_async_interactions_vertex_auth_header(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -361,7 +352,6 @@ async def test_async_interactions_vertex_auth_header(): @pytest.mark.asyncio async def test_async_interactions_vertex_key_no_auth_header(): - from ..._api_client import BaseApiClient client = Client(vertexai=True, api_key='test-api-key') with ( @@ -389,7 +379,6 @@ async def test_async_interactions_vertex_key_no_auth_header(): @pytest.mark.asyncio async def test_async_interactions_vertex_url(): - from ..._api_client import AsyncHttpxClient creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -409,9 +398,6 @@ async def test_async_interactions_vertex_url(): @pytest.mark.asyncio async def test_async_interactions_vertex_auth_refresh_on_retry(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -448,9 +434,6 @@ async def my_send(req, *args, **kwargs): @pytest.mark.asyncio async def test_async_interactions_vertex_extra_headers_override(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) diff --git a/google/genai/tests/interactions/test_integration.py b/google/genai/tests/interactions/test_integration.py index 22edf727c7..d142509dd9 100644 --- a/google/genai/tests/interactions/test_integration.py +++ b/google/genai/tests/interactions/test_integration.py @@ -14,14 +14,19 @@ from unittest import mock -import httpx import pytest +from ... import _api_client from ... import client as client_lib from ..._gaos import google_genai as gaos_google_genai from ..._gaos.types.interactions import interaction as gaos_interaction from ..._gaos.utils import serializers as gaos_serializers +if _api_client._get_http_client_backend() == "httpx2": + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + pytest_plugins = ("pytest_asyncio",) @@ -235,7 +240,7 @@ def test_interaction_lenient_parsing(): def test_interactions_default_timeout(): from ... import _interactions - assert _interactions.DEFAULT_TIMEOUT == httpx.Timeout( + assert _interactions.DEFAULT_TIMEOUT == _interactions.Timeout( timeout=None, connect=5.0 ) diff --git a/google/genai/tests/interactions/test_paths.py b/google/genai/tests/interactions/test_paths.py index 118a8e62d7..335a058ad0 100644 --- a/google/genai/tests/interactions/test_paths.py +++ b/google/genai/tests/interactions/test_paths.py @@ -19,12 +19,17 @@ from unittest import mock import pytest -from httpx import Request, Response -from ..._api_client import AsyncHttpxClient -from httpx import Client as HTTPClient +from ... import _api_client from .. import pytest_helper import google.auth +if _api_client._get_http_client_backend() == "httpx2": + from httpx2 import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpx2Client +else: + from httpx import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpxClient + @mock.patch.object(google.auth, "default", autospec=True) def test_interactions_paths(mock_auth_default, client): interaction_id = "test-interaction-id" @@ -50,6 +55,12 @@ def test_interactions_paths(mock_auth_default, client): mock_send.assert_called_once() request = mock_send.call_args[0][0] assert str(request.url) == f'{expected_base_url}/interactions/{interaction_id}?stream=false' + assert request.extensions.get("timeout") == { + "connect": None, + "read": None, + "write": None, + "pool": None, + } mock_send.reset_mock() mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') @@ -91,6 +102,12 @@ async def test_async_interactions_paths(mock_auth_default, client): mock_send.assert_called_once() request = mock_send.call_args[0][0] assert str(request.url) == f'{expected_base_url}/interactions/{interaction_id}?stream=false' + assert request.extensions.get("timeout") == { + "connect": None, + "read": None, + "write": None, + "pool": None, + } mock_send.reset_mock() mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') diff --git a/google/genai/tests/live/test_live.py b/google/genai/tests/live/test_live.py index dda2286cd6..1297a0a0c9 100644 --- a/google/genai/tests/live/test_live.py +++ b/google/genai/tests/live/test_live.py @@ -31,7 +31,6 @@ import certifi from google.oauth2.credentials import Credentials -import httpx import pytest from websockets import client @@ -42,6 +41,11 @@ from ... import client as gl_client from ... import live from ... import types + +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top try: import aiohttp AIOHTTP_NOT_INSTALLED = False diff --git a/google/genai/tests/local_tokenizer/test_local_tokenizer.py b/google/genai/tests/local_tokenizer/test_local_tokenizer.py index 1d9a12db9f..2bb09c1915 100644 --- a/google/genai/tests/local_tokenizer/test_local_tokenizer.py +++ b/google/genai/tests/local_tokenizer/test_local_tokenizer.py @@ -16,7 +16,12 @@ import unittest from unittest.mock import MagicMock, patch -from sentencepiece import sentencepiece_model_pb2 +import pytest + +try: + from sentencepiece import sentencepiece_model_pb2 +except ImportError: + pytest.skip('sentencepiece is not installed', allow_module_level=True) from ... import local_tokenizer from ... import types diff --git a/google/genai/tests/local_tokenizer/test_local_tokenizer_loader.py b/google/genai/tests/local_tokenizer/test_local_tokenizer_loader.py index 492963e6c7..e404d8d02b 100644 --- a/google/genai/tests/local_tokenizer/test_local_tokenizer_loader.py +++ b/google/genai/tests/local_tokenizer/test_local_tokenizer_loader.py @@ -16,8 +16,13 @@ import unittest from unittest.mock import MagicMock, mock_open, patch -import sentencepiece as spm -from sentencepiece import sentencepiece_model_pb2 +import pytest + +try: + import sentencepiece as spm + from sentencepiece import sentencepiece_model_pb2 +except ImportError: + pytest.skip('sentencepiece is not installed', allow_module_level=True) from ... import _local_tokenizer_loader as loader diff --git a/pyproject.toml b/pyproject.toml index d0396e651d..d7fdcb57ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ dependencies = [ "anyio>=4.8.0, <5.0.0", "google-auth[requests]>=2.56.0, <3.0.0", - "httpx>=0.28.1, <1.0.0", + "httpx2>=2.0.0, <3.0.0", "pydantic>=2.12.5, <3.0.0", "requests>=2.28.1, <3.0.0", "tenacity>=8.2.3, <9.2.0", @@ -40,7 +40,8 @@ dependencies = [ [project.optional-dependencies] aiohttp = ["aiohttp>=3.10.11, <4.0.0"] -httpx2 = ["httpx2>=2.0.0, <3.0.0"] +httpx = ["httpx>=0.28.1, <1.0.0"] +httpx2 = [] local-tokenizer = [ "sentencepiece>=0.2.0", "protobuf", diff --git a/requirements.txt b/requirements.txt index c8f6e0aa4d..2453ad31d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,5 +34,5 @@ typing_extensions>=4.14.1 urllib3==2.5.0 websockets==16.0 mcp>=1.14.0,<2.0.0; python_version > '3.9' -sentencepiece>=0.2.0 +sentencepiece>=0.2.0; python_version < '3.15' protobuf