Add root application command module

This commit is contained in:
Joakim Soderlund 2017-10-13 19:08:03 +02:00
parent 8d0d738fd1
commit 6c0dfd2263
3 changed files with 307 additions and 0 deletions

View file

@ -23,8 +23,10 @@ Command module.
from .base import Command
from .root import RootCommand
__all__ = (
'Command',
'RootCommand',
)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
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:])

193
tests/commands/test_root.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
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