From 8cb50d8cacc3f97b2fc395d513874db4b8cbd37c Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Sun, 7 Nov 2021 19:25:37 -0800 Subject: [PATCH] Add betterhelp epilogue feature, flatten some logic. --- voussoirkit/betterhelp.py | 97 ++++++++++++++++++++++++++--------- voussoirkit/operatornotify.py | 17 +++++- voussoirkit/vlogging.py | 14 +++++ 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/voussoirkit/betterhelp.py b/voussoirkit/betterhelp.py index 8ae17f9..2469715 100644 --- a/voussoirkit/betterhelp.py +++ b/voussoirkit/betterhelp.py @@ -4,9 +4,32 @@ import functools from voussoirkit import pipeable from voussoirkit import vlogging -log = vlogging.getLogger(__name__) +log = vlogging.get_logger(__name__) -HELPSTRINGS = {'', 'help', '-h', '--help'} +# When using a single parser or a command of a subparser, the presence of any +# of these strings in argv will trigger the helptext. +# > application.py --help +# > application.py command --help +HELP_ARGS = {'-h', '--help'} + +# When using a subparser, the command name can be any of these to trigger +# the helptext. +# > application.py help +# > application.py --help +# This does not apply to single-parser applications because the user might try +# to pass the word "help" as the actual argument to the program, but in a +# subparser application it's very unlikely that there is an actual command +# called help. +HELP_COMMANDS = {'help', '-h', '--help'} + +# Modules can add additional helptexts to this set, and they will appear +# after the program's main docstring is shown. This is used when the module +# intercepts sys.argv to change program behavior beyond the options provided +# by the program's argparse. For example, voussoirkit.vlogging.main_decorator +# adds command-line arguments like --debug which the application's argparse +# is not aware of. vlogging registers an epilogue here so that all vlogging- +# enabled applications gain the relevant helptext for free. +HELPTEXT_EPILOGUES = set() # INTERNALS ################################################################################ @@ -85,19 +108,32 @@ def listget(li, index, fallback=None): except IndexError: return fallback +def print_helptext(text) -> None: + ''' + Print the given text to stderr, along with any epilogues added by + other modules. + ''' + fulltext = [text.strip()] + sorted(epi.strip() for epi in HELPTEXT_EPILOGUES) + fulltext = '\n\n'.join(fulltext) + pipeable.stderr() + pipeable.stderr(fulltext) + def set_alias_docstrings(sub_docstrings, subparser_action) -> dict: ''' - When using subparser aliases: + When using subparser aliases like the following: - subp = parser.add_subparser('command', aliases=['comm']) + subp = parser.add_subparser('do_this', aliases=['do-this']) The _SubParsersAction will contain a dictionary `choices` of - {'command': ArgumentParser, 'comm': ArgumentParser}. + {'do_this': ArgumentParser, 'do-this': ArgumentParser}. This choices dict does not indicate which one was the original name; all aliases are equal. So, we'll identify which names are aliases because their ArgumentParsers will have the same ID in memory. And, as long as one of those aliases is in the provided docstrings, all the other aliases will get that docstring too. + + This function modifies the given sub_docstrings so that all aliases are + present as keys. ''' sub_docstrings = {name.lower(): docstring for (name, docstring) in sub_docstrings.items()} # aliases is a map of {action object's id(): [list of alias name strings]}. @@ -140,12 +176,15 @@ def single_betterhelp(parser, docstring): def wrapper(main): @functools.wraps(main) def wrapped(argv): - argument = listget(argv, 0, '').lower() + if len(argv) == 0 and can_bare: + return main(argv) - if argument == '' and can_bare: - pass - elif argument in HELPSTRINGS: - pipeable.stderr(docstring) + if len(argv) == 0: + print_helptext(docstring) + return 1 + + if any(arg.lower() in HELP_ARGS for arg in argv): + print_helptext(docstring) return 1 return main(argv) @@ -166,21 +205,33 @@ def subparser_betterhelp(parser, main_docstring, sub_docstrings): if command == '' and can_bare: return main(argv) - if command not in sub_docstrings: - pipeable.stderr(main_docstring) - if command == '': - because = 'you did not choose a command' - pipeable.stderr(f'You are seeing the default help text because {because}.') - elif command not in HELPSTRINGS: - because = f'"{command}" was not recognized' - pipeable.stderr(f'You are seeing the default help text because {because}.') + if command == '': + print_helptext(main_docstring) + because = 'you did not choose a command' + pipeable.stderr(f'\nYou are seeing the default help text because {because}.') return 1 - argument = listget(argv, 1, '').lower() - if argument == '' and command in can_bares: - pass - elif argument in HELPSTRINGS: - pipeable.stderr(sub_docstrings[command]) + if command in HELP_COMMANDS: + print_helptext(main_docstring) + return 1 + + if command not in sub_docstrings: + print_helptext(main_docstring) + because = f'"{command}" was not recognized' + pipeable.stderr(f'\nYou are seeing the default help text because {because}.') + return 1 + + arguments = argv[1:] + + if len(arguments) == 0 and command in can_bares: + return main(argv) + + if len(arguments) == 0: + print_helptext(sub_docstrings[command]) + return 1 + + if any(arg.lower() in HELP_ARGS for arg in arguments): + print_helptext(sub_docstrings[command]) return 1 return main(argv) diff --git a/voussoirkit/operatornotify.py b/voussoirkit/operatornotify.py index 90ab023..11a9988 100644 --- a/voussoirkit/operatornotify.py +++ b/voussoirkit/operatornotify.py @@ -54,12 +54,25 @@ import io import sys import traceback -from voussoirkit import betterhelp from voussoirkit import pipeable from voussoirkit import vlogging log = vlogging.getLogger(__name__, 'operatornotify') +BETTERHELP_EPILOGUE = ''' +This program uses voussoirkit.operatornotify to allow the program operator to +receive messages about important events. See operatornotify.py's docstring to +learn how to create your own my_operatornotify file. Then, you can call this +program with the following arguments: + +--operatornotify + opts in to notifications and will capture logging at the WARNING level. + +--operatornotify-level X + opts in to notifications and will capture logging at level X, where X is + e.g. debug, info, warning, error, critical. +''' + #################################################################################################### def default_notify(subject, body=''): @@ -239,6 +252,8 @@ def main_decorator(subject, *, log_return_value=True, **kwargs): 2. Remove those args from argv so your argparse doesn't know the difference. 3. Wrap main call with main_log_context. ''' + from voussoirkit import betterhelp + betterhelp.HELPTEXT_EPILOGUES.add(BETTERHELP_EPILOGUE) def wrapper(main): @functools.wraps(main) def wrapped(argv, *args, **kwargs): diff --git a/voussoirkit/vlogging.py b/voussoirkit/vlogging.py index ff9f09f..c15f33f 100644 --- a/voussoirkit/vlogging.py +++ b/voussoirkit/vlogging.py @@ -14,6 +14,18 @@ import sys _getLogger = getLogger +BETTERHELP_EPILOGUE = ''' +This program uses voussoirkit.vlogging to make logging controls accessible via +command line arguments. You can add the following arguments to change the +logging level: + +--loud +--debug +--warning +--quiet +--silent +''' + # Python gives the root logger a level of WARNING. The problem is that prevents # any handlers you add to it from receiving lower level messages. WARNING might # be fine for the stderr handler, but you might like to have a log file @@ -183,6 +195,8 @@ def main_decorator(main): to use --debug, --quiet, etc. on the command line without making any changes to your argparser. ''' + from voussoirkit import betterhelp + betterhelp.HELPTEXT_EPILOGUES.add(BETTERHELP_EPILOGUE) @functools.wraps(main) def wrapped(argv, *args, **kwargs): (level, argv) = get_level_by_argv(argv)