From 30ca889f86c93727caa39d63a40eadba1d1cde2d Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Mon, 4 Aug 2025 10:07:04 +0200 Subject: [PATCH] Move low-level IrcConnection code out of Client. --- ircplom/irc_conn.py | 222 +++++++++++++++++++++++++------------------- ircplom/tui.py | 12 ++- 2 files changed, 132 insertions(+), 102 deletions(-) diff --git a/ircplom/irc_conn.py b/ircplom/irc_conn.py index d2ba858..7edf950 100644 --- a/ircplom/irc_conn.py +++ b/ircplom/irc_conn.py @@ -129,6 +129,69 @@ class IrcMessage: return self._raw +class _IrcConnAbortException(Exception): + pass + + +class _IrcConnection(QueueMixin): + 'Collects low-level server-client connection management.' + + def __init__(self, hostname: str, client_id: UUID, **kwargs) -> None: + super().__init__(**kwargs) + self.client_id = client_id + self._socket = socket() + 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) + self._recv_loop = Loop(iterator=self._read_lines(), q_out=self.q_out) + + 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) + + def _read_lines(self) -> Iterator[Optional['_RecvEvent']]: + assert self._socket is not None + bytes_total = b'' + buffer_linesep = b'' + while True: + try: + bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE) + except TimeoutError: + yield None + continue + except ConnectionResetError as e: + raise e + except OSError as e: + if e.errno == 9: + break + 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 _RecvEvent(client_id=self.client_id, + payload=IrcMessage.from_raw( + bytes_total.decode('utf-8'))) + bytes_total = b'' + + @dataclass class ClientIdMixin: 'Collects a Client\'s ID at .client_id.' @@ -155,9 +218,11 @@ class _ConnectedEvent(ClientEvent): def affect(self, target: 'Client') -> None: target.log(msg='# connected to server', chat=CHAT_GLOB) target.try_send_cap('LS', ('302',)) - target.send(IrcMessage(verb='USER', params=(getuser(), '0', '*', - target.realname))) - target.send(IrcMessage(verb='NICK', params=(target.nickname,))) + target.send(IrcMessage(verb='USER', + params=(getuser(), '0', '*', + target.conn_setup.realname))) + target.send(IrcMessage(verb='NICK', + params=(target.conn_setup.nickname,))) @dataclass @@ -165,7 +230,7 @@ class InitReconnectEvent(ClientEvent): 'To trigger re-opening of connection.' def affect(self, target: 'Client') -> None: - if target.assumed_open: + if target.conn: target.log('# ALERT: reconnection called, but still seem ' 'connected, so nothing to do.') else: @@ -176,13 +241,10 @@ class InitReconnectEvent(ClientEvent): class SendEvent(ClientEvent, PayloadMixin): 'To trigger sending of payload to server.' payload: IrcMessage - - def __init__(self, chat: str = '', **kwargs) -> None: - super().__init__(**kwargs) - self._chat = chat + chat: str = '' def affect(self, target: 'Client') -> None: - target.send(msg=self.payload, chat=self._chat) + target.send(msg=self.payload, chat=self.chat) @dataclass @@ -210,43 +272,40 @@ class ServerCapability: return listing +@dataclass +class IrcConnSetup: + 'All we need to know to set up a new Client connection.' + hostname: str + nickname: str + realname: str + + class Client(ABC, ClientQueueMixin): 'Abstracts socket connection, loop over it, and handling messages from it.' - nick_confirmed: bool - nickname: str + nick_confirmed: bool = False + conn: Optional[_IrcConnection] = None - def __init__(self, hostname: str, nickname: str, realname: str, **kwargs - ) -> None: + def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None: super().__init__(**kwargs) - self._hostname = hostname - self._socket: Optional[socket] = None - self._recv_loop: Optional[Loop] = None + self.conn_setup = conn_setup self._cap_neg_states: dict[str, bool] = {} self.caps: dict[str, ServerCapability] = {} self.id_ = uuid4() - self.assumed_open = False - self.realname = realname - self.update_login(nick_confirmed=False, nickname=nickname) + self.update_login(nick_confirmed=False, + nickname=self.conn_setup.nickname) self.start_connecting() def start_connecting(self) -> None: - 'Start thread to initiate connection, from socket to recv loop.' + 'Start thread to set up IrcConnection at .conn.' def connect(self) -> None: try: - self._socket = socket() - self.log(f'# connecting to server {self._hostname} …') - self._socket.settimeout(_TIMEOUT_CONNECT) - try: - self._socket.connect((self._hostname, _PORT)) - except (TimeoutError, socket_gaierror) as e: - self.log(f'# ALERT: {e}') - return - self._socket.settimeout(_TIMEOUT_RECV_LOOP) - self.assumed_open = True - self._recv_loop = Loop(iterator=self._read_lines(), - q_out=self.q_out) + self.conn = _IrcConnection(hostname=self.conn_setup.hostname, + q_out=self.q_out, + client_id=self.id_) self._cput(_ConnectedEvent) + except _IrcConnAbortException as e: + self.log(f'# ALERT: {e}') except Exception as e: # pylint: disable=broad-exception-caught self._put(ExceptionEvent(e)) @@ -304,21 +363,25 @@ class Client(ABC, ClientQueueMixin): def send(self, msg: IrcMessage, chat: str = '') -> None: 'Send line-separator-delimited message over socket.' - if not (self._socket and self.assumed_open): + if not self.conn: self.log('# ALERT: cannot send, connection seems closed') return - self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR) + self.conn.send(msg) self.log(msg=f'> {msg.raw}', chat=chat) self.log(msg=f'=>| {msg.raw}', chat=':raw') def update_login(self, nick_confirmed: bool, nickname: str = '') -> None: - 'Manage .nickname, .nick_confirmed – useful for subclass extension.' - first_run = not hasattr(self, 'nickname') + '''Manage conn_setup..nickname, .nick_confirmed. + + (Useful for subclass extension.) + ''' + first_run = not hasattr(self.conn_setup, 'nickname') prefix = '# nickname' - if first_run or (nickname and nickname != self.nickname): - verb = 'set' if first_run else f'changed from "{self.nickname}' - self.nickname = nickname - self.log(msg=f'{prefix} {verb} to {nickname}', chat=CHAT_GLOB) + if first_run or (nickname and nickname != self.conn_setup.nickname): + verb = ('set' if first_run + else f'changed from "{self.conn_setup.nickname}"') + self.conn_setup.nickname = nickname + self.log(msg=f'{prefix} {verb} to "{nickname}"', chat=CHAT_GLOB) if first_run or nick_confirmed != self.nick_confirmed: self.nick_confirmed = nick_confirmed if not first_run: @@ -326,73 +389,38 @@ class Client(ABC, ClientQueueMixin): def close(self) -> None: 'Close both recv Loop and socket.' - self.log(msg='# disconnected from server', chat=CHAT_GLOB) - self.assumed_open = False + self.log(msg='# disconnecting from server', chat=CHAT_GLOB) + if self.conn: + self.conn.close() + self.conn = None self.update_login(nick_confirmed=False) - if self._recv_loop: - self._recv_loop.stop() - self._recv_loop = None - if self._socket: - self._socket.close() - self._socket = None - - def _read_lines(self) -> Iterator[Optional['_RecvEvent']]: - assert self._socket is not None - bytes_total = b'' - buffer_linesep = b'' - while True: - try: - bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE) - except TimeoutError: - yield None - continue - except ConnectionResetError as e: - raise e - except OSError as e: - if e.errno == 9: - break - 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 _RecvEvent(client_id=self.id_, - payload=bytes_total.decode('utf-8')) - bytes_total = b'' @dataclass class _RecvEvent(ClientEvent, PayloadMixin): - payload: str + payload: IrcMessage def affect(self, target: Client) -> None: - msg = IrcMessage.from_raw(self.payload) - target.log(f'<-| {self.payload}', ':raw') - if msg.verb == 'PING': - target.send(IrcMessage(verb='PONG', params=(msg.params[0],))) - elif msg.verb == 'ERROR': + target.log(f'<-| {self.payload.raw}', ':raw') + if self.payload.verb == 'PING': + target.send(IrcMessage(verb='PONG', + params=(self.payload.params[0],))) + elif self.payload.verb == 'ERROR': target.close() - elif msg.verb in {'001', 'NICK'}: - target.update_login(nickname=msg.params[0], nick_confirmed=True) - elif msg.verb == 'PRIVMSG': - target.log(msg=str(msg.params), chat=msg.source) - elif msg.verb == 'CAP': - if msg.params[1] in {'LS', 'LIST'}: - target.collect_caps(msg.params[1:]) - elif msg.params[1] == {'ACK', 'NAK'}: - cap_names = msg.params[-1].split() + elif self.payload.verb in {'001', 'NICK'}: + target.update_login(nickname=self.payload.params[0], + nick_confirmed=True) + elif self.payload.verb == 'PRIVMSG': + target.log(msg=str(self.payload.params), chat=self.payload.source) + elif self.payload.verb == 'CAP': + if self.payload.params[1] in {'LS', 'LIST'}: + target.collect_caps(self.payload.params[1:]) + elif self.payload.params[1] == {'ACK', 'NAK'}: + cap_names = self.payload.params[-1].split() for cap_name in cap_names: target.cap_neg_set(f'REQ:{cap_name}', done=True) - target.caps[cap_name].enabled = msg.params[1] == 'ACK' + target.caps[cap_name].enabled = (self.payload.params[1] + == 'ACK') if target.cap_neg_done('LIST'): target.try_send_cap('END') if not target.cap_neg('printing'): diff --git a/ircplom/tui.py b/ircplom/tui.py index f0244a2..39b51f3 100644 --- a/ircplom/tui.py +++ b/ircplom/tui.py @@ -14,8 +14,8 @@ from blessed import Terminal as BlessedTerminal from ircplom.events import ( AffectiveEvent, Loop, PayloadMixin, QueueMixin, QuitEvent) from ircplom.irc_conn import ( - CHAT_GLOB, IrcMessage, Client, ClientIdMixin, ClientQueueMixin, - InitReconnectEvent, NewClientEvent, SendEvent) + CHAT_GLOB, IrcConnSetup, IrcMessage, Client, ClientIdMixin, + ClientQueueMixin, InitReconnectEvent, NewClientEvent, SendEvent) _MIN_HEIGHT = 4 _MIN_WIDTH = 32 @@ -479,8 +479,10 @@ class Tui(QueueMixin): ) -> None: 'Create Client and pass it via NewClientEvent.' self._put(NewClientEvent( - _ClientKnowingTui(q_out=self.q_out, hostname=hostname, - nickname=nickname, realname=realname))) + _ClientKnowingTui( + q_out=self.q_out, + conn_setup=IrcConnSetup(hostname=hostname, nickname=nickname, + realname=realname)))) def cmd__prompt_enter(self) -> None: 'Get prompt content from .window.prompt.enter, parse to & run command.' @@ -720,4 +722,4 @@ class _ClientKnowingTui(Client): def update_login(self, nick_confirmed: bool, nickname: str = '') -> None: super().update_login(nick_confirmed, nickname) self._cput(_ClientPromptEvent, payload=(self.nick_confirmed, - self.nickname)) + self.conn_setup.nickname)) -- 2.30.2