diff --git a/dpath/segments.py b/dpath/segments.py index faa763f..7a48817 100644 --- a/dpath/segments.py +++ b/dpath/segments.py @@ -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]]: @@ -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 @@ -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. @@ -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: @@ -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: @@ -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 diff --git a/dpath/types.py b/dpath/types.py index b876e6a..7bf3d2d 100644 --- a/dpath/types.py +++ b/dpath/types.py @@ -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.""" diff --git a/dpath/version.py b/dpath/version.py index 5b0431e..b777579 100644 --- a/dpath/version.py +++ b/dpath/version.py @@ -1 +1 @@ -VERSION = "2.1.1" +VERSION = "2.1.2" diff --git a/tests/test_search.py b/tests/test_search.py index 5830088..7219d2b 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -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")