Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .fern/metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"cliVersion": "1.9.1",
"cliVersion": "3.27.0",
"generatorName": "fernapi/fern-python-sdk",
"generatorVersion": "4.41.1"
"generatorVersion": "4.41.3"
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "credal"

[tool.poetry]
name = "credal"
version = "0.1.14"
version = "0.1.15"
description = ""
readme = "README.md"
authors = []
Expand Down
4 changes: 2 additions & 2 deletions src/credal/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ def __init__(

def get_headers(self) -> typing.Dict[str, str]:
headers: typing.Dict[str, str] = {
"User-Agent": "credal/0.1.14",
"User-Agent": "credal/0.1.15",
"X-Fern-Language": "Python",
"X-Fern-SDK-Name": "credal",
"X-Fern-SDK-Version": "0.1.14",
"X-Fern-SDK-Version": "0.1.15",
**(self.get_custom_headers() or {}),
}
headers["Authorization"] = f"Bearer {self._get_api_key()}"
Expand Down
25 changes: 24 additions & 1 deletion src/credal/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .force_multipart import FORCE_MULTIPART
from .jsonable_encoder import jsonable_encoder
from .query_encoder import encode_query
from .remove_none_from_dict import remove_none_from_dict
from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict
from .request_options import RequestOptions
from httpx._types import RequestFiles

Expand Down Expand Up @@ -123,6 +123,21 @@ def _should_retry(response: httpx.Response) -> bool:
return response.status_code >= 500 or response.status_code in retryable_400s


def _maybe_filter_none_from_multipart_data(
data: typing.Optional[typing.Any],
request_files: typing.Optional[RequestFiles],
force_multipart: typing.Optional[bool],
) -> typing.Optional[typing.Any]:
"""
Filter None values from data body for multipart/form requests.
This prevents httpx from converting None to empty strings in multipart encoding.
Only applies when files are present or force_multipart is True.
"""
if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart):
return remove_none_from_dict(data)
return data


def remove_omit_from_dict(
original: typing.Dict[str, typing.Optional[typing.Any]],
omit: typing.Optional[typing.Any],
Expand Down Expand Up @@ -244,6 +259,8 @@ def request(
if (request_files is None or len(request_files) == 0) and force_multipart:
request_files = FORCE_MULTIPART

data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)

response = self.httpx_client.request(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
Expand Down Expand Up @@ -341,6 +358,8 @@ def stream(

json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)

data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)

with self.httpx_client.stream(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
Expand Down Expand Up @@ -442,6 +461,8 @@ async def request(

json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)

data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)

# Add the input to each of these and do None-safety checks
response = await self.httpx_client.request(
method=method,
Expand Down Expand Up @@ -539,6 +560,8 @@ async def stream(

json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)

data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)

async with self.httpx_client.stream(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
Expand Down
50 changes: 49 additions & 1 deletion tests/utils/test_http_client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# This file was auto-generated by Fern from our API Definition.

from credal.core.http_client import get_request_body
from credal.core.http_client import get_request_body, remove_none_from_dict
from credal.core.request_options import RequestOptions


def get_request_options() -> RequestOptions:
return {"additional_body_parameters": {"see you": "later"}}


def get_request_options_with_none() -> RequestOptions:
return {"additional_body_parameters": {"see you": "later", "optional": None}}


def test_get_json_request_body() -> None:
json_body, data_body = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=None)
assert json_body == {"hello": "world"}
Expand Down Expand Up @@ -59,3 +63,47 @@ def test_get_empty_json_request_body() -> None:

assert json_body_extras is None
assert data_body_extras is None


def test_json_body_preserves_none_values() -> None:
"""Test that JSON bodies preserve None values (they become JSON null)."""
json_body, data_body = get_request_body(
json={"hello": "world", "optional": None}, data=None, request_options=None, omit=None
)
# JSON bodies should preserve None values
assert json_body == {"hello": "world", "optional": None}
assert data_body is None


def test_data_body_preserves_none_values_without_multipart() -> None:
"""Test that data bodies preserve None values when not using multipart.

The filtering of None values happens in HttpClient.request/stream methods,
not in get_request_body. This test verifies get_request_body doesn't filter None.
"""
json_body, data_body = get_request_body(
json=None, data={"hello": "world", "optional": None}, request_options=None, omit=None
)
# get_request_body should preserve None values in data body
# The filtering happens later in HttpClient.request when multipart is detected
assert data_body == {"hello": "world", "optional": None}
assert json_body is None


def test_remove_none_from_dict_filters_none_values() -> None:
"""Test that remove_none_from_dict correctly filters out None values."""
original = {"hello": "world", "optional": None, "another": "value", "also_none": None}
filtered = remove_none_from_dict(original)
assert filtered == {"hello": "world", "another": "value"}
# Original should not be modified
assert original == {"hello": "world", "optional": None, "another": "value", "also_none": None}


def test_remove_none_from_dict_empty_dict() -> None:
"""Test that remove_none_from_dict handles empty dict."""
assert remove_none_from_dict({}) == {}


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}) == {}