ui_cli_manager.ui_cli_manager

ui_cli_manager.py - Simple Connection Manager for UI/TCP connections

  1"""ui_cli_manager.py - Simple Connection Manager for UI/TCP connections"""
  2from __future__ import annotations
  3from queue import Empty
  4from collections import deque
  5from dataclasses import dataclass
  6from typing import Callable, Any
  7import shlex
  8import socket
  9
 10from micronetcode.utils import logger, JournalMsgType, SOCKET_RECV_SIZE, SOCKET_RECV_MAXSIZE
 11from micronetcode.message import SourceType, Message
 12from micronetcode.connection_manager import ConnectionManager
 13from micronetcode.codec import Codec
 14
 15class ShlexASCIICodec(Codec):
 16    """Basic ASCII/shlex (stdlib) based codec. Matches the initial implementation with ascii/newline data."""
 17    def __init__(self, socket_recv_size: int | None = None, socket_recv_maxsize: int | None = None):
 18        super().__init__()
 19        self.socket_recv_maxsize = socket_recv_maxsize or SOCKET_RECV_MAXSIZE
 20        self.socket_recv_size = socket_recv_size or SOCKET_RECV_SIZE
 21
 22    def recv(self, sock: socket.socket) -> bytes | None:
 23        res = bytearray()
 24        while True:
 25            buf = sock.recv(self.socket_recv_size)
 26            if len(buf) == 0: # This means it closed connection
 27                if len(res) == 0:
 28                    return None
 29                return bytes(res)
 30            res.extend(buf)
 31            if res[-1] == ord("\n"):
 32                return bytes(res)
 33            if len(res) > self.socket_recv_maxsize:
 34                raise ValueError(f"Too much data set: {len(res)=} vs {self.socket_recv_maxsize=}")
 35
 36    def decode(self, data: bytes) -> list[Message]:
 37        lines = data.decode("ascii").strip().split("\n")
 38        return [Message(source_type=SourceType.SOCKET, data=line) for line in lines]
 39
 40    def encode(self, resp: Any) -> bytes:
 41        if not isinstance(resp, str):
 42            raise TypeError(f"Expected str, got {type(resp)}")
 43        return resp.encode("ascii")
 44
 45@dataclass
 46class CLICommand:
 47    """dataclass encapsulating the CLI command, its arguments and the source Message (socket, stdin, etc.)"""
 48    message: Message
 49    command: str
 50    args: tuple[Any, ...]
 51    respond: Callable[[str], None] # a callback to respond to this CLI message created by UICLIManager
 52
 53class UICLIManager:
 54    """
 55    UI-CLI manager. Wrapper on top of ConnectionManager + Channel a list commands
 56    Parameters:
 57    - host The host where the socket listens to (e.g. "0.0.0.0")
 58    - port The port where the socket listens to
 59    - cli_commands A dictionary of all valid cli commands and the expected argument count of each (no type validation)
 60    - journal_length An optional parameter that if >0 makes the underlying channel store up to these many messages
 61    - script_lines A list of scripting commands to be executed first before any TCP message
 62    """
 63
 64    def __init__(self, connection_manager: ConnectionManager, cli_commands: dict[str, int],
 65                 script_lines: list[str] | None = None):
 66        if "help" in cli_commands:
 67            raise ValueError("'help' cannot be part of cli_commands, it is auto-inserted")
 68        self.connection_manager = connection_manager
 69        self.cli_commands = cli_commands
 70        self.script_lines = deque(script_lines or [])
 71        # (command, reply) journal of every SCRIPT-source response. The reliable way to observe
 72        # scripted replies (they have no socket client to reply to) — used by the integration tests.
 73        self.script_responses: list[tuple[str, str]] = []
 74
 75    @classmethod
 76    def with_ascii(cls, host: str, port: int, cli_commands: dict[str, int], script_lines: list[str] | None = None,
 77                   max_connections: int | None = None, journal_length: int | None = None,
 78                   channel_queue_maxsize: int | None = None, socket_timeout_s: float | None = None) -> UICLIManager:
 79        """Convenience wrapper to instantiate UICLIManager with a TCP-based connection manager and ASCII codec"""
 80        connection_manager = ConnectionManager(host, port, codec=ShlexASCIICodec(), max_connections=max_connections,
 81                                               journal_length=journal_length, socket_timeout_s=socket_timeout_s,
 82                                               channel_queue_maxsize=channel_queue_maxsize)
 83        return UICLIManager(connection_manager, cli_commands, script_lines)
 84
 85    def start(self):
 86        """Starts the underlying connection manager thread"""
 87        self.connection_manager.start()
 88
 89    def get_cli_command(self) -> CLICommand | None:
 90        """
 91        Returns a command + its valid args if the CLI provided a valid command, None otherwise. No exceptions.
 92        Ran in main thread context.
 93        """
 94
 95        try:
 96            if len(self.script_lines) > 0: # get data from script lines first
 97                message = Message(source_type=SourceType.SCRIPT, data=self.script_lines.popleft())
 98            else:
 99                # Note: this must be nowait so the UI doesn't lag as it's called from the main loop
