From 2a93ea77e99831dfaa2fbb9a44021bf61787ba07 Mon Sep 17 00:00:00 2001 From: Benedict Harcourt Date: Sun, 20 Aug 2023 20:36:08 +0000 Subject: [PATCH 01/20] examples: Add a stdio IOConfig for examples This makes writing examples asier, as users can trigger events by typing into the console, and the output events can be automatically returned also to the console, removing the need for network connectivity and API access tokens for services like Discord --- src/examples/stdio.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/examples/stdio.py diff --git a/src/examples/stdio.py b/src/examples/stdio.py new file mode 100644 index 00000000..eceb5ce8 --- /dev/null +++ b/src/examples/stdio.py @@ -0,0 +1,104 @@ +from typing import Iterable + +import asyncio +import os +import sys + +from mewbot.api.v1 import Input, InputEvent, IOConfig, Output, OutputEvent +from mewbot.io.common import EventWithReplyMixIn + + +class StandardConsoleInputOutput(IOConfig): + def get_inputs(self) -> Iterable[Input]: + return [StandardInput()] + + def get_outputs(self) -> Iterable[Output]: + return [StandardOutput()] + + +class ConsoleInputLine(EventWithReplyMixIn): + message: str + + def __init__(self, message: str) -> None: + self.message = message + + def get_sender_name(self) -> str: + """ + Returns the human friend name/nickname of the user who sent the event. + """ + return os.getlogin() + + def get_sender_mention(self) -> str: + """ + Returns the string contents required to mention/notify/ping the sender. + + If the reply methods will automatically ping the user, this may just be + the human-readable username. + """ + return os.getlogin() + + def prepare_reply(self, message: str) -> OutputEvent: + """ + Creates an OutputEvent which is a reply to this input event. + + This event will be targeted at the same scope as the incoming message, + e.g. in the same channel. It is expected that all people who saw the + original message will also be able to see the reply. + """ + return ConsoleOutputLine(message) + + def prepare_reply_narrowest_scope(self, message: str) -> OutputEvent: + """ + Creates an OutputEvent which is a reply to this input event. + + This event will attempt to only be visible to a minimal number of + people which still includes the person who sent the message. + Note that for some systems, this may still be the original scope + of all users who could see the original message. + + This function does not guarantee privacy, but is intended for use + where replies are not relevant to other users, and thus can clutter + up the main chat. + """ + return ConsoleOutputLine(message) + + +class ConsoleOutputLine(OutputEvent): + message: str + + def __init__(self, message: str) -> None: + self.message = message + + def __str__(self) -> str: + return self.message + + +class StandardInput(Input): + @staticmethod + def produces_inputs() -> set[type[InputEvent]]: + return {ConsoleInputLine} + + @staticmethod + async def connect_stdin_stdout() -> asyncio.StreamReader: + loop = asyncio.get_event_loop() + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: protocol, sys.stdin) + + return reader + + async def run(self) -> None: + reader = await self.connect_stdin_stdout() + while line := await reader.readline(): + if self.queue: + await self.queue.put(ConsoleInputLine(line.decode())) + + +class StandardOutput(Output): + @staticmethod + def consumes_outputs() -> set[type[OutputEvent]]: + return {ConsoleOutputLine} + + async def output(self, event: OutputEvent) -> bool: + print(event) + return True From cd85f42b15e8df37985c7793cc7d49e8665b0f88 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Mon, 21 Aug 2023 17:58:16 +0100 Subject: [PATCH 02/20] Linting compliance, refactoring stdio.py - stdio refactored to mewbot.io.console - added doc strings throughout - rogue isort needed in docs - pylint ignore of too few public methods --- .../stdio.py => mewbot/io/console.py} | 80 ++++++++++++++++++- src/mewbot/tools/docs.py | 3 +- 2 files changed, 80 insertions(+), 3 deletions(-) rename src/{examples/stdio.py => mewbot/io/console.py} (66%) diff --git a/src/examples/stdio.py b/src/mewbot/io/console.py similarity index 66% rename from src/examples/stdio.py rename to src/mewbot/io/console.py index eceb5ce8..3b0d7940 100644 --- a/src/examples/stdio.py +++ b/src/mewbot/io/console.py @@ -1,3 +1,10 @@ +""" +Allows you to generate InputEvents and receive OutputEvents via typing in the shell. + +Mostly used for demo purposes. +""" + + from typing import Iterable import asyncio @@ -9,17 +16,40 @@ class StandardConsoleInputOutput(IOConfig): + """ + Prints to shell and reads things type in it back. + """ + def get_inputs(self) -> Iterable[Input]: + """ + Input will read from stdio. + + :return: + """ return [StandardInput()] def get_outputs(self) -> Iterable[Output]: + """ + Output will print to the console. + + :return: + """ return [StandardOutput()] class ConsoleInputLine(EventWithReplyMixIn): + """ + Input event generated when the user types a line in the console. + """ + message: str def __init__(self, message: str) -> None: + """ + Startup with the line drawn from the console. + + :param message: + """ self.message = message def get_sender_name(self) -> str: @@ -63,23 +93,51 @@ def prepare_reply_narrowest_scope(self, message: str) -> OutputEvent: return ConsoleOutputLine(message) -class ConsoleOutputLine(OutputEvent): +class ConsoleOutputLine(OutputEvent): # pylint:disable=too-few-public-methods + """ + Line to be printer to the console. + """ + message: str def __init__(self, message: str) -> None: + """ + Takes the console output line as a string. + + :param message: + """ self.message = message def __str__(self) -> str: + """ + Str representation of the original message. + + :return: + """ return self.message class StandardInput(Input): + """ + Reads lines from the console ever time the user enters one. + """ + @staticmethod def produces_inputs() -> set[type[InputEvent]]: + """ + Produces ConsoleInputLine InputEvents. + + :return: + """ return {ConsoleInputLine} @staticmethod async def connect_stdin_stdout() -> asyncio.StreamReader: + """ + Async compatible - non blocking - console line reader. + + :return: + """ loop = asyncio.get_event_loop() reader = asyncio.StreamReader() protocol = asyncio.StreamReaderProtocol(reader) @@ -88,6 +146,11 @@ async def connect_stdin_stdout() -> asyncio.StreamReader: return reader async def run(self) -> None: + """ + Process input typed at the console and convert it to InputEvents on the wire. + + :return: + """ reader = await self.connect_stdin_stdout() while line := await reader.readline(): if self.queue: @@ -95,10 +158,25 @@ async def run(self) -> None: class StandardOutput(Output): + """ + Write out to the console. + """ + @staticmethod def consumes_outputs() -> set[type[OutputEvent]]: + """ + Takes lines to write out to the active console. + + :return: + """ return {ConsoleOutputLine} async def output(self, event: OutputEvent) -> bool: + """ + Just uses the print command to write out to the console. + + :param event: + :return: + """ print(event) return True diff --git a/src/mewbot/tools/docs.py b/src/mewbot/tools/docs.py index 147fb55d..1ee383c3 100644 --- a/src/mewbot/tools/docs.py +++ b/src/mewbot/tools/docs.py @@ -16,15 +16,14 @@ import abc import dataclasses +import logging import os import pathlib -import logging import re import shutil from .toolchain import Annotation, ToolChain - TOP_LEVEL_FILES_LINK_NAME: str = "top_level_md_files" SOURCE_MEWBOT_DIR: str = "source_mewbot" From c80ec03ee5ef6a73ce6f546b1c0d96f50c300e0d Mon Sep 17 00:00:00 2001 From: ajCameron Date: Mon, 21 Aug 2023 23:15:06 +0100 Subject: [PATCH 03/20] Added Windows handling, first example - naturally the linux version of async reading from the console did not work on windows - and visa-versa - added new windows specific method to account for this - added example - making a start on tests --- examples/console_io.yaml | 27 +++++++++++++++++++++++++++ src/mewbot/io/console.py | 31 ++++++++++++++++++++++++++++++- tests/io/test_io_console.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 examples/console_io.yaml create mode 100644 tests/io/test_io_console.py diff --git a/examples/console_io.yaml b/examples/console_io.yaml new file mode 100644 index 00000000..8c2f28f8 --- /dev/null +++ b/examples/console_io.yaml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2021 - 2023 Mewbot Developers +# +# SPDX-License-Identifier: CC-BY-4.0 + +kind: IOConfig +implementation: mewbot.io.console.StandardConsoleInputOutput +uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa00 +properties: {} + +--- + +kind: Behaviour +implementation: mewbot.api.v1.Behaviour +uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa01 +properties: + name: 'Print "world" on receiving "!hello"' +triggers: + - kind: Trigger + implementation: mewbot.io.common.AllEventTrigger + uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa02 + properties: {} +conditions: [] +actions: + - kind: Action + implementation: mewbot.io.common.PrintAction + uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa03 + properties: {} diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 3b0d7940..125232b0 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -52,6 +52,14 @@ def __init__(self, message: str) -> None: """ self.message = message + def __str__(self) -> str: + """ + Str rep of this event. + + :return: + """ + return f"ConsoleInputLine: \"{self.message}\"" + def get_sender_name(self) -> str: """ Returns the human friend name/nickname of the user who sent the event. @@ -134,7 +142,7 @@ def produces_inputs() -> set[type[InputEvent]]: @staticmethod async def connect_stdin_stdout() -> asyncio.StreamReader: """ - Async compatible - non blocking - console line reader. + Async compatible - non-blocking - console line reader. :return: """ @@ -149,6 +157,17 @@ async def run(self) -> None: """ Process input typed at the console and convert it to InputEvents on the wire. + :return: + """ + if os.name.lower() != "nt": + await self._linux_run() + else: + await self._windows_run() + + async def _linux_run(self) -> None: + """ + Linux version of the async reader. + :return: """ reader = await self.connect_stdin_stdout() @@ -156,6 +175,16 @@ async def run(self) -> None: if self.queue: await self.queue.put(ConsoleInputLine(line.decode())) + async def _windows_run(self) -> None: + """ + Windows version of the async reader. + + :return: + """ + while line := await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline): + if self.queue: + await self.queue.put(ConsoleInputLine(line)) + class StandardOutput(Output): """ diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py new file mode 100644 index 00000000..8f718028 --- /dev/null +++ b/tests/io/test_io_console.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2021 - 2023 Mewbot Developers +# +# SPDX-License-Identifier: BSD-2-Clause + +""" +Tests for the Console IO configuration. +""" + +from typing import Type + +import asyncio + +import pytest + +from mewbot.api.v1 import IOConfig +from mewbot.core import InputEvent, InputQueue +from mewbot.io.console import StandardConsoleInputOutput, ConsoleInputLine, ConsoleOutputLine +from mewbot.test import BaseTestClassWithConfig + + +class TestRSSIO(BaseTestClassWithConfig[StandardConsoleInputOutput]): + """ + Tests for the RSS IO configuration. + + Load a bot with an RSSInput - this should yield a fully loaded RSSIO config. + Which can then be tested. + """ + + config_file: str = "examples/console_io.yaml" + implementation: Type[StandardConsoleInputOutput] = StandardConsoleInputOutput + + def test_check_class(self) -> None: + """Confirm the configuration has been correctly loaded.""" + + assert isinstance(self.component, StandardConsoleInputOutput) + assert isinstance(self.component, IOConfig) From 8a43d7aa651c736e5004e944616cc9a04b8af69a Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 02:40:21 +0100 Subject: [PATCH 04/20] Adding tests for the console output - tricks with overloading sys.stdout seem to be causing issues with pytest - as such, the tests are more basic than I'd like... --- ...{console_io.yaml => console_io_print.yaml} | 0 src/mewbot/io/console.py | 9 +- tests/io/test_io_console.py | 132 +++++++++++++++++- 3 files changed, 133 insertions(+), 8 deletions(-) rename examples/{console_io.yaml => console_io_print.yaml} (100%) diff --git a/examples/console_io.yaml b/examples/console_io_print.yaml similarity index 100% rename from examples/console_io.yaml rename to examples/console_io_print.yaml diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 125232b0..1a03b0e0 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -58,7 +58,7 @@ def __str__(self) -> str: :return: """ - return f"ConsoleInputLine: \"{self.message}\"" + return f'ConsoleInputLine: "{self.message}"' def get_sender_name(self) -> str: """ @@ -148,8 +148,7 @@ async def connect_stdin_stdout() -> asyncio.StreamReader: """ loop = asyncio.get_event_loop() reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - await loop.connect_read_pipe(lambda: protocol, sys.stdin) + await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin) return reader @@ -181,7 +180,9 @@ async def _windows_run(self) -> None: :return: """ - while line := await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline): + while line := await asyncio.get_event_loop().run_in_executor( + None, sys.stdin.readline + ): if self.queue: await self.queue.put(ConsoleInputLine(line)) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 8f718028..e0cf2b60 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -2,19 +2,27 @@ # # SPDX-License-Identifier: BSD-2-Clause +# pylint:disable=protected-access + """ Tests for the Console IO configuration. """ - from typing import Type import asyncio +import os import pytest from mewbot.api.v1 import IOConfig -from mewbot.core import InputEvent, InputQueue -from mewbot.io.console import StandardConsoleInputOutput, ConsoleInputLine, ConsoleOutputLine +from mewbot.core import InputEvent +from mewbot.io.console import ( + ConsoleInputLine, + ConsoleOutputLine, + StandardConsoleInputOutput, + StandardInput, + StandardOutput, +) from mewbot.test import BaseTestClassWithConfig @@ -26,7 +34,7 @@ class TestRSSIO(BaseTestClassWithConfig[StandardConsoleInputOutput]): Which can then be tested. """ - config_file: str = "examples/console_io.yaml" + config_file: str = "examples/console_io_print.yaml" implementation: Type[StandardConsoleInputOutput] = StandardConsoleInputOutput def test_check_class(self) -> None: @@ -34,3 +42,119 @@ def test_check_class(self) -> None: assert isinstance(self.component, StandardConsoleInputOutput) assert isinstance(self.component, IOConfig) + + def test_get_inputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + assert isinstance(self.component.get_inputs(), list) + assert len(list(self.component.get_inputs())) == 1 + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + def test_get_outputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + assert isinstance(self.component.get_outputs(), list) + assert len(list(self.component.get_outputs())) == 1 + + cand_input = list(self.component.get_outputs())[0] + + assert isinstance(cand_input, StandardOutput) + + def test_inputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + assert isinstance(cand_input.produces_inputs(), set) + for cand_obj in cand_input.produces_inputs(): + assert issubclass(cand_obj, InputEvent) + + def test_console_input_line_methods(self) -> None: + """ + Tests the ConsoleInputLineEvent input event. + + :return: + """ + test_event = ConsoleInputLine(message="this is a test") + assert test_event.message == "this is a test" + + assert isinstance(str(test_event), str) + assert "this is a test" in str(test_event) + + assert isinstance(test_event.get_sender_name(), str) + assert isinstance(test_event.get_sender_mention(), str) + + assert isinstance(test_event.prepare_reply("this is a thing"), ConsoleOutputLine) + assert isinstance( + test_event.prepare_reply_narrowest_scope("this is a thing"), ConsoleOutputLine + ) + + def test_console_output_line_methods(self) -> None: + """ + Tests the ConsoleOutputLineEvent. + + :return: + """ + test_output_event = ConsoleOutputLine(message="This is a test") + assert test_output_event.message == "This is a test" + + assert isinstance(str(test_output_event), str), "rep should be a string" + assert "This is a test" in str(test_output_event) + + @pytest.mark.asyncio + async def test_input_runs(self) -> None: + """ + Tests that an input actually runs. + + :return: + """ + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + # pytest doesn't like trying to read from stdio during tests + try: + await asyncio.wait_for(cand_input.run(), timeout=2) + except OSError: + pass + + # Directly run the linux version - on Windows this should fail + if os.name.lower() == "nt": + try: + await asyncio.wait_for(cand_input._linux_run(), timeout=2) + except asyncio.exceptions.TimeoutError: + pass + else: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + + @pytest.mark.asyncio + async def test_output_runs(self) -> None: + """ + Tests that an output actually runs... + + :return: + """ + + cand_output = list(self.component.get_outputs())[0] + + assert isinstance(cand_output, StandardOutput) + + test_event = ConsoleOutputLine(message="this is a test") + + # pytest doesn't like trying to read from stdio during tests + await asyncio.wait_for(cand_output.output(event=test_event), timeout=2) From 8152a5af99d6ff077b6d3b06fe76119bbe8e51b1 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 02:46:37 +0100 Subject: [PATCH 05/20] Improving exception handling to deal with ubuntu and macos problems --- tests/io/test_io_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index e0cf2b60..6e4ce273 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -137,7 +137,7 @@ async def test_input_runs(self) -> None: if os.name.lower() == "nt": try: await asyncio.wait_for(cand_input._linux_run(), timeout=2) - except asyncio.exceptions.TimeoutError: + except (AttributeError, asyncio.exceptions.TimeoutError): pass else: await asyncio.wait_for(cand_input._windows_run(), timeout=2) From f1e6f1d0aa2a1d8d108992c4ffb6cebed6ca8d80 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:09:59 +0100 Subject: [PATCH 06/20] Further work on exceptions which can occur on macos --- tests/io/test_io_console.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 6e4ce273..b077dfd8 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -140,7 +140,10 @@ async def test_input_runs(self) -> None: except (AttributeError, asyncio.exceptions.TimeoutError): pass else: - await asyncio.wait_for(cand_input._windows_run(), timeout=2) + try: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + except OSError: + pass @pytest.mark.asyncio async def test_output_runs(self) -> None: From f4d8f242a5a759e96cad0dab98e5b6bf48aa3499 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:36:21 +0100 Subject: [PATCH 07/20] swapping out os.getlogin for getpass.getuser - should be more universal --- src/mewbot/io/console.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 1a03b0e0..bc4e724d 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -8,6 +8,7 @@ from typing import Iterable import asyncio +import getpass import os import sys @@ -64,7 +65,8 @@ def get_sender_name(self) -> str: """ Returns the human friend name/nickname of the user who sent the event. """ - return os.getlogin() + # Seems to be more universal than os.getlogin() + return getpass.getuser() def get_sender_mention(self) -> str: """ @@ -73,7 +75,7 @@ def get_sender_mention(self) -> str: If the reply methods will automatically ping the user, this may just be the human-readable username. """ - return os.getlogin() + return getpass.getuser() def prepare_reply(self, message: str) -> OutputEvent: """ From ef63dbbcd92b28244371849e3c9c7ae2aeaee941 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:46:15 +0100 Subject: [PATCH 08/20] aiming to boost test coverage ... slightly --- tests/io/test_io_console.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index b077dfd8..96182bb6 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -65,9 +65,11 @@ def test_get_outputs(self) -> None: assert isinstance(self.component.get_outputs(), list) assert len(list(self.component.get_outputs())) == 1 - cand_input = list(self.component.get_outputs())[0] + cand_output = list(self.component.get_outputs())[0] + + assert isinstance(cand_output, StandardOutput) - assert isinstance(cand_input, StandardOutput) + assert isinstance(cand_output.consumes_outputs(), set) def test_inputs(self) -> None: """ From 0a6a440340336a6aeb86119f00f516c5e013f856 Mon Sep 17 00:00:00 2001 From: Benedict Harcourt Date: Sun, 20 Aug 2023 20:36:08 +0000 Subject: [PATCH 09/20] examples: Add a stdio IOConfig for examples This makes writing examples asier, as users can trigger events by typing into the console, and the output events can be automatically returned also to the console, removing the need for network connectivity and API access tokens for services like Discord --- src/examples/stdio.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/examples/stdio.py diff --git a/src/examples/stdio.py b/src/examples/stdio.py new file mode 100644 index 00000000..eceb5ce8 --- /dev/null +++ b/src/examples/stdio.py @@ -0,0 +1,104 @@ +from typing import Iterable + +import asyncio +import os +import sys + +from mewbot.api.v1 import Input, InputEvent, IOConfig, Output, OutputEvent +from mewbot.io.common import EventWithReplyMixIn + + +class StandardConsoleInputOutput(IOConfig): + def get_inputs(self) -> Iterable[Input]: + return [StandardInput()] + + def get_outputs(self) -> Iterable[Output]: + return [StandardOutput()] + + +class ConsoleInputLine(EventWithReplyMixIn): + message: str + + def __init__(self, message: str) -> None: + self.message = message + + def get_sender_name(self) -> str: + """ + Returns the human friend name/nickname of the user who sent the event. + """ + return os.getlogin() + + def get_sender_mention(self) -> str: + """ + Returns the string contents required to mention/notify/ping the sender. + + If the reply methods will automatically ping the user, this may just be + the human-readable username. + """ + return os.getlogin() + + def prepare_reply(self, message: str) -> OutputEvent: + """ + Creates an OutputEvent which is a reply to this input event. + + This event will be targeted at the same scope as the incoming message, + e.g. in the same channel. It is expected that all people who saw the + original message will also be able to see the reply. + """ + return ConsoleOutputLine(message) + + def prepare_reply_narrowest_scope(self, message: str) -> OutputEvent: + """ + Creates an OutputEvent which is a reply to this input event. + + This event will attempt to only be visible to a minimal number of + people which still includes the person who sent the message. + Note that for some systems, this may still be the original scope + of all users who could see the original message. + + This function does not guarantee privacy, but is intended for use + where replies are not relevant to other users, and thus can clutter + up the main chat. + """ + return ConsoleOutputLine(message) + + +class ConsoleOutputLine(OutputEvent): + message: str + + def __init__(self, message: str) -> None: + self.message = message + + def __str__(self) -> str: + return self.message + + +class StandardInput(Input): + @staticmethod + def produces_inputs() -> set[type[InputEvent]]: + return {ConsoleInputLine} + + @staticmethod + async def connect_stdin_stdout() -> asyncio.StreamReader: + loop = asyncio.get_event_loop() + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: protocol, sys.stdin) + + return reader + + async def run(self) -> None: + reader = await self.connect_stdin_stdout() + while line := await reader.readline(): + if self.queue: + await self.queue.put(ConsoleInputLine(line.decode())) + + +class StandardOutput(Output): + @staticmethod + def consumes_outputs() -> set[type[OutputEvent]]: + return {ConsoleOutputLine} + + async def output(self, event: OutputEvent) -> bool: + print(event) + return True From e012d993b1596ab1933abaecbb94de8cf19a44eb Mon Sep 17 00:00:00 2001 From: ajCameron Date: Mon, 21 Aug 2023 17:58:16 +0100 Subject: [PATCH 10/20] Linting compliance, refactoring stdio.py - stdio refactored to mewbot.io.console - added doc strings throughout - rogue isort needed in docs - pylint ignore of too few public methods --- .../stdio.py => mewbot/io/console.py} | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) rename src/{examples/stdio.py => mewbot/io/console.py} (66%) diff --git a/src/examples/stdio.py b/src/mewbot/io/console.py similarity index 66% rename from src/examples/stdio.py rename to src/mewbot/io/console.py index eceb5ce8..3b0d7940 100644 --- a/src/examples/stdio.py +++ b/src/mewbot/io/console.py @@ -1,3 +1,10 @@ +""" +Allows you to generate InputEvents and receive OutputEvents via typing in the shell. + +Mostly used for demo purposes. +""" + + from typing import Iterable import asyncio @@ -9,17 +16,40 @@ class StandardConsoleInputOutput(IOConfig): + """ + Prints to shell and reads things type in it back. + """ + def get_inputs(self) -> Iterable[Input]: + """ + Input will read from stdio. + + :return: + """ return [StandardInput()] def get_outputs(self) -> Iterable[Output]: + """ + Output will print to the console. + + :return: + """ return [StandardOutput()] class ConsoleInputLine(EventWithReplyMixIn): + """ + Input event generated when the user types a line in the console. + """ + message: str def __init__(self, message: str) -> None: + """ + Startup with the line drawn from the console. + + :param message: + """ self.message = message def get_sender_name(self) -> str: @@ -63,23 +93,51 @@ def prepare_reply_narrowest_scope(self, message: str) -> OutputEvent: return ConsoleOutputLine(message) -class ConsoleOutputLine(OutputEvent): +class ConsoleOutputLine(OutputEvent): # pylint:disable=too-few-public-methods + """ + Line to be printer to the console. + """ + message: str def __init__(self, message: str) -> None: + """ + Takes the console output line as a string. + + :param message: + """ self.message = message def __str__(self) -> str: + """ + Str representation of the original message. + + :return: + """ return self.message class StandardInput(Input): + """ + Reads lines from the console ever time the user enters one. + """ + @staticmethod def produces_inputs() -> set[type[InputEvent]]: + """ + Produces ConsoleInputLine InputEvents. + + :return: + """ return {ConsoleInputLine} @staticmethod async def connect_stdin_stdout() -> asyncio.StreamReader: + """ + Async compatible - non blocking - console line reader. + + :return: + """ loop = asyncio.get_event_loop() reader = asyncio.StreamReader() protocol = asyncio.StreamReaderProtocol(reader) @@ -88,6 +146,11 @@ async def connect_stdin_stdout() -> asyncio.StreamReader: return reader async def run(self) -> None: + """ + Process input typed at the console and convert it to InputEvents on the wire. + + :return: + """ reader = await self.connect_stdin_stdout() while line := await reader.readline(): if self.queue: @@ -95,10 +158,25 @@ async def run(self) -> None: class StandardOutput(Output): + """ + Write out to the console. + """ + @staticmethod def consumes_outputs() -> set[type[OutputEvent]]: + """ + Takes lines to write out to the active console. + + :return: + """ return {ConsoleOutputLine} async def output(self, event: OutputEvent) -> bool: + """ + Just uses the print command to write out to the console. + + :param event: + :return: + """ print(event) return True From c3c36c8ab043740da910d00c53bbfcc93a5f2a2e Mon Sep 17 00:00:00 2001 From: ajCameron Date: Mon, 21 Aug 2023 23:15:06 +0100 Subject: [PATCH 11/20] Added Windows handling, first example - naturally the linux version of async reading from the console did not work on windows - and visa-versa - added new windows specific method to account for this - added example - making a start on tests --- examples/console_io.yaml | 27 +++++++++++++++++++++++++++ src/mewbot/io/console.py | 31 ++++++++++++++++++++++++++++++- tests/io/test_io_console.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 examples/console_io.yaml create mode 100644 tests/io/test_io_console.py diff --git a/examples/console_io.yaml b/examples/console_io.yaml new file mode 100644 index 00000000..8c2f28f8 --- /dev/null +++ b/examples/console_io.yaml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2021 - 2023 Mewbot Developers +# +# SPDX-License-Identifier: CC-BY-4.0 + +kind: IOConfig +implementation: mewbot.io.console.StandardConsoleInputOutput +uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa00 +properties: {} + +--- + +kind: Behaviour +implementation: mewbot.api.v1.Behaviour +uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa01 +properties: + name: 'Print "world" on receiving "!hello"' +triggers: + - kind: Trigger + implementation: mewbot.io.common.AllEventTrigger + uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa02 + properties: {} +conditions: [] +actions: + - kind: Action + implementation: mewbot.io.common.PrintAction + uuid: aaaaaaaa-aaaa-4aaa-0001-aaaaaaaaaa03 + properties: {} diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 3b0d7940..125232b0 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -52,6 +52,14 @@ def __init__(self, message: str) -> None: """ self.message = message + def __str__(self) -> str: + """ + Str rep of this event. + + :return: + """ + return f"ConsoleInputLine: \"{self.message}\"" + def get_sender_name(self) -> str: """ Returns the human friend name/nickname of the user who sent the event. @@ -134,7 +142,7 @@ def produces_inputs() -> set[type[InputEvent]]: @staticmethod async def connect_stdin_stdout() -> asyncio.StreamReader: """ - Async compatible - non blocking - console line reader. + Async compatible - non-blocking - console line reader. :return: """ @@ -149,6 +157,17 @@ async def run(self) -> None: """ Process input typed at the console and convert it to InputEvents on the wire. + :return: + """ + if os.name.lower() != "nt": + await self._linux_run() + else: + await self._windows_run() + + async def _linux_run(self) -> None: + """ + Linux version of the async reader. + :return: """ reader = await self.connect_stdin_stdout() @@ -156,6 +175,16 @@ async def run(self) -> None: if self.queue: await self.queue.put(ConsoleInputLine(line.decode())) + async def _windows_run(self) -> None: + """ + Windows version of the async reader. + + :return: + """ + while line := await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline): + if self.queue: + await self.queue.put(ConsoleInputLine(line)) + class StandardOutput(Output): """ diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py new file mode 100644 index 00000000..8f718028 --- /dev/null +++ b/tests/io/test_io_console.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2021 - 2023 Mewbot Developers +# +# SPDX-License-Identifier: BSD-2-Clause + +""" +Tests for the Console IO configuration. +""" + +from typing import Type + +import asyncio + +import pytest + +from mewbot.api.v1 import IOConfig +from mewbot.core import InputEvent, InputQueue +from mewbot.io.console import StandardConsoleInputOutput, ConsoleInputLine, ConsoleOutputLine +from mewbot.test import BaseTestClassWithConfig + + +class TestRSSIO(BaseTestClassWithConfig[StandardConsoleInputOutput]): + """ + Tests for the RSS IO configuration. + + Load a bot with an RSSInput - this should yield a fully loaded RSSIO config. + Which can then be tested. + """ + + config_file: str = "examples/console_io.yaml" + implementation: Type[StandardConsoleInputOutput] = StandardConsoleInputOutput + + def test_check_class(self) -> None: + """Confirm the configuration has been correctly loaded.""" + + assert isinstance(self.component, StandardConsoleInputOutput) + assert isinstance(self.component, IOConfig) From b0b2df16c9eaaa0ab560dd6ea7b681c8e2b79417 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 02:40:21 +0100 Subject: [PATCH 12/20] Adding tests for the console output - tricks with overloading sys.stdout seem to be causing issues with pytest - as such, the tests are more basic than I'd like... --- ...{console_io.yaml => console_io_print.yaml} | 0 src/mewbot/io/console.py | 9 +- tests/io/test_io_console.py | 132 +++++++++++++++++- 3 files changed, 133 insertions(+), 8 deletions(-) rename examples/{console_io.yaml => console_io_print.yaml} (100%) diff --git a/examples/console_io.yaml b/examples/console_io_print.yaml similarity index 100% rename from examples/console_io.yaml rename to examples/console_io_print.yaml diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 125232b0..1a03b0e0 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -58,7 +58,7 @@ def __str__(self) -> str: :return: """ - return f"ConsoleInputLine: \"{self.message}\"" + return f'ConsoleInputLine: "{self.message}"' def get_sender_name(self) -> str: """ @@ -148,8 +148,7 @@ async def connect_stdin_stdout() -> asyncio.StreamReader: """ loop = asyncio.get_event_loop() reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - await loop.connect_read_pipe(lambda: protocol, sys.stdin) + await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin) return reader @@ -181,7 +180,9 @@ async def _windows_run(self) -> None: :return: """ - while line := await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline): + while line := await asyncio.get_event_loop().run_in_executor( + None, sys.stdin.readline + ): if self.queue: await self.queue.put(ConsoleInputLine(line)) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 8f718028..e0cf2b60 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -2,19 +2,27 @@ # # SPDX-License-Identifier: BSD-2-Clause +# pylint:disable=protected-access + """ Tests for the Console IO configuration. """ - from typing import Type import asyncio +import os import pytest from mewbot.api.v1 import IOConfig -from mewbot.core import InputEvent, InputQueue -from mewbot.io.console import StandardConsoleInputOutput, ConsoleInputLine, ConsoleOutputLine +from mewbot.core import InputEvent +from mewbot.io.console import ( + ConsoleInputLine, + ConsoleOutputLine, + StandardConsoleInputOutput, + StandardInput, + StandardOutput, +) from mewbot.test import BaseTestClassWithConfig @@ -26,7 +34,7 @@ class TestRSSIO(BaseTestClassWithConfig[StandardConsoleInputOutput]): Which can then be tested. """ - config_file: str = "examples/console_io.yaml" + config_file: str = "examples/console_io_print.yaml" implementation: Type[StandardConsoleInputOutput] = StandardConsoleInputOutput def test_check_class(self) -> None: @@ -34,3 +42,119 @@ def test_check_class(self) -> None: assert isinstance(self.component, StandardConsoleInputOutput) assert isinstance(self.component, IOConfig) + + def test_get_inputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + assert isinstance(self.component.get_inputs(), list) + assert len(list(self.component.get_inputs())) == 1 + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + def test_get_outputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + assert isinstance(self.component.get_outputs(), list) + assert len(list(self.component.get_outputs())) == 1 + + cand_input = list(self.component.get_outputs())[0] + + assert isinstance(cand_input, StandardOutput) + + def test_inputs(self) -> None: + """ + Checks the get_inputs method of the IOConfig. + + :return: + """ + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + assert isinstance(cand_input.produces_inputs(), set) + for cand_obj in cand_input.produces_inputs(): + assert issubclass(cand_obj, InputEvent) + + def test_console_input_line_methods(self) -> None: + """ + Tests the ConsoleInputLineEvent input event. + + :return: + """ + test_event = ConsoleInputLine(message="this is a test") + assert test_event.message == "this is a test" + + assert isinstance(str(test_event), str) + assert "this is a test" in str(test_event) + + assert isinstance(test_event.get_sender_name(), str) + assert isinstance(test_event.get_sender_mention(), str) + + assert isinstance(test_event.prepare_reply("this is a thing"), ConsoleOutputLine) + assert isinstance( + test_event.prepare_reply_narrowest_scope("this is a thing"), ConsoleOutputLine + ) + + def test_console_output_line_methods(self) -> None: + """ + Tests the ConsoleOutputLineEvent. + + :return: + """ + test_output_event = ConsoleOutputLine(message="This is a test") + assert test_output_event.message == "This is a test" + + assert isinstance(str(test_output_event), str), "rep should be a string" + assert "This is a test" in str(test_output_event) + + @pytest.mark.asyncio + async def test_input_runs(self) -> None: + """ + Tests that an input actually runs. + + :return: + """ + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + # pytest doesn't like trying to read from stdio during tests + try: + await asyncio.wait_for(cand_input.run(), timeout=2) + except OSError: + pass + + # Directly run the linux version - on Windows this should fail + if os.name.lower() == "nt": + try: + await asyncio.wait_for(cand_input._linux_run(), timeout=2) + except asyncio.exceptions.TimeoutError: + pass + else: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + + @pytest.mark.asyncio + async def test_output_runs(self) -> None: + """ + Tests that an output actually runs... + + :return: + """ + + cand_output = list(self.component.get_outputs())[0] + + assert isinstance(cand_output, StandardOutput) + + test_event = ConsoleOutputLine(message="this is a test") + + # pytest doesn't like trying to read from stdio during tests + await asyncio.wait_for(cand_output.output(event=test_event), timeout=2) From 18bdc167e00b9b6792b98274b7f4943586fad793 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 02:46:37 +0100 Subject: [PATCH 13/20] Improving exception handling to deal with ubuntu and macos problems --- tests/io/test_io_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index e0cf2b60..6e4ce273 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -137,7 +137,7 @@ async def test_input_runs(self) -> None: if os.name.lower() == "nt": try: await asyncio.wait_for(cand_input._linux_run(), timeout=2) - except asyncio.exceptions.TimeoutError: + except (AttributeError, asyncio.exceptions.TimeoutError): pass else: await asyncio.wait_for(cand_input._windows_run(), timeout=2) From 05aad1d876ac39662f1343d32700e66061f268d8 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:09:59 +0100 Subject: [PATCH 14/20] Further work on exceptions which can occur on macos --- tests/io/test_io_console.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 6e4ce273..b077dfd8 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -140,7 +140,10 @@ async def test_input_runs(self) -> None: except (AttributeError, asyncio.exceptions.TimeoutError): pass else: - await asyncio.wait_for(cand_input._windows_run(), timeout=2) + try: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + except OSError: + pass @pytest.mark.asyncio async def test_output_runs(self) -> None: From e9ae3a26e0ff16d8f9535e4a5f8516cf2a6f9848 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:36:21 +0100 Subject: [PATCH 15/20] swapping out os.getlogin for getpass.getuser - should be more universal --- src/mewbot/io/console.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 1a03b0e0..bc4e724d 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -8,6 +8,7 @@ from typing import Iterable import asyncio +import getpass import os import sys @@ -64,7 +65,8 @@ def get_sender_name(self) -> str: """ Returns the human friend name/nickname of the user who sent the event. """ - return os.getlogin() + # Seems to be more universal than os.getlogin() + return getpass.getuser() def get_sender_mention(self) -> str: """ @@ -73,7 +75,7 @@ def get_sender_mention(self) -> str: If the reply methods will automatically ping the user, this may just be the human-readable username. """ - return os.getlogin() + return getpass.getuser() def prepare_reply(self, message: str) -> OutputEvent: """ From f1f659f69d7fc3fc744fc4cac5d4b655ca1612ae Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 03:46:15 +0100 Subject: [PATCH 16/20] aiming to boost test coverage ... slightly --- tests/io/test_io_console.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index b077dfd8..96182bb6 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -65,9 +65,11 @@ def test_get_outputs(self) -> None: assert isinstance(self.component.get_outputs(), list) assert len(list(self.component.get_outputs())) == 1 - cand_input = list(self.component.get_outputs())[0] + cand_output = list(self.component.get_outputs())[0] + + assert isinstance(cand_output, StandardOutput) - assert isinstance(cand_input, StandardOutput) + assert isinstance(cand_output.consumes_outputs(), set) def test_inputs(self) -> None: """ From 0aebcd87885cf38297c358538ec0375c353c86b0 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 04:14:28 +0100 Subject: [PATCH 17/20] Adding the capacity to swap the stdin file handler in the input class - conceivably actually useful beyond testing - for monitoring arbitrary file handles --- src/mewbot/io/console.py | 18 ++++++++++++++---- tests/io/test_io_console.py | 14 +++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index bc4e724d..ce0f359e 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -5,7 +5,7 @@ """ -from typing import Iterable +from typing import Iterable, TextIO import asyncio import getpass @@ -132,6 +132,15 @@ class StandardInput(Input): Reads lines from the console ever time the user enters one. """ + class_stdin: TextIO + + def __init__(self) -> None: + """ + Startup the Input - settting the stdin. + """ + super().__init__() + self.class_stdin = sys.stdin + @staticmethod def produces_inputs() -> set[type[InputEvent]]: """ @@ -141,8 +150,7 @@ def produces_inputs() -> set[type[InputEvent]]: """ return {ConsoleInputLine} - @staticmethod - async def connect_stdin_stdout() -> asyncio.StreamReader: + async def connect_stdin_stdout(self) -> asyncio.StreamReader: """ Async compatible - non-blocking - console line reader. @@ -150,7 +158,9 @@ async def connect_stdin_stdout() -> asyncio.StreamReader: """ loop = asyncio.get_event_loop() reader = asyncio.StreamReader() - await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin) + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), self.class_stdin + ) return reader diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 96182bb6..1e2eebb4 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -7,9 +7,10 @@ """ Tests for the Console IO configuration. """ -from typing import Type +from typing import TextIO, Type import asyncio +import io import os import pytest @@ -117,6 +118,11 @@ def test_console_output_line_methods(self) -> None: assert isinstance(str(test_output_event), str), "rep should be a string" assert "This is a test" in str(test_output_event) + @staticmethod + async def _add_some_input(local_stdin: TextIO) -> None: + await asyncio.sleep(0.5) + local_stdin.write("Not sure this will work...\n") + @pytest.mark.asyncio async def test_input_runs(self) -> None: """ @@ -129,6 +135,12 @@ async def test_input_runs(self) -> None: assert isinstance(cand_input, StandardInput) + # Replace the file handler in the class + local_stdin = io.StringIO() + cand_input.class_stdin = local_stdin + + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + # pytest doesn't like trying to read from stdio during tests try: await asyncio.wait_for(cand_input.run(), timeout=2) From bdb52bb034dd56429abe924ce30f2cf1d612db07 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 16:21:54 +0100 Subject: [PATCH 18/20] Need isort run --- tests/io/test_io_console.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 24abb52f..1e2eebb4 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -11,9 +11,6 @@ import asyncio import io -from typing import Type - -import asyncio import os import pytest From de2cd0f8574cbc696951d332a1cf6aa3f8d2f6cb Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 17:15:17 +0100 Subject: [PATCH 19/20] Restructuring to (hopefully) take sonarcloud over the top - moving putting strings on the queue to a common function --- src/mewbot/io/console.py | 21 ++++-- tests/io/test_io_console.py | 143 +++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 7 deletions(-) diff --git a/src/mewbot/io/console.py b/src/mewbot/io/console.py index 231d19f6..5879ebb0 100644 --- a/src/mewbot/io/console.py +++ b/src/mewbot/io/console.py @@ -133,12 +133,15 @@ class StandardInput(Input): class_stdin: TextIO + os_name: str + def __init__(self) -> None: """ Startup the Input - settting the stdin. """ super().__init__() self.class_stdin = sys.stdin + self.os_name = os.name.lower() @staticmethod def produces_inputs() -> set[type[InputEvent]]: @@ -169,7 +172,7 @@ async def run(self) -> None: :return: """ - if os.name.lower() != "nt": + if self.os_name != "nt": await self._linux_run() else: await self._windows_run() @@ -182,8 +185,7 @@ async def _linux_run(self) -> None: """ reader = await self.connect_stdin_stdout() while line := await reader.readline(): - if self.queue: - await self.queue.put(ConsoleInputLine(line.decode())) + await self.put_on_queue(line.decode()) async def _windows_run(self) -> None: """ @@ -194,8 +196,17 @@ async def _windows_run(self) -> None: while line := await asyncio.get_event_loop().run_in_executor( None, sys.stdin.readline ): - if self.queue: - await self.queue.put(ConsoleInputLine(line)) + await self.put_on_queue(line) + + async def put_on_queue(self, input_line: str) -> None: + """ + Put an output event on the queue. + + :param str: + :return: + """ + if self.queue: + await self.queue.put(ConsoleInputLine(input_line)) class StandardOutput(Output): diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index 1e2eebb4..f6a07202 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -12,11 +12,12 @@ import asyncio import io import os +import tempfile import pytest from mewbot.api.v1 import IOConfig -from mewbot.core import InputEvent +from mewbot.core import InputEvent, InputQueue from mewbot.io.console import ( ConsoleInputLine, ConsoleOutputLine, @@ -124,7 +125,7 @@ async def _add_some_input(local_stdin: TextIO) -> None: local_stdin.write("Not sure this will work...\n") @pytest.mark.asyncio - async def test_input_runs(self) -> None: + async def test_input_runs_with_io_stringio(self) -> None: """ Tests that an input actually runs. @@ -139,6 +140,8 @@ async def test_input_runs(self) -> None: local_stdin = io.StringIO() cand_input.class_stdin = local_stdin + cand_input.queue = InputQueue() + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) # pytest doesn't like trying to read from stdio during tests @@ -148,6 +151,142 @@ async def test_input_runs(self) -> None: pass # Directly run the linux version - on Windows this should fail + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + if os.name.lower() == "nt": + try: + await asyncio.wait_for(cand_input._linux_run(), timeout=2) + except (AttributeError, asyncio.exceptions.TimeoutError): + pass + else: + try: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + except OSError: + pass + + @pytest.mark.asyncio + async def test_input_runs_with_io_true_file_handle(self) -> None: + """ + Tests that an input actually runs. + + :return: + """ + + with tempfile.TemporaryDirectory() as tmp_dir_path: + cand_input = list(self.component.get_inputs())[0] + assert isinstance(cand_input, StandardInput) + + target_input_file = os.path.join(tmp_dir_path, "target_file.txt") + + with open(target_input_file, "w", encoding="utf-8") as test_file: + test_file.write("This is a test") + + with open(target_input_file, "r+", encoding="utf-8") as local_stdin: + # Replace the file handler in the class + cand_input.class_stdin = local_stdin + + cand_input.queue = InputQueue() + + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + # pytest doesn't like trying to read from stdio during tests + try: + await asyncio.wait_for(cand_input.run(), timeout=2) + except OSError: + pass + + # Write back out + with open(target_input_file, "w", encoding="utf-8") as test_file: + test_file.write("This is a test") + + # Directly run the linux version - on Windows this should fail + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + if os.name.lower() == "nt": + try: + await asyncio.wait_for(cand_input._linux_run(), timeout=2) + except (AttributeError, asyncio.exceptions.TimeoutError): + pass + else: + try: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + except OSError: + pass + + @pytest.mark.asyncio + async def test_input_runs_force_nt(self) -> None: + """ + Tests that an input actually runs. + + :return: + """ + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + # Replace the file handler in the class + local_stdin = io.StringIO() + cand_input.class_stdin = local_stdin + + cand_input.os_name = "nt" + + cand_input.queue = InputQueue() + + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + # pytest doesn't like trying to read from stdio during tests + try: + await asyncio.wait_for(cand_input.run(), timeout=2) + except OSError: + pass + + # Directly run the linux version - on Windows this should fail + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + if os.name.lower() == "nt": + try: + await asyncio.wait_for(cand_input._linux_run(), timeout=2) + except (AttributeError, asyncio.exceptions.TimeoutError): + pass + else: + try: + await asyncio.wait_for(cand_input._windows_run(), timeout=2) + except OSError: + pass + + @pytest.mark.asyncio + async def test_input_runs_force_unix(self) -> None: + """ + Tests that an input actually runs. + + :return: + """ + + cand_input = list(self.component.get_inputs())[0] + + assert isinstance(cand_input, StandardInput) + + # Load a queue as well + cand_input.queue = InputQueue() + + # Replace the file handler in the class + local_stdin = io.StringIO() + cand_input.class_stdin = local_stdin + + cand_input.os_name = "unix" + + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + + # pytest doesn't like trying to read from stdio during tests + try: + await asyncio.wait_for(cand_input.run(), timeout=2) + except (OSError, asyncio.exceptions.TimeoutError): + pass + + # Directly run the linux version - on Windows this should fail + await asyncio.wait_for(self._add_some_input(local_stdin), timeout=2) + if os.name.lower() == "nt": try: await asyncio.wait_for(cand_input._linux_run(), timeout=2) From 51d1911a1c4663ab117ce773bee702681adc7211 Mon Sep 17 00:00:00 2001 From: ajCameron Date: Tue, 22 Aug 2023 19:15:11 +0100 Subject: [PATCH 20/20] Naturally different versions of python have different error handling on different OSs - beefing up error handling --- tests/io/test_io_console.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/io/test_io_console.py b/tests/io/test_io_console.py index f6a07202..88b90fdb 100644 --- a/tests/io/test_io_console.py +++ b/tests/io/test_io_console.py @@ -192,7 +192,7 @@ async def test_input_runs_with_io_true_file_handle(self) -> None: # pytest doesn't like trying to read from stdio during tests try: await asyncio.wait_for(cand_input.run(), timeout=2) - except OSError: + except (OSError, ValueError): pass # Write back out @@ -205,7 +205,7 @@ async def test_input_runs_with_io_true_file_handle(self) -> None: if os.name.lower() == "nt": try: await asyncio.wait_for(cand_input._linux_run(), timeout=2) - except (AttributeError, asyncio.exceptions.TimeoutError): + except (AttributeError, asyncio.exceptions.TimeoutError, ValueError): pass else: try: