Add basic command line framework
`wutta setup` is the only real sub/command yet, and it does nothing
This commit is contained in:
parent
417f7e5c38
commit
005f43d14e
6
docs/api/wuttjamaican/commands.base.rst
Normal file
6
docs/api/wuttjamaican/commands.base.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.commands.base``
|
||||
==============================
|
||||
|
||||
.. automodule:: wuttjamaican.commands.base
|
||||
:members:
|
6
docs/api/wuttjamaican/commands.rst
Normal file
6
docs/api/wuttjamaican/commands.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.commands``
|
||||
=========================
|
||||
|
||||
.. automodule:: wuttjamaican.commands
|
||||
:members:
|
6
docs/api/wuttjamaican/commands.setup.rst
Normal file
6
docs/api/wuttjamaican/commands.setup.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.commands.setup``
|
||||
===============================
|
||||
|
||||
.. automodule:: wuttjamaican.commands.setup
|
||||
:members:
|
|
@ -8,6 +8,9 @@
|
|||
:maxdepth: 1
|
||||
|
||||
app
|
||||
commands
|
||||
commands.base
|
||||
commands.setup
|
||||
conf
|
||||
db
|
||||
db.conf
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
33
src/wuttjamaican/commands/__init__.py
Normal file
33
src/wuttjamaican/commands/__init__.py
Normal 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
|
393
src/wuttjamaican/commands/base.py
Normal file
393
src/wuttjamaican/commands/base.py
Normal 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)
|
42
src/wuttjamaican/commands/setup.py
Normal file
42
src/wuttjamaican/commands/setup.py
Normal 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")
|
|
@ -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:
|
||||
|
|
0
tests/commands/__init__.py
Normal file
0
tests/commands/__init__.py
Normal file
181
tests/commands/test_base.py
Normal file
181
tests/commands/test_base.py
Normal 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
|
16
tests/commands/test_setup.py
Normal file
16
tests/commands/test_setup.py
Normal 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()
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue