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