1#
2#  Copyright (C) 2017 Codethink Limited
3#
4#  This program is free software; you can redistribute it and/or
5#  modify it under the terms of the GNU Lesser General Public
6#  License as published by the Free Software Foundation; either
7#  version 2 of the License, or (at your option) any later version.
8#
9#  This library is distributed in the hope that it will be useful,
10#  but WITHOUT ANY WARRANTY; without even the implied warranty of
11#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
12#  Lesser General Public License for more details.
13#
14#  You should have received a copy of the GNU Lesser General Public
15#  License along with this library. If not, see <http://www.gnu.org/licenses/>.
16#
17#  Authors:
18#        Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
19import datetime
20import os
21from collections import defaultdict, OrderedDict
22from contextlib import ExitStack
23from mmap import mmap
24import re
25import textwrap
26import click
27from ruamel import yaml
28
29from . import Profile
30from .. import Element, Consistency
31from .. import _yaml
32from .. import __version__ as bst_version
33from .._exceptions import ImplError
34from .._message import MessageType
35from ..plugin import Plugin
36
37
38# These messages are printed a bit differently
39ERROR_MESSAGES = [MessageType.FAIL, MessageType.ERROR, MessageType.BUG]
40
41
42# Widget()
43#
44# Args:
45#    content_profile (Profile): The profile to use for rendering content
46#    format_profile (Profile): The profile to use for rendering formatting
47#
48# An abstract class for printing output columns in our text UI.
49#
50class Widget():
51
52    def __init__(self, context, content_profile, format_profile):
53
54        # The context
55        self.context = context
56
57        # The content profile
58        self.content_profile = content_profile
59
60        # The formatting profile
61        self.format_profile = format_profile
62
63    # render()
64    #
65    # Renders a string to be printed in the UI
66    #
67    # Args:
68    #    message (Message): A message to print
69    #
70    # Returns:
71    #    (str): The string this widget prints for the given message
72    #
73    def render(self, message):
74        raise ImplError("{} does not implement render()".format(type(self).__name__))
75
76
77# Used to add spacing between columns
78class Space(Widget):
79
80    def render(self, message):
81        return ' '
82
83
84# Used to add fixed text between columns
85class FixedText(Widget):
86
87    def __init__(self, context, text, content_profile, format_profile):
88        super(FixedText, self).__init__(context, content_profile, format_profile)
89        self.text = text
90
91    def render(self, message):
92        return self.format_profile.fmt(self.text)
93
94
95# Used to add the wallclock time this message was created at
96class WallclockTime(Widget):
97    def render(self, message):
98        fields = [self.content_profile.fmt("{:02d}".format(x)) for x in
99                  [message.creation_time.hour,
100                   message.creation_time.minute,
101                   message.creation_time.second]]
102        return self.format_profile.fmt(":").join(fields)
103
104
105# A widget for rendering the debugging column
106class Debug(Widget):
107
108    def render(self, message):
109        unique_id = 0 if message.unique_id is None else message.unique_id
110
111        text = self.format_profile.fmt('pid:')
112        text += self.content_profile.fmt("{: <5}".format(message.pid))
113        text += self.format_profile.fmt(" id:")
114        text += self.content_profile.fmt("{:0>3}".format(unique_id))
115
116        return text
117
118
119# A widget for rendering the time codes
120class TimeCode(Widget):
121    def __init__(self, context, content_profile, format_profile, microseconds=False):
122        self._microseconds = microseconds
123        super(TimeCode, self).__init__(context, content_profile, format_profile)
124
125    def render(self, message):
126        return self.render_time(message.elapsed)
127
128    def render_time(self, elapsed):
129        if elapsed is None:
130            fields = [
131                self.content_profile.fmt('--')
132                for i in range(3)
133            ]
134        else:
135            hours, remainder = divmod(int(elapsed.total_seconds()), 60 * 60)
136            minutes, seconds = divmod(remainder, 60)
137            fields = [
138                self.content_profile.fmt("{0:02d}".format(field))
139                for field in [hours, minutes, seconds]
140            ]
141
142        text = self.format_profile.fmt(':').join(fields)
143
144        if self._microseconds:
145            if elapsed is not None:
146                text += self.content_profile.fmt(".{0:06d}".format(elapsed.microseconds))
147            else:
148                text += self.content_profile.fmt(".------")
149        return text
150
151
152# A widget for rendering the MessageType
153class TypeName(Widget):
154
155    _action_colors = {
156        MessageType.DEBUG: "cyan",
157        MessageType.STATUS: "cyan",
158        MessageType.INFO: "magenta",
159        MessageType.WARN: "yellow",
160        MessageType.START: "blue",
161        MessageType.SUCCESS: "green",
162        MessageType.FAIL: "red",
163        MessageType.SKIPPED: "yellow",
164        MessageType.ERROR: "red",
165        MessageType.BUG: "red",
166    }
167
168    def render(self, message):
169        return self.content_profile.fmt("{: <7}"
170                                        .format(message.message_type.upper()),
171                                        bold=True, dim=True,
172                                        fg=self._action_colors[message.message_type])
173
174
175# A widget for displaying the Element name
176class ElementName(Widget):
177
178    def __init__(self, context, content_profile, format_profile):
179        super(ElementName, self).__init__(context, content_profile, format_profile)
180
181        # Pre initialization format string, before we know the length of
182        # element names in the pipeline
183        self._fmt_string = '{: <30}'
184
185    def render(self, message):
186        element_id = message.task_id or message.unique_id
187        if element_id is None:
188            return ""
189
190        plugin = Plugin._lookup(element_id)
191        name = plugin._get_full_name()
192
193        # Sneak the action name in with the element name
194        action_name = message.action_name
195        if not action_name:
196            action_name = "Main"
197
198        return self.content_profile.fmt("{: >5}".format(action_name.lower())) + \
199            self.format_profile.fmt(':') + \
200            self.content_profile.fmt(self._fmt_string.format(name))
201
202
203# A widget for displaying the primary message text
204class MessageText(Widget):
205
206    def render(self, message):
207        return message.message
208
209
210# A widget for formatting the element cache key
211class CacheKey(Widget):
212
213    def __init__(self, context, content_profile, format_profile, err_profile):
214        super(CacheKey, self).__init__(context, content_profile, format_profile)
215
216        self._err_profile = err_profile
217        self._key_length = context.log_key_length
218
219    def render(self, message):
220
221        element_id = message.task_id or message.unique_id
222        if element_id is None or not self._key_length:
223            return ""
224
225        missing = False
226        key = ' ' * self._key_length
227        plugin = Plugin._lookup(element_id)
228        if isinstance(plugin, Element):
229            _, key, missing = plugin._get_display_key()
230
231        if message.message_type in ERROR_MESSAGES:
232            text = self._err_profile.fmt(key)
233        else:
234            text = self.content_profile.fmt(key, dim=missing)
235
236        return text
237
238
239# A widget for formatting the log file
240class LogFile(Widget):
241
242    def __init__(self, context, content_profile, format_profile, err_profile):
243        super(LogFile, self).__init__(context, content_profile, format_profile)
244
245        self._err_profile = err_profile
246        self._logdir = context.logdir
247
248    def render(self, message, abbrev=True):
249
250        if message.logfile and message.scheduler:
251            logfile = message.logfile
252
253            if abbrev and self._logdir != "" and logfile.startswith(self._logdir):
254                logfile = logfile[len(self._logdir):]
255                logfile = logfile.lstrip(os.sep)
256
257            if message.message_type in ERROR_MESSAGES:
258                text = self._err_profile.fmt(logfile)
259            else:
260                text = self.content_profile.fmt(logfile, dim=True)
261        else:
262            text = ''
263
264        return text
265
266
267# START and SUCCESS messages are expected to have no useful
268# information in the message text, so we display the logfile name for
269# these messages, and the message text for other types.
270#
271class MessageOrLogFile(Widget):
272    def __init__(self, context, content_profile, format_profile, err_profile):
273        super(MessageOrLogFile, self).__init__(context, content_profile, format_profile)
274        self._message_widget = MessageText(context, content_profile, format_profile)
275        self._logfile_widget = LogFile(context, content_profile, format_profile, err_profile)
276
277    def render(self, message):
278        # Show the log file only in the main start/success messages
279        if message.logfile and message.scheduler and \
280           message.message_type in [MessageType.START, MessageType.SUCCESS]:
281            text = self._logfile_widget.render(message)
282        else:
283            text = self._message_widget.render(message)
284        return text
285
286
287# LogLine
288#
289# A widget for formatting a log line
290#
291# Args:
292#    context (Context): The Context
293#    content_profile (Profile): Formatting profile for content text
294#    format_profile (Profile): Formatting profile for formatting text
295#    success_profile (Profile): Formatting profile for success text
296#    error_profile (Profile): Formatting profile for error text
297#    detail_profile (Profile): Formatting profile for detail text
298#    indent (int): Number of spaces to use for general indentation
299#
300class LogLine(Widget):
301
302    def __init__(self, context,
303                 content_profile,
304                 format_profile,
305                 success_profile,
306                 err_profile,
307                 detail_profile,
308                 indent=4):
309        super(LogLine, self).__init__(context, content_profile, format_profile)
310
311        self._columns = []
312        self._failure_messages = defaultdict(list)
313        self._success_profile = success_profile
314        self._err_profile = err_profile
315        self._detail_profile = detail_profile
316        self._indent = ' ' * indent
317        self._log_lines = context.log_error_lines
318        self._message_lines = context.log_message_lines
319        self._resolved_keys = None
320
321        self._space_widget = Space(context, content_profile, format_profile)
322        self._logfile_widget = LogFile(context, content_profile, format_profile, err_profile)
323
324        if context.log_debug:
325            self._columns.extend([
326                Debug(context, content_profile, format_profile)
327            ])
328
329        self.logfile_variable_names = {
330            "elapsed": TimeCode(context, content_profile, format_profile, microseconds=False),
331            "elapsed-us": TimeCode(context, content_profile, format_profile, microseconds=True),
332            "wallclock": WallclockTime(context, content_profile, format_profile),
333            "key": CacheKey(context, content_profile, format_profile, err_profile),
334            "element": ElementName(context, content_profile, format_profile),
335            "action": TypeName(context, content_profile, format_profile),
336            "message": MessageOrLogFile(context, content_profile, format_profile, err_profile)
337        }
338        logfile_tokens = self._parse_logfile_format(context.log_message_format, content_profile, format_profile)
339        self._columns.extend(logfile_tokens)
340
341    # show_pipeline()
342    #
343    # Display a list of elements in the specified format.
344    #
345    # The formatting string is the one currently documented in `bst show`, this
346    # is used in pipeline session headings and also to implement `bst show`.
347    #
348    # Args:
349    #    dependencies (list of Element): A list of Element objects
350    #    format_: A formatting string, as specified by `bst show`
351    #
352    # Returns:
353    #    (str): The formatted list of elements
354    #
355    def show_pipeline(self, dependencies, format_):
356        report = ''
357        p = Profile()
358
359        for element in dependencies:
360            line = format_
361
362            full_key, cache_key, dim_keys = element._get_display_key()
363
364            line = p.fmt_subst(line, 'name', element._get_full_name(), fg='blue', bold=True)
365            line = p.fmt_subst(line, 'key', cache_key, fg='yellow', dim=dim_keys)
366            line = p.fmt_subst(line, 'full-key', full_key, fg='yellow', dim=dim_keys)
367
368            consistency = element._get_consistency()
369            if consistency == Consistency.INCONSISTENT:
370                line = p.fmt_subst(line, 'state', "no reference", fg='red')
371            else:
372                if element._cached():
373                    line = p.fmt_subst(line, 'state', "cached", fg='magenta')
374                elif consistency == Consistency.RESOLVED:
375                    line = p.fmt_subst(line, 'state', "fetch needed", fg='red')
376                elif element._buildable():
377                    line = p.fmt_subst(line, 'state', "buildable", fg='green')
378                else:
379                    line = p.fmt_subst(line, 'state', "waiting", fg='blue')
380
381            # Element configuration
382            if "%{config" in format_:
383                config = _yaml.node_sanitize(element._Element__config)
384                line = p.fmt_subst(
385                    line, 'config',
386                    yaml.round_trip_dump(config, default_flow_style=False, allow_unicode=True))
387
388            # Variables
389            if "%{vars" in format_:
390                variables = _yaml.node_sanitize(element._Element__variables.variables)
391                line = p.fmt_subst(
392                    line, 'vars',
393                    yaml.round_trip_dump(variables, default_flow_style=False, allow_unicode=True))
394
395            # Environment
396            if "%{env" in format_:
397                environment = _yaml.node_sanitize(element._Element__environment)
398                line = p.fmt_subst(
399                    line, 'env',
400                    yaml.round_trip_dump(environment, default_flow_style=False, allow_unicode=True))
401
402            # Public
403            if "%{public" in format_:
404                environment = _yaml.node_sanitize(element._Element__public)
405                line = p.fmt_subst(
406                    line, 'public',
407                    yaml.round_trip_dump(environment, default_flow_style=False, allow_unicode=True))
408
409            # Workspaced
410            if "%{workspaced" in format_:
411                line = p.fmt_subst(
412                    line, 'workspaced',
413                    '(workspaced)' if element._get_workspace() else '', fg='yellow')
414
415            # Workspace-dirs
416            if "%{workspace-dirs" in format_:
417                workspace = element._get_workspace()
418                if workspace is not None:
419                    path = workspace.path.replace(os.getenv('HOME', '/root'), '~')
420                    line = p.fmt_subst(line, 'workspace-dirs', "Workspace: {}".format(path))
421                else:
422                    line = p.fmt_subst(
423                        line, 'workspace-dirs', '')
424
425            report += line + '\n'
426
427        return report.rstrip('\n')
428
429    # print_heading()
430    #
431    # A message to be printed at program startup, indicating
432    # some things about user configuration and BuildStream version
433    # and so on.
434    #
435    # Args:
436    #    project (Project): The toplevel project we were invoked from
437    #    stream (Stream): The stream
438    #    log_file (file): An optional file handle for additional logging
439    #    styling (bool): Whether to enable ansi escape codes in the output
440    #
441    def print_heading(self, project, stream, *, log_file, styling=False):
442        context = self.context
443        starttime = datetime.datetime.now()
444        text = ''
445
446        self._resolved_keys = {element: element._get_cache_key() for element in stream.session_elements}
447
448        # Main invocation context
449        text += '\n'
450        text += self.content_profile.fmt("BuildStream Version {}\n".format(bst_version), bold=True)
451        values = OrderedDict()
452        values["Session Start"] = starttime.strftime('%A, %d-%m-%Y at %H:%M:%S')
453        values["Project"] = "{} ({})".format(project.name, project.directory)
454        values["Targets"] = ", ".join([t.name for t in stream.targets])
455        values["Cache Usage"] = "{}".format(context.get_artifact_cache_usage())
456        text += self._format_values(values)
457
458        # User configurations
459        text += '\n'
460        text += self.content_profile.fmt("User Configuration\n", bold=True)
461        values = OrderedDict()
462        values["Configuration File"] = \
463            "Default Configuration" if not context.config_origin else context.config_origin
464        values["Log Files"] = context.logdir
465        values["Source Mirrors"] = context.sourcedir
466        values["Build Area"] = context.builddir
467        values["Artifact Cache"] = context.artifactdir
468        values["Strict Build Plan"] = "Yes" if context.get_strict() else "No"
469        values["Maximum Fetch Tasks"] = context.sched_fetchers
470        values["Maximum Build Tasks"] = context.sched_builders
471        values["Maximum Push Tasks"] = context.sched_pushers
472        values["Maximum Network Retries"] = context.sched_network_retries
473        text += self._format_values(values)
474        text += '\n'
475
476        # Project Options
477        values = OrderedDict()
478        project.options.printable_variables(values)
479        if values:
480            text += self.content_profile.fmt("Project Options\n", bold=True)
481            text += self._format_values(values)
482            text += '\n'
483
484        # Plugins
485        text += self._format_plugins(project.first_pass_config.element_factory.loaded_dependencies,
486                                     project.first_pass_config.source_factory.loaded_dependencies)
487        if project.config.element_factory and project.config.source_factory:
488            text += self._format_plugins(project.config.element_factory.loaded_dependencies,
489                                         project.config.source_factory.loaded_dependencies)
490
491        # Pipeline state
492        text += self.content_profile.fmt("Pipeline\n", bold=True)
493        text += self.show_pipeline(stream.total_elements, context.log_element_format)
494        text += '\n'
495
496        # Separator line before following output
497        text += self.format_profile.fmt("=" * 79 + '\n')
498
499        click.echo(text, color=styling, nl=False, err=True)
500        if log_file:
501            click.echo(text, file=log_file, color=False, nl=False)
502
503    # print_summary()
504    #
505    # Print a summary of activities at the end of a session
506    #
507    # Args:
508    #    stream (Stream): The Stream
509    #    log_file (file): An optional file handle for additional logging
510    #    styling (bool): Whether to enable ansi escape codes in the output
511    #
512    def print_summary(self, stream, log_file, styling=False):
513
514        # Early silent return if there are no queues, can happen
515        # only in the case that the stream early returned due to
516        # an inconsistent pipeline state.
517        if not stream.queues:
518            return
519
520        text = ''
521
522        assert self._resolved_keys is not None
523        elements = sorted(e for (e, k) in self._resolved_keys.items() if k != e._get_cache_key())
524        if elements:
525            text += self.content_profile.fmt("Resolved key Summary\n", bold=True)
526            text += self.show_pipeline(elements, self.context.log_element_format)
527            text += "\n\n"
528
529        if self._failure_messages:
530            values = OrderedDict()
531
532            for element, messages in sorted(self._failure_messages.items(), key=lambda x: x[0].name):
533                for queue in stream.queues:
534                    if any(el.name == element.name for el in queue.failed_elements):
535                        values[element.name] = ''.join(self._render(v) for v in messages)
536            if values:
537                text += self.content_profile.fmt("Failure Summary\n", bold=True)
538                text += self._format_values(values, style_value=False)
539
540        text += self.content_profile.fmt("Pipeline Summary\n", bold=True)
541        values = OrderedDict()
542
543        values['Total'] = self.content_profile.fmt(str(len(stream.total_elements)))
544        values['Session'] = self.content_profile.fmt(str(len(stream.session_elements)))
545
546        processed_maxlen = 1
547        skipped_maxlen = 1
548        failed_maxlen = 1
549        for queue in stream.queues:
550            processed_maxlen = max(len(str(len(queue.processed_elements))), processed_maxlen)
551            skipped_maxlen = max(len(str(len(queue.skipped_elements))), skipped_maxlen)
552            failed_maxlen = max(len(str(len(queue.failed_elements))), failed_maxlen)
553
554        for queue in stream.queues:
555            processed = str(len(queue.processed_elements))
556            skipped = str(len(queue.skipped_elements))
557            failed = str(len(queue.failed_elements))
558
559            processed_align = ' ' * (processed_maxlen - len(processed))
560            skipped_align = ' ' * (skipped_maxlen - len(skipped))
561            failed_align = ' ' * (failed_maxlen - len(failed))
562
563            status_text = self.content_profile.fmt("processed ") + \
564                self._success_profile.fmt(processed) + \
565                self.format_profile.fmt(', ') + processed_align
566
567            status_text += self.content_profile.fmt("skipped ") + \
568                self.content_profile.fmt(skipped) + \
569                self.format_profile.fmt(', ') + skipped_align
570
571            status_text += self.content_profile.fmt("failed ") + \
572                self._err_profile.fmt(failed) + ' ' + failed_align
573            values["{} Queue".format(queue.action_name)] = status_text
574
575        text += self._format_values(values, style_value=False)
576
577        click.echo(text, color=styling, nl=False, err=True)
578        if log_file:
579            click.echo(text, file=log_file, color=False, nl=False)
580
581    ###################################################
582    #             Widget Abstract Methods             #
583    ###################################################
584
585    def render(self, message):
586
587        # Track logfiles for later use
588        element_id = message.task_id or message.unique_id
589        if message.message_type in ERROR_MESSAGES and element_id is not None:
590            plugin = Plugin._lookup(element_id)
591            self._failure_messages[plugin].append(message)
592
593        return self._render(message)
594
595    ###################################################
596    #                 Private Methods                 #
597    ###################################################
598    def _parse_logfile_format(self, format_string, content_profile, format_profile):
599        logfile_tokens = []
600        while format_string:
601            if format_string.startswith("%%"):
602                logfile_tokens.append(FixedText(self.context, "%", content_profile, format_profile))
603                format_string = format_string[2:]
604                continue
605            m = re.search(r"^%\{([^\}]+)\}", format_string)
606            if m is not None:
607                variable = m.group(1)
608                format_string = format_string[m.end(0):]
609                if variable not in self.logfile_variable_names:
610                    raise Exception("'{0}' is not a valid log variable name.".format(variable))
611                logfile_tokens.append(self.logfile_variable_names[variable])
612            else:
613                m = re.search("^[^%]+", format_string)
614                if m is not None:
615                    text = FixedText(self.context, m.group(0), content_profile, format_profile)
616                    format_string = format_string[m.end(0):]
617                    logfile_tokens.append(text)
618                else:
619                    # No idea what to do now
620                    raise Exception("'{0}' could not be parsed into a valid logging format.".format(format_string))
621        return logfile_tokens
622
623    def _render(self, message):
624
625        # Render the column widgets first
626        text = ''
627        for widget in self._columns:
628            text += widget.render(message)
629
630        text += '\n'
631
632        extra_nl = False
633
634        # Now add some custom things
635        if message.detail:
636
637            # Identify frontend messages, we never abbreviate these
638            frontend_message = not (message.task_id or message.unique_id)
639
640            # Split and truncate message detail down to message_lines lines
641            lines = message.detail.splitlines(True)
642
643            n_lines = len(lines)
644            abbrev = False
645            if message.message_type not in ERROR_MESSAGES \
646               and not frontend_message and n_lines > self._message_lines:
647                abbrev = True
648                lines = lines[0:self._message_lines]
649            else:
650                lines[n_lines - 1] = lines[n_lines - 1].rstrip('\n')
651
652            detail = self._indent + self._indent.join(lines)
653
654            text += '\n'
655            if message.message_type in ERROR_MESSAGES:
656                text += self._err_profile.fmt(detail, bold=True)
657            else:
658                text += self._detail_profile.fmt(detail)
659
660            if abbrev:
661                text += self._indent + \
662                    self.content_profile.fmt('Message contains {} additional lines'
663                                             .format(n_lines - self._message_lines), dim=True)
664            text += '\n'
665
666            extra_nl = True
667
668        if message.sandbox is not None:
669            sandbox = self._indent + 'Sandbox directory: ' + message.sandbox
670
671            text += '\n'
672            if message.message_type == MessageType.FAIL:
673                text += self._err_profile.fmt(sandbox, bold=True)
674            else:
675                text += self._detail_profile.fmt(sandbox)
676            text += '\n'
677            extra_nl = True
678
679        if message.scheduler and message.message_type == MessageType.FAIL:
680            text += '\n'
681
682            if self.context is not None and not self.context.log_verbose:
683                text += self._indent + self._err_profile.fmt("Log file: ")
684                text += self._indent + self._logfile_widget.render(message) + '\n'
685            else:
686                text += self._indent + self._err_profile.fmt("Printing the last {} lines from log file:"
687                                                             .format(self._log_lines)) + '\n'
688                text += self._indent + self._logfile_widget.render(message, abbrev=False) + '\n'
689                text += self._indent + self._err_profile.fmt("=" * 70) + '\n'
690
691                log_content = self._read_last_lines(message.logfile)
692                log_content = textwrap.indent(log_content, self._indent)
693                text += self._detail_profile.fmt(log_content)
694                text += '\n'
695                text += self._indent + self._err_profile.fmt("=" * 70) + '\n'
696            extra_nl = True
697
698        if extra_nl:
699            text += '\n'
700
701        return text
702
703    def _read_last_lines(self, logfile):
704        with ExitStack() as stack:
705            # mmap handles low-level memory details, allowing for
706            # faster searches
707            f = stack.enter_context(open(logfile, 'r+'))
708            log = stack.enter_context(mmap(f.fileno(), os.path.getsize(f.name)))
709
710            count = 0
711            end = log.size() - 1
712
713            while count < self._log_lines and end >= 0:
714                location = log.rfind(b'\n', 0, end)
715                count += 1
716
717                # If location is -1 (none found), this will print the
718                # first character because of the later +1
719                end = location
720
721            # end+1 is correct whether or not a newline was found at
722            # that location. If end is -1 (seek before beginning of file)
723            # then we get the first characther. If end is a newline position,
724            # we discard it and only want to print the beginning of the next
725            # line.
726            lines = log[(end + 1):].splitlines()
727            return '\n'.join([line.decode('utf-8') for line in lines]).rstrip()
728
729    def _format_plugins(self, element_plugins, source_plugins):
730        text = ""
731
732        if not (element_plugins or source_plugins):
733            return text
734
735        text += self.content_profile.fmt("Loaded Plugins\n", bold=True)
736
737        if element_plugins:
738            text += self.format_profile.fmt("  Element Plugins\n")
739            for plugin in element_plugins:
740                text += self.content_profile.fmt("    - {}\n".format(plugin))
741
742        if source_plugins:
743            text += self.format_profile.fmt("  Source Plugins\n")
744            for plugin in source_plugins:
745                text += self.content_profile.fmt("    - {}\n".format(plugin))
746
747        text += '\n'
748
749        return text
750
751    # _format_values()
752    #
753    # Formats an indented dictionary of titles / values, ensuring
754    # the values are aligned.
755    #
756    # Args:
757    #    values: A dictionary, usually an OrderedDict()
758    #    style_value: Whether to use the content profile for the values
759    #
760    # Returns:
761    #    (str): The formatted values
762    #
763    def _format_values(self, values, style_value=True):
764        text = ''
765        max_key_len = 0
766        for key, value in values.items():
767            max_key_len = max(len(key), max_key_len)
768
769        for key, value in values.items():
770            if isinstance(value, str) and '\n' in value:
771                text += self.format_profile.fmt("  {}:\n".format(key))
772                text += textwrap.indent(value, self._indent)
773                continue
774
775            text += self.format_profile.fmt("  {}: {}".format(key, ' ' * (max_key_len - len(key))))
776            if style_value:
777                text += self.content_profile.fmt(str(value))
778            else:
779                text += str(value)
780            text += '\n'
781
782        return text
783