home · contact · privacy
Move low-level IrcConnection code out of Client.
authorChristian Heller <c.heller@plomlompom.de>
Mon, 4 Aug 2025 08:07:04 +0000 (10:07 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 4 Aug 2025 08:07:04 +0000 (10:07 +0200)
ircplom/irc_conn.py
ircplom/tui.py

index d2ba8582a8ee508c6b3518cee9fe16a3f1d7a271..7edf950bbfed9985712a582c9cdc83bfd2917e28 100644 (file)
@@ -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'):
index f0244a23f7f8708704ee5cafcb2eddfa8c6d9f27..39b51f34dc5e50daf8cbf55f8a52d5e2b40d47dd 100644 (file)
@@ -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))