From db28da5d44854a002aa377c2349a69d3fa22cb64 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:42:21 +0000 Subject: [PATCH] SDK regeneration --- .fern/metadata.json | 2 +- pyproject.toml | 1 + reference.md | 44 +++---- src/credal/core/client_wrapper.py | 10 ++ src/credal/core/http_client.py | 200 ++++++++++++++++++------------ tests/utils/test_http_client.py | 169 ++++++++++++++++++++++++- 6 files changed, 317 insertions(+), 109 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index a8511c8..9e72389 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,5 +1,5 @@ { "cliVersion": "3.27.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "4.41.3" + "generatorVersion": "4.46.3" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 39b64ad..a66e7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "credal" +dynamic = ["version"] [tool.poetry] name = "credal" diff --git a/reference.md b/reference.md index db46e40..c7b809f 100644 --- a/reference.md +++ b/reference.md @@ -1,6 +1,6 @@ # Reference ## Copilots -
client.copilots.create_copilot(...) +
client.copilots.create_copilot(...) -> AsyncHttpResponse[CreateCopilotResponse]
@@ -94,7 +94,7 @@ client.copilots.create_copilot(
-
client.copilots.create_conversation(...) +
client.copilots.create_conversation(...) -> AsyncHttpResponse[CreateConversationResponse]
@@ -177,7 +177,7 @@ client.copilots.create_conversation(
-
client.copilots.provide_message_feedback(...) +
client.copilots.provide_message_feedback(...) -> AsyncHttpResponse[None]
@@ -271,7 +271,7 @@ client.copilots.provide_message_feedback(
-
client.copilots.send_message(...) +
client.copilots.send_message(...) -> AsyncHttpResponse[SendAgentMessageResponse]
@@ -387,7 +387,7 @@ client.copilots.send_message(
-
client.copilots.stream_message(...) +
client.copilots.stream_message(...) -> typing.AsyncIterator[AsyncHttpResponse[typing.AsyncIterator[StreamingChunk]]]
@@ -519,7 +519,7 @@ for chunk in response.data:
-
client.copilots.add_collection_to_copilot(...) +
client.copilots.add_collection_to_copilot(...) -> AsyncHttpResponse[None]
@@ -604,7 +604,7 @@ client.copilots.add_collection_to_copilot(
-
client.copilots.remove_collection_from_copilot(...) +
client.copilots.remove_collection_from_copilot(...) -> AsyncHttpResponse[None]
@@ -689,7 +689,7 @@ client.copilots.remove_collection_from_copilot(
-
client.copilots.update_configuration(...) +
client.copilots.update_configuration(...) -> AsyncHttpResponse[None]
@@ -781,7 +781,7 @@ client.copilots.update_configuration(
-
client.copilots.delete_copilot(...) +
client.copilots.delete_copilot(...) -> AsyncHttpResponse[DeleteCopilotResponse]
@@ -841,7 +841,7 @@ client.copilots.delete_copilot(
-
client.copilots.export(...) +
client.copilots.export(...) -> AsyncHttpResponse[ExportCopilotsResponse]
@@ -965,7 +965,7 @@ client.copilots.export(
## DocumentCatalog -
client.document_catalog.upload_document_contents(...) +
client.document_catalog.upload_document_contents(...) -> AsyncHttpResponse[UploadDocumentResponse]
@@ -1105,7 +1105,7 @@ client.document_catalog.upload_document_contents(
-
client.document_catalog.sync_source_by_url(...) +
client.document_catalog.sync_source_by_url(...) -> AsyncHttpResponse[SyncSourceByUrlResponse]
@@ -1184,7 +1184,7 @@ client.document_catalog.sync_source_by_url(
-
client.document_catalog.metadata(...) +
client.document_catalog.metadata(...) -> AsyncHttpResponse[None]
@@ -1281,7 +1281,7 @@ client.document_catalog.metadata(
## DocumentCollections -
client.document_collections.add_documents_to_collection(...) +
client.document_collections.add_documents_to_collection(...) -> AsyncHttpResponse[None]
@@ -1374,7 +1374,7 @@ client.document_collections.add_documents_to_collection(
-
client.document_collections.remove_documents_from_collection(...) +
client.document_collections.remove_documents_from_collection(...) -> AsyncHttpResponse[None]
@@ -1467,7 +1467,7 @@ client.document_collections.remove_documents_from_collection(
-
client.document_collections.list_documents_in_collection(...) +
client.document_collections.list_documents_in_collection(...) -> AsyncHttpResponse[ListDocumentsInCollectionResponse]
@@ -1541,7 +1541,7 @@ client.document_collections.list_documents_in_collection(
-
client.document_collections.create_collection(...) +
client.document_collections.create_collection(...) -> AsyncHttpResponse[CreateCollectionResponse]
@@ -1635,7 +1635,7 @@ client.document_collections.create_collection(
-
client.document_collections.delete_collection(...) +
client.document_collections.delete_collection(...) -> AsyncHttpResponse[DeleteCollectionResponse]
@@ -1709,7 +1709,7 @@ client.document_collections.delete_collection(
-
client.document_collections.create_mongo_collection_sync(...) +
client.document_collections.create_mongo_collection_sync(...) -> AsyncHttpResponse[MongoCollectionSyncResponse]
@@ -1815,7 +1815,7 @@ client.document_collections.create_mongo_collection_sync(
-
client.document_collections.update_mongo_collection_sync(...) +
client.document_collections.update_mongo_collection_sync(...) -> AsyncHttpResponse[MongoCollectionSyncResponse]
@@ -1924,7 +1924,7 @@ client.document_collections.update_mongo_collection_sync(
## Search -
client.search.search_document_collection(...) +
client.search.search_document_collection(...) -> AsyncHttpResponse[SearchDocumentCollectionResponse]
@@ -2057,7 +2057,7 @@ client.search.search_document_collection(
## Users -
client.users.metadata(...) +
client.users.metadata(...) -> AsyncHttpResponse[None]
diff --git a/src/credal/core/client_wrapper.py b/src/credal/core/client_wrapper.py index e3e2515..bad2a39 100644 --- a/src/credal/core/client_wrapper.py +++ b/src/credal/core/client_wrapper.py @@ -74,12 +74,22 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, httpx_client: httpx.AsyncClient, ): super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout) + self._async_token = async_token self.httpx_client = AsyncHttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + async_base_headers=self.async_get_headers, ) + + async def async_get_headers(self) -> typing.Dict[str, str]: + headers = self.get_headers() + if self._async_token is not None: + token = await self._async_token() + headers["Authorization"] = f"Bearer {token}" + return headers diff --git a/src/credal/core/http_client.py b/src/credal/core/http_client.py index 901b55f..fb7cd4e 100644 --- a/src/credal/core/http_client.py +++ b/src/credal/core/http_client.py @@ -192,8 +192,19 @@ def get_request_body( # If both data and json are None, we send json data in the event extra properties are specified json_body = maybe_filter_request_body(json, request_options, omit) - # If you have an empty JSON body, you should just send None - return (json_body if json_body != {} else None), data_body if data_body != {} else None + has_additional_body_parameters = bool( + request_options is not None and request_options.get("additional_body_parameters") + ) + + # Only collapse empty dict to None when the body was not explicitly provided + # and there are no additional body parameters. This preserves explicit empty + # bodies (e.g., when an endpoint has a request body type but all fields are optional). + if json_body == {} and json is None and not has_additional_body_parameters: + json_body = None + if data_body == {} and data is None and not has_additional_body_parameters: + data_body = None + + return json_body, data_body class HttpClient: @@ -237,7 +248,7 @@ def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -261,6 +272,26 @@ def request( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + response = self.httpx_client.request( method=method, url=urllib.parse.urljoin(f"{base_url}/", path), @@ -273,23 +304,7 @@ def request( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -297,9 +312,9 @@ def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: time.sleep(_retry_timeout(response=response, retries=retries)) return self.request( path=path, @@ -336,7 +351,7 @@ def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: @@ -360,6 +375,26 @@ def stream( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + with self.httpx_client.stream( method=method, url=urllib.parse.urljoin(f"{base_url}/", path), @@ -372,23 +407,7 @@ def stream( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -406,12 +425,19 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers + self.async_base_headers = async_base_headers self.httpx_client = httpx_client + async def _get_headers(self) -> typing.Dict[str, str]: + if self.async_base_headers is not None: + return await self.async_base_headers() + return self.base_headers() + def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: base_url = maybe_base_url if self.base_url is not None and base_url is None: @@ -439,7 +465,7 @@ async def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -463,6 +489,29 @@ async def request( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + # Add the input to each of these and do None-safety checks response = await self.httpx_client.request( method=method, @@ -470,29 +519,13 @@ async def request( headers=jsonable_encoder( remove_none_from_dict( { - **self.base_headers(), + **_headers, **(headers if headers is not None else {}), **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -500,9 +533,9 @@ async def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: await asyncio.sleep(_retry_timeout(response=response, retries=retries)) return await self.request( path=path, @@ -538,7 +571,7 @@ async def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: @@ -562,35 +595,42 @@ async def stream( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, + ) + ) + ) + ) + async with self.httpx_client.stream( method=method, url=urllib.parse.urljoin(f"{base_url}/", path), headers=jsonable_encoder( remove_none_from_dict( { - **self.base_headers(), + **_headers, **(headers if headers is not None else {}), **(request_options.get("additional_headers", {}) if request_options is not None else {}), } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit=omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 9af706c..788eea9 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,9 +1,43 @@ # This file was auto-generated by Fern from our API Definition. -from credal.core.http_client import get_request_body, remove_none_from_dict +from typing import Any, Dict + +import pytest + +from credal.core.http_client import AsyncHttpClient, HttpClient, get_request_body, remove_none_from_dict from credal.core.request_options import RequestOptions +# Stub clients for testing HttpClient and AsyncHttpClient +class _DummySyncClient: + """A minimal stub for httpx.Client that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyAsyncClient: + """A minimal stub for httpx.AsyncClient that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + async def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyResponse: + """A minimal stub for httpx.Response.""" + + status_code = 200 + headers: Dict[str, str] = {} + + def get_request_options() -> RequestOptions: return {"additional_body_parameters": {"see you": "later"}} @@ -52,17 +86,30 @@ def test_get_none_request_body() -> None: def test_get_empty_json_request_body() -> None: + """Test that implicit empty bodies (json=None) are collapsed to None.""" unrelated_request_options: RequestOptions = {"max_retries": 3} json_body, data_body = get_request_body(json=None, data=None, request_options=unrelated_request_options, omit=None) assert json_body is None assert data_body is None - json_body_extras, data_body_extras = get_request_body( - json={}, data=None, request_options=unrelated_request_options, omit=None - ) - assert json_body_extras is None - assert data_body_extras is None +def test_explicit_empty_json_body_is_preserved() -> None: + """Test that explicit empty bodies (json={}) are preserved and sent as {}. + + This is important for endpoints where the request body is required but all + fields are optional. The server expects valid JSON ({}) not an empty body. + """ + unrelated_request_options: RequestOptions = {"max_retries": 3} + + # Explicit json={} should be preserved + json_body, data_body = get_request_body(json={}, data=None, request_options=unrelated_request_options, omit=None) + assert json_body == {} + assert data_body is None + + # Explicit data={} should also be preserved + json_body2, data_body2 = get_request_body(json=None, data={}, request_options=unrelated_request_options, omit=None) + assert json_body2 is None + assert data_body2 == {} def test_json_body_preserves_none_values() -> None: @@ -107,3 +154,113 @@ def test_remove_none_from_dict_empty_dict() -> None: def test_remove_none_from_dict_all_none() -> None: """Test that remove_none_from_dict handles dict with all None values.""" assert remove_none_from_dict({"a": None, "b": None}) == {} + + +def test_http_client_does_not_pass_empty_params_list() -> None: + """Test that HttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + + # Use a path with query params (e.g., pagination cursor URL) + http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +def test_http_client_passes_encoded_params_when_present() -> None: + """Test that HttpClient passes encoded params when params are provided.""" + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + ) + + http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +@pytest.mark.asyncio +async def test_async_http_client_does_not_pass_empty_params_list() -> None: + """Test that AsyncHttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + + # Use a path with query params (e.g., pagination cursor URL) + await http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +@pytest.mark.asyncio +async def test_async_http_client_passes_encoded_params_when_present() -> None: + """Test that AsyncHttpClient passes encoded params when params are provided.""" + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + async_base_headers=None, + ) + + await http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")]