3
0
Fork 0

Add basic command line framework

`wutta setup` is the only real sub/command yet, and it does nothing
This commit is contained in:
Lance Edgar 2023-11-19 14:22:25 -06:00
parent 417f7e5c38
commit 005f43d14e
15 changed files with 742 additions and 12 deletions

View file

@ -0,0 +1,6 @@
``wuttjamaican.commands.base``
==============================
.. automodule:: wuttjamaican.commands.base
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.commands``
=========================
.. automodule:: wuttjamaican.commands
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.commands.setup``
===============================
.. automodule:: wuttjamaican.commands.setup
:members:

View file

@ -8,6 +8,9 @@
:maxdepth: 1
app
commands
commands.base
commands.setup
conf
db
db.conf

View file

@ -24,6 +24,7 @@ templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'python-configuration': ('https://python-configuration.readthedocs.io/en/latest/', None),
'rattail': ('https://rattailproject.org/docs/rattail/', None),
'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None),

View file

@ -17,7 +17,8 @@ Much remains to be done, and it may be slow going since I'll be trying
to incorporate this package into the main Rattail package along the
way. So we'll see where this goes...
At this point the main focus is the configuration interface.
Main points of focus so far are the configuration and command line
interfaces.
Basic Usage
@ -37,6 +38,7 @@ Create a config file, e.g. ``my.conf``:
bar = A
baz = 2
feature = true
words = the quick brown fox
In your app, load the config and reference its values as needed::
@ -44,13 +46,18 @@ In your app, load the config and reference its values as needed::
config = make_config('/path/to/my.conf')
config.get('foo.bar') # returns 'A'
# this call.. ..returns this value
config.get('foo.baz') # returns '2'
config.get_int('foo.baz') # returns 2
config.get('foo.bar') # 'A'
config.get('foo.feature') # returns 'true'
config.get_bool('foo.feature') # returns True
config.get('foo.baz') # '2'
config.get_int('foo.baz') # 2
config.get('foo.feature') # 'true'
config.get_bool('foo.feature') # True
config.get('foo.words') # 'the quick brown fox'
config.get_list('foo.words') # ['the', 'quick', 'brown', 'fox']
Contents

View file