100                message: Message = self.connection_manager.get_one_message()
101
102            cli_command, err = self._handle_one_message(message)
103            if err is not None:
104                logger.error(err)
105                self._send_response(message=message, data=err)
106                return None
107            return cli_command
108
109        except Empty:
110            return None
111
112    # Private helper functions
113
114    def _handle_one_message(self, message: Message) -> tuple[CLICommand | None, str | None]:
115        """Given one message (source_type, payload, [source]), turn it into a CLICommand(msg, cmd, args) or err"""
116        if not isinstance(message.data, str):
117            raise TypeError(f"Message {message} doesn't have data as string: {type(message.data)}. This is an error. "
118                            f"The connection's Codec ({self.connection_manager.codec}) must make it string.")
119
120        try:
121            cmd_args = shlex.split(message.data)
122        except ValueError as e:
123            return None, f"Bad quoting: {e}"
124
125        if not cmd_args or len(cmd_args) == 0 or cmd_args[0] == "#": # empty or commented out command
126            return None, f"Comment or empty line: {message.data}"
127
128        command, args = cmd_args[0], cmd_args[1: ]
129
130        if command == "help":
131            return None, f"Supported commands: {list(self.cli_commands)}"
132
133        if command not in self.cli_commands:
134            return None, f"Unknown command '{command}'. Supported: {list(self.cli_commands)}"
135
136        if len(args) != self.cli_commands[command]:
137            return None, f"{cmd_args=}, Expected args: {self.cli_commands[command]}, got {len(args)}"
138
139        respond_cb = lambda data: self._send_response(message=message, data=data)
140        return CLICommand(message=message, command=command, args=args, respond=respond_cb), None
141
142    def _send_response(self, message: Message, data: str):
143        """Validate + newline + route by source. Shared by cli_cmd.respond() and the error path."""
144        if not isinstance(data, str):
145            raise TypeError(f"Expected 'str', got {type(data)}")
146        if len(data) == 0:
147            raise ValueError("Cannot respond with empty string")
148        if data[-1] != "\n":
149            data += "\n"
150        if message.client is not None:
151            message.client.channel.main2tcp.put(data, timeout=self.connection_manager.socket_timeout_s)
152        else: # for script/stdin etc: journal the response for tests (no socket client to reply to)
153            logger.debug(f"{JournalMsgType.RESP} - {message=} - {data=}")
154            self.script_responses.append((message.data.rstrip("\n"), data.rstrip("\n")))
class ShlexASCIICodec(micronetcode.codec.Codec):
16class ShlexASCIICodec(Codec):
17    """Basic ASCII/shlex (stdlib) based codec. Matches the initial implementation with ascii/newline data."""
18    def __init__(self, socket_recv_size: int | None = None, socket_recv_maxsize: int | None = None):
19        super().__init__()
20        self.socket_recv_maxsize = socket_recv_maxsize or SOCKET_RECV_MAXSIZE
21        self.socket_recv_size = socket_recv_size or SOCKET_RECV_SIZE
22
23    def recv(self, sock: socket.socket) -> bytes | None:
24        res = bytearray()
25        while True:
26            buf = sock.recv(self.socket_recv_size)
27            if len(buf) == 0: # This means it closed connection
28                if len(res) == 0:
29                    return None
30                return bytes(res)
31            res.extend(buf)
32            if res[-1] == ord("\n"):
33                return bytes(res)
34            if len(res) > self.socket_recv_maxsize:
35                raise ValueError(f"Too much data set: {len(res)=} vs {self.socket_recv_maxsize=}")
36
37    def decode(self, data: bytes) -> list[Message]:
38        lines = data.decode("ascii").strip().split("\n")
39        return [Message(source_type=SourceType.SOCKET, data=line) for line in lines]
40
41    def encode(self, resp: Any) -> bytes:
42        if not isinstance(resp, str):
43            raise TypeError(f"Expected str, got {type(resp)}")
44        return resp.encode("ascii")

