From c3914738d507332f38da52f7d64ac2b4bdb74878 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 22 Nov 2023 11:13:39 -0600 Subject: [PATCH] Move cli framework to `wuttjamaican.cmd` subpackage deprecate `wuttjamaican.commands` --- docs/api/wuttjamaican/cmd.base.rst | 6 + docs/api/wuttjamaican/cmd.make_appdir.rst | 8 + docs/api/wuttjamaican/cmd.rst | 6 + docs/api/wuttjamaican/cmd.setup.rst | 8 + docs/api/wuttjamaican/commands.base.rst | 6 - .../api/wuttjamaican/commands.make_appdir.rst | 6 - docs/api/wuttjamaican/commands.rst | 6 - docs/api/wuttjamaican/commands.setup.rst | 6 - docs/api/wuttjamaican/index.rst | 8 +- docs/conf.py | 1 + setup.cfg | 8 +- src/wuttjamaican/cmd/__init__.py | 33 ++ src/wuttjamaican/cmd/base.py | 528 ++++++++++++++++++ src/wuttjamaican/cmd/make_appdir.py | 68 +++ src/wuttjamaican/cmd/setup.py | 42 ++ src/wuttjamaican/commands/__init__.py | 16 +- src/wuttjamaican/commands/base.py | 505 +---------------- src/wuttjamaican/commands/make_appdir.py | 41 +- src/wuttjamaican/commands/setup.py | 21 +- tests/{commands => cmd}/__init__.py | 0 tests/{commands => cmd}/test_base.py | 14 +- tests/{commands => cmd}/test_make_appdir.py | 8 +- tests/{commands => cmd}/test_setup.py | 6 +- 23 files changed, 753 insertions(+), 598 deletions(-) create mode 100644 docs/api/wuttjamaican/cmd.base.rst create mode 100644 docs/api/wuttjamaican/cmd.make_appdir.rst create mode 100644 docs/api/wuttjamaican/cmd.rst create mode 100644 docs/api/wuttjamaican/cmd.setup.rst delete mode 100644 docs/api/wuttjamaican/commands.base.rst delete mode 100644 docs/api/wuttjamaican/commands.make_appdir.rst delete mode 100644 docs/api/wuttjamaican/commands.rst delete mode 100644 docs/api/wuttjamaican/commands.setup.rst create mode 100644 src/wuttjamaican/cmd/__init__.py create mode 100644 src/wuttjamaican/cmd/base.py create mode 100644 src/wuttjamaican/cmd/make_appdir.py create mode 100644 src/wuttjamaican/cmd/setup.py rename tests/{commands => cmd}/__init__.py (100%) rename tests/{commands => cmd}/test_base.py (95%) rename tests/{commands => cmd}/test_make_appdir.py (87%) rename tests/{commands => cmd}/test_setup.py (69%) diff --git a/docs/api/wuttjamaican/cmd.base.rst b/docs/api/wuttjamaican/cmd.base.rst new file mode 100644 index 0000000..5a02ec8 --- /dev/null +++ b/docs/api/wuttjamaican/cmd.base.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cmd.base`` +========================= + +.. automodule:: wuttjamaican.cmd.base + :members: diff --git a/docs/api/wuttjamaican/cmd.make_appdir.rst b/docs/api/wuttjamaican/cmd.make_appdir.rst new file mode 100644 index 0000000..235af56 --- /dev/null +++ b/docs/api/wuttjamaican/cmd.make_appdir.rst @@ -0,0 +1,8 @@ + +``wuttjamaican.cmd.make_appdir`` +================================ + +.. automodule:: wuttjamaican.cmd.make_appdir + :members: + +.. program-output:: wutta make-appdir -h diff --git a/docs/api/wuttjamaican/cmd.rst b/docs/api/wuttjamaican/cmd.rst new file mode 100644 index 0000000..18dc054 --- /dev/null +++ b/docs/api/wuttjamaican/cmd.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cmd`` +==================== + +.. automodule:: wuttjamaican.cmd + :members: diff --git a/docs/api/wuttjamaican/cmd.setup.rst b/docs/api/wuttjamaican/cmd.setup.rst new file mode 100644 index 0000000..140599c --- /dev/null +++ b/docs/api/wuttjamaican/cmd.setup.rst @@ -0,0 +1,8 @@ + +``wuttjamaican.cmd.setup`` +========================== + +.. automodule:: wuttjamaican.cmd.setup + :members: + +.. program-output:: wutta setup -h diff --git a/docs/api/wuttjamaican/commands.base.rst b/docs/api/wuttjamaican/commands.base.rst deleted file mode 100644 index 4803073..0000000 --- a/docs/api/wuttjamaican/commands.base.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.commands.base`` -============================== - -.. automodule:: wuttjamaican.commands.base - :members: diff --git a/docs/api/wuttjamaican/commands.make_appdir.rst b/docs/api/wuttjamaican/commands.make_appdir.rst deleted file mode 100644 index b7627a9..0000000 --- a/docs/api/wuttjamaican/commands.make_appdir.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.commands.make_appdir`` -===================================== - -.. automodule:: wuttjamaican.commands.make_appdir - :members: diff --git a/docs/api/wuttjamaican/commands.rst b/docs/api/wuttjamaican/commands.rst deleted file mode 100644 index a935249..0000000 --- a/docs/api/wuttjamaican/commands.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.commands`` -========================= - -.. automodule:: wuttjamaican.commands - :members: diff --git a/docs/api/wuttjamaican/commands.setup.rst b/docs/api/wuttjamaican/commands.setup.rst deleted file mode 100644 index 7a32f20..0000000 --- a/docs/api/wuttjamaican/commands.setup.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.commands.setup`` -=============================== - -.. automodule:: wuttjamaican.commands.setup - :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index b7e3ca4..2f3c248 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -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 diff --git a/docs/conf.py b/docs/conf.py index 6abeee2..249e8f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ release = '0.1' extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinxcontrib.programoutput', 'sphinx.ext.viewcode', 'sphinx.ext.todo', ] diff --git a/setup.cfg b/setup.cfg index 541b040..3ba857d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/src/wuttjamaican/cmd/__init__.py b/src/wuttjamaican/cmd/__init__.py new file mode 100644 index 0000000..60c5a35 --- /dev/null +++ b/src/wuttjamaican/cmd/__init__.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/wuttjamaican/cmd/base.py b/src/wuttjamaican/cmd/base.py new file mode 100644 index 0000000..0911132 --- /dev/null +++ b/src/wuttjamaican/cmd/base.py @@ -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 . +# +################################################################################ +""" +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-options] [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-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} -h +""" + + return CommandArgumentParser( + prog=self.name, + description=self.description, + add_help=False, + usage=f"{self.name} [options] [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) diff --git a/src/wuttjamaican/cmd/make_appdir.py b/src/wuttjamaican/cmd/make_appdir.py new file mode 100644 index 0000000..e2c1dba --- /dev/null +++ b/src/wuttjamaican/cmd/make_appdir.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttjamaican/cmd/setup.py b/src/wuttjamaican/cmd/setup.py new file mode 100644 index 0000000..5df6143 --- /dev/null +++ b/src/wuttjamaican/cmd/setup.py @@ -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 . +# +################################################################################ +""" +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") diff --git a/src/wuttjamaican/commands/__init__.py b/src/wuttjamaican/commands/__init__.py index 3a15d6a..72d43cd 100644 --- a/src/wuttjamaican/commands/__init__.py +++ b/src/wuttjamaican/commands/__init__.py @@ -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 * diff --git a/src/wuttjamaican/commands/base.py b/src/wuttjamaican/commands/base.py index 0911132..38014c1 100644 --- a/src/wuttjamaican/commands/base.py +++ b/src/wuttjamaican/commands/base.py @@ -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-options] [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-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} -h -""" - - return CommandArgumentParser( - prog=self.name, - description=self.description, - add_help=False, - usage=f"{self.name} [options] [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 * diff --git a/src/wuttjamaican/commands/make_appdir.py b/src/wuttjamaican/commands/make_appdir.py index b501e6e..15b6f34 100644 --- a/src/wuttjamaican/commands/make_appdir.py +++ b/src/wuttjamaican/commands/make_appdir.py @@ -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 * diff --git a/src/wuttjamaican/commands/setup.py b/src/wuttjamaican/commands/setup.py index 416fe44..8f71954 100644 --- a/src/wuttjamaican/commands/setup.py +++ b/src/wuttjamaican/commands/setup.py @@ -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 * diff --git a/tests/commands/__init__.py b/tests/cmd/__init__.py similarity index 100% rename from tests/commands/__init__.py rename to tests/cmd/__init__.py diff --git a/tests/commands/test_base.py b/tests/cmd/test_base.py similarity index 95% rename from tests/commands/test_base.py rename to tests/cmd/test_base.py index c549837..0b18796 100644 --- a/tests/commands/test_base.py +++ b/tests/cmd/test_base.py @@ -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 diff --git a/tests/commands/test_make_appdir.py b/tests/cmd/test_make_appdir.py similarity index 87% rename from tests/commands/test_make_appdir.py rename to tests/cmd/test_make_appdir.py index a0eaa08..a19cb6b 100644 --- a/tests/commands/test_make_appdir.py +++ b/tests/cmd/test_make_appdir.py @@ -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 diff --git a/tests/commands/test_setup.py b/tests/cmd/test_setup.py similarity index 69% rename from tests/commands/test_setup.py rename to tests/cmd/test_setup.py index d1342ba..8989f92 100644 --- a/tests/commands/test_setup.py +++ b/tests/cmd/test_setup.py @@ -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):