diff --git a/.release-please-manifest.json b/.release-please-manifest.json index caf5ca3f..59acac47 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.26.0" + ".": "0.27.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 06232146..87168119 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 20 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-cd8c28747615d8967e4e20e6fd5b9488a3022fece37f1c4c133c9b8d9c4415f3.yml -openapi_spec_hash: 2aa6c5d6faa2cbd4038108b5ebc103b3 -config_hash: e29127278ff246754ce4801403db0cd9 +configured_endpoints: 21 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/hyperspell%2Fhyperspell-622b43986c45c1efbeb06dd933786980257f300b7a0edbb2d2a4f708afacce36.yml +openapi_spec_hash: ade837ffc4873d3b50a0fab3f061b397 +config_hash: a3a8e3c71c17eabb21ab8173521181a4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ea68271c..77dc76c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 0.27.0 (2025-12-12) + +Full Changelog: [v0.26.0...v0.27.0](https://github.com/hyperspell/python-sdk/compare/v0.26.0...v0.27.0) + +### Features + +* **api:** api update ([cc4b7a1](https://github.com/hyperspell/python-sdk/commit/cc4b7a13f9aa8f38e2d115fc2292cf6b1f959cd0)) +* **api:** api update ([2e9f356](https://github.com/hyperspell/python-sdk/commit/2e9f356bd097204a9d89b927768e261e79397b36)) +* **api:** api update ([0f29501](https://github.com/hyperspell/python-sdk/commit/0f2950160b0842b7522f9a7ac3100b003f276cc4)) +* **api:** api update ([add1d76](https://github.com/hyperspell/python-sdk/commit/add1d76538a307e7a1c3fc592207d50351c4c5ca)) +* **api:** api update ([1bfef28](https://github.com/hyperspell/python-sdk/commit/1bfef28fdcd09c6b6cccfb0aa21055b2ad220403)) +* **api:** update via SDK Studio ([18945ea](https://github.com/hyperspell/python-sdk/commit/18945ea3354e140c6d150fb0d8e2238a33113c5e)) +* **api:** update via SDK Studio ([b005609](https://github.com/hyperspell/python-sdk/commit/b005609bbfd778bf2bbb8eba1c22fae89f75441a)) +* **api:** update via SDK Studio ([49e68c8](https://github.com/hyperspell/python-sdk/commit/49e68c866483f9be8900eef7fc3e776e43cd74b1)) + + +### Bug Fixes + +* **client:** close streams without requiring full consumption ([669e4cf](https://github.com/hyperspell/python-sdk/commit/669e4cf750158df6aa381083911c051a6afb8f07)) +* compat with Python 3.14 ([53fdd97](https://github.com/hyperspell/python-sdk/commit/53fdd97e8631bf9093c775de401b0f1fd8965efe)) +* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([6ed583e](https://github.com/hyperspell/python-sdk/commit/6ed583ec067269fb1c27c8dcce9167795a1abd17)) +* ensure streams are always closed ([c676043](https://github.com/hyperspell/python-sdk/commit/c6760434add9e200c865154a08d0289d556b70a8)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9c7824f](https://github.com/hyperspell/python-sdk/commit/9c7824f603cd5b1935ee1aac676e3fcf299d51f6)) + + +### Chores + +* add missing docstrings ([16f09b6](https://github.com/hyperspell/python-sdk/commit/16f09b68057a3be4b878f32980d7d07fefbc7cb9)) +* add Python 3.14 classifier and testing ([75b429e](https://github.com/hyperspell/python-sdk/commit/75b429e1dc942a6595024682d7bf9d7b22cad632)) +* bump `httpx-aiohttp` version to 0.1.9 ([802a6ee](https://github.com/hyperspell/python-sdk/commit/802a6ee6c0b67bddd725727078fc9c3ce2650b69)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([223b7fc](https://github.com/hyperspell/python-sdk/commit/223b7fcd14499af7a908d7ddb9cbe83e44656737)) +* **docs:** use environment variables for authentication in code snippets ([0e42e5d](https://github.com/hyperspell/python-sdk/commit/0e42e5d97de61b3224eeaac980df5efc35a9336e)) +* **internal/tests:** avoid race condition with implicit client cleanup ([01d946a](https://github.com/hyperspell/python-sdk/commit/01d946a29b5dbfd37f2f7a7c93f6824c5865af1b)) +* **internal:** grammar fix (it's -> its) ([516dd5e](https://github.com/hyperspell/python-sdk/commit/516dd5e4f13fcffa9593e58f7a4c8bf855968ae0)) +* **package:** drop Python 3.8 support ([4d8d0a5](https://github.com/hyperspell/python-sdk/commit/4d8d0a501af22b0d5321e34e1d6c368dec622d34)) +* update lockfile ([9170730](https://github.com/hyperspell/python-sdk/commit/9170730ae0dd8259dcd9b5814b76e6a79247b237)) + ## 0.26.0 (2025-10-14) Full Changelog: [v0.25.0...v0.26.0](https://github.com/hyperspell/python-sdk/compare/v0.25.0...v0.26.0) diff --git a/README.md b/README.md index c94d2be3..65ced851 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/hyperspell.svg?label=pypi%20(stable))](https://pypi.org/project/hyperspell/) -The Hyperspell Python library provides convenient access to the Hyperspell REST API from any Python 3.8+ +The Hyperspell Python library provides convenient access to the Hyperspell REST API from any Python 3.9+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). @@ -29,7 +29,7 @@ import os from hyperspell import Hyperspell client = Hyperspell( - api_key=os.environ.get("HYPERSPELL_TOKEN"), # This is the default and can be omitted + api_key=os.environ.get("HYPERSPELL_API_KEY"), # This is the default and can be omitted ) memory_status = client.memories.add( @@ -40,7 +40,7 @@ print(memory_status.resource_id) While you can provide an `api_key` keyword argument, we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) -to add `HYPERSPELL_TOKEN="My API Key"` to your `.env` file +to add `HYPERSPELL_API_KEY="My API Key"` to your `.env` file so that your API Key is not stored in source control. ## Async usage @@ -53,7 +53,7 @@ import asyncio from hyperspell import AsyncHyperspell client = AsyncHyperspell( - api_key=os.environ.get("HYPERSPELL_TOKEN"), # This is the default and can be omitted + api_key=os.environ.get("HYPERSPELL_API_KEY"), # This is the default and can be omitted ) @@ -83,6 +83,7 @@ pip install hyperspell[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from hyperspell import DefaultAioHttpClient from hyperspell import AsyncHyperspell @@ -90,7 +91,7 @@ from hyperspell import AsyncHyperspell async def main() -> None: async with AsyncHyperspell( - api_key="My API Key", + api_key=os.environ.get("HYPERSPELL_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: memory_status = await client.memories.add( @@ -476,7 +477,7 @@ print(hyperspell.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/api.md b/api.md index a0af7c08..0452defa 100644 --- a/api.md +++ b/api.md @@ -4,23 +4,31 @@ from hyperspell.types import QueryResult ``` +# Connections + +Types: + +```python +from hyperspell.types import ConnectionListResponse, ConnectionRevokeResponse +``` + +Methods: + +- client.connections.list() -> ConnectionListResponse +- client.connections.revoke(connection_id) -> ConnectionRevokeResponse + # Integrations Types: ```python -from hyperspell.types import ( - IntegrationListResponse, - IntegrationConnectResponse, - IntegrationRevokeResponse, -) +from hyperspell.types import IntegrationListResponse, IntegrationConnectResponse ``` Methods: - client.integrations.list() -> IntegrationListResponse - client.integrations.connect(integration_id, \*\*params) -> IntegrationConnectResponse -- client.integrations.revoke(integration_id) -> IntegrationRevokeResponse ## GoogleCalendar diff --git a/pyproject.toml b/pyproject.toml index 1889a095..1dd7a71c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,30 +1,32 @@ [project] name = "hyperspell" -version = "0.26.0" +version = "0.27.0" description = "The official Python library for the hyperspell API" dynamic = ["readme"] license = "MIT" authors = [ { name = "Hyperspell", email = "hello@hyperspell.com" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] -requires-python = ">= 3.8" + +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -39,14 +41,14 @@ Homepage = "https://github.com/hyperspell/python-sdk" Repository = "https://github.com/hyperspell/python-sdk" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", @@ -141,7 +143,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/requirements-dev.lock b/requirements-dev.lock index c3a47ff0..26605768 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via hyperspell -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via hyperspell -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via hyperspell -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -56,82 +61,89 @@ httpx==0.28.1 # via httpx-aiohttp # via hyperspell # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via hyperspell -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -mypy==1.14.1 -mypy-extensions==1.0.0 +mypy==1.17.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest -platformdirs==3.11.0 +pathspec==0.12.1 + # via mypy +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via hyperspell -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via hyperspell -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via hyperspell # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index e90ee670..313b1a01 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via httpx-aiohttp # via hyperspell -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via httpx # via hyperspell async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via hyperspell -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -43,33 +43,34 @@ httpcore==1.0.9 httpx==0.28.1 # via httpx-aiohttp # via hyperspell -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via hyperspell -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via hyperspell -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via hyperspell -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio + # via exceptiongroup # via hyperspell # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp diff --git a/src/hyperspell/_client.py b/src/hyperspell/_client.py index 3abe0ae8..a41a2c80 100644 --- a/src/hyperspell/_client.py +++ b/src/hyperspell/_client.py @@ -21,7 +21,7 @@ ) from ._utils import is_given, get_async_library from ._version import __version__ -from .resources import auth, vaults, evaluate, memories +from .resources import auth, vaults, evaluate, memories, connections from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, HyperspellError from ._base_client import ( @@ -44,6 +44,7 @@ class Hyperspell(SyncAPIClient): + connections: connections.ConnectionsResource integrations: integrations.IntegrationsResource memories: memories.MemoriesResource evaluate: evaluate.EvaluateResource @@ -82,13 +83,13 @@ def __init__( ) -> None: """Construct a new synchronous Hyperspell client instance. - This automatically infers the `api_key` argument from the `HYPERSPELL_TOKEN` environment variable if it is not provided. + This automatically infers the `api_key` argument from the `HYPERSPELL_API_KEY` environment variable if it is not provided. """ if api_key is None: - api_key = os.environ.get("HYPERSPELL_TOKEN") + api_key = os.environ.get("HYPERSPELL_API_KEY") if api_key is None: raise HyperspellError( - "The api_key client option must be set either by passing api_key to the client or by setting the HYPERSPELL_TOKEN environment variable" + "The api_key client option must be set either by passing api_key to the client or by setting the HYPERSPELL_API_KEY environment variable" ) self.api_key = api_key @@ -110,6 +111,7 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + self.connections = connections.ConnectionsResource(self) self.integrations = integrations.IntegrationsResource(self) self.memories = memories.MemoriesResource(self) self.evaluate = evaluate.EvaluateResource(self) @@ -237,6 +239,7 @@ def _make_status_error( class AsyncHyperspell(AsyncAPIClient): + connections: connections.AsyncConnectionsResource integrations: integrations.AsyncIntegrationsResource memories: memories.AsyncMemoriesResource evaluate: evaluate.AsyncEvaluateResource @@ -275,13 +278,13 @@ def __init__( ) -> None: """Construct a new async AsyncHyperspell client instance. - This automatically infers the `api_key` argument from the `HYPERSPELL_TOKEN` environment variable if it is not provided. + This automatically infers the `api_key` argument from the `HYPERSPELL_API_KEY` environment variable if it is not provided. """ if api_key is None: - api_key = os.environ.get("HYPERSPELL_TOKEN") + api_key = os.environ.get("HYPERSPELL_API_KEY") if api_key is None: raise HyperspellError( - "The api_key client option must be set either by passing api_key to the client or by setting the HYPERSPELL_TOKEN environment variable" + "The api_key client option must be set either by passing api_key to the client or by setting the HYPERSPELL_API_KEY environment variable" ) self.api_key = api_key @@ -303,6 +306,7 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + self.connections = connections.AsyncConnectionsResource(self) self.integrations = integrations.AsyncIntegrationsResource(self) self.memories = memories.AsyncMemoriesResource(self) self.evaluate = evaluate.AsyncEvaluateResource(self) @@ -431,6 +435,7 @@ def _make_status_error( class HyperspellWithRawResponse: def __init__(self, client: Hyperspell) -> None: + self.connections = connections.ConnectionsResourceWithRawResponse(client.connections) self.integrations = integrations.IntegrationsResourceWithRawResponse(client.integrations) self.memories = memories.MemoriesResourceWithRawResponse(client.memories) self.evaluate = evaluate.EvaluateResourceWithRawResponse(client.evaluate) @@ -440,6 +445,7 @@ def __init__(self, client: Hyperspell) -> None: class AsyncHyperspellWithRawResponse: def __init__(self, client: AsyncHyperspell) -> None: + self.connections = connections.AsyncConnectionsResourceWithRawResponse(client.connections) self.integrations = integrations.AsyncIntegrationsResourceWithRawResponse(client.integrations) self.memories = memories.AsyncMemoriesResourceWithRawResponse(client.memories) self.evaluate = evaluate.AsyncEvaluateResourceWithRawResponse(client.evaluate) @@ -449,6 +455,7 @@ def __init__(self, client: AsyncHyperspell) -> None: class HyperspellWithStreamedResponse: def __init__(self, client: Hyperspell) -> None: + self.connections = connections.ConnectionsResourceWithStreamingResponse(client.connections) self.integrations = integrations.IntegrationsResourceWithStreamingResponse(client.integrations) self.memories = memories.MemoriesResourceWithStreamingResponse(client.memories) self.evaluate = evaluate.EvaluateResourceWithStreamingResponse(client.evaluate) @@ -458,6 +465,7 @@ def __init__(self, client: Hyperspell) -> None: class AsyncHyperspellWithStreamedResponse: def __init__(self, client: AsyncHyperspell) -> None: + self.connections = connections.AsyncConnectionsResourceWithStreamingResponse(client.connections) self.integrations = integrations.AsyncIntegrationsResourceWithStreamingResponse(client.integrations) self.memories = memories.AsyncMemoriesResourceWithStreamingResponse(client.memories) self.evaluate = evaluate.AsyncEvaluateResourceWithStreamingResponse(client.evaluate) diff --git a/src/hyperspell/_models.py b/src/hyperspell/_models.py index 6a3cd1d2..ca9500b2 100644 --- a/src/hyperspell/_models.py +++ b/src/hyperspell/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -256,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -272,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -298,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -314,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -354,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, @@ -573,6 +591,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +636,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +691,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/src/hyperspell/_streaming.py b/src/hyperspell/_streaming.py index 327dec92..11fc4ffe 100644 --- a/src/hyperspell/_streaming.py +++ b/src/hyperspell/_streaming.py @@ -54,12 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - for _sse in iterator: - ... + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -118,12 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self diff --git a/src/hyperspell/_types.py b/src/hyperspell/_types.py index 5f369eea..59d3b796 100644 --- a/src/hyperspell/_types.py +++ b/src/hyperspell/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case diff --git a/src/hyperspell/_utils/_sync.py b/src/hyperspell/_utils/_sync.py index ad7ec71b..f6027c18 100644 --- a/src/hyperspell/_utils/_sync.py +++ b/src/hyperspell/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _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 _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. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - 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 asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: diff --git a/src/hyperspell/_utils/_utils.py b/src/hyperspell/_utils/_utils.py index 50d59269..eec7f4a1 100644 --- a/src/hyperspell/_utils/_utils.py +++ b/src/hyperspell/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input diff --git a/src/hyperspell/_version.py b/src/hyperspell/_version.py index d4a6d30b..5d030cd7 100644 --- a/src/hyperspell/_version.py +++ b/src/hyperspell/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "hyperspell" -__version__ = "0.26.0" # x-release-please-version +__version__ = "0.27.0" # x-release-please-version diff --git a/src/hyperspell/resources/__init__.py b/src/hyperspell/resources/__init__.py index d9c32c3f..085ac7c5 100644 --- a/src/hyperspell/resources/__init__.py +++ b/src/hyperspell/resources/__init__.py @@ -32,6 +32,14 @@ MemoriesResourceWithStreamingResponse, AsyncMemoriesResourceWithStreamingResponse, ) +from .connections import ( + ConnectionsResource, + AsyncConnectionsResource, + ConnectionsResourceWithRawResponse, + AsyncConnectionsResourceWithRawResponse, + ConnectionsResourceWithStreamingResponse, + AsyncConnectionsResourceWithStreamingResponse, +) from .integrations import ( IntegrationsResource, AsyncIntegrationsResource, @@ -42,6 +50,12 @@ ) __all__ = [ + "ConnectionsResource", + "AsyncConnectionsResource", + "ConnectionsResourceWithRawResponse", + "AsyncConnectionsResourceWithRawResponse", + "ConnectionsResourceWithStreamingResponse", + "AsyncConnectionsResourceWithStreamingResponse", "IntegrationsResource", "AsyncIntegrationsResource", "IntegrationsResourceWithRawResponse", diff --git a/src/hyperspell/resources/connections.py b/src/hyperspell/resources/connections.py new file mode 100644 index 00000000..98799ee3 --- /dev/null +++ b/src/hyperspell/resources/connections.py @@ -0,0 +1,216 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.connection_list_response import ConnectionListResponse +from ..types.connection_revoke_response import ConnectionRevokeResponse + +__all__ = ["ConnectionsResource", "AsyncConnectionsResource"] + + +class ConnectionsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ConnectionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/hyperspell/python-sdk#accessing-raw-response-data-eg-headers + """ + return ConnectionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ConnectionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/hyperspell/python-sdk#with_streaming_response + """ + return ConnectionsResourceWithStreamingResponse(self) + + def list( + self, + *, + # 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ConnectionListResponse: + """List all connections for the user.""" + return self._get( + "/connections/list", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ConnectionListResponse, + ) + + def revoke( + self, + connection_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ConnectionRevokeResponse: + """ + Revokes Hyperspell's access the given provider and deletes all stored + credentials and indexed data. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not connection_id: + raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}") + return self._delete( + f"/connections/{connection_id}/revoke", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ConnectionRevokeResponse, + ) + + +class AsyncConnectionsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncConnectionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/hyperspell/python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncConnectionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncConnectionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/hyperspell/python-sdk#with_streaming_response + """ + return AsyncConnectionsResourceWithStreamingResponse(self) + + async def list( + self, + *, + # 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ConnectionListResponse: + """List all connections for the user.""" + return await self._get( + "/connections/list", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ConnectionListResponse, + ) + + async def revoke( + self, + connection_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ConnectionRevokeResponse: + """ + Revokes Hyperspell's access the given provider and deletes all stored + credentials and indexed data. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not connection_id: + raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}") + return await self._delete( + f"/connections/{connection_id}/revoke", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ConnectionRevokeResponse, + ) + + +class ConnectionsResourceWithRawResponse: + def __init__(self, connections: ConnectionsResource) -> None: + self._connections = connections + + self.list = to_raw_response_wrapper( + connections.list, + ) + self.revoke = to_raw_response_wrapper( + connections.revoke, + ) + + +class AsyncConnectionsResourceWithRawResponse: + def __init__(self, connections: AsyncConnectionsResource) -> None: + self._connections = connections + + self.list = async_to_raw_response_wrapper( + connections.list, + ) + self.revoke = async_to_raw_response_wrapper( + connections.revoke, + ) + + +class ConnectionsResourceWithStreamingResponse: + def __init__(self, connections: ConnectionsResource) -> None: + self._connections = connections + + self.list = to_streamed_response_wrapper( + connections.list, + ) + self.revoke = to_streamed_response_wrapper( + connections.revoke, + ) + + +class AsyncConnectionsResourceWithStreamingResponse: + def __init__(self, connections: AsyncConnectionsResource) -> None: + self._connections = connections + + self.list = async_to_streamed_response_wrapper( + connections.list, + ) + self.revoke = async_to_streamed_response_wrapper( + connections.revoke, + ) diff --git a/src/hyperspell/resources/integrations/integrations.py b/src/hyperspell/resources/integrations/integrations.py index 178d7b9a..09eb99a0 100644 --- a/src/hyperspell/resources/integrations/integrations.py +++ b/src/hyperspell/resources/integrations/integrations.py @@ -43,7 +43,6 @@ AsyncGoogleCalendarResourceWithStreamingResponse, ) from ...types.integration_list_response import IntegrationListResponse -from ...types.integration_revoke_response import IntegrationRevokeResponse from ...types.integration_connect_response import IntegrationConnectResponse __all__ = ["IntegrationsResource", "AsyncIntegrationsResource"] @@ -140,40 +139,6 @@ def connect( cast_to=IntegrationConnectResponse, ) - def revoke( - self, - integration_id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> IntegrationRevokeResponse: - """ - Revokes Hyperspell's access the given provider and deletes all stored - credentials and indexed data. - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not integration_id: - raise ValueError(f"Expected a non-empty value for `integration_id` but received {integration_id!r}") - return self._get( - f"/integrations/{integration_id}/revoke", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=IntegrationRevokeResponse, - ) - class AsyncIntegrationsResource(AsyncAPIResource): @cached_property @@ -266,40 +231,6 @@ async def connect( cast_to=IntegrationConnectResponse, ) - async def revoke( - self, - integration_id: str, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> IntegrationRevokeResponse: - """ - Revokes Hyperspell's access the given provider and deletes all stored - credentials and indexed data. - - Args: - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not integration_id: - raise ValueError(f"Expected a non-empty value for `integration_id` but received {integration_id!r}") - return await self._get( - f"/integrations/{integration_id}/revoke", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=IntegrationRevokeResponse, - ) - class IntegrationsResourceWithRawResponse: def __init__(self, integrations: IntegrationsResource) -> None: @@ -311,9 +242,6 @@ def __init__(self, integrations: IntegrationsResource) -> None: self.connect = to_raw_response_wrapper( integrations.connect, ) - self.revoke = to_raw_response_wrapper( - integrations.revoke, - ) @cached_property def google_calendar(self) -> GoogleCalendarResourceWithRawResponse: @@ -338,9 +266,6 @@ def __init__(self, integrations: AsyncIntegrationsResource) -> None: self.connect = async_to_raw_response_wrapper( integrations.connect, ) - self.revoke = async_to_raw_response_wrapper( - integrations.revoke, - ) @cached_property def google_calendar(self) -> AsyncGoogleCalendarResourceWithRawResponse: @@ -365,9 +290,6 @@ def __init__(self, integrations: IntegrationsResource) -> None: self.connect = to_streamed_response_wrapper( integrations.connect, ) - self.revoke = to_streamed_response_wrapper( - integrations.revoke, - ) @cached_property def google_calendar(self) -> GoogleCalendarResourceWithStreamingResponse: @@ -392,9 +314,6 @@ def __init__(self, integrations: AsyncIntegrationsResource) -> None: self.connect = async_to_streamed_response_wrapper( integrations.connect, ) - self.revoke = async_to_streamed_response_wrapper( - integrations.revoke, - ) @cached_property def google_calendar(self) -> AsyncGoogleCalendarResourceWithStreamingResponse: diff --git a/src/hyperspell/resources/memories.py b/src/hyperspell/resources/memories.py index 044081ad..628e868b 100644 --- a/src/hyperspell/resources/memories.py +++ b/src/hyperspell/resources/memories.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Union, Mapping, Optional, cast +from typing import Dict, List, Union, Mapping, Optional, cast from datetime import datetime from typing_extensions import Literal @@ -256,6 +256,7 @@ def add( text: str, collection: Optional[str] | Omit = omit, date: Union[str, datetime] | Omit = omit, + metadata: Optional[Dict[str, Union[str, float, bool]]] | Omit = omit, resource_id: str | Omit = omit, title: Optional[str] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -281,6 +282,9 @@ def add( the date of the last message). This helps the ranking algorithm and allows you to filter by date range. + metadata: Custom metadata for filtering. Keys must be alphanumeric with underscores, max + 64 chars. Values must be string, number, or boolean. + resource_id: The resource ID to add the document to. If not provided, a new resource ID will be generated. If provided, the document will be updated if it already exists. @@ -301,6 +305,7 @@ def add( "text": text, "collection": collection, "date": date, + "metadata": metadata, "resource_id": resource_id, "title": title, }, @@ -527,6 +532,7 @@ def upload( *, file: FileTypes, collection: Optional[str] | Omit = omit, + metadata: Optional[str] | Omit = omit, # 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, @@ -546,6 +552,9 @@ def upload( collection: The collection to add the document to. + metadata: Custom metadata as JSON string for filtering. Keys must be alphanumeric with + underscores, max 64 chars. Values must be string, number, or boolean. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -558,6 +567,7 @@ def upload( { "file": file, "collection": collection, + "metadata": metadata, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) @@ -802,6 +812,7 @@ async def add( text: str, collection: Optional[str] | Omit = omit, date: Union[str, datetime] | Omit = omit, + metadata: Optional[Dict[str, Union[str, float, bool]]] | Omit = omit, resource_id: str | Omit = omit, title: Optional[str] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -827,6 +838,9 @@ async def add( the date of the last message). This helps the ranking algorithm and allows you to filter by date range. + metadata: Custom metadata for filtering. Keys must be alphanumeric with underscores, max + 64 chars. Values must be string, number, or boolean. + resource_id: The resource ID to add the document to. If not provided, a new resource ID will be generated. If provided, the document will be updated if it already exists. @@ -847,6 +861,7 @@ async def add( "text": text, "collection": collection, "date": date, + "metadata": metadata, "resource_id": resource_id, "title": title, }, @@ -1073,6 +1088,7 @@ async def upload( *, file: FileTypes, collection: Optional[str] | Omit = omit, + metadata: Optional[str] | Omit = omit, # 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, @@ -1092,6 +1108,9 @@ async def upload( collection: The collection to add the document to. + metadata: Custom metadata as JSON string for filtering. Keys must be alphanumeric with + underscores, max 64 chars. Values must be string, number, or boolean. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -1104,6 +1123,7 @@ async def upload( { "file": file, "collection": collection, + "metadata": metadata, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) diff --git a/src/hyperspell/types/__init__.py b/src/hyperspell/types/__init__.py index 5f3077cf..fb573526 100644 --- a/src/hyperspell/types/__init__.py +++ b/src/hyperspell/types/__init__.py @@ -16,11 +16,12 @@ from .auth_user_token_params import AuthUserTokenParams as AuthUserTokenParams from .memory_delete_response import MemoryDeleteResponse as MemoryDeleteResponse from .memory_status_response import MemoryStatusResponse as MemoryStatusResponse +from .connection_list_response import ConnectionListResponse as ConnectionListResponse from .auth_delete_user_response import AuthDeleteUserResponse as AuthDeleteUserResponse from .integration_list_response import IntegrationListResponse as IntegrationListResponse +from .connection_revoke_response import ConnectionRevokeResponse as ConnectionRevokeResponse from .integration_connect_params import IntegrationConnectParams as IntegrationConnectParams from .evaluate_score_query_params import EvaluateScoreQueryParams as EvaluateScoreQueryParams -from .integration_revoke_response import IntegrationRevokeResponse as IntegrationRevokeResponse from .integration_connect_response import IntegrationConnectResponse as IntegrationConnectResponse from .evaluate_score_query_response import EvaluateScoreQueryResponse as EvaluateScoreQueryResponse from .evaluate_score_highlight_params import EvaluateScoreHighlightParams as EvaluateScoreHighlightParams diff --git a/src/hyperspell/types/auth_me_response.py b/src/hyperspell/types/auth_me_response.py index 7eb70095..7eee2ac6 100644 --- a/src/hyperspell/types/auth_me_response.py +++ b/src/hyperspell/types/auth_me_response.py @@ -6,10 +6,12 @@ from .._models import BaseModel -__all__ = ["AuthMeResponse", "App", "Connection"] +__all__ = ["AuthMeResponse", "App"] class App(BaseModel): + """The Hyperspell app's id this user belongs to""" + id: str """The Hyperspell app's id this user belongs to""" @@ -23,64 +25,6 @@ class App(BaseModel): """The app's redirect URL""" -class Connection(BaseModel): - id: str - """The connection's id""" - - label: Optional[str] = None - """The connection's label""" - - provider: Literal[ - "collections", - "vault", - "web_crawler", - "notion", - "slack", - "google_calendar", - "reddit", - "box", - "google_drive", - "airtable", - "algolia", - "amplitude", - "asana", - "ashby", - "bamboohr", - "basecamp", - "bubbles", - "calendly", - "confluence", - "clickup", - "datadog", - "deel", - "discord", - "dropbox", - "exa", - "facebook", - "front", - "github", - "gitlab", - "google_docs", - "google_mail", - "google_sheet", - "hubspot", - "jira", - "linear", - "microsoft_teams", - "mixpanel", - "monday", - "outlook", - "perplexity", - "rippling", - "salesforce", - "segment", - "todoist", - "twitter", - "zoom", - ] - """The connection's provider""" - - class AuthMeResponse(BaseModel): id: str """The user's id""" @@ -140,9 +84,6 @@ class AuthMeResponse(BaseModel): ] """All integrations available for the app""" - connections: List[Connection] - """Established connections for the user""" - installed_integrations: List[ Literal[ "collections", diff --git a/src/hyperspell/types/connection_list_response.py b/src/hyperspell/types/connection_list_response.py new file mode 100644 index 00000000..0a60e0e6 --- /dev/null +++ b/src/hyperspell/types/connection_list_response.py @@ -0,0 +1,73 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ConnectionListResponse", "Connection"] + + +class Connection(BaseModel): + id: str + """The connection's id""" + + integration_id: str + """The connection's integration id""" + + label: Optional[str] = None + """The connection's label""" + + provider: Literal[ + "collections", + "vault", + "web_crawler", + "notion", + "slack", + "google_calendar", + "reddit", + "box", + "google_drive", + "airtable", + "algolia", + "amplitude", + "asana", + "ashby", + "bamboohr", + "basecamp", + "bubbles", + "calendly", + "confluence", + "clickup", + "datadog", + "deel", + "discord", + "dropbox", + "exa", + "facebook", + "front", + "github", + "gitlab", + "google_docs", + "google_mail", + "google_sheet", + "hubspot", + "jira", + "linear", + "microsoft_teams", + "mixpanel", + "monday", + "outlook", + "perplexity", + "rippling", + "salesforce", + "segment", + "todoist", + "twitter", + "zoom", + ] + """The connection's provider""" + + +class ConnectionListResponse(BaseModel): + connections: List[Connection] diff --git a/src/hyperspell/types/integration_revoke_response.py b/src/hyperspell/types/connection_revoke_response.py similarity index 65% rename from src/hyperspell/types/integration_revoke_response.py rename to src/hyperspell/types/connection_revoke_response.py index 2385ca96..63530302 100644 --- a/src/hyperspell/types/integration_revoke_response.py +++ b/src/hyperspell/types/connection_revoke_response.py @@ -2,10 +2,10 @@ from .._models import BaseModel -__all__ = ["IntegrationRevokeResponse"] +__all__ = ["ConnectionRevokeResponse"] -class IntegrationRevokeResponse(BaseModel): +class ConnectionRevokeResponse(BaseModel): message: str success: bool diff --git a/src/hyperspell/types/integration_list_response.py b/src/hyperspell/types/integration_list_response.py index 36c8fed7..c189654f 100644 --- a/src/hyperspell/types/integration_list_response.py +++ b/src/hyperspell/types/integration_list_response.py @@ -15,6 +15,15 @@ class Integration(BaseModel): allow_multiple_connections: bool """Whether the integration allows multiple connections""" + auth_provider: Literal["nango", "hyperspell", "composio", "whitelabel", "unified"] + """The integration's auth provider""" + + icon: str + """Generate a display name from the provider by capitalizing each word.""" + + name: str + """Generate a display name from the provider by capitalizing each word.""" + provider: Literal[ "collections", "vault", diff --git a/src/hyperspell/types/memory_add_params.py b/src/hyperspell/types/memory_add_params.py index 5c94f1db..e963f6d2 100644 --- a/src/hyperspell/types/memory_add_params.py +++ b/src/hyperspell/types/memory_add_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Union, Optional +from typing import Dict, Union, Optional from datetime import datetime from typing_extensions import Required, Annotated, TypedDict @@ -27,6 +27,13 @@ class MemoryAddParams(TypedDict, total=False): range. """ + metadata: Optional[Dict[str, Union[str, float, bool]]] + """Custom metadata for filtering. + + Keys must be alphanumeric with underscores, max 64 chars. Values must be string, + number, or boolean. + """ + resource_id: str """The resource ID to add the document to. diff --git a/src/hyperspell/types/memory_search_params.py b/src/hyperspell/types/memory_search_params.py index 07e692a2..a12d2359 100644 --- a/src/hyperspell/types/memory_search_params.py +++ b/src/hyperspell/types/memory_search_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Union, Optional +from typing import Dict, List, Union, Optional from datetime import datetime from typing_extensions import Literal, Required, Annotated, TypedDict @@ -91,12 +91,20 @@ class MemorySearchParams(TypedDict, total=False): class OptionsBox(TypedDict, total=False): + """Search options for Box""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + weight: float """Weight of results from this source. @@ -107,12 +115,20 @@ class OptionsBox(TypedDict, total=False): class OptionsCollections(TypedDict, total=False): + """Search options for vault""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + weight: float """Weight of results from this source. @@ -123,6 +139,8 @@ class OptionsCollections(TypedDict, total=False): class OptionsGoogleCalendar(TypedDict, total=False): + """Search options for Google Calendar""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" @@ -136,6 +154,12 @@ class OptionsGoogleCalendar(TypedDict, total=False): list of calendars with the `/integrations/google_calendar/list` endpoint. """ + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + weight: float """Weight of results from this source. @@ -146,12 +170,20 @@ class OptionsGoogleCalendar(TypedDict, total=False): class OptionsGoogleDrive(TypedDict, total=False): + """Search options for Google Drive""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + weight: float """Weight of results from this source. @@ -162,12 +194,20 @@ class OptionsGoogleDrive(TypedDict, total=False): class OptionsGoogleMail(TypedDict, total=False): + """Search options for Gmail""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + label_ids: SequenceNotStr[str] """List of label IDs to filter messages (e.g., ['INBOX', 'SENT', 'DRAFT']). @@ -186,12 +226,20 @@ class OptionsGoogleMail(TypedDict, total=False): class OptionsNotion(TypedDict, total=False): + """Search options for Notion""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + notion_page_ids: SequenceNotStr[str] """List of Notion page IDs to search. @@ -208,12 +256,20 @@ class OptionsNotion(TypedDict, total=False): class OptionsReddit(TypedDict, total=False): + """Search options for Reddit""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + period: Literal["hour", "day", "week", "month", "year", "all"] """The time period to search. Defaults to 'month'.""" @@ -236,6 +292,8 @@ class OptionsReddit(TypedDict, total=False): class OptionsSlack(TypedDict, total=False): + """Search options for Slack""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" @@ -248,6 +306,12 @@ class OptionsSlack(TypedDict, total=False): exclude_archived: Optional[bool] """If set, pass 'exclude_archived' to Slack. If None, omit the param.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + include_dms: bool """Include direct messages (im) when listing conversations.""" @@ -270,16 +334,24 @@ class OptionsSlack(TypedDict, total=False): class OptionsWebCrawler(TypedDict, total=False): + """Search options for Web Crawler""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" before: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created before this date.""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + max_depth: int """Maximum depth to crawl from the starting URL""" - url: Union[str, object] + url: Optional[str] """The URL to crawl""" weight: float @@ -292,6 +364,8 @@ class OptionsWebCrawler(TypedDict, total=False): class Options(TypedDict, total=False): + """Search options for the query.""" + after: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] """Only query documents created on or after this date.""" @@ -307,6 +381,12 @@ class Options(TypedDict, total=False): collections: OptionsCollections """Search options for vault""" + filter: Optional[Dict[str, object]] + """Metadata filters using MongoDB-style operators. + + Example: {'status': 'published', 'priority': {'$gt': 3}} + """ + google_calendar: OptionsGoogleCalendar """Search options for Google Calendar""" diff --git a/src/hyperspell/types/memory_upload_params.py b/src/hyperspell/types/memory_upload_params.py index 51f93268..7e866983 100644 --- a/src/hyperspell/types/memory_upload_params.py +++ b/src/hyperspell/types/memory_upload_params.py @@ -16,3 +16,10 @@ class MemoryUploadParams(TypedDict, total=False): collection: Optional[str] """The collection to add the document to.""" + + metadata: Optional[str] + """Custom metadata as JSON string for filtering. + + Keys must be alphanumeric with underscores, max 64 chars. Values must be string, + number, or boolean. + """ diff --git a/tests/api_resources/test_connections.py b/tests/api_resources/test_connections.py new file mode 100644 index 00000000..d81921ab --- /dev/null +++ b/tests/api_resources/test_connections.py @@ -0,0 +1,150 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from hyperspell import Hyperspell, AsyncHyperspell +from tests.utils import assert_matches_type +from hyperspell.types import ConnectionListResponse, ConnectionRevokeResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestConnections: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_list(self, client: Hyperspell) -> None: + connection = client.connections.list() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: Hyperspell) -> None: + response = client.connections.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: Hyperspell) -> None: + with client.connections.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_revoke(self, client: Hyperspell) -> None: + connection = client.connections.revoke( + "connection_id", + ) + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + @parametrize + def test_raw_response_revoke(self, client: Hyperspell) -> None: + response = client.connections.with_raw_response.revoke( + "connection_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + @parametrize + def test_streaming_response_revoke(self, client: Hyperspell) -> None: + with client.connections.with_streaming_response.revoke( + "connection_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_revoke(self, client: Hyperspell) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `connection_id` but received ''"): + client.connections.with_raw_response.revoke( + "", + ) + + +class TestAsyncConnections: + 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: AsyncHyperspell) -> None: + connection = await async_client.connections.list() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncHyperspell) -> None: + response = await async_client.connections.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncHyperspell) -> None: + async with async_client.connections.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(ConnectionListResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_revoke(self, async_client: AsyncHyperspell) -> None: + connection = await async_client.connections.revoke( + "connection_id", + ) + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + @parametrize + async def test_raw_response_revoke(self, async_client: AsyncHyperspell) -> None: + response = await async_client.connections.with_raw_response.revoke( + "connection_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + @parametrize + async def test_streaming_response_revoke(self, async_client: AsyncHyperspell) -> None: + async with async_client.connections.with_streaming_response.revoke( + "connection_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(ConnectionRevokeResponse, connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_revoke(self, async_client: AsyncHyperspell) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `connection_id` but received ''"): + await async_client.connections.with_raw_response.revoke( + "", + ) diff --git a/tests/api_resources/test_integrations.py b/tests/api_resources/test_integrations.py index 3b2934d7..ced89a16 100644 --- a/tests/api_resources/test_integrations.py +++ b/tests/api_resources/test_integrations.py @@ -9,11 +9,7 @@ from hyperspell import Hyperspell, AsyncHyperspell from tests.utils import assert_matches_type -from hyperspell.types import ( - IntegrationListResponse, - IntegrationRevokeResponse, - IntegrationConnectResponse, -) +from hyperspell.types import IntegrationListResponse, IntegrationConnectResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -92,44 +88,6 @@ def test_path_params_connect(self, client: Hyperspell) -> None: integration_id="", ) - @parametrize - def test_method_revoke(self, client: Hyperspell) -> None: - integration = client.integrations.revoke( - "integration_id", - ) - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - @parametrize - def test_raw_response_revoke(self, client: Hyperspell) -> None: - response = client.integrations.with_raw_response.revoke( - "integration_id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - integration = response.parse() - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - @parametrize - def test_streaming_response_revoke(self, client: Hyperspell) -> None: - with client.integrations.with_streaming_response.revoke( - "integration_id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - integration = response.parse() - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_path_params_revoke(self, client: Hyperspell) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `integration_id` but received ''"): - client.integrations.with_raw_response.revoke( - "", - ) - class TestAsyncIntegrations: parametrize = pytest.mark.parametrize( @@ -206,41 +164,3 @@ async def test_path_params_connect(self, async_client: AsyncHyperspell) -> None: await async_client.integrations.with_raw_response.connect( integration_id="", ) - - @parametrize - async def test_method_revoke(self, async_client: AsyncHyperspell) -> None: - integration = await async_client.integrations.revoke( - "integration_id", - ) - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - @parametrize - async def test_raw_response_revoke(self, async_client: AsyncHyperspell) -> None: - response = await async_client.integrations.with_raw_response.revoke( - "integration_id", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - integration = await response.parse() - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - @parametrize - async def test_streaming_response_revoke(self, async_client: AsyncHyperspell) -> None: - async with async_client.integrations.with_streaming_response.revoke( - "integration_id", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - integration = await response.parse() - assert_matches_type(IntegrationRevokeResponse, integration, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_path_params_revoke(self, async_client: AsyncHyperspell) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `integration_id` but received ''"): - await async_client.integrations.with_raw_response.revoke( - "", - ) diff --git a/tests/api_resources/test_memories.py b/tests/api_resources/test_memories.py index 182601a5..87f5dbac 100644 --- a/tests/api_resources/test_memories.py +++ b/tests/api_resources/test_memories.py @@ -115,6 +115,7 @@ def test_method_add_with_all_params(self, client: Hyperspell) -> None: text="text", collection="collection", date=parse_datetime("2019-12-27T18:11:19.117Z"), + metadata={"foo": "string"}, resource_id="resource_id", title="title", ) @@ -206,27 +207,33 @@ def test_method_search_with_all_params(self, client: Hyperspell) -> None: "box": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, "collections": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, + "filter": {"foo": "bar"}, "google_calendar": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), "calendar_id": "calendar_id", + "filter": {"foo": "bar"}, "weight": 0, }, "google_drive": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, "google_mail": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "label_ids": ["string"], "weight": 0, }, @@ -234,12 +241,14 @@ def test_method_search_with_all_params(self, client: Hyperspell) -> None: "notion": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "notion_page_ids": ["string"], "weight": 0, }, "reddit": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "period": "hour", "sort": "relevance", "subreddit": "subreddit", @@ -250,6 +259,7 @@ def test_method_search_with_all_params(self, client: Hyperspell) -> None: "before": parse_datetime("2019-12-27T18:11:19.117Z"), "channels": ["string"], "exclude_archived": True, + "filter": {"foo": "bar"}, "include_dms": True, "include_group_dms": True, "include_private": True, @@ -258,8 +268,9 @@ def test_method_search_with_all_params(self, client: Hyperspell) -> None: "web_crawler": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "max_depth": 0, - "url": "string", + "url": "url", "weight": 0, }, }, @@ -328,6 +339,7 @@ def test_method_upload_with_all_params(self, client: Hyperspell) -> None: memory = client.memories.upload( file=b"raw file contents", collection="collection", + metadata="metadata", ) assert_matches_type(MemoryStatus, memory, path=["response"]) @@ -451,6 +463,7 @@ async def test_method_add_with_all_params(self, async_client: AsyncHyperspell) - text="text", collection="collection", date=parse_datetime("2019-12-27T18:11:19.117Z"), + metadata={"foo": "string"}, resource_id="resource_id", title="title", ) @@ -542,27 +555,33 @@ async def test_method_search_with_all_params(self, async_client: AsyncHyperspell "box": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, "collections": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, + "filter": {"foo": "bar"}, "google_calendar": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), "calendar_id": "calendar_id", + "filter": {"foo": "bar"}, "weight": 0, }, "google_drive": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "weight": 0, }, "google_mail": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "label_ids": ["string"], "weight": 0, }, @@ -570,12 +589,14 @@ async def test_method_search_with_all_params(self, async_client: AsyncHyperspell "notion": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "notion_page_ids": ["string"], "weight": 0, }, "reddit": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "period": "hour", "sort": "relevance", "subreddit": "subreddit", @@ -586,6 +607,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncHyperspell "before": parse_datetime("2019-12-27T18:11:19.117Z"), "channels": ["string"], "exclude_archived": True, + "filter": {"foo": "bar"}, "include_dms": True, "include_group_dms": True, "include_private": True, @@ -594,8 +616,9 @@ async def test_method_search_with_all_params(self, async_client: AsyncHyperspell "web_crawler": { "after": parse_datetime("2019-12-27T18:11:19.117Z"), "before": parse_datetime("2019-12-27T18:11:19.117Z"), + "filter": {"foo": "bar"}, "max_depth": 0, - "url": "string", + "url": "url", "weight": 0, }, }, @@ -664,6 +687,7 @@ async def test_method_upload_with_all_params(self, async_client: AsyncHyperspell memory = await async_client.memories.upload( file=b"raw file contents", collection="collection", + metadata="metadata", ) assert_matches_type(MemoryStatus, memory, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index eff94130..770b4bba 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -60,55 +60,53 @@ def _get_open_connections(client: Hyperspell | AsyncHyperspell) -> int: class TestHyperspell: - client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: Hyperspell) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: Hyperspell) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: Hyperspell) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(api_key="another My API Key") + copied = client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert client.api_key == "My API Key" - copied = self.client.copy(user_id="another My User ID") + copied = client.copy(user_id="another My User ID") assert copied.user_id == "another My User ID" - assert self.client.user_id == "My User ID" + assert client.user_id == "My User ID" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: Hyperspell) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = Hyperspell( @@ -147,6 +145,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = Hyperspell( @@ -188,13 +187,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: Hyperspell) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -205,12 +206,12 @@ def test_copy_signature(self) -> None: 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: + def test_copy_build_request(self, client: Hyperspell) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -267,14 +268,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: Hyperspell) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -291,6 +290,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -306,6 +307,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = Hyperspell( @@ -320,6 +323,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = Hyperspell( @@ -334,6 +339,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -346,18 +353,18 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = Hyperspell( + test_client = Hyperspell( base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True, default_headers={"X-Foo": "bar"}, ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = Hyperspell( + test_client2 = Hyperspell( base_url=base_url, api_key=api_key, user_id=user_id, @@ -367,10 +374,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -379,7 +389,7 @@ def test_validate_headers(self) -> None: assert request.headers.get("X-As-User") == user_id with pytest.raises(HyperspellError): - with update_env(**{"HYPERSPELL_TOKEN": Omit()}): + with update_env(**{"HYPERSPELL_API_KEY": Omit()}): client2 = Hyperspell(base_url=base_url, api_key=None, user_id=None, _strict_response_validation=True) _ = client2 @@ -405,8 +415,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -417,7 +429,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -428,7 +440,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -439,8 +451,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -450,7 +462,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -461,8 +473,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -475,7 +487,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -489,7 +501,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -532,7 +544,7 @@ def test_multipart_repeating_array(self, client: Hyperspell) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: Hyperspell) -> None: class Model1(BaseModel): name: str @@ -541,12 +553,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: Hyperspell) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -557,18 +569,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: Hyperspell) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -584,7 +596,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -598,6 +610,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(HYPERSPELL_BASE_URL="http://localhost:5000/from/env"): client = Hyperspell(api_key=api_key, user_id=user_id, _strict_response_validation=True) @@ -631,6 +645,7 @@ def test_base_url_trailing_slash(self, client: Hyperspell) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -660,6 +675,7 @@ def test_base_url_no_trailing_slash(self, client: Hyperspell) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -689,35 +705,36 @@ def test_absolute_request_url(self, client: Hyperspell) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - assert not client.is_closed() + test_client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: Hyperspell) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -745,11 +762,16 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=False) + non_strict_client = Hyperspell( + base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=False + ) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -772,9 +794,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = Hyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: Hyperspell + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -788,7 +810,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.memories.with_streaming_response.add(text="text").__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -797,7 +819,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.memories.with_streaming_response.add(text="text").__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -899,87 +921,81 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: Hyperspell) -> 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) + response = 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: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: Hyperspell) -> 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 - ) + 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 TestAsyncHyperspell: - client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncHyperspell) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(api_key="another My API Key") + copied = async_client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert async_client.api_key == "My API Key" - copied = self.client.copy(user_id="another My User ID") + copied = async_client.copy(user_id="another My User ID") assert copied.user_id == "another My User ID" - assert self.client.user_id == "My User ID" + assert async_client.user_id == "My User ID" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncHyperspell) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncHyperspell( base_url=base_url, api_key=api_key, @@ -1016,8 +1032,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncHyperspell( base_url=base_url, api_key=api_key, @@ -1057,13 +1074,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncHyperspell) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -1074,12 +1093,12 @@ def test_copy_signature(self) -> None: 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: + def test_copy_build_request(self, async_client: AsyncHyperspell) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1136,12 +1155,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncHyperspell) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1160,6 +1179,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1175,6 +1196,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncHyperspell( @@ -1189,6 +1212,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncHyperspell( @@ -1203,6 +1228,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1214,19 +1241,19 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncHyperspell( + async def test_default_headers_option(self) -> None: + test_client = AsyncHyperspell( base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True, default_headers={"X-Foo": "bar"}, ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncHyperspell( + test_client2 = AsyncHyperspell( base_url=base_url, api_key=api_key, user_id=user_id, @@ -1236,10 +1263,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1248,13 +1278,13 @@ def test_validate_headers(self) -> None: assert request.headers.get("X-As-User") == user_id with pytest.raises(HyperspellError): - with update_env(**{"HYPERSPELL_TOKEN": Omit()}): + with update_env(**{"HYPERSPELL_API_KEY": Omit()}): client2 = AsyncHyperspell( base_url=base_url, api_key=None, user_id=None, _strict_response_validation=True ) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncHyperspell( base_url=base_url, api_key=api_key, @@ -1276,8 +1306,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1288,7 +1320,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1299,7 +1331,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1310,8 +1342,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1321,7 +1353,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1332,8 +1364,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Hyperspell) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1346,7 +1378,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1360,7 +1392,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1403,7 +1435,7 @@ def test_multipart_repeating_array(self, async_client: AsyncHyperspell) -> None: ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> None: class Model1(BaseModel): name: str @@ -1412,12 +1444,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1428,18 +1460,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncHyperspell + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1455,11 +1489,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncHyperspell( base_url="https://example.com/from_init", api_key=api_key, user_id=user_id, _strict_response_validation=True ) @@ -1469,7 +1503,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(HYPERSPELL_BASE_URL="http://localhost:5000/from/env"): client = AsyncHyperspell(api_key=api_key, user_id=user_id, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1493,7 +1529,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncHyperspell) -> None: + async def test_base_url_trailing_slash(self, client: AsyncHyperspell) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1502,6 +1538,7 @@ def test_base_url_trailing_slash(self, client: AsyncHyperspell) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1522,7 +1559,7 @@ def test_base_url_trailing_slash(self, client: AsyncHyperspell) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncHyperspell) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncHyperspell) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1531,6 +1568,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncHyperspell) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1551,7 +1589,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncHyperspell) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncHyperspell) -> None: + async def test_absolute_request_url(self, client: AsyncHyperspell) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1560,37 +1598,43 @@ def test_absolute_request_url(self, client: AsyncHyperspell) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncHyperspell( + base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True + ) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncHyperspell( + base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True + ) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error( + self, respx_mock: MockRouter, async_client: AsyncHyperspell + ) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1605,7 +1649,6 @@ async def test_client_max_retries_validation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1619,11 +1662,16 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=False) + non_strict_client = AsyncHyperspell( + base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=False + ) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1646,13 +1694,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncHyperspell(base_url=base_url, api_key=api_key, user_id=user_id, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncHyperspell + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1665,7 +1712,7 @@ async def test_retrying_timeout_errors_doesnt_leak( with pytest.raises(APITimeoutError): await async_client.memories.with_streaming_response.add(text="text").__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1676,12 +1723,11 @@ async def test_retrying_status_errors_doesnt_leak( with pytest.raises(APIStatusError): await async_client.memories.with_streaming_response.add(text="text").__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1713,7 +1759,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncHyperspell, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1739,7 +1784,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("hyperspell._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncHyperspell, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1789,26 +1833,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> 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) + response = await async_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: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncHyperspell) -> 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( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) diff --git a/tests/test_models.py b/tests/test_models.py index 088404f1..88942a41 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from hyperspell._utils import PropertyInfo from hyperspell._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from hyperspell._models import BaseModel, construct_type +from hyperspell._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1")