@ -43,3 +43,12 @@ where = src
db = SQLAlchemy<2
docs = Sphinx
tests = pytest-cov; tox
[options.entry_points]
console_scripts =
wutta = wuttjamaican.commands.base:main
wutta.commands =
setup = wuttjamaican.commands.setup:Setup

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework 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.
#
# Wutta Framework 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
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - command line
For convenience, from this ``wuttjamaican.commands`` namespace you can
access the following:
* :class:`~wuttjamaican.commands.base.Command`
* :class:`~wuttjamaican.commands.base.Subcommand`
"""
from .base import Command, Subcommand

View file

@ -0,0 +1,393 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework 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.
#
# Wutta Framework 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
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - command framework
"""
import argparse
import logging
import sys
from wuttjamaican import __version__
from wuttjamaican.util import load_entry_points
log = logging.getLogger(__name__)
class Command:
"""
Primary command for the application.
A primary command will usually have multiple subcommands it can
run. The typical command line interface is like:
.. code-block:: none
<command> [command-options] <subcommand> [subcommand-options]
:class:`Subcommand` will contain most of the logic, in terms of
what actually happens when it runs. Top-level commands are mostly
a stub for sake of logically grouping the subcommands.
The base class serves as the primary ``wutta`` command for
WuttJamaican. Most apps will subclass this and register their own
top-level command, and create subcommands as needed.
To do that, first create your Command class, and a ``main()``
entry point (e.g. in ``poser/commands.py``)::
import sys
from wuttjamaican.commands import Command
class Poser(Command):
name = 'poser'
description = 'my custom top-level command'
version = '0.1'
def main(*args):
args = list(args) or sys.argv[1:]
cmd = Poser()
cmd.run(*args)
Then register the ``main()`` entry point (in your ``setup.cfg``):
.. code-block:: ini
[options.entry_points]
console_scripts =
poser = poser.commands:main
Next time your (``poser``) package is installed, the command will be
available, so you can e.g.:
.. code-block:: sh
cd /path/to/venv
bin/poser --help
And see :class:`Subcommand` for info about adding those.
Note that you can add as many top-level commands as needed. Most
apps only need one of course, but there is no actual limit.
.. attribute:: name
Name of the primary command, e.g. ``wutta``
.. attribute:: description
Description of the app itself or the primary command.
.. attribute:: version
Version string for the app or primary command.
"""
name = 'wutta'
version = __version__
description = "Wutta Software Framework"
def __init__(self, **kwargs):
self.name = kwargs.get('name', self.name)
self.stdout = kwargs.get('stdout', sys.stdout)
self.stderr = kwargs.get('stderr', sys.stderr)
self.subcommands = (kwargs.get('subcommands') or
load_entry_points(f'{self.name}.commands'))
def sorted_subcommands(self):
"""
Get a sorted list of subcommand classes.
"""
return [self.subcommands[name]
for name in sorted(self.subcommands)]
def print_help(self):
"""
Print usage help text for the main command.
"""
self.parser.print_help()
def run(self, *args):
"""
Parse command line arguments and execute appropriate
subcommand.
Or, if requested, or args are ambiguous, show help for either
the top-level or subcommand.
Usually of course this method is invoked by way of command
line. But if you need to run it programmatically, you must
specify the full command line args *except* not the top-level
command name. So for example::
from wuttjamaican.commands import Command
cmd = Command()
assert cmd.name == 'wutta'
cmd.run('setup', '--help')
"""
subcommands = ""
for subcmd in self.sorted_subcommands():
subcommands += f" {subcmd.name:<20s} {subcmd.description}\n"
epilog = f"""\
subcommands:
{subcommands}
also try: {self.name} <subcommand> -h
"""
self.parser = parser = CommandArgumentParser(
prog=self.name,
description=self.description,
add_help=False,
usage=f"{self.name} [options] <subcommand> [subcommand-options]",
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-c', '--config', metavar='PATH',
action='append', dest='config_paths',
help="Config path (may be specified more than once)")
parser.add_argument('--plus-config', metavar='PATH',
action='append', dest='plus_config_paths',
help="Extra configs to load in addition to normal config")
parser.add_argument('-P', '--progress', action='store_true', default=False,
help="Report progress when relevant")
parser.add_argument('-V', '--version', action='version',
version=f"%(prog)s {self.version}")
# primary parser gets first pass at full args, and stores
# everything not used within args.argv
args = parser.parse_args(args)
if not args or not args.argv:
self.print_help()
sys.exit(1)
# then argv should include <subcommand> [subcommand-options]
subcmd = args.argv[0]
if subcmd in self.subcommands:
if '-h' in args.argv or '--help' in args.argv:
subcmd = self.subcommands[subcmd](self)
subcmd.print_help()
sys.exit(0)
else:
self.print_help()
sys.exit(1)
# make the config object
from wuttjamaican.conf import make_config
self.config = make_config(args.config_paths,
plus_files=args.plus_config_paths)
# invoke subcommand
log.debug("running command line: %s", sys.argv)
subcmd = self.subcommands[subcmd](self)
subcmd._run(*args.argv[1:])
class CommandArgumentParser(argparse.ArgumentParser):
"""
Custom argument parser for use with :class:`Command`.
This overrides some of the parsing logic which is specific to the
primary command object, to separate command options from
subcommand options.
This is documented as FYI but you probably should not need to know
about or try to use this yourself. It will be used automatically
by ``Command`` or a subclass thereof.
"""
def parse_args(self, args=None, namespace=None):
args, argv = self.parse_known_args(args, namespace)
args.argv = argv
return args
class Subcommand:
"""
Base class for application subcommands.
Subcommands are where the real action happens. Each must define
the :meth:`run()` method with whatever logic is needed. They can
also define :meth:`add_args()` to expose options.
Subcommands always belong to a top-level command - the association
is made by way of entry point registration, and the constructor
for this class.
:param command: Reference to top-level :class:`Command` object.
Note that unlike :class:`Command`, the base ``Subcommand`` does
not correspond to any real subcommand for WuttJamaican. (It's
*only* a base class.) For a real example see
:class:`~wuttjamaican.commands.setup.Setup`.
In your project you can define new subcommands for any top-level
command. For instance to add a ``hello`` subcommand to the
``poser`` command example (cf. :class:`Command` docs):
First create a Subcommand class (e.g. by adding to
``poser/commands.py``)::
from wuttjamaican.commands import Subcommand
class Hello(Subcommand):
\"""
Say hello to the user
\"""
name = 'hello'
description = __doc__.strip()
def add_args(self):
self.parser.add_argument('--foo', default='bar', help="Foo value")
def run(self, args):
print("hello, foo value is:", args.foo)
You may notice there is nothing in that subcommand definition
which ties it to the ``poser`` top-level command. That is done by
way of another entry point in your ``setup.cfg`` file:
.. code-block:: ini
[options.entry_points]
poser.commands =
hello = poser.commands:Hello
Next time your (``poser``) package is installed, the subcommand
will be available, so you can e.g.:
.. code-block:: sh
cd /path/to/venv
bin/poser hello --help
Since the connection between command and subcommand is only "real"
if there is an entry point, this means a) you can add the
subcommand under *any* top-level command, but also b) you could
technically add it to multiple top-level commands. So for
instance in addition to the above entry point you might also do:
.. code-block:: ini
[options.entry_points]
wutta.commands =
hello = poser.commands:Hello
After re-installing your package then these commands would do the
same thing:
.. code-block:: sh
cd /path/to/venv
bin/poser hello
bin/wutta hello
"""
name = 'UNDEFINED'
description = "TODO: not defined"
def __init__(
self,
command,
):
self.command = command
self.stdout = self.command.stdout
self.stderr = self.command.stderr
self.parser = argparse.ArgumentParser(
prog=f'{self.command.name} {self.name}',
description=self.description)
self.add_args()
def add_args(self):
"""
Configure additional args for the subcommand arg parser.
Anything you setup here will then be available within
:meth:`run()`. You can add arguments directly to
``self.parser``, e.g.::
self.parser.add_argument('--foo', default='bar', help="Foo value")
See also docs for :meth:`python:argparse.ArgumentParser.add_argument()`.
"""
def print_help(self):
"""
Print usage help text for the subcommand.
"""
self.parser.print_help()
def _run(self, *args):
args = self.parser.parse_args(args)
return self.run(args)
def run(self, args):
"""
Run the subcommand logic. Subclass should override this.
:param args: Reference to the
:class:`python:argparse.Namespace` object, as returned by
the subcommand arg parser.
The ``args`` should have values for everything setup in
:meth:`add_args()`. For example if you added the ``--foo``
arg then here in ``run()`` you can do::
print("foo value is:", args.foo)
Usually of course this method is invoked by way of command
line. But if you need to run it programmatically, you should
*not* try to invoke this method directly. Instead create the
``Command`` object and invoke its :meth:`~Command.run()`
method.
For a command line like ``bin/poser hello --foo=baz`` then,
you might do this::
from poser.commands import Poser
cmd = Poser()
assert cmd.name == 'poser'
cmd.run('hello', '--foo=baz')
"""
self.stdout.write("TODO: command logic not yet implemented\n")
def main(*args):
"""
Primary entry point for the ``wutta`` command.
"""
args = list(args) or sys.argv[1:]
cmd = Command()
cmd.run(*args)

