From db306a1a1ffdd0ddce653684d5c9e640734b3ae1 Mon Sep 17 00:00:00 2001 From: Daniil Fajnberg Date: Fri, 8 Apr 2022 11:53:53 +0200 Subject: [PATCH] Fixed Python 3.8 compatibility bugs; classmethod+property workaround; control session buffer --- .github/workflows/main.yaml | 1 + .readthedocs.yaml | 2 +- README.md | 2 +- .../api/asyncio_taskpool.control.parser.rst | 7 ---- docs/source/api/asyncio_taskpool.control.rst | 2 -- .../api/asyncio_taskpool.control.session.rst | 7 ---- setup.cfg | 4 +-- src/asyncio_taskpool/control/client.py | 13 ++++---- src/asyncio_taskpool/control/parser.py | 26 +++++++-------- src/asyncio_taskpool/control/server.py | 1 + src/asyncio_taskpool/control/session.py | 16 ++++++--- src/asyncio_taskpool/internals/constants.py | 1 - src/asyncio_taskpool/internals/helpers.py | 26 ++++++++++++++- tests/test_control/test___main__.py | 2 +- tests/test_control/test_parser.py | 22 ++++++------- tests/test_control/test_session.py | 33 ++++++++++++------- tests/test_internals/test_helpers.py | 32 +++++++++++++++++- tests/test_pool.py | 6 ++-- 18 files changed, 130 insertions(+), 73 deletions(-) delete mode 100644 docs/source/api/asyncio_taskpool.control.parser.rst delete mode 100644 docs/source/api/asyncio_taskpool.control.session.rst diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 0ce784d..e47a94c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -7,6 +7,7 @@ jobs: strategy: matrix: python-version: + - '3.8' - '3.9' - '3.10' steps: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index ccd4605..b18092b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,7 +2,7 @@ version: 2 build: os: 'ubuntu-20.04' tools: - python: '3.9' + python: '3.8' python: install: - method: pip diff --git a/README.md b/README.md index d19744f..272058d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ pip install asyncio-taskpool ## Dependencies -Python Version 3.9+, tested on Linux +Python Version 3.8+, tested on Linux ## Testing diff --git a/docs/source/api/asyncio_taskpool.control.parser.rst b/docs/source/api/asyncio_taskpool.control.parser.rst deleted file mode 100644 index 2642ab5..0000000 --- a/docs/source/api/asyncio_taskpool.control.parser.rst +++ /dev/null @@ -1,7 +0,0 @@ -asyncio\_taskpool.control.parser module -======================================= - -.. automodule:: asyncio_taskpool.control.parser - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/asyncio_taskpool.control.rst b/docs/source/api/asyncio_taskpool.control.rst index a5451e1..fdc5ce7 100644 --- a/docs/source/api/asyncio_taskpool.control.rst +++ b/docs/source/api/asyncio_taskpool.control.rst @@ -13,6 +13,4 @@ Submodules :maxdepth: 4 asyncio_taskpool.control.client - asyncio_taskpool.control.parser asyncio_taskpool.control.server - asyncio_taskpool.control.session diff --git a/docs/source/api/asyncio_taskpool.control.session.rst b/docs/source/api/asyncio_taskpool.control.session.rst deleted file mode 100644 index f732085..0000000 --- a/docs/source/api/asyncio_taskpool.control.session.rst +++ /dev/null @@ -1,7 +0,0 @@ -asyncio\_taskpool.control.session module -======================================== - -.. automodule:: asyncio_taskpool.control.session - :members: - :undoc-members: - :show-inheritance: diff --git a/setup.cfg b/setup.cfg index f141b17..6051110 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = asyncio-taskpool -version = 1.0.0 +version = 1.0.1 author = Daniil Fajnberg author_email = mail@daniil.fajnberg.de description = Dynamically manage pools of asyncio tasks @@ -25,7 +25,7 @@ classifiers = package_dir = = src packages = find: -python_requires = >=3.9 +python_requires = >=3.8 [options.extras_require] dev = diff --git a/src/asyncio_taskpool/control/client.py b/src/asyncio_taskpool/control/client.py index 01b370c..6bcda10 100644 --- a/src/asyncio_taskpool/control/client.py +++ b/src/asyncio_taskpool/control/client.py @@ -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: """ diff --git a/src/asyncio_taskpool/control/parser.py b/src/asyncio_taskpool/control/parser.py index 8190e45..5616b0c 100644 --- a/src/asyncio_taskpool/control/parser.py +++ b/src/asyncio_taskpool/control/parser.py @@ -17,19 +17,21 @@ If not, see .""" __doc__ = """ Definition of the :class:`ControlParser` used in a :class:`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.""" diff --git a/src/asyncio_taskpool/control/server.py b/src/asyncio_taskpool/control/server.py index b7ba0fc..3b82d12 100644 --- a/src/asyncio_taskpool/control/server.py +++ b/src/asyncio_taskpool/control/server.py @@ -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 diff --git a/src/asyncio_taskpool/control/session.py b/src/asyncio_taskpool/control/session.py index cc8ef65..24786a2 100644 --- a/src/asyncio_taskpool/control/session.py +++ b/src/asyncio_taskpool/control/session.py @@ -16,6 +16,8 @@ If not, see .""" __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() diff --git a/src/asyncio_taskpool/internals/constants.py b/src/asyncio_taskpool/internals/constants.py index 693f2be..25d33f5 100644 --- a/src/asyncio_taskpool/internals/constants.py +++ b/src/asyncio_taskpool/internals/constants.py @@ -27,7 +27,6 @@ DEFAULT_TASK_GROUP = 'default' SESSION_MSG_BYTES = 1024 * 100 -STREAM_WRITER = 'stream_writer' CMD = 'command' CMD_OK = b"ok" diff --git a/src/asyncio_taskpool/internals/helpers.py b/src/asyncio_taskpool/internals/helpers.py index 7deb381..c207d2c 100644 --- a/src/asyncio_taskpool/internals/helpers.py +++ b/src/asyncio_taskpool/internals/helpers.py @@ -19,10 +19,12 @@ Miscellaneous helper functions. None of these should be considered part of the p """ +import builtins +import sys from asyncio.coroutines import iscoroutinefunction from importlib import import_module from inspect import getdoc -from typing import Any, Optional, Union +from typing import Any, Callable, Optional, Type, Union from .types import T, AnyCallableT, ArgsT, KwArgsT @@ -131,3 +133,25 @@ def resolve_dotted_path(dotted_path: str) -> object: import_module(module_name) found = getattr(found, name) return found + + +class ClassMethodWorkaround: + """Dirty workaround to make the `@classmethod` decorator work with properties.""" + + def __init__(self, method_or_property: Union[Callable, property]) -> None: + if isinstance(method_or_property, property): + self._getter = method_or_property.fget + else: + self._getter = method_or_property + + def __get__(self, obj: Union[T, None], cls: Union[Type[T], None]) -> Any: + if obj is None: + return self._getter(cls) + return self._getter(obj) + + +# Starting with Python 3.9, this is thankfully no longer necessary. +if sys.version_info[:2] < (3, 9): + classmethod = ClassMethodWorkaround +else: + classmethod = builtins.classmethod diff --git a/tests/test_control/test___main__.py b/tests/test_control/test___main__.py index 62a5747..9fa685e 100644 --- a/tests/test_control/test___main__.py +++ b/tests/test_control/test___main__.py @@ -38,7 +38,7 @@ class CLITestCase(IsolatedAsyncioTestCase): mock_client = MagicMock(start=mock_client_start) mock_client_cls = MagicMock(return_value=mock_client) mock_client_kwargs = {'foo': 123, 'bar': 456, 'baz': 789} - mock_parse_cli.return_value = {module.CLIENT_CLASS: mock_client_cls} | mock_client_kwargs + mock_parse_cli.return_value = {module.CLIENT_CLASS: mock_client_cls, **mock_client_kwargs} self.assertIsNone(await module.main()) mock_parse_cli.assert_called_once_with() mock_client_cls.assert_called_once_with(**mock_client_kwargs) diff --git a/tests/test_control/test_parser.py b/tests/test_control/test_parser.py index b18f6dd..fcc1683 100644 --- a/tests/test_control/test_parser.py +++ b/tests/test_control/test_parser.py @@ -41,9 +41,9 @@ class ControlParserTestCase(TestCase): self.help_formatter_factory_patcher = patch.object(parser.ControlParser, 'help_formatter_factory') self.mock_help_formatter_factory = self.help_formatter_factory_patcher.start() self.mock_help_formatter_factory.return_value = RawTextHelpFormatter - self.stream_writer, self.terminal_width = MagicMock(), 420 + self.stream, self.terminal_width = MagicMock(), 420 self.kwargs = { - 'stream_writer': self.stream_writer, + 'stream': self.stream, 'terminal_width': self.terminal_width, 'formatter_class': FOO } @@ -72,10 +72,9 @@ class ControlParserTestCase(TestCase): def test_init(self): self.assertIsInstance(self.parser, ArgumentParser) - self.assertEqual(self.stream_writer, self.parser._stream_writer) + self.assertEqual(self.stream, self.parser._stream) self.assertEqual(self.terminal_width, self.parser._terminal_width) self.mock_help_formatter_factory.assert_called_once_with(self.terminal_width, FOO) - self.assertFalse(getattr(self.parser, 'exit_on_error')) self.assertEqual(RawTextHelpFormatter, getattr(self.parser, 'formatter_class')) self.assertSetEqual(set(), self.parser._flags) self.assertIsNone(self.parser._commands) @@ -89,7 +88,7 @@ class ControlParserTestCase(TestCase): mock_get_first_doc_line.return_value = mock_help = 'help 123' kwargs = {FOO: 1, BAR: 2, parser.DESCRIPTION: FOO + BAR} expected_name = 'foo-bar' - expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: mock_help} | kwargs + expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: mock_help, **kwargs} to_omit = ['abc', 'xyz'] output = self.parser.add_function_command(foo_bar, omit_params=to_omit, **kwargs) self.assertEqual(mock_subparser, output) @@ -107,7 +106,7 @@ class ControlParserTestCase(TestCase): mock_get_first_doc_line.return_value = mock_help = 'help 123' kwargs = {FOO: 1, BAR: 2, parser.DESCRIPTION: FOO + BAR} expected_name = 'get-prop' - expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: mock_help} | kwargs + expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: mock_help, **kwargs} output = self.parser.add_property_command(prop, **kwargs) self.assertEqual(mock_subparser, output) mock_get_first_doc_line.assert_called_once_with(get_prop) @@ -119,7 +118,7 @@ class ControlParserTestCase(TestCase): prop = property(get_prop, set_prop) expected_help = f"Get/set the `.{expected_name}` property" - expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: expected_help} | kwargs + expected_kwargs = {parser.NAME: expected_name, parser.PROG: expected_name, parser.HELP: expected_help, **kwargs} output = self.parser.add_property_command(prop, **kwargs) self.assertEqual(mock_subparser, output) mock_get_first_doc_line.assert_has_calls([call(get_prop), call(set_prop)]) @@ -152,8 +151,7 @@ class ControlParserTestCase(TestCase): mock_subparser = MagicMock(set_defaults=mock_set_defaults) mock_add_function_command.return_value = mock_add_property_command.return_value = mock_subparser x = 'x' - common_kwargs = {parser.STREAM_WRITER: self.parser._stream_writer, - parser.CLIENT_INFO.TERMINAL_WIDTH: self.parser._terminal_width} + common_kwargs = {'stream': self.parser._stream, parser.CLIENT_INFO.TERMINAL_WIDTH: self.parser._terminal_width} expected_output = {'method': mock_subparser, 'prop': mock_subparser} output = self.parser.add_class_commands(FooBar, public_only=True, omit_members=['to_omit'], member_arg_name=x) self.assertDictEqual(expected_output, output) @@ -170,12 +168,12 @@ class ControlParserTestCase(TestCase): mock_base_add_subparsers.assert_called_once_with(*args, **kwargs) def test__print_message(self): - self.stream_writer.write = MagicMock() + self.stream.write = MagicMock() self.assertIsNone(self.parser._print_message('')) - self.stream_writer.write.assert_not_called() + self.stream.write.assert_not_called() msg = 'foo bar baz' self.assertIsNone(self.parser._print_message(msg)) - self.stream_writer.write.assert_called_once_with(msg.encode()) + self.stream.write.assert_called_once_with(msg) @patch.object(parser.ControlParser, '_print_message') def test_exit(self, mock__print_message: MagicMock): diff --git a/tests/test_control/test_session.py b/tests/test_control/test_session.py index 30e768d..0ad2786 100644 --- a/tests/test_control/test_session.py +++ b/tests/test_control/test_session.py @@ -21,11 +21,12 @@ Unittests for the `asyncio_taskpool.session` module. import json from argparse import ArgumentError, Namespace +from io import StringIO from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, MagicMock, patch, call from asyncio_taskpool.control import session -from asyncio_taskpool.internals.constants import CLIENT_INFO, CMD, SESSION_MSG_BYTES, STREAM_WRITER +from asyncio_taskpool.internals.constants import CLIENT_INFO, CMD, SESSION_MSG_BYTES from asyncio_taskpool.exceptions import HelpRequested from asyncio_taskpool.pool import SimpleTaskPool @@ -61,14 +62,15 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): self.assertEqual(self.mock_reader, self.session._reader) self.assertEqual(self.mock_writer, self.session._writer) self.assertIsNone(self.session._parser) + self.assertIsInstance(self.session._response_buffer, StringIO) @patch.object(session, 'return_or_exception') async def test__exec_method_and_respond(self, mock_return_or_exception: AsyncMock): def method(self, arg1, arg2, *var_args, **rest): pass test_arg1, test_arg2, test_var_args, test_rest = 123, 'xyz', [0.1, 0.2, 0.3], {'aaa': 1, 'bbb': 11} - kwargs = {'arg1': test_arg1, 'arg2': test_arg2, 'var_args': test_var_args} | test_rest + kwargs = {'arg1': test_arg1, 'arg2': test_arg2, 'var_args': test_var_args} mock_return_or_exception.return_value = None - self.assertIsNone(await self.session._exec_method_and_respond(method, **kwargs)) + self.assertIsNone(await self.session._exec_method_and_respond(method, **kwargs, **test_rest)) mock_return_or_exception.assert_awaited_once_with( method, self.mock_pool, test_arg1, test_arg2, *test_var_args, **test_rest ) @@ -104,7 +106,7 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): self.mock_reader.read = mock_read self.mock_writer.drain = AsyncMock() expected_parser_kwargs = { - STREAM_WRITER: self.mock_writer, + 'stream': self.session._response_buffer, CLIENT_INFO.TERMINAL_WIDTH: width, 'prog': '', 'usage': f'[-h] [{CMD}] ...' @@ -132,10 +134,9 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): kwargs = {FOO: BAR, 'hello': 'python'} mock_parse_args = MagicMock(return_value=Namespace(**{CMD: method}, **kwargs)) self.session._parser = MagicMock(parse_args=mock_parse_args) - self.mock_writer.write = MagicMock() self.assertIsNone(await self.session._parse_command(msg)) mock_parse_args.assert_called_once_with(msg.split(' ')) - self.mock_writer.write.assert_not_called() + self.assertEqual('', self.session._response_buffer.getvalue()) mock__exec_method_and_respond.assert_awaited_once_with(method, **kwargs) mock__exec_property_and_respond.assert_not_called() @@ -145,7 +146,7 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): mock_parse_args.return_value = Namespace(**{CMD: prop}, **kwargs) self.assertIsNone(await self.session._parse_command(msg)) mock_parse_args.assert_called_once_with(msg.split(' ')) - self.mock_writer.write.assert_not_called() + self.assertEqual('', self.session._response_buffer.getvalue()) mock__exec_method_and_respond.assert_not_called() mock__exec_property_and_respond.assert_awaited_once_with(prop, **kwargs) @@ -161,26 +162,28 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): mock_parse_args.assert_called_once_with(msg.split(' ')) mock__exec_method_and_respond.assert_not_called() mock__exec_property_and_respond.assert_not_called() - self.mock_writer.write.assert_called_once_with(str(exc).encode()) + self.assertEqual(str(exc), self.session._response_buffer.getvalue()) mock__exec_property_and_respond.reset_mock() mock_parse_args.reset_mock() - self.mock_writer.write.reset_mock() + self.session._response_buffer.seek(0) + self.session._response_buffer.truncate() mock_parse_args.side_effect = exc = ArgumentError(MagicMock(), "oops") self.assertIsNone(await self.session._parse_command(msg)) mock_parse_args.assert_called_once_with(msg.split(' ')) - self.mock_writer.write.assert_called_once_with(str(exc).encode()) + self.assertEqual(str(exc), self.session._response_buffer.getvalue()) mock__exec_method_and_respond.assert_not_awaited() mock__exec_property_and_respond.assert_not_awaited() - self.mock_writer.write.reset_mock() mock_parse_args.reset_mock() + self.session._response_buffer.seek(0) + self.session._response_buffer.truncate() mock_parse_args.side_effect = HelpRequested() self.assertIsNone(await self.session._parse_command(msg)) mock_parse_args.assert_called_once_with(msg.split(' ')) - self.mock_writer.write.assert_not_called() + self.assertEqual('', self.session._response_buffer.getvalue()) mock__exec_method_and_respond.assert_not_awaited() mock__exec_property_and_respond.assert_not_awaited() @@ -191,17 +194,23 @@ class ControlServerTestCase(IsolatedAsyncioTestCase): self.mock_writer.drain = AsyncMock(side_effect=make_reader_return_empty) msg = "fascinating" self.mock_reader.read = AsyncMock(return_value=f' {msg} '.encode()) + response = FOO + BAR + FOO + self.session._response_buffer.write(response) self.assertIsNone(await self.session.listen()) self.mock_reader.read.assert_has_awaits([call(SESSION_MSG_BYTES), call(SESSION_MSG_BYTES)]) mock__parse_command.assert_awaited_once_with(msg) + self.assertEqual('', self.session._response_buffer.getvalue()) + self.mock_writer.write.assert_called_once_with(response.encode()) self.mock_writer.drain.assert_awaited_once_with() self.mock_reader.read.reset_mock() mock__parse_command.reset_mock() + self.mock_writer.write.reset_mock() self.mock_writer.drain.reset_mock() self.mock_server.is_serving = MagicMock(return_value=False) self.assertIsNone(await self.session.listen()) self.mock_reader.read.assert_not_awaited() mock__parse_command.assert_not_awaited() + self.mock_writer.write.assert_not_called() self.mock_writer.drain.assert_not_awaited() diff --git a/tests/test_internals/test_helpers.py b/tests/test_internals/test_helpers.py index 6b66aee..db06215 100644 --- a/tests/test_internals/test_helpers.py +++ b/tests/test_internals/test_helpers.py @@ -19,7 +19,7 @@ Unittests for the `asyncio_taskpool.helpers` module. """ -from unittest import IsolatedAsyncioTestCase +from unittest import IsolatedAsyncioTestCase, TestCase from unittest.mock import MagicMock, AsyncMock, NonCallableMagicMock, call, patch from asyncio_taskpool.internals import helpers @@ -122,3 +122,33 @@ class HelpersTestCase(IsolatedAsyncioTestCase): with self.assertRaises(AttributeError): helpers.resolve_dotted_path('foo.bar.baz') mock_import_module.assert_has_calls([call('foo'), call('foo.bar')]) + + +class ClassMethodWorkaroundTestCase(TestCase): + def test_init(self): + def func(): return 'foo' + def getter(): return 'bar' + prop = property(getter) + instance = helpers.ClassMethodWorkaround(func) + self.assertIs(func, instance._getter) + instance = helpers.ClassMethodWorkaround(prop) + self.assertIs(getter, instance._getter) + + @patch.object(helpers.ClassMethodWorkaround, '__init__', return_value=None) + def test_get(self, _mock_init: MagicMock): + def func(x: MagicMock): return x.__name__ + instance = helpers.ClassMethodWorkaround(MagicMock()) + instance._getter = func + obj, cls = None, MagicMock + expected_output = 'MagicMock' + output = instance.__get__(obj, cls) + self.assertEqual(expected_output, output) + + obj = MagicMock(__name__='bar') + expected_output = 'bar' + output = instance.__get__(obj, cls) + self.assertEqual(expected_output, output) + + cls = None + output = instance.__get__(obj, cls) + self.assertEqual(expected_output, output) diff --git a/tests/test_pool.py b/tests/test_pool.py index 912e16f..6d5ea9c 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -729,13 +729,15 @@ class SimpleTaskPoolTestCase(CommonTestCase): TEST_POOL_CANCEL_CB = MagicMock() def get_task_pool_init_params(self) -> dict: - return super().get_task_pool_init_params() | { + params = super().get_task_pool_init_params() + params.update({ 'func': self.TEST_POOL_FUNC, 'args': self.TEST_POOL_ARGS, 'kwargs': self.TEST_POOL_KWARGS, 'end_callback': self.TEST_POOL_END_CB, 'cancel_callback': self.TEST_POOL_CANCEL_CB, - } + }) + return params def setUp(self) -> None: self.base_class_init_patcher = patch.object(pool.BaseTaskPool, '__init__')