# Copyright (C) 2018-2019 Team tiramisu (see AUTHORS for all contributors) # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, either version 3 of the License, or (at your # option) any later version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more # details. # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . from typing import Union, List, Dict, Tuple, Optional, Any from argparse import ArgumentParser, Namespace, SUPPRESS, _HelpAction, RawDescriptionHelpFormatter from copy import copy from gettext import gettext as _ try: from tiramisu import Config from tiramisu.error import PropertiesOptionError, RequirementError except ModuleNotFoundError: Config = None from tiramisu_json_api.error import PropertiesOptionError RequirementError = PropertiesOptionError try: from tiramisu_json_api import Config as ConfigJson if Config is None: Config = ConfigJson except ModuleNotFoundError: ConfigJson = Config class TiramisuNamespace(Namespace): def __init__(self, config: Config, root: Optional[str]) -> None: super().__setattr__('_config', config) super().__setattr__('_root', root) super().__setattr__('list_force_no', {}) self._populate() super().__init__() def _populate(self) -> None: if self._root is None: config = self._config else: config = self._config.option(self._root) for tiramisu_key, tiramisu_value in config.value.dict(fullpath=True).items(): option = self._config.option(tiramisu_key) if not option.option.issymlinkoption(): if tiramisu_value == [] and \ option.option.ismulti(): # and \ # option.owner.isdefault(): tiramisu_value = None super().__setattr__(tiramisu_key, tiramisu_value) def __setattr__(self, key: str, value: Any) -> None: if key in self.list_force_no: true_key = self.list_force_no[key] else: true_key = key option = self._config.option(true_key) if option.option.isfollower(): _setattr = self._setattr_follower true_value = ','.join(value[1:]) else: _setattr = self._setattr true_value = value try: _setattr(option, true_key, key, value) except ValueError as err: if option.option.type() == 'choice': raise ValueError("invalid choice: '{}' (choose from {})".format(true_value, ', '.join([f"'{val}'" for val in option.value.list()]))) else: raise err def _setattr(self, option: 'Option', true_key: str, key: str, value: Any) -> None: if option.option.ismulti() and \ value is not None and \ not isinstance(value, list): value = [value] option.value.set(value) def _setattr_follower(self, option: 'Option', true_key: str, key: str, value: Any) -> None: if not value[0].isdecimal(): raise ValueError('index must be a number, not {}'.format(value[0])) index = int(value[0]) if option.option.type() == 'boolean': value = key not in self.list_force_no elif option.option.issubmulti(): value = value[1:] else: value = value[1] self._config.option(true_key, index).value.set(value) class TiramisuHelpFormatter(RawDescriptionHelpFormatter): def _get_default_metavar_for_optional(self, action): ret = super()._get_default_metavar_for_optional(action) if '.' in ret: ret = ret.rsplit('.', 1)[1] return ret class _Section(RawDescriptionHelpFormatter._Section): def format_help(self): # Remove empty OD if self.formatter.remove_empty_od and \ len(self.items) == 1 and \ self.items[0][0].__name__ == '_format_text': return '' # Remove OD if name == description if self.items and \ self.formatter.remove_empty_description_od and \ self.items[0][0].__name__ == '_format_text': name = self.items[0][1][0] path = self.heading if '.' in path: compare = path.rsplit('.', 1)[1] else: compare = path if name == compare: return '' return super().format_help() class _TiramisuHelpAction(_HelpAction): needs = False def __call__(self, *args, **kwargs): _TiramisuHelpAction.needs = True def display(self, parser): _HelpAction.__call__(self, parser, None, None) class _BuildKwargs: def __init__(self, name: str, option: 'Option', cmdlineparser: 'TiramisuCmdlineParser', properties: List[str], force_no: bool) -> None: self.kwargs = {} self.cmdlineparser = cmdlineparser self.properties = properties self.force_no = force_no if not self.force_no: self.kwargs['help'] = option.doc().replace('%', '%%') if 'positional' not in self.properties: is_short_name = self.cmdlineparser._is_short_name(name, 'longargument' in self.properties) if self.force_no: ga_name = self.gen_argument_name(name, is_short_name) self.cmdlineparser.namespace.list_force_no[ga_name] = option.path() else: ga_name = name self.kwargs['dest'] = self.gen_argument_name(option.path(), False) self.args = [self.cmdlineparser._gen_argument(ga_name, is_short_name)] else: self.args = [option.path()] def __setitem__(self, key: str, value: Any) -> None: self.kwargs[key] = value def add_argument(self, option: 'Option'): is_short_name = self.cmdlineparser._is_short_name(option.name(), 'longargument' in self.properties) if self.force_no: name = self.gen_argument_name(option.name(), is_short_name) else: name = option.name() self.args.insert(0, self.cmdlineparser._gen_argument(name, is_short_name)) def gen_argument_name(self, name, is_short_name): if self.force_no: if is_short_name: prefix = 'n' else: prefix = 'no-' if '.' in name: sname = name.rsplit('.', 1) name = sname[0] + '.' + prefix + sname[1] else: name = prefix + name return name def get(self) -> Tuple[Dict]: return self.args, self.kwargs class TiramisuCmdlineParser(ArgumentParser): def __init__(self, config: Union[Config, ConfigJson], *args, root: str=None, fullpath: bool=True, remove_empty_od: bool=False, remove_empty_description_od: bool=False, _forhelp: bool=False, **kwargs): self.fullpath = fullpath self.config = config self.root = root self.remove_empty_od = remove_empty_od self.remove_empty_description_od = remove_empty_description_od TiramisuHelpFormatter.remove_empty_od = self.remove_empty_od TiramisuHelpFormatter.remove_empty_description_od = self.remove_empty_description_od kwargs['formatter_class'] = TiramisuHelpFormatter if self.root is None: subconfig = self.config.option else: subconfig = self.config.option(self.root) self.namespace = TiramisuNamespace(self.config, self.root) super().__init__(*args, **kwargs) self.register('action', 'help', _TiramisuHelpAction) self._config_to_argparser(_forhelp, subconfig, self.root) def _pop_action_class(self, kwargs, default=None): ret = super()._pop_action_class(kwargs, default) if kwargs.get('action') != 'help' and kwargs.get('dest') != 'help': return ret return _TiramisuHelpAction def _match_arguments_partial(self, actions, arg_string_pattern): # used only when check first proposal for first value # we have to remove all actions with propertieserror # so only first settable option will be returned actions_pop = [] for idx, action in enumerate(actions): if self.config.option(action.dest).property.get(only_raises=True): actions_pop.append(idx) else: break for idx in actions_pop: actions.pop(0) return super()._match_arguments_partial(actions, arg_string_pattern) def _is_short_name(self, name, longargument): return len(name) == 1 and not longargument def _gen_argument(self, name, is_short_name): if is_short_name: return self.prefix_chars + name return self.prefix_chars * 2 + name def _parse_known_args(self, args=None, namespace=None): try: namespace_, args_ = super()._parse_known_args(args, namespace) except ValueError as err: self.error(err) if args != args_ and args_ and args_[0].startswith(self.prefix_chars): # option that was disabled are no more disable # so create a new parser new_parser = TiramisuCmdlineParser(self.config, self.prog, root=self.root, remove_empty_od=self.remove_empty_od, remove_empty_description_od=self.remove_empty_description_od, epilog=self.epilog, fullpath=self.fullpath) namespace_, args_ = new_parser._parse_known_args(args_, namespace) else: if self._registries['action']['help'].needs: # display help only when all variables assignemnt are done self._registries['action']['help'].needs = False helper = self._registries['action']['help'](None) helper.display(self) return namespace_, args_ def add_argument(self, *args, **kwargs): if args == ('-h', '--help'): super().add_argument(*args, **kwargs) else: raise NotImplementedError(_('do not use add_argument')) def add_arguments(self, *args, **kwargs): raise NotImplementedError(_('do not use add_argument')) def add_subparsers(self, *args, **kwargs): raise NotImplementedError(_('do not use add_subparsers')) def _option_is_not_default(self, properties, type, name, value): if 'positional' not in properties: is_short_name = self._is_short_name(name, 'longargument' in properties) self.prog += ' {}'.format(self._gen_argument(name, is_short_name)) if type != 'boolean': self.prog += f' {value}' def _config_list(self, config: Config, prefix: Optional[str], _forhelp: bool, group): for obj in config.list(type='all'): # do not display frozen option if 'frozen' in obj.option.properties(): continue if obj.option.isoptiondescription(): if _forhelp: newgroup = self.add_argument_group(obj.option.path(), obj.option.doc()) else: newgroup = group if prefix: prefix_ = prefix + '.' + obj.option.name() else: prefix_ = obj.option.path() self._config_to_argparser(_forhelp, obj, prefix_, newgroup) elif obj.option.type() == 'boolean' and not obj.option.issymlinkoption(): yield obj, False yield obj, True else: yield obj, None def _config_to_argparser(self, _forhelp: bool, config, prefix: Optional[str], group=None) -> None: if group is None: group = super() actions = {} leadership_len = None for obj, force_no in self._config_list(config, prefix, _forhelp, group): option = obj.option name = option.name() if name.startswith(self.prefix_chars): raise ValueError(_('name cannot startswith "{}"').format(self.prefix_chars)) if option.issymlinkoption(): symlink_name = option.name(follow_symlink=True) if symlink_name in actions: for action in actions[symlink_name]: action.add_argument(option) continue if option.isleader(): value = obj.value.get() leadership_len = len(value) elif option.isfollower(): value = [] for index in range(leadership_len): value.append(self.config.option(obj.option.path(), index).value.get()) else: value = obj.value.get() if self.fullpath and prefix: name = prefix + '.' + name if option.isfollower(): properties = obj.option.properties() else: properties = obj.property.get() kwargs = _BuildKwargs(name, option, self, properties, force_no) if not option.isfollower() and _forhelp and not obj.owner.isdefault() and value is not None: if not force_no: self._option_is_not_default(properties, option.type(), name, value) else: if 'positional' in properties: if option.type() == 'boolean': raise ValueError(_('boolean option must not be positional')) if not 'mandatory' in properties: raise ValueError('"positional" argument must be "mandatory" too') if _forhelp: kwargs['default'] = obj.value.default() else: kwargs['default'] = value kwargs['nargs'] = '?' else: kwargs['default'] = SUPPRESS if _forhelp and 'mandatory' in properties: kwargs['required'] = True if option.type() == 'boolean' and not option.isfollower(): if 'storefalse' in properties: if force_no: action = 'store_true' else: action = 'store_false' elif force_no: action = 'store_false' else: action = 'store_true' kwargs['action'] = action else: if option.type() == 'boolean': kwargs['metavar'] = 'INDEX' if option.type() != 'boolean': if _forhelp: value = obj.value.default() if value not in [None, []]: #kwargs['default'] = kwargs['const'] = option.default() #kwargs['action'] = 'store_const' kwargs['nargs'] = '?' if not option.isfollower() and option.ismulti(): if _forhelp and 'mandatory' in properties: kwargs['nargs'] = '+' else: kwargs['nargs'] = '*' if option.isfollower() and not option.type() == 'boolean': metavar = option.name().upper() if option.issubmulti(): kwargs['nargs'] = '+' else: kwargs['nargs'] = 2 if _forhelp and 'mandatory' not in properties: metavar = f'[{metavar}]' if option.type() == 'choice': choice_list = obj.value.list() if choice_list[0] == '': del choice_list[0] choices = '{{{}}}'.format(','.join(choice_list)) if 'mandatory' not in properties: choices = f'[{choices}]' kwargs['metavar'] = ('INDEX', choices) else: kwargs['metavar'] = ('INDEX', metavar) if option.type() == 'string': pass elif option.type() == 'integer' or option.type() == 'boolean': # when boolean we are here only if follower kwargs['type'] = int if _forhelp and option.type() == 'boolean': kwargs['metavar'] = 'INDEX' kwargs['nargs'] = 1 elif option.type() == 'choice' and not option.isfollower(): kwargs['choices'] = obj.value.list() else: pass #raise NotImplementedError('not supported yet') actions.setdefault(option.name(), []).append(kwargs) for values in actions.values(): for value in values: args, kwargs = value.get() group.add_argument(*args, **kwargs) def _valid_mandatory(self): pass def parse_args(self, *args, valid_mandatory=True, **kwargs): kwargs['namespace'] = self.namespace try: namespaces = super().parse_args(*args, **kwargs) except PropertiesOptionError as err: name = err._option_bag.option.impl_getname() properties = self.config.option(name).property.get() if self.fullpath and 'positional' not in properties: if len(name) == 1 and 'longargument' not in properties: name = self.prefix_chars + name else: name = self.prefix_chars * 2 + name if err.proptype == ['mandatory']: self.error('the following arguments are required: {}'.format(name)) else: self.error('unrecognized arguments: {}'.format(name)) if valid_mandatory: errors = [] for key in self.config.value.mandatory(): properties = self.config.option(key).option.properties() if not self.config.option(key).option.isfollower(): if 'positional' not in properties: if self.fullpath or '.' not in key: name = key else: name = key.rsplit('.', 1)[1] is_short_name = self._is_short_name(name, 'longargument' in self.config.option(key).property.get()) args = self._gen_argument(name, is_short_name) else: args = key else: if 'positional' not in properties: args = self._gen_argument(key, False) else: args = key if not self.fullpath and '.' in args: args = args.rsplit('.', 1)[1] if 'positional' not in properties: args = self._gen_argument(args, False) errors.append(args) if errors: self.error('the following arguments are required: {}'.format(', '.join(errors))) return namespaces def format_usage(self, *args, **kwargs): help_formatter = TiramisuCmdlineParser(self.config, self.prog, root=self.root, fullpath=self.fullpath, remove_empty_od=self.remove_empty_od, remove_empty_description_od=self.remove_empty_description_od, epilog=self.epilog, _forhelp=True) return super(TiramisuCmdlineParser, help_formatter).format_usage(*args, **kwargs) def format_help(self): help_formatter = TiramisuCmdlineParser(self.config, self.prog, root=self.root, fullpath=self.fullpath, remove_empty_od=self.remove_empty_od, remove_empty_description_od=self.remove_empty_description_od, epilog=self.epilog, _forhelp=True) return super(TiramisuCmdlineParser, help_formatter).format_help() def get_config(self): return self.config