1
0
Fork 0

Move cli framework to wuttjamaican.cmd subpackage

deprecate `wuttjamaican.commands`
This commit is contained in:
Lance Edgar 2023-11-22 11:13:39 -06:00
parent 37e42eebbc
commit c3914738d5
23 changed files with 753 additions and 598 deletions

View file

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

View file

@ -0,0 +1,8 @@
``wuttjamaican.cmd.make_appdir``
================================
.. automodule:: wuttjamaican.cmd.make_appdir
:members:
.. program-output:: wutta make-appdir -h

View file

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

View file

@ -0,0 +1,8 @@
``wuttjamaican.cmd.setup``
==========================
.. automodule:: wuttjamaican.cmd.setup
:members:
.. program-output:: wutta setup -h

View file

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

View file

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

View file

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

View file

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

View file

@ -8,10 +8,10 @@
:maxdepth: 1
app
commands
commands.base
commands.make_appdir
commands.setup
cmd
cmd.base
cmd.make_appdir
cmd.setup
conf
db
db.conf

View file

@ -17,6 +17,7 @@ release = '0.1'
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinxcontrib.programoutput',
'sphinx.ext.viewcode',
'sphinx.ext.todo',
]

View file

@ -41,15 +41,15 @@ where = src
[options.extras_require]
db = SQLAlchemy<2
docs = Sphinx
docs = Sphinx; sphinxcontrib-programoutput
tests = pytest-cov; tox
[options.entry_points]
console_scripts =
wutta = wuttjamaican.commands.base:main
wutta = wuttjamaican.cmd.base:main
wutta.subcommands =
make-appdir = wuttjamaican.commands.make_appdir:MakeAppDir
setup = wuttjamaican.commands.setup:Setup
make-appdir = wuttjamaican.cmd.make_appdir:MakeAppDir
setup = wuttjamaican.cmd.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 interface
For convenience, from this ``wuttjamaican.cmd`` namespace you can
access the following:
* :class:`~wuttjamaican.cmd.base.Command`
* :class:`~wuttjamaican.cmd.base.Subcommand`
"""
from .base import Command, Subcommand

View file

@ -0,0 +1,528 @@
# -*- 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
import warnings
from wuttjamaican import __version__
from wuttjamaican.conf import make_config
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.
:param config: Optional config object to use.
Usually a command is being ran via actual command line, and
there is no config object yet so it must create one. (It does
this within its :meth:`run()` method.)
But if you already have a config object you can specify it here
and it will be used instead.
:param name: Optional value to assign to :attr:`name`. Usually
this is declared within the command class definition, but if
needed it can be provided dynamically.
:param stdout: Optional replacement to use for :attr:`stdout`.
:param stderr: Optional replacement to use for :attr:`stderr`.
:param subcommands: Optional dictionary to use for
:attr:`subcommands`, instead of loading those via entry points.
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 function for it (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 poser_main(*args):
args = list(args) or sys.argv[1:]
cmd = Poser()
cmd.run(*args)
Then register the ``main()`` entry point(s) in your ``setup.cfg``.
The command name should be "one word" (no spaces) but may include
hyphens or underscore etc.
You can register more than one top-level command if needed; these
could refer to the same ``main()`` function (in which case they
are really aliases) or can use different functions:
.. code-block:: ini
[options.entry_points]
console_scripts =
poser = poser.commands:poser_main
wutta-poser = poser.commands:wutta_poser_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
bin/wutta-poser --help
And see :class:`Subcommand` for info about adding those.
.. 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.
.. attribute:: stdout
Reference to file-like object which should be used for writing
to STDOUT. By default this is just ``sys.stdout``.
.. attribute:: stderr
Reference to file-like object which should be used for writing
to STDERR. By default this is just ``sys.stderr``.
.. attribute:: subcommands
Dictionary of available subcommand classes, keyed by subcommand
name. These are usually loaded from setuptools entry points.
"""
name = 'wutta'
version = __version__
description = "Wutta Software Framework"
def __init__(
self,
config=None,
name=None,
stdout=None,
stderr=None,
subcommands=None):
self.config = config
self.name = name or self.name
self.stdout = stdout or sys.stdout
self.stderr = stderr or sys.stderr
# nb. default entry point is like 'wutta_poser.subcommands'
safe_name = self.name.replace('-', '_')
self.subcommands = subcommands or load_entry_points(f'{safe_name}.subcommands')
if not self.subcommands:
# nb. legacy entry point is like 'wutta_poser.commands'
self.subcommands = load_entry_points(f'{safe_name}.commands')
if self.subcommands:
msg = (f"entry point group '{safe_name}.commands' uses deprecated name; "
f"please define '{safe_name}.subcommands' instead")
warnings.warn(msg, DeprecationWarning, stacklevel=2)
log.warning(msg)
def __str__(self):
return self.name
def sorted_subcommands(self):
"""
Get the list of subcommand classes, sorted by name.
"""
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 to run the equivalent of this
command line:
.. code-block:: sh
wutta setup --help
You could do this in Python::
from wuttjamaican.commands import Command
cmd = Command()
assert cmd.name == 'wutta'
cmd.run('setup', '--help')
"""
# build arg parser
self.parser = self.make_arg_parser()
self.add_args()
# primary parser gets first pass at full args, and stores
# everything not used within args.argv
args = self.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)
# we should be done needing to print help messages. now it's
# safe to redirect STDOUT/STDERR, if necessary
if args.stdout:
self.stdout = args.stdout
if args.stderr:
self.stderr = args.stderr
# make the config object
if not self.config:
self.config = self.make_config(args)
# invoke subcommand
log.debug("running command line: %s", sys.argv)
subcmd = self.subcommands[subcmd](self)
self.prep_subcommand(subcmd, args)
subcmd._run(*args.argv[1:])
# nb. must flush these in case they are file objects
self.stdout.flush()
self.stderr.flush()
def make_arg_parser(self):
"""
Must return a new :class:`argparse.ArgumentParser` instance
for use by the main command.
This will use :class:`CommandArgumentParser` by default.
"""
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
"""
return CommandArgumentParser(
prog=self.name,
description=self.description,
add_help=False,
usage=f"{self.name} [options] <subcommand> [subcommand-options]",
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
def add_args(self):
"""
Configure args for the main command arg parser.
Anything you setup here will then be available when the
command runs. 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()`.
"""
parser = self.parser
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}")
parser.add_argument('--stdout', metavar='PATH', type=argparse.FileType('w'),
help="Optional path to which STDOUT should be written.")
parser.add_argument('--stderr', metavar='PATH', type=argparse.FileType('w'),
help="Optional path to which STDERR should be written.")
def make_config(self, args):
"""
Make the config object in preparation for running a subcommand.
By default this is a straightforward wrapper around
:func:`wuttjamaican.conf.make_config()`.
:returns: The new config object.
"""
return make_config(args.config_paths,
plus_files=args.plus_config_paths)
def prep_subcommand(self, subcommand, args):
"""
Prepare the subcommand for running, as needed.
"""
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.
As with top-level commands, you can "alias" the same subcommand so
it appears under multiple top-level commands. Note that if the
top-level command name contains a hyphen, that must be replaced
with underscore for sake of the subcommand entry point:
.. code-block:: ini
[options.entry_points]
poser.subcommands =
hello = poser.commands:Hello
wutta_poser.subcommands =
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
bin/wutta-poser hello --help
.. attribute:: stdout
Reference to file-like object which should be used for writing
to STDOUT. This is inherited from :attr:`Command.stdout`.
.. attribute:: stderr
Reference to file-like object which should be used for writing
to STDERR. This is inherited from :attr:`Command.stderr`.
"""
name = 'UNDEFINED'
description = "TODO: not defined"
def __init__(
self,
command,
):
self.command = command
self.stdout = self.command.stdout
self.stderr = self.command.stderr
self.config = self.command.config
if self.config:
self.app = self.config.get_app()
# build arg parser
self.parser = self.make_arg_parser()
self.add_args()
def __repr__(self):
return f"Subcommand(name={self.name})"
def make_arg_parser(self):
"""
Must return a new :class:`argparse.ArgumentParser` instance
for use by the subcommand.
"""
return argparse.ArgumentParser(
prog=f'{self.command.name} {self.name}',
description=self.description)
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,68 @@
# -*- 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 - subcommand ``make-appdir``
"""
import os
import sys
from .base import Subcommand
class MakeAppDir(Subcommand):
"""
Make or refresh the "app dir" for virtual environment
"""
name = 'make-appdir'
description = __doc__.strip()
def add_args(self):
""" """
self.parser.add_argument('--path', metavar='APPDIR',
help="Optional path to desired app dir. If not specified "
"it will be named ``app`` and placed in the root of the "
"virtual environment.")
def run(self, args):
"""
This may be used during setup to establish the :term:`app dir`
for a virtual environment. This folder will contain config
files, log files etc. used by the app.
"""
if args.path:
appdir = os.path.abspath(args.path)
else:
appdir = os.path.join(sys.prefix, 'app')
self.make_appdir(appdir, args)
self.stdout.write(f"established appdir: {appdir}\n")
def make_appdir(self, appdir, args):
"""
Make the :term:`app dir` for the given path.
Calls :meth:`~wuttjamaican.app.AppHandler.make_appdir()` to do
the heavy lifting.
"""
self.app.make_appdir(appdir)

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 - subcommand ``setup``
"""
from .base import Subcommand
class Setup(Subcommand):
"""
Install and configure various 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