View file

@ -0,0 +1,42 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework 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.
#
# Wutta Framework 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
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - setup command
"""
from .base import Subcommand
class Setup(Subcommand):
"""
Install and configure misc. software
"""
name = 'setup'
description = __doc__.strip()
def run(self, args):
"""
TODO: Eventually this will run an interactive setup utility.
But it doesn't yet.
"""
self.stderr.write("TODO: setup is not yet implemented\n")

View file

@ -45,10 +45,11 @@ def load_entry_points(group, ignore_errors=False):
of subcommands which belong to a main command.
:param group: The group (string name) of entry points to be
loaded.
loaded, e.g. ``'wutta.commands'``.
:ignore_errors: If false (the default), any errors will be raised
normally. If true, errors will be logged but not raised.
:param ignore_errors: If false (the default), any errors will be
raised normally. If true, errors will be logged but not
raised.
:returns: A dictionary whose keys are the entry point names, and
values are the loaded entry points.
@ -79,7 +80,7 @@ def load_entry_points(group, ignore_errors=False):
# newer setup (python >= 3.8); can use importlib, but the
# details may vary
eps = importlib.metadata.entry_points()
if isinstance(eps, dict):
if not hasattr(eps, 'select'):
# python < 3.10
eps = eps.get(group, [])
else:

View file

181
tests/commands/test_base.py Normal file
View file

@ -0,0 +1,181 @@
# -*- coding: utf-8; -*-
import argparse
import sys
from unittest import TestCase
from unittest.mock import MagicMock, patch
from wuttjamaican.commands import base
class TestCommand(TestCase):
def test_base(self):
# base command is for 'wutta' and has a 'setup' subcommand
cmd = base.Command()
self.assertEqual(cmd.name, 'wutta')
self.assertIn('setup', cmd.subcommands)
def test_sorted_subcommands(self):
cmd = base.Command(subcommands={'foo': 'FooSubcommand',
'bar': 'BarSubcommand'})
srtd = cmd.sorted_subcommands()
self.assertEqual(srtd, ['BarSubcommand', 'FooSubcommand'])
def test_run_may_print_help(self):
class Hello(base.Subcommand):
name = 'hello'
cmd = base.Command(subcommands={'hello': Hello})
# first run is not "tested" per se but gives us some coverage.
# (this will *actually* print help, b/c no args specified)
try:
cmd.run()
except SystemExit:
pass
# from now on we mock the help
print_help = MagicMock()
cmd.print_help = print_help
# help is shown if no subcommand is given
try:
cmd.run()
except SystemExit:
pass
print_help.assert_called_once_with()
# help is shown if -h is given
print_help.reset_mock()
try:
cmd.run('-h')
except SystemExit:
pass
print_help.assert_called_once_with()
# help is shown if --help is given
print_help.reset_mock()
try:
cmd.run('--help')
except SystemExit:
pass
print_help.assert_called_once_with()
# help is shown if bad arg is given
print_help.reset_mock()
try:
cmd.run('--this-means-nothing')
except SystemExit:
pass
print_help.assert_called_once_with()
# help is shown if bad subcmd is given
print_help.reset_mock()
try:
cmd.run('make-sandwich')
except SystemExit:
pass
print_help.assert_called_once_with()
# main help is *not* shown if subcommand *and* -h are given
# (sub help is shown instead in that case)
print_help.reset_mock()
try:
cmd.run('hello', '-h')
except SystemExit:
pass
print_help.assert_not_called()
def test_run_invokes_subcommand(self):
class Hello(base.Subcommand):
name = 'hello'
def add_args(self):
self.parser.add_argument('--foo', action='store_true')
def run(self, args):
self.run_with(foo=args.foo)
run_with = Hello.run_with = MagicMock()
cmd = base.Command(subcommands={'hello': Hello})
# omit --foo in which case that is false by default
cmd.run('hello')
run_with.assert_called_once_with(foo=False)
# specify --foo in which case that is true
run_with.reset_mock()
cmd.run('hello', '--foo')
run_with.assert_called_once_with(foo=True)
class TestCommandArgumentParser(TestCase):
def test_parse_args(self):
kw = {
'prog': 'wutta',
'add_help': False,
# nb. this flag requires python 3.9
'exit_on_error': False,
}
# nb. examples below assume a command line like:
# bin/wutta foo --bar
# first here is what default parser does
parser = argparse.ArgumentParser(**kw)
parser.add_argument('subcommand', nargs='*')
try:
args = parser.parse_args(['foo', '--bar'])
except SystemExit:
# nb. parser was not happy, tried to exit process
args = None
else:
self.assertEqual(args.subcommand, ['foo', '--bar'])
self.assertFalse(hasattr(args, 'argv'))
# now here is was custom parser does
# (moves extras to argv for subcommand parser)
parser = base.CommandArgumentParser(**kw)
parser.add_argument('subcommand', nargs='*')
args = parser.parse_args(['foo', '--bar'])
self.assertEqual(args.subcommand, ['foo'])
self.assertEqual(args.argv, ['--bar'])
class TestSubcommand(TestCase):
def test_run(self):
cmd = base.Command()
subcmd = base.Subcommand(cmd)
# TODO: this doesn't really test anything per se, but at least
# gives us the coverage..
subcmd._run()
class TestMain(TestCase):
# nb. this doesn't test anything per se but gives coverage
def test_explicit_args(self):
try:
base.main('--help')
except SystemExit:
pass
def test_implicit_args(self):
def true_exit(*args):
sys.exit(*args)
with patch('wuttjamaican.commands.base.sys') as mocksys:
mocksys.argv = ['wutta', '--help']
mocksys.exit = true_exit
try:
base.main()
except SystemExit:
pass

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from wuttjamaican.commands import setup, Command
class TestSetup(TestCase):
def setUp(self):
self.command = Command()
self.subcommand = setup.Setup(self.command)
def test_run(self):
# TODO: this doesn't really test anything yet
self.subcommand._run()

View file

@ -27,6 +27,32 @@ class TestLoadEntryPoints(TestCase):
self.assertTrue(len(result) >= 1)
self.assertIn('pip', result)
def test_basic_pre_python_3_10(self):
# the goal here is to get coverage for code which would only
# run on python 3,9 and older, but we only need that coverage
# if we are currently testing python 3.10+
if sys.version_info.major == 3 and sys.version_info.minor < 10:
pytest.skip("this test is not relevant before python 3.10")
import importlib.metadata
real_entry_points = importlib.metadata.entry_points()
class FakeEntryPoints(dict):
def get(self, group, default):
return real_entry_points.select(group=group)
importlib = MagicMock()
importlib.metadata.entry_points.return_value = FakeEntryPoints()
with patch.dict('sys.modules', **{'importlib': importlib}):
# load some entry points which should "always" be present,
# even in a testing environment. basic sanity check
result = util.load_entry_points('console_scripts', ignore_errors=True)
self.assertTrue(len(result) >= 1)
self.assertIn('pip', result)
def test_error(self):
# skip if < 3.8
@ -34,7 +60,7 @@ class TestLoadEntryPoints(TestCase):
pytest.skip("this requires python 3.8 for entry points via importlib")
entry_point = MagicMock()
entry_point.load.side_effect = NotImplementedError("just a testin")
entry_point.load.side_effect = NotImplementedError
entry_points = MagicMock()
entry_points.select.return_value = [entry_point]
@ -94,7 +120,7 @@ class TestLoadEntryPoints(TestCase):
orig_import = __import__
entry_point = MagicMock()
entry_point.load.side_effect = NotImplementedError("just a testin")
entry_point.load.side_effect = NotImplementedError
iter_entry_points = MagicMock(return_value=[entry_point])
pkg_resources = MagicMock(iter_entry_points=iter_entry_points)