Source code for scriptconfig.modal

"""
The scriptconfig ModalCLI

This module defines a way to group several smaller scriptconfig CLIs into a
single parent CLI that chooses between them "modally". E.g. if we define two
configs: do_foo and do_bar, we use ModalCLI to define a parent program that can run
one or the other. Let's make this more concrete.

CommandLine:
    xdoctest -m scriptconfig.modal __doc__:0

Example

    >>> import scriptconfig as scfg
    >>> #
    >>> class DoFooCLI(scfg.DataConfig):
    >>>     __command__ = 'do_foo'
    >>>     option1 = scfg.Value(None, help='option1')
    >>>     #
    >>>     @classmethod
    >>>     def main(cls, cmdline=1, **kwargs):
    >>>         self = cls.cli(cmdline=cmdline, data=kwargs)
    >>>         print('Called Foo with: ' + str(self))
    >>> #
    >>> class DoBarCLI(scfg.DataConfig):
    >>>     __command__ = 'do_bar'
    >>>     option1 = scfg.Value(None, help='option1')
    >>>     #
    >>>     @classmethod
    >>>     def main(cls, cmdline=1, **kwargs):
    >>>         self = cls.cli(cmdline=cmdline, data=kwargs)
    >>>         print('Called Bar with: ' + str(self))
    >>> #
    >>> #
    >>> class MyModalCLI(scfg.ModalCLI):
    >>>     __version__ = '1.2.3'
    >>>     foo = DoFooCLI
    >>>     bar = DoBarCLI
    >>> #
    >>> modal = MyModalCLI()
    >>> MyModalCLI.main(argv=['do_foo'])
    >>> #MyModalCLI.main(argv=['do-foo'])
    >>> MyModalCLI.main(argv=['--version'])
    >>> try:
    >>>     MyModalCLI.main(argv=['--help'])
    >>> except SystemExit:
    >>>     print('prevent system exit due to calling --help')
"""

import ubelt as ub
from scriptconfig.util.util_class import class_or_instancemethod
# from scriptconfig.config import MetaConfig


DEFAULT_GROUP = 'commands'


