controller.py 18 KB

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