controller.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. """Cement core controller module."""
  2. import re
  3. import textwrap
  4. import argparse
  5. from ..core import exc, interface, handler
  6. from ..utils.misc import minimal_logger
  7. LOG = minimal_logger(__name__)
  8. def controller_validator(klass, obj):
  9. """
  10. Validates a handler implementation against the IController interface.
  11. """
  12. members = [
  13. '_setup',
  14. '_dispatch',
  15. ]
  16. meta = [
  17. 'label',
  18. 'aliases',
  19. 'interface',
  20. 'description',
  21. 'config_section',
  22. 'config_defaults',
  23. 'arguments',
  24. 'usage',
  25. 'epilog',
  26. 'stacked_on',
  27. 'stacked_type',
  28. 'hide',
  29. ]
  30. interface.validate(IController, obj, members, meta=meta)
  31. # also check _meta.arguments values
  32. errmsg = "Controller arguments must be a list of tuples. I.e. " + \
  33. "[ (['-f', '--foo'], dict(action='store')), ]"
  34. if obj._meta.arguments is not None:
  35. if type(obj._meta.arguments) is not list:
  36. raise exc.InterfaceError(errmsg)
  37. for item in obj._meta.arguments:
  38. if type(item) is not tuple:
  39. raise exc.InterfaceError(errmsg)
  40. if type(item[0]) is not list:
  41. raise exc.InterfaceError(errmsg)
  42. if type(item[1]) is not dict:
  43. raise exc.InterfaceError(errmsg)
  44. class IController(interface.Interface):
  45. """
  46. This class defines the Controller Handler Interface. Classes that
  47. implement this handler must provide the methods and attributes defined
  48. below.
  49. Implementations do *not* subclass from interfaces.
  50. Usage:
  51. .. code-block:: python
  52. from cement.core import controller
  53. class MyBaseController(controller.CementBaseController):
  54. class Meta:
  55. interface = controller.IController
  56. ...
  57. """
  58. # pylint: disable=W0232, C0111, R0903
  59. class IMeta:
  60. """Interface meta-data."""
  61. label = 'controller'
  62. """The string identifier of the interface."""
  63. validator = controller_validator
  64. """The interface validator function."""
  65. # Must be provided by the implementation
  66. Meta = interface.Attribute('Handler meta-data')
  67. def _setup(app_obj):
  68. """
  69. The _setup function is after application initialization and after it
  70. is determined that this controller was requested via command line
  71. arguments. Meaning, a controllers _setup() function is only called
  72. right before it's _dispatch() function is called to execute a command.
  73. Must 'setup' the handler object making it ready for the framework
  74. or the application to make further calls to it.
  75. :param app_obj: The application object.
  76. :returns: None
  77. """
  78. def _dispatch(self):
  79. """
  80. Reads the application object's data to dispatch a command from this
  81. controller. For example, reading self.app.pargs to determine what
  82. command was passed, and then executing that command function.
  83. Note that Cement does *not* parse arguments when calling _dispatch()
  84. on a controller, as it expects the controller to handle parsing
  85. arguments (I.e. self.app.args.parse()).
  86. :returns: None
  87. """
  88. class expose(object):
  89. """
  90. Used to expose controller functions to be listed as commands, and to
  91. decorate the function with Meta data for the argument parser.
  92. :param help: Help text to display for that command.
  93. :type help: str
  94. :param hide: Whether the command should be visible.
  95. :type hide: boolean
  96. :param aliases: Aliases to this command.
  97. :param aliases_only: Whether to only display the aliases (not the label).
  98. This is useful for situations where you have obscure function names
  99. which you do not want displayed. Effecively, if there are aliases and
  100. `aliases_only` is True, then aliases[0] will appear as the actual
  101. command/function label.
  102. :type aliases: list
  103. Usage:
  104. .. code-block:: python
  105. from cement.core.controller import CementBaseController, expose
  106. class MyAppBaseController(CementBaseController):
  107. class Meta:
  108. label = 'base'
  109. @expose(hide=True, aliases=['run'])
  110. def default(self):
  111. print("In MyAppBaseController.default()")
  112. @expose()
  113. def my_command(self):
  114. print("In MyAppBaseController.my_command()")
  115. """
  116. # pylint: disable=W0622
  117. def __init__(self, help='', hide=False, aliases=[], aliases_only=False):
  118. self.hide = hide
  119. self.help = help
  120. self.aliases = aliases
  121. self.aliases_only = aliases_only
  122. def __call__(self, func):
  123. metadict = {}
  124. metadict['label'] = re.sub('_', '-', func.__name__)
  125. metadict['func_name'] = func.__name__
  126. metadict['exposed'] = True
  127. metadict['hide'] = self.hide
  128. metadict['help'] = self.help
  129. metadict['aliases'] = self.aliases
  130. metadict['aliases_only'] = self.aliases_only
  131. metadict['controller'] = None # added by the controller
  132. func.__cement_meta__ = metadict
  133. return func
  134. # pylint: disable=R0921
  135. class CementBaseController(handler.CementBaseHandler):
  136. """
  137. This is an implementation of the
  138. `IControllerHandler <#cement.core.controller.IController>`_ interface, but
  139. as a base class that application controllers `should` subclass from.
  140. Registering it directly as a handler is useless.
  141. NOTE: This handler **requires** that the applications 'arg_handler' be
  142. argparse. If using an alternative argument handler you will need to
  143. write your own controller base class.
  144. Usage:
  145. .. code-block:: python
  146. from cement.core import controller
  147. class MyAppBaseController(controller.CementBaseController):
  148. class Meta:
  149. label = 'base'
  150. description = 'MyApp is awesome'
  151. config_defaults = dict()
  152. arguments = []
  153. epilog = "This is the text at the bottom of --help."
  154. # ...
  155. class MyStackedController(controller.CementBaseController):
  156. class Meta:
  157. label = 'second_controller'
  158. aliases = ['sec', 'secondary']
  159. stacked_on = 'base'
  160. stacked_type = 'embedded'
  161. # ...
  162. """
  163. class Meta:
  164. """
  165. Controller meta-data (can be passed as keyword arguments to the parent
  166. class).
  167. """
  168. interface = IController
  169. """The interface this class implements."""
  170. label = 'base'
  171. """The string identifier for the controller."""
  172. aliases = []
  173. """
  174. A list of aliases for the controller. Will be treated like
  175. command/function aliases for non-stacked controllers. For example:
  176. 'myapp <controller_label> --help' is the same as
  177. 'myapp <controller_alias> --help'.
  178. """
  179. aliases_only = False
  180. """
  181. When set to True, the controller label will not be displayed at
  182. command line, only the aliases will. Effectively, aliases[0] will
  183. appear as the label. This feature is useful for the situation Where
  184. you might want two controllers to have the same label when stacked
  185. on top of separate controllers. For example, 'myapp users list' and
  186. 'myapp servers list' where 'list' is a stacked controller, not a
  187. function.
  188. """
  189. description = None
  190. """The description shown at the top of '--help'. Default: None"""
  191. config_section = None
  192. """
  193. A config [section] to merge config_defaults into. Cement will default
  194. to controller.<label> if None is set.
  195. """
  196. config_defaults = {}
  197. """
  198. Configuration defaults (type: dict) that are merged into the
  199. applications config object for the config_section mentioned above.
  200. """
  201. arguments = []
  202. """
  203. Arguments to pass to the argument_handler. The format is a list
  204. of tuples whos items are a ( list, dict ). Meaning:
  205. ``[ ( ['-f', '--foo'], dict(dest='foo', help='foo option') ), ]``
  206. This is equivelant to manually adding each argument to the argument
  207. parser as in the following example:
  208. ``parser.add_argument(['-f', '--foo'], help='foo option', dest='foo')``
  209. """
  210. stacked_on = 'base'
  211. """
  212. A label of another controller to 'stack' commands/arguments on top of.
  213. """
  214. stacked_type = 'embedded'
  215. """
  216. Whether to `embed` commands and arguments within the parent controller
  217. or to simply `nest` the controller under the parent controller (making
  218. it a sub-sub-command). Must be one of `['embedded', 'nested']` only
  219. if `stacked_on` is not `None`.
  220. """
  221. hide = False
  222. """Whether or not to hide the controller entirely."""
  223. epilog = None
  224. """
  225. The text that is displayed at the bottom when '--help' is passed.
  226. """
  227. usage = None
  228. """
  229. The text that is displayed at the top when '--help' is passed.
  230. Although the default is `None`, Cement will set this to a generic
  231. usage based on the `prog`, `controller` name, etc if nothing else is
  232. passed.
  233. """
  234. argument_formatter = argparse.RawDescriptionHelpFormatter
  235. """
  236. The argument formatter class to use to display --help output.
  237. """
  238. def __init__(self, *args, **kw):
  239. super(CementBaseController, self).__init__(*args, **kw)
  240. self.app = None
  241. self._commands = {} # used to store collected commands
  242. self._visible_commands = [] # used to sort visible command labels
  243. self._arguments = [] # used to store collected arguments
  244. self._dispatch_map = {} # used to map commands/aliases to controller
  245. self._dispatch_command = None # set during _parse_args()
  246. def _setup(self, app_obj):
  247. """
  248. See `IController._setup() <#cement.core.cache.IController._setup>`_.
  249. """
  250. super(CementBaseController, self)._setup(app_obj)
  251. if getattr(self._meta, 'description', None) is None:
  252. self._meta.description = "%s Controller" % \
  253. self._meta.label.capitalize()
  254. self.app = app_obj
  255. def _collect(self):
  256. self.app.log.debug("collecting arguments/commands for %s" % self)
  257. arguments = []
  258. commands = []
  259. # process my arguments and commands first
  260. arguments = list(self._meta.arguments)
  261. for member in dir(self.__class__):
  262. if member.startswith('_'):
  263. continue
  264. try:
  265. func = getattr(self.__class__, member).__cement_meta__
  266. except AttributeError:
  267. continue
  268. else:
  269. func['controller'] = self
  270. commands.append(func)
  271. # process stacked controllers second for commands and args
  272. for contr in handler.list('controller'):
  273. # don't include self here
  274. if contr == self.__class__:
  275. continue
  276. contr = contr()
  277. contr._setup(self.app)
  278. if contr._meta.stacked_on == self._meta.label:
  279. if contr._meta.stacked_type == 'embedded':
  280. contr_arguments, contr_commands = contr._collect()
  281. for arg in contr_arguments:
  282. arguments.append(arg)
  283. for func in contr_commands:
  284. commands.append(func)
  285. elif contr._meta.stacked_type == 'nested':
  286. metadict = {}
  287. metadict['label'] = re.sub('_', '-', contr._meta.label)
  288. metadict['func_name'] = '_dispatch'
  289. metadict['exposed'] = True
  290. metadict['hide'] = contr._meta.hide
  291. metadict['help'] = contr._meta.description
  292. metadict['aliases'] = contr._meta.aliases
  293. metadict['aliases_only'] = contr._meta.aliases_only
  294. metadict['controller'] = contr
  295. commands.append(metadict)
  296. else:
  297. raise exc.FrameworkError(
  298. "Controller '%s' " % contr._meta.label +
  299. "has an unknown stacked type of '%s'." %
  300. contr._meta.stacked_type
  301. )
  302. return (arguments, commands)
  303. def _process_arguments(self):
  304. for _arg, _kw in self._arguments:
  305. try:
  306. self.app.args.add_argument(*_arg, **_kw)
  307. except argparse.ArgumentError as e:
  308. raise exc.FrameworkError(e.__str__())
  309. def _process_commands(self):
  310. self._dispatch_map = {}
  311. self._visible_commands = []
  312. for cmd in self._commands:
  313. # process command labels
  314. if cmd['label'] in self._dispatch_map.keys():
  315. raise exc.FrameworkError(
  316. "Duplicate command named '%s' " % cmd['label'] +
  317. "found in controller '%s'" % cmd['controller']
  318. )
  319. self._dispatch_map[cmd['label']] = cmd
  320. if not cmd['hide']:
  321. self._visible_commands.append(cmd['label'])
  322. # process command aliases
  323. for alias in cmd['aliases']:
  324. if alias in self._dispatch_map.keys():
  325. raise exc.FrameworkError(
  326. "The alias '%s' of the " % alias +
  327. "'%s' controller collides " % cmd['controller'] +
  328. "with a command or alias of the same name."
  329. )
  330. self._dispatch_map[alias] = cmd
  331. self._visible_commands.sort()
  332. def _get_dispatch_command(self):
  333. if (len(self.app.argv) <= 0) or (self.app.argv[0].startswith('-')):
  334. # if no command is passed, then use default
  335. if 'default' in self._dispatch_map.keys():
  336. self._dispatch_command = self._dispatch_map['default']
  337. elif self.app.argv[0] in self._dispatch_map.keys():
  338. self._dispatch_command = self._dispatch_map[self.app.argv[0]]
  339. self.app.argv.pop(0)
  340. else:
  341. # check for default again (will get here if command line has
  342. # positional arguments that don't start with a -)
  343. if 'default' in self._dispatch_map.keys():
  344. self._dispatch_command = self._dispatch_map['default']
  345. def _parse_args(self):
  346. self.app.args.description = self._help_text
  347. self.app.args.usage = self._usage_text
  348. self.app.args.formatter_class = self._meta.argument_formatter
  349. self.app._parse_args()
  350. def _dispatch(self):
  351. """
  352. Takes the remaining arguments from self.app.argv and parses for a
  353. command to dispatch, and if so... dispatches it.
  354. """
  355. if hasattr(self._meta, 'epilog'):
  356. if self._meta.epilog is not None:
  357. self.app.args.epilog = self._meta.epilog
  358. self._arguments, self._commands = self._collect()
  359. self._process_commands()
  360. self._get_dispatch_command()
  361. if self._dispatch_command:
  362. if self._dispatch_command['func_name'] == '_dispatch':
  363. func = getattr(self._dispatch_command['controller'],
  364. '_dispatch')
  365. func()
  366. else:
  367. self._process_arguments()
  368. self._parse_args()
  369. func = getattr(self._dispatch_command['controller'],
  370. self._dispatch_command['func_name'])
  371. func()
  372. else:
  373. self._process_arguments()
  374. self._parse_args()
  375. @property
  376. def _usage_text(self):
  377. """Returns the usage text displayed when '--help' is passed."""
  378. if self._meta.usage is not None:
  379. return self._meta.usage
  380. txt = "%s (sub-commands ...) [options ...] {arguments ...}" % \
  381. self.app.args.prog
  382. return txt
  383. @property
  384. def _help_text(self):
  385. """Returns the help text displayed when '--help' is passed."""
  386. cmd_txt = ''
  387. for label in self._visible_commands:
  388. cmd = self._dispatch_map[label]
  389. if len(cmd['aliases']) > 0 and cmd['aliases_only']:
  390. if len(cmd['aliases']) > 1:
  391. first = cmd['aliases'].pop(0)
  392. cmd_txt = cmd_txt + " %s (aliases: %s)\n" % \
  393. (first, ', '.join(cmd['aliases']))
  394. else:
  395. cmd_txt = cmd_txt + " %s\n" % cmd['aliases'][0]
  396. elif len(cmd['aliases']) > 0:
  397. cmd_txt = cmd_txt + " %s (aliases: %s)\n" % \
  398. (label, ', '.join(cmd['aliases']))
  399. else:
  400. cmd_txt = cmd_txt + " %s\n" % label
  401. if cmd['help']:
  402. cmd_txt = cmd_txt + " %s\n\n" % cmd['help']
  403. else:
  404. cmd_txt = cmd_txt + "\n"
  405. if len(cmd_txt) > 0:
  406. txt = '''%s
  407. commands:
  408. %s
  409. ''' % (self._meta.description, cmd_txt)
  410. else:
  411. txt = self._meta.description
  412. return textwrap.dedent(txt)