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")))
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.
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.
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.)
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
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]] = []
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
86 def start(self): 87 """Starts the underlying connection manager thread""" 88 self.connection_manager.start()
Starts the underlying connection manager thread
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.