Skip to content
30 changes: 22 additions & 8 deletions dpath/segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dpath import options
from dpath.exceptions import InvalidGlob, InvalidKeyName, PathNotFound
from dpath.types import PathSegment, Creator, Hints
from dpath.types import PathSegment, Creator, Hints, Glob, Path, SymmetricInt


def make_walkable(node) -> Iterator[Tuple[PathSegment, Any]]:
Expand All @@ -21,7 +21,10 @@ def make_walkable(node) -> Iterator[Tuple[PathSegment, Any]]:
return iter(node.items())
except AttributeError:
try:
return zip(range(len(node)), node)
indices = range(len(node))
# Convert all list indices to object so negative indexes are supported.
indices = map(lambda i: SymmetricInt(i, len(node)), indices)
return zip(indices, node)
except TypeError:
# This can happen in cases where the node isn't leaf(node) == True,
# but also isn't actually iterable. Instead of this being an error
Expand Down Expand Up @@ -163,7 +166,7 @@ class Star(object):
STAR = Star()


def match(segments: Sequence[PathSegment], glob: Sequence[str]):
def match(segments: Path, glob: Glob):
"""
Return True if the segments match the given glob, otherwise False.

Expand Down Expand Up @@ -214,7 +217,8 @@ def match(segments: Sequence[PathSegment], glob: Sequence[str]):
# If we were successful in matching up the lengths, then we can
# compare them using fnmatch.
if path_len == len(ss_glob):
for s, g in zip(map(int_str, segments), map(int_str, ss_glob)):
i = zip(segments, ss_glob)
for s, g in i:
# Match the stars we added to the glob to the type of the
# segment itself.
if g is STAR:
Expand All @@ -223,10 +227,20 @@ def match(segments: Sequence[PathSegment], glob: Sequence[str]):
else:
g = '*'

# Let's see if the glob matches. We will turn any kind of
# exception while attempting to match into a False for the
# match.
try:
# If search path segment (s) is an int then assume currently evaluated index (g) might be a sequence
# index as well. Try converting it to an int.
if isinstance(s, int) and s == int(g):
continue
except:
# Will reach this point if g can't be converted to an int (e.g. when g is a RegEx pattern).
# In this case convert s to a str so fnmatch can work on it.
s = str(s)

try:
# Let's see if the glob matches. We will turn any kind of
# exception while attempting to match into a False for the
# match.
if not fnmatchcase(s, g):
return False
except:
Expand Down Expand Up @@ -391,7 +405,7 @@ def foldm(obj, f, acc):
return acc


def view(obj, glob):
def view(obj: MutableMapping, glob: Glob):
"""
Return a view of the object where the glob matches. A view retains
the same form as the obj, but is limited to only the paths that
Expand Down
31 changes: 31 additions & 0 deletions dpath/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@
from typing import Union, Any, Callable, Sequence, Tuple, List, Optional, MutableMapping


class SymmetricInt(int):
"""Same as a normal int but mimicks the behavior of list indexes (can be compared to a negative number)."""

def __new__(cls, value: int, max_value: int, *args, **kwargs):
if value >= max_value:
raise TypeError(
f"Tried to initiate a {cls.__name__} with a value ({value}) "
f"greater than the provided max value ({max_value})"
)

obj = super().__new__(cls, value)
obj.max_value = max_value

return obj

def __eq__(self, other):
if not isinstance(other, int):
return False

if other >= self.max_value or other <= -self.max_value:
return False

return int(self) == (self.max_value + other) % self.max_value

def __repr__(self):
return f"<{self.__class__.__name__} {int(self)}%{self.max_value}>"

def __str__(self):
return str(int(self))


class MergeType(IntFlag):
ADDITIVE = auto()
"""List objects are combined onto one long list (NOT a set). This is the default flag."""
Expand Down
2 changes: 1 addition & 1 deletion dpath/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "2.1.1"
VERSION = "2.1.2"
7 changes: 7 additions & 0 deletions tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,10 @@ def test_search_multiple_stars():
assert res['a'][0]['b'][0]['c'] == 1
assert res['a'][0]['b'][1]['c'] == 2
assert res['a'][0]['b'][2]['c'] == 3


def test_search_negative_index():
d = {'a': {'b': [1, 2, 3]}}
res = dpath.search(d, 'a/b/-1')

assert res == dpath.search(d, "a/b/2")