Basic ASCII/shlex (stdlib) based codec. Matches the initial implementation with ascii/newline data.

ShlexASCIICodec( socket_recv_size: int | None = None, socket_recv_maxsize: int | None = None)
18    def __init__(self, socket_recv_size: int | None = None, socket_recv_maxsize: int | None = None):
19        super().__init__()
20        self.socket_recv_maxsize = socket_recv_maxsize or SOCKET_RECV_MAXSIZE
21        self.socket_recv_size = socket_recv_size or SOCKET_RECV_SIZE
socket_recv_maxsize
socket_recv_size
def recv(self, sock: socket.socket) -> bytes | None:
23    def recv(self, sock: socket.socket) -> bytes | None:
24        res = bytearray()
25        while True:
26            buf = sock.recv(self.socket_recv_size)
27            if len(buf) == 0: # This means it closed connection
28                if len(res) == 0:
29                    return None
30                return bytes(res)
31            res.extend(buf)
32            if res[-1] == ord("\n"):
33                return bytes(res)
34            if len(res) > self.socket_recv_maxsize:
35                raise ValueError(f"Too much data set: {len(res)=} vs {self.socket_recv_maxsize=}")

Receive raw bytes from the wire (socket). Different protocols may have different ways to recv (len+data). Returns None if the socket closed the connection. This is used to re-use the client thread for new connections.

def decode(self, data: bytes) -> list[micronetcode.message.Message]:
37    def decode(self, data: bytes) -> list[Message]:
38        lines = data.decode("ascii").strip().split("\n")
39        return [Message(source_type=SourceType.SOCKET, data=line) for line in lines]

Converts raw bytes data fro recv(sock) into a list of messages for the main app

def encode(self, resp: Any) -> bytes:
41    def encode(self, resp: Any) -> bytes:
42        if not isinstance(resp, str):
43            raise TypeError(f"Expected str, got {type(resp)}")
44        return resp.encode("ascii")

Encodes the response of the main app back into bytes to be sent via the socket used at recv()

@dataclass
class CLICommand:
46@dataclass
47class CLICommand:
48    """dataclass encapsulating the CLI command, its arguments and the source Message (socket, stdin, etc.)"""
49    message: Message
50    command: str
51    args: tuple[Any, ...]
52    respond: Callable[[str], None] # a callback to respond to this CLI message created by UICLIManager

dataclass encapsulating the CLI command, its arguments and the source Message (socket, stdin, etc.)

