From c253ead4750b611c5c650bbbcb3f26d7b9c68905 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:50:07 +0000 Subject: [PATCH 01/17] fix: ensure streams are always closed --- src/zeroentropy/_streaming.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/zeroentropy/_streaming.py b/src/zeroentropy/_streaming.py index 9f17ee2..838d5cd 100644 --- a/src/zeroentropy/_streaming.py +++ b/src/zeroentropy/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self From 2b44852cac52b0b51c0159582ec4f185e666e012 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:51:30 +0000 Subject: [PATCH 02/17] chore(deps): mypy 1.18.1 has a regression, pin to 1.17 --- pyproject.toml | 2 +- requirements-dev.lock | 4 +++- requirements.lock | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82bffd6..22b58cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index 1b78d4c..6a3fee1 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -72,7 +72,7 @@ mdurl==0.1.2 multidict==6.4.4 # via aiohttp # via yarl -mypy==1.14.1 +mypy==1.17.0 mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 @@ -81,6 +81,8 @@ nox==2023.4.22 packaging==23.2 # via nox # via pytest +pathspec==0.12.1 + # via mypy platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 diff --git a/requirements.lock b/requirements.lock index 13241b6..4ffc3ac 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,21 +55,21 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via zeroentropy -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic sniffio==1.3.0 # via anyio # via zeroentropy -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via anyio # via multidict # via pydantic # via pydantic-core # via typing-inspection # via zeroentropy -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic yarl==1.20.0 # via aiohttp From 0178c61d65f910bb134becf71fbb9c500c9b2009 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:04:45 +0000 Subject: [PATCH 03/17] chore: update lockfile --- pyproject.toml | 14 +++--- requirements-dev.lock | 108 +++++++++++++++++++++++------------------- requirements.lock | 31 ++++++------ 3 files changed, 83 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22b58cd..fb75251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "Apache-2.0" authors = [ { name = "ZeroEntropy", email = "founders@zeroentropy.dev" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", diff --git a/requirements-dev.lock b/requirements-dev.lock index 6a3fee1..9904783 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via zeroentropy -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via zeroentropy -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via zeroentropy -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,82 +63,87 @@ httpx==0.28.1 # via zeroentropy httpx-aiohttp==0.1.9 # via zeroentropy -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest pathspec==0.12.1 # via mypy -platformdirs==3.11.0 +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via zeroentropy -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via zeroentropy -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection + # via virtualenv # via zeroentropy -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 4ffc3ac..4f59ec7 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via zeroentropy -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via zeroentropy async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via zeroentropy -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,25 +45,26 @@ httpx==0.28.1 # via zeroentropy httpx-aiohttp==0.1.9 # via zeroentropy -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 # via zeroentropy pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via zeroentropy typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via multidict # via pydantic # via pydantic-core @@ -71,5 +72,5 @@ typing-extensions==4.15.0 # via zeroentropy typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp From b3281987cab77990b3d83155308872f848c930d0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:13:20 +0000 Subject: [PATCH 04/17] chore(docs): use environment variables for authentication in code snippets --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index effdcf2..9187223 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ pip install --pre zeroentropy[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from zeroentropy import DefaultAioHttpClient from zeroentropy import AsyncZeroEntropy @@ -100,7 +101,7 @@ from zeroentropy import AsyncZeroEntropy async def main() -> None: async with AsyncZeroEntropy( - api_key="My API Key", + api_key=os.environ.get("ZEROENTROPY_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: response = await client.documents.add( From 75191f8c6094f4a4560eb7ce3c156fc1b742df73 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 05:46:55 +0000 Subject: [PATCH 05/17] fix(types): allow pyright to infer TypedDict types within SequenceNotStr --- src/zeroentropy/_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/zeroentropy/_types.py b/src/zeroentropy/_types.py index ac690c6..66c12b6 100644 --- a/src/zeroentropy/_types.py +++ b/src/zeroentropy/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case From ae8ed924d17668c0452d957d50722e09866606e4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 05:48:55 +0000 Subject: [PATCH 06/17] chore: add missing docstrings --- src/zeroentropy/types/query_top_pages_response.py | 2 ++ src/zeroentropy/types/query_top_snippets_response.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/zeroentropy/types/query_top_pages_response.py b/src/zeroentropy/types/query_top_pages_response.py index 6fe0c3e..ca243f1 100644 --- a/src/zeroentropy/types/query_top_pages_response.py +++ b/src/zeroentropy/types/query_top_pages_response.py @@ -8,6 +8,8 @@ class Result(BaseModel): + """A Page's metadata.""" + content: Optional[str] = None """The contents of this page. diff --git a/src/zeroentropy/types/query_top_snippets_response.py b/src/zeroentropy/types/query_top_snippets_response.py index 9f21eb7..78fd51c 100644 --- a/src/zeroentropy/types/query_top_snippets_response.py +++ b/src/zeroentropy/types/query_top_snippets_response.py @@ -31,6 +31,11 @@ class DocumentResult(BaseModel): class Result(BaseModel): + """This is a Snippet. + + A snippet refers to a particular document path, and index range. Note that all documents, regardless of filetype, are converted into `UTF-8`-encoded strings. The `start_index` and `end_index` refer to the range of characters in that string, that have been matched by this snippet. + """ + content: str """The full string content of this snippet.""" From 48488580f4d52661a9facbad07ffa2ae5a82cc31 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 05:27:43 +0000 Subject: [PATCH 07/17] chore(internal): add missing files argument to base client --- src/zeroentropy/_base_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py index 2769d40..406a0a0 100644 --- a/src/zeroentropy/_base_client.py +++ b/src/zeroentropy/_base_client.py @@ -1247,9 +1247,12 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1767,9 +1770,12 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return await self.request(cast_to, opts) async def put( From 5e2b1ce9622fd4b32b237041b6e16b2bdcafe44e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:28:46 +0000 Subject: [PATCH 08/17] chore: speedup initial import --- src/zeroentropy/_client.py | 268 ++++++++++++++++++++++++++++++------- 1 file changed, 216 insertions(+), 52 deletions(-) diff --git a/src/zeroentropy/_client.py b/src/zeroentropy/_client.py index c87e7f0..b7fd70d 100644 --- a/src/zeroentropy/_client.py +++ b/src/zeroentropy/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import models, status, queries, documents, collections from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, ZeroEntropyError from ._base_client import ( @@ -30,6 +30,14 @@ AsyncAPIClient, ) +if TYPE_CHECKING: + from .resources import models, status, queries, documents, collections + from .resources.models import ModelsResource, AsyncModelsResource + from .resources.status import StatusResource, AsyncStatusResource + from .resources.queries import QueriesResource, AsyncQueriesResource + from .resources.documents import DocumentsResource, AsyncDocumentsResource + from .resources.collections import CollectionsResource, AsyncCollectionsResource + __all__ = [ "Timeout", "Transport", @@ -43,14 +51,6 @@ class ZeroEntropy(SyncAPIClient): - status: status.StatusResource - collections: collections.CollectionsResource - documents: documents.DocumentsResource - queries: queries.QueriesResource - models: models.ModelsResource - with_raw_response: ZeroEntropyWithRawResponse - with_streaming_response: ZeroEntropyWithStreamedResponse - # client options api_key: str @@ -105,13 +105,43 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.status = status.StatusResource(self) - self.collections = collections.CollectionsResource(self) - self.documents = documents.DocumentsResource(self) - self.queries = queries.QueriesResource(self) - self.models = models.ModelsResource(self) - self.with_raw_response = ZeroEntropyWithRawResponse(self) - self.with_streaming_response = ZeroEntropyWithStreamedResponse(self) + @cached_property + def status(self) -> StatusResource: + from .resources.status import StatusResource + + return StatusResource(self) + + @cached_property + def collections(self) -> CollectionsResource: + from .resources.collections import CollectionsResource + + return CollectionsResource(self) + + @cached_property + def documents(self) -> DocumentsResource: + from .resources.documents import DocumentsResource + + return DocumentsResource(self) + + @cached_property + def queries(self) -> QueriesResource: + from .resources.queries import QueriesResource + + return QueriesResource(self) + + @cached_property + def models(self) -> ModelsResource: + from .resources.models import ModelsResource + + return ModelsResource(self) + + @cached_property + def with_raw_response(self) -> ZeroEntropyWithRawResponse: + return ZeroEntropyWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ZeroEntropyWithStreamedResponse: + return ZeroEntropyWithStreamedResponse(self) @property @override @@ -219,14 +249,6 @@ def _make_status_error( class AsyncZeroEntropy(AsyncAPIClient): - status: status.AsyncStatusResource - collections: collections.AsyncCollectionsResource - documents: documents.AsyncDocumentsResource - queries: queries.AsyncQueriesResource - models: models.AsyncModelsResource - with_raw_response: AsyncZeroEntropyWithRawResponse - with_streaming_response: AsyncZeroEntropyWithStreamedResponse - # client options api_key: str @@ -281,13 +303,43 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.status = status.AsyncStatusResource(self) - self.collections = collections.AsyncCollectionsResource(self) - self.documents = documents.AsyncDocumentsResource(self) - self.queries = queries.AsyncQueriesResource(self) - self.models = models.AsyncModelsResource(self) - self.with_raw_response = AsyncZeroEntropyWithRawResponse(self) - self.with_streaming_response = AsyncZeroEntropyWithStreamedResponse(self) + @cached_property + def status(self) -> AsyncStatusResource: + from .resources.status import AsyncStatusResource + + return AsyncStatusResource(self) + + @cached_property + def collections(self) -> AsyncCollectionsResource: + from .resources.collections import AsyncCollectionsResource + + return AsyncCollectionsResource(self) + + @cached_property + def documents(self) -> AsyncDocumentsResource: + from .resources.documents import AsyncDocumentsResource + + return AsyncDocumentsResource(self) + + @cached_property + def queries(self) -> AsyncQueriesResource: + from .resources.queries import AsyncQueriesResource + + return AsyncQueriesResource(self) + + @cached_property + def models(self) -> AsyncModelsResource: + from .resources.models import AsyncModelsResource + + return AsyncModelsResource(self) + + @cached_property + def with_raw_response(self) -> AsyncZeroEntropyWithRawResponse: + return AsyncZeroEntropyWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncZeroEntropyWithStreamedResponse: + return AsyncZeroEntropyWithStreamedResponse(self) @property @override @@ -395,39 +447,151 @@ def _make_status_error( class ZeroEntropyWithRawResponse: + _client: ZeroEntropy + def __init__(self, client: ZeroEntropy) -> None: - self.status = status.StatusResourceWithRawResponse(client.status) - self.collections = collections.CollectionsResourceWithRawResponse(client.collections) - self.documents = documents.DocumentsResourceWithRawResponse(client.documents) - self.queries = queries.QueriesResourceWithRawResponse(client.queries) - self.models = models.ModelsResourceWithRawResponse(client.models) + self._client = client + + @cached_property + def status(self) -> status.StatusResourceWithRawResponse: + from .resources.status import StatusResourceWithRawResponse + + return StatusResourceWithRawResponse(self._client.status) + + @cached_property + def collections(self) -> collections.CollectionsResourceWithRawResponse: + from .resources.collections import CollectionsResourceWithRawResponse + + return CollectionsResourceWithRawResponse(self._client.collections) + + @cached_property + def documents(self) -> documents.DocumentsResourceWithRawResponse: + from .resources.documents import DocumentsResourceWithRawResponse + + return DocumentsResourceWithRawResponse(self._client.documents) + + @cached_property + def queries(self) -> queries.QueriesResourceWithRawResponse: + from .resources.queries import QueriesResourceWithRawResponse + + return QueriesResourceWithRawResponse(self._client.queries) + + @cached_property + def models(self) -> models.ModelsResourceWithRawResponse: + from .resources.models import ModelsResourceWithRawResponse + + return ModelsResourceWithRawResponse(self._client.models) class AsyncZeroEntropyWithRawResponse: + _client: AsyncZeroEntropy + def __init__(self, client: AsyncZeroEntropy) -> None: - self.status = status.AsyncStatusResourceWithRawResponse(client.status) - self.collections = collections.AsyncCollectionsResourceWithRawResponse(client.collections) - self.documents = documents.AsyncDocumentsResourceWithRawResponse(client.documents) - self.queries = queries.AsyncQueriesResourceWithRawResponse(client.queries) - self.models = models.AsyncModelsResourceWithRawResponse(client.models) + self._client = client + + @cached_property + def status(self) -> status.AsyncStatusResourceWithRawResponse: + from .resources.status import AsyncStatusResourceWithRawResponse + + return AsyncStatusResourceWithRawResponse(self._client.status) + + @cached_property + def collections(self) -> collections.AsyncCollectionsResourceWithRawResponse: + from .resources.collections import AsyncCollectionsResourceWithRawResponse + + return AsyncCollectionsResourceWithRawResponse(self._client.collections) + + @cached_property + def documents(self) -> documents.AsyncDocumentsResourceWithRawResponse: + from .resources.documents import AsyncDocumentsResourceWithRawResponse + + return AsyncDocumentsResourceWithRawResponse(self._client.documents) + + @cached_property + def queries(self) -> queries.AsyncQueriesResourceWithRawResponse: + from .resources.queries import AsyncQueriesResourceWithRawResponse + + return AsyncQueriesResourceWithRawResponse(self._client.queries) + + @cached_property + def models(self) -> models.AsyncModelsResourceWithRawResponse: + from .resources.models import AsyncModelsResourceWithRawResponse + + return AsyncModelsResourceWithRawResponse(self._client.models) class ZeroEntropyWithStreamedResponse: + _client: ZeroEntropy + def __init__(self, client: ZeroEntropy) -> None: - self.status = status.StatusResourceWithStreamingResponse(client.status) - self.collections = collections.CollectionsResourceWithStreamingResponse(client.collections) - self.documents = documents.DocumentsResourceWithStreamingResponse(client.documents) - self.queries = queries.QueriesResourceWithStreamingResponse(client.queries) - self.models = models.ModelsResourceWithStreamingResponse(client.models) + self._client = client + + @cached_property + def status(self) -> status.StatusResourceWithStreamingResponse: + from .resources.status import StatusResourceWithStreamingResponse + + return StatusResourceWithStreamingResponse(self._client.status) + + @cached_property + def collections(self) -> collections.CollectionsResourceWithStreamingResponse: + from .resources.collections import CollectionsResourceWithStreamingResponse + + return CollectionsResourceWithStreamingResponse(self._client.collections) + + @cached_property + def documents(self) -> documents.DocumentsResourceWithStreamingResponse: + from .resources.documents import DocumentsResourceWithStreamingResponse + + return DocumentsResourceWithStreamingResponse(self._client.documents) + + @cached_property + def queries(self) -> queries.QueriesResourceWithStreamingResponse: + from .resources.queries import QueriesResourceWithStreamingResponse + + return QueriesResourceWithStreamingResponse(self._client.queries) + + @cached_property + def models(self) -> models.ModelsResourceWithStreamingResponse: + from .resources.models import ModelsResourceWithStreamingResponse + + return ModelsResourceWithStreamingResponse(self._client.models) class AsyncZeroEntropyWithStreamedResponse: + _client: AsyncZeroEntropy + def __init__(self, client: AsyncZeroEntropy) -> None: - self.status = status.AsyncStatusResourceWithStreamingResponse(client.status) - self.collections = collections.AsyncCollectionsResourceWithStreamingResponse(client.collections) - self.documents = documents.AsyncDocumentsResourceWithStreamingResponse(client.documents) - self.queries = queries.AsyncQueriesResourceWithStreamingResponse(client.queries) - self.models = models.AsyncModelsResourceWithStreamingResponse(client.models) + self._client = client + + @cached_property + def status(self) -> status.AsyncStatusResourceWithStreamingResponse: + from .resources.status import AsyncStatusResourceWithStreamingResponse + + return AsyncStatusResourceWithStreamingResponse(self._client.status) + + @cached_property + def collections(self) -> collections.AsyncCollectionsResourceWithStreamingResponse: + from .resources.collections import AsyncCollectionsResourceWithStreamingResponse + + return AsyncCollectionsResourceWithStreamingResponse(self._client.collections) + + @cached_property + def documents(self) -> documents.AsyncDocumentsResourceWithStreamingResponse: + from .resources.documents import AsyncDocumentsResourceWithStreamingResponse + + return AsyncDocumentsResourceWithStreamingResponse(self._client.documents) + + @cached_property + def queries(self) -> queries.AsyncQueriesResourceWithStreamingResponse: + from .resources.queries import AsyncQueriesResourceWithStreamingResponse + + return AsyncQueriesResourceWithStreamingResponse(self._client.queries) + + @cached_property + def models(self) -> models.AsyncModelsResourceWithStreamingResponse: + from .resources.models import AsyncModelsResourceWithStreamingResponse + + return AsyncModelsResourceWithStreamingResponse(self._client.models) Client = ZeroEntropy From 80e7b4e3f5456134a521d24056f47d8f501d8e5b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:04:13 +0000 Subject: [PATCH 09/17] fix: use async_to_httpx_files in patch method --- src/zeroentropy/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py index 406a0a0..ee01405 100644 --- a/src/zeroentropy/_base_client.py +++ b/src/zeroentropy/_base_client.py @@ -1774,7 +1774,7 @@ async def patch( options: RequestOptions = {}, ) -> ResponseT: opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) From cc848fe86aa35af50041babacb33920adf3dd90b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:26:06 +0000 Subject: [PATCH 10/17] chore(internal): add `--fix` argument to lint script --- scripts/lint | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lint b/scripts/lint index 8958b25..22be8a6 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import zeroentropy' From 3084586d9d839b9d56172a9c020d5d8adb6f02de Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:23:12 +0000 Subject: [PATCH 11/17] chore(internal): codegen related update --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 1b68672..c8c0313 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 ZeroEntropy + Copyright 2026 ZeroEntropy Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 63df3e7b3af67b1dc7cd1d9a91ef9b109e209e75 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:50:50 +0000 Subject: [PATCH 12/17] chore(internal): codegen related update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9187223..4608555 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ You can enable this by installing `aiohttp`: ```sh # install from PyPI -pip install --pre zeroentropy[aiohttp] +pip install '--pre zeroentropy[aiohttp]' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From b06f1c2a4e00ed0a0ab9b3584c61fc487824ae63 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:05:07 +0000 Subject: [PATCH 13/17] feat(client): add support for binary request streaming --- src/zeroentropy/_base_client.py | 145 +++++++++++++++++++++++-- src/zeroentropy/_models.py | 17 ++- src/zeroentropy/_types.py | 9 ++ tests/test_client.py | 187 +++++++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 14 deletions(-) diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py index ee01405..ea48701 100644 --- a/src/zeroentropy/_base_client.py +++ b/src/zeroentropy/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,11 +1282,23 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1275,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1717,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1729,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1770,11 +1854,28 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1798,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py index ca9500b..29070e0 100644 --- a/src/zeroentropy/_models.py +++ b/src/zeroentropy/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/zeroentropy/_types.py b/src/zeroentropy/_types.py index 66c12b6..80afed5 100644 --- a/src/zeroentropy/_types.py +++ b/src/zeroentropy/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/tests/test_client.py b/tests/test_client.py index 38f4ee5..68c5407 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "My API Key" @@ -50,6 +52,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: ZeroEntropy | AsyncZeroEntropy) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -502,6 +555,70 @@ def test_multipart_repeating_array(self, client: ZeroEntropy) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: ZeroEntropy) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with ZeroEntropy( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: ZeroEntropy) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: ZeroEntropy) -> None: class Model1(BaseModel): @@ -1331,6 +1448,72 @@ def test_multipart_repeating_array(self, async_client: AsyncZeroEntropy) -> None b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncZeroEntropy) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncZeroEntropy( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncZeroEntropy + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncZeroEntropy) -> None: class Model1(BaseModel): From 2448d28a581c998a24ada6a39180cdc894a3a1c2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 07:50:53 +0000 Subject: [PATCH 14/17] chore(internal): update `actions/checkout` version --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182006b..495c681 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/zeroentropy-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/zeroentropy-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -81,7 +81,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/zeroentropy-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 36479c4..ee48e47 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index e36a659..50373dd 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'zeroentropy-ai/zeroentropy-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | From dbd1650daad7af66a53f12fc7b10db1814893483 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:51:42 +0000 Subject: [PATCH 15/17] feat(api): manual updates --- .stats.yml | 4 +-- src/zeroentropy/resources/documents.py | 18 +++++++---- src/zeroentropy/resources/models.py | 4 +-- .../types/document_delete_params.py | 14 +++++--- .../types/document_delete_response.py | 5 ++- .../types/model_rerank_response.py | 32 +++++++++++++++++++ tests/api_resources/test_documents.py | 12 +++---- 7 files changed, 66 insertions(+), 23 deletions(-) diff --git a/.stats.yml b/.stats.yml index 6531ade..578af72 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-c95681b13dc56e64126746c6e546b564c7f802ae567fc9ccc1aeb8eddd40bb1e.yml -openapi_spec_hash: 2ac723122fe938e384f11b5cf19e85ec +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-b5badb1383675a2606a4e65b515426cf70010d0e834372de7bcf39fda4939692.yml +openapi_spec_hash: b3b0c03c89fe5ea66cc91fea2fa9726b config_hash: 3be2ee54cbc850c508c90b9ffae2efe5 diff --git a/src/zeroentropy/resources/documents.py b/src/zeroentropy/resources/documents.py index 22a8e32..ded3180 100644 --- a/src/zeroentropy/resources/documents.py +++ b/src/zeroentropy/resources/documents.py @@ -133,7 +133,7 @@ def delete( self, *, collection_name: str, - path: str, + path: Union[str, SequenceNotStr[str]], # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -150,8 +150,11 @@ def delete( Args: collection_name: The name of the collection. - path: The filepath of the document that you are deleting. A `404 Not Found` status - code will be returned if no document with this path was found. + path: The path(s) of the document(s) that you are deleting. Must be either a `string`, + or a `list[str]` between 1 and 64 inclusive. A `404 Not Found` status code will + be returned if no document(s) with this path was found. If at least one of the + paths provided do exist, then `200 OK` will be returned, along with an array of + the document paths that were found and thus deleted. extra_headers: Send extra headers @@ -536,7 +539,7 @@ async def delete( self, *, collection_name: str, - path: str, + path: Union[str, SequenceNotStr[str]], # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -553,8 +556,11 @@ async def delete( Args: collection_name: The name of the collection. - path: The filepath of the document that you are deleting. A `404 Not Found` status - code will be returned if no document with this path was found. + path: The path(s) of the document(s) that you are deleting. Must be either a `string`, + or a `list[str]` between 1 and 64 inclusive. A `404 Not Found` status code will + be returned if no document(s) with this path was found. If at least one of the + paths provided do exist, then `200 OK` will be returned, along with an array of + the document paths that were found and thus deleted. extra_headers: Send extra headers diff --git a/src/zeroentropy/resources/models.py b/src/zeroentropy/resources/models.py index 97fe9d0..33b1764 100644 --- a/src/zeroentropy/resources/models.py +++ b/src/zeroentropy/resources/models.py @@ -70,7 +70,7 @@ def rerank( Organizations will, by default, have a ratelimit of `2,500,000` bytes-per-minute. If this is exceeded, requests will be throttled into - `latency: "slow"` mode, up to `10,000,000` bytes-per-minute. If even this is + `latency: "slow"` mode, up to `20,000,000` bytes-per-minute. If even this is exceeded, you will get a `429` error. To request higher ratelimits, please contact [founders@zeroentropy.dev](mailto:founders@zeroentropy.dev) or message us on [Discord](https://go.zeroentropy.dev/discord) or @@ -167,7 +167,7 @@ async def rerank( Organizations will, by default, have a ratelimit of `2,500,000` bytes-per-minute. If this is exceeded, requests will be throttled into - `latency: "slow"` mode, up to `10,000,000` bytes-per-minute. If even this is + `latency: "slow"` mode, up to `20,000,000` bytes-per-minute. If even this is exceeded, you will get a `429` error. To request higher ratelimits, please contact [founders@zeroentropy.dev](mailto:founders@zeroentropy.dev) or message us on [Discord](https://go.zeroentropy.dev/discord) or diff --git a/src/zeroentropy/types/document_delete_params.py b/src/zeroentropy/types/document_delete_params.py index 7a43636..0db5742 100644 --- a/src/zeroentropy/types/document_delete_params.py +++ b/src/zeroentropy/types/document_delete_params.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import Union from typing_extensions import Required, TypedDict +from .._types import SequenceNotStr + __all__ = ["DocumentDeleteParams"] @@ -11,9 +14,12 @@ class DocumentDeleteParams(TypedDict, total=False): collection_name: Required[str] """The name of the collection.""" - path: Required[str] - """The filepath of the document that you are deleting. + path: Required[Union[str, SequenceNotStr[str]]] + """The path(s) of the document(s) that you are deleting. - A `404 Not Found` status code will be returned if no document with this path was - found. + Must be either a `string`, or a `list[str]` between 1 and 64 inclusive. A + `404 Not Found` status code will be returned if no document(s) with this path + was found. If at least one of the paths provided do exist, then `200 OK` will be + returned, along with an array of the document paths that were found and thus + deleted. """ diff --git a/src/zeroentropy/types/document_delete_response.py b/src/zeroentropy/types/document_delete_response.py index 665ff46..1eb9394 100644 --- a/src/zeroentropy/types/document_delete_response.py +++ b/src/zeroentropy/types/document_delete_response.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import List from .._models import BaseModel @@ -8,5 +8,4 @@ class DocumentDeleteResponse(BaseModel): - message: Optional[str] = None - """This string will always be "Success!". This may change in the future.""" + deleted_paths: List[str] diff --git a/src/zeroentropy/types/model_rerank_response.py b/src/zeroentropy/types/model_rerank_response.py index fed8fe1..c8fde17 100644 --- a/src/zeroentropy/types/model_rerank_response.py +++ b/src/zeroentropy/types/model_rerank_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List +from typing_extensions import Literal from .._models import BaseModel @@ -25,5 +26,36 @@ class Result(BaseModel): class ModelRerankResponse(BaseModel): + actual_latency_mode: Literal["fast", "slow"] + """The type of inference actually used. + + If `auto` is requested, then `fast` will be used by default, with `slow` as a + fallback if your ratelimit is exceeded. Else, this field will be identical to + the requested latency mode. + """ + + e2e_latency: float + """ + The total time, in seconds, between rerank request received and rerank response + returned. Client latency should equal `e2e_latency` + your ping to ZeroEntropy's + API. + """ + + inference_latency: float + """The time, in seconds, to actually inference the request. + + If this is significantly lower than `e2e_latency`, this is likely due to + ratelimiting. Please request a higher ratelimit at + [founders@zeroentropy.dev](mailto:founders@zeroentropy.dev) or message us on + [Discord](https://go.zeroentropy.dev/discord) or + [Slack](https://go.zeroentropy.dev/slack)! + """ + results: List[Result] """The results, ordered by descending order of relevance to the query.""" + + total_bytes: int + """The total number of bytes in the request. This is used for ratelimiting.""" + + total_tokens: int + """The total number of tokens in the request. This is used for billing.""" diff --git a/tests/api_resources/test_documents.py b/tests/api_resources/test_documents.py index bd094f7..a0e3e48 100644 --- a/tests/api_resources/test_documents.py +++ b/tests/api_resources/test_documents.py @@ -73,7 +73,7 @@ def test_streaming_response_update(self, client: ZeroEntropy) -> None: def test_method_delete(self, client: ZeroEntropy) -> None: document = client.documents.delete( collection_name="collection_name", - path="path", + path="string", ) assert_matches_type(DocumentDeleteResponse, document, path=["response"]) @@ -81,7 +81,7 @@ def test_method_delete(self, client: ZeroEntropy) -> None: def test_raw_response_delete(self, client: ZeroEntropy) -> None: response = client.documents.with_raw_response.delete( collection_name="collection_name", - path="path", + path="string", ) assert response.is_closed is True @@ -93,7 +93,7 @@ def test_raw_response_delete(self, client: ZeroEntropy) -> None: def test_streaming_response_delete(self, client: ZeroEntropy) -> None: with client.documents.with_streaming_response.delete( collection_name="collection_name", - path="path", + path="string", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -348,7 +348,7 @@ async def test_streaming_response_update(self, async_client: AsyncZeroEntropy) - async def test_method_delete(self, async_client: AsyncZeroEntropy) -> None: document = await async_client.documents.delete( collection_name="collection_name", - path="path", + path="string", ) assert_matches_type(DocumentDeleteResponse, document, path=["response"]) @@ -356,7 +356,7 @@ async def test_method_delete(self, async_client: AsyncZeroEntropy) -> None: async def test_raw_response_delete(self, async_client: AsyncZeroEntropy) -> None: response = await async_client.documents.with_raw_response.delete( collection_name="collection_name", - path="path", + path="string", ) assert response.is_closed is True @@ -368,7 +368,7 @@ async def test_raw_response_delete(self, async_client: AsyncZeroEntropy) -> None async def test_streaming_response_delete(self, async_client: AsyncZeroEntropy) -> None: async with async_client.documents.with_streaming_response.delete( collection_name="collection_name", - path="path", + path="string", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From ef1f5e11cbb9e1a30fc600c6a1eab11445fd6eee Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:57:04 +0000 Subject: [PATCH 16/17] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 578af72..9da5d63 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-b5badb1383675a2606a4e65b515426cf70010d0e834372de7bcf39fda4939692.yml openapi_spec_hash: b3b0c03c89fe5ea66cc91fea2fa9726b -config_hash: 3be2ee54cbc850c508c90b9ffae2efe5 +config_hash: f5fb1effd4b0e263e1e93de3f573f46f From bbe1299ed02d8ff9ef11b98146b29414f27f05ab Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:57:20 +0000 Subject: [PATCH 17/17] release: 0.1.0-alpha.8 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroentropy/_version.py | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b5db7ce..c373724 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.7" + ".": "0.1.0-alpha.8" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a48de33..8034868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.1.0-alpha.8 (2026-01-21) + +Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.7...v0.1.0-alpha.8) + +### Features + +* **api:** manual updates ([dbd1650](https://github.com/zeroentropy-ai/zeroentropy-python/commit/dbd1650daad7af66a53f12fc7b10db1814893483)) +* **client:** add support for binary request streaming ([b06f1c2](https://github.com/zeroentropy-ai/zeroentropy-python/commit/b06f1c2a4e00ed0a0ab9b3584c61fc487824ae63)) + + +### Bug Fixes + +* ensure streams are always closed ([c253ead](https://github.com/zeroentropy-ai/zeroentropy-python/commit/c253ead4750b611c5c650bbbcb3f26d7b9c68905)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([75191f8](https://github.com/zeroentropy-ai/zeroentropy-python/commit/75191f8c6094f4a4560eb7ce3c156fc1b742df73)) +* use async_to_httpx_files in patch method ([80e7b4e](https://github.com/zeroentropy-ai/zeroentropy-python/commit/80e7b4e3f5456134a521d24056f47d8f501d8e5b)) + + +### Chores + +* add missing docstrings ([ae8ed92](https://github.com/zeroentropy-ai/zeroentropy-python/commit/ae8ed924d17668c0452d957d50722e09866606e4)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([2b44852](https://github.com/zeroentropy-ai/zeroentropy-python/commit/2b44852cac52b0b51c0159582ec4f185e666e012)) +* **docs:** use environment variables for authentication in code snippets ([b328198](https://github.com/zeroentropy-ai/zeroentropy-python/commit/b3281987cab77990b3d83155308872f848c930d0)) +* **internal:** add `--fix` argument to lint script ([cc848fe](https://github.com/zeroentropy-ai/zeroentropy-python/commit/cc848fe86aa35af50041babacb33920adf3dd90b)) +* **internal:** add missing files argument to base client ([4848858](https://github.com/zeroentropy-ai/zeroentropy-python/commit/48488580f4d52661a9facbad07ffa2ae5a82cc31)) +* **internal:** codegen related update ([63df3e7](https://github.com/zeroentropy-ai/zeroentropy-python/commit/63df3e7b3af67b1dc7cd1d9a91ef9b109e209e75)) +* **internal:** codegen related update ([3084586](https://github.com/zeroentropy-ai/zeroentropy-python/commit/3084586d9d839b9d56172a9c020d5d8adb6f02de)) +* **internal:** update `actions/checkout` version ([2448d28](https://github.com/zeroentropy-ai/zeroentropy-python/commit/2448d28a581c998a24ada6a39180cdc894a3a1c2)) +* speedup initial import ([5e2b1ce](https://github.com/zeroentropy-ai/zeroentropy-python/commit/5e2b1ce9622fd4b32b237041b6e16b2bdcafe44e)) +* update lockfile ([0178c61](https://github.com/zeroentropy-ai/zeroentropy-python/commit/0178c61d65f910bb134becf71fbb9c500c9b2009)) + ## 0.1.0-alpha.7 (2025-11-24) Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.6...v0.1.0-alpha.7) diff --git a/pyproject.toml b/pyproject.toml index fb75251..2512f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zeroentropy" -version = "0.1.0-alpha.7" +version = "0.1.0-alpha.8" description = "The official Python library for the ZeroEntropy API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/zeroentropy/_version.py b/src/zeroentropy/_version.py index 50f41a0..69be61f 100644 --- a/src/zeroentropy/_version.py +++ b/src/zeroentropy/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "zeroentropy" -__version__ = "0.1.0-alpha.7" # x-release-please-version +__version__ = "0.1.0-alpha.8" # x-release-please-version