You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

279 lines
11 KiB

"""
All things Dynamic Updates & Validation.
Hear me all ye who enter!
=========================
This is a module of disgusting hacks and monkey patching. Control flow
is all over the place and a comprised of hodgepodge of various strategies.
This is all because Argparse's internal parsing design (a) really,
really, REALLY wants to fail and sys.exit at the first error it
finds, and (b) does these program ending validations at seemingly random
points throughout its code base. Meaning, there is no single centralized
validation module, class, or function which could be overridden in order to
achieve the desired behavior.
All that means is that it takes a fair amount of indirect, non-standard, and
gross monkey-patching to get Argparse to collect all its errors as it parses
rather than violently explode each time it finds one.
For additional background, see the original design here:
https://github.com/chriskiehl/Gooey/issues/755
"""
from argparse import ArgumentParser, _SubParsersAction, _MutuallyExclusiveGroup
from functools import wraps
from typing import Union, Any, Mapping, Dict, Callable
from gooey.python_bindings.types import Success, Failure, Try, InvalidChoiceException
from gooey.python_bindings.argparse_to_json import is_subparser
from gooey.util.functional import lift, identity, merge
from gooey.gui.constants import VALUE_PLACEHOLDER
from gooey.python_bindings.constants import Events
from gooey.python_bindings.coms import decode_payload
from gooey.gui.constants import RADIO_PLACEHOLDER
unexpected_exit_explanations = f'''
+=======================+
|Gooey Unexpected Error!|
+=======================+
Gooey encountered an unexpected error while trying to communicate
with your program to process one of the {Events._fields} events.
These features are new and experimental! You may have encountered a bug!
You can open a ticket with a small reproducible example here
https://github.com/chriskiehl/Gooey/issues
''' # type: ignore
deserialize_failure_explanations = f'''
+==================================+
|Gooey Event Deserialization Error!|
+==================================+
Gooey was unable to deserialize the payload returned from your
program when processing one of the {Events._fields} events.
The payload *MUST* be in the `GooeyPublicState` schema. You can
view the type information in `gooey.python_bindings.types.py`
Note, these features are new an experimental. This may be a bug on
Gooey's side!
You can open a ticket with a small reproducible example here:
https://github.com/chriskiehl/Gooey/issues
'''
def check_value(registry: Dict[str, Exception], original_fn):
"""
A Monkey Patch for `Argparse._check_value` which changes its
behavior from one which throws an exception, to one which swallows
the exception and silently records the failure.
For certain argument types, Argparse calls a
one-off `check_value` method. This method is inconvenient for us
as it either returns nothing or throws an ArgumentException (thus leading
to a sys.exit). Because our goal is to collect ALL
errors for the entire parser, we must patch around this behavior.
"""
@wraps(original_fn)
def inner(self, action, value: Union[Any, Success, Failure]):
def update_reg(_self, _action, _value):
try:
original_fn(_action, _value)
except Exception as e:
# check_value exclusively handles validating that the
# supplied argument is a member of the `choices` set.
# by default, it pops an exception containing all of the
# available choices. However, since we're in a UI environment
# all of that is redundant information. It's also *way too much*
# information for things like FilterableDropdown. Thus we just
# remap it to a 'simple' exception here.
error = InvalidChoiceException("Selected option is not a valid choice")
# IMPORTANT! note that this mutates the
# reference that is passed in!
registry[action.dest] = error
# Inside of Argparse, `type_func` gets applied before the calls
# to `check_value`. A such, depending on the type, this may already
# be a lifted value.
if isinstance(value, Success) and not isinstance(value, Failure):
update_reg(self, action, value.value)
elif isinstance(value, list) and all(x.isSuccess() for x in value):
update_reg(self, action, [x.value for x in value])
else:
update_reg(self, action, value)
return inner
def patch_args(*args, **kwargs):
def inner(parser):
return patch_argument(parser, *args, **kwargs)
return inner
def patch_argument(parser, *args, **kwargs):
"""
Mutates the supplied parser to append the arguments (args, kwargs) to
the root parser and all subparsers.
Example: `patch_argument(parser, '--ignore-gooey', action='store_true')
This is used to punch additional cli arguments into the user's
existing parser definition. By adding our arguments everywhere it allows
us to use the `parse_args` machinery 'for free' without needing to
worry about context shifts (like a repeated `dest` between subparsers).
"""
parser.add_argument(*args, **kwargs)
subparsers = list(filter(is_subparser, parser._actions))
if subparsers:
for sub in subparsers[0].choices.values(): # type: ignore
patch_argument(sub, *args, **kwargs)
return parser
def patch_all_parsers(patch_fn: Callable[[ArgumentParser], None], parser):
subparsers = list(filter(is_subparser, parser._actions))
if subparsers:
for sub in subparsers[0].choices.values(): # type: ignore
patch_all_parsers(patch_fn, sub)
return parser
def recursively_patch_parser(parser, fn, *args):
fn(parser, *args)
subparsers = list(filter(is_subparser, parser._actions))
if subparsers:
for sub in subparsers[0].choices.values(): # type: ignore
recursively_patch_parser(sub, fn, *args)
return parser
def recursively_patch_actions(parser, fn):
for action in parser._actions:
if issubclass(type(action), _SubParsersAction):
for subparser in action.choices.values():
recursively_patch_actions(subparser, fn)
else:
fn(action)
def lift_action_type(action):
""""""
action.type = lift(action.type or identity)
def lift_actions_mutating(parser):
"""
Mutates the supplied parser to lift all of its (likely) partial
functions into total functions. See module docs for additional
background. TL;DR: we have to "trick" Argparse into thinking
every value is valid so that it doesn't short-circuit and sys.exit
when it encounters a validation error. As such, we wrap everything
in an Either/Try, and defer deciding the actual success/failure of
the type transform until later in the execution when we have control.
"""
recursively_patch_actions(parser, lift_action_type)
# for action in parser._actions:
# if issubclass(type(action), _SubParsersAction):
# for subparser in action.choices.values():
# lift_actions_mutating(subparser)
# else:
# action.type = lift(action.type or identity)
def collect_errors(parser, error_registry: Dict[str, Exception], args: Dict[str, Try]) -> Dict[str, str]:
"""
Merges all the errors from the Args mapping and error registry
into a final dict.
"""
# As is a theme throughout this module, to avoid Argparse
# short-circuiting during parse-time, we pass a placeholder string
# for required positional arguments which haven't yet been provided
# by the user. So, what's happening here is that we're now collecting
# all the args which have the placeholders so that we can flag them
# all as required and missing.
# Again, to be hyper clear, this is about being able to collect ALL
# errors, versus just ONE error (Argparse default).
required_but_missing = {k: 'This field is required'
for k, v in args.items()
if isinstance(v, Success) and v.value == VALUE_PLACEHOLDER}
mutexes_required_but_missing = collect_mutex_errors(parser, args)
errors = {k: str(v.error)
for k, v in args.items()
if v is not None and isinstance(v, Failure)}
# Secondary errors are those which get frustratingly applied by
# Argparse in a way which can't be easily tracked with patching
# or higher order functions. See: `check_value` for more details.
secondary = {k: str(e) for k, e in error_registry.items() if e}
return merge(required_but_missing, errors, secondary, mutexes_required_but_missing)
def collect_mutex_errors(parser, args: Dict[str, Try]):
"""
RadioGroups / MutuallyExclusiveGroup require extra care.
Mutexes are not normal actions. They're not argument targets
themselves, they have no `dest`, they're just parse-time containers
for arguments. As such, there's no top-level argument destination
we can tie a single error to. So, the strategy here is to mark _all_ of
a radio group's children with an error if *any* of them are missing.
It's a bit clunky, but what we've got to work with.
"""
def dest_targets(group: _MutuallyExclusiveGroup):
return [action.dest for action in group._group_actions]
mutexes_missing = {dest for dest, v in args.items()
if isinstance(v, Success) and v.value == RADIO_PLACEHOLDER}
return {dest: 'One of these must be provided'
for group in parser._mutually_exclusive_groups
for dest in dest_targets(group)
# if the group is required and we've got one of its
# children marked as missing
if group.required and set(dest_targets(group)).intersection(mutexes_missing)}
def patch(obj, old_fn, new_fn):
setattr(obj, old_fn, new_fn.__get__(obj, ArgumentParser))
def monkey_patch_check_value(parser, new_fn):
parser._check_value = new_fn.__get__(parser, ArgumentParser)
return parser
def monkey_patch(patcher, error_registry: Dict[str, Exception], parser):
lift_actions_mutating(parser)
patcher(parser)
new_check_value = check_value(error_registry, parser._check_value)
# https://stackoverflow.com/questions/28127874/monkey-patching-python-an-instance-method
# parser._check_value = new_check_value.__get__(parser, ArgumentParser)
return parser
def monkey_patch_for_form_validation(error_registry: Dict[str, Exception], parser):
"""
Applies all the crufty monkey patching required to
process a validate_form event
"""
lift_actions_mutating(parser)
patch_argument(parser, '--gooey-validate-form', action='store_true')
patch_argument(parser, '--gooey-state', action='store', type=decode_payload)
new_check_value = check_value(error_registry, parser._check_value)
recursively_patch_parser(parser, monkey_patch_check_value, new_check_value)
# https://stackoverflow.com/questions/28127874/monkey-patching-python-an-instance-method
# patch(parser, '_check_value', new_check_value)
# parser._check_value = new_check_value.__get__(parser, ArgumentParser)
return monkey_patch_check_value(parser, new_check_value)