Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/cmd/base.py: 100%
109 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-24 19:30 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-24 19:30 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24WuttJamaican - command framework
25"""
27import argparse
28import logging
29import sys
30import warnings
32from wuttjamaican import __version__
33from wuttjamaican.conf import make_config
34from wuttjamaican.util import load_entry_points
37log = logging.getLogger(__name__)
40class Command:
41 """
42 Primary command for the application.
44 A primary command will usually have multiple subcommands it can
45 run. The typical command line interface is like:
47 .. code-block:: none
49 <command> [command-options] <subcommand> [subcommand-options]
51 :class:`Subcommand` will contain most of the logic, in terms of
52 what actually happens when it runs. Top-level commands are mostly
53 a stub for sake of logically grouping the subcommands.
55 :param config: Optional config object to use.
57 Usually a command is being ran via actual command line, and
58 there is no config object yet so it must create one. (It does
59 this within its :meth:`run()` method.)
61 But if you already have a config object you can specify it here
62 and it will be used instead.
64 :param name: Optional value to assign to :attr:`name`. Usually
65 this is declared within the command class definition, but if
66 needed it can be provided dynamically.
68 :param stdout: Optional replacement to use for :attr:`stdout`.
70 :param stderr: Optional replacement to use for :attr:`stderr`.
72 :param subcommands: Optional dictionary to use for
73 :attr:`subcommands`, instead of loading those via entry points.
75 This base class also serves as the primary ``wutta`` command for
76 WuttJamaican. Most apps will subclass this and register their own
77 top-level command, then create subcommands as needed.
79 For more info see :doc:`/narr/cli/commands`.
81 .. attribute:: name
83 Name of the primary command, e.g. ``wutta``
85 .. attribute:: description
87 Description of the app itself or the primary command.
89 .. attribute:: version
91 Version string for the app or primary command.
93 .. attribute:: stdout
95 Reference to file-like object which should be used for writing
96 to STDOUT. By default this is just ``sys.stdout``.
98 .. attribute:: stderr
100 Reference to file-like object which should be used for writing
101 to STDERR. By default this is just ``sys.stderr``.
103 .. attribute:: subcommands
105 Dictionary of available subcommand classes, keyed by subcommand
106 name. These are usually loaded from setuptools entry points.
107 """
108 name = 'wutta'
109 version = __version__
110 description = "Wutta Software Framework"
112 def __init__(
113 self,
114 config=None,
115 name=None,
116 stdout=None,
117 stderr=None,
118 subcommands=None):
120 self.config = config
121 self.name = name or self.name
122 self.stdout = stdout or sys.stdout
123 self.stderr = stderr or sys.stderr
125 # nb. default entry point is like 'wutta_poser.subcommands'
126 safe_name = self.name.replace('-', '_')
127 self.subcommands = subcommands or load_entry_points(f'{safe_name}.subcommands')
128 if not self.subcommands:
130 # nb. legacy entry point is like 'wutta_poser.commands'
131 self.subcommands = load_entry_points(f'{safe_name}.commands')
132 if self.subcommands:
133 msg = (f"entry point group '{safe_name}.commands' uses deprecated name; "
134 f"please define '{safe_name}.subcommands' instead")
135 warnings.warn(msg, DeprecationWarning, stacklevel=2)
136 log.warning(msg)
138 def __str__(self):
139 return self.name
141 def sorted_subcommands(self):
142 """
143 Get the list of subcommand classes, sorted by name.
144 """
145 return [self.subcommands[name]
146 for name in sorted(self.subcommands)]
148 def print_help(self):
149 """
150 Print usage help text for the main command.
151 """
152 self.parser.print_help()
154 def run(self, *args):
155 """
156 Parse command line arguments and execute appropriate
157 subcommand.
159 Or, if requested, or args are ambiguous, show help for either
160 the top-level or subcommand.
162 Usually of course this method is invoked by way of command
163 line. But if you need to run it programmatically, you must
164 specify the full command line args *except* not the top-level
165 command name. So for example to run the equivalent of this
166 command line:
168 .. code-block:: sh
170 wutta setup --help
172 You could do this in Python::
174 from wuttjamaican.cmd import Command
176 cmd = Command()
177 assert cmd.name == 'wutta'
178 cmd.run('setup', '--help')
179 """
180 # build arg parser
181 self.parser = self.make_arg_parser()
182 self.add_args()
184 # primary parser gets first pass at full args, and stores
185 # everything not used within args.argv
186 args = self.parser.parse_args(args)
187 if not args or not args.argv:
188 self.print_help()
189 sys.exit(1)
191 # then argv should include <subcommand> [subcommand-options]
192 subcmd = args.argv[0]
193 if subcmd in self.subcommands:
194 if '-h' in args.argv or '--help' in args.argv:
195 subcmd = self.subcommands[subcmd](self)
196 subcmd.print_help()
197 sys.exit(0)
198 else:
199 self.print_help()
200 sys.exit(1)
202 # we should be done needing to print help messages. now it's
203 # safe to redirect STDOUT/STDERR, if necessary
204 if args.stdout:
205 self.stdout = args.stdout
206 if args.stderr:
207 self.stderr = args.stderr
209 # make the config object
210 if not self.config:
211 self.config = self.make_config(args)
213 # invoke subcommand
214 log.debug("running command line: %s", sys.argv)
215 subcmd = self.subcommands[subcmd](self)
216 self.prep_subcommand(subcmd, args)
217 subcmd._run(*args.argv[1:])
219 # nb. must flush these in case they are file objects
220 self.stdout.flush()
221 self.stderr.flush()
223 def make_arg_parser(self):
224 """
225 Must return a new :class:`argparse.ArgumentParser` instance
226 for use by the main command.
228 This will use :class:`CommandArgumentParser` by default.
229 """
230 subcommands = ""
231 for subcmd in self.sorted_subcommands():
232 subcommands += f" {subcmd.name:<20s} {subcmd.description}\n"
234 epilog = f"""\
235subcommands:
236{subcommands}
238also try: {self.name} <subcommand> -h
239"""
241 return CommandArgumentParser(
242 prog=self.name,
243 description=self.description,
244 add_help=False,
245 usage=f"{self.name} [options] <subcommand> [subcommand-options]",
246 epilog=epilog,
247 formatter_class=argparse.RawDescriptionHelpFormatter)
249 def add_args(self):
250 """
251 Configure args for the main command arg parser.
253 Anything you setup here will then be available when the
254 command runs. You can add arguments directly to
255 ``self.parser``, e.g.::
257 self.parser.add_argument('--foo', default='bar', help="Foo value")
259 See also docs for :meth:`python:argparse.ArgumentParser.add_argument()`.
260 """
261 parser = self.parser
263 parser.add_argument('-c', '--config', metavar='PATH',
264 action='append', dest='config_paths',
265 help="Config path (may be specified more than once)")
267 parser.add_argument('--plus-config', metavar='PATH',
268 action='append', dest='plus_config_paths',
269 help="Extra configs to load in addition to normal config")
271 parser.add_argument('-P', '--progress', action='store_true', default=False,
272 help="Report progress when relevant")
274 parser.add_argument('-V', '--version', action='version',
275 version=f"%(prog)s {self.version}")
277 parser.add_argument('--stdout', metavar='PATH', type=argparse.FileType('w'),
278 help="Optional path to which STDOUT should be written")
279 parser.add_argument('--stderr', metavar='PATH', type=argparse.FileType('w'),
280 help="Optional path to which STDERR should be written")
282 def make_config(self, args):
283 """
284 Make the config object in preparation for running a subcommand.
286 By default this is a straightforward wrapper around
287 :func:`wuttjamaican.conf.make_config()`.
289 :returns: The new config object.
290 """
291 return make_config(args.config_paths,
292 plus_files=args.plus_config_paths)
294 def prep_subcommand(self, subcommand, args):
295 """
296 Prepare the subcommand for running, as needed.
297 """
300class CommandArgumentParser(argparse.ArgumentParser):
301 """
302 Custom argument parser for use with :class:`Command`.
304 This is based on standard :class:`python:argparse.ArgumentParser`
305 but overrides some of the parsing logic which is specific to the
306 primary command object, to separate command options from
307 subcommand options.
309 This is documented as FYI but you probably should not need to know
310 about or try to use this yourself. It will be used automatically
311 by :class:`Command` or a subclass thereof.
312 """
314 def parse_args(self, args=None, namespace=None):
315 args, argv = self.parse_known_args(args, namespace)
316 args.argv = argv
317 return args
320class Subcommand:
321 """
322 Base class for application subcommands.
324 Subcommands are where the real action happens. Each must define
325 the :meth:`run()` method with whatever logic is needed. They can
326 also define :meth:`add_args()` to expose options.
328 Subcommands always belong to a top-level command - the association
329 is made by way of :term:`entry point` registration, and the
330 constructor for this class.
332 :param command: Reference to top-level :class:`Command` object.
334 Note that unlike :class:`Command`, the base ``Subcommand`` does
335 not correspond to any real subcommand for WuttJamaican. (It's
336 *only* a base class.) For a real example see
337 :class:`~wuttjamaican.cmd.make_appdir.MakeAppDir`.
339 For more info see :doc:`/narr/cli/subcommands`.
341 .. attribute:: stdout
343 Reference to file-like object which should be used for writing
344 to STDOUT. This is inherited from :attr:`Command.stdout`.
346 .. attribute:: stderr
348 Reference to file-like object which should be used for writing
349 to STDERR. This is inherited from :attr:`Command.stderr`.
350 """
351 name = 'UNDEFINED'
352 description = "TODO: not defined"
354 def __init__(
355 self,
356 command,
357 ):
358 self.command = command
359 self.stdout = self.command.stdout
360 self.stderr = self.command.stderr
361 self.config = self.command.config
362 if self.config:
363 self.app = self.config.get_app()
365 # build arg parser
366 self.parser = self.make_arg_parser()
367 self.add_args()
369 def __repr__(self):
370 return f"Subcommand(name={self.name})"
372 def make_arg_parser(self):
373 """
374 Must return a new :class:`argparse.ArgumentParser` instance
375 for use by the subcommand.
376 """
377 return argparse.ArgumentParser(
378 prog=f'{self.command.name} {self.name}',
379 description=self.description)
381 def add_args(self):
382 """
383 Configure additional args for the subcommand arg parser.
385 Anything you setup here will then be available within
386 :meth:`run()`. You can add arguments directly to
387 ``self.parser``, e.g.::
389 self.parser.add_argument('--foo', default='bar', help="Foo value")
391 See also docs for :meth:`python:argparse.ArgumentParser.add_argument()`.
392 """
394 def print_help(self):
395 """
396 Print usage help text for the subcommand.
397 """
398 self.parser.print_help()
400 def _run(self, *args):
401 args = self.parser.parse_args(args)
402 return self.run(args)
404 def run(self, args):
405 """
406 Run the subcommand logic. Subclass should override this.
408 :param args: Reference to the
409 :class:`python:argparse.Namespace` object, as returned by
410 the subcommand arg parser.
412 The ``args`` should have values for everything setup in
413 :meth:`add_args()`. For example if you added the ``--foo``
414 arg then here in ``run()`` you can do::
416 print("foo value is:", args.foo)
418 Usually of course this method is invoked by way of command
419 line. But if you need to run it programmatically, you should
420 *not* try to invoke this method directly. Instead create the
421 ``Command`` object and invoke its :meth:`~Command.run()`
422 method.
424 For a command line like ``bin/poser hello --foo=baz`` then,
425 you might do this::
427 from poser.commands import PoserCommand
429 cmd = PoserCommand()
430 assert cmd.name == 'poser'
431 cmd.run('hello', '--foo=baz')
432 """
433 self.stdout.write("TODO: command logic not yet implemented\n")
436def main(*args):
437 """
438 Primary entry point for the ``wutta`` command.
439 """
440 args = list(args) or sys.argv[1:]
442 cmd = Command()
443 cmd.run(*args)