From 43d10d901619ce657496f622e315661b484c3c56 Mon Sep 17 00:00:00 2001 From: rohit kuwarbi Date: Sat, 29 Aug 2026 19:25:58 +0530 Subject: [PATCH 1/2] fix: handle bare `dict` and `list` annotations without type arguments `transform()` and `construct_type()` both assumed that any `dict` or `list` annotation is parameterised, and indexed into `get_args()` unconditionally. For a bare, unparameterised annotation `get_args()` returns an empty tuple, so the index access raised instead of transforming/constructing the value: ```py class Params(TypedDict, total=False): metadata: dict transform({"metadata": {"key": "value"}}, Params) # IndexError: tuple index out of range construct_type(value={"key": "value"}, type_=dict) # ValueError: not enough values to unpack (expected 2, got 0) ``` The same happened for bare `list` annotations, and in `construct_type()` this also crashed for any `BaseModel` with a bare `dict`/`list` field, since `Model.construct()` goes through the same code path. Bare containers are now treated as if their contents were annotated with `Any`, matching the existing behaviour of `dict[str, Any]` / `list[Any]`. Fixes #3338 Fixes #3341 --- src/openai/_models.py | 7 +++++-- src/openai/_utils/_transform.py | 23 ++++++++++++++++++----- tests/test_models.py | 30 ++++++++++++++++++++++++++++++ tests/test_transform.py | 26 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/openai/_models.py b/src/openai/_models.py index ed4c1f82d6..77ce1c6956 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -657,7 +657,9 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_mapping(value): return value - _, items_type = get_args(type_) # Dict[_, items_type] + # bare, unparameterised `dict` annotations don't have any type arguments, + # in which case the values are treated as if they were annotated with `Any` + items_type = args[1] if len(args) > 1 else object # Dict[_, items_type] return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} if ( @@ -678,7 +680,8 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_list(value): return value - inner_type = args[0] # List[inner_type] + # as with `dict` above, a bare `list` annotation has no type arguments + inner_type = args[0] if args else object # List[inner_type] return [construct_type(value=entry, type_=inner_type) for entry in value] if origin == float: diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 414f38c340..01ee2a1333 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -23,7 +23,6 @@ from ._typing import ( is_list_type, is_union_type, - extract_type_arg, is_iterable_type, is_required_type, is_sequence_type, @@ -151,6 +150,20 @@ def _no_transform_needed(annotation: type) -> bool: return annotation == float or annotation == int +def _extract_container_arg(typ: type, index: int) -> type: + """Return the type argument at the given index for a container type. + + Bare, unparameterised containers, e.g. `dict` instead of `dict[str, int]`, don't have + any type arguments, in which case the contained values are treated as if they were + annotated with `Any`. + """ + args = get_args(typ) + if index >= len(args): + return cast(type, object) + + return cast(type, args[index]) + + def _transform_recursive( data: object, *, @@ -180,7 +193,7 @@ def _transform_recursive( return _transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] + items_type = _extract_container_arg(stripped_type, 1) return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} if ( @@ -196,7 +209,7 @@ def _transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = extract_type_arg(stripped_type, 0) + inner_type = _extract_container_arg(stripped_type, 0) if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small # perf boost from skipping that work. @@ -346,7 +359,7 @@ async def _async_transform_recursive( return await _async_transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] + items_type = _extract_container_arg(stripped_type, 1) return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} if ( @@ -362,7 +375,7 @@ async def _async_transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = extract_type_arg(stripped_type, 0) + inner_type = _extract_container_arg(stripped_type, 0) if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small # perf boost from skipping that work. diff --git a/tests/test_models.py b/tests/test_models.py index cc204bac1d..2181391cda 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -359,6 +359,36 @@ class Model(BaseModel): assert cast(Any, m.items[1]) == 156 +def test_bare_dict_annotation() -> None: + # a bare `dict` has no type arguments so the values are left as-is + class Model(BaseModel): + metadata: dict # type: ignore[type-arg] + + m = Model.construct(metadata={"key": "value"}) + assert m.metadata == {"key": "value"} # pyright: ignore[reportUnknownMemberType] + + assert construct_type(value={"key": "value"}, type_=dict) == {"key": "value"} + assert construct_type(value={"key": "value"}, type_=Dict) == {"key": "value"} + + # non-mapping values are still passed through unchanged + assert construct_type(value="not a dict", type_=dict) == "not a dict" + + +def test_bare_list_annotation() -> None: + # a bare `list` has no type arguments so the entries are left as-is + class Model(BaseModel): + items: list # type: ignore[type-arg] + + m = Model.construct(items=[{"key": "value"}, 1]) + assert m.items == [{"key": "value"}, 1] # pyright: ignore[reportUnknownMemberType] + + assert construct_type(value=[1, 2], type_=list) == [1, 2] + assert construct_type(value=[1, 2], type_=List) == [1, 2] + + # non-list values are still passed through unchanged + assert construct_type(value="not a list", type_=list) == "not a list" + + def test_dict_of_union() -> None: class SubModel1(BaseModel): name: str diff --git a/tests/test_transform.py b/tests/test_transform.py index 5af84df7fd..ea1d81cc11 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -397,6 +397,32 @@ class DictItems(TypedDict): assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} +@parametrize +@pytest.mark.asyncio +async def test_bare_dict_annotation(use_async: bool) -> None: + """bare `dict` annotations have no type arguments so the values are left as-is""" + + class BareDict(TypedDict): + metadata: dict # type: ignore[type-arg] + + assert await transform({"metadata": {"key": "value"}}, BareDict, use_async) == {"metadata": {"key": "value"}} + assert await transform({"key": "value"}, dict, use_async) == {"key": "value"} + assert await transform({"key": "value"}, Dict, use_async) == {"key": "value"} + + +@parametrize +@pytest.mark.asyncio +async def test_bare_list_annotation(use_async: bool) -> None: + """bare `list` annotations have no type arguments so the entries are left as-is""" + + class BareList(TypedDict): + items: list # type: ignore[type-arg] + + assert await transform({"items": [{"foo_baz": "bar"}]}, BareList, use_async) == {"items": [{"foo_baz": "bar"}]} + assert await transform([1, 2, 3], list, use_async) == [1, 2, 3] + assert await transform([1, 2, 3], List, use_async) == [1, 2, 3] + + class TypedDictIterableUnionStr(TypedDict): foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] From 157d96c68917a98dedd1b724f16f942c302adfef Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 9 Sep 2026 23:14:20 +0000 Subject: [PATCH 2/2] fix: simplify bare container handling and cover client parsing --- src/openai/_models.py | 4 +--- src/openai/_utils/_transform.py | 26 ++++++++----------------- tests/test_client.py | 34 +++++++++++++++++++++++++++++++++ tests/test_models.py | 2 ++ tests/test_transform.py | 10 ++++++++-- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/openai/_models.py b/src/openai/_models.py index 77ce1c6956..f0d2f97810 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -657,8 +657,7 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_mapping(value): return value - # bare, unparameterised `dict` annotations don't have any type arguments, - # in which case the values are treated as if they were annotated with `Any` + # Bare containers have no type arguments; leave their contents untyped. items_type = args[1] if len(args) > 1 else object # Dict[_, items_type] return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} @@ -680,7 +679,6 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_list(value): return value - # as with `dict` above, a bare `list` annotation has no type arguments inner_type = args[0] if args else object # List[inner_type] return [construct_type(value=entry, type_=inner_type) for entry in value] diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 01ee2a1333..304fd12ffe 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -150,20 +150,6 @@ def _no_transform_needed(annotation: type) -> bool: return annotation == float or annotation == int -def _extract_container_arg(typ: type, index: int) -> type: - """Return the type argument at the given index for a container type. - - Bare, unparameterised containers, e.g. `dict` instead of `dict[str, int]`, don't have - any type arguments, in which case the contained values are treated as if they were - annotated with `Any`. - """ - args = get_args(typ) - if index >= len(args): - return cast(type, object) - - return cast(type, args[index]) - - def _transform_recursive( data: object, *, @@ -193,7 +179,8 @@ def _transform_recursive( return _transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = _extract_container_arg(stripped_type, 1) + args = get_args(stripped_type) + items_type = args[1] if len(args) > 1 else object return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} if ( @@ -209,7 +196,8 @@ def _transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = _extract_container_arg(stripped_type, 0) + args = get_args(stripped_type) + inner_type = cast(type, args[0]) if args else object if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small # perf boost from skipping that work. @@ -359,7 +347,8 @@ async def _async_transform_recursive( return await _async_transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = _extract_container_arg(stripped_type, 1) + args = get_args(stripped_type) + items_type = args[1] if len(args) > 1 else object return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} if ( @@ -375,7 +364,8 @@ async def _async_transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = _extract_container_arg(stripped_type, 0) + args = get_args(stripped_type) + inner_type = cast(type, args[0]) if args else object if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small # perf boost from skipping that work. diff --git a/tests/test_client.py b/tests/test_client.py index d82c39e616..7fa7ee31bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -827,6 +827,23 @@ def test_binary_content_upload_with_body_is_deprecated(self, respx2_mock: MockRo assert response.request.headers["Content-Type"] == "application/octet-stream" assert response.content == file_content + @pytest.mark.respx2(base_url=base_url) + @pytest.mark.parametrize("client", [False], indirect=True) + def test_bare_container_response(self, respx2_mock: MockRouter, client: OpenAI) -> None: + class Model(BaseModel): + metadata: dict # type: ignore[type-arg] + items: list # type: ignore[type-arg] + + data = {"metadata": {"key": "value"}, "items": [1, "two"]} + respx2_mock.get("/foo").mock(return_value=httpx2.Response(200, json=data)) + + assert client.get("/foo", cast_to=dict) == data + response = client.get("/foo", cast_to=Model) + assert response.model_dump() == data + + respx2_mock.get("/items").mock(return_value=httpx2.Response(200, json=[1, "two"])) + assert client.get("/items", cast_to=list) == [1, "two"] + @pytest.mark.respx2(base_url=base_url) def test_basic_union_response(self, respx2_mock: MockRouter, client: OpenAI) -> None: class Model1(BaseModel): @@ -2131,6 +2148,23 @@ async def test_binary_content_upload_with_body_is_deprecated( assert response.request.headers["Content-Type"] == "application/octet-stream" assert response.content == file_content + @pytest.mark.respx2(base_url=base_url) + @pytest.mark.parametrize("async_client", [False], indirect=True) + async def test_bare_container_response(self, respx2_mock: MockRouter, async_client: AsyncOpenAI) -> None: + class Model(BaseModel): + metadata: dict # type: ignore[type-arg] + items: list # type: ignore[type-arg] + + data = {"metadata": {"key": "value"}, "items": [1, "two"]} + respx2_mock.get("/foo").mock(return_value=httpx2.Response(200, json=data)) + + assert await async_client.get("/foo", cast_to=dict) == data + response = await async_client.get("/foo", cast_to=Model) + assert response.model_dump() == data + + respx2_mock.get("/items").mock(return_value=httpx2.Response(200, json=[1, "two"])) + assert await async_client.get("/items", cast_to=list) == [1, "two"] + @pytest.mark.respx2(base_url=base_url) async def test_basic_union_response(self, respx2_mock: MockRouter, async_client: AsyncOpenAI) -> None: class Model1(BaseModel): diff --git a/tests/test_models.py b/tests/test_models.py index 2181391cda..a98fb37d33 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -369,6 +369,7 @@ class Model(BaseModel): assert construct_type(value={"key": "value"}, type_=dict) == {"key": "value"} assert construct_type(value={"key": "value"}, type_=Dict) == {"key": "value"} + assert construct_type(value={}, type_=dict) == {} # non-mapping values are still passed through unchanged assert construct_type(value="not a dict", type_=dict) == "not a dict" @@ -384,6 +385,7 @@ class Model(BaseModel): assert construct_type(value=[1, 2], type_=list) == [1, 2] assert construct_type(value=[1, 2], type_=List) == [1, 2] + assert construct_type(value=[], type_=list) == [] # non-list values are still passed through unchanged assert construct_type(value="not a list", type_=list) == "not a list" diff --git a/tests/test_transform.py b/tests/test_transform.py index ea1d81cc11..93f7ad8dd8 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -400,7 +400,7 @@ class DictItems(TypedDict): @parametrize @pytest.mark.asyncio async def test_bare_dict_annotation(use_async: bool) -> None: - """bare `dict` annotations have no type arguments so the values are left as-is""" + """Bare dictionaries still serialize their values.""" class BareDict(TypedDict): metadata: dict # type: ignore[type-arg] @@ -408,12 +408,16 @@ class BareDict(TypedDict): assert await transform({"metadata": {"key": "value"}}, BareDict, use_async) == {"metadata": {"key": "value"}} assert await transform({"key": "value"}, dict, use_async) == {"key": "value"} assert await transform({"key": "value"}, Dict, use_async) == {"key": "value"} + model = ModelWithDefaultField(foo="value") + assert cast(object, await transform({"metadata": {"model": model}}, BareDict, use_async)) == { + "metadata": {"model": {"foo": "value"}} + } @parametrize @pytest.mark.asyncio async def test_bare_list_annotation(use_async: bool) -> None: - """bare `list` annotations have no type arguments so the entries are left as-is""" + """Bare lists still serialize their entries.""" class BareList(TypedDict): items: list # type: ignore[type-arg] @@ -421,6 +425,8 @@ class BareList(TypedDict): assert await transform({"items": [{"foo_baz": "bar"}]}, BareList, use_async) == {"items": [{"foo_baz": "bar"}]} assert await transform([1, 2, 3], list, use_async) == [1, 2, 3] assert await transform([1, 2, 3], List, use_async) == [1, 2, 3] + model = ModelWithDefaultField(foo="value") + assert cast(object, await transform({"items": [model]}, BareList, use_async)) == {"items": [{"foo": "value"}]} class TypedDictIterableUnionStr(TypedDict):