From 639fded4600411143a31ec215906b36461133d84 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 20:13:00 +0100 Subject: [PATCH 1/7] Fix a few issues with the misrendering of -c inputs and libraries in groups --- pyinstrument/frame.py | 9 ++++++ pyinstrument/processors.py | 3 -- pyinstrument/renderers/console.py | 52 ++++++++++++++++++++++++------- test/test_cmdline.py | 2 ++ test/test_processors.py | 2 -- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/pyinstrument/frame.py b/pyinstrument/frame.py index f1151ba0..572f5b45 100644 --- a/pyinstrument/frame.py +++ b/pyinstrument/frame.py @@ -199,6 +199,15 @@ def is_application_code(self) -> bool: if file_path.startswith("" or file_path == "": + # eval/exec is app code if started by a parent frame that is + # app code + if self.parent: + return self.parent.is_application_code + else: + # if this is the root frame, it must have been started + # with -c, so it's app code + return True else: # otherwise, this is probably some library-internal code gen return False diff --git a/pyinstrument/processors.py b/pyinstrument/processors.py index 6894b44b..89d02d4e 100644 --- a/pyinstrument/processors.py +++ b/pyinstrument/processors.py @@ -281,9 +281,6 @@ def is_runpy_frame(frame: Frame): result = result.children[0] - if not is_runpy_frame(result): - return frame - # at this point we know we've matched the first few frames of a command # line invocation. We'll trim some runpy frames and return. diff --git a/pyinstrument/renderers/console.py b/pyinstrument/renderers/console.py index 4249c1a8..c9f945a1 100644 --- a/pyinstrument/renderers/console.py +++ b/pyinstrument/renderers/console.py @@ -1,11 +1,12 @@ from __future__ import annotations +import re import time from typing import Any, Dict, List, Tuple import pyinstrument from pyinstrument import processors -from pyinstrument.frame import Frame +from pyinstrument.frame import Frame, FrameGroup from pyinstrument.renderers.base import FrameRenderer, ProcessorList, Renderer from pyinstrument.session import Session from pyinstrument.typing import LiteralStr @@ -98,12 +99,39 @@ def render_preamble(self, session: Session) -> str: return "\n".join(lines) - def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") -> str: - if not frame.group or ( + def should_render_frame(self, frame: Frame) -> bool: + if not frame.group: + return True + # Only render the root frame, or frames that are significant + if ( frame.group.root == frame or frame.total_self_time > 0.2 * self.root_frame.time or frame in frame.group.exit_frames ): + return True + return False + + def group_description(self, group: FrameGroup) -> str: + hidden_frames = [f for f in group.frames if not self.should_render_frame(f)] + libraries = self.libraries_for_frames(hidden_frames) + return "[{count} frames hidden] {c.faint}{libraries}{c.end}\n".format( + count=len(hidden_frames), + libraries=truncate(", ".join(libraries), 40), + c=self.colors, + ) + + def libraries_for_frames(self, frames: list[Frame]) -> list[str]: + libraries: list[str] = [] + for frame in frames: + if frame.file_path_short: + library = re.split(r"[\\/\.]", frame.file_path_short, maxsplit=1)[0] + + if library and library not in libraries: + libraries.append(library) + return libraries + + def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") -> str: + if self.should_render_frame(frame): result = f"{indent}{self.frame_description(frame)}\n" if self.unicode: @@ -112,12 +140,7 @@ def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") - indents = {"├": "|- ", "│": "| ", "└": "`- ", " ": " "} if frame.group and frame.group.root == frame: - result += "{indent}[{count} frames hidden] {c.faint}{libraries}{c.end}\n".format( - indent=child_indent + " ", - count=len(frame.group.frames), - libraries=truncate(", ".join(frame.group.libraries), 40), - c=self.colors, - ) + result += f"{child_indent} {self.group_description(frame.group)}" for key in indents: indents[key] = " " else: @@ -125,10 +148,15 @@ def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") - indents = {"├": "", "│": "", "└": "", " ": ""} if frame.children: - last_child = frame.children[-1] + children_to_be_rendered_indices = [ + i for i, f in enumerate(frame.children) if self.should_render_frame(f) + ] + last_rendered_child_index = ( + children_to_be_rendered_indices[-1] if children_to_be_rendered_indices else -1 + ) - for child in frame.children: - if child is not last_child: + for i, child in enumerate(frame.children): + if i < last_rendered_child_index: c_indent = child_indent + indents["├"] cc_indent = child_indent + indents["│"] else: diff --git a/test/test_cmdline.py b/test/test_cmdline.py index 425a3a5c..c787c1ae 100644 --- a/test/test_cmdline.py +++ b/test/test_cmdline.py @@ -106,6 +106,8 @@ def test_program_passed_as_string(self, pyinstrument_invocation, tmp_path: Path) # check the output output = subprocess.check_output([*pyinstrument_invocation, "-c", BUSY_WAIT_SCRIPT]) + print(output.decode("utf-8")) + assert "busy_wait" in str(output) assert "do_nothing" in str(output) diff --git a/test/test_processors.py b/test/test_processors.py index 4b0c30bd..2ccb3268 100644 --- a/test/test_processors.py +++ b/test/test_processors.py @@ -374,5 +374,3 @@ def test_group_library_frames_processor(monkeypatch): assert group_root.children[0].children[0] in group.frames assert group_root.children[0].children[0] in group.exit_frames assert group_root.children[0].children[0].children[0] not in group.frames - - assert group.libraries == ["django"] From 1f6467c4db2f6279ca5e1e6115b8ac905786d22d Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 20:13:38 +0100 Subject: [PATCH 2/7] Add a nox job for running the html renderer --- noxfile.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/noxfile.py b/noxfile.py index 1fa5d7ae..3855c42d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,3 +26,10 @@ def livedocs(session): session.env["UV_PRERELEASE"] = "allow" session.install("-e", ".[docs]") session.run("make", "-C", "docs", "livehtml") + + +@nox.session(python=False) +def htmldev(session): + with session.chdir("html_renderer"): + session.run("npm", "install") + session.run("npm", "run", "dev") From 74a056af13a660dd4dfc1973dd3d65e20df786c4 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 20:13:57 +0100 Subject: [PATCH 3/7] Remove unused method --- pyinstrument/frame.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/pyinstrument/frame.py b/pyinstrument/frame.py index 572f5b45..60d7650e 100644 --- a/pyinstrument/frame.py +++ b/pyinstrument/frame.py @@ -344,7 +344,6 @@ def __repr__(self): class FrameGroup: - _libraries: list[str] | None _frames: list[Frame] _exit_frames: list[Frame] | None @@ -353,25 +352,9 @@ def __init__(self, root: Frame): self.id = str(uuid.uuid4()) self._frames = [] self._exit_frames = None - self._libraries = None self.add_frame(root) - @property - def libraries(self) -> list[str]: - if self._libraries is None: - libraries: list[str] = [] - - for frame in self.frames: - if frame.file_path_short: - library = frame.file_path_short.split(os.sep)[0] - library, _ = os.path.splitext(library) - if library and library not in libraries: - libraries.append(library) - self._libraries = libraries - - return self._libraries - @property def frames(self) -> Sequence[Frame]: return tuple(self._frames) From d7005dbb0c77183ab15e0a3bab1150275b045b80 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 20:17:51 +0100 Subject: [PATCH 4/7] Add a context manager style of invoking pyinstrument --- examples/context_api.py | 42 +++++++++ pyinstrument/__init__.py | 3 +- pyinstrument/__main__.py | 2 +- pyinstrument/context_manager.py | 113 +++++++++++++++++++++++++ pyinstrument/profiler.py | 31 +++++-- pyinstrument/renderers/console.py | 2 +- pyinstrument/renderers/jsonrenderer.py | 2 +- pyinstrument/renderers/speedscope.py | 2 +- pyinstrument/session.py | 10 +-- pyinstrument/typing.py | 5 +- test/test_context_manager.py | 36 ++++++++ 11 files changed, 229 insertions(+), 19 deletions(-) create mode 100644 examples/context_api.py create mode 100644 pyinstrument/context_manager.py create mode 100644 test/test_context_manager.py diff --git a/examples/context_api.py b/examples/context_api.py new file mode 100644 index 00000000..03f66d77 --- /dev/null +++ b/examples/context_api.py @@ -0,0 +1,42 @@ +import os +import pprint +import sys + +pprint.pprint(sys.path) +import pyinstrument + + +@pyinstrument.profile() +def main(): + py_file_count = 0 + py_file_size = 0 + + print("Start.") + print("scanning home dir...") + + with pyinstrument.profile(): + for dir_path, dirnames, filenames in os.walk(os.path.expanduser("~/Music")): + for filename in filenames: + file_path = os.path.join(dir_path, filename) + _, ext = os.path.splitext(file_path) + if ext == ".py": + py_file_count += 1 + try: + py_file_size += os.stat(file_path).st_size + except: + pass + + print("There are {} python files on your system.".format(py_file_count)) + print("Total size: {} kB".format(py_file_size / 1024)) + + +class A: + @pyinstrument.profile() + def foo(self): + print("foo") + + +if __name__ == "__main__": + a = A() + a.foo() + main() diff --git a/pyinstrument/__init__.py b/pyinstrument/__init__.py index 78fddb6b..d9a7fced 100644 --- a/pyinstrument/__init__.py +++ b/pyinstrument/__init__.py @@ -1,8 +1,9 @@ import warnings +from pyinstrument.context_manager import profile from pyinstrument.profiler import Profiler -__all__ = ["__version__", "Profiler", "load_ipython_extension"] +__all__ = ["__version__", "Profiler", "load_ipython_extension", "profile"] __version__ = "4.6.2" # enable deprecation warnings diff --git a/pyinstrument/__main__.py b/pyinstrument/__main__.py index 92dcc23c..6c9bb455 100644 --- a/pyinstrument/__main__.py +++ b/pyinstrument/__main__.py @@ -375,7 +375,7 @@ def store_and_consume_remaining( use_timing_thread=options.use_timing_thread, ) - profiler.start() + profiler.start(target_description=f'Program: {" ".join(argv)}') try: sys.argv[:] = argv diff --git a/pyinstrument/context_manager.py b/pyinstrument/context_manager.py new file mode 100644 index 00000000..ec5cbf69 --- /dev/null +++ b/pyinstrument/context_manager.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import functools +import inspect +import sys +import typing + +from pyinstrument.profiler import AsyncMode, Profiler +from pyinstrument.renderers.base import Renderer +from pyinstrument.renderers.console import ConsoleRenderer +from pyinstrument.typing import Unpack +from pyinstrument.util import file_supports_color, file_supports_unicode + +CallableVar = typing.TypeVar("CallableVar", bound=typing.Callable) + + +class ProfileContextOptions(typing.TypedDict, total=False): + interval: float + async_mode: AsyncMode + use_timing_thread: bool | None + renderer: Renderer | None + target_description: str | None + + +class ProfileContext: + options: ProfileContextOptions + + def __init__( + self, + **kwargs: Unpack[ProfileContextOptions], + ): + profiler_options = { + "interval": kwargs.get("interval", 0.001), + # note- different async mode from the default, because it's easy + # to run multiple profilers at once using the decorator/context + # manager + "async_mode": kwargs.get("async_mode", "disabled"), + "use_timing_thread": kwargs.get("use_timing_thread", None), + } + self.profiler = Profiler(**profiler_options) + self.options = kwargs + + @typing.overload + def __call__(self, func: CallableVar, /) -> CallableVar: ... + @typing.overload + def __call__(self, /, **kwargs: Unpack[ProfileContextOptions]) -> "ProfileContext": ... + def __call__( + self, func: typing.Callable | None = None, /, **kwargs: Unpack[ProfileContextOptions] + ): + if func is not None: + + @functools.wraps(func) + def wrapper(*args, **kwargs): + target_description = self.options.get("target_description") + if target_description is None: + target_description = f"Function {func.__qualname__} at {func.__code__.co_filename}:{func.__code__.co_firstlineno}" + + with self(target_description=target_description): + return func(*args, **kwargs) + + return typing.cast(typing.Callable, wrapper) + else: + return ProfileContext(**{**self.options, **kwargs}) + + def __enter__(self): + if self.profiler.is_running: + raise RuntimeError( + "This profiler is already running - did you forget the brackets on pyinstrument.profile() ?" + ) + + caller_frame = inspect.currentframe().f_back # type: ignore + assert caller_frame is not None + target_description = self.options.get("target_description") + if target_description is None: + target_description = "Block at {}:{}".format( + caller_frame.f_code.co_filename, caller_frame.f_lineno + ) + + self.profiler.start( + caller_frame=caller_frame, + target_description=target_description, + ) + + def __exit__(self, exc_type, exc_value, traceback): + session = self.profiler.stop() + + renderer = self.options.get("renderer") + f = sys.stderr + + if renderer is None: + renderer = ConsoleRenderer( + color=file_supports_color(f), + unicode=file_supports_unicode(f), + ) + + f.write(renderer.render(session)) + + +class _Profile: + @typing.overload + def __call__(self, func: CallableVar, /) -> CallableVar: ... + @typing.overload + def __call__(self, /, **kwargs: Unpack[ProfileContextOptions]) -> "ProfileContext": ... + def __call__( + self, func: typing.Callable | None = None, /, **kwargs: Unpack[ProfileContextOptions] + ): + if func is not None: + return ProfileContext(**kwargs)(func) + else: + return ProfileContext(**kwargs) + + +profile = _Profile() diff --git a/pyinstrument/profiler.py b/pyinstrument/profiler.py index e4bc73a3..94eb7001 100644 --- a/pyinstrument/profiler.py +++ b/pyinstrument/profiler.py @@ -28,11 +28,13 @@ def __init__( start_time: float, start_process_time: float, start_call_stack: list[str], + target_description: str, ) -> None: self.start_time = start_time self.start_process_time = start_process_time self.start_call_stack = start_call_stack self.frame_records = [] + self.target_description = target_description AsyncMode = LiteralStr["enabled", "disabled", "strict"] @@ -111,7 +113,9 @@ def last_session(self) -> Session | None: """ return self._last_session - def start(self, caller_frame: types.FrameType | None = None): + def start( + self, caller_frame: types.FrameType | None = None, target_description: str | None = None + ): """ Instructs the profiler to start - to begin observing the program's execution and recording frames. @@ -131,11 +135,20 @@ def start(self, caller_frame: types.FrameType | None = None): if caller_frame is None: caller_frame = inspect.currentframe().f_back # type: ignore + if target_description is None: + if caller_frame is None: + target_description = "Profile at unknown location" + else: + target_description = "Profile at {}:{}".format( + caller_frame.f_code.co_filename, caller_frame.f_lineno + ) + try: self._active_session = ActiveProfilerSession( start_time=time.time(), start_process_time=process_time(), start_call_stack=build_call_stack(caller_frame, "initial", None), + target_description=target_description, ) use_async_context = self.async_mode != "disabled" @@ -168,16 +181,18 @@ def stop(self) -> Session: cpu_time = process_time() - self._active_session.start_process_time + active_session = self._active_session + self._active_session = None + session = Session( - frame_records=self._active_session.frame_records, - start_time=self._active_session.start_time, - duration=time.time() - self._active_session.start_time, - sample_count=len(self._active_session.frame_records), - program=" ".join(sys.argv), - start_call_stack=self._active_session.start_call_stack, + frame_records=active_session.frame_records, + start_time=active_session.start_time, + duration=time.time() - active_session.start_time, + sample_count=len(active_session.frame_records), + target_description=active_session.target_description, + start_call_stack=active_session.start_call_stack, cpu_time=cpu_time, ) - self._active_session = None if self.last_session is not None: # include the previous session's data too diff --git a/pyinstrument/renderers/console.py b/pyinstrument/renderers/console.py index c9f945a1..e4d13ae6 100644 --- a/pyinstrument/renderers/console.py +++ b/pyinstrument/renderers/console.py @@ -93,7 +93,7 @@ def render_preamble(self, session: Session) -> str: lines[2] += f" CPU time: {session.cpu_time:.3f}" lines.append("") - lines.append("Program: %s" % session.program) + lines.append(session.target_description) lines.append("") lines.append("") diff --git a/pyinstrument/renderers/jsonrenderer.py b/pyinstrument/renderers/jsonrenderer.py index ec3c6b3d..f87898db 100644 --- a/pyinstrument/renderers/jsonrenderer.py +++ b/pyinstrument/renderers/jsonrenderer.py @@ -68,7 +68,7 @@ def render(self, session: Session): property_decls.append('"start_time": %f' % session.start_time) property_decls.append('"duration": %f' % session.duration) property_decls.append('"sample_count": %d' % session.sample_count) - property_decls.append('"program": %s' % encode_str(session.program)) + property_decls.append('"target_description": %s' % encode_str(session.target_description)) property_decls.append('"cpu_time": %f' % session.cpu_time) property_decls.append('"root_frame": %s' % self.render_frame(frame)) diff --git a/pyinstrument/renderers/speedscope.py b/pyinstrument/renderers/speedscope.py index b164ab2e..3df1acd9 100644 --- a/pyinstrument/renderers/speedscope.py +++ b/pyinstrument/renderers/speedscope.py @@ -210,7 +210,7 @@ def render(self, session: Session): frame = self.preprocess(session.root_frame()) id_: str = time.strftime("%Y-%m-%dT%H-%M-%S", time.localtime(session.start_time)) - name: str = f"CPU profile for {session.program} at {id_}" + name: str = f"CPU profile for '{session.target_description}' at {id_}" sprofile_list: list[SpeedscopeProfile] = [ SpeedscopeProfile(name, self.render_frame(frame), session.duration) diff --git a/pyinstrument/session.py b/pyinstrument/session.py index 45f56ca3..493d17a9 100644 --- a/pyinstrument/session.py +++ b/pyinstrument/session.py @@ -26,7 +26,7 @@ def __init__( duration: float, sample_count: int, start_call_stack: list[str], - program: str, + target_description: str, cpu_time: float, ): """Session() @@ -40,7 +40,7 @@ def __init__( self.duration = duration self.sample_count = sample_count self.start_call_stack = start_call_stack - self.program = program + self.target_description = target_description self.cpu_time = cpu_time @staticmethod @@ -70,7 +70,7 @@ def to_json(self): "duration": self.duration, "sample_count": self.sample_count, "start_call_stack": self.start_call_stack, - "program": self.program, + "target_description": self.target_description, "cpu_time": self.cpu_time, } @@ -82,7 +82,7 @@ def from_json(json_dict: dict[str, Any]): duration=json_dict["duration"], sample_count=json_dict["sample_count"], start_call_stack=json_dict["start_call_stack"], - program=json_dict["program"], + target_description=json_dict["target_description"], cpu_time=json_dict["cpu_time"] or 0, ) @@ -107,7 +107,7 @@ def combine(session1: Session, session2: Session) -> Session: duration=session1.duration + session2.duration, sample_count=session1.sample_count + session2.sample_count, start_call_stack=session1.start_call_stack, - program=session1.program, + target_description=session1.target_description, cpu_time=session1.cpu_time + session2.cpu_time, ) diff --git a/pyinstrument/typing.py b/pyinstrument/typing.py index 52c5c9e8..afc1b9da 100644 --- a/pyinstrument/typing.py +++ b/pyinstrument/typing.py @@ -6,6 +6,7 @@ LiteralStr = typing_extensions.Literal assert_never = typing_extensions.assert_never + Unpack = typing_extensions.Unpack else: # a type, that when subscripted, returns `str`. class _LiteralStr: @@ -17,7 +18,9 @@ def __getitem__(self, values): def assert_never(value: Any): raise ValueError(value) + Unpack = Any + PathOrStr = Union[str, "os.PathLike[str]"] -__all__ = ["PathOrStr", "LiteralStr", "assert_never"] +__all__ = ["PathOrStr", "LiteralStr", "assert_never", "Unpack"] diff --git a/test/test_context_manager.py b/test/test_context_manager.py new file mode 100644 index 00000000..34c416fd --- /dev/null +++ b/test/test_context_manager.py @@ -0,0 +1,36 @@ +from test.fake_time_util import fake_time + +import pytest + +import pyinstrument +from pyinstrument.context_manager import ProfileContext + + +def test_profile_context_decorator(capfd): + with fake_time() as clock: + + @pyinstrument.profile + def my_function(): + clock.sleep(1.0) + + my_function() + + out, err = capfd.readouterr() + print(err) + assert "Function test_profile_context_decorator" in err + assert "1.000 my_function" in err + + +def test_profile_context_manager(capfd): + with fake_time() as clock: + with pyinstrument.profile(): + + def my_function(): + clock.sleep(1.0) + + my_function() + + out, err = capfd.readouterr() + print(err) + assert "Block at" in err + assert "1.000 my_function" in err From 3bf6af8a24e8b1d15039ebe38fe75b04e8e437e5 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 20:50:32 +0100 Subject: [PATCH 5/7] Add a short render option for use by the function/block style --- examples/context_api.py | 3 ++- pyinstrument/context_manager.py | 1 + pyinstrument/profiler.py | 6 +++++- pyinstrument/renderers/console.py | 36 +++++++++++++++++++++++-------- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/examples/context_api.py b/examples/context_api.py index 03f66d77..42348113 100644 --- a/examples/context_api.py +++ b/examples/context_api.py @@ -1,6 +1,7 @@ import os import pprint import sys +import time pprint.pprint(sys.path) import pyinstrument @@ -33,7 +34,7 @@ def main(): class A: @pyinstrument.profile() def foo(self): - print("foo") + time.sleep(0.1) if __name__ == "__main__": diff --git a/pyinstrument/context_manager.py b/pyinstrument/context_manager.py index ec5cbf69..9e933d8a 100644 --- a/pyinstrument/context_manager.py +++ b/pyinstrument/context_manager.py @@ -91,6 +91,7 @@ def __exit__(self, exc_type, exc_value, traceback): renderer = ConsoleRenderer( color=file_supports_color(f), unicode=file_supports_unicode(f), + short_mode=True, ) f.write(renderer.render(session)) diff --git a/pyinstrument/profiler.py b/pyinstrument/profiler.py index 94eb7001..128ede57 100644 --- a/pyinstrument/profiler.py +++ b/pyinstrument/profiler.py @@ -290,9 +290,10 @@ def print( time: LiteralStr["seconds", "percent_of_total"] = "seconds", flat: bool = False, flat_time: FlatTimeMode = "self", + short_mode: bool = False, processor_options: dict[str, Any] | None = None, ): - """print(file=sys.stdout, *, unicode=None, color=None, show_all=False, timeline=False, time='seconds', flat=False, flat_time='self', processor_options=None) + """print(file=sys.stdout, *, unicode=None, color=None, show_all=False, timeline=False, time='seconds', flat=False, flat_time='self', short_mode=False, processor_options=None) Print the captured profile to the console, as rendered by :class:`renderers.ConsoleRenderer` @@ -314,6 +315,7 @@ def print( time=time, flat=flat, flat_time=flat_time, + short_mode=short_mode, processor_options=processor_options, ), file=file, @@ -328,6 +330,7 @@ def output_text( time: LiteralStr["seconds", "percent_of_total"] = "seconds", flat: bool = False, flat_time: FlatTimeMode = "self", + short_mode: bool = False, processor_options: dict[str, Any] | None = None, ) -> str: """ @@ -344,6 +347,7 @@ def output_text( time=time, flat=flat, flat_time=flat_time, + short_mode=short_mode, processor_options=processor_options, ) ) diff --git a/pyinstrument/renderers/console.py b/pyinstrument/renderers/console.py index e4d13ae6..e4f35108 100644 --- a/pyinstrument/renderers/console.py +++ b/pyinstrument/renderers/console.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import textwrap import time from typing import Any, Dict, List, Tuple @@ -33,6 +34,7 @@ def __init__( flat: bool = False, time: LiteralStr["seconds", "percent_of_total"] = "seconds", flat_time: FlatTimeMode = "self", + short_mode: bool = False, ) -> None: """ :param unicode: Use unicode, like box-drawing characters in the output. @@ -40,6 +42,7 @@ def __init__( :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'`` :param flat_time: Show ``'self'`` time or ``'total'`` time (including children) in flat profile. + :param short_mode: Display a short version of the output. :param show_all: See :class:`FrameRenderer`. :param timeline: See :class:`FrameRenderer`. :param processor_options: See :class:`FrameRenderer`. @@ -51,6 +54,7 @@ def __init__( self.flat = flat self.time = time self.flat_time = flat_time + self.short_mode = short_mode if self.flat and self.timeline: raise Renderer.MisconfigurationError("Cannot use timeline and flat options together.") @@ -61,23 +65,37 @@ def render(self, session: Session) -> str: result = self.render_preamble(session) frame = self.preprocess(session.root_frame()) + indent = ". " if self.short_mode else "" if frame is None: - result += "No samples were recorded.\n\n" - return result + result += f"{indent}No samples were recorded.\n" + else: + self.root_frame = frame - self.root_frame = frame + if self.flat: + result += self.render_frame_flat(self.root_frame, indent=indent) + else: + result += self.render_frame(self.root_frame, indent=indent, child_indent=indent) - if self.flat: - result += self.render_frame_flat(self.root_frame) - else: - result += self.render_frame(self.root_frame) - result += "\n" + result += f"{indent}\n" + + if self.short_mode: + result += "." * 53 + "\n\n" return result # pylint: disable=W1401 def render_preamble(self, session: Session) -> str: + if self.short_mode: + return textwrap.dedent( + f""" + pyinstrument ........................................ + . + . {session.target_description} + . + """ + ) + lines = [ r"", r" _ ._ __/__ _ _ _ _ _/_ ", @@ -166,7 +184,7 @@ def render_frame(self, frame: Frame, indent: str = "", child_indent: str = "") - return result - def render_frame_flat(self, frame: Frame) -> str: + def render_frame_flat(self, frame: Frame, indent: str) -> str: def walk(frame: Frame): frame_id_to_time[frame.identifier] = ( frame_id_to_time.get(frame.identifier, 0) + frame.total_self_time From aae3933e5e32536137f8d8ba2fdd4ea3836f6cd7 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 21:25:24 +0100 Subject: [PATCH 6/7] Add docs for the context manager --- docs/guide.md | 27 ++++++++++++++++++++++++--- docs/reference.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 555645b7..d7343adf 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -70,8 +70,27 @@ spent. ## Profile a specific chunk of code -Pyinstrument also has a Python API. Just surround your code with Pyinstrument, -like this: +Pyinstrument also has a Python API. You can use a with-block, like this: + +```python +import pyinstrument + +with pyinstrument.profile(): + # code you want to profile +``` + +Or you can decorate a function/method, like this: + +```python +import pyinstrument + +@pyinstrument.profile() +def my_function(): + # code you want to profile + +``` + +There's also a lower-level API called Profiler, that's more flexible: ```python from pyinstrument import Profiler @@ -82,7 +101,6 @@ profiler.start() # code you want to profile profiler.stop() - profiler.print() ``` @@ -91,6 +109,8 @@ If you get "No samples were recorded." because your code executed in under value smaller than the default 0.001 (1 millisecond) like this: ```python +pyinstrument.profile(interval=0.0001) +# or, profiler = Profiler(interval=0.0001) ... ``` @@ -104,6 +124,7 @@ save this HTML for later, use {meth}`profiler.output_html() `. ## Profile code in Jupyter/IPython + Via [IPython magics](https://ipython.readthedocs.io/en/stable/interactive/magics.html), you can profile a line or a cell in IPython or Jupyter. diff --git a/docs/reference.md b/docs/reference.md index 3955c530..8ed3dd93 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -17,6 +17,49 @@ print a profile report to the console. The Python API is also available, for calling pyinstrument directly from Python and writing integrations with with other tools. +### The `profile` function + +For example: + +```python +with pyinstrument.profile(): + time.sleep(1) +``` + +This will print something like: + +``` +pyinstrument ........................................ +. +. Block at testfile.py:2 +. +. 1.000 testfile.py:1 +. └─ 1.000 sleep +. +..................................................... +``` + +You can also use it as a function/method decorator, like this: + +```python +@pyinstrument.profile() +def my_function(): + time.sleep(1) +``` + +```{eval-rst} +.. function:: pyinstrument.profile(*, interval=0.001, async_mode="disabled", \ + use_timing_thread=None, renderer=None, \ + target_description=None) + + Creates a context-manager or function decorator object, which profiles the given code and prints the output to stdout. + + The *interval*, *async_mode* and *use_timing_thread* parameters are passed through to the underlying :class:`pyinstrument.Profiler` object. + + You can pass a renderer to customise the output. By default, it uses a :class:`ConsoleRenderer ` with `short_mode` set. + +``` + ### The Profiler object ```{eval-rst} From 0fadef0f34d3821399036f7367edc2cefee8bc7c Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 1 Aug 2024 21:46:31 +0100 Subject: [PATCH 7/7] Ensure flaky runs n emulated tests --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d11d8e4d..2c06f949 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,6 +66,7 @@ jobs: run: | docker run --rm -v ${{ github.workspace }}:/ws:rw --workdir=/ws \ -e QEMU_EMULATED=1 \ + -e CI=1 \ ${{ env.img }} \ bash -exc '${{ env.py }} -m venv .env && \ source .env/bin/activate && \