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:
voussoir 2022-02-12 19:43:23 -08:00
parent c1cba4f287
commit 232398eff0
No known key found for this signature in database
GPG key ID: 5F7554F8C26DACCB
4 changed files with 683 additions and 271 deletions

View file

@ -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)

View file

@ -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:]))

View file

@ -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:]))

View file

@ -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:]))