From 6c0dfd2263d7fe180d12dde187dbaf6dfed3d335 Mon Sep 17 00:00:00 2001 From: Joakim Soderlund Date: Fri, 13 Oct 2017 19:08:03 +0200 Subject: [PATCH] Add root application command module --- fimfarchive/commands/__init__.py | 2 + fimfarchive/commands/root.py | 112 ++++++++++++++++++ tests/commands/test_root.py | 193 +++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 fimfarchive/commands/root.py create mode 100644 tests/commands/test_root.py diff --git a/fimfarchive/commands/__init__.py b/fimfarchive/commands/__init__.py index 415ed5e..96631e0 100644 --- a/fimfarchive/commands/__init__.py +++ b/fimfarchive/commands/__init__.py @@ -23,8 +23,10 @@ Command module. from .base import Command +from .root import RootCommand __all__ = ( 'Command', + 'RootCommand', ) diff --git a/fimfarchive/commands/root.py b/fimfarchive/commands/root.py new file mode 100644 index 0000000..6f9d7b8 --- /dev/null +++ b/fimfarchive/commands/root.py @@ -0,0 +1,112 @@ +""" +Root command. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2015 Joakim Soderlund +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +from typing import Dict, Type + +from .base import Command + + +__all__ = ( + 'RootCommand', +) + + +class RootCommand(Command): + """ + The main application command. + """ + commands: Dict[str, Type[Command]] = dict() + + def load(self, command: str) -> Command: + """ + Instantiates a command. + + Args: + command: Name of the command to instantiate. + + Returns: + An instance of the specified command. + + Raises: + KeyError: If the command does not exist. + """ + return self.commands[command]() + + def doc(self, command: str, adjust=1, indent=2) -> str: + """ + Generates documentation for a single command. + + Args: + command: Name of the command to document. + adjust: Number of spaces before the command summary. + indent: Number of spaces before the command name. + + Returns: + A name and summary for the command. + """ + cls = self.load(command) + doc = getattr(cls, '__doc__', None) + + if doc: + doc = str(doc).strip() + doc = doc.split('\n', 1)[0] + + description = [ + indent * ' ', + command.ljust(adjust), + str(doc), + ] + + return ''.join(description) + + @property + def usage(self) -> str: + """ + Generates a list of the available commands. + + Returns: + A usage help text for the application. + """ + text = [ + 'Usage: COMMAND [PARAMETERS]\n\n', + 'Fimfarchive, ensuring that history is preseved.\n\n', + ] + + commands = sorted(cmd for cmd in self.commands.keys()) + adjust = max(len(cmd) for cmd in commands) + 2 + + text.append('Commands:\n') + for command in commands: + line = self.doc(command, adjust) + text.extend((line, '\n')) + + return ''.join(text).strip() + + def __call__(self, *args: str) -> int: + try: + cmd = self.load(args[0]) + except (IndexError, KeyError): + exit(self.usage) + else: + return cmd(*args[1:]) diff --git a/tests/commands/test_root.py b/tests/commands/test_root.py new file mode 100644 index 0000000..22d323b --- /dev/null +++ b/tests/commands/test_root.py @@ -0,0 +1,193 @@ +""" +Root command tests. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2015 Joakim Soderlund +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from fimfarchive.commands import Command, RootCommand + + +@pytest.fixture +def success(): + """ + Returns a command that succeeds. + """ + class cls(Command): + """ + Command that returns 0. + + With a truncated documentation line. + """ + __call__ = MagicMock(return_value=0) + + return cls() + + +@pytest.fixture +def failure(): + """ + Returns a command that fails. + """ + class cls(Command): + """ + Command that returns 1. + + With a truncated documentation line. + """ + __call__ = MagicMock(return_value=1) + + return cls() + + +@pytest.fixture +def root(success, failure): + """ + Returns a root command with custom subcommands. + """ + class cls(RootCommand): + """ + Custom root command. + """ + commands = { + 'success': type(success), + 'failure': type(failure), + } + + return cls() + + +@pytest.fixture +def args(): + """ + Returns random command line arguments. + """ + return [object() for i in range(3)] + + +class TestRootCommanad(): + """ + Tests the root command. + """ + + def mock_usage(self, cmd): + """ + Returns a mocked usage property for the command. + """ + usage = PropertyMock() + type(cmd).usage = usage + return usage + + def test_root_usage(self, root): + """ + Tests usage contains first line of class documentation. + """ + doc = root.usage + assert "success Command that returns 0." in doc + assert "failure Command that returns 1." in doc + assert "truncated documentation line" not in doc + + def test_root_call_without_args(self, root, success, failure): + """ + Tests `SystemExit` is raised if called without arguments. + """ + usage = self.mock_usage(root) + + with pytest.raises(SystemExit): + root() + + usage.assert_called_once_with() + success.__call__.assert_not_called() + failure.__call__.assert_not_called() + + def test_root_call_with_args(self, root, success, failure, args): + """ + Tests `SystemExit` is raised if called with invalid arguments. + """ + usage = self.mock_usage(root) + + with pytest.raises(SystemExit): + root(*args) + + usage.assert_called_once_with() + success.__call__.assert_not_called() + failure.__call__.assert_not_called() + + def test_success_usage(self, root): + """ + Tests success usage contains first line of class documentation. + """ + doc = root.doc('success', adjust=9, indent=0) + assert "success Command that returns 0." == doc + + def test_success_call_without_args(self, root, success, failure): + """ + Tests success can be called without arguments. + """ + usage = self.mock_usage(root) + code = root('success') + success.__call__.assert_called_once_with() + failure.__call__.assert_not_called() + usage.assert_not_called() + assert code == 0 + + def test_success_call_with_args(self, root, success, failure, args): + """ + Tests success can be called with arguments. + """ + usage = self.mock_usage(root) + code = root('success', *args) + success.__call__.assert_called_once_with(*args) + failure.__call__.assert_not_called() + usage.assert_not_called() + assert code == 0 + + def test_failure_usage(self, root): + """ + Tests failure usage contains first line of class documentation. + """ + doc = root.doc('failure', adjust=9, indent=0) + assert "failure Command that returns 1." == doc + + def test_failure_call_without_args(self, root, success, failure): + """ + Tests failure can be called without arguments. + """ + usage = self.mock_usage(root) + code = root('failure') + success.__call__.assert_not_called() + failure.__call__.assert_called_once_with() + usage.assert_not_called() + assert code == 1 + + def test_failure_call_with_args(self, root, success, failure, args): + """ + Tests failure can be called with arguments. + """ + usage = self.mock_usage(root) + code = root('failure', *args) + success.__call__.assert_not_called() + failure.__call__.assert_called_once_with(*args) + usage.assert_not_called() + assert code == 1