From f9c2194e284569f939ddea84e185b1e1099442e6 Mon Sep 17 00:00:00 2001 From: Niall Byrne Date: Wed, 9 Feb 2022 17:37:32 +0000 Subject: [PATCH] feat(CLI): logging verbosity control --- pi_portal/cli.py | 25 ++++--- pi_portal/commands/door_monitor.py | 3 +- pi_portal/commands/mixins/__init__.py | 0 pi_portal/commands/mixins/state.py | 19 ++++++ pi_portal/commands/mixins/tests/__init__.py | 0 pi_portal/commands/mixins/tests/test_state.py | 33 +++++++++ pi_portal/commands/slack_bot.py | 3 +- pi_portal/commands/tests/test_door_monitor.py | 4 ++ pi_portal/commands/tests/test_slack_bot.py | 4 ++ .../commands/tests/test_upload_snapshot.py | 4 ++ pi_portal/commands/tests/test_upload_video.py | 4 ++ pi_portal/commands/upload_snapshot.py | 5 +- pi_portal/commands/upload_video.py | 5 +- pi_portal/modules/configuration/logger.py | 3 +- pi_portal/modules/configuration/state.py | 20 ++++++ .../tests/fixtures/mock_state.py | 9 ++- .../modules/configuration/tests/test_state.py | 30 ++++++--- pi_portal/tests/test_cli.py | 67 +++++++++++-------- 18 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 pi_portal/commands/mixins/__init__.py create mode 100644 pi_portal/commands/mixins/state.py create mode 100644 pi_portal/commands/mixins/tests/__init__.py create mode 100644 pi_portal/commands/mixins/tests/test_state.py diff --git a/pi_portal/cli.py b/pi_portal/cli.py index 002aaae1..29274bac 100644 --- a/pi_portal/cli.py +++ b/pi_portal/cli.py @@ -9,54 +9,63 @@ upload_video, version, ) -from .modules.configuration import state @click.group() -def cli() -> None: +@click.option('--debug', default=False, is_flag=True, help='Enable debug logs.') +@click.pass_context +def cli(ctx: click.Context, debug: bool) -> None: """Door Monitor CLI.""" - running_state = state.State() - running_state.load() + ctx.ensure_object(dict) + ctx.obj['DEBUG'] = debug @cli.command("monitor") -def monitor_command() -> None: +@click.pass_context +def monitor_command(ctx: click.Context) -> None: """Begin monitoring the door.""" command = door_monitor.DoorMonitorCommand() + command.load_state(debug=ctx.obj['DEBUG']) command.invoke() @cli.command("slack_bot") -def slack_bot_command() -> None: +@click.pass_context +def slack_bot_command(ctx: click.Context) -> None: """Connect the interactive Slack bot.""" command = slack_bot.SlackBotCommand() + command.load_state(debug=ctx.obj['DEBUG']) command.invoke() @cli.command("upload_snapshot") @click.argument('filename', type=click.Path(exists=True)) -def upload_snapshot_command(filename: str) -> None: +@click.pass_context +def upload_snapshot_command(ctx: click.Context, filename: str) -> None: """Upload a snapshot image to Slack. FILENAME: The path to the image file to upload. """ command = upload_snapshot.UploadSnapshotCommand(filename) + command.load_state(debug=ctx.obj['DEBUG']) command.invoke() @cli.command("upload_video") @click.argument('filename', type=click.Path(exists=True)) -def upload_video_command(filename: str) -> None: +@click.pass_context +def upload_video_command(ctx: click.Context, filename: str) -> None: """Upload a video to Slack and S3. FILENAME: The path to the video file to upload. """ command = upload_video.UploadVideoCommand(filename) + command.load_state(debug=ctx.obj['DEBUG']) command.invoke() diff --git a/pi_portal/commands/door_monitor.py b/pi_portal/commands/door_monitor.py index e14ef5ed..6ea44f37 100644 --- a/pi_portal/commands/door_monitor.py +++ b/pi_portal/commands/door_monitor.py @@ -2,9 +2,10 @@ from pi_portal.modules.integrations import gpio from .bases import command +from .mixins import state -class DoorMonitorCommand(command.CommandBase): +class DoorMonitorCommand(command.CommandBase, state.CommandManagedStateMixin): """CLI command to start the Door Monitor.""" def invoke(self) -> None: diff --git a/pi_portal/commands/mixins/__init__.py b/pi_portal/commands/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pi_portal/commands/mixins/state.py b/pi_portal/commands/mixins/state.py new file mode 100644 index 00000000..2aab11ae --- /dev/null +++ b/pi_portal/commands/mixins/state.py @@ -0,0 +1,19 @@ +"""CommandBase mixin to provide configured running state.""" + +import logging + +from pi_portal.modules.configuration import state + + +class CommandManagedStateMixin: + """Provide configured state to a CLI command.""" + + def load_state(self, debug: bool) -> None: + """Load and configure state. + + :param debug: Enable or disable debug logs. + """ + running_state = state.State() + running_state.load() + if debug: + running_state.log_level = logging.DEBUG diff --git a/pi_portal/commands/mixins/tests/__init__.py b/pi_portal/commands/mixins/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pi_portal/commands/mixins/tests/test_state.py b/pi_portal/commands/mixins/tests/test_state.py new file mode 100644 index 00000000..3aec942e --- /dev/null +++ b/pi_portal/commands/mixins/tests/test_state.py @@ -0,0 +1,33 @@ +"""Test the CommandManagedStateMixin class.""" + +import logging +from unittest import TestCase, mock + +from .. import state + + +class TestLoadStateCommand(TestCase): + """Test the CommandManagedStateMixin class.""" + + def setUp(self) -> None: + self.instance = state.CommandManagedStateMixin() + + @mock.patch(state.__name__ + ".state") + def test_load_state(self, m_state: mock.Mock) -> None: + self.instance.load_state(False) + m_state.State.assert_called_once_with() + m_state.State.return_value.load.assert_called_once_with() + self.assertNotEqual( + m_state.State.return_value.log_level, + logging.DEBUG, + ) + + @mock.patch(state.__name__ + ".state") + def test_load_state_debug_logging(self, m_state: mock.Mock) -> None: + self.instance.load_state(True) + m_state.State.assert_called_once_with() + m_state.State.return_value.load.assert_called_once_with() + self.assertEqual( + m_state.State.return_value.log_level, + logging.DEBUG, + ) diff --git a/pi_portal/commands/slack_bot.py b/pi_portal/commands/slack_bot.py index 2fd70b5c..df1bbf7c 100644 --- a/pi_portal/commands/slack_bot.py +++ b/pi_portal/commands/slack_bot.py @@ -2,9 +2,10 @@ from pi_portal.modules.integrations import slack from .bases import command +from .mixins import state -class SlackBotCommand(command.CommandBase): +class SlackBotCommand(command.CommandBase, state.CommandManagedStateMixin): """CLI command to start the Slack bot.""" def invoke(self) -> None: diff --git a/pi_portal/commands/tests/test_door_monitor.py b/pi_portal/commands/tests/test_door_monitor.py index 2b781770..5d49655e 100644 --- a/pi_portal/commands/tests/test_door_monitor.py +++ b/pi_portal/commands/tests/test_door_monitor.py @@ -4,6 +4,7 @@ from pi_portal.commands.bases.tests.fixtures import command_harness from .. import door_monitor +from ..mixins import state class TestDoorMonitorCommand(command_harness.CommandBaseTestHarness): @@ -15,6 +16,9 @@ class TestDoorMonitorCommand(command_harness.CommandBaseTestHarness): def setUpClass(cls) -> None: cls.test_class = door_monitor.DoorMonitorCommand + def test_mixins(self) -> None: + self.assertIsInstance(self.instance, state.CommandManagedStateMixin) + @mock.patch(door_monitor.__name__ + ".gpio") def test_invoke(self, m_module: mock.Mock) -> None: diff --git a/pi_portal/commands/tests/test_slack_bot.py b/pi_portal/commands/tests/test_slack_bot.py index 9dc60383..555402c7 100644 --- a/pi_portal/commands/tests/test_slack_bot.py +++ b/pi_portal/commands/tests/test_slack_bot.py @@ -4,6 +4,7 @@ from pi_portal.commands.bases.tests.fixtures import command_harness from .. import slack_bot +from ..mixins import state class TestSlackBotCommand(command_harness.CommandBaseTestHarness): @@ -15,6 +16,9 @@ class TestSlackBotCommand(command_harness.CommandBaseTestHarness): def setUpClass(cls) -> None: cls.test_class = slack_bot.SlackBotCommand + def test_mixins(self) -> None: + self.assertIsInstance(self.instance, state.CommandManagedStateMixin) + @mock.patch(slack_bot.__name__ + ".slack") def test_invoke(self, m_module: mock.Mock) -> None: diff --git a/pi_portal/commands/tests/test_upload_snapshot.py b/pi_portal/commands/tests/test_upload_snapshot.py index cbdd1c7d..cb87b93b 100644 --- a/pi_portal/commands/tests/test_upload_snapshot.py +++ b/pi_portal/commands/tests/test_upload_snapshot.py @@ -4,6 +4,7 @@ from pi_portal.commands.bases.tests.fixtures import file_command_harness from .. import upload_snapshot +from ..mixins import state class TestUploadSnapshotCommand( @@ -20,6 +21,9 @@ class TestUploadSnapshotCommand( def setUpClass(cls) -> None: cls.test_class = upload_snapshot.UploadSnapshotCommand + def test_mixins(self) -> None: + self.assertIsInstance(self.instance, state.CommandManagedStateMixin) + @mock.patch(upload_snapshot.__name__ + ".slack") def test_invoke(self, m_module: mock.Mock) -> None: self.instance.invoke() diff --git a/pi_portal/commands/tests/test_upload_video.py b/pi_portal/commands/tests/test_upload_video.py index fbcb08b1..bf98b528 100644 --- a/pi_portal/commands/tests/test_upload_video.py +++ b/pi_portal/commands/tests/test_upload_video.py @@ -4,6 +4,7 @@ from pi_portal.commands.bases.tests.fixtures import file_command_harness from .. import upload_video +from ..mixins import state class TestUploadVideoCommand(file_command_harness.FileCommandBaseTestHarness): @@ -18,6 +19,9 @@ class TestUploadVideoCommand(file_command_harness.FileCommandBaseTestHarness): def setUpClass(cls) -> None: cls.test_class = upload_video.UploadVideoCommand + def test_mixins(self) -> None: + self.assertIsInstance(self.instance, state.CommandManagedStateMixin) + @mock.patch(upload_video.__name__ + ".slack") def test_invoke(self, m_module: mock.Mock) -> None: diff --git a/pi_portal/commands/upload_snapshot.py b/pi_portal/commands/upload_snapshot.py index 6ef62690..bca20bb5 100644 --- a/pi_portal/commands/upload_snapshot.py +++ b/pi_portal/commands/upload_snapshot.py @@ -2,9 +2,12 @@ from pi_portal.modules.integrations import slack from .bases import file_command +from .mixins import state -class UploadSnapshotCommand(file_command.FileCommandBase): +class UploadSnapshotCommand( + file_command.FileCommandBase, state.CommandManagedStateMixin +): """CLI command to send a Motion snapshot to Slack.""" def invoke(self) -> None: diff --git a/pi_portal/commands/upload_video.py b/pi_portal/commands/upload_video.py index efe9e29e..3be04031 100644 --- a/pi_portal/commands/upload_video.py +++ b/pi_portal/commands/upload_video.py @@ -2,9 +2,12 @@ from pi_portal.modules.integrations import slack from .bases import file_command +from .mixins import state -class UploadVideoCommand(file_command.FileCommandBase): +class UploadVideoCommand( + file_command.FileCommandBase, state.CommandManagedStateMixin +): """CLI command to send a Motion video to Slack and S3.""" def invoke(self) -> None: diff --git a/pi_portal/modules/configuration/logger.py b/pi_portal/modules/configuration/logger.py index c3200d84..7e9321d8 100644 --- a/pi_portal/modules/configuration/logger.py +++ b/pi_portal/modules/configuration/logger.py @@ -8,10 +8,9 @@ class LoggingConfiguration: """Pi Portal logging configuration.""" - level = logging.DEBUG - def __init__(self) -> None: running_state = state.State() + self.level = running_state.log_level self.formatter = logging.Formatter( "%(asctime)s [ " + running_state.log_uuid + " ] [ %(levelname)s ] %(message)s" diff --git a/pi_portal/modules/configuration/state.py b/pi_portal/modules/configuration/state.py index c4928395..e56c1724 100644 --- a/pi_portal/modules/configuration/state.py +++ b/pi_portal/modules/configuration/state.py @@ -1,5 +1,6 @@ """Borg monostate of the current running configuration.""" +import logging import uuid from typing import Any, Dict, cast @@ -19,6 +20,25 @@ def __init__(self) -> None: if not self.__shared_state: self.user_config: TypeUserConfig = cast(TypeUserConfig, {}) self.log_uuid = str(uuid.uuid4()) + self._log_level = logging.INFO + + @property + def log_level(self) -> int: + """Return the currently configured logging level. + + :returns: The currently configured logging level. + """ + + return self._log_level + + @log_level.setter + def log_level(self, level: int) -> None: + """Configure the logging level. + + :param level: The desired logging level. + """ + + self._log_level = level def load(self) -> None: """Load the end user configuration.""" diff --git a/pi_portal/modules/configuration/tests/fixtures/mock_state.py b/pi_portal/modules/configuration/tests/fixtures/mock_state.py index be51628f..5682ff16 100644 --- a/pi_portal/modules/configuration/tests/fixtures/mock_state.py +++ b/pi_portal/modules/configuration/tests/fixtures/mock_state.py @@ -1,5 +1,5 @@ """Fixtures for mocking required environment variables.""" - +import logging from typing import Any, Callable, TypeVar from unittest import mock @@ -13,6 +13,7 @@ MOCK_SLACK_TOKEN = "secretValue" MOCK_S3_BUCKET_NAME = 'MOCK_S3_BUCKET_NAME' MOCK_LOG_UUID = "MOCK_UUID_VALUE" +MOCK_LOG_LEVEL = logging.DEBUG TypeReturn = TypeVar("TypeReturn") @@ -23,7 +24,8 @@ def patched_function(*args: Any, **kwargs: Any) -> TypeReturn: with mock.patch(state.__name__ + ".State") as mock_state: - mock_state.return_value.user_config = { + mock_state_instance = mock_state.return_value + mock_state_instance.user_config = { "AWS_ACCESS_KEY_ID": MOCK_AWS_ACCESS_KEY_ID, "AWS_SECRET_ACCESS_KEY": MOCK_AWS_SECRET_ACCESS_KEY, "LOGZ_IO_CODE": MOCK_LOGZ_IO_CODE, @@ -36,7 +38,8 @@ def patched_function(*args: Any, **kwargs: Any) -> TypeReturn: "GPIO": 5, }] } - mock_state.return_value.log_uuid = MOCK_LOG_UUID + mock_state_instance.log_uuid = MOCK_LOG_UUID + mock_state_instance.log_level = MOCK_LOG_LEVEL return func(*args, **kwargs) diff --git a/pi_portal/modules/configuration/tests/test_state.py b/pi_portal/modules/configuration/tests/test_state.py index 2d3cb243..e086c39d 100644 --- a/pi_portal/modules/configuration/tests/test_state.py +++ b/pi_portal/modules/configuration/tests/test_state.py @@ -1,5 +1,6 @@ """Test RunningConfig monostate.""" +import logging from typing import cast from unittest import TestCase, mock @@ -10,21 +11,34 @@ class TestRunningConfig(TestCase): """Test the RunningConfig monostate.""" def setUp(self) -> None: - self.state = state.State() + self.instance = state.State() + + def test_instantiate(self) -> None: + instance = state.State() + self.assertEqual(instance.user_config, {}) + self.assertEqual(instance.log_level, logging.INFO) + self.assertIsInstance(instance.log_uuid, str) + + def test_debug_enabled(self) -> None: + self.instance.log_level = logging.DEBUG + self.assertEqual(self.instance.log_level, logging.DEBUG) + self.instance.log_level = logging.INFO def test_mono_state_user_config(self) -> None: - self.state.user_config = cast( + self.instance.user_config = cast( user_config.TypeUserConfig, {'test': 'value'} ) instance2 = state.State() - self.assertEqual(self.state.user_config, getattr(instance2, 'user_config')) + self.assertEqual( + self.instance.user_config, getattr(instance2, 'user_config') + ) def test_mono_state_log_uuid(self) -> None: - self.state.log_uuid = "test id" + self.instance.log_uuid = "test id" instance2 = state.State() - self.assertEqual(self.state.log_uuid, getattr(instance2, 'log_uuid')) + self.assertEqual(self.instance.log_uuid, getattr(instance2, 'log_uuid')) @mock.patch(state.__name__ + ".UserConfiguration") def test_load_config(self, m_user_config: mock.Mock) -> None: @@ -33,10 +47,10 @@ def test_load_config(self, m_user_config: mock.Mock) -> None: } m_user_config.return_value.user_config = mock_config - self.state.load() + self.instance.load() m_user_config.return_value.load.assert_called_once_with() - self.assertEqual(self.state.user_config, mock_config) + self.assertEqual(self.instance.user_config, mock_config) instance2 = state.State() - self.assertEqual(self.state.user_config, instance2.user_config) + self.assertEqual(self.instance.user_config, instance2.user_config) diff --git a/pi_portal/tests/test_cli.py b/pi_portal/tests/test_cli.py index ecf3b36e..a3e39f58 100644 --- a/pi_portal/tests/test_cli.py +++ b/pi_portal/tests/test_cli.py @@ -1,5 +1,6 @@ """Tests for the Click CLI.""" +from typing import List, Tuple from unittest import TestCase from unittest.mock import Mock, patch @@ -7,91 +8,99 @@ from .. import cli -@patch(cli.__name__ + ".state") class TestCLI(TestCase): """Test the Click CLI.""" def setUp(self) -> None: self.runner = CliRunner() - def check_state(self, m_state: Mock) -> None: - m_state.State.return_value.load.assert_called_once_with() + def check_state(self, m_command: Mock, debug: bool) -> None: + m_command.return_value.load_state.assert_called_once_with(debug=debug) + m_command.return_value.load_state.reset_mock() + + def check_no_state(self, m_command: Mock) -> None: + m_command.return_value.load_state.assert_not_called() def check_invoke(self, m_command: Mock) -> None: m_command.assert_called_once_with() m_command.return_value.invoke.assert_called_once_with() + m_command.return_value.invoke.reset_mock() + m_command.reset_mock() def check_invoke_with_file(self, m_command: Mock, file: str) -> None: m_command.assert_called_once_with(file) m_command.return_value.invoke.assert_called_once_with() + m_command.return_value.invoke.reset_mock() + m_command.reset_mock() + + def get_debug_subtests(self, command: str) -> List[Tuple[str, bool]]: + return [(command, False), ("--debug " + command, True)] @patch(cli.__name__ + ".door_monitor") def test_monitor( self, m_command: Mock, - m_state: Mock, ) -> None: - command = "monitor" - self.runner.invoke(cli.cli, command) - self.check_state(m_state) - self.check_invoke(m_command.DoorMonitorCommand) + for command, debug in self.get_debug_subtests("monitor"): + self.runner.invoke(cli.cli, command) + self.check_state(m_command.DoorMonitorCommand, debug) + self.check_invoke(m_command.DoorMonitorCommand) @patch(cli.__name__ + ".slack_bot") def test_slack_bot( self, m_command: Mock, - m_state: Mock, ) -> None: - command = "slack_bot" - self.runner.invoke(cli.cli, command) - self.check_state(m_state) - self.check_invoke(m_command.SlackBotCommand) + for command, debug in self.get_debug_subtests("slack_bot"): + self.runner.invoke(cli.cli, command) + self.check_state(m_command.SlackBotCommand, debug) + self.check_invoke(m_command.SlackBotCommand) @patch(cli.__name__ + ".upload_snapshot") def test_upload_snapshot( self, m_command: Mock, - m_state: Mock, ) -> None: mock_snapshot_name = __file__ - command = f"upload_snapshot {mock_snapshot_name}" - self.runner.invoke(cli.cli, command) - self.check_state(m_state) - self.check_invoke_with_file( - m_command.UploadSnapshotCommand, mock_snapshot_name - ) + for command, debug in self.get_debug_subtests( + f"upload_snapshot {mock_snapshot_name}" + ): + self.runner.invoke(cli.cli, command) + self.check_state(m_command.UploadSnapshotCommand, debug) + self.check_invoke_with_file( + m_command.UploadSnapshotCommand, mock_snapshot_name + ) @patch(cli.__name__ + ".upload_video") def test_upload_video( self, m_command: Mock, - m_state: Mock, ) -> None: mock_video_name = __file__ - command = f"upload_video {mock_video_name}" - self.runner.invoke(cli.cli, command) - self.check_state(m_state) - self.check_invoke_with_file(m_command.UploadVideoCommand, mock_video_name) + for command, debug in self.get_debug_subtests( + f"upload_video {mock_video_name}" + ): + self.runner.invoke(cli.cli, command) + self.check_state(m_command.UploadVideoCommand, debug) + self.check_invoke_with_file(m_command.UploadVideoCommand, mock_video_name) @patch(cli.__name__ + ".installer") def test_installer( self, m_command: Mock, - m_state: Mock, ) -> None: mock_config_file = __file__ command = f"installer {mock_config_file}" self.runner.invoke(cli.cli, command) - self.check_state(m_state) + self.check_no_state(m_command.InstallerCommand) self.check_invoke_with_file(m_command.InstallerCommand, mock_config_file) @patch(cli.__name__ + ".version") def test_version( self, m_command: Mock, - m_state: Mock, ) -> None: command = "version" self.runner.invoke(cli.cli, command) - self.check_state(m_state) + self.check_no_state(m_command.VersionCommand) self.check_invoke(m_command.VersionCommand)