CLICommand( message: micronetcode.message.Message, command: str, args: tuple[typing.Any, ...], respond: Callable[[str], NoneType])
command: str
args: tuple[typing.Any, ...]
respond: Callable[[str], NoneType]
class UICLIManager:
 54class UICLIManager:
 55    """
 56    UI-CLI manager. Wrapper on top of ConnectionManager + Channel a list commands
 57    Parameters:
 58    - host The host where the socket listens to (e.g. "0.0.0.0")
 59    - port The port where the socket listens to
 60    - cli_commands A dictionary of all valid cli commands and the expected argument count of each (no type validation)
 61    - journal_length An optional parameter that if >0 makes the underlying channel store up to these many messages
 62    - script_lines A list of scripting commands to be executed first before any TCP message
 63    """
 64
 65    def __init__(self, connection_manager: ConnectionManager, cli_commands: dict[str, int],
 66                 script_lines: list[str] | None = None):
 67        if "help" in cli_commands:
 68            raise ValueError("'help' cannot be part of cli_commands, it is auto-inserted")
 69        self.connection_manager = connection_manager
 70        self.cli_commands = cli_commands
 71        self.script_lines = deque(script_lines or [])
 72        # (command, reply) journal of every SCRIPT-source response. The reliable way to observe
 73        # scripted replies (they have no socket client to reply to) — used by the integration tests.
 74        self.script_responses: list[tuple[str, str]] = []
 75
 76    @classmethod
 77    def with_ascii(cls, host: str, port: int, cli_commands: dict[str, int], script_lines: list[str] | None = None,
 78                   max_connections: int | None = None, journal_length: int | None = None,
 79                   channel_queue_maxsize: int | None = None, socket_timeout_s: float | None = None) -> UICLIManager:
 80        """Convenience wrapper to instantiate UICLIManager with a TCP-based connection manager and ASCII codec"""
 81        connection_manager = ConnectionManager(host, port, codec=ShlexASCIICodec(), max_connections=max_connections,
 82                                               journal_length=journal_length, socket_timeout_s=socket_timeout_s,
 83                                               channel_queue_maxsize=channel_queue_maxsize)
 84        return UICLIManager(connection_manager, cli_commands, script_lines)
 85
 86    def start(self):
 87        """Starts the underlying connection manager thread"""
 88        self.connection_manager.start()
 89
 90    def get_cli_command(self) -> CLICommand | None:
 91        """
 92        Returns a command + its valid args if the CLI provided a valid command, None otherwise. No exceptions.
 93        Ran in main thread context.
 94        """
 95
 96        try:
 97            if len(self.script_lines) > 0: # get data from script lines first
 98                message = Message(source_type=SourceType.SCRIPT, data=self.script_lines.popleft())
 99            else:
100                # Note: this must be nowait so the UI doesn't lag as it's called from the main loop
101                message: Message = self.connection_manager.get_one_message()
102
103            cli_command, err = self._handle_one_message(message)
104            if err is not None:
105                logger.error(err)
106                self._send_response(message=message, data=err)
107                return None
108            return cli_command
109
110        except Empty:
111            return None
112
113    # Private helper functions
114
115    def _handle_one_message(self, message: Message) -> tuple[CLICommand | None, str | None]:
116        """Given one message (source_type, payload, [source]), turn it into a CLICommand(msg, cmd, args) or err"""
117        if not isinstance(message.data, str):
118            raise TypeError(f"Message {message} doesn't have data as string: {type(message.data)}. This is an error. "
119                            f"The connection's Codec ({self.connection_manager.codec}) must make it string.")
120
121        try:
122            cmd_args = shlex.split(message.data)
123        except ValueError as e:
124            return None, f"Bad quoting: {e}"
125
126        if not cmd_args or len(cmd_args) == 0 or cmd_args[0] == "#": # empty or commented out command
127            return None, f"Comment or empty line: {message.data}"
128
129        command, args = cmd_args[0], cmd_args[1: ]
130
131        if command == "help":
132            return None, f"Supported commands: {list(self.cli_commands)}"
133
134        if command not in self.cli_commands:
135            return None, f"Unknown command '{command}'. Supported: {list(self.cli_commands)}"
136
137        if len(args) != self.cli_commands[command]:
138            return None, f"{cmd_args=}, Expected args: {self.cli_commands[command]}, got {len(args)}"
139
140        respond_cb = lambda data: self._send_response(message=message, data=data)
141        return CLICommand(message=message, command=command, args=args, respond=respond_cb), None
142
143    def _send_response(self, message: Message, data: str):
144        """Validate + newline + route by source. Shared by cli_cmd.respond() and the error path."""
145        if not isinstance(data, str):
146            raise TypeError(f"Expected 'str', got {type(data)}")
147        if len(data) == 0:
148            raise ValueError("Cannot respond with empty string")
149        if data[-1] != "\n":
150            data += "\n"
151        if message.client is not None:
152            message.client.channel.main2tcp.put(data, timeout=self.connection_manager.socket_timeout_s)
153        else: # for script/stdin etc: journal the response for tests (no socket client to reply to)
154            logger.debug(f"{JournalMsgType.RESP} - {message=} - {data=}")
155            self.script_responses.append((message.data.rstrip("\n"), data.rstrip("\n")))

