mirror of https://github.com/chriskiehl/Gooey.git
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
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)
|
|
|