1# Licensed under the Apache License, Version 2.0 (the "License"); you may 2# not use this file except in compliance with the License. You may obtain 3# a copy of the License at 4# 5# http://www.apache.org/licenses/LICENSE-2.0 6# 7# Unless required by applicable law or agreed to in writing, software 8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10# License for the specific language governing permissions and limitations 11# under the License. 12 13"""Application base class. 14""" 15 16import codecs 17import locale 18import logging 19import logging.handlers 20import os 21import six 22import sys 23 24from cliff import _argparse 25from . import complete 26from . import help 27from . import utils 28 29 30logging.getLogger('cliff').addHandler(logging.NullHandler()) 31 32 33class App(object): 34 """Application base class. 35 36 :param description: one-liner explaining the program purpose 37 :paramtype description: str 38 :param version: application version number 39 :paramtype version: str 40 :param command_manager: plugin loader 41 :paramtype command_manager: cliff.commandmanager.CommandManager 42 :param stdin: Standard input stream 43 :paramtype stdin: readable I/O stream 44 :param stdout: Standard output stream 45 :paramtype stdout: writable I/O stream 46 :param stderr: Standard error output stream 47 :paramtype stderr: writable I/O stream 48 :param interactive_app_factory: callable to create an 49 interactive application 50 :paramtype interactive_app_factory: cliff.interactive.InteractiveApp 51 :param deferred_help: True - Allow subcommands to accept --help with 52 allowing to defer help print after initialize_app 53 :paramtype deferred_help: bool 54 """ 55 56 NAME = os.path.splitext(os.path.basename(sys.argv[0]))[0] 57 if NAME == '__main__': 58 NAME = os.path.split(os.path.dirname(sys.argv[0]))[-1] 59 LOG = logging.getLogger(NAME) 60 61 CONSOLE_MESSAGE_FORMAT = '%(message)s' 62 LOG_FILE_MESSAGE_FORMAT = \ 63 '[%(asctime)s] %(levelname)-8s %(name)s %(message)s' 64 DEFAULT_VERBOSE_LEVEL = 1 65 DEFAULT_OUTPUT_ENCODING = 'utf-8' 66 67 def __init__(self, description, version, command_manager, 68 stdin=None, stdout=None, stderr=None, 69 interactive_app_factory=None, 70 deferred_help=False): 71 """Initialize the application. 72 """ 73 self.command_manager = command_manager 74 self.command_manager.add_command('help', help.HelpCommand) 75 self.command_manager.add_command('complete', complete.CompleteCommand) 76 self._set_streams(stdin, stdout, stderr) 77 self.interactive_app_factory = interactive_app_factory 78 self.deferred_help = deferred_help 79 self.parser = self.build_option_parser(description, version) 80 self.interactive_mode = False 81 self.interpreter = None 82 83 def _set_streams(self, stdin, stdout, stderr): 84 try: 85 locale.setlocale(locale.LC_ALL, '') 86 except locale.Error: 87 pass 88 89 # Unicode must be encoded/decoded for text I/O streams, the 90 # correct encoding for the stream must be selected and it must 91 # be capable of handling the set of characters in the stream 92 # or Python will raise a codec error. The correct codec is 93 # selected based on the locale. Python2 uses the locales 94 # encoding but only when the I/O stream is attached to a 95 # terminal (TTY) otherwise it uses the default ASCII 96 # encoding. The effect is internationalized text written to 97 # the terminal works as expected but if command line output is 98 # redirected (file or pipe) the ASCII codec is used and the 99 # program aborts with a codec error. 100 # 101 # The default I/O streams stdin, stdout and stderr can be 102 # wrapped in a codec based on the locale thus assuring the 103 # users desired encoding is always used no matter the I/O 104 # destination. Python3 does this by default. 105 # 106 # If the caller supplies an I/O stream we use it unmodified on 107 # the assumption the caller has taken all responsibility for 108 # the stream. But with Python2 if the caller allows us to 109 # default the I/O streams to sys.stdin, sys.stdout and 110 # sys.stderr we apply the locales encoding just as Python3 111 # would do. We also check to make sure the main Python program 112 # has not already already wrapped sys.stdin, sys.stdout and 113 # sys.stderr as this is a common recommendation. 114 115 if six.PY2: 116 encoding = locale.getpreferredencoding() 117 if encoding: 118 if not (stdin or isinstance(sys.stdin, codecs.StreamReader)): 119 stdin = codecs.getreader(encoding)(sys.stdin) 120 121 if not (stdout or isinstance(sys.stdout, codecs.StreamWriter)): 122 stdout = utils.getwriter(encoding)(sys.stdout) 123 124 if not (stderr or isinstance(sys.stderr, codecs.StreamWriter)): 125 stderr = utils.getwriter(encoding)(sys.stderr) 126 127 self.stdin = stdin or sys.stdin 128 self.stdout = stdout or sys.stdout 129 self.stderr = stderr or sys.stderr 130 131 def build_option_parser(self, description, version, 132 argparse_kwargs=None): 133 """Return an argparse option parser for this application. 134 135 Subclasses may override this method to extend 136 the parser with more global options. 137 138 :param description: full description of the application 139 :paramtype description: str 140 :param version: version number for the application 141 :paramtype version: str 142 :param argparse_kwargs: extra keyword argument passed to the 143 ArgumentParser constructor 144 :paramtype extra_kwargs: dict 145 """ 146 argparse_kwargs = argparse_kwargs or {} 147 parser = _argparse.ArgumentParser( 148 description=description, 149 add_help=False, 150 **argparse_kwargs 151 ) 152 parser.add_argument( 153 '--version', 154 action='version', 155 version='{0} {1}'.format(App.NAME, version), 156 ) 157 verbose_group = parser.add_mutually_exclusive_group() 158 verbose_group.add_argument( 159 '-v', '--verbose', 160 action='count', 161 dest='verbose_level', 162 default=self.DEFAULT_VERBOSE_LEVEL, 163 help='Increase verbosity of output. Can be repeated.', 164 ) 165 verbose_group.add_argument( 166 '-q', '--quiet', 167 action='store_const', 168 dest='verbose_level', 169 const=0, 170 help='Suppress output except warnings and errors.', 171 ) 172 parser.add_argument( 173 '--log-file', 174 action='store', 175 default=None, 176 help='Specify a file to log output. Disabled by default.', 177 ) 178 if self.deferred_help: 179 parser.add_argument( 180 '-h', '--help', 181 dest='deferred_help', 182 action='store_true', 183 help="Show help message and exit.", 184 ) 185 else: 186 parser.add_argument( 187 '-h', '--help', 188 action=help.HelpAction, 189 nargs=0, 190 default=self, # tricky 191 help="Show help message and exit.", 192 ) 193 parser.add_argument( 194 '--debug', 195 default=False, 196 action='store_true', 197 help='Show tracebacks on errors.', 198 ) 199 return parser 200 201 def configure_logging(self): 202 """Create logging handlers for any log output. 203 """ 204 root_logger = logging.getLogger('') 205 root_logger.setLevel(logging.DEBUG) 206 207 # Set up logging to a file 208 if self.options.log_file: 209 file_handler = logging.FileHandler( 210 filename=self.options.log_file, 211 ) 212 formatter = logging.Formatter(self.LOG_FILE_MESSAGE_FORMAT) 213 file_handler.setFormatter(formatter) 214 root_logger.addHandler(file_handler) 215 216 # Always send higher-level messages to the console via stderr 217 console = logging.StreamHandler(self.stderr) 218 console_level = {0: logging.WARNING, 219 1: logging.INFO, 220 2: logging.DEBUG, 221 }.get(self.options.verbose_level, logging.DEBUG) 222 console.setLevel(console_level) 223 formatter = logging.Formatter(self.CONSOLE_MESSAGE_FORMAT) 224 console.setFormatter(formatter) 225 root_logger.addHandler(console) 226 return 227 228 def print_help_if_requested(self): 229 """Print help and exits if deferred help is enabled and requested. 230 231 '--help' shows the help message and exits: 232 * without calling initialize_app if not self.deferred_help (default), 233 * after initialize_app call if self.deferred_help, 234 * during initialize_app call if self.deferred_help and subclass calls 235 explicitly this method in initialize_app. 236 """ 237 if self.deferred_help and self.options.deferred_help: 238 action = help.HelpAction(None, None, default=self) 239 action(self.parser, self.options, None, None) 240 241 def run(self, argv): 242 """Equivalent to the main program for the application. 243 244 :param argv: input arguments and options 245 :paramtype argv: list of str 246 """ 247 248 try: 249 self.options, remainder = self.parser.parse_known_args(argv) 250 self.configure_logging() 251 self.interactive_mode = not remainder 252 if self.deferred_help and self.options.deferred_help and remainder: 253 # When help is requested and `remainder` has any values disable 254 # `deferred_help` and instead allow the help subcommand to 255 # handle the request during run_subcommand(). This turns 256 # "app foo bar --help" into "app help foo bar". However, when 257 # `remainder` is empty use print_help_if_requested() to allow 258 # for an early exit. 259 # Disabling `deferred_help` here also ensures that 260 # print_help_if_requested will not fire if called by a subclass 261 # during its initialize_app(). 262 self.options.deferred_help = False 263 remainder.insert(0, "help") 264 self.initialize_app(remainder) 265 self.print_help_if_requested() 266 except Exception as err: 267 if hasattr(self, 'options'): 268 debug = self.options.debug 269 else: 270 debug = True 271 if debug: 272 self.LOG.exception(err) 273 raise 274 else: 275 self.LOG.error(err) 276 return 1 277 result = 1 278 if self.interactive_mode: 279 result = self.interact() 280 else: 281 result = self.run_subcommand(remainder) 282 return result 283 284 # FIXME(dhellmann): Consider moving these command handling methods 285 # to a separate class. 286 def initialize_app(self, argv): 287 """Hook for subclasses to take global initialization action 288 after the arguments are parsed but before a command is run. 289 Invoked only once, even in interactive mode. 290 291 :param argv: List of arguments, including the subcommand to run. 292 Empty for interactive mode. 293 """ 294 return 295 296 def prepare_to_run_command(self, cmd): 297 """Perform any preliminary work needed to run a command. 298 299 :param cmd: command processor being invoked 300 :paramtype cmd: cliff.command.Command 301 """ 302 return 303 304 def clean_up(self, cmd, result, err): 305 """Hook run after a command is done to shutdown the app. 306 307 :param cmd: command processor being invoked 308 :paramtype cmd: cliff.command.Command 309 :param result: return value of cmd 310 :paramtype result: int 311 :param err: exception or None 312 :paramtype err: Exception 313 """ 314 return 315 316 def interact(self): 317 # Defer importing .interactive as cmd2 is a slow import 318 from .interactive import InteractiveApp 319 320 if self.interactive_app_factory is None: 321 self.interactive_app_factory = InteractiveApp 322 self.interpreter = self.interactive_app_factory(self, 323 self.command_manager, 324 self.stdin, 325 self.stdout, 326 ) 327 return self.interpreter.cmdloop() 328 329 def get_fuzzy_matches(self, cmd): 330 """return fuzzy matches of unknown command 331 """ 332 333 sep = '_' 334 if self.command_manager.convert_underscores: 335 sep = ' ' 336 all_cmds = [k[0] for k in self.command_manager] 337 dist = [] 338 for candidate in sorted(all_cmds): 339 prefix = candidate.split(sep)[0] 340 # Give prefix match a very good score 341 if candidate.startswith(cmd): 342 dist.append((0, candidate)) 343 continue 344 # Levenshtein distance 345 dist.append((utils.damerau_levenshtein(cmd, prefix, utils.COST)+1, 346 candidate)) 347 348 matches = [] 349 match_distance = 0 350 for distance, candidate in sorted(dist): 351 if distance > match_distance: 352 if match_distance: 353 # we copied all items with minimum distance, we are done 354 break 355 # we copied all items with distance=0, 356 # now we match all candidates at the minimum distance 357 match_distance = distance 358 matches.append(candidate) 359 360 return matches 361 362 def run_subcommand(self, argv): 363 try: 364 subcommand = self.command_manager.find_command(argv) 365 except ValueError as err: 366 # If there was no exact match, try to find a fuzzy match 367 the_cmd = argv[0] 368 fuzzy_matches = self.get_fuzzy_matches(the_cmd) 369 if fuzzy_matches: 370 article = 'a' 371 if self.NAME[0] in 'aeiou': 372 article = 'an' 373 self.stdout.write('%s: \'%s\' is not %s %s command. ' 374 'See \'%s --help\'.\n' 375 % (self.NAME, ' '.join(argv), article, 376 self.NAME, self.NAME)) 377 self.stdout.write('Did you mean one of these?\n') 378 for match in fuzzy_matches: 379 self.stdout.write(' %s\n' % match) 380 else: 381 if self.options.debug: 382 raise 383 else: 384 self.LOG.error(err) 385 return 2 386 cmd_factory, cmd_name, sub_argv = subcommand 387 kwargs = {} 388 if 'cmd_name' in utils.getargspec(cmd_factory.__init__).args: 389 kwargs['cmd_name'] = cmd_name 390 cmd = cmd_factory(self, self.options, **kwargs) 391 result = 1 392 try: 393 self.prepare_to_run_command(cmd) 394 full_name = (cmd_name 395 if self.interactive_mode 396 else ' '.join([self.NAME, cmd_name]) 397 ) 398 cmd_parser = cmd.get_parser(full_name) 399 parsed_args = cmd_parser.parse_args(sub_argv) 400 result = cmd.run(parsed_args) 401 except Exception as err: 402 if self.options.debug: 403 self.LOG.exception(err) 404 else: 405 self.LOG.error(err) 406 try: 407 self.clean_up(cmd, result, err) 408 except Exception as err2: 409 if self.options.debug: 410 self.LOG.exception(err2) 411 else: 412 self.LOG.error('Could not clean up: %s', err2) 413 if self.options.debug: 414 # 'raise' here gets caught and does not exit like we want 415 return result 416 else: 417 try: 418 self.clean_up(cmd, result, None) 419 except Exception as err3: 420 if self.options.debug: 421 self.LOG.exception(err3) 422 else: 423 self.LOG.error('Could not clean up: %s', err3) 424 return result 425