From 5d9aa754ace5d53eb90c1055dd6b1ca8e7deee4f Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 19:02:30 -0500 Subject: [PATCH 01/17] fix(client): ensure retried requests are closed (#261) --- src/anthropic/_base_client.py | 100 +++++++++++++---- src/anthropic/_constants.py | 1 + tests/test_client.py | 198 +++++++++++++++++++++++++++++++++- 3 files changed, 278 insertions(+), 21 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index a168301f..89d9ce48 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_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/anthropic/_constants.py b/src/anthropic/_constants.py index 7343a7a4..4e59e854 100644 --- a/src/anthropic/_constants.py +++ b/src/anthropic/_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 3beacc06..6fa89caa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,7 +19,12 @@ from anthropic._client import Anthropic, AsyncAnthropic from anthropic._models import BaseModel, FinalRequestOptions from anthropic._streaming import Stream, AsyncStream -from anthropic._exceptions import APIResponseValidationError +from anthropic._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) from anthropic._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -39,6 +44,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: Anthropic | AsyncAnthropic) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + class TestAnthropic: client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -690,6 +713,92 @@ 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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 = Anthropic( + 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 TestAsyncAnthropic: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1357,3 +1466,90 @@ 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + 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 = AsyncAnthropic( + 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 f545c350dd802079d057d34ff29444e32dc7bdcb Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:52:19 -0500 Subject: [PATCH 02/17] chore(internal): replace string concatenation with f-strings (#263) --- src/anthropic/_utils/_utils.py | 2 +- tests/test_required_args.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index d2bfc91a..83f88cc3 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_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/tests/test_required_args.py b/tests/test_required_args.py index c506e4ef..fc774314 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 1bfc69b0e2a1eb79598409cbfcba060f699d28a7 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 1 Dec 2023 09:30:10 -0500 Subject: [PATCH 03/17] chore(internal): remove unused file (#264) --- src/anthropic/pagination.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/anthropic/pagination.py diff --git a/src/anthropic/pagination.py b/src/anthropic/pagination.py deleted file mode 100644 index 2b7c4e7b..00000000 --- a/src/anthropic/pagination.py +++ /dev/null @@ -1,7 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. - -from typing import TypeVar - -from ._models import BaseModel - -_BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) From 29d0c8b0eb174b499a904e02cce7fe7a6aaa1a01 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:55:00 -0500 Subject: [PATCH 04/17] fix(client): correct base_url setter implementation (#265) --- src/anthropic/_base_client.py | 2 +- tests/test_client.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 89d9ce48..2e5678e8 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_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 6fa89caa..3ff1ef2b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -454,6 +454,14 @@ class Model(BaseModel): assert isinstance(response, Model) assert response.foo == 2 + def test_base_url_setter(self) -> None: + client = Anthropic(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(ANTHROPIC_BASE_URL="http://localhost:5000/from/env"): client = Anthropic(api_key=api_key, _strict_response_validation=True) @@ -1194,6 +1202,16 @@ class Model(BaseModel): assert isinstance(response, Model) assert response.foo == 2 + def test_base_url_setter(self) -> None: + client = AsyncAnthropic( + 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(ANTHROPIC_BASE_URL="http://localhost:5000/from/env"): client = AsyncAnthropic(api_key=api_key, _strict_response_validation=True) From a217e9955569852d35ab1bc1351dd66ba807fc44 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 4 Dec 2023 05:26:21 -0500 Subject: [PATCH 05/17] chore(package): lift anyio v4 restriction (#266) --- pyproject.toml | 4 ++-- requirements-dev.lock | 13 ++++++------- requirements.lock | 17 ++++++++--------- tests/test_client.py | 30 +++++++++++++++++++----------- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 678097f4..23c6619c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", "typing-extensions>=4.5, <5", - "anyio>=3.5.0, <4", + "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", "tokenizers >= 0.13.0" @@ -49,7 +49,7 @@ dev-dependencies = [ "pyright==1.1.332", "mypy==1.7.1", "black==23.3.0", - "respx==0.19.2", + "respx==0.20.2", "pytest==7.1.1", "pytest-asyncio==0.21.1", "ruff==0.0.282", diff --git a/requirements-dev.lock b/requirements-dev.lock index 31342685..899a7e1f 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -8,7 +8,7 @@ -e file:. annotated-types==0.6.0 -anyio==3.7.1 +anyio==4.1.0 argcomplete==3.1.2 attrs==23.1.0 black==23.3.0 @@ -21,10 +21,10 @@ distlib==0.3.7 distro==1.8.0 exceptiongroup==1.1.3 filelock==3.12.4 -fsspec==2023.10.0 -h11==0.12.0 -httpcore==0.15.0 -httpx==0.23.0 +fsspec==2023.12.0 +h11==0.14.0 +httpcore==1.0.2 +httpx==0.25.2 huggingface-hub==0.16.4 idna==3.4 iniconfig==2.0.0 @@ -47,8 +47,7 @@ python-dateutil==2.8.2 pytz==2023.3.post1 pyyaml==6.0.1 requests==2.31.0 -respx==0.19.2 -rfc3986==1.5.0 +respx==0.20.2 ruff==0.0.282 six==1.16.0 sniffio==1.3.0 diff --git a/requirements.lock b/requirements.lock index 6386a2f7..54d268da 100644 --- a/requirements.lock +++ b/requirements.lock @@ -8,16 +8,16 @@ -e file:. annotated-types==0.6.0 -anyio==3.7.1 +anyio==4.1.0 certifi==2023.7.22 -charset-normalizer==3.3.0 +charset-normalizer==3.3.2 distro==1.8.0 exceptiongroup==1.1.3 -filelock==3.12.4 -fsspec==2023.9.2 -h11==0.12.0 -httpcore==0.15.0 -httpx==0.23.0 +filelock==3.13.1 +fsspec==2023.12.0 +h11==0.14.0 +httpcore==1.0.2 +httpx==0.25.2 huggingface-hub==0.16.4 idna==3.4 packaging==23.2 @@ -25,9 +25,8 @@ pydantic==2.4.2 pydantic-core==2.10.1 pyyaml==6.0.1 requests==2.31.0 -rfc3986==1.5.0 sniffio==1.3.0 tokenizers==0.14.0 tqdm==4.66.1 typing-extensions==4.8.0 -urllib3==2.0.7 +urllib3==2.1.0 diff --git a/tests/test_client.py b/tests/test_client.py index 3ff1ef2b..83d1c953 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,7 +6,7 @@ import json import asyncio import inspect -from typing import Any, Dict, Union, cast +from typing import Any, Union, cast from unittest import mock import httpx @@ -366,7 +366,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged @@ -380,7 +380,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash @@ -394,7 +394,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"foo": "2"} @pytest.mark.respx(base_url=base_url) @@ -541,7 +541,9 @@ def test_transport_option_is_deprecated(self) -> None: DeprecationWarning, match="The `transport` argument is deprecated. The `http_client` argument should be passed instead", ): - transport = httpx.MockTransport(lambda: None) + transport = httpx.MockTransport( + lambda: None, # type: ignore + ) client = Anthropic( base_url=base_url, api_key=api_key, _strict_response_validation=True, transport=transport @@ -557,7 +559,9 @@ def test_transport_option_mutually_exclusive_with_http_client(self) -> None: base_url=base_url, api_key=api_key, _strict_response_validation=True, - transport=httpx.MockTransport(lambda: None), + transport=httpx.MockTransport( + lambda: None, # type: ignore + ), http_client=http_client, ) @@ -1114,7 +1118,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged @@ -1128,7 +1132,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash @@ -1142,7 +1146,7 @@ def test_request_extra_query(self) -> None: ), ), ) - params = cast(Dict[str, str], dict(request.url.params)) + params = dict(request.url.params) assert params == {"foo": "2"} @pytest.mark.respx(base_url=base_url) @@ -1297,7 +1301,9 @@ def test_transport_option_is_deprecated(self) -> None: DeprecationWarning, match="The `transport` argument is deprecated. The `http_client` argument should be passed instead", ): - transport = httpx.MockTransport(lambda: None) + transport = httpx.MockTransport( + lambda: None, # type: ignore + ) client = AsyncAnthropic( base_url=base_url, api_key=api_key, _strict_response_validation=True, transport=transport @@ -1313,7 +1319,9 @@ async def test_transport_option_mutually_exclusive_with_http_client(self) -> Non base_url=base_url, api_key=api_key, _strict_response_validation=True, - transport=httpx.MockTransport(lambda: None), + transport=httpx.MockTransport( + lambda: None, # type: ignore + ), http_client=http_client, ) From 5fc05492b46049b59757e4696f5427ff8ae5dd80 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 4 Dec 2023 05:44:35 -0500 Subject: [PATCH 06/17] ci: ensure PR titles use conventional commits (#267) --- .github/workflows/lint-pr.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/lint-pr.yml diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..1dbd8501 --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,21 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + pr_title: + name: Validate PR title + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.repository == 'anthropics/anthropic-sdk-python' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 875180dae4dcd82dda5dd850fc17702adb5a87b7 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 6 Dec 2023 01:04:08 +0000 Subject: [PATCH 07/17] ci: remove PR title linter (#269) --- .github/workflows/lint-pr.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/lint-pr.yml diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml deleted file mode 100644 index 1dbd8501..00000000 --- a/.github/workflows/lint-pr.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "Lint PR" - -on: - pull_request_target: - types: - - opened - - edited - - synchronize - -permissions: - pull-requests: read - -jobs: - pr_title: - name: Validate PR title - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.repository == 'anthropics/anthropic-sdk-python' - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From dc55724673dfa59911a05fe4827b8804beba0b05 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 7 Dec 2023 10:09:14 +0000 Subject: [PATCH 08/17] chore(internal): reformat imports (#270) --- pyproject.toml | 37 +++++++------ requirements-dev.lock | 5 +- src/anthropic/__init__.py | 3 +- src/anthropic/_client.py | 3 +- src/anthropic/_compat.py | 30 +++++----- src/anthropic/_models.py | 14 ++--- src/anthropic/_types.py | 9 +-- src/anthropic/_utils/__init__.py | 76 ++++++++++++++------------ src/anthropic/_utils/_utils.py | 4 +- src/anthropic/resources/__init__.py | 7 +-- src/anthropic/resources/completions.py | 12 +++- tests/utils.py | 7 ++- 12 files changed, 102 insertions(+), 105 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23c6619c..3bfdb256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,16 +46,16 @@ Repository = "https://github.com/anthropics/anthropic-sdk-python" [tool.rye] managed = true dev-dependencies = [ - "pyright==1.1.332", - "mypy==1.7.1", - "black==23.3.0", - "respx==0.20.2", - "pytest==7.1.1", - "pytest-asyncio==0.21.1", - "ruff==0.0.282", - "isort==5.10.1", - "time-machine==2.9.0", - "nox==2023.4.22", + # version pins are in requirements-dev.lock + "pyright", + "mypy", + "black", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", "dirty-equals>=0.6.0", ] @@ -65,12 +65,10 @@ format = { chain = [ "format:black", "format:docs", "format:ruff", - "format:isort", ]} "format:black" = "black ." "format:docs" = "python bin/blacken-docs.py README.md api.md" "format:ruff" = "ruff --fix ." -"format:isort" = "isort ." "check:ruff" = "ruff ." @@ -125,16 +123,13 @@ reportImplicitOverride = true reportImportCycles = false reportPrivateUsage = false -[tool.isort] -profile = "black" -length_sort = true -extra_standard_library = ["typing_extensions"] - [tool.ruff] line-length = 120 -format = "grouped" +output-format = "grouped" target-version = "py37" select = [ + # isort + "I", # remove unused imports "F401", # bare except statements @@ -152,6 +147,12 @@ unfixable = [ ] ignore-init-module-imports = true +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["anthropic", "tests"] [tool.ruff.per-file-ignores] "bin/**.py" = ["T201", "T203"] diff --git a/requirements-dev.lock b/requirements-dev.lock index 899a7e1f..e55c1c41 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -21,14 +21,13 @@ distlib==0.3.7 distro==1.8.0 exceptiongroup==1.1.3 filelock==3.12.4 -fsspec==2023.12.0 +fsspec==2023.12.1 h11==0.14.0 httpcore==1.0.2 httpx==0.25.2 huggingface-hub==0.16.4 idna==3.4 iniconfig==2.0.0 -isort==5.10.1 mypy==1.7.1 mypy-extensions==1.0.0 nodeenv==1.8.0 @@ -48,7 +47,7 @@ pytz==2023.3.post1 pyyaml==6.0.1 requests==2.31.0 respx==0.20.2 -ruff==0.0.282 +ruff==0.1.7 six==1.16.0 sniffio==1.3.0 time-machine==2.9.0 diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 01b0a81c..54e6bc84 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -15,8 +15,7 @@ RequestOptions, ) from ._version import __title__, __version__ -from ._constants import AI_PROMPT as AI_PROMPT -from ._constants import HUMAN_PROMPT as HUMAN_PROMPT +from ._constants import AI_PROMPT as AI_PROMPT, HUMAN_PROMPT as HUMAN_PROMPT from ._exceptions import ( APIError, ConflictError, diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 33648605..9123bc7b 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -25,8 +25,7 @@ ) from ._utils import is_given, get_async_library from ._version import __version__ -from ._streaming import Stream as Stream -from ._streaming import AsyncStream as AsyncStream +from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError from ._tokenizers import sync_get_tokenizer, async_get_tokenizer from ._base_client import ( diff --git a/src/anthropic/_compat.py b/src/anthropic/_compat.py index 34323c9b..d95db8ed 100644 --- a/src/anthropic/_compat.py +++ b/src/anthropic/_compat.py @@ -43,21 +43,23 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 else: if PYDANTIC_V2: - from pydantic.v1.typing import get_args as get_args - from pydantic.v1.typing import is_union as is_union - from pydantic.v1.typing import get_origin as get_origin - from pydantic.v1.typing import is_typeddict as is_typeddict - from pydantic.v1.typing import is_literal_type as is_literal_type - from pydantic.v1.datetime_parse import parse_date as parse_date - from pydantic.v1.datetime_parse import parse_datetime as parse_datetime + from pydantic.v1.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime else: - from pydantic.typing import get_args as get_args - from pydantic.typing import is_union as is_union - from pydantic.typing import get_origin as get_origin - from pydantic.typing import is_typeddict as is_typeddict - from pydantic.typing import is_literal_type as is_literal_type - from pydantic.datetime_parse import parse_date as parse_date - from pydantic.datetime_parse import parse_datetime as parse_datetime + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime # refactored config diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 5b8c9601..cdd44ccb 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -30,17 +30,11 @@ AnyMapping, HttpxRequestFiles, ) -from ._utils import ( - is_list, - is_given, - is_mapping, - parse_date, - parse_datetime, - strip_not_given, -) -from ._compat import PYDANTIC_V2, ConfigDict -from ._compat import GenericModel as BaseGenericModel +from ._utils import is_list, is_given, is_mapping, parse_date, parse_datetime, strip_not_given from ._compat import ( + PYDANTIC_V2, + ConfigDict, + GenericModel as BaseGenericModel, get_args, is_union, parse_obj, diff --git a/src/anthropic/_types.py b/src/anthropic/_types.py index 7e95adbe..4b7d5e7f 100644 --- a/src/anthropic/_types.py +++ b/src/anthropic/_types.py @@ -19,14 +19,7 @@ Sequence, AsyncIterator, ) -from typing_extensions import ( - Literal, - Protocol, - TypeAlias, - TypedDict, - override, - runtime_checkable, -) +from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable import pydantic from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport diff --git a/src/anthropic/_utils/__init__.py b/src/anthropic/_utils/__init__.py index 400ca9b8..e98636c9 100644 --- a/src/anthropic/_utils/__init__.py +++ b/src/anthropic/_utils/__init__.py @@ -1,37 +1,41 @@ from ._proxy import LazyProxy as LazyProxy -from ._utils import flatten as flatten -from ._utils import is_dict as is_dict -from ._utils import is_list as is_list -from ._utils import is_given as is_given -from ._utils import is_tuple as is_tuple -from ._utils import is_mapping as is_mapping -from ._utils import is_tuple_t as is_tuple_t -from ._utils import parse_date as parse_date -from ._utils import is_sequence as is_sequence -from ._utils import coerce_float as coerce_float -from ._utils import is_list_type as is_list_type -from ._utils import is_mapping_t as is_mapping_t -from ._utils import removeprefix as removeprefix -from ._utils import removesuffix as removesuffix -from ._utils import extract_files as extract_files -from ._utils import is_sequence_t as is_sequence_t -from ._utils import is_union_type as is_union_type -from ._utils import required_args as required_args -from ._utils import coerce_boolean as coerce_boolean -from ._utils import coerce_integer as coerce_integer -from ._utils import file_from_path as file_from_path -from ._utils import parse_datetime as parse_datetime -from ._utils import strip_not_given as strip_not_given -from ._utils import deepcopy_minimal as deepcopy_minimal -from ._utils import extract_type_arg as extract_type_arg -from ._utils import is_required_type as is_required_type -from ._utils import get_async_library as get_async_library -from ._utils import is_annotated_type as is_annotated_type -from ._utils import maybe_coerce_float as maybe_coerce_float -from ._utils import get_required_header as get_required_header -from ._utils import maybe_coerce_boolean as maybe_coerce_boolean -from ._utils import maybe_coerce_integer as maybe_coerce_integer -from ._utils import strip_annotated_type as strip_annotated_type -from ._transform import PropertyInfo as PropertyInfo -from ._transform import transform as transform -from ._transform import maybe_transform as maybe_transform +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + parse_date as parse_date, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_list_type as is_list_type, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + is_union_type as is_union_type, + required_args as required_args, + coerce_boolean as coerce_boolean, + coerce_integer as coerce_integer, + file_from_path as file_from_path, + parse_datetime as parse_datetime, + strip_not_given as strip_not_given, + deepcopy_minimal as deepcopy_minimal, + extract_type_arg as extract_type_arg, + is_required_type as is_required_type, + get_async_library as get_async_library, + is_annotated_type as is_annotated_type, + maybe_coerce_float as maybe_coerce_float, + get_required_header as get_required_header, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, + strip_annotated_type as strip_annotated_type, +) +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + maybe_transform as maybe_transform, +) diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index 83f88cc3..cce69238 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_utils/_utils.py @@ -21,9 +21,7 @@ import sniffio from .._types import Headers, NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import is_union as _is_union -from .._compat import parse_date as parse_date -from .._compat import parse_datetime as parse_datetime +from .._compat import is_union as _is_union, parse_date as parse_date, parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/src/anthropic/resources/__init__.py b/src/anthropic/resources/__init__.py index bbb8787d..a03af036 100644 --- a/src/anthropic/resources/__init__.py +++ b/src/anthropic/resources/__init__.py @@ -1,10 +1,5 @@ # File generated from our OpenAPI spec by Stainless. -from .completions import ( - Completions, - AsyncCompletions, - CompletionsWithRawResponse, - AsyncCompletionsWithRawResponse, -) +from .completions import Completions, AsyncCompletions, CompletionsWithRawResponse, AsyncCompletionsWithRawResponse __all__ = ["Completions", "AsyncCompletions", "CompletionsWithRawResponse", "AsyncCompletionsWithRawResponse"] diff --git a/src/anthropic/resources/completions.py b/src/anthropic/resources/completions.py index a603634d..103d0b7f 100644 --- a/src/anthropic/resources/completions.py +++ b/src/anthropic/resources/completions.py @@ -8,12 +8,20 @@ import httpx from ..types import Completion, completion_create_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import ( + NOT_GIVEN, + Body, + Query, + Headers, + NotGiven, +) from .._utils import required_args, maybe_transform from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_raw_response_wrapper, async_to_raw_response_wrapper from .._streaming import Stream, AsyncStream -from .._base_client import make_request_options +from .._base_client import ( + make_request_options, +) if TYPE_CHECKING: from .._client import Anthropic, AsyncAnthropic diff --git a/tests/utils.py b/tests/utils.py index 348363a5..994c1fc9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -8,7 +8,12 @@ from typing_extensions import Literal, get_args, get_origin, assert_type from anthropic._types import NoneType -from anthropic._utils import is_dict, is_list, is_list_type, is_union_type +from anthropic._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, +) from anthropic._compat import PYDANTIC_V2, field_outer_type, get_model_fields from anthropic._models import BaseModel From 802ab59401b06986b8023e9ef0d0f9e0d6858b86 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 7 Dec 2023 10:56:25 +0000 Subject: [PATCH 09/17] chore(internal): update formatting (#271) --- src/anthropic/_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 9123bc7b..72d3f2d1 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -23,7 +23,10 @@ AsyncTransport, RequestOptions, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + get_async_library, +) from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError From 0d82ce4784c3a6c9599e6c09b8190e97ea028dc3 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 7 Dec 2023 14:54:29 +0000 Subject: [PATCH 10/17] chore(internal): reformat imports (#272) --- pyproject.toml | 37 ++++++------- requirements-dev.lock | 10 +--- src/anthropic/__init__.py | 3 +- src/anthropic/_client.py | 8 +-- src/anthropic/_compat.py | 30 +++++----- src/anthropic/_models.py | 14 +++-- src/anthropic/_types.py | 9 ++- src/anthropic/_utils/__init__.py | 76 ++++++++++++-------------- src/anthropic/_utils/_utils.py | 4 +- src/anthropic/resources/__init__.py | 7 ++- src/anthropic/resources/completions.py | 12 +--- tests/utils.py | 7 +-- 12 files changed, 105 insertions(+), 112 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3bfdb256..23c6619c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,16 +46,16 @@ Repository = "https://github.com/anthropics/anthropic-sdk-python" [tool.rye] managed = true dev-dependencies = [ - # version pins are in requirements-dev.lock - "pyright", - "mypy", - "black", - "respx", - "pytest", - "pytest-asyncio", - "ruff", - "time-machine", - "nox", + "pyright==1.1.332", + "mypy==1.7.1", + "black==23.3.0", + "respx==0.20.2", + "pytest==7.1.1", + "pytest-asyncio==0.21.1", + "ruff==0.0.282", + "isort==5.10.1", + "time-machine==2.9.0", + "nox==2023.4.22", "dirty-equals>=0.6.0", ] @@ -65,10 +65,12 @@ format = { chain = [ "format:black", "format:docs", "format:ruff", + "format:isort", ]} "format:black" = "black ." "format:docs" = "python bin/blacken-docs.py README.md api.md" "format:ruff" = "ruff --fix ." +"format:isort" = "isort ." "check:ruff" = "ruff ." @@ -123,13 +125,16 @@ reportImplicitOverride = true reportImportCycles = false reportPrivateUsage = false +[tool.isort] +profile = "black" +length_sort = true +extra_standard_library = ["typing_extensions"] + [tool.ruff] line-length = 120 -output-format = "grouped" +format = "grouped" target-version = "py37" select = [ - # isort - "I", # remove unused imports "F401", # bare except statements @@ -147,12 +152,6 @@ unfixable = [ ] ignore-init-module-imports = true -[tool.ruff.lint.isort] -length-sort = true -length-sort-straight = true -combine-as-imports = true -extra-standard-library = ["typing_extensions"] -known-first-party = ["anthropic", "tests"] [tool.ruff.per-file-ignores] "bin/**.py" = ["T201", "T203"] diff --git a/requirements-dev.lock b/requirements-dev.lock index e55c1c41..49d026c1 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -13,7 +13,6 @@ argcomplete==3.1.2 attrs==23.1.0 black==23.3.0 certifi==2023.7.22 -charset-normalizer==3.3.2 click==8.1.7 colorlog==6.7.0 dirty-equals==0.6.0 @@ -21,13 +20,12 @@ distlib==0.3.7 distro==1.8.0 exceptiongroup==1.1.3 filelock==3.12.4 -fsspec==2023.12.1 h11==0.14.0 httpcore==1.0.2 httpx==0.25.2 -huggingface-hub==0.16.4 idna==3.4 iniconfig==2.0.0 +isort==5.10.1 mypy==1.7.1 mypy-extensions==1.0.0 nodeenv==1.8.0 @@ -44,18 +42,14 @@ pytest==7.1.1 pytest-asyncio==0.21.1 python-dateutil==2.8.2 pytz==2023.3.post1 -pyyaml==6.0.1 -requests==2.31.0 respx==0.20.2 -ruff==0.1.7 +ruff==0.0.282 six==1.16.0 sniffio==1.3.0 time-machine==2.9.0 tokenizers==0.14.0 tomli==2.0.1 -tqdm==4.66.1 typing-extensions==4.8.0 -urllib3==2.1.0 virtualenv==20.24.5 # The following packages are considered to be unsafe in a requirements file: setuptools==68.2.2 diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 54e6bc84..01b0a81c 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -15,7 +15,8 @@ RequestOptions, ) from ._version import __title__, __version__ -from ._constants import AI_PROMPT as AI_PROMPT, HUMAN_PROMPT as HUMAN_PROMPT +from ._constants import AI_PROMPT as AI_PROMPT +from ._constants import HUMAN_PROMPT as HUMAN_PROMPT from ._exceptions import ( APIError, ConflictError, diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 72d3f2d1..33648605 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -23,12 +23,10 @@ AsyncTransport, RequestOptions, ) -from ._utils import ( - is_given, - get_async_library, -) +from ._utils import is_given, get_async_library from ._version import __version__ -from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._streaming import Stream as Stream +from ._streaming import AsyncStream as AsyncStream from ._exceptions import APIStatusError from ._tokenizers import sync_get_tokenizer, async_get_tokenizer from ._base_client import ( diff --git a/src/anthropic/_compat.py b/src/anthropic/_compat.py index d95db8ed..34323c9b 100644 --- a/src/anthropic/_compat.py +++ b/src/anthropic/_compat.py @@ -43,23 +43,21 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 else: if PYDANTIC_V2: - from pydantic.v1.typing import ( - get_args as get_args, - is_union as is_union, - get_origin as get_origin, - is_typeddict as is_typeddict, - is_literal_type as is_literal_type, - ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.v1.typing import get_args as get_args + from pydantic.v1.typing import is_union as is_union + from pydantic.v1.typing import get_origin as get_origin + from pydantic.v1.typing import is_typeddict as is_typeddict + from pydantic.v1.typing import is_literal_type as is_literal_type + from pydantic.v1.datetime_parse import parse_date as parse_date + from pydantic.v1.datetime_parse import parse_datetime as parse_datetime else: - from pydantic.typing import ( - get_args as get_args, - is_union as is_union, - get_origin as get_origin, - is_typeddict as is_typeddict, - is_literal_type as is_literal_type, - ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.typing import get_args as get_args + from pydantic.typing import is_union as is_union + from pydantic.typing import get_origin as get_origin + from pydantic.typing import is_typeddict as is_typeddict + from pydantic.typing import is_literal_type as is_literal_type + from pydantic.datetime_parse import parse_date as parse_date + from pydantic.datetime_parse import parse_datetime as parse_datetime # refactored config diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index cdd44ccb..5b8c9601 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -30,11 +30,17 @@ AnyMapping, HttpxRequestFiles, ) -from ._utils import is_list, is_given, is_mapping, parse_date, parse_datetime, strip_not_given +from ._utils import ( + is_list, + is_given, + is_mapping, + parse_date, + parse_datetime, + strip_not_given, +) +from ._compat import PYDANTIC_V2, ConfigDict +from ._compat import GenericModel as BaseGenericModel from ._compat import ( - PYDANTIC_V2, - ConfigDict, - GenericModel as BaseGenericModel, get_args, is_union, parse_obj, diff --git a/src/anthropic/_types.py b/src/anthropic/_types.py index 4b7d5e7f..7e95adbe 100644 --- a/src/anthropic/_types.py +++ b/src/anthropic/_types.py @@ -19,7 +19,14 @@ Sequence, AsyncIterator, ) -from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable +from typing_extensions import ( + Literal, + Protocol, + TypeAlias, + TypedDict, + override, + runtime_checkable, +) import pydantic from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport diff --git a/src/anthropic/_utils/__init__.py b/src/anthropic/_utils/__init__.py index e98636c9..400ca9b8 100644 --- a/src/anthropic/_utils/__init__.py +++ b/src/anthropic/_utils/__init__.py @@ -1,41 +1,37 @@ from ._proxy import LazyProxy as LazyProxy -from ._utils import ( - flatten as flatten, - is_dict as is_dict, - is_list as is_list, - is_given as is_given, - is_tuple as is_tuple, - is_mapping as is_mapping, - is_tuple_t as is_tuple_t, - parse_date as parse_date, - is_sequence as is_sequence, - coerce_float as coerce_float, - is_list_type as is_list_type, - is_mapping_t as is_mapping_t, - removeprefix as removeprefix, - removesuffix as removesuffix, - extract_files as extract_files, - is_sequence_t as is_sequence_t, - is_union_type as is_union_type, - required_args as required_args, - coerce_boolean as coerce_boolean, - coerce_integer as coerce_integer, - file_from_path as file_from_path, - parse_datetime as parse_datetime, - strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, - extract_type_arg as extract_type_arg, - is_required_type as is_required_type, - get_async_library as get_async_library, - is_annotated_type as is_annotated_type, - maybe_coerce_float as maybe_coerce_float, - get_required_header as get_required_header, - maybe_coerce_boolean as maybe_coerce_boolean, - maybe_coerce_integer as maybe_coerce_integer, - strip_annotated_type as strip_annotated_type, -) -from ._transform import ( - PropertyInfo as PropertyInfo, - transform as transform, - maybe_transform as maybe_transform, -) +from ._utils import flatten as flatten +from ._utils import is_dict as is_dict +from ._utils import is_list as is_list +from ._utils import is_given as is_given +from ._utils import is_tuple as is_tuple +from ._utils import is_mapping as is_mapping +from ._utils import is_tuple_t as is_tuple_t +from ._utils import parse_date as parse_date +from ._utils import is_sequence as is_sequence +from ._utils import coerce_float as coerce_float +from ._utils import is_list_type as is_list_type +from ._utils import is_mapping_t as is_mapping_t +from ._utils import removeprefix as removeprefix +from ._utils import removesuffix as removesuffix +from ._utils import extract_files as extract_files +from ._utils import is_sequence_t as is_sequence_t +from ._utils import is_union_type as is_union_type +from ._utils import required_args as required_args +from ._utils import coerce_boolean as coerce_boolean +from ._utils import coerce_integer as coerce_integer +from ._utils import file_from_path as file_from_path +from ._utils import parse_datetime as parse_datetime +from ._utils import strip_not_given as strip_not_given +from ._utils import deepcopy_minimal as deepcopy_minimal +from ._utils import extract_type_arg as extract_type_arg +from ._utils import is_required_type as is_required_type +from ._utils import get_async_library as get_async_library +from ._utils import is_annotated_type as is_annotated_type +from ._utils import maybe_coerce_float as maybe_coerce_float +from ._utils import get_required_header as get_required_header +from ._utils import maybe_coerce_boolean as maybe_coerce_boolean +from ._utils import maybe_coerce_integer as maybe_coerce_integer +from ._utils import strip_annotated_type as strip_annotated_type +from ._transform import PropertyInfo as PropertyInfo +from ._transform import transform as transform +from ._transform import maybe_transform as maybe_transform diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index cce69238..83f88cc3 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_utils/_utils.py @@ -21,7 +21,9 @@ import sniffio from .._types import Headers, NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import is_union as _is_union, parse_date as parse_date, parse_datetime as parse_datetime +from .._compat import is_union as _is_union +from .._compat import parse_date as parse_date +from .._compat import parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/src/anthropic/resources/__init__.py b/src/anthropic/resources/__init__.py index a03af036..bbb8787d 100644 --- a/src/anthropic/resources/__init__.py +++ b/src/anthropic/resources/__init__.py @@ -1,5 +1,10 @@ # File generated from our OpenAPI spec by Stainless. -from .completions import Completions, AsyncCompletions, CompletionsWithRawResponse, AsyncCompletionsWithRawResponse +from .completions import ( + Completions, + AsyncCompletions, + CompletionsWithRawResponse, + AsyncCompletionsWithRawResponse, +) __all__ = ["Completions", "AsyncCompletions", "CompletionsWithRawResponse", "AsyncCompletionsWithRawResponse"] diff --git a/src/anthropic/resources/completions.py b/src/anthropic/resources/completions.py index 103d0b7f..a603634d 100644 --- a/src/anthropic/resources/completions.py +++ b/src/anthropic/resources/completions.py @@ -8,20 +8,12 @@ import httpx from ..types import Completion, completion_create_params -from .._types import ( - NOT_GIVEN, - Body, - Query, - Headers, - NotGiven, -) +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven from .._utils import required_args, maybe_transform from .._resource import SyncAPIResource, AsyncAPIResource from .._response import to_raw_response_wrapper, async_to_raw_response_wrapper from .._streaming import Stream, AsyncStream -from .._base_client import ( - make_request_options, -) +from .._base_client import make_request_options if TYPE_CHECKING: from .._client import Anthropic, AsyncAnthropic diff --git a/tests/utils.py b/tests/utils.py index 994c1fc9..348363a5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -8,12 +8,7 @@ from typing_extensions import Literal, get_args, get_origin, assert_type from anthropic._types import NoneType -from anthropic._utils import ( - is_dict, - is_list, - is_list_type, - is_union_type, -) +from anthropic._utils import is_dict, is_list, is_list_type, is_union_type from anthropic._compat import PYDANTIC_V2, field_outer_type, get_model_fields from anthropic._models import BaseModel From 0ac62bc127ddf0367561427836ff19c1272fb0e1 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 7 Dec 2023 18:23:31 +0000 Subject: [PATCH 11/17] chore(internal): enable more lint rules (#273) --- pyproject.toml | 31 ++++++++++++++++++++----------- requirements-dev.lock | 9 ++++++++- src/anthropic/__init__.py | 2 +- src/anthropic/_streaming.py | 4 ++-- src/anthropic/_types.py | 1 + src/anthropic/_utils/_utils.py | 8 +++++--- tests/test_utils/test_proxy.py | 2 +- tests/utils.py | 2 +- 8 files changed, 39 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23c6619c..a9e1eca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,17 +45,18 @@ Repository = "https://github.com/anthropics/anthropic-sdk-python" [tool.rye] managed = true +# version pins are in requirements-dev.lock dev-dependencies = [ - "pyright==1.1.332", - "mypy==1.7.1", - "black==23.3.0", - "respx==0.20.2", - "pytest==7.1.1", - "pytest-asyncio==0.21.1", - "ruff==0.0.282", - "isort==5.10.1", - "time-machine==2.9.0", - "nox==2023.4.22", + "pyright", + "mypy", + "black", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "isort", + "time-machine", + "nox", "dirty-equals>=0.6.0", ] @@ -132,9 +133,11 @@ extra_standard_library = ["typing_extensions"] [tool.ruff] line-length = 120 -format = "grouped" +output-format = "grouped" target-version = "py37" select = [ + # bugbear rules + "B", # remove unused imports "F401", # bare except statements @@ -145,6 +148,12 @@ select = [ "T201", "T203", ] +ignore = [ + # lru_cache in methods, will be fixed separately + "B019", + # mutable defaults + "B006", +] unfixable = [ # disable auto fix for print statements "T201", diff --git a/requirements-dev.lock b/requirements-dev.lock index 49d026c1..f1d546fc 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -13,6 +13,7 @@ argcomplete==3.1.2 attrs==23.1.0 black==23.3.0 certifi==2023.7.22 +charset-normalizer==3.3.2 click==8.1.7 colorlog==6.7.0 dirty-equals==0.6.0 @@ -20,9 +21,11 @@ distlib==0.3.7 distro==1.8.0 exceptiongroup==1.1.3 filelock==3.12.4 +fsspec==2023.12.1 h11==0.14.0 httpcore==1.0.2 httpx==0.25.2 +huggingface-hub==0.16.4 idna==3.4 iniconfig==2.0.0 isort==5.10.1 @@ -42,14 +45,18 @@ pytest==7.1.1 pytest-asyncio==0.21.1 python-dateutil==2.8.2 pytz==2023.3.post1 +pyyaml==6.0.1 +requests==2.31.0 respx==0.20.2 -ruff==0.0.282 +ruff==0.1.7 six==1.16.0 sniffio==1.3.0 time-machine==2.9.0 tokenizers==0.14.0 tomli==2.0.1 +tqdm==4.66.1 typing-extensions==4.8.0 +urllib3==2.1.0 virtualenv==20.24.5 # The following packages are considered to be unsafe in a requirements file: setuptools==68.2.2 diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 01b0a81c..3bae0eba 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -79,7 +79,7 @@ for __name in __all__: if not __name.startswith("__"): try: - setattr(__locals[__name], "__module__", "anthropic") + __locals[__name].__module__ = "anthropic" except (TypeError, AttributeError): # Some of our exported symbols are builtins which we can't set attributes for. pass diff --git a/src/anthropic/_streaming.py b/src/anthropic/_streaming.py index f2d2246a..5be75e9a 100644 --- a/src/anthropic/_streaming.py +++ b/src/anthropic/_streaming.py @@ -70,7 +70,7 @@ def __stream__(self) -> Iterator[ResponseT]: ) # Ensure the entire stream is consumed - for sse in iterator: + for _sse in iterator: ... @@ -132,7 +132,7 @@ async def __stream__(self) -> AsyncIterator[ResponseT]: ) # Ensure the entire stream is consumed - async for sse in iterator: + async for _sse in iterator: ... diff --git a/src/anthropic/_types.py b/src/anthropic/_types.py index 7e95adbe..6c001136 100644 --- a/src/anthropic/_types.py +++ b/src/anthropic/_types.py @@ -44,6 +44,7 @@ class BinaryResponseContent(ABC): + @abstractmethod def __init__( self, response: Any, diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index 83f88cc3..c874d368 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_utils/_utils.py @@ -194,8 +194,8 @@ def extract_type_arg(typ: type, index: int) -> type: args = get_args(typ) try: return cast(type, args[index]) - except IndexError: - raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err def deepcopy_minimal(item: _T) -> _T: @@ -275,7 +275,9 @@ def wrapper(*args: object, **kwargs: object) -> object: try: given_params.add(positional[i]) except IndexError: - raise TypeError(f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given") + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None for key in kwargs.keys(): given_params.add(key) diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py index 620bb806..8a2a9cbd 100644 --- a/tests/test_utils/test_proxy.py +++ b/tests/test_utils/test_proxy.py @@ -19,5 +19,5 @@ def test_recursive_proxy() -> None: assert repr(proxy) == "RecursiveLazyProxy" assert str(proxy) == "RecursiveLazyProxy" assert dir(proxy) == [] - assert getattr(type(proxy), "__name__") == "RecursiveLazyProxy" + assert type(proxy).__name__ == "RecursiveLazyProxy" assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" diff --git a/tests/utils.py b/tests/utils.py index 348363a5..3a5830b4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -91,7 +91,7 @@ def assert_matches_type( traceback.print_exc() continue - assert False, "Did not match any variants" + raise AssertionError("Did not match any variants") elif issubclass(origin, BaseModel): assert isinstance(value, type_) assert assert_matches_model(type_, cast(Any, value), path=path) From 342846fa4d424a4d18dd2289d2b652bf53c97901 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 8 Dec 2023 10:07:24 +0000 Subject: [PATCH 12/17] fix(errors): properly assign APIError.body (#274) --- src/anthropic/_exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/anthropic/_exceptions.py b/src/anthropic/_exceptions.py index 0e2d5252..47568a94 100644 --- a/src/anthropic/_exceptions.py +++ b/src/anthropic/_exceptions.py @@ -41,6 +41,7 @@ def __init__(self, message: str, request: httpx.Request, *, body: object | None) super().__init__(message) self.request = request self.message = message + self.body = body class APIResponseValidationError(APIError): From 5e51ebdbc6e5c23c8c237b5e0231ef66f585f964 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 8 Dec 2023 19:02:07 +0000 Subject: [PATCH 13/17] fix: avoid leaking memory when Client.with_options is used (#275) Fixes https://github.com/openai/openai-python/issues/865. --- pyproject.toml | 2 - src/anthropic/_base_client.py | 28 ++++---- src/anthropic/_client.py | 4 +- tests/test_client.py | 124 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9e1eca8..0c318644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,8 +149,6 @@ select = [ "T203", ] ignore = [ - # lru_cache in methods, will be fixed separately - "B019", # mutable defaults "B006", ] diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 2e5678e8..bbbb8a54 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -403,14 +403,12 @@ def _build_headers(self, options: FinalRequestOptions) -> httpx.Headers: headers_dict = _merge_mappings(self.default_headers, custom_headers) self._validate_headers(headers_dict, custom_headers) + # headers are case-insensitive while dictionaries are not. headers = httpx.Headers(headers_dict) idempotency_header = self._idempotency_header if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: - if not options.idempotency_key: - options.idempotency_key = self._idempotency_key() - - headers[idempotency_header] = options.idempotency_key + headers[idempotency_header] = options.idempotency_key or self._idempotency_key() return headers @@ -594,16 +592,8 @@ def base_url(self) -> URL: def base_url(self, url: URL | str) -> None: 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]: - return { - "X-Stainless-Lang": "python", - "X-Stainless-Package-Version": self._version, - "X-Stainless-OS": str(get_platform()), - "X-Stainless-Arch": str(get_architecture()), - "X-Stainless-Runtime": platform.python_implementation(), - "X-Stainless-Runtime-Version": platform.python_version(), - } + return platform_headers(self._version) def _calculate_retry_timeout( self, @@ -1691,6 +1681,18 @@ def get_platform() -> Platform: return "Unknown" +@lru_cache(maxsize=None) +def platform_headers(version: str) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": platform.python_implementation(), + "X-Stainless-Runtime-Version": platform.python_version(), + } + + class OtherArch: def __init__(self, name: str) -> None: self.name = name diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 33648605..91900a8a 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -239,7 +239,7 @@ def copy( return self.__class__( api_key=api_key or self.api_key, auth_token=auth_token or self.auth_token, - base_url=base_url or str(self.base_url), + base_url=base_url or self.base_url, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, http_client=http_client, connection_pool_limits=connection_pool_limits, @@ -500,7 +500,7 @@ def copy( return self.__class__( api_key=api_key or self.api_key, auth_token=auth_token or self.auth_token, - base_url=base_url or str(self.base_url), + base_url=base_url or self.base_url, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, http_client=http_client, connection_pool_limits=connection_pool_limits, diff --git a/tests/test_client.py b/tests/test_client.py index 83d1c953..33b5095d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,10 +2,12 @@ from __future__ import annotations +import gc import os import json import asyncio import inspect +import tracemalloc from typing import Any, Union, cast from unittest import mock @@ -195,6 +197,67 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + gc.collect() + + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "anthropic/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "anthropic/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + def test_request_timeout(self) -> None: request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -947,6 +1010,67 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + gc.collect() + + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "anthropic/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "anthropic/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + async def test_request_timeout(self) -> None: request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore From 8f562f47f13ffaaab93f08b9b4c59d06e4a18b6c Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Sat, 9 Dec 2023 19:23:37 +0000 Subject: [PATCH 14/17] docs: update examples to show claude-2.1 (#276) --- README.md | 16 +++++------ examples/demo_async.py | 2 +- examples/demo_sync.py | 2 +- examples/streaming.py | 4 +-- src/anthropic/resources/completions.py | 28 +++++++++---------- .../types/completion_create_params.py | 4 +-- tests/api_resources/test_completions.py | 24 ++++++++-------- tests/test_client.py | 12 ++++---- 8 files changed, 46 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index d134e1af..749e9e4a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ anthropic = Anthropic( ) completion = anthropic.completions.create( - model="claude-2", + model="claude-2.1", max_tokens_to_sample=300, prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court?{AI_PROMPT}", ) @@ -122,7 +122,7 @@ anthropic = AsyncAnthropic( async def main(): completion = await anthropic.completions.create( - model="claude-2", + model="claude-2.1", max_tokens_to_sample=300, prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court?{AI_PROMPT}", ) @@ -146,7 +146,7 @@ anthropic = Anthropic() stream = anthropic.completions.create( prompt=f"{HUMAN_PROMPT} Your prompt here{AI_PROMPT}", max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", stream=True, ) for completion in stream: @@ -163,7 +163,7 @@ anthropic = AsyncAnthropic() stream = await anthropic.completions.create( prompt=f"{HUMAN_PROMPT} Your prompt here{AI_PROMPT}", max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", stream=True, ) async for completion in stream: @@ -206,7 +206,7 @@ try: client.completions.create( prompt=f"{anthropic.HUMAN_PROMPT} Your prompt here{anthropic.AI_PROMPT}", max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", ) except anthropic.APIConnectionError as e: print("The server could not be reached") @@ -253,7 +253,7 @@ anthropic = Anthropic( anthropic.with_options(max_retries=5).completions.create( prompt=f"{HUMAN_PROMPT} Can you help me effectively ask for a raise at work?{AI_PROMPT}", max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", ) ``` @@ -280,7 +280,7 @@ anthropic = Anthropic( anthropic.with_options(timeout=5 * 1000).completions.create( prompt=f"{HUMAN_PROMPT} Where can I get a good coffee in my neighbourhood?{AI_PROMPT}", max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", ) ``` @@ -338,7 +338,7 @@ from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT anthropic = Anthropic() response = anthropic.completions.with_raw_response.create( - model="claude-2", + model="claude-2.1", max_tokens_to_sample=300, prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court?{AI_PROMPT}", ) diff --git a/examples/demo_async.py b/examples/demo_async.py index e4ec78d0..49fd5191 100644 --- a/examples/demo_async.py +++ b/examples/demo_async.py @@ -10,7 +10,7 @@ async def main() -> None: client = AsyncAnthropic() res = await client.completions.create( - model="claude-2", + model="claude-2.1", prompt=f"{anthropic.HUMAN_PROMPT} how does a court case get to the Supreme Court? {anthropic.AI_PROMPT}", max_tokens_to_sample=1000, ) diff --git a/examples/demo_sync.py b/examples/demo_sync.py index 0c1c0084..e386e2ad 100644 --- a/examples/demo_sync.py +++ b/examples/demo_sync.py @@ -8,7 +8,7 @@ def main() -> None: client = Anthropic() res = client.completions.create( - model="claude-2", + model="claude-2.1", prompt=f"{anthropic.HUMAN_PROMPT} how does a court case get to the Supreme Court? {anthropic.AI_PROMPT}", max_tokens_to_sample=1000, ) diff --git a/examples/streaming.py b/examples/streaming.py index 2e6d85dd..a738aad9 100644 --- a/examples/streaming.py +++ b/examples/streaming.py @@ -15,7 +15,7 @@ def sync_stream() -> None: stream = client.completions.create( prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", - model="claude-2", + model="claude-2.1", stream=True, max_tokens_to_sample=300, ) @@ -29,7 +29,7 @@ def sync_stream() -> None: async def async_stream() -> None: stream = await async_client.completions.create( prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", - model="claude-2", + model="claude-2.1", stream=True, max_tokens_to_sample=300, ) diff --git a/src/anthropic/resources/completions.py b/src/anthropic/resources/completions.py index a603634d..15d3d153 100644 --- a/src/anthropic/resources/completions.py +++ b/src/anthropic/resources/completions.py @@ -33,7 +33,7 @@ def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, stop_sequences: List[str] | NotGiven = NOT_GIVEN, @@ -62,7 +62,7 @@ def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -125,7 +125,7 @@ def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, stream: Literal[True], metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, @@ -154,7 +154,7 @@ def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -217,7 +217,7 @@ def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, stream: bool, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, @@ -246,7 +246,7 @@ def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -309,7 +309,7 @@ def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, stop_sequences: List[str] | NotGiven = NOT_GIVEN, @@ -361,7 +361,7 @@ async def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, stop_sequences: List[str] | NotGiven = NOT_GIVEN, @@ -390,7 +390,7 @@ async def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -453,7 +453,7 @@ async def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, stream: Literal[True], metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, @@ -482,7 +482,7 @@ async def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -545,7 +545,7 @@ async def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, stream: bool, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, @@ -574,7 +574,7 @@ async def create( As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. @@ -637,7 +637,7 @@ async def create( self, *, max_tokens_to_sample: int, - model: Union[str, Literal["claude-2", "claude-instant-1"]], + model: Union[str, Literal["claude-2.1", "claude-instant-1"]], prompt: str, metadata: completion_create_params.Metadata | NotGiven = NOT_GIVEN, stop_sequences: List[str] | NotGiven = NOT_GIVEN, diff --git a/src/anthropic/types/completion_create_params.py b/src/anthropic/types/completion_create_params.py index 3dc40e37..ba1ebbe6 100644 --- a/src/anthropic/types/completion_create_params.py +++ b/src/anthropic/types/completion_create_params.py @@ -25,13 +25,13 @@ class CompletionCreateParamsBase(TypedDict, total=False): only specifies the absolute maximum number of tokens to generate. """ - model: Required[Union[str, Literal["claude-2", "claude-instant-1"]]] + model: Required[Union[str, Literal["claude-2.1", "claude-instant-1"]]] """The model that will complete your prompt. As we improve Claude, we develop new versions of it that you can query. This parameter controls which version of Claude answers your request. Right now we are offering two model families: Claude, and Claude Instant. You can use them by - setting `model` to `"claude-2"` or `"claude-instant-1"`, respectively. See + setting `model` to `"claude-2.1"` or `"claude-instant-1"`, respectively. See [models](https://docs.anthropic.com/claude/reference/selecting-a-model) for additional details. """ diff --git a/tests/api_resources/test_completions.py b/tests/api_resources/test_completions.py index bc152842..f96563c9 100644 --- a/tests/api_resources/test_completions.py +++ b/tests/api_resources/test_completions.py @@ -24,7 +24,7 @@ class TestCompletions: def test_method_create_overload_1(self, client: Anthropic) -> None: completion = client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", ) assert_matches_type(Completion, completion, path=["response"]) @@ -33,7 +33,7 @@ def test_method_create_overload_1(self, client: Anthropic) -> None: def test_method_create_with_all_params_overload_1(self, client: Anthropic) -> None: completion = client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, stop_sequences=["string", "string", "string"], @@ -48,7 +48,7 @@ def test_method_create_with_all_params_overload_1(self, client: Anthropic) -> No def test_raw_response_create_overload_1(self, client: Anthropic) -> None: response = client.completions.with_raw_response.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", ) assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -59,7 +59,7 @@ def test_raw_response_create_overload_1(self, client: Anthropic) -> None: def test_method_create_overload_2(self, client: Anthropic) -> None: client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, ) @@ -68,7 +68,7 @@ def test_method_create_overload_2(self, client: Anthropic) -> None: def test_method_create_with_all_params_overload_2(self, client: Anthropic) -> None: client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, @@ -82,7 +82,7 @@ def test_method_create_with_all_params_overload_2(self, client: Anthropic) -> No def test_raw_response_create_overload_2(self, client: Anthropic) -> None: response = client.completions.with_raw_response.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, ) @@ -99,7 +99,7 @@ class TestAsyncCompletions: async def test_method_create_overload_1(self, client: AsyncAnthropic) -> None: completion = await client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", ) assert_matches_type(Completion, completion, path=["response"]) @@ -108,7 +108,7 @@ async def test_method_create_overload_1(self, client: AsyncAnthropic) -> None: async def test_method_create_with_all_params_overload_1(self, client: AsyncAnthropic) -> None: completion = await client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, stop_sequences=["string", "string", "string"], @@ -123,7 +123,7 @@ async def test_method_create_with_all_params_overload_1(self, client: AsyncAnthr async def test_raw_response_create_overload_1(self, client: AsyncAnthropic) -> None: response = await client.completions.with_raw_response.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", ) assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -134,7 +134,7 @@ async def test_raw_response_create_overload_1(self, client: AsyncAnthropic) -> N async def test_method_create_overload_2(self, client: AsyncAnthropic) -> None: await client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, ) @@ -143,7 +143,7 @@ async def test_method_create_overload_2(self, client: AsyncAnthropic) -> None: async def test_method_create_with_all_params_overload_2(self, client: AsyncAnthropic) -> None: await client.completions.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, @@ -157,7 +157,7 @@ async def test_method_create_with_all_params_overload_2(self, client: AsyncAnthr async def test_raw_response_create_overload_2(self, client: AsyncAnthropic) -> None: response = await client.completions.with_raw_response.create( max_tokens_to_sample=256, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman: Hello, world!\n\nAssistant:", stream=True, ) diff --git a/tests/test_client.py b/tests/test_client.py index 33b5095d..0aba50cf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -799,7 +799,7 @@ def raise_for_status(response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, @@ -819,7 +819,7 @@ def raise_for_status(_response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, @@ -840,7 +840,7 @@ def raise_for_status(response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, @@ -1628,7 +1628,7 @@ def raise_for_status(response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, @@ -1648,7 +1648,7 @@ def raise_for_status(_response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, @@ -1669,7 +1669,7 @@ def raise_for_status(response: httpx.Response) -> None: "/v1/complete", body=dict( max_tokens_to_sample=300, - model="claude-2", + model="claude-2.1", prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", ), cast_to=httpx.Response, From 3611ae24d93fa33e55f2e9193a3c787bfd041da5 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Tue, 12 Dec 2023 14:52:14 +0000 Subject: [PATCH 15/17] refactor(client): simplify cleanup (#278) This removes Client.__del__, but users are not expected to call this directly. --- pyproject.toml | 2 +- src/anthropic/_base_client.py | 26 ++++++++++++++++++++------ src/anthropic/_client.py | 30 ++++-------------------------- tests/test_client.py | 23 ++--------------------- 4 files changed, 27 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c318644..cdf72d23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ typecheck = { chain = [ ]} "typecheck:pyright" = "pyright" "typecheck:verify-types" = "pyright --verifytypes anthropic --ignoreexternal" -"typecheck:mypy" = "mypy --enable-incomplete-feature=Unpack ." +"typecheck:mypy" = "mypy ." [build-system] requires = ["hatchling"] diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index bbbb8a54..04a20bfd 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -5,6 +5,7 @@ import time import uuid import email +import asyncio import inspect import logging import platform @@ -672,9 +673,16 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" +class SyncHttpxClientWrapper(httpx.Client): + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): _client: httpx.Client - _has_custom_http_client: bool _default_stream_cls: type[Stream[Any]] | None = None def __init__( @@ -747,7 +755,7 @@ def __init__( custom_headers=custom_headers, _strict_response_validation=_strict_response_validation, ) - self._client = http_client or httpx.Client( + self._client = http_client or SyncHttpxClientWrapper( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), @@ -755,7 +763,6 @@ def __init__( transport=transport, limits=limits, ) - self._has_custom_http_client = bool(http_client) def is_closed(self) -> bool: return self._client.is_closed @@ -1135,9 +1142,17 @@ def get_api_list( return self._request_api_list(model, page, opts) +class AsyncHttpxClientWrapper(httpx.AsyncClient): + def __del__(self) -> None: + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): _client: httpx.AsyncClient - _has_custom_http_client: bool _default_stream_cls: type[AsyncStream[Any]] | None = None def __init__( @@ -1210,7 +1225,7 @@ def __init__( custom_headers=custom_headers, _strict_response_validation=_strict_response_validation, ) - self._client = http_client or httpx.AsyncClient( + self._client = http_client or AsyncHttpxClientWrapper( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), @@ -1218,7 +1233,6 @@ def __init__( transport=transport, limits=limits, ) - self._has_custom_http_client = bool(http_client) def is_closed(self) -> bool: return self._client.is_closed diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 91900a8a..be2ac5ea 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import asyncio from typing import Any, Union, Mapping from typing_extensions import Self, override @@ -34,6 +33,8 @@ DEFAULT_MAX_RETRIES, SyncAPIClient, AsyncAPIClient, + SyncHttpxClientWrapper, + AsyncHttpxClientWrapper, ) __all__ = [ @@ -222,7 +223,7 @@ def copy( if http_client is not None: raise ValueError("The 'http_client' argument is mutually exclusive with 'connection_pool_limits'") - if self._has_custom_http_client: + if not isinstance(self._client, SyncHttpxClientWrapper): raise ValueError( "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" ) @@ -253,16 +254,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def __del__(self) -> None: - if not hasattr(self, "_has_custom_http_client") or not hasattr(self, "close"): - # this can happen if the '__init__' method raised an error - return - - if self._has_custom_http_client: - return - - self.close() - def count_tokens( self, text: str, @@ -483,7 +474,7 @@ def copy( if http_client is not None: raise ValueError("The 'http_client' argument is mutually exclusive with 'connection_pool_limits'") - if self._has_custom_http_client: + if not isinstance(self._client, AsyncHttpxClientWrapper): raise ValueError( "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" ) @@ -514,19 +505,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def __del__(self) -> None: - if not hasattr(self, "_has_custom_http_client") or not hasattr(self, "close"): - # this can happen if the '__init__' method raised an error - return - - if self._has_custom_http_client: - return - - try: - asyncio.get_running_loop().create_task(self.close()) - except Exception: - pass - async def count_tokens( self, text: str, diff --git a/tests/test_client.py b/tests/test_client.py index 0aba50cf..e6ad25b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -692,14 +692,6 @@ def test_proxies_option_mutually_exclusive_with_http_client(self) -> None: http_client=http_client, ) - def test_client_del(self) -> None: - client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() - - client.__del__() - - assert client.is_closed() - def test_copied_client_does_not_close_http(self) -> None: client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) assert not client.is_closed() @@ -707,9 +699,8 @@ def test_copied_client_does_not_close_http(self) -> None: copied = client.copy() assert copied is not client - copied.__del__() + del copied - assert not copied.is_closed() assert not client.is_closed() def test_client_context_manager(self) -> None: @@ -1515,15 +1506,6 @@ async def test_proxies_option_mutually_exclusive_with_http_client(self) -> None: http_client=http_client, ) - async def test_client_del(self) -> None: - client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() - - client.__del__() - - await asyncio.sleep(0.2) - assert client.is_closed() - async def test_copied_client_does_not_close_http(self) -> None: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) assert not client.is_closed() @@ -1531,10 +1513,9 @@ async def test_copied_client_does_not_close_http(self) -> None: copied = client.copy() assert copied is not client - copied.__del__() + del copied await asyncio.sleep(0.2) - assert not copied.is_closed() assert not client.is_closed() async def test_client_context_manager(self) -> None: From 993b51aa4f41bae3938a12d60919065c4865a734 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Tue, 12 Dec 2023 18:17:31 -0500 Subject: [PATCH 16/17] refactor: simplify internal error handling (#279) --- src/anthropic/_base_client.py | 102 +++++++-------- tests/test_client.py | 228 +++++++++------------------------- 2 files changed, 108 insertions(+), 222 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 04a20bfd..92189617 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -873,40 +873,25 @@ 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 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() + except httpx.TimeoutException as err: + if retries > 0: return self._retry_request( options, cast_to, retries, - err.response.headers, stream=stream, stream_cls=stream_cls, + response_headers=None, ) - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - 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() - + raise APITimeoutError(request=request) from err + except Exception as err: if retries > 0: return self._retry_request( options, @@ -914,25 +899,35 @@ def _request( retries, stream=stream, stream_cls=stream_cls, - response_headers=response.headers if response is not None else None, + response_headers=None, ) - raise APITimeoutError(request=request) from err - except Exception as err: - if response is not None: - response.close() + raise APIConnectionError(request=request) from err - if retries > 0: + log.debug( + 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase + ) + + try: + 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, retries, + err.response.headers, stream=stream, stream_cls=stream_cls, - response_headers=response.headers if response is not None else None, ) - raise APIConnectionError(request=request) from err + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + raise self._make_status_error_from_response(err.response) from None return self._process_response( cast_to=cast_to, @@ -1340,40 +1335,25 @@ 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 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() + except httpx.TimeoutException as err: + if retries > 0: return await self._retry_request( options, cast_to, retries, - err.response.headers, stream=stream, stream_cls=stream_cls, + response_headers=None, ) - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - await err.response.aread() - - raise self._make_status_error_from_response(err.response) from None - except httpx.TimeoutException as err: - if response is not None: - await response.aclose() - + raise APITimeoutError(request=request) from err + except Exception as err: if retries > 0: return await self._retry_request( options, @@ -1381,25 +1361,35 @@ async def _request( retries, stream=stream, stream_cls=stream_cls, - response_headers=response.headers if response is not None else None, + response_headers=None, ) - raise APITimeoutError(request=request) from err - except Exception as err: - if response is not None: - await response.aclose() + raise APIConnectionError(request=request) from err - if retries > 0: + log.debug( + 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase + ) + + try: + 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, retries, + err.response.headers, stream=stream, stream_cls=stream_cls, - response_headers=response.headers if response is not None else None, ) - raise APIConnectionError(request=request) from err + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + raise self._make_status_error_from_response(err.response) from None return self._process_response( cast_to=cast_to, diff --git a/tests/test_client.py b/tests/test_client.py index e6ad25b2..f474752c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,7 +24,6 @@ from anthropic._exceptions import ( APIStatusError, APITimeoutError, - APIConnectionError, APIResponseValidationError, ) from anthropic._base_client import ( @@ -46,14 +45,8 @@ 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 _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 def _get_open_connections(client: Anthropic | AsyncAnthropic) -> int: @@ -779,91 +772,43 @@ 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - cast_to=httpx.Response, - options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, - ) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/complete").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + self.client.post( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2.1", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) assert _get_open_connections(self.client) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @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 test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/complete").mock(return_value=httpx.Response(500)) - def on_response(response: httpx.Response) -> None: - raise httpx.HTTPStatusError( - "Simulating an error inside httpx", - response=response, - request=response.request, + with pytest.raises(APIStatusError): + self.client.post( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2.1", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, ) - client = Anthropic( - 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) + assert _get_open_connections(self.client) == 0 class TestAsyncAnthropic: @@ -1598,89 +1543,40 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - 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( - "/v1/complete", - body=dict( - max_tokens_to_sample=300, - model="claude-2.1", - prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", - ), - cast_to=httpx.Response, - options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, - ) + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/complete").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await self.client.post( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2.1", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, + ) assert _get_open_connections(self.client) == 0 + @mock.patch("anthropic._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @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"})) + async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/complete").mock(return_value=httpx.Response(500)) - def on_response(response: httpx.Response) -> None: - raise httpx.HTTPStatusError( - "Simulating an error inside httpx", - response=response, - request=response.request, + with pytest.raises(APIStatusError): + await self.client.post( + "/v1/complete", + body=dict( + max_tokens_to_sample=300, + model="claude-2.1", + prompt="\n\nHuman:Where can I get a good coffee in my neighbourhood?\n\nAssistant:", + ), + cast_to=httpx.Response, + options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}}, ) - client = AsyncAnthropic( - 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) + assert _get_open_connections(self.client) == 0 From d4b76a82e351140a7d4075e2a65b4a3b2dbb80bb Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Tue, 12 Dec 2023 18:17:51 -0500 Subject: [PATCH 17/17] release: 0.7.8 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/anthropic/_version.py | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7ded2be8..ef29836b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.7" + ".": "0.7.8" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b1709d72..52ff46e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 0.7.8 (2023-12-12) + +Full Changelog: [v0.7.7...v0.7.8](https://github.com/anthropics/anthropic-sdk-python/compare/v0.7.7...v0.7.8) + +### Bug Fixes + +* avoid leaking memory when Client.with_options is used ([#275](https://github.com/anthropics/anthropic-sdk-python/issues/275)) ([5e51ebd](https://github.com/anthropics/anthropic-sdk-python/commit/5e51ebdbc6e5c23c8c237b5e0231ef66f585f964)) +* **client:** correct base_url setter implementation ([#265](https://github.com/anthropics/anthropic-sdk-python/issues/265)) ([29d0c8b](https://github.com/anthropics/anthropic-sdk-python/commit/29d0c8b0eb174b499a904e02cce7fe7a6aaa1a01)) +* **client:** ensure retried requests are closed ([#261](https://github.com/anthropics/anthropic-sdk-python/issues/261)) ([5d9aa75](https://github.com/anthropics/anthropic-sdk-python/commit/5d9aa754ace5d53eb90c1055dd6b1ca8e7deee4f)) +* **errors:** properly assign APIError.body ([#274](https://github.com/anthropics/anthropic-sdk-python/issues/274)) ([342846f](https://github.com/anthropics/anthropic-sdk-python/commit/342846fa4d424a4d18dd2289d2b652bf53c97901)) + + +### Chores + +* **internal:** enable more lint rules ([#273](https://github.com/anthropics/anthropic-sdk-python/issues/273)) ([0ac62bc](https://github.com/anthropics/anthropic-sdk-python/commit/0ac62bc127ddf0367561427836ff19c1272fb0e1)) +* **internal:** reformat imports ([#270](https://github.com/anthropics/anthropic-sdk-python/issues/270)) ([dc55724](https://github.com/anthropics/anthropic-sdk-python/commit/dc55724673dfa59911a05fe4827b8804beba0b05)) +* **internal:** reformat imports ([#272](https://github.com/anthropics/anthropic-sdk-python/issues/272)) ([0d82ce4](https://github.com/anthropics/anthropic-sdk-python/commit/0d82ce4784c3a6c9599e6c09b8190e97ea028dc3)) +* **internal:** remove unused file ([#264](https://github.com/anthropics/anthropic-sdk-python/issues/264)) ([1bfc69b](https://github.com/anthropics/anthropic-sdk-python/commit/1bfc69b0e2a1eb79598409cbfcba060f699d28a7)) +* **internal:** replace string concatenation with f-strings ([#263](https://github.com/anthropics/anthropic-sdk-python/issues/263)) ([f545c35](https://github.com/anthropics/anthropic-sdk-python/commit/f545c350dd802079d057d34ff29444e32dc7bdcb)) +* **internal:** update formatting ([#271](https://github.com/anthropics/anthropic-sdk-python/issues/271)) ([802ab59](https://github.com/anthropics/anthropic-sdk-python/commit/802ab59401b06986b8023e9ef0d0f9e0d6858b86)) +* **package:** lift anyio v4 restriction ([#266](https://github.com/anthropics/anthropic-sdk-python/issues/266)) ([a217e99](https://github.com/anthropics/anthropic-sdk-python/commit/a217e9955569852d35ab1bc1351dd66ba807fc44)) + + +### Documentation + +* update examples to show claude-2.1 ([#276](https://github.com/anthropics/anthropic-sdk-python/issues/276)) ([8f562f4](https://github.com/anthropics/anthropic-sdk-python/commit/8f562f47f13ffaaab93f08b9b4c59d06e4a18b6c)) + + +### Refactors + +* **client:** simplify cleanup ([#278](https://github.com/anthropics/anthropic-sdk-python/issues/278)) ([3611ae2](https://github.com/anthropics/anthropic-sdk-python/commit/3611ae24d93fa33e55f2e9193a3c787bfd041da5)) +* simplify internal error handling ([#279](https://github.com/anthropics/anthropic-sdk-python/issues/279)) ([993b51a](https://github.com/anthropics/anthropic-sdk-python/commit/993b51aa4f41bae3938a12d60919065c4865a734)) + ## 0.7.7 (2023-11-29) Full Changelog: [v0.7.6...v0.7.7](https://github.com/anthropics/anthropic-sdk-python/compare/v0.7.6...v0.7.7) diff --git a/pyproject.toml b/pyproject.toml index cdf72d23..7539f3be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anthropic" -version = "0.7.7" +version = "0.7.8" description = "The official Python library for the anthropic API" readme = "README.md" license = "MIT" diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 4a703aca..d324b798 100644 --- a/src/anthropic/_version.py +++ b/src/anthropic/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. __title__ = "anthropic" -__version__ = "0.7.7" # x-release-please-version +__version__ = "0.7.8" # x-release-please-version