--- /dev/null
+[submodule "plomlib"]
+ path = plomlib
+ url = https://plomlompom.com/repos/clone/plomlib
--- /dev/null
+#!/usr/bin/sh
+./plomlib/sh/install.sh ircplom
+ircplom install_deps
+++ /dev/null
-#!/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)
+++ /dev/null
-'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.'
+++ /dev/null
-'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))
+++ /dev/null
-'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)))
+++ /dev/null
-'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)
+++ /dev/null
-'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'),)),
-]
+++ /dev/null
-'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
+++ /dev/null
-'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)
--- /dev/null
+Subproject commit f2dc66a2d4f1e8823246d1621b424e44ec423897
--- /dev/null
+'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.'
--- /dev/null
+'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))
--- /dev/null
+'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)))
--- /dev/null
+'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)
--- /dev/null
+'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'),)),
+]
--- /dev/null
+'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
--- /dev/null
+'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)
--- /dev/null
+#!/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)