[docs] class MetaModalCLI(type): """ A metaclass to help minimize boilerplate when defining a ModalCLI """ @staticmethod def __new__(mcls, name, bases, namespace, *args, **kwargs): # Note: this code has an impact on startuptime efficiency. # optimizations here can help. # Iterate over class attributes and register any Configs in the # __subconfigs__ dictionary. attr_subconfigs = { k: v for k, v in namespace.items() if not k.startswith('_') and isinstance(v, type) } final_subconfigs = list(attr_subconfigs.values()) cls_subconfigs = namespace.get('__subconfigs__', []) if cls_subconfigs: final_subconfigs.extend(cls_subconfigs) # Helps make the class pickleable. Pretty hacky though. # for k in attr_subconfigs.keys(): # namespace.pop(k) namespace['__subconfigs__'] = final_subconfigs cls = super().__new__(mcls, name, bases, namespace, *args, **kwargs) return cls
[docs] class ModalCLI(metaclass=MetaModalCLI): """ Contains multiple scriptconfig.Config items with corresponding `main` functions. CommandLine: xdoctest -m scriptconfig.modal ModalCLI Example: >>> from scriptconfig.modal import * # NOQA >>> import scriptconfig as scfg >>> self = ModalCLI(description='A modal CLI') >>> # >>> @self.register >>> class Command1Config(scfg.Config): >>> __command__ = 'command1' >>> __default__ = { >>> 'foo': 'spam' >>> } >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls(cmdline=cmdline, data=kwargs) >>> print('config1 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> @self.register >>> class Command2Config(scfg.DataConfig): >>> __command__ = 'command2' >>> foo = 'eggs' >>> baz = 'biz' >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls.cli(cmdline=cmdline, data=kwargs) >>> print('config2 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> parser = self.argparse() >>> parser.print_help() ... A modal CLI ... commands: {command1,command2} specify a command to run command1 argparse CLI generated by scriptconfig... command2 argparse CLI generated by scriptconfig... >>> self.run(argv=['command1']) config1 = { 'foo': 'spam', } >>> self.run(argv=['command2', '--baz=buz']) config2 = { 'foo': 'eggs', 'baz': 'buz', } CommandLine: xdoctest -m scriptconfig.modal ModalCLI:1 Example: >>> # Declarative modal CLI (new in 0.7.9) >>> import scriptconfig as scfg >>> class MyModalCLI(scfg.ModalCLI): >>> # >>> class Command1(scfg.DataConfig): >>> __command__ = 'command1' >>> foo = scfg.Value('spam', help='spam spam spam spam') >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls.cli(cmdline=cmdline, data=kwargs) >>> print('config1 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> class Command2(scfg.DataConfig): >>> __command__ = 'command2' >>> foo = 'eggs' >>> baz = 'biz' >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls.cli(cmdline=cmdline, data=kwargs) >>> print('config2 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> MyModalCLI.main(argv=['command1']) >>> MyModalCLI.main(argv=['command2', '--baz=buz']) Example: >>> # Declarative modal CLI (new in 0.7.9) >>> import scriptconfig as scfg >>> class MyModalCLI(scfg.ModalCLI): >>> ... >>> # >>> @MyModalCLI.register >>> class Command1(scfg.DataConfig): >>> __command__ = 'command1' >>> foo = scfg.Value('spam', help='spam spam spam spam') >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls.cli(cmdline=cmdline, data=kwargs) >>> print('config1 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> @MyModalCLI.register >>> class Command2(scfg.DataConfig): >>> __command__ = 'command2' >>> foo = 'eggs' >>> baz = 'biz' >>> @classmethod >>> def main(cls, cmdline=1, **kwargs): >>> config = cls.cli(cmdline=cmdline, data=kwargs) >>> print('config2 = {}'.format(ub.urepr(dict(config), nl=1))) >>> # >>> MyModalCLI.main(argv=['command1']) >>> MyModalCLI.main(argv=['command2', '--baz=buz']) """ __subconfigs__ = [] def __init__(self, description='', sub_clis=None, version=None): if sub_clis is None: sub_clis = [] if self.__class__.__name__ != 'ModalCLI': self.description = description or ub.codeblock(self.__doc__ or '') else: self.description = description self._instance_subconfigs = sub_clis + self.__subconfigs__ if version is None: version = getattr(self.__class__, '__version__', None) if version is None: version = getattr(self.__class__, 'version', None) self.version = version def __call__(self, cli_cls): """ alias of register """ return self.register(cli_cls) @property def sub_clis(self): # backwards compat return self._instance_subconfigs
[docs] @class_or_instancemethod def register(cls_or_self, cli_cls): """ Args: cli_cli (scriptconfig.Config): A CLI-aware config object to register as a sub CLI """ # Note: the order or registration is how it will appear in the CLI help # Hack for older scriptconfig # if not hasattr(cli_cls, 'default'): # cli_cls.default = cli_cls.__default__ if isinstance(cls_or_self, type): # Called as a class method cls_or_self.__subconfigs__.append(cli_cls) else: # Called as an instance method cls_or_self._instance_subconfigs.append(cli_cls) return cli_cls
[docs] def _build_subcmd_infos(self): cmdinfo_list = [] for cli_cls in self.sub_clis: cmdname = getattr(cli_cls, '__command__', None) if cmdname is None: cmdname = cli_cls.__name__ if cmdname is None: raise ValueError(ub.paragraph( f''' The ModalCLI expects that registered subconfigs have a ``__command__: str`` attribute, but {cli_cls} is missing one. ''')) if not hasattr(cli_cls, 'main'): raise ValueError(ub.paragraph( f''' The ModalCLI expects that registered subconfigs have a ``main`` classmethod with the signature ``main(cls, cmdline: bool, **kwargs)``, but {cli_cls} is missing one. ''')) parserkw = {} __alias__ = getattr(cli_cls, '__alias__', []) if __alias__: parserkw['aliases'] = __alias__ group = getattr(cli_cls, '__group__', DEFAULT_GROUP) # group = 'FOO' # print(f'cli_cls={cli_cls}') # print(isinstance(cli_cls, ModalCLI)) # print('cli_cls.__bases__ = {}'.format(ub.urepr(cli_cls.__bases__, nl=1))) # print('ModalCLI = {}'.format(ub.urepr(ModalCLI, nl=1))) if isinstance(cli_cls, ModalCLI) or issubclass(cli_cls, ModalCLI): # Another modal layer modal = cli_cls() parserkw.update(modal._parserkw()) parserkw['help'] = parserkw['description'].split('\n')[0] cmdinfo_list.append({ 'is_modal': True, 'cmdname': cmdname, 'parserkw': parserkw, 'main_func': cli_cls.main, 'subconfig': modal, 'group': group, }) else: # A leaf Config CLI subconfig = cli_cls() parserkw.update(subconfig._parserkw()) parserkw['help'] = parserkw['description'].split('\n')[0] cmdinfo_list.append({ 'is_modal': False, 'cmdname': cmdname, 'parserkw': parserkw, 'main_func': cli_cls.main, 'subconfig': subconfig, 'group': group, }) return cmdinfo_list
[docs] def _parserkw(self): """ Generate the kwargs for making a new argparse.ArgumentParser """ from scriptconfig.argparse_ext import RawDescriptionDefaultsHelpFormatter parserkw = dict( description=self.description, formatter_class=RawDescriptionDefaultsHelpFormatter, epilog=getattr(self, '__epilog__', None), prog=getattr(self, '__prog__', None), ) if hasattr(self, '__allow_abbrev__'): parserkw['allow_abbrev'] = self.__allow_abbrev__ return parserkw
[docs] def argparse(self, parser=None, special_options=...): """ Builds a new argparse object for this ModalCLI or extends an existing one with it. """ if parser is None: import argparse as argparse_mod parserkw = self._parserkw() parser = argparse_mod.ArgumentParser(**parserkw) if hasattr(self, 'version') and self.version is not None: parser.add_argument('--version', action='store_true', help='show version number and exit') # Prepare information to be added to the subparser before it is created cmdinfo_list = self._build_subcmd_infos() # Build a list of primary command names to display as the valid options # for subparsers. This avoids cluttering the screen with all aliases # which happens by default. # The subparser is what enables the modal CLI. It will redirect a # command to a chosen subparser. # group_to_cmdinfos = ub.group_items(cmdinfo_list, key=lambda x: x['group']) # TODO: groups? # https://stackoverflow.com/questions/32017020/grouping-argparse-subparser-arguments _command_choices = [d['cmdname'] for d in cmdinfo_list] _metavar = '{' + ','.join(_command_choices) + '}' command_subparsers = parser.add_subparsers( title='commands', help='specify a command to run', metavar=_metavar) # group_to_subparser = {} # for group, cmdinfos in group_to_cmdinfos.items(): # ... def fuzzy_cmd_names(n): options = [] options.append(n) v1 = n.replace('-', '_') if v1 not in options: options.append(v1) v2 = n.replace('_', '-') if v2 not in options: options.append(v2) main_cmd, *aliases = options return main_cmd, aliases for cmdinfo in cmdinfo_list: # group = cmdinfo['group'] # Add a new command to subparser_group main_cmd, aliases = fuzzy_cmd_names(cmdinfo['cmdname']) # TODO: enable alternate hyphen/underscore aliases, but supress # them from the help output. Even better would be to handle # argument completion so they aren't clobbered. aliases = [] parserkw = cmdinfo['parserkw'] if 'aliases' in parserkw: parserkw['aliases'] = list(parserkw['aliases']) + list(aliases) else: if aliases: parserkw['aliases'] = aliases if cmdinfo['is_modal']: modal_inst = cmdinfo['subconfig'] modal_parser = command_subparsers.add_parser( main_cmd, **parserkw) modal_parser = modal_inst.argparse(parser=modal_parser) modal_parser.set_defaults(main=cmdinfo['main_func']) else: subparser = command_subparsers.add_parser( main_cmd, **parserkw) subparser = cmdinfo['subconfig'].argparse(subparser) subparser.set_defaults(main=cmdinfo['main_func']) return parser
build_parser = argparse
[docs] @class_or_instancemethod def main(self, argv=None, strict=True, autocomplete='auto'): """ Execute the modal CLI as the main script """ if isinstance(self, type): self = self() parser = self.argparse() if autocomplete: try: import argcomplete # Need to run: "$(register-python-argcomplete xdev)" # or activate-global-python-argcomplete --dest=- # activate-global-python-argcomplete --dest ~/.bash_completion.d # To enable this. except ImportError: argcomplete = None if autocomplete != 'auto': raise else: argcomplete = None if argcomplete is not None: argcomplete.autocomplete(parser) if strict: ns = parser.parse_args(args=argv) else: ns, _ = parser.parse_known_args(args=argv) kw = ns.__dict__ if kw.pop('version', None): print(self.version) return 0 sub_main = kw.pop('main', None) if sub_main is None: parser.print_help() raise ValueError('no command given') return 1 try: ret = sub_main(cmdline=False, **kw) except Exception as ex: print('ERROR ex = {!r}'.format(ex)) raise return 1 else: if ret is None: ret = 0 return ret
run = main # alias for backwards compatiability