Skip to content

Commit 10638eb

Browse files
feat(client): support accessing raw response objects (#154)
1 parent 9ffdf66 commit 10638eb

38 files changed

+1344
-169
lines changed

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,25 @@ if response.my_field is None:
296296
print('Got json like {"my_field": null}.')
297297
```
298298

299+
### Accessing raw response data (e.g. headers)
300+
301+
The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call.
302+
303+
```py
304+
from finch import Finch
305+
306+
client = Finch()
307+
page = client.hris.directory.with_raw_response.list()
308+
response = page.individuals[0]
309+
310+
print(response.headers.get('X-My-Header'))
311+
312+
directory = response.parse() # get the object that `hris.directory.list()` would have returned
313+
print(directory.first_name)
314+
```
315+
316+
These methods return an [`APIResponse`](https://github.com/Finch-API/finch-api-python/src/finch/_response.py) object.
317+
299318
### Configuring the HTTP client
300319

301320
You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including:

pyproject.toml

-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ format = { chain = [
5858

5959
typecheck = { chain = [
6060
"typecheck:pyright",
61-
"typecheck:verify-types",
6261
"typecheck:mypy"
6362
]}
6463
"typecheck:pyright" = "pyright"

src/finch/_base_client.py

+85-132
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
overload,
3030
)
3131
from functools import lru_cache
32-
from typing_extensions import Literal, get_args, override, get_origin
32+
from typing_extensions import Literal, override
3333

3434
import anyio
3535
import httpx
@@ -49,11 +49,11 @@
4949
ModelT,
5050
Headers,
5151
Timeout,
52-
NoneType,
5352
NotGiven,
5453
ResponseT,
5554
Transport,
5655
AnyMapping,
56+
PostParser,
5757
ProxiesTypes,
5858
RequestFiles,
5959
AsyncTransport,
@@ -63,20 +63,16 @@
6363
)
6464
from ._utils import is_dict, is_given, is_mapping
6565
from ._compat import model_copy, model_dump
66-
from ._models import (
67-
BaseModel,
68-
GenericModel,
69-
FinalRequestOptions,
70-
validate_type,
71-
construct_type,
66+
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
67+
from ._response import APIResponse
68+
from ._constants import (
69+
DEFAULT_LIMITS,
70+
DEFAULT_TIMEOUT,
71+
DEFAULT_MAX_RETRIES,
72+
RAW_RESPONSE_HEADER,
7273
)
7374
from ._streaming import Stream, AsyncStream
74-
from ._exceptions import (
75-
APIStatusError,
76-
APITimeoutError,
77-
APIConnectionError,
78-
APIResponseValidationError,
79-
)
75+
from ._exceptions import APIStatusError, APITimeoutError, APIConnectionError
8076

8177
log: logging.Logger = logging.getLogger(__name__)
8278

@@ -101,19 +97,6 @@
10197
HTTPX_DEFAULT_TIMEOUT = Timeout(5.0)
10298

10399

104-
# default timeout is 1 minute
105-
DEFAULT_TIMEOUT = Timeout(timeout=60.0, connect=5.0)
106-
DEFAULT_MAX_RETRIES = 2
107-
DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20)
108-
109-
110-
class MissingStreamClassError(TypeError):
111-
def __init__(self) -> None:
112-
super().__init__(
113-
"The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `finch._streaming` for reference",
114-
)
115-
116-
117100
class PageInfo:
118101
"""Stores the necesary information to build the request to retrieve the next page.
119102
@@ -182,6 +165,7 @@ def _params_from_url(self, url: URL) -> httpx.QueryParams:
182165

183166
def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
184167
options = model_copy(self._options)
168+
options._strip_raw_response_header()
185169

186170
if not isinstance(info.params, NotGiven):
187171
options.params = {**options.params, **info.params}
@@ -260,13 +244,17 @@ def __await__(self) -> Generator[Any, None, AsyncPageT]:
260244
return self._get_page().__await__()
261245

262246
async def _get_page(self) -> AsyncPageT:
263-
page = await self._client.request(self._page_cls, self._options)
264-
page._set_private_attributes( # pyright: ignore[reportPrivateUsage]
265-
model=self._model,
266-
options=self._options,
267-
client=self._client,
268-
)
269-
return page
247+
def _parser(resp: AsyncPageT) -> AsyncPageT:
248+
resp._set_private_attributes(
249+
model=self._model,
250+
options=self._options,
251+
client=self._client,
252+
)
253+
return resp
254+
255+
self._options.post_parser = _parser
256+
257+
return await self._client.request(self._page_cls, self._options)
270258

271259
async def __aiter__(self) -> AsyncIterator[ModelT]:
272260
# https://github.com/microsoft/pyright/issues/3464
@@ -317,9 +305,10 @@ async def get_next_page(self: AsyncPageT) -> AsyncPageT:
317305

318306

319307
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
308+
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
320309

321310

322-
class BaseClient(Generic[_HttpxClientT]):
311+
class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]):
323312
_client: _HttpxClientT
324313
_version: str
325314
_base_url: URL
@@ -330,6 +319,7 @@ class BaseClient(Generic[_HttpxClientT]):
330319
_transport: Transport | AsyncTransport | None
331320
_strict_response_validation: bool
332321
_idempotency_header: str | None
322+
_default_stream_cls: type[_DefaultStreamT] | None = None
333323

334324
def __init__(
335325
self,
@@ -504,80 +494,28 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o
504494
serialized[key] = value
505495
return serialized
506496

507-
def _extract_stream_chunk_type(self, stream_cls: type) -> type:
508-
args = get_args(stream_cls)
509-
if not args:
510-
raise TypeError(
511-
f"Expected stream_cls to have been given a generic type argument, e.g. Stream[Foo] but received {stream_cls}",
512-
)
513-
return cast(type, args[0])
514-
515497
def _process_response(
516498
self,
517499
*,
518500
cast_to: Type[ResponseT],
519-
options: FinalRequestOptions, # noqa: ARG002
501+
options: FinalRequestOptions,
520502
response: httpx.Response,
503+
stream: bool,
504+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
521505
) -> ResponseT:
522-
if cast_to is NoneType:
523-
return cast(ResponseT, None)
524-
525-
if cast_to == str:
526-
return cast(ResponseT, response.text)
527-
528-
origin = get_origin(cast_to) or cast_to
529-
530-
if inspect.isclass(origin) and issubclass(origin, httpx.Response):
531-
# Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response
532-
# and pass that class to our request functions. We cannot change the variance to be either
533-
# covariant or contravariant as that makes our usage of ResponseT illegal. We could construct
534-
# the response class ourselves but that is something that should be supported directly in httpx
535-
# as it would be easy to incorrectly construct the Response object due to the multitude of arguments.
536-
if cast_to != httpx.Response:
537-
raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`")
538-
return cast(ResponseT, response)
539-
540-
# The check here is necessary as we are subverting the the type system
541-
# with casts as the relationship between TypeVars and Types are very strict
542-
# which means we must return *exactly* what was input or transform it in a
543-
# way that retains the TypeVar state. As we cannot do that in this function
544-
# then we have to resort to using `cast`. At the time of writing, we know this
545-
# to be safe as we have handled all the types that could be bound to the
546-
# `ResponseT` TypeVar, however if that TypeVar is ever updated in the future, then
547-
# this function would become unsafe but a type checker would not report an error.
548-
if (
549-
cast_to is not UnknownResponse
550-
and not origin is list
551-
and not origin is dict
552-
and not origin is Union
553-
and not issubclass(origin, BaseModel)
554-
):
555-
raise RuntimeError(
556-
f"Invalid state, expected {cast_to} to be a subclass type of {BaseModel}, {dict}, {list} or {Union}."
557-
)
558-
559-
# split is required to handle cases where additional information is included
560-
# in the response, e.g. application/json; charset=utf-8
561-
content_type, *_ = response.headers.get("content-type").split(";")
562-
if content_type != "application/json":
563-
if self._strict_response_validation:
564-
raise APIResponseValidationError(
565-
response=response,
566-
message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.",
567-
body=response.text,
568-
)
569-
570-
# If the API responds with content that isn't JSON then we just return
571-
# the (decoded) text without performing any parsing so that you can still
572-
# handle the response however you need to.
573-
return response.text # type: ignore
506+
api_response = APIResponse(
507+
raw=response,
508+
client=self,
509+
cast_to=cast_to,
510+
stream=stream,
511+
stream_cls=stream_cls,
512+
options=options,
513+
)
574514

575-
data = response.json()
515+
if response.request.headers.get(RAW_RESPONSE_HEADER) == "true":
516+
return cast(ResponseT, api_response)
576517

577-
try:
578-
return self._process_response_data(data=data, cast_to=cast_to, response=response)
579-
except pydantic.ValidationError as err:
580-
raise APIResponseValidationError(response=response, body=data) from err
518+
return api_response.parse()
581519

582520
def _process_response_data(
583521
self,
@@ -734,7 +672,7 @@ def _idempotency_key(self) -> str:
734672
return f"stainless-python-retry-{uuid.uuid4()}"
735673

736674

737-
class SyncAPIClient(BaseClient[httpx.Client]):
675+
class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
738676
_client: httpx.Client
739677
_has_custom_http_client: bool
740678
_default_stream_cls: type[Stream[Any]] | None = None
@@ -930,23 +868,32 @@ def _request(
930868
raise self._make_status_error_from_response(err.response) from None
931869
except httpx.TimeoutException as err:
932870
if retries > 0:
933-
return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls)
871+
return self._retry_request(
872+
options,
873+
cast_to,
874+
retries,
875+
stream=stream,
876+
stream_cls=stream_cls,
877+
)
934878
raise APITimeoutError(request=request) from err
935879
except Exception as err:
936880
if retries > 0:
937-
return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls)
881+
return self._retry_request(
882+
options,
883+
cast_to,
884+
retries,
885+
stream=stream,
886+
stream_cls=stream_cls,
887+
)
938888
raise APIConnectionError(request=request) from err
939889

940-
if stream:
941-
if stream_cls:
942-
return stream_cls(cast_to=self._extract_stream_chunk_type(stream_cls), response=response, client=self)
943-
944-
stream_cls = cast("type[_StreamT] | None", self._default_stream_cls)
945-
if stream_cls is None:
946-
raise MissingStreamClassError()
947-
return stream_cls(cast_to=cast_to, response=response, client=self)
948-
949-
return self._process_response(cast_to=cast_to, options=options, response=response)
890+
return self._process_response(
891+
cast_to=cast_to,
892+
options=options,
893+
response=response,
894+
stream=stream,
895+
stream_cls=stream_cls,
896+
)
950897

951898
def _retry_request(
952899
self,
@@ -980,13 +927,17 @@ def _request_api_list(
980927
page: Type[SyncPageT],
981928
options: FinalRequestOptions,
982929
) -> SyncPageT:
983-
resp = self.request(page, options, stream=False)
984-
resp._set_private_attributes( # pyright: ignore[reportPrivateUsage]
985-
client=self,
986-
model=model,
987-
options=options,
988-
)
989-
return resp
930+
def _parser(resp: SyncPageT) -> SyncPageT:
931+
resp._set_private_attributes(
932+
client=self,
933+
model=model,
934+
options=options,
935+
)
936+
return resp
937+
938+
options.post_parser = _parser
939+
940+
return self.request(page, options, stream=False)
990941

991942
@overload
992943
def get(
@@ -1144,7 +1095,7 @@ def get_api_list(
11441095
return self._request_api_list(model, page, opts)
11451096

11461097

1147-
class AsyncAPIClient(BaseClient[httpx.AsyncClient]):
1098+
class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
11481099
_client: httpx.AsyncClient
11491100
_has_custom_http_client: bool
11501101
_default_stream_cls: type[AsyncStream[Any]] | None = None
@@ -1354,16 +1305,13 @@ async def _request(
13541305
return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls)
13551306
raise APIConnectionError(request=request) from err
13561307

1357-
if stream:
1358-
if stream_cls:
1359-
return stream_cls(cast_to=self._extract_stream_chunk_type(stream_cls), response=response, client=self)
1360-
1361-
stream_cls = cast("type[_AsyncStreamT] | None", self._default_stream_cls)
1362-
if stream_cls is None:
1363-
raise MissingStreamClassError()
1364-
return stream_cls(cast_to=cast_to, response=response, client=self)
1365-
1366-
return self._process_response(cast_to=cast_to, options=options, response=response)
1308+
return self._process_response(
1309+
cast_to=cast_to,
1310+
options=options,
1311+
response=response,
1312+
stream=stream,
1313+
stream_cls=stream_cls,
1314+
)
13671315

13681316
async def _retry_request(
13691317
self,
@@ -1560,6 +1508,7 @@ def make_request_options(
15601508
extra_body: Body | None = None,
15611509
idempotency_key: str | None = None,
15621510
timeout: float | None | NotGiven = NOT_GIVEN,
1511+
post_parser: PostParser | NotGiven = NOT_GIVEN,
15631512
) -> RequestOptions:
15641513
"""Create a dict of type RequestOptions without keys of NotGiven values."""
15651514
options: RequestOptions = {}
@@ -1581,6 +1530,10 @@ def make_request_options(
15811530
if idempotency_key is not None:
15821531
options["idempotency_key"] = idempotency_key
15831532

1533+
if is_given(post_parser):
1534+
# internal
1535+
options["post_parser"] = post_parser # type: ignore
1536+
15841537
return options
15851538

15861539

0 commit comments

Comments
 (0)