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