UI-CLI manager. Wrapper on top of ConnectionManager + Channel a list commands Parameters:

  • host The host where the socket listens to (e.g. "0.0.0.0")
  • port The port where the socket listens to
  • cli_commands A dictionary of all valid cli commands and the expected argument count of each (no type validation)
  • journal_length An optional parameter that if >0 makes the underlying channel store up to these many messages
  • script_lines A list of scripting commands to be executed first before any TCP message
UICLIManager( connection_manager: micronetcode.connection_manager.ConnectionManager, cli_commands: dict[str, int], script_lines: list[str] | None = None)
65    def __init__(self, connection_manager: ConnectionManager, cli_commands: dict[str, int],
66                 script_lines: list[str] | None = None):
67        if "help" in cli_commands:
68            raise ValueError("'help' cannot be part of cli_commands, it is auto-inserted")
69        self.connection_manager = connection_manager
70        self.cli_commands = cli_commands
71        self.script_lines = deque(script_lines or [])
72        # (command, reply) journal of every SCRIPT-source response. The reliable way to observe
73        # scripted replies (they have no socket client to reply to) — used by the integration tests.
74        self.script_responses: list[tuple[str, str]] = []
connection_manager
cli_commands
script_lines
script_responses: list[tuple[str, str]]
@classmethod
def with_ascii( cls, host: str, port: int, cli_commands: dict[str, int], script_lines: list[str] | None = None, max_connections: int | None = None, journal_length: int | None = None, channel_queue_maxsize: int | None = None, socket_timeout_s: float | None = None) -> UICLIManager:
76    @classmethod
77    def with_ascii(cls, host: str, port: int, cli_commands: dict[str, int], script_lines: list[str] | None = None,
78                   max_connections: int | None = None, journal_length: int | None = None,
79                   channel_queue_maxsize: int | None = None, socket_timeout_s: float | None = None) -> UICLIManager:
80        """Convenience wrapper to instantiate UICLIManager with a TCP-based connection manager and ASCII codec"""
81        connection_manager = ConnectionManager(host, port, codec=ShlexASCIICodec(), max_connections=max_connections,
82                                               journal_length=journal_length, socket_timeout_s=socket_timeout_s,
83                                               channel_queue_maxsize=channel_queue_maxsize)
84        return UICLIManager(connection_manager, cli_commands, script_lines)

Convenience wrapper to instantiate UICLIManager with a TCP-based connection manager and ASCII codec

def start(self):
86    def start(self):
87        """Starts the underlying connection manager thread"""
88        self.connection_manager.start()

Starts the underlying connection manager thread

def get_cli_command(self) -> CLICommand | None:
 90    def get_cli_command(self) -> CLICommand | None:
 91        """
 92        Returns a command + its valid args if the CLI provided a valid command, None otherwise. No exceptions.
 93        Ran in main thread context.
 94        """
 95
 96        try:
 97            if len(self.script_lines) > 0: # get data from script lines first
 98                message = Message(source_type=SourceType.SCRIPT, data=self.script_lines.popleft())
 99            else:
100                # Note: this must be nowait so the UI doesn't lag as it's called from the main loop
101                message: Message = self.connection_manager.get_one_message()
102
103            cli_command, err = self._handle_one_message(message)
104            if err is not None:
105                logger.error(err)
106                self._send_response(message=message, data=err)
107                return None
108            return cli_command
109
110        except Empty:
111            return None

Returns a command + its valid args if the CLI provided a valid command, None otherwise. No exceptions. Ran in main thread context.