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

2 years ago
  1. """
  2. All things Dynamic Updates & Validation.
  3. Hear me all ye who enter!
  4. =========================
  5. This is a module of disgusting hacks and monkey patching. Control flow
  6. is all over the place and a comprised of hodgepodge of various strategies.
  7. This is all because Argparse's internal parsing design (a) really,
  8. really, REALLY wants to fail and sys.exit at the first error it
  9. finds, and (b) does these program ending validations at seemingly random
  10. points throughout its code base. Meaning, there is no single centralized
  11. validation module, class, or function which could be overridden in order to
  12. achieve the desired behavior.
  13. All that means is that it takes a fair amount of indirect, non-standard, and
  14. gross monkey-patching to get Argparse to collect all its errors as it parses
  15. rather than violently explode each time it finds one.
  16. For additional background, see the original design here:
  17. https://github.com/chriskiehl/Gooey/issues/755
  18. """
  19. from argparse import ArgumentParser, _SubParsersAction, _MutuallyExclusiveGroup
  20. from functools import wraps
  21. from typing import Union, Any, Mapping, Dict, Callable
  22. from gooey.python_bindings.types import Success, Failure, Try, InvalidChoiceException
  23. from gooey.python_bindings.argparse_to_json import is_subparser
  24. from gooey.util.functional import lift, identity, merge
  25. from gooey.gui.constants import VALUE_PLACEHOLDER
  26. from gooey.python_bindings.constants import Events
  27. from gooey.python_bindings.coms import decode_payload
  28. from gooey.gui.constants import RADIO_PLACEHOLDER
  29. unexpected_exit_explanations = f'''
  30. +=======================+
  31. |Gooey Unexpected Error!|
  32. +=======================+
  33. Gooey encountered an unexpected error while trying to communicate
  34. with your program to process one of the {Events._fields} events.
  35. These features are new and experimental! You may have encountered a bug!
  36. You can open a ticket with a small reproducible example here
  37. https://github.com/chriskiehl/Gooey/issues
  38. ''' # type: ignore
  39. deserialize_failure_explanations = f'''
  40. +==================================+
  41. |Gooey Event Deserialization Error!|
  42. +==================================+
  43. Gooey was unable to deserialize the payload returned from your
  44. program when processing one of the {Events._fields} events.
  45. The payload *MUST* be in the `GooeyPublicState` schema. You can
  46. view the type information in `gooey.python_bindings.types.py`
  47. Note, these features are new an experimental. This may be a bug on
  48. Gooey's side!
  49. You can open a ticket with a small reproducible example here:
  50. https://github.com/chriskiehl/Gooey/issues
  51. '''
  52. def check_value(registry: Dict[str, Exception], original_fn):
  53. """
  54. A Monkey Patch for `Argparse._check_value` which changes its
  55. behavior from one which throws an exception, to one which swallows
  56. the exception and silently records the failure.
  57. For certain argument types, Argparse calls a
  58. one-off `check_value` method. This method is inconvenient for us
  59. as it either returns nothing or throws an ArgumentException (thus leading
  60. to a sys.exit). Because our goal is to collect ALL
  61. errors for the entire parser, we must patch around this behavior.
  62. """
  63. @wraps(original_fn)
  64. def inner(self, action, value: Union[Any, Success, Failure]):
  65. def update_reg(_self, _action, _value):
  66. try:
  67. original_fn(_action, _value)
  68. except Exception as e:
  69. # check_value exclusively handles validating that the
  70. # supplied argument is a member of the `choices` set.
  71. # by default, it pops an exception containing all of the
  72. # available choices. However, since we're in a UI environment
  73. # all of that is redundant information. It's also *way too much*
  74. # information for things like FilterableDropdown. Thus we just
  75. # remap it to a 'simple' exception here.
  76. error = InvalidChoiceException("Selected option is not a valid choice")
  77. # IMPORTANT! note that this mutates the
  78. # reference that is passed in!
  79. registry[action.dest] = error
  80. # Inside of Argparse, `type_func` gets applied before the calls
  81. # to `check_value`. A such, depending on the type, this may already
  82. # be a lifted value.
  83. if isinstance(value, Success) and not isinstance(value, Failure):
  84. update_reg(self, action, value.value)
  85. elif isinstance(value, list) and all(x.isSuccess() for x in value):
  86. update_reg(self, action, [x.value for x in value])
  87. else:
  88. update_reg(self, action, value)
  89. return inner
  90. def patch_args(*args, **kwargs):
  91. def inner(parser):
  92. return patch_argument(parser, *args, **kwargs)
  93. return inner
  94. def patch_argument(parser, *args, **kwargs):
  95. """
  96. Mutates the supplied parser to append the arguments (args, kwargs) to
  97. the root parser and all subparsers.
  98. Example: `patch_argument(parser, '--ignore-gooey', action='store_true')
  99. This is used to punch additional cli arguments into the user's
  100. existing parser definition. By adding our arguments everywhere it allows
  101. us to use the `parse_args` machinery 'for free' without needing to
  102. worry about context shifts (like a repeated `dest` between subparsers).
  103. """
  104. parser.add_argument(*args, **kwargs)
  105. subparsers = list(filter(is_subparser, parser._actions))
  106. if subparsers:
  107. for sub in subparsers[0].choices.values(): # type: ignore
  108. patch_argument(sub, *args, **kwargs)
  109. return parser
  110. def patch_all_parsers(patch_fn: Callable[[ArgumentParser], None], parser):
  111. subparsers = list(filter(is_subparser, parser._actions))
  112. if subparsers:
  113. for sub in subparsers[0].choices.values(): # type: ignore
  114. patch_all_parsers(patch_fn, sub)
  115. return parser
  116. def recursively_patch_parser(parser, fn, *args):
  117. fn(parser, *args)
  118. subparsers = list(filter(is_subparser, parser._actions))
  119. if subparsers:
  120. for sub in subparsers[0].choices.values(): # type: ignore
  121. recursively_patch_parser(sub, fn, *args)
  122. return parser
  123. def recursively_patch_actions(parser, fn):
  124. for action in parser._actions:
  125. if issubclass(type(action), _SubParsersAction):
  126. for subparser in action.choices.values():
  127. recursively_patch_actions(subparser, fn)
  128. else:
  129. fn(action)
  130. def lift_action_type(action):
  131. """"""
  132. action.type = lift(action.type or identity)
  133. def lift_actions_mutating(parser):
  134. """
  135. Mutates the supplied parser to lift all of its (likely) partial
  136. functions into total functions. See module docs for additional
  137. background. TL;DR: we have to "trick" Argparse into thinking
  138. every value is valid so that it doesn't short-circuit and sys.exit
  139. when it encounters a validation error. As such, we wrap everything
  140. in an Either/Try, and defer deciding the actual success/failure of
  141. the type transform until later in the execution when we have control.
  142. """
  143. recursively_patch_actions(parser, lift_action_type)
  144. # for action in parser._actions:
  145. # if issubclass(type(action), _SubParsersAction):
  146. # for subparser in action.choices.values():
  147. # lift_actions_mutating(subparser)
  148. # else:
  149. # action.type = lift(action.type or identity)
  150. def collect_errors(parser, error_registry: Dict[str, Exception], args: Dict[str, Try]) -> Dict[str, str]:
  151. """
  152. Merges all the errors from the Args mapping and error registry
  153. into a final dict.
  154. """
  155. # As is a theme throughout this module, to avoid Argparse
  156. # short-circuiting during parse-time, we pass a placeholder string
  157. # for required positional arguments which haven't yet been provided
  158. # by the user. So, what's happening here is that we're now collecting
  159. # all the args which have the placeholders so that we can flag them
  160. # all as required and missing.
  161. # Again, to be hyper clear, this is about being able to collect ALL
  162. # errors, versus just ONE error (Argparse default).
  163. required_but_missing = {k: 'This field is required'
  164. for k, v in args.items()
  165. if isinstance(v, Success) and v.value == VALUE_PLACEHOLDER}
  166. mutexes_required_but_missing = collect_mutex_errors(parser, args)
  167. errors = {k: str(v.error)
  168. for k, v in args.items()
  169. if v is not None and isinstance(v, Failure)}
  170. # Secondary errors are those which get frustratingly applied by
  171. # Argparse in a way which can't be easily tracked with patching
  172. # or higher order functions. See: `check_value` for more details.
  173. secondary = {k: str(e) for k, e in error_registry.items() if e}
  174. return merge(required_but_missing, errors, secondary, mutexes_required_but_missing)
  175. def collect_mutex_errors(parser, args: Dict[str, Try]):
  176. """
  177. RadioGroups / MutuallyExclusiveGroup require extra care.
  178. Mutexes are not normal actions. They're not argument targets
  179. themselves, they have no `dest`, they're just parse-time containers
  180. for arguments. As such, there's no top-level argument destination
  181. we can tie a single error to. So, the strategy here is to mark _all_ of
  182. a radio group's children with an error if *any* of them are missing.
  183. It's a bit clunky, but what we've got to work with.
  184. """
  185. def dest_targets(group: _MutuallyExclusiveGroup):
  186. return [action.dest for action in group._group_actions]
  187. mutexes_missing = {dest for dest, v in args.items()
  188. if isinstance(v, Success) and v.value == RADIO_PLACEHOLDER}
  189. return {dest: 'One of these must be provided'
  190. for group in parser._mutually_exclusive_groups
  191. for dest in dest_targets(group)
  192. # if the group is required and we've got one of its
  193. # children marked as missing
  194. if group.required and set(dest_targets(group)).intersection(mutexes_missing)}
  195. def patch(obj, old_fn, new_fn):
  196. setattr(obj, old_fn, new_fn.__get__(obj, ArgumentParser))
  197. def monkey_patch_check_value(parser, new_fn):
  198. parser._check_value = new_fn.__get__(parser, ArgumentParser)
  199. return parser
  200. def monkey_patch(patcher, error_registry: Dict[str, Exception], parser):
  201. lift_actions_mutating(parser)
  202. patcher(parser)
  203. new_check_value = check_value(error_registry, parser._check_value)
  204. # https://stackoverflow.com/questions/28127874/monkey-patching-python-an-instance-method
  205. # parser._check_value = new_check_value.__get__(parser, ArgumentParser)
  206. return parser
  207. def monkey_patch_for_form_validation(error_registry: Dict[str, Exception], parser):
  208. """
  209. Applies all the crufty monkey patching required to
  210. process a validate_form event
  211. """
  212. lift_actions_mutating(parser)
  213. patch_argument(parser, '--gooey-validate-form', action='store_true')
  214. patch_argument(parser, '--gooey-state', action='store', type=decode_payload)
  215. new_check_value = check_value(error_registry, parser._check_value)
  216. recursively_patch_parser(parser, monkey_patch_check_value, new_check_value)
  217. # https://stackoverflow.com/questions/28127874/monkey-patching-python-an-instance-method
  218. # patch(parser, '_check_value', new_check_value)
  219. # parser._check_value = new_check_value.__get__(parser, ArgumentParser)
  220. return monkey_patch_check_value(parser, new_check_value)