diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6db19b95..463488b6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.17.0" + ".": "0.17.1" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b07d9d6..53df7203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.17.1 (2024-04-04) + +Full Changelog: [v0.17.0...v0.17.1](https://github.com/Finch-API/finch-api-python/compare/v0.17.0...v0.17.1) + +### Chores + +* **api:** improve descriptions ([#341](https://github.com/Finch-API/finch-api-python/issues/341)) ([43c477f](https://github.com/Finch-API/finch-api-python/commit/43c477f4da8e71a6b28598993dd15b8557017641)) +* **client:** validate that max_retries is not None ([#337](https://github.com/Finch-API/finch-api-python/issues/337)) ([6558aeb](https://github.com/Finch-API/finch-api-python/commit/6558aebd5ea0c2716a5e321787e24420dd8f203a)) +* **internal:** defer model build for import latency ([#339](https://github.com/Finch-API/finch-api-python/issues/339)) ([8e383bc](https://github.com/Finch-API/finch-api-python/commit/8e383bc6893f3344256368c0de28b55f385a09d2)) +* **internal:** streaming updates ([#340](https://github.com/Finch-API/finch-api-python/issues/340)) ([71fca9d](https://github.com/Finch-API/finch-api-python/commit/71fca9d60e40b63ff14df78f4a0cbccaaa5fff18)) + ## 0.17.0 (2024-03-29) Full Changelog: [v0.16.8...v0.17.0](https://github.com/Finch-API/finch-api-python/compare/v0.16.8...v0.17.0) diff --git a/pyproject.toml b/pyproject.toml index 6973bd5e..2d143e28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "finch-api" -version = "0.17.0" +version = "0.17.1" description = "The official Python library for the Finch API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 7a8595c1..ebf39f15 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -361,6 +361,11 @@ def __init__( self._strict_response_validation = _strict_response_validation self._idempotency_header = None + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `finch-api.DEFAULT_MAX_RETRIES`" + ) + def _enforce_trailing_slash(self, url: URL) -> URL: if url.raw_path.endswith(b"/"): return url diff --git a/src/finch/_models.py b/src/finch/_models.py index 77c755b1..0f001150 100644 --- a/src/finch/_models.py +++ b/src/finch/_models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import inspect from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast from datetime import date, datetime @@ -38,6 +39,7 @@ is_given, is_mapping, parse_date, + coerce_boolean, parse_datetime, strip_not_given, extract_type_arg, @@ -74,7 +76,9 @@ class _ConfigProtocol(Protocol): class BaseModel(pydantic.BaseModel): if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) else: @property diff --git a/src/finch/_streaming.py b/src/finch/_streaming.py index 1cd37536..37a057c0 100644 --- a/src/finch/_streaming.py +++ b/src/finch/_streaming.py @@ -23,7 +23,7 @@ class Stream(Generic[_T]): response: httpx.Response - _decoder: SSEDecoder | SSEBytesDecoder + _decoder: SSEBytesDecoder def __init__( self, @@ -46,10 +46,7 @@ def __iter__(self) -> Iterator[_T]: yield item def _iter_events(self) -> Iterator[ServerSentEvent]: - if isinstance(self._decoder, SSEBytesDecoder): - yield from self._decoder.iter_bytes(self.response.iter_bytes()) - else: - yield from self._decoder.iter(self.response.iter_lines()) + yield from self._decoder.iter_bytes(self.response.iter_bytes()) def __stream__(self) -> Iterator[_T]: cast_to = cast(Any, self._cast_to) @@ -112,12 +109,8 @@ async def __aiter__(self) -> AsyncIterator[_T]: yield item async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: - if isinstance(self._decoder, SSEBytesDecoder): - async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): - yield sse - else: - async for sse in self._decoder.aiter(self.response.aiter_lines()): - yield sse + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse async def __stream__(self) -> AsyncIterator[_T]: cast_to = cast(Any, self._cast_to) @@ -205,21 +198,49 @@ def __init__(self) -> None: self._last_event_id = None self._retry = None - def iter(self, iterator: Iterator[str]) -> Iterator[ServerSentEvent]: - """Given an iterator that yields lines, iterate over it & yield every event encountered""" - for line in iterator: - line = line.rstrip("\n") - sse = self.decode(line) - if sse is not None: - yield sse - - async def aiter(self, iterator: AsyncIterator[str]) -> AsyncIterator[ServerSentEvent]: - """Given an async iterator that yields lines, iterate over it & yield every event encountered""" - async for line in iterator: - line = line.rstrip("\n") - sse = self.decode(line) - if sse is not None: - yield sse + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data def decode(self, line: str) -> ServerSentEvent | None: # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 diff --git a/src/finch/_version.py b/src/finch/_version.py index 59183cc5..ddac6293 100644 --- a/src/finch/_version.py +++ b/src/finch/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "finch" -__version__ = "0.17.0" # x-release-please-version +__version__ = "0.17.1" # x-release-please-version diff --git a/src/finch/types/introspection.py b/src/finch/types/introspection.py index 199dbd65..ff876a95 100644 --- a/src/finch/types/introspection.py +++ b/src/finch/types/introspection.py @@ -39,9 +39,8 @@ class Introspection(BaseModel): connection_type: Literal["provider", "finch"] """The type of the connection associated with the token. - `provider` - connection to an external provider - - `finch` - finch-generated data. + - `provider` - connection to an external provider + - `finch` - finch-generated data. """ manual: bool diff --git a/src/finch/types/shared/operation_support_matrix.py b/src/finch/types/shared/operation_support_matrix.py index bfd21bb4..b299b813 100644 --- a/src/finch/types/shared/operation_support_matrix.py +++ b/src/finch/types/shared/operation_support_matrix.py @@ -10,53 +10,45 @@ class OperationSupportMatrix(BaseModel): create: Optional[OperationSupport] = None - """- `supported`: This operation is supported by both the provider and Finch - + """ + - `supported`: This operation is supported by both the provider and Finch - `not_supported_by_finch`: This operation is not supported by Finch but supported by the provider - - `not_supported_by_provider`: This operation is not supported by the provider, so Finch cannot support - - `client_access_only`: This behavior is supported by the provider, but only available to the client and not to Finch """ delete: Optional[OperationSupport] = None - """- `supported`: This operation is supported by both the provider and Finch - + """ + - `supported`: This operation is supported by both the provider and Finch - `not_supported_by_finch`: This operation is not supported by Finch but supported by the provider - - `not_supported_by_provider`: This operation is not supported by the provider, so Finch cannot support - - `client_access_only`: This behavior is supported by the provider, but only available to the client and not to Finch """ read: Optional[OperationSupport] = None - """- `supported`: This operation is supported by both the provider and Finch - + """ + - `supported`: This operation is supported by both the provider and Finch - `not_supported_by_finch`: This operation is not supported by Finch but supported by the provider - - `not_supported_by_provider`: This operation is not supported by the provider, so Finch cannot support - - `client_access_only`: This behavior is supported by the provider, but only available to the client and not to Finch """ update: Optional[OperationSupport] = None - """- `supported`: This operation is supported by both the provider and Finch - + """ + - `supported`: This operation is supported by both the provider and Finch - `not_supported_by_finch`: This operation is not supported by Finch but supported by the provider - - `not_supported_by_provider`: This operation is not supported by the provider, so Finch cannot support - - `client_access_only`: This behavior is supported by the provider, but only available to the client and not to Finch """ diff --git a/tests/test_client.py b/tests/test_client.py index 64706426..0995ec32 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -875,6 +875,17 @@ class Model(BaseModel): assert isinstance(exc.value.__cause__, ValidationError) + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + Finch( + base_url=base_url, + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + @pytest.mark.respx(base_url=base_url) def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): @@ -1782,6 +1793,17 @@ class Model(BaseModel): assert isinstance(exc.value.__cause__, ValidationError) + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncFinch( + base_url=base_url, + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + @pytest.mark.respx(base_url=base_url) @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 70eb81a3..2955cd66 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -1,104 +1,248 @@ +from __future__ import annotations + from typing import Iterator, AsyncIterator +import httpx import pytest -from finch._streaming import SSEDecoder +from finch import Finch, AsyncFinch +from finch._streaming import Stream, AsyncStream, ServerSentEvent @pytest.mark.asyncio -async def test_basic_async() -> None: - async def body() -> AsyncIterator[str]: - yield "event: completion" - yield 'data: {"foo":true}' - yield "" - - async for sse in SSEDecoder().aiter(body()): - assert sse.event == "completion" - assert sse.json() == {"foo": True} +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) -def test_basic() -> None: - def body() -> Iterator[str]: - yield "event: completion" - yield 'data: {"foo":true}' - yield "" - - it = SSEDecoder().iter(body()) - sse = next(it) + sse = await iter_next(iterator) assert sse.event == "completion" assert sse.json() == {"foo": True} - with pytest.raises(StopIteration): - next(it) + await assert_empty_iter(iterator) -def test_data_missing_event() -> None: - def body() -> Iterator[str]: - yield 'data: {"foo":true}' - yield "" +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" - it = SSEDecoder().iter(body()) - sse = next(it) + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) assert sse.event is None assert sse.json() == {"foo": True} - with pytest.raises(StopIteration): - next(it) + await assert_empty_iter(iterator) + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" -def test_event_missing_data() -> None: - def body() -> Iterator[str]: - yield "event: ping" - yield "" + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - it = SSEDecoder().iter(body()) - sse = next(it) + sse = await iter_next(iterator) assert sse.event == "ping" assert sse.data == "" - with pytest.raises(StopIteration): - next(it) + await assert_empty_iter(iterator) -def test_multiple_events() -> None: - def body() -> Iterator[str]: - yield "event: ping" - yield "" - yield "event: completion" - yield "" +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" - it = SSEDecoder().iter(body()) + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) - sse = next(it) + sse = await iter_next(iterator) assert sse.event == "ping" assert sse.data == "" - sse = next(it) + sse = await iter_next(iterator) assert sse.event == "completion" assert sse.data == "" - with pytest.raises(StopIteration): - next(it) - - -def test_multiple_events_with_data() -> None: - def body() -> Iterator[str]: - yield "event: ping" - yield 'data: {"foo":true}' - yield "" - yield "event: completion" - yield 'data: {"bar":false}' - yield "" + await assert_empty_iter(iterator) - it = SSEDecoder().iter(body()) - sse = next(it) +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) assert sse.event == "ping" assert sse.json() == {"foo": True} - sse = next(it) + sse = await iter_next(iterator) assert sse.event == "completion" assert sse.json() == {"bar": False} - with pytest.raises(StopIteration): - next(it) + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: Finch, async_client: AsyncFinch) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: Finch, + async_client: AsyncFinch, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: Finch, + async_client: AsyncFinch, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: Finch, + async_client: AsyncFinch, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events()