diff --git a/CHANGES b/CHANGES index 225d26ec6..dc15b2963 100644 --- a/CHANGES +++ b/CHANGES @@ -13,9 +13,21 @@ $ pip install --user --upgrade --pre libvcs - _Add your latest changes from PRs here_ +## libvcs 0.16.3 (unreleased) + +### Bug fixes + +- `QueryList`: Fix lookups of objects (#415) + ### Tests - Basic pytest plugin test (#413) +- Add test for object based lookups (#414) + +### Documentation + +- Improve doc examples / tests for `keygetter` and `QueryList` to show deep lookups for objects + (#415) ## libvcs 0.16.2 (2022-09-11) diff --git a/src/libvcs/_internal/query_list.py b/src/libvcs/_internal/query_list.py index c8f54ddee..214dfadc0 100644 --- a/src/libvcs/_internal/query_list.py +++ b/src/libvcs/_internal/query_list.py @@ -17,22 +17,68 @@ def keygetter( obj: Mapping[str, Any], path: str, ) -> Union[None, Any, str, list[str], Mapping[str, str]]: - """obj, "foods__breakfast", obj['foods']['breakfast'] + """Fetch values in objects and keys, deeply. - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") - 'cereal' - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") + **With dictionaries**: + + >>> keygetter({ "menu": { "breakfast": "cereal" } }, "menu") {'breakfast': 'cereal'} + >>> keygetter({ "menu": { "breakfast": "cereal" } }, "menu__breakfast") + 'cereal' + + **With objects**: + + >>> from typing import Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Menu: + ... fruit: list[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... menu: Menu = field(default_factory=Menu) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... menu=Menu( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> keygetter(restaurant, "menu") + Menu(fruit=['banana', 'orange'], breakfast='cereal') + + >>> keygetter(restaurant, "menu__breakfast") + 'cereal' """ try: sub_fields = path.split("__") dct = obj for sub_field in sub_fields: - dct = dct[sub_field] + if isinstance(dct, dict): + dct = dct[sub_field] + elif hasattr(dct, sub_field): + dct = getattr(dct, sub_field) return dct - except Exception: + except Exception as e: traceback.print_stack() + print(f"Above error was {e}") return None @@ -41,10 +87,24 @@ def parse_lookup(obj: Mapping[str, Any], path: str, lookup: str) -> Optional[Any If comparator not used or value not found, return None. - mykey__endswith("mykey") -> "mykey" else None - >>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith") 'red apple' + + It can also look up objects: + + >>> from dataclasses import dataclass + + >>> @dataclass() + ... class Inventory: + ... food: str + + >>> item = Inventory(food="red apple") + + >>> item + Inventory(food='red apple') + + >>> parse_lookup(item, "food__istartswith", "__istartswith") + 'red apple' """ try: if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup): @@ -258,6 +318,89 @@ class QueryList(list[T]): 'Elmhurst' >>> query.filter(foods__fruit__in="orange")[0]['city'] 'Tampa' + + Examples of object lookups: + + >>> from typing import Any + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... foods: dict[str, Any] + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... foods={ + ... "fruit": ["banana", "orange"], "breakfast": "cereal" + ... } + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'}) + + >>> query = QueryList([restaurant]) + + >>> query.filter(foods__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'})] + + >>> query.filter(foods__fruit__in="banana")[0].city + 'Tampa' + + Example of deeper object lookups: + + >>> from typing import Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Menu: + ... fruit: list[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... menu: Menu = field(default_factory=Menu) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... menu=Menu( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> query = QueryList([restaurant]) + + >>> query.filter(menu__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal'))] + + >>> query.filter(menu__fruit__in="banana")[0].city + 'Tampa' """ data: Sequence[T] diff --git a/tests/_internal/test_query_list.py b/tests/_internal/test_query_list.py index 4b3056819..552b360e9 100644 --- a/tests/_internal/test_query_list.py +++ b/tests/_internal/test_query_list.py @@ -16,14 +16,16 @@ class Obj: "items,filter_expr,expected_result", [ [[Obj(test=1)], None, [Obj(test=1)]], - [[{"test": 1}], None, [{"test": 1}]], - [[{"test": 1}], None, QueryList([{"test": 1}])], - [[{"fruit": "apple"}], None, QueryList([{"fruit": "apple"}])], + [[Obj(test=1)], dict(test=1), [Obj(test=1)]], + [[Obj(test=1)], dict(test=2), []], [ [Obj(test=2, fruit=["apple"])], - None, + dict(fruit__in="apple"), QueryList([Obj(test=2, fruit=["apple"])]), ], + [[{"test": 1}], None, [{"test": 1}]], + [[{"test": 1}], None, QueryList([{"test": 1}])], + [[{"fruit": "apple"}], None, QueryList([{"fruit": "apple"}])], [ [{"fruit": "apple", "banana": object()}], None,