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.

479 lines
15 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.python_bindings.gooey_parser import GooeyParser
  18. from gooey.util.functional import merge, getin, identity, assoc
  19. VALID_WIDGETS = (
  20. 'FileChooser',
  21. 'MultiFileChooser',
  22. 'FileSaver',
  23. 'DirChooser',
  24. 'DateChooser',
  25. 'TextField',
  26. 'Dropdown',
  27. 'Counter',
  28. 'RadioGroup',
  29. 'CheckBox',
  30. 'BlockCheckbox',
  31. 'MultiDirChooser',
  32. 'Textarea',
  33. 'PasswordField',
  34. 'Listbox'
  35. )
  36. class UnknownWidgetType(Exception):
  37. pass
  38. class UnsupportedConfiguration(Exception):
  39. pass
  40. # TODO: merge the default foreground and bg colors from the
  41. # baseline build_spec
  42. item_default = {
  43. 'error_color': '#ea7878',
  44. 'label_color': '#000000',
  45. 'help_color': '#363636',
  46. 'full_width': False,
  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. """
  58. Converts a parser into a JSON representation
  59. This is in desperate need of refactor. It wasn't build with supporting
  60. all (or any) of this configuration in mind. The use of global defaults
  61. are actively getting in the way of easily adding more configuration options.
  62. """
  63. group_defaults = {
  64. 'legacy': {
  65. 'required_cols': kwargs['num_required_cols'],
  66. 'optional_cols': kwargs['num_optional_cols']
  67. },
  68. 'columns': 2,
  69. 'padding': 10,
  70. 'show_border': False
  71. }
  72. assert_subparser_constraints(parser)
  73. x = {
  74. 'layout': 'standard',
  75. 'widgets': OrderedDict(
  76. (choose_name(name, sub_parser), {
  77. 'command': name,
  78. 'name': choose_name(name, sub_parser),
  79. 'help': get_subparser_help(sub_parser),
  80. 'description': '',
  81. 'contents': process(sub_parser,
  82. getattr(sub_parser, 'widgets', {}),
  83. getattr(sub_parser, 'options', {}),
  84. group_defaults)
  85. }) for name, sub_parser in iter_parsers(parser))
  86. }
  87. if kwargs.get('use_legacy_titles'):
  88. return apply_default_rewrites(x)
  89. return x
  90. def process(parser, widget_dict, options, group_defaults):
  91. mutex_groups = parser._mutually_exclusive_groups
  92. raw_action_groups = [extract_groups(group, group_defaults) for group in parser._action_groups
  93. if group._group_actions]
  94. corrected_action_groups = reapply_mutex_groups(mutex_groups, raw_action_groups)
  95. return categorize2(strip_empty(corrected_action_groups), widget_dict, options)
  96. def strip_empty(groups):
  97. return [group for group in groups if group['items']]
  98. def assert_subparser_constraints(parser):
  99. if has_subparsers(parser._actions):
  100. if has_required(parser._actions):
  101. raise UnsupportedConfiguration(
  102. "Gooey doesn't currently support top level required arguments "
  103. "when subparsers are present.")
  104. def iter_parsers(parser):
  105. ''' Iterate over name, parser pairs '''
  106. try:
  107. return get_subparser(parser._actions).choices.items()
  108. except:
  109. return iter([('::gooey/default', parser)])
  110. def get_subparser_help(parser):
  111. if isinstance(parser, GooeyParser):
  112. return getattr(parser.parser, 'usage', '')
  113. else:
  114. return getattr(parser, 'usage', '')
  115. def extract_groups(action_group, group_defaults):
  116. '''
  117. Recursively extract argument groups and associated actions
  118. from ParserGroup objects
  119. '''
  120. return {
  121. 'name': action_group.title,
  122. 'description': action_group.description,
  123. 'items': [action for action in action_group._group_actions
  124. if not is_help_message(action)],
  125. 'groups': [extract_groups(group, group_defaults)
  126. for group in action_group._action_groups],
  127. 'options': handle_option_merge(
  128. group_defaults,
  129. getattr(action_group, 'gooey_options', {}),
  130. action_group.title)
  131. }
  132. def handle_option_merge(group_defaults, incoming_options, title):
  133. """
  134. Merges a set of group defaults with incoming options.
  135. A bunch of ceremony here is to ensure backwards compatibility with the old
  136. num_required_cols and num_optional_cols decorator args. They are used as
  137. the seed values for the new group defaults which keeps the old behavior
  138. _mostly_ in tact.
  139. Known failure points:
  140. * Using custom groups / names. No 'positional arguments' group
  141. means no required_cols arg being honored
  142. * Non-positional args marked as required. It would take group
  143. shuffling along the lines of that required to make
  144. mutually exclusive groups show in the correct place. In short, not
  145. worth the complexity for a legacy feature that's been succeeded by
  146. a much more powerful alternative.
  147. """
  148. if title == 'positional arguments':
  149. # the argparse default 'required' bucket
  150. req_cols = getin(group_defaults, ['legacy', 'required_cols'], 2)
  151. new_defaults = assoc(group_defaults, 'columns', req_cols)
  152. return merge(new_defaults, incoming_options)
  153. else:
  154. opt_cols = getin(group_defaults, ['legacy', 'optional_cols'], 2)
  155. new_defaults = assoc(group_defaults, 'columns', opt_cols)
  156. return merge(new_defaults, incoming_options)
  157. def apply_default_rewrites(spec):
  158. top_level_subgroups = list(spec['widgets'].keys())
  159. for subgroup in top_level_subgroups:
  160. path = ['widgets', subgroup, 'contents']
  161. contents = getin(spec, path)
  162. for group in contents:
  163. if group['name'] == 'positional arguments':
  164. group['name'] = 'Required Arguments'
  165. if group['name'] == 'optional arguments':
  166. group['name'] = 'Optional Arguments'
  167. return spec
  168. def contains_actions(a, b):
  169. ''' check if any actions(a) are present in actions(b) '''
  170. return set(a).intersection(set(b))
  171. def reapply_mutex_groups(mutex_groups, action_groups):
  172. # argparse stores mutually exclusive groups independently
  173. # of all other groups. So, they must be manually re-combined
  174. # with the groups/subgroups to which they were originally declared
  175. # in order to have them appear in the correct location in the UI.
  176. #
  177. # Order is attempted to be preserved by inserting the MutexGroup
  178. # into the _actions list at the first occurrence of any item
  179. # where the two groups intersect
  180. def swap_actions(actions):
  181. for mutexgroup in mutex_groups:
  182. mutex_actions = mutexgroup._group_actions
  183. if contains_actions(mutex_actions, actions):
  184. # make a best guess as to where we should store the group
  185. targetindex = actions.index(mutexgroup._group_actions[0])
  186. # insert the _ArgumentGroup container
  187. actions[targetindex] = mutexgroup
  188. # remove the duplicated individual actions
  189. return [action for action in actions
  190. if action not in mutex_actions]
  191. return actions
  192. return [group.update({'items': swap_actions(group['items'])}) or group
  193. for group in action_groups]
  194. def categorize2(groups, widget_dict, options):
  195. defaults = {'label_color': '#000000', 'description_color': '#363636'}
  196. return [{
  197. 'name': group['name'],
  198. 'items': list(categorize(group['items'], widget_dict, options)),
  199. 'groups': categorize2(group['groups'], widget_dict, options),
  200. 'description': group['description'],
  201. 'options': merge(defaults ,group['options'])
  202. } for group in groups]
  203. def categorize(actions, widget_dict, options):
  204. _get_widget = partial(get_widget, widget_dict)
  205. for action in actions:
  206. if is_mutex(action):
  207. yield build_radio_group(action, widget_dict, options)
  208. elif is_standard(action):
  209. yield action_to_json(action, _get_widget(action, 'TextField'), options)
  210. elif is_choice(action):
  211. yield action_to_json(action, _get_widget(action, 'Dropdown'), options)
  212. elif is_flag(action):
  213. yield action_to_json(action, _get_widget(action, 'CheckBox'), options)
  214. elif is_counter(action):
  215. _json = action_to_json(action, _get_widget(action, 'Counter'), options)
  216. # pre-fill the 'counter' dropdown
  217. _json['data']['choices'] = list(map(str, range(1, 11)))
  218. yield _json
  219. else:
  220. raise UnknownWidgetType(action)
  221. def get_widget(widgets, action, default):
  222. supplied_widget = widgets.get(action.dest, None)
  223. type_arg_widget = 'FileChooser' if action.type == argparse.FileType else None
  224. return supplied_widget or type_arg_widget or default
  225. def is_required(action):
  226. '''
  227. _actions possessing the `required` flag and not implicitly optional
  228. through `nargs` being '*' or '?'
  229. '''
  230. return not isinstance(action, _SubParsersAction) and (
  231. action.required == True and action.nargs not in ['*', '?'])
  232. def is_mutex(action):
  233. return isinstance(action, argparse._MutuallyExclusiveGroup)
  234. def has_required(actions):
  235. return list(filter(None, list(filter(is_required, actions))))
  236. def is_subparser(action):
  237. return isinstance(action, _SubParsersAction)
  238. def has_subparsers(actions):
  239. return list(filter(is_subparser, actions))
  240. def get_subparser(actions):
  241. return list(filter(is_subparser, actions))[0]
  242. def is_optional(action):
  243. '''
  244. _actions either not possessing the `required` flag or implicitly optional
  245. through `nargs` being '*' or '?'
  246. '''
  247. return (not action.required) or action.nargs in ['*', '?']
  248. def is_choice(action):
  249. ''' action with choices supplied '''
  250. return action.choices
  251. def is_standard(action):
  252. """ actions which are general "store" instructions.
  253. e.g. anything which has an argument style like:
  254. $ script.py -f myfilename.txt
  255. """
  256. boolean_actions = (
  257. _StoreConstAction, _StoreFalseAction,
  258. _StoreTrueAction
  259. )
  260. return (not action.choices
  261. and not isinstance(action, _CountAction)
  262. and not isinstance(action, _HelpAction)
  263. and type(action) not in boolean_actions)
  264. def is_flag(action):
  265. """ _actions which are either storeconst, store_bool, etc.. """
  266. action_types = [_StoreTrueAction, _StoreFalseAction, _StoreConstAction]
  267. return any(list(map(lambda Action: isinstance(action, Action), action_types)))
  268. def is_counter(action):
  269. """ _actions which are of type _CountAction """
  270. return isinstance(action, _CountAction)
  271. def is_default_progname(name, subparser):
  272. return subparser.prog == '{} {}'.format(os.path.split(sys.argv[0])[-1], name)
  273. def is_help_message(action):
  274. return isinstance(action, _HelpAction)
  275. def choose_name(name, subparser):
  276. return name if is_default_progname(name, subparser) else subparser.prog
  277. def build_radio_group(mutex_group, widget_group, options):
  278. return {
  279. 'id': str(uuid4()),
  280. 'type': 'RadioGroup',
  281. 'cli_type': 'optional',
  282. 'group_name': 'Choose Option',
  283. 'required': mutex_group.required,
  284. 'options': merge(item_default, getattr(mutex_group, 'gooey_options', {})),
  285. 'data': {
  286. 'commands': [action.option_strings for action in mutex_group._group_actions],
  287. 'widgets': list(categorize(mutex_group._group_actions, widget_group, options))
  288. }
  289. }
  290. def action_to_json(action, widget, options):
  291. dropdown_types = {'Listbox', 'Dropdown', 'Counter'}
  292. if action.required:
  293. # Text fields get a default check that user input is present
  294. # and not just spaces, dropdown types get a simplified
  295. # is-it-present style check
  296. validator = ('user_input and not user_input.isspace()'
  297. if widget not in dropdown_types
  298. else 'user_input')
  299. error_msg = 'This field is required'
  300. else:
  301. # not required; do nothing;
  302. validator = 'True'
  303. error_msg = ''
  304. base = merge(item_default, {
  305. 'validator': {
  306. 'test': validator,
  307. 'message': error_msg
  308. },
  309. })
  310. default = coerce_default(action.default, widget)
  311. return {
  312. 'id': action.option_strings[0] if action.option_strings else action.dest,
  313. 'type': widget,
  314. 'cli_type': choose_cli_type(action),
  315. 'required': action.required,
  316. 'data': {
  317. 'display_name': action.metavar or action.dest,
  318. 'help': action.help,
  319. 'required': action.required,
  320. 'nargs': action.nargs or '',
  321. 'commands': action.option_strings,
  322. 'choices': list(map(str, action.choices)) if action.choices else [],
  323. 'default': default,
  324. 'dest': action.dest,
  325. },
  326. 'options': merge(base, options.get(action.dest) or {})
  327. }
  328. def choose_cli_type(action):
  329. return 'positional' \
  330. if action.required and not action.option_strings \
  331. else 'optional'
  332. def coerce_default(default, widget):
  333. """coerce a default value to the best appropriate type for
  334. ingestion into wx"""
  335. dispatcher = {
  336. 'Listbox': clean_list_defaults,
  337. 'Dropdown': safe_string,
  338. 'Counter': safe_string
  339. }
  340. # Issue #321:
  341. # Defaults for choice types must be coerced to strings
  342. # to be able to match the stringified `choices` used by `wx.ComboBox`
  343. cleaned = clean_default(default)
  344. # dispatch to the appropriate cleaning function, or return the value
  345. # as is if no special handler is present
  346. return dispatcher.get(widget, identity)(cleaned)
  347. def clean_list_defaults(default_values):
  348. """
  349. Listbox's default's can be passed as a single value
  350. or a list of values (due to multiple selections). The list interface
  351. is standardized on for ease.
  352. """
  353. wrapped_values = ([default_values]
  354. if isinstance(default_values, str)
  355. else default_values)
  356. return [safe_string(value) for value in wrapped_values]
  357. def clean_default(default):
  358. '''
  359. Attemps to safely coalesce the default value down to
  360. a valid JSON type.
  361. See: Issue #147.
  362. function references supplied as arguments to the
  363. `default` parameter in Argparse cause errors in Gooey.
  364. '''
  365. return default.__name__ if callable(default) else default
  366. def safe_string(value):
  367. """
  368. Coerce a type to string as long as it isn't None
  369. """
  370. if value is None or isinstance(value, bool):
  371. return value
  372. else:
  373. return str(value)