From c4fbbe6af970317989f0f6d3d2d40a96f04976e4 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 29 Jan 2025 13:07:30 +0000
Subject: [PATCH 01/30] chore(internal): version bump (#22)
From 9a3559f3f98f1699fe89a03239a9426860c097ef Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 4 Feb 2025 03:38:36 +0000
Subject: [PATCH 02/30] chore(internal): change default timeout to an int (#25)
---
src/zeroentropy/_constants.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/zeroentropy/_constants.py b/src/zeroentropy/_constants.py
index a2ac3b6..6ddf2c7 100644
--- a/src/zeroentropy/_constants.py
+++ b/src/zeroentropy/_constants.py
@@ -6,7 +6,7 @@
OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to"
# default timeout is 1 minute
-DEFAULT_TIMEOUT = httpx.Timeout(timeout=60.0, connect=5.0)
+DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0)
DEFAULT_MAX_RETRIES = 2
DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)
From 02a42b40a585d70fbaec2a28e3534ffa0f86968b Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 4 Feb 2025 03:53:53 +0000
Subject: [PATCH 03/30] chore(internal): bummp ruff dependency (#26)
---
pyproject.toml | 2 +-
requirements-dev.lock | 2 +-
scripts/utils/ruffen-docs.py | 4 ++--
src/zeroentropy/_models.py | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 164fad7..ba9c99c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -177,7 +177,7 @@ select = [
"T201",
"T203",
# misuse of typing.TYPE_CHECKING
- "TCH004",
+ "TC004",
# import rules
"TID251",
]
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 75976bd..8431408 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -78,7 +78,7 @@ pytz==2023.3.post1
# via dirty-equals
respx==0.22.0
rich==13.7.1
-ruff==0.6.9
+ruff==0.9.4
setuptools==68.2.2
# via nodeenv
six==1.16.0
diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py
index 37b3d94..0cf2bd2 100644
--- a/scripts/utils/ruffen-docs.py
+++ b/scripts/utils/ruffen-docs.py
@@ -47,7 +47,7 @@ def _md_match(match: Match[str]) -> str:
with _collect_error(match):
code = format_code_block(code)
code = textwrap.indent(code, match["indent"])
- return f'{match["before"]}{code}{match["after"]}'
+ return f"{match['before']}{code}{match['after']}"
def _pycon_match(match: Match[str]) -> str:
code = ""
@@ -97,7 +97,7 @@ def finish_fragment() -> None:
def _md_pycon_match(match: Match[str]) -> str:
code = _pycon_match(match)
code = textwrap.indent(code, match["indent"])
- return f'{match["before"]}{code}{match["after"]}'
+ return f"{match['before']}{code}{match['after']}"
src = MD_RE.sub(_md_match, src)
src = MD_PYCON_RE.sub(_md_pycon_match, src)
diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py
index 9a918aa..12c34b7 100644
--- a/src/zeroentropy/_models.py
+++ b/src/zeroentropy/_models.py
@@ -172,7 +172,7 @@ def to_json(
@override
def __str__(self) -> str:
# mypy complains about an invalid self arg
- return f'{self.__repr_name__()}({self.__repr_str__(", ")})' # type: ignore[misc]
+ return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc]
# Override the 'construct' method in a way that supports recursive parsing without validation.
# Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836.
From 2b1bf5a3ede3b9184f95924a33152a12f7237fb0 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 6 Feb 2025 04:05:41 +0000
Subject: [PATCH 04/30] feat(client): send `X-Stainless-Read-Timeout` header
(#27)
---
src/zeroentropy/_base_client.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py
index 4ac6c33..2b5764b 100644
--- a/src/zeroentropy/_base_client.py
+++ b/src/zeroentropy/_base_client.py
@@ -418,10 +418,17 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0
if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers:
headers[idempotency_header] = options.idempotency_key or self._idempotency_key()
- # Don't set the retry count header if it was already set or removed by the caller. We check
+ # Don't set these headers if they were already set or removed by the caller. We check
# `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case.
- if "x-stainless-retry-count" not in (header.lower() for header in custom_headers):
+ lower_custom_headers = [header.lower() for header in custom_headers]
+ if "x-stainless-retry-count" not in lower_custom_headers:
headers["x-stainless-retry-count"] = str(retries_taken)
+ if "x-stainless-read-timeout" not in lower_custom_headers:
+ timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
+ if isinstance(timeout, Timeout):
+ timeout = timeout.read
+ if timeout is not None:
+ headers["x-stainless-read-timeout"] = str(timeout)
return headers
From 298eed24ae48668d7eea7d35a97917e8920656b9 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 7 Feb 2025 03:49:43 +0000
Subject: [PATCH 05/30] chore(internal): fix type traversing dictionary params
(#28)
---
src/zeroentropy/_utils/_transform.py | 12 +++++++++++-
tests/test_transform.py | 11 ++++++++++-
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_utils/_transform.py b/src/zeroentropy/_utils/_transform.py
index a6b62ca..18afd9d 100644
--- a/src/zeroentropy/_utils/_transform.py
+++ b/src/zeroentropy/_utils/_transform.py
@@ -25,7 +25,7 @@
is_annotated_type,
strip_annotated_type,
)
-from .._compat import model_dump, is_typeddict
+from .._compat import get_origin, model_dump, is_typeddict
_T = TypeVar("_T")
@@ -164,9 +164,14 @@ def _transform_recursive(
inner_type = annotation
stripped_type = strip_annotated_type(inner_type)
+ origin = get_origin(stripped_type) or stripped_type
if is_typeddict(stripped_type) and is_mapping(data):
return _transform_typeddict(data, stripped_type)
+ if origin == dict and is_mapping(data):
+ items_type = get_args(stripped_type)[1]
+ return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
+
if (
# List[T]
(is_list_type(stripped_type) and is_list(data))
@@ -307,9 +312,14 @@ async def _async_transform_recursive(
inner_type = annotation
stripped_type = strip_annotated_type(inner_type)
+ origin = get_origin(stripped_type) or stripped_type
if is_typeddict(stripped_type) and is_mapping(data):
return await _async_transform_typeddict(data, stripped_type)
+ if origin == dict and is_mapping(data):
+ items_type = get_args(stripped_type)[1]
+ return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
+
if (
# List[T]
(is_list_type(stripped_type) and is_list(data))
diff --git a/tests/test_transform.py b/tests/test_transform.py
index 1ea57b6..7747fb4 100644
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -2,7 +2,7 @@
import io
import pathlib
-from typing import Any, List, Union, TypeVar, Iterable, Optional, cast
+from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast
from datetime import date, datetime
from typing_extensions import Required, Annotated, TypedDict
@@ -388,6 +388,15 @@ def my_iter() -> Iterable[Baz8]:
}
+@parametrize
+@pytest.mark.asyncio
+async def test_dictionary_items(use_async: bool) -> None:
+ class DictItems(TypedDict):
+ foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")]
+
+ assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}}
+
+
class TypedDictIterableUnionStr(TypedDict):
foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")]
From 9921817d4c6c9cdb5487499cedff14a23542440c Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 7 Feb 2025 03:53:05 +0000
Subject: [PATCH 06/30] chore(internal): minor type handling changes (#29)
---
src/zeroentropy/_models.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py
index 12c34b7..c4401ff 100644
--- a/src/zeroentropy/_models.py
+++ b/src/zeroentropy/_models.py
@@ -426,10 +426,16 @@ def construct_type(*, value: object, type_: object) -> object:
If the given value does not match the expected type then it is returned as-is.
"""
+
+ # store a reference to the original type we were given before we extract any inner
+ # types so that we can properly resolve forward references in `TypeAliasType` annotations
+ original_type = None
+
# we allow `object` as the input type because otherwise, passing things like
# `Literal['value']` will be reported as a type error by type checkers
type_ = cast("type[object]", type_)
if is_type_alias_type(type_):
+ original_type = type_ # type: ignore[unreachable]
type_ = type_.__value__ # type: ignore[unreachable]
# unwrap `Annotated[T, ...]` -> `T`
@@ -446,7 +452,7 @@ def construct_type(*, value: object, type_: object) -> object:
if is_union(origin):
try:
- return validate_type(type_=cast("type[object]", type_), value=value)
+ return validate_type(type_=cast("type[object]", original_type or type_), value=value)
except Exception:
pass
From 329cd1ee7b7fbd41f4563b829e5c276f42aa8e34 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 13 Feb 2025 05:20:26 +0000
Subject: [PATCH 07/30] chore(internal): update client tests (#30)
---
tests/test_client.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/tests/test_client.py b/tests/test_client.py
index 7f1603b..0d3a71b 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -23,6 +23,7 @@
from zeroentropy import ZeroEntropy, AsyncZeroEntropy, APIResponseValidationError
from zeroentropy._types import Omit
+from zeroentropy._utils import maybe_transform
from zeroentropy._models import BaseModel, FinalRequestOptions
from zeroentropy._constants import RAW_RESPONSE_HEADER
from zeroentropy._exceptions import APIStatusError, APITimeoutError, ZeroEntropyError, APIResponseValidationError
@@ -32,6 +33,7 @@
BaseClient,
make_request_options,
)
+from zeroentropy.types.status_get_status_params import StatusGetStatusParams
from .utils import update_env
@@ -727,7 +729,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> No
with pytest.raises(APITimeoutError):
self.client.post(
"/status/get-status",
- body=cast(object, dict()),
+ body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -742,7 +744,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> Non
with pytest.raises(APIStatusError):
self.client.post(
"/status/get-status",
- body=cast(object, dict()),
+ body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -1503,7 +1505,7 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter)
with pytest.raises(APITimeoutError):
await self.client.post(
"/status/get-status",
- body=cast(object, dict()),
+ body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -1518,7 +1520,7 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter)
with pytest.raises(APIStatusError):
await self.client.post(
"/status/get-status",
- body=cast(object, dict()),
+ body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
From fd3d006d7d08b6904fb9b56a9c1bd9b2b5c408f4 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 14 Feb 2025 04:35:10 +0000
Subject: [PATCH 08/30] fix: asyncify on non-asyncio runtimes (#31)
---
src/zeroentropy/_utils/_sync.py | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_utils/_sync.py b/src/zeroentropy/_utils/_sync.py
index 8b3aaf2..ad7ec71 100644
--- a/src/zeroentropy/_utils/_sync.py
+++ b/src/zeroentropy/_utils/_sync.py
@@ -7,16 +7,20 @@
from typing import Any, TypeVar, Callable, Awaitable
from typing_extensions import ParamSpec
+import anyio
+import sniffio
+import anyio.to_thread
+
T_Retval = TypeVar("T_Retval")
T_ParamSpec = ParamSpec("T_ParamSpec")
if sys.version_info >= (3, 9):
- to_thread = asyncio.to_thread
+ _asyncio_to_thread = asyncio.to_thread
else:
# backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread
# for Python 3.8 support
- async def to_thread(
+ async def _asyncio_to_thread(
func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
) -> Any:
"""Asynchronously run function *func* in a separate thread.
@@ -34,6 +38,17 @@ async def to_thread(
return await loop.run_in_executor(None, func_call)
+async def to_thread(
+ func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
+) -> T_Retval:
+ if sniffio.current_async_library() == "asyncio":
+ return await _asyncio_to_thread(func, *args, **kwargs)
+
+ return await anyio.to_thread.run_sync(
+ functools.partial(func, *args, **kwargs),
+ )
+
+
# inspired by `asyncer`, https://github.com/tiangolo/asyncer
def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]:
"""
From d8618e8fed41ffb439ccce21ef0cb7e0ee3ee6db Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 14 Feb 2025 04:37:15 +0000
Subject: [PATCH 09/30] chore(internal): update client tests (#32)
---
tests/test_client.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_client.py b/tests/test_client.py
index 0d3a71b..cb180b3 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -729,7 +729,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> No
with pytest.raises(APITimeoutError):
self.client.post(
"/status/get-status",
- body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
+ body=cast(object, maybe_transform({}, StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -744,7 +744,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> Non
with pytest.raises(APIStatusError):
self.client.post(
"/status/get-status",
- body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
+ body=cast(object, maybe_transform({}, StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -1505,7 +1505,7 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter)
with pytest.raises(APITimeoutError):
await self.client.post(
"/status/get-status",
- body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
+ body=cast(object, maybe_transform({}, StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
@@ -1520,7 +1520,7 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter)
with pytest.raises(APIStatusError):
await self.client.post(
"/status/get-status",
- body=cast(object, maybe_transform(dict(), StatusGetStatusParams)),
+ body=cast(object, maybe_transform({}, StatusGetStatusParams)),
cast_to=httpx.Response,
options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
)
From fc2b31c7c6f11f046ae4aa7415fb9d58b450a0b5 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 21 Feb 2025 07:22:27 +0000
Subject: [PATCH 10/30] feat(client): allow passing `NotGiven` for body (#33)
fix(client): mark some request bodies as optional
---
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 2b5764b..af5a3db 100644
--- a/src/zeroentropy/_base_client.py
+++ b/src/zeroentropy/_base_client.py
@@ -518,7 +518,7 @@ def _build_request(
# so that passing a `TypedDict` doesn't cause an error.
# https://github.com/microsoft/pyright/issues/3526#event-6715453066
params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None,
- json=json_data,
+ json=json_data if is_given(json_data) else None,
files=files,
**kwargs,
)
From aebe3f69ddf00105381945d6c02858bcb3a42837 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 22 Feb 2025 05:06:20 +0000
Subject: [PATCH 11/30] chore(internal): fix devcontainers setup (#34)
---
.devcontainer/Dockerfile | 2 +-
.devcontainer/devcontainer.json | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index ac9a2e7..55d2025 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -6,4 +6,4 @@ USER vscode
RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash
ENV PATH=/home/vscode/.rye/shims:$PATH
-RUN echo "[[ -d .venv ]] && source .venv/bin/activate" >> /home/vscode/.bashrc
+RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index bbeb30b..c17fdc1 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -24,6 +24,9 @@
}
}
}
+ },
+ "features": {
+ "ghcr.io/devcontainers/features/node:1": {}
}
// Features to add to the dev container. More info: https://containers.dev/features.
From 406c052ee2860e8af31e6c5d656bedfbde2f79a5 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 26 Feb 2025 04:16:03 +0000
Subject: [PATCH 12/30] chore(internal): properly set __pydantic_private__
(#35)
---
src/zeroentropy/_base_client.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py
index af5a3db..c558af6 100644
--- a/src/zeroentropy/_base_client.py
+++ b/src/zeroentropy/_base_client.py
@@ -63,7 +63,7 @@
ModelBuilderProtocol,
)
from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
-from ._compat import model_copy, model_dump
+from ._compat import PYDANTIC_V2, model_copy, model_dump
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
from ._response import (
APIResponse,
@@ -207,6 +207,9 @@ def _set_private_attributes(
model: Type[_T],
options: FinalRequestOptions,
) -> None:
+ if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None:
+ self.__pydantic_private__ = {}
+
self._model = model
self._client = client
self._options = options
@@ -292,6 +295,9 @@ def _set_private_attributes(
client: AsyncAPIClient,
options: FinalRequestOptions,
) -> None:
+ if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None:
+ self.__pydantic_private__ = {}
+
self._model = model
self._client = client
self._options = options
From 97b55c0c6482507e6d34f64d418d7a6bf3b68931 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:46:17 +0000
Subject: [PATCH 13/30] docs: update URLs from stainlessapi.com to
stainless.com (#36)
More details at https://www.stainless.com/changelog/stainless-com
---
SECURITY.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index 73d8066..374311e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,9 +2,9 @@
## Reporting Security Issues
-This SDK is generated by [Stainless Software Inc](http://stainlessapi.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken.
+This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken.
-To report a security issue, please contact the Stainless team at security@stainlessapi.com.
+To report a security issue, please contact the Stainless team at security@stainless.com.
## Responsible Disclosure
From c64233754816e51e25fbd988110d6d677bae971f Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 28 Feb 2025 03:18:28 +0000
Subject: [PATCH 14/30] chore(docs): update client docstring (#38)
---
src/zeroentropy/_client.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_client.py b/src/zeroentropy/_client.py
index 9b3dc0f..698847c 100644
--- a/src/zeroentropy/_client.py
+++ b/src/zeroentropy/_client.py
@@ -81,7 +81,7 @@ def __init__(
# part of our public interface in the future.
_strict_response_validation: bool = False,
) -> None:
- """Construct a new synchronous zeroentropy client instance.
+ """Construct a new synchronous ZeroEntropy client instance.
This automatically infers the `api_key` argument from the `ZEROENTROPY_API_KEY` environment variable if it is not provided.
"""
@@ -259,7 +259,7 @@ def __init__(
# part of our public interface in the future.
_strict_response_validation: bool = False,
) -> None:
- """Construct a new async zeroentropy client instance.
+ """Construct a new async AsyncZeroEntropy client instance.
This automatically infers the `api_key` argument from the `ZEROENTROPY_API_KEY` environment variable if it is not provided.
"""
From 2cabf492018133ba9870fcc568366e822d3df628 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 4 Mar 2025 06:20:44 +0000
Subject: [PATCH 15/30] chore(internal): remove unused http client options
forwarding (#39)
---
src/zeroentropy/_base_client.py | 97 +--------------------------------
1 file changed, 1 insertion(+), 96 deletions(-)
diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py
index c558af6..f4d46b9 100644
--- a/src/zeroentropy/_base_client.py
+++ b/src/zeroentropy/_base_client.py
@@ -9,7 +9,6 @@
import inspect
import logging
import platform
-import warnings
import email.utils
from types import TracebackType
from random import random
@@ -36,7 +35,7 @@
import httpx
import distro
import pydantic
-from httpx import URL, Limits
+from httpx import URL
from pydantic import PrivateAttr
from . import _exceptions
@@ -51,13 +50,10 @@
Timeout,
NotGiven,
ResponseT,
- Transport,
AnyMapping,
PostParser,
- ProxiesTypes,
RequestFiles,
HttpxSendArgs,
- AsyncTransport,
RequestOptions,
HttpxRequestFiles,
ModelBuilderProtocol,
@@ -337,9 +333,6 @@ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]):
_base_url: URL
max_retries: int
timeout: Union[float, Timeout, None]
- _limits: httpx.Limits
- _proxies: ProxiesTypes | None
- _transport: Transport | AsyncTransport | None
_strict_response_validation: bool
_idempotency_header: str | None
_default_stream_cls: type[_DefaultStreamT] | None = None
@@ -352,9 +345,6 @@ def __init__(
_strict_response_validation: bool,
max_retries: int = DEFAULT_MAX_RETRIES,
timeout: float | Timeout | None = DEFAULT_TIMEOUT,
- limits: httpx.Limits,
- transport: Transport | AsyncTransport | None,
- proxies: ProxiesTypes | None,
custom_headers: Mapping[str, str] | None = None,
custom_query: Mapping[str, object] | None = None,
) -> None:
@@ -362,9 +352,6 @@ def __init__(
self._base_url = self._enforce_trailing_slash(URL(base_url))
self.max_retries = max_retries
self.timeout = timeout
- self._limits = limits
- self._proxies = proxies
- self._transport = transport
self._custom_headers = custom_headers or {}
self._custom_query = custom_query or {}
self._strict_response_validation = _strict_response_validation
@@ -800,46 +787,11 @@ def __init__(
base_url: str | URL,
max_retries: int = DEFAULT_MAX_RETRIES,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
- transport: Transport | None = None,
- proxies: ProxiesTypes | None = None,
- limits: Limits | None = None,
http_client: httpx.Client | None = None,
custom_headers: Mapping[str, str] | None = None,
custom_query: Mapping[str, object] | None = None,
_strict_response_validation: bool,
) -> None:
- kwargs: dict[str, Any] = {}
- if limits is not None:
- warnings.warn(
- "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`")
- else:
- limits = DEFAULT_CONNECTION_LIMITS
-
- if transport is not None:
- kwargs["transport"] = transport
- warnings.warn(
- "The `transport` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `transport`")
-
- if proxies is not None:
- kwargs["proxies"] = proxies
- warnings.warn(
- "The `proxies` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `proxies`")
-
if not is_given(timeout):
# if the user passed in a custom http client with a non-default
# timeout set then we use that timeout.
@@ -860,12 +812,9 @@ def __init__(
super().__init__(
version=version,
- limits=limits,
# cast to a valid type because mypy doesn't understand our type narrowing
timeout=cast(Timeout, timeout),
- proxies=proxies,
base_url=base_url,
- transport=transport,
max_retries=max_retries,
custom_query=custom_query,
custom_headers=custom_headers,
@@ -875,9 +824,6 @@ def __init__(
base_url=base_url,
# cast to a valid type because mypy doesn't understand our type narrowing
timeout=cast(Timeout, timeout),
- limits=limits,
- follow_redirects=True,
- **kwargs, # type: ignore
)
def is_closed(self) -> bool:
@@ -1372,45 +1318,10 @@ def __init__(
_strict_response_validation: bool,
max_retries: int = DEFAULT_MAX_RETRIES,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
- transport: AsyncTransport | None = None,
- proxies: ProxiesTypes | None = None,
- limits: Limits | None = None,
http_client: httpx.AsyncClient | None = None,
custom_headers: Mapping[str, str] | None = None,
custom_query: Mapping[str, object] | None = None,
) -> None:
- kwargs: dict[str, Any] = {}
- if limits is not None:
- warnings.warn(
- "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`")
- else:
- limits = DEFAULT_CONNECTION_LIMITS
-
- if transport is not None:
- kwargs["transport"] = transport
- warnings.warn(
- "The `transport` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `transport`")
-
- if proxies is not None:
- kwargs["proxies"] = proxies
- warnings.warn(
- "The `proxies` argument is deprecated. The `http_client` argument should be passed instead",
- category=DeprecationWarning,
- stacklevel=3,
- )
- if http_client is not None:
- raise ValueError("The `http_client` argument is mutually exclusive with `proxies`")
-
if not is_given(timeout):
# if the user passed in a custom http client with a non-default
# timeout set then we use that timeout.
@@ -1432,11 +1343,8 @@ def __init__(
super().__init__(
version=version,
base_url=base_url,
- limits=limits,
# cast to a valid type because mypy doesn't understand our type narrowing
timeout=cast(Timeout, timeout),
- proxies=proxies,
- transport=transport,
max_retries=max_retries,
custom_query=custom_query,
custom_headers=custom_headers,
@@ -1446,9 +1354,6 @@ def __init__(
base_url=base_url,
# cast to a valid type because mypy doesn't understand our type narrowing
timeout=cast(Timeout, timeout),
- limits=limits,
- follow_redirects=True,
- **kwargs, # type: ignore
)
def is_closed(self) -> bool:
From ce5b0aeba04f7d2f8ad0db82372d4e972a6f0aef Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 11 Mar 2025 11:37:01 +0000
Subject: [PATCH 16/30] test: add DEFER_PYDANTIC_BUILD=false flag to tests
(#40)
---
scripts/test | 2 ++
1 file changed, 2 insertions(+)
diff --git a/scripts/test b/scripts/test
index 4fa5698..2b87845 100755
--- a/scripts/test
+++ b/scripts/test
@@ -52,6 +52,8 @@ else
echo
fi
+export DEFER_PYDANTIC_BUILD=false
+
echo "==> Running tests"
rye run pytest "$@"
From fd60612d55b1423e96f5aca7e3297bc732c94005 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 14 Mar 2025 08:33:44 +0000
Subject: [PATCH 17/30] chore(internal): remove extra empty newlines (#41)
---
pyproject.toml | 2 --
1 file changed, 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index ba9c99c..5f97428 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,6 @@ Homepage = "https://github.com/zeroentropy-ai/zeroentropy-python"
Repository = "https://github.com/zeroentropy-ai/zeroentropy-python"
-
[tool.rye]
managed = true
# version pins are in requirements-dev.lock
@@ -152,7 +151,6 @@ reportImplicitOverride = true
reportImportCycles = false
reportPrivateUsage = false
-
[tool.ruff]
line-length = 120
output-format = "grouped"
From 8cb037b4bf93d1ae9879ab7b456b563b0da38e4d Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 15 Mar 2025 06:50:41 +0000
Subject: [PATCH 18/30] chore(internal): codegen related update (#42)
---
requirements-dev.lock | 1 +
requirements.lock | 1 +
2 files changed, 2 insertions(+)
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 8431408..3b41dbd 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -7,6 +7,7 @@
# all-features: true
# with-sources: false
# generate-hashes: false
+# universal: false
-e file:.
annotated-types==0.6.0
diff --git a/requirements.lock b/requirements.lock
index 7701b5f..2f5d5bf 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -7,6 +7,7 @@
# all-features: true
# with-sources: false
# generate-hashes: false
+# universal: false
-e file:.
annotated-types==0.6.0
From 027f69f2275239cfcbaa8658df2f1d5d5d9e6595 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 15 Mar 2025 06:53:53 +0000
Subject: [PATCH 19/30] chore(internal): bump rye to 0.44.0 (#43)
---
.devcontainer/Dockerfile | 2 +-
.github/workflows/ci.yml | 4 ++--
.github/workflows/publish-pypi.yml | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 55d2025..ff261ba 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -3,7 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
USER vscode
-RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash
+RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash
ENV PATH=/home/vscode/.rye/shims:$PATH
RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8a8a4f..3b286e5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
env:
- RYE_VERSION: '0.35.0'
+ RYE_VERSION: '0.44.0'
RYE_INSTALL_OPTION: '--yes'
- name: Install dependencies
@@ -42,7 +42,7 @@ jobs:
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
env:
- RYE_VERSION: '0.35.0'
+ RYE_VERSION: '0.44.0'
RYE_INSTALL_OPTION: '--yes'
- name: Bootstrap
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index 7b0203a..36479c4 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -21,7 +21,7 @@ jobs:
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
env:
- RYE_VERSION: '0.35.0'
+ RYE_VERSION: '0.44.0'
RYE_INSTALL_OPTION: '--yes'
- name: Publish to PyPI
From c2760e1fc8ec87deda30efd5f35cb812d9c27623 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Sat, 15 Mar 2025 06:58:02 +0000
Subject: [PATCH 20/30] fix(types): handle more discriminated union shapes
(#44)
---
src/zeroentropy/_models.py | 7 +++++--
tests/test_models.py | 32 ++++++++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py
index c4401ff..b51a1bf 100644
--- a/src/zeroentropy/_models.py
+++ b/src/zeroentropy/_models.py
@@ -65,7 +65,7 @@
from ._constants import RAW_RESPONSE_HEADER
if TYPE_CHECKING:
- from pydantic_core.core_schema import ModelField, LiteralSchema, ModelFieldsSchema
+ from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema
__all__ = ["BaseModel", "GenericModel"]
@@ -646,15 +646,18 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any,
def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None:
schema = model.__pydantic_core_schema__
+ if schema["type"] == "definitions":
+ schema = schema["schema"]
+
if schema["type"] != "model":
return None
+ schema = cast("ModelSchema", schema)
fields_schema = schema["schema"]
if fields_schema["type"] != "model-fields":
return None
fields_schema = cast("ModelFieldsSchema", fields_schema)
-
field = fields_schema["fields"].get(field_name)
if not field:
return None
diff --git a/tests/test_models.py b/tests/test_models.py
index 16e041d..994d6a0 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -854,3 +854,35 @@ class Model(BaseModel):
m = construct_type(value={"cls": "foo"}, type_=Model)
assert isinstance(m, Model)
assert isinstance(m.cls, str)
+
+
+def test_discriminated_union_case() -> None:
+ class A(BaseModel):
+ type: Literal["a"]
+
+ data: bool
+
+ class B(BaseModel):
+ type: Literal["b"]
+
+ data: List[Union[A, object]]
+
+ class ModelA(BaseModel):
+ type: Literal["modelA"]
+
+ data: int
+
+ class ModelB(BaseModel):
+ type: Literal["modelB"]
+
+ required: str
+
+ data: Union[A, B]
+
+ # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required`
+ m = construct_type(
+ value={"type": "modelB", "data": {"type": "a", "data": True}},
+ type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]),
+ )
+
+ assert isinstance(m, ModelB)
From 544dcaee18e2bfa523642f59e9e1175488b6fde8 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 17 Mar 2025 17:57:31 +0000
Subject: [PATCH 21/30] fix(ci): ensure pip is always available (#45)
---
bin/publish-pypi | 1 +
1 file changed, 1 insertion(+)
diff --git a/bin/publish-pypi b/bin/publish-pypi
index 05bfccb..ebebf91 100644
--- a/bin/publish-pypi
+++ b/bin/publish-pypi
@@ -5,5 +5,6 @@ mkdir -p dist
rye build --clean
# Patching importlib-metadata version until upstream library version is updated
# https://github.com/pypa/twine/issues/977#issuecomment-2189800841
+"$HOME/.rye/self/bin/python3" -m ensurepip
"$HOME/.rye/self/bin/python3" -m pip install 'importlib-metadata==7.2.1'
rye publish --yes --token=$PYPI_TOKEN
From a2b09502670c7142eb0ad2947f1b8d610632a532 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 17 Mar 2025 18:05:22 +0000
Subject: [PATCH 22/30] fix(ci): remove publishing patch (#46)
---
bin/publish-pypi | 4 ----
pyproject.toml | 2 +-
2 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/bin/publish-pypi b/bin/publish-pypi
index ebebf91..826054e 100644
--- a/bin/publish-pypi
+++ b/bin/publish-pypi
@@ -3,8 +3,4 @@
set -eux
mkdir -p dist
rye build --clean
-# Patching importlib-metadata version until upstream library version is updated
-# https://github.com/pypa/twine/issues/977#issuecomment-2189800841
-"$HOME/.rye/self/bin/python3" -m ensurepip
-"$HOME/.rye/self/bin/python3" -m pip install 'importlib-metadata==7.2.1'
rye publish --yes --token=$PYPI_TOKEN
diff --git a/pyproject.toml b/pyproject.toml
index 5f97428..5022d75 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -86,7 +86,7 @@ typecheck = { chain = [
"typecheck:mypy" = "mypy ."
[build-system]
-requires = ["hatchling", "hatch-fancy-pypi-readme"]
+requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"
[tool.hatch.build]
From 8fe5be4670c414102d31ca5501bcec49c3142dba Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 27 Mar 2025 05:51:30 +0000
Subject: [PATCH 23/30] chore: fix typos (#47)
---
src/zeroentropy/_models.py | 2 +-
src/zeroentropy/_utils/_transform.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py
index b51a1bf..3493571 100644
--- a/src/zeroentropy/_models.py
+++ b/src/zeroentropy/_models.py
@@ -681,7 +681,7 @@ def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None:
setattr(typ, "__pydantic_config__", config) # noqa: B010
-# our use of subclasssing here causes weirdness for type checkers,
+# our use of subclassing here causes weirdness for type checkers,
# so we just pretend that we don't subclass
if TYPE_CHECKING:
GenericModel = BaseModel
diff --git a/src/zeroentropy/_utils/_transform.py b/src/zeroentropy/_utils/_transform.py
index 18afd9d..7ac2e17 100644
--- a/src/zeroentropy/_utils/_transform.py
+++ b/src/zeroentropy/_utils/_transform.py
@@ -126,7 +126,7 @@ def _get_annotated_type(type_: type) -> type | None:
def _maybe_transform_key(key: str, type_: type) -> str:
"""Transform the given `data` based on the annotations provided in `type_`.
- Note: this function only looks at `Annotated` types that contain `PropertInfo` metadata.
+ Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata.
"""
annotated_type = _get_annotated_type(type_)
if annotated_type is None:
From 95360bc372a60575e5e519d7d85a14a822ddb08f Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 27 Mar 2025 05:52:22 +0000
Subject: [PATCH 24/30] codegen metadata
---
.stats.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.stats.yml b/.stats.yml
index d9064ff..d0fd0cf 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,2 +1,4 @@
configured_endpoints: 15
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-99a41bb73e432cb47fe1937219b3c7b109ae534456e8dd019cc00cc1e6018a16.yml
+openapi_spec_hash: 25800181cde6b0a31d6726d77ade2aff
+config_hash: caa8421b07b8eb2eeb82175ec8427837
From 7cfedb53ac5ed2c2e7b57ec3fa94c10cdfa5d770 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 27 Mar 2025 05:54:41 +0000
Subject: [PATCH 25/30] chore(internal): codegen related update (#48)
---
README.md | 71 -------------------
api.md | 2 +-
src/zeroentropy/resources/documents.py | 23 +++---
.../types/document_get_info_list_response.py | 8 ++-
tests/api_resources/test_documents.py | 19 +++--
5 files changed, 24 insertions(+), 99 deletions(-)
diff --git a/README.md b/README.md
index ae365c1..a418971 100644
--- a/README.md
+++ b/README.md
@@ -88,77 +88,6 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ
Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`.
-## Pagination
-
-List methods in the ZeroEntropy API are paginated.
-
-This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually:
-
-```python
-from zeroentropy import ZeroEntropy
-
-client = ZeroEntropy()
-
-all_documents = []
-# Automatically fetches more pages as needed.
-for document in client.documents.get_info_list(
- collection_name="example_collection",
-):
- # Do something with document here
- all_documents.append(document)
-print(all_documents)
-```
-
-Or, asynchronously:
-
-```python
-import asyncio
-from zeroentropy import AsyncZeroEntropy
-
-client = AsyncZeroEntropy()
-
-
-async def main() -> None:
- all_documents = []
- # Iterate through items across all pages, issuing requests as needed.
- async for document in client.documents.get_info_list(
- collection_name="example_collection",
- ):
- all_documents.append(document)
- print(all_documents)
-
-
-asyncio.run(main())
-```
-
-Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages:
-
-```python
-first_page = await client.documents.get_info_list(
- collection_name="example_collection",
-)
-if first_page.has_next_page():
- print(f"will fetch next page using these details: {first_page.next_page_info()}")
- next_page = await first_page.get_next_page()
- print(f"number of items we just fetched: {len(next_page.documents)}")
-
-# Remove `await` for non-async usage.
-```
-
-Or just work directly with the returned data:
-
-```python
-first_page = await client.documents.get_info_list(
- collection_name="example_collection",
-)
-
-print(f"next page cursor: {first_page.id_gt}") # => "next page cursor: ..."
-for document in first_page.documents:
- print(document.id)
-
-# Remove `await` for non-async usage.
-```
-
## Handling errors
When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `zeroentropy.APIConnectionError` is raised.
diff --git a/api.md b/api.md
index 8c93fa7..4342be0 100644
--- a/api.md
+++ b/api.md
@@ -61,7 +61,7 @@ Methods:
- client.documents.delete(\*\*params) -> DocumentDeleteResponse
- client.documents.add(\*\*params) -> DocumentAddResponse
- client.documents.get_info(\*\*params) -> DocumentGetInfoResponse
-- client.documents.get_info_list(\*\*params) -> SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]
+- client.documents.get_info_list(\*\*params) -> DocumentGetInfoListResponse
- client.documents.get_page_info(\*\*params) -> DocumentGetPageInfoResponse
# Queries
diff --git a/src/zeroentropy/resources/documents.py b/src/zeroentropy/resources/documents.py
index 0d195a2..c7aec0b 100644
--- a/src/zeroentropy/resources/documents.py
+++ b/src/zeroentropy/resources/documents.py
@@ -27,8 +27,7 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
-from ..pagination import SyncGetDocumentInfoListCursor, AsyncGetDocumentInfoListCursor
-from .._base_client import AsyncPaginator, make_request_options
+from .._base_client import make_request_options
from ..types.document_add_response import DocumentAddResponse
from ..types.document_delete_response import DocumentDeleteResponse
from ..types.document_update_response import DocumentUpdateResponse
@@ -317,7 +316,7 @@ def get_info_list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
- ) -> SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]:
+ ) -> DocumentGetInfoListResponse:
"""
Retrives a list of document metadata information that matches the provided
filters.
@@ -346,9 +345,8 @@ def get_info_list(
timeout: Override the client-level default timeout for this request, in seconds
"""
- return self._get_api_list(
+ return self._post(
"/documents/get-document-info-list",
- page=SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse],
body=maybe_transform(
{
"collection_name": collection_name,
@@ -360,8 +358,7 @@ def get_info_list(
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
- model=DocumentGetInfoListResponse,
- method="post",
+ cast_to=DocumentGetInfoListResponse,
)
def get_page_info(
@@ -693,7 +690,7 @@ async def get_info(
cast_to=DocumentGetInfoResponse,
)
- def get_info_list(
+ async def get_info_list(
self,
*,
collection_name: str,
@@ -705,7 +702,7 @@ def get_info_list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
- ) -> AsyncPaginator[DocumentGetInfoListResponse, AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]]:
+ ) -> DocumentGetInfoListResponse:
"""
Retrives a list of document metadata information that matches the provided
filters.
@@ -734,10 +731,9 @@ def get_info_list(
timeout: Override the client-level default timeout for this request, in seconds
"""
- return self._get_api_list(
+ return await self._post(
"/documents/get-document-info-list",
- page=AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse],
- body=maybe_transform(
+ body=await async_maybe_transform(
{
"collection_name": collection_name,
"id_gt": id_gt,
@@ -748,8 +744,7 @@ def get_info_list(
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
- model=DocumentGetInfoListResponse,
- method="post",
+ cast_to=DocumentGetInfoListResponse,
)
async def get_page_info(
diff --git a/src/zeroentropy/types/document_get_info_list_response.py b/src/zeroentropy/types/document_get_info_list_response.py
index a8f1189..50b3817 100644
--- a/src/zeroentropy/types/document_get_info_list_response.py
+++ b/src/zeroentropy/types/document_get_info_list_response.py
@@ -5,10 +5,10 @@
from .._models import BaseModel
-__all__ = ["DocumentGetInfoListResponse"]
+__all__ = ["DocumentGetInfoListResponse", "Document"]
-class DocumentGetInfoListResponse(BaseModel):
+class Document(BaseModel):
id: str
collection_name: str
@@ -27,3 +27,7 @@ class DocumentGetInfoListResponse(BaseModel):
"""
path: str
+
+
+class DocumentGetInfoListResponse(BaseModel):
+ documents: List[Document]
diff --git a/tests/api_resources/test_documents.py b/tests/api_resources/test_documents.py
index 2e9a3cd..582bd8b 100644
--- a/tests/api_resources/test_documents.py
+++ b/tests/api_resources/test_documents.py
@@ -17,7 +17,6 @@
DocumentGetInfoListResponse,
DocumentGetPageInfoResponse,
)
-from zeroentropy.pagination import SyncGetDocumentInfoListCursor, AsyncGetDocumentInfoListCursor
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -210,7 +209,7 @@ def test_method_get_info_list(self, client: ZeroEntropy) -> None:
document = client.documents.get_info_list(
collection_name="collection_name",
)
- assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
def test_method_get_info_list_with_all_params(self, client: ZeroEntropy) -> None:
@@ -219,7 +218,7 @@ def test_method_get_info_list_with_all_params(self, client: ZeroEntropy) -> None
id_gt="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
limit=0,
)
- assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
def test_raw_response_get_info_list(self, client: ZeroEntropy) -> None:
@@ -230,7 +229,7 @@ def test_raw_response_get_info_list(self, client: ZeroEntropy) -> None:
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = response.parse()
- assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
def test_streaming_response_get_info_list(self, client: ZeroEntropy) -> None:
@@ -241,7 +240,7 @@ def test_streaming_response_get_info_list(self, client: ZeroEntropy) -> None:
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = response.parse()
- assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
assert cast(Any, response.is_closed) is True
@@ -481,7 +480,7 @@ async def test_method_get_info_list(self, async_client: AsyncZeroEntropy) -> Non
document = await async_client.documents.get_info_list(
collection_name="collection_name",
)
- assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
async def test_method_get_info_list_with_all_params(self, async_client: AsyncZeroEntropy) -> None:
@@ -490,7 +489,7 @@ async def test_method_get_info_list_with_all_params(self, async_client: AsyncZer
id_gt="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
limit=0,
)
- assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
async def test_raw_response_get_info_list(self, async_client: AsyncZeroEntropy) -> None:
@@ -501,7 +500,7 @@ async def test_raw_response_get_info_list(self, async_client: AsyncZeroEntropy)
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = await response.parse()
- assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@parametrize
async def test_streaming_response_get_info_list(self, async_client: AsyncZeroEntropy) -> None:
@@ -512,9 +511,7 @@ async def test_streaming_response_get_info_list(self, async_client: AsyncZeroEnt
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = await response.parse()
- assert_matches_type(
- AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"]
- )
+ assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
assert cast(Any, response.is_closed) is True
From 1444264df4b3d12c6df32621622e9b4ae2d35645 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:25:38 +0000
Subject: [PATCH 26/30] feat(api): manual updates
---
.github/workflows/ci.yml | 44 +-
.stats.yml | 4 +-
CONTRIBUTING.md | 3 +-
SECURITY.md | 4 +-
pyproject.toml | 3 +-
requirements-dev.lock | 2 +-
scripts/utils/upload-artifact.sh | 25 +
src/zeroentropy/__init__.py | 5 +
src/zeroentropy/_base_client.py | 442 +++++++++---------
src/zeroentropy/_client.py | 5 +-
src/zeroentropy/_models.py | 7 +-
src/zeroentropy/_response.py | 2 +-
src/zeroentropy/_types.py | 2 +
src/zeroentropy/_utils/_proxy.py | 5 +-
src/zeroentropy/_utils/_resources_proxy.py | 24 +
src/zeroentropy/_utils/_transform.py | 47 +-
src/zeroentropy/_utils/_typing.py | 4 +-
src/zeroentropy/_utils/_utils.py | 10 +-
src/zeroentropy/resources/admin.py | 5 +-
src/zeroentropy/resources/collections.py | 5 +-
src/zeroentropy/resources/documents.py | 51 +-
src/zeroentropy/resources/parsers.py | 5 +-
src/zeroentropy/resources/queries.py | 17 +-
src/zeroentropy/resources/status.py | 5 +-
.../admin_create_organization_response.py | 1 -
.../types/document_get_info_list_params.py | 20 +-
.../types/document_get_info_list_response.py | 14 +
.../types/document_get_info_response.py | 12 +
.../types/document_get_page_info_response.py | 3 +
.../types/document_update_response.py | 1 -
.../types/query_top_documents_response.py | 9 +
.../types/query_top_pages_response.py | 3 +
.../types/query_top_snippets_params.py | 7 +
.../types/query_top_snippets_response.py | 44 +-
.../types/status_get_status_response.py | 1 -
tests/api_resources/test_documents.py | 6 +-
tests/api_resources/test_queries.py | 2 +
tests/conftest.py | 2 +-
tests/test_client.py | 56 ++-
tests/test_models.py | 5 +-
tests/test_transform.py | 21 +-
tests/test_utils/test_proxy.py | 11 +
42 files changed, 625 insertions(+), 319 deletions(-)
create mode 100755 scripts/utils/upload-artifact.sh
create mode 100644 src/zeroentropy/_utils/_resources_proxy.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b286e5..025d0a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,18 +1,18 @@
name: CI
on:
push:
- branches:
- - main
- pull_request:
- branches:
- - main
- - next
+ branches-ignore:
+ - 'generated'
+ - 'codegen/**'
+ - 'integrated/**'
+ - 'stl-preview-head/**'
+ - 'stl-preview-base/**'
jobs:
lint:
+ timeout-minutes: 10
name: lint
- runs-on: ubuntu-latest
-
+ runs-on: ${{ github.repository == 'stainless-sdks/zeroentropy-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
@@ -30,10 +30,34 @@ jobs:
- name: Run lints
run: ./scripts/lint
+ upload:
+ if: github.repository == 'stainless-sdks/zeroentropy-python'
+ timeout-minutes: 10
+ name: upload
+ permissions:
+ contents: read
+ id-token: write
+ runs-on: depot-ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get GitHub OIDC Token
+ id: github-oidc
+ uses: actions/github-script@v6
+ with:
+ script: core.setOutput('github_token', await core.getIDToken());
+
+ - name: Upload tarball
+ env:
+ URL: https://pkg.stainless.com/s
+ AUTH: ${{ steps.github-oidc.outputs.github_token }}
+ SHA: ${{ github.sha }}
+ run: ./scripts/utils/upload-artifact.sh
+
test:
+ timeout-minutes: 10
name: test
- runs-on: ubuntu-latest
-
+ runs-on: ${{ github.repository == 'stainless-sdks/zeroentropy-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
diff --git a/.stats.yml b/.stats.yml
index d0fd0cf..d4574b4 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 15
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-99a41bb73e432cb47fe1937219b3c7b109ae534456e8dd019cc00cc1e6018a16.yml
-openapi_spec_hash: 25800181cde6b0a31d6726d77ade2aff
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-6d4d2509d6d6d6f6cb90dcad9ddc75dc7562ff8ed3a055bee111921a83cb26d3.yml
+openapi_spec_hash: 9cf0d15c10c9061cebd0816868366d1d
config_hash: caa8421b07b8eb2eeb82175ec8427837
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1cfe843..e66002e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,8 +17,7 @@ $ rye sync --all-features
You can then run scripts using `rye run python script.py` or by activating the virtual environment:
```sh
-$ rye shell
-# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work
+# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work
$ source .venv/bin/activate
# now you can omit the `rye run` prefix
diff --git a/SECURITY.md b/SECURITY.md
index 374311e..7856016 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,11 +16,11 @@ before making any information public.
## Reporting Non-SDK Related Security Issues
If you encounter security issues that are not directly related to SDKs but pertain to the services
-or products provided by ZeroEntropy please follow the respective company's security reporting guidelines.
+or products provided by ZeroEntropy, please follow the respective company's security reporting guidelines.
### ZeroEntropy Terms and Policies
-Please contact founders@zeroentropy.dev for any questions or concerns regarding security of our services.
+Please contact founders@zeroentropy.dev for any questions or concerns regarding the security of our services.
---
diff --git a/pyproject.toml b/pyproject.toml
index 5022d75..3a300f7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,7 @@ Repository = "https://github.com/zeroentropy-ai/zeroentropy-python"
managed = true
# version pins are in requirements-dev.lock
dev-dependencies = [
- "pyright>=1.1.359",
+ "pyright==1.1.399",
"mypy",
"respx",
"pytest",
@@ -147,6 +147,7 @@ exclude = [
]
reportImplicitOverride = true
+reportOverlappingOverload = false
reportImportCycles = false
reportPrivateUsage = false
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 3b41dbd..9dd3c5a 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -69,7 +69,7 @@ pydantic-core==2.27.1
# via pydantic
pygments==2.18.0
# via rich
-pyright==1.1.392.post0
+pyright==1.1.399
pytest==8.3.3
# via pytest-asyncio
pytest-asyncio==0.24.0
diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh
new file mode 100755
index 0000000..7a82b4d
--- /dev/null
+++ b/scripts/utils/upload-artifact.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -exuo pipefail
+
+RESPONSE=$(curl -X POST "$URL" \
+ -H "Authorization: Bearer $AUTH" \
+ -H "Content-Type: application/json")
+
+SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url')
+
+if [[ "$SIGNED_URL" == "null" ]]; then
+ echo -e "\033[31mFailed to get signed URL.\033[0m"
+ exit 1
+fi
+
+UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \
+ -H "Content-Type: application/gzip" \
+ --data-binary @- "$SIGNED_URL" 2>&1)
+
+if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then
+ echo -e "\033[32mUploaded build to Stainless storage.\033[0m"
+ echo -e "\033[32mInstallation: pip install --pre 'https://pkg.stainless.com/s/zeroentropy-python/$SHA'\033[0m"
+else
+ echo -e "\033[31mFailed to upload artifact.\033[0m"
+ exit 1
+fi
diff --git a/src/zeroentropy/__init__.py b/src/zeroentropy/__init__.py
index db88878..06ec13f 100644
--- a/src/zeroentropy/__init__.py
+++ b/src/zeroentropy/__init__.py
@@ -1,5 +1,7 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+import typing as _t
+
from . import types
from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes
from ._utils import file_from_path
@@ -78,6 +80,9 @@
"DefaultAsyncHttpxClient",
]
+if not _t.TYPE_CHECKING:
+ from ._utils._resources_proxy import resources as resources
+
_setup_logging()
# Update the __module__ attribute for exported symbols so that
diff --git a/src/zeroentropy/_base_client.py b/src/zeroentropy/_base_client.py
index f4d46b9..4788795 100644
--- a/src/zeroentropy/_base_client.py
+++ b/src/zeroentropy/_base_client.py
@@ -98,7 +98,11 @@
_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any])
if TYPE_CHECKING:
- from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
+ from httpx._config import (
+ DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage]
+ )
+
+ HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG
else:
try:
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
@@ -115,6 +119,7 @@ class PageInfo:
url: URL | NotGiven
params: Query | NotGiven
+ json: Body | NotGiven
@overload
def __init__(
@@ -130,19 +135,30 @@ def __init__(
params: Query,
) -> None: ...
+ @overload
+ def __init__(
+ self,
+ *,
+ json: Body,
+ ) -> None: ...
+
def __init__(
self,
*,
url: URL | NotGiven = NOT_GIVEN,
+ json: Body | NotGiven = NOT_GIVEN,
params: Query | NotGiven = NOT_GIVEN,
) -> None:
self.url = url
+ self.json = json
self.params = params
@override
def __repr__(self) -> str:
if self.url:
return f"{self.__class__.__name__}(url={self.url})"
+ if self.json:
+ return f"{self.__class__.__name__}(json={self.json})"
return f"{self.__class__.__name__}(params={self.params})"
@@ -191,6 +207,19 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
options.url = str(url)
return options
+ if not isinstance(info.json, NotGiven):
+ if not is_mapping(info.json):
+ raise TypeError("Pagination is only supported with mappings")
+
+ if not options.json_data:
+ options.json_data = {**info.json}
+ else:
+ if not is_mapping(options.json_data):
+ raise TypeError("Pagination is only supported with mappings")
+
+ options.json_data = {**options.json_data, **info.json}
+ return options
+
raise ValueError("Unexpected PageInfo state")
@@ -408,8 +437,8 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0
headers = httpx.Headers(headers_dict)
idempotency_header = self._idempotency_header
- if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers:
- headers[idempotency_header] = options.idempotency_key or self._idempotency_key()
+ if idempotency_header and options.idempotency_key and idempotency_header not in headers:
+ headers[idempotency_header] = options.idempotency_key
# Don't set these headers if they were already set or removed by the caller. We check
# `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case.
@@ -873,7 +902,6 @@ def request(
self,
cast_to: Type[ResponseT],
options: FinalRequestOptions,
- remaining_retries: Optional[int] = None,
*,
stream: Literal[True],
stream_cls: Type[_StreamT],
@@ -884,7 +912,6 @@ def request(
self,
cast_to: Type[ResponseT],
options: FinalRequestOptions,
- remaining_retries: Optional[int] = None,
*,
stream: Literal[False] = False,
) -> ResponseT: ...
@@ -894,7 +921,6 @@ def request(
self,
cast_to: Type[ResponseT],
options: FinalRequestOptions,
- remaining_retries: Optional[int] = None,
*,
stream: bool = False,
stream_cls: Type[_StreamT] | None = None,
@@ -904,121 +930,112 @@ def request(
self,
cast_to: Type[ResponseT],
options: FinalRequestOptions,
- remaining_retries: Optional[int] = None,
*,
stream: bool = False,
stream_cls: type[_StreamT] | None = None,
) -> ResponseT | _StreamT:
- if remaining_retries is not None:
- retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
- else:
- retries_taken = 0
-
- return self._request(
- cast_to=cast_to,
- options=options,
- stream=stream,
- stream_cls=stream_cls,
- retries_taken=retries_taken,
- )
+ cast_to = self._maybe_override_cast_to(cast_to, options)
- def _request(
- self,
- *,
- cast_to: Type[ResponseT],
- options: FinalRequestOptions,
- retries_taken: int,
- stream: bool,
- stream_cls: type[_StreamT] | None,
- ) -> ResponseT | _StreamT:
# create a copy of the options we were given so that if the
# options are mutated later & we then retry, the retries are
# given the original options
input_options = model_copy(options)
+ if input_options.idempotency_key is None and input_options.method.lower() != "get":
+ # ensure the idempotency key is reused between requests
+ input_options.idempotency_key = self._idempotency_key()
- cast_to = self._maybe_override_cast_to(cast_to, options)
- options = self._prepare_options(options)
+ response: httpx.Response | None = None
+ max_retries = input_options.get_max_retries(self.max_retries)
- remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
- request = self._build_request(options, retries_taken=retries_taken)
- self._prepare_request(request)
+ retries_taken = 0
+ for retries_taken in range(max_retries + 1):
+ options = model_copy(input_options)
+ options = self._prepare_options(options)
- kwargs: HttpxSendArgs = {}
- if self.custom_auth is not None:
- kwargs["auth"] = self.custom_auth
+ remaining_retries = max_retries - retries_taken
+ request = self._build_request(options, retries_taken=retries_taken)
+ self._prepare_request(request)
- log.debug("Sending HTTP Request: %s %s", request.method, request.url)
+ kwargs: HttpxSendArgs = {}
+ if self.custom_auth is not None:
+ kwargs["auth"] = self.custom_auth
- try:
- response = self._client.send(
- request,
- stream=stream or self._should_stream_response_body(request=request),
- **kwargs,
- )
- except httpx.TimeoutException as err:
- log.debug("Encountered httpx.TimeoutException", exc_info=True)
+ if options.follow_redirects is not None:
+ kwargs["follow_redirects"] = options.follow_redirects
- if remaining_retries > 0:
- return self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- stream=stream,
- stream_cls=stream_cls,
- response_headers=None,
- )
+ log.debug("Sending HTTP Request: %s %s", request.method, request.url)
- log.debug("Raising timeout error")
- raise APITimeoutError(request=request) from err
- except Exception as err:
- log.debug("Encountered Exception", exc_info=True)
-
- if remaining_retries > 0:
- return self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- stream=stream,
- stream_cls=stream_cls,
- response_headers=None,
+ response = None
+ try:
+ response = self._client.send(
+ request,
+ stream=stream or self._should_stream_response_body(request=request),
+ **kwargs,
)
+ except httpx.TimeoutException as err:
+ log.debug("Encountered httpx.TimeoutException", exc_info=True)
+
+ if remaining_retries > 0:
+ self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=None,
+ )
+ continue
+
+ log.debug("Raising timeout error")
+ raise APITimeoutError(request=request) from err
+ except Exception as err:
+ log.debug("Encountered Exception", exc_info=True)
+
+ if remaining_retries > 0:
+ self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=None,
+ )
+ continue
+
+ log.debug("Raising connection error")
+ raise APIConnectionError(request=request) from err
+
+ log.debug(
+ 'HTTP Response: %s %s "%i %s" %s',
+ request.method,
+ request.url,
+ response.status_code,
+ response.reason_phrase,
+ response.headers,
+ )
- log.debug("Raising connection error")
- raise APIConnectionError(request=request) from err
-
- log.debug(
- 'HTTP Response: %s %s "%i %s" %s',
- request.method,
- request.url,
- response.status_code,
- response.reason_phrase,
- response.headers,
- )
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
+ log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
+
+ if remaining_retries > 0 and self._should_retry(err.response):
+ err.response.close()
+ self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=response,
+ )
+ continue
- try:
- response.raise_for_status()
- except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
- log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
-
- if remaining_retries > 0 and self._should_retry(err.response):
- err.response.close()
- return self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- response_headers=err.response.headers,
- stream=stream,
- stream_cls=stream_cls,
- )
+ # If the response is streamed then we need to explicitly read the response
+ # to completion before attempting to access the response text.
+ if not err.response.is_closed:
+ err.response.read()
- # If the response is streamed then we need to explicitly read the response
- # to completion before attempting to access the response text.
- if not err.response.is_closed:
- err.response.read()
+ log.debug("Re-raising status error")
+ raise self._make_status_error_from_response(err.response) from None
- log.debug("Re-raising status error")
- raise self._make_status_error_from_response(err.response) from None
+ break
+ assert response is not None, "could not resolve response (should never happen)"
return self._process_response(
cast_to=cast_to,
options=options,
@@ -1028,37 +1045,20 @@ def _request(
retries_taken=retries_taken,
)
- def _retry_request(
- self,
- options: FinalRequestOptions,
- cast_to: Type[ResponseT],
- *,
- retries_taken: int,
- response_headers: httpx.Headers | None,
- stream: bool,
- stream_cls: type[_StreamT] | None,
- ) -> ResponseT | _StreamT:
- remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
+ def _sleep_for_retry(
+ self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
+ ) -> None:
+ remaining_retries = max_retries - retries_taken
if remaining_retries == 1:
log.debug("1 retry left")
else:
log.debug("%i retries left", remaining_retries)
- timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
+ timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
log.info("Retrying request to %s in %f seconds", options.url, timeout)
- # In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a
- # different thread if necessary.
time.sleep(timeout)
- return self._request(
- options=options,
- cast_to=cast_to,
- retries_taken=retries_taken + 1,
- stream=stream,
- stream_cls=stream_cls,
- )
-
def _process_response(
self,
*,
@@ -1402,7 +1402,6 @@ async def request(
options: FinalRequestOptions,
*,
stream: Literal[False] = False,
- remaining_retries: Optional[int] = None,
) -> ResponseT: ...
@overload
@@ -1413,7 +1412,6 @@ async def request(
*,
stream: Literal[True],
stream_cls: type[_AsyncStreamT],
- remaining_retries: Optional[int] = None,
) -> _AsyncStreamT: ...
@overload
@@ -1424,7 +1422,6 @@ async def request(
*,
stream: bool,
stream_cls: type[_AsyncStreamT] | None = None,
- remaining_retries: Optional[int] = None,
) -> ResponseT | _AsyncStreamT: ...
async def request(
@@ -1434,116 +1431,114 @@ async def request(
*,
stream: bool = False,
stream_cls: type[_AsyncStreamT] | None = None,
- remaining_retries: Optional[int] = None,
- ) -> ResponseT | _AsyncStreamT:
- if remaining_retries is not None:
- retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
- else:
- retries_taken = 0
-
- return await self._request(
- cast_to=cast_to,
- options=options,
- stream=stream,
- stream_cls=stream_cls,
- retries_taken=retries_taken,
- )
-
- async def _request(
- self,
- cast_to: Type[ResponseT],
- options: FinalRequestOptions,
- *,
- stream: bool,
- stream_cls: type[_AsyncStreamT] | None,
- retries_taken: int,
) -> ResponseT | _AsyncStreamT:
if self._platform is None:
# `get_platform` can make blocking IO calls so we
# execute it earlier while we are in an async context
self._platform = await asyncify(get_platform)()
+ cast_to = self._maybe_override_cast_to(cast_to, options)
+
# create a copy of the options we were given so that if the
# options are mutated later & we then retry, the retries are
# given the original options
input_options = model_copy(options)
+ if input_options.idempotency_key is None and input_options.method.lower() != "get":
+ # ensure the idempotency key is reused between requests
+ input_options.idempotency_key = self._idempotency_key()
- cast_to = self._maybe_override_cast_to(cast_to, options)
- options = await self._prepare_options(options)
+ response: httpx.Response | None = None
+ max_retries = input_options.get_max_retries(self.max_retries)
- remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
- request = self._build_request(options, retries_taken=retries_taken)
- await self._prepare_request(request)
+ retries_taken = 0
+ for retries_taken in range(max_retries + 1):
+ options = model_copy(input_options)
+ options = await self._prepare_options(options)
- kwargs: HttpxSendArgs = {}
- if self.custom_auth is not None:
- kwargs["auth"] = self.custom_auth
+ remaining_retries = max_retries - retries_taken
+ request = self._build_request(options, retries_taken=retries_taken)
+ await self._prepare_request(request)
- try:
- response = await self._client.send(
- request,
- stream=stream or self._should_stream_response_body(request=request),
- **kwargs,
- )
- except httpx.TimeoutException as err:
- log.debug("Encountered httpx.TimeoutException", exc_info=True)
+ kwargs: HttpxSendArgs = {}
+ if self.custom_auth is not None:
+ kwargs["auth"] = self.custom_auth
- if remaining_retries > 0:
- return await self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- stream=stream,
- stream_cls=stream_cls,
- response_headers=None,
- )
+ if options.follow_redirects is not None:
+ kwargs["follow_redirects"] = options.follow_redirects
- log.debug("Raising timeout error")
- raise APITimeoutError(request=request) from err
- except Exception as err:
- log.debug("Encountered Exception", exc_info=True)
+ log.debug("Sending HTTP Request: %s %s", request.method, request.url)
- if remaining_retries > 0:
- return await self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- stream=stream,
- stream_cls=stream_cls,
- response_headers=None,
+ response = None
+ try:
+ response = await self._client.send(
+ request,
+ stream=stream or self._should_stream_response_body(request=request),
+ **kwargs,
)
+ except httpx.TimeoutException as err:
+ log.debug("Encountered httpx.TimeoutException", exc_info=True)
+
+ if remaining_retries > 0:
+ await self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=None,
+ )
+ continue
+
+ log.debug("Raising timeout error")
+ raise APITimeoutError(request=request) from err
+ except Exception as err:
+ log.debug("Encountered Exception", exc_info=True)
+
+ if remaining_retries > 0:
+ await self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=None,
+ )
+ continue
+
+ log.debug("Raising connection error")
+ raise APIConnectionError(request=request) from err
+
+ log.debug(
+ 'HTTP Response: %s %s "%i %s" %s',
+ request.method,
+ request.url,
+ response.status_code,
+ response.reason_phrase,
+ response.headers,
+ )
- log.debug("Raising connection error")
- raise APIConnectionError(request=request) from err
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
+ log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
+
+ if remaining_retries > 0 and self._should_retry(err.response):
+ await err.response.aclose()
+ await self._sleep_for_retry(
+ retries_taken=retries_taken,
+ max_retries=max_retries,
+ options=input_options,
+ response=response,
+ )
+ continue
- log.debug(
- 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase
- )
+ # If the response is streamed then we need to explicitly read the response
+ # to completion before attempting to access the response text.
+ if not err.response.is_closed:
+ await err.response.aread()
- try:
- response.raise_for_status()
- except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
- log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
-
- if remaining_retries > 0 and self._should_retry(err.response):
- await err.response.aclose()
- return await self._retry_request(
- input_options,
- cast_to,
- retries_taken=retries_taken,
- response_headers=err.response.headers,
- stream=stream,
- stream_cls=stream_cls,
- )
+ log.debug("Re-raising status error")
+ raise self._make_status_error_from_response(err.response) from None
- # If the response is streamed then we need to explicitly read the response
- # to completion before attempting to access the response text.
- if not err.response.is_closed:
- await err.response.aread()
-
- log.debug("Re-raising status error")
- raise self._make_status_error_from_response(err.response) from None
+ break
+ assert response is not None, "could not resolve response (should never happen)"
return await self._process_response(
cast_to=cast_to,
options=options,
@@ -1553,35 +1548,20 @@ async def _request(
retries_taken=retries_taken,
)
- async def _retry_request(
- self,
- options: FinalRequestOptions,
- cast_to: Type[ResponseT],
- *,
- retries_taken: int,
- response_headers: httpx.Headers | None,
- stream: bool,
- stream_cls: type[_AsyncStreamT] | None,
- ) -> ResponseT | _AsyncStreamT:
- remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
+ async def _sleep_for_retry(
+ self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
+ ) -> None:
+ remaining_retries = max_retries - retries_taken
if remaining_retries == 1:
log.debug("1 retry left")
else:
log.debug("%i retries left", remaining_retries)
- timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
+ timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
log.info("Retrying request to %s in %f seconds", options.url, timeout)
await anyio.sleep(timeout)
- return await self._request(
- options=options,
- cast_to=cast_to,
- retries_taken=retries_taken + 1,
- stream=stream,
- stream_cls=stream_cls,
- )
-
async def _process_response(
self,
*,
diff --git a/src/zeroentropy/_client.py b/src/zeroentropy/_client.py
index 698847c..a53554b 100644
--- a/src/zeroentropy/_client.py
+++ b/src/zeroentropy/_client.py
@@ -19,10 +19,7 @@
ProxiesTypes,
RequestOptions,
)
-from ._utils import (
- is_given,
- get_async_library,
-)
+from ._utils import is_given, get_async_library
from ._version import __version__
from .resources import admin, status, parsers, queries, documents, collections
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py
index 3493571..4f21498 100644
--- a/src/zeroentropy/_models.py
+++ b/src/zeroentropy/_models.py
@@ -19,7 +19,6 @@
)
import pydantic
-import pydantic.generics
from pydantic.fields import FieldInfo
from ._types import (
@@ -627,8 +626,8 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any,
# Note: if one variant defines an alias then they all should
discriminator_alias = field_info.alias
- if field_info.annotation and is_literal_type(field_info.annotation):
- for entry in get_args(field_info.annotation):
+ if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation):
+ for entry in get_args(annotation):
if isinstance(entry, str):
mapping[entry] = variant
@@ -738,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
idempotency_key: str
json_data: Body
extra_json: AnyMapping
+ follow_redirects: bool
@final
@@ -751,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel):
files: Union[HttpxRequestFiles, None] = None
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
+ follow_redirects: Union[bool, None] = None
# It should be noted that we cannot use `json` here as that would override
# a BaseModel method in an incompatible fashion.
diff --git a/src/zeroentropy/_response.py b/src/zeroentropy/_response.py
index e23a0c7..f63ea68 100644
--- a/src/zeroentropy/_response.py
+++ b/src/zeroentropy/_response.py
@@ -235,7 +235,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T:
# split is required to handle cases where additional information is included
# in the response, e.g. application/json; charset=utf-8
content_type, *_ = response.headers.get("content-type", "*").split(";")
- if content_type != "application/json":
+ if not content_type.endswith("json"):
if is_basemodel(cast_to):
try:
data = response.json()
diff --git a/src/zeroentropy/_types.py b/src/zeroentropy/_types.py
index 19b8842..308c2cd 100644
--- a/src/zeroentropy/_types.py
+++ b/src/zeroentropy/_types.py
@@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False):
params: Query
extra_json: AnyMapping
idempotency_key: str
+ follow_redirects: bool
# Sentinel class used until PEP 0661 is accepted
@@ -215,3 +216,4 @@ class _GenericAlias(Protocol):
class HttpxSendArgs(TypedDict, total=False):
auth: httpx.Auth
+ follow_redirects: bool
diff --git a/src/zeroentropy/_utils/_proxy.py b/src/zeroentropy/_utils/_proxy.py
index ffd883e..0f239a3 100644
--- a/src/zeroentropy/_utils/_proxy.py
+++ b/src/zeroentropy/_utils/_proxy.py
@@ -46,7 +46,10 @@ def __dir__(self) -> Iterable[str]:
@property # type: ignore
@override
def __class__(self) -> type: # pyright: ignore
- proxied = self.__get_proxied__()
+ try:
+ proxied = self.__get_proxied__()
+ except Exception:
+ return type(self)
if issubclass(type(proxied), LazyProxy):
return type(proxied)
return proxied.__class__
diff --git a/src/zeroentropy/_utils/_resources_proxy.py b/src/zeroentropy/_utils/_resources_proxy.py
new file mode 100644
index 0000000..0cb5a64
--- /dev/null
+++ b/src/zeroentropy/_utils/_resources_proxy.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from typing import Any
+from typing_extensions import override
+
+from ._proxy import LazyProxy
+
+
+class ResourcesProxy(LazyProxy[Any]):
+ """A proxy for the `zeroentropy.resources` module.
+
+ This is used so that we can lazily import `zeroentropy.resources` only when
+ needed *and* so that users can just import `zeroentropy` and reference `zeroentropy.resources`
+ """
+
+ @override
+ def __load__(self) -> Any:
+ import importlib
+
+ mod = importlib.import_module("zeroentropy.resources")
+ return mod
+
+
+resources = ResourcesProxy().__as_proxied__()
diff --git a/src/zeroentropy/_utils/_transform.py b/src/zeroentropy/_utils/_transform.py
index 7ac2e17..b0cc20a 100644
--- a/src/zeroentropy/_utils/_transform.py
+++ b/src/zeroentropy/_utils/_transform.py
@@ -5,13 +5,15 @@
import pathlib
from typing import Any, Mapping, TypeVar, cast
from datetime import date, datetime
-from typing_extensions import Literal, get_args, override, get_type_hints
+from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints
import anyio
import pydantic
from ._utils import (
is_list,
+ is_given,
+ lru_cache,
is_mapping,
is_iterable,
)
@@ -108,6 +110,7 @@ class Params(TypedDict, total=False):
return cast(_T, transformed)
+@lru_cache(maxsize=8096)
def _get_annotated_type(type_: type) -> type | None:
"""If the given type is an `Annotated` type then it is returned, if not `None` is returned.
@@ -142,6 +145,10 @@ def _maybe_transform_key(key: str, type_: type) -> str:
return key
+def _no_transform_needed(annotation: type) -> bool:
+ return annotation == float or annotation == int
+
+
def _transform_recursive(
data: object,
*,
@@ -184,6 +191,15 @@ def _transform_recursive(
return cast(object, data)
inner_type = extract_type_arg(stripped_type, 0)
+ if _no_transform_needed(inner_type):
+ # for some types there is no need to transform anything, so we can get a small
+ # perf boost from skipping that work.
+ #
+ # but we still need to convert to a list to ensure the data is json-serializable
+ if is_list(data):
+ return data
+ return list(data)
+
return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]
if is_union_type(stripped_type):
@@ -245,6 +261,11 @@ def _transform_typeddict(
result: dict[str, object] = {}
annotations = get_type_hints(expected_type, include_extras=True)
for key, value in data.items():
+ if not is_given(value):
+ # we don't need to include `NotGiven` values here as they'll
+ # be stripped out before the request is sent anyway
+ continue
+
type_ = annotations.get(key)
if type_ is None:
# we do not have a type annotation for this field, leave it as is
@@ -332,6 +353,15 @@ async def _async_transform_recursive(
return cast(object, data)
inner_type = extract_type_arg(stripped_type, 0)
+ if _no_transform_needed(inner_type):
+ # for some types there is no need to transform anything, so we can get a small
+ # perf boost from skipping that work.
+ #
+ # but we still need to convert to a list to ensure the data is json-serializable
+ if is_list(data):
+ return data
+ return list(data)
+
return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]
if is_union_type(stripped_type):
@@ -393,6 +423,11 @@ async def _async_transform_typeddict(
result: dict[str, object] = {}
annotations = get_type_hints(expected_type, include_extras=True)
for key, value in data.items():
+ if not is_given(value):
+ # we don't need to include `NotGiven` values here as they'll
+ # be stripped out before the request is sent anyway
+ continue
+
type_ = annotations.get(key)
if type_ is None:
# we do not have a type annotation for this field, leave it as is
@@ -400,3 +435,13 @@ async def _async_transform_typeddict(
else:
result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_)
return result
+
+
+@lru_cache(maxsize=8096)
+def get_type_hints(
+ obj: Any,
+ globalns: dict[str, Any] | None = None,
+ localns: Mapping[str, Any] | None = None,
+ include_extras: bool = False,
+) -> dict[str, Any]:
+ return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras)
diff --git a/src/zeroentropy/_utils/_typing.py b/src/zeroentropy/_utils/_typing.py
index 278749b..1bac954 100644
--- a/src/zeroentropy/_utils/_typing.py
+++ b/src/zeroentropy/_utils/_typing.py
@@ -13,6 +13,7 @@
get_origin,
)
+from ._utils import lru_cache
from .._types import InheritsGeneric
from .._compat import is_union as _is_union
@@ -66,6 +67,7 @@ def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]:
# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]]
+@lru_cache(maxsize=8096)
def strip_annotated_type(typ: type) -> type:
if is_required_type(typ) or is_annotated_type(typ):
return strip_annotated_type(cast(type, get_args(typ)[0]))
@@ -108,7 +110,7 @@ class MyResponse(Foo[_T]):
```
"""
cls = cast(object, get_origin(typ) or typ)
- if cls in generic_bases:
+ if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains]
# we're given the class directly
return extract_type_arg(typ, index)
diff --git a/src/zeroentropy/_utils/_utils.py b/src/zeroentropy/_utils/_utils.py
index e5811bb..ea3cf3f 100644
--- a/src/zeroentropy/_utils/_utils.py
+++ b/src/zeroentropy/_utils/_utils.py
@@ -72,8 +72,16 @@ def _extract_items(
from .._files import assert_is_file_content
# We have exhausted the path, return the entry we found.
- assert_is_file_content(obj, key=flattened_key)
assert flattened_key is not None
+
+ if is_list(obj):
+ files: list[tuple[str, FileTypes]] = []
+ for entry in obj:
+ assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "")
+ files.append((flattened_key + "[]", cast(FileTypes, entry)))
+ return files
+
+ assert_is_file_content(obj, key=flattened_key)
return [(flattened_key, cast(FileTypes, obj))]
index += 1
diff --git a/src/zeroentropy/resources/admin.py b/src/zeroentropy/resources/admin.py
index 21bc16e..af2b633 100644
--- a/src/zeroentropy/resources/admin.py
+++ b/src/zeroentropy/resources/admin.py
@@ -6,10 +6,7 @@
from ..types import admin_create_organization_params
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
diff --git a/src/zeroentropy/resources/collections.py b/src/zeroentropy/resources/collections.py
index 17809e2..24aeaa8 100644
--- a/src/zeroentropy/resources/collections.py
+++ b/src/zeroentropy/resources/collections.py
@@ -6,10 +6,7 @@
from ..types import collection_add_params, collection_delete_params
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
diff --git a/src/zeroentropy/resources/documents.py b/src/zeroentropy/resources/documents.py
index c7aec0b..ef220c6 100644
--- a/src/zeroentropy/resources/documents.py
+++ b/src/zeroentropy/resources/documents.py
@@ -15,10 +15,7 @@
document_get_page_info_params,
)
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
@@ -308,8 +305,9 @@ def get_info_list(
self,
*,
collection_name: str,
- id_gt: Optional[str] | NotGiven = NOT_GIVEN,
limit: int | NotGiven = NOT_GIVEN,
+ path_gt: Optional[str] | NotGiven = NOT_GIVEN,
+ path_prefix: Optional[str] | NotGiven = NOT_GIVEN,
# 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,
@@ -321,9 +319,9 @@ def get_info_list(
Retrives a list of document metadata information that matches the provided
filters.
- The documents returned will be sorted by ID in ascending order. `id_gt` can be
- used for pagination, and should be set to the ID of the last document returned
- in the previous call.
+ The documents returned will be sorted by path in lexicographically ascending
+ order. `path_gt` can be used for pagination, and should be set to the path of
+ the last document returned in the previous call.
A `404 Not Found` will be returned if either the collection name does not exist,
or the document path does not exist within the provided collection.
@@ -331,12 +329,17 @@ def get_info_list(
Args:
collection_name: The name of the collection.
- id_gt: All documents returned will have a UUID strictly greater than the provided UUID.
- (Comparison will be on the binary representations of the UUIDs)
-
limit: The maximum number of documents to return. This field is by default 1024, and
cannot be set larger than 1024
+ path_gt: All documents returned will have a path strictly greater than the provided
+ `path_gt` argument. (Comparison will be based on lexicographic comparison. It is
+ guaranteed that two strings are lexicographically equal if and only if they have
+ identical binary representations.).
+
+ path_prefix: All documents returned will have a path that starts with the provided path
+ prefix.
+
extra_headers: Send extra headers
extra_query: Add additional query parameters to the request
@@ -350,8 +353,9 @@ def get_info_list(
body=maybe_transform(
{
"collection_name": collection_name,
- "id_gt": id_gt,
"limit": limit,
+ "path_gt": path_gt,
+ "path_prefix": path_prefix,
},
document_get_info_list_params.DocumentGetInfoListParams,
),
@@ -694,8 +698,9 @@ async def get_info_list(
self,
*,
collection_name: str,
- id_gt: Optional[str] | NotGiven = NOT_GIVEN,
limit: int | NotGiven = NOT_GIVEN,
+ path_gt: Optional[str] | NotGiven = NOT_GIVEN,
+ path_prefix: Optional[str] | NotGiven = NOT_GIVEN,
# 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,
@@ -707,9 +712,9 @@ async def get_info_list(
Retrives a list of document metadata information that matches the provided
filters.
- The documents returned will be sorted by ID in ascending order. `id_gt` can be
- used for pagination, and should be set to the ID of the last document returned
- in the previous call.
+ The documents returned will be sorted by path in lexicographically ascending
+ order. `path_gt` can be used for pagination, and should be set to the path of
+ the last document returned in the previous call.
A `404 Not Found` will be returned if either the collection name does not exist,
or the document path does not exist within the provided collection.
@@ -717,12 +722,17 @@ async def get_info_list(
Args:
collection_name: The name of the collection.
- id_gt: All documents returned will have a UUID strictly greater than the provided UUID.
- (Comparison will be on the binary representations of the UUIDs)
-
limit: The maximum number of documents to return. This field is by default 1024, and
cannot be set larger than 1024
+ path_gt: All documents returned will have a path strictly greater than the provided
+ `path_gt` argument. (Comparison will be based on lexicographic comparison. It is
+ guaranteed that two strings are lexicographically equal if and only if they have
+ identical binary representations.).
+
+ path_prefix: All documents returned will have a path that starts with the provided path
+ prefix.
+
extra_headers: Send extra headers
extra_query: Add additional query parameters to the request
@@ -736,8 +746,9 @@ async def get_info_list(
body=await async_maybe_transform(
{
"collection_name": collection_name,
- "id_gt": id_gt,
"limit": limit,
+ "path_gt": path_gt,
+ "path_prefix": path_prefix,
},
document_get_info_list_params.DocumentGetInfoListParams,
),
diff --git a/src/zeroentropy/resources/parsers.py b/src/zeroentropy/resources/parsers.py
index 9fccea6..0d0d002 100644
--- a/src/zeroentropy/resources/parsers.py
+++ b/src/zeroentropy/resources/parsers.py
@@ -6,10 +6,7 @@
from ..types import parser_parse_document_params
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
diff --git a/src/zeroentropy/resources/queries.py b/src/zeroentropy/resources/queries.py
index 3ae0187..f0c0958 100644
--- a/src/zeroentropy/resources/queries.py
+++ b/src/zeroentropy/resources/queries.py
@@ -9,10 +9,7 @@
from ..types import query_top_pages_params, query_top_snippets_params, query_top_documents_params
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
@@ -191,6 +188,7 @@ def top_snippets(
k: int,
query: str,
filter: Optional[Dict[str, object]] | NotGiven = NOT_GIVEN,
+ include_document_metadata: bool | NotGiven = NOT_GIVEN,
latency_mode: Literal["low"] | NotGiven = NOT_GIVEN,
precise_responses: bool | NotGiven = NOT_GIVEN,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -220,6 +218,10 @@ def top_snippets(
filter: The query filter to apply. Please read [Metadata Filtering](/metadata-filtering)
for more information. If not provided, then all documents will be searched.
+ include_document_metadata: If true, the `document_results` returns will additionally contain document
+ metadata. This is false by default, as returning metadata can add overhead if
+ the amount of data to return is large.
+
latency_mode: Note that for Top K Snippets, only latency_mode "low" is available. This option
selects between our latency modes. The higher latency mode takes longer, but can
allow for more accurate responses. If desired, test both to customize your
@@ -247,6 +249,7 @@ def top_snippets(
"k": k,
"query": query,
"filter": filter,
+ "include_document_metadata": include_document_metadata,
"latency_mode": latency_mode,
"precise_responses": precise_responses,
},
@@ -421,6 +424,7 @@ async def top_snippets(
k: int,
query: str,
filter: Optional[Dict[str, object]] | NotGiven = NOT_GIVEN,
+ include_document_metadata: bool | NotGiven = NOT_GIVEN,
latency_mode: Literal["low"] | NotGiven = NOT_GIVEN,
precise_responses: bool | NotGiven = NOT_GIVEN,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -450,6 +454,10 @@ async def top_snippets(
filter: The query filter to apply. Please read [Metadata Filtering](/metadata-filtering)
for more information. If not provided, then all documents will be searched.
+ include_document_metadata: If true, the `document_results` returns will additionally contain document
+ metadata. This is false by default, as returning metadata can add overhead if
+ the amount of data to return is large.
+
latency_mode: Note that for Top K Snippets, only latency_mode "low" is available. This option
selects between our latency modes. The higher latency mode takes longer, but can
allow for more accurate responses. If desired, test both to customize your
@@ -477,6 +485,7 @@ async def top_snippets(
"k": k,
"query": query,
"filter": filter,
+ "include_document_metadata": include_document_metadata,
"latency_mode": latency_mode,
"precise_responses": precise_responses,
},
diff --git a/src/zeroentropy/resources/status.py b/src/zeroentropy/resources/status.py
index fe605b0..5cba378 100644
--- a/src/zeroentropy/resources/status.py
+++ b/src/zeroentropy/resources/status.py
@@ -8,10 +8,7 @@
from ..types import status_get_status_params
from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven
-from .._utils import (
- maybe_transform,
- async_maybe_transform,
-)
+from .._utils import maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
diff --git a/src/zeroentropy/types/admin_create_organization_response.py b/src/zeroentropy/types/admin_create_organization_response.py
index b1d4b8d..b2b8992 100644
--- a/src/zeroentropy/types/admin_create_organization_response.py
+++ b/src/zeroentropy/types/admin_create_organization_response.py
@@ -1,6 +1,5 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
from .._models import BaseModel
__all__ = ["AdminCreateOrganizationResponse"]
diff --git a/src/zeroentropy/types/document_get_info_list_params.py b/src/zeroentropy/types/document_get_info_list_params.py
index 7558f73..38d1a31 100644
--- a/src/zeroentropy/types/document_get_info_list_params.py
+++ b/src/zeroentropy/types/document_get_info_list_params.py
@@ -12,14 +12,22 @@ class DocumentGetInfoListParams(TypedDict, total=False):
collection_name: Required[str]
"""The name of the collection."""
- id_gt: Optional[str]
- """All documents returned will have a UUID strictly greater than the provided UUID.
-
- (Comparison will be on the binary representations of the UUIDs)
- """
-
limit: int
"""The maximum number of documents to return.
This field is by default 1024, and cannot be set larger than 1024
"""
+
+ path_gt: Optional[str]
+ """
+ All documents returned will have a path strictly greater than the provided
+ `path_gt` argument. (Comparison will be based on lexicographic comparison. It is
+ guaranteed that two strings are lexicographically equal if and only if they have
+ identical binary representations.).
+ """
+
+ path_prefix: Optional[str]
+ """
+ All documents returned will have a path that starts with the provided path
+ prefix.
+ """
diff --git a/src/zeroentropy/types/document_get_info_list_response.py b/src/zeroentropy/types/document_get_info_list_response.py
index 50b3817..1363ef7 100644
--- a/src/zeroentropy/types/document_get_info_list_response.py
+++ b/src/zeroentropy/types/document_get_info_list_response.py
@@ -13,6 +13,17 @@ class Document(BaseModel):
collection_name: str
+ created_at: str
+
+ file_url: str
+ """
+ A URL to the document data, which can be used to download the raw document
+ content or to display the document in frontend applications.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
+ """
+
index_status: Literal[
"not_parsed", "parsing", "not_indexed", "indexing", "indexed", "parsing_failed", "indexing_failed"
]
@@ -28,6 +39,9 @@ class Document(BaseModel):
path: str
+ size: int
+ """The total size of the raw document data, in bytes."""
+
class DocumentGetInfoListResponse(BaseModel):
documents: List[Document]
diff --git a/src/zeroentropy/types/document_get_info_response.py b/src/zeroentropy/types/document_get_info_response.py
index 7880c75..4f9b5b4 100644
--- a/src/zeroentropy/types/document_get_info_response.py
+++ b/src/zeroentropy/types/document_get_info_response.py
@@ -13,6 +13,15 @@ class Document(BaseModel):
collection_name: str
+ file_url: str
+ """
+ A URL to the document data, which can be used to download the raw document
+ content or to display the document in frontend applications.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
+ """
+
index_status: Literal[
"not_parsed", "parsing", "not_indexed", "indexing", "indexed", "parsing_failed", "indexing_failed"
]
@@ -28,6 +37,9 @@ class Document(BaseModel):
path: str
+ size: int
+ """The total size of the raw document data, in bytes."""
+
content: Optional[str] = None
"""This will be `null`, unless `include_content` was available and set to `true`."""
diff --git a/src/zeroentropy/types/document_get_page_info_response.py b/src/zeroentropy/types/document_get_page_info_response.py
index 69ec809..77e79a4 100644
--- a/src/zeroentropy/types/document_get_page_info_response.py
+++ b/src/zeroentropy/types/document_get_page_info_response.py
@@ -19,6 +19,9 @@ class Page(BaseModel):
This field will only be provided if the document has finished parsing, and if it
is a filetype that is capable of producing images (e.g. PDF, DOCX, PPT, etc). In
all other cases, this field will be `null`.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
"""
page_index: int
diff --git a/src/zeroentropy/types/document_update_response.py b/src/zeroentropy/types/document_update_response.py
index f3e3cb3..13203cd 100644
--- a/src/zeroentropy/types/document_update_response.py
+++ b/src/zeroentropy/types/document_update_response.py
@@ -1,6 +1,5 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
from .._models import BaseModel
__all__ = ["DocumentUpdateResponse"]
diff --git a/src/zeroentropy/types/query_top_documents_response.py b/src/zeroentropy/types/query_top_documents_response.py
index 876d39d..eb239f8 100644
--- a/src/zeroentropy/types/query_top_documents_response.py
+++ b/src/zeroentropy/types/query_top_documents_response.py
@@ -8,6 +8,15 @@
class Result(BaseModel):
+ file_url: str
+ """
+ A URL to the document data, which can be used to download the raw document
+ content or to display the document in frontend applications.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
+ """
+
metadata: Optional[Dict[str, Union[str, List[str]]]] = None
"""The metadata for that document.
diff --git a/src/zeroentropy/types/query_top_pages_response.py b/src/zeroentropy/types/query_top_pages_response.py
index 66ca7bf..6fe0c3e 100644
--- a/src/zeroentropy/types/query_top_pages_response.py
+++ b/src/zeroentropy/types/query_top_pages_response.py
@@ -21,6 +21,9 @@ class Result(BaseModel):
This field will only be provided if the document has finished parsing, and if it
is a filetype that is capable of producing images (e.g. PDF, DOCX, PPT, etc). In
all other cases, this field will be `null`.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
"""
page_index: int
diff --git a/src/zeroentropy/types/query_top_snippets_params.py b/src/zeroentropy/types/query_top_snippets_params.py
index 23a7e95..e7a29e2 100644
--- a/src/zeroentropy/types/query_top_snippets_params.py
+++ b/src/zeroentropy/types/query_top_snippets_params.py
@@ -33,6 +33,13 @@ class QueryTopSnippetsParams(TypedDict, total=False):
not provided, then all documents will be searched.
"""
+ include_document_metadata: bool
+ """
+ If true, the `document_results` returns will additionally contain document
+ metadata. This is false by default, as returning metadata can add overhead if
+ the amount of data to return is large.
+ """
+
latency_mode: Literal["low"]
"""Note that for Top K Snippets, only latency_mode "low" is available.
diff --git a/src/zeroentropy/types/query_top_snippets_response.py b/src/zeroentropy/types/query_top_snippets_response.py
index 00f50f6..16d25d9 100644
--- a/src/zeroentropy/types/query_top_snippets_response.py
+++ b/src/zeroentropy/types/query_top_snippets_response.py
@@ -1,10 +1,33 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-from typing import List, Optional
+from typing import Dict, List, Union, Optional
from .._models import BaseModel
-__all__ = ["QueryTopSnippetsResponse", "Result"]
+__all__ = ["QueryTopSnippetsResponse", "DocumentResult", "Result"]
+
+
+class DocumentResult(BaseModel):
+ file_url: str
+ """
+ A URL to the document data, which can be used to download the raw document
+ content or to display the document in frontend applications.
+
+ NOTE: If a `/documents/update-document` call returned a new document id, then
+ this url will be invalidated and must be retrieved again.
+ """
+
+ metadata: Optional[Dict[str, Union[str, List[str]]]] = None
+ """The metadata for that document.
+
+ Will be `None` if `include_metadata` is `False`.
+ """
+
+ path: str
+ """The path of the document."""
+
+ score: float
+ """The relevancy score assigned to this document."""
class Result(BaseModel):
@@ -14,7 +37,7 @@ class Result(BaseModel):
end_index: int
"""The end index of this snippet."""
- page_span: List[int]
+ page_span: List[object]
"""The range of page indices spanned by this snippet, as a 2-tuple of integers.
Inclusive on the first page_index and exclusive on the second page_index.
@@ -31,4 +54,19 @@ class Result(BaseModel):
class QueryTopSnippetsResponse(BaseModel):
+ document_results: List[DocumentResult]
+ """The array of associated document information.
+
+ Note how each snippet has an associated document path. After deduplicating the
+ document paths, this array will contain document info for each document path
+ that is referenced by at least one snippet result.
+ """
+
results: List[Result]
+ """The array of snippets returned by this endpoint.
+
+ Each snippet result 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` of a snippet refer to the range of
+ characters in that string, that have been matched by this snippet.
+ """
diff --git a/src/zeroentropy/types/status_get_status_response.py b/src/zeroentropy/types/status_get_status_response.py
index 40a1686..f9aaf57 100644
--- a/src/zeroentropy/types/status_get_status_response.py
+++ b/src/zeroentropy/types/status_get_status_response.py
@@ -1,6 +1,5 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
from .._models import BaseModel
__all__ = ["StatusGetStatusResponse"]
diff --git a/tests/api_resources/test_documents.py b/tests/api_resources/test_documents.py
index 582bd8b..c0228c3 100644
--- a/tests/api_resources/test_documents.py
+++ b/tests/api_resources/test_documents.py
@@ -215,8 +215,9 @@ def test_method_get_info_list(self, client: ZeroEntropy) -> None:
def test_method_get_info_list_with_all_params(self, client: ZeroEntropy) -> None:
document = client.documents.get_info_list(
collection_name="collection_name",
- id_gt="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
limit=0,
+ path_gt="path_gt",
+ path_prefix="path_prefix",
)
assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
@@ -486,8 +487,9 @@ async def test_method_get_info_list(self, async_client: AsyncZeroEntropy) -> Non
async def test_method_get_info_list_with_all_params(self, async_client: AsyncZeroEntropy) -> None:
document = await async_client.documents.get_info_list(
collection_name="collection_name",
- id_gt="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
limit=0,
+ path_gt="path_gt",
+ path_prefix="path_prefix",
)
assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
diff --git a/tests/api_resources/test_queries.py b/tests/api_resources/test_queries.py
index 0fe62a7..f406a4f 100644
--- a/tests/api_resources/test_queries.py
+++ b/tests/api_resources/test_queries.py
@@ -135,6 +135,7 @@ def test_method_top_snippets_with_all_params(self, client: ZeroEntropy) -> None:
k=0,
query="query",
filter={"foo": "bar"},
+ include_document_metadata=True,
latency_mode="low",
precise_responses=True,
)
@@ -286,6 +287,7 @@ async def test_method_top_snippets_with_all_params(self, async_client: AsyncZero
k=0,
query="query",
filter={"foo": "bar"},
+ include_document_metadata=True,
latency_mode="low",
precise_responses=True,
)
diff --git a/tests/conftest.py b/tests/conftest.py
index 46ee434..9ec316c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,7 +10,7 @@
from zeroentropy import ZeroEntropy, AsyncZeroEntropy
if TYPE_CHECKING:
- from _pytest.fixtures import FixtureRequest
+ from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage]
pytest.register_assert_rewrite("tests.utils")
diff --git a/tests/test_client.py b/tests/test_client.py
index cb180b3..8113248 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -828,6 +828,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
assert response.http_request.headers.get("x-stainless-retry-count") == "42"
+ @pytest.mark.respx(base_url=base_url)
+ def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ # Test that the default follow_redirects=True allows following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+ respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
+
+ response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ # Test that follow_redirects=False prevents following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+
+ with pytest.raises(APIStatusError) as exc_info:
+ self.client.post(
+ "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
+ )
+
+ assert exc_info.value.response.status_code == 302
+ assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
+
class TestAsyncZeroEntropy:
client = AsyncZeroEntropy(base_url=base_url, api_key=api_key, _strict_response_validation=True)
@@ -1619,7 +1646,7 @@ def test_get_platform(self) -> None:
import threading
from zeroentropy._utils import asyncify
- from zeroentropy._base_client import get_platform
+ from zeroentropy._base_client import get_platform
async def test_main() -> None:
result = await asyncify(get_platform)()
@@ -1651,3 +1678,30 @@ async def test_main() -> None:
raise AssertionError("calling get_platform using asyncify resulted in a hung process")
time.sleep(0.1)
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ # Test that the default follow_redirects=True allows following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+ respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
+
+ response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ # Test that follow_redirects=False prevents following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+
+ with pytest.raises(APIStatusError) as exc_info:
+ await self.client.post(
+ "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
+ )
+
+ assert exc_info.value.response.status_code == 302
+ assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
diff --git a/tests/test_models.py b/tests/test_models.py
index 994d6a0..19461b3 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -492,12 +492,15 @@ class Model(BaseModel):
resource_id: Optional[str] = None
m = Model.construct()
+ assert m.resource_id is None
assert "resource_id" not in m.model_fields_set
m = Model.construct(resource_id=None)
+ assert m.resource_id is None
assert "resource_id" in m.model_fields_set
m = Model.construct(resource_id="foo")
+ assert m.resource_id == "foo"
assert "resource_id" in m.model_fields_set
@@ -832,7 +835,7 @@ class B(BaseModel):
@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1")
def test_type_alias_type() -> None:
- Alias = TypeAliasType("Alias", str)
+ Alias = TypeAliasType("Alias", str) # pyright: ignore
class Model(BaseModel):
alias: Alias
diff --git a/tests/test_transform.py b/tests/test_transform.py
index 7747fb4..956dba0 100644
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -8,7 +8,7 @@
import pytest
-from zeroentropy._types import Base64FileInput
+from zeroentropy._types import NOT_GIVEN, Base64FileInput
from zeroentropy._utils import (
PropertyInfo,
transform as _transform,
@@ -432,3 +432,22 @@ async def test_base64_file_input(use_async: bool) -> None:
assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == {
"foo": "SGVsbG8sIHdvcmxkIQ=="
} # type: ignore[comparison-overlap]
+
+
+@parametrize
+@pytest.mark.asyncio
+async def test_transform_skipping(use_async: bool) -> None:
+ # lists of ints are left as-is
+ data = [1, 2, 3]
+ assert await transform(data, List[int], use_async) is data
+
+ # iterables of ints are converted to a list
+ data = iter([1, 2, 3])
+ assert await transform(data, Iterable[int], use_async) == [1, 2, 3]
+
+
+@parametrize
+@pytest.mark.asyncio
+async def test_strips_notgiven(use_async: bool) -> None:
+ assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"}
+ assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {}
diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py
index 18e246f..3782ed4 100644
--- a/tests/test_utils/test_proxy.py
+++ b/tests/test_utils/test_proxy.py
@@ -21,3 +21,14 @@ def test_recursive_proxy() -> None:
assert dir(proxy) == []
assert type(proxy).__name__ == "RecursiveLazyProxy"
assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy"
+
+
+def test_isinstance_does_not_error() -> None:
+ class AlwaysErrorProxy(LazyProxy[Any]):
+ @override
+ def __load__(self) -> Any:
+ raise RuntimeError("Mocking missing dependency")
+
+ proxy = AlwaysErrorProxy()
+ assert not isinstance(proxy, dict)
+ assert isinstance(proxy, LazyProxy)
From 28723016250ec0bba2196d157efd920b79a524b5 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:29:41 +0000
Subject: [PATCH 27/30] feat(api): manual updates
---
.stats.yml | 2 +-
src/zeroentropy/pagination.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index d4574b4..b84f19f 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 15
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-6d4d2509d6d6d6f6cb90dcad9ddc75dc7562ff8ed3a055bee111921a83cb26d3.yml
openapi_spec_hash: 9cf0d15c10c9061cebd0816868366d1d
-config_hash: caa8421b07b8eb2eeb82175ec8427837
+config_hash: a7dc8380ffa9d78eee964a96e0eb7b54
diff --git a/src/zeroentropy/pagination.py b/src/zeroentropy/pagination.py
index e298814..aa4b575 100644
--- a/src/zeroentropy/pagination.py
+++ b/src/zeroentropy/pagination.py
@@ -36,7 +36,7 @@ def next_page_info(self) -> Optional[PageInfo]:
# TODO emit warning log
return None
- return PageInfo(params={"id_gt": item.id})
+ return PageInfo(params={"path_gt": item.id})
class AsyncGetDocumentInfoListCursor(BaseAsyncPage[_T], BasePage[_T], Generic[_T]):
@@ -60,4 +60,4 @@ def next_page_info(self) -> Optional[PageInfo]:
# TODO emit warning log
return None
- return PageInfo(params={"id_gt": item.id})
+ return PageInfo(params={"path_gt": item.id})
From da3806c9866a91252236ff7ff10b5a2eaf9d336a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:30:22 +0000
Subject: [PATCH 28/30] feat(api): manual updates
---
.stats.yml | 4 ++--
src/zeroentropy/types/query_top_snippets_response.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index b84f19f..e679301 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 15
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-6d4d2509d6d6d6f6cb90dcad9ddc75dc7562ff8ed3a055bee111921a83cb26d3.yml
-openapi_spec_hash: 9cf0d15c10c9061cebd0816868366d1d
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-f06c49dfd4b38a4f9d5bcad56348156bbf641aa8b7968acfbf655ad6ceff2126.yml
+openapi_spec_hash: cac52dd65fbcb65ffa7a183e764b7f06
config_hash: a7dc8380ffa9d78eee964a96e0eb7b54
diff --git a/src/zeroentropy/types/query_top_snippets_response.py b/src/zeroentropy/types/query_top_snippets_response.py
index 16d25d9..3fc1450 100644
--- a/src/zeroentropy/types/query_top_snippets_response.py
+++ b/src/zeroentropy/types/query_top_snippets_response.py
@@ -37,7 +37,7 @@ class Result(BaseModel):
end_index: int
"""The end index of this snippet."""
- page_span: List[object]
+ page_span: List[int]
"""The range of page indices spanned by this snippet, as a 2-tuple of integers.
Inclusive on the first page_index and exclusive on the second page_index.
From 4185d7aecfe3cca9e31679b2c6ef37776b98b91f Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:41:44 +0000
Subject: [PATCH 29/30] feat(api): manual updates
---
.stats.yml | 2 +-
README.md | 71 +++++++++++++++++++
api.md | 2 +-
src/zeroentropy/pagination.py | 4 +-
src/zeroentropy/resources/documents.py | 23 +++---
.../types/document_get_info_list_response.py | 8 +--
tests/api_resources/test_documents.py | 19 ++---
7 files changed, 102 insertions(+), 27 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index e679301..482b6d4 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 15
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-f06c49dfd4b38a4f9d5bcad56348156bbf641aa8b7968acfbf655ad6ceff2126.yml
openapi_spec_hash: cac52dd65fbcb65ffa7a183e764b7f06
-config_hash: a7dc8380ffa9d78eee964a96e0eb7b54
+config_hash: 34c8a6deaedce51a258bc46b38c9caa0
diff --git a/README.md b/README.md
index a418971..a0c514e 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,77 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ
Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`.
+## Pagination
+
+List methods in the ZeroEntropy API are paginated.
+
+This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually:
+
+```python
+from zeroentropy import ZeroEntropy
+
+client = ZeroEntropy()
+
+all_documents = []
+# Automatically fetches more pages as needed.
+for document in client.documents.get_info_list(
+ collection_name="example_collection",
+):
+ # Do something with document here
+ all_documents.append(document)
+print(all_documents)
+```
+
+Or, asynchronously:
+
+```python
+import asyncio
+from zeroentropy import AsyncZeroEntropy
+
+client = AsyncZeroEntropy()
+
+
+async def main() -> None:
+ all_documents = []
+ # Iterate through items across all pages, issuing requests as needed.
+ async for document in client.documents.get_info_list(
+ collection_name="example_collection",
+ ):
+ all_documents.append(document)
+ print(all_documents)
+
+
+asyncio.run(main())
+```
+
+Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages:
+
+```python
+first_page = await client.documents.get_info_list(
+ collection_name="example_collection",
+)
+if first_page.has_next_page():
+ print(f"will fetch next page using these details: {first_page.next_page_info()}")
+ next_page = await first_page.get_next_page()
+ print(f"number of items we just fetched: {len(next_page.documents)}")
+
+# Remove `await` for non-async usage.
+```
+
+Or just work directly with the returned data:
+
+```python
+first_page = await client.documents.get_info_list(
+ collection_name="example_collection",
+)
+
+print(f"next page cursor: {first_page.path_gt}") # => "next page cursor: ..."
+for document in first_page.documents:
+ print(document.id)
+
+# Remove `await` for non-async usage.
+```
+
## Handling errors
When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `zeroentropy.APIConnectionError` is raised.
diff --git a/api.md b/api.md
index 4342be0..8c93fa7 100644
--- a/api.md
+++ b/api.md
@@ -61,7 +61,7 @@ Methods:
- client.documents.delete(\*\*params) -> DocumentDeleteResponse
- client.documents.add(\*\*params) -> DocumentAddResponse
- client.documents.get_info(\*\*params) -> DocumentGetInfoResponse
-- client.documents.get_info_list(\*\*params) -> DocumentGetInfoListResponse
+- client.documents.get_info_list(\*\*params) -> SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]
- client.documents.get_page_info(\*\*params) -> DocumentGetPageInfoResponse
# Queries
diff --git a/src/zeroentropy/pagination.py b/src/zeroentropy/pagination.py
index aa4b575..88e7e0d 100644
--- a/src/zeroentropy/pagination.py
+++ b/src/zeroentropy/pagination.py
@@ -36,7 +36,7 @@ def next_page_info(self) -> Optional[PageInfo]:
# TODO emit warning log
return None
- return PageInfo(params={"path_gt": item.id})
+ return PageInfo(json={"path_gt": item.id})
class AsyncGetDocumentInfoListCursor(BaseAsyncPage[_T], BasePage[_T], Generic[_T]):
@@ -60,4 +60,4 @@ def next_page_info(self) -> Optional[PageInfo]:
# TODO emit warning log
return None
- return PageInfo(params={"path_gt": item.id})
+ return PageInfo(json={"path_gt": item.id})
diff --git a/src/zeroentropy/resources/documents.py b/src/zeroentropy/resources/documents.py
index ef220c6..f23eba1 100644
--- a/src/zeroentropy/resources/documents.py
+++ b/src/zeroentropy/resources/documents.py
@@ -24,7 +24,8 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
-from .._base_client import make_request_options
+from ..pagination import SyncGetDocumentInfoListCursor, AsyncGetDocumentInfoListCursor
+from .._base_client import AsyncPaginator, make_request_options
from ..types.document_add_response import DocumentAddResponse
from ..types.document_delete_response import DocumentDeleteResponse
from ..types.document_update_response import DocumentUpdateResponse
@@ -314,7 +315,7 @@ def get_info_list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
- ) -> DocumentGetInfoListResponse:
+ ) -> SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]:
"""
Retrives a list of document metadata information that matches the provided
filters.
@@ -348,8 +349,9 @@ def get_info_list(
timeout: Override the client-level default timeout for this request, in seconds
"""
- return self._post(
+ return self._get_api_list(
"/documents/get-document-info-list",
+ page=SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse],
body=maybe_transform(
{
"collection_name": collection_name,
@@ -362,7 +364,8 @@ def get_info_list(
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
- cast_to=DocumentGetInfoListResponse,
+ model=DocumentGetInfoListResponse,
+ method="post",
)
def get_page_info(
@@ -694,7 +697,7 @@ async def get_info(
cast_to=DocumentGetInfoResponse,
)
- async def get_info_list(
+ def get_info_list(
self,
*,
collection_name: str,
@@ -707,7 +710,7 @@ async def get_info_list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
- ) -> DocumentGetInfoListResponse:
+ ) -> AsyncPaginator[DocumentGetInfoListResponse, AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse]]:
"""
Retrives a list of document metadata information that matches the provided
filters.
@@ -741,9 +744,10 @@ async def get_info_list(
timeout: Override the client-level default timeout for this request, in seconds
"""
- return await self._post(
+ return self._get_api_list(
"/documents/get-document-info-list",
- body=await async_maybe_transform(
+ page=AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse],
+ body=maybe_transform(
{
"collection_name": collection_name,
"limit": limit,
@@ -755,7 +759,8 @@ async def get_info_list(
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
- cast_to=DocumentGetInfoListResponse,
+ model=DocumentGetInfoListResponse,
+ method="post",
)
async def get_page_info(
diff --git a/src/zeroentropy/types/document_get_info_list_response.py b/src/zeroentropy/types/document_get_info_list_response.py
index 1363ef7..1df6f8b 100644
--- a/src/zeroentropy/types/document_get_info_list_response.py
+++ b/src/zeroentropy/types/document_get_info_list_response.py
@@ -5,10 +5,10 @@
from .._models import BaseModel
-__all__ = ["DocumentGetInfoListResponse", "Document"]
+__all__ = ["DocumentGetInfoListResponse"]
-class Document(BaseModel):
+class DocumentGetInfoListResponse(BaseModel):
id: str
collection_name: str
@@ -41,7 +41,3 @@ class Document(BaseModel):
size: int
"""The total size of the raw document data, in bytes."""
-
-
-class DocumentGetInfoListResponse(BaseModel):
- documents: List[Document]
diff --git a/tests/api_resources/test_documents.py b/tests/api_resources/test_documents.py
index c0228c3..dd80f98 100644
--- a/tests/api_resources/test_documents.py
+++ b/tests/api_resources/test_documents.py
@@ -17,6 +17,7 @@
DocumentGetInfoListResponse,
DocumentGetPageInfoResponse,
)
+from zeroentropy.pagination import SyncGetDocumentInfoListCursor, AsyncGetDocumentInfoListCursor
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -209,7 +210,7 @@ def test_method_get_info_list(self, client: ZeroEntropy) -> None:
document = client.documents.get_info_list(
collection_name="collection_name",
)
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
def test_method_get_info_list_with_all_params(self, client: ZeroEntropy) -> None:
@@ -219,7 +220,7 @@ def test_method_get_info_list_with_all_params(self, client: ZeroEntropy) -> None
path_gt="path_gt",
path_prefix="path_prefix",
)
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
def test_raw_response_get_info_list(self, client: ZeroEntropy) -> None:
@@ -230,7 +231,7 @@ def test_raw_response_get_info_list(self, client: ZeroEntropy) -> None:
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = response.parse()
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
def test_streaming_response_get_info_list(self, client: ZeroEntropy) -> None:
@@ -241,7 +242,7 @@ def test_streaming_response_get_info_list(self, client: ZeroEntropy) -> None:
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = response.parse()
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
assert cast(Any, response.is_closed) is True
@@ -481,7 +482,7 @@ async def test_method_get_info_list(self, async_client: AsyncZeroEntropy) -> Non
document = await async_client.documents.get_info_list(
collection_name="collection_name",
)
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
async def test_method_get_info_list_with_all_params(self, async_client: AsyncZeroEntropy) -> None:
@@ -491,7 +492,7 @@ async def test_method_get_info_list_with_all_params(self, async_client: AsyncZer
path_gt="path_gt",
path_prefix="path_prefix",
)
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
async def test_raw_response_get_info_list(self, async_client: AsyncZeroEntropy) -> None:
@@ -502,7 +503,7 @@ async def test_raw_response_get_info_list(self, async_client: AsyncZeroEntropy)
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = await response.parse()
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"])
@parametrize
async def test_streaming_response_get_info_list(self, async_client: AsyncZeroEntropy) -> None:
@@ -513,7 +514,9 @@ async def test_streaming_response_get_info_list(self, async_client: AsyncZeroEnt
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
document = await response.parse()
- assert_matches_type(DocumentGetInfoListResponse, document, path=["response"])
+ assert_matches_type(
+ AsyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"]
+ )
assert cast(Any, response.is_closed) is True
From 6a6f0f2bfc96fc33dcfcd33b9827d2d7230153f3 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 22:51:30 +0000
Subject: [PATCH 30/30] release: 0.1.0-alpha.4
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++
pyproject.toml | 2 +-
src/zeroentropy/_version.py | 2 +-
4 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index aaf968a..b56c3d0 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.1.0-alpha.3"
+ ".": "0.1.0-alpha.4"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b830f65..32cf338 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,52 @@
# Changelog
+## 0.1.0-alpha.4 (2025-06-03)
+
+Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.3...v0.1.0-alpha.4)
+
+### Features
+
+* **api:** manual updates ([4185d7a](https://github.com/zeroentropy-ai/zeroentropy-python/commit/4185d7aecfe3cca9e31679b2c6ef37776b98b91f))
+* **api:** manual updates ([da3806c](https://github.com/zeroentropy-ai/zeroentropy-python/commit/da3806c9866a91252236ff7ff10b5a2eaf9d336a))
+* **api:** manual updates ([2872301](https://github.com/zeroentropy-ai/zeroentropy-python/commit/28723016250ec0bba2196d157efd920b79a524b5))
+* **api:** manual updates ([1444264](https://github.com/zeroentropy-ai/zeroentropy-python/commit/1444264df4b3d12c6df32621622e9b4ae2d35645))
+* **client:** allow passing `NotGiven` for body ([#33](https://github.com/zeroentropy-ai/zeroentropy-python/issues/33)) ([fc2b31c](https://github.com/zeroentropy-ai/zeroentropy-python/commit/fc2b31c7c6f11f046ae4aa7415fb9d58b450a0b5))
+* **client:** send `X-Stainless-Read-Timeout` header ([#27](https://github.com/zeroentropy-ai/zeroentropy-python/issues/27)) ([2b1bf5a](https://github.com/zeroentropy-ai/zeroentropy-python/commit/2b1bf5a3ede3b9184f95924a33152a12f7237fb0))
+
+
+### Bug Fixes
+
+* asyncify on non-asyncio runtimes ([#31](https://github.com/zeroentropy-ai/zeroentropy-python/issues/31)) ([fd3d006](https://github.com/zeroentropy-ai/zeroentropy-python/commit/fd3d006d7d08b6904fb9b56a9c1bd9b2b5c408f4))
+* **ci:** ensure pip is always available ([#45](https://github.com/zeroentropy-ai/zeroentropy-python/issues/45)) ([544dcae](https://github.com/zeroentropy-ai/zeroentropy-python/commit/544dcaee18e2bfa523642f59e9e1175488b6fde8))
+* **ci:** remove publishing patch ([#46](https://github.com/zeroentropy-ai/zeroentropy-python/issues/46)) ([a2b0950](https://github.com/zeroentropy-ai/zeroentropy-python/commit/a2b09502670c7142eb0ad2947f1b8d610632a532))
+* **client:** mark some request bodies as optional ([fc2b31c](https://github.com/zeroentropy-ai/zeroentropy-python/commit/fc2b31c7c6f11f046ae4aa7415fb9d58b450a0b5))
+* **types:** handle more discriminated union shapes ([#44](https://github.com/zeroentropy-ai/zeroentropy-python/issues/44)) ([c2760e1](https://github.com/zeroentropy-ai/zeroentropy-python/commit/c2760e1fc8ec87deda30efd5f35cb812d9c27623))
+
+
+### Chores
+
+* **docs:** update client docstring ([#38](https://github.com/zeroentropy-ai/zeroentropy-python/issues/38)) ([c642337](https://github.com/zeroentropy-ai/zeroentropy-python/commit/c64233754816e51e25fbd988110d6d677bae971f))
+* fix typos ([#47](https://github.com/zeroentropy-ai/zeroentropy-python/issues/47)) ([8fe5be4](https://github.com/zeroentropy-ai/zeroentropy-python/commit/8fe5be4670c414102d31ca5501bcec49c3142dba))
+* **internal:** bummp ruff dependency ([#26](https://github.com/zeroentropy-ai/zeroentropy-python/issues/26)) ([02a42b4](https://github.com/zeroentropy-ai/zeroentropy-python/commit/02a42b40a585d70fbaec2a28e3534ffa0f86968b))
+* **internal:** bump rye to 0.44.0 ([#43](https://github.com/zeroentropy-ai/zeroentropy-python/issues/43)) ([027f69f](https://github.com/zeroentropy-ai/zeroentropy-python/commit/027f69f2275239cfcbaa8658df2f1d5d5d9e6595))
+* **internal:** change default timeout to an int ([#25](https://github.com/zeroentropy-ai/zeroentropy-python/issues/25)) ([9a3559f](https://github.com/zeroentropy-ai/zeroentropy-python/commit/9a3559f3f98f1699fe89a03239a9426860c097ef))
+* **internal:** codegen related update ([#42](https://github.com/zeroentropy-ai/zeroentropy-python/issues/42)) ([8cb037b](https://github.com/zeroentropy-ai/zeroentropy-python/commit/8cb037b4bf93d1ae9879ab7b456b563b0da38e4d))
+* **internal:** codegen related update ([#48](https://github.com/zeroentropy-ai/zeroentropy-python/issues/48)) ([7cfedb5](https://github.com/zeroentropy-ai/zeroentropy-python/commit/7cfedb53ac5ed2c2e7b57ec3fa94c10cdfa5d770))
+* **internal:** fix devcontainers setup ([#34](https://github.com/zeroentropy-ai/zeroentropy-python/issues/34)) ([aebe3f6](https://github.com/zeroentropy-ai/zeroentropy-python/commit/aebe3f69ddf00105381945d6c02858bcb3a42837))
+* **internal:** fix type traversing dictionary params ([#28](https://github.com/zeroentropy-ai/zeroentropy-python/issues/28)) ([298eed2](https://github.com/zeroentropy-ai/zeroentropy-python/commit/298eed24ae48668d7eea7d35a97917e8920656b9))
+* **internal:** minor type handling changes ([#29](https://github.com/zeroentropy-ai/zeroentropy-python/issues/29)) ([9921817](https://github.com/zeroentropy-ai/zeroentropy-python/commit/9921817d4c6c9cdb5487499cedff14a23542440c))
+* **internal:** properly set __pydantic_private__ ([#35](https://github.com/zeroentropy-ai/zeroentropy-python/issues/35)) ([406c052](https://github.com/zeroentropy-ai/zeroentropy-python/commit/406c052ee2860e8af31e6c5d656bedfbde2f79a5))
+* **internal:** remove extra empty newlines ([#41](https://github.com/zeroentropy-ai/zeroentropy-python/issues/41)) ([fd60612](https://github.com/zeroentropy-ai/zeroentropy-python/commit/fd60612d55b1423e96f5aca7e3297bc732c94005))
+* **internal:** remove unused http client options forwarding ([#39](https://github.com/zeroentropy-ai/zeroentropy-python/issues/39)) ([2cabf49](https://github.com/zeroentropy-ai/zeroentropy-python/commit/2cabf492018133ba9870fcc568366e822d3df628))
+* **internal:** update client tests ([#30](https://github.com/zeroentropy-ai/zeroentropy-python/issues/30)) ([329cd1e](https://github.com/zeroentropy-ai/zeroentropy-python/commit/329cd1ee7b7fbd41f4563b829e5c276f42aa8e34))
+* **internal:** update client tests ([#32](https://github.com/zeroentropy-ai/zeroentropy-python/issues/32)) ([d8618e8](https://github.com/zeroentropy-ai/zeroentropy-python/commit/d8618e8fed41ffb439ccce21ef0cb7e0ee3ee6db))
+* **internal:** version bump ([#22](https://github.com/zeroentropy-ai/zeroentropy-python/issues/22)) ([c4fbbe6](https://github.com/zeroentropy-ai/zeroentropy-python/commit/c4fbbe6af970317989f0f6d3d2d40a96f04976e4))
+
+
+### Documentation
+
+* update URLs from stainlessapi.com to stainless.com ([#36](https://github.com/zeroentropy-ai/zeroentropy-python/issues/36)) ([97b55c0](https://github.com/zeroentropy-ai/zeroentropy-python/commit/97b55c0c6482507e6d34f64d418d7a6bf3b68931))
+
## 0.1.0-alpha.3 (2025-01-28)
Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/zeroentropy-ai/zeroentropy-python/compare/v0.1.0-alpha.2...v0.1.0-alpha.3)
diff --git a/pyproject.toml b/pyproject.toml
index 3a300f7..1cf44f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "zeroentropy"
-version = "0.1.0-alpha.3"
+version = "0.1.0-alpha.4"
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 f43d082..dd4cd31 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.3" # x-release-please-version
+__version__ = "0.1.0-alpha.4" # x-release-please-version