_legacy_response.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. from __future__ import annotations
  2. import os
  3. import inspect
  4. import logging
  5. import datetime
  6. import functools
  7. from typing import (
  8. TYPE_CHECKING,
  9. Any,
  10. Union,
  11. Generic,
  12. TypeVar,
  13. Callable,
  14. Iterator,
  15. AsyncIterator,
  16. cast,
  17. overload,
  18. )
  19. from typing_extensions import Awaitable, ParamSpec, override, deprecated, get_origin
  20. import anyio
  21. import httpx
  22. import pydantic
  23. from ._types import NoneType
  24. from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type
  25. from ._models import BaseModel, is_basemodel, add_request_id
  26. from ._constants import RAW_RESPONSE_HEADER
  27. from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type
  28. from ._exceptions import APIResponseValidationError
  29. if TYPE_CHECKING:
  30. from ._models import FinalRequestOptions
  31. from ._base_client import BaseClient
  32. P = ParamSpec("P")
  33. R = TypeVar("R")
  34. _T = TypeVar("_T")
  35. log: logging.Logger = logging.getLogger(__name__)
  36. class LegacyAPIResponse(Generic[R]):
  37. """This is a legacy class as it will be replaced by `APIResponse`
  38. and `AsyncAPIResponse` in the `_response.py` file in the next major
  39. release.
  40. For the sync client this will mostly be the same with the exception
  41. of `content` & `text` will be methods instead of properties. In the
  42. async client, all methods will be async.
  43. A migration script will be provided & the migration in general should
  44. be smooth.
  45. """
  46. _cast_to: type[R]
  47. _client: BaseClient[Any, Any]
  48. _parsed_by_type: dict[type[Any], Any]
  49. _stream: bool
  50. _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None
  51. _options: FinalRequestOptions
  52. http_response: httpx.Response
  53. retries_taken: int
  54. """The number of retries made. If no retries happened this will be `0`"""
  55. def __init__(
  56. self,
  57. *,
  58. raw: httpx.Response,
  59. cast_to: type[R],
  60. client: BaseClient[Any, Any],
  61. stream: bool,
  62. stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
  63. options: FinalRequestOptions,
  64. retries_taken: int = 0,
  65. ) -> None:
  66. self._cast_to = cast_to
  67. self._client = client
  68. self._parsed_by_type = {}
  69. self._stream = stream
  70. self._stream_cls = stream_cls
  71. self._options = options
  72. self.http_response = raw
  73. self.retries_taken = retries_taken
  74. @property
  75. def request_id(self) -> str | None:
  76. return self.http_response.headers.get("x-request-id") # type: ignore[no-any-return]
  77. @overload
  78. def parse(self, *, to: type[_T]) -> _T: ...
  79. @overload
  80. def parse(self) -> R: ...
  81. def parse(self, *, to: type[_T] | None = None) -> R | _T:
  82. """Returns the rich python representation of this response's data.
  83. NOTE: For the async client: this will become a coroutine in the next major version.
  84. For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`.
  85. You can customise the type that the response is parsed into through
  86. the `to` argument, e.g.
  87. ```py
  88. from openai import BaseModel
  89. class MyModel(BaseModel):
  90. foo: str
  91. obj = response.parse(to=MyModel)
  92. print(obj.foo)
  93. ```
  94. We support parsing:
  95. - `BaseModel`
  96. - `dict`
  97. - `list`
  98. - `Union`
  99. - `str`
  100. - `int`
  101. - `float`
  102. - `httpx.Response`
  103. """
  104. cache_key = to if to is not None else self._cast_to
  105. cached = self._parsed_by_type.get(cache_key)
  106. if cached is not None:
  107. return cached # type: ignore[no-any-return]
  108. parsed = self._parse(to=to)
  109. if is_given(self._options.post_parser):
  110. parsed = self._options.post_parser(parsed)
  111. if isinstance(parsed, BaseModel):
  112. add_request_id(parsed, self.request_id)
  113. self._parsed_by_type[cache_key] = parsed
  114. return cast(R, parsed)
  115. @property
  116. def headers(self) -> httpx.Headers:
  117. return self.http_response.headers
  118. @property
  119. def http_request(self) -> httpx.Request:
  120. return self.http_response.request
  121. @property
  122. def status_code(self) -> int:
  123. return self.http_response.status_code
  124. @property
  125. def url(self) -> httpx.URL:
  126. return self.http_response.url
  127. @property
  128. def method(self) -> str:
  129. return self.http_request.method
  130. @property
  131. def content(self) -> bytes:
  132. """Return the binary response content.
  133. NOTE: this will be removed in favour of `.read()` in the
  134. next major version.
  135. """
  136. return self.http_response.content
  137. @property
  138. def text(self) -> str:
  139. """Return the decoded response content.
  140. NOTE: this will be turned into a method in the next major version.
  141. """
  142. return self.http_response.text
  143. @property
  144. def http_version(self) -> str:
  145. return self.http_response.http_version
  146. @property
  147. def is_closed(self) -> bool:
  148. return self.http_response.is_closed
  149. @property
  150. def elapsed(self) -> datetime.timedelta:
  151. """The time taken for the complete request/response cycle to complete."""
  152. return self.http_response.elapsed
  153. def _parse(self, *, to: type[_T] | None = None) -> R | _T:
  154. cast_to = to if to is not None else self._cast_to
  155. # unwrap `TypeAlias('Name', T)` -> `T`
  156. if is_type_alias_type(cast_to):
  157. cast_to = cast_to.__value__ # type: ignore[unreachable]
  158. # unwrap `Annotated[T, ...]` -> `T`
  159. if cast_to and is_annotated_type(cast_to):
  160. cast_to = extract_type_arg(cast_to, 0)
  161. origin = get_origin(cast_to) or cast_to
  162. if self._stream:
  163. if to:
  164. if not is_stream_class_type(to):
  165. raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}")
  166. return cast(
  167. _T,
  168. to(
  169. cast_to=extract_stream_chunk_type(
  170. to,
  171. failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]",
  172. ),
  173. response=self.http_response,
  174. client=cast(Any, self._client),
  175. ),
  176. )
  177. if self._stream_cls:
  178. return cast(
  179. R,
  180. self._stream_cls(
  181. cast_to=extract_stream_chunk_type(self._stream_cls),
  182. response=self.http_response,
  183. client=cast(Any, self._client),
  184. ),
  185. )
  186. stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls)
  187. if stream_cls is None:
  188. raise MissingStreamClassError()
  189. return cast(
  190. R,
  191. stream_cls(
  192. cast_to=cast_to,
  193. response=self.http_response,
  194. client=cast(Any, self._client),
  195. ),
  196. )
  197. if cast_to is NoneType:
  198. return cast(R, None)
  199. response = self.http_response
  200. if cast_to == str:
  201. return cast(R, response.text)
  202. if cast_to == int:
  203. return cast(R, int(response.text))
  204. if cast_to == float:
  205. return cast(R, float(response.text))
  206. if cast_to == bool:
  207. return cast(R, response.text.lower() == "true")
  208. if inspect.isclass(origin) and issubclass(origin, HttpxBinaryResponseContent):
  209. return cast(R, cast_to(response)) # type: ignore
  210. if origin == LegacyAPIResponse:
  211. raise RuntimeError("Unexpected state - cast_to is `APIResponse`")
  212. if inspect.isclass(
  213. origin # pyright: ignore[reportUnknownArgumentType]
  214. ) and issubclass(origin, httpx.Response):
  215. # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response
  216. # and pass that class to our request functions. We cannot change the variance to be either
  217. # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct
  218. # the response class ourselves but that is something that should be supported directly in httpx
  219. # as it would be easy to incorrectly construct the Response object due to the multitude of arguments.
  220. if cast_to != httpx.Response:
  221. raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`")
  222. return cast(R, response)
  223. if (
  224. inspect.isclass(
  225. origin # pyright: ignore[reportUnknownArgumentType]
  226. )
  227. and not issubclass(origin, BaseModel)
  228. and issubclass(origin, pydantic.BaseModel)
  229. ):
  230. raise TypeError("Pydantic models must subclass our base model type, e.g. `from openai import BaseModel`")
  231. if (
  232. cast_to is not object
  233. and not origin is list
  234. and not origin is dict
  235. and not origin is Union
  236. and not issubclass(origin, BaseModel)
  237. ):
  238. raise RuntimeError(
  239. f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}."
  240. )
  241. # split is required to handle cases where additional information is included
  242. # in the response, e.g. application/json; charset=utf-8
  243. content_type, *_ = response.headers.get("content-type", "*").split(";")
  244. if not content_type.endswith("json"):
  245. if is_basemodel(cast_to):
  246. try:
  247. data = response.json()
  248. except Exception as exc:
  249. log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc)
  250. else:
  251. return self._client._process_response_data(
  252. data=data,
  253. cast_to=cast_to, # type: ignore
  254. response=response,
  255. )
  256. if self._client._strict_response_validation:
  257. raise APIResponseValidationError(
  258. response=response,
  259. message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.",
  260. body=response.text,
  261. )
  262. # If the API responds with content that isn't JSON then we just return
  263. # the (decoded) text without performing any parsing so that you can still
  264. # handle the response however you need to.
  265. return response.text # type: ignore
  266. data = response.json()
  267. return self._client._process_response_data(
  268. data=data,
  269. cast_to=cast_to, # type: ignore
  270. response=response,
  271. )
  272. @override
  273. def __repr__(self) -> str:
  274. return f"<APIResponse [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>"
  275. class MissingStreamClassError(TypeError):
  276. def __init__(self) -> None:
  277. super().__init__(
  278. "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `openai._streaming` for reference",
  279. )
  280. def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, LegacyAPIResponse[R]]:
  281. """Higher order function that takes one of our bound API methods and wraps it
  282. to support returning the raw `APIResponse` object directly.
  283. """
  284. @functools.wraps(func)
  285. def wrapped(*args: P.args, **kwargs: P.kwargs) -> LegacyAPIResponse[R]:
  286. extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
  287. extra_headers[RAW_RESPONSE_HEADER] = "true"
  288. kwargs["extra_headers"] = extra_headers
  289. return cast(LegacyAPIResponse[R], func(*args, **kwargs))
  290. return wrapped
  291. def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[LegacyAPIResponse[R]]]:
  292. """Higher order function that takes one of our bound API methods and wraps it
  293. to support returning the raw `APIResponse` object directly.
  294. """
  295. @functools.wraps(func)
  296. async def wrapped(*args: P.args, **kwargs: P.kwargs) -> LegacyAPIResponse[R]:
  297. extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
  298. extra_headers[RAW_RESPONSE_HEADER] = "true"
  299. kwargs["extra_headers"] = extra_headers
  300. return cast(LegacyAPIResponse[R], await func(*args, **kwargs))
  301. return wrapped
  302. class HttpxBinaryResponseContent:
  303. response: httpx.Response
  304. def __init__(self, response: httpx.Response) -> None:
  305. self.response = response
  306. @property
  307. def content(self) -> bytes:
  308. return self.response.content
  309. @property
  310. def text(self) -> str:
  311. return self.response.text
  312. @property
  313. def encoding(self) -> str | None:
  314. return self.response.encoding
  315. @property
  316. def charset_encoding(self) -> str | None:
  317. return self.response.charset_encoding
  318. def json(self, **kwargs: Any) -> Any:
  319. return self.response.json(**kwargs)
  320. def read(self) -> bytes:
  321. return self.response.read()
  322. def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]:
  323. return self.response.iter_bytes(chunk_size)
  324. def iter_text(self, chunk_size: int | None = None) -> Iterator[str]:
  325. return self.response.iter_text(chunk_size)
  326. def iter_lines(self) -> Iterator[str]:
  327. return self.response.iter_lines()
  328. def iter_raw(self, chunk_size: int | None = None) -> Iterator[bytes]:
  329. return self.response.iter_raw(chunk_size)
  330. def write_to_file(
  331. self,
  332. file: str | os.PathLike[str],
  333. ) -> None:
  334. """Write the output to the given file.
  335. Accepts a filename or any path-like object, e.g. pathlib.Path
  336. Note: if you want to stream the data to the file instead of writing
  337. all at once then you should use `.with_streaming_response` when making
  338. the API request, e.g. `client.with_streaming_response.foo().stream_to_file('my_filename.txt')`
  339. """
  340. with open(file, mode="wb") as f:
  341. for data in self.response.iter_bytes():
  342. f.write(data)
  343. @deprecated(
  344. "Due to a bug, this method doesn't actually stream the response content, `.with_streaming_response.method()` should be used instead"
  345. )
  346. def stream_to_file(
  347. self,
  348. file: str | os.PathLike[str],
  349. *,
  350. chunk_size: int | None = None,
  351. ) -> None:
  352. with open(file, mode="wb") as f:
  353. for data in self.response.iter_bytes(chunk_size):
  354. f.write(data)
  355. def close(self) -> None:
  356. return self.response.close()
  357. async def aread(self) -> bytes:
  358. return await self.response.aread()
  359. async def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
  360. return self.response.aiter_bytes(chunk_size)
  361. async def aiter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]:
  362. return self.response.aiter_text(chunk_size)
  363. async def aiter_lines(self) -> AsyncIterator[str]:
  364. return self.response.aiter_lines()
  365. async def aiter_raw(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
  366. return self.response.aiter_raw(chunk_size)
  367. @deprecated(
  368. "Due to a bug, this method doesn't actually stream the response content, `.with_streaming_response.method()` should be used instead"
  369. )
  370. async def astream_to_file(
  371. self,
  372. file: str | os.PathLike[str],
  373. *,
  374. chunk_size: int | None = None,
  375. ) -> None:
  376. path = anyio.Path(file)
  377. async with await path.open(mode="wb") as f:
  378. async for data in self.response.aiter_bytes(chunk_size):
  379. await f.write(data)
  380. async def aclose(self) -> None:
  381. return await self.response.aclose()