diff options
author | Chandan Singh <csingh43@bloomberg.net> | 2019-04-24 22:53:19 +0100 |
---|---|---|
committer | Chandan Singh <csingh43@bloomberg.net> | 2019-05-21 12:41:18 +0100 |
commit | 070d053e5cc47e572e9f9e647315082bd7a15c63 (patch) | |
tree | 7fb0fdff52f9b5f8a18ec8fe9c75b661f9e0839e /src/buildstream/_frontend/widget.py | |
parent | 6c59e7901a52be961c2a1b671cf2b30f90bc4d0a (diff) | |
download | buildstream-070d053e5cc47e572e9f9e647315082bd7a15c63.tar.gz |
Move source from 'buildstream' to 'src/buildstream'
This was discussed in #1008.
Fixes #1009.
Diffstat (limited to 'src/buildstream/_frontend/widget.py')
-rw-r--r-- | src/buildstream/_frontend/widget.py | 806 |
1 files changed, 806 insertions, 0 deletions
diff --git a/src/buildstream/_frontend/widget.py b/src/buildstream/_frontend/widget.py new file mode 100644 index 000000000..cfe3a06e9 --- /dev/null +++ b/src/buildstream/_frontend/widget.py @@ -0,0 +1,806 @@ +# +# Copyright (C) 2017 Codethink Limited +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. +# +# Authors: +# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk> +import datetime +import os +from collections import defaultdict, OrderedDict +from contextlib import ExitStack +from mmap import mmap +import re +import textwrap +from ruamel import yaml +import click + +from .profile import Profile +from .. import Element, Consistency, Scope +from .. import _yaml +from .. import __version__ as bst_version +from .._exceptions import ImplError +from .._message import MessageType +from ..plugin import Plugin + + +# These messages are printed a bit differently +ERROR_MESSAGES = [MessageType.FAIL, MessageType.ERROR, MessageType.BUG] + + +# Widget() +# +# Args: +# content_profile (Profile): The profile to use for rendering content +# format_profile (Profile): The profile to use for rendering formatting +# +# An abstract class for printing output columns in our text UI. +# +class Widget(): + + def __init__(self, context, content_profile, format_profile): + + # The context + self.context = context + + # The content profile + self.content_profile = content_profile + + # The formatting profile + self.format_profile = format_profile + + # render() + # + # Renders a string to be printed in the UI + # + # Args: + # message (Message): A message to print + # + # Returns: + # (str): The string this widget prints for the given message + # + def render(self, message): + raise ImplError("{} does not implement render()".format(type(self).__name__)) + + +# Used to add spacing between columns +class Space(Widget): + + def render(self, message): + return ' ' + + +# Used to add fixed text between columns +class FixedText(Widget): + + def __init__(self, context, text, content_profile, format_profile): + super(FixedText, self).__init__(context, content_profile, format_profile) + self.text = text + + def render(self, message): + return self.format_profile.fmt(self.text) + + +# Used to add the wallclock time this message was created at +class WallclockTime(Widget): + def __init__(self, context, content_profile, format_profile, output_format=False): + self._output_format = output_format + super(WallclockTime, self).__init__(context, content_profile, format_profile) + + def render(self, message): + + fields = [self.content_profile.fmt("{:02d}".format(x)) for x in + [message.creation_time.hour, + message.creation_time.minute, + message.creation_time.second, + ] + ] + text = self.format_profile.fmt(":").join(fields) + + if self._output_format == 'us': + text += self.content_profile.fmt(".{:06d}".format(message.creation_time.microsecond)) + + return text + + +# A widget for rendering the debugging column +class Debug(Widget): + + def render(self, message): + unique_id = 0 if message.unique_id is None else message.unique_id + + text = self.format_profile.fmt('pid:') + text += self.content_profile.fmt("{: <5}".format(message.pid)) + text += self.format_profile.fmt(" id:") + text += self.content_profile.fmt("{:0>3}".format(unique_id)) + + return text + + +# A widget for rendering the time codes +class TimeCode(Widget): + def __init__(self, context, content_profile, format_profile, microseconds=False): + self._microseconds = microseconds + super(TimeCode, self).__init__(context, content_profile, format_profile) + + def render(self, message): + return self.render_time(message.elapsed) + + def render_time(self, elapsed): + if elapsed is None: + fields = [ + self.content_profile.fmt('--') + for i in range(3) + ] + else: + hours, remainder = divmod(int(elapsed.total_seconds()), 60 * 60) + minutes, seconds = divmod(remainder, 60) + fields = [ + self.content_profile.fmt("{0:02d}".format(field)) + for field in [hours, minutes, seconds] + ] + + text = self.format_profile.fmt(':').join(fields) + + if self._microseconds: + if elapsed is not None: + text += self.content_profile.fmt(".{0:06d}".format(elapsed.microseconds)) + else: + text += self.content_profile.fmt(".------") + return text + + +# A widget for rendering the MessageType +class TypeName(Widget): + + _action_colors = { + MessageType.DEBUG: "cyan", + MessageType.STATUS: "cyan", + MessageType.INFO: "magenta", + MessageType.WARN: "yellow", + MessageType.START: "blue", + MessageType.SUCCESS: "green", + MessageType.FAIL: "red", + MessageType.SKIPPED: "yellow", + MessageType.ERROR: "red", + MessageType.BUG: "red", + } + + def render(self, message): + return self.content_profile.fmt("{: <7}" + .format(message.message_type.upper()), + bold=True, dim=True, + fg=self._action_colors[message.message_type]) + + +# A widget for displaying the Element name +class ElementName(Widget): + + def render(self, message): + action_name = message.action_name + element_id = message.task_id or message.unique_id + if element_id is not None: + plugin = Plugin._lookup(element_id) + name = plugin._get_full_name() + name = '{: <30}'.format(name) + else: + name = 'core activity' + name = '{: <30}'.format(name) + + if not action_name: + action_name = "Main" + + return self.content_profile.fmt("{: >8}".format(action_name.lower())) + \ + self.format_profile.fmt(':') + self.content_profile.fmt(name) + + +# A widget for displaying the primary message text +class MessageText(Widget): + + def render(self, message): + return message.message + + +# A widget for formatting the element cache key +class CacheKey(Widget): + + def __init__(self, context, content_profile, format_profile, err_profile): + super(CacheKey, self).__init__(context, content_profile, format_profile) + + self._err_profile = err_profile + self._key_length = context.log_key_length + + def render(self, message): + + element_id = message.task_id or message.unique_id + if not self._key_length: + return "" + + if element_id is None: + return ' ' * self._key_length + + missing = False + key = ' ' * self._key_length + plugin = Plugin._lookup(element_id) + if isinstance(plugin, Element): + _, key, missing = plugin._get_display_key() + + if message.message_type in ERROR_MESSAGES: + text = self._err_profile.fmt(key) + else: + text = self.content_profile.fmt(key, dim=missing) + + return text + + +# A widget for formatting the log file +class LogFile(Widget): + + def __init__(self, context, content_profile, format_profile, err_profile): + super(LogFile, self).__init__(context, content_profile, format_profile) + + self._err_profile = err_profile + self._logdir = context.logdir + + def render(self, message, abbrev=True): + + if message.logfile and message.scheduler: + logfile = message.logfile + + if abbrev and self._logdir != "" and logfile.startswith(self._logdir): + logfile = logfile[len(self._logdir):] + logfile = logfile.lstrip(os.sep) + + if message.message_type in ERROR_MESSAGES: + text = self._err_profile.fmt(logfile) + else: + text = self.content_profile.fmt(logfile, dim=True) + else: + text = '' + + return text + + +# START and SUCCESS messages are expected to have no useful +# information in the message text, so we display the logfile name for +# these messages, and the message text for other types. +# +class MessageOrLogFile(Widget): + def __init__(self, context, content_profile, format_profile, err_profile): + super(MessageOrLogFile, self).__init__(context, content_profile, format_profile) + self._message_widget = MessageText(context, content_profile, format_profile) + self._logfile_widget = LogFile(context, content_profile, format_profile, err_profile) + + def render(self, message): + # Show the log file only in the main start/success messages + if message.logfile and message.scheduler and \ + message.message_type in [MessageType.START, MessageType.SUCCESS]: + text = self._logfile_widget.render(message) + else: + text = self._message_widget.render(message) + return text + + +# LogLine +# +# A widget for formatting a log line +# +# Args: +# context (Context): The Context +# content_profile (Profile): Formatting profile for content text +# format_profile (Profile): Formatting profile for formatting text +# success_profile (Profile): Formatting profile for success text +# error_profile (Profile): Formatting profile for error text +# detail_profile (Profile): Formatting profile for detail text +# indent (int): Number of spaces to use for general indentation +# +class LogLine(Widget): + + def __init__(self, context, + content_profile, + format_profile, + success_profile, + err_profile, + detail_profile, + indent=4): + super(LogLine, self).__init__(context, content_profile, format_profile) + + self._columns = [] + self._failure_messages = defaultdict(list) + self._success_profile = success_profile + self._err_profile = err_profile + self._detail_profile = detail_profile + self._indent = ' ' * indent + self._log_lines = context.log_error_lines + self._message_lines = context.log_message_lines + self._resolved_keys = None + + self._space_widget = Space(context, content_profile, format_profile) + self._logfile_widget = LogFile(context, content_profile, format_profile, err_profile) + + if context.log_debug: + self._columns.extend([ + Debug(context, content_profile, format_profile) + ]) + + self.logfile_variable_names = { + "elapsed": TimeCode(context, content_profile, format_profile, microseconds=False), + "elapsed-us": TimeCode(context, content_profile, format_profile, microseconds=True), + "wallclock": WallclockTime(context, content_profile, format_profile), + "wallclock-us": WallclockTime(context, content_profile, format_profile, output_format='us'), + "key": CacheKey(context, content_profile, format_profile, err_profile), + "element": ElementName(context, content_profile, format_profile), + "action": TypeName(context, content_profile, format_profile), + "message": MessageOrLogFile(context, content_profile, format_profile, err_profile) + } + logfile_tokens = self._parse_logfile_format(context.log_message_format, content_profile, format_profile) + self._columns.extend(logfile_tokens) + + # show_pipeline() + # + # Display a list of elements in the specified format. + # + # The formatting string is the one currently documented in `bst show`, this + # is used in pipeline session headings and also to implement `bst show`. + # + # Args: + # dependencies (list of Element): A list of Element objects + # format_: A formatting string, as specified by `bst show` + # + # Returns: + # (str): The formatted list of elements + # + def show_pipeline(self, dependencies, format_): + report = '' + p = Profile() + + for element in dependencies: + line = format_ + + full_key, cache_key, dim_keys = element._get_display_key() + + line = p.fmt_subst(line, 'name', element._get_full_name(), fg='blue', bold=True) + line = p.fmt_subst(line, 'key', cache_key, fg='yellow', dim=dim_keys) + line = p.fmt_subst(line, 'full-key', full_key, fg='yellow', dim=dim_keys) + + consistency = element._get_consistency() + if consistency == Consistency.INCONSISTENT: + line = p.fmt_subst(line, 'state', "no reference", fg='red') + else: + if element._cached_failure(): + line = p.fmt_subst(line, 'state', "failed", fg='red') + elif element._cached_success(): + line = p.fmt_subst(line, 'state', "cached", fg='magenta') + elif consistency == Consistency.RESOLVED and not element._source_cached(): + line = p.fmt_subst(line, 'state', "fetch needed", fg='red') + elif element._buildable(): + line = p.fmt_subst(line, 'state', "buildable", fg='green') + else: + line = p.fmt_subst(line, 'state', "waiting", fg='blue') + + # Element configuration + if "%{config" in format_: + config = _yaml.node_sanitize(element._Element__config) + line = p.fmt_subst( + line, 'config', + yaml.round_trip_dump(config, default_flow_style=False, allow_unicode=True)) + + # Variables + if "%{vars" in format_: + variables = _yaml.node_sanitize(element._Element__variables.flat) + line = p.fmt_subst( + line, 'vars', + yaml.round_trip_dump(variables, default_flow_style=False, allow_unicode=True)) + + # Environment + if "%{env" in format_: + environment = _yaml.node_sanitize(element._Element__environment) + line = p.fmt_subst( + line, 'env', + yaml.round_trip_dump(environment, default_flow_style=False, allow_unicode=True)) + + # Public + if "%{public" in format_: + environment = _yaml.node_sanitize(element._Element__public) + line = p.fmt_subst( + line, 'public', + yaml.round_trip_dump(environment, default_flow_style=False, allow_unicode=True)) + + # Workspaced + if "%{workspaced" in format_: + line = p.fmt_subst( + line, 'workspaced', + '(workspaced)' if element._get_workspace() else '', fg='yellow') + + # Workspace-dirs + if "%{workspace-dirs" in format_: + workspace = element._get_workspace() + if workspace is not None: + path = workspace.get_absolute_path() + if path.startswith("~/"): + path = os.path.join(os.getenv('HOME', '/root'), path[2:]) + line = p.fmt_subst(line, 'workspace-dirs', "Workspace: {}".format(path)) + else: + line = p.fmt_subst( + line, 'workspace-dirs', '') + + # Dependencies + if "%{deps" in format_: + deps = [e.name for e in element.dependencies(Scope.ALL, recurse=False)] + line = p.fmt_subst( + line, 'deps', + yaml.safe_dump(deps, default_style=None).rstrip('\n')) + + # Build Dependencies + if "%{build-deps" in format_: + build_deps = [e.name for e in element.dependencies(Scope.BUILD, recurse=False)] + line = p.fmt_subst( + line, 'build-deps', + yaml.safe_dump(build_deps, default_style=False).rstrip('\n')) + + # Runtime Dependencies + if "%{runtime-deps" in format_: + runtime_deps = [e.name for e in element.dependencies(Scope.RUN, recurse=False)] + line = p.fmt_subst( + line, 'runtime-deps', + yaml.safe_dump(runtime_deps, default_style=False).rstrip('\n')) + + report += line + '\n' + + return report.rstrip('\n') + + # print_heading() + # + # A message to be printed at program startup, indicating + # some things about user configuration and BuildStream version + # and so on. + # + # Args: + # project (Project): The toplevel project we were invoked from + # stream (Stream): The stream + # log_file (file): An optional file handle for additional logging + # styling (bool): Whether to enable ansi escape codes in the output + # + def print_heading(self, project, stream, *, log_file, styling=False): + context = self.context + starttime = datetime.datetime.now() + text = '' + + self._resolved_keys = {element: element._get_cache_key() for element in stream.session_elements} + + # Main invocation context + text += '\n' + text += self.content_profile.fmt("BuildStream Version {}\n".format(bst_version), bold=True) + values = OrderedDict() + values["Session Start"] = starttime.strftime('%A, %d-%m-%Y at %H:%M:%S') + values["Project"] = "{} ({})".format(project.name, project.directory) + values["Targets"] = ", ".join([t.name for t in stream.targets]) + values["Cache Usage"] = str(context.get_cache_usage()) + text += self._format_values(values) + + # User configurations + text += '\n' + text += self.content_profile.fmt("User Configuration\n", bold=True) + values = OrderedDict() + values["Configuration File"] = \ + "Default Configuration" if not context.config_origin else context.config_origin + values["Cache Directory"] = context.cachedir + values["Log Files"] = context.logdir + values["Source Mirrors"] = context.sourcedir + values["Build Area"] = context.builddir + values["Strict Build Plan"] = "Yes" if context.get_strict() else "No" + values["Maximum Fetch Tasks"] = context.sched_fetchers + values["Maximum Build Tasks"] = context.sched_builders + values["Maximum Push Tasks"] = context.sched_pushers + values["Maximum Network Retries"] = context.sched_network_retries + text += self._format_values(values) + text += '\n' + + # Project Options + values = OrderedDict() + project.options.printable_variables(values) + if values: + text += self.content_profile.fmt("Project Options\n", bold=True) + text += self._format_values(values) + text += '\n' + + # Plugins + text += self._format_plugins(project.first_pass_config.element_factory.loaded_dependencies, + project.first_pass_config.source_factory.loaded_dependencies) + if project.config.element_factory and project.config.source_factory: + text += self._format_plugins(project.config.element_factory.loaded_dependencies, + project.config.source_factory.loaded_dependencies) + + # Pipeline state + text += self.content_profile.fmt("Pipeline\n", bold=True) + text += self.show_pipeline(stream.total_elements, context.log_element_format) + text += '\n' + + # Separator line before following output + text += self.format_profile.fmt("=" * 79 + '\n') + + click.echo(text, color=styling, nl=False, err=True) + if log_file: + click.echo(text, file=log_file, color=False, nl=False) + + # print_summary() + # + # Print a summary of activities at the end of a session + # + # Args: + # stream (Stream): The Stream + # log_file (file): An optional file handle for additional logging + # styling (bool): Whether to enable ansi escape codes in the output + # + def print_summary(self, stream, log_file, styling=False): + + # Early silent return if there are no queues, can happen + # only in the case that the stream early returned due to + # an inconsistent pipeline state. + if not stream.queues: + return + + text = '' + + assert self._resolved_keys is not None + elements = sorted(e for (e, k) in self._resolved_keys.items() if k != e._get_cache_key()) + if elements: + text += self.content_profile.fmt("Resolved key Summary\n", bold=True) + text += self.show_pipeline(elements, self.context.log_element_format) + text += "\n\n" + + if self._failure_messages: + values = OrderedDict() + + for element, messages in sorted(self._failure_messages.items(), key=lambda x: x[0].name): + for queue in stream.queues: + if any(el.name == element.name for el in queue.failed_elements): + values[element.name] = ''.join(self._render(v) for v in messages) + if values: + text += self.content_profile.fmt("Failure Summary\n", bold=True) + text += self._format_values(values, style_value=False) + + text += self.content_profile.fmt("Pipeline Summary\n", bold=True) + values = OrderedDict() + + values['Total'] = self.content_profile.fmt(str(len(stream.total_elements))) + values['Session'] = self.content_profile.fmt(str(len(stream.session_elements))) + + processed_maxlen = 1 + skipped_maxlen = 1 + failed_maxlen = 1 + for queue in stream.queues: + processed_maxlen = max(len(str(len(queue.processed_elements))), processed_maxlen) + skipped_maxlen = max(len(str(len(queue.skipped_elements))), skipped_maxlen) + failed_maxlen = max(len(str(len(queue.failed_elements))), failed_maxlen) + + for queue in stream.queues: + processed = str(len(queue.processed_elements)) + skipped = str(len(queue.skipped_elements)) + failed = str(len(queue.failed_elements)) + + processed_align = ' ' * (processed_maxlen - len(processed)) + skipped_align = ' ' * (skipped_maxlen - len(skipped)) + failed_align = ' ' * (failed_maxlen - len(failed)) + + status_text = self.content_profile.fmt("processed ") + \ + self._success_profile.fmt(processed) + \ + self.format_profile.fmt(', ') + processed_align + + status_text += self.content_profile.fmt("skipped ") + \ + self.content_profile.fmt(skipped) + \ + self.format_profile.fmt(', ') + skipped_align + + status_text += self.content_profile.fmt("failed ") + \ + self._err_profile.fmt(failed) + ' ' + failed_align + values["{} Queue".format(queue.action_name)] = status_text + + text += self._format_values(values, style_value=False) + + click.echo(text, color=styling, nl=False, err=True) + if log_file: + click.echo(text, file=log_file, color=False, nl=False) + + ################################################### + # Widget Abstract Methods # + ################################################### + + def render(self, message): + + # Track logfiles for later use + element_id = message.task_id or message.unique_id + if message.message_type in ERROR_MESSAGES and element_id is not None: + plugin = Plugin._lookup(element_id) + self._failure_messages[plugin].append(message) + + return self._render(message) + + ################################################### + # Private Methods # + ################################################### + def _parse_logfile_format(self, format_string, content_profile, format_profile): + logfile_tokens = [] + while format_string: + if format_string.startswith("%%"): + logfile_tokens.append(FixedText(self.context, "%", content_profile, format_profile)) + format_string = format_string[2:] + continue + m = re.search(r"^%\{([^\}]+)\}", format_string) + if m is not None: + variable = m.group(1) + format_string = format_string[m.end(0):] + if variable not in self.logfile_variable_names: + raise Exception("'{0}' is not a valid log variable name.".format(variable)) + logfile_tokens.append(self.logfile_variable_names[variable]) + else: + m = re.search("^[^%]+", format_string) + if m is not None: + text = FixedText(self.context, m.group(0), content_profile, format_profile) + format_string = format_string[m.end(0):] + logfile_tokens.append(text) + else: + # No idea what to do now + raise Exception("'{0}' could not be parsed into a valid logging format.".format(format_string)) + return logfile_tokens + + def _render(self, message): + + # Render the column widgets first + text = '' + for widget in self._columns: + text += widget.render(message) + + text += '\n' + + extra_nl = False + + # Now add some custom things + if message.detail: + + # Identify frontend messages, we never abbreviate these + frontend_message = not (message.task_id or message.unique_id) + + # Split and truncate message detail down to message_lines lines + lines = message.detail.splitlines(True) + + n_lines = len(lines) + abbrev = False + if message.message_type not in ERROR_MESSAGES \ + and not frontend_message and n_lines > self._message_lines: + lines = lines[0:self._message_lines] + if self._message_lines > 0: + abbrev = True + else: + lines[n_lines - 1] = lines[n_lines - 1].rstrip('\n') + + detail = self._indent + self._indent.join(lines) + + text += '\n' + if message.message_type in ERROR_MESSAGES: + text += self._err_profile.fmt(detail, bold=True) + else: + text += self._detail_profile.fmt(detail) + + if abbrev: + text += self._indent + \ + self.content_profile.fmt('Message contains {} additional lines' + .format(n_lines - self._message_lines), dim=True) + text += '\n' + + extra_nl = True + + if message.scheduler and message.message_type == MessageType.FAIL: + text += '\n' + + if self.context is not None and not self.context.log_verbose: + text += self._indent + self._err_profile.fmt("Log file: ") + text += self._indent + self._logfile_widget.render(message) + '\n' + elif self._log_lines > 0: + text += self._indent + self._err_profile.fmt("Printing the last {} lines from log file:" + .format(self._log_lines)) + '\n' + text += self._indent + self._logfile_widget.render(message, abbrev=False) + '\n' + text += self._indent + self._err_profile.fmt("=" * 70) + '\n' + + log_content = self._read_last_lines(message.logfile) + log_content = textwrap.indent(log_content, self._indent) + text += self._detail_profile.fmt(log_content) + text += '\n' + text += self._indent + self._err_profile.fmt("=" * 70) + '\n' + extra_nl = True + + if extra_nl: + text += '\n' + + return text + + def _read_last_lines(self, logfile): + with ExitStack() as stack: + # mmap handles low-level memory details, allowing for + # faster searches + f = stack.enter_context(open(logfile, 'r+')) + log = stack.enter_context(mmap(f.fileno(), os.path.getsize(f.name))) + + count = 0 + end = log.size() - 1 + + while count < self._log_lines and end >= 0: + location = log.rfind(b'\n', 0, end) + count += 1 + + # If location is -1 (none found), this will print the + # first character because of the later +1 + end = location + + # end+1 is correct whether or not a newline was found at + # that location. If end is -1 (seek before beginning of file) + # then we get the first characther. If end is a newline position, + # we discard it and only want to print the beginning of the next + # line. + lines = log[(end + 1):].splitlines() + return '\n'.join([line.decode('utf-8') for line in lines]).rstrip() + + def _format_plugins(self, element_plugins, source_plugins): + text = "" + + if not (element_plugins or source_plugins): + return text + + text += self.content_profile.fmt("Loaded Plugins\n", bold=True) + + if element_plugins: + text += self.format_profile.fmt(" Element Plugins\n") + for plugin in element_plugins: + text += self.content_profile.fmt(" - {}\n".format(plugin)) + + if source_plugins: + text += self.format_profile.fmt(" Source Plugins\n") + for plugin in source_plugins: + text += self.content_profile.fmt(" - {}\n".format(plugin)) + + text += '\n' + + return text + + # _format_values() + # + # Formats an indented dictionary of titles / values, ensuring + # the values are aligned. + # + # Args: + # values: A dictionary, usually an OrderedDict() + # style_value: Whether to use the content profile for the values + # + # Returns: + # (str): The formatted values + # + def _format_values(self, values, style_value=True): + text = '' + max_key_len = 0 + for key, value in values.items(): + max_key_len = max(len(key), max_key_len) + + for key, value in values.items(): + if isinstance(value, str) and '\n' in value: + text += self.format_profile.fmt(" {}:\n".format(key)) + text += textwrap.indent(value, self._indent) + continue + + text += self.format_profile.fmt(" {}: {}".format(key, ' ' * (max_key_len - len(key)))) + if style_value: + text += self.content_profile.fmt(str(value)) + else: + text += str(value) + text += '\n' + + return text |