generated from daniil-berg/boilerplate-py
Fixed Python 3.8 compatibility bugs; classmethod+property workaround; control session buffer
This commit is contained in:
@ -97,21 +97,22 @@ class ControlClient(ABC):
|
||||
writer: The `asyncio.StreamWriter` returned by the `_open_connection()` method
|
||||
|
||||
Returns:
|
||||
`None`, if either `Ctrl+C` was hit, or the user wants the client to disconnect;
|
||||
otherwise, the user's input, stripped of leading and trailing spaces and converted to lowercase.
|
||||
`None`, if either `Ctrl+C` was hit, an empty or whitespace-only string was entered, or the user wants the
|
||||
client to disconnect; otherwise, returns the user's input, stripped of leading and trailing spaces and
|
||||
converted to lowercase.
|
||||
"""
|
||||
try:
|
||||
msg = input("> ").strip().lower()
|
||||
cmd = input("> ").strip().lower()
|
||||
except EOFError: # Ctrl+D shall be equivalent to the :const:`CLIENT_EXIT` command.
|
||||
msg = CLIENT_EXIT
|
||||
cmd = CLIENT_EXIT
|
||||
except KeyboardInterrupt: # Ctrl+C shall simply reset to the input prompt.
|
||||
print()
|
||||
return
|
||||
if msg == CLIENT_EXIT:
|
||||
if cmd == CLIENT_EXIT:
|
||||
writer.close()
|
||||
self._connected = False
|
||||
return
|
||||
return msg
|
||||
return cmd or None # will be None if `cmd` is an empty string
|
||||
|
||||
async def _interact(self, reader: StreamReader, writer: StreamWriter) -> None:
|
||||
"""
|
||||
|
@ -17,19 +17,21 @@ If not, see <https://www.gnu.org/licenses/>."""
|
||||
__doc__ = """
|
||||
Definition of the :class:`ControlParser` used in a
|
||||
:class:`ControlSession <asyncio_taskpool.control.session.ControlSession>`.
|
||||
|
||||
It should not be considered part of the public API.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from argparse import Action, ArgumentParser, ArgumentDefaultsHelpFormatter, HelpFormatter, ArgumentTypeError, SUPPRESS
|
||||
from ast import literal_eval
|
||||
from asyncio.streams import StreamWriter
|
||||
from inspect import Parameter, getmembers, isfunction, signature
|
||||
from io import StringIO
|
||||
from shutil import get_terminal_size
|
||||
from typing import Any, Callable, Container, Dict, Iterable, Set, Type, TypeVar
|
||||
|
||||
from ..exceptions import HelpRequested, ParserError
|
||||
from ..internals.constants import CLIENT_INFO, CMD, STREAM_WRITER
|
||||
from ..internals.constants import CLIENT_INFO, CMD
|
||||
from ..internals.helpers import get_first_doc_line, resolve_dotted_path
|
||||
from ..internals.types import ArgsT, CancelCB, CoroutineFunc, EndCB, KwArgsT
|
||||
|
||||
@ -52,8 +54,8 @@ class ControlParser(ArgumentParser):
|
||||
"""
|
||||
Subclass of the standard :code:`argparse.ArgumentParser` for pool control.
|
||||
|
||||
Such a parser is not supposed to ever print to stdout/stderr, but instead direct all messages to a `StreamWriter`
|
||||
instance passed to it during initialization.
|
||||
Such a parser is not supposed to ever print to stdout/stderr, but instead direct all messages to a file-like
|
||||
`StringIO` instance passed to it during initialization.
|
||||
Furthermore, it requires defining the width of the terminal, to adjust help formatting to the terminal size of a
|
||||
connected client.
|
||||
Finally, it offers some convenience methods and makes use of custom exceptions.
|
||||
@ -87,25 +89,23 @@ class ControlParser(ArgumentParser):
|
||||
super().__init__(*args, **kwargs)
|
||||
return ClientHelpFormatter
|
||||
|
||||
def __init__(self, stream_writer: StreamWriter, terminal_width: int = None, **kwargs) -> None:
|
||||
def __init__(self, stream: StringIO, terminal_width: int = None, **kwargs) -> None:
|
||||
"""
|
||||
Sets some internal attributes in addition to the base class.
|
||||
|
||||
Args:
|
||||
stream_writer:
|
||||
The instance of the :class:`asyncio.StreamWriter` to use for message output.
|
||||
stream:
|
||||
A file-like I/O object to use for message output.
|
||||
terminal_width (optional):
|
||||
The terminal width to use for all message formatting. By default the :code:`columns` attribute from
|
||||
:func:`shutil.get_terminal_size` is taken.
|
||||
**kwargs(optional):
|
||||
Passed to the parent class constructor. The exception is the `formatter_class` parameter: Even if a
|
||||
class is specified, it will always be subclassed in the :meth:`help_formatter_factory`.
|
||||
Also, by default, `exit_on_error` is set to `False` (as opposed to how the parent class handles it).
|
||||
"""
|
||||
self._stream_writer: StreamWriter = stream_writer
|
||||
self._stream: StringIO = stream
|
||||
self._terminal_width: int = terminal_width if terminal_width is not None else get_terminal_size().columns
|
||||
kwargs['formatter_class'] = self.help_formatter_factory(self._terminal_width, kwargs.get('formatter_class'))
|
||||
kwargs.setdefault('exit_on_error', False)
|
||||
super().__init__(**kwargs)
|
||||
self._flags: Set[str] = set()
|
||||
self._commands = None
|
||||
@ -194,7 +194,7 @@ class ControlParser(ArgumentParser):
|
||||
Dictionary mapping class member names to the (sub-)parsers created from them.
|
||||
"""
|
||||
parsers: ParsersDict = {}
|
||||
common_kwargs = {STREAM_WRITER: self._stream_writer, CLIENT_INFO.TERMINAL_WIDTH: self._terminal_width}
|
||||
common_kwargs = {'stream': self._stream, CLIENT_INFO.TERMINAL_WIDTH: self._terminal_width}
|
||||
for name, member in getmembers(cls):
|
||||
if name in omit_members or (name.startswith('_') and public_only):
|
||||
continue
|
||||
@ -214,9 +214,9 @@ class ControlParser(ArgumentParser):
|
||||
return self._commands
|
||||
|
||||
def _print_message(self, message: str, *args, **kwargs) -> None:
|
||||
"""This is overridden to ensure that no messages are sent to stdout/stderr, but always to the stream writer."""
|
||||
"""This is overridden to ensure that no messages are sent to stdout/stderr, but always to the stream buffer."""
|
||||
if message:
|
||||
self._stream_writer.write(message.encode())
|
||||
self._stream.write(message)
|
||||
|
||||
def exit(self, status: int = 0, message: str = None) -> None:
|
||||
"""This is overridden to prevent system exit to be invoked."""
|
||||
|
@ -31,6 +31,7 @@ from typing import Optional, Union
|
||||
from .client import ControlClient, TCPControlClient, UnixControlClient
|
||||
from .session import ControlSession
|
||||
from ..pool import AnyTaskPoolT
|
||||
from ..internals.helpers import classmethod
|
||||
from ..internals.types import ConnectedCallbackT, PathT
|
||||
|
||||
|
||||
|
@ -16,6 +16,8 @@ If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
Definition of the :class:`ControlSession` used by a :class:`ControlServer`.
|
||||
|
||||
It should not be considered part of the public API.
|
||||
"""
|
||||
|
||||
|
||||
@ -24,12 +26,13 @@ import json
|
||||
from argparse import ArgumentError
|
||||
from asyncio.streams import StreamReader, StreamWriter
|
||||
from inspect import isfunction, signature
|
||||
from io import StringIO
|
||||
from typing import Callable, Optional, Union, TYPE_CHECKING
|
||||
|
||||
from .parser import ControlParser
|
||||
from ..exceptions import CommandError, HelpRequested, ParserError
|
||||
from ..pool import TaskPool, SimpleTaskPool
|
||||
from ..internals.constants import CLIENT_INFO, CMD, CMD_OK, SESSION_MSG_BYTES, STREAM_WRITER
|
||||
from ..internals.constants import CLIENT_INFO, CMD, CMD_OK, SESSION_MSG_BYTES
|
||||
from ..internals.helpers import return_or_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -72,6 +75,7 @@ class ControlSession:
|
||||
self._reader: StreamReader = reader
|
||||
self._writer: StreamWriter = writer
|
||||
self._parser: Optional[ControlParser] = None
|
||||
self._response_buffer: StringIO = StringIO()
|
||||
|
||||
async def _exec_method_and_respond(self, method: Callable, **kwargs) -> None:
|
||||
"""
|
||||
@ -133,7 +137,7 @@ class ControlSession:
|
||||
client_info = json.loads((await self._reader.read(SESSION_MSG_BYTES)).decode().strip())
|
||||
log.debug("%s connected", self._client_class_name)
|
||||
parser_kwargs = {
|
||||
STREAM_WRITER: self._writer,
|
||||
'stream': self._response_buffer,
|
||||
CLIENT_INFO.TERMINAL_WIDTH: client_info[CLIENT_INFO.TERMINAL_WIDTH],
|
||||
'prog': '',
|
||||
'usage': f'[-h] [{CMD}] ...'
|
||||
@ -160,7 +164,7 @@ class ControlSession:
|
||||
kwargs = vars(self._parser.parse_args(msg.split(' ')))
|
||||
except ArgumentError as e:
|
||||
log.debug("%s got an ArgumentError", self._client_class_name)
|
||||
self._writer.write(str(e).encode())
|
||||
self._response_buffer.write(str(e))
|
||||
return
|
||||
except (HelpRequested, ParserError):
|
||||
log.debug("%s received usage help", self._client_class_name)
|
||||
@ -171,7 +175,7 @@ class ControlSession:
|
||||
elif isinstance(command, property):
|
||||
await self._exec_property_and_respond(command, **kwargs)
|
||||
else:
|
||||
self._writer.write(str(CommandError(f"Unknown command object: {command}")).encode())
|
||||
self._response_buffer.write(str(CommandError(f"Unknown command object: {command}")))
|
||||
|
||||
async def listen(self) -> None:
|
||||
"""
|
||||
@ -188,4 +192,8 @@ class ControlSession:
|
||||
log.debug("%s disconnected", self._client_class_name)
|
||||
break
|
||||
await self._parse_command(msg)
|
||||
response = self._response_buffer.getvalue()
|
||||
self._response_buffer.seek(0)
|
||||
self._response_buffer.truncate()
|
||||
self._writer.write(response.encode())
|
||||
await self._writer.drain()
|
||||
|
Reference in New Issue
Block a user