diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ac9a2e7..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" >> /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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a8a4f..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 @@ -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 @@ -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 @@ -42,7 +66,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 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/.stats.yml b/.stats.yml index d9064ff..482b6d4 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_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/zeroentropy%2Fzeroentropy-f06c49dfd4b38a4f9d5bcad56348156bbf641aa8b7968acfbf655ad6ceff2126.yml +openapi_spec_hash: cac52dd65fbcb65ffa7a183e764b7f06 +config_hash: 34c8a6deaedce51a258bc46b38c9caa0 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/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/README.md b/README.md index ae365c1..a0c514e 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ first_page = await client.documents.get_info_list( collection_name="example_collection", ) -print(f"next page cursor: {first_page.id_gt}") # => "next page cursor: ..." +print(f"next page cursor: {first_page.path_gt}") # => "next page cursor: ..." for document in first_page.documents: print(document.id) diff --git a/SECURITY.md b/SECURITY.md index 73d8066..7856016 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 @@ -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/bin/publish-pypi b/bin/publish-pypi index 05bfccb..826054e 100644 --- a/bin/publish-pypi +++ b/bin/publish-pypi @@ -3,7 +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 pip install 'importlib-metadata==7.2.1' rye publish --yes --token=$PYPI_TOKEN diff --git a/pyproject.toml b/pyproject.toml index 164fad7..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" @@ -38,12 +38,11 @@ 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 dev-dependencies = [ - "pyright>=1.1.359", + "pyright==1.1.399", "mypy", "respx", "pytest", @@ -87,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] @@ -148,11 +147,11 @@ exclude = [ ] reportImplicitOverride = true +reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false - [tool.ruff] line-length = 120 output-format = "grouped" @@ -177,7 +176,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..9dd3c5a 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 @@ -68,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 @@ -78,7 +79,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/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 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 "$@" 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/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 4ac6c33..4788795 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,19 +50,16 @@ Timeout, NotGiven, ResponseT, - Transport, AnyMapping, PostParser, - ProxiesTypes, RequestFiles, HttpxSendArgs, - AsyncTransport, RequestOptions, HttpxRequestFiles, 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, @@ -102,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 @@ -119,6 +119,7 @@ class PageInfo: url: URL | NotGiven params: Query | NotGiven + json: Body | NotGiven @overload def __init__( @@ -134,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})" @@ -195,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") @@ -207,6 +232,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 +320,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 @@ -331,9 +362,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 @@ -346,9 +374,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: @@ -356,9 +381,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 @@ -415,13 +437,20 @@ 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 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 @@ -511,7 +540,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, ) @@ -787,46 +816,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. @@ -847,12 +841,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, @@ -862,9 +853,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: @@ -914,7 +902,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[True], stream_cls: Type[_StreamT], @@ -925,7 +912,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[False] = False, ) -> ResponseT: ... @@ -935,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, @@ -945,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, @@ -1069,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, *, @@ -1359,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. @@ -1419,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, @@ -1433,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: @@ -1484,7 +1402,6 @@ async def request( options: FinalRequestOptions, *, stream: Literal[False] = False, - remaining_retries: Optional[int] = None, ) -> ResponseT: ... @overload @@ -1495,7 +1412,6 @@ async def request( *, stream: Literal[True], stream_cls: type[_AsyncStreamT], - remaining_retries: Optional[int] = None, ) -> _AsyncStreamT: ... @overload @@ -1506,7 +1422,6 @@ async def request( *, stream: bool, stream_cls: type[_AsyncStreamT] | None = None, - remaining_retries: Optional[int] = None, ) -> ResponseT | _AsyncStreamT: ... async def request( @@ -1516,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 - - log.debug( - 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase - ) + 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 - 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, - ) + # 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() - # 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 - 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, @@ -1635,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 9b3dc0f..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 @@ -81,7 +78,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 +256,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. """ 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) diff --git a/src/zeroentropy/_models.py b/src/zeroentropy/_models.py index 9a918aa..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 ( @@ -65,7 +64,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"] @@ -172,7 +171,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. @@ -426,10 +425,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 +451,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 @@ -621,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 @@ -640,15 +645,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 @@ -672,7 +680,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 @@ -729,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -742,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/_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]]: """ diff --git a/src/zeroentropy/_utils/_transform.py b/src/zeroentropy/_utils/_transform.py index a6b62ca..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, ) @@ -25,7 +27,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") @@ -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. @@ -126,7 +129,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: @@ -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, *, @@ -164,9 +171,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)) @@ -179,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): @@ -240,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 @@ -307,9 +333,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)) @@ -322,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): @@ -383,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 @@ -390,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/_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 diff --git a/src/zeroentropy/pagination.py b/src/zeroentropy/pagination.py index e298814..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={"id_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={"id_gt": item.id}) + return PageInfo(json={"path_gt": item.id}) 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 0d195a2..f23eba1 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 ( @@ -309,8 +306,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, @@ -322,9 +320,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. @@ -332,12 +330,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 @@ -352,8 +355,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, ), @@ -697,8 +701,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, @@ -710,9 +715,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. @@ -720,12 +725,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 @@ -740,8 +750,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, ), 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 a8f1189..1df6f8b 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 DocumentGetInfoListResponse(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" ] @@ -27,3 +38,6 @@ class DocumentGetInfoListResponse(BaseModel): """ path: str + + size: int + """The total size of the raw document data, in bytes.""" 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..3fc1450 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): @@ -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 2e9a3cd..dd80f98 100644 --- a/tests/api_resources/test_documents.py +++ b/tests/api_resources/test_documents.py @@ -216,8 +216,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(SyncGetDocumentInfoListCursor[DocumentGetInfoListResponse], document, path=["response"]) @@ -487,8 +488,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(AsyncGetDocumentInfoListCursor[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 7f1603b..8113248 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({}, 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({}, StatusGetStatusParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -826,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) @@ -1503,7 +1532,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({}, StatusGetStatusParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1518,7 +1547,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({}, StatusGetStatusParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1617,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)() @@ -1649,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 16e041d..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 @@ -854,3 +857,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) diff --git a/tests/test_transform.py b/tests/test_transform.py index 1ea57b6..956dba0 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -2,13 +2,13 @@ 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 import pytest -from zeroentropy._types import Base64FileInput +from zeroentropy._types import NOT_GIVEN, Base64FileInput from zeroentropy._utils import ( PropertyInfo, transform as _transform, @@ -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")] @@ -423,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)