From 232398eff0387310122d612f90e00e9e6d6ae9c0 Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Sat, 12 Feb 2022 19:43:23 -0800 Subject: [PATCH] Big updates to betterhelp. Instead of handwriting the help text, which was time consuming and prone to errors, I'm finally using the help parameter in parser.add_argument. betterhelp will render and colorize this for some good-looking automatic help text. The make_helptext function is still extremely long and would be nice to refactor, but I've been sitting on this commit for a few weeks now and I want to get my git repositories back in sync. --- voussoirkit/betterhelp.py | 693 +++++++++++++++++++++++++--------- voussoirkit/bytestring.py | 47 +-- voussoirkit/operatornotify.py | 33 +- voussoirkit/passwordy.py | 181 +++++---- 4 files changed, 683 insertions(+), 271 deletions(-) diff --git a/voussoirkit/betterhelp.py b/voussoirkit/betterhelp.py index 73849e8..7df45a4 100644 --- a/voussoirkit/betterhelp.py +++ b/voussoirkit/betterhelp.py @@ -1,7 +1,16 @@ import argparse -import functools +import colorama +import io +import os +import re +import shlex +import sys +import textwrap +from voussoirkit import dotdict +from voussoirkit import niceprints from voussoirkit import pipeable +from voussoirkit import subproctools from voussoirkit import vlogging log = vlogging.get_logger(__name__) @@ -33,18 +42,6 @@ HELPTEXT_EPILOGUES = set() # INTERNALS ################################################################################ -def add_previews(docstring, sub_docstrings) -> str: - ''' - Given a primary docstring which contains {command_name} formatting elements, - and a dict of sub_docstrings of {command_name: docstring}, insert previews - of each command into the primary docstring. - ''' - previews = { - sub_name: docstring_preview(sub_text) - for (sub_name, sub_text) in sub_docstrings.items() - } - docstring = docstring.format(**previews) - return docstring def can_use_bare(parser) -> bool: ''' @@ -52,15 +49,6 @@ def can_use_bare(parser) -> bool: This is used to decide whether running `> myprogram.py` should show the helptext or just run normally. ''' - def is_required(action): - # I found that positional arguments marked with nargs=* were still being - # considered 'required', which is not what I want as far as can_use_bare - # goes. I believe option_strings==[] is what indicates this action is - # positional. If I'm wrong let's fix it. - if action.option_strings == [] and action.nargs == '*': - return False - return action.required - has_func = bool(parser.get_default('func')) has_required_args = any(is_required(action) for action in parser._actions) return has_func and not has_required_args @@ -75,32 +63,39 @@ def can_use_bare_subparsers(subparser_action) -> set: ) return can_bares -def docstring_preview(text) -> str: - ''' - This function assumes that your docstring is formatted with a single blank - line separating the command's primary summary and the rest of the text, - and will return the summary above the blank. - - >>> cookbacon = """ - ... cookbacon: Cooks all nearby bacon to a specified temperature. - ... - ... Usage: - ... > cookbacon 350F - ... > cookbacon 175C - ... """ - >>> - >>> - >>> docstring_preview(cookbacon) - 'cookbacon: Cooks all nearby bacon to a specified temperature.' - ''' - text = text.split('\n\n')[0].strip() - return text +def get_program_name(): + program_name = os.path.basename(sys.argv[0]) + program_name = re.sub(r'\.pyw?$', '', program_name) + return program_name def get_subparser_action(parser): for action in parser._actions: if isinstance(action, argparse._SubParsersAction): return action - raise TypeError('Couldn\'t locate the SubParsersAction.') + return None + +def get_subparsers(parser): + ''' + Return a dictionary mapping subparser actions to a list of their aliases, + i.e. {parser: [names]} + ''' + action = get_subparser_action(parser) + if action is None: + return {} + + subparsers = {} + for (sp_name, sp) in action.choices.items(): + subparsers.setdefault(sp, []).append(sp_name) + return subparsers + +def is_required(action): + # I found that positional arguments marked with nargs=* were still being + # considered 'required', which is not what I want as far as can_use_bare + # goes. I believe option_strings==[] is what indicates this action is + # positional. If I'm wrong let's fix it. + if action.option_strings == [] and action.nargs == '*': + return False + return action.required def listget(li, index, fallback=None): try: @@ -108,6 +103,432 @@ def listget(li, index, fallback=None): except IndexError: return fallback +def listsplit(li, target, count=float('inf')): + ''' + Split a list into multiple lists wherever the target element appears. + ''' + newli = [] + subli = [] + for item in li: + if item == target and count > 0: + newli.append(subli) + subli = [] + count -= 1 + else: + subli.append(item) + newli.append(subli) + return newli + +def make_helptext( + parser, + all_command_names=None, + command_name=None, + do_colors=True, + do_headline=True, + full_subparsers=False, + program_name=None, + ): + # Even though this text is going out on stderr, we only colorize it if + # both stdout and stderr are tty because as soon as pipe buffers are + # involved, even on stdout, things start to get weird. + if do_colors and pipeable.stdout_tty() and pipeable.stderr_tty(): + colorama.init() + color = dotdict.DotDict( + positional=colorama.Style.BRIGHT + colorama.Fore.CYAN, + named=colorama.Style.BRIGHT + colorama.Fore.GREEN, + flag=colorama.Style.BRIGHT + colorama.Fore.MAGENTA, + command=colorama.Style.BRIGHT + colorama.Fore.YELLOW, + reset=colorama.Style.RESET_ALL, + required_asterisk=colorama.Style.BRIGHT + colorama.Fore.RED + '(*)' + colorama.Style.RESET_ALL, + ) + else: + color = dotdict.DotDict( + positional='', + named='', + flag='', + command='', + reset='', + required_asterisk=colorama.Style.BRIGHT + colorama.Fore.RED + '(*)' + colorama.Style.RESET_ALL, + ) + + if program_name is None: + program_name = get_program_name() + + if command_name is None: + invoke_name = program_name + else: + invoke_name = f'{program_name} {color.command}{command_name}{color.reset}' + + # If we are currently generating the helptext for a subparser, the caller + # can give us the names of all other command names, so that we can colorize + # them if this subparser references another one in its text. Otherwise, we + # will only gather up subparsers below the given parser object. + if all_command_names is None: + all_command_names = set() + + # GATHER UP ARGUMENT TYPES + # Here we go through the list of actions in the parser and classify them + # into a few basic types, which will be rendered and colorized in + # different ways. + + all_positional_names = set() + all_named_names = set() + all_flags_names = set(HELP_ARGS) + dest_to_action = {} + main_invocation = [invoke_name] + positional_actions = [] + named_actions = [] + required_named_actions = [] + optional_named_actions = [] + flag_types = {argparse._StoreTrueAction, argparse._StoreFalseAction, argparse._StoreConstAction} + flag_actions = [] + + for action in parser._actions: + if type(action) is argparse._HelpAction: + continue + + if type(action) is argparse._SubParsersAction: + all_command_names.update(action.choices.keys()) + continue + + if type(action) is argparse._StoreAction: + if action.option_strings == []: + positional_actions.append(action) + all_positional_names.add(action.dest) + else: + named_actions.append(action) + if action.required: + required_named_actions.append(action) + else: + optional_named_actions.append(action) + all_named_names.update(action.option_strings) + elif type(action) in flag_types: + flag_actions.append(action) + all_flags_names.update(action.option_strings) + else: + raise TypeError(f'betterhelp doesn\'t know what to do with {action}.') + dest_to_action[action.dest] = action + + # COLORIZE ARGUMENT INVOCATIONS + # Here we generate invocation strings for each of the arguments. That is, + # an argument called `--scales` with type=int and nargs=+ will be shown as + # `--scales int [int, ...]`. Each type of argument has different + # considerations involving the dest, type, metavar, nargs, etc. + # The required arguments will be added to the main_invocation, the optional + # arguments will simply wait in the action_invocations dictionary until we + # show their full help in an upcoming section. + + def render_nargs(argname, nargs): + if nargs is None: + return argname + elif isinstance(nargs, int): + return ' '.join([argname] * nargs) + elif nargs == '?': + return f'[{argname}]' + elif nargs == '*': + return f'[{argname}, {argname}, ...]' + elif nargs == '+': + return f'{argname} [{argname}, ...]' + elif nargs == '...': + return f'[{argname}, ...]' + + action_invocations = {} + for action in positional_actions: + if action.type is not None: + typename = f'({action.type.__name__})' + else: + typename = '' + + if action.metavar is not None: + argname = action.metavar + else: + argname = action.dest + + inv = render_nargs(argname, action.nargs) + inv = f'{color.positional}{inv}{typename}{color.reset}' + action_invocations[action] = [inv] + main_invocation.append(inv) + + ## + + for action in named_actions: + action_invocations[action] = [] + for alias in action.option_strings: + if action.metavar is not None: + argname = action.metavar + elif action.type is None: + argname = action.dest + else: + argname = action.type.__name__ + + inv = render_nargs(argname, action.nargs) + inv = f'{color.named}{alias} {inv}{color.reset}' + action_invocations[action].append(inv) + + for action in required_named_actions: + main_invocation.append(action_invocations[action][0]) + + if optional_named_actions: + main_invocation.append(f'{color.named}[options]{color.reset}') + + ## + + for action in flag_actions: + action_invocations[action] = [] + for alias in action.option_strings: + inv = f'{color.flag}{alias}{color.reset}' + action_invocations[action].append(inv) + + if flag_actions: + main_invocation.append(f'{color.flag}[flags]{color.reset}') + + # COLORIZE ARGUMENT NAMES THAT APPEAR IN OTHER TEXTS + # Now that we know the names of all the different types of arguments, we + # can use them to colorize the program description and the help text of + # each individual argument. This makes it really easy to see when one + # argument has an influence on another argument. + # If you use a positional argument that is a common noun this can be + # a bit annoying. + + def colorize_names(text): + for command in all_command_names: + text = re.sub(rf'((?:^|\s){command}(?:\b))', rf'{color.command}\1{color.reset}', text) + for positional in all_positional_names: + text = re.sub(rf'((?:^|\s){positional}(?:\b))', rf'{color.positional}\1{color.reset}', text) + for named in all_named_names: + text = re.sub(rf'((?:^|\s){named}(?:\b))', rf'{color.named}\1{color.reset}', text) + for flag in all_flags_names: + text = re.sub(rf'((?:^|\s){flag}(?:\b))', rf'{color.flag}\1{color.reset}', text) + return text + + # PUTTING TOGETHER PROGRAM DESCRIPTION & ARGUMENT HELPS + # This is the portion that actually constructs the majority of the help + # text, by combining the program's own help description with the invocation + # tips and help texts of each of the arguments. + + program_description = parser.description or '' + program_description = textwrap.dedent(program_description).strip() + program_description = colorize_names(program_description) + + argument_helps = [] + for action in (positional_actions + named_actions + flag_actions): + inv = '\n'.join(action_invocations[action]) + arghelp = [] + if action.help is not None: + arghelp.append(textwrap.dedent(action.help).strip()) + if type(action) is argparse._StoreAction and action.default is not None: + arghelp.append(f'Default: {repr(action.default)}') + if action.option_strings and action.required: + arghelp.append(f'{color.required_asterisk} Required{color.reset}') + arghelp = '\n'.join(arghelp) + + arghelp = colorize_names(arghelp) + + arghelp = textwrap.indent(arghelp, ' ') + argument_helps.append(f'{inv}\n{arghelp}'.strip()) + + if len(main_invocation) > 1 or can_use_bare(parser): + main_invocation = ' '.join(main_invocation) + main_invocation = f'> {main_invocation}' + else: + main_invocation = '' + + # SUBPARSER PREVIEWS + # If this program has subparsers, we will generate a preview of their name + # and description. If full_subparsers is True, we also show their full + # invocation and argument helps. + + subparser_previews = [] + for (sp, aliases) in get_subparsers(parser).items(): + sp_help = [] + for alias in aliases: + sp_help.append(f'{program_name} {color.command}{alias}{color.reset}') + if full_subparsers: + desc = make_helptext( + sp, + command_name=aliases[0], + do_headline=False, + all_command_names=all_command_names, + ) + desc = textwrap.dedent(desc).strip() + desc = textwrap.indent(desc, ' ') + sp_help.append(f'{desc}') + elif sp.description is not None: + first_para = textwrap.dedent(sp.description).split('\n\n')[0].strip() + first_para = textwrap.indent(first_para, ' ') + sp_help.append(f'{first_para}') + sp_help = '\n'.join(sp_help) + subparser_previews.append(sp_help) + + if subparser_previews: + subparser_previews = '\n\n'.join(subparser_previews) + subparser_previews = f'{color.command}Commands{color.reset}\n--------\n\n{subparser_previews}' + + # COLORIZE EXAMPLE INVOCATIONS + # Here we take example invocation strings provided by the program itself, + # and run them through the argparser to colorize the positional, named, + # and flag arguments. + # argparse does not expose to us exactly which input strings led to which + # members of the outputted namespace, so we have to deduce it based on dest. + + def dear_argparse_please_dont_call_sys_exit_im_trying_to_work_here(message): + raise TypeError() + + example_invocations = [] + for example in getattr(parser, 'examples', []): + args = example + if isinstance(args, dict): + args = args['args'] + if isinstance(args, str): + args = shlex.split(args, posix=os.name != 'nt') + example_invocation = [invoke_name] + parser.error = dear_argparse_please_dont_call_sys_exit_im_trying_to_work_here + + # more_positional is a list of positional arguments that we will put + # after -- in the colorized output. Since the argparse namespace will + # not tell us which positional arguments came from before or after + # the --, we will have to figure it out ourselves. + doubledash_parts = listsplit(args, '--', count=1) + if len(doubledash_parts) == 2: + more_positional = doubledash_parts[-1] + else: + more_positional = [] + + # more_positional_verify is a list of arguments we expect to be in + # more_positional but have not yet seen. This is needed because of the + # argparse type parameter which means a value in the namespace might + # not match the string that was inputted and we won't recognize it. + more_positional_verify = more_positional[:] + + try: + parsed_example = parser.parse_args(args) + except TypeError: + example_invocation.extend(subproctools.quote(arg) for arg in args) + else: + keyvals = parsed_example.__dict__.copy() + keyvals.pop('func', None) + positional_args = [] + for (dest, value) in list(keyvals.items()): + action = dest_to_action[dest] + if value == action.default: + keyvals.pop(dest) + continue + if action not in positional_actions: + continue + if action.nargs == '*' and value == []: + keyvals.pop(dest) + continue + if isinstance(value, list): + positional_args.extend(value) + else: + positional_args.append(value) + keyvals.pop(dest) + + positional_args2 = [] + for arg in reversed(positional_args): + try: + more_positional_verify.remove(arg) + except ValueError: + positional_args2.append(arg) + + positional_args2.reverse() + positional_args = positional_args2 + + if positional_args: + positional_inv = ' '.join(subproctools.quote(str(arg)) for arg in positional_args) + positional_inv = f'{color.positional}{positional_inv}{color.reset}' + example_invocation.append(positional_inv) + for (dest, value) in list(keyvals.items()): + action = dest_to_action[dest] + if action in named_actions: + if isinstance(value, list): + value = ' '.join(subproctools.quote(str(arg)) for arg in value) + else: + value = subproctools.quote(str(value)) + inv = f'{color.named}{action.option_strings[0]} {value}{color.reset}' + elif action in flag_actions: + inv = f'{color.flag}{action.option_strings[0]}{color.reset}' + example_invocation.append(inv) + + # While we were looking for more_positional arguments, some of them + # may have slipped past us because of their type parameter. That is, + # the parsed namespace contains ints or other objects that were not + # matched to the strings in more_positional_verify. So, we remove + # the remaining more_positional_verify from more_positional, and + # those objects will appear in the regular positional area instead + # of after the --. This could be problematic if the input string + # has a leading hyphen and the type parameter turned it into + # something else (a negative number), but I think it's the best + # we can do without being able to inspect argparse's decisions. + + # Not using set operations or [x if x not in verify] here because + # the positional arguments can be duplicates and the quantity of + # removals matters. + while more_positional_verify: + more_positional.remove(more_positional_verify.pop(0)) + + if more_positional: + example_invocation.append('--') + more_positional = ' '.join(subproctools.quote(str(arg)) for arg in more_positional) + more_positional = f'{color.positional}{more_positional}{color.reset}' + example_invocation.append(more_positional) + + example_invocation = ' '.join(example_invocation) + example_invocation = f'> {example_invocation}' + if isinstance(example, dict) and example.get('comment'): + comment = example['comment'] + example_invocation = f'# {comment}\n{example_invocation}' + + if isinstance(example, dict) and example.get('run'): + _stdout = sys.stdout + _stderr = sys.stderr + buffer = io.StringIO() + sys.stdout = buffer + sys.stderr = buffer + try: + parsed_example.func(parsed_example) + buffer.seek(0) + output = buffer.read().strip() + buffer.close() + example_invocation = f'{example_invocation}\n{output}' + sys.stdout = _stdout + sys.stderr = _stderr + except Exception: + sys.stdout = _stdout + sys.stderr = _stderr + raise + + example_invocations.append(example_invocation) + + example_invocations = '\n\n'.join(example_invocations) + if example_invocations: + example_invocations = f'Examples:\n{example_invocations}' + + if subparser_previews and not full_subparsers: + subparser_epilogue = textwrap.dedent(f''' + To see details on each command, run + > {program_name} {color.command}{color.reset} {color.flag}--help{color.reset} + ''').strip() + else: + subparser_epilogue = None + + # PUT IT ALL TOGETHER + + parts = [ + niceprints.equals_header(program_name) if do_headline else None, + program_description, + main_invocation, + '\n\n'.join(argument_helps), + subparser_previews, + subparser_epilogue, + example_invocations, + ] + parts = [part for part in parts if part] + parts = [part.strip() for part in parts] + parts = [part for part in parts if part] + helptext = '\n\n'.join(parts) + return helptext + def print_helptext(text) -> None: ''' Print the given text to stderr, along with any epilogues added by @@ -115,147 +536,81 @@ def print_helptext(text) -> None: ''' fulltext = [] fulltext.append(text.strip()) - fulltext.extend(sorted(epi.strip() for epi in HELPTEXT_EPILOGUES)) - separator = '\n' + ('-'*80) + '\n' + epilogues = {textwrap.dedent(epi).strip() for epi in HELPTEXT_EPILOGUES} + fulltext.extend(sorted(epi.strip() for epi in epilogues)) + separator = '\n' + ('-' * 80) + '\n' fulltext = separator.join(fulltext) # Ensure one blank line above helptext. pipeable.stderr() pipeable.stderr(fulltext) -def set_alias_docstrings(sub_docstrings, subparser_action) -> dict: - ''' - When using subparser aliases like the following: - - subp = parser.add_subparser('do_this', aliases=['do-this']) - - The _SubParsersAction will contain a dictionary `choices` of - {'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]}. - aliases = {} - # primary_aliases is {action object's id(): 'name string'} - primary_aliases = {} - - for (sp_name, sp) in subparser_action.choices.items(): - sp_id = id(sp) - sp_name = sp_name.lower() - aliases.setdefault(sp_id, []).append(sp_name) - if sp_name in sub_docstrings: - primary_aliases[sp_id] = sp_name - - for (sp_id, sp_aliases) in aliases.items(): - try: - primary_alias = primary_aliases[sp_id] - except KeyError: - log.warning('There is no docstring for any of %s.', sp_aliases) - docstring = '' - else: - docstring = sub_docstrings[primary_alias] - - for sp_alias in sp_aliases: - sub_docstrings[sp_alias] = docstring - - return sub_docstrings - -# DECORATORS +# MAINS ################################################################################ -def single_betterhelp(parser, docstring): - ''' - This decorator actually doesn't need the `parser`, but the - subparser_betterhelp decorator does, so in the interest of having similar - function signatures I'm making it required here too. I figure it's the - lesser of two evils. Plus, maybe someday I'll find a need for it and won't - have to make any changes to do it. - ''' + +def _go_single(parser, argv, *, args_postprocessor=None): can_bare = can_use_bare(parser) - def wrapper(main): - @functools.wraps(main) - def wrapped(argv): - if len(argv) == 0 and can_bare: - return main(argv) - if len(argv) == 0: - print_helptext(docstring) - return 1 + needs_help = ( + any(arg.lower() in HELP_ARGS for arg in argv) or + len(argv) == 0 and not can_bare + ) + if needs_help: + print_helptext(make_helptext(parser)) + return 1 - if any(arg.lower() in HELP_ARGS for arg in argv): - print_helptext(docstring) - return 1 + args = parser.parse_args(argv) + if args_postprocessor is not None: + args = args_postprocessor(args) + return args.func(args) - return main(argv) - return wrapped - return wrapper - -def subparser_betterhelp(parser, main_docstring, sub_docstrings): - subparser_action = get_subparser_action(parser) - sub_docstrings = set_alias_docstrings(sub_docstrings, subparser_action) +def _go_multi(parser, argv, *, args_postprocessor=None): + subparsers = get_subparser_action(parser).choices can_bare = can_use_bare(parser) - can_bares = can_use_bare_subparsers(subparser_action) - def wrapper(main): - @functools.wraps(main) - def wrapped(argv): - command = listget(argv, 0, '').lower() - - if command == '' and can_bare: - return main(argv) - - 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 - - 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) - return wrapped - return wrapper - -# EASY MAINS -################################################################################ -def single_main(argv, parser, docstring, args_postprocessor=None): def main(argv): args = parser.parse_args(argv) if args_postprocessor is not None: args = args_postprocessor(args) return args.func(args) - return single_betterhelp(parser, docstring)(main)(argv) -def subparser_main(argv, parser, main_docstring, sub_docstrings, args_postprocessor=None): - def main(argv): - args = parser.parse_args(argv) - if args_postprocessor is not None: - args = args_postprocessor(args) - return args.func(args) - return subparser_betterhelp(parser, main_docstring, sub_docstrings)(main)(argv) + all_command_names = set(subparsers.keys()) + command = listget(argv, 0, '').lower() + + if command == '' and can_bare: + return main(argv) + + if command == '': + print_helptext(make_helptext(parser, all_command_names=all_command_names)) + because = 'you did not choose a command' + pipeable.stderr(f'\nYou are seeing the default help text because {because}.') + return 1 + + if command == 'helpall': + print_helptext(make_helptext(parser, full_subparsers=True, all_command_names=all_command_names)) + return 1 + + if command in HELP_COMMANDS: + print_helptext(make_helptext(parser, all_command_names=all_command_names)) + return 1 + + if command not in subparsers: + print_helptext(make_helptext(parser, all_command_names=all_command_names)) + because = f'"{command}" was not recognized' + pipeable.stderr(f'\nYou are seeing the default help text because {because}.') + return 1 + + subparser = subparsers[command] + arguments = argv[1:] + + no_args = len(arguments) == 0 and not can_use_bare(subparser) + if no_args or any(arg.lower() in HELP_ARGS for arg in arguments): + print_helptext(make_helptext(subparser, command_name=command, all_command_names=all_command_names)) + return 1 + + return main(argv) + +def go(parser, argv, *, args_postprocessor=None): + if get_subparser_action(parser): + return _go_multi(parser, argv, args_postprocessor=args_postprocessor) + else: + return _go_single(parser, argv, args_postprocessor=args_postprocessor) diff --git a/voussoirkit/bytestring.py b/voussoirkit/bytestring.py index 973c3ff..8a2f0a2 100644 --- a/voussoirkit/bytestring.py +++ b/voussoirkit/bytestring.py @@ -1,23 +1,8 @@ ''' -bytestring -========== - This module provides integer constants for power-of-two byte size units, and functions for converting between ints and human-readable strings. E.g.: bytestring.bytestring(5000000) -> '4.768 MiB' bytestring.parsebytes('8.5gb') -> 9126805504 - -Commandline usage: - -> bytestring number1 [number2 number3...] - -number: - An integer. Uses pipeable to support !c clipboard, !i stdin, which should - be one number per line. - -Examples: -> bytestring 12345 89989 -> some_process | bytestring !i ''' import argparse import re @@ -186,22 +171,40 @@ def parsebytes(string): return int(number * multiplier) def bytestring_argparse(args): - numbers = pipeable.input_many(args.numbers) + numbers = pipeable.input_many(args.numbers, strip=True, skip_blank=True) for number in numbers: try: number = int(number) + pipeable.stdout(bytestring(number)) except ValueError: - pipeable.stderr(f'bytestring: Each number should be an integer, not {number}.') - return 1 - pipeable.stdout(bytestring(number)) + pipeable.stdout(parsebytes(number)) + return 0 def main(argv): - parser = argparse.ArgumentParser(description=__doc__) + parser = argparse.ArgumentParser( + description=''' + Converts integers into byte strings and back again. + ''', + ) + parser.examples = [ + {'args': '10000', 'run': True}, + {'args': '123456789', 'run': True}, + {'args': '999999999999 888888888 890', 'run': True}, + {'args': ['800 gb'], 'run': True}, + {'args': ['9.2 kib', '100kb', '42b'], 'run': True}, + ] - parser.add_argument('numbers', nargs='+') + parser.add_argument( + 'numbers', + nargs='+', + help=''' + Uses pipeable to support !c clipboard, !i stdin, which should be one + number per line. + ''', + ) parser.set_defaults(func=bytestring_argparse) - return betterhelp.single_main(argv, parser, __doc__) + return betterhelp.go(parser, argv) if __name__ == '__main__': raise SystemExit(main(sys.argv[1:])) diff --git a/voussoirkit/operatornotify.py b/voussoirkit/operatornotify.py index fb556e7..d085987 100644 --- a/voussoirkit/operatornotify.py +++ b/voussoirkit/operatornotify.py @@ -32,20 +32,9 @@ If your application already uses the logging module, consider these options: - wrap your function call in a operatornotify.LogHandlerContext, or - add @operatornotify.main_decorator to your main function. -Commandline usage: -> operatornotify --subject XXX [--body XXX] - ---subject xxx: - A string. Uses pipeable to support !c clipboard, !i stdin. - Required. - ---body xxx: - A string. Uses pipeable to support !c clipboard, !i stdin. - Optional. - Examples: > some_process && operatornotify --subject success || operatornotify --subject fail -> some_process | operatornotify --subject "Results of some_process" --body !i 2>&1 +> some_process 2>&1 | operatornotify --subject "Results of some_process" --body !i ''' import argparse import contextlib @@ -324,19 +313,29 @@ def parse_argv(argv): def operatornotify_argparse(args): notify( subject=pipeable.input(args.subject, split_lines=False).strip(), - body=pipeable.input(args.body, split_lines=False), + body=pipeable.input(args.body or '', split_lines=False), ) return 0 @vlogging.main_decorator def main(argv): parser = argparse.ArgumentParser(description=__doc__) - - parser.add_argument('--subject', required=True) - parser.add_argument('--body', default='') + parser.add_argument( + '--subject', + required=True, + help=''' + A string. Uses pipeable to support !c clipboard, !i stdin. + ''', + ) + parser.add_argument( + '--body', + help=''' + A string. Uses pipeable to support !c clipboard, !i stdin. + ''', + ) parser.set_defaults(func=operatornotify_argparse) - return betterhelp.single_main(argv, parser, __doc__) + return betterhelp.go(parser, argv) if __name__ == '__main__': raise SystemExit(main(sys.argv[1:])) diff --git a/voussoirkit/passwordy.py b/voussoirkit/passwordy.py index 42b310f..c88a3d3 100644 --- a/voussoirkit/passwordy.py +++ b/voussoirkit/passwordy.py @@ -1,57 +1,9 @@ ''' -passwordy -========= - This module provides functions for generating random strings. All functions use cryptographically strong randomness if the operating system supports it, and non-cs randomness if it does not. If os.urandom(1) gives you a byte, your system has cs randomness. - -Command line usage: - -> passwordy length - -length: - Integer number of characters in normal mode. - Integer number of words in sentence mode. - -# Sentence mode: ---sentence: - If this argument is passed, `length` random words are chosen. - ---separator X: - When using sentence mode, the words will be joined with this string. - -# Normal mode: ---letters: - Include ASCII letters in the password. - If none of the other following options are chosen, letters is the default. - ---digits: - Include digits 0-9 in the password. - ---hex - Include 0-9, a-f in the password. - ---binary - Include 0, 1 in the password. - ---punctuation - Include punctuation symbols in the password. - -# Both modes: ---upper - Convert the entire password to uppercase. - ---lower - Convert the entire password to lowercase. - ---prefix X: - Add a static prefix to the password. - ---suffix X: - Add a static suffix to the password. ''' import argparse import math @@ -61,6 +13,7 @@ import string import sys from voussoirkit import betterhelp +from voussoirkit import gentools from voussoirkit import pipeable try: @@ -142,6 +95,11 @@ def passwordy_argparse(args): elif args.upper: password = password.upper() + if args.groups_of is not None: + chunks = gentools.chunk_generator(password, args.groups_of) + chunks = (''.join(chunk) for chunk in chunks) + password = args.separator.join(chunks) + prefix = args.prefix or '' suffix = args.suffix or '' password = f'{prefix}{password}{suffix}' @@ -150,23 +108,120 @@ def passwordy_argparse(args): return 0 def main(argv): - parser = argparse.ArgumentParser(description=__doc__) - - parser.add_argument('length', type=int) - parser.add_argument('--sentence', action='store_true') - parser.add_argument('--separator', default=' ') - parser.add_argument('--letters', action='store_true') - parser.add_argument('--digits', action='store_true') - parser.add_argument('--hex', action='store_true') - parser.add_argument('--binary', action='store_true') - parser.add_argument('--punctuation', action='store_true') - parser.add_argument('--lower', action='store_true') - parser.add_argument('--upper', action='store_true') - parser.add_argument('--prefix', default=None) - parser.add_argument('--suffix', default=None) + parser = argparse.ArgumentParser( + description=''' + Generate random passwords using cryptographically strong randomness. + ''', + ) + parser.examples = [ + {'args': '32 --letters --digits --punctuation', 'run': True}, + {'args': '48 --hex --upper', 'run': True}, + {'args': '8 --sentence --separator +', 'run': True}, + {'args': '16 --digits --groups-of 4 --separator -', 'run': True}, + {'args': '48 --prefix example.com_ --lower', 'run': True}, + ] + parser.add_argument( + 'length', + type=int, + help=''' + Integer number of characters in normal mode. + Integer number of words in sentence mode. + ''', + ) + parser.add_argument( + '--sentence', + action='store_true', + help=''' + If this argument is passed, the password is made of length random words and + the other alphabet options are ignored. + ''', + ) + parser.add_argument( + '--groups_of', '--groups-of', + type=int, + help=''' + Split the password up into chunks of this many characters, and join them + back together with the --separator. + ''', + ) + parser.add_argument( + '--separator', + type=str, + default=' ', + help=''' + In sentence mode, the words will be joined with this string. + In normal mode, the --groups-of chunks will be joined with this string. + ''', + ) + parser.add_argument( + '--letters', + action='store_true', + help=''' + Include ASCII letters in the password. + If none of the other following options are chosen, letters is the default. + ''', + ) + parser.add_argument( + '--digits', + action='store_true', + help=''' + Include digits 0-9 in the password. + ''', + ) + parser.add_argument( + '--hex', + action='store_true', + help=''' + Include 0-9, a-f in the password. + ''', + ) + parser.add_argument( + '--binary', + action='store_true', + help=''' + Include 0, 1 in the password. + ''', + ) + parser.add_argument( + '--punctuation', + action='store_true', + help=''' + Include punctuation symbols in the password. + ''', + ) + parser.add_argument( + '--prefix', + type=str, + default=None, + help=''' + Add a static prefix to the password. + ''', + ) + parser.add_argument( + '--suffix', + type=str, + default=None, + help=''' + Add a static suffix to the password. + ''', + ) + parser.add_argument( + '--lower', + action='store_true', + help=''' + Convert the entire password to lowercase. + ''', + ) + parser.add_argument( + '--upper', + action='store_true', + help=''' + Convert the entire password to uppercase. + ''', + ) parser.set_defaults(func=passwordy_argparse) - return betterhelp.single_main(argv, parser, __doc__) + return betterhelp.go(parser, argv) if __name__ == '__main__': raise SystemExit(main(sys.argv[1:]))