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
|
:maxdepth: 1
|
||||||
|
|
||||||
app
|
app
|
||||||
|
commands
|
||||||
|
commands.base
|
||||||
|
commands.setup
|
||||||
conf
|
conf
|
||||||
db
|
db
|
||||||
db.conf
|
db.conf
|
||||||
|
|
|
@ -24,6 +24,7 @@ templates_path = ['_templates']
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||||
|
|
||||||
intersphinx_mapping = {
|
intersphinx_mapping = {
|
||||||
|
'python': ('https://docs.python.org/3/', None),
|
||||||
'python-configuration': ('https://python-configuration.readthedocs.io/en/latest/', None),
|
'python-configuration': ('https://python-configuration.readthedocs.io/en/latest/', None),
|
||||||
'rattail': ('https://rattailproject.org/docs/rattail/', None),
|
'rattail': ('https://rattailproject.org/docs/rattail/', None),
|
||||||
'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', 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
|
to incorporate this package into the main Rattail package along the
|
||||||
way. So we'll see where this goes...
|
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
|
Basic Usage
|
||||||
|
@ -37,6 +38,7 @@ Create a config file, e.g. ``my.conf``:
|
||||||
bar = A
|
bar = A
|
||||||
baz = 2
|
baz = 2
|
||||||
feature = true
|
feature = true
|
||||||
|
words = the quick brown fox
|
||||||
|
|
||||||
In your app, load the config and reference its values as needed::
|
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 = 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('foo.bar') # 'A'
|
||||||
config.get_int('foo.baz') # returns 2
|
|
||||||
|
|
||||||
config.get('foo.feature') # returns 'true'
|
config.get('foo.baz') # '2'
|
||||||
config.get_bool('foo.feature') # returns True
|
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
|
Contents
|
||||||
|
|
|
@ -43,3 +43,12 @@ where = src
|
||||||
db = SQLAlchemy<2
|
db = SQLAlchemy<2
|
||||||
docs = Sphinx
|
docs = Sphinx
|
||||||
tests = pytest-cov; tox
|
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.
|
of subcommands which belong to a main command.
|
||||||
|
|
||||||
:param group: The group (string name) of entry points to be
|
: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
|
:param ignore_errors: If false (the default), any errors will be
|
||||||
normally. If true, errors will be logged but not raised.
|
raised normally. If true, errors will be logged but not
|
||||||
|
raised.
|
||||||
|
|
||||||
:returns: A dictionary whose keys are the entry point names, and
|
:returns: A dictionary whose keys are the entry point names, and
|
||||||
values are the loaded entry points.
|
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
|
# newer setup (python >= 3.8); can use importlib, but the
|
||||||
# details may vary
|
# details may vary
|
||||||
eps = importlib.metadata.entry_points()
|
eps = importlib.metadata.entry_points()
|
||||||
if isinstance(eps, dict):
|
if not hasattr(eps, 'select'):
|
||||||
# python < 3.10
|
# python < 3.10
|
||||||
eps = eps.get(group, [])
|
eps = eps.get(group, [])
|
||||||
else:
|
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.assertTrue(len(result) >= 1)
|
||||||
self.assertIn('pip', result)
|
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):
|
def test_error(self):
|
||||||
|
|
||||||
# skip if < 3.8
|
# skip if < 3.8
|
||||||
|
@ -34,7 +60,7 @@ class TestLoadEntryPoints(TestCase):
|
||||||
pytest.skip("this requires python 3.8 for entry points via importlib")
|
pytest.skip("this requires python 3.8 for entry points via importlib")
|
||||||
|
|
||||||
entry_point = MagicMock()
|
entry_point = MagicMock()
|
||||||
entry_point.load.side_effect = NotImplementedError("just a testin")
|
entry_point.load.side_effect = NotImplementedError
|
||||||
|
|
||||||
entry_points = MagicMock()
|
entry_points = MagicMock()
|
||||||
entry_points.select.return_value = [entry_point]
|
entry_points.select.return_value = [entry_point]
|
||||||
|
@ -94,7 +120,7 @@ class TestLoadEntryPoints(TestCase):
|
||||||
orig_import = __import__
|
orig_import = __import__
|
||||||
|
|
||||||
entry_point = MagicMock()
|
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])
|
iter_entry_points = MagicMock(return_value=[entry_point])
|
||||||
pkg_resources = MagicMock(iter_entry_points=iter_entry_points)
|
pkg_resources = MagicMock(iter_entry_points=iter_entry_points)
|
||||||
|
|
Loading…
Reference in a new issue