diff --git a/src/openai/_models.py b/src/openai/_models.py index ed4c1f82d6..53aa6e1d13 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -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_) @@ -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): diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 414f38c340..83b48e74bb 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -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] @@ -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 @@ -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] @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py index cc204bac1d..f28a4c17b0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 == [] diff --git a/tests/test_transform.py b/tests/test_transform.py index bece75dfc7..42dc3f6107 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -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"}}}