Skip to content
Open
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
15 changes: 11 additions & 4 deletions src/openai/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,12 @@ 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]
return {key: construct_type(value=item, type_=items_type) for key, item in value.items()}
dict_args = get_args(type_)
if len(dict_args) >= 2:
_, items_type = dict_args # Dict[_, items_type]
return {key: construct_type(value=item, type_=items_type) for key, item in value.items()}
# bare dict without type args - return value as-is
return value

if (
not is_literal_type(type_)
Expand All @@ -678,8 +682,11 @@ 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]
return [construct_type(value=entry, type_=inner_type) for entry in value]
if args:
inner_type = args[0] # List[inner_type]
return [construct_type(value=entry, type_=inner_type) for entry in value]
# bare list without type args - return value as-is
return value

if origin == float:
if isinstance(value, int):
Expand Down
32 changes: 28 additions & 4 deletions src/openai/_utils/_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,12 @@ def _transform_recursive(
return _transform_typeddict(data, stripped_type)

if origin == dict and is_mapping(data):
items_type = get_args(stripped_type)[1]
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
args = get_args(stripped_type)
if len(args) >= 2:
items_type = args[1]
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
# bare dict without type args - return data as-is
return data

if (
# List[T]
Expand All @@ -196,6 +200,14 @@ def _transform_recursive(
if isinstance(data, dict):
return cast(object, data)

# Handle bare list/iterable/sequence without type arguments
list_args = get_args(stripped_type)
if not list_args:
# bare list without type args - just ensure it's a list for JSON serialization
if is_list(data):
return data
return list(data)

inner_type = extract_type_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
Expand Down Expand Up @@ -346,8 +358,12 @@ 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]
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
args = get_args(stripped_type)
if len(args) >= 2:
items_type = args[1]
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
# bare dict without type args - return data as-is
return data

if (
# List[T]
Expand All @@ -362,6 +378,14 @@ async def _async_transform_recursive(
if isinstance(data, dict):
return cast(object, data)

# Handle bare list/iterable/sequence without type arguments
list_args = get_args(stripped_type)
if not list_args:
# bare list without type args - just ensure it's a list for JSON serialization
if is_list(data):
return data
return list(data)

inner_type = extract_type_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
Expand Down
30 changes: 30 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,3 +1015,33 @@ class Model(BaseModel):
# falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"])
assert m.data["items"] == ["h", "e", "l", "l", "o"]
assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"]


# Regression tests for https://github.com/openai/openai-python/issues/3338 and #3341
# Tests for bare dict/list type annotations in construct_type


def test_construct_type_bare_dict() -> None:
"""Test that construct_type handles bare `dict` annotation without crashing (issue #3341)."""
# This used to raise: ValueError: not enough values to unpack (expected 2, got 0)
result = construct_type(value={"key": "value", "nested": {"a": 1}}, type_=dict)
assert result == {"key": "value", "nested": {"a": 1}}


def test_construct_type_bare_list() -> None:
"""Test that construct_type handles bare `list` annotation without crashing."""
# This used to raise: IndexError: tuple index out of range
result = construct_type(value=[1, "two", {"three": 3}], type_=list)
assert result == [1, "two", {"three": 3}]


def test_construct_type_bare_dict_empty() -> None:
"""Test that construct_type handles empty dict."""
result = construct_type(value={}, type_=dict)
assert result == {}


def test_construct_type_bare_list_empty() -> None:
"""Test that construct_type handles empty list."""
result = construct_type(value=[], type_=list)
assert result == []
44 changes: 44 additions & 0 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,47 @@ async def test_strips_notgiven(use_async: bool) -> None:
async def test_strips_omit(use_async: bool) -> None:
assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"}
assert await transform({"foo_bar": omit}, Foo1, use_async) == {}


# Tests for bare dict/list type annotations (no type parameters)
# Regression tests for https://github.com/openai/openai-python/issues/3338 and #3341


class TypedDictWithBareDict(TypedDict, total=False):
"""TypedDict with bare dict annotation (no type parameters)."""

metadata: dict


class TypedDictWithBareList(TypedDict, total=False):
"""TypedDict with bare list annotation (no type parameters)."""

items: list


@parametrize
@pytest.mark.asyncio
async def test_bare_dict_annotation(use_async: bool) -> None:
"""Test that bare `dict` annotation doesn't crash (issue #3338)."""
result = await transform({"metadata": {"key": "value", "nested": {"a": 1}}}, TypedDictWithBareDict, use_async)
assert result == {"metadata": {"key": "value", "nested": {"a": 1}}}


@parametrize
@pytest.mark.asyncio
async def test_bare_list_annotation(use_async: bool) -> None:
"""Test that bare `list` annotation doesn't crash."""
result = await transform({"items": [1, "two", {"three": 3}]}, TypedDictWithBareList, use_async)
assert result == {"items": [1, "two", {"three": 3}]}


@parametrize
@pytest.mark.asyncio
async def test_bare_dict_with_typeddict_field(use_async: bool) -> None:
"""Test bare dict containing TypedDict values."""

class Container(TypedDict, total=False):
data: dict

result = await transform({"data": {"foo": {"foo_bar": "hello"}}}, Container, use_async)
assert result == {"data": {"foo": {"foo_bar": "hello"}}}