From 1f04b9ecca3a4a3d2235c5cfa21bd9b36a358754 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 04:19:14 +0000 Subject: [PATCH 01/35] chore(ci): upload sdks to package manager --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ scripts/utils/upload-artifact.sh | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100755 scripts/utils/upload-artifact.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ea316c..45646ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,30 @@ jobs: - name: Run lints run: ./scripts/lint + upload: + if: github.repository == 'stainless-sdks/sunrise-python' + timeout-minutes: 10 + name: upload + permissions: + contents: read + id-token: write + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Get GitHub OIDC Token + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + test: timeout-minutes: 10 name: test diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..d87f5fb --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -exuo pipefail + +RESPONSE=$(curl -X POST "$URL" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ + -H "Content-Type: application/gzip" \ + --data-binary @- "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: npm install 'https://pkg.stainless.com/s/sunrise-python/$SHA'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi From f191464e75f48395e76d6007712ae8548268b45f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 16 May 2025 03:26:21 +0000 Subject: [PATCH 02/35] chore(ci): fix installation instructions --- scripts/utils/upload-artifact.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index d87f5fb..8ce93e3 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -18,7 +18,7 @@ UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: npm install 'https://pkg.stainless.com/s/sunrise-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/sunrise-python/$SHA'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 From 01370fb62278f1def879352910c2520102c89993 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 22 May 2025 02:34:34 +0000 Subject: [PATCH 03/35] chore(docs): grammar improvements --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 92a473f..97e18f0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,11 +16,11 @@ before making any information public. ## Reporting Non-SDK Related Security Issues If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Contextual AI please follow the respective company's security reporting guidelines. +or products provided by Contextual AI, please follow the respective company's security reporting guidelines. ### Contextual AI Terms and Policies -Please contact support@contextual.ai for any questions or concerns regarding security of our services. +Please contact support@contextual.ai for any questions or concerns regarding the security of our services. --- From 9fd7133c6748ba1b1676a674da35d57f02f01a86 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 03:31:23 +0000 Subject: [PATCH 04/35] fix(docs/api): remove references to nonexistent types --- api.md | 67 +++++++++++++++------------------------------------------- 1 file changed, 17 insertions(+), 50 deletions(-) diff --git a/api.md b/api.md index 34b9ced..900abd8 100644 --- a/api.md +++ b/api.md @@ -8,8 +8,6 @@ from contextual.types import ( Datastore, DatastoreMetadata, ListDatastoresResponse, - DatastoreDeleteResponse, - DatastoreResetResponse, ) ``` @@ -17,9 +15,9 @@ Methods: - client.datastores.create(\*\*params) -> CreateDatastoreResponse - client.datastores.list(\*\*params) -> SyncDatastoresPage[Datastore] -- client.datastores.delete(datastore_id) -> object +- client.datastores.delete(datastore_id) -> object - client.datastores.metadata(datastore_id) -> DatastoreMetadata -- client.datastores.reset(datastore_id) -> object +- client.datastores.reset(datastore_id) -> object ## Documents @@ -31,14 +29,13 @@ from contextual.types.datastores import ( DocumentMetadata, IngestionResponse, ListDocumentsResponse, - DocumentDeleteResponse, ) ``` Methods: - client.datastores.documents.list(datastore_id, \*\*params) -> SyncDocumentsPage[DocumentMetadata] -- client.datastores.documents.delete(document_id, \*, datastore_id) -> object +- client.datastores.documents.delete(document_id, \*, datastore_id) -> object - client.datastores.documents.ingest(datastore_id, \*\*params) -> IngestionResponse - client.datastores.documents.metadata(document_id, \*, datastore_id) -> DocumentMetadata - client.datastores.documents.set_metadata(document_id, \*, datastore_id, \*\*params) -> DocumentMetadata @@ -58,39 +55,31 @@ from contextual.types import ( GlobalConfig, ListAgentsResponse, RetrievalConfig, - AgentUpdateResponse, - AgentDeleteResponse, AgentMetadataResponse, - AgentResetResponse, ) ``` Methods: - client.agents.create(\*\*params) -> CreateAgentOutput -- client.agents.update(agent_id, \*\*params) -> object +- client.agents.update(agent_id, \*\*params) -> object - client.agents.list(\*\*params) -> SyncPage[Agent] -- client.agents.delete(agent_id) -> object +- client.agents.delete(agent_id) -> object - client.agents.metadata(agent_id) -> AgentMetadataResponse -- client.agents.reset(agent_id) -> object +- client.agents.reset(agent_id) -> object ## Query Types: ```python -from contextual.types.agents import ( - QueryResponse, - RetrievalInfoResponse, - QueryFeedbackResponse, - QueryMetricsResponse, -) +from contextual.types.agents import QueryResponse, RetrievalInfoResponse, QueryMetricsResponse ``` Methods: - client.agents.query.create(agent_id, \*\*params) -> QueryResponse -- client.agents.query.feedback(agent_id, \*\*params) -> object +- client.agents.query.feedback(agent_id, \*\*params) -> object - client.agents.query.metrics(agent_id, \*\*params) -> QueryMetricsResponse - client.agents.query.retrieval_info(message_id, \*, agent_id, \*\*params) -> RetrievalInfoResponse @@ -111,17 +100,13 @@ Methods: Types: ```python -from contextual.types.agents.evaluate import ( - EvaluationJobMetadata, - ListEvaluationJobsResponse, - JobCancelResponse, -) +from contextual.types.agents.evaluate import EvaluationJobMetadata, ListEvaluationJobsResponse ``` Methods: - client.agents.evaluate.jobs.list(agent_id) -> ListEvaluationJobsResponse -- client.agents.evaluate.jobs.cancel(job_id, \*, agent_id) -> object +- client.agents.evaluate.jobs.cancel(job_id, \*, agent_id) -> object - client.agents.evaluate.jobs.metadata(job_id, \*, agent_id) -> EvaluationJobMetadata ## Datasets @@ -134,36 +119,24 @@ from contextual.types.agents import CreateDatasetResponse, DatasetMetadata, List ### Tune -Types: - -```python -from contextual.types.agents.datasets import TuneDeleteResponse -``` - Methods: - client.agents.datasets.tune.create(agent_id, \*\*params) -> CreateDatasetResponse - client.agents.datasets.tune.retrieve(dataset_name, \*, agent_id, \*\*params) -> BinaryAPIResponse - client.agents.datasets.tune.update(dataset_name, \*, agent_id, \*\*params) -> CreateDatasetResponse - client.agents.datasets.tune.list(agent_id, \*\*params) -> ListDatasetsResponse -- client.agents.datasets.tune.delete(dataset_name, \*, agent_id) -> object +- client.agents.datasets.tune.delete(dataset_name, \*, agent_id) -> object - client.agents.datasets.tune.metadata(dataset_name, \*, agent_id, \*\*params) -> DatasetMetadata ### Evaluate -Types: - -```python -from contextual.types.agents.datasets import EvaluateDeleteResponse -``` - Methods: - client.agents.datasets.evaluate.create(agent_id, \*\*params) -> CreateDatasetResponse - client.agents.datasets.evaluate.retrieve(dataset_name, \*, agent_id, \*\*params) -> BinaryAPIResponse - client.agents.datasets.evaluate.update(dataset_name, \*, agent_id, \*\*params) -> CreateDatasetResponse - client.agents.datasets.evaluate.list(agent_id, \*\*params) -> ListDatasetsResponse -- client.agents.datasets.evaluate.delete(dataset_name, \*, agent_id) -> object +- client.agents.datasets.evaluate.delete(dataset_name, \*, agent_id) -> object - client.agents.datasets.evaluate.metadata(dataset_name, \*, agent_id, \*\*params) -> DatasetMetadata ## Tune @@ -183,13 +156,13 @@ Methods: Types: ```python -from contextual.types.agents.tune import ListTuneJobsResponse, TuneJobMetadata, JobDeleteResponse +from contextual.types.agents.tune import ListTuneJobsResponse, TuneJobMetadata ``` Methods: - client.agents.tune.jobs.list(agent_id) -> ListTuneJobsResponse -- client.agents.tune.jobs.delete(job_id, \*, agent_id) -> object +- client.agents.tune.jobs.delete(job_id, \*, agent_id) -> object - client.agents.tune.jobs.metadata(job_id, \*, agent_id) -> TuneJobMetadata ### Models @@ -209,20 +182,14 @@ Methods: Types: ```python -from contextual.types import ( - InviteUsersResponse, - ListUsersResponse, - NewUser, - UserUpdateResponse, - UserDeactivateResponse, -) +from contextual.types import InviteUsersResponse, ListUsersResponse, NewUser ``` Methods: -- client.users.update(\*\*params) -> object +- client.users.update(\*\*params) -> object - client.users.list(\*\*params) -> SyncUsersPage[User] -- client.users.deactivate(\*\*params) -> object +- client.users.deactivate(\*\*params) -> object - client.users.invite(\*\*params) -> InviteUsersResponse # LMUnit From 68f70a88e5b45773140c4b4a02c0506f3d078ad9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:26:42 +0000 Subject: [PATCH 05/35] chore(docs): remove reference to rye shell --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab82b5a..3c21def 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix From f603dcdd966c77ce3e8b8dba8e878eb273ef1688 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:38:57 +0000 Subject: [PATCH 06/35] chore(docs): remove unnecessary param examples --- README.md | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/README.md b/README.md index 4be5749..8863beb 100644 --- a/README.md +++ b/README.md @@ -149,33 +149,7 @@ client = ContextualAI() create_agent_output = client.agents.create( name="xxx", - agent_configs={ - "filter_and_rerank_config": { - "rerank_instructions": "rerank_instructions", - "reranker_score_filter_threshold": 0, - "top_k_reranked_chunks": 0, - }, - "generate_response_config": { - "avoid_commentary": True, - "calculate_groundedness": True, - "frequency_penalty": 0, - "max_new_tokens": 0, - "seed": 0, - "temperature": 0, - "top_p": 0, - }, - "global_config": { - "enable_filter": True, - "enable_multi_turn": True, - "enable_rerank": True, - "should_check_retrieval_need": True, - }, - "retrieval_config": { - "lexical_alpha": 0, - "semantic_alpha": 0, - "top_k_retrieved_chunks": 0, - }, - }, + agent_configs={}, ) print(create_agent_output.agent_configs) ``` From 35e7c78c7d1801a0afe4d73bbff3e7c695f5f19f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:42:45 +0000 Subject: [PATCH 07/35] feat(client): add follow_redirects request option --- src/contextual/_base_client.py | 6 ++++ src/contextual/_models.py | 2 ++ src/contextual/_types.py | 2 ++ tests/test_client.py | 54 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/contextual/_base_client.py b/src/contextual/_base_client.py index 7369dca..7f2624f 100644 --- a/src/contextual/_base_client.py +++ b/src/contextual/_base_client.py @@ -960,6 +960,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1460,6 +1463,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None diff --git a/src/contextual/_models.py b/src/contextual/_models.py index 798956f..4f21498 100644 --- a/src/contextual/_models.py +++ b/src/contextual/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/contextual/_types.py b/src/contextual/_types.py index 883a2da..46a038e 100644 --- a/src/contextual/_types.py +++ b/src/contextual/_types.py @@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +216,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/tests/test_client.py b/tests/test_client.py index 4617f67..a8bfe5c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -828,6 +828,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncContextualAI: client = AsyncContextualAI(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1657,3 +1684,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" From f75c912ff643028317dde5fb0dfd08470b26ac29 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 02:12:55 +0000 Subject: [PATCH 08/35] chore(tests): run tests in parallel --- pyproject.toml | 3 ++- requirements-dev.lock | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a3b763..8d72211 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", ] [tool.rye.scripts] @@ -125,7 +126,7 @@ replacement = '[\1](https://github.com/ContextualAI/contextual-client-python/tre [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short" +addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/requirements-dev.lock b/requirements-dev.lock index 42c2907..7680c2b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -30,6 +30,8 @@ distro==1.8.0 exceptiongroup==1.2.2 # via anyio # via pytest +execnet==2.1.1 + # via pytest-xdist filelock==3.12.4 # via virtualenv h11==0.14.0 @@ -72,7 +74,9 @@ pygments==2.18.0 pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio + # via pytest-xdist pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 python-dateutil==2.8.2 # via time-machine pytz==2023.3.post1 From 518cbabda3ce7f53721c0fc916ae89706899a4ec Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 02:37:22 +0000 Subject: [PATCH 09/35] fix(client): correctly parse binary response | stream --- src/contextual/_base_client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/contextual/_base_client.py b/src/contextual/_base_client.py index 7f2624f..ec3767e 100644 --- a/src/contextual/_base_client.py +++ b/src/contextual/_base_client.py @@ -1071,7 +1071,14 @@ def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, APIResponse): raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") @@ -1574,7 +1581,14 @@ async def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, AsyncAPIResponse): raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") From 0c4973fed123a77a16b189439b3f4976fcc91770 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 02:42:29 +0000 Subject: [PATCH 10/35] chore(tests): add tests for httpx client instantiation & proxies --- tests/test_client.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index a8bfe5c..bf5b6ce 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -31,6 +31,8 @@ DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, make_request_options, ) from contextual.types.agent_create_params import AgentCreateParams @@ -828,6 +830,28 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + @pytest.mark.respx(base_url=base_url) def test_follow_redirects(self, respx_mock: MockRouter) -> None: # Test that the default follow_redirects=True allows following redirects @@ -1685,6 +1709,28 @@ async def test_main() -> None: time.sleep(0.1) + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + @pytest.mark.respx(base_url=base_url) async def test_follow_redirects(self, respx_mock: MockRouter) -> None: # Test that the default follow_redirects=True allows following redirects From b324ed373c9c174a44eb52dc6d2384e82c0af4b8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 04:12:38 +0000 Subject: [PATCH 11/35] chore(internal): update conftest.py --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index aa99261..5f3d8ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os From 84fbba4c22dbbf8517841c7961a37dba246126dc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 06:43:28 +0000 Subject: [PATCH 12/35] chore(ci): enable for pull requests --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45646ff..381a918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: From b747f452ab31df0805dd07a516fe63c460353c57 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 02:15:34 +0000 Subject: [PATCH 13/35] chore(readme): update badges --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8863beb..4659d84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Contextual AI Python API library -[![PyPI version](https://img.shields.io/pypi/v/contextual-client.svg)](https://pypi.org/project/contextual-client/) +[![PyPI version]()](https://pypi.org/project/contextual-client/) The Contextual AI Python library provides convenient access to the Contextual AI REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, From d7920f111d6175e6714482918e34992fb51739d9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 05:52:30 +0000 Subject: [PATCH 14/35] fix(tests): fix: tests which call HTTP endpoints directly with the example parameters --- tests/test_client.py | 45 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index bf5b6ce..12b2537 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,9 +23,7 @@ from contextual import ContextualAI, AsyncContextualAI, APIResponseValidationError from contextual._types import Omit -from contextual._utils import maybe_transform from contextual._models import BaseModel, FinalRequestOptions -from contextual._constants import RAW_RESPONSE_HEADER from contextual._exceptions import APIStatusError, APITimeoutError, ContextualAIError, APIResponseValidationError from contextual._base_client import ( DEFAULT_TIMEOUT, @@ -35,7 +33,6 @@ DefaultAsyncHttpxClient, make_request_options, ) -from contextual.types.agent_create_params import AgentCreateParams from .utils import update_env @@ -725,32 +722,21 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("contextual._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: ContextualAI) -> None: respx_mock.post("/agents").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - self.client.post( - "/agents", - body=cast(object, maybe_transform(dict(name="Example"), AgentCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + client.agents.with_streaming_response.create(name="xxx").__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("contextual._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: ContextualAI) -> None: respx_mock.post("/agents").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.post( - "/agents", - body=cast(object, maybe_transform(dict(name="Example"), AgentCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + client.agents.with_streaming_response.create(name="xxx").__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1550,32 +1536,25 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("contextual._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncContextualAI + ) -> None: respx_mock.post("/agents").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await self.client.post( - "/agents", - body=cast(object, maybe_transform(dict(name="Example"), AgentCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + await async_client.agents.with_streaming_response.create(name="xxx").__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("contextual._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncContextualAI + ) -> None: respx_mock.post("/agents").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.post( - "/agents", - body=cast(object, maybe_transform(dict(name="Example"), AgentCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + await async_client.agents.with_streaming_response.create(name="xxx").__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) From 3517a3d02c7447c027bc82baf3a83333eb3c9b55 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 02:53:32 +0000 Subject: [PATCH 15/35] docs(client): fix httpx.Timeout documentation reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4659d84..e4997ac 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ client.with_options(max_retries=5).agents.create( ### Timeouts By default requests time out after 1 minute. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python from contextual import ContextualAI From d54f53cfa0878acbad344622f7aae1b2e939ae1c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Jun 2025 04:12:21 +0000 Subject: [PATCH 16/35] feat(client): add support for aiohttp --- README.md | 34 +++++++++++++++ pyproject.toml | 2 + requirements-dev.lock | 27 ++++++++++++ requirements.lock | 27 ++++++++++++ src/contextual/__init__.py | 3 +- src/contextual/_base_client.py | 22 ++++++++++ .../agents/datasets/test_evaluate.py | 4 +- .../agents/datasets/test_tune.py | 4 +- .../agents/evaluate/test_jobs.py | 4 +- tests/api_resources/agents/test_evaluate.py | 4 +- tests/api_resources/agents/test_query.py | 4 +- tests/api_resources/agents/test_tune.py | 4 +- tests/api_resources/agents/tune/test_jobs.py | 4 +- .../api_resources/agents/tune/test_models.py | 4 +- .../datastores/test_documents.py | 4 +- tests/api_resources/test_agents.py | 4 +- tests/api_resources/test_datastores.py | 4 +- tests/api_resources/test_generate.py | 4 +- tests/api_resources/test_lmunit.py | 4 +- tests/api_resources/test_parse.py | 4 +- tests/api_resources/test_rerank.py | 4 +- tests/api_resources/test_users.py | 4 +- tests/conftest.py | 43 ++++++++++++++++--- 23 files changed, 199 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e4997ac..19b3884 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,40 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install contextual-client[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from contextual import DefaultAioHttpClient +from contextual import AsyncContextualAI + + +async def main() -> None: + async with AsyncContextualAI( + api_key=os.environ.get("CONTEXTUAL_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + create_agent_output = await client.agents.create( + name="Example", + ) + print(create_agent_output.id) + + +asyncio.run(main()) +``` + ## Using types Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: diff --git a/pyproject.toml b/pyproject.toml index 8d72211..51667fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ classifiers = [ Homepage = "https://github.com/ContextualAI/contextual-client-python" Repository = "https://github.com/ContextualAI/contextual-client-python" +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 7680c2b..7ccc541 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,13 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via contextual-client + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 @@ -17,6 +24,10 @@ anyio==4.4.0 # via httpx argcomplete==3.1.2 # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -34,16 +45,23 @@ execnet==2.1.1 # via pytest-xdist filelock==3.12.4 # via virtualenv +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 # via contextual-client + # via httpx-aiohttp # via respx +httpx-aiohttp==0.1.6 + # via contextual-client idna==3.4 # via anyio # via httpx + # via yarl importlib-metadata==7.0.0 iniconfig==2.0.0 # via pytest @@ -51,6 +69,9 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl mypy==1.14.1 mypy-extensions==1.0.0 # via mypy @@ -65,6 +86,9 @@ platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via contextual-client pydantic-core==2.27.1 @@ -98,11 +122,14 @@ tomli==2.0.2 typing-extensions==4.12.2 # via anyio # via contextual-client + # via multidict # via mypy # via pydantic # via pydantic-core # via pyright virtualenv==20.24.5 # via nox +yarl==1.20.0 + # via aiohttp zipp==3.17.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index bc4698e..f69333d 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,11 +10,22 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via contextual-client + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 # via contextual-client # via httpx +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -22,15 +33,28 @@ distro==1.8.0 # via contextual-client exceptiongroup==1.2.2 # via anyio +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 # via contextual-client + # via httpx-aiohttp +httpx-aiohttp==0.1.6 + # via contextual-client idna==3.4 # via anyio # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via contextual-client pydantic-core==2.27.1 @@ -41,5 +65,8 @@ sniffio==1.3.0 typing-extensions==4.12.2 # via anyio # via contextual-client + # via multidict # via pydantic # via pydantic-core +yarl==1.20.0 + # via aiohttp diff --git a/src/contextual/__init__.py b/src/contextual/__init__.py index c9d0895..831570c 100644 --- a/src/contextual/__init__.py +++ b/src/contextual/__init__.py @@ -36,7 +36,7 @@ UnprocessableEntityError, APIResponseValidationError, ) -from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging __all__ = [ @@ -78,6 +78,7 @@ "DEFAULT_CONNECTION_LIMITS", "DefaultHttpxClient", "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", ] if not _t.TYPE_CHECKING: diff --git a/src/contextual/_base_client.py b/src/contextual/_base_client.py index ec3767e..fb8a539 100644 --- a/src/contextual/_base_client.py +++ b/src/contextual/_base_client.py @@ -1289,6 +1289,24 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + if TYPE_CHECKING: DefaultAsyncHttpxClient = httpx.AsyncClient """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK @@ -1297,8 +1315,12 @@ def __init__(self, **kwargs: Any) -> None: This is useful because overriding the `http_client` with your own instance of `httpx.AsyncClient` will result in httpx's defaults being used, not ours. """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" else: DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): diff --git a/tests/api_resources/agents/datasets/test_evaluate.py b/tests/api_resources/agents/datasets/test_evaluate.py index 752fcd6..9ef82cd 100644 --- a/tests/api_resources/agents/datasets/test_evaluate.py +++ b/tests/api_resources/agents/datasets/test_evaluate.py @@ -369,7 +369,9 @@ def test_path_params_metadata(self, client: ContextualAI) -> None: class TestAsyncEvaluate: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/datasets/test_tune.py b/tests/api_resources/agents/datasets/test_tune.py index 704dac4..ef47686 100644 --- a/tests/api_resources/agents/datasets/test_tune.py +++ b/tests/api_resources/agents/datasets/test_tune.py @@ -369,7 +369,9 @@ def test_path_params_metadata(self, client: ContextualAI) -> None: class TestAsyncTune: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/evaluate/test_jobs.py b/tests/api_resources/agents/evaluate/test_jobs.py index 75f186f..353f37e 100644 --- a/tests/api_resources/agents/evaluate/test_jobs.py +++ b/tests/api_resources/agents/evaluate/test_jobs.py @@ -153,7 +153,9 @@ def test_path_params_metadata(self, client: ContextualAI) -> None: class TestAsyncJobs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/test_evaluate.py b/tests/api_resources/agents/test_evaluate.py index ab28d72..ad00bfb 100644 --- a/tests/api_resources/agents/test_evaluate.py +++ b/tests/api_resources/agents/test_evaluate.py @@ -74,7 +74,9 @@ def test_path_params_create(self, client: ContextualAI) -> None: class TestAsyncEvaluate: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/test_query.py b/tests/api_resources/agents/test_query.py index 26b5b1d..e67f949 100644 --- a/tests/api_resources/agents/test_query.py +++ b/tests/api_resources/agents/test_query.py @@ -279,7 +279,9 @@ def test_path_params_retrieval_info(self, client: ContextualAI) -> None: class TestAsyncQuery: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/test_tune.py b/tests/api_resources/agents/test_tune.py index c9dacef..964bdc4 100644 --- a/tests/api_resources/agents/test_tune.py +++ b/tests/api_resources/agents/test_tune.py @@ -77,7 +77,9 @@ def test_path_params_create(self, client: ContextualAI) -> None: class TestAsyncTune: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/tune/test_jobs.py b/tests/api_resources/agents/tune/test_jobs.py index 0074de1..14a8cae 100644 --- a/tests/api_resources/agents/tune/test_jobs.py +++ b/tests/api_resources/agents/tune/test_jobs.py @@ -153,7 +153,9 @@ def test_path_params_metadata(self, client: ContextualAI) -> None: class TestAsyncJobs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/agents/tune/test_models.py b/tests/api_resources/agents/tune/test_models.py index f2918fe..4b7185a 100644 --- a/tests/api_resources/agents/tune/test_models.py +++ b/tests/api_resources/agents/tune/test_models.py @@ -57,7 +57,9 @@ def test_path_params_list(self, client: ContextualAI) -> None: class TestAsyncModels: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/datastores/test_documents.py b/tests/api_resources/datastores/test_documents.py index 525988a..570ebfc 100644 --- a/tests/api_resources/datastores/test_documents.py +++ b/tests/api_resources/datastores/test_documents.py @@ -278,7 +278,9 @@ def test_path_params_set_metadata(self, client: ContextualAI) -> None: class TestAsyncDocuments: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_agents.py b/tests/api_resources/test_agents.py index 9009cee..a3e19ba 100644 --- a/tests/api_resources/test_agents.py +++ b/tests/api_resources/test_agents.py @@ -320,7 +320,9 @@ def test_path_params_reset(self, client: ContextualAI) -> None: class TestAsyncAgents: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_datastores.py b/tests/api_resources/test_datastores.py index fe99724..767d1f8 100644 --- a/tests/api_resources/test_datastores.py +++ b/tests/api_resources/test_datastores.py @@ -203,7 +203,9 @@ def test_path_params_reset(self, client: ContextualAI) -> None: class TestAsyncDatastores: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_generate.py b/tests/api_resources/test_generate.py index 44938ae..bd288b7 100644 --- a/tests/api_resources/test_generate.py +++ b/tests/api_resources/test_generate.py @@ -90,7 +90,9 @@ def test_streaming_response_create(self, client: ContextualAI) -> None: class TestAsyncGenerate: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_lmunit.py b/tests/api_resources/test_lmunit.py index 4c5a5a2..367b4bd 100644 --- a/tests/api_resources/test_lmunit.py +++ b/tests/api_resources/test_lmunit.py @@ -56,7 +56,9 @@ def test_streaming_response_create(self, client: ContextualAI) -> None: class TestAsyncLMUnit: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_parse.py b/tests/api_resources/test_parse.py index 08898d9..78a8a18 100644 --- a/tests/api_resources/test_parse.py +++ b/tests/api_resources/test_parse.py @@ -185,7 +185,9 @@ def test_streaming_response_jobs(self, client: ContextualAI) -> None: class TestAsyncParse: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_rerank.py b/tests/api_resources/test_rerank.py index 6f97de0..68fbefc 100644 --- a/tests/api_resources/test_rerank.py +++ b/tests/api_resources/test_rerank.py @@ -68,7 +68,9 @@ def test_streaming_response_create(self, client: ContextualAI) -> None: class TestAsyncRerank: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/api_resources/test_users.py b/tests/api_resources/test_users.py index acd99a9..78edb82 100644 --- a/tests/api_resources/test_users.py +++ b/tests/api_resources/test_users.py @@ -163,7 +163,9 @@ def test_streaming_response_invite(self, client: ContextualAI) -> None: class TestAsyncUsers: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_update(self, async_client: AsyncContextualAI) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 5f3d8ba..125175d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,12 @@ import logging from typing import TYPE_CHECKING, Iterator, AsyncIterator +import httpx import pytest from pytest_asyncio import is_async_test -from contextual import ContextualAI, AsyncContextualAI +from contextual import ContextualAI, AsyncContextualAI, DefaultAioHttpClient +from contextual._utils import is_dict if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -27,6 +29,19 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -45,9 +60,25 @@ def client(request: FixtureRequest) -> Iterator[ContextualAI]: @pytest.fixture(scope="session") async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncContextualAI]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - async with AsyncContextualAI(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncContextualAI( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: yield client From dd32830a8266dbf736c85285ec611854659511e7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:19:48 +0000 Subject: [PATCH 17/35] chore(tests): skip some failing tests on the latest python versions --- tests/test_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 12b2537..15a6d55 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -191,6 +191,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -1001,6 +1002,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") From ce0af3be8b2f90af2bc4e38979a801df1e98e989 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 02:37:06 +0000 Subject: [PATCH 18/35] =?UTF-8?q?fix(ci):=20release-doctor=20=E2=80=94=20r?= =?UTF-8?q?eport=20correct=20token=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/check-release-environment | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/check-release-environment b/bin/check-release-environment index 4fafbfe..b845b0f 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${PYPI_TOKEN}" ]; then - errors+=("The CONTEXTUAL_AI_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi lenErrors=${#errors[@]} From b9520a0ad9c16d3ad0386ce70a15df4191751364 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 08:46:20 +0000 Subject: [PATCH 19/35] chore(ci): only run for pushes and fork pull requests --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 381a918..c97a085 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/sunrise-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -42,6 +43,7 @@ jobs: contents: read id-token: write runs-on: depot-ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -62,6 +64,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/sunrise-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 From 0e1ab57132d5a038aac790b463166200ae436fc3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:32:03 +0000 Subject: [PATCH 20/35] fix(ci): correct conditional --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c97a085..80da1b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,14 +36,13 @@ jobs: run: ./scripts/lint upload: - if: github.repository == 'stainless-sdks/sunrise-python' + if: github.repository == 'stainless-sdks/sunrise-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) timeout-minutes: 10 name: upload permissions: contents: read id-token: write runs-on: depot-ubuntu-24.04 - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 From f72dfb77ff1fcae80efa5b286800ed77af6d0889 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 05:07:43 +0000 Subject: [PATCH 21/35] chore(ci): change upload type --- .github/workflows/ci.yml | 18 ++++++++++++++++-- scripts/utils/upload-artifact.sh | 12 +++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80da1b5..242ae46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,10 @@ jobs: - name: Run lints run: ./scripts/lint - upload: + build: if: github.repository == 'stainless-sdks/sunrise-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) timeout-minutes: 10 - name: upload + name: build permissions: contents: read id-token: write @@ -46,6 +46,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + - name: Get GitHub OIDC Token id: github-oidc uses: actions/github-script@v6 diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index 8ce93e3..6fb2114 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -exuo pipefail -RESPONSE=$(curl -X POST "$URL" \ +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ -H "Authorization: Bearer $AUTH" \ -H "Content-Type: application/json") @@ -12,13 +14,13 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ - -H "Content-Type: application/gzip" \ - --data-binary @- "$SIGNED_URL" 2>&1) +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/sunrise-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/sunrise-python/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 From 0310d7ce2bca6a80cd3b0d53a1103b4dc1fa8c32 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:16:29 +0000 Subject: [PATCH 22/35] chore(internal): codegen related update --- requirements-dev.lock | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index 7ccc541..f3228a1 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via contextual-client # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via contextual-client idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index f69333d..2c03be1 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.2 httpx==0.28.1 # via contextual-client # via httpx-aiohttp -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via contextual-client idna==3.4 # via anyio From f0aca79b109176c6a83b31434ccdbc30e58f059d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 02:33:10 +0000 Subject: [PATCH 23/35] chore(internal): bump pinned h11 dep --- requirements-dev.lock | 4 ++-- requirements.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index f3228a1..1a6388b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -48,9 +48,9 @@ filelock==3.12.4 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via contextual-client diff --git a/requirements.lock b/requirements.lock index 2c03be1..321707d 100644 --- a/requirements.lock +++ b/requirements.lock @@ -36,9 +36,9 @@ exceptiongroup==1.2.2 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via contextual-client From f37217ff20d84d47c9adaf89c14151075e329972 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 02:53:23 +0000 Subject: [PATCH 24/35] chore(package): mark python 3.13 as supported --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 51667fd..5b36ef7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From 130f4c17f8fbf89a42fa1709d6e4b4a8b36c4036 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 02:47:43 +0000 Subject: [PATCH 25/35] fix(parsing): correctly handle nested discriminated unions --- src/contextual/_models.py | 13 ++++++----- tests/test_models.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/contextual/_models.py b/src/contextual/_models.py index 4f21498..528d568 100644 --- a/src/contextual/_models.py +++ b/src/contextual/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -366,7 +367,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) def is_basemodel(type_: type) -> bool: @@ -420,7 +421,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -438,8 +439,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() diff --git a/tests/test_models.py b/tests/test_models.py index 5adfed9..2a573c7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -889,3 +889,48 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) From 5857ef3c8252e39ab66b1dea3e035580d0f2f006 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 03:19:29 +0000 Subject: [PATCH 26/35] chore(readme): fix version rendering on pypi --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19b3884..e7083e2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Contextual AI Python API library -[![PyPI version]()](https://pypi.org/project/contextual-client/) + +[![PyPI version](https://img.shields.io/pypi/v/contextual-client.svg?label=pypi%20(stable))](https://pypi.org/project/contextual-client/) The Contextual AI Python library provides convenient access to the Contextual AI REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, From 1ba6bcc49090112b3ec0dc9a0b1f5c2b487e378e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 12 Jul 2025 02:13:02 +0000 Subject: [PATCH 27/35] fix(client): don't send Content-Type header on GET requests --- pyproject.toml | 2 +- src/contextual/_base_client.py | 11 +++++++++-- tests/test_client.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b36ef7..2433533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/ContextualAI/contextual-client-python" Repository = "https://github.com/ContextualAI/contextual-client-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true diff --git a/src/contextual/_base_client.py b/src/contextual/_base_client.py index fb8a539..afe0b3f 100644 --- a/src/contextual/_base_client.py +++ b/src/contextual/_base_client.py @@ -529,6 +529,15 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -540,8 +549,6 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) diff --git a/tests/test_client.py b/tests/test_client.py index 15a6d55..df3fc8d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -464,7 +464,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: ContextualAI) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1275,7 +1275,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncContextualAI) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, From 5aacfd73cd62e9440b927c74e29bd4ee03766334 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:12:26 +0000 Subject: [PATCH 28/35] feat: clean up environment call outs --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e7083e2..c05a737 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,6 @@ pip install contextual-client[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from contextual import DefaultAioHttpClient from contextual import AsyncContextualAI @@ -89,7 +88,7 @@ from contextual import AsyncContextualAI async def main() -> None: async with AsyncContextualAI( - api_key=os.environ.get("CONTEXTUAL_API_KEY"), # This is the default and can be omitted + api_key="My API Key", http_client=DefaultAioHttpClient(), ) as client: create_agent_output = await client.agents.create( From a81e19084356382c7b709215b1462e099d56f2a6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:15:04 +0000 Subject: [PATCH 29/35] fix(parsing): ignore empty metadata --- src/contextual/_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contextual/_models.py b/src/contextual/_models.py index 528d568..ffcbf67 100644 --- a/src/contextual/_models.py +++ b/src/contextual/_models.py @@ -439,7 +439,7 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if metadata is not None: + if metadata is not None and len(metadata) > 0: meta: tuple[Any, ...] = tuple(metadata) elif is_annotated_type(type_): meta = get_args(type_)[1:] From 89f10b3a97483b99e0ec06a346286619faec5c12 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:17:42 +0000 Subject: [PATCH 30/35] fix(parsing): parse extra field types --- src/contextual/_models.py | 25 +++++++++++++++++++++++-- tests/test_models.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/contextual/_models.py b/src/contextual/_models.py index ffcbf67..b8387ce 100644 --- a/src/contextual/_models.py +++ b/src/contextual/_models.py @@ -208,14 +208,18 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] else: fields_values[name] = field_get_default(field) + extra_field_type = _get_extra_fields_type(__cls) + _extra = {} for key, value in values.items(): if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + if PYDANTIC_V2: - _extra[key] = value + _extra[key] = parsed else: _fields_set.add(key) - fields_values[key] = value + fields_values[key] = parsed object.__setattr__(m, "__dict__", fields_values) @@ -370,6 +374,23 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if not PYDANTIC_V2: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + def is_basemodel(type_: type) -> bool: """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" if is_union(type_): diff --git a/tests/test_models.py b/tests/test_models.py index 2a573c7..ae4b3f0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal, Annotated, TypeAliasType @@ -934,3 +934,30 @@ class Type2(BaseModel): ) assert isinstance(model, Type1) assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" From 77265c18261b46255146f4a0fd82e2aae41ae160 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 04:24:01 +0000 Subject: [PATCH 31/35] chore(project): add settings file for vscode --- .gitignore | 1 - .vscode/settings.json | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 5ef86bd..4aeb2ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .prism.log -.vscode _dev __pycache__ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b01030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} From 44d064d3013ef31ec6cb709682ab5fef4d2ed531 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 05:59:46 +0000 Subject: [PATCH 32/35] feat(client): support file upload requests --- src/contextual/_base_client.py | 5 ++++- src/contextual/_files.py | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/contextual/_base_client.py b/src/contextual/_base_client.py index afe0b3f..2d5b5fa 100644 --- a/src/contextual/_base_client.py +++ b/src/contextual/_base_client.py @@ -532,7 +532,10 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - kwargs["json"] = json_data if is_given(json_data) else None + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/contextual/_files.py b/src/contextual/_files.py index 8c48fd6..3a712ec 100644 --- a/src/contextual/_files.py +++ b/src/contextual/_files.py @@ -69,12 +69,12 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], _read_file_content(file[1]), *file[2:]) + return (file[0], read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -def _read_file_content(file: FileContent) -> HttpxFileContent: +def read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return pathlib.Path(file).read_bytes() return file @@ -111,12 +111,12 @@ async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], await _async_read_file_content(file[1]), *file[2:]) + return (file[0], await async_read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -async def _async_read_file_content(file: FileContent) -> HttpxFileContent: +async def async_read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return await anyio.Path(file).read_bytes() From 40379a3d51aef12b1a0264e515ac145c91e41644 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 04:35:31 +0000 Subject: [PATCH 33/35] chore(internal): update examples --- tests/api_resources/agents/test_query.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/api_resources/agents/test_query.py b/tests/api_resources/agents/test_query.py index e67f949..96f5da6 100644 --- a/tests/api_resources/agents/test_query.py +++ b/tests/api_resources/agents/test_query.py @@ -49,13 +49,7 @@ def test_method_create_with_all_params(self, client: ContextualAI) -> None: retrievals_only=True, conversation_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", documents_filters={ - "filters": [ - { - "field": "field1", - "operator": "equals", - "value": "value1", - } - ], + "filters": [], "operator": "AND", }, llm_model_id="llm_model_id", @@ -310,13 +304,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncContextual retrievals_only=True, conversation_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", documents_filters={ - "filters": [ - { - "field": "field1", - "operator": "equals", - "value": "value1", - } - ], + "filters": [], "operator": "AND", }, llm_model_id="llm_model_id", From 465af9ec69d6456078fb4137b39d1dd33a3f60b2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:30:44 +0000 Subject: [PATCH 34/35] chore(internal): fix ruff target version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2433533..604435c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ reportPrivateUsage = false [tool.ruff] line-length = 120 output-format = "grouped" -target-version = "py37" +target-version = "py38" [tool.ruff.format] docstring-code-format = true From ecfa79a03fd7966d0642fd1aa9827056ebd9c85a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:31:30 +0000 Subject: [PATCH 35/35] release: 0.8.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 53 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/contextual/_version.py | 2 +- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1b77f50..6538ca9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.0" + ".": "0.8.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a2efd9b..8a8f485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## 0.8.0 (2025-08-06) + +Full Changelog: [v0.7.0...v0.8.0](https://github.com/ContextualAI/contextual-client-python/compare/v0.7.0...v0.8.0) + +### Features + +* clean up environment call outs ([5aacfd7](https://github.com/ContextualAI/contextual-client-python/commit/5aacfd73cd62e9440b927c74e29bd4ee03766334)) +* **client:** add follow_redirects request option ([35e7c78](https://github.com/ContextualAI/contextual-client-python/commit/35e7c78c7d1801a0afe4d73bbff3e7c695f5f19f)) +* **client:** add support for aiohttp ([d54f53c](https://github.com/ContextualAI/contextual-client-python/commit/d54f53cfa0878acbad344622f7aae1b2e939ae1c)) +* **client:** support file upload requests ([44d064d](https://github.com/ContextualAI/contextual-client-python/commit/44d064d3013ef31ec6cb709682ab5fef4d2ed531)) + + +### Bug Fixes + +* **ci:** correct conditional ([0e1ab57](https://github.com/ContextualAI/contextual-client-python/commit/0e1ab57132d5a038aac790b463166200ae436fc3)) +* **ci:** release-doctor — report correct token name ([ce0af3b](https://github.com/ContextualAI/contextual-client-python/commit/ce0af3be8b2f90af2bc4e38979a801df1e98e989)) +* **client:** correctly parse binary response | stream ([518cbab](https://github.com/ContextualAI/contextual-client-python/commit/518cbabda3ce7f53721c0fc916ae89706899a4ec)) +* **client:** don't send Content-Type header on GET requests ([1ba6bcc](https://github.com/ContextualAI/contextual-client-python/commit/1ba6bcc49090112b3ec0dc9a0b1f5c2b487e378e)) +* **docs/api:** remove references to nonexistent types ([9fd7133](https://github.com/ContextualAI/contextual-client-python/commit/9fd7133c6748ba1b1676a674da35d57f02f01a86)) +* **parsing:** correctly handle nested discriminated unions ([130f4c1](https://github.com/ContextualAI/contextual-client-python/commit/130f4c17f8fbf89a42fa1709d6e4b4a8b36c4036)) +* **parsing:** ignore empty metadata ([a81e190](https://github.com/ContextualAI/contextual-client-python/commit/a81e19084356382c7b709215b1462e099d56f2a6)) +* **parsing:** parse extra field types ([89f10b3](https://github.com/ContextualAI/contextual-client-python/commit/89f10b3a97483b99e0ec06a346286619faec5c12)) +* **tests:** fix: tests which call HTTP endpoints directly with the example parameters ([d7920f1](https://github.com/ContextualAI/contextual-client-python/commit/d7920f111d6175e6714482918e34992fb51739d9)) + + +### Chores + +* **ci:** change upload type ([f72dfb7](https://github.com/ContextualAI/contextual-client-python/commit/f72dfb77ff1fcae80efa5b286800ed77af6d0889)) +* **ci:** enable for pull requests ([84fbba4](https://github.com/ContextualAI/contextual-client-python/commit/84fbba4c22dbbf8517841c7961a37dba246126dc)) +* **ci:** fix installation instructions ([f191464](https://github.com/ContextualAI/contextual-client-python/commit/f191464e75f48395e76d6007712ae8548268b45f)) +* **ci:** only run for pushes and fork pull requests ([b9520a0](https://github.com/ContextualAI/contextual-client-python/commit/b9520a0ad9c16d3ad0386ce70a15df4191751364)) +* **ci:** upload sdks to package manager ([1f04b9e](https://github.com/ContextualAI/contextual-client-python/commit/1f04b9ecca3a4a3d2235c5cfa21bd9b36a358754)) +* **docs:** grammar improvements ([01370fb](https://github.com/ContextualAI/contextual-client-python/commit/01370fb62278f1def879352910c2520102c89993)) +* **docs:** remove reference to rye shell ([68f70a8](https://github.com/ContextualAI/contextual-client-python/commit/68f70a88e5b45773140c4b4a02c0506f3d078ad9)) +* **docs:** remove unnecessary param examples ([f603dcd](https://github.com/ContextualAI/contextual-client-python/commit/f603dcdd966c77ce3e8b8dba8e878eb273ef1688)) +* **internal:** bump pinned h11 dep ([f0aca79](https://github.com/ContextualAI/contextual-client-python/commit/f0aca79b109176c6a83b31434ccdbc30e58f059d)) +* **internal:** codegen related update ([0310d7c](https://github.com/ContextualAI/contextual-client-python/commit/0310d7ce2bca6a80cd3b0d53a1103b4dc1fa8c32)) +* **internal:** fix ruff target version ([465af9e](https://github.com/ContextualAI/contextual-client-python/commit/465af9ec69d6456078fb4137b39d1dd33a3f60b2)) +* **internal:** update conftest.py ([b324ed3](https://github.com/ContextualAI/contextual-client-python/commit/b324ed373c9c174a44eb52dc6d2384e82c0af4b8)) +* **internal:** update examples ([40379a3](https://github.com/ContextualAI/contextual-client-python/commit/40379a3d51aef12b1a0264e515ac145c91e41644)) +* **package:** mark python 3.13 as supported ([f37217f](https://github.com/ContextualAI/contextual-client-python/commit/f37217ff20d84d47c9adaf89c14151075e329972)) +* **project:** add settings file for vscode ([77265c1](https://github.com/ContextualAI/contextual-client-python/commit/77265c18261b46255146f4a0fd82e2aae41ae160)) +* **readme:** fix version rendering on pypi ([5857ef3](https://github.com/ContextualAI/contextual-client-python/commit/5857ef3c8252e39ab66b1dea3e035580d0f2f006)) +* **readme:** update badges ([b747f45](https://github.com/ContextualAI/contextual-client-python/commit/b747f452ab31df0805dd07a516fe63c460353c57)) +* **tests:** add tests for httpx client instantiation & proxies ([0c4973f](https://github.com/ContextualAI/contextual-client-python/commit/0c4973fed123a77a16b189439b3f4976fcc91770)) +* **tests:** run tests in parallel ([f75c912](https://github.com/ContextualAI/contextual-client-python/commit/f75c912ff643028317dde5fb0dfd08470b26ac29)) +* **tests:** skip some failing tests on the latest python versions ([dd32830](https://github.com/ContextualAI/contextual-client-python/commit/dd32830a8266dbf736c85285ec611854659511e7)) + + +### Documentation + +* **client:** fix httpx.Timeout documentation reference ([3517a3d](https://github.com/ContextualAI/contextual-client-python/commit/3517a3d02c7447c027bc82baf3a83333eb3c9b55)) + ## 0.7.0 (2025-05-13) Full Changelog: [v0.6.0...v0.7.0](https://github.com/ContextualAI/contextual-client-python/compare/v0.6.0...v0.7.0) diff --git a/pyproject.toml b/pyproject.toml index 604435c..1169a3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contextual-client" -version = "0.7.0" +version = "0.8.0" description = "The official Python library for the Contextual AI API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/contextual/_version.py b/src/contextual/_version.py index 99bb927..a855209 100644 --- a/src/contextual/_version.py +++ b/src/contextual/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "contextual" -__version__ = "0.7.0" # x-release-please-version +__version__ = "0.8.0" # x-release-please-version