From 24d7401822cbfa9441c1a33468331f8f144ab20c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Sep 2022 17:26:05 -0500 Subject: [PATCH 1/9] feat! Port libvcs utilities --- src/libtmux/_internal/__init__.py | 0 src/libtmux/_internal/dataclasses.py | 84 ++++ src/libtmux/_internal/query_list.py | 349 ++++++++++++++++ src/libtmux/_internal/subprocess.py | 587 +++++++++++++++++++++++++++ src/libtmux/_internal/types.py | 22 + 5 files changed, 1042 insertions(+) create mode 100644 src/libtmux/_internal/__init__.py create mode 100644 src/libtmux/_internal/dataclasses.py create mode 100644 src/libtmux/_internal/query_list.py create mode 100644 src/libtmux/_internal/subprocess.py create mode 100644 src/libtmux/_internal/types.py diff --git a/src/libtmux/_internal/__init__.py b/src/libtmux/_internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/libtmux/_internal/dataclasses.py b/src/libtmux/_internal/dataclasses.py new file mode 100644 index 000000000..5cabfb19d --- /dev/null +++ b/src/libtmux/_internal/dataclasses.py @@ -0,0 +1,84 @@ +""":mod:`dataclasses` utilities. + +Note +---- +This is an internal API not covered by versioning policy. +""" +import dataclasses +from operator import attrgetter + + +class SkipDefaultFieldsReprMixin: + r"""Skip default fields in :func:`~dataclasses.dataclass` + :func:`object representation `. + + Notes + ----- + + Credit: Pietro Oldrati, 2022-05-08, Unilicense + + https://stackoverflow.com/a/72161437/1396928 + + Examples + -------- + + >>> @dataclasses.dataclass() + ... class Item: + ... name: str + ... unit_price: float = 1.00 + ... quantity_on_hand: int = 0 + ... + + >>> @dataclasses.dataclass(repr=False) + ... class ItemWithMixin(SkipDefaultFieldsReprMixin): + ... name: str + ... unit_price: float = 1.00 + ... quantity_on_hand: int = 0 + ... + + >>> Item('Test') + Item(name='Test', unit_price=1.0, quantity_on_hand=0) + + >>> ItemWithMixin('Test') + ItemWithMixin(name=Test) + + >>> Item('Test', quantity_on_hand=2) + Item(name='Test', unit_price=1.0, quantity_on_hand=2) + + >>> ItemWithMixin('Test', quantity_on_hand=2) + ItemWithMixin(name=Test, quantity_on_hand=2) + + If you want to copy/paste the :meth:`~.__repr__()` + directly, you can omit the ``repr=False``: + + >>> @dataclasses.dataclass + ... class ItemWithMixin(SkipDefaultFieldsReprMixin): + ... name: str + ... unit_price: float = 1.00 + ... quantity_on_hand: int = 0 + ... __repr__ = SkipDefaultFieldsReprMixin.__repr__ + ... + + >>> ItemWithMixin('Test') + ItemWithMixin(name=Test) + + >>> ItemWithMixin('Test', unit_price=2.00) + ItemWithMixin(name=Test, unit_price=2.0) + + >>> item = ItemWithMixin('Test') + >>> item.unit_price = 2.05 + + >>> item + ItemWithMixin(name=Test, unit_price=2.05) + """ + + def __repr__(self) -> str: + """Omit default fields in object representation.""" + nodef_f_vals = ( + (f.name, attrgetter(f.name)(self)) + for f in dataclasses.fields(self) + if attrgetter(f.name)(self) != f.default + ) + + nodef_f_repr = ", ".join(f"{name}={value}" for name, value in nodef_f_vals) + return f"{self.__class__.__name__}({nodef_f_repr})" diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py new file mode 100644 index 000000000..70af21f0b --- /dev/null +++ b/src/libtmux/_internal/query_list.py @@ -0,0 +1,349 @@ +"""Utilities for filtering or searching :class:`list` of objects / list data. + +Note +---- +This is an internal API not covered by versioning policy. +""" +import re +import traceback +from collections.abc import Mapping, Sequence +from re import Pattern +from typing import TYPE_CHECKING, Any, Callable, List, Optional, TypeVar, Union + +if TYPE_CHECKING: + from typing_extensions import Protocol + + class LookupProtocol(Protocol): + """Protocol for :class:`QueryList` filtering operators.""" + + def __call__( + self, + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], + ) -> bool: + """Callback for :class:`QueryList` filtering operators.""" + ... + + +T = TypeVar("T", Any, Any) + + +def keygetter( + obj: "Mapping[str, Any]", + path: str, +) -> Union[None, Any, str, List[str], "Mapping[str, str]"]: + """obj, "foods__breakfast", obj['foods']['breakfast'] + + >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") + 'cereal' + >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") + {'breakfast': 'cereal'} + + """ + try: + sub_fields = path.split("__") + dct = obj + for sub_field in sub_fields: + if isinstance(dct, dict): + dct = dct[sub_field] + elif hasattr(dct, sub_field): + dct = getattr(dct, sub_field) + + return dct + except Exception as e: + traceback.print_stack() + print(f"Above error was {e}") + return None + + +def parse_lookup(obj: "Mapping[str, Any]", path: str, lookup: str) -> Optional[Any]: + """Check if field lookup key, e.g. "my__path__contains" has comparator, return val. + + 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' + """ + try: + if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup): + field_name = path.rsplit(lookup)[0] + if field_name is not None: + return keygetter(obj, field_name) + except Exception: + traceback.print_stack() + return None + + +def lookup_exact( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + return rhs == data + + +def lookup_iexact( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, str): + return False + + return rhs.lower() == data.lower() + + +def lookup_contains( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, (str, Mapping, list)): + return False + + return rhs in data + + +def lookup_icontains( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, (str, Mapping, list)): + return False + + if isinstance(data, str): + return rhs.lower() in data.lower() + if isinstance(data, Mapping): + return rhs.lower() in [k.lower() for k in data.keys()] + + return False + + +def lookup_startswith( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, str): + return False + + return data.startswith(rhs) + + +def lookup_istartswith( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, str): + return False + + return data.lower().startswith(rhs.lower()) + + +def lookup_endswith( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, str): + return False + + return data.endswith(rhs) + + +def lookup_iendswith( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if not isinstance(rhs, str) or not isinstance(data, str): + return False + return data.lower().endswith(rhs.lower()) + + +def lookup_in( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if isinstance(rhs, list): + return data in rhs + + try: + if isinstance(rhs, str) and isinstance(data, Mapping): + return rhs in data + if isinstance(rhs, str) and isinstance(data, (str, list)): + return rhs in data + if isinstance(rhs, str) and isinstance(data, Mapping): + return rhs in data + # TODO: Add a deep Mappingionary matcher + # if isinstance(rhs, Mapping) and isinstance(data, Mapping): + # return rhs.items() not in data.items() + except Exception: + return False + return False + + +def lookup_nin( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if isinstance(rhs, list): + return data not in rhs + + try: + if isinstance(rhs, str) and isinstance(data, Mapping): + return rhs not in data + if isinstance(rhs, str) and isinstance(data, (str, list)): + return rhs not in data + if isinstance(rhs, str) and isinstance(data, Mapping): + return rhs not in data + # TODO: Add a deep Mappingionary matcher + # if isinstance(rhs, Mapping) and isinstance(data, Mapping): + # return rhs.items() not in data.items() + except Exception: + return False + return False + + +def lookup_regex( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if isinstance(data, (str, bytes, re.Pattern)) and isinstance(rhs, (str, bytes)): + return bool(re.search(rhs, data)) + return False + + +def lookup_iregex( + data: Union[str, List[str], "Mapping[str, str]"], + rhs: Union[str, List[str], "Mapping[str, str]", "Pattern[str]"], +) -> bool: + if isinstance(data, (str, bytes, re.Pattern)) and isinstance(rhs, (str, bytes)): + return bool(re.search(rhs, data, re.IGNORECASE)) + return False + + +LOOKUP_NAME_MAP: 'Mapping[str, "LookupProtocol"]' = { + "eq": lookup_exact, + "exact": lookup_exact, + "iexact": lookup_iexact, + "contains": lookup_contains, + "icontains": lookup_icontains, + "startswith": lookup_startswith, + "istartswith": lookup_istartswith, + "endswith": lookup_endswith, + "iendswith": lookup_iendswith, + "in": lookup_in, + "nin": lookup_nin, + "regex": lookup_regex, + "iregex": lookup_iregex, +} + + +class QueryList(List[T]): + """Filter list of object/dictionaries. For small, local datasets. + + *Experimental, unstable*. + + >>> query = QueryList( + ... [ + ... { + ... "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"}, + ... }, + ... ] + ... ) + >>> query.filter(place="Chicago suburbs")[0]['city'] + 'Elmhurst' + >>> query.filter(place__icontains="chicago")[0]['city'] + 'Elmhurst' + >>> query.filter(foods__breakfast="waffles")[0]['city'] + 'Elmhurst' + >>> query.filter(foods__fruit__in="cantelope")[0]['city'] + 'Elmhurst' + >>> query.filter(foods__fruit__in="orange")[0]['city'] + 'Tampa' + """ + + data: "Sequence[T]" + pk_key: Optional[str] + + def items(self) -> List[T]: + data: "Sequence[T]" + + if self.pk_key is None: + raise Exception("items() require a pk_key exists") + return [(getattr(item, self.pk_key), item) for item in self] + + def __eq__( + self, + other: object, + # other: Union[ + # "QueryList[T]", + # List[Mapping[str, str]], + # List[Mapping[str, int]], + # List[Mapping[str, Union[str, Mapping[str, Union[List[str], str]]]]], + # ], + ) -> bool: + data = other + + if not isinstance(self, list) or not isinstance(data, list): + return False + + if len(self) == len(data): + for (a, b) in zip(self, data): + if isinstance(a, Mapping): + a_keys = a.keys() + if a.keys == b.keys(): + for key in a_keys: + if abs(a[key] - b[key]) > 1: + return False + else: + if a != b: + return False + + return True + return False + + def filter( + self, matcher: Optional[Union[Callable[[T], bool], T]] = None, **kwargs: Any + ) -> "QueryList[T]": + def filter_lookup(obj: Any) -> bool: + for path, v in kwargs.items(): + try: + lhs, op = path.rsplit("__", 1) + + if op not in LOOKUP_NAME_MAP: + raise ValueError(f"{op} not in LOOKUP_NAME_MAP") + except ValueError: + lhs = path + op = "exact" + + assert op in LOOKUP_NAME_MAP + path = lhs + data = keygetter(obj, path) + + if data is None or not LOOKUP_NAME_MAP[op](data, v): + return False + + return True + + if callable(matcher): + _filter = matcher + elif matcher is not None: + + def val_match(obj: Union[str, List[Any]]) -> bool: + if isinstance(matcher, list): + return obj in matcher + else: + return obj == matcher + + _filter = val_match + else: + _filter = filter_lookup + + return self.__class__(k for k in self if _filter(k)) diff --git a/src/libtmux/_internal/subprocess.py b/src/libtmux/_internal/subprocess.py new file mode 100644 index 000000000..1059a478f --- /dev/null +++ b/src/libtmux/_internal/subprocess.py @@ -0,0 +1,587 @@ +"""Invokable :mod:`subprocess` wrapper. + +Defer running a subprocess, such as by handing to an executor. + +Note +---- +This is an internal API not covered by versioning policy. + +Examples +-------- + +- :class:`~SubprocessCommand`: Wraps :class:`subprocess.Popen` and + :func:`subprocess.run` in a :func:`~dataclasses.dataclass`. + + Before: + + >>> import subprocess + >>> subprocess.run( + ... ['echo', 'hi'], + ... capture_output=True, universal_newlines=True + ... ).stdout + 'hi\\n' + + With this: + + >>> cmd = SubprocessCommand(['echo', 'hi']) + >>> cmd.args + ['echo', 'hi'] + >>> cmd.run(capture_output=True, universal_newlines=True).stdout + 'hi\\n' + + Tweak params before invocation: + + >>> cmd = SubprocessCommand(['echo', 'hi']) + >>> cmd.args[1] = 'hello' + >>> cmd.args + ['echo', 'hello'] + >>> cmd.run(capture_output=True, universal_newlines=True).stdout + 'hello\\n' +""" +import dataclasses +import subprocess +import sys +from collections.abc import Mapping, Sequence +from typing import ( + IO, + TYPE_CHECKING, + Any, + Callable, + List, + Optional, + TypeVar, + Union, + overload, +) + +from typing_extensions import Literal, TypeAlias + +from .dataclasses import SkipDefaultFieldsReprMixin +from .types import StrOrBytesPath + +F = TypeVar("F", bound=Callable[..., Any]) + + +if sys.platform == "win32": + _ENV: TypeAlias = "Mapping[str, str]" +else: + _ENV: TypeAlias = Union[ + "Mapping[bytes, StrOrBytesPath]", "Mapping[str, StrOrBytesPath]" + ] +_FILE: TypeAlias = Union[None, int, IO[Any]] +_TXT: TypeAlias = Union[bytes, str] + +if TYPE_CHECKING: + #: Command + _CMD: TypeAlias = Union[StrOrBytesPath, "Sequence[StrOrBytesPath]"] + + +@dataclasses.dataclass(repr=False) +class SubprocessCommand(SkipDefaultFieldsReprMixin): + """Wraps a :mod:`subprocess` request. Inspect, mutate, control before invocation. + + Attributes + ---------- + args : _CMD + A string, or a sequence of program arguments. + + bufsize : int + supplied as the buffering argument to the open() function when creating the + stdin/stdout/stderr pipe file objects + + executable : Optional[StrOrBytesPath] + A replacement program to execute. + + stdin : _FILE + standard output for executed program + + stdout : + standard output for executed program + + stderr : + standard output for executed program + + close_fds : Controls closing or inheriting of file descriptors. + + shell : If true, the command will be executed through the shell. + + cwd : Sets the current directory before the child is executed. + + env : Defines the environment variables for the new process. + + text : + If ``True``, decode stdin, stdout and stderr using the given encoding (if set) + or the system default otherwise. + + universal_newlines : + Alias of text, provided for backwards compatibility. + + startupinfo : + Windows only + + creationflags : + Windows only + + preexec_fn : + (POSIX only) An object to be called in the child process just before the child + is executed. + + restore_signals : + POSIX only + + start_new_session : + POSIX only + + group : + POSIX only + + extra_groups : + POSIX only + + user : + POSIX only + + umask : + POSIX only + + pass_fds : + POSIX only + + encoding : + Text mode encoding to use for file objects stdin, stdout and stderr. + + errors : + Text mode error handling to use for file objects stdin, stdout and stderr. + + Examples + -------- + >>> cmd = SubprocessCommand("ls") + >>> cmd.args + 'ls' + + With ``shell=True``: + + >>> cmd = SubprocessCommand("ls -l", shell=True) + >>> cmd.shell + True + >>> cmd.args + 'ls -l' + >>> cmd.check_call() + 0 + """ + + args: "_CMD" + bufsize: int = -1 + executable: Optional[StrOrBytesPath] = None + stdin: _FILE = None + stdout: _FILE = None + stderr: _FILE = None + preexec_fn: Optional[Callable[[], Any]] = None + close_fds: bool = True + shell: bool = False + cwd: Optional[StrOrBytesPath] = None + env: Optional[_ENV] = None + + # Windows + creationflags: int = 0 + startupinfo: Optional[Any] = None + + # POSIX-only + restore_signals: bool = True + start_new_session: bool = False + pass_fds: Any = () + if sys.version_info >= (3, 9): + umask: int = -1 + if sys.version_info >= (3, 10): + pipesize: int = -1 + + if sys.version_info >= (3, 9): + user: Optional[str] = None + group: Optional[str] = None + extra_groups: Optional[List[str]] = None + + # Alias of text, for backwards compatibility + universal_newlines: Optional[bool] = None + text: Optional[Literal[True]] = None + + # Text mode encoding and error handling to use for file objects + # stdin, stdout, stderr + encoding: Optional[str] = None + errors: Optional[str] = None + + # user, group, extra_groups, umask were added in 3.9 + @overload + def Popen( + self, + args: Optional["_CMD"] = ..., + universal_newlines: bool = ..., + *, + text: Optional[bool] = ..., + encoding: str, + errors: Optional[str] = ..., + ) -> "subprocess.Popen[str]": + ... + + @overload + def Popen( + self, + args: Optional["_CMD"] = ..., + universal_newlines: bool = ..., + *, + text: Optional[bool] = ..., + encoding: Optional[str] = ..., + errors: str, + ) -> "subprocess.Popen[str]": + ... + + @overload + def Popen( + self, + args: Optional["_CMD"] = ..., + *, + universal_newlines: Literal[True], + # where the *real* keyword only args start + text: Optional[bool] = ..., + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + ) -> "subprocess.Popen[str]": + ... + + @overload + def Popen( + self, + args: Optional["_CMD"] = ..., + universal_newlines: bool = ..., + *, + text: Literal[True], + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + ) -> "subprocess.Popen[str]": + ... + + @overload + def Popen( + self, + args: Optional["_CMD"] = ..., + universal_newlines: Literal[False] = ..., + *, + text: Literal[None, False] = ..., + encoding: None = ..., + errors: None = ..., + ) -> "subprocess.Popen[bytes]": + ... + + def Popen( + self, + args: Optional["_CMD"] = None, + universal_newlines: Optional[bool] = None, + *, + text: Optional[bool] = None, + encoding: Optional[str] = None, + errors: Optional[str] = None, + **kwargs: Any, + ) -> "subprocess.Popen[Any]": + """Run commands :class:`subprocess.Popen`, optionally overrides via kwargs. + + Parameters + ---------- + **kwargs : dict, optional + Overrides existing attributes for :class:`subprocess.Popen` + + Examples + -------- + >>> cmd = SubprocessCommand(args=['echo', 'hello']) + >>> proc = cmd.Popen(stdout=subprocess.PIPE) + >>> proc.communicate() # doctest: +SKIP + """ + return subprocess.Popen( + **dataclasses.replace( + self, + args=args or self.args, + encoding=encoding, + errors=errors, + text=text, + universal_newlines=universal_newlines, + **kwargs, + ).__dict__, + ) + + def check_call(self, **kwargs: Any) -> int: + """Run command :func:`subprocess.check_call`, optionally overrides via kwargs. + + Parameters + ---------- + **kwargs : dict, optional + Overrides existing attributes for :func:`subprocess.check_call` + + Examples + -------- + >>> cmd = SubprocessCommand(args=['echo', 'hello']) + >>> cmd.check_call(stdout=subprocess.PIPE) + 0 + """ + return subprocess.check_call(**dataclasses.replace(self, **kwargs).__dict__) + + @overload + def check_output( + self, + universal_newlines: bool = ..., + *, + input: Optional[Union[str, bytes]] = ..., + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + text: Literal[True], + **kwargs: Any, + ) -> str: + ... + + @overload + def check_output( + self, + universal_newlines: Optional[bool] = ..., + *, + input: Optional[Union[str, bytes]] = ..., + encoding: str, + errors: Optional[str] = ..., + text: Optional[bool] = ..., + **kwargs: Any, + ) -> str: + ... + + @overload + def check_output( + self, + universal_newlines: bool = ..., + *, + input: Optional[Union[str, bytes]] = ..., + encoding: Optional[str] = ..., + errors: str, + text: Optional[bool] = ..., + **kwargs: Any, + ) -> str: + ... + + @overload + def check_output( + self, + universal_newlines: Literal[True] = ..., + *, + input: Optional[Union[str, bytes]] = ..., + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + text: Optional[bool] = ..., + **kwargs: Any, + ) -> str: + ... + + @overload + def check_output( + self, + universal_newlines: Literal[False], + *, + input: Optional[Union[str, bytes]] = ..., + encoding: None = ..., + errors: None = ..., + text: Literal[None, False] = ..., + **kwargs: Any, + ) -> bytes: + ... + + def check_output( + self, + universal_newlines: Optional[bool] = None, + *, + input: Optional[Union[str, bytes]] = None, + encoding: Optional[str] = None, + errors: Optional[str] = None, + text: Optional[bool] = None, + **kwargs: Any, + ) -> Union[bytes, str]: + r"""Run command :func:`subprocess.check_output`, optionally override via kwargs. + + Parameters + ---------- + input : Union[bytes, str], optional + pass string to subprocess's stdin. Bytes by default, str in text mode. + + Text mode is triggered by setting any of text, encoding, errors or + universal_newlines. + **kwargs : dict, optional + Overrides existing attributes for :func:`subprocess.check_output` + + Examples + -------- + >>> cmd = SubprocessCommand(args=['echo', 'hello']) + >>> proc = cmd.check_output(shell=True) + + Examples from :mod:`subprocess`: + + >>> import subprocess + >>> cmd = SubprocessCommand( + ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) + >>> cmd.check_output(stderr=subprocess.STDOUT) + b"ls: ...non_existent_file...: No such file or directory\n" + + >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) + >>> cmd.check_output(input=b"when in the course of fooman events\n") + b'when in the course of barman events\n' + """ + params = dataclasses.replace(self, **kwargs).__dict__ + params.pop("stdout") + output = subprocess.check_output(input=input, **params) + if isinstance(output, (bytes, str)): + return output + raise Exception(f"output is not str or bytes: {output}") + + @overload + def run( + self, + universal_newlines: bool = ..., + *, + capture_output: bool = ..., + check: bool = ..., + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + input: Optional[str] = ..., + text: Literal[True], + ) -> "subprocess.CompletedProcess[str]": + ... + + @overload + def run( + self, + universal_newlines: bool = ..., + *, + capture_output: bool = ..., + check: bool = ..., + encoding: str, + errors: Optional[str] = ..., + input: Optional[str] = ..., + text: Optional[bool] = ..., + ) -> "subprocess.CompletedProcess[str]": + ... + + @overload + def run( + self, + universal_newlines: bool = ..., + *, + capture_output: bool = ..., + check: bool = ..., + encoding: Optional[str] = ..., + errors: str, + input: Optional[str] = ..., + text: Optional[bool] = ..., + ) -> "subprocess.CompletedProcess[str]": + ... + + @overload + def run( + self, + *, + universal_newlines: Literal[True], + # where the *real* keyword only args start + capture_output: bool = ..., + check: bool = ..., + encoding: Optional[str] = ..., + errors: Optional[str] = ..., + input: Optional[str] = ..., + text: Optional[bool] = ..., + ) -> "subprocess.CompletedProcess[str]": + ... + + @overload + def run( + self, + universal_newlines: Literal[False] = ..., + *, + capture_output: bool = ..., + check: bool = ..., + encoding: None = ..., + errors: None = ..., + input: Optional[bytes] = ..., + text: Literal[None, False] = ..., + ) -> "subprocess.CompletedProcess[bytes]": + ... + + def run( + self, + universal_newlines: Optional[bool] = None, + *, + capture_output: bool = False, + check: bool = False, + encoding: Optional[str] = None, + errors: Optional[str] = None, + input: Optional[Union[str, bytes]] = None, + text: Optional[bool] = None, + timeout: Optional[float] = None, + **kwargs: Any, + ) -> "subprocess.CompletedProcess[Any]": + r"""Run command in :func:`subprocess.run`, optionally overrides via kwargs. + + Parameters + ---------- + input : Union[bytes, str], optional + pass string to subprocess's stdin. Bytes by default, str in text mode. + + Text mode is triggered by setting any of text, encoding, errors or + universal_newlines. + + check : bool + If True and the exit code was non-zero, it raises a + :exc:`subprocess.CalledProcessError`. The CalledProcessError object will + have the return code in the returncode attribute, and output & stderr + attributes if those streams were captured. + + timeout : int + If given, and the process takes too long, a :exc:`subprocess.TimeoutExpired` + + **kwargs : dict, optional + Overrides existing attributes for :func:`subprocess.run` + + Examples + -------- + >>> import subprocess + >>> cmd = SubprocessCommand( + ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) + >>> cmd.run() + CompletedProcess(args=['/bin/sh', '-c', 'ls -l non_existent_file ; exit 0'], + returncode=0) + + >>> import subprocess + >>> cmd = SubprocessCommand( + ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) + >>> cmd.run(check=True) + CompletedProcess(args=['/bin/sh', '-c', 'ls -l non_existent_file ; exit 0'], + returncode=0) + + >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) + >>> completed = cmd.run(input=b"when in the course of fooman events\n") + >>> completed + CompletedProcess(args=['sed', '-e', 's/foo/bar/'], returncode=0) + >>> completed.stderr + + >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) + >>> completed = cmd.run(input=b"when in the course of fooman events\n", + ... capture_output=True) + >>> completed + CompletedProcess(args=['sed', '-e', 's/foo/bar/'], returncode=0, + stdout=b'when in the course of barman events\n', stderr=b'') + >>> completed.stdout + b'when in the course of barman events\n' + >>> completed.stderr + b'' + """ + return subprocess.run( + **dataclasses.replace( + self, + universal_newlines=universal_newlines, + errors=errors, + text=text, + **kwargs, + ).__dict__, + check=check, + capture_output=capture_output, + input=input, + timeout=timeout, + ) diff --git a/src/libtmux/_internal/types.py b/src/libtmux/_internal/types.py new file mode 100644 index 000000000..b092268a7 --- /dev/null +++ b/src/libtmux/_internal/types.py @@ -0,0 +1,22 @@ +"""Internal :term:`type annotations ` + +Notes +----- + +:class:`StrPath` and :class:`StrOrBytesPath` is based on `typeshed's`_. + +.. _typeshed's: https://github.com/python/typeshed/blob/5df8de7/stdlib/_typeshed/__init__.pyi#L115-L118 +""" # NOQA E501 +from os import PathLike +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +StrPath: "TypeAlias" = Union[str, "PathLike[str]"] # stable +""":class:`os.PathLike` or :class:`str`""" + +StrOrBytesPath: "TypeAlias" = Union[ + str, bytes, "PathLike[str]", "PathLike[bytes]" +] # stable +""":class:`os.PathLike`, :class:`str` or :term:`bytes-like object`""" From c62c82ef415872c37372a103dcecf923730f70ea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 26 Dec 2022 16:11:19 -0600 Subject: [PATCH 2/9] chore: Remove subprocess (unused, keeping for types) --- src/libtmux/_internal/subprocess.py | 587 ---------------------------- 1 file changed, 587 deletions(-) delete mode 100644 src/libtmux/_internal/subprocess.py diff --git a/src/libtmux/_internal/subprocess.py b/src/libtmux/_internal/subprocess.py deleted file mode 100644 index 1059a478f..000000000 --- a/src/libtmux/_internal/subprocess.py +++ /dev/null @@ -1,587 +0,0 @@ -"""Invokable :mod:`subprocess` wrapper. - -Defer running a subprocess, such as by handing to an executor. - -Note ----- -This is an internal API not covered by versioning policy. - -Examples --------- - -- :class:`~SubprocessCommand`: Wraps :class:`subprocess.Popen` and - :func:`subprocess.run` in a :func:`~dataclasses.dataclass`. - - Before: - - >>> import subprocess - >>> subprocess.run( - ... ['echo', 'hi'], - ... capture_output=True, universal_newlines=True - ... ).stdout - 'hi\\n' - - With this: - - >>> cmd = SubprocessCommand(['echo', 'hi']) - >>> cmd.args - ['echo', 'hi'] - >>> cmd.run(capture_output=True, universal_newlines=True).stdout - 'hi\\n' - - Tweak params before invocation: - - >>> cmd = SubprocessCommand(['echo', 'hi']) - >>> cmd.args[1] = 'hello' - >>> cmd.args - ['echo', 'hello'] - >>> cmd.run(capture_output=True, universal_newlines=True).stdout - 'hello\\n' -""" -import dataclasses -import subprocess -import sys -from collections.abc import Mapping, Sequence -from typing import ( - IO, - TYPE_CHECKING, - Any, - Callable, - List, - Optional, - TypeVar, - Union, - overload, -) - -from typing_extensions import Literal, TypeAlias - -from .dataclasses import SkipDefaultFieldsReprMixin -from .types import StrOrBytesPath - -F = TypeVar("F", bound=Callable[..., Any]) - - -if sys.platform == "win32": - _ENV: TypeAlias = "Mapping[str, str]" -else: - _ENV: TypeAlias = Union[ - "Mapping[bytes, StrOrBytesPath]", "Mapping[str, StrOrBytesPath]" - ] -_FILE: TypeAlias = Union[None, int, IO[Any]] -_TXT: TypeAlias = Union[bytes, str] - -if TYPE_CHECKING: - #: Command - _CMD: TypeAlias = Union[StrOrBytesPath, "Sequence[StrOrBytesPath]"] - - -@dataclasses.dataclass(repr=False) -class SubprocessCommand(SkipDefaultFieldsReprMixin): - """Wraps a :mod:`subprocess` request. Inspect, mutate, control before invocation. - - Attributes - ---------- - args : _CMD - A string, or a sequence of program arguments. - - bufsize : int - supplied as the buffering argument to the open() function when creating the - stdin/stdout/stderr pipe file objects - - executable : Optional[StrOrBytesPath] - A replacement program to execute. - - stdin : _FILE - standard output for executed program - - stdout : - standard output for executed program - - stderr : - standard output for executed program - - close_fds : Controls closing or inheriting of file descriptors. - - shell : If true, the command will be executed through the shell. - - cwd : Sets the current directory before the child is executed. - - env : Defines the environment variables for the new process. - - text : - If ``True``, decode stdin, stdout and stderr using the given encoding (if set) - or the system default otherwise. - - universal_newlines : - Alias of text, provided for backwards compatibility. - - startupinfo : - Windows only - - creationflags : - Windows only - - preexec_fn : - (POSIX only) An object to be called in the child process just before the child - is executed. - - restore_signals : - POSIX only - - start_new_session : - POSIX only - - group : - POSIX only - - extra_groups : - POSIX only - - user : - POSIX only - - umask : - POSIX only - - pass_fds : - POSIX only - - encoding : - Text mode encoding to use for file objects stdin, stdout and stderr. - - errors : - Text mode error handling to use for file objects stdin, stdout and stderr. - - Examples - -------- - >>> cmd = SubprocessCommand("ls") - >>> cmd.args - 'ls' - - With ``shell=True``: - - >>> cmd = SubprocessCommand("ls -l", shell=True) - >>> cmd.shell - True - >>> cmd.args - 'ls -l' - >>> cmd.check_call() - 0 - """ - - args: "_CMD" - bufsize: int = -1 - executable: Optional[StrOrBytesPath] = None - stdin: _FILE = None - stdout: _FILE = None - stderr: _FILE = None - preexec_fn: Optional[Callable[[], Any]] = None - close_fds: bool = True - shell: bool = False - cwd: Optional[StrOrBytesPath] = None - env: Optional[_ENV] = None - - # Windows - creationflags: int = 0 - startupinfo: Optional[Any] = None - - # POSIX-only - restore_signals: bool = True - start_new_session: bool = False - pass_fds: Any = () - if sys.version_info >= (3, 9): - umask: int = -1 - if sys.version_info >= (3, 10): - pipesize: int = -1 - - if sys.version_info >= (3, 9): - user: Optional[str] = None - group: Optional[str] = None - extra_groups: Optional[List[str]] = None - - # Alias of text, for backwards compatibility - universal_newlines: Optional[bool] = None - text: Optional[Literal[True]] = None - - # Text mode encoding and error handling to use for file objects - # stdin, stdout, stderr - encoding: Optional[str] = None - errors: Optional[str] = None - - # user, group, extra_groups, umask were added in 3.9 - @overload - def Popen( - self, - args: Optional["_CMD"] = ..., - universal_newlines: bool = ..., - *, - text: Optional[bool] = ..., - encoding: str, - errors: Optional[str] = ..., - ) -> "subprocess.Popen[str]": - ... - - @overload - def Popen( - self, - args: Optional["_CMD"] = ..., - universal_newlines: bool = ..., - *, - text: Optional[bool] = ..., - encoding: Optional[str] = ..., - errors: str, - ) -> "subprocess.Popen[str]": - ... - - @overload - def Popen( - self, - args: Optional["_CMD"] = ..., - *, - universal_newlines: Literal[True], - # where the *real* keyword only args start - text: Optional[bool] = ..., - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - ) -> "subprocess.Popen[str]": - ... - - @overload - def Popen( - self, - args: Optional["_CMD"] = ..., - universal_newlines: bool = ..., - *, - text: Literal[True], - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - ) -> "subprocess.Popen[str]": - ... - - @overload - def Popen( - self, - args: Optional["_CMD"] = ..., - universal_newlines: Literal[False] = ..., - *, - text: Literal[None, False] = ..., - encoding: None = ..., - errors: None = ..., - ) -> "subprocess.Popen[bytes]": - ... - - def Popen( - self, - args: Optional["_CMD"] = None, - universal_newlines: Optional[bool] = None, - *, - text: Optional[bool] = None, - encoding: Optional[str] = None, - errors: Optional[str] = None, - **kwargs: Any, - ) -> "subprocess.Popen[Any]": - """Run commands :class:`subprocess.Popen`, optionally overrides via kwargs. - - Parameters - ---------- - **kwargs : dict, optional - Overrides existing attributes for :class:`subprocess.Popen` - - Examples - -------- - >>> cmd = SubprocessCommand(args=['echo', 'hello']) - >>> proc = cmd.Popen(stdout=subprocess.PIPE) - >>> proc.communicate() # doctest: +SKIP - """ - return subprocess.Popen( - **dataclasses.replace( - self, - args=args or self.args, - encoding=encoding, - errors=errors, - text=text, - universal_newlines=universal_newlines, - **kwargs, - ).__dict__, - ) - - def check_call(self, **kwargs: Any) -> int: - """Run command :func:`subprocess.check_call`, optionally overrides via kwargs. - - Parameters - ---------- - **kwargs : dict, optional - Overrides existing attributes for :func:`subprocess.check_call` - - Examples - -------- - >>> cmd = SubprocessCommand(args=['echo', 'hello']) - >>> cmd.check_call(stdout=subprocess.PIPE) - 0 - """ - return subprocess.check_call(**dataclasses.replace(self, **kwargs).__dict__) - - @overload - def check_output( - self, - universal_newlines: bool = ..., - *, - input: Optional[Union[str, bytes]] = ..., - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - text: Literal[True], - **kwargs: Any, - ) -> str: - ... - - @overload - def check_output( - self, - universal_newlines: Optional[bool] = ..., - *, - input: Optional[Union[str, bytes]] = ..., - encoding: str, - errors: Optional[str] = ..., - text: Optional[bool] = ..., - **kwargs: Any, - ) -> str: - ... - - @overload - def check_output( - self, - universal_newlines: bool = ..., - *, - input: Optional[Union[str, bytes]] = ..., - encoding: Optional[str] = ..., - errors: str, - text: Optional[bool] = ..., - **kwargs: Any, - ) -> str: - ... - - @overload - def check_output( - self, - universal_newlines: Literal[True] = ..., - *, - input: Optional[Union[str, bytes]] = ..., - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - text: Optional[bool] = ..., - **kwargs: Any, - ) -> str: - ... - - @overload - def check_output( - self, - universal_newlines: Literal[False], - *, - input: Optional[Union[str, bytes]] = ..., - encoding: None = ..., - errors: None = ..., - text: Literal[None, False] = ..., - **kwargs: Any, - ) -> bytes: - ... - - def check_output( - self, - universal_newlines: Optional[bool] = None, - *, - input: Optional[Union[str, bytes]] = None, - encoding: Optional[str] = None, - errors: Optional[str] = None, - text: Optional[bool] = None, - **kwargs: Any, - ) -> Union[bytes, str]: - r"""Run command :func:`subprocess.check_output`, optionally override via kwargs. - - Parameters - ---------- - input : Union[bytes, str], optional - pass string to subprocess's stdin. Bytes by default, str in text mode. - - Text mode is triggered by setting any of text, encoding, errors or - universal_newlines. - **kwargs : dict, optional - Overrides existing attributes for :func:`subprocess.check_output` - - Examples - -------- - >>> cmd = SubprocessCommand(args=['echo', 'hello']) - >>> proc = cmd.check_output(shell=True) - - Examples from :mod:`subprocess`: - - >>> import subprocess - >>> cmd = SubprocessCommand( - ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) - >>> cmd.check_output(stderr=subprocess.STDOUT) - b"ls: ...non_existent_file...: No such file or directory\n" - - >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) - >>> cmd.check_output(input=b"when in the course of fooman events\n") - b'when in the course of barman events\n' - """ - params = dataclasses.replace(self, **kwargs).__dict__ - params.pop("stdout") - output = subprocess.check_output(input=input, **params) - if isinstance(output, (bytes, str)): - return output - raise Exception(f"output is not str or bytes: {output}") - - @overload - def run( - self, - universal_newlines: bool = ..., - *, - capture_output: bool = ..., - check: bool = ..., - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - input: Optional[str] = ..., - text: Literal[True], - ) -> "subprocess.CompletedProcess[str]": - ... - - @overload - def run( - self, - universal_newlines: bool = ..., - *, - capture_output: bool = ..., - check: bool = ..., - encoding: str, - errors: Optional[str] = ..., - input: Optional[str] = ..., - text: Optional[bool] = ..., - ) -> "subprocess.CompletedProcess[str]": - ... - - @overload - def run( - self, - universal_newlines: bool = ..., - *, - capture_output: bool = ..., - check: bool = ..., - encoding: Optional[str] = ..., - errors: str, - input: Optional[str] = ..., - text: Optional[bool] = ..., - ) -> "subprocess.CompletedProcess[str]": - ... - - @overload - def run( - self, - *, - universal_newlines: Literal[True], - # where the *real* keyword only args start - capture_output: bool = ..., - check: bool = ..., - encoding: Optional[str] = ..., - errors: Optional[str] = ..., - input: Optional[str] = ..., - text: Optional[bool] = ..., - ) -> "subprocess.CompletedProcess[str]": - ... - - @overload - def run( - self, - universal_newlines: Literal[False] = ..., - *, - capture_output: bool = ..., - check: bool = ..., - encoding: None = ..., - errors: None = ..., - input: Optional[bytes] = ..., - text: Literal[None, False] = ..., - ) -> "subprocess.CompletedProcess[bytes]": - ... - - def run( - self, - universal_newlines: Optional[bool] = None, - *, - capture_output: bool = False, - check: bool = False, - encoding: Optional[str] = None, - errors: Optional[str] = None, - input: Optional[Union[str, bytes]] = None, - text: Optional[bool] = None, - timeout: Optional[float] = None, - **kwargs: Any, - ) -> "subprocess.CompletedProcess[Any]": - r"""Run command in :func:`subprocess.run`, optionally overrides via kwargs. - - Parameters - ---------- - input : Union[bytes, str], optional - pass string to subprocess's stdin. Bytes by default, str in text mode. - - Text mode is triggered by setting any of text, encoding, errors or - universal_newlines. - - check : bool - If True and the exit code was non-zero, it raises a - :exc:`subprocess.CalledProcessError`. The CalledProcessError object will - have the return code in the returncode attribute, and output & stderr - attributes if those streams were captured. - - timeout : int - If given, and the process takes too long, a :exc:`subprocess.TimeoutExpired` - - **kwargs : dict, optional - Overrides existing attributes for :func:`subprocess.run` - - Examples - -------- - >>> import subprocess - >>> cmd = SubprocessCommand( - ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) - >>> cmd.run() - CompletedProcess(args=['/bin/sh', '-c', 'ls -l non_existent_file ; exit 0'], - returncode=0) - - >>> import subprocess - >>> cmd = SubprocessCommand( - ... ["/bin/sh", "-c", "ls -l non_existent_file ; exit 0"]) - >>> cmd.run(check=True) - CompletedProcess(args=['/bin/sh', '-c', 'ls -l non_existent_file ; exit 0'], - returncode=0) - - >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) - >>> completed = cmd.run(input=b"when in the course of fooman events\n") - >>> completed - CompletedProcess(args=['sed', '-e', 's/foo/bar/'], returncode=0) - >>> completed.stderr - - >>> cmd = SubprocessCommand(["sed", "-e", "s/foo/bar/"]) - >>> completed = cmd.run(input=b"when in the course of fooman events\n", - ... capture_output=True) - >>> completed - CompletedProcess(args=['sed', '-e', 's/foo/bar/'], returncode=0, - stdout=b'when in the course of barman events\n', stderr=b'') - >>> completed.stdout - b'when in the course of barman events\n' - >>> completed.stderr - b'' - """ - return subprocess.run( - **dataclasses.replace( - self, - universal_newlines=universal_newlines, - errors=errors, - text=text, - **kwargs, - ).__dict__, - check=check, - capture_output=capture_output, - input=input, - timeout=timeout, - ) From 91b59ddb60580c1f28c98d9b706b3f111d9aab8e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 26 Dec 2022 16:16:11 -0600 Subject: [PATCH 3/9] chore(internals): Remove types module for now, keep for adjustments These were retrofitting to support Python 3.7, so keep in git history in case we ever bring it back. --- src/libtmux/_internal/types.py | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 src/libtmux/_internal/types.py diff --git a/src/libtmux/_internal/types.py b/src/libtmux/_internal/types.py deleted file mode 100644 index b092268a7..000000000 --- a/src/libtmux/_internal/types.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Internal :term:`type annotations ` - -Notes ------ - -:class:`StrPath` and :class:`StrOrBytesPath` is based on `typeshed's`_. - -.. _typeshed's: https://github.com/python/typeshed/blob/5df8de7/stdlib/_typeshed/__init__.pyi#L115-L118 -""" # NOQA E501 -from os import PathLike -from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: - from typing_extensions import TypeAlias - -StrPath: "TypeAlias" = Union[str, "PathLike[str]"] # stable -""":class:`os.PathLike` or :class:`str`""" - -StrOrBytesPath: "TypeAlias" = Union[ - str, bytes, "PathLike[str]", "PathLike[bytes]" -] # stable -""":class:`os.PathLike`, :class:`str` or :term:`bytes-like object`""" From 4cc7849c8e688f7f291f1bc6f11d81e4fed327ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 26 Dec 2022 13:02:56 -0600 Subject: [PATCH 4/9] feat(QueryList): Add .get() --- src/libtmux/_internal/query_list.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 70af21f0b..05354cd57 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -27,6 +27,8 @@ def __call__( T = TypeVar("T", Any, Any) +no_arg = object() + def keygetter( obj: "Mapping[str, Any]", @@ -347,3 +349,18 @@ def val_match(obj: Union[str, List[Any]]) -> bool: _filter = filter_lookup return self.__class__(k for k in self if _filter(k)) + + def get( + self, + matcher: Optional[Union[Callable[[T], bool], T]] = None, + default: Optional[Any] = no_arg, + **kwargs: Any, + ) -> Optional[T]: + objs = self.filter(matcher=matcher, **kwargs) + if len(objs) > 1: + raise Exception("Multiple objects returned") + elif len(objs) == 0: + if default == no_arg: + raise Exception("No objects found") + return default + return objs[0] From f536105df6c6066f9f20fda2381453ca85bece8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Dec 2022 21:22:17 -0600 Subject: [PATCH 5/9] Add tests for legacy API --- tests/legacy_api/__init__.py | 0 tests/legacy_api/test_common.py | 212 ++++++++++++++++ tests/legacy_api/test_pane.py | 88 +++++++ tests/legacy_api/test_server.py | 158 ++++++++++++ tests/legacy_api/test_session.py | 314 ++++++++++++++++++++++++ tests/legacy_api/test_test.py | 88 +++++++ tests/legacy_api/test_tmuxobject.py | 191 +++++++++++++++ tests/legacy_api/test_version.py | 70 ++++++ tests/legacy_api/test_window.py | 367 ++++++++++++++++++++++++++++ 9 files changed, 1488 insertions(+) create mode 100644 tests/legacy_api/__init__.py create mode 100644 tests/legacy_api/test_common.py create mode 100644 tests/legacy_api/test_pane.py create mode 100644 tests/legacy_api/test_server.py create mode 100644 tests/legacy_api/test_session.py create mode 100644 tests/legacy_api/test_test.py create mode 100644 tests/legacy_api/test_tmuxobject.py create mode 100644 tests/legacy_api/test_version.py create mode 100644 tests/legacy_api/test_window.py diff --git a/tests/legacy_api/__init__.py b/tests/legacy_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py new file mode 100644 index 000000000..da9bcdeea --- /dev/null +++ b/tests/legacy_api/test_common.py @@ -0,0 +1,212 @@ +"""Tests for utility functions in libtmux.""" + +import re +import sys +import typing as t +from typing import Optional + +import pytest + +import libtmux +from libtmux._compat import LooseVersion +from libtmux.common import ( + TMUX_MAX_VERSION, + TMUX_MIN_VERSION, + get_libtmux_version, + get_version, + has_gt_version, + has_gte_version, + has_lt_version, + has_lte_version, + has_minimum_version, + has_version, + session_check_name, + tmux_cmd, +) +from libtmux.exc import BadSessionName, LibTmuxException, TmuxCommandNotFound +from libtmux.session import Session + +version_regex = re.compile(r"([0-9]\.[0-9])|(master)") + + +def test_allows_master_version(monkeypatch: pytest.MonkeyPatch) -> None: + class Hi: + stdout = ["tmux master"] + stderr = None + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: + return Hi() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + + assert has_minimum_version() + assert has_gte_version(TMUX_MIN_VERSION) + assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" + assert ( + "%s-master" % TMUX_MAX_VERSION == get_version() + ), "Is the latest supported version with -master appended" + + +def test_allows_next_version(monkeypatch: pytest.MonkeyPatch) -> None: + TMUX_NEXT_VERSION = str(float(TMUX_MAX_VERSION) + 0.1) + + class Hi: + stdout = [f"tmux next-{TMUX_NEXT_VERSION}"] + stderr = None + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: + return Hi() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + + assert has_minimum_version() + assert has_gte_version(TMUX_MIN_VERSION) + assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" + assert TMUX_NEXT_VERSION == get_version() + + +def test_get_version_openbsd(monkeypatch: pytest.MonkeyPatch) -> None: + class Hi: + stderr = ["tmux: unknown option -- V"] + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: + return Hi() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + monkeypatch.setattr(sys, "platform", "openbsd 5.2") + assert has_minimum_version() + assert has_gte_version(TMUX_MIN_VERSION) + assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" + assert ( + "%s-openbsd" % TMUX_MAX_VERSION == get_version() + ), "Is the latest supported version with -openbsd appended" + + +def test_get_version_too_low(monkeypatch: pytest.MonkeyPatch) -> None: + class Hi: + stderr = ["tmux: unknown option -- V"] + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: + return Hi() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + with pytest.raises(LibTmuxException) as exc_info: + get_version() + exc_info.match("is running tmux 1.3 or earlier") + + +def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: + """Ignore letters such as 1.8b. + + See ticket https://github.com/tmux-python/tmuxp/issues/55. + + In version 0.1.7 this is adjusted to use LooseVersion, in order to + allow letters. + + """ + monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.9a") + result = has_minimum_version() + assert result + + monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.8a") + result = has_minimum_version() + assert result + + # Should not throw + assert type(has_version("1.8")) is bool + assert type(has_version("1.8a")) is bool + assert type(has_version("1.9a")) is bool + + +def test_error_version_less_1_7(monkeypatch: pytest.MonkeyPatch) -> None: + def mock_get_version() -> LooseVersion: + return LooseVersion("1.7") + + monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) + with pytest.raises(LibTmuxException) as excinfo: + has_minimum_version() + excinfo.match(r"libtmux only supports") + + with pytest.raises(LibTmuxException) as excinfo: + has_minimum_version() + + excinfo.match(r"libtmux only supports") + + +def test_has_version() -> None: + assert has_version(str(get_version())) + + +def test_has_gt_version() -> None: + assert has_gt_version("1.6") + assert has_gt_version("1.6b") + + assert not has_gt_version("4.0") + assert not has_gt_version("4.0b") + + +def test_has_gte_version() -> None: + assert has_gte_version("1.6") + assert has_gte_version("1.6b") + assert has_gte_version(str(get_version())) + + assert not has_gte_version("4.0") + assert not has_gte_version("4.0b") + + +def test_has_lt_version() -> None: + assert has_lt_version("4.0a") + assert has_lt_version("4.0") + + assert not has_lt_version("1.7") + assert not has_lt_version(str(get_version())) + + +def test_has_lte_version() -> None: + assert has_lte_version("4.0a") + assert has_lte_version("4.0") + assert has_lte_version(str(get_version())) + + assert not has_lte_version("1.7") + assert not has_lte_version("1.7b") + + +def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", "") + with pytest.raises(TmuxCommandNotFound): + tmux_cmd("-V") + + +def test_tmux_cmd_unicode(session: Session) -> None: + session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") + + +@pytest.mark.parametrize( + "session_name,raises,exc_msg_regex", + [ + ("", True, "may not be empty"), + (None, True, "may not be empty"), + ("my great session.", True, "may not contain periods"), + ("name: great session", True, "may not contain colons"), + ("new great session", False, None), + ("ajf8a3fa83fads,,,a", False, None), + ], +) +def test_session_check_name( + session_name: Optional[str], raises: bool, exc_msg_regex: Optional[str] +) -> None: + if raises: + with pytest.raises(BadSessionName) as exc_info: + session_check_name(session_name) + if exc_msg_regex is not None: + assert exc_info.match(exc_msg_regex) + else: + session_check_name(session_name) + + +def test_get_libtmux_version() -> None: + from libtmux.__about__ import __version__ + + version = get_libtmux_version() + assert isinstance(version, LooseVersion) + assert LooseVersion(__version__) == version diff --git a/tests/legacy_api/test_pane.py b/tests/legacy_api/test_pane.py new file mode 100644 index 000000000..c9c658f6e --- /dev/null +++ b/tests/legacy_api/test_pane.py @@ -0,0 +1,88 @@ +"""Tests for libtmux Pane object.""" +import logging +import shutil + +from libtmux.session import Session + +logger = logging.getLogger(__name__) + + +def test_resize_pane(session: Session) -> None: + """Test Pane.resize_pane().""" + + window = session.attached_window + window.rename_window("test_resize_pane") + + pane1 = window.attached_pane + assert pane1 is not None + pane1_height = pane1["pane_height"] + window.split_window() + + pane1.resize_pane(height=4) + assert pane1["pane_height"] != pane1_height + assert int(pane1["pane_height"]) == 4 + + pane1.resize_pane(height=3) + assert int(pane1["pane_height"]) == 3 + + +def test_send_keys(session: Session) -> None: + pane = session.attached_window.attached_pane + assert pane is not None + pane.send_keys("c-c", literal=True) + + pane_contents = "\n".join(pane.cmd("capture-pane", "-p").stdout) + assert "c-c" in pane_contents + + pane.send_keys("c-a", literal=False) + assert "c-a" not in pane_contents, "should not print to pane" + + +def test_set_height(session: Session) -> None: + window = session.new_window(window_name="test_set_height") + window.split_window() + pane1 = window.attached_pane + assert pane1 is not None + pane1_height = pane1["pane_height"] + + pane1.set_height(4) + assert pane1["pane_height"] != pane1_height + assert int(pane1["pane_height"]) == 4 + + +def test_set_width(session: Session) -> None: + window = session.new_window(window_name="test_set_width") + window.split_window() + + window.select_layout("main-vertical") + pane1 = window.attached_pane + assert pane1 is not None + pane1_width = pane1["pane_width"] + + pane1.set_width(10) + assert pane1["pane_width"] != pane1_width + assert int(pane1["pane_width"]) == 10 + + pane1.reset() + + +def test_capture_pane(session: Session) -> None: + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="capture_pane", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.attached_window.attached_pane + assert pane is not None + pane_contents = "\n".join(pane.capture_pane()) + assert pane_contents == "$" + pane.send_keys( + r'printf "\n%s\n" "Hello World !"', literal=True, suppress_history=False + ) + pane_contents = "\n".join(pane.capture_pane()) + assert pane_contents == r'$ printf "\n%s\n" "Hello World !"{}'.format( + "\n\nHello World !\n$" + ) diff --git a/tests/legacy_api/test_server.py b/tests/legacy_api/test_server.py new file mode 100644 index 000000000..d7830fd0b --- /dev/null +++ b/tests/legacy_api/test_server.py @@ -0,0 +1,158 @@ +"""Test for libtmux Server object.""" +import logging + +import pytest + +from libtmux.common import has_gte_version +from libtmux.server import Server +from libtmux.session import Session + +logger = logging.getLogger(__name__) + + +def test_has_session(server: Server, session: Session) -> None: + session_name = session.get("session_name") + assert session_name is not None + assert server.has_session(session_name) + assert not server.has_session("asdf2314324321") + + +def test_socket_name(server: Server) -> None: + """``-L`` socket_name. + + ``-L`` socket_name file name of socket. which will be stored in + env TMUX_TMPDIR or /tmp if unset.) + + """ + myserver = Server(socket_name="test") + + assert myserver.socket_name == "test" + + +def test_socket_path(server: Server) -> None: + """``-S`` socket_path (alternative path for server socket).""" + myserver = Server(socket_path="test") + + assert myserver.socket_path == "test" + + +def test_config(server: Server) -> None: + """``-f`` file for tmux(1) configuration.""" + myserver = Server(config_file="test") + assert myserver.config_file == "test" + + +def test_256_colors(server: Server) -> None: + myserver = Server(colors=256) + assert myserver.colors == 256 + + proc = myserver.cmd("list-sessions") + + assert "-2" in proc.cmd + assert "-8" not in proc.cmd + + +def test_88_colors(server: Server) -> None: + myserver = Server(colors=88) + assert myserver.colors == 88 + + proc = myserver.cmd("list-sessions") + + assert "-8" in proc.cmd + assert "-2" not in proc.cmd + + +def test_show_environment(server: Server) -> None: + """Server.show_environment() returns dict.""" + _vars = server.show_environment() + assert isinstance(_vars, dict) + + +def test_getenv(server: Server, session: Session) -> None: + """Set environment then Server.show_environment(key).""" + server.set_environment("FOO", "BAR") + assert "BAR" == server.getenv("FOO") + + server.set_environment("FOO", "DAR") + assert "DAR" == server.getenv("FOO") + + assert "DAR" == server.show_environment()["FOO"] + + +def test_show_environment_not_set(server: Server) -> None: + """Unset environment variable returns None.""" + assert server.getenv("BAR") is None + + +def test_new_session(server: Server) -> None: + """Server.new_session creates and returns valid session""" + mysession = server.new_session("test_new_session") + assert mysession.get("session_name") == "test_new_session" + assert server.has_session("test_new_session") + + +def test_new_session_no_name(server: Server) -> None: + """Server.new_session works with no name""" + first_session = server.new_session() + first_session_name = first_session.get("session_name") + assert first_session_name is not None + assert server.has_session(first_session_name) + + expected_session_name = str(int(first_session_name) + 1) + + # When a new session is created, it should enumerate + second_session = server.new_session() + second_session_name = second_session.get("session_name") + assert expected_session_name == second_session_name + assert second_session_name is not None + assert server.has_session(second_session_name) + + +def test_new_session_shell(server: Server) -> None: + """Server.new_session creates and returns valid session running with + specified command""" + cmd = "sleep 1m" + mysession = server.new_session("test_new_session", window_command=cmd) + window = mysession.list_windows()[0] + pane = window.list_panes()[0] + assert mysession.get("session_name") == "test_new_session" + assert server.has_session("test_new_session") + + pane_start_command = pane.get("pane_start_command") + assert pane_start_command is not None + + if has_gte_version("3.2"): + assert pane_start_command.replace('"', "") == cmd + else: + assert pane_start_command == cmd + + +def test_no_server_sessions() -> None: + server = Server(socket_name="test_attached_session_no_server") + assert server.sessions == [] + + +def test_no_server_attached_sessions() -> None: + server = Server(socket_name="test_no_server_attached_sessions") + assert server.attached_sessions == [] + + +def test_no_server_is_alive() -> None: + dead_server = Server(socket_name="test_no_server_is_alive") + assert not dead_server.is_alive() + + +def test_with_server_is_alive(server: Server) -> None: + server.new_session() + assert server.is_alive() + + +def test_no_server_raise_if_dead() -> None: + dead_server = Server(socket_name="test_attached_session_no_server") + with pytest.raises(Exception): + dead_server.raise_if_dead() + + +def test_with_server_raise_if_dead(server: Server) -> None: + server.new_session() + server.raise_if_dead() diff --git a/tests/legacy_api/test_session.py b/tests/legacy_api/test_session.py new file mode 100644 index 000000000..b3db613cb --- /dev/null +++ b/tests/legacy_api/test_session.py @@ -0,0 +1,314 @@ +"""Test for libtmux Session object.""" +import logging +import shutil +import typing as t + +import pytest + +from libtmux import exc +from libtmux.common import has_gte_version, has_lt_version +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.test import TEST_SESSION_PREFIX, namer +from libtmux.window import Window + +logger = logging.getLogger(__name__) + + +def test_has_session(server: Server, session: Session) -> None: + """Server.has_session returns True if has session_name exists.""" + TEST_SESSION_NAME = session.get("session_name") + assert TEST_SESSION_NAME is not None + assert server.has_session(TEST_SESSION_NAME) + if has_gte_version("2.1"): + assert not server.has_session(TEST_SESSION_NAME[:-2]) + assert server.has_session(TEST_SESSION_NAME[:-2], exact=False) + assert not server.has_session("asdf2314324321") + + +def test_select_window(session: Session) -> None: + """Session.select_window moves window.""" + # get the current window_base_index, since different user tmux config + # may start at 0 or 1, or whatever they want. + window_idx = session.attached_window.get("window_index") + assert window_idx is not None + window_base_index = int(window_idx) + + session.new_window(window_name="test_window") + window_count = len(session._windows) + + assert window_count >= 2 # 2 or more windows + + assert len(session._windows) == window_count + + # tmux selects a window, moves to it, shows it as attached_window + selected_window1 = session.select_window(window_base_index) + assert isinstance(selected_window1, Window) + attached_window1 = session.attached_window + + assert selected_window1 == attached_window1 + assert selected_window1.__dict__ == attached_window1.__dict__ + + # again: tmux selects a window, moves to it, shows it as + # attached_window + selected_window2 = session.select_window(window_base_index + 1) + assert isinstance(selected_window2, Window) + attached_window2 = session.attached_window + + assert selected_window2 == attached_window2 + assert selected_window2.__dict__ == attached_window2.__dict__ + + # assure these windows were really different + assert selected_window1 != selected_window2 + assert selected_window1.__dict__ != selected_window2.__dict__ + + +def test_select_window_returns_Window(session: Session) -> None: + """Session.select_window returns Window object.""" + + window_count = len(session._windows) + assert len(session._windows) == window_count + + window_idx = session.attached_window.get("window_index") + assert window_idx is not None + window_base_index = int(window_idx) + window = session.select_window(window_base_index) + assert isinstance(window, Window) + + +def test_attached_window(session: Session) -> None: + """Session.attached_window returns Window.""" + assert isinstance(session.attached_window, Window) + + +def test_attached_pane(session: Session) -> None: + """Session.attached_pane returns Pane.""" + assert isinstance(session.attached_pane, Pane) + + +def test_session_rename(session: Session) -> None: + """Session.rename_session renames session.""" + session_name = session.get("session_name") + assert session_name is not None + TEST_SESSION_NAME = session_name + + test_name = "testingdis_sessname" + session.rename_session(test_name) + session_name = session.get("session_name") + assert session_name is not None + assert session_name == test_name + session.rename_session(TEST_SESSION_NAME) + session_name = session.get("session_name") + assert session_name is not None + assert session_name == TEST_SESSION_NAME + + +def test_new_session(server: Server) -> None: + """Server.new_session creates new session.""" + new_session_name = TEST_SESSION_PREFIX + next(namer) + new_session = server.new_session(session_name=new_session_name, detach=True) + + assert isinstance(new_session, Session) + assert new_session.get("session_name") == new_session_name + + +def test_show_options(session: Session) -> None: + """Session.show_options() returns dict.""" + + options = session.show_options() + assert isinstance(options, dict) + + +def test_set_show_options_single(session: Session) -> None: + """Set option then Session.show_options(key).""" + + session.set_option("history-limit", 20) + assert session.show_option("history-limit") == 20 + + session.set_option("history-limit", 40) + assert session.show_option("history-limit") == 40 + + assert session.show_options()["history-limit"] == 40 + + +def test_set_show_option(session: Session) -> None: + """Set option then Session.show_option(key).""" + session.set_option("history-limit", 20) + assert session.show_option("history-limit") == 20 + + session.set_option("history-limit", 40) + + assert session.show_option("history-limit") == 40 + + +def test_empty_session_option_returns_None(session: Session) -> None: + assert session.show_option("default-shell") is None + + +def test_show_option_unknown(session: Session) -> None: + """Session.show_option raises UnknownOption for invalid option.""" + cmd_exception: t.Type[exc.OptionError] = exc.UnknownOption + if has_gte_version("3.0"): + cmd_exception = exc.InvalidOption + with pytest.raises(cmd_exception): + session.show_option("moooz") + + +def test_show_option_ambiguous(session: Session) -> None: + """Session.show_option raises AmbiguousOption for ambiguous option.""" + with pytest.raises(exc.AmbiguousOption): + session.show_option("default-") + + +def test_set_option_ambigous(session: Session) -> None: + """Session.set_option raises AmbiguousOption for invalid option.""" + with pytest.raises(exc.AmbiguousOption): + session.set_option("default-", 43) + + +def test_set_option_invalid(session: Session) -> None: + """Session.set_option raises UnknownOption for invalid option.""" + if has_gte_version("2.4"): + with pytest.raises(exc.InvalidOption): + session.set_option("afewewfew", 43) + else: + with pytest.raises(exc.UnknownOption): + session.set_option("afewewfew", 43) + + +def test_show_environment(session: Session) -> None: + """Session.show_environment() returns dict.""" + + _vars = session.show_environment() + assert isinstance(_vars, dict) + + +def test_set_show_environment_single(session: Session) -> None: + """Set environment then Session.show_environment(key).""" + + session.set_environment("FOO", "BAR") + assert session.getenv("FOO") == "BAR" + + session.set_environment("FOO", "DAR") + assert session.getenv("FOO") == "DAR" + + assert session.show_environment()["FOO"] == "DAR" + + +def test_show_environment_not_set(session: Session) -> None: + """Not set environment variable returns None.""" + assert session.getenv("BAR") is None + + +def test_remove_environment(session: Session) -> None: + """Remove environment variable.""" + assert session.getenv("BAM") is None + session.set_environment("BAM", "OK") + assert session.getenv("BAM") == "OK" + session.remove_environment("BAM") + assert session.getenv("BAM") is None + + +def test_unset_environment(session: Session) -> None: + """Unset environment variable.""" + assert session.getenv("BAM") is None + session.set_environment("BAM", "OK") + assert session.getenv("BAM") == "OK" + session.unset_environment("BAM") + assert session.getenv("BAM") is None + + +@pytest.mark.parametrize( + "session_name,raises", + [("hey.period", True), ("hey:its a colon", True), ("hey moo", False)], +) +def test_periods_raise_badsessionname( + server: Server, session: Session, session_name: str, raises: bool +) -> None: + new_name = session_name + "moo" # used for rename / switch + if raises: + with pytest.raises(exc.BadSessionName): + session.rename_session(new_name) + + with pytest.raises(exc.BadSessionName): + server.new_session(session_name) + + with pytest.raises(exc.BadSessionName): + server.has_session(session_name) + + with pytest.raises(exc.BadSessionName): + server.switch_client(new_name) + + with pytest.raises(exc.BadSessionName): + server.attach_session(new_name) + + else: + server.new_session(session_name) + server.has_session(session_name) + session.rename_session(new_name) + with pytest.raises(exc.LibTmuxException): + server.switch_client(new_name) + + +def test_cmd_inserts_sesion_id(session: Session) -> None: + current_session_id = session.id + last_arg = "last-arg" + cmd = session.cmd("not-a-command", last_arg) + assert "-t" in cmd.cmd + assert current_session_id in cmd.cmd + assert cmd.cmd[-1] == last_arg + + +@pytest.mark.skipif( + has_lt_version("3.0"), + reason="needs -e flag for new-window which was introduced in 3.0", +) +@pytest.mark.parametrize( + "environment", + [ + {"ENV_VAR": "window"}, + {"ENV_VAR_1": "window_1", "ENV_VAR_2": "window_2"}, + ], +) +def test_new_window_with_environment( + session: Session, + environment: t.Dict[str, str], +) -> None: + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="window_with_environment", + window_shell=f"{env} PS1='$ ' sh", + environment=environment, + ) + pane = window.attached_pane + assert pane is not None + for k, v in environment.items(): + pane.send_keys(f"echo ${k}") + assert pane.capture_pane()[-2] == v + + +@pytest.mark.skipif( + has_gte_version("3.0"), + reason="3.0 has the -e flag on new-window", +) +def test_new_window_with_environment_logs_warning_for_old_tmux( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="window_with_environment", + window_shell=f"{env} PS1='$ ' sh", + environment={"ENV_VAR": "window"}, + ) + + assert any( + "Cannot set up environment" in record.msg for record in caplog.records + ), "Warning missing" diff --git a/tests/legacy_api/test_test.py b/tests/legacy_api/test_test.py new file mode 100644 index 000000000..741a340bd --- /dev/null +++ b/tests/legacy_api/test_test.py @@ -0,0 +1,88 @@ +from time import time + +import pytest + +from libtmux.exc import WaitTimeout +from libtmux.test import retry_until + + +def test_retry_three_times() -> None: + ini = time() + value = 0 + + def call_me_three_times() -> bool: + nonlocal value + + if value == 2: + return True + + value += 1 + + return False + + retry_until(call_me_three_times, 1) + + end = time() + + assert abs((end - ini) - 0.1) < 0.01 + + +def test_function_times_out() -> None: + ini = time() + + def never_true() -> bool: + return False + + with pytest.raises(WaitTimeout): + retry_until(never_true, 1) + + end = time() + + assert abs((end - ini) - 1.0) < 0.01 + + +def test_function_times_out_no_rise() -> None: + ini = time() + + def never_true() -> bool: + return False + + retry_until(never_true, 1, raises=False) + + end = time() + + assert abs((end - ini) - 1.0) < 0.01 + + +def test_function_times_out_no_raise_assert() -> None: + ini = time() + + def never_true() -> bool: + return False + + assert not retry_until(never_true, 1, raises=False) + + end = time() + + assert abs((end - ini) - 1.0) < 0.01 + + +def test_retry_three_times_no_raise_assert() -> None: + ini = time() + value = 0 + + def call_me_three_times() -> bool: + nonlocal value + + if value == 2: + return True + + value += 1 + + return False + + assert retry_until(call_me_three_times, 1, raises=False) + + end = time() + + assert abs((end - ini) - 0.1) < 0.01 diff --git a/tests/legacy_api/test_tmuxobject.py b/tests/legacy_api/test_tmuxobject.py new file mode 100644 index 000000000..992c73d3f --- /dev/null +++ b/tests/legacy_api/test_tmuxobject.py @@ -0,0 +1,191 @@ +"""Tests for libtmux TmuxRelationalObject and TmuxMappingObject.""" +import logging + +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.test import TEST_SESSION_PREFIX, namer +from libtmux.window import Window + +logger = logging.getLogger(__name__) + + +"""Test the :class:`TmuxRelationalObject` base class object.""" + + +def test_find_where(server: Server, session: Session) -> None: + """Test that find_where() retrieves single matching object.""" + # server.find_where + for session in server.sessions: + session_id = session.get("session_id") + assert session_id is not None + + assert server.find_where({"session_id": session_id}) == session + assert isinstance(server.find_where({"session_id": session_id}), Session) + + # session.find_where + for window in session.windows: + window_id = window.get("window_id") + assert window_id is not None + + assert session.find_where({"window_id": window_id}) == window + assert isinstance(session.find_where({"window_id": window_id}), Window) + + # window.find_where + for pane in window.panes: + pane_id = pane.get("pane_id") + assert pane_id is not None + + assert window.find_where({"pane_id": pane_id}) == pane + assert isinstance(window.find_where({"pane_id": pane_id}), Pane) + + +def test_find_where_None(server: Server, session: Session) -> None: + """.find_where returns None if no results found.""" + + while True: + nonexistant_session = TEST_SESSION_PREFIX + next(namer) + + if not server.has_session(nonexistant_session): + break + + assert server.find_where({"session_name": nonexistant_session}) is None + + +def test_find_where_multiple_infos(server: Server, session: Session) -> None: + """.find_where returns objects with multiple attributes.""" + + for session in server.sessions: + session_id = session.get("session_id") + assert session_id is not None + session_name = session.get("session_name") + assert session_name is not None + + find_where = server.find_where( + {"session_id": session_id, "session_name": session_name} + ) + + assert find_where == session + assert isinstance(find_where, Session) + + # session.find_where + for window in session.windows: + window_id = window.get("window_id") + assert window_id is not None + window_index = window.get("window_index") + assert window_index is not None + + find_where = session.find_where( + {"window_id": window_id, "window_index": window_index} + ) + + assert find_where == window + assert isinstance(find_where, Window) + + # window.find_where + for pane in window.panes: + pane_id = pane.get("pane_id") + assert pane_id is not None + pane_tty = pane.get("pane_tty") + assert pane_tty is not None + + find_where = window.find_where( + {"pane_id": pane_id, "pane_tty": pane_tty} + ) + + assert find_where == pane + assert isinstance(find_where, Pane) + + +def test_where(server: Server, session: Session) -> None: + """Test self.where() returns matching objects.""" + + window = session.attached_window + window.split_window() # create second pane + + for session in server.sessions: + session_id = session.get("session_id") + assert session_id is not None + session_name = session.get("session_name") + assert session_name is not None + + server_sessions = server.where( + {"session_id": session_id, "session_name": session_name} + ) + + assert len(server_sessions) == 1 + assert isinstance(server_sessions, list) + assert server_sessions[0] == session + assert isinstance(server_sessions[0], Session) + + # session.where + for window in session.windows: + window_id = window.get("window_id") + assert window_id is not None + + window_index = window.get("window_index") + assert window_index is not None + + session_windows = session.where( + {"window_id": window_id, "window_index": window_index} + ) + + assert len(session_windows) == 1 + assert isinstance(session_windows, list) + assert session_windows[0] == window + assert isinstance(session_windows[0], Window) + + # window.where + for pane in window.panes: + pane_id = pane.get("pane_id") + assert pane_id is not None + + pane_tty = pane.get("pane_tty") + assert pane_tty is not None + + window_panes = window.where({"pane_id": pane_id, "pane_tty": pane_tty}) + + assert len(window_panes) == 1 + assert isinstance(window_panes, list) + assert window_panes[0] == pane + assert isinstance(window_panes[0], Pane) + + +def test_get_by_id(server: Server, session: Session) -> None: + """Test self.get_by_id() retrieves child object.""" + + window = session.attached_window + + window.split_window() # create second pane + + for session in server.sessions: + session_id = session.get("session_id") + assert session_id is not None + get_session_by_id = server.get_by_id(session_id) + + assert get_session_by_id == session + assert isinstance(get_session_by_id, Session) + assert server.get_by_id("$" + next(namer)) is None + + # session.get_by_id + for window in session.windows: + window_id = window.get("window_id") + assert window_id is not None + + get_window_by_id = session.get_by_id(window_id) + + assert get_window_by_id == window + assert isinstance(get_window_by_id, Window) + + assert session.get_by_id("@" + next(namer)) is None + + # window.get_by_id + for pane in window.panes: + pane_id = pane.get("pane_id") + assert pane_id is not None + + get_pane_by_id = window.get_by_id(pane_id) + + assert get_pane_by_id == pane + assert isinstance(get_pane_by_id, Pane) + assert window.get_by_id("%" + next(namer)) is None diff --git a/tests/legacy_api/test_version.py b/tests/legacy_api/test_version.py new file mode 100644 index 000000000..dc4af269d --- /dev/null +++ b/tests/legacy_api/test_version.py @@ -0,0 +1,70 @@ +import operator +import typing as t +from contextlib import nullcontext as does_not_raise + +import pytest + +from libtmux._compat import LooseVersion + +if t.TYPE_CHECKING: + from _pytest.python_api import RaisesContext + from typing_extensions import TypeAlias + + VersionCompareOp: TypeAlias = t.Callable[ + [t.Any, t.Any], + bool, + ] + + +@pytest.mark.parametrize( + "version", + [ + "1", + "1.0", + "1.0.0", + "1.0.0b", + "1.0.0b1", + "1.0.0b-openbsd", + "1.0.0-next", + "1.0.0-next.1", + ], +) +def test_version(version: str) -> None: + assert LooseVersion(version) + + +class VersionCompareFixture(t.NamedTuple): + a: object + op: "VersionCompareOp" + b: object + raises: t.Union[t.Type[Exception], bool] + + +@pytest.mark.parametrize( + VersionCompareFixture._fields, + [ + VersionCompareFixture(a="1", op=operator.eq, b="1", raises=False), + VersionCompareFixture(a="1", op=operator.eq, b="1.0", raises=False), + VersionCompareFixture(a="1", op=operator.eq, b="1.0.0", raises=False), + VersionCompareFixture(a="1", op=operator.gt, b="1.0.0a", raises=False), + VersionCompareFixture(a="1", op=operator.gt, b="1.0.0b", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1.0.0p1", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1.0.0-openbsd", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1", raises=AssertionError), + VersionCompareFixture(a="1", op=operator.lt, b="1", raises=AssertionError), + VersionCompareFixture(a="1.0.0c", op=operator.gt, b="1.0.0b", raises=False), + ], +) +def test_version_compare( + a: str, + op: "VersionCompareOp", + b: str, + raises: t.Union[t.Type[Exception], bool], +) -> None: + raises_ctx: "RaisesContext[Exception]" = ( + pytest.raises(t.cast(t.Type[Exception], raises)) + if raises + else t.cast("RaisesContext[Exception]", does_not_raise()) + ) + with raises_ctx: + assert op(LooseVersion(a), LooseVersion(b)) diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py new file mode 100644 index 000000000..02fb40552 --- /dev/null +++ b/tests/legacy_api/test_window.py @@ -0,0 +1,367 @@ +"""Test for libtmux Window object.""" +import logging +import shutil +import time +import typing as t + +import pytest + +from libtmux import exc +from libtmux.common import has_gte_version, has_lt_version +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.window import Window + +logger = logging.getLogger(__name__) + + +def test_select_window(session: Session) -> None: + window_count = len(session._windows) + # to do, get option for base-index from tmux + # for now however, let's get the index from the first window. + assert window_count == 1 + + window_base_index = int(session.attached_window.index) + + window = session.new_window(window_name="testing 3") + + # self.assertEqual(2, + # int(session.attached_window.index)) + assert int(window_base_index) + 1 == int(window.index) + + session.select_window(str(window_base_index)) + assert window_base_index == int(session.attached_window.index) + + session.select_window("testing 3") + assert int(window_base_index) + 1 == int(session.attached_window.index) + + assert len(session._windows) == 2 + + +def test_zfresh_window_data(session: Session) -> None: + attached_window = session.attached_window + assert attached_window is not None + pane_base_idx = attached_window.show_window_option("pane-base-index", g=True) + assert pane_base_idx is not None + pane_base_index = int(pane_base_idx) + + assert len(session.windows) == 1 + + assert len(session.attached_window.panes) == 1 + current_windows = len(session._windows) + assert session.get("session_id") != "@0" + assert current_windows == 1 + + assert len(session.attached_window.panes) == 1 + assert isinstance(session.server, Server) + # len(session.attached_window.panes)) + + assert len(session.windows), 1 + assert len(session.attached_window.panes) == 1 + for w in session.windows: + assert isinstance(w, Window) + window = session.attached_window + assert isinstance(window, Window) + assert len(session.attached_window.panes) == 1 + window.split_window() + + attached_window = session.attached_window + assert attached_window is not None + attached_window.select_pane(pane_base_index) + + attached_pane = session.attached_pane + assert attached_pane is not None + attached_pane.send_keys("cd /srv/www/flaskr") + + attached_window.select_pane(pane_base_index + 1) + attached_pane = session.attached_pane + assert attached_pane is not None + attached_pane.send_keys("source .venv/bin/activate") + session.new_window(window_name="second") + current_windows += 1 + assert current_windows == len(session._windows) + session.new_window(window_name="hey") + current_windows += 1 + assert current_windows == len(session._windows) + + session.select_window("1") + session.kill_window(target_window="hey") + current_windows -= 1 + assert current_windows == len(session._windows) + + +def test_newest_pane_data(session: Session) -> None: + window = session.new_window(window_name="test", attach=True) + assert isinstance(window, Window) + assert len(window.panes) == 1 + window.split_window(attach=True) + + assert len(window.panes) == 2 + # note: the below used to accept -h, removing because split_window now + # has attach as its only argument now + window.split_window(attach=True) + assert len(window.panes) == 3 + + +def test_attached_pane(session: Session) -> None: + """Window.attached_window returns active Pane.""" + + window = session.attached_window # current window + assert isinstance(window.attached_pane, Pane) + + +def test_split_window(session: Session) -> None: + """Window.split_window() splits window, returns new Pane, vertical.""" + window_name = "test split window" + window = session.new_window(window_name=window_name, attach=True) + pane = window.split_window() + assert len(window.panes) == 2 + assert isinstance(pane, Pane) + assert float(window.panes[0].height) <= ((float(window.width) + 1) / 2) + + +def test_split_window_shell(session: Session) -> None: + """Window.split_window() splits window, returns new Pane, vertical.""" + window_name = "test split window" + cmd = "sleep 1m" + window = session.new_window(window_name=window_name, attach=True) + pane = window.split_window(shell=cmd) + assert len(window.panes) == 2 + assert isinstance(pane, Pane) + assert float(window.panes[0].height) <= ((float(window.width) + 1) / 2) + if has_gte_version("3.2"): + assert pane.get("pane_start_command", "").replace('"', "") == cmd + else: + assert pane.get("pane_start_command") == cmd + + +def test_split_window_horizontal(session: Session) -> None: + """Window.split_window() splits window, returns new Pane, horizontal.""" + window_name = "test split window" + window = session.new_window(window_name=window_name, attach=True) + pane = window.split_window(vertical=False) + assert len(window.panes) == 2 + assert isinstance(pane, Pane) + assert float(window.panes[0].width) <= ((float(window.width) + 1) / 2) + + +@pytest.mark.parametrize( + "window_name_before,window_name_after", + [("test", "ha ha ha fjewlkjflwef"), ("test", "hello \\ wazzup 0")], +) +def test_window_rename( + session: Session, window_name_before: str, window_name_after: str +) -> None: + """Window.rename_window().""" + window_name_before = "test" + window_name_after = "ha ha ha fjewlkjflwef" + + session.set_option("automatic-rename", "off") + window = session.new_window(window_name=window_name_before, attach=True) + + assert window == session.attached_window + assert window.get("window_name") == window_name_before + + window.rename_window(window_name_after) + + window = session.attached_window + + assert window.get("window_name") == window_name_after + + window = session.attached_window + + assert window.get("window_name") == window_name_after + + +def test_kill_window(session: Session) -> None: + session.new_window() + # create a second window to not kick out the client. + # there is another way to do this via options too. + + w = session.attached_window + + w.get("window_id") + + w.kill_window() + with pytest.raises(IndexError): + w.get("window_id") + + +def test_show_window_options(session: Session) -> None: + """Window.show_window_options() returns dict.""" + window = session.new_window(window_name="test_window") + + options = window.show_window_options() + assert isinstance(options, dict) + + +def test_set_show_window_options(session: Session) -> None: + """Set option then Window.show_window_options(key).""" + window = session.new_window(window_name="test_window") + + window.set_window_option("main-pane-height", 20) + assert window.show_window_option("main-pane-height") == 20 + + window.set_window_option("main-pane-height", 40) + assert window.show_window_option("main-pane-height") == 40 + assert window.show_window_options()["main-pane-height"] == 40 + + if has_gte_version("2.3"): + window.set_window_option("pane-border-format", " #P ") + assert window.show_window_option("pane-border-format") == " #P " + + +def test_empty_window_option_returns_None(session: Session) -> None: + window = session.new_window(window_name="test_window") + assert window.show_window_option("alternate-screen") is None + + +def test_show_window_option(session: Session) -> None: + """Set option then Window.show_window_option(key).""" + window = session.new_window(window_name="test_window") + + window.set_window_option("main-pane-height", 20) + assert window.show_window_option("main-pane-height") == 20 + + window.set_window_option("main-pane-height", 40) + assert window.show_window_option("main-pane-height") == 40 + assert window.show_window_option("main-pane-height") == 40 + + +def test_show_window_option_unknown(session: Session) -> None: + """Window.show_window_option raises UnknownOption for bad option key.""" + window = session.new_window(window_name="test_window") + + cmd_exception: t.Type[exc.OptionError] = exc.UnknownOption + if has_gte_version("3.0"): + cmd_exception = exc.InvalidOption + with pytest.raises(cmd_exception): + window.show_window_option("moooz") + + +def test_show_window_option_ambiguous(session: Session) -> None: + """show_window_option raises AmbiguousOption for ambiguous option.""" + window = session.new_window(window_name="test_window") + + with pytest.raises(exc.AmbiguousOption): + window.show_window_option("clock-mode") + + +def test_set_window_option_ambiguous(session: Session) -> None: + """set_window_option raises AmbiguousOption for ambiguous option.""" + window = session.new_window(window_name="test_window") + + with pytest.raises(exc.AmbiguousOption): + window.set_window_option("clock-mode", 12) + + +def test_set_window_option_invalid(session: Session) -> None: + """Window.set_window_option raises ValueError for invalid option key.""" + + window = session.new_window(window_name="test_window") + + if has_gte_version("2.4"): + with pytest.raises(exc.InvalidOption): + window.set_window_option("afewewfew", 43) + else: + with pytest.raises(exc.UnknownOption): + window.set_window_option("afewewfew", 43) + + +def test_move_window(session: Session) -> None: + """Window.move_window results in changed index""" + + window = session.new_window(window_name="test_window") + new_index = str(int(window.index) + 1) + window.move_window(new_index) + assert window.index == new_index + + +def test_move_window_to_other_session(server: Server, session: Session) -> None: + window = session.new_window(window_name="test_window") + new_session = server.new_session("test_move_window") + window.move_window(session=new_session.get("session_id")) + window_id = window.get("window_id") + assert window_id is not None + assert new_session.get_by_id(window_id) == window + + +def test_select_layout_accepts_no_arg(server: Server, session: Session) -> None: + """tmux allows select-layout with no arguments, so let's allow it here.""" + + window = session.new_window(window_name="test_window") + window.select_layout() + + +@pytest.mark.skipif( + has_lt_version("3.2"), reason="needs filter introduced in tmux >= 3.2" +) +def test_empty_window_name(session: Session) -> None: + session.set_option("automatic-rename", "off") + window = session.new_window(window_name="''", attach=True) + + assert window == session.attached_window + assert window.get("window_name") == "''" + + cmd = session.cmd( + "list-windows", + "-F", + "#{window_name}", + "-f", + "#{==:#{session_name}," + session.name + "}", + ) + assert "''" in cmd.stdout + + +@pytest.mark.skipif( + has_lt_version("3.0"), + reason="needs -e flag for split-window which was introduced in 3.0", +) +@pytest.mark.parametrize( + "environment", + [ + {"ENV_VAR": "pane"}, + {"ENV_VAR_1": "pane_1", "ENV_VAR_2": "pane_2"}, + ], +) +def test_split_window_with_environment( + session: Session, + environment: t.Dict[str, str], +) -> None: + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in Path." + + window = session.new_window(window_name="split_window_with_environment") + pane = window.split_window( + shell=f"{env} PS1='$ ' sh", + environment=environment, + ) + assert pane is not None + # wait a bit for the prompt to be ready as the test gets flaky otherwise + time.sleep(0.05) + for k, v in environment.items(): + pane.send_keys(f"echo ${k}") + assert pane.capture_pane()[-2] == v + + +@pytest.mark.skipif( + has_gte_version("3.0"), + reason="3.0 has the -e flag on split-window", +) +def test_split_window_with_environment_logs_warning_for_old_tmux( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in Path." + + window = session.new_window(window_name="split_window_with_environment") + window.split_window( + shell=f"{env} PS1='$ ' sh", + environment={"ENV_VAR": "pane"}, + ) + + assert any( + "Cannot set up environment" in record.msg for record in caplog.records + ), "Warning missing" From 9231e3f98031c528a4b4c092017097fa5790b8dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 18 Dec 2022 07:38:02 -0600 Subject: [PATCH 6/9] tests(legacy API[window]): Update for newest behavior --- tests/legacy_api/test_window.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py index 02fb40552..e6a26a6c1 100644 --- a/tests/legacy_api/test_window.py +++ b/tests/legacy_api/test_window.py @@ -181,11 +181,12 @@ def test_kill_window(session: Session) -> None: w = session.attached_window - w.get("window_id") + assert isinstance(w.get("window_id"), str) + assert len(session.windows.filter(window_id=w.get("window_id"))) == 1 w.kill_window() - with pytest.raises(IndexError): - w.get("window_id") + + assert len(session.windows.filter(window_id=w.get("window_id"))) == 0 def test_show_window_options(session: Session) -> None: From 20f7c5c27cc3fe87c37c93302d390ac9b38bc9d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 18 Dec 2022 07:45:23 -0600 Subject: [PATCH 7/9] chore(legacy test): Typings --- tests/legacy_api/test_tmuxobject.py | 12 ++++++------ tests/legacy_api/test_window.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/legacy_api/test_tmuxobject.py b/tests/legacy_api/test_tmuxobject.py index 992c73d3f..73d7a95ed 100644 --- a/tests/legacy_api/test_tmuxobject.py +++ b/tests/legacy_api/test_tmuxobject.py @@ -75,12 +75,12 @@ def test_find_where_multiple_infos(server: Server, session: Session) -> None: window_index = window.get("window_index") assert window_index is not None - find_where = session.find_where( + find_where_window = session.find_where( {"window_id": window_id, "window_index": window_index} ) - assert find_where == window - assert isinstance(find_where, Window) + assert find_where_window == window + assert isinstance(find_where_window, Window) # window.find_where for pane in window.panes: @@ -89,12 +89,12 @@ def test_find_where_multiple_infos(server: Server, session: Session) -> None: pane_tty = pane.get("pane_tty") assert pane_tty is not None - find_where = window.find_where( + find_where_pane = window.find_where( {"pane_id": pane_id, "pane_tty": pane_tty} ) - assert find_where == pane - assert isinstance(find_where, Pane) + assert find_where_pane == pane + assert isinstance(find_where_pane, Pane) def test_where(server: Server, session: Session) -> None: diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py index e6a26a6c1..b58150f51 100644 --- a/tests/legacy_api/test_window.py +++ b/tests/legacy_api/test_window.py @@ -22,12 +22,15 @@ def test_select_window(session: Session) -> None: # for now however, let's get the index from the first window. assert window_count == 1 + assert session.attached_window is not None + assert session.attached_window.index is not None window_base_index = int(session.attached_window.index) window = session.new_window(window_name="testing 3") # self.assertEqual(2, # int(session.attached_window.index)) + assert window.index is not None assert int(window_base_index) + 1 == int(window.index) session.select_window(str(window_base_index)) @@ -118,6 +121,8 @@ def test_split_window(session: Session) -> None: pane = window.split_window() assert len(window.panes) == 2 assert isinstance(pane, Pane) + assert window.width is not None + assert window.panes[0].height is not None assert float(window.panes[0].height) <= ((float(window.width) + 1) / 2) @@ -129,6 +134,8 @@ def test_split_window_shell(session: Session) -> None: pane = window.split_window(shell=cmd) assert len(window.panes) == 2 assert isinstance(pane, Pane) + assert window.width is not None + assert window.panes[0].height is not None assert float(window.panes[0].height) <= ((float(window.width) + 1) / 2) if has_gte_version("3.2"): assert pane.get("pane_start_command", "").replace('"', "") == cmd @@ -143,6 +150,8 @@ def test_split_window_horizontal(session: Session) -> None: pane = window.split_window(vertical=False) assert len(window.panes) == 2 assert isinstance(pane, Pane) + assert window.width is not None + assert window.panes[0].width is not None assert float(window.panes[0].width) <= ((float(window.width) + 1) / 2) @@ -274,6 +283,7 @@ def test_move_window(session: Session) -> None: """Window.move_window results in changed index""" window = session.new_window(window_name="test_window") + assert window.index is not None new_index = str(int(window.index) + 1) window.move_window(new_index) assert window.index == new_index @@ -304,6 +314,7 @@ def test_empty_window_name(session: Session) -> None: assert window == session.attached_window assert window.get("window_name") == "''" + assert session.name is not None cmd = session.cmd( "list-windows", From ec073f80e5dfc03d470f75fa79e6faaa8bb710b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Sep 2022 17:21:24 -0500 Subject: [PATCH 8/9] Formats: Update to remove pane_title (removed in later tmux versions) --- src/libtmux/formats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/formats.py b/src/libtmux/formats.py index ce3745318..545e28d56 100644 --- a/src/libtmux/formats.py +++ b/src/libtmux/formats.py @@ -64,7 +64,7 @@ "pane_index", "pane_width", "pane_height", - "pane_title", + # "pane_title", # removed in 3.1+ "pane_id", "pane_active", "pane_dead", From 373973b2c59d9193677d6db478106c3a74662c2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 12 Sep 2022 19:59:17 -0500 Subject: [PATCH 9/9] feat: Basic dataclass --- CHANGES | 31 ++ MIGRATION | 33 ++ README.md | 8 +- docs/about.md | 4 +- docs/internals/dataclasses.md | 8 + docs/internals/index.md | 11 +- docs/internals/query_list.md | 6 + docs/quickstart.md | 28 +- docs/reference/properties.md | 55 +-- src/libtmux/_internal/query_list.py | 22 +- src/libtmux/common.py | 168 ------- src/libtmux/formats.py | 2 +- src/libtmux/neo.py | 244 ++++++++++ src/libtmux/pane.py | 302 +++++++----- src/libtmux/pytest_plugin.py | 8 +- src/libtmux/server.py | 416 ++++++++--------- src/libtmux/session.py | 688 ++++++++++++++++------------ src/libtmux/test.py | 8 +- src/libtmux/window.py | 596 ++++++++++++++---------- tests/test_dataclasses.py | 180 ++++++++ tests/test_pane.py | 24 +- tests/test_pytest_plugin.py | 6 +- tests/test_server.py | 16 +- tests/test_session.py | 24 +- tests/test_tmuxobject.py | 103 +++-- tests/test_window.py | 81 ++-- 26 files changed, 1849 insertions(+), 1223 deletions(-) create mode 100644 docs/internals/dataclasses.md create mode 100644 docs/internals/query_list.md create mode 100644 src/libtmux/neo.py create mode 100644 tests/test_dataclasses.py diff --git a/CHANGES b/CHANGES index 64b36ec79..a21ae251a 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,37 @@ $ pip install --user --upgrade --pre libtmux +### Breaking changes + +- Finding objects / relations + + - 0.16 and below: `session._windows()`, `session.list_windows()`, etc. + + 0.17 and after: {attr}`session.windows ` + + - 0.16 and below: `session.find_where({'window_name': my_window})` + + 0.17 and after: {meth}`session.windows.get(window_name=my_window, default=None) ` + + - If not found and not `default`, raises {exc}`~libtmux._internal.query_list.ObjectDoesNotExist` + - If multiple objects found, raises {exc}`~libtmux._internal.query_list.MultipleObjectsReturned` + + - 0.16 and below: `session.where({'window_name': my_window})` + + 0.17 and after: {meth}`session.windows.filter(window_name=my_window) ` + +- Accessing attributes + + - 0.16 and below: `window['id']` + + 0.17 and after: `window.id` + - 0.16 and below: `window.get('id')` + + 0.17 and after: `window.id` + - 0.16 and below: `window.get('id', None)` + + 0.17 and after: `getattr(window, 'id', None)` + ### New features #### Detect if server active (#448) diff --git a/MIGRATION b/MIGRATION index 9d0d28b5f..987892cba 100644 --- a/MIGRATION +++ b/MIGRATION @@ -19,6 +19,39 @@ well. [tracker]: https://github.com/tmux-python/libtmux/discussions ``` +## 0.17.x: Simplified attributes + +### Finding objects / relations + +- 0.16 and below: `session._windows()`, `session.list_windows()`, etc. + + 0.17 and after: {attr}`session.windows ` + +- 0.16 and below: `session.find_where({'window_name': my_window})` + + 0.17 and after: {meth}`session.windows.get(window_name=my_window, default=None) ` + + - If not found and not `default`, raises {exc}`~libtmux._internal.query_list.ObjectDoesNotExist` + - If multiple objects found, raises {exc}`~libtmux._internal.query_list.MultipleObjectsReturned` + + + +- 0.16 and below: `session.where({'window_name': my_window})` + + 0.17 and after: {meth}`session.windows.filter(window_name=my_window) ` + +### Accessing attributes + +- 0.16 and below: `window['id']` + + 0.17 and after: `window.id` +- 0.16 and below: `window.get('id')` + + 0.17 and after: `window.id` +- 0.16 and below: `window.get('id', None)` + + 0.17 and after: `getattr(window, 'id', None)` + ## Next release _Migration instructions for the upcoming release will be added here_ diff --git a/README.md b/README.md index e71025bbd..dabad67fa 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,14 @@ current tmux server / session / window pane. List sessions: ```python ->>> server.list_sessions() +>>> server.sessions [Session($1 ...), Session($0 ...)] ``` Find session: ```python ->>> server.get_by_id('$1') +>>> server.sessions.filter(session_id='$1')[0] Session($1 ...) ``` @@ -85,7 +85,7 @@ Find session by dict lookup: ```python >>> server.sessions[0].rename_session('foo') Session($1 foo) ->>> server.find_where({ "session_name": "foo" }) +>>> server.sessions.filter(session_name="foo")[0] Session($1 foo) ``` @@ -150,12 +150,14 @@ Type inside the pane (send key strokes): >>> pane.send_keys('echo hey', enter=False) >>> pane.enter() +Pane(%1 ...) ``` Grab the output of pane: ```python >>> pane.clear() # clear the pane +Pane(%1 ...) >>> pane.send_keys("cowsay 'hello'", enter=True) >>> print('\n'.join(pane.cmd('capture-pane', '-p').stdout)) # doctest: +SKIP $ cowsay 'hello' diff --git a/docs/about.md b/docs/about.md index d2aad5aaf..37197e55d 100644 --- a/docs/about.md +++ b/docs/about.md @@ -85,8 +85,8 @@ How is libtmux able to keep references to panes, windows and sessions? > Window index refers to the # of the window in the session. > > To assert pane, window and session data, libtmux will use -> {meth}`Server.list_sessions()`, {meth}`Session.list_windows()`, -> {meth}`Window.list_panes()` to update objects. +> {meth}`Server.sessions()`, {meth}`Session.windows()`, +> {meth}`Window.panes()` to update objects. ## Naming conventions diff --git a/docs/internals/dataclasses.md b/docs/internals/dataclasses.md new file mode 100644 index 000000000..96249dc28 --- /dev/null +++ b/docs/internals/dataclasses.md @@ -0,0 +1,8 @@ +# Dataclass helpers - `libtmux._internal.dataclasses` + +```{eval-rst} +.. automodule:: libtmux._internal.dataclasses + :members: + :special-members: + +``` diff --git a/docs/internals/index.md b/docs/internals/index.md index 348a1df32..cc23abcc4 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -3,9 +3,9 @@ # Internals :::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! -These APIs are internal and not covered by versioning policy. - +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/libtmux/issues). ::: ## Environmental variables @@ -23,3 +23,10 @@ to split `tmux(1)`'s formatting information. If you find any compatibility problems with the default, or better yet find a string copacetic many environments and tmux releases, note it at . + +## Changes + +```{toctree} +dataclasses +query_list +`` diff --git a/docs/internals/query_list.md b/docs/internals/query_list.md new file mode 100644 index 000000000..29f1f9c33 --- /dev/null +++ b/docs/internals/query_list.md @@ -0,0 +1,6 @@ +# List querying - `libtmux._internal.query_list` + +```{eval-rst} +.. automodule:: libtmux._internal.query_list + :members: +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 749c4cb95..ed6386a89 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -125,10 +125,10 @@ Windows and Panes. If you have multiple tmux sessions open, you can see that all of the methods in {class}`Server` are available. -We can list sessions with {meth}`Server.list_sessions`: +We can list sessions with {meth}`Server.sessions`: ```python ->>> server.list_sessions() +>>> server.sessions [Session($1 ...), Session($0 ...)] ``` @@ -136,22 +136,22 @@ This returns a list of {class}`Session` objects you can grab. We can find our current session with: ```python ->>> server.list_sessions()[0] +>>> server.sessions[0] Session($1 ...) ``` However, this isn't guaranteed, libtmux works against current tmux information, the session's name could be changed, or another tmux session may be created, -so {meth}`Server.get_by_id` and {meth}`Server.find_where` exists as a lookup. +so {meth}`Server.sessions` and {meth}`Server.windows` exists as a lookup. ## Get session by ID tmux sessions use the `$[0-9]` convention as a way to identify sessions. -`$1` is whatever the ID `list_sessions()` returned above. +`$1` is whatever the ID `sessions()` returned above. ```python ->>> server.get_by_id('$1') +>>> server.sessions.filter(session_id='$1')[0] Session($1 ...) ``` @@ -163,13 +163,16 @@ You may `session = server.get_by_id('$')` to use the session object. >>> server.sessions[0].rename_session('foo') Session($1 foo) ->>> server.find_where({ "session_name": "foo" }) +>>> server.sessions.filter(session_name="foo")[0] +Session($1 foo) + +>>> server.sessions.get(session_name="foo") Session($1 foo) ``` -With `find_where`, pass in a dict and return the first object found. In +With `filter`, pass in attributes and return a list of matches. In this case, a {class}`Server` holds a collection of child {class}`Session`. -{class}`Session` and {class}`Window` both utilize `find_where` to sift +{class}`Session` and {class}`Window` both utilize `filter` to sift through Windows and Panes, respectively. So you may now use: @@ -178,7 +181,7 @@ So you may now use: >>> server.sessions[0].rename_session('foo') Session($1 foo) ->>> session = server.find_where({ "session_name": "foo" }) +>>> session = server.sessions.get(session_name="foo") >>> session Session($1 foo) ``` @@ -213,7 +216,7 @@ Let's delete that window ({meth}`Session.kill_window`). Method 1: Use passthrough to tmux's `target` system. ```python ->>> session.kill_window(window.id) +>>> session.kill_window(window.window_id) ``` The window in the bg dissappeared. This was the equivalent of @@ -260,7 +263,7 @@ And kill: >>> window.kill_window() ``` -Use {meth}`Session.list_windows()` and {meth}`Session.find_where()` to list and sort +Use {meth}`Session.windows` and {meth}`Session.windows.filter()` to list and sort through active {class}`Window`'s. ## Manipulating windows @@ -346,6 +349,7 @@ using {meth}`Pane.enter()`: ```python >>> pane.enter() +Pane(%1 ...) ``` ### Avoid cluttering shell history diff --git a/docs/reference/properties.md b/docs/reference/properties.md index 085361f3f..2f5e63906 100644 --- a/docs/reference/properties.md +++ b/docs/reference/properties.md @@ -49,25 +49,24 @@ Session($1 libtmux_...) Quick access to basic attributes: ```python ->>> session.name +>>> session.session_name 'libtmux_...' ->>> session.id +>>> session.session_id '$1' ``` To see all attributes for a session: ```python ->>> sorted(list(session._info.keys())) +from libtmux.neo import Obj + +>>> sorted(list(Obj.__dataclass_fields__.keys())) ['session_attached', 'session_created', ...] ``` -Some may conflict with python API, to access them, you can use `.get()`, to get the count -of sessions in a window: - ```python ->>> session.get('session_windows') +>>> session.session_windows '...' ``` @@ -85,30 +84,23 @@ Window(@1 ...:..., Session($1 ...)) Basics: ```python ->>> window.name +>>> window.window_name '...' ->>> window.id +>>> window.window_id '@1' ->>> window.height +>>> window.window_height '...' ->>> window.width +>>> window.window_width '...' ``` -Everything available: - -```python ->>> sorted(list(window.keys())) -['session_id', 'session_name', 'window_active', ..., 'window_width'] -``` - -Use `get()` for details not accessible via properties: +Use attribute access for details not accessible via properties: ```python ->>> window.get('window_panes') +>>> window.window_panes '1' ``` @@ -126,33 +118,20 @@ Pane(%1 Window(@1 ...:..., Session($1 libtmux_...))) Basics: ```python ->>> pane.current_command +>>> pane.pane_current_command '...' ->>> type(pane.current_command) +>>> type(pane.pane_current_command) ->>> pane.height +>>> pane.pane_height '...' ->>> pane.width +>>> pane.pane_width '...' ->>> pane.index +>>> pane.pane_index '0' ``` -Everything: - -````python ->>> sorted(list(pane._info.keys())) -['alternate_on', 'alternate_saved_x', ..., 'wrap_flag'] - -Use `get()` for details keys: - -```python ->>> pane.get('pane_width') -'...' -```` - [formats]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#FORMATS diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 05354cd57..f4ea936ce 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -30,6 +30,14 @@ def __call__( no_arg = object() +class MultipleObjectsReturned(Exception): + """The requested object does not exist""" + + +class ObjectDoesNotExist(Exception): + """The query returned multiple objects when only one was expected.""" + + def keygetter( obj: "Mapping[str, Any]", path: str, @@ -269,6 +277,8 @@ class QueryList(List[T]): 'Elmhurst' >>> query.filter(foods__fruit__in="orange")[0]['city'] 'Tampa' + >>> query.get(foods__fruit__in="orange")['city'] + 'Tampa' """ data: "Sequence[T]" @@ -314,6 +324,8 @@ def __eq__( def filter( self, matcher: Optional[Union[Callable[[T], bool], T]] = None, **kwargs: Any ) -> "QueryList[T]": + """Filter list of objects.""" + def filter_lookup(obj: Any) -> bool: for path, v in kwargs.items(): try: @@ -356,11 +368,17 @@ def get( default: Optional[Any] = no_arg, **kwargs: Any, ) -> Optional[T]: + """Retrieve one object + + Raises exception if multiple objects found. + + Raises exception if no object found, unless ``default`` + """ objs = self.filter(matcher=matcher, **kwargs) if len(objs) > 1: - raise Exception("Multiple objects returned") + raise MultipleObjectsReturned() elif len(objs) == 0: if default == no_arg: - raise Exception("No objects found") + raise ObjectDoesNotExist() return default return objs[0] diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 88029b30c..281abc7ec 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -274,174 +274,6 @@ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: ) -# class TmuxMappingObject(t.Mapping[str, t.Union[str,int,bool]]): -class TmuxMappingObject(t.Mapping[t.Any, t.Any]): - r"""Base: :py:class:`MutableMapping`. - - Convenience container. Base class for :class:`Pane`, :class:`Window`, - :class:`Session` and :class:`Server`. - - Instance attributes for useful information :term:`tmux(1)` uses for - Session, Window, Pane, stored :attr:`self._info`. For example, a - :class:`Window` will have a ``window_id`` and ``window_name``. - - ================ ================================== ============== - Object formatter_prefix value - ================ ================================== ============== - :class:`Server` n/a n/a - :class:`Session` :attr:`Session.formatter_prefix` session\_ - :class:`Window` :attr:`Window.formatter_prefix` window\_ - :class:`Pane` :attr:`Pane.formatter_prefix` pane\_ - ================ ================================== ============== - """ - _info: t.Dict[t.Any, t.Any] - formatter_prefix: str - - def __getitem__(self, key: str) -> str: - item = self._info[key] - assert item is not None - assert isinstance(item, str) - return item - - def __setitem__(self, key: str, value: str) -> None: - self._info[key] = value - self.dirty = True - - def __delitem__(self, key: str) -> None: - del self._info[key] - self.dirty = True - - def keys(self) -> KeysView[str]: - """Return list of keys.""" - return self._info.keys() - - def __iter__(self) -> t.Iterator[str]: - return self._info.__iter__() - - def __len__(self) -> int: - return len(self._info.keys()) - - def __getattr__(self, key: str) -> str: - try: - val = self._info[self.formatter_prefix + key] - assert val is not None - assert isinstance(val, str) - return val - except KeyError: - raise AttributeError(f"{self.__class__} has no property {key}") - - -O = TypeVar("O", "Pane", "Window", "Session") -D = TypeVar("D", "PaneDict", "WindowDict", "SessionDict") - - -class TmuxRelationalObject(Generic[O, D]): - """Base Class for managing tmux object child entities. .. # NOQA - - Manages collection of child objects (a :class:`Server` has a collection of - :class:`Session` objects, a :class:`Session` has collection of - :class:`Window`) - - Children of :class:`TmuxRelationalObject` are going to have a - ``self.children``, ``self.child_id_attribute``. - - ================ ========================= ================================= - Object .children method - ================ ========================= ================================= - :class:`Server` :attr:`Server._sessions` :meth:`Server.list_sessions` - :class:`Session` :attr:`Session._windows` :meth:`Session.list_windows` - :class:`Window` :attr:`Window._panes` :meth:`Window.list_panes` - :class:`Pane` n/a n/a - ================ ========================= ================================= - - ================ ================================== ============== - Object child_id_attribute value - ================ ================================== ============== - :class:`Server` :attr:`Server.child_id_attribute` session_id - :class:`Session` :attr:`Session.child_id_attribute` window_id - :class:`Window` :attr:`Window.child_id_attribute` pane_id - :class:`Pane` n/a n/a - ================ ================================== ============== - """ - - children: t.List[O] - child_id_attribute: str - - def find_where(self, attrs: D) -> Optional[Union["Pane", "Window", "Session"]]: - """Return object on first match. - - .. versionchanged:: 0.4 - Renamed from ``.findWhere`` to ``.find_where``. - - """ - try: - return self.where(attrs)[0] - except IndexError: - return None - - @overload - def where(self, attrs: D, first: "Literal[True]") -> O: - ... - - @overload - def where(self, attrs: D, first: "Literal[False]") -> t.List[O]: - ... - - @overload - def where(self, attrs: D) -> t.List[O]: - ... - - def where(self, attrs: D, first: bool = False) -> t.Union[List[O], O]: - """ - Return objects matching child objects properties. - - Parameters - ---------- - attrs : dict - tmux properties to match values of - - Returns - ------- - list of objects, or one object if ``first=True`` - """ - - # from https://github.com/serkanyersen/underscore.py - def by(val: O) -> bool: - for key in attrs.keys(): - try: - if attrs[key] != val[key]: - return False - except KeyError: - return False - return True - - target_children: t.List[O] = [s for s in self.children if by(s)] - - if first: - return target_children[0] - return target_children - - def get_by_id(self, id: str) -> Optional[O]: - """ - Return object based on ``child_id_attribute``. - - Parameters - ---------- - val : str - - Returns - ------- - object - """ - for child in self.children: - if child[self.child_id_attribute] == id: - return child - else: - continue - - return None - - def get_version() -> LooseVersion: """ Return tmux version. diff --git a/src/libtmux/formats.py b/src/libtmux/formats.py index 545e28d56..b74bd989f 100644 --- a/src/libtmux/formats.py +++ b/src/libtmux/formats.py @@ -19,7 +19,7 @@ "session_created", "session_created_string", "session_attached", - "session_grouped", + # "session_grouped", Apparently unused, mistake found while adding dataclasses "session_group", ] diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py new file mode 100644 index 000000000..be1621214 --- /dev/null +++ b/src/libtmux/neo.py @@ -0,0 +1,244 @@ +import dataclasses +import logging +import typing as t + +from libtmux import exc +from libtmux.common import tmux_cmd +from libtmux.formats import FORMAT_SEPARATOR + +if t.TYPE_CHECKING: + from typing_extensions import Literal + + ListCmd = Literal["list-sessions", "list-windows", "list-panes"] + ListExtraArgs = t.Optional[t.Iterable[str]] + + from libtmux.server import Server + +logger = logging.getLogger(__name__) + + +OutputRaw = t.Dict[str, t.Any] +OutputsRaw = t.List[OutputRaw] + + +""" +Quirks: + +QUIRK_TMUX_3_1_X_0001: + +- tmux 3.1 and 3.1a: +- server crash with list-panes w/ buffer_created, client_activity, client_created +""" + + +@dataclasses.dataclass() +class Obj: + server: "Server" + + active_window_index: t.Union[str, None] = None + alternate_saved_x: t.Union[str, None] = None + alternate_saved_y: t.Union[str, None] = None + # See QUIRK_TMUX_3_1_X_0001 + # buffer_created: t.Union[str, None] = None + buffer_name: t.Union[str, None] = None + buffer_sample: t.Union[str, None] = None + buffer_size: t.Union[str, None] = None + # See QUIRK_TMUX_3_1_X_0001 + # client_activity: t.Union[str, None] = None + client_cell_height: t.Union[str, None] = None + client_cell_width: t.Union[str, None] = None + # See QUIRK_TMUX_3_1_X_0001 + # client_created: t.Union[str, None] = None + client_discarded: t.Union[str, None] = None + client_flags: t.Union[str, None] = None + client_height: t.Union[str, None] = None + client_key_table: t.Union[str, None] = None + client_name: t.Union[str, None] = None + client_pid: t.Union[str, None] = None + client_termname: t.Union[str, None] = None + client_tty: t.Union[str, None] = None + client_uid: t.Union[str, None] = None + client_user: t.Union[str, None] = None + client_width: t.Union[str, None] = None + client_written: t.Union[str, None] = None + command_list_alias: t.Union[str, None] = None + command_list_name: t.Union[str, None] = None + command_list_usage: t.Union[str, None] = None + config_files: t.Union[str, None] = None + copy_cursor_line: t.Union[str, None] = None + copy_cursor_word: t.Union[str, None] = None + copy_cursor_x: t.Union[str, None] = None + copy_cursor_y: t.Union[str, None] = None + current_file: t.Union[str, None] = None + cursor_character: t.Union[str, None] = None + cursor_flag: t.Union[str, None] = None + cursor_x: t.Union[str, None] = None + cursor_y: t.Union[str, None] = None + history_bytes: t.Union[str, None] = None + history_limit: t.Union[str, None] = None + history_size: t.Union[str, None] = None + insert_flag: t.Union[str, None] = None + keypad_cursor_flag: t.Union[str, None] = None + keypad_flag: t.Union[str, None] = None + last_window_index: t.Union[str, None] = None + line: t.Union[str, None] = None + mouse_all_flag: t.Union[str, None] = None + mouse_any_flag: t.Union[str, None] = None + mouse_button_flag: t.Union[str, None] = None + mouse_sgr_flag: t.Union[str, None] = None + mouse_standard_flag: t.Union[str, None] = None + next_session_id: t.Union[str, None] = None + origin_flag: t.Union[str, None] = None + pane_active: t.Union[str, None] = None # Not detected by script + pane_bg: t.Union[str, None] = None + pane_bottom: t.Union[str, None] = None + pane_current_command: t.Union[str, None] = None + pane_current_path: t.Union[str, None] = None + pane_dead_signal: t.Union[str, None] = None + pane_dead_status: t.Union[str, None] = None + pane_dead_time: t.Union[str, None] = None + pane_fg: t.Union[str, None] = None + pane_height: t.Union[str, None] = None + pane_id: t.Union[str, None] = None + pane_index: t.Union[str, None] = None + pane_left: t.Union[str, None] = None + pane_pid: t.Union[str, None] = None + pane_right: t.Union[str, None] = None + pane_search_string: t.Union[str, None] = None + pane_start_command: t.Union[str, None] = None + pane_start_path: t.Union[str, None] = None + pane_tabs: t.Union[str, None] = None + pane_top: t.Union[str, None] = None + pane_tty: t.Union[str, None] = None + pane_width: t.Union[str, None] = None + + pid: t.Union[str, None] = None + scroll_position: t.Union[str, None] = None + scroll_region_lower: t.Union[str, None] = None + scroll_region_upper: t.Union[str, None] = None + search_match: t.Union[str, None] = None + selection_end_x: t.Union[str, None] = None + selection_end_y: t.Union[str, None] = None + selection_start_x: t.Union[str, None] = None + selection_start_y: t.Union[str, None] = None + session_activity: t.Union[str, None] = None + session_alerts: t.Union[str, None] = None + session_attached: t.Union[str, None] = None + session_attached_list: t.Union[str, None] = None + session_created: t.Union[str, None] = None + session_group: t.Union[str, None] = None + session_group_attached: t.Union[str, None] = None + session_group_list: t.Union[str, None] = None + session_group_size: t.Union[str, None] = None + session_id: t.Union[str, None] = None + session_last_attached: t.Union[str, None] = None + session_name: t.Union[str, None] = None + session_path: t.Union[str, None] = None + session_stack: t.Union[str, None] = None + session_windows: t.Union[str, None] = None + socket_path: t.Union[str, None] = None + start_time: t.Union[str, None] = None + uid: t.Union[str, None] = None + user: t.Union[str, None] = None + version: t.Union[str, None] = None + window_active: t.Union[str, None] = None # Not detected by script + window_active_clients: t.Union[str, None] = None + window_active_sessions: t.Union[str, None] = None + window_activity: t.Union[str, None] = None + window_cell_height: t.Union[str, None] = None + window_cell_width: t.Union[str, None] = None + window_height: t.Union[str, None] = None + window_id: t.Union[str, None] = None + window_index: t.Union[str, None] = None + window_layout: t.Union[str, None] = None + window_linked: t.Union[str, None] = None + window_linked_sessions: t.Union[str, None] = None + window_linked_sessions_list: t.Union[str, None] = None + window_marked_flag: t.Union[str, None] = None + window_name: t.Union[str, None] = None + window_offset_x: t.Union[str, None] = None + window_offset_y: t.Union[str, None] = None + window_panes: t.Union[str, None] = None + window_raw_flags: t.Union[str, None] = None + window_stack_index: t.Union[str, None] = None + window_width: t.Union[str, None] = None + wrap_flag: t.Union[str, None] = None + + def _refresh( + self, + obj_key: str, + obj_id: str, + list_cmd: "ListCmd" = "list-panes", + ) -> None: + assert isinstance(obj_id, str) + obj = fetch_obj( + obj_key=obj_key, obj_id=obj_id, list_cmd=list_cmd, server=self.server + ) + assert obj is not None + if obj is not None: + for k, v in obj.items(): + setattr(self, k, v) + + +def fetch_objs( + server: "Server", list_cmd: "ListCmd", list_extra_args: "ListExtraArgs" = None +) -> OutputsRaw: + formats = list(Obj.__dataclass_fields__.keys()) + + cmd_args: t.List[t.Union[str, int]] = list() + + if server.socket_name: + cmd_args.insert(0, f"-L{server.socket_name}") + if server.socket_path: + cmd_args.insert(0, f"-S{server.socket_path}") + tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] + + tmux_cmds = [ + *cmd_args, + list_cmd, + ] + + if list_extra_args is not None and isinstance(list_extra_args, t.Iterable): + tmux_cmds.extend(list(list_extra_args)) + + tmux_cmds.append("-F%s" % "".join(tmux_formats)) + + proc = tmux_cmd(*tmux_cmds) # output + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + obj_output = proc.stdout + + obj_formatters = [ + dict(zip(formats, formatter.split(FORMAT_SEPARATOR))) + for formatter in obj_output + ] + + # Filter empty values + obj_formatters_filtered = [ + {k: v for k, v in formatter.items() if v} for formatter in obj_formatters + ] + + return obj_formatters_filtered + + +def fetch_obj( + server: "Server", + obj_key: str, + obj_id: str, + list_cmd: "ListCmd" = "list-panes", + list_extra_args: "ListExtraArgs" = None, +) -> OutputRaw: + obj_formatters_filtered = fetch_objs( + server=server, list_cmd=list_cmd, list_extra_args=list_extra_args + ) + + obj = None + for _obj in obj_formatters_filtered: + if _obj.get(obj_key) == obj_id: + obj = _obj + + assert obj is not None + + return obj diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 469a0d9b3..bdc161b0d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -5,14 +5,17 @@ ~~~~~~~~~~~~ """ +import dataclasses import logging import typing as t +import warnings from typing import overload from libtmux.common import tmux_cmd +from libtmux.neo import Obj, fetch_obj -from . import exc -from .common import PaneDict, TmuxMappingObject, TmuxRelationalObject +from . import exc, formats +from .common import PaneDict if t.TYPE_CHECKING: from typing_extensions import Literal @@ -25,7 +28,8 @@ logger = logging.getLogger(__name__) -class Pane(TmuxMappingObject): +@dataclasses.dataclass() +class Pane(Obj): """ A :term:`tmux(1)` :term:`Pane` [pane_manual]_. @@ -68,47 +72,41 @@ class Pane(TmuxMappingObject): Accessed April 1st, 2018. """ - formatter_prefix = "pane_" - """Namespace used for :class:`~libtmux.common.TmuxMappingObject`""" - window: "Window" - """:class:`libtmux.Window` pane is linked to""" - session: "Session" - """:class:`libtmux.Session` pane is linked to""" server: "Server" - """:class:`libtmux.Server` pane is linked to""" - def __init__( - self, - window: "Window", - pane_id: t.Union[str, int], - **kwargs: t.Any, - ) -> None: - self.window = window - self.session = self.window.session - self.server = self.session.server - - self._pane_id = pane_id + def refresh(self) -> None: + assert isinstance(self.pane_id, str) + return super()._refresh(obj_key="pane_id", obj_id=self.pane_id) + + @classmethod + def from_pane_id(cls, server: "Server", pane_id: str) -> "Pane": + pane = fetch_obj( + obj_key="pane_id", + obj_id=pane_id, + server=server, + list_cmd="list-panes", + list_extra_args=("-a",), + ) + return cls(server=server, **pane) - self.server._update_panes() + # + # Relations + # @property - def _info(self) -> PaneDict: # type: ignore # mypy#1362 - attrs = {"pane_id": self._pane_id} - - # from https://github.com/serkanyersen/underscore.py - def by(val: PaneDict) -> bool: - for key in attrs.keys(): - try: - if attrs[key] != val[key]: - return False - except KeyError: - return False - return True + def window(self) -> "Window": + assert isinstance(self.window_id, str) + from libtmux.window import Window - target_panes = [s for s in self.server._panes if by(s)] + return Window.from_window_id(server=self.server, window_id=self.window_id) - return target_panes[0] + @property + def session(self) -> "Session": + return self.window.session + # + # Command (pane-scoped) + # def cmd(self, cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: """Return :meth:`Server.cmd` defaulting to ``target_pane`` as target. @@ -116,16 +114,56 @@ def cmd(self, cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: Specifying ``('-t', 'custom-target')`` or ``('-tcustom_target')`` in ``args`` will override using the object's ``pane_id`` as target. - - Returns - ------- - :class:`Server.cmd` """ if not any(arg.startswith("-t") for arg in args): - args = ("-t", self.get("pane_id")) + args + args = ("-t", self.pane_id) + args return self.server.cmd(cmd, *args, **kwargs) + # + # Commands (tmux-like) + # + def resize_pane(self, *args: t.Any, **kwargs: t.Any) -> "Pane": + """ + ``$ tmux resize-pane`` of pane and return ``self``. + + Parameters + ---------- + target_pane : str + ``target_pane``, or ``-U``,``-D``, ``-L``, ``-R``. + + Other Parameters + ---------------- + height : int + ``resize-pane -y`` dimensions + width : int + ``resize-pane -x`` dimensions + + Raises + ------ + exc.LibTmuxException + """ + if "height" in kwargs: + proc = self.cmd("resize-pane", "-y%s" % int(kwargs["height"])) + elif "width" in kwargs: + proc = self.cmd("resize-pane", "-x%s" % int(kwargs["width"])) + else: + proc = self.cmd("resize-pane", args[0]) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + self.refresh() + return self + + def capture_pane(self) -> t.Union[str, t.List[str]]: + """ + Capture text from pane. + + ``$ tmux capture-pane`` to pane. + """ + return self.cmd("capture-pane", "-p").stdout + def send_keys( self, cmd: str, @@ -204,11 +242,6 @@ def display_message( get_text : bool, optional Returns only text without displaying a message in target-client status line. - - Returns - ------- - :class:`list` - :class:`None` """ if get_text: return self.cmd("display-message", "-p", cmd).stdout @@ -216,14 +249,25 @@ def display_message( self.cmd("display-message", cmd) return None - def clear(self) -> None: - """Clear pane.""" - self.send_keys("reset") - - def reset(self) -> None: - """Reset and clear pane history.""" + # + # Commands ("climber"-helpers) + # + # These are commands that climb to the parent scope's methods with + # additional scoped window info. + # + def select_pane(self) -> "Pane": + """ + Select pane. Return ``self``. - self.cmd("send-keys", r"-R \; clear-history") + To select a window object asynchrously. If a ``pane`` object exists + and is no longer longer the current window, ``w.select_pane()`` + will make ``p`` the current pane. + """ + assert isinstance(self.pane_id, str) + pane = self.window.select_pane(self.pane_id) + if pane is None: + raise exc.LibTmuxException(f"Pane not found: {self}") + return pane def split_window( self, @@ -231,7 +275,7 @@ def split_window( vertical: bool = True, start_directory: t.Optional[str] = None, percent: t.Optional[int] = None, - ) -> "Pane": + ) -> "Pane": # New Pane, not self """ Split window at pane and return newly created :class:`Pane`. @@ -245,20 +289,19 @@ def split_window( specifies the working directory in which the new pane is created. percent: int, optional percentage to occupy with respect to current pane - - Returns - ------- - :class:`Pane` """ return self.window.split_window( - target=self.get("pane_id"), + target=self.pane_id, start_directory=start_directory, attach=attach, vertical=vertical, percent=percent, ) - def set_width(self, width: int) -> None: + # + # Commands (helpers) + # + def set_width(self, width: int) -> "Pane": """ Set width of pane. @@ -268,8 +311,9 @@ def set_width(self, width: int) -> None: pane width, in cells """ self.resize_pane(width=width) + return self - def set_height(self, height: int) -> None: + def set_height(self, height: int) -> "Pane": """ Set height of pane. @@ -279,82 +323,102 @@ def set_height(self, height: int) -> None: height of pain, in cells """ self.resize_pane(height=height) + return self - def resize_pane(self, *args: t.Any, **kwargs: t.Any) -> "Pane": + def enter(self) -> "Pane": """ - ``$ tmux resize-pane`` of pane and return ``self``. + Send carriage return to pane. - Parameters - ---------- - target_pane : str - ``target_pane``, or ``-U``,``-D``, ``-L``, ``-R``. + ``$ tmux send-keys`` send Enter to the pane. + """ + self.cmd("send-keys", "Enter") + return self - Other Parameters - ---------------- - height : int - ``resize-pane -y`` dimensions - width : int - ``resize-pane -x`` dimensions + def clear(self) -> "Pane": + """Clear pane.""" + self.send_keys("reset") + return self - Returns - ------- - :class:`Pane` + def reset(self) -> "Pane": + """Reset and clear pane history.""" - Raises - ------ - exc.LibTmuxException - """ - if "height" in kwargs: - proc = self.cmd("resize-pane", "-y%s" % int(kwargs["height"])) - elif "width" in kwargs: - proc = self.cmd("resize-pane", "-x%s" % int(kwargs["width"])) - else: - proc = self.cmd("resize-pane", args[0]) + self.cmd("send-keys", r"-R \; clear-history") + return self - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + # + # Dunder + # + def __eq__(self, other: object) -> bool: + assert isinstance(other, Pane) + return self.pane_id == other.pane_id - self.server._update_panes() - return self + def __repr__(self) -> str: + return "{}({} {})".format(self.__class__.__name__, self.pane_id, self.window) - def enter(self) -> None: - """ - Send carriage return to pane. + # + # Aliases + # + @property + def id(self) -> t.Optional[str]: + """Alias of :attr:`Pane.pane_id` - ``$ tmux send-keys`` send Enter to the pane. - """ - self.cmd("send-keys", "Enter") + >>> pane.id + '%1' - def capture_pane(self) -> t.Union[str, t.List[str]]: + >>> pane.id == pane.pane_id + True """ - Capture text from pane. + return self.pane_id - ``$ tmux capture-pane`` to pane. + @property + def index(self) -> t.Optional[str]: + """Alias of :attr:`Pane.pane_index` - Returns - ------- - :class:`list` + >>> pane.index + '0' + + >>> pane.index == pane.pane_index + True """ - return self.cmd("capture-pane", "-p").stdout + return self.pane_index - def select_pane(self) -> "Pane": + @property + def height(self) -> t.Optional[str]: + """Alias of :attr:`Pane.pane_height` + + >>> pane.height.isdigit() + True + + >>> pane.height == pane.pane_height + True """ - Select pane. Return ``self``. + return self.pane_height - To select a window object asynchrously. If a ``pane`` object exists - and is no longer longer the current window, ``w.select_pane()`` - will make ``p`` the current pane. + @property + def width(self) -> t.Optional[str]: + """Alias of :attr:`Pane.pane_width` + + >>> pane.width.isdigit() + True - Returns - ------- - :class:`pane` + >>> pane.width == pane.pane_width + True """ - pane = self.window.select_pane(self._pane_id) - if pane is None: - raise exc.LibTmuxException(f"Pane not found: {self}") - return pane + return self.pane_width - def __repr__(self) -> str: - return "{}({} {})".format( - self.__class__.__name__, self.get("pane_id"), self.window - ) + # + # Legacy + # + def get(self, key: str, default: t.Optional[t.Any] = None) -> t.Any: + """ + .. deprecated:: 0.16 + """ + warnings.warn("Pane.get() is deprecated") + return getattr(self, key, default) + + def __getitem__(self, key: str) -> t.Any: + """ + .. deprecated:: 0.16 + """ + warnings.warn(f"Item lookups, e.g. pane['{key}'] is deprecated") + return getattr(self, key) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index ce62da913..8a8124c1d 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -178,8 +178,8 @@ def session(request: pytest.FixtureRequest, server: Server) -> "Session": # find current sessions prefixed with tmuxp old_test_sessions = [] - for s in server._sessions: - old_name = s.get("session_name") + for s in server.sessions: + old_name = s.session_name if old_name is not None and old_name.startswith(TEST_SESSION_PREFIX): old_test_sessions.append(old_name) @@ -194,7 +194,7 @@ def session(request: pytest.FixtureRequest, server: Server) -> "Session": Make sure that tmuxp can :ref:`test_builder_visually` and switches to the newly created session for that testcase. """ - session_id = session.get("session_id") + session_id = session.session_id assert session_id is not None try: @@ -206,7 +206,7 @@ def session(request: pytest.FixtureRequest, server: Server) -> "Session": for old_test_session in old_test_sessions: logger.debug(f"Old test test session {old_test_session} found. Killing it.") server.kill_session(old_test_session) - assert TEST_SESSION_NAME == session.get("session_name") + assert TEST_SESSION_NAME == session.session_name assert TEST_SESSION_NAME != "tmuxp" return session diff --git a/src/libtmux/server.py b/src/libtmux/server.py index cfc0bda44..af3ed718a 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -9,16 +9,20 @@ import shutil import subprocess import typing as t +import warnings +from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd +from libtmux.neo import fetch_objs +from libtmux.pane import Pane from libtmux.session import Session +from libtmux.window import Window from . import exc, formats from .common import ( EnvironmentMixin, PaneDict, SessionDict, - TmuxRelationalObject, WindowDict, has_gte_version, session_check_name, @@ -27,14 +31,13 @@ logger = logging.getLogger(__name__) -class Server(TmuxRelationalObject["Session", "SessionDict"], EnvironmentMixin): - +class Server(EnvironmentMixin): """ The :term:`tmux(1)` :term:`Server` [server_manual]_. - - :attr:`Server._sessions` [:class:`Session`, ...] + - :attr:`Server.sessions` [:class:`Session`, ...] - - :attr:`Session._windows` [:class:`Window`, ...] + - :attr:`Session.windows` [:class:`Window`, ...] - :attr:`Window._panes` [:class:`Pane`, ...] @@ -153,10 +156,18 @@ def raise_if_dead(self) -> None: subprocess.check_call([tmux_bin] + cmd_args) + # + # Command + # def cmd(self, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: """ Execute tmux command and return output. + Examples + -------- + >>> server.cmd('display-message', 'hi') + + Returns ------- :class:`common.tmux_cmd` @@ -185,233 +196,35 @@ def cmd(self, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: return tmux_cmd(*cmd_args, **kwargs) - def _list_sessions(self) -> t.List[SessionDict]: - """ - Return list of sessions in :py:obj:`dict` form. - - Retrieved from ``$ tmux(1) list-sessions`` stdout. - - The :py:obj:`list` is derived from ``stdout`` in - :class:`common.tmux_cmd` which wraps :py:class:`subprocess.Popen`. - - Returns - ------- - list of dict - """ - - sformats = formats.SESSION_FORMATS - tmux_formats = ["#{%s}" % f for f in sformats] - - tmux_args = ("-F%s" % formats.FORMAT_SEPARATOR.join(tmux_formats),) # output - - list_sessions_cmd = self.cmd("list-sessions", *tmux_args) - - if list_sessions_cmd.stderr: - raise exc.LibTmuxException(list_sessions_cmd.stderr) - - sformats = formats.SESSION_FORMATS - tmux_formats = ["#{%s}" % format for format in sformats] - sessions_output = list_sessions_cmd.stdout - - # combine format keys with values returned from ``tmux list-sessions`` - sessions_formatters = [ - dict(zip(sformats, session.split(formats.FORMAT_SEPARATOR))) - for session in sessions_output - ] - - # clear up empty dict - sessions_formatters_filtered = [ - {k: v for k, v in session.items() if v} for session in sessions_formatters - ] - - return sessions_formatters_filtered - - @property - def _sessions(self) -> t.List[SessionDict]: - """Property / alias to return :meth:`~._list_sessions`.""" - - return self._list_sessions() - - def list_sessions(self) -> t.List[Session]: - """ - Return list of :class:`Session` from the ``tmux(1)`` session. - - Returns - ------- - list of :class:`Session` - """ - return [Session(server=self, **s) for s in self._sessions] - - @property - def sessions(self) -> t.List[Session]: - """Property / alias to return :meth:`~.list_sessions`.""" - try: - return self.list_sessions() - except Exception: - return [] - - #: Alias :attr:`sessions` for :class:`~libtmux.common.TmuxRelationalObject` - children = sessions # type: ignore - - def _list_windows(self) -> t.List[WindowDict]: - """ - Return list of windows in :py:obj:`dict` form. - - Retrieved from ``$ tmux(1) list-windows`` stdout. - - The :py:obj:`list` is derived from ``stdout`` in - :class:`common.tmux_cmd` which wraps :py:class:`subprocess.Popen`. - - Returns - ------- - list of dict - """ - - wformats = ["session_name", "session_id"] + formats.WINDOW_FORMATS - tmux_formats = ["#{%s}" % format for format in wformats] - - proc = self.cmd( - "list-windows", # ``tmux list-windows`` - "-a", - "-F%s" % formats.FORMAT_SEPARATOR.join(tmux_formats), # output - ) - - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) - - window_output = proc.stdout - - wformats = ["session_name", "session_id"] + formats.WINDOW_FORMATS - - # combine format keys with values returned from ``tmux list-windows`` - window_formatters = [ - dict(zip(wformats, window.split(formats.FORMAT_SEPARATOR))) - for window in window_output - ] - - # clear up empty dict - window_formatters_filtered = [ - {k: v for k, v in window.items() if v} for window in window_formatters - ] - - # tmux < 1.8 doesn't have window_id, use window_name - for w in window_formatters_filtered: - if "window_id" not in w: - w["window_id"] = w["window_name"] - - if self._windows: - self._windows[:] = [] - - self._windows.extend(window_formatters_filtered) - - return self._windows - - def _update_windows(self) -> "Server": - """ - Update internal window data and return ``self`` for chainability. - - Returns - ------- - :class:`Server` - """ - self._list_windows() - return self - - def _list_panes(self) -> t.List[PaneDict]: - """ - Return list of panes in :py:obj:`dict` form. - - Retrieved from ``$ tmux(1) list-panes`` stdout. - - The :py:obj:`list` is derived from ``stdout`` in - :class:`util.tmux_cmd` which wraps :py:class:`subprocess.Popen`. - - Returns - ------- - list - """ - - pformats = [ - "session_name", - "session_id", - "window_index", - "window_id", - "window_name", - ] + formats.PANE_FORMATS - tmux_formats = [("#{%%s}%s" % formats.FORMAT_SEPARATOR) % f for f in pformats] - - proc = self.cmd("list-panes", "-a", "-F%s" % "".join(tmux_formats)) # output - - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) - - pane_output = proc.stdout - - pformats = [ - "session_name", - "session_id", - "window_index", - "window_id", - "window_name", - ] + formats.PANE_FORMATS - - # combine format keys with values returned from ``tmux list-panes`` - pane_formatters = [ - dict(zip(pformats, formatter.split(formats.FORMAT_SEPARATOR))) - for formatter in pane_output - ] - - # Filter empty values - pane_formatters_filtered = [ - { - k: v for k, v in formatter.items() if v or k == "pane_current_path" - } # preserve pane_current_path, in case it entered a new process - # where we may not get a cwd from. - for formatter in pane_formatters - ] - - if self._panes: - self._panes[:] = [] - - self._panes.extend(pane_formatters_filtered) - - return self._panes - - def _update_panes(self) -> "Server": - """ - Update internal pane data and return ``self`` for chainability. - - Returns - ------- - :class:`Server` - """ - self._list_panes() - return self - @property def attached_sessions(self) -> t.List[Session]: """ Return active :class:`Session` objects. + Examples + -------- + >>> server.attached_sessions + [] + Returns ------- list of :class:`Session` """ - try: - sessions = self._sessions + sessions = self.sessions attached_sessions = list() for session in sessions: - attached = session.get("session_attached") + attached = session.session_attached # for now session_active is a unicode if attached != "0": - logger.debug(f"session {session.get('name')} attached") + logger.debug(f"session {session.name} attached") attached_sessions.append(session) else: continue - return [Session(server=self, **s) for s in attached_sessions] or [] + return attached_sessions + # return [Session(**s) for s in attached_sessions] or None except Exception: return [] @@ -610,9 +423,6 @@ def new_session( logger.debug(f"creating session {session_name}") - sformats = formats.SESSION_FORMATS - tmux_formats = ["#{%s}" % f for f in sformats] - env = os.environ.get("TMUX") if env: @@ -620,7 +430,7 @@ def new_session( tmux_args: t.Tuple[t.Union[str, int], ...] = ( "-P", - "-F%s" % formats.FORMAT_SEPARATOR.join(tmux_formats), # output + "-F#{session_id}", # output ) if session_name is not None: @@ -653,14 +463,172 @@ def new_session( if env: os.environ["TMUX"] = env - # Combine format keys with values returned from ``tmux list-windows`` - session_params = dict( - zip(sformats, session_stdout.split(formats.FORMAT_SEPARATOR)) + session_formatters = dict( + zip(["session_id"], session_stdout.split(formats.FORMAT_SEPARATOR)) + ) + + return Session.from_session_id( + server=self, session_id=session_formatters["session_id"] ) - # Filter empty values - session_params = {k: v for k, v in session_params.items() if v} + # + # Relations + # + @property + def sessions(self) -> QueryList[Session]: # type:ignore + """Sessions belonging server. + + Can be accessed via + :meth:`.sessions.get() ` and + :meth:`.sessions.filter() ` + """ + sessions: t.List["Session"] = [] + + try: + for obj in fetch_objs( + list_cmd="list-sessions", + server=self, + ): + sessions.append(Session(server=self, **obj)) + except Exception: + pass - session = Session(server=self, **session_params) + return QueryList(sessions) - return session + @property + def windows(self) -> QueryList[Window]: # type:ignore + """Windows belonging server. + + Can be accessed via + :meth:`.sessions.get() ` and + :meth:`.sessions.filter() ` + """ + windows: t.List["Window"] = [] + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=("-a",), + server=self, + ): + windows.append(Window(server=self, **obj)) + + return QueryList(windows) + + @property + def panes(self) -> QueryList[Pane]: # type:ignore + """Panes belonging server. + + Can be accessed via + :meth:`.sessions.get() ` and + :meth:`.sessions.filter() ` + """ + panes: t.List["Pane"] = [] + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-s"], + server=self, + ): + panes.append(Pane(server=self, **obj)) + + return QueryList(panes) + + def _list_panes(self) -> t.List[PaneDict]: + """ + Return list of panes in :py:obj:`dict` form. + + Retrieved from ``$ tmux(1) list-panes`` stdout. + + The :py:obj:`list` is derived from ``stdout`` in + :class:`util.tmux_cmd` which wraps :py:class:`subprocess.Popen`. + """ + return [p.__dict__ for p in self.panes] + + def _update_panes(self) -> "Server": + """ + Update internal pane data and return ``self`` for chainability. + + Returns + ------- + :class:`Server` + """ + self._list_panes() + return self + + # + # Legacy: Redundant stuff we want to remove + # + def get_by_id(self, id: str) -> t.Optional[Session]: + """ + .. deprecated:: 0.16 + """ + warnings.warn("Server.get_by_id() is deprecated") + return self.sessions.get(session_id=id, default=None) + + def where(self, kwargs: t.Dict[str, t.Any]) -> t.List[Session]: + """ + .. deprecated:: 0.16 + """ + warnings.warn("Server.find_where() is deprecated") + try: + return self.sessions.filter(**kwargs) + except IndexError: + return [] + + def find_where(self, kwargs: t.Dict[str, t.Any]) -> t.Optional[Session]: + """ + .. deprecated:: 0.16 + """ + warnings.warn("Server.find_where() is deprecated") + return self.sessions.get(default=None, **kwargs) + + def _list_windows(self) -> t.List[WindowDict]: + """Return list of windows in :py:obj:`dict` form. + + Retrieved from ``$ tmux(1) list-windows`` stdout. + + The :py:obj:`list` is derived from ``stdout`` in + :class:`common.tmux_cmd` which wraps :py:class:`subprocess.Popen`. + + .. deprecated:: 0.16 + """ + warnings.warn("Server._list_windows() is deprecated") + return [w.__dict__ for w in self.windows] + + def _update_windows(self) -> "Server": + """Update internal window data and return ``self`` for chainability. + + .. deprecated:: 0.16 + """ + warnings.warn("Server._update_windows() is deprecated") + self._list_windows() + return self + + @property + def _sessions(self) -> t.List[SessionDict]: + """Property / alias to return :meth:`~._list_sessions`. + + .. deprecated:: 0.16 + """ + warnings.warn("Server._sessions is deprecated") + return self._list_sessions() + + def _list_sessions(self) -> t.List["SessionDict"]: + """ + .. deprecated:: 0.16 + """ + warnings.warn("Server._list_sessions() is deprecated") + return [s.__dict__ for s in self.sessions] + + def list_sessions(self) -> t.List[Session]: + """Return list of :class:`Session` from the ``tmux(1)`` session. + + .. deprecated:: 0.16 + + Returns + ------- + list of :class:`Session` + """ + warnings.warn("Server.list_sessions is deprecated") + return self.sessions + + #: Alias :attr:`sessions` for :class:`~libtmux.common.TmuxRelationalObject` + children = sessions diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 37cd8c122..b1ffabf0f 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -4,19 +4,22 @@ ~~~~~~~~~~~~~~~ """ +import dataclasses import logging import os import typing as t +import warnings +from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd +from libtmux.formats import FORMAT_SEPARATOR +from libtmux.neo import Obj, fetch_obj, fetch_objs +from libtmux.pane import Pane from libtmux.window import Window -from . import exc, formats +from . import exc from .common import ( EnvironmentMixin, - SessionDict, - TmuxMappingObject, - TmuxRelationalObject, WindowDict, handle_option_error, has_gte_version, @@ -25,16 +28,48 @@ ) if t.TYPE_CHECKING: - from .pane import Pane from .server import Server logger = logging.getLogger(__name__) -class Session( - TmuxMappingObject, TmuxRelationalObject["Window", "WindowDict"], EnvironmentMixin -): +# Dataclasses define the schema in a typed annotation way. +# +# Users load through YAML, JSON, TOML, or any sort of data entry, these are parsed +# into dataclasses. Bi-directionals. +# +# This means the "intermediary language" is actually a first class, typed, declarative +# python syntax. It's so first class, it's used in tests, exposed as an experimental API +# with full intention for users to bootstrap sessions fast. +# +# Pitfalls: Lock / pin your versions. APIs may change. It's a Public API - but +# considered experimental in the sense we . +# +# The good news is, since it's typed, you will be able to refactor quickly. In fact, +# it's easier to factor these than a config where there's no safety. +# +# Session.build_workspace( +# windows=[ +# Window( +# panes=[ +# panes=[] +# ] +# +# ) +# ], +# settings=WorkspaceBuilderSettings( +# backend='libtmux.ext.workspace_builder.backends.default' +# ) +# ) +# WorkspaceBuilder: +# backend = 'libtmux.ext.workspace_builder.backends.default' +# + + +@dataclasses.dataclass() +class Session(Obj, EnvironmentMixin): + """ A :term:`tmux(1)` :term:`Session` [session_manual]_. @@ -71,40 +106,62 @@ class Session( https://man.openbsd.org/tmux.1#DESCRIPTION. Accessed April 1st, 2018. """ - child_id_attribute = "window_id" - """Unique child ID key for :class:`~libtmux.common.TmuxRelationalObject`""" - formatter_prefix = "session_" - """Namespace used for :class:`~libtmux.common.TmuxMappingObject`""" server: "Server" - """:class:`libtmux.server.Server` session is linked to""" - def __init__(self, server: "Server", session_id: str, **kwargs: t.Any) -> None: - EnvironmentMixin.__init__(self) - self.server = server + def refresh(self) -> None: + assert isinstance(self.session_id, str) + return super()._refresh( + obj_key="session_id", obj_id=self.session_id, list_cmd="list-sessions" + ) - self._session_id = session_id - self.server._update_windows() + @classmethod + def from_session_id(cls, server: "Server", session_id: str) -> "Session": + session = fetch_obj( + obj_key="session_id", + obj_id=session_id, + list_cmd="list-sessions", + server=server, + ) + return cls(server=server, **session) + # + # Relations + # @property - def _info(self) -> t.Optional[SessionDict]: # type: ignore # mypy#1362 - attrs = {"session_id": str(self._session_id)} - - def by(val: SessionDict) -> bool: - for key in attrs.keys(): - try: - if attrs[key] != val[key]: - return False - except KeyError: - return False - return True - - target_sessions = [s for s in self.server._sessions if by(s)] - try: - return target_sessions[0] - except IndexError as e: - logger.error(e) - return None + def windows(self) -> QueryList["Window"]: # type:ignore + """Windows belonging session. + + Can be accessed via + :meth:`.windows.get() ` and + :meth:`.windows.filter() ` + """ + windows: t.List["Window"] = [] + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=["-t", str(self.session_id)], + server=self.server, + ): + if obj.get("session_id") == self.session_id: + windows.append(Window(server=self.server, **obj)) + + return QueryList(windows) + @property + def panes(self) -> QueryList["Pane"]: # type:ignore + panes: t.List["Pane"] = [] + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-s", "-t", str(self.session_id)], + server=self.server, + ): + if obj.get("session_id") == self.session_id: + panes.append(Pane(server=self.server, **obj)) + + return QueryList(panes) + + # + # Command + # def cmd(self, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: """ Return :meth:`server.cmd`. @@ -124,43 +181,258 @@ def cmd(self, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: # insert -t immediately after 1st arg, as per tmux format new_args: t.Tuple[str, ...] = tuple() new_args += (args[0],) + assert isinstance(self.session_id, str) new_args += ( "-t", - self.id, + self.session_id, ) new_args += tuple(args[1:]) args = new_args return self.server.cmd(*args, **kwargs) - def attach_session(self) -> None: + # + # Commands (tmux-like) + # + def set_option( + self, option: str, value: t.Union[str, int], _global: bool = False + ) -> "Session": + """ + Set option ``$ tmux set-option