From 1f9825775d58ed8a62b000caaddd622ed4ba3fd2 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:21:34 -0500 Subject: [PATCH 01/19] docs: add some missing inline documentation (#151) --- src/anthropic/_base_client.py | 11 +++++++++++ src/anthropic/_utils/_transform.py | 4 ++++ src/anthropic/_utils/_utils.py | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 23f617f1..1c4944f6 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -132,6 +132,17 @@ def __init__( class BasePage(GenericModel, Generic[ModelT]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necesary information to make a request for the next page + """ + _options: FinalRequestOptions = PrivateAttr() _model: Type[ModelT] = PrivateAttr() diff --git a/src/anthropic/_utils/_transform.py b/src/anthropic/_utils/_transform.py index 9bb71090..c007d8b0 100644 --- a/src/anthropic/_utils/_transform.py +++ b/src/anthropic/_utils/_transform.py @@ -108,6 +108,10 @@ def _get_annoted_type(type_: type) -> type | None: def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertInfo` metadata. + """ annotated_type = _get_annoted_type(type_) if annotated_type is None: # no `Annotated` definition for this type, no transformation needed diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index dde4b457..603f7c10 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_utils/_utils.py @@ -27,6 +27,12 @@ def extract_files( *, paths: Sequence[Sequence[str]], ) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + Note: this mutates the given dictionary. + """ files: list[tuple[str, FileTypes]] = [] for path in paths: files.extend(_extract_items(query, path, index=0, flattened_key=None)) From 9cac11b8f5942efe0c28c682ca010ca9c75d6637 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 14 Sep 2023 04:36:57 -0500 Subject: [PATCH 02/19] ci: add workflow to handle release PR edit (#153) --- .../handle-release-pr-title-edit.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/handle-release-pr-title-edit.yml diff --git a/.github/workflows/handle-release-pr-title-edit.yml b/.github/workflows/handle-release-pr-title-edit.yml new file mode 100644 index 00000000..7f2784e9 --- /dev/null +++ b/.github/workflows/handle-release-pr-title-edit.yml @@ -0,0 +1,23 @@ +name: Handle release PR title edits +on: + pull_request: + types: + - edited + - unlabeled + +jobs: + update_pr_content: + name: Update pull request content + if: | + (github.event.type == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || + (github.event.type == 'unlabeled' && github.event.label.name == 'autorelease: custom version') && + github.event.pull_request.state == 'open' && + github.event.sender.login != 'stainless-bot' && + github.repository == 'anthropics/anthropic-sdk-python' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: stainless-api/trigger-release-please@v1 + with: + repo: ${{ github.event.repository.full_name }} + stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} From da6ccb10a38e862153871a540cb75af0afdaefb3 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 14 Sep 2023 11:16:43 -0500 Subject: [PATCH 03/19] fix(client): properly configure model set fields (#154) This means you can check if a field was included in the response by accessing `model_fields_set` in pydantic v2 and `__fields_set__` in v1. --- README.md | 18 ++++++++++++++++-- src/anthropic/_models.py | 11 +++++++++-- tests/test_models.py | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 771908d5..cd4f6d8b 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,21 @@ client = Anthropic( ) ``` -## Advanced: Configuring custom URLs, proxies, and transports +## Advanced + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly null, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Configuring custom URLs, proxies, and transports You can configure the following keyword arguments when instantiating the client: @@ -315,7 +329,7 @@ client = Anthropic( See the httpx documentation for information about the [`proxies`](https://www.python-httpx.org/advanced/#http-proxying) and [`transport`](https://www.python-httpx.org/advanced/#custom-transports) keyword arguments. -## Advanced: Managing HTTP resources +### Managing HTTP resources By default we will close the underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__) is called but you can also manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 296d36c0..7bbdca3b 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -49,6 +49,11 @@ class BaseModel(pydantic.BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") else: + @property + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = Extra.allow # type: ignore @@ -74,6 +79,9 @@ def construct( else config.get("populate_by_name") ) + if _fields_set is None: + _fields_set = set() + model_fields = get_model_fields(cls) for name, field in model_fields.items(): key = field.alias @@ -82,6 +90,7 @@ def construct( if key in values: fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) else: fields_values[name] = field_get_default(field) @@ -94,8 +103,6 @@ def construct( fields_values[key] = value object.__setattr__(m, "__dict__", fields_values) - if _fields_set is None: - _fields_set = set(fields_values.keys()) if PYDANTIC_V2: # these properties are copied from Pydantic's `model_construct()` method diff --git a/tests/test_models.py b/tests/test_models.py index 735bbfae..71eaf270 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -471,3 +471,17 @@ def model_id(self) -> str: assert m.model_id == "id" assert m.resource_id == "id" assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert "resource_id" in m.model_fields_set From 46386f8f60223f45bc133ddfcfda8d9ca9da26a8 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 14 Sep 2023 11:34:06 -0500 Subject: [PATCH 04/19] feat(client): retry on 408 Request Timeout (#155) --- README.md | 4 ++-- src/anthropic/_base_client.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd4f6d8b..e272a7a0 100644 --- a/README.md +++ b/README.md @@ -228,8 +228,8 @@ Error codes are as followed: ### Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. -Connection errors (for example, due to a network connectivity problem), 409 Conflict, 429 Rate Limit, -and >=500 Internal errors will all be retried by default. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors will all be retried by default. You can use the `max_retries` option to configure or disable this: diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 1c4944f6..e8a42612 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -644,6 +644,10 @@ def _should_retry(self, response: httpx.Response) -> bool: if should_retry_header == "false": return False + # Retry on request timeouts. + if response.status_code == 408: + return True + # Retry on lock timeouts. if response.status_code == 409: return True From 00f5a19c9393f6238759faea40405e60b2054da3 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:55:27 -0400 Subject: [PATCH 05/19] chore(internal): add helpers (#156) --- src/anthropic/__init__.py | 1 - src/anthropic/_base_client.py | 15 +++++++++ src/anthropic/_utils/__init__.py | 1 + src/anthropic/_utils/_proxy.py | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/anthropic/_utils/_proxy.py diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index d47dbb93..dd589f66 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -11,7 +11,6 @@ Transport, AsyncClient, AsyncStream, - ProxiesTypes, AsyncAnthropic, RequestOptions, ) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index e8a42612..f7d910e4 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -304,6 +304,8 @@ class BaseClient: max_retries: int timeout: Union[float, Timeout, None] _limits: httpx.Limits + _proxies: ProxiesTypes | None + _transport: Transport | None _strict_response_validation: bool _idempotency_header: str | None @@ -315,6 +317,8 @@ def __init__( max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None = DEFAULT_TIMEOUT, limits: httpx.Limits, + transport: Transport | None, + proxies: ProxiesTypes | None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: @@ -322,6 +326,8 @@ def __init__( self.max_retries = max_retries self.timeout = timeout self._limits = limits + self._proxies = proxies + self._transport = transport self._custom_headers = custom_headers or {} self._custom_query = custom_query or {} self._strict_response_validation = _strict_response_validation @@ -590,6 +596,11 @@ def user_agent(self) -> str: def base_url(self) -> URL: return self._client.base_url + @base_url.setter + def base_url(self, url: URL | str) -> None: + # mypy doesn't use the type from the setter + self._client.base_url = url # type: ignore[assignment] + @lru_cache(maxsize=None) def platform_headers(self) -> Dict[str, str]: return { @@ -689,6 +700,8 @@ def __init__( version=version, limits=limits, timeout=timeout, + proxies=proxies, + transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, @@ -1045,6 +1058,8 @@ def __init__( version=version, limits=limits, timeout=timeout, + proxies=proxies, + transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, diff --git a/src/anthropic/_utils/__init__.py b/src/anthropic/_utils/__init__.py index b45dc1b1..679193bd 100644 --- a/src/anthropic/_utils/__init__.py +++ b/src/anthropic/_utils/__init__.py @@ -1,3 +1,4 @@ +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 diff --git a/src/anthropic/_utils/_proxy.py b/src/anthropic/_utils/_proxy.py new file mode 100644 index 00000000..fd85ebd5 --- /dev/null +++ b/src/anthropic/_utils/_proxy.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import ClassVar + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and othe methods. + """ + + should_cache: ClassVar[bool] = False + + def __init__(self) -> None: + self.__proxied: T | None = None + + def __getattr__(self, attr: str) -> object: + return getattr(self.__get_proxied__(), attr) + + def __repr__(self) -> str: + return repr(self.__get_proxied__()) + + def __str__(self) -> str: + return str(self.__get_proxied__()) + + def __dir__(self) -> Iterable[str]: + return self.__get_proxied__().__dir__() + + @property # type: ignore + def __class__(self) -> type: + return self.__get_proxied__().__class__ + + def __get_proxied__(self) -> T: + if not self.should_cache: + return self.__load__() + + proxied = self.__proxied + if proxied is not None: + return proxied + + self.__proxied = proxied = self.__load__() + return proxied + + def __set_proxied__(self, value: T) -> None: + self.__proxied = value + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: + ... From 59fe4e33bd1af3940007c6eb935d21290dd4b59b Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 15 Sep 2023 06:14:09 -0400 Subject: [PATCH 06/19] ci: configure release PR title (#158) --- release-please-config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/release-please-config.json b/release-please-config.json index 25f5db1a..44a3718f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,6 +8,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", "changelog-sections": [ { "type": "feat", From e22f0e7bad7b14ef659d7a1918020ab826b8838b Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 15 Sep 2023 08:05:55 -0400 Subject: [PATCH 07/19] ci: fix handle-release-pr-title-edit workflow (#159) --- .github/workflows/handle-release-pr-title-edit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/handle-release-pr-title-edit.yml b/.github/workflows/handle-release-pr-title-edit.yml index 7f2784e9..e0015aa8 100644 --- a/.github/workflows/handle-release-pr-title-edit.yml +++ b/.github/workflows/handle-release-pr-title-edit.yml @@ -9,8 +9,8 @@ jobs: update_pr_content: name: Update pull request content if: | - (github.event.type == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || - (github.event.type == 'unlabeled' && github.event.label.name == 'autorelease: custom version') && + (github.event.action == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || + (github.event.action == 'unlabeled' && github.event.label.name == 'autorelease: custom version') && github.event.pull_request.state == 'open' && github.event.sender.login != 'stainless-bot' && github.repository == 'anthropics/anthropic-sdk-python' From 43544a62c8410061c1a50282f4c45d029db7779b Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 18 Sep 2023 22:30:34 -0400 Subject: [PATCH 08/19] feat(types): improve params type names (#160) --- .../types/completion_create_params.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/anthropic/types/completion_create_params.py b/src/anthropic/types/completion_create_params.py index 2022daa2..3dc40e37 100644 --- a/src/anthropic/types/completion_create_params.py +++ b/src/anthropic/types/completion_create_params.py @@ -8,10 +8,12 @@ __all__ = [ "CompletionRequestStreamingMetadata", "CompletionRequestNonStreamingMetadata", - "CompletionCreateParamsBase", - "Metadata", "CompletionRequestNonStreaming", "CompletionRequestStreaming", + "CompletionCreateParamsBase", + "Metadata", + "CompletionCreateParamsNonStreaming", + "CompletionCreateParamsStreaming", ] @@ -94,7 +96,7 @@ class Metadata(TypedDict, total=False): """ -class CompletionRequestNonStreaming(CompletionCreateParamsBase): +class CompletionCreateParamsNonStreaming(CompletionCreateParamsBase): stream: Literal[False] """Whether to incrementally stream the response using server-sent events. @@ -104,7 +106,7 @@ class CompletionRequestNonStreaming(CompletionCreateParamsBase): """ -class CompletionRequestStreaming(CompletionCreateParamsBase): +class CompletionCreateParamsStreaming(CompletionCreateParamsBase): stream: Required[Literal[True]] """Whether to incrementally stream the response using server-sent events. @@ -120,4 +122,10 @@ class CompletionRequestStreaming(CompletionCreateParamsBase): CompletionRequestNonStreamingMetadata = Metadata """This is deprecated, `Metadata` should be used instead""" -CompletionCreateParams = Union[CompletionRequestNonStreaming, CompletionRequestStreaming] +CompletionRequestNonStreaming = CompletionCreateParamsNonStreaming +"""This is deprecated, `CompletionCreateParamsNonStreaming` should be used instead""" + +CompletionRequestStreaming = CompletionCreateParamsStreaming +"""This is deprecated, `CompletionCreateParamsStreaming` should be used instead""" + +CompletionCreateParams = Union[CompletionCreateParamsNonStreaming, CompletionCreateParamsStreaming] From 76cfcf91172f9804056a7d5c1ec99666ad5991a2 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:51:42 -0400 Subject: [PATCH 09/19] fix(client): don't error by default for unexpected content types (#161) --- src/anthropic/_base_client.py | 14 +++++++++--- src/anthropic/_base_exceptions.py | 6 +++-- tests/test_client.py | 37 ++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index f7d910e4..fbfba66f 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -531,9 +531,17 @@ def _process_response( # in the response, e.g. application/json; charset=utf-8 content_type, *_ = response.headers.get("content-type").split(";") if content_type != "application/json": - raise ValueError( - f"Expected Content-Type response header to be `application/json` but received {content_type} instead." - ) + if self._strict_response_validation: + raise exceptions.APIResponseValidationError( + response=response, + request=response.request, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore data = response.json() return self._process_response_data(data=data, cast_to=cast_to, response=response) diff --git a/src/anthropic/_base_exceptions.py b/src/anthropic/_base_exceptions.py index aac00103..e2ba6aa4 100644 --- a/src/anthropic/_base_exceptions.py +++ b/src/anthropic/_base_exceptions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing_extensions import Literal from httpx import Request, Response @@ -17,8 +19,8 @@ class APIResponseValidationError(APIError): response: Response status_code: int - def __init__(self, request: Request, response: Response) -> None: - super().__init__("Data returned by API invalid for expected schema.", request) + def __init__(self, request: Request, response: Response, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", request) self.response = response self.status_code = response.status_code diff --git a/tests/test_client.py b/tests/test_client.py index 55db3be0..d6fae54c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,7 +12,7 @@ import pytest from respx import MockRouter -from anthropic import Anthropic, AsyncAnthropic +from anthropic import Anthropic, AsyncAnthropic, APIResponseValidationError from anthropic._types import Omit from anthropic._models import BaseModel, FinalRequestOptions from anthropic._streaming import Stream, AsyncStream @@ -383,6 +383,23 @@ class Model(BaseModel): response = self.client.post("/foo", cast_to=Model, stream=True) assert isinstance(response, Stream) + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + class TestAsyncAnthropic: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -740,3 +757,21 @@ class Model(BaseModel): response = await self.client.post("/foo", cast_to=Model, stream=True) assert isinstance(response, AsyncStream) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = await client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] From 329b307c205435d367c0d4b29b252be807c61c68 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 22 Sep 2023 18:27:45 -0400 Subject: [PATCH 10/19] =?UTF-8?q?chore(internal):=20move=20error=20classes?= =?UTF-8?q?=20from=20=5Fbase=5Fexceptions=20to=20=5Fexceptions=20(?= =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20breaking)=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Migration Guide If you were instantiating our error classes directly, you may no longer pass a `request` kwarg (it is now pulled from the `response`). ```diff # before: - BadRequestError("Test", response=response, request=request) # after: + BadRequestError("Test", response=response) ``` --- src/anthropic/__init__.py | 16 ++-- src/anthropic/_base_client.py | 56 ++++---------- src/anthropic/_base_exceptions.py | 119 ------------------------------ src/anthropic/_client.py | 67 ++++++++++++++++- src/anthropic/_exceptions.py | 118 +++++++++++++++++++++++------ src/anthropic/_streaming.py | 2 - tests/test_client.py | 27 +++++++ 7 files changed, 212 insertions(+), 193 deletions(-) delete mode 100644 src/anthropic/_base_exceptions.py diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index dd589f66..1994c9db 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -41,18 +41,18 @@ "Transport", "ProxiesTypes", "APIError", - "APIConnectionError", - "APIResponseValidationError", "APIStatusError", "APITimeoutError", - "AuthenticationError", + "APIConnectionError", + "APIResponseValidationError", "BadRequestError", - "ConflictError", - "InternalServerError", - "NotFoundError", + "AuthenticationError", "PermissionDeniedError", - "RateLimitError", + "NotFoundError", + "ConflictError", "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", "Timeout", "RequestOptions", "Client", @@ -69,7 +69,7 @@ # Update the __module__ attribute for exported symbols so that # error messages point to this module instead of the module # it was originally defined in, e.g. -# anthropic._base_exceptions.NotFoundError -> anthropic.NotFoundError +# anthropic._exceptions.NotFoundError -> anthropic.NotFoundError __locals = locals() for __name in __all__: if not __name.startswith("__"): diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index fbfba66f..852c3b6f 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -33,7 +33,7 @@ from httpx import URL, Limits from pydantic import PrivateAttr -from . import _base_exceptions as exceptions +from . import _exceptions from ._qs import Querystring from ._types import ( NOT_GIVEN, @@ -64,7 +64,7 @@ construct_type, ) from ._streaming import Stream, AsyncStream -from ._base_exceptions import ( +from ._exceptions import ( APIStatusError, APITimeoutError, APIConnectionError, @@ -335,7 +335,6 @@ def __init__( def _make_status_error_from_response( self, - request: httpx.Request, response: httpx.Response, ) -> APIStatusError: err_text = response.text.strip() @@ -347,33 +346,16 @@ def _make_status_error_from_response( except Exception: err_msg = err_text or f"Error code: {response.status_code}" - return self._make_status_error(err_msg, body=body, request=request, response=response) + return self._make_status_error(err_msg, body=body, response=response) def _make_status_error( self, err_msg: str, *, body: object, - request: httpx.Request, response: httpx.Response, - ) -> APIStatusError: - if response.status_code == 400: - return exceptions.BadRequestError(err_msg, request=request, response=response, body=body) - if response.status_code == 401: - return exceptions.AuthenticationError(err_msg, request=request, response=response, body=body) - if response.status_code == 403: - return exceptions.PermissionDeniedError(err_msg, request=request, response=response, body=body) - if response.status_code == 404: - return exceptions.NotFoundError(err_msg, request=request, response=response, body=body) - if response.status_code == 409: - return exceptions.ConflictError(err_msg, request=request, response=response, body=body) - if response.status_code == 422: - return exceptions.UnprocessableEntityError(err_msg, request=request, response=response, body=body) - if response.status_code == 429: - return exceptions.RateLimitError(err_msg, request=request, response=response, body=body) - if response.status_code >= 500: - return exceptions.InternalServerError(err_msg, request=request, response=response, body=body) - return APIStatusError(err_msg, request=request, response=response, body=body) + ) -> _exceptions.APIStatusError: + raise NotImplementedError() def _remaining_retries( self, @@ -532,10 +514,10 @@ def _process_response( content_type, *_ = response.headers.get("content-type").split(";") if content_type != "application/json": if self._strict_response_validation: - raise exceptions.APIResponseValidationError( + raise APIResponseValidationError( response=response, - request=response.request, message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, ) # If the API responds with content that isn't JSON then we just return @@ -544,7 +526,11 @@ def _process_response( return response.text # type: ignore data = response.json() - return self._process_response_data(data=data, cast_to=cast_to, response=response) + + try: + return self._process_response_data(data=data, cast_to=cast_to, response=response) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err def _process_response_data( self, @@ -826,7 +812,7 @@ 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() - raise self._make_status_error_from_response(request, err.response) from None + raise self._make_status_error_from_response(err.response) from None except httpx.TimeoutException as err: if retries > 0: return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) @@ -845,12 +831,7 @@ def _request( raise MissingStreamClassError() return stream_cls(cast_to=cast_to, response=response, client=self) - try: - rsp = self._process_response(cast_to=cast_to, options=options, response=response) - except pydantic.ValidationError as err: - raise APIResponseValidationError(request=request, response=response) from err - - return rsp + return self._process_response(cast_to=cast_to, options=options, response=response) def _retry_request( self, @@ -1184,7 +1165,7 @@ 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() - raise self._make_status_error_from_response(request, err.response) from None + 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) @@ -1213,12 +1194,7 @@ async def _request( raise MissingStreamClassError() return stream_cls(cast_to=cast_to, response=response, client=self) - try: - rsp = self._process_response(cast_to=cast_to, options=options, response=response) - except pydantic.ValidationError as err: - raise APIResponseValidationError(request=request, response=response) from err - - return rsp + return self._process_response(cast_to=cast_to, options=options, response=response) async def _retry_request( self, diff --git a/src/anthropic/_base_exceptions.py b/src/anthropic/_base_exceptions.py deleted file mode 100644 index e2ba6aa4..00000000 --- a/src/anthropic/_base_exceptions.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -from typing_extensions import Literal - -from httpx import Request, Response - - -class APIError(Exception): - message: str - request: Request - - def __init__(self, message: str, request: Request) -> None: - super().__init__(message) - self.request = request - self.message = message - - -class APIResponseValidationError(APIError): - response: Response - status_code: int - - def __init__(self, request: Request, response: Response, *, message: str | None = None) -> None: - super().__init__(message or "Data returned by API invalid for expected schema.", request) - self.response = response - self.status_code = response.status_code - - -class APIStatusError(APIError): - """Raised when an API response has a status code of 4xx or 5xx.""" - - response: Response - status_code: int - - body: object - """The API response body. - - If the API responded with a valid JSON structure then this property will be the decoded result. - If it isn't a valid JSON structure then this will be the raw response. - """ - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request) - self.response = response - self.status_code = response.status_code - self.body = body - - -class BadRequestError(APIStatusError): - status_code: Literal[400] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 400 - - -class AuthenticationError(APIStatusError): - status_code: Literal[401] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 401 - - -class PermissionDeniedError(APIStatusError): - status_code: Literal[403] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 403 - - -class NotFoundError(APIStatusError): - status_code: Literal[404] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 404 - - -class ConflictError(APIStatusError): - status_code: Literal[409] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 409 - - -class UnprocessableEntityError(APIStatusError): - status_code: Literal[422] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 422 - - -class RateLimitError(APIStatusError): - status_code: Literal[429] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 429 - - -class InternalServerError(APIStatusError): - status_code: int - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = response.status_code - - -class APIConnectionError(APIError): - def __init__(self, request: Request, message: str = "Connection error.") -> None: - super().__init__(message, request) - - -class APITimeoutError(APIConnectionError): - def __init__(self, request: Request) -> None: - super().__init__(request, "Request timed out.") diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 5f9cec44..13cbe85d 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -9,7 +9,7 @@ import httpx from tokenizers import Tokenizer # type: ignore[import] -from . import resources, _constants +from . import resources, _constants, _exceptions from ._qs import Querystring from ._types import ( NOT_GIVEN, @@ -24,6 +24,7 @@ from ._version import __version__ 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 ( DEFAULT_LIMITS, @@ -236,6 +237,38 @@ def count_tokens( def get_tokenizer(self) -> Tokenizer: return sync_get_tokenizer() + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + class AsyncAnthropic(AsyncAPIClient): completions: resources.AsyncCompletions @@ -430,6 +463,38 @@ async def count_tokens( async def get_tokenizer(self) -> Tokenizer: return await async_get_tokenizer() + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + Client = Anthropic diff --git a/src/anthropic/_exceptions.py b/src/anthropic/_exceptions.py index ffdf6a23..cfcd58af 100644 --- a/src/anthropic/_exceptions.py +++ b/src/anthropic/_exceptions.py @@ -1,31 +1,103 @@ # File generated from our OpenAPI spec by Stainless. -from ._base_exceptions import APIError as APIError -from ._base_exceptions import ConflictError as ConflictError -from ._base_exceptions import NotFoundError as NotFoundError -from ._base_exceptions import APIStatusError as APIStatusError -from ._base_exceptions import RateLimitError as RateLimitError -from ._base_exceptions import APITimeoutError as APITimeoutError -from ._base_exceptions import BadRequestError as BadRequestError -from ._base_exceptions import APIConnectionError as APIConnectionError -from ._base_exceptions import AuthenticationError as AuthenticationError -from ._base_exceptions import InternalServerError as InternalServerError -from ._base_exceptions import PermissionDeniedError as PermissionDeniedError -from ._base_exceptions import UnprocessableEntityError as UnprocessableEntityError -from ._base_exceptions import APIResponseValidationError as APIResponseValidationError +from __future__ import annotations + +from typing_extensions import Literal + +import httpx __all__ = [ - "APIError", - "APIConnectionError", - "APIResponseValidationError", - "APIStatusError", - "APITimeoutError", - "AuthenticationError", "BadRequestError", - "ConflictError", - "InternalServerError", - "NotFoundError", + "AuthenticationError", "PermissionDeniedError", - "RateLimitError", + "NotFoundError", + "ConflictError", "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", ] + + +class APIError(Exception): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: + super().__init__(message) + self.request = request + self.message = message + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 + + +class InternalServerError(APIStatusError): + pass diff --git a/src/anthropic/_streaming.py b/src/anthropic/_streaming.py index cf074893..510e290b 100644 --- a/src/anthropic/_streaming.py +++ b/src/anthropic/_streaming.py @@ -65,7 +65,6 @@ def __stream__(self) -> Iterator[ResponseT]: err_msg, body=body, response=self.response, - request=self.response.request, ) @@ -123,7 +122,6 @@ async def __stream__(self) -> AsyncIterator[ResponseT]: err_msg, body=body, response=self.response, - request=self.response.request, ) diff --git a/tests/test_client.py b/tests/test_client.py index d6fae54c..8fab6b6d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,11 +11,13 @@ import httpx import pytest from respx import MockRouter +from pydantic import ValidationError from anthropic import Anthropic, AsyncAnthropic, APIResponseValidationError from anthropic._types import Omit from anthropic._models import BaseModel, FinalRequestOptions from anthropic._streaming import Stream, AsyncStream +from anthropic._exceptions import APIResponseValidationError from anthropic._base_client import BaseClient, make_request_options base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") @@ -373,6 +375,18 @@ def test_client_context_manager(self) -> None: assert not client.is_closed() assert client.is_closed() + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + @pytest.mark.respx(base_url=base_url) def test_default_stream_cls(self, respx_mock: MockRouter) -> None: class Model(BaseModel): @@ -747,6 +761,19 @@ async def test_client_context_manager(self) -> None: assert not client.is_closed() assert client.is_closed() + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + @pytest.mark.respx(base_url=base_url) @pytest.mark.asyncio async def test_default_stream_cls(self, respx_mock: MockRouter) -> None: From e7aa3e7785ae511fa35a68ac72079a6230ca84f3 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 25 Sep 2023 09:59:25 -0400 Subject: [PATCH 11/19] feat(package): export a root error type (#163) --- src/anthropic/__init__.py | 2 ++ src/anthropic/_exceptions.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 1994c9db..3eac9fc7 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -21,6 +21,7 @@ APIError, ConflictError, NotFoundError, + AnthropicError, APIStatusError, RateLimitError, APITimeoutError, @@ -40,6 +41,7 @@ "NoneType", "Transport", "ProxiesTypes", + "AnthropicError", "APIError", "APIStatusError", "APITimeoutError", diff --git a/src/anthropic/_exceptions.py b/src/anthropic/_exceptions.py index cfcd58af..aef58337 100644 --- a/src/anthropic/_exceptions.py +++ b/src/anthropic/_exceptions.py @@ -18,7 +18,11 @@ ] -class APIError(Exception): +class AnthropicError(Exception): + pass + + +class APIError(AnthropicError): message: str request: httpx.Request From 8042473bd73faa0b819c27a68bfc19b918361461 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 27 Sep 2023 04:54:45 +0100 Subject: [PATCH 12/19] chore(tests): improve raw response test (#166) --- tests/test_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 8fab6b6d..fdd1e323 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,6 +33,15 @@ def _get_params(client: BaseClient) -> dict[str, str]: class TestAnthropic: client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + def test_copy(self) -> None: copied = self.client.copy() assert id(copied) != id(self.client) @@ -418,6 +427,16 @@ class Model(BaseModel): class TestAsyncAnthropic: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + def test_copy(self) -> None: copied = self.client.copy() assert id(copied) != id(self.client) From bb7d1297ae6cf7a42393814d5d455eb915603058 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 2 Oct 2023 11:51:45 +0100 Subject: [PATCH 13/19] ci: only run workflow handle-release-pr-title-edit for release PRs (#167) --- .github/workflows/handle-release-pr-title-edit.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/handle-release-pr-title-edit.yml b/.github/workflows/handle-release-pr-title-edit.yml index e0015aa8..aadbe7fa 100644 --- a/.github/workflows/handle-release-pr-title-edit.yml +++ b/.github/workflows/handle-release-pr-title-edit.yml @@ -9,8 +9,9 @@ jobs: update_pr_content: name: Update pull request content if: | - (github.event.action == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || - (github.event.action == 'unlabeled' && github.event.label.name == 'autorelease: custom version') && + ((github.event.action == 'edited' && github.event.changes.title.from != github.event.pull_request.title) || + (github.event.action == 'unlabeled' && github.event.label.name == 'autorelease: custom version')) && + startsWith(github.event.pull_request.head.ref, 'release-please--') && github.event.pull_request.state == 'open' && github.event.sender.login != 'stainless-bot' && github.repository == 'anthropics/anthropic-sdk-python' From afeabf13aa5795a7fadd141e53ec81eadbce099a Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:37:37 -0400 Subject: [PATCH 14/19] feat(client): handle retry-after header with a date format (#168) --- src/anthropic/_base_client.py | 18 +++++++++-- tests/test_client.py | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 852c3b6f..e3a27338 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -3,8 +3,10 @@ import json import time import uuid +import email import inspect import platform +import email.utils from types import TracebackType from random import random from typing import ( @@ -616,10 +618,22 @@ def _calculate_retry_timeout( try: # About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After # - # TODO: we may want to handle the case where the header is using the http-date syntax: "Retry-After: # ". See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax for # details. - retry_after = -1 if response_headers is None else int(response_headers.get("retry-after")) + if response_headers is not None: + retry_header = response_headers.get("retry-after") + try: + retry_after = int(retry_header) + except Exception: + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + retry_after = -1 + else: + retry_date = email.utils.mktime_tz(retry_date_tuple) + retry_after = int(retry_date - time.time()) + else: + retry_after = -1 + except Exception: retry_after = -1 diff --git a/tests/test_client.py b/tests/test_client.py index fdd1e323..4b38d3ba 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ import asyncio import inspect from typing import Any, Dict, Union, cast +from unittest import mock import httpx import pytest @@ -423,6 +424,33 @@ class Model(BaseModel): response = client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 2], + [3, "-10", 2], + [3, "60", 60], + [3, "61", 2], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 2], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 2], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 2], + [3, "99999999999999999999999999999999999", 2], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 2], + [3, "", 2], + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=2) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.6) # pyright: ignore[reportUnknownMemberType] + class TestAsyncAnthropic: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -821,3 +849,31 @@ class Model(BaseModel): response = await client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 2], + [3, "-10", 2], + [3, "60", 60], + [3, "61", 2], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 2], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 2], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 2], + [3, "99999999999999999999999999999999999", 2], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 2], + [3, "", 2], + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + @pytest.mark.asyncio + async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=2) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.6) # pyright: ignore[reportUnknownMemberType] From b472605ea93ba82bf291cd5b754ea808a02e2df9 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:46:17 -0400 Subject: [PATCH 15/19] test: rename `API_BASE_URL` to `TEST_API_BASE_URL` (#169) --- bin/check-test-server | 4 ++-- tests/api_resources/test_completions.py | 2 +- tests/api_resources/test_top_level.py | 2 +- tests/test_client.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/check-test-server b/bin/check-test-server index 34efa9da..a6fa3495 100755 --- a/bin/check-test-server +++ b/bin/check-test-server @@ -10,13 +10,13 @@ function prism_is_running() { } function is_overriding_api_base_url() { - [ -n "$API_BASE_URL" ] + [ -n "$TEST_API_BASE_URL" ] } if is_overriding_api_base_url ; then # If someone is running the tests against the live API, we can trust they know # what they're doing and exit early. - echo -e "${GREEN}✔ Running tests against ${API_BASE_URL}${NC}" + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" exit 0 elif prism_is_running ; then diff --git a/tests/api_resources/test_completions.py b/tests/api_resources/test_completions.py index d0981ab6..8bf12c45 100644 --- a/tests/api_resources/test_completions.py +++ b/tests/api_resources/test_completions.py @@ -10,7 +10,7 @@ from tests.utils import assert_matches_type from anthropic.types import Completion -base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = os.environ.get("API_KEY", "something1234") diff --git a/tests/api_resources/test_top_level.py b/tests/api_resources/test_top_level.py index 0283fa80..ff25b467 100644 --- a/tests/api_resources/test_top_level.py +++ b/tests/api_resources/test_top_level.py @@ -8,7 +8,7 @@ from anthropic import Anthropic, AsyncAnthropic -base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = os.environ.get("API_KEY", "something1234") diff --git a/tests/test_client.py b/tests/test_client.py index 4b38d3ba..c0d4bf03 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,7 +21,7 @@ from anthropic._exceptions import APIResponseValidationError from anthropic._base_client import BaseClient, make_request_options -base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = os.environ.get("API_KEY", "something1234") From 4c5289eb8519ca9a53e9483422237aa25944f8d8 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 9 Oct 2023 06:59:56 -0400 Subject: [PATCH 16/19] feat(client): add forwards-compatible pydantic methods (#171) If you're still using Pydantic v1 then we've added aliases for these methods introduced in Pydantic v2: - model_dump - model_dump_json --- noxfile.py | 2 +- src/anthropic/_models.py | 102 ++++++++++++++++++++++++++++++++++++++- src/anthropic/_types.py | 6 ++- tests/test_models.py | 75 ++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index bac3c5c8..669f6af7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,4 +9,4 @@ def test_pydantic_v1(session: nox.Session) -> None: # https://github.com/cjolowicz/nox-poetry/issues/1116 session._session.run("python", "-m", "pip", "install", "pydantic<2", external=True) # type: ignore - session.run("pytest", "--showlocals", "--ignore=tests/functional") + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 7bbdca3b..5568ee97 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -3,7 +3,7 @@ import inspect from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, cast from datetime import date, datetime -from typing_extensions import ClassVar, Protocol, final, runtime_checkable +from typing_extensions import Literal, ClassVar, Protocol, final, runtime_checkable import pydantic import pydantic.generics @@ -12,6 +12,7 @@ from ._types import ( Body, + IncEx, Query, ModelT, Headers, @@ -124,6 +125,105 @@ def construct( # although not in practice model_construct = construct + if not PYDANTIC_V2: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specifc pydantic version as some users may not know which + # pydantic version they are currently using + + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx = None, + exclude: IncEx = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool = True, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the dictionary will only contain JSON serializable types. + If mode is 'python', the dictionary may contain any Python objects. + include: A list of fields to include in the output. + exclude: A list of fields to exclude from the output. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that are unset or None from the output. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + round_trip: Whether to enable serialization and deserialization round-trip support. + warnings: Whether to log warnings when invalid fields are encountered. + + Returns: + A dictionary representation of the model. + """ + if mode != "python": + raise ValueError("mode is only supported in Pydantic v2") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + return super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + def model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx = None, + exclude: IncEx = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool = True, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: diff --git a/src/anthropic/_types.py b/src/anthropic/_types.py index 7ed4bc8a..653cd1ab 100644 --- a/src/anthropic/_types.py +++ b/src/anthropic/_types.py @@ -14,7 +14,7 @@ Optional, Sequence, ) -from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, runtime_checkable import httpx import pydantic @@ -157,3 +157,7 @@ def get(self, __key: str) -> str | None: ) StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/32ea570bf96e84234d2992e1ddf40ab8a565925a/pydantic/main.py#L49 +IncEx: TypeAlias = "set[int] | set[str] | dict[int, Any] | dict[str, Any] | None" diff --git a/tests/test_models.py b/tests/test_models.py index 71eaf270..9173a022 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,10 @@ +import json from typing import Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal import pytest +import pydantic from pydantic import Field from anthropic._compat import PYDANTIC_V2, parse_obj, model_dump, model_json @@ -485,3 +487,76 @@ class Model(BaseModel): m = Model.construct(resource_id="foo") assert "resource_id" in m.model_fields_set + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"): + m.model_dump(mode="json") + + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) From 351095b189b111a74e9e1825ce5b6da6673a1635 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 11 Oct 2023 06:08:15 -0400 Subject: [PATCH 17/19] docs: update readme (#172) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e272a7a0..f7f77f8c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ The Anthropic Python library provides convenient access to the Anthropic REST AP application. It includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). +For the AWS Bedrock API, see [`anthropic-bedrock`](github.com/anthropics/anthropic-bedrock-python). + ## Migration from v0.2.x and below In `v0.3.0`, we introduced a fully rewritten SDK. From 25046c4fbc6f9d343e3b1f21024cf3982ac48c35 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 11 Oct 2023 06:47:47 -0400 Subject: [PATCH 18/19] feat(client): add support for passing in a httpx client (#173) --- README.md | 51 ++-- src/anthropic/_base_client.py | 177 ++++++++++++-- src/anthropic/_client.py | 97 ++++++-- src/anthropic/_types.py | 5 +- tests/test_client.py | 438 ++++++++++++++++++++++++++++++++-- 5 files changed, 678 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index f7f77f8c..aa67dc20 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Anthropic Python API Library +# Anthropic Python API library [![PyPI version](https://img.shields.io/pypi/v/anthropic.svg)](https://pypi.org/project/anthropic/) @@ -74,7 +74,7 @@ as v0.3 sends the `Anthropic-Version: 2023-06-01` header. ## Documentation -The API documentation can be found [here](https://docs.anthropic.com/claude/reference/). +The API documentation can be found at [https://docs.anthropic.com/claude/reference/](https://docs.anthropic.com/claude/reference/). ## Installation @@ -105,7 +105,7 @@ print(completion.completion) While you can provide an `api_key` keyword argument, we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) and adding `ANTHROPIC_API_KEY="my api key"` to your `.env` file so that your API Key is not stored in source control. -## Async Usage +## Async usage Simply import `AsyncAnthropic` instead of `Anthropic` and use `await` with each API call: @@ -168,11 +168,11 @@ async for completion in stream: print(completion.completion, end="", flush=True) ``` -## Using Types +## Using types -Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev), which provide helper methods for things like serializing back into json ([v1](https://docs.pydantic.dev/1.10/usage/models/), [v2](https://docs.pydantic.dev/latest/usage/serialization/)). To get a dictionary, you can call `dict(model)`. +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev), which provide helper methods for things like serializing back into JSON ([v1](https://docs.pydantic.dev/1.10/usage/models/), [v2](https://docs.pydantic.dev/latest/usage/serialization/)). To get a dictionary, call `dict(model)`. -This helps provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `"basic"`. +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. ## Token counting @@ -185,10 +185,10 @@ client.count_tokens('Hello world!') # 3 ## Handling errors -When the library is unable to connect to the API (e.g., due to network connection problems or a timeout), a subclass of `anthropic.APIConnectionError` is raised. +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `anthropic.APIConnectionError` is raised. -When the API returns a non-success status code (i.e., 4xx or 5xx -response), a subclass of `anthropic.APIStatusError` will be raised, containing `status_code` and `response` properties. +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `anthropic.APIStatusError` is raised, containing `status_code` and `response` properties. All errors inherit from `anthropic.APIError`. @@ -229,11 +229,11 @@ Error codes are as followed: ### Retries -Certain errors will be automatically retried 2 times by default, with a short exponential backoff. +Certain errors are automatically retried 2 times by default, with a short exponential backoff. Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, -429 Rate Limit, and >=500 Internal errors will all be retried by default. +429 Rate Limit, and >=500 Internal errors are all retried by default. -You can use the `max_retries` option to configure or disable this: +You can use the `max_retries` option to configure or disable retry settings: ```python from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT @@ -254,8 +254,8 @@ anthropic.with_options(max_retries=5).completions.create( ### Timeouts -Requests time out after 10 minutes by default. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration): +By default requests time out after 10 minutes. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: ```python from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT @@ -281,7 +281,7 @@ anthropic.with_options(timeout=5 * 1000).completions.create( On timeout, an `APITimeoutError` is thrown. -Note that requests which time out will be [retried twice by default](#retries). +Note that requests that time out are [retried twice by default](#retries). ## Default Headers @@ -303,7 +303,7 @@ client = Anthropic( ### How to tell whether `None` means `null` or missing -In an API response, a field may be explicitly null, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: ```py if response.my_field is None: @@ -313,27 +313,30 @@ if response.my_field is None: print('Got json like {"my_field": null}.') ``` -### Configuring custom URLs, proxies, and transports +### Configuring the HTTP client -You can configure the following keyword arguments when instantiating the client: +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for proxies +- Custom transports +- Additional [advanced](https://www.python-httpx.org/advanced/#client-instances) functionality ```python import httpx from anthropic import Anthropic client = Anthropic( - # Use a custom base URL base_url="http://my.test.server.example.com:8083", - proxies="http://my.test.proxy.example.com", - transport=httpx.HTTPTransport(local_address="0.0.0.0"), + http_client=httpx.Client( + proxies="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), ) ``` -See the httpx documentation for information about the [`proxies`](https://www.python-httpx.org/advanced/#http-proxying) and [`transport`](https://www.python-httpx.org/advanced/#custom-transports) keyword arguments. - ### Managing HTTP resources -By default we will close the underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__) is called but you can also manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. ## Versioning diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index e3a27338..304bea79 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -6,10 +6,12 @@ import email import inspect import platform +import warnings import email.utils from types import TracebackType from random import random from typing import ( + TYPE_CHECKING, Any, Dict, Type, @@ -52,11 +54,12 @@ AnyMapping, ProxiesTypes, RequestFiles, + AsyncTransport, RequestOptions, UnknownResponse, ModelBuilderProtocol, ) -from ._utils import is_dict, is_mapping +from ._utils import is_dict, is_given, is_mapping from ._compat import model_copy from ._models import ( BaseModel, @@ -84,6 +87,15 @@ _StreamT = TypeVar("_StreamT", bound=Stream[Any]) _AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) +if TYPE_CHECKING: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + # default timeout is 10 minutes DEFAULT_TIMEOUT = Timeout(timeout=600.0, connect=5.0) @@ -303,11 +315,12 @@ async def get_next_page(self: AsyncPageT) -> AsyncPageT: class BaseClient: _client: httpx.Client | httpx.AsyncClient _version: str + _base_url: URL max_retries: int timeout: Union[float, Timeout, None] _limits: httpx.Limits _proxies: ProxiesTypes | None - _transport: Transport | None + _transport: Transport | AsyncTransport | None _strict_response_validation: bool _idempotency_header: str | None @@ -315,16 +328,18 @@ def __init__( self, *, version: str, + base_url: str, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None = DEFAULT_TIMEOUT, limits: httpx.Limits, - transport: Transport | None, + transport: Transport | AsyncTransport | None, proxies: ProxiesTypes | None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) self.max_retries = max_retries self.timeout = timeout self._limits = limits @@ -335,6 +350,11 @@ def __init__( self._strict_response_validation = _strict_response_validation self._idempotency_header = None + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + def _make_status_error_from_response( self, response: httpx.Response, @@ -391,6 +411,19 @@ def _prepare_request(self, request: httpx.Request) -> None: """ return None + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + def _build_request( self, options: FinalRequestOptions, @@ -432,7 +465,7 @@ def _build_request( headers=headers, timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, method=options.method, - url=options.url, + url=self._prepare_url(options.url), # the `Query` type that we use is incompatible with qs' # `Params` type as it needs to be typed as `Mapping[str, object]` # so that passing a `TypedDict` doesn't cause an error. @@ -570,6 +603,7 @@ def auth_headers(self) -> dict[str, str]: @property def default_headers(self) -> dict[str, str | Omit]: return { + "Accept": "application/json", "Content-Type": "application/json", "User-Agent": self.user_agent, **self.platform_headers(), @@ -590,12 +624,11 @@ def user_agent(self) -> str: @property def base_url(self) -> URL: - return self._client.base_url + return self._base_url @base_url.setter def base_url(self, url: URL | str) -> None: - # mypy doesn't use the type from the setter - self._client.base_url = url # type: ignore[assignment] + self._client.base_url = url if isinstance(url, URL) else URL(url) @lru_cache(maxsize=None) def platform_headers(self) -> Dict[str, str]: @@ -687,6 +720,7 @@ def _idempotency_key(self) -> str: class SyncAPIClient(BaseClient): _client: httpx.Client + _has_custom_http_client: bool _default_stream_cls: type[Stream[Any]] | None = None def __init__( @@ -695,34 +729,79 @@ def __init__( version: str, base_url: str, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None = DEFAULT_TIMEOUT, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, transport: Transport | None = None, proxies: ProxiesTypes | None = None, - limits: Limits | None = DEFAULT_LIMITS, + limits: Limits | None = None, + http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, _strict_response_validation: bool, ) -> None: - limits = limits or DEFAULT_LIMITS + if limits is not None: + warnings.warn( + "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") + else: + limits = DEFAULT_LIMITS + + if transport is not None: + warnings.warn( + "The `transport` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `transport`") + + if proxies is not None: + warnings.warn( + "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") + + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + super().__init__( version=version, limits=limits, - timeout=timeout, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), proxies=proxies, + base_url=base_url, transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, _strict_response_validation=_strict_response_validation, ) - self._client = httpx.Client( + self._client = http_client or httpx.Client( base_url=base_url, - timeout=timeout, - proxies=proxies, # type: ignore - transport=transport, # type: ignore + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + proxies=proxies, + transport=transport, limits=limits, - headers={"Accept": "application/json"}, ) + self._has_custom_http_client = bool(http_client) def is_closed(self) -> bool: return self._client.is_closed @@ -1040,6 +1119,7 @@ def get_api_list( class AsyncAPIClient(BaseClient): _client: httpx.AsyncClient + _has_custom_http_client: bool _default_stream_cls: type[AsyncStream[Any]] | None = None def __init__( @@ -1049,18 +1129,62 @@ def __init__( base_url: str, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None = DEFAULT_TIMEOUT, - transport: Transport | None = None, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + transport: AsyncTransport | None = None, proxies: ProxiesTypes | None = None, - limits: Limits | None = DEFAULT_LIMITS, + limits: Limits | None = None, + http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: - limits = limits or DEFAULT_LIMITS + if limits is not None: + warnings.warn( + "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") + else: + limits = DEFAULT_LIMITS + + if transport is not None: + warnings.warn( + "The `transport` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `transport`") + + if proxies is not None: + warnings.warn( + "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", + category=DeprecationWarning, + stacklevel=3, + ) + if http_client is not None: + raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") + + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + super().__init__( version=version, + base_url=base_url, limits=limits, - timeout=timeout, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), proxies=proxies, transport=transport, max_retries=max_retries, @@ -1068,14 +1192,15 @@ def __init__( custom_headers=custom_headers, _strict_response_validation=_strict_response_validation, ) - self._client = httpx.AsyncClient( + self._client = http_client or httpx.AsyncClient( base_url=base_url, - timeout=timeout, - proxies=proxies, # type: ignore - transport=transport, # type: ignore + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + proxies=proxies, + transport=transport, limits=limits, - headers={"Accept": "application/json"}, ) + 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 13cbe85d..993c34ec 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -19,8 +19,10 @@ NotGiven, Transport, ProxiesTypes, + AsyncTransport, RequestOptions, ) +from ._utils import is_given from ._version import __version__ from ._streaming import Stream as Stream from ._streaming import AsyncStream as AsyncStream @@ -28,7 +30,6 @@ from ._tokenizers import sync_get_tokenizer, async_get_tokenizer from ._base_client import ( DEFAULT_LIMITS, - DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, SyncAPIClient, AsyncAPIClient, @@ -64,16 +65,18 @@ def __init__( auth_token: str | None = None, base_url: Optional[str] = None, api_key: Optional[str] = None, - timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, # See httpx documentation for [custom transports](https://www.python-httpx.org/advanced/#custom-transports) - transport: Optional[Transport] = None, + transport: Transport | None = None, # See httpx documentation for [proxies](https://www.python-httpx.org/advanced/#http-proxying) - proxies: Optional[ProxiesTypes] = None, + proxies: ProxiesTypes | None = None, # See httpx documentation for [limits](https://www.python-httpx.org/advanced/#pool-limit-configuration) - connection_pool_limits: httpx.Limits | None = DEFAULT_LIMITS, + connection_pool_limits: httpx.Limits | None = None, # Enable or disable schema validation for data returned by the API. # When enabled an error APIResponseValidationError is raised # if the API responds with invalid data for the expected schema. @@ -104,6 +107,7 @@ def __init__( base_url=base_url, max_retries=max_retries, timeout=timeout, + http_client=http_client, transport=transport, proxies=proxies, limits=connection_pool_limits, @@ -172,7 +176,8 @@ def copy( api_key: str | None = None, base_url: str | None = None, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - connection_pool_limits: httpx.Limits | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + connection_pool_limits: httpx.Limits | None = None, max_retries: int | NotGiven = NOT_GIVEN, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, @@ -203,16 +208,32 @@ def copy( elif set_default_query is not None: params = set_default_query - # TODO: share the same httpx client between instances + if connection_pool_limits is not None: + 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: + raise ValueError( + "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" + ) + + http_client = None + else: + if self._limits is not DEFAULT_LIMITS: + connection_pool_limits = self._limits + else: + connection_pool_limits = None + + http_client = http_client or self._client + return self.__class__( auth_token=auth_token or self.auth_token, base_url=base_url or str(self.base_url), api_key=api_key or self.api_key, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, - connection_pool_limits=self._limits - if isinstance(connection_pool_limits, NotGiven) - else connection_pool_limits, - max_retries=self.max_retries if isinstance(max_retries, NotGiven) else max_retries, + http_client=http_client, + connection_pool_limits=connection_pool_limits, + max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, ) @@ -222,6 +243,13 @@ def copy( 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( @@ -287,16 +315,18 @@ def __init__( auth_token: str | None = None, base_url: Optional[str] = None, api_key: Optional[str] = None, - timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, # See httpx documentation for [custom transports](https://www.python-httpx.org/advanced/#custom-transports) - transport: Optional[Transport] = None, + transport: AsyncTransport | None = None, # See httpx documentation for [proxies](https://www.python-httpx.org/advanced/#http-proxying) - proxies: Optional[ProxiesTypes] = None, + proxies: ProxiesTypes | None = None, # See httpx documentation for [limits](https://www.python-httpx.org/advanced/#pool-limit-configuration) - connection_pool_limits: httpx.Limits | None = DEFAULT_LIMITS, + connection_pool_limits: httpx.Limits | None = None, # Enable or disable schema validation for data returned by the API. # When enabled an error APIResponseValidationError is raised # if the API responds with invalid data for the expected schema. @@ -327,6 +357,7 @@ def __init__( base_url=base_url, max_retries=max_retries, timeout=timeout, + http_client=http_client, transport=transport, proxies=proxies, limits=connection_pool_limits, @@ -395,7 +426,8 @@ def copy( api_key: str | None = None, base_url: str | None = None, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - connection_pool_limits: httpx.Limits | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + connection_pool_limits: httpx.Limits | None = None, max_retries: int | NotGiven = NOT_GIVEN, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, @@ -426,16 +458,32 @@ def copy( elif set_default_query is not None: params = set_default_query - # TODO: share the same httpx client between instances + if connection_pool_limits is not None: + 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: + raise ValueError( + "A custom HTTP client has been set and is mutually exclusive with the 'connection_pool_limits' argument" + ) + + http_client = None + else: + if self._limits is not DEFAULT_LIMITS: + connection_pool_limits = self._limits + else: + connection_pool_limits = None + + http_client = http_client or self._client + return self.__class__( auth_token=auth_token or self.auth_token, base_url=base_url or str(self.base_url), api_key=api_key or self.api_key, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, - connection_pool_limits=self._limits - if isinstance(connection_pool_limits, NotGiven) - else connection_pool_limits, - max_retries=self.max_retries if isinstance(max_retries, NotGiven) else max_retries, + http_client=http_client, + connection_pool_limits=connection_pool_limits, + max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, ) @@ -445,6 +493,13 @@ def copy( 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: diff --git a/src/anthropic/_types.py b/src/anthropic/_types.py index 653cd1ab..43d4fed7 100644 --- a/src/anthropic/_types.py +++ b/src/anthropic/_types.py @@ -18,12 +18,13 @@ import httpx import pydantic -from httpx import Proxy, Timeout, Response, BaseTransport +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport if TYPE_CHECKING: from ._models import BaseModel Transport = BaseTransport +AsyncTransport = AsyncBaseTransport Query = Mapping[str, object] Body = object AnyMapping = Mapping[str, object] @@ -31,7 +32,7 @@ _T = TypeVar("_T") # Approximates httpx internal ProxiesTypes and RequestFiles types -ProxiesDict = Dict[str, Union[None, str, Proxy]] +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] ProxiesTypes = Union[str, Proxy, ProxiesDict] FileContent = Union[IO[bytes], bytes] FileTypes = Union[ diff --git a/tests/test_client.py b/tests/test_client.py index c0d4bf03..bdcefaed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,7 +19,12 @@ from anthropic._models import BaseModel, FinalRequestOptions from anthropic._streaming import Stream, AsyncStream from anthropic._exceptions import APIResponseValidationError -from anthropic._base_client import BaseClient, make_request_options +from anthropic._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + make_request_options, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = os.environ.get("API_KEY", "something1234") @@ -156,6 +161,57 @@ 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_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = Anthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = Anthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = Anthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = Anthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + def test_default_headers_option(self) -> None: client = Anthropic( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} @@ -343,10 +399,20 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 - def test_base_url_trailing_slash(self) -> None: - client = Anthropic( - base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True - ) + @pytest.mark.parametrize( + "client", + [ + Anthropic(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Anthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: Anthropic) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -356,10 +422,20 @@ def test_base_url_trailing_slash(self) -> None: ) assert request.url == "http://localhost:5000/custom/path/foo" - def test_base_url_no_trailing_slash(self) -> None: - client = Anthropic( - base_url="http://localhost:5000/custom/path", api_key=api_key, _strict_response_validation=True - ) + @pytest.mark.parametrize( + "client", + [ + Anthropic(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Anthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: Anthropic) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -369,6 +445,118 @@ def test_base_url_no_trailing_slash(self) -> None: ) assert request.url == "http://localhost:5000/custom/path/foo" + @pytest.mark.parametrize( + "client", + [ + Anthropic(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Anthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: Anthropic) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + def test_transport_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `transport` argument is deprecated. The `http_client` argument should be passed instead", + ): + transport = httpx.MockTransport(lambda: None) + + client = Anthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, transport=transport + ) + + assert client._client._transport is transport + + def test_transport_option_mutually_exclusive_with_http_client(self) -> None: + with httpx.Client() as http_client: + with pytest.raises(ValueError, match="The `http_client` argument is mutually exclusive with `transport`"): + with pytest.warns(DeprecationWarning): + Anthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + transport=httpx.MockTransport(lambda: None), + http_client=http_client, + ) + + def test_connection_pool_limits_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", + ): + connection_pool_limits = httpx.Limits( + max_connections=101, max_keepalive_connections=76, keepalive_expiry=23 + ) + + client = Anthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + connection_pool_limits=connection_pool_limits, + ) + + assert isinstance(client._client._transport, httpx.HTTPTransport) + assert client._client._transport._pool._max_connections == 101 + assert client._client._transport._pool._max_keepalive_connections == 76 + assert client._client._transport._pool._keepalive_expiry == 23 + + def test_connection_pool_limits_option_mutually_exclusive_with_http_client(self) -> None: + with httpx.Client() as http_client: + with pytest.raises( + ValueError, match="The `http_client` argument is mutually exclusive with `connection_pool_limits`" + ): + with pytest.warns(DeprecationWarning): + Anthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + connection_pool_limits=httpx.Limits( + max_connections=101, max_keepalive_connections=76, keepalive_expiry=23 + ), + http_client=http_client, + ) + + def test_proxies_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `proxies` argument is deprecated. The `http_client` argument should be passed instead", + ): + proxies = "https://www.example.com/proxy" + + client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True, proxies=proxies) + + mounts = list(client._client._mounts.keys()) + assert len(mounts) == 1 + + pattern = mounts[0].pattern + assert pattern == "all://" + + def test_proxies_option_mutually_exclusive_with_http_client(self) -> None: + with httpx.Client() as http_client: + with pytest.raises(ValueError, match="The `http_client` argument is mutually exclusive with `proxies`"): + with pytest.warns(DeprecationWarning): + Anthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + proxies="https://www.example.com/proxy", + 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() @@ -377,6 +565,18 @@ def test_client_del(self) -> None: 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() + + copied = client.copy() + assert copied is not client + + copied.__del__() + + assert not copied.is_closed() + assert not client.is_closed() + def test_client_context_manager(self) -> None: client = Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) with client as c2: @@ -578,6 +778,57 @@ 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" + 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 + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + def test_default_headers_option(self) -> None: client = AsyncAnthropic( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} @@ -765,10 +1016,22 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 - def test_base_url_trailing_slash(self) -> None: - client = AsyncAnthropic( - base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True - ) + @pytest.mark.parametrize( + "client", + [ + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: AsyncAnthropic) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -778,10 +1041,22 @@ def test_base_url_trailing_slash(self) -> None: ) assert request.url == "http://localhost:5000/custom/path/foo" - def test_base_url_no_trailing_slash(self) -> None: - client = AsyncAnthropic( - base_url="http://localhost:5000/custom/path", api_key=api_key, _strict_response_validation=True - ) + @pytest.mark.parametrize( + "client", + [ + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: AsyncAnthropic) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -791,6 +1066,122 @@ def test_base_url_no_trailing_slash(self) -> None: ) assert request.url == "http://localhost:5000/custom/path/foo" + @pytest.mark.parametrize( + "client", + [ + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAnthropic( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: AsyncAnthropic) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + def test_transport_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `transport` argument is deprecated. The `http_client` argument should be passed instead", + ): + transport = httpx.MockTransport(lambda: None) + + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, transport=transport + ) + + assert client._client._transport is transport + + async def test_transport_option_mutually_exclusive_with_http_client(self) -> None: + async with httpx.AsyncClient() as http_client: + with pytest.raises(ValueError, match="The `http_client` argument is mutually exclusive with `transport`"): + with pytest.warns(DeprecationWarning): + AsyncAnthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + transport=httpx.MockTransport(lambda: None), + http_client=http_client, + ) + + def test_connection_pool_limits_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", + ): + connection_pool_limits = httpx.Limits( + max_connections=101, max_keepalive_connections=76, keepalive_expiry=23 + ) + + client = AsyncAnthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + connection_pool_limits=connection_pool_limits, + ) + + assert isinstance(client._client._transport, httpx.AsyncHTTPTransport) + assert client._client._transport._pool._max_connections == 101 + assert client._client._transport._pool._max_keepalive_connections == 76 + assert client._client._transport._pool._keepalive_expiry == 23 + + async def test_connection_pool_limits_option_mutually_exclusive_with_http_client(self) -> None: + async with httpx.AsyncClient() as http_client: + with pytest.raises( + ValueError, match="The `http_client` argument is mutually exclusive with `connection_pool_limits`" + ): + with pytest.warns(DeprecationWarning): + AsyncAnthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + connection_pool_limits=httpx.Limits( + max_connections=101, max_keepalive_connections=76, keepalive_expiry=23 + ), + http_client=http_client, + ) + + def test_proxies_option_is_deprecated(self) -> None: + with pytest.warns( + DeprecationWarning, + match="The `proxies` argument is deprecated. The `http_client` argument should be passed instead", + ): + proxies = "https://www.example.com/proxy" + + client = AsyncAnthropic( + base_url=base_url, api_key=api_key, _strict_response_validation=True, proxies=proxies + ) + + mounts = list(client._client._mounts.keys()) + assert len(mounts) == 1 + + pattern = mounts[0].pattern + assert pattern == "all://" + + async def test_proxies_option_mutually_exclusive_with_http_client(self) -> None: + async with httpx.AsyncClient() as http_client: + with pytest.raises(ValueError, match="The `http_client` argument is mutually exclusive with `proxies`"): + with pytest.warns(DeprecationWarning): + AsyncAnthropic( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + proxies="https://www.example.com/proxy", + 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() @@ -800,6 +1191,19 @@ async def test_client_del(self) -> None: 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() + + copied = client.copy() + assert copied is not client + + copied.__del__() + + await asyncio.sleep(0.2) + assert not copied.is_closed() + assert not client.is_closed() + async def test_client_context_manager(self) -> None: client = AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True) async with client as c2: From 432a3ac7ebd3fb281cf9350144f5c47cc59ad48a Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 11 Oct 2023 06:48:05 -0400 Subject: [PATCH 19/19] release: 0.3.14 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/anthropic/_version.py | 2 +- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4c529f9d..d3f3364c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.13" + ".": "0.3.14" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ebde81d7..26c32428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.3.14 (2023-10-11) + +Full Changelog: [v0.3.13...v0.3.14](https://github.com/anthropics/anthropic-sdk-python/compare/v0.3.13...v0.3.14) + +### Features + +* **client:** add forwards-compatible pydantic methods ([#171](https://github.com/anthropics/anthropic-sdk-python/issues/171)) ([4c5289e](https://github.com/anthropics/anthropic-sdk-python/commit/4c5289eb8519ca9a53e9483422237aa25944f8d8)) +* **client:** add support for passing in a httpx client ([#173](https://github.com/anthropics/anthropic-sdk-python/issues/173)) ([25046c4](https://github.com/anthropics/anthropic-sdk-python/commit/25046c4fbc6f9d343e3b1f21024cf3982ac48c35)) +* **client:** handle retry-after header with a date format ([#168](https://github.com/anthropics/anthropic-sdk-python/issues/168)) ([afeabf1](https://github.com/anthropics/anthropic-sdk-python/commit/afeabf13aa5795a7fadd141e53ec81eadbce099a)) +* **client:** retry on 408 Request Timeout ([#155](https://github.com/anthropics/anthropic-sdk-python/issues/155)) ([46386f8](https://github.com/anthropics/anthropic-sdk-python/commit/46386f8f60223f45bc133ddfcfda8d9ca9da26a8)) +* **package:** export a root error type ([#163](https://github.com/anthropics/anthropic-sdk-python/issues/163)) ([e7aa3e7](https://github.com/anthropics/anthropic-sdk-python/commit/e7aa3e7785ae511fa35a68ac72079a6230ca84f3)) +* **types:** improve params type names ([#160](https://github.com/anthropics/anthropic-sdk-python/issues/160)) ([43544a6](https://github.com/anthropics/anthropic-sdk-python/commit/43544a62c8410061c1a50282f4c45d029db7779b)) + + +### Bug Fixes + +* **client:** don't error by default for unexpected content types ([#161](https://github.com/anthropics/anthropic-sdk-python/issues/161)) ([76cfcf9](https://github.com/anthropics/anthropic-sdk-python/commit/76cfcf91172f9804056a7d5c1ec99666ad5991a2)) +* **client:** properly configure model set fields ([#154](https://github.com/anthropics/anthropic-sdk-python/issues/154)) ([da6ccb1](https://github.com/anthropics/anthropic-sdk-python/commit/da6ccb10a38e862153871a540cb75af0afdaefb3)) + + +### Chores + +* **internal:** add helpers ([#156](https://github.com/anthropics/anthropic-sdk-python/issues/156)) ([00f5a19](https://github.com/anthropics/anthropic-sdk-python/commit/00f5a19c9393f6238759faea40405e60b2054da3)) +* **internal:** move error classes from _base_exceptions to _exceptions (⚠️ breaking) ([#162](https://github.com/anthropics/anthropic-sdk-python/issues/162)) ([329b307](https://github.com/anthropics/anthropic-sdk-python/commit/329b307c205435d367c0d4b29b252be807c61c68)) +* **tests:** improve raw response test ([#166](https://github.com/anthropics/anthropic-sdk-python/issues/166)) ([8042473](https://github.com/anthropics/anthropic-sdk-python/commit/8042473bd73faa0b819c27a68bfc19b918361461)) + + +### Documentation + +* add some missing inline documentation ([#151](https://github.com/anthropics/anthropic-sdk-python/issues/151)) ([1f98257](https://github.com/anthropics/anthropic-sdk-python/commit/1f9825775d58ed8a62b000caaddd622ed4ba3fd2)) +* update readme ([#172](https://github.com/anthropics/anthropic-sdk-python/issues/172)) ([351095b](https://github.com/anthropics/anthropic-sdk-python/commit/351095b189b111a74e9e1825ce5b6da6673a1635)) + ## 0.3.13 (2023-09-11) Full Changelog: [v0.3.12...v0.3.13](https://github.com/anthropics/anthropic-sdk-python/compare/v0.3.12...v0.3.13) diff --git a/pyproject.toml b/pyproject.toml index b96b27d7..964c6118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "anthropic" -version = "0.3.13" +version = "0.3.14" description = "Client library for the anthropic API" readme = "README.md" authors = ["Anthropic "] diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 139cd91c..065b3f21 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.3.13" # x-release-please-version +__version__ = "0.3.14" # x-release-please-version