diff --git a/src/openai/_models.py b/src/openai/_models.py index ed4c1f82d6..f0d2f97810 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -657,7 +657,8 @@ 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 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()} if ( @@ -678,7 +679,7 @@ 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] + 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..304fd12ffe 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, @@ -180,7 +179,8 @@ def _transform_recursive( return _transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(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 ( @@ -196,7 +196,8 @@ def _transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = extract_type_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. @@ -346,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 = get_args(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 ( @@ -362,7 +364,8 @@ async def _async_transform_recursive( if isinstance(data, dict): return cast(object, data) - inner_type = extract_type_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 cc204bac1d..a98fb37d33 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -359,6 +359,38 @@ 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"} + 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" + + +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] + 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" + + 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..93f7ad8dd8 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -397,6 +397,38 @@ 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 dictionaries still serialize their values.""" + + 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"} + 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 lists still serialize their entries.""" + + 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] + model = ModelWithDefaultField(foo="value") + assert cast(object, await transform({"items": [model]}, BareList, use_async)) == {"items": [{"foo": "value"}]} + + class TypedDictIterableUnionStr(TypedDict): foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")]