diff --git a/docs/api/wuttjamaican/commands.base.rst b/docs/api/wuttjamaican/commands.base.rst new file mode 100644 index 0000000..4803073 --- /dev/null +++ b/docs/api/wuttjamaican/commands.base.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.commands.base`` +============================== + +.. automodule:: wuttjamaican.commands.base + :members: diff --git a/docs/api/wuttjamaican/commands.rst b/docs/api/wuttjamaican/commands.rst new file mode 100644 index 0000000..a935249 --- /dev/null +++ b/docs/api/wuttjamaican/commands.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.commands`` +========================= + +.. automodule:: wuttjamaican.commands + :members: diff --git a/docs/api/wuttjamaican/commands.setup.rst b/docs/api/wuttjamaican/commands.setup.rst new file mode 100644 index 0000000..7a32f20 --- /dev/null +++ b/docs/api/wuttjamaican/commands.setup.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.commands.setup`` +=============================== + +.. automodule:: wuttjamaican.commands.setup + :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 68abe0a..6139ede 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -8,6 +8,9 @@ :maxdepth: 1 app + commands + commands.base + commands.setup conf db db.conf diff --git a/docs/conf.py b/docs/conf.py index d453db1..683ba15 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), 'python-configuration': ('https://python-configuration.readthedocs.io/en/latest/', None), 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), diff --git a/docs/index.rst b/docs/index.rst index 61a6394..8f551b0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,8 @@ Much remains to be done, and it may be slow going since I'll be trying to incorporate this package into the main Rattail package along the way. So we'll see where this goes... -At this point the main focus is the configuration interface. +Main points of focus so far are the configuration and command line +interfaces. Basic Usage @@ -37,6 +38,7 @@ Create a config file, e.g. ``my.conf``: bar = A baz = 2 feature = true + words = the quick brown fox In your app, load the config and reference its values as needed:: @@ -44,13 +46,18 @@ In your app, load the config and reference its values as needed:: config = make_config('/path/to/my.conf') - config.get('foo.bar') # returns 'A' + # this call.. ..returns this value - config.get('foo.baz') # returns '2' - config.get_int('foo.baz') # returns 2 + config.get('foo.bar') # 'A' - config.get('foo.feature') # returns 'true' - config.get_bool('foo.feature') # returns True + config.get('foo.baz') # '2' + config.get_int('foo.baz') # 2 + + config.get('foo.feature') # 'true' + config.get_bool('foo.feature') # True + + config.get('foo.words') # 'the quick brown fox' + config.get_list('foo.words') # ['the', 'quick', 'brown', 'fox'] Contents diff --git a/setup.cfg b/setup.cfg index 79659c4..8bae617 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,3 +43,12 @@ where = src db = SQLAlchemy<2 docs = Sphinx tests = pytest-cov; tox + + +[options.entry_points] + +console_scripts = + wutta = wuttjamaican.commands.base:main + +wutta.commands = + setup = wuttjamaican.commands.setup:Setup diff --git a/src/wuttjamaican/commands/__init__.py b/src/wuttjamaican/commands/__init__.py new file mode 100644 index 0000000..3a15d6a --- /dev/null +++ b/src/wuttjamaican/commands/__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 + +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 diff --git a/src/wuttjamaican/commands/base.py b/src/wuttjamaican/commands/base.py new file mode 100644 index 0000000..92fa155 --- /dev/null +++ b/src/wuttjamaican/commands/base.py @@ -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 . +# +################################################################################ +""" +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-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. + + 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} -h +""" + + self.parser = parser = CommandArgumentParser( + prog=self.name, + description=self.description, + add_help=False, + usage=f"{self.name} [options] [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-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) diff --git a/src/wuttjamaican/commands/setup.py b/src/wuttjamaican/commands/setup.py new file mode 100644 index 0000000..27306eb --- /dev/null +++ b/src/wuttjamaican/commands/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 - 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") diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 296eec7..3d3b1d6 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -45,10 +45,11 @@ def load_entry_points(group, ignore_errors=False): of subcommands which belong to a main command. :param group: The group (string name) of entry points to be - loaded. + loaded, e.g. ``'wutta.commands'``. - :ignore_errors: If false (the default), any errors will be raised - normally. If true, errors will be logged but not raised. + :param ignore_errors: If false (the default), any errors will be + raised normally. If true, errors will be logged but not + raised. :returns: A dictionary whose keys are the entry point names, and values are the loaded entry points. @@ -79,7 +80,7 @@ def load_entry_points(group, ignore_errors=False): # newer setup (python >= 3.8); can use importlib, but the # details may vary eps = importlib.metadata.entry_points() - if isinstance(eps, dict): + if not hasattr(eps, 'select'): # python < 3.10 eps = eps.get(group, []) else: diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/test_base.py b/tests/commands/test_base.py new file mode 100644 index 0000000..e400fe4 --- /dev/null +++ b/tests/commands/test_base.py @@ -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 diff --git a/tests/commands/test_setup.py b/tests/commands/test_setup.py new file mode 100644 index 0000000..d1342ba --- /dev/null +++ b/tests/commands/test_setup.py @@ -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() diff --git a/tests/test_util.py b/tests/test_util.py index 14de3a1..22fa82c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -27,6 +27,32 @@ class TestLoadEntryPoints(TestCase): self.assertTrue(len(result) >= 1) self.assertIn('pip', result) + def test_basic_pre_python_3_10(self): + + # the goal here is to get coverage for code which would only + # run on python 3,9 and older, but we only need that coverage + # if we are currently testing python 3.10+ + if sys.version_info.major == 3 and sys.version_info.minor < 10: + pytest.skip("this test is not relevant before python 3.10") + + import importlib.metadata + real_entry_points = importlib.metadata.entry_points() + + class FakeEntryPoints(dict): + def get(self, group, default): + return real_entry_points.select(group=group) + + importlib = MagicMock() + importlib.metadata.entry_points.return_value = FakeEntryPoints() + + with patch.dict('sys.modules', **{'importlib': importlib}): + + # load some entry points which should "always" be present, + # even in a testing environment. basic sanity check + result = util.load_entry_points('console_scripts', ignore_errors=True) + self.assertTrue(len(result) >= 1) + self.assertIn('pip', result) + def test_error(self): # skip if < 3.8 @@ -34,7 +60,7 @@ class TestLoadEntryPoints(TestCase): pytest.skip("this requires python 3.8 for entry points via importlib") entry_point = MagicMock() - entry_point.load.side_effect = NotImplementedError("just a testin") + entry_point.load.side_effect = NotImplementedError entry_points = MagicMock() entry_points.select.return_value = [entry_point] @@ -94,7 +120,7 @@ class TestLoadEntryPoints(TestCase): orig_import = __import__ entry_point = MagicMock() - entry_point.load.side_effect = NotImplementedError("just a testin") + entry_point.load.side_effect = NotImplementedError iter_entry_points = MagicMock(return_value=[entry_point]) pkg_resources = MagicMock(iter_entry_points=iter_entry_points)