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.

663 lines
22 KiB

  1. """
  2. Converts argparse parser actions into json "Build Specs"
  3. """
  4. import argparse
  5. import json
  6. import os
  7. import sys
  8. from argparse import (
  9. _CountAction,
  10. _HelpAction,
  11. _StoreConstAction,
  12. _StoreFalseAction,
  13. _StoreTrueAction,
  14. _StoreAction,
  15. _SubParsersAction,
  16. _VersionAction)
  17. from collections import OrderedDict
  18. from functools import partial
  19. from uuid import uuid4
  20. from gooey.python_bindings.gooey_parser import GooeyParser
  21. from gooey.util.functional import merge, getin, identity, assoc
  22. from gooey.gui.components.options.validators import validators
  23. from gooey.gui.components.options.validators import collect_errors
  24. VALID_WIDGETS = (
  25. 'FileChooser',
  26. 'MultiFileChooser',
  27. 'FileSaver',
  28. 'DirChooser',
  29. 'DateChooser',
  30. 'TimeChooser',
  31. 'TextField',
  32. 'Dropdown',
  33. 'Counter',
  34. 'RadioGroup',
  35. 'CheckBox',
  36. 'BlockCheckbox',
  37. 'MultiDirChooser',
  38. 'Textarea',
  39. 'PasswordField',
  40. 'Listbox',
  41. 'FilterableDropdown',
  42. 'IntegerField',
  43. 'DecimalField',
  44. 'Slider'
  45. )
  46. # TODO: validate Listbox. When required, nargs must be +
  47. class UnknownWidgetType(Exception):
  48. pass
  49. class UnsupportedConfiguration(Exception):
  50. pass
  51. # TODO: merge the default foreground and bg colors from the
  52. # baseline build_spec
  53. item_default = {
  54. 'error_color': '#ea7878',
  55. 'label_color': '#000000',
  56. 'help_color': '#363636',
  57. 'full_width': False,
  58. 'validator': {
  59. 'type': 'local',
  60. 'test': 'lambda x: True',
  61. 'message': ''
  62. },
  63. 'external_validator': {
  64. 'cmd': '',
  65. }
  66. }
  67. def convert(parser, **kwargs):
  68. """
  69. Converts a parser into a JSON representation
  70. TODO:
  71. This is in desperate need of refactor. It wasn't build with supporting
  72. all (or any) of this configuration in mind. The use of global defaults
  73. are actively getting in the way of easily adding more configuration options.
  74. Pain points:
  75. - global data sprinkled throughout the calls
  76. - local data threaded through calls
  77. - totally unclear what the data structures even hold
  78. - everything is just mushed together and gross. unwinding argparse also
  79. builds validators, handles coercion, and so on...
  80. - converts to an entirely bespoke json mini-language that mirrors
  81. the internal structure of argparse.
  82. Refactor plan:
  83. - Investigate restructuring the core data representation. As is, it is ad-hoc
  84. and largely tied to argparse's goofy internal structure. May be worth moving to
  85. something "standard." Though, not sure what the options are.
  86. - standardize how these things read from the environment. No global in some local in others.
  87. - Investigate splitting the whole thing into phases (ala Ring). Current thinking is that
  88. a lot of this stuff could be modelled more like pluggable upgrades to the base structure.
  89. - I want to add a helpful validation stage to catch user errors like invalid gooey_options
  90. """
  91. group_defaults = {
  92. 'legacy': {
  93. 'required_cols': kwargs['num_required_cols'],
  94. 'optional_cols': kwargs['num_optional_cols']
  95. },
  96. 'columns': 2,
  97. 'padding': 10,
  98. 'show_border': False
  99. }
  100. assert_subparser_constraints(parser)
  101. x = {
  102. 'layout': 'standard',
  103. 'widgets': OrderedDict(
  104. (choose_name(name, sub_parser), {
  105. 'command': name,
  106. 'name': choose_name(name, sub_parser),
  107. 'help': get_subparser_help(sub_parser),
  108. 'description': '',
  109. 'contents': process(sub_parser,
  110. getattr(sub_parser, 'widgets', {}),
  111. getattr(sub_parser, 'options', {}),
  112. group_defaults)
  113. }) for name, sub_parser in iter_parsers(parser))
  114. }
  115. if kwargs.get('use_legacy_titles'):
  116. return apply_default_rewrites(x)
  117. return x
  118. def process(parser, widget_dict, options, group_defaults):
  119. mutex_groups = parser._mutually_exclusive_groups
  120. raw_action_groups = [extract_groups(group, group_defaults) for group in parser._action_groups
  121. if group._group_actions]
  122. corrected_action_groups = reapply_mutex_groups(mutex_groups, raw_action_groups)
  123. return categorize2(strip_empty(corrected_action_groups), widget_dict, options)
  124. def strip_empty(groups):
  125. return [group for group in groups if group['items']]
  126. def assert_subparser_constraints(parser):
  127. if has_subparsers(parser._actions):
  128. if has_required(parser._actions):
  129. raise UnsupportedConfiguration(
  130. "Gooey doesn't currently support top level required arguments "
  131. "when subparsers are present.")
  132. def iter_parsers(parser):
  133. ''' Iterate over name, parser pairs '''
  134. try:
  135. return get_subparser(parser._actions).choices.items()
  136. except:
  137. return iter([('::gooey/default', parser)])
  138. def get_subparser_help(parser):
  139. if isinstance(parser, GooeyParser):
  140. return getattr(parser.parser, 'usage', '')
  141. else:
  142. return getattr(parser, 'usage', '')
  143. def extract_groups(action_group, group_defaults):
  144. '''
  145. Recursively extract argument groups and associated actions
  146. from ParserGroup objects
  147. '''
  148. return {
  149. 'name': action_group.title,
  150. 'description': action_group.description,
  151. 'items': [action for action in action_group._group_actions
  152. if not is_help_message(action)],
  153. 'groups': [extract_groups(group, group_defaults)
  154. for group in action_group._action_groups],
  155. 'options': handle_option_merge(
  156. group_defaults,
  157. getattr(action_group, 'gooey_options', {}),
  158. action_group.title)
  159. }
  160. def handle_option_merge(group_defaults, incoming_options, title):
  161. """
  162. Merges a set of group defaults with incoming options.
  163. A bunch of ceremony here is to ensure backwards compatibility with the old
  164. num_required_cols and num_optional_cols decorator args. They are used as
  165. the seed values for the new group defaults which keeps the old behavior
  166. _mostly_ in tact.
  167. Known failure points:
  168. * Using custom groups / names. No 'positional arguments' group
  169. means no required_cols arg being honored
  170. * Non-positional args marked as required. It would take group
  171. shuffling along the lines of that required to make
  172. mutually exclusive groups show in the correct place. In short, not
  173. worth the complexity for a legacy feature that's been succeeded by
  174. a much more powerful alternative.
  175. """
  176. if title == 'positional arguments':
  177. # the argparse default 'required' bucket
  178. req_cols = getin(group_defaults, ['legacy', 'required_cols'], 2)
  179. new_defaults = assoc(group_defaults, 'columns', req_cols)
  180. return merge(new_defaults, incoming_options)
  181. else:
  182. opt_cols = getin(group_defaults, ['legacy', 'optional_cols'], 2)
  183. new_defaults = assoc(group_defaults, 'columns', opt_cols)
  184. return merge(new_defaults, incoming_options)
  185. def apply_default_rewrites(spec):
  186. top_level_subgroups = list(spec['widgets'].keys())
  187. for subgroup in top_level_subgroups:
  188. path = ['widgets', subgroup, 'contents']
  189. contents = getin(spec, path)
  190. for group in contents:
  191. if group['name'] == 'positional arguments':
  192. group['name'] = 'required_args_msg'
  193. if group['name'] == 'optional arguments':
  194. group['name'] = 'optional_args_msg'
  195. return spec
  196. def contains_actions(a, b):
  197. ''' check if any actions(a) are present in actions(b) '''
  198. return set(a).intersection(set(b))
  199. def reapply_mutex_groups(mutex_groups, action_groups):
  200. # argparse stores mutually exclusive groups independently
  201. # of all other groups. So, they must be manually re-combined
  202. # with the groups/subgroups to which they were originally declared
  203. # in order to have them appear in the correct location in the UI.
  204. #
  205. # Order is attempted to be preserved by inserting the MutexGroup
  206. # into the _actions list at the first occurrence of any item
  207. # where the two groups intersect
  208. def swap_actions(actions):
  209. for mutexgroup in mutex_groups:
  210. mutex_actions = mutexgroup._group_actions
  211. if contains_actions(mutex_actions, actions):
  212. # make a best guess as to where we should store the group
  213. targetindex = actions.index(mutexgroup._group_actions[0])
  214. # insert the _ArgumentGroup container
  215. actions[targetindex] = mutexgroup
  216. # remove the duplicated individual actions
  217. actions = [action for action in actions
  218. if action not in mutex_actions]
  219. return actions
  220. return [group.update({'items': swap_actions(group['items'])}) or group
  221. for group in action_groups]
  222. def categorize2(groups, widget_dict, options):
  223. defaults = {'label_color': '#000000', 'description_color': '#363636'}
  224. return [{
  225. 'name': group['name'],
  226. 'items': list(categorize(group['items'], widget_dict, options)),
  227. 'groups': categorize2(group['groups'], widget_dict, options),
  228. 'description': group['description'],
  229. 'options': merge(defaults ,group['options'])
  230. } for group in groups]
  231. def categorize(actions, widget_dict, options):
  232. _get_widget = partial(get_widget, widget_dict)
  233. for action in actions:
  234. if is_version(action):
  235. yield action_to_json(action, _get_widget(action, 'CheckBox'), options)
  236. elif is_mutex(action):
  237. yield build_radio_group(action, widget_dict, options)
  238. elif is_standard(action):
  239. yield action_to_json(action, _get_widget(action, 'TextField'), options)
  240. elif is_file(action):
  241. yield action_to_json(action, _get_widget(action, 'FileSaver'), options)
  242. elif is_choice(action):
  243. yield action_to_json(action, _get_widget(action, 'Dropdown'), options)
  244. elif is_flag(action):
  245. yield action_to_json(action, _get_widget(action, 'CheckBox'), options)
  246. elif is_counter(action):
  247. _json = action_to_json(action, _get_widget(action, 'Counter'), options)
  248. # pre-fill the 'counter' dropdown
  249. _json['data']['choices'] = list(map(str, range(0, 11)))
  250. yield _json
  251. else:
  252. raise UnknownWidgetType(action)
  253. def get_widget(widgets, action, default):
  254. supplied_widget = widgets.get(action.dest, None)
  255. return supplied_widget or default
  256. def is_required(action):
  257. '''
  258. _actions possessing the `required` flag and not implicitly optional
  259. through `nargs` being '*' or '?'
  260. '''
  261. return not isinstance(action, _SubParsersAction) and (
  262. action.required == True and action.nargs not in ['*', '?'])
  263. def is_mutex(action):
  264. return isinstance(action, argparse._MutuallyExclusiveGroup)
  265. def has_required(actions):
  266. return list(filter(None, list(filter(is_required, actions))))
  267. def is_subparser(action):
  268. return isinstance(action, _SubParsersAction)
  269. def has_subparsers(actions):
  270. return list(filter(is_subparser, actions))
  271. def get_subparser(actions):
  272. return list(filter(is_subparser, actions))[0]
  273. def is_optional(action):
  274. '''
  275. _actions either not possessing the `required` flag or implicitly optional
  276. through `nargs` being '*' or '?'
  277. '''
  278. return (not action.required) or action.nargs in ['*', '?']
  279. def is_choice(action):
  280. ''' action with choices supplied '''
  281. return action.choices
  282. def is_file(action):
  283. ''' action with FileType '''
  284. return isinstance(action.type, argparse.FileType)
  285. def is_version(action):
  286. return isinstance(action, _VersionAction)
  287. def is_standard(action):
  288. """ actions which are general "store" instructions.
  289. e.g. anything which has an argument style like:
  290. $ script.py -f myfilename.txt
  291. """
  292. boolean_actions = (
  293. _StoreConstAction, _StoreFalseAction,
  294. _StoreTrueAction
  295. )
  296. return (not action.choices
  297. and not isinstance(action.type, argparse.FileType)
  298. and not isinstance(action, _CountAction)
  299. and not isinstance(action, _HelpAction)
  300. and type(action) not in boolean_actions)
  301. def is_flag(action):
  302. """ _actions which are either storeconst, store_bool, etc.. """
  303. action_types = [_StoreTrueAction, _StoreFalseAction, _StoreConstAction]
  304. return any(list(map(lambda Action: isinstance(action, Action), action_types)))
  305. def is_counter(action):
  306. """ _actions which are of type _CountAction """
  307. return isinstance(action, _CountAction)
  308. def is_default_progname(name, subparser):
  309. return subparser.prog == '{} {}'.format(os.path.split(sys.argv[0])[-1], name)
  310. def is_help_message(action):
  311. return isinstance(action, _HelpAction)
  312. def choose_name(name, subparser):
  313. return name if is_default_progname(name, subparser) else subparser.prog
  314. def build_radio_group(mutex_group, widget_group, options):
  315. return {
  316. 'id': str(uuid4()),
  317. 'type': 'RadioGroup',
  318. 'cli_type': 'optional',
  319. 'group_name': 'Choose Option',
  320. 'required': mutex_group.required,
  321. 'options': merge(item_default, getattr(mutex_group, 'gooey_options', {})),
  322. 'data': {
  323. 'commands': [action.option_strings for action in mutex_group._group_actions],
  324. 'widgets': list(categorize(mutex_group._group_actions, widget_group, options))
  325. }
  326. }
  327. def action_to_json(action, widget, options):
  328. dropdown_types = {'Listbox', 'Dropdown', 'Counter'}
  329. if action.required:
  330. # Text fields get a default check that user input is present
  331. # and not just spaces, dropdown types get a simplified
  332. # is-it-present style check
  333. validator = ('user_input and not user_input.isspace()'
  334. if widget not in dropdown_types
  335. else 'user_input')
  336. error_msg = 'This field is required'
  337. else:
  338. # not required; do nothing;
  339. validator = 'True'
  340. error_msg = ''
  341. base = merge(item_default, {
  342. 'validator': {
  343. 'type': 'ExpressionValidator',
  344. 'test': validator,
  345. 'message': error_msg
  346. },
  347. })
  348. if (options.get(action.dest) or {}).get('initial_value') != None:
  349. value = options[action.dest]['initial_value']
  350. options[action.dest]['initial_value'] = handle_initial_values(action, widget, value)
  351. default = handle_initial_values(action, widget, action.default)
  352. if default == argparse.SUPPRESS:
  353. default = None
  354. final_options = merge(base, options.get(action.dest) or {})
  355. validate_gooey_options(action, widget, final_options)
  356. return {
  357. 'id': action.option_strings[0] if action.option_strings else action.dest,
  358. 'type': widget,
  359. 'cli_type': choose_cli_type(action),
  360. 'required': action.required,
  361. 'data': {
  362. 'display_name': action.metavar or action.dest,
  363. 'help': action.help,
  364. 'required': action.required,
  365. 'nargs': action.nargs or '',
  366. 'commands': action.option_strings,
  367. 'choices': list(map(str, action.choices)) if action.choices else [],
  368. 'default': default,
  369. 'dest': action.dest,
  370. },
  371. 'options': final_options
  372. }
  373. def validate_gooey_options(action, widget, options):
  374. """Very basic field validation / sanity checking for
  375. the time being.
  376. Future plans are to assert against the options and actions together
  377. to facilitate checking that certain options like `initial_selection` in
  378. RadioGroups map to a value which actually exists (rather than exploding
  379. at runtime with an unhelpful error)
  380. Additional problems with the current approach is that no feedback is given
  381. as to WHERE the issue took place (in terms of stacktrace). Which means we should
  382. probably explode in GooeyParser proper rather than trying to collect all the errors here.
  383. It's not super ideal in that the user will need to run multiple times to
  384. see all the issues, but, ultimately probably less annoying that trying to
  385. debug which `gooey_option` key had an issue in a large program.
  386. That said "better is the enemy of done." This is good enough for now. It'll be
  387. a TODO: better validation
  388. """
  389. errors = collect_errors(validators, options)
  390. if errors:
  391. from pprint import pformat
  392. raise ValueError(str(action.dest) + str(pformat(errors)))
  393. def choose_cli_type(action):
  394. return 'positional' \
  395. if action.required and not action.option_strings \
  396. else 'optional'
  397. def coerce_default(default, widget):
  398. """coerce a default value to the best appropriate type for
  399. ingestion into wx"""
  400. dispatcher = {
  401. 'Listbox': clean_list_defaults,
  402. 'Dropdown': safe_string,
  403. 'Counter': safe_string
  404. }
  405. # Issue #321:
  406. # Defaults for choice types must be coerced to strings
  407. # to be able to match the stringified `choices` used by `wx.ComboBox`
  408. cleaned = clean_default(default)
  409. # dispatch to the appropriate cleaning function, or return the value
  410. # as is if no special handler is present
  411. return dispatcher.get(widget, identity)(cleaned)
  412. def handle_initial_values(action, widget, value):
  413. handlers = [
  414. [textinput_with_nargs_and_list_default, coerse_nargs_list],
  415. [is_widget('Listbox'), clean_list_defaults],
  416. [is_widget('Dropdown'), coerce_str],
  417. [is_widget('Counter'), safe_string]
  418. ]
  419. for matches, apply_coercion in handlers:
  420. if matches(action, widget):
  421. return apply_coercion(value)
  422. return clean_default(value)
  423. def coerse_nargs_list(default):
  424. """
  425. nargs=* and defaults which are collection types
  426. must be transformed into a CLI equivalent form. So, for
  427. instance, ['one two', 'three'] => "one two" "three"
  428. This only applies when the target widget is a text input. List
  429. based widgets such as ListBox should keep their defaults in list form
  430. Without this transformation, `add_arg('--foo', default=['a b'], nargs='*')` would show up in
  431. the UI as the literal string `['a b']` brackets and all.
  432. """
  433. return ' '.join('"{}"'.format(x) for x in default)
  434. def is_widget(name):
  435. def equals(action, widget):
  436. return widget == name
  437. return equals
  438. def textinput_with_nargs_and_list_default(action, widget):
  439. """
  440. Vanilla TextInputs which have nargs options which produce lists (i.e.
  441. nargs +, *, N, or REMAINDER) need to have their default values transformed
  442. into CLI style space-separated entries when they're supplied as a list of values
  443. on the Python side.
  444. """
  445. return (
  446. widget in {'TextField', 'Textarea', 'PasswordField'}
  447. and (isinstance(action.default, list) or isinstance(action.default, tuple))
  448. and is_list_based_nargs(action))
  449. def is_list_based_nargs(action):
  450. """ """
  451. return isinstance(action.nargs, int) or action.nargs in {'*', '+', '...'}
  452. def clean_list_defaults(default_values):
  453. """
  454. Listbox's default's can be passed as a single value
  455. or a collection of values (due to multiple selections). The list interface
  456. is standardized on for ease.
  457. """
  458. wrapped_values = ([default_values]
  459. if isinstance(default_values, str)
  460. else default_values)
  461. return [safe_string(value) for value in wrapped_values or []]
  462. def clean_default(default):
  463. """
  464. Attempts to safely coalesce the default value down to
  465. a valid JSON type.
  466. """
  467. try:
  468. json.dumps(default)
  469. return default
  470. except TypeError as e:
  471. # see: Issue #377
  472. # if is ins't json serializable (i.e. primitive data) there's nothing
  473. # useful for Gooey to do with it (since Gooey deals in primitive data
  474. # types). So the correct behavior is dropping them. This affects ONLY
  475. # gooey, not the client code.
  476. return None
  477. def safe_string(value):
  478. """
  479. Coerce a type to string as long as it isn't None or Boolean
  480. TODO: why do I have this special boolean case..?
  481. """
  482. if value is None or isinstance(value, bool):
  483. return value
  484. else:
  485. return str(value)
  486. def coerce_str(value):
  487. """
  488. Coerce the incoming type to string as long as it isn't None
  489. """
  490. return str(value) if value is not None else value
  491. def this_is_a_comment(action, widget):
  492. """
  493. TODO:
  494. - better handling of nargs.
  495. - allowing a class of "Narg'able" widget variants that allow dynamically adding options.
  496. Below are some rough notes on the current widgets and their nargs behavior (or lack of)
  497. """
  498. asdf = [
  499. # choosers are all currently treated as
  500. # singular inputs regardless of nargs status.
  501. 'FileChooser',
  502. 'MultiFileChooser',
  503. 'FileSaver',
  504. 'DirChooser',
  505. 'DateChooser',
  506. 'TimeChooser',
  507. # radiogroup requires no special logic. Delegates to internal widgets
  508. 'RadioGroup',
  509. # nargs invalid
  510. 'CheckBox',
  511. # nargs invalid
  512. 'BlockCheckbox',
  513. # currently combines everything into a single, system _sep separated string
  514. # potential nargs behavior
  515. # input: - each item gets a readonly textbox?
  516. # - each item is its own editable widget?
  517. # - problem with this idea is selected a ton of files would break the UI.
  518. # maybe a better option would be to expose what's been added as a small
  519. # list view? That way its a fixed size even if they choose 100s of files.
  520. #
  521. 'MultiDirChooser',
  522. # special case. convert default to list of strings
  523. 'Listbox',
  524. # special cases. coerce default to string
  525. 'Dropdown',
  526. 'Counter',
  527. # nargs behavior:
  528. # - convert to space separated list of strings
  529. 'TextField',
  530. 'Textarea',
  531. 'PasswordField',
  532. ]