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

"""
Converts argparse parser actions into json "Build Specs"
"""
import functools
import pprint
import argparse
import os
import sys
from argparse import (
_CountAction,
_HelpAction,
_StoreConstAction,
_StoreFalseAction,
_StoreTrueAction,
_SubParsersAction)
from collections import OrderedDict
from functools import partial
from uuid import uuid4
from gooey.util.functional import merge, getin
VALID_WIDGETS = (
'FileChooser',
'MultiFileChooser',
'FileSaver',
'DirChooser',
'DateChooser',
'TextField',
'Dropdown',
'Counter',
'RadioGroup',
'CheckBox',
'MultiDirChooser',
'Textarea',
'PasswordField',
'Listbox'
)
class UnknownWidgetType(Exception):
pass
class UnsupportedConfiguration(Exception):
pass
group_defaults = {
'columns': 2,
'padding': 10,
'show_border': False
}
item_default = {
'error_color': '#ea7878',
'validator': {
'type': 'local',
'test': 'lambda x: True',
'message': ''
},
'external_validator': {
'cmd': '',
}
}
def convert(parser, **kwargs):
assert_subparser_constraints(parser)
x = {
'layout': 'standard',
'widgets': OrderedDict(
(choose_name(name, sub_parser), {
'command': name,
'contents': process(sub_parser,
getattr(sub_parser, 'widgets', {}),
getattr(sub_parser, 'options', {}))
}) for name, sub_parser in iter_parsers(parser))
}
if kwargs.get('use_legacy_titles'):
return apply_default_rewrites(x)
return x
def process(parser, widget_dict, options):
mutex_groups = parser._mutually_exclusive_groups
raw_action_groups = [extract_groups(group) for group in parser._action_groups
if group._group_actions]
corrected_action_groups = reapply_mutex_groups(mutex_groups, raw_action_groups)
return categorize2(strip_empty(corrected_action_groups), widget_dict, options)
def strip_empty(groups):
return [group for group in groups if group['items']]
def assert_subparser_constraints(parser):
if has_subparsers(parser._actions):
if has_required(parser._actions):
raise UnsupportedConfiguration(
"Gooey doesn't currently support top level required arguments "
"when subparsers are present.")
def iter_parsers(parser):
''' Iterate over name, parser pairs '''
try:
return get_subparser(parser._actions).choices.items()
except:
return iter([('::gooey/default', parser)])
def extract_groups(action_group):
'''
Recursively extract argument groups and associated actions
from ParserGroup objects
'''
return {
'name': action_group.title,
'description': action_group.description,
'items': [action for action in action_group._group_actions
if not is_help_message(action)],
'groups': [extract_groups(group)
for group in action_group._action_groups],
'options': merge(group_defaults,
getattr(action_group, 'gooey_options', {}))
}
def apply_default_rewrites(spec):
top_level_subgroups = list(spec['widgets'].keys())
for subgroup in top_level_subgroups:
path = ['widgets', subgroup, 'contents']
contents = getin(spec, path)
for group in contents:
if group['name'] == 'positional arguments':
group['name'] = 'Required Arguments'
if group['name'] == 'optional arguments':
group['name'] = 'Optional Arguments'
return spec
def contains_actions(a, b):
''' check if any actions(a) are present in actions(b) '''
return set(a).intersection(set(b))
def reapply_mutex_groups(mutex_groups, action_groups):
# argparse stores mutually exclusive groups independently
# of all other groups. So, they must be manually re-combined
# with the groups/subgroups to which they were originally declared
# in order to have them appear in the correct location in the UI.
#
# Order is attempted to be preserved by inserting the MutexGroup
# into the _actions list at the first occurrence of any item
# where the two groups intersect
def swap_actions(actions):
for mutexgroup in mutex_groups:
mutex_actions = mutexgroup._group_actions
if contains_actions(mutex_actions, actions):
# make a best guess as to where we should store the group
targetindex = actions.index(mutexgroup._group_actions[0])
# insert the _ArgumentGroup container
actions[targetindex] = mutexgroup
# remove the duplicated individual actions
return [action for action in actions
if action not in mutex_actions]
return actions
return [group.update({'items': swap_actions(group['items'])}) or group
for group in action_groups]
def categorize2(groups, widget_dict, options):
return [{
'name': group['name'],
'items': list(categorize(group['items'], widget_dict, options)),
'groups': categorize2(group['groups'], widget_dict, options),
'description': group['description'],
'options': group['options']
} for group in groups]
def categorize(actions, widget_dict, options):
_get_widget = partial(get_widget, widget_dict)
for action in actions:
if is_mutex(action):
yield build_radio_group(action, widget_dict, options)
elif is_standard(action):
yield action_to_json(action, _get_widget(action, 'TextField'), options)
elif is_choice(action):
yield action_to_json(action, _get_widget(action, 'Dropdown'), options)
elif is_flag(action):
yield action_to_json(action, _get_widget(action, 'CheckBox'), options)
elif is_counter(action):
_json = action_to_json(action, _get_widget(action, 'Counter'), options)
# pre-fill the 'counter' dropdown
_json['data']['choices'] = list(map(str, range(1, 11)))
yield _json
else:
raise UnknownWidgetType(action)
def get_widget(widgets, action, default):
supplied_widget = widgets.get(action.dest, None)
type_arg_widget = 'FileChooser' if action.type == argparse.FileType else None
return supplied_widget or type_arg_widget or default
def is_required(action):
'''
_actions possessing the `required` flag and not implicitly optional
through `nargs` being '*' or '?'
'''
return not isinstance(action, _SubParsersAction) and (
action.required == True and action.nargs not in ['*', '?'])
def is_mutex(action):
return isinstance(action, argparse._MutuallyExclusiveGroup)
def has_required(actions):
return list(filter(None, list(filter(is_required, actions))))
def is_subparser(action):
return isinstance(action, _SubParsersAction)
def has_subparsers(actions):
return list(filter(is_subparser, actions))
def get_subparser(actions):
return list(filter(is_subparser, actions))[0]
def is_optional(action):
'''
_actions either not possessing the `required` flag or implicitly optional
through `nargs` being '*' or '?'
'''
return (not action.required) or action.nargs in ['*', '?']
def is_choice(action):
''' action with choices supplied '''
return action.choices
def is_standard(action):
""" actions which are general "store" instructions.
e.g. anything which has an argument style like:
$ script.py -f myfilename.txt
"""
boolean_actions = (
_StoreConstAction, _StoreFalseAction,
_StoreTrueAction
)
return (not action.choices
and not isinstance(action, _CountAction)
and not isinstance(action, _HelpAction)
and type(action) not in boolean_actions)
def is_flag(action):
""" _actions which are either storeconst, store_bool, etc.. """
action_types = [_StoreTrueAction, _StoreFalseAction, _StoreConstAction]
return any(list(map(lambda Action: isinstance(action, Action), action_types)))
def is_counter(action):
""" _actions which are of type _CountAction """
return isinstance(action, _CountAction)
def is_default_progname(name, subparser):
return subparser.prog == '{} {}'.format(os.path.split(sys.argv[0])[-1], name)
def is_help_message(action):
return isinstance(action, _HelpAction)
def choose_name(name, subparser):
return name if is_default_progname(name, subparser) else subparser.prog
def build_radio_group(mutex_group, widget_group, options):
return {
'id': str(uuid4()),
'type': 'RadioGroup',
'cli_type': 'optional',
'group_name': 'Choose Option',
'required': mutex_group.required,
'options': getattr(mutex_group, 'gooey_options', {}),
'data': {
'commands': [action.option_strings for action in mutex_group._group_actions],
'widgets': list(categorize(mutex_group._group_actions, widget_group, options))
}
}
def action_to_json(action, widget, options):
if action.required:
# check that it's present and not just spaces
validator = 'user_input and not user_input.isspace()'
error_msg = 'This field is required'
else:
# not required; do nothing;
validator = 'True'
error_msg = ''
base = merge(item_default, {
'validator': {
'test': validator,
'message': error_msg
},
})
return {
'id': action.option_strings[0] if action.option_strings else action.dest,
'type': widget,
'cli_type': choose_cli_type(action),
'required': action.required,
'data': {
'display_name': action.metavar or action.dest,
'help': action.help,
'required': action.required,
'nargs': action.nargs or '',
'commands': action.option_strings,
'choices': action.choices or [],
'default': clean_default(action.default),
'dest': action.dest,
},
'options': merge(base, options.get(action.dest) or {})
}
def choose_cli_type(action):
return 'positional' \
if action.required and not action.option_strings \
else 'optional'
def clean_default(default):
'''
Attemps to safely coalesce the default value down to
a valid JSON type.
See: Issue #147.
function references supplied as arguments to the
`default` parameter in Argparse cause errors in Gooey.
'''
return default.__name__ if callable(default) else default