Skip to content

Commit 17f98e5

Browse files
committed
Merge branch 'chuenchen309-fix/namedtuple-as-dict-default-key'
2 parents da19903 + 40acb7f commit 17f98e5

3 files changed

Lines changed: 89 additions & 10 deletions

File tree

mashumaro/core/meta/types/unpack.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,7 @@ def unpack_named_tuple(spec: ValueSpec) -> Expression:
10731073
}
10741074
fields = getattr(spec.type, "_fields", ())
10751075
defaults = getattr(spec.type, "_field_defaults", {})
1076-
unpackers = []
1076+
unpackers: dict[str, Expression] = {}
10771077
as_dict = spec.builder.get_dialect_or_config_option(
10781078
"namedtuple_as_dict", False
10791079
)
@@ -1107,11 +1107,11 @@ def unpack_named_tuple(spec: ValueSpec) -> Expression:
11071107
could_be_none=True,
11081108
)
11091109
)
1110-
unpackers.append(unpacker)
1110+
unpackers[field] = unpacker
11111111

11121112
if not defaults:
11131113
field_type = spec.builder.get_type_name_identifier(spec.type)
1114-
return f"{field_type}({', '.join(unpackers)})"
1114+
return f"{field_type}({', '.join(unpackers.values())})"
11151115

11161116
lines = CodeLines()
11171117
method_name = (
@@ -1130,14 +1130,30 @@ def unpack_named_tuple(spec: ValueSpec) -> Expression:
11301130
# we shouldn't be here because there will be default_kwargs
11311131
lines.append(f"def {method_name}({method_args}):")
11321132
with lines.indent():
1133-
lines.append("fields = []")
1134-
with lines.indent("try:"):
1135-
for unpacker in unpackers:
1136-
lines.append(f"fields.append({unpacker})")
1137-
with lines.indent("except IndexError:"):
1138-
lines.append("pass")
1133+
use_fields_list = not as_dict or len(fields) - len(defaults) > 0
1134+
if use_fields_list:
1135+
lines.append("fields_list = []")
1136+
if as_dict:
1137+
lines.append("fields_dict = {}")
1138+
if use_fields_list:
1139+
with lines.indent("try:"):
1140+
for field, unpacker in unpackers.items():
1141+
if not as_dict or as_dict and field not in defaults:
1142+
lines.append(f"fields_list.append({unpacker})")
1143+
with lines.indent("except IndexError:"):
1144+
lines.append("pass")
1145+
if as_dict:
1146+
for field, unpacker in unpackers.items():
1147+
if field in defaults:
1148+
with lines.indent("try:"):
1149+
lines.append(f"fields_dict['{field}'] = {unpacker}")
1150+
with lines.indent("except KeyError:"):
1151+
lines.append("pass")
11391152
field_type = spec.builder.get_type_name_identifier(spec.type)
1140-
lines.append(f"return {field_type}(*fields)")
1153+
args = "*fields_list" if use_fields_list else ""
1154+
if as_dict:
1155+
args = ", ".join(filter(None, (args, "**fields_dict")))
1156+
lines.append(f"return {field_type}({args})")
11411157
lines.append(
11421158
f"setattr({spec.cls_attrs_name}, '{method_name}', {method_name})"
11431159
)

tests/entities.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ class MyNamedTupleWithDefaults(NamedTuple):
273273
f: float = 2.0
274274

275275

276+
class MyNamedTupleWithRequiredAndDefaults(NamedTuple):
277+
i: int
278+
s: str = "default"
279+
f: float = 42.0
280+
281+
276282
class MyNamedTupleWithOptional(NamedTuple):
277283
i: Optional[int]
278284
f: int

tests/test_config.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
MyDataClassWithOptionalAndOmitNoneFlag,
1515
MyNamedTuple,
1616
MyNamedTupleWithDefaults,
17+
MyNamedTupleWithRequiredAndDefaults,
1718
MyUntypedNamedTuple,
1819
MyUntypedNamedTupleWithDefaults,
1920
TypedDictRequiredKeys,
@@ -204,6 +205,62 @@ class Config(BaseConfig):
204205
assert DataClass.from_dict({"munpwd": {"i": 1, "f": 2.0}}) == obj
205206

206207

208+
def test_named_tuple_as_dict_with_missing_trailing_default_keys():
209+
@dataclass
210+
class DataClass(DataClassDictMixin):
211+
mnpwd: MyNamedTupleWithDefaults
212+
munpwd: MyUntypedNamedTupleWithDefaults
213+
mnpwrd: MyNamedTupleWithRequiredAndDefaults
214+
215+
class Config(BaseConfig):
216+
namedtuple_as_dict = True
217+
218+
# Omitting a key that has a default should fall back to that default,
219+
# the same way an omitted trailing element does for the as_list engine.
220+
obj = DataClass(
221+
mnpwd=MyNamedTupleWithDefaults(i=10),
222+
munpwd=MyUntypedNamedTupleWithDefaults(i=10),
223+
mnpwrd=MyNamedTupleWithRequiredAndDefaults(i=10, s="sss"),
224+
)
225+
assert (
226+
DataClass.from_dict(
227+
{
228+
"mnpwd": {"i": 10},
229+
"munpwd": {"i": 10},
230+
"mnpwrd": {"i": 10, "s": "sss"},
231+
}
232+
)
233+
== obj
234+
)
235+
236+
237+
def test_named_tuple_as_dict_with_missing_middle_default_keys():
238+
@dataclass
239+
class DataClass(DataClassDictMixin):
240+
mnpwd: MyNamedTupleWithDefaults
241+
munpwd: MyUntypedNamedTupleWithDefaults
242+
mnpwrd: MyNamedTupleWithRequiredAndDefaults
243+
244+
class Config(BaseConfig):
245+
namedtuple_as_dict = True
246+
247+
obj = DataClass(
248+
mnpwd=MyNamedTupleWithDefaults(f=2.2),
249+
munpwd=MyUntypedNamedTupleWithDefaults(f=2.2),
250+
mnpwrd=MyNamedTupleWithRequiredAndDefaults(33, f=2.2),
251+
)
252+
assert (
253+
DataClass.from_dict(
254+
{
255+
"mnpwd": {"f": 2.2},
256+
"munpwd": {"f": 2.2},
257+
"mnpwrd": {"i": 33, "f": 2.2},
258+
}
259+
)
260+
== obj
261+
)
262+
263+
207264
def test_named_tuple_as_dict_and_as_list_engine():
208265
@dataclass
209266
class DataClass(DataClassDictMixin):

0 commit comments

Comments
 (0)