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
30 changes: 28 additions & 2 deletions dpath/segments.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from copy import deepcopy
from fnmatch import fnmatchcase
from typing import Sequence, Tuple, Iterator, Any, Union, Optional, MutableMapping, MutableSequence
from typing import Sequence, Tuple, Iterator, Any, Union, Optional, Mapping, MutableMapping, MutableSequence

from dpath import options
from dpath.exceptions import InvalidGlob, InvalidKeyName, PathNotFound
Expand Down Expand Up @@ -305,7 +305,22 @@ def _default_creator(

# Infer the type from the hints provided.
if i < len(hints):
current[segment] = hints[i][1]()
hint_type = hints[i][1]
if issubclass(hint_type, MutableMapping) or issubclass(hint_type, MutableSequence):
current[segment] = hint_type()
else:
# The hinted type was read off of the source object as-is (see
# types()), and it can be something like tuple, set or frozenset:
# a container walk() is happy to recurse into but that cannot be
# filled in afterwards through the item assignment and extend()
# calls this module uses to build nested containers incrementally.
# Creating one of those here would only defer the same crash to
# the next segment (or, for a mapping-like immutable type, to the
# assignment right below). Since the concrete type cannot survive
# this kind of reconstruction anyway, fall back to a mutable
# container with the same general shape: a plain dict for
# anything mapping-like, a plain list otherwise.
current[segment] = dict() if issubclass(hint_type, Mapping) else []
else:
# Peek at the next segment to determine if we should be
# creating an array for it to access or dictionary.
Expand Down Expand Up @@ -356,6 +371,17 @@ def set(
creator(current, segments, i, hints)
else:
raise
else:
# A value already sits at this segment. If it is an immutable
# sequence such as a tuple, it cannot support the extend() call
# or item assignment further down this function needs to fill
# in a deeper segment; that happens whenever a walk (search(),
# merge(), view()) has already written the tuple in whole at a
# shallower path before reaching one of its own elements at a
# deeper path. Swap it for an equivalent, mutable list in
# place so the walk can keep descending into it.
if isinstance(current[segment], tuple):
current[segment] = list(current[segment])

current = current[segment]
if i != length - 1 and leaf(current):
Expand Down
28 changes: 28 additions & 0 deletions tests/test_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,31 @@ def test_merge_list():
dpath.merge(dst2, d)
assert dst1["l"] == [1, 2]
assert dst2["l"] == [1, 2]


def test_merge_list_of_tuples():
# A tuple nested inside a list used to crash merge(): walking the
# source assigns the tuple in whole at its own path first, and then
# tries to descend into its elements at a deeper path as if it were
# still mutable, which raised AttributeError from extend(). See #189.
dst = {"foo": []}
src = {"foo": [("bar", "baz")]}

dpath.merge(dst, src)
assert dst["foo"] == [["bar", "baz"]]


def test_merge_tuple_value():
dst = {}
src = {"a": ("x", "y")}

dpath.merge(dst, src)
assert dst["a"] == ["x", "y"]


def test_merge_nested_tuple_value():
dst = {}
src = {"a": ("x", ("nested", "tuple"))}

dpath.merge(dst, src)
assert dst["a"] == ["x", ["nested", "tuple"]]