Skip to content

Commit da0aa24

Browse files
authored
fix!(QueryList): Generic fixes (#515)
Improves typings from: - Server.sessions - Session.windows - Window.panes
2 parents 32fff3e + 76bcfc8 commit da0aa24

File tree

8 files changed

+498
-31
lines changed

8 files changed

+498
-31
lines changed

CHANGES

+12
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ $ pip install --user --upgrade --pre libtmux
1414

1515
<!-- Maintainers and contributors: Insert change notes for the next release above -->
1616

17+
### Improvement
18+
19+
- QueryList typings (#515)
20+
21+
- This improves the annotations in descendant objects such as:
22+
23+
- `Server.sessions`
24+
- `Session.windows`
25+
- `Window.panes`
26+
27+
- Bolster tests (ported from `libvcs`): doctests and pytests
28+
1729
## libtmux 0.26.0 (2024-02-06)
1830

1931
### Breaking change

src/libtmux/_internal/query_list.py

+185-18
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import re
88
import traceback
99
import typing as t
10-
from collections.abc import Mapping, Sequence
10+
from collections.abc import Iterable, Mapping, Sequence
1111

1212
if t.TYPE_CHECKING:
1313

@@ -23,7 +23,7 @@ def __call__(
2323
...
2424

2525

26-
T = t.TypeVar("T", t.Any, t.Any)
26+
T = t.TypeVar("T")
2727

2828
no_arg = object()
2929

@@ -40,13 +40,55 @@ def keygetter(
4040
obj: "Mapping[str, t.Any]",
4141
path: str,
4242
) -> t.Union[None, t.Any, str, t.List[str], "Mapping[str, str]"]:
43-
"""obj, "foods__breakfast", obj['foods']['breakfast'].
43+
"""Fetch values in objects and keys, supported nested data.
4444
45-
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast")
46-
'cereal'
47-
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods")
45+
**With dictionaries**:
46+
47+
>>> keygetter({ "food": { "breakfast": "cereal" } }, "food")
4848
{'breakfast': 'cereal'}
4949
50+
>>> keygetter({ "food": { "breakfast": "cereal" } }, "food__breakfast")
51+
'cereal'
52+
53+
**With objects**:
54+
55+
>>> from typing import List, Optional
56+
>>> from dataclasses import dataclass, field
57+
58+
>>> @dataclass()
59+
... class Food:
60+
... fruit: List[str] = field(default_factory=list)
61+
... breakfast: Optional[str] = None
62+
63+
64+
>>> @dataclass()
65+
... class Restaurant:
66+
... place: str
67+
... city: str
68+
... state: str
69+
... food: Food = field(default_factory=Food)
70+
71+
72+
>>> restaurant = Restaurant(
73+
... place="Largo",
74+
... city="Tampa",
75+
... state="Florida",
76+
... food=Food(
77+
... fruit=["banana", "orange"], breakfast="cereal"
78+
... )
79+
... )
80+
81+
>>> restaurant
82+
Restaurant(place='Largo',
83+
city='Tampa',
84+
state='Florida',
85+
food=Food(fruit=['banana', 'orange'], breakfast='cereal'))
86+
87+
>>> keygetter(restaurant, "food")
88+
Food(fruit=['banana', 'orange'], breakfast='cereal')
89+
90+
>>> keygetter(restaurant, "food__breakfast")
91+
'cereal'
5092
"""
5193
try:
5294
sub_fields = path.split("__")
@@ -74,10 +116,24 @@ def parse_lookup(
74116
75117
If comparator not used or value not found, return None.
76118
77-
mykey__endswith("mykey") -> "mykey" else None
78-
79119
>>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith")
80120
'red apple'
121+
122+
It can also look up objects:
123+
124+
>>> from dataclasses import dataclass
125+
126+
>>> @dataclass()
127+
... class Inventory:
128+
... food: str
129+
130+
>>> item = Inventory(food="red apple")
131+
132+
>>> item
133+
Inventory(food='red apple')
134+
135+
>>> parse_lookup(item, "food__istartswith", "__istartswith")
136+
'red apple'
81137
"""
82138
try:
83139
if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup):
@@ -259,11 +315,13 @@ def __init__(self, op: str, *args: object):
259315
return super().__init__(f"{op} not in LOOKUP_NAME_MAP")
260316

261317

262-
class QueryList(t.List[T]):
318+
class QueryList(t.Generic[T], t.List[T]):
263319
"""Filter list of object/dictionaries. For small, local datasets.
264320
265321
*Experimental, unstable*.
266322
323+
**With dictionaries**:
324+
267325
>>> query = QueryList(
268326
... [
269327
... {
@@ -280,6 +338,7 @@ class QueryList(t.List[T]):
280338
... },
281339
... ]
282340
... )
341+
283342
>>> query.filter(place="Chicago suburbs")[0]['city']
284343
'Elmhurst'
285344
>>> query.filter(place__icontains="chicago")[0]['city']
@@ -290,27 +349,135 @@ class QueryList(t.List[T]):
290349
'Elmhurst'
291350
>>> query.filter(foods__fruit__in="orange")[0]['city']
292351
'Tampa'
293-
>>> query.get(foods__fruit__in="orange")['city']
352+
353+
>>> query.filter(foods__fruit__in="apple")
354+
[{'place': 'Chicago suburbs',
355+
'city': 'Elmhurst',
356+
'state': 'Illinois',
357+
'foods':
358+
{'fruit': ['apple', 'cantelope'], 'breakfast': 'waffles'}}]
359+
360+
>>> query.filter(foods__fruit__in="non_existent")
361+
[]
362+
363+
**With objects**:
364+
365+
>>> from typing import Any, Dict
366+
>>> from dataclasses import dataclass, field
367+
368+
>>> @dataclass()
369+
... class Restaurant:
370+
... place: str
371+
... city: str
372+
... state: str
373+
... foods: Dict[str, Any]
374+
375+
>>> restaurant = Restaurant(
376+
... place="Largo",
377+
... city="Tampa",
378+
... state="Florida",
379+
... foods={
380+
... "fruit": ["banana", "orange"], "breakfast": "cereal"
381+
... }
382+
... )
383+
384+
>>> restaurant
385+
Restaurant(place='Largo',
386+
city='Tampa',
387+
state='Florida',
388+
foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'})
389+
390+
>>> query = QueryList([restaurant])
391+
392+
>>> query.filter(foods__fruit__in="banana")
393+
[Restaurant(place='Largo',
394+
city='Tampa',
395+
state='Florida',
396+
foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'})]
397+
398+
>>> query.filter(foods__fruit__in="banana")[0].city
294399
'Tampa'
400+
401+
>>> query.get(foods__fruit__in="banana").city
402+
'Tampa'
403+
404+
**With objects (nested)**:
405+
406+
>>> from typing import List, Optional
407+
>>> from dataclasses import dataclass, field
408+
409+
>>> @dataclass()
410+
... class Food:
411+
... fruit: List[str] = field(default_factory=list)
412+
... breakfast: Optional[str] = None
413+
414+
415+
>>> @dataclass()
416+
... class Restaurant:
417+
... place: str
418+
... city: str
419+
... state: str
420+
... food: Food = field(default_factory=Food)
421+
422+
423+
>>> query = QueryList([
424+
... Restaurant(
425+
... place="Largo",
426+
... city="Tampa",
427+
... state="Florida",
428+
... food=Food(
429+
... fruit=["banana", "orange"], breakfast="cereal"
430+
... )
431+
... ),
432+
... Restaurant(
433+
... place="Chicago suburbs",
434+
... city="Elmhurst",
435+
... state="Illinois",
436+
... food=Food(
437+
... fruit=["apple", "cantelope"], breakfast="waffles"
438+
... )
439+
... )
440+
... ])
441+
442+
>>> query.filter(food__fruit__in="banana")
443+
[Restaurant(place='Largo',
444+
city='Tampa',
445+
state='Florida',
446+
food=Food(fruit=['banana', 'orange'], breakfast='cereal'))]
447+
448+
>>> query.filter(food__fruit__in="banana")[0].city
449+
'Tampa'
450+
451+
>>> query.get(food__fruit__in="banana").city
452+
'Tampa'
453+
454+
>>> query.filter(food__breakfast="waffles")
455+
[Restaurant(place='Chicago suburbs',
456+
city='Elmhurst',
457+
state='Illinois',
458+
food=Food(fruit=['apple', 'cantelope'], breakfast='waffles'))]
459+
460+
>>> query.filter(food__breakfast="waffles")[0].city
461+
'Elmhurst'
462+
463+
>>> query.filter(food__breakfast="non_existent")
464+
[]
295465
"""
296466

297467
data: "Sequence[T]"
298468
pk_key: t.Optional[str]
299469

300-
def items(self) -> t.List[T]:
470+
def __init__(self, items: t.Optional["Iterable[T]"] = None) -> None:
471+
super().__init__(items if items is not None else [])
472+
473+
def items(self) -> t.List[t.Tuple[str, T]]:
301474
if self.pk_key is None:
302475
raise PKRequiredException()
303476
return [(getattr(item, self.pk_key), item) for item in self]
304477

305478
def __eq__(
306479
self,
307480
other: object,
308-
# other: t.Union[
309-
# "QueryList[T]",
310-
# t.List[Mapping[str, str]],
311-
# t.List[Mapping[str, int]],
312-
# t.List[Mapping[str, t.Union[str, Mapping[str, t.Union[List[str], str]]]]],
313-
# ],
314481
) -> bool:
315482
data = other
316483

@@ -363,7 +530,7 @@ def filter_lookup(obj: t.Any) -> bool:
363530
_filter = matcher
364531
elif matcher is not None:
365532

366-
def val_match(obj: t.Union[str, t.List[t.Any]]) -> bool:
533+
def val_match(obj: t.Union[str, t.List[t.Any], T]) -> bool:
367534
if isinstance(matcher, list):
368535
return obj in matcher
369536
else:

src/libtmux/server.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ def new_session(
491491
# Relations
492492
#
493493
@property
494-
def sessions(self) -> QueryList[Session]: # type:ignore
494+
def sessions(self) -> QueryList[Session]:
495495
"""Sessions contained in server.
496496
497497
Can be accessed via
@@ -512,7 +512,7 @@ def sessions(self) -> QueryList[Session]: # type:ignore
512512
return QueryList(sessions)
513513

514514
@property
515-
def windows(self) -> QueryList[Window]: # type:ignore
515+
def windows(self) -> QueryList[Window]:
516516
"""Windows contained in server's sessions.
517517
518518
Can be accessed via
@@ -531,7 +531,7 @@ def windows(self) -> QueryList[Window]: # type:ignore
531531
return QueryList(windows)
532532

533533
@property
534-
def panes(self) -> QueryList[Pane]: # type:ignore
534+
def panes(self) -> QueryList[Pane]:
535535
"""Panes contained in tmux server (across all windows in all sessions).
536536
537537
Can be accessed via
@@ -707,7 +707,7 @@ def list_sessions(self) -> t.List[Session]:
707707
return self.sessions
708708

709709
@property
710-
def children(self) -> QueryList["Session"]: # type:ignore
710+
def children(self) -> QueryList["Session"]:
711711
"""Was used by TmuxRelationalObject (but that's longer used in this class).
712712
713713
.. deprecated:: 0.16

src/libtmux/session.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def from_session_id(cls, server: "Server", session_id: str) -> "Session":
9797
# Relations
9898
#
9999
@property
100-
def windows(self) -> QueryList["Window"]: # type:ignore
100+
def windows(self) -> QueryList["Window"]:
101101
"""Windows contained by session.
102102
103103
Can be accessed via
@@ -117,7 +117,7 @@ def windows(self) -> QueryList["Window"]: # type:ignore
117117
return QueryList(windows)
118118

119119
@property
120-
def panes(self) -> QueryList["Pane"]: # type:ignore
120+
def panes(self) -> QueryList["Pane"]:
121121
"""Panes contained by session's windows.
122122
123123
Can be accessed via
@@ -689,7 +689,7 @@ def list_windows(self) -> t.List["Window"]:
689689
return self.windows
690690

691691
@property
692-
def children(self) -> QueryList["Window"]: # type:ignore
692+
def children(self) -> QueryList["Window"]:
693693
"""Was used by TmuxRelationalObject (but that's longer used in this class).
694694
695695
.. deprecated:: 0.16

src/libtmux/window.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def session(self) -> "Session":
108108
return Session.from_session_id(server=self.server, session_id=self.session_id)
109109

110110
@property
111-
def panes(self) -> QueryList["Pane"]: # type: ignore
111+
def panes(self) -> QueryList["Pane"]:
112112
"""Panes contained by window.
113113
114114
Can be accessed via
@@ -724,7 +724,7 @@ def list_panes(self) -> t.List["Pane"]:
724724
return self.panes
725725

726726
@property
727-
def children(self) -> QueryList["Pane"]: # type:ignore
727+
def children(self) -> QueryList["Pane"]:
728728
"""Was used by TmuxRelationalObject (but that's longer used in this class).
729729
730730
.. deprecated:: 0.16

tests/_internal/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)