diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75814a6a..beb8dd8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,17 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/writer-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -30,10 +35,49 @@ jobs: - name: Run lints run: ./scripts/lint + build: + if: github.repository == 'stainless-sdks/writer-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + + - 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: ${{ github.repository == 'stainless-sdks/writer-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bfc26f9c..89d8ff81 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.2.0" + ".": "2.2.1" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 6d11c6b0..38d9f82d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 32 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/writerai%2Fwriter-07dea48ea85e600712dcdfd99a688f6a9cb8dd1f56d0a06e0ab54fc8a98a89b1.yml -openapi_spec_hash: 0d30ab04c227bf53f3109dc4d861e5dc +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/writerai%2Fwriter-fab7a71148b6f413a4425ca9f2ce3d42557b65d35ab28c6f64daa7fce6d0ffe2.yml +openapi_spec_hash: 0ead6944545bc40172176e15cc704633 config_hash: c0c9f57ab19252f82cf765939edc61de diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c3cf2a..b1bca812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## 2.2.1 (2025-07-16) + +Full Changelog: [v2.2.0...v2.2.1](https://github.com/writer/writer-python/compare/v2.2.0...v2.2.1) + +### Features + +* clean up environment call outs ([fc436af](https://github.com/writer/writer-python/commit/fc436af17d30723e147c0cc8198886adfa43d0a1)) +* **client:** add follow_redirects request option ([1bf8218](https://github.com/writer/writer-python/commit/1bf8218881286ddf4bf1c9c2c4c8e8b58837425b)) +* **client:** add support for aiohttp ([843d69d](https://github.com/writer/writer-python/commit/843d69decc9eb5c1cb793fc466c026917e8036ce)) + + +### Bug Fixes + +* allow utf-8 encoded filename in headers ([27ebc83](https://github.com/writer/writer-python/commit/27ebc83f9eb76b7ab2208ed8ac991bd857773928)) +* **ci:** correct conditional ([7f49d8d](https://github.com/writer/writer-python/commit/7f49d8d55819747831db0b7baa015b274b18108d)) +* **ci:** release-doctor — report correct token name ([0d20a9e](https://github.com/writer/writer-python/commit/0d20a9e7d8be4f8fbc316480335afd314d112a8a)) +* **client:** correctly parse binary response | stream ([55872c2](https://github.com/writer/writer-python/commit/55872c2302ae5458f872ab34809c85781f0f6f17)) +* **client:** don't send Content-Type header on GET requests ([ffcc579](https://github.com/writer/writer-python/commit/ffcc5799423bca552deabaa0cf98dc2e67d11a39)) +* merged_headers -> default_headers to avoid conflicts ([6b7aeb4](https://github.com/writer/writer-python/commit/6b7aeb4335534bcfafc2f9849fa4162f44002075)) +* **package:** support direct resource imports ([2016cda](https://github.com/writer/writer-python/commit/2016cda2813d2b49abf8f6aa2dafbecdcaddfa36)) +* **parsing:** correctly handle nested discriminated unions ([a4774a8](https://github.com/writer/writer-python/commit/a4774a8592d82d03ff1d2947ee61a909ac40d534)) +* **tests:** fix: tests which call HTTP endpoints directly with the example parameters ([f5db115](https://github.com/writer/writer-python/commit/f5db1150f0c8b53e9c9b471c1b1f555cfa6be2df)) + + +### Chores + +* **ci:** change upload type ([9ee05fb](https://github.com/writer/writer-python/commit/9ee05fb02fede8fcba4418c55361080106f5172e)) +* **ci:** enable for pull requests ([2dc262b](https://github.com/writer/writer-python/commit/2dc262bf297d3d0f42489cf2c3e2ea59cb1a17cc)) +* **ci:** fix installation instructions ([b421fd0](https://github.com/writer/writer-python/commit/b421fd089015ede20a5cfa8214c19dc16b66f27c)) +* **ci:** only run for pushes and fork pull requests ([ced5ec3](https://github.com/writer/writer-python/commit/ced5ec33765e4d8454c597a135d20be8b89e0c4e)) +* **ci:** upload sdks to package manager ([bfc448f](https://github.com/writer/writer-python/commit/bfc448fb9516e07a3a5fa903ef04b2e838865a0a)) +* **docs:** grammar improvements ([302e1a8](https://github.com/writer/writer-python/commit/302e1a8fb48a3f0fa83bef9fa1fb5ee2244893f1)) +* **docs:** remove reference to rye shell ([b6a9aff](https://github.com/writer/writer-python/commit/b6a9aff0e0da58c0f19b4634448be6b6d539593c)) +* **docs:** remove unnecessary param examples ([b78bf04](https://github.com/writer/writer-python/commit/b78bf04d813ce51075386f660a0cfa3647db77d3)) +* **internal:** avoid errors for isinstance checks on proxies ([6bec701](https://github.com/writer/writer-python/commit/6bec701344af3c9cd7853cc9dcacfbd959dbf485)) +* **internal:** avoid lint errors in pagination expressions ([abf0203](https://github.com/writer/writer-python/commit/abf02038a3857004cbb061cffaa9f0bf29f841b0)) +* **internal:** bump pinned h11 dep ([383abab](https://github.com/writer/writer-python/commit/383ababb87995f734a70b58178d407b00df130ea)) +* **internal:** codegen related update ([81c98bd](https://github.com/writer/writer-python/commit/81c98bd5948b7b5d0175134f43ced00a559d3f41)) +* **internal:** update conftest.py ([f6aa1eb](https://github.com/writer/writer-python/commit/f6aa1eb979f08b6ee1f9518f19b618e9346c04c6)) +* **package:** mark python 3.13 as supported ([fe9f5d9](https://github.com/writer/writer-python/commit/fe9f5d904f516d1eb37037acd07df4e43e86246a)) +* parse environment variables for default headers ([fd7c358](https://github.com/writer/writer-python/commit/fd7c358ae9671a20c338d6868b4420b8a982fe17)) +* **readme:** fix version rendering on pypi ([b93bb29](https://github.com/writer/writer-python/commit/b93bb292fe04ff647049ef0b1431181435463c92)) +* **readme:** update badges ([52ec704](https://github.com/writer/writer-python/commit/52ec7049643a69006ad01aa7d3c47533e017afd9)) +* **tests:** add tests for httpx client instantiation & proxies ([5e99c8f](https://github.com/writer/writer-python/commit/5e99c8fae8879b664c7982fc88a0f5dfd0f111d7)) +* **tests:** run tests in parallel ([6c8b5ad](https://github.com/writer/writer-python/commit/6c8b5ad718480211ef16b7a81064eadb988c69de)) +* **tests:** skip some failing tests on the latest python versions ([94bb5d6](https://github.com/writer/writer-python/commit/94bb5d63562691568da5e2a48e4890dbbbcda456)) + + +### Documentation + +* **api:** updates to API spec ([4902642](https://github.com/writer/writer-python/commit/4902642b8d9cc80b3bae1d5a10d44a0959e2d0b0)) +* **api:** updates to API spec ([e704323](https://github.com/writer/writer-python/commit/e7043234b044b7fa0897c2f3d07685d429071c08)) +* **client:** fix httpx.Timeout documentation reference ([f0cc6ef](https://github.com/writer/writer-python/commit/f0cc6ef6ac440627a7ed903142b79d49c3d0a22e)) + ## 2.2.0 (2025-04-30) Full Changelog: [v2.2.0-rc1...v2.2.0](https://github.com/writer/writer-python/compare/v2.2.0-rc1...v2.2.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 920e3390..47a8ec3e 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 e37a5941..b0ae7de7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Writer Python API library -[![PyPI version](https://img.shields.io/pypi/v/writer-sdk.svg)](https://pypi.org/project/writer-sdk/) + +[![PyPI version](https://img.shields.io/pypi/v/writer-sdk.svg?label=pypi%20(stable))](https://pypi.org/project/writer-sdk/) The Writer Python library provides access to the Writer REST API from any Python 3.8+ application. It includes a set of tools and utilities that make it easy to integrate the capabilities @@ -107,7 +108,46 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. -## Streaming versus non-streaming responses +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install writer-sdk[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from writerai import DefaultAioHttpClient +from writerai import AsyncWriter + + +async def main() -> None: + async with AsyncWriter( + api_key="My API Key", + http_client=DefaultAioHttpClient(), + ) as client: + chat_completion = await client.chat.chat( + messages=[ + { + "content": "Write a haiku about programming", + "role": "user", + } + ], + model="palmyra-x5", + ) + print(chat_completion.id) + + +asyncio.run(main()) +``` + +## Streaming responses The Writer Python library provides support for streaming responses using Server Side Events (SSE). @@ -255,10 +295,7 @@ client = Writer() chat_completion = client.chat.chat( messages=[{"role": "user"}], model="model", - response_format={ - "type": "text", - "json_schema": {}, - }, + response_format={"type": "text"}, ) print(chat_completion.response_format) ``` @@ -365,8 +402,8 @@ client.with_options(max_retries=5).chat.chat( ### Timeouts -By default, requests time out after three minutes. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: +By default requests time out after three minutes. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python import httpx diff --git a/SECURITY.md b/SECURITY.md index b7b1acaa..edf66351 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,11 +16,11 @@ before making any information public. ## Reporting Non-SDK Related Security Issues If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Writer please follow the respective company's security reporting guidelines. +or products provided by Writer, please follow the respective company's security reporting guidelines. ### Writer Terms and Policies -Please contact dev-feedback@writer.com for any questions or concerns regarding security of our services. +Please contact dev-feedback@writer.com for any questions or concerns regarding the security of our services. --- diff --git a/bin/check-release-environment b/bin/check-release-environment index fe3283dd..b845b0f4 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${PYPI_TOKEN}" ]; then - errors+=("The WRITER_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi lenErrors=${#errors[@]} diff --git a/pyproject.toml b/pyproject.toml index dd25ac67..c3cfa50d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "writer-sdk" -version = "2.2.0" +version = "2.2.1" description = "The official Python library for the writer API" dynamic = ["readme"] license = "Apache-2.0" @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -39,6 +40,8 @@ classifiers = [ Homepage = "https://github.com/writer/writer-python" Repository = "https://github.com/writer/writer-python" +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true @@ -56,7 +59,8 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "inline-snapshot >=0.7.0", - "nest_asyncio==1.6.0" + "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", ] [tool.rye.scripts] @@ -128,7 +132,7 @@ replacement = '[\1](https://github.com/writer/writer-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short" +addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/requirements-dev.lock b/requirements-dev.lock index af0fa291..1285d614 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,13 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via writer-sdk +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 @@ -17,6 +24,10 @@ anyio==4.4.0 # via writer-sdk argcomplete==3.1.2 # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp asttokens==3.0.0 # via inline-snapshot certifi==2023.7.22 @@ -34,18 +45,27 @@ exceptiongroup==1.2.2 # via pytest executing==2.2.0 # via inline-snapshot +execnet==2.1.1 + # via pytest-xdist filelock==3.12.4 # via virtualenv -h11==0.14.0 +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 + # via httpx-aiohttp # via respx # via writer-sdk +httpx-aiohttp==0.1.8 + # via writer-sdk idna==3.4 # via anyio # via httpx + # via yarl importlib-metadata==7.0.0 iniconfig==2.0.0 # via pytest @@ -56,6 +76,9 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl mypy==1.14.1 mypy-extensions==1.0.0 # via mypy @@ -70,6 +93,9 @@ platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via writer-sdk pydantic-core==2.27.1 @@ -79,7 +105,9 @@ pygments==2.18.0 pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio + # via pytest-xdist pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 python-dateutil==2.8.2 # via time-machine pytz==2023.3.post1 @@ -102,6 +130,7 @@ tomli==2.0.2 # via pytest typing-extensions==4.12.2 # via anyio + # via multidict # via mypy # via pydantic # via pydantic-core @@ -109,5 +138,7 @@ typing-extensions==4.12.2 # via writer-sdk virtualenv==20.24.5 # via nox +yarl==1.20.0 + # via aiohttp zipp==3.17.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index f9197fb2..85b74322 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,11 +10,22 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via writer-sdk +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 # via httpx # via writer-sdk +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -22,15 +33,28 @@ distro==1.8.0 # via writer-sdk exceptiongroup==1.2.2 # via anyio -h11==0.14.0 +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 + # via httpx-aiohttp + # via writer-sdk +httpx-aiohttp==0.1.8 # via writer-sdk idna==3.4 # via anyio # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl jiter==0.8.2 # via writer-sdk pydantic==2.10.3 @@ -42,6 +66,9 @@ sniffio==1.3.0 # via writer-sdk typing-extensions==4.12.2 # via anyio + # via multidict # via pydantic # via pydantic-core # via writer-sdk +yarl==1.20.0 + # via aiohttp diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 00000000..af535aa5 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -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=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$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 'https://pkg.stainless.com/s/writer-python/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/writerai/__init__.py b/src/writerai/__init__.py index 9f0a8dcd..6a6582e3 100644 --- a/src/writerai/__init__.py +++ b/src/writerai/__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 @@ -27,7 +29,7 @@ APIResponseValidationError, ContentFilterFinishReasonError, ) -from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging __all__ = [ @@ -71,9 +73,13 @@ "DEFAULT_CONNECTION_LIMITS", "DefaultHttpxClient", "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", "pydantic_function_tool", ] +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/writerai/_base_client.py b/src/writerai/_base_client.py index c7032205..768491b9 100644 --- a/src/writerai/_base_client.py +++ b/src/writerai/_base_client.py @@ -435,7 +435,7 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 self._validate_headers(headers_dict, custom_headers) # headers are case-insensitive while dictionaries are not. - headers = httpx.Headers(headers_dict) + headers = httpx.Headers(headers_dict, encoding="utf-8") idempotency_header = self._idempotency_header if idempotency_header and options.idempotency_key and idempotency_header not in headers: @@ -531,6 +531,15 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -543,8 +552,6 @@ def _build_request( # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, content=content, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) @@ -963,6 +970,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1071,7 +1081,14 @@ def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, APIResponse): raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") @@ -1291,6 +1308,24 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + if TYPE_CHECKING: DefaultAsyncHttpxClient = httpx.AsyncClient """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK @@ -1299,8 +1334,12 @@ def __init__(self, **kwargs: Any) -> None: This is useful because overriding the `http_client` with your own instance of `httpx.AsyncClient` will result in httpx's defaults being used, not ours. """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" else: DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): @@ -1472,6 +1511,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1580,7 +1622,14 @@ async def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, AsyncAPIResponse): raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") diff --git a/src/writerai/_client.py b/src/writerai/_client.py index 34fb3484..1f0e5c88 100644 --- a/src/writerai/_client.py +++ b/src/writerai/_client.py @@ -36,6 +36,39 @@ __all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "Writer", "AsyncWriter", "Client", "AsyncClient"] +_SDK_NAMESPACE_PREFIX = "WRITER_SDK_" +_SDK_HEADER_PREFIX = _SDK_NAMESPACE_PREFIX + "HEADER_" + + +def _extract_sdk_env_headers() -> dict[str, str]: + """ + Collect headers defined through environment variables of the form + `WRITER_SDK_HEADER_`. + + Example + -------- + >>> os.environ["WRITER_SDK_HEADER_X_FOO_BAR"] = "abc123" + >>> _extract_sdk_env_headers() + {'X-Foo-Bar': 'abc123'} + """ + headers: dict[str, str] = {} + + for key, value in os.environ.items(): + if not key.startswith(_SDK_HEADER_PREFIX): + continue + + # Strip the prefix and convert + raw = key[len(_SDK_HEADER_PREFIX):] + parts = raw.split("_") + canonical = "-".join( + word.capitalize() if len(word) > 1 else word.upper() + for word in parts + ) + headers[canonical] = value + + return headers + + class Writer(SyncAPIClient): applications: applications.ApplicationsResource chat: chat.ChatResource @@ -92,6 +125,9 @@ def __init__( if base_url is None: base_url = f"https://api.writer.com" + env_headers = _extract_sdk_env_headers() + default_headers = {**env_headers, **(default_headers or {})} + super().__init__( version=__version__, base_url=base_url, @@ -278,13 +314,16 @@ def __init__( if base_url is None: base_url = f"https://api.writer.com" + env_headers = _extract_sdk_env_headers() + merged_headers = {**env_headers, **(default_headers or {})} + super().__init__( version=__version__, base_url=base_url, max_retries=max_retries, timeout=timeout, http_client=http_client, - custom_headers=default_headers, + custom_headers=merged_headers, custom_query=default_query, _strict_response_validation=_strict_response_validation, ) diff --git a/src/writerai/_models.py b/src/writerai/_models.py index 832dc94c..49331ca1 100644 --- a/src/writerai/_models.py +++ b/src/writerai/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -367,7 +368,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) def is_basemodel(type_: type) -> bool: @@ -421,7 +422,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -439,8 +440,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() @@ -739,6 +742,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -753,6 +757,7 @@ class FinalRequestOptions(pydantic.BaseModel): content: Union[HttpxFileContent, 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/writerai/_types.py b/src/writerai/_types.py index 1b0929e6..cb87a472 100644 --- a/src/writerai/_types.py +++ b/src/writerai/_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/writerai/_utils/_proxy.py b/src/writerai/_utils/_proxy.py index ffd883e9..0f239a33 100644 --- a/src/writerai/_utils/_proxy.py +++ b/src/writerai/_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/writerai/_utils/_resources_proxy.py b/src/writerai/_utils/_resources_proxy.py new file mode 100644 index 00000000..200626ef --- /dev/null +++ b/src/writerai/_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 `writerai.resources` module. + + This is used so that we can lazily import `writerai.resources` only when + needed *and* so that users can just import `writerai` and reference `writerai.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("writerai.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/writerai/_version.py b/src/writerai/_version.py index 3033a06d..4fba2a61 100644 --- a/src/writerai/_version.py +++ b/src/writerai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "writerai" -__version__ = "2.2.0" # x-release-please-version +__version__ = "2.2.1" # x-release-please-version diff --git a/src/writerai/pagination.py b/src/writerai/pagination.py index a68ce8b3..d8534f51 100644 --- a/src/writerai/pagination.py +++ b/src/writerai/pagination.py @@ -129,7 +129,7 @@ def next_page_info(self) -> Optional[PageInfo]: if self.pagination.offset is not None: offset = self.pagination.offset if offset is None: - return None + return None # type: ignore[unreachable] length = len(self._get_page_items()) current_count = offset + length @@ -163,7 +163,7 @@ def next_page_info(self) -> Optional[PageInfo]: if self.pagination.offset is not None: offset = self.pagination.offset if offset is None: - return None + return None # type: ignore[unreachable] length = len(self._get_page_items()) current_count = offset + length diff --git a/src/writerai/resources/files.py b/src/writerai/resources/files.py index 3bd77d8f..73b45fab 100644 --- a/src/writerai/resources/files.py +++ b/src/writerai/resources/files.py @@ -93,6 +93,7 @@ def list( *, after: str | NotGiven = NOT_GIVEN, before: str | NotGiven = NOT_GIVEN, + file_types: str | NotGiven = NOT_GIVEN, graph_id: str | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, order: Literal["asc", "desc"] | NotGiven = NOT_GIVEN, @@ -115,6 +116,9 @@ def list( before: The ID of the first object in the previous page. This parameter instructs the API to return the previous page of results. + file_types: The extensions of the files to retrieve. Separate multiple extensions with a + comma. For example: `pdf,jpg,docx`. + graph_id: The unique identifier of the graph to which the files belong. limit: Specifies the maximum number of objects returned in a page. The default value @@ -146,6 +150,7 @@ def list( { "after": after, "before": before, + "file_types": file_types, "graph_id": graph_id, "limit": limit, "order": order, @@ -356,6 +361,7 @@ def list( *, after: str | NotGiven = NOT_GIVEN, before: str | NotGiven = NOT_GIVEN, + file_types: str | NotGiven = NOT_GIVEN, graph_id: str | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, order: Literal["asc", "desc"] | NotGiven = NOT_GIVEN, @@ -378,6 +384,9 @@ def list( before: The ID of the first object in the previous page. This parameter instructs the API to return the previous page of results. + file_types: The extensions of the files to retrieve. Separate multiple extensions with a + comma. For example: `pdf,jpg,docx`. + graph_id: The unique identifier of the graph to which the files belong. limit: Specifies the maximum number of objects returned in a page. The default value @@ -409,6 +418,7 @@ def list( { "after": after, "before": before, + "file_types": file_types, "graph_id": graph_id, "limit": limit, "order": order, diff --git a/src/writerai/types/file_list_params.py b/src/writerai/types/file_list_params.py index 00ad5b97..8bc101e1 100644 --- a/src/writerai/types/file_list_params.py +++ b/src/writerai/types/file_list_params.py @@ -20,6 +20,12 @@ class FileListParams(TypedDict, total=False): This parameter instructs the API to return the previous page of results. """ + file_types: str + """The extensions of the files to retrieve. + + Separate multiple extensions with a comma. For example: `pdf,jpg,docx`. + """ + graph_id: str """The unique identifier of the graph to which the files belong.""" diff --git a/tests/api_resources/applications/test_graphs.py b/tests/api_resources/applications/test_graphs.py index 3228b9d1..e7c2c30e 100644 --- a/tests/api_resources/applications/test_graphs.py +++ b/tests/api_resources/applications/test_graphs.py @@ -99,7 +99,9 @@ def test_path_params_list(self, client: Writer) -> None: class TestAsyncGraphs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_update(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/applications/test_jobs.py b/tests/api_resources/applications/test_jobs.py index 57eba30f..ff13b328 100644 --- a/tests/api_resources/applications/test_jobs.py +++ b/tests/api_resources/applications/test_jobs.py @@ -210,7 +210,9 @@ def test_path_params_retry(self, client: Writer) -> None: class TestAsyncJobs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_applications.py b/tests/api_resources/test_applications.py index 86678535..b4813d58 100644 --- a/tests/api_resources/test_applications.py +++ b/tests/api_resources/test_applications.py @@ -239,7 +239,9 @@ def test_path_params_generate_content_overload_2(self, client: Writer) -> None: class TestAsyncApplications: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_chat.py b/tests/api_resources/test_chat.py index d04446a9..f47c2ade 100644 --- a/tests/api_resources/test_chat.py +++ b/tests/api_resources/test_chat.py @@ -231,7 +231,9 @@ def test_streaming_response_chat_overload_2(self, client: Writer) -> None: class TestAsyncChat: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_chat_overload_1(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_completions.py b/tests/api_resources/test_completions.py index e39a6d99..246bdfc9 100644 --- a/tests/api_resources/test_completions.py +++ b/tests/api_resources/test_completions.py @@ -119,7 +119,9 @@ def test_streaming_response_create_overload_2(self, client: Writer) -> None: class TestAsyncCompletions: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create_overload_1(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_files.py b/tests/api_resources/test_files.py index d64cd1dd..46a3c6d1 100644 --- a/tests/api_resources/test_files.py +++ b/tests/api_resources/test_files.py @@ -78,6 +78,7 @@ def test_method_list_with_all_params(self, client: Writer) -> None: file = client.files.list( after="after", before="before", + file_types="file_types", graph_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", limit=0, order="asc", @@ -270,7 +271,9 @@ def test_streaming_response_upload(self, client: Writer) -> None: class TestAsyncFiles: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncWriter) -> None: @@ -320,6 +323,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncWriter) -> N file = await async_client.files.list( after="after", before="before", + file_types="file_types", graph_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", limit=0, order="asc", diff --git a/tests/api_resources/test_graphs.py b/tests/api_resources/test_graphs.py index 22c7b21b..15ae7a62 100644 --- a/tests/api_resources/test_graphs.py +++ b/tests/api_resources/test_graphs.py @@ -399,7 +399,9 @@ def test_path_params_remove_file_from_graph(self, client: Writer) -> None: class TestAsyncGraphs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_models.py b/tests/api_resources/test_models.py index 8ed984dc..3ad1f08f 100644 --- a/tests/api_resources/test_models.py +++ b/tests/api_resources/test_models.py @@ -44,7 +44,9 @@ def test_streaming_response_list(self, client: Writer) -> None: class TestAsyncModels: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_tools.py b/tests/api_resources/test_tools.py index 47026f82..bbc2a7db 100644 --- a/tests/api_resources/test_tools.py +++ b/tests/api_resources/test_tools.py @@ -130,7 +130,9 @@ def test_path_params_parse_pdf(self, client: Writer) -> None: class TestAsyncTools: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_ai_detect(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_translation.py b/tests/api_resources/test_translation.py index caa3296f..e7a0babc 100644 --- a/tests/api_resources/test_translation.py +++ b/tests/api_resources/test_translation.py @@ -68,7 +68,9 @@ def test_streaming_response_translate(self, client: Writer) -> None: class TestAsyncTranslation: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_translate(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/test_vision.py b/tests/api_resources/test_vision.py index 20f5d72f..dfca8433 100644 --- a/tests/api_resources/test_vision.py +++ b/tests/api_resources/test_vision.py @@ -83,7 +83,9 @@ def test_streaming_response_analyze(self, client: Writer) -> None: class TestAsyncVision: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_analyze(self, async_client: AsyncWriter) -> None: diff --git a/tests/api_resources/tools/test_comprehend.py b/tests/api_resources/tools/test_comprehend.py index bf026b75..6f7efe39 100644 --- a/tests/api_resources/tools/test_comprehend.py +++ b/tests/api_resources/tools/test_comprehend.py @@ -53,7 +53,9 @@ def test_streaming_response_medical(self, client: Writer) -> None: class TestAsyncComprehend: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_medical(self, async_client: AsyncWriter) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 5ba6ea6a..ec6f42e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os import logging from typing import TYPE_CHECKING, Iterator, AsyncIterator +import httpx import pytest from pytest_asyncio import is_async_test -from writerai import Writer, AsyncWriter +from writerai import Writer, AsyncWriter, DefaultAioHttpClient +from writerai._utils import is_dict if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -25,6 +29,19 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -43,9 +60,25 @@ def client(request: FixtureRequest) -> Iterator[Writer]: @pytest.fixture(scope="session") async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncWriter]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - async with AsyncWriter(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncWriter( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: yield client diff --git a/tests/test_client.py b/tests/test_client.py index 47014385..03dbad51 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,18 +23,17 @@ from writerai import Writer, AsyncWriter, APIResponseValidationError from writerai._types import Omit -from writerai._utils import maybe_transform from writerai._models import BaseModel, FinalRequestOptions -from writerai._constants import RAW_RESPONSE_HEADER from writerai._streaming import Stream, AsyncStream from writerai._exceptions import WriterError, APIStatusError, APITimeoutError, APIResponseValidationError from writerai._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, make_request_options, ) -from writerai.types.chat_chat_params import ChatChatParamsNonStreaming from .utils import update_env @@ -193,6 +192,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -463,7 +463,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: Writer) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -723,60 +723,21 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("writerai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Writer) -> None: respx_mock.post("/v1/chat").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - self.client.post( - "/v1/chat", - body=cast( - object, - maybe_transform( - dict( - messages=[ - { - "content": "Write a haiku about programming", - "role": "user", - } - ], - model="palmyra-x5", - ), - ChatChatParamsNonStreaming, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + client.chat.with_streaming_response.chat(messages=[{"role": "user"}], model="model").__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("writerai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Writer) -> None: respx_mock.post("/v1/chat").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.post( - "/v1/chat", - body=cast( - object, - maybe_transform( - dict( - messages=[ - { - "content": "Write a haiku about programming", - "role": "user", - } - ], - model="palmyra-x5", - ), - ChatChatParamsNonStreaming, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + client.chat.with_streaming_response.chat(messages=[{"role": "user"}], model="model").__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -860,6 +821,55 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @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 TestAsyncWriter: client = AsyncWriter(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -996,6 +1006,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -1268,7 +1279,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncWriter) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1543,60 +1554,25 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("writerai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncWriter) -> None: respx_mock.post("/v1/chat").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await self.client.post( - "/v1/chat", - body=cast( - object, - maybe_transform( - dict( - messages=[ - { - "content": "Write a haiku about programming", - "role": "user", - } - ], - model="palmyra-x5", - ), - ChatChatParamsNonStreaming, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + await async_client.chat.with_streaming_response.chat( + messages=[{"role": "user"}], model="model" + ).__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("writerai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncWriter) -> None: respx_mock.post("/v1/chat").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.post( - "/v1/chat", - body=cast( - object, - maybe_transform( - dict( - messages=[ - { - "content": "Write a haiku about programming", - "role": "user", - } - ], - model="palmyra-x5", - ), - ChatChatParamsNonStreaming, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + await async_client.chat.with_streaming_response.chat( + messages=[{"role": "user"}], model="model" + ).__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1727,3 +1703,52 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @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 93d48eea..65019f23 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -889,3 +889,48 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py index 449f727d..8d75d83d 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)