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.

362 lines
11 KiB

  1. """
  2. Converts argparse parser actions into json "Build Specs"
  3. """
  4. import functools
  5. import pprint
  6. import argparse
  7. import os
  8. import sys
  9. from argparse import (
  10. _CountAction,
  11. _HelpAction,
  12. _StoreConstAction,
  13. _StoreFalseAction,
  14. _StoreTrueAction,
  15. _SubParsersAction)
  16. from collections import OrderedDict
  17. from functools import partial
  18. from uuid import uuid4
  19. from gooey.util.functional import merge, getin
  20. VALID_WIDGETS = (
  21. 'FileChooser',
  22. 'MultiFileChooser',
  23. 'FileSaver',
  24. 'DirChooser',
  25. 'DateChooser',
  26. 'TextField',
  27. 'Dropdown',
  28. 'Counter',
  29. 'RadioGroup',
  30. 'CheckBox',
  31. 'MultiDirChooser',
  32. 'Textarea',
  33. 'PasswordField',
  34. 'Listbox'
  35. )
  36. class UnknownWidgetType(Exception):
  37. pass
  38. class UnsupportedConfiguration(Exception):
  39. pass
  40. group_defaults = {
  41. 'columns': 2,
  42. 'padding': 10,
  43. 'show_border': False
  44. }
  45. item_default = {
  46. 'error_color': '#ea7878',
  47. 'validator': {
  48. 'type': 'local',
  49. 'test': 'lambda x: True',
  50. 'message': ''
  51. },
  52. 'external_validator': {
  53. 'cmd': '',
  54. }
  55. }
  56. def convert(parser, **kwargs):
  57. assert_subparser_constraints(parser)
  58. x = {
  59. 'layout': 'standard',
  60. 'widgets': OrderedDict(
  61. (choose_name(name, sub_parser), {
  62. 'command': name,
  63. 'contents': process(sub_parser,
  64. getattr(sub_parser, 'widgets', {}),
  65. getattr(sub_parser, 'options', {}))
  66. }) for name, sub_parser in iter_parsers(parser))
  67. }
  68. if kwargs.get('use_legacy_titles'):
  69. return apply_default_rewrites(x)
  70. return x
  71. def process(parser, widget_dict, options):
  72. mutex_groups = parser._mutually_exclusive_groups
  73. raw_action_groups = [extract_groups(group) for group in parser._action_groups
  74. if group._group_actions]
  75. corrected_action_groups = reapply_mutex_groups(mutex_groups, raw_action_groups)
  76. return categorize2(strip_empty(corrected_action_groups), widget_dict, options)
  77. def strip_empty(groups):
  78. return [group for group in groups if group['items']]
  79. def assert_subparser_constraints(parser):
  80. if has_subparsers(parser._actions):
  81. if has_required(parser._actions):
  82. raise UnsupportedConfiguration(
  83. "Gooey doesn't currently support top level required arguments "
  84. "when subparsers are present.")
  85. def iter_parsers(parser):
  86. ''' Iterate over name, parser pairs '''
  87. try:
  88. return get_subparser(parser._actions).choices.items()
  89. except:
  90. return iter([('::gooey/default', parser)])
  91. def extract_groups(action_group):
  92. '''
  93. Recursively extract argument groups and associated actions
  94. from ParserGroup objects
  95. '''
  96. return {
  97. 'name': action_group.title,
  98. 'description': action_group.description,
  99. 'items': [action for action in action_group._group_actions
  100. if not is_help_message(action)],
  101. 'groups': [extract_groups(group)
  102. for group in action_group._action_groups],
  103. 'options': merge(group_defaults,
  104. getattr(action_group, 'gooey_options', {}))
  105. }
  106. def apply_default_rewrites(spec):
  107. top_level_subgroups = list(spec['widgets'].keys())
  108. for subgroup in top_level_subgroups:
  109. path = ['widgets', subgroup, 'contents']
  110. contents = getin(spec, path)
  111. for group in contents:
  112. if group['name'] == 'positional arguments':
  113. group['name'] = 'Required Arguments'
  114. if group['name'] == 'optional arguments':
  115. group['name'] = 'Optional Arguments'
  116. return spec
  117. def contains_actions(a, b):
  118. ''' check if any actions(a) are present in actions(b) '''
  119. return set(a).intersection(set(b))
  120. def reapply_mutex_groups(mutex_groups, action_groups):
  121. # argparse stores mutually exclusive groups independently
  122. # of all other groups. So, they must be manually re-combined
  123. # with the groups/subgroups to which they were originally declared
  124. # in order to have them appear in the correct location in the UI.
  125. #
  126. # Order is attempted to be preserved by inserting the MutexGroup
  127. # into the _actions list at the first occurrence of any item
  128. # where the two groups intersect
  129. def swap_actions(actions):
  130. for mutexgroup in mutex_groups:
  131. mutex_actions = mutexgroup._group_actions
  132. if contains_actions(mutex_actions, actions):
  133. # make a best guess as to where we should store the group
  134. targetindex = actions.index(mutexgroup._group_actions[0])
  135. # insert the _ArgumentGroup container
  136. actions[targetindex] = mutexgroup
  137. # remove the duplicated individual actions
  138. return [action for action in actions
  139. if action not in mutex_actions]
  140. return actions
  141. return [group.update({'items': swap_actions(group['items'])}) or group
  142. for group in action_groups]
  143. def categorize2(groups, widget_dict, options):
  144. return [{
  145. 'name': group['name'],
  146. 'items': list(categorize(group['items'], widget_dict, options)),
  147. 'groups': categorize2(group['groups'], widget_dict, options),
  148. 'description': group['description'],
  149. 'options': group['options']
  150. } for group in groups]
  151. def categorize(actions, widget_dict, options):
  152. _get_widget = partial(get_widget, widget_dict)
  153. for action in actions:
  154. if is_mutex(action):
  155. yield build_radio_group(action, widget_dict, options)
  156. elif is_standard(action):
  157. yield action_to_json(action, _get_widget(action, 'TextField'), options)
  158. elif is_choice(action):
  159. yield action_to_json(action, _get_widget(action, 'Dropdown'), options)
  160. elif is_flag(action):
  161. yield action_to_json(action, _get_widget(action, 'CheckBox'), options)
  162. elif is_counter(action):
  163. _json = action_to_json(action, _get_widget(action, 'Counter'), options)
  164. # pre-fill the 'counter' dropdown
  165. _json['data']['choices'] = list(map(str, range(1, 11)))
  166. yield _json
  167. else:
  168. raise UnknownWidgetType(action)
  169. def get_widget(widgets, action, default):
  170. supplied_widget = widgets.get(action.dest, None)
  171. type_arg_widget = 'FileChooser' if action.type == argparse.FileType else None
  172. return supplied_widget or type_arg_widget or default
  173. def is_required(action):
  174. '''
  175. _actions possessing the `required` flag and not implicitly optional
  176. through `nargs` being '*' or '?'
  177. '''
  178. return not isinstance(action, _SubParsersAction) and (
  179. action.required == True and action.nargs not in ['*', '?'])
  180. def is_mutex(action):
  181. return isinstance(action, argparse._MutuallyExclusiveGroup)
  182. def has_required(actions):
  183. return list(filter(None, list(filter(is_required, actions))))
  184. def is_subparser(action):
  185. return isinstance(action, _SubParsersAction)
  186. def has_subparsers(actions):
  187. return list(filter(is_subparser, actions))
  188. def get_subparser(actions):
  189. return list(filter(is_subparser, actions))[0]
  190. def is_optional(action):
  191. '''
  192. _actions either not possessing the `required` flag or implicitly optional
  193. through `nargs` being '*' or '?'
  194. '''
  195. return (not action.required) or action.nargs in ['*', '?']
  196. def is_choice(action):
  197. ''' action with choices supplied '''
  198. return action.choices
  199. def is_standard(action):
  200. """ actions which are general "store" instructions.
  201. e.g. anything which has an argument style like:
  202. $ script.py -f myfilename.txt
  203. """
  204. boolean_actions = (
  205. _StoreConstAction, _StoreFalseAction,
  206. _StoreTrueAction
  207. )
  208. return (not action.choices
  209. and not isinstance(action, _CountAction)
  210. and not isinstance(action, _HelpAction)
  211. and type(action) not in boolean_actions)
  212. def is_flag(action):
  213. """ _actions which are either storeconst, store_bool, etc.. """
  214. action_types = [_StoreTrueAction, _StoreFalseAction, _StoreConstAction]
  215. return any(list(map(lambda Action: isinstance(action, Action), action_types)))
  216. def is_counter(action):
  217. """ _actions which are of type _CountAction """
  218. return isinstance(action, _CountAction)
  219. def is_default_progname(name, subparser):
  220. return subparser.prog == '{} {}'.format(os.path.split(sys.argv[0])[-1], name)
  221. def is_help_message(action):
  222. return isinstance(action, _HelpAction)
  223. def choose_name(name, subparser):
  224. return name if is_default_progname(name, subparser) else subparser.prog
  225. def build_radio_group(mutex_group, widget_group, options):
  226. return {
  227. 'id': str(uuid4()),
  228. 'type': 'RadioGroup',
  229. 'cli_type': 'optional',
  230. 'group_name': 'Choose Option',
  231. 'required': mutex_group.required,
  232. 'options': getattr(mutex_group, 'gooey_options', {}),
  233. 'data': {
  234. 'commands': [action.option_strings for action in mutex_group._group_actions],
  235. 'widgets': list(categorize(mutex_group._group_actions, widget_group, options))
  236. }
  237. }
  238. def action_to_json(action, widget, options):
  239. if action.required:
  240. # check that it's present and not just spaces
  241. validator = 'user_input and not user_input.isspace()'
  242. error_msg = 'This field is required'
  243. else:
  244. # not required; do nothing;
  245. validator = 'True'
  246. error_msg = ''
  247. base = merge(item_default, {
  248. 'validator': {
  249. 'test': validator,
  250. 'message': error_msg
  251. },
  252. })
  253. return {
  254. 'id': action.option_strings[0] if action.option_strings else action.dest,
  255. 'type': widget,
  256. 'cli_type': choose_cli_type(action),
  257. 'required': action.required,
  258. 'data': {
  259. 'display_name': action.metavar or action.dest,
  260. 'help': action.help,
  261. 'required': action.required,
  262. 'nargs': action.nargs or '',
  263. 'commands': action.option_strings,
  264. 'choices': action.choices or [],
  265. 'default': clean_default(action.default),
  266. 'dest': action.dest,
  267. },
  268. 'options': merge(base, options.get(action.dest) or {})
  269. }
  270. def choose_cli_type(action):
  271. return 'positional' \
  272. if action.required and not action.option_strings \
  273. else 'optional'
  274. def clean_default(default):
  275. '''
  276. Attemps to safely coalesce the default value down to
  277. a valid JSON type.
  278. See: Issue #147.
  279. function references supplied as arguments to the
  280. `default` parameter in Argparse cause errors in Gooey.
  281. '''
  282. return default.__name__ if callable(default) else default