From db029a596c90b1af4ef0bfb1cdf31f54b2f5755d Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 06:38:46 -0500 Subject: [PATCH 01/10] fix(client): don't cause crashes when inspecting the module (#897) --- src/openai/_utils/_proxy.py | 26 ++++++++++++++++++++++---- src/openai/lib/_old_api.py | 12 +++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/openai/_utils/_proxy.py b/src/openai/_utils/_proxy.py index aa934a3fbc..3c9e790a25 100644 --- a/src/openai/_utils/_proxy.py +++ b/src/openai/_utils/_proxy.py @@ -18,25 +18,43 @@ class LazyProxy(Generic[T], ABC): def __init__(self) -> None: self.__proxied: T | None = None + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + def __getattr__(self, attr: str) -> object: - return getattr(self.__get_proxied__(), attr) + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) @override def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ return repr(self.__get_proxied__()) @override def __str__(self) -> str: - return str(self.__get_proxied__()) + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) @override def __dir__(self) -> Iterable[str]: - return self.__get_proxied__().__dir__() + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() @property # type: ignore @override def __class__(self) -> type: - return self.__get_proxied__().__class__ + proxied = self.__get_proxied__() + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ def __get_proxied__(self) -> T: if not self.should_cache: diff --git a/src/openai/lib/_old_api.py b/src/openai/lib/_old_api.py index c4038fcfaf..929c87e80b 100644 --- a/src/openai/lib/_old_api.py +++ b/src/openai/lib/_old_api.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import override from .._utils import LazyProxy @@ -23,13 +23,19 @@ def __init__(self, *, symbol: str) -> None: super().__init__(INSTRUCTIONS.format(symbol=symbol)) -class APIRemovedInV1Proxy(LazyProxy[None]): +class APIRemovedInV1Proxy(LazyProxy[Any]): def __init__(self, *, symbol: str) -> None: super().__init__() self._symbol = symbol @override - def __load__(self) -> None: + def __load__(self) -> Any: + # return the proxy until it is eventually called so that + # we don't break people that are just checking the attributes + # of a module + return self + + def __call__(self, *_args: Any, **_kwargs: Any) -> Any: raise APIRemovedInV1(symbol=self._symbol) From 71a13d0c70d105b2b97720c72a1003b942cda2ae Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 07:25:50 -0500 Subject: [PATCH 02/10] chore(internal): add tests for proxy change (#899) --- tests/lib/test_old_api.py | 17 +++++++++++++++++ tests/test_utils/test_proxy.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/lib/test_old_api.py create mode 100644 tests/test_utils/test_proxy.py diff --git a/tests/lib/test_old_api.py b/tests/lib/test_old_api.py new file mode 100644 index 0000000000..261b8acb94 --- /dev/null +++ b/tests/lib/test_old_api.py @@ -0,0 +1,17 @@ +import pytest + +import openai +from openai.lib._old_api import APIRemovedInV1 + + +def test_basic_attribute_access_works() -> None: + for attr in dir(openai): + dir(getattr(openai, attr)) + + +def test_helpful_error_is_raised() -> None: + with pytest.raises(APIRemovedInV1): + openai.Completion.create() # type: ignore + + with pytest.raises(APIRemovedInV1): + openai.ChatCompletion.create() # type: ignore diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 0000000000..57c059150d --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,23 @@ +import operator +from typing import Any +from typing_extensions import override + +from openai._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert getattr(type(proxy), "__name__") == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" From e025e6bee44ea145d948869ef0c79bac0c376b9f Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 19:02:45 -0500 Subject: [PATCH 03/10] fix(client): ensure retried requests are closed (#902) --- src/openai/_base_client.py | 100 +++++++++++++---- src/openai/_constants.py | 1 + tests/test_client.py | 222 ++++++++++++++++++++++++++++++++++++- 3 files changed, 302 insertions(+), 21 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index a168301f75..89d9ce4815 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -72,6 +72,7 @@ DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, RAW_RESPONSE_HEADER, + STREAMED_RAW_RESPONSE_HEADER, ) from ._streaming import Stream, AsyncStream from ._exceptions import ( @@ -363,14 +364,21 @@ def _make_status_error_from_response( self, response: httpx.Response, ) -> APIStatusError: - err_text = response.text.strip() - body = err_text + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text - try: - body = json.loads(err_text) - err_msg = f"Error code: {response.status_code} - {body}" - except Exception: - err_msg = err_text or f"Error code: {response.status_code}" + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" return self._make_status_error(err_msg, body=body, response=response) @@ -534,6 +542,12 @@ def _process_response_data( except pydantic.ValidationError as err: raise APIResponseValidationError(response=response, body=data) from err + def _should_stream_response_body(self, *, request: httpx.Request) -> bool: + if request.headers.get(STREAMED_RAW_RESPONSE_HEADER) == "true": + return True + + return False + @property def qs(self) -> Querystring: return Querystring() @@ -606,7 +620,7 @@ def _calculate_retry_timeout( if response_headers is not None: retry_header = response_headers.get("retry-after") try: - retry_after = int(retry_header) + retry_after = float(retry_header) except Exception: retry_date_tuple = email.utils.parsedate_tz(retry_header) if retry_date_tuple is None: @@ -862,14 +876,21 @@ def _request( request = self._build_request(options) self._prepare_request(request) + response = None + try: - response = self._client.send(request, auth=self.custom_auth, stream=stream) + response = self._client.send( + request, + auth=self.custom_auth, + stream=stream or self._should_stream_response_body(request=request), + ) log.debug( 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase ) response.raise_for_status() except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code if retries > 0 and self._should_retry(err.response): + err.response.close() return self._retry_request( options, cast_to, @@ -881,9 +902,14 @@ def _request( # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. - err.response.read() + if not err.response.is_closed: + err.response.read() + raise self._make_status_error_from_response(err.response) from None except httpx.TimeoutException as err: + if response is not None: + response.close() + if retries > 0: return self._retry_request( options, @@ -891,9 +917,14 @@ def _request( retries, stream=stream, stream_cls=stream_cls, + response_headers=response.headers if response is not None else None, ) + raise APITimeoutError(request=request) from err except Exception as err: + if response is not None: + response.close() + if retries > 0: return self._retry_request( options, @@ -901,7 +932,9 @@ def _request( retries, stream=stream, stream_cls=stream_cls, + response_headers=response.headers if response is not None else None, ) + raise APIConnectionError(request=request) from err return self._process_response( @@ -917,7 +950,7 @@ def _retry_request( options: FinalRequestOptions, cast_to: Type[ResponseT], remaining_retries: int, - response_headers: Optional[httpx.Headers] = None, + response_headers: httpx.Headers | None, *, stream: bool, stream_cls: type[_StreamT] | None, @@ -1303,14 +1336,21 @@ async def _request( request = self._build_request(options) await self._prepare_request(request) + response = None + try: - response = await self._client.send(request, auth=self.custom_auth, stream=stream) + response = await self._client.send( + request, + auth=self.custom_auth, + stream=stream or self._should_stream_response_body(request=request), + ) log.debug( 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase ) response.raise_for_status() except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code if retries > 0 and self._should_retry(err.response): + await err.response.aclose() return await self._retry_request( options, cast_to, @@ -1322,19 +1362,39 @@ async def _request( # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. - await err.response.aread() + if not err.response.is_closed: + await err.response.aread() + raise self._make_status_error_from_response(err.response) from None - except httpx.ConnectTimeout as err: - if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) - raise APITimeoutError(request=request) from err except httpx.TimeoutException as err: + if response is not None: + await response.aclose() + if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) + return await self._retry_request( + options, + cast_to, + retries, + stream=stream, + stream_cls=stream_cls, + response_headers=response.headers if response is not None else None, + ) + raise APITimeoutError(request=request) from err except Exception as err: + if response is not None: + await response.aclose() + if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) + return await self._retry_request( + options, + cast_to, + retries, + stream=stream, + stream_cls=stream_cls, + response_headers=response.headers if response is not None else None, + ) + raise APIConnectionError(request=request) from err return self._process_response( @@ -1350,7 +1410,7 @@ async def _retry_request( options: FinalRequestOptions, cast_to: Type[ResponseT], remaining_retries: int, - response_headers: Optional[httpx.Headers] = None, + response_headers: httpx.Headers | None, *, stream: bool, stream_cls: type[_AsyncStreamT] | None, diff --git a/src/openai/_constants.py b/src/openai/_constants.py index 2e402300d3..7c13feaa25 100644 --- a/src/openai/_constants.py +++ b/src/openai/_constants.py @@ -3,6 +3,7 @@ import httpx RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +STREAMED_RAW_RESPONSE_HEADER = "X-Stainless-Streamed-Raw-Response" # default timeout is 10 minutes DEFAULT_TIMEOUT = httpx.Timeout(timeout=600.0, connect=5.0) diff --git a/tests/test_client.py b/tests/test_client.py index c5dbfe4bfe..51aa90a480 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,7 +18,12 @@ from openai._client import OpenAI, AsyncOpenAI from openai._models import BaseModel, FinalRequestOptions from openai._streaming import Stream, AsyncStream -from openai._exceptions import APIResponseValidationError +from openai._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) from openai._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -38,6 +43,24 @@ def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: return dict(url.params) +_original_response_init = cast(Any, httpx.Response.__init__) # type: ignore + + +def _low_retry_response_init(*args: Any, **kwargs: Any) -> Any: + headers = cast("list[tuple[bytes, bytes]]", kwargs["headers"]) + headers.append((b"retry-after", b"0.1")) + + return _original_response_init(*args, **kwargs) + + +def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + class TestOpenAI: client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -592,6 +615,104 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str calculated = client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + def test_retrying_timeout_errors_doesnt_leak(self) -> None: + def raise_for_status(response: httpx.Response) -> None: + raise httpx.TimeoutException("Test timeout error", request=response.request) + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APITimeoutError): + self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + def test_retrying_runtime_errors_doesnt_leak(self) -> None: + def raise_for_status(_response: httpx.Response) -> None: + raise RuntimeError("Test error") + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APIConnectionError): + self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + def test_retrying_status_errors_doesnt_leak(self) -> None: + def raise_for_status(response: httpx.Response) -> None: + response.status_code = 500 + raise httpx.HTTPStatusError("Test 500 error", response=response, request=response.request) + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APIStatusError): + self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @pytest.mark.respx(base_url=base_url) + def test_status_error_within_httpx(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + def on_response(response: httpx.Response) -> None: + raise httpx.HTTPStatusError( + "Simulating an error inside httpx", + response=response, + request=response.request, + ) + + client = OpenAI( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client( + event_hooks={ + "response": [on_response], + } + ), + max_retries=0, + ) + with pytest.raises(APIStatusError): + client.post("/foo", cast_to=httpx.Response) + class TestAsyncOpenAI: client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1162,3 +1283,102 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + async def test_retrying_timeout_errors_doesnt_leak(self) -> None: + def raise_for_status(response: httpx.Response) -> None: + raise httpx.TimeoutException("Test timeout error", request=response.request) + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APITimeoutError): + await self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + async def test_retrying_runtime_errors_doesnt_leak(self) -> None: + def raise_for_status(_response: httpx.Response) -> None: + raise RuntimeError("Test error") + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APIConnectionError): + await self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("httpx.Response.__init__", _low_retry_response_init) + async def test_retrying_status_errors_doesnt_leak(self) -> None: + def raise_for_status(response: httpx.Response) -> None: + response.status_code = 500 + raise httpx.HTTPStatusError("Test 500 error", response=response, request=response.request) + + with mock.patch("httpx.Response.raise_for_status", raise_for_status): + with pytest.raises(APIStatusError): + await self.client.post( + "/chat/completions", + body=dict( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-3.5-turbo", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) + + assert _get_open_connections(self.client) == 0 + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_status_error_within_httpx(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + def on_response(response: httpx.Response) -> None: + raise httpx.HTTPStatusError( + "Simulating an error inside httpx", + response=response, + request=response.request, + ) + + client = AsyncOpenAI( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient( + event_hooks={ + "response": [on_response], + } + ), + max_retries=0, + ) + with pytest.raises(APIStatusError): + await client.post("/foo", cast_to=httpx.Response) From 472cd44e45a45b0b4f12583a5402e8aeb121d7a2 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:29:58 -0500 Subject: [PATCH 04/10] docs: fix typo in readme (#904) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83392e9585..380ccc58d9 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ stream = client.chat.completions.create( ) for chunk in stream: if chunk.choices[0].delta.content is not None: - print(part.choices[0].delta.content) + print(chunk.choices[0].delta.content) ``` The async client uses the exact same interface. @@ -113,7 +113,7 @@ stream = await client.chat.completions.create( ) async for chunk in stream: if chunk.choices[0].delta.content is not None: - print(part.choices[0].delta.content) + print(chunk.choices[0].delta.content) ``` ## Module-level client From bbb648ef81eb11f81b457e2cbf33a832f4d29a76 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:20:46 -0500 Subject: [PATCH 05/10] docs(readme): update example snippets (#907) --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 380ccc58d9..4cabdb897d 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ pip install openai The full API of this library can be found in [api.md](https://www.github.com/openai/openai-python/blob/main/api.md). ```python +import os from openai import OpenAI client = OpenAI( - # defaults to os.environ.get("OPENAI_API_KEY") - api_key="My API Key", + # This is the default and can be omitted + api_key=os.environ.get("OPENAI_API_KEY"), ) chat_completion = client.chat.completions.create( @@ -54,12 +55,13 @@ so that your API Key is not stored in source control. Simply import `AsyncOpenAI` instead of `OpenAI` and use `await` with each API call: ```python +import os import asyncio from openai import AsyncOpenAI client = AsyncOpenAI( - # defaults to os.environ.get("OPENAI_API_KEY") - api_key="My API Key", + # This is the default and can be omitted + api_key=os.environ.get("OPENAI_API_KEY"), ) From 663a8f6dead5aa523d1e8779e75af1dabb1690c4 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Thu, 30 Nov 2023 20:46:48 +0300 Subject: [PATCH 06/10] chore(internal): replace string concatenation with f-strings (#908) --- src/openai/_utils/_utils.py | 2 +- src/openai/lib/_validators.py | 6 +++--- tests/test_required_args.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/openai/_utils/_utils.py b/src/openai/_utils/_utils.py index d2bfc91a70..83f88cc3e7 100644 --- a/src/openai/_utils/_utils.py +++ b/src/openai/_utils/_utils.py @@ -230,7 +230,7 @@ def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> s def quote(string: str) -> str: """Add single quotation marks around the given string. Does *not* do any escaping.""" - return "'" + string + "'" + return f"'{string}'" def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: diff --git a/src/openai/lib/_validators.py b/src/openai/lib/_validators.py index c8608c0cef..ae48aafa88 100644 --- a/src/openai/lib/_validators.py +++ b/src/openai/lib/_validators.py @@ -309,10 +309,10 @@ def common_completion_prefix_validator(df: pd.DataFrame) -> Remediation: return Remediation(name="common_prefix") def remove_common_prefix(x: Any, prefix: Any, ws_prefix: Any) -> Any: - x["completion"] = x["completion"].str[len(prefix) :] + x["completion"] = x["completion"].str[len(prefix):] if ws_prefix: # keep the single whitespace as prefix - x["completion"] = " " + x["completion"] + x["completion"] = f" {x['completion']}" return x if (df.completion == common_prefix).all(): @@ -624,7 +624,7 @@ def get_outfnames(fname: str, split: bool) -> list[str]: while True: index_suffix = f" ({i})" if i > 0 else "" candidate_fnames = [ - os.path.splitext(fname)[0] + "_prepared" + suffix + index_suffix + ".jsonl" for suffix in suffixes + f"{os.path.splitext(fname)[0]}_prepared{suffix}{index_suffix}.jsonl" for suffix in suffixes ] if not any(os.path.isfile(f) for f in candidate_fnames): return candidate_fnames diff --git a/tests/test_required_args.py b/tests/test_required_args.py index 1de017db24..5d1a5224ff 100644 --- a/tests/test_required_args.py +++ b/tests/test_required_args.py @@ -43,7 +43,7 @@ def foo(*, a: str | None = None) -> str | None: def test_multiple_params() -> None: @required_args(["a", "b", "c"]) def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: - return a + " " + b + " " + c + return f"{a} {b} {c}" assert foo(a="a", b="b", c="c") == "a b c" From caab767156375114078cf8d85031863361326b5f Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:52:50 -0500 Subject: [PATCH 07/10] chore(internal): replace string concatenation with f-strings (#909) --- src/openai/lib/_validators.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/openai/lib/_validators.py b/src/openai/lib/_validators.py index ae48aafa88..e36f0e95fb 100644 --- a/src/openai/lib/_validators.py +++ b/src/openai/lib/_validators.py @@ -309,7 +309,7 @@ def common_completion_prefix_validator(df: pd.DataFrame) -> Remediation: return Remediation(name="common_prefix") def remove_common_prefix(x: Any, prefix: Any, ws_prefix: Any) -> Any: - x["completion"] = x["completion"].str[len(prefix):] + x["completion"] = x["completion"].str[len(prefix) :] if ws_prefix: # keep the single whitespace as prefix x["completion"] = f" {x['completion']}" @@ -623,9 +623,7 @@ def get_outfnames(fname: str, split: bool) -> list[str]: i = 0 while True: index_suffix = f" ({i})" if i > 0 else "" - candidate_fnames = [ - f"{os.path.splitext(fname)[0]}_prepared{suffix}{index_suffix}.jsonl" for suffix in suffixes - ] + candidate_fnames = [f"{os.path.splitext(fname)[0]}_prepared{suffix}{index_suffix}.jsonl" for suffix in suffixes] if not any(os.path.isfile(f) for f in candidate_fnames): return candidate_fnames i += 1 From 4233bcdae5f467f10454fcc008a6e728fa846830 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 1 Dec 2023 10:10:40 -0500 Subject: [PATCH 08/10] chore(internal): remove unused type var (#915) --- src/openai/pagination.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/openai/pagination.py b/src/openai/pagination.py index 4ec300f2d1..17f2d1a4ca 100644 --- a/src/openai/pagination.py +++ b/src/openai/pagination.py @@ -1,16 +1,13 @@ # File generated from our OpenAPI spec by Stainless. -from typing import Any, List, Generic, TypeVar, Optional, cast +from typing import Any, List, Generic, Optional, cast from typing_extensions import Literal, Protocol, override, runtime_checkable from ._types import ModelT -from ._models import BaseModel from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage __all__ = ["SyncPage", "AsyncPage", "SyncCursorPage", "AsyncCursorPage"] -_BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) - @runtime_checkable class CursorPageItem(Protocol): From 135d9cf2820f1524764bf536a9322830bdcd5875 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:55:01 -0500 Subject: [PATCH 09/10] fix(client): correct base_url setter implementation (#919) Co-Authored-By: tomoish --- src/openai/_base_client.py | 2 +- tests/test_client.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 89d9ce4815..2e5678e8e6 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -592,7 +592,7 @@ def base_url(self) -> URL: @base_url.setter def base_url(self, url: URL | str) -> None: - self._client.base_url = url if isinstance(url, URL) else URL(url) + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) @lru_cache(maxsize=None) def platform_headers(self) -> Dict[str, str]: diff --git a/tests/test_client.py b/tests/test_client.py index 51aa90a480..1f1ec6fc98 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -445,6 +445,14 @@ class Model(BaseModel): assert isinstance(response, Model) assert response.foo == 2 + def test_base_url_setter(self) -> None: + client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + def test_base_url_env(self) -> None: with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"): client = OpenAI(api_key=api_key, _strict_response_validation=True) @@ -1102,6 +1110,16 @@ class Model(BaseModel): assert isinstance(response, Model) assert response.foo == 2 + def test_base_url_setter(self) -> None: + client = AsyncOpenAI( + base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + def test_base_url_env(self) -> None: with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"): client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True) From 137ef420669f2e0465988cd57b2e113b512769fb Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:55:42 -0500 Subject: [PATCH 10/10] release: 1.3.7 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 907051ec7d..2fd8c9c83a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.3.6" + ".": "1.3.7" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c324e4f9..88ff899ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.3.7 (2023-12-01) + +Full Changelog: [v1.3.6...v1.3.7](https://github.com/openai/openai-python/compare/v1.3.6...v1.3.7) + +### Bug Fixes + +* **client:** correct base_url setter implementation ([#919](https://github.com/openai/openai-python/issues/919)) ([135d9cf](https://github.com/openai/openai-python/commit/135d9cf2820f1524764bf536a9322830bdcd5875)) +* **client:** don't cause crashes when inspecting the module ([#897](https://github.com/openai/openai-python/issues/897)) ([db029a5](https://github.com/openai/openai-python/commit/db029a596c90b1af4ef0bfb1cdf31f54b2f5755d)) +* **client:** ensure retried requests are closed ([#902](https://github.com/openai/openai-python/issues/902)) ([e025e6b](https://github.com/openai/openai-python/commit/e025e6bee44ea145d948869ef0c79bac0c376b9f)) + + +### Chores + +* **internal:** add tests for proxy change ([#899](https://github.com/openai/openai-python/issues/899)) ([71a13d0](https://github.com/openai/openai-python/commit/71a13d0c70d105b2b97720c72a1003b942cda2ae)) +* **internal:** remove unused type var ([#915](https://github.com/openai/openai-python/issues/915)) ([4233bcd](https://github.com/openai/openai-python/commit/4233bcdae5f467f10454fcc008a6e728fa846830)) +* **internal:** replace string concatenation with f-strings ([#908](https://github.com/openai/openai-python/issues/908)) ([663a8f6](https://github.com/openai/openai-python/commit/663a8f6dead5aa523d1e8779e75af1dabb1690c4)) +* **internal:** replace string concatenation with f-strings ([#909](https://github.com/openai/openai-python/issues/909)) ([caab767](https://github.com/openai/openai-python/commit/caab767156375114078cf8d85031863361326b5f)) + + +### Documentation + +* fix typo in readme ([#904](https://github.com/openai/openai-python/issues/904)) ([472cd44](https://github.com/openai/openai-python/commit/472cd44e45a45b0b4f12583a5402e8aeb121d7a2)) +* **readme:** update example snippets ([#907](https://github.com/openai/openai-python/issues/907)) ([bbb648e](https://github.com/openai/openai-python/commit/bbb648ef81eb11f81b457e2cbf33a832f4d29a76)) + ## 1.3.6 (2023-11-28) Full Changelog: [v1.3.5...v1.3.6](https://github.com/openai/openai-python/compare/v1.3.5...v1.3.6) diff --git a/pyproject.toml b/pyproject.toml index daa765a7c2..81ef1ca317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "1.3.6" +version = "1.3.7" description = "The official Python library for the openai API" readme = "README.md" license = "Apache-2.0" diff --git a/src/openai/_version.py b/src/openai/_version.py index bf8fdd1b4f..3103f3b767 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. __title__ = "openai" -__version__ = "1.3.6" # x-release-please-version +__version__ = "1.3.7" # x-release-please-version