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.
This commit is contained in:
parent
c1cba4f287
commit
232398eff0
4 changed files with 683 additions and 271 deletions
|
@ -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}<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.
|
||||
'''
|
||||
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)
|
||||
def _go_single(parser, argv, *, args_postprocessor=None):
|
||||
can_bare = can_use_bare(parser)
|
||||
|
||||
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):
|
||||
def main(argv):
|
||||
args = parser.parse_args(argv)
|
||||
if args_postprocessor is not None:
|
||||
args = args_postprocessor(args)
|
||||
return args.func(args)
|
||||
|
||||
all_command_names = set(subparsers.keys())
|
||||
command = listget(argv, 0, '').lower()
|
||||
|
||||
if command == '' and can_bare:
|
||||
return main(argv)
|
||||
|
||||
if command == '':
|
||||
print_helptext(main_docstring)
|
||||
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 in HELP_COMMANDS:
|
||||
print_helptext(main_docstring)
|
||||
if command == 'helpall':
|
||||
print_helptext(make_helptext(parser, full_subparsers=True, all_command_names=all_command_names))
|
||||
return 1
|
||||
|
||||
if command not in sub_docstrings:
|
||||
print_helptext(main_docstring)
|
||||
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:]
|
||||
|
||||
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])
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
except ValueError:
|
||||
pipeable.stderr(f'bytestring: Each number should be an integer, not {number}.')
|
||||
return 1
|
||||
pipeable.stdout(bytestring(number))
|
||||
except ValueError:
|
||||
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:]))
|
||||
|
|
|
@ -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:]))
|
||||
|
|
|
@ -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 <flags>
|
||||
|
||||
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:]))
|
||||
|
|
Loading…
Reference in a new issue