diff --git a/CHANGES b/CHANGES index b1430f198..e11bd558c 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,18 @@ $ pip install --user --upgrade --pre libtmux +### Improvement + +- QueryList typings (#515) + + - This improves the annotations in descendant objects such as: + + - `Server.sessions` + - `Session.windows` + - `Window.panes` + + - Bolster tests (ported from `libvcs`): doctests and pytests + ## libtmux 0.26.0 (2024-02-06) ### Breaking change diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 0a12875de..e07536254 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -7,7 +7,7 @@ import re import traceback import typing as t -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence if t.TYPE_CHECKING: @@ -23,7 +23,7 @@ def __call__( ... -T = t.TypeVar("T", t.Any, t.Any) +T = t.TypeVar("T") no_arg = object() @@ -40,13 +40,55 @@ def keygetter( obj: "Mapping[str, t.Any]", path: str, ) -> t.Union[None, t.Any, str, t.List[str], "Mapping[str, str]"]: - """obj, "foods__breakfast", obj['foods']['breakfast']. + """Fetch values in objects and keys, supported nested data. - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") - 'cereal' - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") + **With dictionaries**: + + >>> keygetter({ "food": { "breakfast": "cereal" } }, "food") {'breakfast': 'cereal'} + >>> keygetter({ "food": { "breakfast": "cereal" } }, "food__breakfast") + 'cereal' + + **With objects**: + + >>> from typing import List, Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Food: + ... fruit: List[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... food: Food = field(default_factory=Food) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... food=Food( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + food=Food(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> keygetter(restaurant, "food") + Food(fruit=['banana', 'orange'], breakfast='cereal') + + >>> keygetter(restaurant, "food__breakfast") + 'cereal' """ try: sub_fields = path.split("__") @@ -74,10 +116,24 @@ def parse_lookup( 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): @@ -259,11 +315,13 @@ def __init__(self, op: str, *args: object): return super().__init__(f"{op} not in LOOKUP_NAME_MAP") -class QueryList(t.List[T]): +class QueryList(t.Generic[T], t.List[T]): """Filter list of object/dictionaries. For small, local datasets. *Experimental, unstable*. + **With dictionaries**: + >>> query = QueryList( ... [ ... { @@ -280,6 +338,7 @@ class QueryList(t.List[T]): ... }, ... ] ... ) + >>> query.filter(place="Chicago suburbs")[0]['city'] 'Elmhurst' >>> query.filter(place__icontains="chicago")[0]['city'] @@ -290,14 +349,128 @@ class QueryList(t.List[T]): 'Elmhurst' >>> query.filter(foods__fruit__in="orange")[0]['city'] 'Tampa' - >>> query.get(foods__fruit__in="orange")['city'] + + >>> query.filter(foods__fruit__in="apple") + [{'place': 'Chicago suburbs', + 'city': 'Elmhurst', + 'state': 'Illinois', + 'foods': + {'fruit': ['apple', 'cantelope'], 'breakfast': 'waffles'}}] + + >>> query.filter(foods__fruit__in="non_existent") + [] + + **With objects**: + + >>> from typing import Any, Dict + >>> 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' + + >>> query.get(foods__fruit__in="banana").city + 'Tampa' + + **With objects (nested)**: + + >>> from typing import List, Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Food: + ... fruit: List[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... food: Food = field(default_factory=Food) + + + >>> query = QueryList([ + ... Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... food=Food( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ), + ... Restaurant( + ... place="Chicago suburbs", + ... city="Elmhurst", + ... state="Illinois", + ... food=Food( + ... fruit=["apple", "cantelope"], breakfast="waffles" + ... ) + ... ) + ... ]) + + >>> query.filter(food__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + food=Food(fruit=['banana', 'orange'], breakfast='cereal'))] + + >>> query.filter(food__fruit__in="banana")[0].city + 'Tampa' + + >>> query.get(food__fruit__in="banana").city + 'Tampa' + + >>> query.filter(food__breakfast="waffles") + [Restaurant(place='Chicago suburbs', + city='Elmhurst', + state='Illinois', + food=Food(fruit=['apple', 'cantelope'], breakfast='waffles'))] + + >>> query.filter(food__breakfast="waffles")[0].city + 'Elmhurst' + + >>> query.filter(food__breakfast="non_existent") + [] """ data: "Sequence[T]" pk_key: t.Optional[str] - def items(self) -> t.List[T]: + def __init__(self, items: t.Optional["Iterable[T]"] = None) -> None: + super().__init__(items if items is not None else []) + + def items(self) -> t.List[t.Tuple[str, T]]: if self.pk_key is None: raise PKRequiredException() return [(getattr(item, self.pk_key), item) for item in self] @@ -305,12 +478,6 @@ def items(self) -> t.List[T]: def __eq__( self, other: object, - # other: t.Union[ - # "QueryList[T]", - # t.List[Mapping[str, str]], - # t.List[Mapping[str, int]], - # t.List[Mapping[str, t.Union[str, Mapping[str, t.Union[List[str], str]]]]], - # ], ) -> bool: data = other @@ -363,7 +530,7 @@ def filter_lookup(obj: t.Any) -> bool: _filter = matcher elif matcher is not None: - def val_match(obj: t.Union[str, t.List[t.Any]]) -> bool: + def val_match(obj: t.Union[str, t.List[t.Any], T]) -> bool: if isinstance(matcher, list): return obj in matcher else: diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 06ea1fc28..8e12041a4 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -491,7 +491,7 @@ def new_session( # Relations # @property - def sessions(self) -> QueryList[Session]: # type:ignore + def sessions(self) -> QueryList[Session]: """Sessions contained in server. Can be accessed via @@ -512,7 +512,7 @@ def sessions(self) -> QueryList[Session]: # type:ignore return QueryList(sessions) @property - def windows(self) -> QueryList[Window]: # type:ignore + def windows(self) -> QueryList[Window]: """Windows contained in server's sessions. Can be accessed via @@ -531,7 +531,7 @@ def windows(self) -> QueryList[Window]: # type:ignore return QueryList(windows) @property - def panes(self) -> QueryList[Pane]: # type:ignore + def panes(self) -> QueryList[Pane]: """Panes contained in tmux server (across all windows in all sessions). Can be accessed via @@ -707,7 +707,7 @@ def list_sessions(self) -> t.List[Session]: return self.sessions @property - def children(self) -> QueryList["Session"]: # type:ignore + def children(self) -> QueryList["Session"]: """Was used by TmuxRelationalObject (but that's longer used in this class). .. deprecated:: 0.16 diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 33e04ad5a..9ea495779 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -97,7 +97,7 @@ def from_session_id(cls, server: "Server", session_id: str) -> "Session": # Relations # @property - def windows(self) -> QueryList["Window"]: # type:ignore + def windows(self) -> QueryList["Window"]: """Windows contained by session. Can be accessed via @@ -117,7 +117,7 @@ def windows(self) -> QueryList["Window"]: # type:ignore return QueryList(windows) @property - def panes(self) -> QueryList["Pane"]: # type:ignore + def panes(self) -> QueryList["Pane"]: """Panes contained by session's windows. Can be accessed via @@ -689,7 +689,7 @@ def list_windows(self) -> t.List["Window"]: return self.windows @property - def children(self) -> QueryList["Window"]: # type:ignore + def children(self) -> QueryList["Window"]: """Was used by TmuxRelationalObject (but that's longer used in this class). .. deprecated:: 0.16 diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 23c82f7a8..8283a0903 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -108,7 +108,7 @@ def session(self) -> "Session": return Session.from_session_id(server=self.server, session_id=self.session_id) @property - def panes(self) -> QueryList["Pane"]: # type: ignore + def panes(self) -> QueryList["Pane"]: """Panes contained by window. Can be accessed via @@ -724,7 +724,7 @@ def list_panes(self) -> t.List["Pane"]: return self.panes @property - def children(self) -> QueryList["Pane"]: # type:ignore + def children(self) -> QueryList["Pane"]: """Was used by TmuxRelationalObject (but that's longer used in this class). .. deprecated:: 0.16 diff --git a/tests/_internal/__init__.py b/tests/_internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/_internal/test_query_list.py b/tests/_internal/test_query_list.py new file mode 100644 index 000000000..d15636d0d --- /dev/null +++ b/tests/_internal/test_query_list.py @@ -0,0 +1,288 @@ +import dataclasses +import typing as t + +import pytest + +from libtmux._internal.query_list import ( + MultipleObjectsReturned, + ObjectDoesNotExist, + QueryList, +) + + +@dataclasses.dataclass +class Obj: + test: int + fruit: t.List[str] = dataclasses.field(default_factory=list) + + +@pytest.mark.parametrize( + "items,filter_expr,expected_result", + [ + [[Obj(test=1)], None, [Obj(test=1)]], + [[Obj(test=1)], {"test": 1}, [Obj(test=1)]], + [[Obj(test=1)], {"test": 2}, []], + [ + [Obj(test=2, fruit=["apple"])], + {"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, + QueryList([{"fruit": "apple", "banana": object()}]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__eq": "apple"}, + QueryList([{"fruit": "apple", "banana": object()}]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__eq": "notmatch"}, + QueryList([]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__exact": "apple"}, + QueryList([{"fruit": "apple", "banana": object()}]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__exact": "notmatch"}, + QueryList([]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__iexact": "Apple"}, + QueryList([{"fruit": "apple", "banana": object()}]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit__iexact": "Notmatch"}, + QueryList([]), + ], + [ + [{"fruit": "apple", "banana": object()}], + {"fruit": "notmatch"}, + QueryList([]), + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit": "apple"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__in": "app"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__icontains": "App"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__contains": "app"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__regex": r"app.*"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__iregex": r"App.*"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__startswith": "a"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__istartswith": "AP"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__startswith": "z"}, + [], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__endswith": "le"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__iendswith": "LE"}, + [{"fruit": "apple"}], + ], + [ + [{"fruit": "apple"}, {"fruit": "mango"}], + {"fruit__endswith": "z"}, + [], + ], + [ + [ + {"fruit": "apple"}, + {"fruit": "mango"}, + {"fruit": "banana"}, + {"fruit": "kiwi"}, + ], + {"fruit__in": ["apple", "mango"]}, + [{"fruit": "apple"}, {"fruit": "mango"}], + ], + [ + [ + {"fruit": "apple"}, + {"fruit": "mango"}, + {"fruit": "banana"}, + {"fruit": "kiwi"}, + ], + {"fruit__nin": ["apple", "mango"]}, + [{"fruit": "banana"}, {"fruit": "kiwi"}], + ], + [ + [ + {"place": "book store", "city": "Tampa", "state": "Florida"}, + {"place": "coffee shop", "city": "Tampa", "state": "Florida"}, + { + "place": "chinese restaurant", + "city": "ybor city", + "state": "Florida", + }, + { + "place": "walt disney world", + "city": "Lake Buena Vista", + "state": "Florida", + }, + ], + {"city": "Tampa", "state": "Florida"}, + [ + {"place": "book store", "city": "Tampa", "state": "Florida"}, + {"place": "coffee shop", "city": "Tampa", "state": "Florida"}, + ], + ], + [ + [ + {"place": "book store", "city": "Tampa", "state": "Florida"}, + {"place": "coffee shop", "city": "Tampa", "state": "Florida"}, + { + "place": "chinese restaurant", + "city": "ybor city", + "state": "Florida", + }, + { + "place": "walt disney world", + "city": "Lake Buena Vista", + "state": "Florida", + }, + ], + {"place__contains": "coffee", "state": "Florida"}, + [ + {"place": "coffee shop", "city": "Tampa", "state": "Florida"}, + ], + ], + [ + [ + { + "place": "Largo", + "city": "Tampa", + "state": "Florida", + "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, + }, + { + "place": "Chicago suburbs", + "city": "Elmhurst", + "state": "Illinois", + "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"}, + }, + ], + {"foods__fruit__contains": "banana"}, + [ + { + "place": "Largo", + "city": "Tampa", + "state": "Florida", + "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, + }, + ], + ], + [ + [ + { + "place": "Largo", + "city": "Tampa", + "state": "Florida", + "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, + }, + { + "place": "Chicago suburbs", + "city": "Elmhurst", + "state": "Illinois", + "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"}, + }, + ], + {"foods__breakfast": "cereal"}, + [ + { + "place": "Largo", + "city": "Tampa", + "state": "Florida", + "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, + }, + ], + ], + [[1, 2, 3, 4, 5], None, QueryList([1, 2, 3, 4, 5])], + [[1, 2, 3, 4, 5], [1], QueryList([1])], + [[1, 2, 3, 4, 5], [1, 4], QueryList([1, 4])], + [[1, 2, 3, 4, 5], lambda val: val == 1, QueryList([1])], + [[1, 2, 3, 4, 5], lambda val: val == 2, QueryList([2])], + ], +) +def test_filter( + items: t.List[t.Dict[str, t.Any]], + filter_expr: t.Optional[t.Union[t.Callable[[t.Any], bool], t.Any]], + expected_result: t.Union[QueryList[t.Any], t.List[t.Dict[str, t.Any]]], +) -> None: + qs = QueryList(items) + if filter_expr is not None: + if isinstance(filter_expr, dict): + assert qs.filter(**filter_expr) == expected_result + else: + assert qs.filter(filter_expr) == expected_result + else: + assert qs.filter() == expected_result + + if ( + isinstance(expected_result, list) + and len(expected_result) > 0 + and not isinstance(expected_result[0], dict) + ): + if len(expected_result) == 1: + if isinstance(filter_expr, dict): + assert qs.get(**filter_expr) == expected_result[0] + else: + assert qs.get(filter_expr) == expected_result[0] + elif len(expected_result) > 1: + with pytest.raises(MultipleObjectsReturned) as e: + if isinstance(filter_expr, dict): + assert qs.get(**filter_expr) == expected_result + else: + assert qs.get(filter_expr) == expected_result + assert e.match("Multiple objects returned") + elif len(expected_result) == 0: + with pytest.raises(ObjectDoesNotExist) as exc: + if isinstance(filter_expr, dict): + assert qs.get(**filter_expr) == expected_result + else: + assert qs.get(filter_expr) == expected_result + assert exc.match("No objects found") diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 16a2e01af..a8dcdf517 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -163,12 +163,12 @@ def test_querylist( assert isinstance(w, Window) assert w.window_name == "test_2" - w = qs.get(window_name="test_2") - assert isinstance(w, Window) - assert w.window_name == "test_2" + w_2 = qs.get(window_name="test_2") + assert isinstance(w_2, Window) + assert w_2.window_name == "test_2" with pytest.raises(ObjectDoesNotExist): - w = qs.get(window_name="non_existent") + qs.get(window_name="non_existent") result = qs.get(window_name="non_existent", default="default_value") assert result == "default_value"