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.

390 lines
12 KiB

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