From: Christian Heller Date: Wed, 24 Sep 2025 10:48:00 +0000 (+0200) Subject: Turn into installable by way of plomlib's install procedures. X-Git-Url: https://plomlompom.com/repos/?a=commitdiff_plain;h=39b69c93930729c1c1e519aca9862f0c4b42fa2d;p=ircplom Turn into installable by way of plomlib's install procedures. --- diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42cf7f3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "plomlib"] + path = plomlib + url = https://plomlompom.com/repos/clone/plomlib diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..40deca4 --- /dev/null +++ b/install.sh @@ -0,0 +1,3 @@ +#!/usr/bin/sh +./plomlib/sh/install.sh ircplom +ircplom install_deps diff --git a/ircplom.py b/ircplom.py deleted file mode 100755 index 42f9dc9..0000000 --- a/ircplom.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -'Attempt at an IRC client.' -from queue import SimpleQueue -from sys import argv -from ircplom.events import ExceptionEvent, QuitEvent -from ircplom.client import ClientsDb, ClientEvent, NewClientEvent -from ircplom.tui_base import BaseTui, Terminal, TerminalInterface, TuiEvent -from ircplom.client_tui import ClientTui -from ircplom.testing import TestTerminal, TestingClientTui - - -def main_loop(cls_term: type[TerminalInterface], cls_tui: type[BaseTui] - ) -> None: - 'Main execution code / loop.' - q_events: SimpleQueue = SimpleQueue() - clients_db: ClientsDb = {} - try: - with cls_term(_q_out=q_events).setup() as term: - tui = cls_tui(_q_out=q_events, term=term) - while True: - event = q_events.get() - if isinstance(event, QuitEvent): - break - if isinstance(event, ExceptionEvent): - raise event.exception - if isinstance(event, TuiEvent): - event.affect(tui) - elif isinstance(event, NewClientEvent): - event.affect(clients_db) - elif isinstance(event, ClientEvent): - event.affect(clients_db[event.client_id]) - finally: - for client in clients_db.values(): - client.close() - - -if __name__ == '__main__': - if len(argv) > 1 and argv[1] == 'test': - main_loop(TestTerminal, TestingClientTui) - print('test finished') - else: - main_loop(Terminal, ClientTui) diff --git a/ircplom/__init__.py b/ircplom/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ircplom/client.py b/ircplom/client.py deleted file mode 100644 index d0bc4b8..0000000 --- a/ircplom/client.py +++ /dev/null @@ -1,984 +0,0 @@ -'High-level IRC protocol / server connection management.' -# built-ins -from abc import ABC, abstractmethod -from base64 import b64encode -from dataclasses import dataclass, InitVar -from getpass import getuser -from threading import Thread -from typing import (Any, Callable, Collection, Generic, Iterable, Iterator, - Optional, Self, Set, TypeVar) -# ourselves -from ircplom.events import ( - AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin) -from ircplom.irc_conn import ( - BaseIrcConnection, IrcConnAbortException, IrcMessage, - ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL) -from ircplom.msg_parse_expectations import MSG_EXPECTATIONS - - -_NAMES_DESIRED_SERVER_CAPS = ('sasl',) - - -class SendFail(BaseException): - 'When Client.send fails.' - - -class TargetUserOffline(BaseException): - 'When according to server our target user is not to be found.' - - -class ImplementationFail(BaseException): - 'When no matching parser found for server message.' - - -class AutoAttrMixin: - 'Ensures attribute as defined by annotations along MRO' - - @classmethod - def _deep_annotations(cls) -> dict[str, type]: - types: dict[str, type] = {} - for c in cls.__mro__: - if hasattr(c, '__annotations__'): - types = c.__annotations__ | types - return {k: v for k, v in types.items() if k[0] != '_'} - - def __getattribute__(self, key: str): - if key[0] != '_' and (cls := self._deep_annotations().get(key, None)): - try: - return super().__getattribute__(key) - except AttributeError: - setattr(self, key, self._make_attr(cls, key)) - return super().__getattribute__(key) - - -class _Clearable(ABC): - - @abstractmethod - def clear(self) -> None: - 'Zero internal knowledge.' - - -DictItem = TypeVar('DictItem') - - -class Dict(_Clearable, Generic[DictItem]): - 'Customized dict replacement.' - - def __init__(self, **kwargs) -> None: - self._dict: dict[str, DictItem] = {} - super().__init__(**kwargs) - - def keys(self) -> tuple[str, ...]: - 'Keys of item registrations.' - return tuple(self._dict.keys()) - - def __getitem__(self, key: str) -> DictItem: - return self._dict[key] - - def clear(self) -> None: - self._dict.clear() - - @property - def _item_cls(self): - orig_cls = (self.__orig_class__ if hasattr(self, '__orig_class__') - else self.__orig_bases__[0]) - return orig_cls.__args__[0] - - -class _Dict(Dict[DictItem]): - - def __setitem__(self, key: str, val: DictItem) -> None: - assert isinstance(val, self._item_cls) - self._dict[key] = val - - def __delitem__(self, key: str) -> None: - del self._dict[key] - - def values(self) -> tuple[DictItem, ...]: - 'Items registered.' - return tuple(self._dict.values()) - - @staticmethod - def key_val_from_eq_str(eq_str: str) -> tuple[str, str]: - 'Split eq_str by "=", and if none, into eq_str and "".' - toks = eq_str.split('=', maxsplit=1) - return toks[0], '' if len(toks) == 1 else toks[1] - - -class _Completable(ABC): - completed: Any - - @abstractmethod - def complete(self) -> None: - 'Set .completed to "complete" value if possible of current state.' - - -class _CompletableStringsCollection(_Completable, Collection): - _collected: Collection[str] - completed: Optional[Collection[str]] = None - - @abstractmethod - def _copy_collected(self) -> Collection[str]: - pass - - def complete(self) -> None: - self.completed = self._copy_collected() - - def __len__(self) -> int: - assert self.completed is not None - return len(self.completed) - - def __contains__(self, item) -> bool: - assert self.completed is not None - assert isinstance(item, str) - return item in self.completed - - def __iter__(self) -> Iterator[str]: - assert self.completed is not None - yield from self.completed - - -class _CompletableStringsSet(_CompletableStringsCollection, Set): - _collected: set[str] - completed: Optional[set[str]] = None - - def __init__(self) -> None: - self._collected = set() - - def _copy_collected(self) -> set[str]: - return set(self._collected) - - def _on_set(self, m_name: str, item: str, on_complete: bool, exists: bool - ) -> None: - method = getattr(self._collected, m_name) - if on_complete: - assert self.completed is not None - assert (item in self.completed) == exists - assert (item in self._collected) == exists - method(item) - self.complete() - else: - assert self.completed is None - assert (item in self._collected) == exists - method(item) - - def completable_add(self, item: str, on_complete: bool) -> None: - 'Put item into collection.' - self._on_set('add', item, on_complete, False) - - def completable_remove(self, item: str, on_complete: bool) -> None: - 'Remove item from collection.' - self._on_set('remove', item, on_complete, True) - - def intersection(self, *others: Iterable[str]) -> set[str]: - 'Compare self to other set(s).' - assert self.completed is not None - result = self.completed - for other in others: - assert isinstance(other, set) - result = result.intersection(other) - return result - - -class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection): - _collected: tuple[str, ...] = tuple() - completed: Optional[tuple[str, ...]] = tuple() - - def _copy_collected(self) -> tuple[str, ...]: - return tuple(self._collected) - - def append(self, value: str, complete=False) -> None: - 'Append value to list.' - self._collected += (value,) - if complete: - self.complete() - - def clear(self) -> None: - self._collected = tuple() - self.complete() - - -class _UpdatingMixin: - - def __init__(self, on_update: Callable, **kwargs) -> None: - super().__init__(**kwargs) - self._on_update = on_update - - -class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin): - - def _make_attr(self, cls: Callable, key: str): - return cls(on_update=lambda *steps: self._on_update(key, *steps)) - - def __setattr__(self, key: str, value) -> None: - super().__setattr__(key, value) - if hasattr(self, '_on_update') and key in self._deep_annotations(): - self._on_update(key) - - def attr_names(self) -> tuple[str, ...]: - 'Names of (deeply) annotated attributes.' - return tuple(self._deep_annotations().keys()) - - -class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]): - _create_if_none: Optional[dict[str, Any]] = None - - def __bool__(self) -> bool: - return bool(self._dict) - - def __getitem__(self, key: str) -> DictItem: - if key not in self._dict: - if self._create_if_none is not None: - kw = {} | self._create_if_none - if _UpdatingMixin in self._item_cls.__mro__: - kw |= {'on_update': - lambda *steps: self._on_update(key, *steps)} - self[key] = self._item_cls(**kw) - return super().__getitem__(key) - - def __setitem__(self, key: str, val: DictItem) -> None: - super().__setitem__(key, val) - self._on_update(key) - - def __delitem__(self, key: str) -> None: - super().__delitem__(key) - self._on_update(key) - - def clear(self) -> None: - super().clear() - self._on_update() - - -class _UpdatingCompletable(_UpdatingMixin, _Completable): - - def complete(self) -> None: - super().complete() # type: ignore - self._on_update() - - -class _UpdatingCompletableStringsSet( - _UpdatingCompletable, _CompletableStringsSet): - pass - - -class _UpdatingCompletableStringsOrdered( - _UpdatingCompletable, _CompletableStringsOrdered): - pass - - -@dataclass -class IrcConnSetup: - 'All we need to know to set up a new Client connection.' - hostname: str = '' - port: int = 0 - nick_wanted: str = '' - user_wanted: str = '' - realname: str = '' - password: str = '' - - -class ChatMessage: - 'Collects all we want to know on incoming PRIVMSG or NOTICE chat message.' - content: str = '' - sender: str = '' - target: str = '' - is_notice: bool = False - - def __str__(self) -> str: - return f'{"N" if self.is_notice else "P"}|'\ - + f'{self.sender}|{self.target}|{self.content}' - - def __bool__(self) -> bool: - return bool(self.content + self.sender + self.target) | self.is_notice - - -class SharedClientDbFields(IrcConnSetup): - 'API for fields shared directly in name and type with TUI.' - connection_state: str = '' - isupport: Dict[str] - motd: Iterable[str] - sasl_account: str = '' - sasl_auth_state: str = '' - message: ChatMessage = ChatMessage() - - def is_chan_name(self, name: str) -> bool: - 'Tests name to match CHANTYPES prefixes.' - return name[0] in self.isupport['CHANTYPES'] - - -@dataclass -class NickUserHost: - 'Combination of nickname, username on host, and host.' - nick: str = '?' - user: str = '?' - host: str = '?' - - def __str__(self) -> str: - return f'{self.nick}!{self.user}@{self.host}' - - -class User(NickUserHost): - 'Adds to NickUserHost non-naming-specific attributes.' - modes: str = '?' - exit_msg: str = '' - - -@dataclass -class ServerCapability: - 'Public API for CAP data.' - data: str = '' - enabled: bool = False - - -@dataclass -class Topic: - 'Collects both setter and content of channel topic.' - what: str = '' - who: Optional[NickUserHost] = None - - -class Channel: - 'Collects .topic, and in .user_ids inhabitant IDs.' - topic: Topic - user_ids: Iterable[str] - exits: Dict[str] - - -@dataclass -class _ClientIdMixin: - 'Collects a Client\'s ID at .client_id.' - client_id: str - - -@dataclass -class ClientQueueMixin(QueueMixin, _ClientIdMixin): - 'To QueueMixin adds _cput to send ClientEvent for self.' - - def _client_trigger(self, t_method: str, **kwargs) -> None: - self._put(ClientEvent.affector(t_method, client_id=self.client_id - ).kw(**kwargs)) - - -@dataclass -class IrcConnection(BaseIrcConnection, _ClientIdMixin): - 'Parent extended to work with Client.' - hostname: InitVar[str] # needed by BaseIrcConnection, but not desired as - port: InitVar[int] # dataclass fields, only for __post_init__ call - - def __post_init__(self, hostname, port, **kwargs) -> None: - super().__init__(hostname=hostname, port=port, _q_out=self._q_out, - **kwargs) - - def _make_recv_event(self, msg: IrcMessage) -> 'ClientEvent': - return ClientEvent.affector('handle_msg', client_id=self.client_id - ).kw(msg=msg) - - def _on_handled_loop_exception(self, e: IrcConnAbortException - ) -> 'ClientEvent': - return ClientEvent.affector('on_handled_loop_exception', - client_id=self.client_id).kw(e=e) - - -class _CompletableTopic(_Completable, Topic): - completed: Optional[Topic] = None - - def complete(self) -> None: - assert self.who is not None - copy = _NickUserHost.from_str(str(self.who)) - self.completed = Topic(self.what, - NickUserHost(copy.nick, copy.user, copy.host)) - - -class _Channel(Channel): - user_ids: _CompletableStringsSet - topic: _CompletableTopic - exits: _Dict[str] - - def __init__(self, - userid_for_nickuserhost: Callable, - get_membership_prefixes: Callable, - purge_users: Callable, - **kwargs - ) -> None: - self._userid_for_nickuserhost = userid_for_nickuserhost - self._get_membership_prefixes = get_membership_prefixes - self.purge_users = purge_users - super().__init__(**kwargs) - - def add_from_namreply(self, items: tuple[str, ...]) -> None: - 'Add to .user_ids items assumed as nicknames with membership prefixes.' - for item in items: - n_u_h = NickUserHost(item.lstrip(self._get_membership_prefixes())) - user_id = self._userid_for_nickuserhost(n_u_h, create_if_none=True) - self.user_ids.completable_add(user_id, on_complete=False) - - def add_user(self, user: '_User') -> None: - 'To .user_ids add user.nickname, keep .user_ids declared complete.' - user_id = self._userid_for_nickuserhost(user, create_if_none=True, - updating=True) - self.user_ids.completable_add(user_id, on_complete=True) - - def remove_user(self, user: '_User', msg: str) -> None: - 'From .user_ids remove .nickname, keep .user_ids declared complete.' - self.exits[user.id_] = msg - self.user_ids.completable_remove(user.id_, on_complete=True) - del self.exits[user.id_] - self.purge_users() - - -class _ChatMessage(ChatMessage): - - def __init__(self, - sender: str | NickUserHost = '', - db: Optional['_ClientDb'] = None - ) -> None: - self.sender = sender if isinstance(sender, str) else sender.nick - self._db = db - - def to(self, target: str) -> Self: - 'Extend self with .target, return self.' - self.target = target - return self - - def __setattr__(self, key: str, value: str) -> None: - if key in {'privmsg', 'notice'}: - assert self._db is not None - self.is_notice = key == 'notice' - self.content = value - self._db.message = self - # to clean update cache, enabling equal messages in direct sequence - self._db.message = ChatMessage() - else: - super().__setattr__(key, value) - - -class _SetNickuserhostMixin: - - def __setattr__(self, key: str, value: NickUserHost | str) -> None: - if key == 'nickuserhost' and isinstance(value, NickUserHost): - for annotated_key in NickUserHost.__annotations__: - setattr(self, annotated_key, getattr(value, annotated_key)) - else: - super().__setattr__(key, value) - - -class _NickUserHost(NickUserHost): - - @staticmethod - def possible_from(value: str) -> bool: - 'If class instance could be parsed from value.' - toks = value.split('!') - if not len(toks) == 2: - return False - toks = toks[1].split('@') - if not len(toks) == 2: - return False - return True - - @classmethod - def from_str(cls, value: str) -> Self: - 'Produce from string assumed to fit _!_@_ pattern.' - assert cls.possible_from(value) - toks = value.split('!') - toks = toks[0:1] + toks[1].split('@') - return cls(*toks) - - @property - def incremented(self) -> str: - 'Return .nick with number suffix incremented, or "0" if none.' - name, digits = ([(self.nick, '')] - + [(self.nick[:i], self.nick[i:]) - for i in range(len(self.nick), 0, -1) - if self.nick[i:].isdigit()] - )[-1] - return name + str(0 if not digits else (int(digits) + 1)) - - -class _User(_SetNickuserhostMixin, User): - - def __init__(self, - names_channels_of_user: Callable, - remove_from_channels: Callable, - **kwargs) -> None: - self.names_channels = lambda: names_channels_of_user(self) - self._remove_from_channels = lambda target, msg: remove_from_channels( - self, target, msg) - super().__init__(**kwargs) - - def part(self, channel_name: str, exit_msg: str) -> None: - 'First set .exit_msg, then remove from channel of channel_name.' - self._remove_from_channels(channel_name, f'P{exit_msg}') - - def quit(self, exit_msg: str) -> None: - 'First set .exit_msg, then remove from any channels.' - self.exit_msg = f'Q{exit_msg}' - self._remove_from_channels('', self.exit_msg) - - @property - def id_(self) -> str: - 'To be set to key inside dictionary if placed into one.' - return self._id_ - - @id_.setter - def id_(self, value: str) -> None: - self._id_ = value - - -class _UpdatingServerCapability(_UpdatingAttrsMixin, ServerCapability): - pass - - -class _UpdatingCompletableTopic(_UpdatingCompletable, _CompletableTopic): - pass - - -class _UpdatingChannel(_UpdatingAttrsMixin, _Channel): - user_ids: _UpdatingCompletableStringsSet - topic: _UpdatingCompletableTopic - exits: _UpdatingDict[str] - - -class _UpdatingUser(_UpdatingAttrsMixin, _User): - pass - - -class _UpdatingUsersDict(_UpdatingDict[_UpdatingUser]): - _top_id: int - userlen: int - - def __getitem__(self, key: str) -> _UpdatingUser: - user = super().__getitem__(key) - user.id_ = key - return user - - def clear(self) -> None: - super().clear() - self._top_id = 0 - self._on_update() - - def id_for_nickuserhost(self, - nickuserhost: NickUserHost, - create_if_none=False, - allow_none=False, - updating=False - ) -> Optional[str]: - 'Return user_id for nickuserhost.nick, create if none, maybe update.' - matches = [id_ for id_, user in self._dict.items() - if user.nick == nickuserhost.nick] - assert len(matches) in ({0, 1} if (create_if_none or allow_none) - else {1}) - if len(matches) == 1: - id_ = matches[0] - if '?' in {nickuserhost.user, nickuserhost.host}: - assert nickuserhost.user == nickuserhost.host # both are '?' - # only provided with .nick, no fields we could update - return id_ - stored = self._dict[id_] - # .nick by definition same, check other fields for updatability; - # allow where '?', or for set .user only to add "~" prefix, assert - # nothing else could have changed - if stored.user == '?'\ - or nickuserhost.user == f'~{stored.user}'[:self.userlen]: - assert updating - stored.user = nickuserhost.user - else: - assert stored.user == nickuserhost.user - if stored.host == '?': - assert updating - stored.host = nickuserhost.host - else: - assert stored.host == nickuserhost.host - elif create_if_none: - self._top_id += 1 - id_ = str(self._top_id) - self[id_].nickuserhost = nickuserhost - else: - return None - return id_ - - def purge(self) -> None: - 'Remove all not linked to by existing channels, except of our ID "me".' - for id_ in [id_ for id_, user in self._dict.items() - if id_ != 'me' and not user.names_channels()]: - del self[id_] - - -class _UpdatingChannelsDict(_UpdatingDict[_UpdatingChannel]): - - def _of_user(self, user: _User) -> dict[str, _UpdatingChannel]: - return {k: v for k, v in self._dict.items() if user.id_ in v.user_ids} - - def of_user(self, user: _User) -> tuple[str, ...]: - 'Return names of channels listing user as member.' - return tuple(self._of_user(user).keys()) - - def remove_user(self, user: _User, target: str, msg: str) -> None: - 'Remove user from channel named "target", or all with user if empty.' - if target: - self[target].remove_user(user, msg) - else: - for channel in self._of_user(user).values(): - channel.remove_user(user, msg) - - -class _UpdatingIsupportDict(_UpdatingDict[str]): - - def __delitem__(self, key: str) -> None: - if key in ISUPPORT_DEFAULTS: - self[key] = ISUPPORT_DEFAULTS[key] - else: - super().__delitem__(key) - - def clear(self) -> None: - super().clear() - for key, value in ISUPPORT_DEFAULTS.items(): - self[key] = value - - -class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields): - _updates_cache: dict[tuple[str, ...], Any] = {} - _keep_on_clear = set(IrcConnSetup.__annotations__.keys()) - caps: _UpdatingDict[_UpdatingServerCapability] - channels: _UpdatingChannelsDict - isupport: _UpdatingIsupportDict - motd: _UpdatingCompletableStringsOrdered - users: _UpdatingUsersDict - - def __getattribute__(self, key: str): - attr = super().__getattribute__(key) - if key == 'channels' and attr._create_if_none is None\ - and super().__getattribute__('users' - )._create_if_none is not None: - attr._create_if_none = { - 'userid_for_nickuserhost': self.users.id_for_nickuserhost, - 'get_membership_prefixes': self._get_membership_prefixes, - 'purge_users': self.users.purge} - elif key == 'users': - attr.userlen = int(self.isupport['USERLEN']) - if attr._create_if_none is None: - attr._create_if_none = { - 'names_channels_of_user': self.channels.of_user, - 'remove_from_channels': self.channels.remove_user} - elif key == 'caps' and attr._create_if_none is None: - attr._create_if_none = {} - return attr - - def messaging(self, src: str | NickUserHost) -> ChatMessage: - 'Start input chain for chat message data.' - return _ChatMessage(sender=src, db=self) - - def into_endnode_updates(self, path: tuple[str, ...] - ) -> list[tuple[tuple[str, ...], Any]]: - 'Return path-value pairs for update-worthy (sub-)elements at path.' - - def update_unless_cached(path: tuple[str, ...], val: Any - ) -> list[tuple[tuple[str, ...], Any]]: - if path not in self._updates_cache\ - or val != self._updates_cache[path]: - self._updates_cache[path] = val - return [(path, val)] - return [] - - parent = self - cache_path: tuple[str, ...] = tuple() - for step in path[:-1]: - cache_path = cache_path + (step,) - if cache_path in self._updates_cache: - del self._updates_cache[cache_path] - parent = (parent[step] if isinstance(parent, Dict) - else getattr(parent, step)) - last_step = path[-1] - val_at_path = ((parent[last_step] if last_step in parent.keys() - else None) if isinstance(parent, Dict) - else getattr(parent, last_step)) - if not isinstance(val_at_path, _UpdatingMixin): - return update_unless_cached(path, val_at_path) - if isinstance(val_at_path, _Completable): - if val_at_path.completed is not None: - return update_unless_cached(path, val_at_path.completed) - return [] - if isinstance(val_at_path, Dict) and not val_at_path.keys(): - for cache_path in [p for p in self._updates_cache - if len(p) > len(path) - and p[:len(path)] == path]: - del self._updates_cache[cache_path] - return update_unless_cached(path, None) - if isinstance(val_at_path, Dict): - sub_items = val_at_path.keys() - else: - assert isinstance(val_at_path, _UpdatingAttrsMixin) - sub_items = val_at_path.attr_names() - updates = [] - for key in sub_items: - p = path + (key,) - updates += self.into_endnode_updates(p) - return updates - - def clear(self) -> None: - for key, value in [(k, v) for k, v in self._deep_annotations().items() - if k not in self._keep_on_clear]: - if hasattr(value, '__origin__'): - value = value.__origin__ - if issubclass(value, _Clearable): - getattr(self, key).clear() - elif issubclass(value, str): - setattr(self, key, '') - - def is_nick(self, nick: str) -> bool: - 'Tests name to match rules for nicknames.' - if len(nick) == 0: - return False - if nick[0] in (ILLEGAL_NICK_FIRSTCHARS - + self.isupport['CHANTYPES'] - + self._get_membership_prefixes()): - return False - for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]: - return False - return True - - def _get_membership_prefixes(self) -> str: - 'Registered possible membership nickname prefixes.' - toks = self.isupport['PREFIX'].split(')', maxsplit=1) - assert len(toks) == 2 - assert toks[0][0] == '(' - return toks[1] - - -class _CapsManager(_Clearable): - - def __init__(self, - sender: Callable, - caps_dict: _UpdatingDict[_UpdatingServerCapability] - ) -> None: - self._dict = caps_dict - self._send = lambda *args: sender('CAP', *args) - self.clear() - - def clear(self) -> None: - self._dict.clear() - self._ls = _CompletableStringsSet() - self._list = _CompletableStringsSet() - self._list_expectations: dict[str, set[str]] = { - 'ACK': set(), 'NAK': set()} - - def start_negotation(self) -> None: - 'Call .clear, send CAPS LS 302.' - self.clear() - self._send('LS', '302') - - def end_negotiation(self) -> None: - 'Stop negotation, without emptying caps DB.' - self._send('END') - - def process_msg(self, verb: str, items: tuple[str, ...], complete: bool - ) -> bool: - 'Parse CAP message to negot. steps, DB inputs; return if successful.' - for item in items: - if verb == 'NEW': - key, data = _Dict.key_val_from_eq_str(item) - self._dict[key].data = data - elif verb == 'DEL': - del self._dict[item] - elif verb in {'ACK', 'NAK'}: - self._list_expectations[verb].add(item) - if verb in {'LS', 'LIST'}: - target = getattr(self, f'_{verb.lower()}') - for item in items: - target.completable_add(item, False) - if complete: - target.complete() - if target is self._ls: - for cap_name in _NAMES_DESIRED_SERVER_CAPS: - self._send('REQ', cap_name) - self._send('LIST') - else: - acks = self._list_expectations['ACK'] - naks = self._list_expectations['NAK'] - assert acks == self._list.intersection(acks) - assert set() == self._list.intersection(naks) - for key, data in [_Dict.key_val_from_eq_str(entry) - for entry in sorted(self._ls)]: - self._dict[key].data = data - self._dict[key].enabled = key in self._list - return True - return False - - -class Client(ABC, ClientQueueMixin): - 'Abstracts socket connection, loop over it, and handling messages from it.' - conn: Optional[IrcConnection] = None - _cls_conn: type[IrcConnection] = IrcConnection - - def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None: - self.client_id = conn_setup.hostname - super().__init__(client_id=self.client_id, **kwargs) - self.db = _ClientDb(on_update=self._on_update) - self.db.clear() - self.caps = _CapsManager(self.send, self.db.caps) - for k in conn_setup.__annotations__: - setattr(self.db, k, getattr(conn_setup, k)) - if self.db.port <= 0: - self.db.port = PORT_SSL - if not self.db.user_wanted: - self.db.user_wanted = getuser() - - def connect(self) -> None: - 'Attempt to open connection, on success perform session init steps.' - self.db.connection_state = 'connecting' - - def connect(self) -> None: - try: - self.conn = self._cls_conn( - hostname=self.db.hostname, port=self.db.port, - _q_out=self._q_out, client_id=self.client_id) - except IrcConnAbortException as e: - self.db.connection_state = f'failed to connect: {e}' - except Exception as e: # pylint: disable=broad-exception-caught - self._put(ExceptionEvent(CrashingException(e))) - else: - self.db.connection_state = 'connected' - self.caps.start_negotation() - self.send('USER', self.db.user_wanted, - '0', '*', self.db.realname) - self.send('NICK', self.db.nick_wanted,) - - # Do this in a thread, not to block flow of other (e.g. TUI) events. - Thread(target=connect, daemon=True, args=(self,)).start() - - def close(self) -> None: - 'Close connection and wipe memory of its states.' - self.db.clear() - if self.conn: - self.conn.close() - self.conn = None - - def on_handled_loop_exception(self, e: IrcConnAbortException) -> None: - 'Gracefully handle broken connection.' - self.db.connection_state = f'broken: {e}' - self.close() - - @abstractmethod - def _on_update(self, *path) -> None: - pass - - @abstractmethod - def _alert(self, msg: str) -> None: - pass - - def send(self, verb: str, *args) -> IrcMessage: - 'Send msg over socket, on success log .raw.' - if not self.conn: - raise SendFail('cannot send, connection seems closed') - msg = IrcMessage(verb, args) - self.conn.send(msg) - return msg - - def handle_msg(self, msg: IrcMessage) -> None: - 'Log msg.raw, then process incoming msg into appropriate client steps.' - ret = {} - for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]: - result = ex.parse_msg( - msg=msg, - is_chan_name=self.db.is_chan_name, - is_nick=self.db.is_nick, - possible_nickuserhost=_NickUserHost.possible_from, - into_nickuserhost=_NickUserHost.from_str) - if result is not None: - ret = result - break - if '_verb' not in ret: - raise ImplementationFail(f'No handler implemented for: {msg.raw}') - for n_u_h in ret['_nickuserhosts']: # update, turn into proper users - if (id_ := self.db.users.id_for_nickuserhost( - n_u_h, allow_none=True, updating=True)): - for ret_name in [k for k in ret if ret[k] is n_u_h]: - ret[ret_name] = self.db.users[id_] - for verb in ('setattr', 'do', 'doafter'): - for task, tok_names in [t for t in ret['_tasks'].items() - if t[0].verb == verb]: - node = self - for step in task.path: - key = ret[step] if step.isupper() else step - node = (node[key] if isinstance(node, Dict) - else (node(key) if callable(node) - else getattr(node, key))) - for tok_name in tok_names: - if task.verb == 'setattr': - setattr(node, tok_name, ret[tok_name]) - else: - getattr(node, tok_name)() - if ret['_verb'] == '005': # RPL_ISUPPORT - for item in ret['isupport']: - if item[0] == '-': - del self.db.isupport[item[1:]] - else: - key, data = _Dict.key_val_from_eq_str(item) - self.db.isupport[key] = data - elif ret['_verb'] == '353': # RPL_NAMREPLY - self.db.channels[ret['channel']].add_from_namreply(ret['names']) - elif ret['_verb'] == '372': # RPL_MOTD - self.db.motd.append(ret['line']) - elif ret['_verb'] == '401': # ERR_NOSUCHNICK - raise TargetUserOffline(ret['missing']) - elif ret['_verb'] == '432': # ERR_ERRONEOUSNICKNAME - alert = 'nickname refused for bad format' - if 'nick' not in ret: - alert += ', giving up' - self.close() - self._alert(alert) - elif ret['_verb'] == '433': # ERR_NICKNAMEINUSE - self._alert('nickname already in use, trying increment') - self.send('NICK', _NickUserHost(nick=ret['used']).incremented) - elif ret['_verb'] == 'AUTHENTICATE': - auth = b64encode((self.db.nick_wanted + '\0' - + self.db.nick_wanted + '\0' - + self.db.password - ).encode('utf-8')).decode('utf-8') - self.send('AUTHENTICATE', auth) - elif ret['_verb'] == 'CAP': - if (self.caps.process_msg(verb=ret['subverb'], items=ret['items'], - complete='tbc' not in ret) - and 'sasl' in self.db.caps.keys() - and 'PLAIN' in self.db.caps['sasl'].data.split(',')): - if self.db.password: - self.db.sasl_auth_state = 'attempting' - self.send('AUTHENTICATE', 'PLAIN') - else: - self.caps.end_negotiation() - elif ret['_verb'] == 'JOIN' and ret['joiner'] != self.db.users['me']: - self.db.channels[ret['channel']].add_user(ret['joiner']) - elif ret['_verb'] == 'NICK': - user_id = self.db.users.id_for_nickuserhost(ret['named'], - updating=True) - assert user_id is not None - self.db.users[user_id].nick = ret['nick'] - if user_id == 'me': - self.db.nick_wanted = ret['nick'] - elif ret['_verb'] == 'PART': - ret['parter'].part(ret['channel'], ret.get('message', '')) - if ret['parter'] is self.db.users['me']: - del self.db.channels[ret['channel']] - self.db.users.purge() - elif ret['_verb'] == 'PING': - self.send('PONG', ret['reply']) - elif ret['_verb'] == 'QUIT': - ret['quitter'].quit(ret['message']) - - -ClientsDb = dict[str, Client] - - -@dataclass -class NewClientEvent(AffectiveEvent): - 'Put Client .payload into ClientsDb target.' - payload: 'Client' - - def affect(self, target: ClientsDb) -> None: - target[self.payload.client_id] = self.payload - # only run _after_ spot in ClientsDb secure, for ClientEvents to target - self.payload.connect() - - -@dataclass -class ClientEvent(AffectiveEvent, _ClientIdMixin): - 'To affect Client identified by ClientIdMixin.' diff --git a/ircplom/client_tui.py b/ircplom/client_tui.py deleted file mode 100644 index dbdd498..0000000 --- a/ircplom/client_tui.py +++ /dev/null @@ -1,544 +0,0 @@ -'TUI adaptions to Client.' -# built-ins -from enum import Enum, auto -from getpass import getuser -from pathlib import Path -from typing import Any, Callable, Optional, Sequence -# ourselves -from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window, - CMD_SHORTCUTS) -from ircplom.client import ( - AutoAttrMixin, Channel, ChatMessage, Client, ClientQueueMixin, Dict, - DictItem, ImplementationFail, IrcConnSetup, NewClientEvent, NickUserHost, - SendFail, ServerCapability, SharedClientDbFields, TargetUserOffline, User) -from ircplom.irc_conn import IrcMessage - -CMD_SHORTCUTS['disconnect'] = 'window.disconnect' -CMD_SHORTCUTS['join'] = 'window.join' -CMD_SHORTCUTS['part'] = 'window.part' -CMD_SHORTCUTS['nick'] = 'window.nick' -CMD_SHORTCUTS['privmsg'] = 'window.privmsg' -CMD_SHORTCUTS['reconnect'] = 'window.reconnect' -CMD_SHORTCUTS['raw'] = 'window.raw' - -_LOG_PREFIX_SERVER = '$' -_LOG_PREFIX_OUT = '>' -_LOG_PREFIX_IN = '<' - -_PATH_LOGS = Path.home().joinpath('.local', 'share', 'ircplom', 'logs') - - -class _LogScope(Enum): - 'Where log messages should go.' - ALL = auto() - DEBUG = auto() - CHAT = auto() - USER = auto() - USER_NO_CHANNELS = auto() - - -class _ClientWindow(Window, ClientQueueMixin): - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._title = f'{self.client_id} :DEBUG' - - def log(self, msg: str) -> None: - super().log(msg) - ldir = _PATH_LOGS.joinpath(self._title) - if not ldir.exists(): - ldir.mkdir(parents=True) - assert ldir.is_dir() - with ldir.joinpath(f'{self._last_today}.txt' - ).open('a', encoding='utf8') as f: - f.write(msg + '\n') - - def _send_msg(self, verb: str, params: tuple[str, ...]) -> None: - self._client_trigger('send_w_params_tuple', verb=verb, params=params) - - def cmd__disconnect(self, quit_msg: str = 'ircplom says bye') -> None: - 'Send QUIT command to server.' - self._send_msg('QUIT', (quit_msg,)) - - def cmd__reconnect(self) -> None: - 'Attempt reconnection.' - self._client_trigger('reconnect') - - def cmd__nick(self, new_nick: str) -> None: - 'Attempt nickname change.' - self._send_msg('NICK', (new_nick,)) - - def cmd__join(self, channel: str) -> None: - 'Attempt joining a channel.' - self._send_msg('JOIN', (channel,)) - - def cmd__privmsg(self, target: str, msg: str) -> None: - 'Send chat message msg to target.' - self._client_trigger('privmsg', chat_target=target, msg=msg) - - def cmd__raw(self, verb: str, params_str: str = '') -> None: - 'Send raw command, with direct input of params string.' - if params_str[:1] == ':': - params = [params_str] - else: - params = params_str.split(' :', maxsplit=1) - params = params[0].split() + params[1:2] - self._send_msg(verb, tuple(params)) - - -class _ChatPrompt(PromptWidget): - _nickname: str = '' - - @property - def prefix(self) -> str: - return f'[{self._nickname}] ' - - def set_prefix_data(self, nick: str) -> None: - 'Update prompt prefix with nickname data.' - if nick != self._nickname: - self._tainted = True - self._nickname = nick - - def enter(self) -> str: - to_return = super().enter() - if (not to_return) or to_return[0:1] == '/': - return to_return - return f'/window.chat {to_return}' - - -class _ChatWindow(_ClientWindow): - prompt: _ChatPrompt - - def __init__(self, chatname: str, get_nick_data: Callable, **kwargs - ) -> None: - self.chatname = chatname - self._get_nick_data = get_nick_data - super().__init__(**kwargs) - self._title = f'{self.client_id} {self.chatname}' - self.set_prompt_prefix() - - def set_prompt_prefix(self) -> None: - 'Look up relevant DB data to update prompt prefix.' - self.prompt.set_prefix_data(self._get_nick_data()) - - def cmd__chat(self, msg: str) -> None: - 'PRIVMSG to target identified by .chatname.' - self.cmd__privmsg(target=self.chatname, msg=msg) - - -class _ChannelWindow(_ChatWindow): - - def cmd__join(self, channel='') -> None: - super().cmd__join(channel if channel else self.chatname) - - def cmd__part(self) -> None: - 'Attempt parting channel.' - self._send_msg('PART', (self.chatname,)) - - -class _QueryWindow(_ChatWindow): - pass - - -class _Update: - old_value: Any - results: list[tuple[_LogScope, Any]] - - def __init__(self, path: tuple[str, ...], value: Any) -> None: - self.full_path = path - self.rel_path = self.full_path[:] - self.value = value - self.old_value = None - self.force_log = False - self.results = [] - - @property - def key(self) -> str: - 'Name of item or attribute to be processed.' - return self.rel_path[0] - - def decrement_path(self) -> None: - 'Remove first element from .rel_path.' - self.rel_path = self.rel_path[1:] - - -class _UpdatingNode(AutoAttrMixin): - - def _make_attr(self, cls: Callable, key: str): - return cls() - - def recursive_set_and_report_change(self, update: _Update) -> None: - 'Apply update, and, if it makes a difference, add to its .results.' - update.force_log = update.force_log or (not self._is_set(update.key)) - node = self._get(update.key) - if len(update.rel_path) > 1: - update.decrement_path() - node.recursive_set_and_report_change(update) - return - update.old_value = node - do_report = update.force_log - if update.value is None: - if self._is_set(update.key): - self._unset(update.key) - do_report |= True - elif update.old_value != update.value: - self._set(update.key, update.value) - do_report |= True - if (not do_report) or update.full_path == ('message',): - return - result = (tuple(sorted(update.value)) if isinstance(update.value, set) - else update.value) - announcement = ':' + ':'.join(update.full_path) + ' ' - if result is None: - announcement += 'cleared' - else: - announcement += 'set to:' - if not isinstance(result, tuple): - announcement += f' [{result}]' - scope = _LogScope.DEBUG - update.results += [(scope, [announcement])] - if isinstance(result, tuple): - update.results += [(scope, [f': {item}']) for item in result] - - def _get(self, key: str) -> Any: - return getattr(self, key) - - def _set(self, key: str, value) -> None: - setattr(self, key, value) - - def _unset(self, key: str) -> None: - getattr(self, key).clear() - - def _is_set(self, key: str) -> bool: - return hasattr(self, key) - - -class _UpdatingDict(Dict[DictItem], _UpdatingNode): - - def items(self) -> tuple[tuple[str, DictItem], ...]: - 'Key-value pairs of item registrations.' - return tuple((k, v) for k, v in self._dict.items()) - - def _get(self, key: str): - if key not in self._dict: - self._dict[key] = self._item_cls() - return self._dict[key] - - def _set(self, key: str, value) -> None: - self._dict[key] = value - - def _unset(self, key: str) -> None: - del self._dict[key] - - def _is_set(self, key: str) -> bool: - return key in self._dict - - -class _UpdatingChannel(_UpdatingNode, Channel): - user_ids: set[str] - exits: _UpdatingDict[str] - - def recursive_set_and_report_change(self, update: _Update) -> None: - super().recursive_set_and_report_change(update) - if update.key == 'topic': - msg = f':{self.topic.who} set topic: {self.topic.what}' - update.results += [(_LogScope.CHAT, [msg])] - elif update.key == 'user_ids': - if not update.old_value: - nicks = [] - for id_ in sorted(update.value): - nicks += [f'NICK:{id_}', ':, '] - nicks.pop() - update.results += [(_LogScope.CHAT, [':residents: '] + nicks)] - else: - for id_ in (id_ for id_ in update.value - if id_ not in update.old_value): - update.results += [(_LogScope.CHAT, - [f'NUH:{id_}', ': joins'])] - for id_ in (id_ for id_ in update.old_value - if id_ not in update.value): - update.results += [(_LogScope.CHAT, - _UpdatingUser.exit_msg_toks( - f'NUH:{id_}', self.exits[id_]))] - - -class _UpdatingUser(_UpdatingNode, User): - prev_nick = '?' - - @staticmethod - def exit_msg_toks(tok_who: str, exit_code: str) -> list[str]: - 'Construct part/quit message from user identifier, exit_code.' - verb = 'quits' if exit_code[0] == 'Q' else 'parts' - exit_msg = exit_code[1:] - msg_toks = [tok_who, f': {verb}'] - if exit_msg: - msg_toks += [f':: {exit_msg}'] - return msg_toks - - def recursive_set_and_report_change(self, update: _Update) -> None: - super().recursive_set_and_report_change(update) - if update.key in {'nick', 'exit_msg'}: - if update.key == 'nick': - self.prev_nick = update.old_value - if update.old_value != '?': - update.results += [ - (_LogScope.USER, - [f':{self.prev} renames {update.value}'])] - elif update.key == 'exit_msg': - if update.value: - update.results += [(_LogScope.USER_NO_CHANNELS, - self.exit_msg_toks( - f':{self}', update.value))] - - @property - def prev(self) -> str: - 'Return .nickuserhost with .prev_nick as .nick.' - return str(NickUserHost(self.prev_nick, self.user, self.host)) - - -class _UpdatingServerCapability(_UpdatingNode, ServerCapability): - pass - - -class _TuiClientDb(_UpdatingNode, SharedClientDbFields): - caps: _UpdatingDict[_UpdatingServerCapability] - isupport: _UpdatingDict[str] - motd: tuple[str, ...] = tuple() - users: _UpdatingDict[_UpdatingUser] - channels: _UpdatingDict[_UpdatingChannel] - - def recursive_set_and_report_change(self, update: _Update) -> None: - super().recursive_set_and_report_change(update) - if update.key == 'connection_state': - if update.value == 'connected': - update.results += [(_LogScope.ALL, [':CONNECTED'])] - elif not update.value: - update.results += [(_LogScope.ALL, [':DISCONNECTED'])] - elif update.key == 'message' and update.value: - assert isinstance(update.value, ChatMessage) - toks = [':*** '] if update.value.is_notice else [] - toks += [':['] - toks += [f':{update.value.sender}' if update.value.sender - else 'NICK:me'] - toks += [f':] {update.value.content}'] - update.results += [(_LogScope.CHAT, toks)] - - -class _ClientWindowsManager: - - def __init__(self, tui_log: Callable, tui_new_window: Callable) -> None: - self._tui_log = tui_log - self._tui_new_window = tui_new_window - self.db = _TuiClientDb() - self.windows: list[_ClientWindow] = [] - - def _new_win(self, scope: _LogScope, chatname: str = '') -> _ClientWindow: - if scope == _LogScope.CHAT: - win = self._tui_new_window( - win_cls=(_ChannelWindow if self.db.is_chan_name(chatname) - else _QueryWindow), - chatname=chatname, - get_nick_data=lambda: (self.db.users['me'].nick - if 'me' in self.db.users.keys() - else '?')) - else: - win = self._tui_new_window(win_cls=_ClientWindow) - self.windows += [win] - return win - - def windows_for(self, scope: _LogScope, id_='') -> list[_ClientWindow]: - 'Return client windows of scope, and additional potential identifier.' - ret = [] - if scope == _LogScope.ALL: - ret = [w for w in self.windows - if w not in self.windows_for(_LogScope.DEBUG)] - elif scope == _LogScope.DEBUG: - ret = [w for w in self.windows if not isinstance(w, _ChatWindow)] - elif scope == _LogScope.CHAT: - ret = [w for w in self.windows - if isinstance(w, _ChatWindow) and w.chatname == id_] - elif scope == _LogScope.USER: - chan_names = [c for c, v in self.db.channels.items() - if id_ in v.user_ids] - ret = [w for w in self.windows - if (isinstance(w, _ChannelWindow) - and w.chatname in chan_names) - or (isinstance(w, _QueryWindow) - and (id_ == 'me' or w.chatname in { - self.db.users[id_].nick, - self.db.users[id_].prev_nick}))] - elif scope == _LogScope.USER_NO_CHANNELS: - ret = [w for w in self.windows_for(_LogScope.USER, id_) - if isinstance(w, _QueryWindow)] - if (not ret) and scope in {_LogScope.CHAT, _LogScope.DEBUG}: - ret += [self._new_win(scope, id_)] - ret.sort(key=lambda w: w.idx) - return ret - - def log(self, - msg: str, - scope: _LogScope, - alert=False, - target='', - out: Optional[bool] = None - ) -> None: - 'From parsing scope, kwargs, build prefix before sending to logger.' - prefix = '$' - if out is not None: - prefix = _LOG_PREFIX_OUT if out else _LOG_PREFIX_IN - kwargs = {'alert': True} if alert else {} - kwargs |= {'target': target} if target else {} - self._tui_log(msg, scope=scope, prefix=prefix, **kwargs) - - def update_db(self, update: _Update) -> bool: - 'Apply update to .db, and if changing anything, log and trigger.' - self.db.recursive_set_and_report_change(update) - if not update.results: - return False - for scope, result in update.results: - msg = '' - for item in result: - transform, content = item.split(':', maxsplit=1) - if transform in {'NICK', 'NUH'}: - nuh = self.db.users[content] - content = str(nuh) if transform == 'NUH' else nuh.nick - msg += content - out: Optional[bool] = None - target = '' - if update.full_path == ('message',): - target = update.value.target or update.value.sender - out = not bool(update.value.sender) - elif scope in {_LogScope.CHAT, _LogScope.USER, - _LogScope.USER_NO_CHANNELS}: - target = update.full_path[1] - self.log(msg, scope=scope, target=target, out=out) - for win in [w for w in self.windows if isinstance(w, _ChatWindow)]: - win.set_prompt_prefix() - return bool([w for w in self.windows if w.tainted]) - - -class ClientTui(BaseTui): - 'TUI expanded towards Client features.' - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._client_mngrs: dict[str, _ClientWindowsManager] = {} - - def _log_target_wins(self, **kwargs) -> Sequence[Window]: - if (scope := kwargs.get('scope', None)): - return self._client_mngrs[kwargs['client_id']].windows_for( - scope, kwargs.get('target', '')) - return super()._log_target_wins(**kwargs) - - def for_client_do(self, client_id: str, todo: str, **kwargs) -> None: - 'Forward todo to appropriate _ClientWindowsManager.' - if client_id not in self._client_mngrs: - self._client_mngrs[client_id] = _ClientWindowsManager( - tui_log=lambda msg, **kw: self.log( - msg, client_id=client_id, **kw), - tui_new_window=lambda win_cls, **kw: self._new_window( - win_cls, _q_out=self._q_out, client_id=client_id, **kw)) - if getattr(self._client_mngrs[client_id], todo)(**kwargs) is not False: - self.redraw_affected() - - def _new_client(self, conn_setup: IrcConnSetup) -> 'ClientKnowingTui': - return ClientKnowingTui(_q_out=self._q_out, conn_setup=conn_setup) - - def cmd__connect(self, - host_port: str, - nickname_pw: str = '', - username_realname: str = '' - ) -> Optional[str]: - 'Create Client and pass it via NewClientEvent.' - split = host_port.split(':', maxsplit=1) - hostname = split[0] - if hostname in self._client_mngrs: - return f'already set up connection to {hostname}' - port = -1 - if len(split) > 1: - to_int = split[1] - if to_int.isdigit(): - port = int(split[1]) - else: - return f'invalid port number: {to_int}' - split = nickname_pw.split(':', maxsplit=1) - nickname = split[0] if nickname_pw else getuser() - password = split[1] if len(split) > 1 else '' - if not username_realname: - username = '' - realname = nickname - elif ':' in username_realname: - username, realname = username_realname.split(':', maxsplit=1) - else: - username = '' - realname = username_realname - self._put(NewClientEvent(self._new_client(IrcConnSetup( - hostname, port, nickname, username, realname, password)))) - return None - - -class ClientKnowingTui(Client): - 'Adapted to communicate with ClientTui.' - - def _tui_trigger(self, method_name: str, **kwargs) -> None: - self._put(TuiEvent.affector(method_name).kw(**kwargs)) - - def _tui_alert_trigger(self, msg: str) -> None: - self._tui_trigger('log', msg=msg, prefix=_LOG_PREFIX_SERVER, - alert=True) - - def _client_tui_trigger(self, todo: str, **kwargs) -> None: - self._tui_trigger('for_client_do', client_id=self.client_id, - todo=todo, **kwargs) - - def send_w_params_tuple(self, verb: str, params: tuple[str, ...]) -> None: - 'Helper for ClientWindow to trigger .send, for it can only do kwargs.' - self.send(verb, *params) - - def privmsg(self, chat_target: str, msg: str) -> None: - 'Catch /privmsg, only allow for channel if in channel, else complain.' - try: - if self.db.is_chan_name(chat_target)\ - and chat_target not in self.db.channels.keys(): - raise SendFail('not sending, since not in channel') - self.send('PRIVMSG', chat_target, msg) - except SendFail as e: - self._tui_alert_trigger(f'{e}') - else: - self.db.messaging('').to(chat_target).privmsg = msg # type: ignore - - def reconnect(self) -> None: - 'Catch /reconnect, only initiate if not connected, else complain back.' - if self.conn: - self._tui_alert_trigger( - 'not re-connecting since already connected') - return - self.connect() - - def send(self, verb: str, *args) -> IrcMessage: - msg = super().send(verb, *args) - self._log(msg.raw, out=True) - return msg - - def handle_msg(self, msg: IrcMessage) -> None: - self._log(msg.raw, out=False) - try: - super().handle_msg(msg) - except ImplementationFail as e: - self._alert(str(e)) - except TargetUserOffline as e: - name = f'{e}' - self._log(f'{name} not online', target=name, alert=True) - - def _alert(self, msg: str) -> None: - self._log(msg, alert=True) - - def _log(self, msg: str, alert=False, target='', out: Optional[bool] = None - ) -> None: - scope = _LogScope.CHAT if target else _LogScope.DEBUG - self._client_tui_trigger('log', scope=scope, msg=msg, alert=alert, - target=target, out=out) - - def _on_update(self, *path) -> None: - for path, value in self.db.into_endnode_updates(path): - self._client_tui_trigger('update_db', update=_Update(path, value)) diff --git a/ircplom/events.py b/ircplom/events.py deleted file mode 100644 index 9dac06b..0000000 --- a/ircplom/events.py +++ /dev/null @@ -1,110 +0,0 @@ -'Event system with event loop.' -from abc import abstractmethod, ABC -from dataclasses import dataclass -from queue import SimpleQueue, Empty as QueueEmpty -from threading import Thread -from typing import Any, Iterator, Literal, Self - - -class Event: # pylint: disable=too-few-public-methods - 'Communication unit between threads.' - - -QuitEvent = type('QuitEvent', (Event,), {}) - - -class AffectiveEvent(Event, ABC): - 'For Events that are to affect other objects.' - - @abstractmethod - def affect(self, target: Any) -> None: - 'To be run by main loop on target.' - - @classmethod - def affector(cls, t_method: str, **kwargs): - '''Return instance of subclass affecting target via t_method. - - This will often be more convenient than a full subclass definition, - which mostly only matters for what the main loop gotta differentiate. - ''' - def wrap(): # else current mypy throw [valid-type] on 'Variable "cls"' - class _Affector(cls): - def __init__(self, t_method: str, **kwargs) -> None: - super().__init__(**kwargs) - self.t_method = t_method - self.kwargs: dict[str, Any] = {} - - def kw(self, **kwargs) -> Self: - 'Collect .kwargs expanded, return self for chaining.' - for k, v in kwargs.items(): - self.kwargs[k] = v - return self - - def affect(self, target) -> None: - 'Call target.t_method(**.kwargs).' - getattr(target, self.t_method)(**self.kwargs) - - return _Affector - - return wrap()(t_method=t_method, **kwargs) - - -class CrashingException(BaseException): - 'To explicitly crash, because it should never happen, but explaining why.' - - -@dataclass -class ExceptionEvent(Event): - 'To deliver Exception to main loop for handling.' - exception: CrashingException - - -@dataclass -class QueueMixin: - 'Adds SimpleQueue addressable via ._put(Event).' - _q_out: SimpleQueue - - def _put(self, event: Event) -> None: - self._q_out.put(event) - - -class Loop(QueueMixin): - 'Wraps thread looping over iterator, communicating back via q_out.' - - def __init__(self, iterator: Iterator, **kwargs) -> None: - super().__init__(**kwargs) - self._q_quit: SimpleQueue = SimpleQueue() - self._iterator = iterator - self._thread = Thread(target=self._loop, daemon=False) - self._thread.start() - - def stop(self) -> None: - 'Break threaded loop, but wait for it to finish properly.' - self._q_quit.put(None) - self._thread.join() - - def __enter__(self) -> Self: - return self - - def __exit__(self, *_) -> Literal[False]: - self.stop() - return False # re-raise any exception that above ignored - - def _loop(self) -> None: - try: - while True: - try: - self._q_quit.get(block=True, timeout=0) - except QueueEmpty: - pass - else: - break - try: - it_yield = next(self._iterator) - except StopIteration: - break - if it_yield is not None: - self._put(it_yield) - # catch _all_ just so they exit readably with the main loop - except Exception as e: # pylint: disable=broad-exception-caught - self._put(ExceptionEvent(CrashingException(e))) diff --git a/ircplom/irc_conn.py b/ircplom/irc_conn.py deleted file mode 100644 index fb1bd21..0000000 --- a/ircplom/irc_conn.py +++ /dev/null @@ -1,207 +0,0 @@ -'Low-level IRC protocol / server connection management.' -# built-ins -from abc import ABC, abstractmethod -from socket import socket, gaierror as socket_gaierror -from ssl import create_default_context as create_ssl_context -from typing import Callable, Iterator, NamedTuple, Optional, Self -# ourselves -from ircplom.events import Event, Loop, QueueMixin - - -PORT_SSL = 6697 -_TIMEOUT_RECV_LOOP = 0.1 -_TIMEOUT_CONNECT = 5 -_CONN_RECV_BUFSIZE = 1024 - -ILLEGAL_NICK_CHARS = ' ,*?!@' -ILLEGAL_NICK_FIRSTCHARS = ':$' -ISUPPORT_DEFAULTS = { - 'CHANTYPES': '#&', - 'PREFIX': '(ov)@+', - 'USERLEN': '10' -} -_IRCSPEC_LINE_SEPARATOR = b'\r\n' -_IRCSPEC_TAG_ESCAPES = ((r'\:', ';'), - (r'\s', ' '), - (r'\n', '\n'), - (r'\r', '\r'), - (r'\\', '\\')) - - -class IrcMessage: - 'Properly structured representation of IRC message as per IRCv3 spec.' - _raw: Optional[str] = None - - def __init__(self, - verb: str, - params: Optional[tuple[str, ...]] = None, - source: str = '', - tags: Optional[dict[str, str]] = None - ) -> None: - self.verb: str = verb - self.params: tuple[str, ...] = params or tuple() - self.source: str = source - self.tags: dict[str, str] = tags or {} - - @classmethod - def from_raw(cls, raw_msg: str) -> Self: - 'Parse raw IRC message line into properly structured IrcMessage.' - - class _Stage(NamedTuple): - name: str - prefix_char: Optional[str] - processor: Callable = lambda s: s - - def _parse_tags(str_tags: str) -> dict[str, str]: - tags = {} - for str_tag in [s for s in str_tags.split(';') if s]: - if '=' in str_tag: - key, val = str_tag.split('=', maxsplit=1) - for to_repl, repl_with in _IRCSPEC_TAG_ESCAPES: - val = val.replace(to_repl, repl_with) - else: - key, val = str_tag, '' - tags[key] = val - return tags - - def _split_params(str_params: str) -> tuple[str, ...]: - params = [] - params_stage = 0 # 0: gap, 1: non-trailing, 2: trailing - for char in str_params: - if char == ' ' and params_stage < 2: - params_stage = 0 - continue - if params_stage == 0: - params += [''] - params_stage += 1 - if char == ':': - params_stage += 1 - continue - params[-1] += char - return tuple(p for p in params) - - stages = [_Stage('tags', '@', _parse_tags), - _Stage('source', ':'), - _Stage('verb', None, lambda s: s.upper()), - _Stage('params', None, _split_params)] - harvest = {s.name: '' for s in stages} - idx_stage = -1 - stage = None - for char in raw_msg: - if char == ' ' and idx_stage < (len(stages) - 1): - if stage: - stage = None - continue - if not stage: - while not stage: - idx_stage += 1 - tested = stages[idx_stage] - if (not tested.prefix_char) or char == tested.prefix_char: - stage = tested - if stage.prefix_char: - continue - harvest[stage.name] += char - msg = cls(**{s.name: s.processor(harvest[s.name]) for s in stages}) - msg._raw = raw_msg - return msg - - @property - def raw(self) -> str: - 'Return raw message code – create from known fields if necessary.' - if not self._raw: - to_combine = [] - if self.tags: - tag_strs = [] - for key, val in self.tags.items(): - tag_strs += [key] - if not val: - continue - for repl_with, to_repl in reversed(_IRCSPEC_TAG_ESCAPES): - val = val.replace(to_repl, repl_with) - tag_strs[-1] += f'={val}' - to_combine += ['@' + ';'.join(tag_strs)] - to_combine += [self.verb] - if self.params: - to_combine += self.params[:-1] - to_combine += [f':{self.params[-1]}'] - self._raw = ' '.join(to_combine) - return self._raw - - -class IrcConnAbortException(BaseException): - 'Thrown by BaseIrcConnection on expectable connection failures.' - - -class BaseIrcConnection(QueueMixin, ABC): - 'Collects low-level server-client connection management.' - - def __init__(self, hostname: str, port: int, **kwargs) -> None: - super().__init__(**kwargs) - self.ssl = port == PORT_SSL - self._set_up_socket(hostname, port) - self._recv_loop = Loop(iterator=self._read_lines(), _q_out=self._q_out) - - def _set_up_socket(self, hostname: str, port: int) -> None: - self._socket = socket() - if self.ssl: - self._socket = create_ssl_context().wrap_socket( - self._socket, server_hostname=hostname) - self._socket.settimeout(_TIMEOUT_CONNECT) - try: - self._socket.connect((hostname, port)) - except (TimeoutError, socket_gaierror) as e: - raise IrcConnAbortException(e) from e - self._socket.settimeout(_TIMEOUT_RECV_LOOP) - - def close(self) -> None: - 'Stop recv loop and close socket.' - self._recv_loop.stop() - self._socket.close() - - def send(self, msg: IrcMessage) -> None: - 'Send line-separator-delimited message over socket.' - self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR) - - @abstractmethod - def _make_recv_event(self, msg: IrcMessage) -> Event: - pass - - @abstractmethod - def _on_handled_loop_exception(self, _: IrcConnAbortException) -> Event: - pass - - def _read_lines(self) -> Iterator[Optional[Event]]: - assert self._socket is not None - bytes_total = b'' - buffer_linesep = b'' - try: - while True: - try: - bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE) - except TimeoutError: - yield None - continue - except ConnectionResetError as e: - raise IrcConnAbortException(e) from e - except OSError as e: - if e.errno == 9: - raise IrcConnAbortException(e) from e - raise e - if not bytes_new: - break - for c in bytes_new: - c_byted = c.to_bytes() - if c not in _IRCSPEC_LINE_SEPARATOR: - bytes_total += c_byted - buffer_linesep = b'' - elif c == _IRCSPEC_LINE_SEPARATOR[0]: - buffer_linesep = c_byted - else: - buffer_linesep += c_byted - if buffer_linesep == _IRCSPEC_LINE_SEPARATOR: - buffer_linesep = b'' - yield self._make_recv_event( - IrcMessage.from_raw(bytes_total.decode('utf-8'))) - bytes_total = b'' - except IrcConnAbortException as e: - yield self._on_handled_loop_exception(e) diff --git a/ircplom/msg_parse_expectations.py b/ircplom/msg_parse_expectations.py deleted file mode 100644 index 84478b4..0000000 --- a/ircplom/msg_parse_expectations.py +++ /dev/null @@ -1,560 +0,0 @@ -'Structured expectations and processing hints for server messages.' -from enum import Enum, auto -from typing import Any, Callable, NamedTuple, Optional, Self -from ircplom.irc_conn import IrcMessage - - -class _MsgTok(Enum): - 'Server message token classifications.' - ANY = auto() - CHANNEL = auto() - LIST = auto() - NICKNAME = auto() - NONE = auto() - SERVER = auto() - NICK_USER_HOST = auto() - - -_MsgTokGuide = str | _MsgTok | tuple[str | _MsgTok, str] - - -class _Command(NamedTuple): - verb: str - path: tuple[str, ...] - - @classmethod - def from_(cls, input_: str) -> Self: - 'Split by first "_" into verb, path (split into steps tuple by ".").' - verb, path_str = input_.split('_', maxsplit=1) - return cls(verb, tuple(step for step in path_str.split('.') - if path_str)) - - -class _MsgParseExpectation: - - def __init__(self, - verb: str, - source: _MsgTokGuide, - params: tuple[_MsgTokGuide, ...] = tuple(), - idx_into_list: int = -1, - bonus_tasks: tuple[str, ...] = tuple() - ) -> None: - - class _Code(NamedTuple): - title: str - commands: tuple[_Command, ...] - - @classmethod - def from_(cls, input_: str) -> Self: - 'Split by ":" into commands (further split by ","), title.' - cmdsstr, title = input_.split(':', maxsplit=1) - return cls(title, tuple(_Command.from_(t) - for t in cmdsstr.split(',') if t)) - - class _TokExpectation(NamedTuple): - type_: _MsgTok | str - code: Optional[_Code] - - @classmethod - def from_(cls, val: _MsgTokGuide) -> Self: - 'Standardize value into .type_, (potentially empty) .code.' - t = ((val[0], _Code.from_(val[1])) if isinstance(val, tuple) - else (val, None)) - return cls(*t) - - self.verb = verb - self.source = _TokExpectation.from_(source) - self.params = tuple(_TokExpectation.from_(param) for param in params) - self.idx_into_list = idx_into_list - self.bonus_tasks = tuple(_Code.from_(item) for item in bonus_tasks) - - def parse_msg(self, - msg: IrcMessage, - is_chan_name: Callable, - is_nick: Callable, - possible_nickuserhost: Callable, - into_nickuserhost: Callable - ) -> Optional[dict[str, Any]]: - 'Try parsing msg into informative result dictionary, or None on fail.' - cmp_params: list[str | tuple[str, ...]] - if self.idx_into_list < 0: - cmp_params = list(msg.params) - else: - idx_after = len(msg.params) + 1 - (len(self.params) - - self.idx_into_list) - cmp_params = (list(msg.params[:self.idx_into_list]) + - [msg.params[self.idx_into_list:idx_after]] + - list(msg.params[idx_after:])) - cmp_fields = tuple([msg.source] + cmp_params) - ex_fields = tuple([self.source] + list(self.params)) - if len(ex_fields) != len(cmp_fields): - return None - validators: dict[_MsgTok, Callable[[Any], bool]] = { - _MsgTok.NONE: lambda tok: tok == '', - _MsgTok.CHANNEL: is_chan_name, - _MsgTok.NICKNAME: is_nick, - _MsgTok.NICK_USER_HOST: possible_nickuserhost, - _MsgTok.SERVER: lambda tok: '.' in tok and not set('@!') & set(tok) - } - parsers: dict[_MsgTok, Callable[[Any], Any]] = { - _MsgTok.LIST: lambda tok: tuple(tok.split()), - _MsgTok.NICK_USER_HOST: into_nickuserhost - } - parsed: dict[str, str | tuple[str, ...]] = {} - singled_tasks: list[tuple[_Command, str]] = [] - nickuserhosts = [] - for ex_tok, cmp_tok in [(ex_tok, cmp_fields[idx]) - for idx, ex_tok in enumerate(ex_fields)]: - if isinstance(ex_tok.type_, str) and ex_tok.type_ != cmp_tok: - return None - if (not isinstance(ex_tok.type_, str))\ - and ex_tok.type_ in validators\ - and not validators[ex_tok.type_](cmp_tok): - return None - if ex_tok.code or ex_tok.type_ is _MsgTok.NICK_USER_HOST: - value = (cmp_tok if (isinstance(ex_tok.type_, str) - or ex_tok.type_ not in parsers) - else parsers[ex_tok.type_](cmp_tok)) - if ex_tok.type_ is _MsgTok.NICK_USER_HOST: - nickuserhosts += [value] - if ex_tok.code: - parsed[ex_tok.code.title] = value - singled_tasks += [(cmd, ex_tok.code.title) - for cmd in ex_tok.code.commands] - for code in self.bonus_tasks: - singled_tasks += [(cmd, code.title) for cmd in code.commands] - tasks: dict[_Command, list[str]] = {} - for cmd, title in singled_tasks: - if cmd not in tasks: - tasks[cmd] = [] - tasks[cmd] += [title] - return parsed | {'_verb': self.verb, '_tasks': tasks, - '_nickuserhosts': nickuserhosts} - - -MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ - - # these we ignore except for confirming/collecting the nickname - - _MsgParseExpectation( - '001', # RPL_WELCOME - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '002', # RPL_YOURHOST - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '003', # RPL_CREATED - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '004', # RPL_MYINFO - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY, - _MsgTok.ANY, - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '250', # RPL_STATSDLINE / RPL_STATSCONN - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '251', # RPL_LUSERCLIENT - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '252', # RPL_LUSEROP - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '253', # RPL_LUSERUNKNOWN - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '254', # RPL_LUSERCHANNELS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '255', # RPL_LUSERME - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - _MsgParseExpectation( - '265', # RPL_LOCALUSERS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - _MsgParseExpectation( - '265', # RPL_LOCALUSERS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '266', # RPL_GLOBALUSERS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - _MsgParseExpectation( - '266', # RPL_GLOBALUSERS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, - _MsgTok.ANY, - _MsgTok.ANY)), - - _MsgParseExpectation( - '375', # RPL_MOTDSTART already implied by 1st 372 - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY)), - - # various login stuff - - _MsgParseExpectation( - '005', # RPL_ISUPPORT - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, ':isupport'), - _MsgTok.ANY), # comment - idx_into_list=1), - - _MsgParseExpectation( - '372', # RPL_MOTD - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, ':line'))), - - _MsgParseExpectation( - '376', # RPL_ENDOFMOTD - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY), # comment - bonus_tasks=('do_db.motd:complete',)), - - _MsgParseExpectation( - '396', # RPL_VISIBLEHOST - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.SERVER, 'setattr_db.users.me:host'), - _MsgTok.ANY)), # comment - - # SASL - - _MsgParseExpectation( - '900', # RPL_LOGGEDIN - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'), - (_MsgTok.ANY, 'setattr_db:sasl_account'), - _MsgTok.ANY)), # comment - - _MsgParseExpectation( - '903', # RPL_SASLSUCCESS - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db:sasl_auth_state')), - bonus_tasks=('do_caps:end_negotiation',)), - - _MsgParseExpectation( - '904', # ERR_SASLFAIL - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db:sasl_auth_state')), - bonus_tasks=('do_caps:end_negotiation',)), - - _MsgParseExpectation( - 'AUTHENTICATE', - _MsgTok.NONE, - ('+',)), - - # capability negotation - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('NEW', ':subverb'), - (_MsgTok.LIST, ':items'))), - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('DEL', ':subverb'), - (_MsgTok.LIST, ':items'))), - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('ACK', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('ACK', ':subverb'), - (_MsgTok.LIST, ':items'))), - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('NAK', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('NAK', ':subverb'), - (_MsgTok.LIST, ':items'))), - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('LS', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('LS', ':subverb'), - ('*', ':tbc'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('LS', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('LS', ':subverb'), - ('*', ':tbc'), - (_MsgTok.LIST, ':items'))), - - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('LIST', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ('*', - ('LIST', ':subverb'), - ('*', ':tbc'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('LIST', ':subverb'), - (_MsgTok.LIST, ':items'))), - _MsgParseExpectation( - 'CAP', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - ('LIST', ':subverb'), - ('*', ':tbc'), - (_MsgTok.LIST, ':items'))), - - # nickname management - - _MsgParseExpectation( - '432', # ERR_ERRONEOUSNICKNAME - _MsgTok.SERVER, - ('*', - _MsgTok.ANY, # bad one probably fails our NICKNAME tests - _MsgTok.ANY)), # comment - _MsgParseExpectation( - '432', # ERR_ERRONEOUSNICKNAME - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - _MsgTok.ANY, # bad one probably fails our NICKNAME tests - _MsgTok.ANY)), # comment - - _MsgParseExpectation( - '433', # ERR_NICKNAMEINUSE - _MsgTok.SERVER, - ('*', - (_MsgTok.NICKNAME, ':used'), - _MsgTok.ANY)), # comment - _MsgParseExpectation( - '433', # ERR_NICKNAMEINUSE - _MsgTok.SERVER, - (_MsgTok.NICKNAME, # we rather go for incrementation - (_MsgTok.NICKNAME, ':used'), - _MsgTok.ANY)), # comment - - _MsgParseExpectation( - 'NICK', - (_MsgTok.NICK_USER_HOST, ':named'), - ((_MsgTok.NICKNAME, ':nick'),)), - - # joining/leaving - - _MsgParseExpectation( - '332', # RPL_TOPIC - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.CHANNEL, ':CHAN'), - (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))), - - _MsgParseExpectation( - '333', # RPL_TOPICWHOTIME - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.CHANNEL, ':CHAN'), - (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'), - (_MsgTok.ANY, ':timestamp')), - bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)), - - _MsgParseExpectation( - '353', # RPL_NAMREPLY - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - '@', - (_MsgTok.CHANNEL, ':channel'), - (_MsgTok.LIST, ':names'))), - _MsgParseExpectation( - '353', # RPL_NAMREPLY - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - '=', - (_MsgTok.CHANNEL, ':channel'), - (_MsgTok.LIST, ':names'))), - - _MsgParseExpectation( - '366', # RPL_ENDOFNAMES - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.CHANNEL, ':CHAN'), - _MsgTok.ANY), # comment - bonus_tasks=('doafter_db.channels.CHAN.user_ids:complete',)), - - _MsgParseExpectation( - 'JOIN', - (_MsgTok.NICK_USER_HOST, ':joiner'), - ((_MsgTok.CHANNEL, ':channel'),)), - - _MsgParseExpectation( - 'PART', - (_MsgTok.NICK_USER_HOST, ':parter'), - ((_MsgTok.CHANNEL, ':channel'),)), - _MsgParseExpectation( - 'PART', - (_MsgTok.NICK_USER_HOST, ':parter'), - ((_MsgTok.CHANNEL, ':channel'), - (_MsgTok.ANY, ':message'))), - - # messaging - - _MsgParseExpectation( - '401', # ERR_NOSUCKNICK - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.NICKNAME, ':missing'), - _MsgTok.ANY)), # comment - - _MsgParseExpectation( - 'NOTICE', - _MsgTok.SERVER, - ('*', - (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))), - _MsgParseExpectation( - 'NOTICE', - _MsgTok.SERVER, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))), - - _MsgParseExpectation( - 'NOTICE', - (_MsgTok.NICK_USER_HOST, ':USER'), - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:notice'))), - - _MsgParseExpectation( - 'NOTICE', - (_MsgTok.NICK_USER_HOST, ':USER'), - ((_MsgTok.CHANNEL, ':CHANNEL'), - (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:notice'))), - - _MsgParseExpectation( - 'PRIVMSG', - (_MsgTok.NICK_USER_HOST, ':USER'), - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:privmsg'))), - _MsgParseExpectation( - 'PRIVMSG', - (_MsgTok.NICK_USER_HOST, ':USER'), - ((_MsgTok.CHANNEL, ':CHANNEL'), - (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))), - - # misc. - - _MsgParseExpectation( - 'ERROR', - _MsgTok.NONE, - ((_MsgTok.ANY, 'setattr_db:connection_state'),), - bonus_tasks=('doafter_:close',)), - - _MsgParseExpectation( - 'MODE', - (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'), - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db.users.me:modes'))), - _MsgParseExpectation( - 'MODE', - _MsgTok.NICKNAME, - ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), - (_MsgTok.ANY, 'setattr_db.users.me:modes'))), - - _MsgParseExpectation( - 'PING', - _MsgTok.NONE, - ((_MsgTok.ANY, ':reply'),)), - - _MsgParseExpectation( - 'TOPIC', - (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'), - ((_MsgTok.CHANNEL, ':CHAN'), - (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what')), - bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)), - - _MsgParseExpectation( - 'QUIT', - (_MsgTok.NICK_USER_HOST, ':quitter'), - ((_MsgTok.ANY, ':message'),)), -] diff --git a/ircplom/testing.py b/ircplom/testing.py deleted file mode 100644 index 57de470..0000000 --- a/ircplom/testing.py +++ /dev/null @@ -1,157 +0,0 @@ -'Basic testing.' -from contextlib import contextmanager -from queue import SimpleQueue, Empty as QueueEmpty -from pathlib import Path -from typing import Generator, Iterator, Optional -from ircplom.events import Event, Loop, QueueMixin -from ircplom.client import IrcConnection, IrcConnSetup -from ircplom.client_tui import ClientKnowingTui, ClientTui -from ircplom.irc_conn import IrcConnAbortException, IrcMessage -from ircplom.tui_base import TerminalInterface, TuiEvent - - -_PATH_TEST_TXT = Path('test.txt') - - -class TestTerminal(QueueMixin, TerminalInterface): - 'Collects keypresses from string queue, otherwise mostly dummy.' - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._q_keypresses: SimpleQueue = SimpleQueue() - - @contextmanager - def setup(self) -> Generator: - with Loop(iterator=self._get_keypresses(), _q_out=self._q_out): - yield self - - def flush(self) -> None: - pass - - def calc_geometry(self) -> None: - self.size = TerminalInterface.__annotations__['size'](0, 0) - - def wrap(self, line: str) -> list[str]: - return [] - - def write(self, - msg: str = '', - start_y: Optional[int] = None, - attribute: Optional[str] = None, - padding: bool = True - ) -> None: - pass - - def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: - while True: - try: - to_yield = self._q_keypresses.get(timeout=0.1) - except QueueEmpty: - yield None - continue - yield TuiEvent.affector('handle_keyboard_event' - ).kw(typed_in=to_yield) - - -class _FakeIrcConnection(IrcConnection): - - def __init__(self, **kwargs) -> None: - self._q_server_msgs: SimpleQueue = SimpleQueue() - super().__init__(**kwargs) - - def put_server_msg(self, msg: str) -> None: - 'Simulate message coming from server.' - self._q_server_msgs.put(msg) - - def _set_up_socket(self, hostname: str, port: int) -> None: - pass - - def close(self) -> None: - self._recv_loop.stop() - - def send(self, msg: IrcMessage) -> None: - pass - - def _read_lines(self) -> Iterator[Optional[Event]]: - while True: - try: - msg = self._q_server_msgs.get(timeout=0.1) - except QueueEmpty: - yield None - continue - if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION': - err = IrcConnAbortException(msg) - yield self._on_handled_loop_exception(err) - return - yield self._make_recv_event(IrcMessage.from_raw(msg)) - - -class _TestClientKnowingTui(ClientKnowingTui): - _cls_conn = _FakeIrcConnection - - -class TestingClientTui(ClientTui): - 'Collects keypresses via TestTerminal and test file, compares log results.' - _clients: list[_TestClientKnowingTui] - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._clients = [] - assert isinstance(self._term, TestTerminal) - self._q_keypresses = self._term._q_keypresses - with _PATH_TEST_TXT.open('r', encoding='utf8') as f: - self._playbook = tuple(line[:-1] for line in f.readlines()) - self._playbook_idx = -1 - self._play_till_next_log() - - def _new_client(self, conn_setup: IrcConnSetup) -> _TestClientKnowingTui: - self._clients += [_TestClientKnowingTui(_q_out=self._q_out, - conn_setup=conn_setup)] - return self._clients[-1] - - def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]: - win_ids, logged_msg = super().log(msg, **kwargs) - time_str, msg_sans_time = logged_msg.split(' ', maxsplit=1) - assert len(time_str) == 8 - for c in time_str[:2] + time_str[3:5] + time_str[6:]: - assert c.isdigit() - assert time_str[2] == ':' and time_str[5] == ':' - context, expected_msg = self._playbook[self._playbook_idx - ].split(maxsplit=1) - if ':' in context: - _, context = context.split(':') - expected_win_ids = tuple(int(idx) for idx in context.split(',') if idx) - info = (self._playbook_idx + 1, - 'WANTED:', expected_win_ids, expected_msg, - 'GOT:', win_ids, msg_sans_time) - assert expected_msg == msg_sans_time, info - assert expected_win_ids == win_ids, info - self._play_till_next_log() - return win_ids, logged_msg - - def _play_till_next_log(self) -> None: - while True: - self._playbook_idx += 1 - line = self._playbook[self._playbook_idx] - if line[:1] == '#' or not line.strip(): - continue - context, msg = line.split(' ', maxsplit=1) - if context == 'repeat': - start, end = msg.split(':') - self._playbook = (self._playbook[:self._playbook_idx + 1] - + self._playbook[int(start):int(end)] - + self._playbook[self._playbook_idx + 1:]) - continue - if context == '>': - for c in msg: - self._q_keypresses.put(c) - self._q_keypresses.put('KEY_ENTER') - continue - if ':' in context and msg.startswith('< '): - client_id, win_ids = context.split(':') - client = self._clients[int(client_id)] - assert isinstance(client.conn, _FakeIrcConnection) - client.conn.put_server_msg(msg[2:]) - if not win_ids: - continue - break diff --git a/ircplom/tui_base.py b/ircplom/tui_base.py deleted file mode 100644 index 73115a1..0000000 --- a/ircplom/tui_base.py +++ /dev/null @@ -1,734 +0,0 @@ -'Base Terminal and TUI management.' -# built-ins -from abc import ABC, abstractmethod -from base64 import b64decode -from contextlib import contextmanager -from datetime import datetime -from inspect import _empty as inspect_empty, signature, stack -from signal import SIGWINCH, signal -from typing import (Callable, Generator, Iterator, NamedTuple, Optional, - Sequence) -# requirements.txt -from blessed import Terminal as BlessedTerminal -# ourselves -from ircplom.events import AffectiveEvent, Loop, QueueMixin, QuitEvent - -_LOG_PREFIX_DEFAULT = '#' -_LOG_PREFIX_ALERT = '!' - -_MIN_HEIGHT = 4 -_MIN_WIDTH = 32 - -_TIMEOUT_KEYPRESS_LOOP = 0.5 -_B64_PREFIX = 'b64:' -_OSC52_PREFIX = b']52;c;' -_PASTE_DELIMITER = '\007' - -_PROMPT_TEMPLATE = '> ' -_PROMPT_ELL_IN = '<…' -_PROMPT_ELL_OUT = '…>' - -_CHAR_RESIZE = chr(12) -_KEYBINDINGS = { - 'KEY_BACKSPACE': ('window.prompt.backspace',), - 'KEY_ENTER': ('prompt_enter',), - 'KEY_LEFT': ('window.prompt.move_cursor', 'left'), - 'KEY_RIGHT': ('window.prompt.move_cursor', 'right'), - 'KEY_UP': ('window.prompt.scroll', 'up'), - 'KEY_DOWN': ('window.prompt.scroll', 'down'), - 'KEY_PGUP': ('window.history.scroll', 'up'), - 'KEY_PGDOWN': ('window.history.scroll', 'down'), - 'esc:91:49:59:51:68': ('window', 'left'), - 'esc:91:49:59:51:67': ('window', 'right'), - 'KEY_F1': ('window.paste',), -} -CMD_SHORTCUTS: dict[str, str] = {} - - -class _YX(NamedTuple): - y: int - x: int - - -class _Widget(ABC): - _tainted: bool = True - _sizes = _YX(-1, -1) - - @property - def _drawable(self) -> bool: - return len([m for m in self._sizes if m < 1]) == 0 - - def taint(self) -> None: - 'Declare as in need of re-drawing.' - self._tainted = True - - @property - def tainted(self) -> bool: - 'If in need of re-drawing.' - return self._tainted - - def set_geometry(self, sizes: _YX) -> None: - 'Update widget\'s sizues, re-generate content where necessary.' - self.taint() - self._sizes = sizes - - def draw(self) -> None: - 'Print widget\'s content in shape appropriate to set geometry.' - if self._drawable: - self._draw() - self._tainted = False - - @abstractmethod - def _draw(self) -> None: - pass - - -class _ScrollableWidget(_Widget): - _history_idx: int - - def __init__(self, write: Callable[..., None], **kwargs) -> None: - super().__init__(**kwargs) - self._write = write - self._history: list[str] = [] - - def append(self, to_append: str) -> None: - 'Append to scrollable history.' - self._history += [to_append] - - @abstractmethod - def _scroll(self, up=True) -> None: - self.taint() - - def cmd__scroll(self, direction: str) -> None: - 'Scroll through stored content/history.' - self._scroll(up=direction == 'up') - - -class _HistoryWidget(_ScrollableWidget): - _last_read: int = 0 - _y_pgscroll: int - - def __init__(self, wrap: Callable[[str], list[str]], **kwargs) -> None: - super().__init__(**kwargs) - self._wrap = wrap - self._wrapped_idx = self._history_idx = -1 - self._wrapped: list[tuple[Optional[int], str]] = [] - - def _add_wrapped(self, idx_original, line) -> int: - wrapped_lines = self._wrap(line) - self._wrapped += [(idx_original, line) for line in wrapped_lines] - return len(wrapped_lines) - - def set_geometry(self, sizes: _YX) -> None: - super().set_geometry(sizes) - if self._drawable: - self._y_pgscroll = self._sizes.y // 2 - self._wrapped.clear() - self._wrapped += [(None, '')] * self._sizes.y - if self._history: - for idx_history, line in enumerate(self._history): - self._add_wrapped(idx_history, line) - wrapped_lines_for_history_idx = [ - t for t in self._wrapped - if t[0] == len(self._history) + self._history_idx] - idx_their_last = self._wrapped.index( - wrapped_lines_for_history_idx[-1]) - self._wrapped_idx = idx_their_last - len(self._wrapped) - - def append(self, to_append: str) -> None: - super().append(to_append) - self.taint() - if self._history_idx < -1: - self._history_idx -= 1 - if self._drawable: - n_wrapped_lines = self._add_wrapped(len(self._history) - - 1, to_append) - if self._wrapped_idx < -1: - self._wrapped_idx -= n_wrapped_lines - - def _draw(self) -> None: - start_idx = self._wrapped_idx - self._sizes.y + 1 - end_idx = self._wrapped_idx - to_write = [t[1] for t in self._wrapped[start_idx:end_idx]] - if self._wrapped_idx < -1: - scroll_info = f'vvv [{(-1) * self._wrapped_idx}] ' - scroll_info += 'v' * (self._sizes.x - len(scroll_info)) - to_write += [scroll_info] - else: - to_write += [self._wrapped[self._wrapped_idx][1]] - for i, line in enumerate(to_write): - self._write(line, i) - self._last_read = len(self._history) - - @property - def n_lines_unread(self) -> int: - 'How many new lines have been logged since last focus.' - return len(self._history) - self._last_read - - def _scroll(self, up: bool = True) -> None: - super()._scroll(up) - if self._drawable: - if up: - self._wrapped_idx = max( - self._sizes.y + 1 - len(self._wrapped), - self._wrapped_idx - self._y_pgscroll) - else: - self._wrapped_idx = min( - -1, self._wrapped_idx + self._y_pgscroll) - history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0] - if history_idx_to_wrapped_idx is not None: - self._history_idx = history_idx_to_wrapped_idx\ - - len(self._history) - - -class PromptWidget(_ScrollableWidget): - 'Manages/displays keyboard input field.' - _history_idx: int = 0 - _input_buffer_unsafe: str - _cursor_x: int - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._reset_buffer('') - - @property - def prefix(self) -> str: - 'Main input prefix.' - return _PROMPT_TEMPLATE[:] - - @property - def _input_buffer(self) -> str: - return self._input_buffer_unsafe[:] - - @_input_buffer.setter - def _input_buffer(self, content) -> None: - self.taint() - self._input_buffer_unsafe = content - - def _draw(self) -> None: - prefix = self.prefix[:] - content = self._input_buffer - if self._cursor_x == len(self._input_buffer): - content += ' ' - half_width = (self._sizes.x - len(prefix)) // 2 - offset = 0 - if len(prefix) + len(content) > self._sizes.x\ - and self._cursor_x > half_width: - prefix += _PROMPT_ELL_IN - offset = min(len(prefix) + len(content) - self._sizes.x, - self._cursor_x - half_width + len(_PROMPT_ELL_IN)) - cursor_x_to_write = len(prefix) + self._cursor_x - offset - to_write = f'{prefix}{content[offset:]}' - if len(to_write) > self._sizes.x: - to_write = (to_write[:self._sizes.x-len(_PROMPT_ELL_OUT)] - + _PROMPT_ELL_OUT) - self._write(to_write[:cursor_x_to_write], self._sizes.y, - padding=False) - self._write(to_write[cursor_x_to_write], attribute='reverse', - padding=False) - self._write(to_write[cursor_x_to_write + 1:]) - - def _archive_prompt(self) -> None: - self.append(self._input_buffer) - self._reset_buffer('') - - def _scroll(self, up: bool = True) -> None: - super()._scroll(up) - if up and -(self._history_idx) < len(self._history): - if self._history_idx == 0 and self._input_buffer: - self._archive_prompt() - self._history_idx -= 1 - self._history_idx -= 1 - self._reset_buffer(self._history[self._history_idx]) - elif not up: - if self._history_idx < 0: - self._history_idx += 1 - if self._history_idx == 0: - self._reset_buffer('') - else: - self._reset_buffer(self._history[self._history_idx]) - elif self._input_buffer: - self._archive_prompt() - - def insert(self, to_insert: str) -> None: - 'Insert into prompt input buffer.' - self._cursor_x += len(to_insert) - self._input_buffer = (self._input_buffer[:self._cursor_x - 1] - + to_insert - + self._input_buffer[self._cursor_x - 1:]) - self._history_idx = 0 - - def cmd__backspace(self) -> None: - 'Truncate current content by one character, if possible.' - if self._cursor_x > 0: - self._cursor_x -= 1 - self._input_buffer = (self._input_buffer[:self._cursor_x] - + self._input_buffer[self._cursor_x + 1:]) - self._history_idx = 0 - - def cmd__move_cursor(self, direction: str) -> None: - 'Move cursor one space into direction ("left" or "right") if possible.' - if direction == 'left' and self._cursor_x > 0: - self._cursor_x -= 1 - elif direction == 'right'\ - and self._cursor_x < len(self._input_buffer): - self._cursor_x += 1 - else: - return - self.taint() - - def _reset_buffer(self, content: str) -> None: - self._input_buffer = content - self._cursor_x = len(self._input_buffer) - - def enter(self) -> str: - 'Return current content while also clearing and then redrawing.' - to_return = self._input_buffer[:] - if to_return: - self._archive_prompt() - return to_return - - -class _StatusLine(_Widget): - - def __init__(self, write: Callable, windows: list['Window'], **kwargs - ) -> None: - super().__init__(**kwargs) - self.idx_focus = 0 - self._windows = windows - self._write = write - - def _draw(self) -> None: - listed = [] - focused = None - for w in self._windows: - item = str(w.idx) - if (n := w.history.n_lines_unread): - item = f'({item}:{n})' - if w.idx == self.idx_focus: - focused = w - item = f'[{item}]' - listed += [item] - assert isinstance(focused, Window) - left = f'{focused.title})' - right = f'({" ".join(listed)}' - width_gap = max(1, (self._sizes.x - len(left) - len(right))) - self._write(left + '=' * width_gap + right, self._sizes.y) - - -class Window: - 'Collection of widgets filling entire screen.' - _y_status: int - _drawable = False - prompt: PromptWidget - _title = ':start' - _last_today = '' - - def __init__(self, idx: int, term: 'Terminal', **kwargs) -> None: - super().__init__(**kwargs) - self.idx = idx - self._term = term - self.history = _HistoryWidget(wrap=self._term.wrap, - write=self._term.write) - self.prompt = self.__annotations__['prompt'](write=self._term.write) - if hasattr(self._term, 'size'): - self.set_geometry() - - def ensure_date(self, today: str) -> None: - 'Log date of today if it has not been logged yet.' - if today != self._last_today: - self._last_today = today - self.log(today) - - def log(self, msg: str) -> None: - 'Append msg to .history.' - self.history.append(msg) - - def taint(self) -> None: - 'Declare all widgets as in need of re-drawing.' - self.history.taint() - self.prompt.taint() - - @property - def tainted(self) -> bool: - 'If any widget in need of re-drawing.' - return self.history.tainted or self.prompt.tainted - - def set_geometry(self) -> None: - 'Set geometry for widgets.' - self._drawable = False - if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH: - for widget in (self.history, self.prompt): - widget.set_geometry(_YX(-1, -1)) - return - self._y_status = self._term.size.y - 2 - self.history.set_geometry(_YX(self._y_status, self._term.size.x)) - self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x)) - self._drawable = True - - @property - def title(self) -> str: - 'Window title to display in status line.' - return self._title - - def draw_tainted(self) -> None: - 'Draw tainted widgets (or message that screen too small).' - if self._drawable: - for widget in [w for w in (self.history, self.prompt) - if w.tainted]: - widget.draw() - elif self._term.size.x > 0: - lines = [''] - for i, c in enumerate('screen too small'): - if i > 0 and 0 == i % self._term.size.x: - lines += [''] - lines[-1] += c - for y, line in enumerate(lines): - self._term.write(line, y) - - def cmd__paste(self) -> None: - 'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.' - self.history.append(f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}') - - -class TuiEvent(AffectiveEvent): - 'To affect TUI.' - - -class TerminalInterface(ABC): - 'What BaseTui expects from a Terminal.' - size: _YX - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - - @abstractmethod - @contextmanager - def setup(self) -> Generator: - 'Combine multiple contexts into one and run keypress loop.' - - @abstractmethod - def calc_geometry(self) -> None: - '(Re-)calculate .size..' - - @abstractmethod - def flush(self) -> None: - 'Flush terminal.' - - @abstractmethod - def wrap(self, line: str) -> list[str]: - 'Wrap line to list of lines fitting into terminal width.' - - @abstractmethod - def write(self, - msg: str = '', - start_y: Optional[int] = None, - attribute: Optional[str] = None, - padding: bool = True - ) -> None: - 'Print to terminal, with position, padding to line end, attributes.' - - @abstractmethod - def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: - pass - - -class BaseTui(QueueMixin): - 'Base for graphical user interface elements.' - - def __init__(self, term: TerminalInterface, **kwargs) -> None: - super().__init__(**kwargs) - self._term = term - self._window_idx = 0 - self._windows: list[Window] = [] - self._status_line = _StatusLine(write=self._term.write, - windows=self._windows) - self._new_window() - self._set_screen() - signal(SIGWINCH, lambda *_: self._set_screen()) - - def _log_target_wins(self, **_) -> Sequence[Window]: - # separated to serve as hook for subclass window selection - return [self.window] - - def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]: - 'Write with timestamp, prefix to what window ._log_target_wins offers.' - prefix = kwargs.get('prefix', _LOG_PREFIX_DEFAULT) - if kwargs.get('alert', False): - prefix = _LOG_PREFIX_ALERT + prefix - now = str(datetime.now()) - today, time = now[:10], now[11:19] - msg = f'{time} {prefix} {msg}' - affected_win_indices = [] - for win in self._log_target_wins(**kwargs): - affected_win_indices += [win.idx] - win.ensure_date(today) - win.log(msg) - if win != self.window: - self._status_line.taint() - return tuple(affected_win_indices), msg - - def _new_window(self, win_class=Window, **kwargs) -> Window: - new_idx = len(self._windows) - win = win_class(idx=new_idx, term=self._term, **kwargs) - self._windows += [win] - return win - - def redraw_affected(self) -> None: - 'On focused window call .draw, then flush screen.' - self.window.draw_tainted() - if self._status_line.tainted: - self._status_line.draw() - self._term.flush() - - def _set_screen(self) -> None: - 'Calc screen geometry into windows, then call .redraw_affected.' - self._term.calc_geometry() - for window in self._windows: - window.set_geometry() - self._status_line.set_geometry(_YX(self._term.size.y - 2, - self._term.size.x)) - self.redraw_affected() - - @property - def window(self) -> Window: - 'Currently selected Window.' - return self._windows[self._window_idx] - - def _switch_window(self, idx: int) -> None: - self.window.taint() - self._status_line.idx_focus = self._window_idx = idx - self._status_line.taint() - - @property - def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]], - int, tuple[str, ...]]]: - cmds = {} - method_name_prefix = 'cmd__' - base = 'self' - for path in (base, f'{base}.window', f'{base}.window.prompt', - f'{base}.window.history'): - for cmd_method_name in [name for name in dir(eval(path)) - if name.startswith(method_name_prefix)]: - path_prefix = f'{path}.' - cmd_name = (path_prefix[len(base)+1:] - + cmd_method_name[len(method_name_prefix):]) - method = eval(f'{path_prefix}{cmd_method_name}') - n_args_min = 0 - arg_names = [] - for arg_name, param in signature(method).parameters.items(): - arg_names += [arg_name] - n_args_min += int(param.default == inspect_empty) - cmds[cmd_name] = (method, n_args_min, tuple(arg_names)) - for key, target in CMD_SHORTCUTS.items(): - if target in cmds: - cmds[key] = cmds[target] - return cmds - - def handle_keyboard_event(self, typed_in: str) -> None: - 'Translate keyboard input into appropriate actions.' - if typed_in[0] == _CHAR_RESIZE: - self._set_screen() - return - if typed_in in _KEYBINDINGS: - cmd_data = _KEYBINDINGS[typed_in] - self._commands[cmd_data[0]][0](*cmd_data[1:]) - elif typed_in.startswith(_B64_PREFIX): - encoded = typed_in[len(_B64_PREFIX):] - to_paste = '' - for i, c in enumerate(b64decode(encoded).decode('utf-8')): - if i > 512: - break - if c.isprintable(): - to_paste += c - elif c.isspace(): - to_paste += ' ' - else: - to_paste += '#' - self.window.prompt.insert(to_paste) - elif len(typed_in) == 1: - self.window.prompt.insert(typed_in) - else: - self.log(f'unknown keyboard input: {typed_in}', alert=True) - self.redraw_affected() - - def cmd__prompt_enter(self) -> None: - 'Get prompt content from .window.prompt.enter, parse to & run command' - to_parse = self.window.prompt.enter() - if not to_parse: - return - alert: Optional[str] = None - if to_parse[0] == '/': - toks = to_parse.split(maxsplit=1) - cmd_name = toks.pop(0) - cmd, n_args_min, arg_names = self._commands.get(cmd_name[1:], - (None, 0, ())) - if not cmd: - alert = f'{cmd_name} unknown' - elif cmd.__name__ == stack()[0].function: - alert = f'{cmd_name} would loop into ourselves' - else: - n_args_max = len(arg_names) - if toks and not n_args_max: - alert = f'{cmd_name} given argument(s) while none expected' - else: - if toks: - while ' ' in toks[-1] and len(toks) < n_args_max: - toks = toks[:-1] + toks[-1].split(maxsplit=1) - if len(toks) < n_args_min: - alert = f'{cmd_name} too few arguments '\ - + f'(given {len(toks)}, need {n_args_min})' - else: - alert = cmd(*toks) - else: - alert = 'not prefixed by /' - if alert: - self.log(f'invalid prompt command: {alert}', alert=True) - - def cmd__help(self) -> None: - 'Print available commands.' - self.log('commands available in this window:') - to_log = [] - for cmd_name, cmd_data in self._commands.items(): - to_print = [cmd_name] - for idx, arg in enumerate(cmd_data[2]): - arg = arg.upper() - if idx >= cmd_data[1]: - arg = f'[{arg}]' - to_print += [arg] - to_log += [' '.join(to_print)] - for item in sorted(to_log): - self.log(f' /{item}') - - def cmd__list(self) -> None: - 'List available windows.' - self.log('windows available via /window:') - for win in self._windows: - self.log(f' {win.idx}) {win.title}') - - def cmd__quit(self) -> None: - 'Trigger program exit.' - self._put(QuitEvent()) - - def cmd__window(self, towards: str) -> Optional[str]: - 'Switch window selection.' - n_windows = len(self._windows) - if n_windows < 2: - return 'no alternate window to move into' - if towards in {'left', 'right'}: - multiplier = (+1) if towards == 'right' else (-1) - window_idx = self._window_idx + multiplier - if not 0 <= window_idx < n_windows: - window_idx -= multiplier * n_windows - elif not towards.isdigit(): - return f'neither "left"/"right" nor integer: {towards}' - else: - window_idx = int(towards) - if not 0 <= window_idx < n_windows: - return f'unavailable window idx: {window_idx}' - self._switch_window(window_idx) - return None - - -class Terminal(QueueMixin, TerminalInterface): - 'Abstraction of terminal interface.' - _cursor_yx_: _YX - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self._blessed = BlessedTerminal() - self._cursor_yx = _YX(0, 0) - - @contextmanager - def setup(self) -> Generator: - print(self._blessed.clear, end='') - with (self._blessed.raw(), - self._blessed.fullscreen(), - self._blessed.hidden_cursor(), - Loop(iterator=self._get_keypresses(), _q_out=self._q_out)): - yield self - - @property - def _cursor_yx(self) -> _YX: - return self._cursor_yx_ - - @_cursor_yx.setter - def _cursor_yx(self, yx: _YX) -> None: - print(self._blessed.move_yx(yx.y, yx.x), end='') - self._cursor_yx_ = yx - - def calc_geometry(self) -> None: - self.size = _YX(self._blessed.height, self._blessed.width) - - def flush(self) -> None: - print('', end='', flush=True) - - def wrap(self, line: str) -> list[str]: - return self._blessed.wrap(line, width=self.size.x, - subsequent_indent=' '*4) - - def write(self, - msg: str = '', - start_y: Optional[int] = None, - attribute: Optional[str] = None, - padding: bool = True - ) -> None: - if start_y is not None: - self._cursor_yx = _YX(start_y, 0) - # ._blessed.length can slow down things notably: only use where needed! - end_x = self._cursor_yx.x + (len(msg) if msg.isascii() - else self._blessed.length(msg)) - len_padding = self.size.x - end_x - if len_padding < 0: - msg = self._blessed.truncate(msg, self.size.x - self._cursor_yx.x) - elif padding: - msg += ' ' * len_padding - end_x = self.size.x - if attribute: - msg = getattr(self._blessed, attribute)(msg) - print(msg, end='') - self._cursor_yx = _YX(self._cursor_yx.y, end_x) - - def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: - '''Loop through keypresses from terminal, expand blessed's handling. - - Explicitly collect KEY_ESCAPE-modified key sequences, and recognize - OSC52-prefixed pastables to return the respective base64 code, - prefixed with _B64_PREFIX. - ''' - while True: - to_yield = '' - ks = self._blessed.inkey( - timeout=_TIMEOUT_KEYPRESS_LOOP, # how long until yield None, - esc_delay=0) # incl. until thread dies - if ks.name != 'KEY_ESCAPE': - to_yield = f'{ks.name if ks.name else ks}' - else: - chars = b'' - while (new_chars := self._blessed.inkey(timeout=0, esc_delay=0 - ).encode('utf-8')): - chars += new_chars - len_prefix = len(_OSC52_PREFIX) - if chars[:len_prefix] == _OSC52_PREFIX: - to_yield = _B64_PREFIX[:] - # sometimes, prev .inkey got some or all (including paste - # delimiter) of the paste code (maybe even more), so first - # harvest potential remains of chars post prefix … - caught_delimiter = False - post_prefix_str = chars[len_prefix:].decode('utf-8') - for idx, c in enumerate(post_prefix_str): - if c == _PASTE_DELIMITER: - caught_delimiter = True - if (remains := post_prefix_str[idx + 1:]): - self._blessed.ungetch(remains) - break - to_yield += c - # … before .getch() further until expected delimiter found - if not caught_delimiter: - while (c := self._blessed.getch()) != _PASTE_DELIMITER: - to_yield += c - else: - to_yield = 'esc:' + ':'.join([str(int(b)) for b in chars]) - yield (TuiEvent.affector('handle_keyboard_event' - ).kw(typed_in=to_yield) if to_yield - else None) diff --git a/plomlib b/plomlib new file mode 160000 index 0000000..f2dc66a --- /dev/null +++ b/plomlib @@ -0,0 +1 @@ +Subproject commit f2dc66a2d4f1e8823246d1621b424e44ec423897 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d43de1b..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -blessed diff --git a/src/ircplom/__init__.py b/src/ircplom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ircplom/client.py b/src/ircplom/client.py new file mode 100644 index 0000000..d0bc4b8 --- /dev/null +++ b/src/ircplom/client.py @@ -0,0 +1,984 @@ +'High-level IRC protocol / server connection management.' +# built-ins +from abc import ABC, abstractmethod +from base64 import b64encode +from dataclasses import dataclass, InitVar +from getpass import getuser +from threading import Thread +from typing import (Any, Callable, Collection, Generic, Iterable, Iterator, + Optional, Self, Set, TypeVar) +# ourselves +from ircplom.events import ( + AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin) +from ircplom.irc_conn import ( + BaseIrcConnection, IrcConnAbortException, IrcMessage, + ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL) +from ircplom.msg_parse_expectations import MSG_EXPECTATIONS + + +_NAMES_DESIRED_SERVER_CAPS = ('sasl',) + + +class SendFail(BaseException): + 'When Client.send fails.' + + +class TargetUserOffline(BaseException): + 'When according to server our target user is not to be found.' + + +class ImplementationFail(BaseException): + 'When no matching parser found for server message.' + + +class AutoAttrMixin: + 'Ensures attribute as defined by annotations along MRO' + + @classmethod + def _deep_annotations(cls) -> dict[str, type]: + types: dict[str, type] = {} + for c in cls.__mro__: + if hasattr(c, '__annotations__'): + types = c.__annotations__ | types + return {k: v for k, v in types.items() if k[0] != '_'} + + def __getattribute__(self, key: str): + if key[0] != '_' and (cls := self._deep_annotations().get(key, None)): + try: + return super().__getattribute__(key) + except AttributeError: + setattr(self, key, self._make_attr(cls, key)) + return super().__getattribute__(key) + + +class _Clearable(ABC): + + @abstractmethod + def clear(self) -> None: + 'Zero internal knowledge.' + + +DictItem = TypeVar('DictItem') + + +class Dict(_Clearable, Generic[DictItem]): + 'Customized dict replacement.' + + def __init__(self, **kwargs) -> None: + self._dict: dict[str, DictItem] = {} + super().__init__(**kwargs) + + def keys(self) -> tuple[str, ...]: + 'Keys of item registrations.' + return tuple(self._dict.keys()) + + def __getitem__(self, key: str) -> DictItem: + return self._dict[key] + + def clear(self) -> None: + self._dict.clear() + + @property + def _item_cls(self): + orig_cls = (self.__orig_class__ if hasattr(self, '__orig_class__') + else self.__orig_bases__[0]) + return orig_cls.__args__[0] + + +class _Dict(Dict[DictItem]): + + def __setitem__(self, key: str, val: DictItem) -> None: + assert isinstance(val, self._item_cls) + self._dict[key] = val + + def __delitem__(self, key: str) -> None: + del self._dict[key] + + def values(self) -> tuple[DictItem, ...]: + 'Items registered.' + return tuple(self._dict.values()) + + @staticmethod + def key_val_from_eq_str(eq_str: str) -> tuple[str, str]: + 'Split eq_str by "=", and if none, into eq_str and "".' + toks = eq_str.split('=', maxsplit=1) + return toks[0], '' if len(toks) == 1 else toks[1] + + +class _Completable(ABC): + completed: Any + + @abstractmethod + def complete(self) -> None: + 'Set .completed to "complete" value if possible of current state.' + + +class _CompletableStringsCollection(_Completable, Collection): + _collected: Collection[str] + completed: Optional[Collection[str]] = None + + @abstractmethod + def _copy_collected(self) -> Collection[str]: + pass + + def complete(self) -> None: + self.completed = self._copy_collected() + + def __len__(self) -> int: + assert self.completed is not None + return len(self.completed) + + def __contains__(self, item) -> bool: + assert self.completed is not None + assert isinstance(item, str) + return item in self.completed + + def __iter__(self) -> Iterator[str]: + assert self.completed is not None + yield from self.completed + + +class _CompletableStringsSet(_CompletableStringsCollection, Set): + _collected: set[str] + completed: Optional[set[str]] = None + + def __init__(self) -> None: + self._collected = set() + + def _copy_collected(self) -> set[str]: + return set(self._collected) + + def _on_set(self, m_name: str, item: str, on_complete: bool, exists: bool + ) -> None: + method = getattr(self._collected, m_name) + if on_complete: + assert self.completed is not None + assert (item in self.completed) == exists + assert (item in self._collected) == exists + method(item) + self.complete() + else: + assert self.completed is None + assert (item in self._collected) == exists + method(item) + + def completable_add(self, item: str, on_complete: bool) -> None: + 'Put item into collection.' + self._on_set('add', item, on_complete, False) + + def completable_remove(self, item: str, on_complete: bool) -> None: + 'Remove item from collection.' + self._on_set('remove', item, on_complete, True) + + def intersection(self, *others: Iterable[str]) -> set[str]: + 'Compare self to other set(s).' + assert self.completed is not None + result = self.completed + for other in others: + assert isinstance(other, set) + result = result.intersection(other) + return result + + +class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection): + _collected: tuple[str, ...] = tuple() + completed: Optional[tuple[str, ...]] = tuple() + + def _copy_collected(self) -> tuple[str, ...]: + return tuple(self._collected) + + def append(self, value: str, complete=False) -> None: + 'Append value to list.' + self._collected += (value,) + if complete: + self.complete() + + def clear(self) -> None: + self._collected = tuple() + self.complete() + + +class _UpdatingMixin: + + def __init__(self, on_update: Callable, **kwargs) -> None: + super().__init__(**kwargs) + self._on_update = on_update + + +class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin): + + def _make_attr(self, cls: Callable, key: str): + return cls(on_update=lambda *steps: self._on_update(key, *steps)) + + def __setattr__(self, key: str, value) -> None: + super().__setattr__(key, value) + if hasattr(self, '_on_update') and key in self._deep_annotations(): + self._on_update(key) + + def attr_names(self) -> tuple[str, ...]: + 'Names of (deeply) annotated attributes.' + return tuple(self._deep_annotations().keys()) + + +class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]): + _create_if_none: Optional[dict[str, Any]] = None + + def __bool__(self) -> bool: + return bool(self._dict) + + def __getitem__(self, key: str) -> DictItem: + if key not in self._dict: + if self._create_if_none is not None: + kw = {} | self._create_if_none + if _UpdatingMixin in self._item_cls.__mro__: + kw |= {'on_update': + lambda *steps: self._on_update(key, *steps)} + self[key] = self._item_cls(**kw) + return super().__getitem__(key) + + def __setitem__(self, key: str, val: DictItem) -> None: + super().__setitem__(key, val) + self._on_update(key) + + def __delitem__(self, key: str) -> None: + super().__delitem__(key) + self._on_update(key) + + def clear(self) -> None: + super().clear() + self._on_update() + + +class _UpdatingCompletable(_UpdatingMixin, _Completable): + + def complete(self) -> None: + super().complete() # type: ignore + self._on_update() + + +class _UpdatingCompletableStringsSet( + _UpdatingCompletable, _CompletableStringsSet): + pass + + +class _UpdatingCompletableStringsOrdered( + _UpdatingCompletable, _CompletableStringsOrdered): + pass + + +@dataclass +class IrcConnSetup: + 'All we need to know to set up a new Client connection.' + hostname: str = '' + port: int = 0 + nick_wanted: str = '' + user_wanted: str = '' + realname: str = '' + password: str = '' + + +class ChatMessage: + 'Collects all we want to know on incoming PRIVMSG or NOTICE chat message.' + content: str = '' + sender: str = '' + target: str = '' + is_notice: bool = False + + def __str__(self) -> str: + return f'{"N" if self.is_notice else "P"}|'\ + + f'{self.sender}|{self.target}|{self.content}' + + def __bool__(self) -> bool: + return bool(self.content + self.sender + self.target) | self.is_notice + + +class SharedClientDbFields(IrcConnSetup): + 'API for fields shared directly in name and type with TUI.' + connection_state: str = '' + isupport: Dict[str] + motd: Iterable[str] + sasl_account: str = '' + sasl_auth_state: str = '' + message: ChatMessage = ChatMessage() + + def is_chan_name(self, name: str) -> bool: + 'Tests name to match CHANTYPES prefixes.' + return name[0] in self.isupport['CHANTYPES'] + + +@dataclass +class NickUserHost: + 'Combination of nickname, username on host, and host.' + nick: str = '?' + user: str = '?' + host: str = '?' + + def __str__(self) -> str: + return f'{self.nick}!{self.user}@{self.host}' + + +class User(NickUserHost): + 'Adds to NickUserHost non-naming-specific attributes.' + modes: str = '?' + exit_msg: str = '' + + +@dataclass +class ServerCapability: + 'Public API for CAP data.' + data: str = '' + enabled: bool = False + + +@dataclass +class Topic: + 'Collects both setter and content of channel topic.' + what: str = '' + who: Optional[NickUserHost] = None + + +class Channel: + 'Collects .topic, and in .user_ids inhabitant IDs.' + topic: Topic + user_ids: Iterable[str] + exits: Dict[str] + + +@dataclass +class _ClientIdMixin: + 'Collects a Client\'s ID at .client_id.' + client_id: str + + +@dataclass +class ClientQueueMixin(QueueMixin, _ClientIdMixin): + 'To QueueMixin adds _cput to send ClientEvent for self.' + + def _client_trigger(self, t_method: str, **kwargs) -> None: + self._put(ClientEvent.affector(t_method, client_id=self.client_id + ).kw(**kwargs)) + + +@dataclass +class IrcConnection(BaseIrcConnection, _ClientIdMixin): + 'Parent extended to work with Client.' + hostname: InitVar[str] # needed by BaseIrcConnection, but not desired as + port: InitVar[int] # dataclass fields, only for __post_init__ call + + def __post_init__(self, hostname, port, **kwargs) -> None: + super().__init__(hostname=hostname, port=port, _q_out=self._q_out, + **kwargs) + + def _make_recv_event(self, msg: IrcMessage) -> 'ClientEvent': + return ClientEvent.affector('handle_msg', client_id=self.client_id + ).kw(msg=msg) + + def _on_handled_loop_exception(self, e: IrcConnAbortException + ) -> 'ClientEvent': + return ClientEvent.affector('on_handled_loop_exception', + client_id=self.client_id).kw(e=e) + + +class _CompletableTopic(_Completable, Topic): + completed: Optional[Topic] = None + + def complete(self) -> None: + assert self.who is not None + copy = _NickUserHost.from_str(str(self.who)) + self.completed = Topic(self.what, + NickUserHost(copy.nick, copy.user, copy.host)) + + +class _Channel(Channel): + user_ids: _CompletableStringsSet + topic: _CompletableTopic + exits: _Dict[str] + + def __init__(self, + userid_for_nickuserhost: Callable, + get_membership_prefixes: Callable, + purge_users: Callable, + **kwargs + ) -> None: + self._userid_for_nickuserhost = userid_for_nickuserhost + self._get_membership_prefixes = get_membership_prefixes + self.purge_users = purge_users + super().__init__(**kwargs) + + def add_from_namreply(self, items: tuple[str, ...]) -> None: + 'Add to .user_ids items assumed as nicknames with membership prefixes.' + for item in items: + n_u_h = NickUserHost(item.lstrip(self._get_membership_prefixes())) + user_id = self._userid_for_nickuserhost(n_u_h, create_if_none=True) + self.user_ids.completable_add(user_id, on_complete=False) + + def add_user(self, user: '_User') -> None: + 'To .user_ids add user.nickname, keep .user_ids declared complete.' + user_id = self._userid_for_nickuserhost(user, create_if_none=True, + updating=True) + self.user_ids.completable_add(user_id, on_complete=True) + + def remove_user(self, user: '_User', msg: str) -> None: + 'From .user_ids remove .nickname, keep .user_ids declared complete.' + self.exits[user.id_] = msg + self.user_ids.completable_remove(user.id_, on_complete=True) + del self.exits[user.id_] + self.purge_users() + + +class _ChatMessage(ChatMessage): + + def __init__(self, + sender: str | NickUserHost = '', + db: Optional['_ClientDb'] = None + ) -> None: + self.sender = sender if isinstance(sender, str) else sender.nick + self._db = db + + def to(self, target: str) -> Self: + 'Extend self with .target, return self.' + self.target = target + return self + + def __setattr__(self, key: str, value: str) -> None: + if key in {'privmsg', 'notice'}: + assert self._db is not None + self.is_notice = key == 'notice' + self.content = value + self._db.message = self + # to clean update cache, enabling equal messages in direct sequence + self._db.message = ChatMessage() + else: + super().__setattr__(key, value) + + +class _SetNickuserhostMixin: + + def __setattr__(self, key: str, value: NickUserHost | str) -> None: + if key == 'nickuserhost' and isinstance(value, NickUserHost): + for annotated_key in NickUserHost.__annotations__: + setattr(self, annotated_key, getattr(value, annotated_key)) + else: + super().__setattr__(key, value) + + +class _NickUserHost(NickUserHost): + + @staticmethod + def possible_from(value: str) -> bool: + 'If class instance could be parsed from value.' + toks = value.split('!') + if not len(toks) == 2: + return False + toks = toks[1].split('@') + if not len(toks) == 2: + return False + return True + + @classmethod + def from_str(cls, value: str) -> Self: + 'Produce from string assumed to fit _!_@_ pattern.' + assert cls.possible_from(value) + toks = value.split('!') + toks = toks[0:1] + toks[1].split('@') + return cls(*toks) + + @property + def incremented(self) -> str: + 'Return .nick with number suffix incremented, or "0" if none.' + name, digits = ([(self.nick, '')] + + [(self.nick[:i], self.nick[i:]) + for i in range(len(self.nick), 0, -1) + if self.nick[i:].isdigit()] + )[-1] + return name + str(0 if not digits else (int(digits) + 1)) + + +class _User(_SetNickuserhostMixin, User): + + def __init__(self, + names_channels_of_user: Callable, + remove_from_channels: Callable, + **kwargs) -> None: + self.names_channels = lambda: names_channels_of_user(self) + self._remove_from_channels = lambda target, msg: remove_from_channels( + self, target, msg) + super().__init__(**kwargs) + + def part(self, channel_name: str, exit_msg: str) -> None: + 'First set .exit_msg, then remove from channel of channel_name.' + self._remove_from_channels(channel_name, f'P{exit_msg}') + + def quit(self, exit_msg: str) -> None: + 'First set .exit_msg, then remove from any channels.' + self.exit_msg = f'Q{exit_msg}' + self._remove_from_channels('', self.exit_msg) + + @property + def id_(self) -> str: + 'To be set to key inside dictionary if placed into one.' + return self._id_ + + @id_.setter + def id_(self, value: str) -> None: + self._id_ = value + + +class _UpdatingServerCapability(_UpdatingAttrsMixin, ServerCapability): + pass + + +class _UpdatingCompletableTopic(_UpdatingCompletable, _CompletableTopic): + pass + + +class _UpdatingChannel(_UpdatingAttrsMixin, _Channel): + user_ids: _UpdatingCompletableStringsSet + topic: _UpdatingCompletableTopic + exits: _UpdatingDict[str] + + +class _UpdatingUser(_UpdatingAttrsMixin, _User): + pass + + +class _UpdatingUsersDict(_UpdatingDict[_UpdatingUser]): + _top_id: int + userlen: int + + def __getitem__(self, key: str) -> _UpdatingUser: + user = super().__getitem__(key) + user.id_ = key + return user + + def clear(self) -> None: + super().clear() + self._top_id = 0 + self._on_update() + + def id_for_nickuserhost(self, + nickuserhost: NickUserHost, + create_if_none=False, + allow_none=False, + updating=False + ) -> Optional[str]: + 'Return user_id for nickuserhost.nick, create if none, maybe update.' + matches = [id_ for id_, user in self._dict.items() + if user.nick == nickuserhost.nick] + assert len(matches) in ({0, 1} if (create_if_none or allow_none) + else {1}) + if len(matches) == 1: + id_ = matches[0] + if '?' in {nickuserhost.user, nickuserhost.host}: + assert nickuserhost.user == nickuserhost.host # both are '?' + # only provided with .nick, no fields we could update + return id_ + stored = self._dict[id_] + # .nick by definition same, check other fields for updatability; + # allow where '?', or for set .user only to add "~" prefix, assert + # nothing else could have changed + if stored.user == '?'\ + or nickuserhost.user == f'~{stored.user}'[:self.userlen]: + assert updating + stored.user = nickuserhost.user + else: + assert stored.user == nickuserhost.user + if stored.host == '?': + assert updating + stored.host = nickuserhost.host + else: + assert stored.host == nickuserhost.host + elif create_if_none: + self._top_id += 1 + id_ = str(self._top_id) + self[id_].nickuserhost = nickuserhost + else: + return None + return id_ + + def purge(self) -> None: + 'Remove all not linked to by existing channels, except of our ID "me".' + for id_ in [id_ for id_, user in self._dict.items() + if id_ != 'me' and not user.names_channels()]: + del self[id_] + + +class _UpdatingChannelsDict(_UpdatingDict[_UpdatingChannel]): + + def _of_user(self, user: _User) -> dict[str, _UpdatingChannel]: + return {k: v for k, v in self._dict.items() if user.id_ in v.user_ids} + + def of_user(self, user: _User) -> tuple[str, ...]: + 'Return names of channels listing user as member.' + return tuple(self._of_user(user).keys()) + + def remove_user(self, user: _User, target: str, msg: str) -> None: + 'Remove user from channel named "target", or all with user if empty.' + if target: + self[target].remove_user(user, msg) + else: + for channel in self._of_user(user).values(): + channel.remove_user(user, msg) + + +class _UpdatingIsupportDict(_UpdatingDict[str]): + + def __delitem__(self, key: str) -> None: + if key in ISUPPORT_DEFAULTS: + self[key] = ISUPPORT_DEFAULTS[key] + else: + super().__delitem__(key) + + def clear(self) -> None: + super().clear() + for key, value in ISUPPORT_DEFAULTS.items(): + self[key] = value + + +class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields): + _updates_cache: dict[tuple[str, ...], Any] = {} + _keep_on_clear = set(IrcConnSetup.__annotations__.keys()) + caps: _UpdatingDict[_UpdatingServerCapability] + channels: _UpdatingChannelsDict + isupport: _UpdatingIsupportDict + motd: _UpdatingCompletableStringsOrdered + users: _UpdatingUsersDict + + def __getattribute__(self, key: str): + attr = super().__getattribute__(key) + if key == 'channels' and attr._create_if_none is None\ + and super().__getattribute__('users' + )._create_if_none is not None: + attr._create_if_none = { + 'userid_for_nickuserhost': self.users.id_for_nickuserhost, + 'get_membership_prefixes': self._get_membership_prefixes, + 'purge_users': self.users.purge} + elif key == 'users': + attr.userlen = int(self.isupport['USERLEN']) + if attr._create_if_none is None: + attr._create_if_none = { + 'names_channels_of_user': self.channels.of_user, + 'remove_from_channels': self.channels.remove_user} + elif key == 'caps' and attr._create_if_none is None: + attr._create_if_none = {} + return attr + + def messaging(self, src: str | NickUserHost) -> ChatMessage: + 'Start input chain for chat message data.' + return _ChatMessage(sender=src, db=self) + + def into_endnode_updates(self, path: tuple[str, ...] + ) -> list[tuple[tuple[str, ...], Any]]: + 'Return path-value pairs for update-worthy (sub-)elements at path.' + + def update_unless_cached(path: tuple[str, ...], val: Any + ) -> list[tuple[tuple[str, ...], Any]]: + if path not in self._updates_cache\ + or val != self._updates_cache[path]: + self._updates_cache[path] = val + return [(path, val)] + return [] + + parent = self + cache_path: tuple[str, ...] = tuple() + for step in path[:-1]: + cache_path = cache_path + (step,) + if cache_path in self._updates_cache: + del self._updates_cache[cache_path] + parent = (parent[step] if isinstance(parent, Dict) + else getattr(parent, step)) + last_step = path[-1] + val_at_path = ((parent[last_step] if last_step in parent.keys() + else None) if isinstance(parent, Dict) + else getattr(parent, last_step)) + if not isinstance(val_at_path, _UpdatingMixin): + return update_unless_cached(path, val_at_path) + if isinstance(val_at_path, _Completable): + if val_at_path.completed is not None: + return update_unless_cached(path, val_at_path.completed) + return [] + if isinstance(val_at_path, Dict) and not val_at_path.keys(): + for cache_path in [p for p in self._updates_cache + if len(p) > len(path) + and p[:len(path)] == path]: + del self._updates_cache[cache_path] + return update_unless_cached(path, None) + if isinstance(val_at_path, Dict): + sub_items = val_at_path.keys() + else: + assert isinstance(val_at_path, _UpdatingAttrsMixin) + sub_items = val_at_path.attr_names() + updates = [] + for key in sub_items: + p = path + (key,) + updates += self.into_endnode_updates(p) + return updates + + def clear(self) -> None: + for key, value in [(k, v) for k, v in self._deep_annotations().items() + if k not in self._keep_on_clear]: + if hasattr(value, '__origin__'): + value = value.__origin__ + if issubclass(value, _Clearable): + getattr(self, key).clear() + elif issubclass(value, str): + setattr(self, key, '') + + def is_nick(self, nick: str) -> bool: + 'Tests name to match rules for nicknames.' + if len(nick) == 0: + return False + if nick[0] in (ILLEGAL_NICK_FIRSTCHARS + + self.isupport['CHANTYPES'] + + self._get_membership_prefixes()): + return False + for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]: + return False + return True + + def _get_membership_prefixes(self) -> str: + 'Registered possible membership nickname prefixes.' + toks = self.isupport['PREFIX'].split(')', maxsplit=1) + assert len(toks) == 2 + assert toks[0][0] == '(' + return toks[1] + + +class _CapsManager(_Clearable): + + def __init__(self, + sender: Callable, + caps_dict: _UpdatingDict[_UpdatingServerCapability] + ) -> None: + self._dict = caps_dict + self._send = lambda *args: sender('CAP', *args) + self.clear() + + def clear(self) -> None: + self._dict.clear() + self._ls = _CompletableStringsSet() + self._list = _CompletableStringsSet() + self._list_expectations: dict[str, set[str]] = { + 'ACK': set(), 'NAK': set()} + + def start_negotation(self) -> None: + 'Call .clear, send CAPS LS 302.' + self.clear() + self._send('LS', '302') + + def end_negotiation(self) -> None: + 'Stop negotation, without emptying caps DB.' + self._send('END') + + def process_msg(self, verb: str, items: tuple[str, ...], complete: bool + ) -> bool: + 'Parse CAP message to negot. steps, DB inputs; return if successful.' + for item in items: + if verb == 'NEW': + key, data = _Dict.key_val_from_eq_str(item) + self._dict[key].data = data + elif verb == 'DEL': + del self._dict[item] + elif verb in {'ACK', 'NAK'}: + self._list_expectations[verb].add(item) + if verb in {'LS', 'LIST'}: + target = getattr(self, f'_{verb.lower()}') + for item in items: + target.completable_add(item, False) + if complete: + target.complete() + if target is self._ls: + for cap_name in _NAMES_DESIRED_SERVER_CAPS: + self._send('REQ', cap_name) + self._send('LIST') + else: + acks = self._list_expectations['ACK'] + naks = self._list_expectations['NAK'] + assert acks == self._list.intersection(acks) + assert set() == self._list.intersection(naks) + for key, data in [_Dict.key_val_from_eq_str(entry) + for entry in sorted(self._ls)]: + self._dict[key].data = data + self._dict[key].enabled = key in self._list + return True + return False + + +class Client(ABC, ClientQueueMixin): + 'Abstracts socket connection, loop over it, and handling messages from it.' + conn: Optional[IrcConnection] = None + _cls_conn: type[IrcConnection] = IrcConnection + + def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None: + self.client_id = conn_setup.hostname + super().__init__(client_id=self.client_id, **kwargs) + self.db = _ClientDb(on_update=self._on_update) + self.db.clear() + self.caps = _CapsManager(self.send, self.db.caps) + for k in conn_setup.__annotations__: + setattr(self.db, k, getattr(conn_setup, k)) + if self.db.port <= 0: + self.db.port = PORT_SSL + if not self.db.user_wanted: + self.db.user_wanted = getuser() + + def connect(self) -> None: + 'Attempt to open connection, on success perform session init steps.' + self.db.connection_state = 'connecting' + + def connect(self) -> None: + try: + self.conn = self._cls_conn( + hostname=self.db.hostname, port=self.db.port, + _q_out=self._q_out, client_id=self.client_id) + except IrcConnAbortException as e: + self.db.connection_state = f'failed to connect: {e}' + except Exception as e: # pylint: disable=broad-exception-caught + self._put(ExceptionEvent(CrashingException(e))) + else: + self.db.connection_state = 'connected' + self.caps.start_negotation() + self.send('USER', self.db.user_wanted, + '0', '*', self.db.realname) + self.send('NICK', self.db.nick_wanted,) + + # Do this in a thread, not to block flow of other (e.g. TUI) events. + Thread(target=connect, daemon=True, args=(self,)).start() + + def close(self) -> None: + 'Close connection and wipe memory of its states.' + self.db.clear() + if self.conn: + self.conn.close() + self.conn = None + + def on_handled_loop_exception(self, e: IrcConnAbortException) -> None: + 'Gracefully handle broken connection.' + self.db.connection_state = f'broken: {e}' + self.close() + + @abstractmethod + def _on_update(self, *path) -> None: + pass + + @abstractmethod + def _alert(self, msg: str) -> None: + pass + + def send(self, verb: str, *args) -> IrcMessage: + 'Send msg over socket, on success log .raw.' + if not self.conn: + raise SendFail('cannot send, connection seems closed') + msg = IrcMessage(verb, args) + self.conn.send(msg) + return msg + + def handle_msg(self, msg: IrcMessage) -> None: + 'Log msg.raw, then process incoming msg into appropriate client steps.' + ret = {} + for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]: + result = ex.parse_msg( + msg=msg, + is_chan_name=self.db.is_chan_name, + is_nick=self.db.is_nick, + possible_nickuserhost=_NickUserHost.possible_from, + into_nickuserhost=_NickUserHost.from_str) + if result is not None: + ret = result + break + if '_verb' not in ret: + raise ImplementationFail(f'No handler implemented for: {msg.raw}') + for n_u_h in ret['_nickuserhosts']: # update, turn into proper users + if (id_ := self.db.users.id_for_nickuserhost( + n_u_h, allow_none=True, updating=True)): + for ret_name in [k for k in ret if ret[k] is n_u_h]: + ret[ret_name] = self.db.users[id_] + for verb in ('setattr', 'do', 'doafter'): + for task, tok_names in [t for t in ret['_tasks'].items() + if t[0].verb == verb]: + node = self + for step in task.path: + key = ret[step] if step.isupper() else step + node = (node[key] if isinstance(node, Dict) + else (node(key) if callable(node) + else getattr(node, key))) + for tok_name in tok_names: + if task.verb == 'setattr': + setattr(node, tok_name, ret[tok_name]) + else: + getattr(node, tok_name)() + if ret['_verb'] == '005': # RPL_ISUPPORT + for item in ret['isupport']: + if item[0] == '-': + del self.db.isupport[item[1:]] + else: + key, data = _Dict.key_val_from_eq_str(item) + self.db.isupport[key] = data + elif ret['_verb'] == '353': # RPL_NAMREPLY + self.db.channels[ret['channel']].add_from_namreply(ret['names']) + elif ret['_verb'] == '372': # RPL_MOTD + self.db.motd.append(ret['line']) + elif ret['_verb'] == '401': # ERR_NOSUCHNICK + raise TargetUserOffline(ret['missing']) + elif ret['_verb'] == '432': # ERR_ERRONEOUSNICKNAME + alert = 'nickname refused for bad format' + if 'nick' not in ret: + alert += ', giving up' + self.close() + self._alert(alert) + elif ret['_verb'] == '433': # ERR_NICKNAMEINUSE + self._alert('nickname already in use, trying increment') + self.send('NICK', _NickUserHost(nick=ret['used']).incremented) + elif ret['_verb'] == 'AUTHENTICATE': + auth = b64encode((self.db.nick_wanted + '\0' + + self.db.nick_wanted + '\0' + + self.db.password + ).encode('utf-8')).decode('utf-8') + self.send('AUTHENTICATE', auth) + elif ret['_verb'] == 'CAP': + if (self.caps.process_msg(verb=ret['subverb'], items=ret['items'], + complete='tbc' not in ret) + and 'sasl' in self.db.caps.keys() + and 'PLAIN' in self.db.caps['sasl'].data.split(',')): + if self.db.password: + self.db.sasl_auth_state = 'attempting' + self.send('AUTHENTICATE', 'PLAIN') + else: + self.caps.end_negotiation() + elif ret['_verb'] == 'JOIN' and ret['joiner'] != self.db.users['me']: + self.db.channels[ret['channel']].add_user(ret['joiner']) + elif ret['_verb'] == 'NICK': + user_id = self.db.users.id_for_nickuserhost(ret['named'], + updating=True) + assert user_id is not None + self.db.users[user_id].nick = ret['nick'] + if user_id == 'me': + self.db.nick_wanted = ret['nick'] + elif ret['_verb'] == 'PART': + ret['parter'].part(ret['channel'], ret.get('message', '')) + if ret['parter'] is self.db.users['me']: + del self.db.channels[ret['channel']] + self.db.users.purge() + elif ret['_verb'] == 'PING': + self.send('PONG', ret['reply']) + elif ret['_verb'] == 'QUIT': + ret['quitter'].quit(ret['message']) + + +ClientsDb = dict[str, Client] + + +@dataclass +class NewClientEvent(AffectiveEvent): + 'Put Client .payload into ClientsDb target.' + payload: 'Client' + + def affect(self, target: ClientsDb) -> None: + target[self.payload.client_id] = self.payload + # only run _after_ spot in ClientsDb secure, for ClientEvents to target + self.payload.connect() + + +@dataclass +class ClientEvent(AffectiveEvent, _ClientIdMixin): + 'To affect Client identified by ClientIdMixin.' diff --git a/src/ircplom/client_tui.py b/src/ircplom/client_tui.py new file mode 100644 index 0000000..dbdd498 --- /dev/null +++ b/src/ircplom/client_tui.py @@ -0,0 +1,544 @@ +'TUI adaptions to Client.' +# built-ins +from enum import Enum, auto +from getpass import getuser +from pathlib import Path +from typing import Any, Callable, Optional, Sequence +# ourselves +from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window, + CMD_SHORTCUTS) +from ircplom.client import ( + AutoAttrMixin, Channel, ChatMessage, Client, ClientQueueMixin, Dict, + DictItem, ImplementationFail, IrcConnSetup, NewClientEvent, NickUserHost, + SendFail, ServerCapability, SharedClientDbFields, TargetUserOffline, User) +from ircplom.irc_conn import IrcMessage + +CMD_SHORTCUTS['disconnect'] = 'window.disconnect' +CMD_SHORTCUTS['join'] = 'window.join' +CMD_SHORTCUTS['part'] = 'window.part' +CMD_SHORTCUTS['nick'] = 'window.nick' +CMD_SHORTCUTS['privmsg'] = 'window.privmsg' +CMD_SHORTCUTS['reconnect'] = 'window.reconnect' +CMD_SHORTCUTS['raw'] = 'window.raw' + +_LOG_PREFIX_SERVER = '$' +_LOG_PREFIX_OUT = '>' +_LOG_PREFIX_IN = '<' + +_PATH_LOGS = Path.home().joinpath('.local', 'share', 'ircplom', 'logs') + + +class _LogScope(Enum): + 'Where log messages should go.' + ALL = auto() + DEBUG = auto() + CHAT = auto() + USER = auto() + USER_NO_CHANNELS = auto() + + +class _ClientWindow(Window, ClientQueueMixin): + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._title = f'{self.client_id} :DEBUG' + + def log(self, msg: str) -> None: + super().log(msg) + ldir = _PATH_LOGS.joinpath(self._title) + if not ldir.exists(): + ldir.mkdir(parents=True) + assert ldir.is_dir() + with ldir.joinpath(f'{self._last_today}.txt' + ).open('a', encoding='utf8') as f: + f.write(msg + '\n') + + def _send_msg(self, verb: str, params: tuple[str, ...]) -> None: + self._client_trigger('send_w_params_tuple', verb=verb, params=params) + + def cmd__disconnect(self, quit_msg: str = 'ircplom says bye') -> None: + 'Send QUIT command to server.' + self._send_msg('QUIT', (quit_msg,)) + + def cmd__reconnect(self) -> None: + 'Attempt reconnection.' + self._client_trigger('reconnect') + + def cmd__nick(self, new_nick: str) -> None: + 'Attempt nickname change.' + self._send_msg('NICK', (new_nick,)) + + def cmd__join(self, channel: str) -> None: + 'Attempt joining a channel.' + self._send_msg('JOIN', (channel,)) + + def cmd__privmsg(self, target: str, msg: str) -> None: + 'Send chat message msg to target.' + self._client_trigger('privmsg', chat_target=target, msg=msg) + + def cmd__raw(self, verb: str, params_str: str = '') -> None: + 'Send raw command, with direct input of params string.' + if params_str[:1] == ':': + params = [params_str] + else: + params = params_str.split(' :', maxsplit=1) + params = params[0].split() + params[1:2] + self._send_msg(verb, tuple(params)) + + +class _ChatPrompt(PromptWidget): + _nickname: str = '' + + @property + def prefix(self) -> str: + return f'[{self._nickname}] ' + + def set_prefix_data(self, nick: str) -> None: + 'Update prompt prefix with nickname data.' + if nick != self._nickname: + self._tainted = True + self._nickname = nick + + def enter(self) -> str: + to_return = super().enter() + if (not to_return) or to_return[0:1] == '/': + return to_return + return f'/window.chat {to_return}' + + +class _ChatWindow(_ClientWindow): + prompt: _ChatPrompt + + def __init__(self, chatname: str, get_nick_data: Callable, **kwargs + ) -> None: + self.chatname = chatname + self._get_nick_data = get_nick_data + super().__init__(**kwargs) + self._title = f'{self.client_id} {self.chatname}' + self.set_prompt_prefix() + + def set_prompt_prefix(self) -> None: + 'Look up relevant DB data to update prompt prefix.' + self.prompt.set_prefix_data(self._get_nick_data()) + + def cmd__chat(self, msg: str) -> None: + 'PRIVMSG to target identified by .chatname.' + self.cmd__privmsg(target=self.chatname, msg=msg) + + +class _ChannelWindow(_ChatWindow): + + def cmd__join(self, channel='') -> None: + super().cmd__join(channel if channel else self.chatname) + + def cmd__part(self) -> None: + 'Attempt parting channel.' + self._send_msg('PART', (self.chatname,)) + + +class _QueryWindow(_ChatWindow): + pass + + +class _Update: + old_value: Any + results: list[tuple[_LogScope, Any]] + + def __init__(self, path: tuple[str, ...], value: Any) -> None: + self.full_path = path + self.rel_path = self.full_path[:] + self.value = value + self.old_value = None + self.force_log = False + self.results = [] + + @property + def key(self) -> str: + 'Name of item or attribute to be processed.' + return self.rel_path[0] + + def decrement_path(self) -> None: + 'Remove first element from .rel_path.' + self.rel_path = self.rel_path[1:] + + +class _UpdatingNode(AutoAttrMixin): + + def _make_attr(self, cls: Callable, key: str): + return cls() + + def recursive_set_and_report_change(self, update: _Update) -> None: + 'Apply update, and, if it makes a difference, add to its .results.' + update.force_log = update.force_log or (not self._is_set(update.key)) + node = self._get(update.key) + if len(update.rel_path) > 1: + update.decrement_path() + node.recursive_set_and_report_change(update) + return + update.old_value = node + do_report = update.force_log + if update.value is None: + if self._is_set(update.key): + self._unset(update.key) + do_report |= True + elif update.old_value != update.value: + self._set(update.key, update.value) + do_report |= True + if (not do_report) or update.full_path == ('message',): + return + result = (tuple(sorted(update.value)) if isinstance(update.value, set) + else update.value) + announcement = ':' + ':'.join(update.full_path) + ' ' + if result is None: + announcement += 'cleared' + else: + announcement += 'set to:' + if not isinstance(result, tuple): + announcement += f' [{result}]' + scope = _LogScope.DEBUG + update.results += [(scope, [announcement])] + if isinstance(result, tuple): + update.results += [(scope, [f': {item}']) for item in result] + + def _get(self, key: str) -> Any: + return getattr(self, key) + + def _set(self, key: str, value) -> None: + setattr(self, key, value) + + def _unset(self, key: str) -> None: + getattr(self, key).clear() + + def _is_set(self, key: str) -> bool: + return hasattr(self, key) + + +class _UpdatingDict(Dict[DictItem], _UpdatingNode): + + def items(self) -> tuple[tuple[str, DictItem], ...]: + 'Key-value pairs of item registrations.' + return tuple((k, v) for k, v in self._dict.items()) + + def _get(self, key: str): + if key not in self._dict: + self._dict[key] = self._item_cls() + return self._dict[key] + + def _set(self, key: str, value) -> None: + self._dict[key] = value + + def _unset(self, key: str) -> None: + del self._dict[key] + + def _is_set(self, key: str) -> bool: + return key in self._dict + + +class _UpdatingChannel(_UpdatingNode, Channel): + user_ids: set[str] + exits: _UpdatingDict[str] + + def recursive_set_and_report_change(self, update: _Update) -> None: + super().recursive_set_and_report_change(update) + if update.key == 'topic': + msg = f':{self.topic.who} set topic: {self.topic.what}' + update.results += [(_LogScope.CHAT, [msg])] + elif update.key == 'user_ids': + if not update.old_value: + nicks = [] + for id_ in sorted(update.value): + nicks += [f'NICK:{id_}', ':, '] + nicks.pop() + update.results += [(_LogScope.CHAT, [':residents: '] + nicks)] + else: + for id_ in (id_ for id_ in update.value + if id_ not in update.old_value): + update.results += [(_LogScope.CHAT, + [f'NUH:{id_}', ': joins'])] + for id_ in (id_ for id_ in update.old_value + if id_ not in update.value): + update.results += [(_LogScope.CHAT, + _UpdatingUser.exit_msg_toks( + f'NUH:{id_}', self.exits[id_]))] + + +class _UpdatingUser(_UpdatingNode, User): + prev_nick = '?' + + @staticmethod + def exit_msg_toks(tok_who: str, exit_code: str) -> list[str]: + 'Construct part/quit message from user identifier, exit_code.' + verb = 'quits' if exit_code[0] == 'Q' else 'parts' + exit_msg = exit_code[1:] + msg_toks = [tok_who, f': {verb}'] + if exit_msg: + msg_toks += [f':: {exit_msg}'] + return msg_toks + + def recursive_set_and_report_change(self, update: _Update) -> None: + super().recursive_set_and_report_change(update) + if update.key in {'nick', 'exit_msg'}: + if update.key == 'nick': + self.prev_nick = update.old_value + if update.old_value != '?': + update.results += [ + (_LogScope.USER, + [f':{self.prev} renames {update.value}'])] + elif update.key == 'exit_msg': + if update.value: + update.results += [(_LogScope.USER_NO_CHANNELS, + self.exit_msg_toks( + f':{self}', update.value))] + + @property + def prev(self) -> str: + 'Return .nickuserhost with .prev_nick as .nick.' + return str(NickUserHost(self.prev_nick, self.user, self.host)) + + +class _UpdatingServerCapability(_UpdatingNode, ServerCapability): + pass + + +class _TuiClientDb(_UpdatingNode, SharedClientDbFields): + caps: _UpdatingDict[_UpdatingServerCapability] + isupport: _UpdatingDict[str] + motd: tuple[str, ...] = tuple() + users: _UpdatingDict[_UpdatingUser] + channels: _UpdatingDict[_UpdatingChannel] + + def recursive_set_and_report_change(self, update: _Update) -> None: + super().recursive_set_and_report_change(update) + if update.key == 'connection_state': + if update.value == 'connected': + update.results += [(_LogScope.ALL, [':CONNECTED'])] + elif not update.value: + update.results += [(_LogScope.ALL, [':DISCONNECTED'])] + elif update.key == 'message' and update.value: + assert isinstance(update.value, ChatMessage) + toks = [':*** '] if update.value.is_notice else [] + toks += [':['] + toks += [f':{update.value.sender}' if update.value.sender + else 'NICK:me'] + toks += [f':] {update.value.content}'] + update.results += [(_LogScope.CHAT, toks)] + + +class _ClientWindowsManager: + + def __init__(self, tui_log: Callable, tui_new_window: Callable) -> None: + self._tui_log = tui_log + self._tui_new_window = tui_new_window + self.db = _TuiClientDb() + self.windows: list[_ClientWindow] = [] + + def _new_win(self, scope: _LogScope, chatname: str = '') -> _ClientWindow: + if scope == _LogScope.CHAT: + win = self._tui_new_window( + win_cls=(_ChannelWindow if self.db.is_chan_name(chatname) + else _QueryWindow), + chatname=chatname, + get_nick_data=lambda: (self.db.users['me'].nick + if 'me' in self.db.users.keys() + else '?')) + else: + win = self._tui_new_window(win_cls=_ClientWindow) + self.windows += [win] + return win + + def windows_for(self, scope: _LogScope, id_='') -> list[_ClientWindow]: + 'Return client windows of scope, and additional potential identifier.' + ret = [] + if scope == _LogScope.ALL: + ret = [w for w in self.windows + if w not in self.windows_for(_LogScope.DEBUG)] + elif scope == _LogScope.DEBUG: + ret = [w for w in self.windows if not isinstance(w, _ChatWindow)] + elif scope == _LogScope.CHAT: + ret = [w for w in self.windows + if isinstance(w, _ChatWindow) and w.chatname == id_] + elif scope == _LogScope.USER: + chan_names = [c for c, v in self.db.channels.items() + if id_ in v.user_ids] + ret = [w for w in self.windows + if (isinstance(w, _ChannelWindow) + and w.chatname in chan_names) + or (isinstance(w, _QueryWindow) + and (id_ == 'me' or w.chatname in { + self.db.users[id_].nick, + self.db.users[id_].prev_nick}))] + elif scope == _LogScope.USER_NO_CHANNELS: + ret = [w for w in self.windows_for(_LogScope.USER, id_) + if isinstance(w, _QueryWindow)] + if (not ret) and scope in {_LogScope.CHAT, _LogScope.DEBUG}: + ret += [self._new_win(scope, id_)] + ret.sort(key=lambda w: w.idx) + return ret + + def log(self, + msg: str, + scope: _LogScope, + alert=False, + target='', + out: Optional[bool] = None + ) -> None: + 'From parsing scope, kwargs, build prefix before sending to logger.' + prefix = '$' + if out is not None: + prefix = _LOG_PREFIX_OUT if out else _LOG_PREFIX_IN + kwargs = {'alert': True} if alert else {} + kwargs |= {'target': target} if target else {} + self._tui_log(msg, scope=scope, prefix=prefix, **kwargs) + + def update_db(self, update: _Update) -> bool: + 'Apply update to .db, and if changing anything, log and trigger.' + self.db.recursive_set_and_report_change(update) + if not update.results: + return False + for scope, result in update.results: + msg = '' + for item in result: + transform, content = item.split(':', maxsplit=1) + if transform in {'NICK', 'NUH'}: + nuh = self.db.users[content] + content = str(nuh) if transform == 'NUH' else nuh.nick + msg += content + out: Optional[bool] = None + target = '' + if update.full_path == ('message',): + target = update.value.target or update.value.sender + out = not bool(update.value.sender) + elif scope in {_LogScope.CHAT, _LogScope.USER, + _LogScope.USER_NO_CHANNELS}: + target = update.full_path[1] + self.log(msg, scope=scope, target=target, out=out) + for win in [w for w in self.windows if isinstance(w, _ChatWindow)]: + win.set_prompt_prefix() + return bool([w for w in self.windows if w.tainted]) + + +class ClientTui(BaseTui): + 'TUI expanded towards Client features.' + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._client_mngrs: dict[str, _ClientWindowsManager] = {} + + def _log_target_wins(self, **kwargs) -> Sequence[Window]: + if (scope := kwargs.get('scope', None)): + return self._client_mngrs[kwargs['client_id']].windows_for( + scope, kwargs.get('target', '')) + return super()._log_target_wins(**kwargs) + + def for_client_do(self, client_id: str, todo: str, **kwargs) -> None: + 'Forward todo to appropriate _ClientWindowsManager.' + if client_id not in self._client_mngrs: + self._client_mngrs[client_id] = _ClientWindowsManager( + tui_log=lambda msg, **kw: self.log( + msg, client_id=client_id, **kw), + tui_new_window=lambda win_cls, **kw: self._new_window( + win_cls, _q_out=self._q_out, client_id=client_id, **kw)) + if getattr(self._client_mngrs[client_id], todo)(**kwargs) is not False: + self.redraw_affected() + + def _new_client(self, conn_setup: IrcConnSetup) -> 'ClientKnowingTui': + return ClientKnowingTui(_q_out=self._q_out, conn_setup=conn_setup) + + def cmd__connect(self, + host_port: str, + nickname_pw: str = '', + username_realname: str = '' + ) -> Optional[str]: + 'Create Client and pass it via NewClientEvent.' + split = host_port.split(':', maxsplit=1) + hostname = split[0] + if hostname in self._client_mngrs: + return f'already set up connection to {hostname}' + port = -1 + if len(split) > 1: + to_int = split[1] + if to_int.isdigit(): + port = int(split[1]) + else: + return f'invalid port number: {to_int}' + split = nickname_pw.split(':', maxsplit=1) + nickname = split[0] if nickname_pw else getuser() + password = split[1] if len(split) > 1 else '' + if not username_realname: + username = '' + realname = nickname + elif ':' in username_realname: + username, realname = username_realname.split(':', maxsplit=1) + else: + username = '' + realname = username_realname + self._put(NewClientEvent(self._new_client(IrcConnSetup( + hostname, port, nickname, username, realname, password)))) + return None + + +class ClientKnowingTui(Client): + 'Adapted to communicate with ClientTui.' + + def _tui_trigger(self, method_name: str, **kwargs) -> None: + self._put(TuiEvent.affector(method_name).kw(**kwargs)) + + def _tui_alert_trigger(self, msg: str) -> None: + self._tui_trigger('log', msg=msg, prefix=_LOG_PREFIX_SERVER, + alert=True) + + def _client_tui_trigger(self, todo: str, **kwargs) -> None: + self._tui_trigger('for_client_do', client_id=self.client_id, + todo=todo, **kwargs) + + def send_w_params_tuple(self, verb: str, params: tuple[str, ...]) -> None: + 'Helper for ClientWindow to trigger .send, for it can only do kwargs.' + self.send(verb, *params) + + def privmsg(self, chat_target: str, msg: str) -> None: + 'Catch /privmsg, only allow for channel if in channel, else complain.' + try: + if self.db.is_chan_name(chat_target)\ + and chat_target not in self.db.channels.keys(): + raise SendFail('not sending, since not in channel') + self.send('PRIVMSG', chat_target, msg) + except SendFail as e: + self._tui_alert_trigger(f'{e}') + else: + self.db.messaging('').to(chat_target).privmsg = msg # type: ignore + + def reconnect(self) -> None: + 'Catch /reconnect, only initiate if not connected, else complain back.' + if self.conn: + self._tui_alert_trigger( + 'not re-connecting since already connected') + return + self.connect() + + def send(self, verb: str, *args) -> IrcMessage: + msg = super().send(verb, *args) + self._log(msg.raw, out=True) + return msg + + def handle_msg(self, msg: IrcMessage) -> None: + self._log(msg.raw, out=False) + try: + super().handle_msg(msg) + except ImplementationFail as e: + self._alert(str(e)) + except TargetUserOffline as e: + name = f'{e}' + self._log(f'{name} not online', target=name, alert=True) + + def _alert(self, msg: str) -> None: + self._log(msg, alert=True) + + def _log(self, msg: str, alert=False, target='', out: Optional[bool] = None + ) -> None: + scope = _LogScope.CHAT if target else _LogScope.DEBUG + self._client_tui_trigger('log', scope=scope, msg=msg, alert=alert, + target=target, out=out) + + def _on_update(self, *path) -> None: + for path, value in self.db.into_endnode_updates(path): + self._client_tui_trigger('update_db', update=_Update(path, value)) diff --git a/src/ircplom/events.py b/src/ircplom/events.py new file mode 100644 index 0000000..9dac06b --- /dev/null +++ b/src/ircplom/events.py @@ -0,0 +1,110 @@ +'Event system with event loop.' +from abc import abstractmethod, ABC +from dataclasses import dataclass +from queue import SimpleQueue, Empty as QueueEmpty +from threading import Thread +from typing import Any, Iterator, Literal, Self + + +class Event: # pylint: disable=too-few-public-methods + 'Communication unit between threads.' + + +QuitEvent = type('QuitEvent', (Event,), {}) + + +class AffectiveEvent(Event, ABC): + 'For Events that are to affect other objects.' + + @abstractmethod + def affect(self, target: Any) -> None: + 'To be run by main loop on target.' + + @classmethod + def affector(cls, t_method: str, **kwargs): + '''Return instance of subclass affecting target via t_method. + + This will often be more convenient than a full subclass definition, + which mostly only matters for what the main loop gotta differentiate. + ''' + def wrap(): # else current mypy throw [valid-type] on 'Variable "cls"' + class _Affector(cls): + def __init__(self, t_method: str, **kwargs) -> None: + super().__init__(**kwargs) + self.t_method = t_method + self.kwargs: dict[str, Any] = {} + + def kw(self, **kwargs) -> Self: + 'Collect .kwargs expanded, return self for chaining.' + for k, v in kwargs.items(): + self.kwargs[k] = v + return self + + def affect(self, target) -> None: + 'Call target.t_method(**.kwargs).' + getattr(target, self.t_method)(**self.kwargs) + + return _Affector + + return wrap()(t_method=t_method, **kwargs) + + +class CrashingException(BaseException): + 'To explicitly crash, because it should never happen, but explaining why.' + + +@dataclass +class ExceptionEvent(Event): + 'To deliver Exception to main loop for handling.' + exception: CrashingException + + +@dataclass +class QueueMixin: + 'Adds SimpleQueue addressable via ._put(Event).' + _q_out: SimpleQueue + + def _put(self, event: Event) -> None: + self._q_out.put(event) + + +class Loop(QueueMixin): + 'Wraps thread looping over iterator, communicating back via q_out.' + + def __init__(self, iterator: Iterator, **kwargs) -> None: + super().__init__(**kwargs) + self._q_quit: SimpleQueue = SimpleQueue() + self._iterator = iterator + self._thread = Thread(target=self._loop, daemon=False) + self._thread.start() + + def stop(self) -> None: + 'Break threaded loop, but wait for it to finish properly.' + self._q_quit.put(None) + self._thread.join() + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_) -> Literal[False]: + self.stop() + return False # re-raise any exception that above ignored + + def _loop(self) -> None: + try: + while True: + try: + self._q_quit.get(block=True, timeout=0) + except QueueEmpty: + pass + else: + break + try: + it_yield = next(self._iterator) + except StopIteration: + break + if it_yield is not None: + self._put(it_yield) + # catch _all_ just so they exit readably with the main loop + except Exception as e: # pylint: disable=broad-exception-caught + self._put(ExceptionEvent(CrashingException(e))) diff --git a/src/ircplom/irc_conn.py b/src/ircplom/irc_conn.py new file mode 100644 index 0000000..fb1bd21 --- /dev/null +++ b/src/ircplom/irc_conn.py @@ -0,0 +1,207 @@ +'Low-level IRC protocol / server connection management.' +# built-ins +from abc import ABC, abstractmethod +from socket import socket, gaierror as socket_gaierror +from ssl import create_default_context as create_ssl_context +from typing import Callable, Iterator, NamedTuple, Optional, Self +# ourselves +from ircplom.events import Event, Loop, QueueMixin + + +PORT_SSL = 6697 +_TIMEOUT_RECV_LOOP = 0.1 +_TIMEOUT_CONNECT = 5 +_CONN_RECV_BUFSIZE = 1024 + +ILLEGAL_NICK_CHARS = ' ,*?!@' +ILLEGAL_NICK_FIRSTCHARS = ':$' +ISUPPORT_DEFAULTS = { + 'CHANTYPES': '#&', + 'PREFIX': '(ov)@+', + 'USERLEN': '10' +} +_IRCSPEC_LINE_SEPARATOR = b'\r\n' +_IRCSPEC_TAG_ESCAPES = ((r'\:', ';'), + (r'\s', ' '), + (r'\n', '\n'), + (r'\r', '\r'), + (r'\\', '\\')) + + +class IrcMessage: + 'Properly structured representation of IRC message as per IRCv3 spec.' + _raw: Optional[str] = None + + def __init__(self, + verb: str, + params: Optional[tuple[str, ...]] = None, + source: str = '', + tags: Optional[dict[str, str]] = None + ) -> None: + self.verb: str = verb + self.params: tuple[str, ...] = params or tuple() + self.source: str = source + self.tags: dict[str, str] = tags or {} + + @classmethod + def from_raw(cls, raw_msg: str) -> Self: + 'Parse raw IRC message line into properly structured IrcMessage.' + + class _Stage(NamedTuple): + name: str + prefix_char: Optional[str] + processor: Callable = lambda s: s + + def _parse_tags(str_tags: str) -> dict[str, str]: + tags = {} + for str_tag in [s for s in str_tags.split(';') if s]: + if '=' in str_tag: + key, val = str_tag.split('=', maxsplit=1) + for to_repl, repl_with in _IRCSPEC_TAG_ESCAPES: + val = val.replace(to_repl, repl_with) + else: + key, val = str_tag, '' + tags[key] = val + return tags + + def _split_params(str_params: str) -> tuple[str, ...]: + params = [] + params_stage = 0 # 0: gap, 1: non-trailing, 2: trailing + for char in str_params: + if char == ' ' and params_stage < 2: + params_stage = 0 + continue + if params_stage == 0: + params += [''] + params_stage += 1 + if char == ':': + params_stage += 1 + continue + params[-1] += char + return tuple(p for p in params) + + stages = [_Stage('tags', '@', _parse_tags), + _Stage('source', ':'), + _Stage('verb', None, lambda s: s.upper()), + _Stage('params', None, _split_params)] + harvest = {s.name: '' for s in stages} + idx_stage = -1 + stage = None + for char in raw_msg: + if char == ' ' and idx_stage < (len(stages) - 1): + if stage: + stage = None + continue + if not stage: + while not stage: + idx_stage += 1 + tested = stages[idx_stage] + if (not tested.prefix_char) or char == tested.prefix_char: + stage = tested + if stage.prefix_char: + continue + harvest[stage.name] += char + msg = cls(**{s.name: s.processor(harvest[s.name]) for s in stages}) + msg._raw = raw_msg + return msg + + @property + def raw(self) -> str: + 'Return raw message code – create from known fields if necessary.' + if not self._raw: + to_combine = [] + if self.tags: + tag_strs = [] + for key, val in self.tags.items(): + tag_strs += [key] + if not val: + continue + for repl_with, to_repl in reversed(_IRCSPEC_TAG_ESCAPES): + val = val.replace(to_repl, repl_with) + tag_strs[-1] += f'={val}' + to_combine += ['@' + ';'.join(tag_strs)] + to_combine += [self.verb] + if self.params: + to_combine += self.params[:-1] + to_combine += [f':{self.params[-1]}'] + self._raw = ' '.join(to_combine) + return self._raw + + +class IrcConnAbortException(BaseException): + 'Thrown by BaseIrcConnection on expectable connection failures.' + + +class BaseIrcConnection(QueueMixin, ABC): + 'Collects low-level server-client connection management.' + + def __init__(self, hostname: str, port: int, **kwargs) -> None: + super().__init__(**kwargs) + self.ssl = port == PORT_SSL + self._set_up_socket(hostname, port) + self._recv_loop = Loop(iterator=self._read_lines(), _q_out=self._q_out) + + def _set_up_socket(self, hostname: str, port: int) -> None: + self._socket = socket() + if self.ssl: + self._socket = create_ssl_context().wrap_socket( + self._socket, server_hostname=hostname) + self._socket.settimeout(_TIMEOUT_CONNECT) + try: + self._socket.connect((hostname, port)) + except (TimeoutError, socket_gaierror) as e: + raise IrcConnAbortException(e) from e + self._socket.settimeout(_TIMEOUT_RECV_LOOP) + + def close(self) -> None: + 'Stop recv loop and close socket.' + self._recv_loop.stop() + self._socket.close() + + def send(self, msg: IrcMessage) -> None: + 'Send line-separator-delimited message over socket.' + self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR) + + @abstractmethod + def _make_recv_event(self, msg: IrcMessage) -> Event: + pass + + @abstractmethod + def _on_handled_loop_exception(self, _: IrcConnAbortException) -> Event: + pass + + def _read_lines(self) -> Iterator[Optional[Event]]: + assert self._socket is not None + bytes_total = b'' + buffer_linesep = b'' + try: + while True: + try: + bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE) + except TimeoutError: + yield None + continue + except ConnectionResetError as e: + raise IrcConnAbortException(e) from e + except OSError as e: + if e.errno == 9: + raise IrcConnAbortException(e) from e + raise e + if not bytes_new: + break + for c in bytes_new: + c_byted = c.to_bytes() + if c not in _IRCSPEC_LINE_SEPARATOR: + bytes_total += c_byted + buffer_linesep = b'' + elif c == _IRCSPEC_LINE_SEPARATOR[0]: + buffer_linesep = c_byted + else: + buffer_linesep += c_byted + if buffer_linesep == _IRCSPEC_LINE_SEPARATOR: + buffer_linesep = b'' + yield self._make_recv_event( + IrcMessage.from_raw(bytes_total.decode('utf-8'))) + bytes_total = b'' + except IrcConnAbortException as e: + yield self._on_handled_loop_exception(e) diff --git a/src/ircplom/msg_parse_expectations.py b/src/ircplom/msg_parse_expectations.py new file mode 100644 index 0000000..84478b4 --- /dev/null +++ b/src/ircplom/msg_parse_expectations.py @@ -0,0 +1,560 @@ +'Structured expectations and processing hints for server messages.' +from enum import Enum, auto +from typing import Any, Callable, NamedTuple, Optional, Self +from ircplom.irc_conn import IrcMessage + + +class _MsgTok(Enum): + 'Server message token classifications.' + ANY = auto() + CHANNEL = auto() + LIST = auto() + NICKNAME = auto() + NONE = auto() + SERVER = auto() + NICK_USER_HOST = auto() + + +_MsgTokGuide = str | _MsgTok | tuple[str | _MsgTok, str] + + +class _Command(NamedTuple): + verb: str + path: tuple[str, ...] + + @classmethod + def from_(cls, input_: str) -> Self: + 'Split by first "_" into verb, path (split into steps tuple by ".").' + verb, path_str = input_.split('_', maxsplit=1) + return cls(verb, tuple(step for step in path_str.split('.') + if path_str)) + + +class _MsgParseExpectation: + + def __init__(self, + verb: str, + source: _MsgTokGuide, + params: tuple[_MsgTokGuide, ...] = tuple(), + idx_into_list: int = -1, + bonus_tasks: tuple[str, ...] = tuple() + ) -> None: + + class _Code(NamedTuple): + title: str + commands: tuple[_Command, ...] + + @classmethod + def from_(cls, input_: str) -> Self: + 'Split by ":" into commands (further split by ","), title.' + cmdsstr, title = input_.split(':', maxsplit=1) + return cls(title, tuple(_Command.from_(t) + for t in cmdsstr.split(',') if t)) + + class _TokExpectation(NamedTuple): + type_: _MsgTok | str + code: Optional[_Code] + + @classmethod + def from_(cls, val: _MsgTokGuide) -> Self: + 'Standardize value into .type_, (potentially empty) .code.' + t = ((val[0], _Code.from_(val[1])) if isinstance(val, tuple) + else (val, None)) + return cls(*t) + + self.verb = verb + self.source = _TokExpectation.from_(source) + self.params = tuple(_TokExpectation.from_(param) for param in params) + self.idx_into_list = idx_into_list + self.bonus_tasks = tuple(_Code.from_(item) for item in bonus_tasks) + + def parse_msg(self, + msg: IrcMessage, + is_chan_name: Callable, + is_nick: Callable, + possible_nickuserhost: Callable, + into_nickuserhost: Callable + ) -> Optional[dict[str, Any]]: + 'Try parsing msg into informative result dictionary, or None on fail.' + cmp_params: list[str | tuple[str, ...]] + if self.idx_into_list < 0: + cmp_params = list(msg.params) + else: + idx_after = len(msg.params) + 1 - (len(self.params) + - self.idx_into_list) + cmp_params = (list(msg.params[:self.idx_into_list]) + + [msg.params[self.idx_into_list:idx_after]] + + list(msg.params[idx_after:])) + cmp_fields = tuple([msg.source] + cmp_params) + ex_fields = tuple([self.source] + list(self.params)) + if len(ex_fields) != len(cmp_fields): + return None + validators: dict[_MsgTok, Callable[[Any], bool]] = { + _MsgTok.NONE: lambda tok: tok == '', + _MsgTok.CHANNEL: is_chan_name, + _MsgTok.NICKNAME: is_nick, + _MsgTok.NICK_USER_HOST: possible_nickuserhost, + _MsgTok.SERVER: lambda tok: '.' in tok and not set('@!') & set(tok) + } + parsers: dict[_MsgTok, Callable[[Any], Any]] = { + _MsgTok.LIST: lambda tok: tuple(tok.split()), + _MsgTok.NICK_USER_HOST: into_nickuserhost + } + parsed: dict[str, str | tuple[str, ...]] = {} + singled_tasks: list[tuple[_Command, str]] = [] + nickuserhosts = [] + for ex_tok, cmp_tok in [(ex_tok, cmp_fields[idx]) + for idx, ex_tok in enumerate(ex_fields)]: + if isinstance(ex_tok.type_, str) and ex_tok.type_ != cmp_tok: + return None + if (not isinstance(ex_tok.type_, str))\ + and ex_tok.type_ in validators\ + and not validators[ex_tok.type_](cmp_tok): + return None + if ex_tok.code or ex_tok.type_ is _MsgTok.NICK_USER_HOST: + value = (cmp_tok if (isinstance(ex_tok.type_, str) + or ex_tok.type_ not in parsers) + else parsers[ex_tok.type_](cmp_tok)) + if ex_tok.type_ is _MsgTok.NICK_USER_HOST: + nickuserhosts += [value] + if ex_tok.code: + parsed[ex_tok.code.title] = value + singled_tasks += [(cmd, ex_tok.code.title) + for cmd in ex_tok.code.commands] + for code in self.bonus_tasks: + singled_tasks += [(cmd, code.title) for cmd in code.commands] + tasks: dict[_Command, list[str]] = {} + for cmd, title in singled_tasks: + if cmd not in tasks: + tasks[cmd] = [] + tasks[cmd] += [title] + return parsed | {'_verb': self.verb, '_tasks': tasks, + '_nickuserhosts': nickuserhosts} + + +MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ + + # these we ignore except for confirming/collecting the nickname + + _MsgParseExpectation( + '001', # RPL_WELCOME + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '002', # RPL_YOURHOST + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '003', # RPL_CREATED + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '004', # RPL_MYINFO + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY, + _MsgTok.ANY, + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '250', # RPL_STATSDLINE / RPL_STATSCONN + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '251', # RPL_LUSERCLIENT + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '252', # RPL_LUSEROP + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '253', # RPL_LUSERUNKNOWN + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '254', # RPL_LUSERCHANNELS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '255', # RPL_LUSERME + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + _MsgParseExpectation( + '265', # RPL_LOCALUSERS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + _MsgParseExpectation( + '265', # RPL_LOCALUSERS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '266', # RPL_GLOBALUSERS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + _MsgParseExpectation( + '266', # RPL_GLOBALUSERS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, + _MsgTok.ANY, + _MsgTok.ANY)), + + _MsgParseExpectation( + '375', # RPL_MOTDSTART already implied by 1st 372 + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY)), + + # various login stuff + + _MsgParseExpectation( + '005', # RPL_ISUPPORT + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, ':isupport'), + _MsgTok.ANY), # comment + idx_into_list=1), + + _MsgParseExpectation( + '372', # RPL_MOTD + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, ':line'))), + + _MsgParseExpectation( + '376', # RPL_ENDOFMOTD + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY), # comment + bonus_tasks=('do_db.motd:complete',)), + + _MsgParseExpectation( + '396', # RPL_VISIBLEHOST + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.SERVER, 'setattr_db.users.me:host'), + _MsgTok.ANY)), # comment + + # SASL + + _MsgParseExpectation( + '900', # RPL_LOGGEDIN + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'), + (_MsgTok.ANY, 'setattr_db:sasl_account'), + _MsgTok.ANY)), # comment + + _MsgParseExpectation( + '903', # RPL_SASLSUCCESS + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db:sasl_auth_state')), + bonus_tasks=('do_caps:end_negotiation',)), + + _MsgParseExpectation( + '904', # ERR_SASLFAIL + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db:sasl_auth_state')), + bonus_tasks=('do_caps:end_negotiation',)), + + _MsgParseExpectation( + 'AUTHENTICATE', + _MsgTok.NONE, + ('+',)), + + # capability negotation + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('NEW', ':subverb'), + (_MsgTok.LIST, ':items'))), + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('DEL', ':subverb'), + (_MsgTok.LIST, ':items'))), + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('ACK', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('ACK', ':subverb'), + (_MsgTok.LIST, ':items'))), + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('NAK', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('NAK', ':subverb'), + (_MsgTok.LIST, ':items'))), + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('LS', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('LS', ':subverb'), + ('*', ':tbc'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('LS', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('LS', ':subverb'), + ('*', ':tbc'), + (_MsgTok.LIST, ':items'))), + + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('LIST', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ('*', + ('LIST', ':subverb'), + ('*', ':tbc'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('LIST', ':subverb'), + (_MsgTok.LIST, ':items'))), + _MsgParseExpectation( + 'CAP', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + ('LIST', ':subverb'), + ('*', ':tbc'), + (_MsgTok.LIST, ':items'))), + + # nickname management + + _MsgParseExpectation( + '432', # ERR_ERRONEOUSNICKNAME + _MsgTok.SERVER, + ('*', + _MsgTok.ANY, # bad one probably fails our NICKNAME tests + _MsgTok.ANY)), # comment + _MsgParseExpectation( + '432', # ERR_ERRONEOUSNICKNAME + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + _MsgTok.ANY, # bad one probably fails our NICKNAME tests + _MsgTok.ANY)), # comment + + _MsgParseExpectation( + '433', # ERR_NICKNAMEINUSE + _MsgTok.SERVER, + ('*', + (_MsgTok.NICKNAME, ':used'), + _MsgTok.ANY)), # comment + _MsgParseExpectation( + '433', # ERR_NICKNAMEINUSE + _MsgTok.SERVER, + (_MsgTok.NICKNAME, # we rather go for incrementation + (_MsgTok.NICKNAME, ':used'), + _MsgTok.ANY)), # comment + + _MsgParseExpectation( + 'NICK', + (_MsgTok.NICK_USER_HOST, ':named'), + ((_MsgTok.NICKNAME, ':nick'),)), + + # joining/leaving + + _MsgParseExpectation( + '332', # RPL_TOPIC + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.CHANNEL, ':CHAN'), + (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))), + + _MsgParseExpectation( + '333', # RPL_TOPICWHOTIME + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.CHANNEL, ':CHAN'), + (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'), + (_MsgTok.ANY, ':timestamp')), + bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)), + + _MsgParseExpectation( + '353', # RPL_NAMREPLY + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + '@', + (_MsgTok.CHANNEL, ':channel'), + (_MsgTok.LIST, ':names'))), + _MsgParseExpectation( + '353', # RPL_NAMREPLY + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + '=', + (_MsgTok.CHANNEL, ':channel'), + (_MsgTok.LIST, ':names'))), + + _MsgParseExpectation( + '366', # RPL_ENDOFNAMES + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.CHANNEL, ':CHAN'), + _MsgTok.ANY), # comment + bonus_tasks=('doafter_db.channels.CHAN.user_ids:complete',)), + + _MsgParseExpectation( + 'JOIN', + (_MsgTok.NICK_USER_HOST, ':joiner'), + ((_MsgTok.CHANNEL, ':channel'),)), + + _MsgParseExpectation( + 'PART', + (_MsgTok.NICK_USER_HOST, ':parter'), + ((_MsgTok.CHANNEL, ':channel'),)), + _MsgParseExpectation( + 'PART', + (_MsgTok.NICK_USER_HOST, ':parter'), + ((_MsgTok.CHANNEL, ':channel'), + (_MsgTok.ANY, ':message'))), + + # messaging + + _MsgParseExpectation( + '401', # ERR_NOSUCKNICK + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.NICKNAME, ':missing'), + _MsgTok.ANY)), # comment + + _MsgParseExpectation( + 'NOTICE', + _MsgTok.SERVER, + ('*', + (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))), + _MsgParseExpectation( + 'NOTICE', + _MsgTok.SERVER, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))), + + _MsgParseExpectation( + 'NOTICE', + (_MsgTok.NICK_USER_HOST, ':USER'), + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:notice'))), + + _MsgParseExpectation( + 'NOTICE', + (_MsgTok.NICK_USER_HOST, ':USER'), + ((_MsgTok.CHANNEL, ':CHANNEL'), + (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:notice'))), + + _MsgParseExpectation( + 'PRIVMSG', + (_MsgTok.NICK_USER_HOST, ':USER'), + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:privmsg'))), + _MsgParseExpectation( + 'PRIVMSG', + (_MsgTok.NICK_USER_HOST, ':USER'), + ((_MsgTok.CHANNEL, ':CHANNEL'), + (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))), + + # misc. + + _MsgParseExpectation( + 'ERROR', + _MsgTok.NONE, + ((_MsgTok.ANY, 'setattr_db:connection_state'),), + bonus_tasks=('doafter_:close',)), + + _MsgParseExpectation( + 'MODE', + (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'), + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db.users.me:modes'))), + _MsgParseExpectation( + 'MODE', + _MsgTok.NICKNAME, + ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), + (_MsgTok.ANY, 'setattr_db.users.me:modes'))), + + _MsgParseExpectation( + 'PING', + _MsgTok.NONE, + ((_MsgTok.ANY, ':reply'),)), + + _MsgParseExpectation( + 'TOPIC', + (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'), + ((_MsgTok.CHANNEL, ':CHAN'), + (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what')), + bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)), + + _MsgParseExpectation( + 'QUIT', + (_MsgTok.NICK_USER_HOST, ':quitter'), + ((_MsgTok.ANY, ':message'),)), +] diff --git a/src/ircplom/testing.py b/src/ircplom/testing.py new file mode 100644 index 0000000..57de470 --- /dev/null +++ b/src/ircplom/testing.py @@ -0,0 +1,157 @@ +'Basic testing.' +from contextlib import contextmanager +from queue import SimpleQueue, Empty as QueueEmpty +from pathlib import Path +from typing import Generator, Iterator, Optional +from ircplom.events import Event, Loop, QueueMixin +from ircplom.client import IrcConnection, IrcConnSetup +from ircplom.client_tui import ClientKnowingTui, ClientTui +from ircplom.irc_conn import IrcConnAbortException, IrcMessage +from ircplom.tui_base import TerminalInterface, TuiEvent + + +_PATH_TEST_TXT = Path('test.txt') + + +class TestTerminal(QueueMixin, TerminalInterface): + 'Collects keypresses from string queue, otherwise mostly dummy.' + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._q_keypresses: SimpleQueue = SimpleQueue() + + @contextmanager + def setup(self) -> Generator: + with Loop(iterator=self._get_keypresses(), _q_out=self._q_out): + yield self + + def flush(self) -> None: + pass + + def calc_geometry(self) -> None: + self.size = TerminalInterface.__annotations__['size'](0, 0) + + def wrap(self, line: str) -> list[str]: + return [] + + def write(self, + msg: str = '', + start_y: Optional[int] = None, + attribute: Optional[str] = None, + padding: bool = True + ) -> None: + pass + + def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: + while True: + try: + to_yield = self._q_keypresses.get(timeout=0.1) + except QueueEmpty: + yield None + continue + yield TuiEvent.affector('handle_keyboard_event' + ).kw(typed_in=to_yield) + + +class _FakeIrcConnection(IrcConnection): + + def __init__(self, **kwargs) -> None: + self._q_server_msgs: SimpleQueue = SimpleQueue() + super().__init__(**kwargs) + + def put_server_msg(self, msg: str) -> None: + 'Simulate message coming from server.' + self._q_server_msgs.put(msg) + + def _set_up_socket(self, hostname: str, port: int) -> None: + pass + + def close(self) -> None: + self._recv_loop.stop() + + def send(self, msg: IrcMessage) -> None: + pass + + def _read_lines(self) -> Iterator[Optional[Event]]: + while True: + try: + msg = self._q_server_msgs.get(timeout=0.1) + except QueueEmpty: + yield None + continue + if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION': + err = IrcConnAbortException(msg) + yield self._on_handled_loop_exception(err) + return + yield self._make_recv_event(IrcMessage.from_raw(msg)) + + +class _TestClientKnowingTui(ClientKnowingTui): + _cls_conn = _FakeIrcConnection + + +class TestingClientTui(ClientTui): + 'Collects keypresses via TestTerminal and test file, compares log results.' + _clients: list[_TestClientKnowingTui] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._clients = [] + assert isinstance(self._term, TestTerminal) + self._q_keypresses = self._term._q_keypresses + with _PATH_TEST_TXT.open('r', encoding='utf8') as f: + self._playbook = tuple(line[:-1] for line in f.readlines()) + self._playbook_idx = -1 + self._play_till_next_log() + + def _new_client(self, conn_setup: IrcConnSetup) -> _TestClientKnowingTui: + self._clients += [_TestClientKnowingTui(_q_out=self._q_out, + conn_setup=conn_setup)] + return self._clients[-1] + + def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]: + win_ids, logged_msg = super().log(msg, **kwargs) + time_str, msg_sans_time = logged_msg.split(' ', maxsplit=1) + assert len(time_str) == 8 + for c in time_str[:2] + time_str[3:5] + time_str[6:]: + assert c.isdigit() + assert time_str[2] == ':' and time_str[5] == ':' + context, expected_msg = self._playbook[self._playbook_idx + ].split(maxsplit=1) + if ':' in context: + _, context = context.split(':') + expected_win_ids = tuple(int(idx) for idx in context.split(',') if idx) + info = (self._playbook_idx + 1, + 'WANTED:', expected_win_ids, expected_msg, + 'GOT:', win_ids, msg_sans_time) + assert expected_msg == msg_sans_time, info + assert expected_win_ids == win_ids, info + self._play_till_next_log() + return win_ids, logged_msg + + def _play_till_next_log(self) -> None: + while True: + self._playbook_idx += 1 + line = self._playbook[self._playbook_idx] + if line[:1] == '#' or not line.strip(): + continue + context, msg = line.split(' ', maxsplit=1) + if context == 'repeat': + start, end = msg.split(':') + self._playbook = (self._playbook[:self._playbook_idx + 1] + + self._playbook[int(start):int(end)] + + self._playbook[self._playbook_idx + 1:]) + continue + if context == '>': + for c in msg: + self._q_keypresses.put(c) + self._q_keypresses.put('KEY_ENTER') + continue + if ':' in context and msg.startswith('< '): + client_id, win_ids = context.split(':') + client = self._clients[int(client_id)] + assert isinstance(client.conn, _FakeIrcConnection) + client.conn.put_server_msg(msg[2:]) + if not win_ids: + continue + break diff --git a/src/ircplom/tui_base.py b/src/ircplom/tui_base.py new file mode 100644 index 0000000..73115a1 --- /dev/null +++ b/src/ircplom/tui_base.py @@ -0,0 +1,734 @@ +'Base Terminal and TUI management.' +# built-ins +from abc import ABC, abstractmethod +from base64 import b64decode +from contextlib import contextmanager +from datetime import datetime +from inspect import _empty as inspect_empty, signature, stack +from signal import SIGWINCH, signal +from typing import (Callable, Generator, Iterator, NamedTuple, Optional, + Sequence) +# requirements.txt +from blessed import Terminal as BlessedTerminal +# ourselves +from ircplom.events import AffectiveEvent, Loop, QueueMixin, QuitEvent + +_LOG_PREFIX_DEFAULT = '#' +_LOG_PREFIX_ALERT = '!' + +_MIN_HEIGHT = 4 +_MIN_WIDTH = 32 + +_TIMEOUT_KEYPRESS_LOOP = 0.5 +_B64_PREFIX = 'b64:' +_OSC52_PREFIX = b']52;c;' +_PASTE_DELIMITER = '\007' + +_PROMPT_TEMPLATE = '> ' +_PROMPT_ELL_IN = '<…' +_PROMPT_ELL_OUT = '…>' + +_CHAR_RESIZE = chr(12) +_KEYBINDINGS = { + 'KEY_BACKSPACE': ('window.prompt.backspace',), + 'KEY_ENTER': ('prompt_enter',), + 'KEY_LEFT': ('window.prompt.move_cursor', 'left'), + 'KEY_RIGHT': ('window.prompt.move_cursor', 'right'), + 'KEY_UP': ('window.prompt.scroll', 'up'), + 'KEY_DOWN': ('window.prompt.scroll', 'down'), + 'KEY_PGUP': ('window.history.scroll', 'up'), + 'KEY_PGDOWN': ('window.history.scroll', 'down'), + 'esc:91:49:59:51:68': ('window', 'left'), + 'esc:91:49:59:51:67': ('window', 'right'), + 'KEY_F1': ('window.paste',), +} +CMD_SHORTCUTS: dict[str, str] = {} + + +class _YX(NamedTuple): + y: int + x: int + + +class _Widget(ABC): + _tainted: bool = True + _sizes = _YX(-1, -1) + + @property + def _drawable(self) -> bool: + return len([m for m in self._sizes if m < 1]) == 0 + + def taint(self) -> None: + 'Declare as in need of re-drawing.' + self._tainted = True + + @property + def tainted(self) -> bool: + 'If in need of re-drawing.' + return self._tainted + + def set_geometry(self, sizes: _YX) -> None: + 'Update widget\'s sizues, re-generate content where necessary.' + self.taint() + self._sizes = sizes + + def draw(self) -> None: + 'Print widget\'s content in shape appropriate to set geometry.' + if self._drawable: + self._draw() + self._tainted = False + + @abstractmethod + def _draw(self) -> None: + pass + + +class _ScrollableWidget(_Widget): + _history_idx: int + + def __init__(self, write: Callable[..., None], **kwargs) -> None: + super().__init__(**kwargs) + self._write = write + self._history: list[str] = [] + + def append(self, to_append: str) -> None: + 'Append to scrollable history.' + self._history += [to_append] + + @abstractmethod + def _scroll(self, up=True) -> None: + self.taint() + + def cmd__scroll(self, direction: str) -> None: + 'Scroll through stored content/history.' + self._scroll(up=direction == 'up') + + +class _HistoryWidget(_ScrollableWidget): + _last_read: int = 0 + _y_pgscroll: int + + def __init__(self, wrap: Callable[[str], list[str]], **kwargs) -> None: + super().__init__(**kwargs) + self._wrap = wrap + self._wrapped_idx = self._history_idx = -1 + self._wrapped: list[tuple[Optional[int], str]] = [] + + def _add_wrapped(self, idx_original, line) -> int: + wrapped_lines = self._wrap(line) + self._wrapped += [(idx_original, line) for line in wrapped_lines] + return len(wrapped_lines) + + def set_geometry(self, sizes: _YX) -> None: + super().set_geometry(sizes) + if self._drawable: + self._y_pgscroll = self._sizes.y // 2 + self._wrapped.clear() + self._wrapped += [(None, '')] * self._sizes.y + if self._history: + for idx_history, line in enumerate(self._history): + self._add_wrapped(idx_history, line) + wrapped_lines_for_history_idx = [ + t for t in self._wrapped + if t[0] == len(self._history) + self._history_idx] + idx_their_last = self._wrapped.index( + wrapped_lines_for_history_idx[-1]) + self._wrapped_idx = idx_their_last - len(self._wrapped) + + def append(self, to_append: str) -> None: + super().append(to_append) + self.taint() + if self._history_idx < -1: + self._history_idx -= 1 + if self._drawable: + n_wrapped_lines = self._add_wrapped(len(self._history) + - 1, to_append) + if self._wrapped_idx < -1: + self._wrapped_idx -= n_wrapped_lines + + def _draw(self) -> None: + start_idx = self._wrapped_idx - self._sizes.y + 1 + end_idx = self._wrapped_idx + to_write = [t[1] for t in self._wrapped[start_idx:end_idx]] + if self._wrapped_idx < -1: + scroll_info = f'vvv [{(-1) * self._wrapped_idx}] ' + scroll_info += 'v' * (self._sizes.x - len(scroll_info)) + to_write += [scroll_info] + else: + to_write += [self._wrapped[self._wrapped_idx][1]] + for i, line in enumerate(to_write): + self._write(line, i) + self._last_read = len(self._history) + + @property + def n_lines_unread(self) -> int: + 'How many new lines have been logged since last focus.' + return len(self._history) - self._last_read + + def _scroll(self, up: bool = True) -> None: + super()._scroll(up) + if self._drawable: + if up: + self._wrapped_idx = max( + self._sizes.y + 1 - len(self._wrapped), + self._wrapped_idx - self._y_pgscroll) + else: + self._wrapped_idx = min( + -1, self._wrapped_idx + self._y_pgscroll) + history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0] + if history_idx_to_wrapped_idx is not None: + self._history_idx = history_idx_to_wrapped_idx\ + - len(self._history) + + +class PromptWidget(_ScrollableWidget): + 'Manages/displays keyboard input field.' + _history_idx: int = 0 + _input_buffer_unsafe: str + _cursor_x: int + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._reset_buffer('') + + @property + def prefix(self) -> str: + 'Main input prefix.' + return _PROMPT_TEMPLATE[:] + + @property + def _input_buffer(self) -> str: + return self._input_buffer_unsafe[:] + + @_input_buffer.setter + def _input_buffer(self, content) -> None: + self.taint() + self._input_buffer_unsafe = content + + def _draw(self) -> None: + prefix = self.prefix[:] + content = self._input_buffer + if self._cursor_x == len(self._input_buffer): + content += ' ' + half_width = (self._sizes.x - len(prefix)) // 2 + offset = 0 + if len(prefix) + len(content) > self._sizes.x\ + and self._cursor_x > half_width: + prefix += _PROMPT_ELL_IN + offset = min(len(prefix) + len(content) - self._sizes.x, + self._cursor_x - half_width + len(_PROMPT_ELL_IN)) + cursor_x_to_write = len(prefix) + self._cursor_x - offset + to_write = f'{prefix}{content[offset:]}' + if len(to_write) > self._sizes.x: + to_write = (to_write[:self._sizes.x-len(_PROMPT_ELL_OUT)] + + _PROMPT_ELL_OUT) + self._write(to_write[:cursor_x_to_write], self._sizes.y, + padding=False) + self._write(to_write[cursor_x_to_write], attribute='reverse', + padding=False) + self._write(to_write[cursor_x_to_write + 1:]) + + def _archive_prompt(self) -> None: + self.append(self._input_buffer) + self._reset_buffer('') + + def _scroll(self, up: bool = True) -> None: + super()._scroll(up) + if up and -(self._history_idx) < len(self._history): + if self._history_idx == 0 and self._input_buffer: + self._archive_prompt() + self._history_idx -= 1 + self._history_idx -= 1 + self._reset_buffer(self._history[self._history_idx]) + elif not up: + if self._history_idx < 0: + self._history_idx += 1 + if self._history_idx == 0: + self._reset_buffer('') + else: + self._reset_buffer(self._history[self._history_idx]) + elif self._input_buffer: + self._archive_prompt() + + def insert(self, to_insert: str) -> None: + 'Insert into prompt input buffer.' + self._cursor_x += len(to_insert) + self._input_buffer = (self._input_buffer[:self._cursor_x - 1] + + to_insert + + self._input_buffer[self._cursor_x - 1:]) + self._history_idx = 0 + + def cmd__backspace(self) -> None: + 'Truncate current content by one character, if possible.' + if self._cursor_x > 0: + self._cursor_x -= 1 + self._input_buffer = (self._input_buffer[:self._cursor_x] + + self._input_buffer[self._cursor_x + 1:]) + self._history_idx = 0 + + def cmd__move_cursor(self, direction: str) -> None: + 'Move cursor one space into direction ("left" or "right") if possible.' + if direction == 'left' and self._cursor_x > 0: + self._cursor_x -= 1 + elif direction == 'right'\ + and self._cursor_x < len(self._input_buffer): + self._cursor_x += 1 + else: + return + self.taint() + + def _reset_buffer(self, content: str) -> None: + self._input_buffer = content + self._cursor_x = len(self._input_buffer) + + def enter(self) -> str: + 'Return current content while also clearing and then redrawing.' + to_return = self._input_buffer[:] + if to_return: + self._archive_prompt() + return to_return + + +class _StatusLine(_Widget): + + def __init__(self, write: Callable, windows: list['Window'], **kwargs + ) -> None: + super().__init__(**kwargs) + self.idx_focus = 0 + self._windows = windows + self._write = write + + def _draw(self) -> None: + listed = [] + focused = None + for w in self._windows: + item = str(w.idx) + if (n := w.history.n_lines_unread): + item = f'({item}:{n})' + if w.idx == self.idx_focus: + focused = w + item = f'[{item}]' + listed += [item] + assert isinstance(focused, Window) + left = f'{focused.title})' + right = f'({" ".join(listed)}' + width_gap = max(1, (self._sizes.x - len(left) - len(right))) + self._write(left + '=' * width_gap + right, self._sizes.y) + + +class Window: + 'Collection of widgets filling entire screen.' + _y_status: int + _drawable = False + prompt: PromptWidget + _title = ':start' + _last_today = '' + + def __init__(self, idx: int, term: 'Terminal', **kwargs) -> None: + super().__init__(**kwargs) + self.idx = idx + self._term = term + self.history = _HistoryWidget(wrap=self._term.wrap, + write=self._term.write) + self.prompt = self.__annotations__['prompt'](write=self._term.write) + if hasattr(self._term, 'size'): + self.set_geometry() + + def ensure_date(self, today: str) -> None: + 'Log date of today if it has not been logged yet.' + if today != self._last_today: + self._last_today = today + self.log(today) + + def log(self, msg: str) -> None: + 'Append msg to .history.' + self.history.append(msg) + + def taint(self) -> None: + 'Declare all widgets as in need of re-drawing.' + self.history.taint() + self.prompt.taint() + + @property + def tainted(self) -> bool: + 'If any widget in need of re-drawing.' + return self.history.tainted or self.prompt.tainted + + def set_geometry(self) -> None: + 'Set geometry for widgets.' + self._drawable = False + if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH: + for widget in (self.history, self.prompt): + widget.set_geometry(_YX(-1, -1)) + return + self._y_status = self._term.size.y - 2 + self.history.set_geometry(_YX(self._y_status, self._term.size.x)) + self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x)) + self._drawable = True + + @property + def title(self) -> str: + 'Window title to display in status line.' + return self._title + + def draw_tainted(self) -> None: + 'Draw tainted widgets (or message that screen too small).' + if self._drawable: + for widget in [w for w in (self.history, self.prompt) + if w.tainted]: + widget.draw() + elif self._term.size.x > 0: + lines = [''] + for i, c in enumerate('screen too small'): + if i > 0 and 0 == i % self._term.size.x: + lines += [''] + lines[-1] += c + for y, line in enumerate(lines): + self._term.write(line, y) + + def cmd__paste(self) -> None: + 'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.' + self.history.append(f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}') + + +class TuiEvent(AffectiveEvent): + 'To affect TUI.' + + +class TerminalInterface(ABC): + 'What BaseTui expects from a Terminal.' + size: _YX + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + @abstractmethod + @contextmanager + def setup(self) -> Generator: + 'Combine multiple contexts into one and run keypress loop.' + + @abstractmethod + def calc_geometry(self) -> None: + '(Re-)calculate .size..' + + @abstractmethod + def flush(self) -> None: + 'Flush terminal.' + + @abstractmethod + def wrap(self, line: str) -> list[str]: + 'Wrap line to list of lines fitting into terminal width.' + + @abstractmethod + def write(self, + msg: str = '', + start_y: Optional[int] = None, + attribute: Optional[str] = None, + padding: bool = True + ) -> None: + 'Print to terminal, with position, padding to line end, attributes.' + + @abstractmethod + def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: + pass + + +class BaseTui(QueueMixin): + 'Base for graphical user interface elements.' + + def __init__(self, term: TerminalInterface, **kwargs) -> None: + super().__init__(**kwargs) + self._term = term + self._window_idx = 0 + self._windows: list[Window] = [] + self._status_line = _StatusLine(write=self._term.write, + windows=self._windows) + self._new_window() + self._set_screen() + signal(SIGWINCH, lambda *_: self._set_screen()) + + def _log_target_wins(self, **_) -> Sequence[Window]: + # separated to serve as hook for subclass window selection + return [self.window] + + def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]: + 'Write with timestamp, prefix to what window ._log_target_wins offers.' + prefix = kwargs.get('prefix', _LOG_PREFIX_DEFAULT) + if kwargs.get('alert', False): + prefix = _LOG_PREFIX_ALERT + prefix + now = str(datetime.now()) + today, time = now[:10], now[11:19] + msg = f'{time} {prefix} {msg}' + affected_win_indices = [] + for win in self._log_target_wins(**kwargs): + affected_win_indices += [win.idx] + win.ensure_date(today) + win.log(msg) + if win != self.window: + self._status_line.taint() + return tuple(affected_win_indices), msg + + def _new_window(self, win_class=Window, **kwargs) -> Window: + new_idx = len(self._windows) + win = win_class(idx=new_idx, term=self._term, **kwargs) + self._windows += [win] + return win + + def redraw_affected(self) -> None: + 'On focused window call .draw, then flush screen.' + self.window.draw_tainted() + if self._status_line.tainted: + self._status_line.draw() + self._term.flush() + + def _set_screen(self) -> None: + 'Calc screen geometry into windows, then call .redraw_affected.' + self._term.calc_geometry() + for window in self._windows: + window.set_geometry() + self._status_line.set_geometry(_YX(self._term.size.y - 2, + self._term.size.x)) + self.redraw_affected() + + @property + def window(self) -> Window: + 'Currently selected Window.' + return self._windows[self._window_idx] + + def _switch_window(self, idx: int) -> None: + self.window.taint() + self._status_line.idx_focus = self._window_idx = idx + self._status_line.taint() + + @property + def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]], + int, tuple[str, ...]]]: + cmds = {} + method_name_prefix = 'cmd__' + base = 'self' + for path in (base, f'{base}.window', f'{base}.window.prompt', + f'{base}.window.history'): + for cmd_method_name in [name for name in dir(eval(path)) + if name.startswith(method_name_prefix)]: + path_prefix = f'{path}.' + cmd_name = (path_prefix[len(base)+1:] + + cmd_method_name[len(method_name_prefix):]) + method = eval(f'{path_prefix}{cmd_method_name}') + n_args_min = 0 + arg_names = [] + for arg_name, param in signature(method).parameters.items(): + arg_names += [arg_name] + n_args_min += int(param.default == inspect_empty) + cmds[cmd_name] = (method, n_args_min, tuple(arg_names)) + for key, target in CMD_SHORTCUTS.items(): + if target in cmds: + cmds[key] = cmds[target] + return cmds + + def handle_keyboard_event(self, typed_in: str) -> None: + 'Translate keyboard input into appropriate actions.' + if typed_in[0] == _CHAR_RESIZE: + self._set_screen() + return + if typed_in in _KEYBINDINGS: + cmd_data = _KEYBINDINGS[typed_in] + self._commands[cmd_data[0]][0](*cmd_data[1:]) + elif typed_in.startswith(_B64_PREFIX): + encoded = typed_in[len(_B64_PREFIX):] + to_paste = '' + for i, c in enumerate(b64decode(encoded).decode('utf-8')): + if i > 512: + break + if c.isprintable(): + to_paste += c + elif c.isspace(): + to_paste += ' ' + else: + to_paste += '#' + self.window.prompt.insert(to_paste) + elif len(typed_in) == 1: + self.window.prompt.insert(typed_in) + else: + self.log(f'unknown keyboard input: {typed_in}', alert=True) + self.redraw_affected() + + def cmd__prompt_enter(self) -> None: + 'Get prompt content from .window.prompt.enter, parse to & run command' + to_parse = self.window.prompt.enter() + if not to_parse: + return + alert: Optional[str] = None + if to_parse[0] == '/': + toks = to_parse.split(maxsplit=1) + cmd_name = toks.pop(0) + cmd, n_args_min, arg_names = self._commands.get(cmd_name[1:], + (None, 0, ())) + if not cmd: + alert = f'{cmd_name} unknown' + elif cmd.__name__ == stack()[0].function: + alert = f'{cmd_name} would loop into ourselves' + else: + n_args_max = len(arg_names) + if toks and not n_args_max: + alert = f'{cmd_name} given argument(s) while none expected' + else: + if toks: + while ' ' in toks[-1] and len(toks) < n_args_max: + toks = toks[:-1] + toks[-1].split(maxsplit=1) + if len(toks) < n_args_min: + alert = f'{cmd_name} too few arguments '\ + + f'(given {len(toks)}, need {n_args_min})' + else: + alert = cmd(*toks) + else: + alert = 'not prefixed by /' + if alert: + self.log(f'invalid prompt command: {alert}', alert=True) + + def cmd__help(self) -> None: + 'Print available commands.' + self.log('commands available in this window:') + to_log = [] + for cmd_name, cmd_data in self._commands.items(): + to_print = [cmd_name] + for idx, arg in enumerate(cmd_data[2]): + arg = arg.upper() + if idx >= cmd_data[1]: + arg = f'[{arg}]' + to_print += [arg] + to_log += [' '.join(to_print)] + for item in sorted(to_log): + self.log(f' /{item}') + + def cmd__list(self) -> None: + 'List available windows.' + self.log('windows available via /window:') + for win in self._windows: + self.log(f' {win.idx}) {win.title}') + + def cmd__quit(self) -> None: + 'Trigger program exit.' + self._put(QuitEvent()) + + def cmd__window(self, towards: str) -> Optional[str]: + 'Switch window selection.' + n_windows = len(self._windows) + if n_windows < 2: + return 'no alternate window to move into' + if towards in {'left', 'right'}: + multiplier = (+1) if towards == 'right' else (-1) + window_idx = self._window_idx + multiplier + if not 0 <= window_idx < n_windows: + window_idx -= multiplier * n_windows + elif not towards.isdigit(): + return f'neither "left"/"right" nor integer: {towards}' + else: + window_idx = int(towards) + if not 0 <= window_idx < n_windows: + return f'unavailable window idx: {window_idx}' + self._switch_window(window_idx) + return None + + +class Terminal(QueueMixin, TerminalInterface): + 'Abstraction of terminal interface.' + _cursor_yx_: _YX + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._blessed = BlessedTerminal() + self._cursor_yx = _YX(0, 0) + + @contextmanager + def setup(self) -> Generator: + print(self._blessed.clear, end='') + with (self._blessed.raw(), + self._blessed.fullscreen(), + self._blessed.hidden_cursor(), + Loop(iterator=self._get_keypresses(), _q_out=self._q_out)): + yield self + + @property + def _cursor_yx(self) -> _YX: + return self._cursor_yx_ + + @_cursor_yx.setter + def _cursor_yx(self, yx: _YX) -> None: + print(self._blessed.move_yx(yx.y, yx.x), end='') + self._cursor_yx_ = yx + + def calc_geometry(self) -> None: + self.size = _YX(self._blessed.height, self._blessed.width) + + def flush(self) -> None: + print('', end='', flush=True) + + def wrap(self, line: str) -> list[str]: + return self._blessed.wrap(line, width=self.size.x, + subsequent_indent=' '*4) + + def write(self, + msg: str = '', + start_y: Optional[int] = None, + attribute: Optional[str] = None, + padding: bool = True + ) -> None: + if start_y is not None: + self._cursor_yx = _YX(start_y, 0) + # ._blessed.length can slow down things notably: only use where needed! + end_x = self._cursor_yx.x + (len(msg) if msg.isascii() + else self._blessed.length(msg)) + len_padding = self.size.x - end_x + if len_padding < 0: + msg = self._blessed.truncate(msg, self.size.x - self._cursor_yx.x) + elif padding: + msg += ' ' * len_padding + end_x = self.size.x + if attribute: + msg = getattr(self._blessed, attribute)(msg) + print(msg, end='') + self._cursor_yx = _YX(self._cursor_yx.y, end_x) + + def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]: + '''Loop through keypresses from terminal, expand blessed's handling. + + Explicitly collect KEY_ESCAPE-modified key sequences, and recognize + OSC52-prefixed pastables to return the respective base64 code, + prefixed with _B64_PREFIX. + ''' + while True: + to_yield = '' + ks = self._blessed.inkey( + timeout=_TIMEOUT_KEYPRESS_LOOP, # how long until yield None, + esc_delay=0) # incl. until thread dies + if ks.name != 'KEY_ESCAPE': + to_yield = f'{ks.name if ks.name else ks}' + else: + chars = b'' + while (new_chars := self._blessed.inkey(timeout=0, esc_delay=0 + ).encode('utf-8')): + chars += new_chars + len_prefix = len(_OSC52_PREFIX) + if chars[:len_prefix] == _OSC52_PREFIX: + to_yield = _B64_PREFIX[:] + # sometimes, prev .inkey got some or all (including paste + # delimiter) of the paste code (maybe even more), so first + # harvest potential remains of chars post prefix … + caught_delimiter = False + post_prefix_str = chars[len_prefix:].decode('utf-8') + for idx, c in enumerate(post_prefix_str): + if c == _PASTE_DELIMITER: + caught_delimiter = True + if (remains := post_prefix_str[idx + 1:]): + self._blessed.ungetch(remains) + break + to_yield += c + # … before .getch() further until expected delimiter found + if not caught_delimiter: + while (c := self._blessed.getch()) != _PASTE_DELIMITER: + to_yield += c + else: + to_yield = 'esc:' + ':'.join([str(int(b)) for b in chars]) + yield (TuiEvent.affector('handle_keyboard_event' + ).kw(typed_in=to_yield) if to_yield + else None) diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..d43de1b --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1 @@ +blessed diff --git a/src/run.py b/src/run.py new file mode 100755 index 0000000..42f9dc9 --- /dev/null +++ b/src/run.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +'Attempt at an IRC client.' +from queue import SimpleQueue +from sys import argv +from ircplom.events import ExceptionEvent, QuitEvent +from ircplom.client import ClientsDb, ClientEvent, NewClientEvent +from ircplom.tui_base import BaseTui, Terminal, TerminalInterface, TuiEvent +from ircplom.client_tui import ClientTui +from ircplom.testing import TestTerminal, TestingClientTui + + +def main_loop(cls_term: type[TerminalInterface], cls_tui: type[BaseTui] + ) -> None: + 'Main execution code / loop.' + q_events: SimpleQueue = SimpleQueue() + clients_db: ClientsDb = {} + try: + with cls_term(_q_out=q_events).setup() as term: + tui = cls_tui(_q_out=q_events, term=term) + while True: + event = q_events.get() + if isinstance(event, QuitEvent): + break + if isinstance(event, ExceptionEvent): + raise event.exception + if isinstance(event, TuiEvent): + event.affect(tui) + elif isinstance(event, NewClientEvent): + event.affect(clients_db) + elif isinstance(event, ClientEvent): + event.affect(clients_db[event.client_id]) + finally: + for client in clients_db.values(): + client.close() + + +if __name__ == '__main__': + if len(argv) > 1 and argv[1] == 'test': + main_loop(TestTerminal, TestingClientTui) + print('test finished') + else: + main_loop(Terminal, ClientTui)