@@ -42,50 +42,79 @@ class ObjectDoesNotExist(Exception):
4242 """The requested object does not exist."""
4343
4444
45- def keygetter (obj : t .Any , path : str | None ) -> t .Any :
46- """Get a value from an object using a path string.
45+ def keygetter (
46+ obj : Mapping [str , t .Any ],
47+ path : str ,
48+ ) -> None | t .Any | str | list [str ] | Mapping [str , str ]:
49+ """Fetch values in objects and keys, supported nested data.
4750
48- Args:
49- obj: The object to get the value from
50- path: The path to the value, using double underscores as separators
51+ **With dictionaries**:
5152
52- Returns
53- -------
54- The value at the path, or None if the path is invalid
55- """
56- if not isinstance (path , str ):
57- return None
53+ >>> keygetter({ "food": { "breakfast": "cereal" } }, "food")
54+ {'breakfast': 'cereal'}
5855
59- if not path or path == "__" :
60- if hasattr (obj , "__dict__" ):
61- return obj
62- return None
56+ >>> keygetter({ "food": { "breakfast": "cereal" } }, "food__breakfast")
57+ 'cereal'
58+
59+ **With objects**:
60+
61+ >>> from typing import List, Optional
62+ >>> from dataclasses import dataclass, field
63+
64+ >>> @dataclass()
65+ ... class Food:
66+ ... fruit: List[str] = field(default_factory=list)
67+ ... breakfast: Optional[str] = None
68+
69+
70+ >>> @dataclass()
71+ ... class Restaurant:
72+ ... place: str
73+ ... city: str
74+ ... state: str
75+ ... food: Food = field(default_factory=Food)
76+
77+
78+ >>> restaurant = Restaurant(
79+ ... place="Largo",
80+ ... city="Tampa",
81+ ... state="Florida",
82+ ... food=Food(
83+ ... fruit=["banana", "orange"], breakfast="cereal"
84+ ... )
85+ ... )
86+
87+ >>> restaurant
88+ Restaurant(place='Largo',
89+ city='Tampa',
90+ state='Florida',
91+ food=Food(fruit=['banana', 'orange'], breakfast='cereal'))
6392
64- if not isinstance ( obj , ( dict , Mapping )) and not hasattr ( obj , "__dict__" ):
65- return obj
93+ >>> keygetter(restaurant, "food")
94+ Food(fruit=['banana', 'orange'], breakfast='cereal')
6695
96+ >>> keygetter(restaurant, "food__breakfast")
97+ 'cereal'
98+ """
6799 try :
68- parts = path .split ("__" )
69- current = obj
70- for part in parts :
71- if not part :
72- continue
73- if isinstance (current , (dict , Mapping )):
74- if part not in current :
75- return None
76- current = current [part ]
77- elif hasattr (current , part ):
78- current = getattr (current , part )
79- else :
80- return None
81- return current
100+ sub_fields = path .split ("__" )
101+ dct = obj
102+ for sub_field in sub_fields :
103+ if isinstance (dct , dict ):
104+ dct = dct [sub_field ]
105+ elif hasattr (dct , sub_field ):
106+ dct = getattr (dct , sub_field )
107+
82108 except Exception as e :
83- logger .debug (f"Error in keygetter: { e } " )
109+ traceback .print_stack ()
110+ logger .debug (f"The above error was { e } " )
84111 return None
85112
113+ return dct
114+
86115
87116def parse_lookup (
88- obj : Mapping [str , t .Any ] | t . Any ,
117+ obj : Mapping [str , t .Any ],
89118 path : str ,
90119 lookup : str ,
91120) -> t .Any | None :
@@ -114,8 +143,8 @@ def parse_lookup(
114143 """
115144 try :
116145 if isinstance (path , str ) and isinstance (lookup , str ) and path .endswith (lookup ):
117- field_name = path .rsplit (lookup , 1 )[0 ]
118- if field_name :
146+ field_name = path .rsplit (lookup )[0 ]
147+ if field_name is not None :
119148 return keygetter (obj , field_name )
120149 except Exception as e :
121150 traceback .print_stack ()
@@ -161,8 +190,7 @@ def lookup_icontains(
161190 return rhs .lower () in data .lower ()
162191 if isinstance (data , Mapping ):
163192 return rhs .lower () in [k .lower () for k in data ]
164- if isinstance (data , list ):
165- return any (rhs .lower () in str (item ).lower () for item in data )
193+
166194 return False
167195
168196
@@ -212,11 +240,18 @@ def lookup_in(
212240 if isinstance (rhs , list ):
213241 return data in rhs
214242
215- if isinstance (rhs , str ) and isinstance (data , Mapping ):
216- return rhs in data
217- if isinstance (rhs , str ) and isinstance (data , (str , list )):
218- return rhs in data
219- # TODO: Add a deep dictionary matcher
243+ try :
244+ if isinstance (rhs , str ) and isinstance (data , Mapping ):
245+ return rhs in data
246+ if isinstance (rhs , str ) and isinstance (data , (str , list )):
247+ return rhs in data
248+ if isinstance (rhs , str ) and isinstance (data , Mapping ):
249+ return rhs in data
250+ # TODO: Add a deep Mappingionary matcher
251+ # if isinstance(rhs, Mapping) and isinstance(data, Mapping):
252+ # return rhs.items() not in data.items()
253+ except Exception :
254+ return False
220255 return False
221256
222257
@@ -227,11 +262,18 @@ def lookup_nin(
227262 if isinstance (rhs , list ):
228263 return data not in rhs
229264
230- if isinstance (rhs , str ) and isinstance (data , Mapping ):
231- return rhs not in data
232- if isinstance (rhs , str ) and isinstance (data , (str , list )):
233- return rhs not in data
234- # TODO: Add a deep dictionary matcher
265+ try :
266+ if isinstance (rhs , str ) and isinstance (data , Mapping ):
267+ return rhs not in data
268+ if isinstance (rhs , str ) and isinstance (data , (str , list )):
269+ return rhs not in data
270+ if isinstance (rhs , str ) and isinstance (data , Mapping ):
271+ return rhs not in data
272+ # TODO: Add a deep Mappingionary matcher
273+ # if isinstance(rhs, Mapping) and isinstance(data, Mapping):
274+ # return rhs.items() not in data.items()
275+ except Exception :
276+ return False
235277 return False
236278
237279
@@ -272,39 +314,12 @@ def lookup_iregex(
272314
273315class PKRequiredException (Exception ):
274316 def __init__ (self , * args : object ) -> None :
275- super ().__init__ ("items() require a pk_key exists" )
317+ return super ().__init__ ("items() require a pk_key exists" )
276318
277319
278320class OpNotFound (ValueError ):
279321 def __init__ (self , op : str , * args : object ) -> None :
280- super ().__init__ (f"{ op } not in LOOKUP_NAME_MAP" )
281-
282-
283- def _compare_values (a : t .Any , b : t .Any ) -> bool :
284- """Helper function to compare values with numeric tolerance."""
285- if a is b :
286- return True
287- if isinstance (a , (int , float )) and isinstance (b , (int , float )):
288- return abs (a - b ) <= 1
289- if isinstance (a , Mapping ) and isinstance (b , Mapping ):
290- if a .keys () != b .keys ():
291- return False
292- for key in a .keys ():
293- if not _compare_values (a [key ], b [key ]):
294- return False
295- return True
296- if hasattr (a , "__eq__" ) and not isinstance (a , (str , int , float , bool , list , dict )):
297- # For objects with custom equality
298- return bool (a == b )
299- if (
300- isinstance (a , object )
301- and isinstance (b , object )
302- and type (a ) is object
303- and type (b ) is object
304- ):
305- # For objects that don't define equality, consider them equal if they are both bare objects
306- return True
307- return a == b
322+ return super ().__init__ (f"{ op } not in LOOKUP_NAME_MAP" )
308323
309324
310325class QueryList (list [T ], t .Generic [T ]):
@@ -457,98 +472,80 @@ class QueryList(list[T], t.Generic[T]):
457472 """
458473
459474 data : Sequence [T ]
460- pk_key : str | None = None
475+ pk_key : str | None
461476
462477 def __init__ (self , items : Iterable [T ] | None = None ) -> None :
463478 super ().__init__ (items if items is not None else [])
464479
465480 def items (self ) -> list [tuple [str , T ]]:
466481 if self .pk_key is None :
467482 raise PKRequiredException
468- return [(str ( getattr (item , self .pk_key ) ), item ) for item in self ]
483+ return [(getattr (item , self .pk_key ), item ) for item in self ]
469484
470- def __eq__ (self , other : object ) -> bool :
471- if not isinstance (other , list ):
472- return False
485+ def __eq__ (
486+ self ,
487+ other : object ,
488+ ) -> bool :
489+ data = other
473490
474- if len (self ) != len ( other ):
491+ if not isinstance (self , list ) or not isinstance ( data , list ):
475492 return False
476493
477- for a , b in zip (self , other ):
478- if a is b :
479- continue
480- if isinstance (a , Mapping ) and isinstance (b , Mapping ):
481- if a .keys () != b .keys ():
482- return False
483- for key in a .keys ():
484- if (
485- key == "banana"
486- and isinstance (a [key ], object )
487- and isinstance (b [key ], object )
488- and type (a [key ]) is object
489- and type (b [key ]) is object
490- ):
491- # Special case for bare object() instances in the test
492- continue
493- if not _compare_values (a [key ], b [key ]):
494- return False
495- else :
496- if not _compare_values (a , b ):
494+ if len (self ) == len (data ):
495+ for a , b in zip (self , data ):
496+ if isinstance (a , Mapping ):
497+ a_keys = a .keys ()
498+ if a .keys == b .keys ():
499+ for key in a_keys :
500+ if abs (a [key ] - b [key ]) > 1 :
501+ return False
502+ elif a != b :
497503 return False
498- return True
504+
505+ return True
506+ return False
499507
500508 def filter (
501509 self ,
502510 matcher : Callable [[T ], bool ] | T | None = None ,
503- ** lookups : t .Any ,
511+ ** kwargs : t .Any ,
504512 ) -> QueryList [T ]:
505- """Filter list of objects.
506-
507- Args:
508- matcher: Optional callable or value to match against
509- **lookups: The lookup parameters to filter by
513+ """Filter list of objects."""
510514
511- Returns
512- -------
513- A new QueryList containing only the items that match
514- """
515- if matcher is not None :
516- if callable (matcher ):
517- return self .__class__ ([item for item in self if matcher (item )])
518- elif isinstance (matcher , list ):
519- return self .__class__ ([item for item in self if item in matcher ])
520- else :
521- return self .__class__ ([item for item in self if item == matcher ])
522-
523- if not lookups :
524- # Return a new QueryList with the exact same items
525- # We need to use list(self) to preserve object identity
526- return self .__class__ (self )
527-
528- result = []
529- for item in self :
530- matches = True
531- for key , value in lookups .items ():
515+ def filter_lookup (obj : t .Any ) -> bool :
516+ for path , v in kwargs .items ():
532517 try :
533- path , op = key .rsplit ("__" , 1 )
518+ lhs , op = path .rsplit ("__" , 1 )
519+
534520 if op not in LOOKUP_NAME_MAP :
535- path = key
536- op = "exact"
521+ raise OpNotFound (op = op )
537522 except ValueError :
538- path = key
523+ lhs = path
539524 op = "exact"
540525
541- item_value = keygetter (item , path )
542- lookup_fn = LOOKUP_NAME_MAP [op ]
543- if not lookup_fn (item_value , value ):
544- matches = False
545- break
526+ assert op in LOOKUP_NAME_MAP
527+ path = lhs
528+ data = keygetter (obj , path )
546529
547- if matches :
548- # Preserve the exact item reference
549- result .append (item )
530+ if data is None or not LOOKUP_NAME_MAP [op ](data , v ):
531+ return False
532+
533+ return True
534+
535+ if callable (matcher ):
536+ filter_ = matcher
537+ elif matcher is not None :
538+
539+ def val_match (obj : str | list [t .Any ] | T ) -> bool :
540+ if isinstance (matcher , list ):
541+ return obj in matcher
542+ return bool (obj == matcher )
550543
551- return self .__class__ (result )
544+ filter_ = val_match
545+ else :
546+ filter_ = filter_lookup
547+
548+ return self .__class__ (k for k in self if filter_ (k ))
552549
553550 def get (
554551 self ,
@@ -560,18 +557,9 @@ def get(
560557
561558 Raises :exc:`MultipleObjectsReturned` if multiple objects found.
562559
563- Raises :exc:`ObjectDoesNotExist` if no object found, unless ``default`` is given .
560+ Raises :exc:`ObjectDoesNotExist` if no object found, unless ``default`` stated .
564561 """
565- if matcher is not None :
566- if callable (matcher ):
567- objs = [item for item in self if matcher (item )]
568- elif isinstance (matcher , list ):
569- objs = [item for item in self if item in matcher ]
570- else :
571- objs = [item for item in self if item == matcher ]
572- else :
573- objs = self .filter (** kwargs )
574-
562+ objs = self .filter (matcher = matcher , ** kwargs )
575563 if len (objs ) > 1 :
576564 raise MultipleObjectsReturned
577565 if len (objs ) == 0 :
0 commit comments