Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add flat mode to console renderer #240

Merged
merged 18 commits into from
May 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyinstrument/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ def create_renderer(options: CommandLineOptions, output_file: TextIO) -> rendere

try:
return renderer_class(**render_options)
except TypeError as err:
except (TypeError, renderers.Renderer.MisconfigurationError) as err:
# TypeError is probably a bad renderer option, so we produce a nicer error message
raise OptionsParseError(
f"Failed to create {renderer_class.__name__}. Check your renderer options.\n {err}\n"
Expand Down
9 changes: 9 additions & 0 deletions pyinstrument/renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def render(self, session: Session) -> str:
"""
raise NotImplementedError()

class MisconfigurationError(Exception):
pass


class FrameRenderer(Renderer):
"""
Expand All @@ -56,6 +59,9 @@ class FrameRenderer(Renderer):
Dictionary containing processor options, passed to each processor.
"""

show_all: bool
timeline: bool

def __init__(
self,
show_all: bool = False,
Expand All @@ -72,6 +78,9 @@ def __init__(
self.processors = self.default_processors()
self.processor_options = processor_options or {}

self.show_all = show_all
self.timeline = timeline

if show_all:
for p in (
processors.group_library_frames_processor,
Expand Down
116 changes: 82 additions & 34 deletions pyinstrument/renderers/console.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import time
from typing import Any
from typing import Any, Dict, List, Tuple

import pyinstrument
from pyinstrument import processors
from pyinstrument.frame import Frame
from pyinstrument.renderers.base import FrameRenderer, ProcessorList
from pyinstrument.renderers.base import FrameRenderer, ProcessorList, Renderer
from pyinstrument.session import Session
from pyinstrument.typing import LiteralStr
from pyinstrument.util import truncate
Expand All @@ -22,22 +24,29 @@ def __init__(
self,
unicode: bool = False,
color: bool = False,
flat: bool = False,
time: LiteralStr["seconds", "percent_of_total"] = "seconds",
**kwargs: Any,
):
) -> None:
"""
:param unicode: Use unicode, like box-drawing characters in the output.
:param color: Enable color support, using ANSI color sequences.
:param flat: Display a flat profile instead of a call graph.
:param time: How to display the duration of each frame - ``'seconds'`` or ``'percent_of_total'``
"""
super().__init__(**kwargs)

self.unicode = unicode
self.color = color
self.colors = self.colors_enabled if color else self.colors_disabled
self.flat = flat
self.time = time

def render(self, session: Session):
if self.flat and self.timeline:
raise Renderer.MisconfigurationError("Cannot use timeline and flat options together.")

self.colors = self.colors_enabled if color else self.colors_disabled

def render(self, session: Session) -> str:
result = self.render_preamble(session)

frame = self.preprocess(session.root_frame())
Expand All @@ -48,13 +57,16 @@ def render(self, session: Session):

self.root_frame = frame

result += self.render_frame(self.root_frame)
if self.flat:
result += self.render_frame_flat(self.root_frame)
else:
result += self.render_frame(self.root_frame)
result += "\n"

return result

# pylint: disable=W1401
def render_preamble(self, session: Session):
def render_preamble(self, session: Session) -> str:
lines = [
r"",
r" _ ._ __/__ _ _ _ _ _/_ ",
Expand Down Expand Up @@ -82,28 +94,7 @@ def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") -
or frame.total_self_time > 0.2 * self.root_frame.time
or frame in frame.group.exit_frames
):
if self.time == "percent_of_total":
percent = self.frame_proportion_of_total_time(frame) * 100
time_str = self._ansi_color_for_time(frame) + f"{percent:.0f}%" + self.colors.end
else:
time_str = self._ansi_color_for_time(frame) + f"{frame.time:.3f}" + self.colors.end

name_color = self._ansi_color_for_name(frame)

class_name = frame.class_name
if class_name:
name = f"{class_name}.{frame.function}"
else:
name = frame.function

result = "{indent}{time_str} {name_color}{name}{c.end} {c.faint}{code_position}{c.end}\n".format(
indent=indent,
time_str=time_str,
name_color=name_color,
name=name,
code_position=frame.code_position_short,
c=self.colors,
)
result = f"{indent}{self.frame_description(frame)}\n"

if self.unicode:
indents = {"├": "├─ ", "│": "│ ", "└": "└─ ", " ": " "}
Expand Down Expand Up @@ -137,11 +128,68 @@ def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") -

return result

def frame_proportion_of_total_time(self, frame: Frame):
return frame.time / self.root_frame.time
def render_frame_flat(self, frame: Frame) -> str:
def walk(frame: Frame):
frame_id_to_time[frame.identifier] = (
frame_id_to_time.get(frame.identifier, 0) + frame.total_self_time
)

frame_id_to_frame[frame.identifier] = frame

for child in frame.children:
walk(child)

frame_id_to_time: Dict[str, float] = {}
frame_id_to_frame: Dict[str, Frame] = {}

walk(frame)

id_time_pairs: List[Tuple[str, float]] = sorted(
frame_id_to_time.items(), key=(lambda item: item[1]), reverse=True
)

if not self.show_all:
# remove nodes that represent less than 0.1% of the total time
id_time_pairs = [
pair for pair in id_time_pairs if pair[1] / self.root_frame.time > 0.001
]

result = ""

for frame_id, self_time in id_time_pairs:
result += self.frame_description(frame_id_to_frame[frame_id], override_time=self_time)
result += "\n"

return result

def frame_description(self, frame: Frame, *, override_time: float | None = None) -> str:
time = override_time if override_time is not None else frame.time
time_color = self._ansi_color_for_time(time)

if self.time == "percent_of_total":
time_str = f"{self.frame_proportion_of_total_time(time) * 100:.1f}%"
else:
time_str = f"{time:.3f}"

value_str = f"{time_color}{time_str}{self.colors.end}"

class_name = frame.class_name
if class_name:
function_name = f"{class_name}.{frame.function}"
else:
function_name = frame.function
function_color = self._ansi_color_for_name(frame)
function_str = f"{function_color}{function_name}{self.colors.end}"

code_position_str = f"{self.colors.faint}{frame.code_position_short}{self.colors.end}"

return f"{value_str} {function_str} {code_position_str}"

def frame_proportion_of_total_time(self, time: float) -> float:
return time / self.root_frame.time

def _ansi_color_for_time(self, frame: Frame):
proportion_of_total = self.frame_proportion_of_total_time(frame)
def _ansi_color_for_time(self, time: float) -> str:
proportion_of_total = self.frame_proportion_of_total_time(time)

if proportion_of_total > 0.6:
return self.colors.red
Expand All @@ -152,7 +200,7 @@ def _ansi_color_for_time(self, frame: Frame):
else:
return self.colors.bright_green + self.colors.faint

def _ansi_color_for_name(self, frame: Frame):
def _ansi_color_for_name(self, frame: Frame) -> str:
if frame.is_application_code:
return self.colors.bg_dark_blue_255 + self.colors.white_255
else:
Expand Down