Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

release: 0.7.0 #229

Merged
merged 10 commits into from
Nov 16, 2023
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"python.pythonPath": ".venv/bin/python",
"python.defaultInterpreterPath": ".venv/bin/python",
"python.typeChecking": "basic",
"terminal.integrated.env.linux": {
"PATH": "/home/vscode/.rye/shims:${env:PATH}"
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.6.0"
".": "0.7.0"
}
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 0.7.0 (2023-11-15)

Full Changelog: [v0.6.0...v0.7.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.6.0...v0.7.0)

### Features

* **client:** support reading the base url from an env variable ([#237](https://github.com/anthropics/anthropic-sdk-python/issues/237)) ([dd91bfd](https://github.com/anthropics/anthropic-sdk-python/commit/dd91bfd278f4e2e76b2f194098f34070fd5a3ff9))


### Bug Fixes

* **client:** correctly flush the stream response body ([#230](https://github.com/anthropics/anthropic-sdk-python/issues/230)) ([a60d543](https://github.com/anthropics/anthropic-sdk-python/commit/a60d54331f8f6d28bf57dc979d4393759f5e1534))
* **client:** retry if SSLWantReadError occurs in the async client ([#233](https://github.com/anthropics/anthropic-sdk-python/issues/233)) ([33b553a](https://github.com/anthropics/anthropic-sdk-python/commit/33b553a8de5d45273ca9f335c59a263136385f14))
* **client:** serialise pydantic v1 default fields correctly in params ([#232](https://github.com/anthropics/anthropic-sdk-python/issues/232)) ([d5e70e8](https://github.com/anthropics/anthropic-sdk-python/commit/d5e70e8b803c96c8640508b31773b8b9d827d903))
* **models:** mark unknown fields as set in pydantic v1 ([#231](https://github.com/anthropics/anthropic-sdk-python/issues/231)) ([4ce7a1e](https://github.com/anthropics/anthropic-sdk-python/commit/4ce7a1e676023984be80fe0eacb1a0223780886c))


### Chores

* **internal:** fix devcontainer interpeter path ([#235](https://github.com/anthropics/anthropic-sdk-python/issues/235)) ([7f92e25](https://github.com/anthropics/anthropic-sdk-python/commit/7f92e25d6fa15bed799994d173ad62bcf60e5b3b))
* **internal:** fix typo in NotGiven docstring ([#234](https://github.com/anthropics/anthropic-sdk-python/issues/234)) ([ce5cccc](https://github.com/anthropics/anthropic-sdk-python/commit/ce5cccc9bc8482e4e3f6af034892a347eb2b52fc))


### Documentation

* fix code comment typo ([#236](https://github.com/anthropics/anthropic-sdk-python/issues/236)) ([7ef0464](https://github.com/anthropics/anthropic-sdk-python/commit/7ef0464724346d930ff1580526fd70b592759641))
* reword package description ([#228](https://github.com/anthropics/anthropic-sdk-python/issues/228)) ([c18e5ed](https://github.com/anthropics/anthropic-sdk-python/commit/c18e5ed77700bc98ba1c85638a503e9e0a35afb7))

## 0.6.0 (2023-11-08)

Full Changelog: [v0.5.1...v0.6.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.5.1...v0.6.0)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ import httpx
from anthropic import Anthropic

client = Anthropic(
# Or use the `ANTHROPIC_BASE_URL` env var
base_url="http://my.test.server.example.com:8083",
http_client=httpx.Client(
proxies="http://my.test.proxy.example.com",
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "anthropic"
version = "0.6.0"
description = "Client library for the anthropic API"
version = "0.7.0"
description = "The official Python library for the anthropic API"
readme = "README.md"
license = "MIT"
authors = [
Expand Down
6 changes: 0 additions & 6 deletions src/anthropic/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,12 +1320,6 @@ async def _request(
if retries > 0:
return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls)
raise APITimeoutError(request=request) from err
except httpx.ReadTimeout as err:
# We explicitly do not retry on ReadTimeout errors as this means
# that the server processing the request has taken 60 seconds
# (our default timeout). This likely indicates that something
# is not working as expected on the server side.
raise
except httpx.TimeoutException as err:
if retries > 0:
return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls)
Expand Down
4 changes: 4 additions & 0 deletions src/anthropic/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ def __init__(
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
self.auth_token = auth_token

if base_url is None:
base_url = os.environ.get("ANTHROPIC_BASE_URL")
if base_url is None:
base_url = f"https://api.anthropic.com"

Expand Down Expand Up @@ -362,6 +364,8 @@ def __init__(
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
self.auth_token = auth_token

if base_url is None:
base_url = os.environ.get("ANTHROPIC_BASE_URL")
if base_url is None:
base_url = f"https://api.anthropic.com"

Expand Down
3 changes: 2 additions & 1 deletion src/anthropic/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def construct(
if PYDANTIC_V2:
_extra[key] = value
else:
_fields_set.add(key)
fields_values[key] = value

object.__setattr__(m, "__dict__", fields_values)
Expand Down Expand Up @@ -148,7 +149,7 @@ def 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
# a specific pydantic version as some users may not know which
# pydantic version they are currently using

@override
Expand Down
14 changes: 12 additions & 2 deletions src/anthropic/_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ def __stream__(self) -> Iterator[ResponseT]:
cast_to = self._cast_to
response = self.response
process_data = self._client._process_response_data
iterator = self._iter_events()

for sse in self._iter_events():
for sse in iterator:
if sse.event == "completion":
yield process_data(data=sse.json(), cast_to=cast_to, response=response)

Expand All @@ -68,6 +69,10 @@ def __stream__(self) -> Iterator[ResponseT]:
response=self.response,
)

# Ensure the entire stream is consumed
for sse in iterator:
...


class AsyncStream(Generic[ResponseT]):
"""Provides the core interface to iterate over an asynchronous stream response."""
Expand Down Expand Up @@ -102,8 +107,9 @@ async def __stream__(self) -> AsyncIterator[ResponseT]:
cast_to = self._cast_to
response = self.response
process_data = self._client._process_response_data
iterator = self._iter_events()

async for sse in self._iter_events():
async for sse in iterator:
if sse.event == "completion":
yield process_data(data=sse.json(), cast_to=cast_to, response=response)

Expand All @@ -125,6 +131,10 @@ async def __stream__(self) -> AsyncIterator[ResponseT]:
response=self.response,
)

# Ensure the entire stream is consumed
async for sse in iterator:
...


class ServerSentEvent:
def __init__(
Expand Down
4 changes: 2 additions & 2 deletions src/anthropic/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ class NotGiven:
```py
def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ...

get(timout=1) # 1s timeout
get(timout=None) # No timeout
get(timeout=1) # 1s timeout
get(timeout=None) # No timeout
get() # Default timeout behavior, which may not be statically known at the method definition.
```
"""
Expand Down
2 changes: 1 addition & 1 deletion src/anthropic/_utils/_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def _transform_recursive(
return data

if isinstance(data, pydantic.BaseModel):
return model_dump(data, exclude_unset=True, exclude_defaults=True)
return model_dump(data, exclude_unset=True)

return _transform_value(data, annotation)

Expand Down
2 changes: 1 addition & 1 deletion src/anthropic/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless.

__title__ = "anthropic"
__version__ = "0.6.0" # x-release-please-version
__version__ = "0.7.0" # x-release-please-version
24 changes: 18 additions & 6 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
make_request_options,
)

from .utils import update_env

base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
api_key = "my-anthropic-api-key"

Expand All @@ -42,12 +44,12 @@ class TestAnthropic:

@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"}'))
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"}'
assert response.json() == {"foo": "bar"}

@pytest.mark.respx(base_url=base_url)
def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
Expand All @@ -58,7 +60,7 @@ def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
response = self.client.post("/foo", cast_to=httpx.Response)
assert response.status_code == 200
assert isinstance(response, httpx.Response)
assert response.json() == '{"foo": "bar"}'
assert response.json() == {"foo": "bar"}

def test_copy(self) -> None:
copied = self.client.copy()
Expand Down Expand Up @@ -408,6 +410,11 @@ class Model2(BaseModel):
assert isinstance(response, Model1)
assert response.foo == 1

def test_base_url_env(self) -> None:
with update_env(ANTHROPIC_BASE_URL="http://localhost:5000/from/env"):
client = Anthropic(api_key=api_key, _strict_response_validation=True)
assert client.base_url == "http://localhost:5000/from/env/"

@pytest.mark.parametrize(
"client",
[
Expand Down Expand Up @@ -669,12 +676,12 @@ class TestAsyncAnthropic:
@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"}'))
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"}'
assert response.json() == {"foo": "bar"}

@pytest.mark.respx(base_url=base_url)
@pytest.mark.asyncio
Expand All @@ -686,7 +693,7 @@ async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
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"}'
assert response.json() == {"foo": "bar"}

def test_copy(self) -> None:
copied = self.client.copy()
Expand Down Expand Up @@ -1036,6 +1043,11 @@ class Model2(BaseModel):
assert isinstance(response, Model1)
assert response.foo == 1

def test_base_url_env(self) -> None:
with update_env(ANTHROPIC_BASE_URL="http://localhost:5000/from/env"):
client = AsyncAnthropic(api_key=api_key, _strict_response_validation=True)
assert client.base_url == "http://localhost:5000/from/env/"

@pytest.mark.parametrize(
"client",
[
Expand Down
37 changes: 35 additions & 2 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from anthropic._utils import PropertyInfo, transform, parse_datetime
from anthropic._compat import PYDANTIC_V2
from anthropic._models import BaseModel


Expand Down Expand Up @@ -210,14 +211,20 @@ def test_pydantic_unknown_field() -> None:

def test_pydantic_mismatched_types() -> None:
model = MyModel.construct(foo=True)
with pytest.warns(UserWarning):
if PYDANTIC_V2:
with pytest.warns(UserWarning):
params = transform(model, Any)
else:
params = transform(model, Any)
assert params == {"foo": True}


def test_pydantic_mismatched_object_type() -> None:
model = MyModel.construct(foo=MyModel.construct(hello="world"))
with pytest.warns(UserWarning):
if PYDANTIC_V2:
with pytest.warns(UserWarning):
params = transform(model, Any)
else:
params = transform(model, Any)
assert params == {"foo": {"hello": "world"}}

Expand All @@ -230,3 +237,29 @@ def test_pydantic_nested_objects() -> None:
model = ModelNestedObjects.construct(nested={"foo": "stainless"})
assert isinstance(model.nested, MyModel)
assert transform(model, Any) == {"nested": {"foo": "stainless"}}


class ModelWithDefaultField(BaseModel):
foo: str
with_none_default: Union[str, None] = None
with_str_default: str = "foo"


def test_pydantic_default_field() -> None:
# should be excluded when defaults are used
model = ModelWithDefaultField.construct()
assert model.with_none_default is None
assert model.with_str_default == "foo"
assert transform(model, Any) == {}

# should be included when the default value is explicitly given
model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo")
assert model.with_none_default is None
assert model.with_str_default == "foo"
assert transform(model, Any) == {"with_none_default": None, "with_str_default": "foo"}

# should be included when a non-default value is explicitly given
model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz")
assert model.with_none_default == "bar"
assert model.with_str_default == "baz"
assert transform(model, Any) == {"with_none_default": "bar", "with_str_default": "baz"}
17 changes: 16 additions & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import os
import traceback
from typing import Any, TypeVar, cast
import contextlib
from typing import Any, TypeVar, Iterator, cast
from datetime import date, datetime
from typing_extensions import Literal, get_args, get_origin, assert_type

Expand Down Expand Up @@ -103,3 +105,16 @@ def _assert_list_type(type_: type[object], value: object) -> None:
inner_type = get_args(type_)[0]
for entry in value:
assert_type(inner_type, entry) # type: ignore


@contextlib.contextmanager
def update_env(**new_env: str) -> Iterator[None]:
old = os.environ.copy()

try:
os.environ.update(new_env)

yield None
finally:
os.environ.clear()
os.environ.update(old)