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
7 changes: 5 additions & 2 deletions src/openai/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down
23 changes: 18 additions & 5 deletions src/openai/_utils/_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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 (
Expand All @@ -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.
Expand Down Expand Up @@ -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 (
Expand All @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]

Expand Down