@ -21,13 +21,13 @@
#
################################################################################
"""
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`
WuttJamaican - command line interface
"""
from .base import Command, Subcommand
import warnings
warnings.warn("wuttjamaican.commands package is deprecated; "
"please use wuttjamaican.cmd instead",
DeprecationWarning, stacklevel=2)
from wuttjamaican.cmd import *

View file

@ -21,508 +21,13 @@
#
################################################################################
"""
WuttJamaican - command framework
WuttJamaican - base classes for cli
"""
import argparse
import logging
import sys
import warnings
from wuttjamaican import __version__
from wuttjamaican.conf import make_config
from wuttjamaican.util import load_entry_points
warnings.warn("wuttjamaican.commands package is deprecated; "
"please use wuttjamaican.cmd instead",
DeprecationWarning, stacklevel=2)
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.
:param config: Optional config object to use.
Usually a command is being ran via actual command line, and
there is no config object yet so it must create one. (It does
this within its :meth:`run()` method.)
But if you already have a config object you can specify it here
and it will be used instead.
:param name: Optional value to assign to :attr:`name`. Usually
this is declared within the command class definition, but if
needed it can be provided dynamically.
:param stdout: Optional replacement to use for :attr:`stdout`.
:param stderr: Optional replacement to use for :attr:`stderr`.
:param subcommands: Optional dictionary to use for
:attr:`subcommands`, instead of loading those via entry points.
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 function for it (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 poser_main(*args):
args = list(args) or sys.argv[1:]
cmd = Poser()
cmd.run(*args)
Then register the ``main()`` entry point(s) in your ``setup.cfg``.
The command name should be "one word" (no spaces) but may include
hyphens or underscore etc.
You can register more than one top-level command if needed; these
could refer to the same ``main()`` function (in which case they
are really aliases) or can use different functions:
.. code-block:: ini
[options.entry_points]
console_scripts =
poser = poser.commands:poser_main
wutta-poser = poser.commands:wutta_poser_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
bin/wutta-poser --help
And see :class:`Subcommand` for info about adding those.
.. 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.
.. attribute:: stdout
Reference to file-like object which should be used for writing
to STDOUT. By default this is just ``sys.stdout``.
.. attribute:: stderr
Reference to file-like object which should be used for writing
to STDERR. By default this is just ``sys.stderr``.
.. attribute:: subcommands
Dictionary of available subcommand classes, keyed by subcommand
name. These are usually loaded from setuptools entry points.
"""
name = 'wutta'
version = __version__
description = "Wutta Software Framework"
def __init__(
self,
config=None,
name=None,
stdout=None,
stderr=None,
subcommands=None):
self.config = config
self.name = name or self.name
self.stdout = stdout or sys.stdout
self.stderr = stderr or sys.stderr
# nb. default entry point is like 'wutta_poser.subcommands'
safe_name = self.name.replace('-', '_')
self.subcommands = subcommands or load_entry_points(f'{safe_name}.subcommands')
if not self.subcommands:
# nb. legacy entry point is like 'wutta_poser.commands'
self.subcommands = load_entry_points(f'{safe_name}.commands')
if self.subcommands:
msg = (f"entry point group '{safe_name}.commands' uses deprecated name; "
f"please define '{safe_name}.subcommands' instead")
warnings.warn(msg, DeprecationWarning, stacklevel=2)
log.warning(msg)
def __str__(self):
return self.name
def sorted_subcommands(self):
"""
Get the list of subcommand classes, sorted by name.
"""
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 to run the equivalent of this
command line:
.. code-block:: sh
wutta setup --help
You could do this in Python::
from wuttjamaican.commands import Command
cmd = Command()
assert cmd.name == 'wutta'
cmd.run('setup', '--help')
"""
# build arg parser
self.parser = self.make_arg_parser()
self.add_args()
# primary parser gets first pass at full args, and stores
# everything not used within args.argv
args = self.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)
# we should be done needing to print help messages. now it's
# safe to redirect STDOUT/STDERR, if necessary
if args.stdout:
self.stdout = args.stdout
if args.stderr:
self.stderr = args.stderr
# make the config object
if not self.config:
self.config = self.make_config(args)
# invoke subcommand
log.debug("running command line: %s", sys.argv)
subcmd = self.subcommands[subcmd](self)
self.prep_subcommand(subcmd, args)
subcmd._run(*args.argv[1:])
# nb. must flush these in case they are file objects
self.stdout.flush()
self.stderr.flush()
def make_arg_parser(self):
"""
Must return a new :class:`argparse.ArgumentParser` instance
for use by the main command.
This will use :class:`CommandArgumentParser` by default.
"""
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
"""
return CommandArgumentParser(
prog=self.name,
description=self.description,
add_help=False,
usage=f"{self.name} [options] <subcommand> [subcommand-options]",
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
def add_args(self):
"""
Configure args for the main command arg parser.
Anything you setup here will then be available when the
command runs. 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()`.
"""
parser = self.parser
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}")
parser.add_argument('--stdout', metavar='PATH', type=argparse.FileType('w'),
help="Optional path to which STDOUT should be written.")
parser.add_argument('--stderr', metavar='PATH', type=argparse.FileType('w'),
help="Optional path to which STDERR should be written.")
def make_config(self, args):
"""
Make the config object in preparation for running a subcommand.
By default this is a straightforward wrapper around
:func:`wuttjamaican.conf.make_config()`.
:returns: The new config object.
"""
return make_config(args.config_paths,
plus_files=args.plus_config_paths)
def prep_subcommand(self, subcommand, args):
"""
Prepare the subcommand for running, as needed.
"""
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.
As with top-level commands, you can "alias" the same subcommand so
it appears under multiple top-level commands. Note that if the
top-level command name contains a hyphen, that must be replaced
with underscore for sake of the subcommand entry point:
.. code-block:: ini
[options.entry_points]
poser.subcommands =
hello = poser.commands:Hello
wutta_poser.subcommands =
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
bin/wutta-poser hello --help
.. attribute:: stdout
Reference to file-like object which should be used for writing
to STDOUT. This is inherited from :attr:`Command.stdout`.
.. attribute:: stderr
Reference to file-like object which should be used for writing
to STDERR. This is inherited from :attr:`Command.stderr`.
"""
name = 'UNDEFINED'
description = "TODO: not defined"
def __init__(
self,
command,
):
self.command = command
self.stdout = self.command.stdout
self.stderr = self.command.stderr
self.config = self.command.config
if self.config:
self.app = self.config.get_app()
# build arg parser
self.parser = self.make_arg_parser()
self.add_args()
def __repr__(self):
return f"Subcommand(name={self.name})"
def make_arg_parser(self):
"""
Must return a new :class:`argparse.ArgumentParser` instance
for use by the subcommand.
"""
return argparse.ArgumentParser(
prog=f'{self.command.name} {self.name}',
description=self.description)
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)
from wuttjamaican.cmd.base import *

View file

@ -21,42 +21,13 @@
#
################################################################################
"""
WuttJamaican - subcommand: make-appdir
WuttJamaican - subcommand ``make-appdir``
"""
import os
import sys
import warnings
from .base import Subcommand
warnings.warn("wuttjamaican.commands package is deprecated; "
"please use wuttjamaican.cmd instead",
DeprecationWarning, stacklevel=2)
class MakeAppDir(Subcommand):
"""
Make or refresh the "app dir" for virtual environment
"""
name = 'make-appdir'
description = __doc__.strip()
def add_args(self):
""" """
self.parser.add_argument('--path', metavar='APPDIR',
help="Optional path to desired app dir. If not specified "
"it will be named ``app`` and placed in the root of the "
"virtual environment.")
def run(self, args):
"""
This may be used during setup to establish the :term:`app dir`
for a virtual environment. This folder will contain config
files, log files etc. used by the app.
Calls :meth:`~wuttjamaican.app.AppHandler.make_appdir()` to do
the heavy lifting.
"""
if args.path:
appdir = os.path.abspath(args.path)
else:
appdir = os.path.join(sys.prefix, 'app')
self.app.make_appdir(appdir)
self.stdout.write(f"established appdir: {appdir}\n")
from wuttjamaican.cmd.make_appdir import *

View file

@ -21,22 +21,13 @@
#
################################################################################
"""
WuttJamaican - setup command
WuttJamaican - subcommand ``setup``
"""
from .base import Subcommand
import warnings
warnings.warn("wuttjamaican.commands package is deprecated; "
"please use wuttjamaican.cmd instead",
DeprecationWarning, stacklevel=2)
class Setup(Subcommand):
"""
Install and configure various 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")
from wuttjamaican.cmd.setup import *

View file

@ -5,11 +5,15 @@ import sys
from unittest import TestCase
from unittest.mock import MagicMock, patch
from wuttjamaican.commands import base
from wuttjamaican.commands.setup import Setup
from wuttjamaican.cmd import base
from wuttjamaican.cmd.setup import Setup
from wuttjamaican.testing import FileConfigTestCase
# nb. do this just for coverage
from wuttjamaican.commands.base import Command as legacy
class TestCommand(FileConfigTestCase):
def test_base(self):
@ -20,7 +24,7 @@ class TestCommand(FileConfigTestCase):
self.assertEqual(str(cmd), 'wutta')
def test_subcommand_entry_points(self):
with patch('wuttjamaican.commands.base.load_entry_points') as load_entry_points:
with patch('wuttjamaican.cmd.base.load_entry_points') as load_entry_points:
# empty entry points
load_entry_points.side_effect = lambda group: {}
@ -144,7 +148,7 @@ class TestCommand(FileConfigTestCase):
self.stdout.write("hello world")
self.stderr.write("error text")
with patch('wuttjamaican.commands.base.sys') as sys:
with patch('wuttjamaican.cmd.base.sys') as sys:
cmd = base.Command(subcommands={'hello': Hello})
@ -226,7 +230,7 @@ class TestMain(TestCase):
def true_exit(*args):
sys.exit(*args)
with patch('wuttjamaican.commands.base.sys') as mocksys:
with patch('wuttjamaican.cmd.base.sys') as mocksys:
mocksys.argv = ['wutta', '--help']
mocksys.exit = true_exit

View file

@ -7,7 +7,11 @@ from unittest import TestCase
from unittest.mock import patch
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.commands import Command, make_appdir
from wuttjamaican.cmd import Command, make_appdir
# nb. do this just for coverage
from wuttjamaican.commands.make_appdir import MakeAppDir as legacy
class TestMakeAppDir(TestCase):
@ -38,7 +42,7 @@ class TestMakeAppDir(TestCase):
shutil.rmtree(tempdir)
# mock out sys.prefix to get coverage
with patch('wuttjamaican.commands.make_appdir.sys') as sys:
with patch('wuttjamaican.cmd.make_appdir.sys') as sys:
tempdir = tempfile.mkdtemp()
appdir = os.path.join(tempdir, 'app')
sys.prefix = tempdir

View file

@ -2,7 +2,11 @@
from unittest import TestCase
from wuttjamaican.commands import setup, Command
from wuttjamaican.cmd import Command, setup
# nb. do this just for coverage
from wuttjamaican.commands.setup import Setup as legacy
class TestSetup(TestCase):