From 43d10d901619ce657496f622e315661b484c3c56 Mon Sep 17 00:00:00 2001 From: rohit kuwarbi Date: Sat, 29 Aug 2026 19:25:58 +0530 Subject: [PATCH] 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")]