generated from daniil-berg/boilerplate-py
moved control-related modules to a sub-package; minor corrections
This commit is contained in:
0
src/asyncio_taskpool/control/__init__.py
Normal file
0
src/asyncio_taskpool/control/__init__.py
Normal file
78
src/asyncio_taskpool/control/__main__.py
Normal file
78
src/asyncio_taskpool/control/__main__.py
Normal file
@ -0,0 +1,78 @@
|
||||
__author__ = "Daniil Fajnberg"
|
||||
__copyright__ = "Copyright © 2022 Daniil Fajnberg"
|
||||
__license__ = """GNU LGPLv3.0
|
||||
|
||||
This file is part of asyncio-taskpool.
|
||||
|
||||
asyncio-taskpool is free software: you can redistribute it and/or modify it under the terms of
|
||||
version 3.0 of the GNU Lesser General Public License as published by the Free Software Foundation.
|
||||
|
||||
asyncio-taskpool is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along with asyncio-taskpool.
|
||||
If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
CLI client entry point.
|
||||
"""
|
||||
|
||||
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from asyncio import run
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from ..constants import PACKAGE_NAME
|
||||
from ..pool import TaskPool
|
||||
from .client import ControlClient, TCPControlClient, UnixControlClient
|
||||
from .server import TCPControlServer, UnixControlServer
|
||||
|
||||
|
||||
CONN_TYPE = 'conn_type'
|
||||
UNIX, TCP = 'unix', 'tcp'
|
||||
SOCKET_PATH = 'path'
|
||||
HOST, PORT = 'host', 'port'
|
||||
|
||||
|
||||
def parse_cli() -> Dict[str, Any]:
|
||||
parser = ArgumentParser(
|
||||
prog=PACKAGE_NAME,
|
||||
description=f"CLI based {ControlClient.__name__} for {PACKAGE_NAME}"
|
||||
)
|
||||
subparsers = parser.add_subparsers(title="Connection types", dest=CONN_TYPE)
|
||||
unix_parser = subparsers.add_parser(UNIX, help="Connect via unix socket")
|
||||
unix_parser.add_argument(
|
||||
SOCKET_PATH,
|
||||
type=Path,
|
||||
help=f"Path to the unix socket on which the {UnixControlServer.__name__} for the {TaskPool.__name__} is "
|
||||
f"listening."
|
||||
)
|
||||
tcp_parser = subparsers.add_parser(TCP, help="Connect via TCP socket")
|
||||
tcp_parser.add_argument(
|
||||
HOST,
|
||||
help=f"IP address or url that the {TCPControlServer.__name__} for the {TaskPool.__name__} is listening on."
|
||||
)
|
||||
tcp_parser.add_argument(
|
||||
PORT,
|
||||
type=int,
|
||||
help=f"Port that the {TCPControlServer.__name__} for the {TaskPool.__name__} is listening on."
|
||||
)
|
||||
return vars(parser.parse_args())
|
||||
|
||||
|
||||
async def main():
|
||||
kwargs = parse_cli()
|
||||
if kwargs[CONN_TYPE] == UNIX:
|
||||
client = UnixControlClient(socket_path=kwargs[SOCKET_PATH])
|
||||
elif kwargs[CONN_TYPE] == TCP:
|
||||
client = TCPControlClient(host=kwargs[HOST], port=kwargs[PORT])
|
||||
else:
|
||||
print("Invalid connection type", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
await client.start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(main())
|
191
src/asyncio_taskpool/control/client.py
Normal file
191
src/asyncio_taskpool/control/client.py
Normal file
@ -0,0 +1,191 @@
|
||||
__author__ = "Daniil Fajnberg"
|
||||
__copyright__ = "Copyright © 2022 Daniil Fajnberg"
|
||||
__license__ = """GNU LGPLv3.0
|
||||
|
||||
This file is part of asyncio-taskpool.
|
||||
|
||||
asyncio-taskpool is free software: you can redistribute it and/or modify it under the terms of
|
||||
version 3.0 of the GNU Lesser General Public License as published by the Free Software Foundation.
|
||||
|
||||
asyncio-taskpool is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along with asyncio-taskpool.
|
||||
If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
Classes of control clients for a simply interface to a task pool control server.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio.streams import StreamReader, StreamWriter, open_connection
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from ..constants import CLIENT_EXIT, CLIENT_INFO, SESSION_MSG_BYTES
|
||||
from ..types import ClientConnT, PathT
|
||||
|
||||
|
||||
class ControlClient(ABC):
|
||||
"""
|
||||
Abstract base class for a simple implementation of a task pool control client.
|
||||
|
||||
Since the server's control interface is simply expecting commands to be sent, any process able to connect to the
|
||||
TCP or UNIX socket and issue the relevant commands (and optionally read the responses) will work just as well.
|
||||
This is a minimal working implementation.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def client_info() -> dict:
|
||||
"""Returns a dictionary of client information relevant for the handshake with the server."""
|
||||
return {CLIENT_INFO.TERMINAL_WIDTH: shutil.get_terminal_size().columns}
|
||||
|
||||
@abstractmethod
|
||||
async def _open_connection(self, **kwargs) -> ClientConnT:
|
||||
"""
|
||||
Tries to connect to a socket using the provided arguments and return the associated reader-writer-pair.
|
||||
|
||||
This method will be invoked by the public `start()` method with the pre-defined internal `_conn_kwargs`
|
||||
(unpacked) as keyword-arguments.
|
||||
This method should return either a tuple of `asyncio.StreamReader` and `asyncio.StreamWriter` or a tuple of
|
||||
`None` and `None`, if it failed to establish the defined connection.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(self, **conn_kwargs) -> None:
|
||||
"""Simply stores the connection keyword-arguments necessary for opening the connection."""
|
||||
self._conn_kwargs = conn_kwargs
|
||||
self._connected: bool = False
|
||||
|
||||
async def _server_handshake(self, reader: StreamReader, writer: StreamWriter) -> None:
|
||||
"""
|
||||
Performs the first interaction with the server providing it with the necessary client information.
|
||||
|
||||
Upon completion, the server's info is printed.
|
||||
|
||||
Args:
|
||||
reader: The `asyncio.StreamReader` returned by the `_open_connection()` method
|
||||
writer: The `asyncio.StreamWriter` returned by the `_open_connection()` method
|
||||
"""
|
||||
self._connected = True
|
||||
writer.write(json.dumps(self.client_info()).encode())
|
||||
await writer.drain()
|
||||
print("Connected to", (await reader.read(SESSION_MSG_BYTES)).decode())
|
||||
|
||||
def _get_command(self, writer: StreamWriter) -> Optional[str]:
|
||||
"""
|
||||
Prompts the user for input and either returns it (after cleaning it up) or `None` in special cases.
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
msg = input("> ").strip().lower()
|
||||
except EOFError: # Ctrl+D shall be equivalent to the `CLIENT_EXIT` command.
|
||||
msg = CLIENT_EXIT
|
||||
except KeyboardInterrupt: # Ctrl+C shall simply reset to the input prompt.
|
||||
print()
|
||||
return
|
||||
if msg == CLIENT_EXIT:
|
||||
writer.close()
|
||||
self._connected = False
|
||||
return
|
||||
return msg
|
||||
|
||||
async def _interact(self, reader: StreamReader, writer: StreamWriter) -> None:
|
||||
"""
|
||||
Reacts to the user's command, potentially performing a back-and-forth interaction with the server.
|
||||
|
||||
If `_get_command` returns `None`, this may imply that the client disconnected, but may also just be `Ctrl+C`.
|
||||
If an actual command is retrieved, it is written to the stream, a response is awaited and eventually printed.
|
||||
|
||||
Args:
|
||||
reader: The `asyncio.StreamReader` returned by the `_open_connection()` method
|
||||
writer: The `asyncio.StreamWriter` returned by the `_open_connection()` method
|
||||
"""
|
||||
cmd = self._get_command(writer)
|
||||
if cmd is None:
|
||||
return
|
||||
try:
|
||||
# Send the command to the server.
|
||||
writer.write(cmd.encode())
|
||||
await writer.drain()
|
||||
except ConnectionError as e:
|
||||
self._connected = False
|
||||
print(e, file=sys.stderr)
|
||||
return
|
||||
# Await the server's response, then print it.
|
||||
print((await reader.read(SESSION_MSG_BYTES)).decode())
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
This method opens the pre-defined connection, performs the server-handshake, and enters the interaction loop.
|
||||
|
||||
If the connection can not be established, an error message is printed to `stderr` and the method returns.
|
||||
If the `_connected` flag is set to `False` during the interaction loop, the method returns and prints out a
|
||||
disconnected-message.
|
||||
"""
|
||||
reader, writer = await self._open_connection(**self._conn_kwargs)
|
||||
if reader is None:
|
||||
print("Failed to connect.", file=sys.stderr)
|
||||
return
|
||||
await self._server_handshake(reader, writer)
|
||||
while self._connected:
|
||||
await self._interact(reader, writer)
|
||||
print("Disconnected from control server.")
|
||||
|
||||
|
||||
class TCPControlClient(ControlClient):
|
||||
"""Task pool control client that expects a TCP socket to be exposed by the control server."""
|
||||
|
||||
def __init__(self, host: str, port: Union[int, str], **conn_kwargs) -> None:
|
||||
"""In addition to what the base class does, `host` and `port` are expected as non-optional arguments."""
|
||||
self._host = host
|
||||
self._port = port
|
||||
super().__init__(**conn_kwargs)
|
||||
|
||||
async def _open_connection(self, **kwargs) -> ClientConnT:
|
||||
"""
|
||||
Wrapper around the `asyncio.open_connection` function.
|
||||
|
||||
Returns a tuple of `None` and `None`, if the connection can not be established;
|
||||
otherwise, the stream-reader and -writer tuple is returned.
|
||||
"""
|
||||
try:
|
||||
return await open_connection(self._host, self._port, **kwargs)
|
||||
except ConnectionError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return None, None
|
||||
|
||||
|
||||
class UnixControlClient(ControlClient):
|
||||
"""Task pool control client that expects a unix socket to be exposed by the control server."""
|
||||
|
||||
def __init__(self, socket_path: PathT, **conn_kwargs) -> None:
|
||||
"""In addition to what the base class does, the `socket_path` is expected as a non-optional argument."""
|
||||
from asyncio.streams import open_unix_connection
|
||||
self._open_unix_connection = open_unix_connection
|
||||
self._socket_path = Path(socket_path)
|
||||
super().__init__(**conn_kwargs)
|
||||
|
||||
async def _open_connection(self, **kwargs) -> ClientConnT:
|
||||
"""
|
||||
Wrapper around the `asyncio.open_unix_connection` function.
|
||||
|
||||
Returns a tuple of `None` and `None`, if the socket is not found at the pre-defined path;
|
||||
otherwise, the stream-reader and -writer tuple is returned.
|
||||
"""
|
||||
try:
|
||||
return await self._open_unix_connection(self._socket_path, **kwargs)
|
||||
except FileNotFoundError:
|
||||
print("No socket at", self._socket_path, file=sys.stderr)
|
||||
return None, None
|
299
src/asyncio_taskpool/control/parser.py
Normal file
299
src/asyncio_taskpool/control/parser.py
Normal file
@ -0,0 +1,299 @@
|
||||
__author__ = "Daniil Fajnberg"
|
||||
__copyright__ = "Copyright © 2022 Daniil Fajnberg"
|
||||
__license__ = """GNU LGPLv3.0
|
||||
|
||||
This file is part of asyncio-taskpool.
|
||||
|
||||
asyncio-taskpool is free software: you can redistribute it and/or modify it under the terms of
|
||||
version 3.0 of the GNU Lesser General Public License as published by the Free Software Foundation.
|
||||
|
||||
asyncio-taskpool is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along with asyncio-taskpool.
|
||||
If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
This module contains the the definition of the `ControlParser` class used by a control server.
|
||||
"""
|
||||
|
||||
|
||||
from argparse import Action, ArgumentParser, ArgumentDefaultsHelpFormatter, HelpFormatter, SUPPRESS
|
||||
from asyncio.streams import StreamWriter
|
||||
from inspect import Parameter, getmembers, isfunction, signature
|
||||
from shutil import get_terminal_size
|
||||
from typing import Callable, Container, Dict, Set, Type, TypeVar
|
||||
|
||||
from ..constants import CLIENT_INFO, CMD, STREAM_WRITER
|
||||
from ..exceptions import HelpRequested
|
||||
from ..helpers import get_first_doc_line
|
||||
|
||||
|
||||
FmtCls = TypeVar('FmtCls', bound=Type[HelpFormatter])
|
||||
ParsersDict = Dict[str, 'ControlParser']
|
||||
|
||||
OMIT_PARAMS_DEFAULT = ('self', )
|
||||
|
||||
FORMATTER_CLASS = 'formatter_class'
|
||||
NAME, PROG, HELP, DESCRIPTION = 'name', 'prog', 'help', 'description'
|
||||
|
||||
|
||||
class ControlParser(ArgumentParser):
|
||||
"""
|
||||
Subclass of the standard `argparse.ArgumentParser` for remote interaction.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def help_formatter_factory(terminal_width: int, base_cls: FmtCls = None) -> FmtCls:
|
||||
"""
|
||||
Constructs and returns a subclass of `argparse.HelpFormatter` with a fixed terminal width argument.
|
||||
|
||||
Although a custom formatter class can be explicitly passed into the `ArgumentParser` constructor, this is not
|
||||
as convenient, when making use of sub-parsers.
|
||||
|
||||
Args:
|
||||
terminal_width:
|
||||
The number of columns of the terminal to which to adjust help formatting.
|
||||
base_cls (optional):
|
||||
The base class to use for inheritance. By default `argparse.ArgumentDefaultsHelpFormatter` is used.
|
||||
|
||||
Returns:
|
||||
The subclass of `base_cls` which fixes the constructor's `width` keyword-argument to `terminal_width`.
|
||||
"""
|
||||
if base_cls is None:
|
||||
base_cls = ArgumentDefaultsHelpFormatter
|
||||
|
||||
class ClientHelpFormatter(base_cls):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
kwargs['width'] = terminal_width
|
||||
super().__init__(*args, **kwargs)
|
||||
return ClientHelpFormatter
|
||||
|
||||
def __init__(self, stream_writer: StreamWriter, terminal_width: int = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
Sets additional internal attributes depending on whether a parent-parser was defined.
|
||||
|
||||
The `help_formatter_factory` is called and the returned class is mapped to the `FORMATTER_CLASS` keyword.
|
||||
By default, `exit_on_error` is set to `False` (as opposed to how the parent class handles it).
|
||||
|
||||
Args:
|
||||
stream_writer:
|
||||
The instance of the `asyncio.StreamWriter` to use for message output.
|
||||
terminal_width (optional):
|
||||
The terminal width to assume for all message formatting. Defaults to `shutil.get_terminal_size`.
|
||||
**kwargs(optional):
|
||||
In addition to the regular `ArgumentParser` constructor parameters, this method expects the instance of
|
||||
the `StreamWriter` as well as the terminal width both to be passed explicitly, if the `parent` argument
|
||||
is empty.
|
||||
"""
|
||||
self._stream_writer: StreamWriter = stream_writer
|
||||
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
|
||||
|
||||
def add_function_command(self, function: Callable, omit_params: Container[str] = OMIT_PARAMS_DEFAULT,
|
||||
**subparser_kwargs) -> 'ControlParser':
|
||||
"""
|
||||
Takes a function along with its parameters and adds a corresponding (sub-)command to the parser.
|
||||
|
||||
The `add_subparsers` method must have been called prior to this.
|
||||
|
||||
NOTE: Currently, only a limited spectrum of parameters can be accurately converted to a parser argument.
|
||||
This method works correctly with any public method of the `SimpleTaskPool` class.
|
||||
|
||||
Args:
|
||||
function:
|
||||
The reference to the function to be "converted" to a parser command.
|
||||
omit_params (optional):
|
||||
Names of function parameters not to add as parser arguments.
|
||||
**subparser_kwargs (optional):
|
||||
Passed directly to the `add_parser` method.
|
||||
|
||||
Returns:
|
||||
The subparser instance created from the function.
|
||||
"""
|
||||
subparser_kwargs.setdefault(NAME, function.__name__.replace('_', '-'))
|
||||
subparser_kwargs.setdefault(PROG, subparser_kwargs[NAME])
|
||||
subparser_kwargs.setdefault(HELP, get_first_doc_line(function))
|
||||
subparser_kwargs.setdefault(DESCRIPTION, subparser_kwargs[HELP])
|
||||
subparser: ControlParser = self._commands.add_parser(**subparser_kwargs)
|
||||
subparser.add_function_args(function, omit_params)
|
||||
return subparser
|
||||
|
||||
def add_property_command(self, prop: property, cls_name: str = '', **subparser_kwargs) -> 'ControlParser':
|
||||
"""
|
||||
Same as the `add_function_command` method, but for properties.
|
||||
|
||||
Args:
|
||||
prop:
|
||||
The reference to the property to be "converted" to a parser command.
|
||||
cls_name (optional):
|
||||
Name of the class the property is defined on to appear in the command help text.
|
||||
**subparser_kwargs (optional):
|
||||
Passed directly to the `add_parser` method.
|
||||
|
||||
Returns:
|
||||
The subparser instance created from the property.
|
||||
"""
|
||||
subparser_kwargs.setdefault(NAME, prop.fget.__name__.replace('_', '-'))
|
||||
subparser_kwargs.setdefault(PROG, subparser_kwargs[NAME])
|
||||
getter_help = get_first_doc_line(prop.fget)
|
||||
if prop.fset is None:
|
||||
subparser_kwargs.setdefault(HELP, getter_help)
|
||||
else:
|
||||
subparser_kwargs.setdefault(HELP, f"Get/set the `{cls_name}.{subparser_kwargs[NAME]}` property")
|
||||
subparser_kwargs.setdefault(DESCRIPTION, subparser_kwargs[HELP])
|
||||
subparser: ControlParser = self._commands.add_parser(**subparser_kwargs)
|
||||
if prop.fset is not None:
|
||||
_, param = signature(prop.fset).parameters.values()
|
||||
setter_arg_help = f"If provided: {get_first_doc_line(prop.fset)} If omitted: {getter_help}"
|
||||
subparser.add_function_arg(param, nargs='?', default=SUPPRESS, help=setter_arg_help)
|
||||
return subparser
|
||||
|
||||
def add_class_commands(self, cls: Type, public_only: bool = True, omit_members: Container[str] = (),
|
||||
member_arg_name: str = CMD) -> ParsersDict:
|
||||
"""
|
||||
Takes a class and adds its methods and properties as (sub-)commands to the parser.
|
||||
|
||||
The `add_subparsers` method must have been called prior to this.
|
||||
|
||||
NOTE: Currently, only a limited spectrum of function parameters can be accurately converted to parser arguments.
|
||||
This method works correctly with the `SimpleTaskPool` class.
|
||||
|
||||
Args:
|
||||
cls:
|
||||
The reference to the class whose methods/properties are to be "converted" to parser commands.
|
||||
public_only (optional):
|
||||
If `False`, protected and private members are considered as well. `True` by default.
|
||||
omit_members (optional):
|
||||
Names of functions/properties not to add as parser commands.
|
||||
member_arg_name (optional):
|
||||
After parsing the arguments, depending on which command was invoked by the user, the corresponding
|
||||
method/property will be stored as an extra argument in the parsed namespace under this attribute name.
|
||||
Defaults to `constants.CMD`.
|
||||
|
||||
Returns:
|
||||
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}
|
||||
for name, member in getmembers(cls):
|
||||
if name in omit_members or (name.startswith('_') and public_only):
|
||||
continue
|
||||
if isfunction(member):
|
||||
subparser = self.add_function_command(member, **common_kwargs)
|
||||
elif isinstance(member, property):
|
||||
subparser = self.add_property_command(member, cls.__name__, **common_kwargs)
|
||||
else:
|
||||
continue
|
||||
subparser.set_defaults(**{member_arg_name: member})
|
||||
parsers[name] = subparser
|
||||
return parsers
|
||||
|
||||
def add_subparsers(self, *args, **kwargs):
|
||||
"""Adds the subparsers action as an internal attribute before returning it."""
|
||||
self._commands = super().add_subparsers(*args, **kwargs)
|
||||
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."""
|
||||
if message:
|
||||
self._stream_writer.write(message.encode())
|
||||
|
||||
def exit(self, status: int = 0, message: str = None) -> None:
|
||||
"""This is overridden to prevent system exit to be invoked."""
|
||||
if message:
|
||||
self._print_message(message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
"""This just adds the custom `HelpRequested` exception after the parent class' method."""
|
||||
super().error(message=message)
|
||||
raise HelpRequested
|
||||
|
||||
def print_help(self, file=None) -> None:
|
||||
"""This just adds the custom `HelpRequested` exception after the parent class' method."""
|
||||
super().print_help(file)
|
||||
raise HelpRequested
|
||||
|
||||
def add_function_arg(self, parameter: Parameter, **kwargs) -> Action:
|
||||
"""
|
||||
Takes an `inspect.Parameter` of a function and adds a corresponding argument to the parser.
|
||||
|
||||
NOTE: Currently, only a limited spectrum of parameters can be accurately converted to a parser argument.
|
||||
This method works correctly with any parameter of any public method of the `SimpleTaskPool` class.
|
||||
|
||||
Args:
|
||||
parameter: The `inspect.Parameter` object to be converted to a parser argument.
|
||||
**kwargs: Passed to the `add_argument` method of the base class.
|
||||
|
||||
Returns:
|
||||
The `argparse.Action` returned by the `add_argument` method.
|
||||
"""
|
||||
if parameter.default is Parameter.empty:
|
||||
# A non-optional function parameter should correspond to a positional argument.
|
||||
name_or_flags = [parameter.name]
|
||||
else:
|
||||
flag = None
|
||||
long = f'--{parameter.name.replace("_", "-")}'
|
||||
# We try to generate a short version (flag) for the argument.
|
||||
letter = parameter.name[0]
|
||||
if letter not in self._flags:
|
||||
flag = f'-{letter}'
|
||||
self._flags.add(letter)
|
||||
elif letter.upper() not in self._flags:
|
||||
flag = f'-{letter.upper()}'
|
||||
self._flags.add(letter.upper())
|
||||
name_or_flags = [long] if flag is None else [flag, long]
|
||||
if parameter.annotation is bool:
|
||||
# If we are dealing with a boolean parameter, always use the 'store_true' action.
|
||||
# Even if the parameter's default value is `True`, this will make the parser argument's default `False`.
|
||||
kwargs.setdefault('action', 'store_true')
|
||||
else:
|
||||
# For now, any other type annotation will implicitly use the default action 'store'.
|
||||
# In addition, we always set the default value.
|
||||
kwargs.setdefault('default', parameter.default)
|
||||
if parameter.kind == Parameter.VAR_POSITIONAL:
|
||||
# This is to be able to later unpack an arbitrary number of positional arguments.
|
||||
kwargs.setdefault('nargs', '*')
|
||||
if not kwargs.get('action') == 'store_true':
|
||||
# The lambda wrapper around the type annotation is to avoid ValueError being raised on suppressed arguments.
|
||||
# See: https://bugs.python.org/issue36078
|
||||
kwargs.setdefault('type', get_arg_type_wrapper(parameter.annotation))
|
||||
return self.add_argument(*name_or_flags, **kwargs)
|
||||
|
||||
def add_function_args(self, function: Callable, omit: Container[str] = OMIT_PARAMS_DEFAULT) -> None:
|
||||
"""
|
||||
Takes a function reference and adds its parameters as arguments to the parser.
|
||||
|
||||
NOTE: Currently, only a limited spectrum of parameters can be accurately converted to a parser argument.
|
||||
This method works correctly with any public method of the `SimpleTaskPool` class.
|
||||
|
||||
Args:
|
||||
function:
|
||||
The function whose parameters are to be converted to parser arguments.
|
||||
Its parameters must be properly annotated.
|
||||
omit (optional):
|
||||
Names of function parameters not to add as parser arguments.
|
||||
"""
|
||||
for param in signature(function).parameters.values():
|
||||
if param.name not in omit:
|
||||
# TODO: Look into parsing docstrings properly to try and extract argument help text.
|
||||
# For now, the argument help just shows the type it will be converted to.
|
||||
self.add_function_arg(param, help=repr(param.annotation))
|
||||
|
||||
|
||||
def get_arg_type_wrapper(cls: Type) -> Callable:
|
||||
def wrapper(arg):
|
||||
return arg if arg is SUPPRESS else cls(arg)
|
||||
return wrapper
|
171
src/asyncio_taskpool/control/server.py
Normal file
171
src/asyncio_taskpool/control/server.py
Normal file
@ -0,0 +1,171 @@
|
||||
__author__ = "Daniil Fajnberg"
|
||||
__copyright__ = "Copyright © 2022 Daniil Fajnberg"
|
||||
__license__ = """GNU LGPLv3.0
|
||||
|
||||
This file is part of asyncio-taskpool.
|
||||
|
||||
asyncio-taskpool is free software: you can redistribute it and/or modify it under the terms of
|
||||
version 3.0 of the GNU Lesser General Public License as published by the Free Software Foundation.
|
||||
|
||||
asyncio-taskpool is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along with asyncio-taskpool.
|
||||
If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
This module contains the task pool control server class definitions.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio import AbstractServer
|
||||
from asyncio.exceptions import CancelledError
|
||||
from asyncio.streams import StreamReader, StreamWriter, start_server
|
||||
from asyncio.tasks import Task, create_task
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from ..pool import TaskPool, SimpleTaskPool
|
||||
from ..types import ConnectedCallbackT
|
||||
from .client import ControlClient, TCPControlClient, UnixControlClient
|
||||
from .session import ControlSession
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ControlServer(ABC): # TODO: Implement interface for normal TaskPool instances, not just SimpleTaskPool
|
||||
"""
|
||||
Abstract base class for a task pool control server.
|
||||
|
||||
This class acts as a wrapper around an async server instance and initializes a `ControlSession` upon a client
|
||||
connecting to it. The entire interface is defined within that session class.
|
||||
"""
|
||||
_client_class = ControlClient
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def client_class_name(cls) -> str:
|
||||
"""Returns the name of the control client class matching the server class."""
|
||||
return cls._client_class.__name__
|
||||
|
||||
@abstractmethod
|
||||
async def _get_server_instance(self, client_connected_cb: ConnectedCallbackT, **kwargs) -> AbstractServer:
|
||||
"""
|
||||
Initializes, starts, and returns an async server instance (Unix or TCP type).
|
||||
|
||||
Args:
|
||||
client_connected_cb:
|
||||
The callback for when a client connects to the server (as per `asyncio.start_server` or
|
||||
`asyncio.start_unix_server`); will always be the internal `_client_connected_cb` method.
|
||||
**kwargs (optional):
|
||||
Keyword arguments to pass into the function that starts the server.
|
||||
|
||||
Returns:
|
||||
The running server object (a type of `asyncio.Server`).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _final_callback(self) -> None:
|
||||
"""The method to run after the server's `serve_forever` methods ends for whatever reason."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(self, pool: Union[TaskPool, SimpleTaskPool], **server_kwargs) -> None:
|
||||
"""
|
||||
Initializes by merely saving the internal attributes, but without starting the server yet.
|
||||
The task pool must be passed here and can not be set/changed afterwards. This means a control server is always
|
||||
tied to one specific task pool.
|
||||
|
||||
Args:
|
||||
pool:
|
||||
An instance of a `BaseTaskPool` subclass to tie the server to.
|
||||
**server_kwargs (optional):
|
||||
Keyword arguments that will be passed into the function that starts the server.
|
||||
"""
|
||||
self._pool: Union[TaskPool, SimpleTaskPool] = pool
|
||||
self._server_kwargs = server_kwargs
|
||||
self._server: Optional[AbstractServer] = None
|
||||
|
||||
@property
|
||||
def pool(self) -> Union[TaskPool, SimpleTaskPool]:
|
||||
"""Read-only property for accessing the task pool instance controlled by the server."""
|
||||
return self._pool
|
||||
|
||||
def is_serving(self) -> bool:
|
||||
"""Wrapper around the `asyncio.Server.is_serving` method."""
|
||||
return self._server.is_serving()
|
||||
|
||||
async def _client_connected_cb(self, reader: StreamReader, writer: StreamWriter) -> None:
|
||||
"""
|
||||
The universal client callback that will be passed into the `_get_server_instance` method.
|
||||
Instantiates a control session, performs the client handshake, and enters the session's `listen` loop.
|
||||
"""
|
||||
session = ControlSession(self, reader, writer)
|
||||
await session.client_handshake()
|
||||
await session.listen()
|
||||
|
||||
async def _serve_forever(self) -> None:
|
||||
"""
|
||||
To be run as an `asyncio.Task` by the following method.
|
||||
Serves as a wrapper around the the `asyncio.Server.serve_forever` method that ensures that the `_final_callback`
|
||||
method is called, when the former method ends for whatever reason.
|
||||
"""
|
||||
try:
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
except CancelledError:
|
||||
log.debug("%s stopped", self.__class__.__name__)
|
||||
finally:
|
||||
self._final_callback()
|
||||
|
||||
async def serve_forever(self) -> Task:
|
||||
"""
|
||||
This method actually starts the server and begins listening to client connections on the specified interface.
|
||||
It should never block because the serving will be performed in a separate task.
|
||||
"""
|
||||
log.debug("Starting %s...", self.__class__.__name__)
|
||||
self._server = await self._get_server_instance(self._client_connected_cb, **self._server_kwargs)
|
||||
return create_task(self._serve_forever())
|
||||
|
||||
|
||||
class TCPControlServer(ControlServer):
|
||||
"""Task pool control server class that exposes a TCP socket for control clients to connect to."""
|
||||
_client_class = TCPControlClient
|
||||
|
||||
def __init__(self, pool: SimpleTaskPool, **server_kwargs) -> None:
|
||||
self._host = server_kwargs.pop('host')
|
||||
self._port = server_kwargs.pop('port')
|
||||
super().__init__(pool, **server_kwargs)
|
||||
|
||||
async def _get_server_instance(self, client_connected_cb: ConnectedCallbackT, **kwargs) -> AbstractServer:
|
||||
server = await start_server(client_connected_cb, self._host, self._port, **kwargs)
|
||||
log.debug("Opened socket at %s:%s", self._host, self._port)
|
||||
return server
|
||||
|
||||
def _final_callback(self) -> None:
|
||||
log.debug("Closed socket at %s:%s", self._host, self._port)
|
||||
|
||||
|
||||
class UnixControlServer(ControlServer):
|
||||
"""Task pool control server class that exposes a unix socket for control clients to connect to."""
|
||||
_client_class = UnixControlClient
|
||||
|
||||
def __init__(self, pool: SimpleTaskPool, **server_kwargs) -> None:
|
||||
from asyncio.streams import start_unix_server
|
||||
self._start_unix_server = start_unix_server
|
||||
self._socket_path = Path(server_kwargs.pop('path'))
|
||||
super().__init__(pool, **server_kwargs)
|
||||
|
||||
async def _get_server_instance(self, client_connected_cb: ConnectedCallbackT, **kwargs) -> AbstractServer:
|
||||
server = await self._start_unix_server(client_connected_cb, self._socket_path, **kwargs)
|
||||
log.debug("Opened socket '%s'", str(self._socket_path))
|
||||
return server
|
||||
|
||||
def _final_callback(self) -> None:
|
||||
"""Removes the unix socket on which the server was listening."""
|
||||
self._socket_path.unlink()
|
||||
log.debug("Removed socket '%s'", str(self._socket_path))
|
181
src/asyncio_taskpool/control/session.py
Normal file
181
src/asyncio_taskpool/control/session.py
Normal file
@ -0,0 +1,181 @@
|
||||
__author__ = "Daniil Fajnberg"
|
||||
__copyright__ = "Copyright © 2022 Daniil Fajnberg"
|
||||
__license__ = """GNU LGPLv3.0
|
||||
|
||||
This file is part of asyncio-taskpool.
|
||||
|
||||
asyncio-taskpool is free software: you can redistribute it and/or modify it under the terms of
|
||||
version 3.0 of the GNU Lesser General Public License as published by the Free Software Foundation.
|
||||
|
||||
asyncio-taskpool is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along with asyncio-taskpool.
|
||||
If not, see <https://www.gnu.org/licenses/>."""
|
||||
|
||||
__doc__ = """
|
||||
This module contains the the definition of the `ControlSession` class used by the control server.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
import json
|
||||
from argparse import ArgumentError
|
||||
from asyncio.streams import StreamReader, StreamWriter
|
||||
from inspect import isfunction, signature
|
||||
from typing import Callable, Optional, Union, TYPE_CHECKING
|
||||
|
||||
from ..constants import CLIENT_INFO, CMD, CMD_OK, SESSION_MSG_BYTES, STREAM_WRITER
|
||||
from ..exceptions import HelpRequested
|
||||
from ..helpers import return_or_exception
|
||||
from ..pool import TaskPool, SimpleTaskPool
|
||||
from .parser import ControlParser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import ControlServer
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ControlSession:
|
||||
"""
|
||||
This class defines the API for controlling a task pool instance from the outside.
|
||||
|
||||
The commands received from a connected client are translated into method calls on the task pool instance.
|
||||
A subclass of the standard `argparse.ArgumentParser` is used to handle the input read from the stream.
|
||||
"""
|
||||
|
||||
def __init__(self, server: 'ControlServer', reader: StreamReader, writer: StreamWriter) -> None:
|
||||
"""
|
||||
Instantiation should happen once a client connection to the control server has already been established.
|
||||
|
||||
For more convenient/efficient access, some of the server's properties are saved in separate attributes.
|
||||
The argument parser is _not_ instantiated in the constructor. It requires a bit of client information during
|
||||
initialization, which is obtained in the `client_handshake` method; only there is the parser fully configured.
|
||||
|
||||
Args:
|
||||
server:
|
||||
The instance of a `ControlServer` subclass starting the session.
|
||||
reader:
|
||||
The `asyncio.StreamReader` created when a client connected to the server.
|
||||
writer:
|
||||
The `asyncio.StreamWriter` created when a client connected to the server.
|
||||
"""
|
||||
self._control_server: 'ControlServer' = server
|
||||
self._pool: Union[TaskPool, SimpleTaskPool] = server.pool
|
||||
self._client_class_name = server.client_class_name
|
||||
self._reader: StreamReader = reader
|
||||
self._writer: StreamWriter = writer
|
||||
self._parser: Optional[ControlParser] = None
|
||||
|
||||
async def _exec_method_and_respond(self, method: Callable, **kwargs) -> None:
|
||||
"""
|
||||
Takes a pool method reference, executes it, and writes a response accordingly.
|
||||
|
||||
If the first parameter is named `self`, the method will be called with the `_pool` instance as its first
|
||||
positional argument. If it returns nothing, the response upon successful execution will be `constants.CMD_OK`,
|
||||
otherwise the response written to the stream will be its return value (as an encoded string).
|
||||
|
||||
Args:
|
||||
prop:
|
||||
The reference to the method defined on the `_pool` instance's class.
|
||||
**kwargs (optional):
|
||||
Must correspond to the arguments expected by the `method`.
|
||||
Correctly unpacks arbitrary-length positional and keyword-arguments.
|
||||
"""
|
||||
log.warning("%s calls %s.%s", self._client_class_name, self._pool.__class__.__name__, method.__name__)
|
||||
normal_pos, var_pos = [], []
|
||||
for param in signature(method).parameters.values():
|
||||
if param.name == 'self':
|
||||
normal_pos.append(self._pool)
|
||||
elif param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY):
|
||||
normal_pos.append(kwargs.pop(param.name))
|
||||
elif param.kind == param.VAR_POSITIONAL:
|
||||
var_pos = kwargs.pop(param.name)
|
||||
output = await return_or_exception(method, *normal_pos, *var_pos, **kwargs)
|
||||
self._writer.write(CMD_OK if output is None else str(output).encode())
|
||||
|
||||
async def _exec_property_and_respond(self, prop: property, **kwargs) -> None:
|
||||
"""
|
||||
Takes a pool property reference, executes its setter or getter, and writes a response accordingly.
|
||||
|
||||
The property set/get method will always be called with the `_pool` instance as its first positional argument.
|
||||
|
||||
Args:
|
||||
prop:
|
||||
The reference to the property defined on the `_pool` instance's class.
|
||||
**kwargs (optional):
|
||||
If not empty, the property setter is executed and the keyword arguments are passed along to it; the
|
||||
response upon successful execution will be `constants.CMD_OK`. Otherwise the property getter is
|
||||
executed and the response written to the stream will be its return value (as an encoded string).
|
||||
"""
|
||||
if kwargs:
|
||||
log.warning("%s sets %s.%s", self._client_class_name, self._pool.__class__.__name__, prop.fset.__name__)
|
||||
await return_or_exception(prop.fset, self._pool, **kwargs)
|
||||
self._writer.write(CMD_OK)
|
||||
else:
|
||||
log.warning("%s gets %s.%s", self._client_class_name, self._pool.__class__.__name__, prop.fget.__name__)
|
||||
self._writer.write(str(await return_or_exception(prop.fget, self._pool)).encode())
|
||||
|
||||
async def client_handshake(self) -> None:
|
||||
"""
|
||||
This method must be invoked before starting any other client interaction.
|
||||
|
||||
Client info is retrieved, server info is sent back, and the `ControlParser` is initialized and configured.
|
||||
"""
|
||||
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,
|
||||
CLIENT_INFO.TERMINAL_WIDTH: client_info[CLIENT_INFO.TERMINAL_WIDTH],
|
||||
'prog': '',
|
||||
'usage': f'[-h] [{CMD}] ...'
|
||||
}
|
||||
self._parser = ControlParser(**parser_kwargs)
|
||||
self._parser.add_subparsers(title="Commands",
|
||||
metavar="(A command followed by '-h' or '--help' will show command-specific help.)")
|
||||
self._parser.add_class_commands(self._pool.__class__)
|
||||
self._writer.write(str(self._pool).encode())
|
||||
await self._writer.drain()
|
||||
|
||||
async def _parse_command(self, msg: str) -> None:
|
||||
"""
|
||||
Takes a message from the client and attempts to parse it.
|
||||
|
||||
If a parsing error occurs, it is returned to the client. If the `HelpRequested` exception was raised by the
|
||||
`ControlParser`, nothing else happens. Otherwise, the appropriate `_exec...` method is called with the entire
|
||||
dictionary of keyword-arguments returned by the `ControlParser` passed into it.
|
||||
|
||||
Args:
|
||||
msg: The non-empty string read from the client stream.
|
||||
"""
|
||||
try:
|
||||
kwargs = vars(self._parser.parse_args(msg.split(' ')))
|
||||
except ArgumentError as e:
|
||||
self._writer.write(str(e).encode())
|
||||
return
|
||||
except HelpRequested:
|
||||
return
|
||||
command = kwargs.pop(CMD)
|
||||
if isfunction(command):
|
||||
await self._exec_method_and_respond(command, **kwargs)
|
||||
elif isinstance(command, property):
|
||||
await self._exec_property_and_respond(command, **kwargs)
|
||||
|
||||
async def listen(self) -> None:
|
||||
"""
|
||||
Enters the main control loop that only ends if either the server or the client disconnect.
|
||||
|
||||
Messages from the client are read and passed into the `_parse_command` method, which handles the rest.
|
||||
This method should be called, when the client connection was established and the handshake was successful.
|
||||
It will obviously block indefinitely.
|
||||
"""
|
||||
while self._control_server.is_serving():
|
||||
msg = (await self._reader.read(SESSION_MSG_BYTES)).decode().strip()
|
||||
if not msg:
|
||||
log.debug("%s disconnected", self._client_class_name)
|
||||
break
|
||||
await self._parse_command(msg)
|
||||
await self._writer.drain()
|
Reference in New Issue
Block a user