cli_function.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. #!/usr/bin/env python3
  2. '''
  3. This file is GPLv3 like the rest of this repo.
  4. However, you may use it in a project with any license through imports,
  5. without affecting the license of the rest of your project, even if you include
  6. this file in the project source tree, as long as you publish any modifications
  7. made to this file.
  8. '''
  9. import argparse
  10. import bisect
  11. import collections
  12. import os
  13. import sys
  14. import lkmc.import_path
  15. class _Argument:
  16. def __init__(
  17. self,
  18. long_or_short_1,
  19. long_or_short_2=None,
  20. default=None,
  21. dest=None,
  22. help=None,
  23. nargs=None,
  24. **kwargs
  25. ):
  26. self.args = []
  27. # argparse is crappy and cannot tell us if arguments were given or not.
  28. # We need that information to decide if the config file should override argparse or not.
  29. # So we just use None as a sentinel.
  30. self.kwargs = {'default': None}
  31. shortname, longname, key, is_option = self.get_key(
  32. long_or_short_1,
  33. long_or_short_2,
  34. dest
  35. )
  36. if shortname is not None:
  37. self.args.append(shortname)
  38. if is_option:
  39. self.args.append(longname)
  40. else:
  41. self.args.append(key)
  42. self.kwargs['metavar'] = longname
  43. if default is not None and nargs is None:
  44. self.kwargs['nargs'] = '?'
  45. if dest is not None:
  46. self.kwargs['dest'] = dest
  47. if nargs is not None:
  48. self.kwargs['nargs'] = nargs
  49. if default is True or default is False:
  50. bool_action = 'store_true'
  51. self.is_bool = True
  52. else:
  53. self.is_bool = False
  54. if default is None and (
  55. nargs in ('*', '+')
  56. or ('action' in kwargs and kwargs['action'] == 'append')
  57. ):
  58. default = []
  59. if self.is_bool and not 'action' in kwargs:
  60. self.kwargs['action'] = bool_action
  61. if help is not None:
  62. if default is not None:
  63. if help[-1] == '\n':
  64. if '\n\n' in help[:-1]:
  65. help += '\n'
  66. elif help[-1] == ' ':
  67. pass
  68. else:
  69. help += ' '
  70. help += 'Default: {}'.format(default)
  71. self.kwargs['help'] = help
  72. self.optional = (
  73. default is not None or
  74. self.is_bool or
  75. is_option or
  76. nargs in ('?', '*', '+')
  77. )
  78. self.kwargs.update(kwargs)
  79. self.default = default
  80. self.longname = longname
  81. self.key = key
  82. self.is_option = is_option
  83. self.nargs = nargs
  84. def __str__(self):
  85. return str(self.args) + ' ' + str(self.kwargs)
  86. @staticmethod
  87. def get_key(
  88. long_or_short_1,
  89. long_or_short_2=None,
  90. dest=None,
  91. **kwargs
  92. ):
  93. if long_or_short_2 is None:
  94. shortname = None
  95. longname = long_or_short_1
  96. else:
  97. shortname = long_or_short_1
  98. longname = long_or_short_2
  99. if longname[0] == '-':
  100. key = longname.lstrip('-').replace('-', '_')
  101. is_option = True
  102. else:
  103. key = longname.replace('-', '_')
  104. is_option = False
  105. if dest is not None:
  106. key = dest
  107. return shortname, longname, key, is_option
  108. class CliFunction:
  109. '''
  110. A function that can be called either from Python code, or from the command line.
  111. Features:
  112. * single argument description in format very similar to argparse
  113. * handle default arguments transparently in both cases
  114. * expose a configuration file mechanism to get default parameters from a file
  115. * fix some argparse.ArgumentParser() annoyances:
  116. ** allow dashes in positional arguments:
  117. https://stackoverflow.com/questions/12834785/having-options-in-argparse-with-a-dash
  118. ** boolean defaults automatically use store_true or store_false, and add a --no-* CLI
  119. option to invert them if set from the config
  120. * from a Python call, get the corresponding CLI string list. See get_cli.
  121. * easily determine if arguments were given on the command line
  122. https://stackoverflow.com/questions/30487767/check-if-argparse-optional-argument-is-set-or-not/30491369
  123. This somewhat duplicates: https://click.palletsprojects.com but:
  124. * that decorator API is insane
  125. * CLI + Python for single functions was wontfixed: https://github.com/pallets/click/issues/40
  126. +
  127. Oh, and I commented on that issue pointing to this alternative and they deleted my comment:
  128. https://github.com/pallets/click/issues/40#event-2088718624 Lol. It could have been useful
  129. for other Googlers and as an implementation reference.
  130. '''
  131. def __call__(self, **kwargs):
  132. '''
  133. Python version of the function call. Not called by cli() indirectly,
  134. so can be overridden to distinguish between Python and CLI calls.
  135. :type arguments: Dict
  136. '''
  137. return self._do_main(kwargs)
  138. def _do_main(self, kwargs):
  139. return self.main(**self._get_args(kwargs))
  140. def __init__(self, default_config_file=None, description=None, extra_config_params=None):
  141. self._arguments = collections.OrderedDict()
  142. self._default_config_file = default_config_file
  143. self._description = description
  144. self.extra_config_params = extra_config_params
  145. if self._default_config_file is not None:
  146. self.add_argument(
  147. '--config-file',
  148. default=self._default_config_file,
  149. help='Path to the configuration file to use'
  150. )
  151. def __str__(self):
  152. return '\n'.join(str(arg[key]) for key in self._arguments)
  153. def _get_args(self, kwargs):
  154. '''
  155. Resolve default arguments from the config file and CLI param defaults.
  156. Add an extra _args_given argument which determines if an argument was given or not.
  157. Args set from the config file count as given.
  158. '''
  159. args_with_defaults = kwargs.copy()
  160. # Add missing args from config file.
  161. config_file = None
  162. args_given = {}
  163. if 'config_file' in args_with_defaults and args_with_defaults['config_file'] is not None:
  164. config_file = args_with_defaults['config_file']
  165. args_given['config_file'] = True
  166. else:
  167. config_file = self._default_config_file
  168. args_given['config_file'] = False
  169. for key in self._arguments:
  170. args_given[key] = not (
  171. not key in args_with_defaults or
  172. args_with_defaults[key] is None or
  173. self._arguments[key].nargs == '*' and args_with_defaults[key] == []
  174. )
  175. if config_file is not None:
  176. if os.path.exists(config_file):
  177. config_configs = {}
  178. config = lkmc.import_path.import_path(config_file)
  179. if self.extra_config_params is None:
  180. config.set_args(config_configs)
  181. else:
  182. config.set_args(config_configs, self.extra_config_params)
  183. for key in config_configs:
  184. if key not in self._arguments:
  185. raise Exception('Unknown key in config file: ' + key)
  186. if not args_given[key]:
  187. args_with_defaults[key] = config_configs[key]
  188. args_given[key] = True
  189. elif args_given['config_file']:
  190. raise Exception('Config file does not exist: ' + config_file)
  191. # Add missing args from hard-coded defaults.
  192. for key in self._arguments:
  193. argument = self._arguments[key]
  194. # TODO: in (None, []) is ugly, and will probably go wrong at some point,
  195. # there must be a better way to do it, but I'm lazy now to think.
  196. if (not key in args_with_defaults) or args_with_defaults[key] in (None, []):
  197. if argument.optional:
  198. args_with_defaults[key] = argument.default
  199. else:
  200. raise Exception('Value not given for mandatory argument: ' + key)
  201. args_with_defaults['_args_given'] = args_given
  202. if 'config_file' in args_with_defaults:
  203. del args_with_defaults['config_file']
  204. return args_with_defaults
  205. def add_argument(
  206. self,
  207. *args,
  208. **kwargs
  209. ):
  210. argument = _Argument(*args, **kwargs)
  211. self._arguments[argument.key] = argument
  212. def cli_noexit(self, cli_args=None):
  213. '''
  214. Call the function from the CLI. Parse command line arguments
  215. to get all arguments.
  216. :return: the return of main
  217. '''
  218. parser = argparse.ArgumentParser(
  219. description=self._description,
  220. formatter_class=argparse.RawTextHelpFormatter,
  221. )
  222. for key in self._arguments:
  223. argument = self._arguments[key]
  224. parser.add_argument(*argument.args, **argument.kwargs)
  225. # print(key)
  226. # print(argument.args)
  227. # print(argument.kwargs)
  228. if argument.is_bool:
  229. new_longname = '--no' + argument.longname[1:]
  230. kwargs = argument.kwargs.copy()
  231. kwargs['default'] = not argument.default
  232. if kwargs['action'] in ('store_true', 'store_false'):
  233. kwargs['action'] = 'store_false'
  234. if 'help' in kwargs:
  235. del kwargs['help']
  236. parser.add_argument(new_longname, dest=argument.key, **kwargs)
  237. args = parser.parse_args(args=cli_args)
  238. return self._do_main(vars(args))
  239. def cli(self, *args, **kwargs):
  240. '''
  241. Same as cli_noxit, but also exit the program with status equal to the
  242. return value of main. main must return an integer for this to be used.
  243. None is considered as 0.
  244. '''
  245. exit_status = self.cli_noexit(*args, **kwargs)
  246. if exit_status is None:
  247. exit_status = 0
  248. sys.exit(exit_status)
  249. def get_cli(self, **kwargs):
  250. '''
  251. :rtype: List[Type(str)]
  252. :return: the canonical command line arguments arguments that would
  253. generate this Python function call.
  254. (--key, value) option pairs are grouped into tuples, and all
  255. other values are grouped in their own tuple (positional_arg,)
  256. or (--bool-arg,).
  257. Arguments with default values are not added, but arguments
  258. that are set by the config are also given.
  259. The optional arguments are sorted alphabetically, followed by
  260. positional arguments.
  261. The long option name is used if both long and short versions
  262. are given.
  263. '''
  264. options = []
  265. positional_dict = {}
  266. kwargs = self._get_args(kwargs)
  267. for key in kwargs:
  268. if not key in ('_args_given',):
  269. argument = self._arguments[key]
  270. default = argument.default
  271. value = kwargs[key]
  272. if value != default:
  273. if argument.is_option:
  274. if argument.is_bool:
  275. if value:
  276. vals = [(argument.longname,)]
  277. else:
  278. vals = [('--no-' + argument.longname[2:],)]
  279. elif 'action' in argument.kwargs and argument.kwargs['action'] == 'append':
  280. vals = [(argument.longname, str(val)) for val in value]
  281. else:
  282. vals = [(argument.longname, str(value))]
  283. for val in vals:
  284. bisect.insort(options, val)
  285. else:
  286. if type(value) is list:
  287. positional_dict[key] = [tuple([v]) for v in value]
  288. else:
  289. positional_dict[key] = [(str(value),)]
  290. # Python built-in data structures suck.
  291. # https://stackoverflow.com/questions/27726245/getting-the-key-index-in-a-python-ordereddict/27726534#27726534
  292. positional = []
  293. for key in self._arguments.keys():
  294. if key in positional_dict:
  295. positional.extend(positional_dict[key])
  296. return options + positional
  297. @staticmethod
  298. def get_key(*args, **kwargs):
  299. return _Argument.get_key(*args, **kwargs)
  300. def main(self, **kwargs):
  301. '''
  302. Do the main function call work.
  303. :type arguments: Dict
  304. '''
  305. raise NotImplementedError
  306. if __name__ == '__main__':
  307. class OneCliFunction(CliFunction):
  308. def __init__(self):
  309. super().__init__(
  310. default_config_file='cli_function_test_config.py',
  311. description = '''\
  312. Description of this
  313. amazing function!
  314. ''',
  315. )
  316. self.add_argument('-a', '--asdf', default='A', help='Help for asdf'),
  317. self.add_argument('-q', '--qwer', default='Q', help='Help for qwer'),
  318. self.add_argument('-b', '--bool-true', default=True, help='Help for bool-true'),
  319. self.add_argument('--bool-false', default=False, help='Help for bool-false'),
  320. self.add_argument('--dest', dest='custom_dest', help='Help for dest'),
  321. self.add_argument('--bool-cli', default=False, help='Help for bool'),
  322. self.add_argument('--bool-nargs', default=False, nargs='?', action='store', const='')
  323. self.add_argument('--no-default', help='Help for no-bool'),
  324. self.add_argument('--append', action='append')
  325. self.add_argument('pos-mandatory', help='Help for pos-mandatory', type=int),
  326. self.add_argument('pos-optional', default=0, help='Help for pos-optional', type=int),
  327. self.add_argument('args-star', help='Help for args-star', nargs='*'),
  328. def main(self, **kwargs):
  329. del kwargs['_args_given']
  330. return kwargs
  331. one_cli_function = OneCliFunction()
  332. # Default code call.
  333. default = one_cli_function(pos_mandatory=1)
  334. assert default == {
  335. 'asdf': 'A',
  336. 'qwer': 'Q',
  337. 'bool_true': True,
  338. 'bool_false': False,
  339. 'bool_nargs': False,
  340. 'bool_cli': True,
  341. 'custom_dest': None,
  342. 'no_default': None,
  343. 'append': [],
  344. 'pos_mandatory': 1,
  345. 'pos_optional': 0,
  346. 'args_star': []
  347. }
  348. # Default CLI call with programmatic CLI arguments.
  349. out = one_cli_function.cli_noexit(['1'])
  350. assert out == default
  351. # asdf
  352. out = one_cli_function(pos_mandatory=1, asdf='B')
  353. assert out['asdf'] == 'B'
  354. out['asdf'] = default['asdf']
  355. assert out == default
  356. # asdf and qwer
  357. out = one_cli_function(pos_mandatory=1, asdf='B', qwer='R')
  358. assert out['asdf'] == 'B'
  359. assert out['qwer'] == 'R'
  360. out['asdf'] = default['asdf']
  361. out['qwer'] = default['qwer']
  362. assert out == default
  363. if '--bool-true':
  364. out = one_cli_function(pos_mandatory=1, bool_true=False)
  365. cli_out = one_cli_function.cli_noexit(['--no-bool-true', '1'])
  366. assert out == cli_out
  367. assert out['bool_true'] == False
  368. out['bool_true'] = default['bool_true']
  369. assert out == default
  370. if '--bool-false':
  371. out = one_cli_function(pos_mandatory=1, bool_false=True)
  372. cli_out = one_cli_function.cli_noexit(['--bool-false', '1'])
  373. assert out == cli_out
  374. assert out['bool_false'] == True
  375. out['bool_false'] = default['bool_false']
  376. assert out == default
  377. if '--bool-nargs':
  378. out = one_cli_function(pos_mandatory=1, bool_nargs=True)
  379. assert out['bool_nargs'] == True
  380. out['bool_nargs'] = default['bool_nargs']
  381. assert out == default
  382. out = one_cli_function(pos_mandatory=1, bool_nargs='asdf')
  383. assert out['bool_nargs'] == 'asdf'
  384. out['bool_nargs'] = default['bool_nargs']
  385. assert out == default
  386. # --dest
  387. out = one_cli_function(pos_mandatory=1, custom_dest='a')
  388. cli_out = one_cli_function.cli_noexit(['--dest', 'a', '1'])
  389. assert out == cli_out
  390. assert out['custom_dest'] == 'a'
  391. out['custom_dest'] = default['custom_dest']
  392. assert out == default
  393. # Positional
  394. out = one_cli_function(pos_mandatory=1, pos_optional=2, args_star=['3', '4'])
  395. assert out['pos_mandatory'] == 1
  396. assert out['pos_optional'] == 2
  397. assert out['args_star'] == ['3', '4']
  398. cli_out = one_cli_function.cli_noexit(['1', '2', '3', '4'])
  399. assert out == cli_out
  400. out['pos_mandatory'] = default['pos_mandatory']
  401. out['pos_optional'] = default['pos_optional']
  402. out['args_star'] = default['args_star']
  403. assert out == default
  404. # Star
  405. out = one_cli_function(append=['1', '2'], pos_mandatory=1)
  406. cli_out = one_cli_function.cli_noexit(['--append', '1', '--append', '2', '1'])
  407. assert out == cli_out
  408. assert out['append'] == ['1', '2']
  409. out['append'] = default['append']
  410. assert out == default
  411. # Force a boolean value set on the config to be False on CLI.
  412. assert one_cli_function.cli_noexit(['--no-bool-cli', '1'])['bool_cli'] is False
  413. # Pick another config file.
  414. assert one_cli_function.cli_noexit(['--config-file', 'cli_function_test_config_2.py', '1'])['bool_cli'] is False
  415. # Extra config file for '*'.
  416. assert one_cli_function.cli_noexit(['--config-file', 'cli_function_test_config_2.py', '1', '2', '3', '4'])['args_star'] == ['3', '4']
  417. assert one_cli_function.cli_noexit(['--config-file', 'cli_function_test_config_2.py', '1', '2'])['args_star'] == ['asdf', 'qwer']
  418. # get_cli
  419. assert one_cli_function.get_cli(pos_mandatory=1, asdf='B') == [('--asdf', 'B'), ('--bool-cli',), ('1',)]
  420. assert one_cli_function.get_cli(pos_mandatory=1, asdf='B', qwer='R') == [('--asdf', 'B'), ('--bool-cli',), ('--qwer', 'R'), ('1',)]
  421. assert one_cli_function.get_cli(pos_mandatory=1, bool_true=False) == [('--bool-cli',), ('--no-bool-true',), ('1',)]
  422. assert one_cli_function.get_cli(pos_mandatory=1, bool_false=True) == [('--bool-cli',), ('--bool-false',), ('1',)]
  423. assert one_cli_function.get_cli(pos_mandatory=1, pos_optional=2, args_star=['asdf', 'qwer']) == [('--bool-cli',), ('1',), ('2',), ('asdf',), ('qwer',)]
  424. assert one_cli_function.get_cli(pos_mandatory=1, append=['2', '3']) == [('--append', '2'), ('--append', '3',), ('--bool-cli',), ('1',)]
  425. class NargsWithDefault(CliFunction):
  426. def __init__(self):
  427. super().__init__()
  428. self.add_argument('args-star', default=['1', '2'], nargs='*'),
  429. def main(self, **kwargs):
  430. return kwargs
  431. nargs_with_default = NargsWithDefault()
  432. default = nargs_with_default()
  433. assert default['args_star'] == ['1', '2']
  434. default_cli = nargs_with_default.cli_noexit([])
  435. assert default_cli['args_star'] == ['1', '2']
  436. assert nargs_with_default.cli_noexit(['1', '2', '3', '4'])['args_star'] == ['1', '2', '3', '4']
  437. if len(sys.argv) > 1:
  438. # CLI call with argv command line arguments.
  439. print(one_cli_function.cli())