├── .devcontainer ├── Dockerfile ├── devcontainer.json └── reinstall-cmake.sh ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── wheels.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── MAINTAINERS.md ├── MANIFEST.in ├── README.md ├── bin ├── build_js_bundle.py ├── bump_version.py ├── create_demo_data.py ├── create_sample_json.py └── serve_docs ├── docs ├── Makefile ├── _static │ └── preview │ │ ├── assets │ │ ├── django_template_render-CIkNzFIy.js │ │ ├── index-DqekWbme.js │ │ ├── index-paBu1EOJ.css │ │ ├── sympy_calculation-B9Pn_4RL.js │ │ └── wikipedia_article_word_count-CGt_pvsZ.js │ │ └── index.html ├── conf.py ├── guide.md ├── home.md ├── how-it-works.md ├── img │ ├── async-context.svg │ ├── screenshot.jpg │ └── timeline.png ├── index.md └── reference.md ├── examples ├── async_example_simple.py ├── async_experiment_1.py ├── async_experiment_3.py ├── busy_wait.py ├── c_sort.py ├── context_api.py ├── demo_scripts │ ├── django_example │ │ ├── .gitignore │ │ ├── README.md │ │ ├── django_example │ │ │ ├── __init__.py │ │ │ ├── settings.py │ │ │ ├── templates │ │ │ │ ├── template.html │ │ │ │ └── template_base.html │ │ │ ├── urls.py │ │ │ └── views.py │ │ └── manage.py │ ├── django_template_render.py │ ├── sympy_calculation.py │ └── wikipedia_article_word_count.py ├── falcon_hello.py ├── falcon_hello_file.py ├── flask_hello.py ├── litestar_hello.py ├── np_c_function.py └── tbhide_demo.py ├── html_renderer ├── .editorconfig ├── .gitignore ├── demo-data │ ├── django_template_render.json │ ├── sympy_calculation.json │ └── wikipedia_article_word_count.json ├── demo-src │ ├── DemoApp.svelte │ └── main.ts ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.svelte │ ├── app.css │ ├── assets │ │ └── favicon.png │ ├── components │ │ ├── CallStackView.svelte │ │ ├── CogIcon.svelte │ │ ├── Frame.svelte │ │ ├── Header.svelte │ │ ├── Logo.svelte │ │ ├── TimelineCanvasView.ts │ │ ├── TimelineCanvasViewTooltip.svelte │ │ ├── TimelineView.svelte │ │ ├── ViewOptions.svelte │ │ ├── ViewOptionsCallStack.svelte │ │ └── ViewOptionsTimeline.svelte │ ├── lib │ │ ├── CanvasView.ts │ │ ├── DevicePixelRatioObserver.ts │ │ ├── appState.ts │ │ ├── color.ts │ │ ├── dataTypes.ts │ │ ├── model │ │ │ ├── Frame.ts │ │ │ ├── FrameGroup.ts │ │ │ ├── Session.ts │ │ │ ├── frameOps.test.ts │ │ │ ├── frameOps.ts │ │ │ ├── modelUtil.ts │ │ │ └── processors.ts │ │ ├── settings.ts │ │ └── utils.ts │ ├── main.ts │ ├── types.d.ts │ └── vite-env.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── metrics ├── count_samples.py ├── frame_info.py ├── interrupt.py ├── multi_overhead.py ├── overflow.py └── overhead.py ├── noxfile.py ├── pyinstrument ├── __init__.py ├── __main__.py ├── context_manager.py ├── frame.py ├── frame_info.py ├── frame_ops.py ├── low_level │ ├── pyi_floatclock.c │ ├── pyi_floatclock.h │ ├── pyi_shared.h │ ├── pyi_timing_thread.c │ ├── pyi_timing_thread.h │ ├── pyi_timing_thread_python.py │ ├── stat_profile.c │ ├── stat_profile.pyi │ ├── stat_profile_python.py │ └── types.py ├── magic │ ├── __init__.py │ ├── _utils.py │ └── magic.py ├── middleware.py ├── processors.py ├── profiler.py ├── py.typed ├── renderers │ ├── __init__.py │ ├── base.py │ ├── console.py │ ├── html.py │ ├── html_resources │ │ ├── app.css │ │ └── app.js │ ├── jsonrenderer.py │ ├── pstatsrenderer.py │ ├── session.py │ └── speedscope.py ├── session.py ├── stack_sampler.py ├── typing.py ├── util.py └── vendor │ ├── __init__.py │ ├── appdirs.py │ ├── decorator.py │ └── keypath.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── conftest.py ├── fake_time_util.py ├── low_level ├── __init__.py ├── test_context.py ├── test_custom_timer.py ├── test_floatclock.py ├── test_frame_info.py ├── test_setstatprofile.py ├── test_threaded.py ├── test_timing_thread.py └── util.py ├── test_cmdline.py ├── test_cmdline_main.py ├── test_context_manager.py ├── test_ipython_magic.py ├── test_overflow.py ├── test_processors.py ├── test_profiler.py ├── test_profiler_async.py ├── test_pstats_renderer.py ├── test_renderers.py ├── test_stack_sampler.py ├── test_threading.py └── util.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/cpp/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Debian / Ubuntu version (use Debian 11, Ubuntu 18.04/22.04 on local arm64/Apple Silicon): debian-11, debian-10, ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 4 | ARG VARIANT="bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/cpp:0-${VARIANT} 6 | 7 | # [Optional] Install CMake version different from what base image has already installed. 8 | # CMake reinstall choices: none, 3.21.5, 3.22.2, or versions from https://cmake.org/download/ 9 | ARG REINSTALL_CMAKE_VERSION_FROM_SOURCE="none" 10 | 11 | # Optionally install the cmake for vcpkg 12 | COPY ./reinstall-cmake.sh /tmp/ 13 | RUN if [ "${REINSTALL_CMAKE_VERSION_FROM_SOURCE}" != "none" ]; then \ 14 | chmod +x /tmp/reinstall-cmake.sh && /tmp/reinstall-cmake.sh ${REINSTALL_CMAKE_VERSION_FROM_SOURCE}; \ 15 | fi \ 16 | && rm -f /tmp/reinstall-cmake.sh 17 | 18 | # [Optional] Uncomment this section to install additional vcpkg ports. 19 | # RUN su vscode -c "${VCPKG_ROOT}/vcpkg install " 20 | 21 | # [Optional] Uncomment this section to install additional packages. 22 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 23 | # && apt-get -y install --no-install-recommends 24 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/cpp 3 | { 4 | "name": "C++", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick an Debian / Ubuntu OS version: debian-11, debian-10, ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 8 | // Use Debian 11, Ubuntu 18.04 or Ubuntu 22.04 on local arm64/Apple Silicon 9 | "args": { "VARIANT": "ubuntu-22.04" } 10 | }, 11 | "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"], 12 | 13 | // Configure tool-specific properties. 14 | "customizations": { 15 | // Configure properties specific to VS Code. 16 | "vscode": { 17 | // Add the IDs of extensions you want installed when the container is created. 18 | "extensions": [ 19 | "benjamin-simmonds.pythoncpp-debug", 20 | "eamodio.gitlens", 21 | "ms-python.python", 22 | "ms-python.vscode-pylance", 23 | "ms-vscode.cmake-tools", 24 | "ms-vscode.cpptools", 25 | "samuelcolvin.jinjahtml", 26 | "xr0master.webstorm-intellij-darcula-theme" 27 | ] 28 | } 29 | }, 30 | 31 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 32 | // "forwardPorts": [], 33 | 34 | // Use 'postCreateCommand' to run commands after the container is created. 35 | // "postCreateCommand": "gcc -v", 36 | 37 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 38 | "remoteUser": "vscode", 39 | "features": { 40 | "git": "latest", 41 | "github-cli": "latest", 42 | "sshd": "latest", 43 | "node": "16", 44 | "python": "3.10" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.devcontainer/reinstall-cmake.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #------------------------------------------------------------------------------------------------------------- 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 5 | #------------------------------------------------------------------------------------------------------------- 6 | # 7 | set -e 8 | 9 | CMAKE_VERSION=${1:-"none"} 10 | 11 | if [ "${CMAKE_VERSION}" = "none" ]; then 12 | echo "No CMake version specified, skipping CMake reinstallation" 13 | exit 0 14 | fi 15 | 16 | # Cleanup temporary directory and associated files when exiting the script. 17 | cleanup() { 18 | EXIT_CODE=$? 19 | set +e 20 | if [[ -n "${TMP_DIR}" ]]; then 21 | echo "Executing cleanup of tmp files" 22 | rm -Rf "${TMP_DIR}" 23 | fi 24 | exit $EXIT_CODE 25 | } 26 | trap cleanup EXIT 27 | 28 | 29 | echo "Installing CMake..." 30 | apt-get -y purge --auto-remove cmake 31 | mkdir -p /opt/cmake 32 | 33 | architecture=$(dpkg --print-architecture) 34 | case "${architecture}" in 35 | arm64) 36 | ARCH=aarch64 ;; 37 | amd64) 38 | ARCH=x86_64 ;; 39 | *) 40 | echo "Unsupported architecture ${architecture}." 41 | exit 1 42 | ;; 43 | esac 44 | 45 | CMAKE_BINARY_NAME="cmake-${CMAKE_VERSION}-linux-${ARCH}.sh" 46 | CMAKE_CHECKSUM_NAME="cmake-${CMAKE_VERSION}-SHA-256.txt" 47 | TMP_DIR=$(mktemp -d -t cmake-XXXXXXXXXX) 48 | 49 | echo "${TMP_DIR}" 50 | cd "${TMP_DIR}" 51 | 52 | curl -sSL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/${CMAKE_BINARY_NAME}" -O 53 | curl -sSL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/${CMAKE_CHECKSUM_NAME}" -O 54 | 55 | sha256sum -c --ignore-missing "${CMAKE_CHECKSUM_NAME}" 56 | sh "${TMP_DIR}/${CMAKE_BINARY_NAME}" --prefix=/opt/cmake --skip-license 57 | 58 | ln -s /opt/cmake/bin/cmake /usr/local/bin/cmake 59 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [html_renderer/**] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | ignore: 8 | # Optional: Official actions have moving tags like v1; 9 | # if you use those, you don't need updates. 10 | - dependency-name: "actions/*" 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ main ] 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | # Checkout pull request HEAD commit instead of merge commit 14 | ref: ${{ github.event.pull_request.head.sha }} 15 | fetch-depth: 0 16 | - uses: actions/setup-python@v5 17 | - name: Install Python deps 18 | run: | 19 | pip install -r requirements-dev.txt 20 | - uses: pre-commit/action@v3.0.1 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | test: 25 | runs-on: ${{ matrix.os }} 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest, windows-latest, macos-latest] 29 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 30 | # also add an aarch64 test, just one python version 31 | include: 32 | - os: ubuntu-24.04-arm 33 | python-version: "3.13" 34 | fail-fast: false 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip setuptools wheel 47 | pip install -e '.[test]' 48 | 49 | - name: Test with pytest 50 | run: | 51 | pytest && pytest --only-ipython-magic 52 | -------------------------------------------------------------------------------- /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build wheels 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - v* 8 | 9 | pull_request: 10 | branches: [ main ] 11 | # only run on pull requests that change a C file or build system 12 | paths: 13 | - '**.c' 14 | - setup.py 15 | - setup.cfg 16 | - pyproject.toml 17 | - .github/workflows/wheels.yml 18 | 19 | jobs: 20 | build_wheels: 21 | name: Build wheels on ${{ matrix.archs }} for ${{ matrix.os }} 22 | runs-on: ${{ matrix.os }} 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest, windows-latest, macos-latest, ubuntu-24.04-arm] 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up QEMU 31 | if: ${{ matrix.archs == 'aarch64' }} 32 | uses: docker/setup-qemu-action@v3 33 | 34 | - uses: actions/setup-python@v5 35 | name: Install Python 36 | with: 37 | python-version: '3.8' 38 | 39 | - name: Build wheels 40 | uses: joerick/cibuildwheel@v3.0.0b1 41 | env: 42 | CIBW_SKIP: pp* 43 | CIBW_ARCHS_MACOS: auto universal2 44 | 45 | - uses: actions/upload-artifact@v4 46 | with: 47 | name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} 48 | path: ./wheelhouse/*.whl 49 | 50 | build_sdist: 51 | name: Build source distribution 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - uses: actions/setup-python@v5 57 | name: Install Python 58 | with: 59 | python-version: '3.8' 60 | 61 | - name: Check manifest 62 | run: pipx run check-manifest 63 | 64 | - name: Build sdist 65 | run: python setup.py sdist 66 | 67 | - uses: actions/upload-artifact@v4 68 | with: 69 | name: cibw-sdist 70 | path: dist/*.tar.gz 71 | 72 | upload_pypi: 73 | needs: [build_wheels, build_sdist] 74 | runs-on: ubuntu-latest 75 | # upload to PyPI on every tag starting with 'v' 76 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') 77 | environment: 78 | name: pypi 79 | url: https://pypi.org/p/pyinstrument 80 | permissions: 81 | id-token: write 82 | attestations: write 83 | 84 | steps: 85 | - uses: actions/download-artifact@v4 86 | with: 87 | # unpacks all CIBW artifacts into dist/ 88 | pattern: cibw-* 89 | path: dist 90 | merge-multiple: true 91 | 92 | - uses: pypa/gh-action-pypi-publish@release/v1 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # virtualenv 2 | env/ 3 | env2/ 4 | env3*/ 5 | .Python 6 | /env 7 | 8 | # python 9 | *.pyc 10 | __pycache__/ 11 | 12 | # C extensions 13 | *.so 14 | *.pyd 15 | 16 | # distribution 17 | dist/ 18 | *.egg-info/ 19 | build 20 | .eggs 21 | 22 | # testing 23 | .cache 24 | .pytest_cache 25 | 26 | # editor 27 | *.code-workspace 28 | .history 29 | .vscode 30 | .idea 31 | 32 | # docs 33 | docs/_build 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.13.2 12 | hooks: 13 | - id: isort 14 | name: isort (python) 15 | 16 | - repo: https://github.com/psf/black 17 | rev: 24.4.2 18 | hooks: 19 | - id: black 20 | language_version: python3 21 | 22 | - repo: https://github.com/codespell-project/codespell 23 | rev: v2.3.0 24 | hooks: 25 | - id: codespell 26 | exclude: "\\.(json)$|docs/_static/preview" 27 | args: 28 | - --ignore-words-list=vas 29 | 30 | - repo: https://github.com/RobertCraigie/pyright-python 31 | rev: v1.1.373 32 | hooks: 33 | - id: pyright 34 | additional_dependencies: 35 | - pytest 36 | - ipython == 8.12.3 37 | - django 38 | - flask 39 | - trio 40 | - flaky 41 | - numpy 42 | - nox 43 | - requests 44 | - greenlet 45 | - types-click 46 | - types-requests 47 | - falcon 48 | - litestar 49 | 50 | 51 | - repo: local 52 | hooks: 53 | - id: build 54 | name: build js bundle 55 | entry: bin/build_js_bundle.py --force 56 | files: html_renderer/.* 57 | language: node 58 | pass_filenames: false 59 | 60 | - repo: https://github.com/asottile/pyupgrade 61 | rev: v3.17.0 62 | hooks: 63 | - id: pyupgrade 64 | args: [--py37-plus] 65 | stages: [manual] 66 | exclude: ^pyinstrument/vendor/ 67 | 68 | exclude: ^pyinstrument/renderers/html_resources/app.js$|^pyinstrument/vendor 69 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: requirements-dev.txt 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2020, Joe Rickerby and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ``` 4 | bin/bump_version.py 5 | git push && git push --tags 6 | ``` 7 | 8 | Deployment to PyPI is performed in GitHub Actions. 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune ** 2 | 3 | graft pyinstrument 4 | graft test 5 | graft bin 6 | graft html_renderer 7 | prune html_renderer/node_modules 8 | prune html_renderer/dist 9 | 10 | include LICENSE README.md pyproject.toml setup.py setup.cfg noxfile.py requirements-dev.txt 11 | 12 | global-exclude __pycache__ *.py[cod] .* dist 13 | -------------------------------------------------------------------------------- /bin/build_js_bundle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | 9 | HTML_RENDERER_DIR = "html_renderer" 10 | JS_BUNDLE = "pyinstrument/renderers/html_resources/app.js" 11 | CSS_BUNDLE = "pyinstrument/renderers/html_resources/app.css" 12 | 13 | DOCS_PREVIEW_DIR = "docs/_static/preview" 14 | 15 | if __name__ == "__main__": 16 | # chdir to root of repo 17 | os.chdir(os.path.dirname(__file__)) 18 | os.chdir("..") 19 | 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument("--force", action="store_true", help="force a rebuild of the bundle") 22 | 23 | args = parser.parse_args() 24 | 25 | js_source_mtime = 0 26 | for dirpath, dirnames, filenames in os.walk(HTML_RENDERER_DIR): 27 | if "node_modules" in dirnames: 28 | dirnames.remove("node_modules") 29 | 30 | for filename in filenames: 31 | file = os.path.join(dirpath, filename) 32 | js_source_mtime = max(js_source_mtime, os.path.getmtime(file)) 33 | 34 | js_bundle_is_up_to_date = ( 35 | os.path.exists(JS_BUNDLE) and os.path.getmtime(JS_BUNDLE) >= js_source_mtime 36 | ) 37 | 38 | if js_bundle_is_up_to_date and not args.force: 39 | print("Bundle up-to-date") 40 | sys.exit(0) 41 | 42 | if subprocess.call("npm --version", shell=True) != 0: 43 | raise RuntimeError("npm is required to build the HTML renderer.") 44 | 45 | subprocess.check_call("npm ci", cwd=HTML_RENDERER_DIR, shell=True) 46 | subprocess.check_call("npm run build", cwd=HTML_RENDERER_DIR, shell=True) 47 | 48 | shutil.copyfile(HTML_RENDERER_DIR + "/dist/pyinstrument-html.iife.js", JS_BUNDLE) 49 | shutil.copyfile(HTML_RENDERER_DIR + "/dist/style.css", CSS_BUNDLE) 50 | 51 | subprocess.check_call("npm run build -- --mode preview", cwd=HTML_RENDERER_DIR, shell=True) 52 | shutil.rmtree(DOCS_PREVIEW_DIR, ignore_errors=True) 53 | shutil.copytree(HTML_RENDERER_DIR + "/dist", DOCS_PREVIEW_DIR) 54 | -------------------------------------------------------------------------------- /bin/bump_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | from __future__ import annotations 5 | 6 | import glob 7 | import os 8 | import subprocess 9 | import sys 10 | import urllib.parse 11 | from pathlib import Path 12 | 13 | import click 14 | from packaging.version import InvalidVersion, Version 15 | 16 | import pyinstrument 17 | 18 | config = [ 19 | # file path, version find/replace format 20 | ("setup.py", 'version="{}"'), 21 | ("pyinstrument/__init__.py", '__version__ = "{}"'), 22 | ("docs/conf.py", 'release = "{}"'), 23 | ] 24 | 25 | RED = "\u001b[31m" 26 | GREEN = "\u001b[32m" 27 | OFF = "\u001b[0m" 28 | 29 | 30 | @click.command() 31 | def bump_version() -> None: 32 | current_version = pyinstrument.__version__ 33 | 34 | try: 35 | commit_date_str = subprocess.run( 36 | [ 37 | "git", 38 | "show", 39 | "--no-patch", 40 | "--pretty=format:%ci", 41 | f"v{current_version}^{{commit}}", 42 | ], 43 | check=True, 44 | capture_output=True, 45 | encoding="utf8", 46 | ).stdout 47 | cd_date, cd_time, cd_tz = commit_date_str.split(" ") 48 | 49 | url_opts = urllib.parse.urlencode({"q": f"is:pr merged:>{cd_date}T{cd_time}{cd_tz}"}) 50 | url = f"https://github.com/joerick/pyinstrument/pulls?{url_opts}" 51 | 52 | print(f"PRs merged since last release:\n {url}") 53 | print() 54 | except subprocess.CalledProcessError as e: 55 | print(e) 56 | print("Failed to get previous version tag information.") 57 | 58 | git_changes_result = subprocess.run(["git diff-index --quiet HEAD --"], shell=True) 59 | repo_has_uncommitted_changes = git_changes_result.returncode != 0 60 | 61 | if repo_has_uncommitted_changes: 62 | print("error: Uncommitted changes detected.") 63 | sys.exit(1) 64 | 65 | # fmt: off 66 | print( 'Current version:', current_version) # noqa 67 | new_version = input(' New version: ').strip() 68 | # fmt: on 69 | 70 | try: 71 | Version(new_version) 72 | except InvalidVersion: 73 | print("error: This version doesn't conform to PEP440") 74 | print(" https://www.python.org/dev/peps/pep-0440/") 75 | sys.exit(1) 76 | 77 | actions = [] 78 | 79 | for path_pattern, version_pattern in config: 80 | paths = [Path(p) for p in glob.glob(path_pattern)] 81 | 82 | if not paths: 83 | print(f"error: Pattern {path_pattern} didn't match any files") 84 | sys.exit(1) 85 | 86 | find_pattern = version_pattern.format(current_version) 87 | replace_pattern = version_pattern.format(new_version) 88 | found_at_least_one_file_needing_update = False 89 | 90 | for path in paths: 91 | contents = path.read_text(encoding="utf8") 92 | if find_pattern in contents: 93 | found_at_least_one_file_needing_update = True 94 | actions.append( 95 | ( 96 | path, 97 | find_pattern, 98 | replace_pattern, 99 | ) 100 | ) 101 | 102 | if not found_at_least_one_file_needing_update: 103 | print(f'''error: Didn't find any occurrences of "{find_pattern}" in "{path_pattern}"''') 104 | sys.exit(1) 105 | 106 | print() 107 | print("Here's the plan:") 108 | print() 109 | 110 | for action in actions: 111 | path, find, replace = action 112 | print(f"{path} {RED}{find}{OFF} → {GREEN}{replace}{OFF}") 113 | 114 | print(f"Then commit, and tag as v{new_version}") 115 | 116 | answer = input("Proceed? [y/N] ").strip() 117 | 118 | if answer != "y": 119 | print("Aborted") 120 | sys.exit(1) 121 | 122 | for path, find, replace in actions: 123 | contents = path.read_text(encoding="utf8") 124 | contents = contents.replace(find, replace) 125 | path.write_text(contents, encoding="utf8") 126 | 127 | print("Files updated. If you want to update the changelog as part of this") 128 | print("commit, do that now.") 129 | print() 130 | 131 | while input('Type "done" to continue: ').strip().lower() != "done": 132 | pass 133 | 134 | subprocess.run( 135 | [ 136 | "git", 137 | "commit", 138 | "--all", 139 | f"--message=Bump version: v{new_version}", 140 | ], 141 | check=True, 142 | ) 143 | 144 | subprocess.run( 145 | [ 146 | "git", 147 | "tag", 148 | "--annotate", 149 | f"--message=v{new_version}", 150 | f"v{new_version}", 151 | ], 152 | check=True, 153 | ) 154 | 155 | print("Done.") 156 | 157 | 158 | if __name__ == "__main__": 159 | os.chdir(Path(__file__).parent.parent.resolve()) 160 | bump_version() 161 | -------------------------------------------------------------------------------- /bin/create_demo_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | from glob import glob 7 | from pathlib import Path 8 | 9 | ROOT_DIR = Path(__file__).parent.parent 10 | 11 | 12 | def main(): 13 | os.chdir(ROOT_DIR) 14 | for script in glob(str("examples/demo_scripts/*.py")): 15 | subprocess.run([sys.executable, "bin/create_sample_json.py", script], check=True) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /bin/create_sample_json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | 7 | ROOT_DIR = Path(__file__).parent.parent 8 | OUTPUT_DIR = ROOT_DIR / "html_renderer" / "demo-data" 9 | 10 | 11 | def main(): 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument("SCRIPT", help="The script to run to produce the sample", type=Path) 14 | args = parser.parse_args() 15 | script_file: Path = args.SCRIPT 16 | output_file = (OUTPUT_DIR / script_file.with_suffix("").name).with_suffix(".json") 17 | 18 | result = subprocess.run( 19 | [ 20 | "pyinstrument", 21 | "-o", 22 | str(output_file), 23 | "-r", 24 | "pyinstrument.renderers.html.JSONForHTMLRenderer", 25 | script_file, 26 | ] 27 | ) 28 | 29 | if result.returncode != 0: 30 | return result.returncode 31 | 32 | print(f"Sample JSON written to {output_file}") 33 | 34 | 35 | if __name__ == "__main__": 36 | sys.exit(main()) 37 | -------------------------------------------------------------------------------- /bin/serve_docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd "$(dirname "$0")" 4 | cd .. 5 | 6 | exec make -C docs livehtml 7 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile livehtml 16 | 17 | livehtml: 18 | sphinx-autobuild -a --watch ../pyinstrument "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | -------------------------------------------------------------------------------- /docs/_static/preview/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pyinstrument Demo 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "pyinstrument" 21 | copyright = "2021, Joe Rickerby" 22 | author = "Joe Rickerby" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "5.0.2" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["myst_parser", "sphinx.ext.autodoc", "sphinxcontrib.programoutput"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "furo" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ["_static"] 55 | 56 | # -- Autodoc setup 57 | 58 | autoclass_content = "both" 59 | autodoc_member_order = "bysource" 60 | autodoc_typehints = "description" 61 | autodoc_typehints_description_target = "documented" 62 | # napoleon_google_docstring = True 63 | # napoleon_use_rtype = False 64 | -------------------------------------------------------------------------------- /docs/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | html_meta: 3 | title: Home 4 | hide-toc: 5 | --- 6 | 7 | # pyinstrument 8 | 9 | ```{include} ../README.md 10 | --- 11 | relative-docs: docs/ 12 | relative-images: 13 | start-after: '' 14 | end-before: '' 15 | --- 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/img/async-context.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | APP ENTRYPOINT 18 | 19 | 20 | APP ENTRYPOINT 21 | 22 | 23 | RUN LOOP 24 | 25 | 26 | RUN LOOP 27 | 28 | 29 | TASK 1 30 | 31 | 32 | TASK 1 33 | 34 | 35 | TASK 1 36 | 37 | 38 | TASK 1 39 | 40 | 41 | TASK 2 42 | 43 | 44 | TASK 2 45 | 46 | 47 | PROFILER STARTED HERE 48 | 49 | 50 | PROFILER STARTED HERE 51 | 52 | 53 | TASK 2 54 | 55 | 56 | TASK 2 57 | 58 | 59 | A profiler started in an async task is scoped to that async context. 60 | 61 | 62 | When async tasks are created, they inherit the context from the caller. So starting a profiler before the run loop causes all async tasks to be profiled. 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/img/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/docs/img/screenshot.jpg -------------------------------------------------------------------------------- /docs/img/timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/docs/img/timeline.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | pyinstrument 2 | ============ 3 | 4 | ```{toctree} 5 | --- 6 | maxdepth: 2 7 | caption: "Contents" 8 | --- 9 | Home 10 | guide.md 11 | how-it-works.md 12 | reference.md 13 | GitHub 14 | ``` 15 | 16 | Indices and tables 17 | ------------------ 18 | 19 | * {ref}`genindex` 20 | * {ref}`search` 21 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Command line interface 4 | 5 | ``pyinstrument`` works just like ``python``, on the command line, so you can 6 | call your scripts like ``pyinstrument script.py`` or ``pyinstrument -m 7 | my_module``. 8 | 9 | When your script ends, or when you kill it with `ctrl-c`, pyinstrument will 10 | print a profile report to the console. 11 | 12 | ```{program-output} pyinstrument --help 13 | ``` 14 | 15 | ## Python API 16 | 17 | The Python API is also available, for calling pyinstrument directly from 18 | Python and writing integrations with with other tools. 19 | 20 | ### The `profile` function 21 | 22 | For example: 23 | 24 | ```python 25 | with pyinstrument.profile(): 26 | time.sleep(1) 27 | ``` 28 | 29 | This will print something like: 30 | 31 | ``` 32 | pyinstrument ........................................ 33 | . 34 | . Block at testfile.py:2 35 | . 36 | . 1.000 testfile.py:1 37 | . └─ 1.000 sleep 38 | . 39 | ..................................................... 40 | ``` 41 | 42 | You can also use it as a function/method decorator, like this: 43 | 44 | ```python 45 | @pyinstrument.profile() 46 | def my_function(): 47 | time.sleep(1) 48 | ``` 49 | 50 | ```{eval-rst} 51 | .. function:: pyinstrument.profile(*, interval=0.001, async_mode="disabled", \ 52 | use_timing_thread=None, renderer=None, \ 53 | target_description=None) 54 | 55 | Creates a context-manager or function decorator object, which profiles the given code and prints the output to stdout. 56 | 57 | The *interval*, *async_mode* and *use_timing_thread* parameters are passed through to the underlying :class:`pyinstrument.Profiler` object. 58 | 59 | You can pass a renderer to customise the output. By default, it uses a :class:`ConsoleRenderer ` with `short_mode` set. 60 | 61 | ``` 62 | 63 | ### The Profiler object 64 | 65 | ```{eval-rst} 66 | .. autoclass:: pyinstrument.Profiler 67 | :members: 68 | :special-members: __enter__ 69 | ``` 70 | 71 | ### Sessions 72 | 73 | ```{eval-rst} 74 | .. autoclass:: pyinstrument.session.Session 75 | :members: 76 | ``` 77 | 78 | ### Renderers 79 | 80 | Renderers transform a tree of {class}`Frame` objects into some form of output. 81 | 82 | Rendering has two steps: 83 | 84 | 1. First, the renderer will 'preprocess' the Frame tree, applying each processor in the ``processor`` property, in turn. 85 | 2. The resulting tree is rendered into the desired format. 86 | 87 | Therefore, rendering can be customised by changing the ``processors`` property. For example, you can disable time-aggregation (making the profile into a timeline) by removing {func}`aggregate_repeated_calls`. 88 | 89 | ```{eval-rst} 90 | .. autoclass:: pyinstrument.renderers.FrameRenderer 91 | :members: 92 | 93 | .. autoclass:: pyinstrument.renderers.ConsoleRenderer 94 | 95 | .. autoclass:: pyinstrument.renderers.HTMLRenderer 96 | 97 | .. autoclass:: pyinstrument.renderers.JSONRenderer 98 | 99 | .. autoclass:: pyinstrument.renderers.SpeedscopeRenderer 100 | ``` 101 | 102 | ### Processors 103 | 104 | ```{eval-rst} 105 | .. automodule:: pyinstrument.processors 106 | :members: 107 | ``` 108 | 109 | ### Internals notes 110 | 111 | Frames are recorded by the Profiler in a time-linear fashion. While profiling, 112 | the profiler builds a list of frame stacks, with the frames having in format: 113 | 114 | function_name filename function_line_number 115 | 116 | When profiling is complete, this list is turned into a tree structure of 117 | Frame objects. This tree contains all the information as gathered by the 118 | profiler, suitable for a flame render. 119 | 120 | #### Frame objects, the call tree, and processors 121 | 122 | The frames are assembled to a call tree by the profiler session. The 123 | time-linearity is retained at this stage. 124 | 125 | Before rendering, the call tree is then fed through a sequence of 'processors' 126 | to transform the tree for output. 127 | 128 | The most interesting is `aggregate_repeated_calls`, which combines different 129 | instances of function calls into the same frame. This is intuitive as a 130 | summary of where time was spent during execution. 131 | 132 | The rest of the processors focus on removing or hiding irrelevant Frames 133 | from the output. 134 | 135 | #### Self time frames vs. frame.self_time 136 | 137 | Self time nodes exist to record time spent in a node, but not in its children. 138 | But normal frame objects can have self_time too. Why? frame.self_time is used 139 | to store the self_time of any nodes that were removed during processing. 140 | -------------------------------------------------------------------------------- /examples/async_example_simple.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pyinstrument import Profiler 4 | 5 | 6 | async def main(): 7 | p = Profiler() 8 | with p: 9 | print("Hello ...") 10 | await asyncio.sleep(1) 11 | print("... World!") 12 | p.print() 13 | 14 | 15 | asyncio.run(main()) 16 | -------------------------------------------------------------------------------- /examples/async_experiment_1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | import pyinstrument 5 | 6 | 7 | def do_nothing(): 8 | pass 9 | 10 | 11 | def busy_wait(duration): 12 | end_time = time.time() + duration 13 | 14 | while time.time() < end_time: 15 | do_nothing() 16 | 17 | 18 | async def say(what, when, profile=False): 19 | if profile: 20 | p = pyinstrument.Profiler() 21 | p.start() 22 | else: 23 | p = None 24 | 25 | busy_wait(0.1) 26 | sleep_start = time.time() 27 | await asyncio.sleep(when) 28 | print(f"slept for {time.time() - sleep_start:.3f} seconds") 29 | busy_wait(0.1) 30 | 31 | print(what) 32 | if p: 33 | p.stop() 34 | p.print(show_all=True) 35 | 36 | 37 | loop = asyncio.get_event_loop() 38 | 39 | loop.create_task(say("first hello", 2, profile=True)) 40 | loop.create_task(say("second hello", 1, profile=True)) 41 | loop.create_task(say("third hello", 3, profile=True)) 42 | 43 | loop.run_forever() 44 | loop.close() 45 | -------------------------------------------------------------------------------- /examples/async_experiment_3.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | import trio 5 | 6 | import pyinstrument 7 | 8 | 9 | def do_nothing(): 10 | pass 11 | 12 | 13 | def busy_wait(duration): 14 | end_time = time.time() + duration 15 | 16 | while time.time() < end_time: 17 | do_nothing() 18 | 19 | 20 | async def say(what, when, profile=False): 21 | if profile: 22 | p = pyinstrument.Profiler() 23 | p.start() 24 | else: 25 | p = None 26 | 27 | busy_wait(0.1) 28 | sleep_start = time.time() 29 | await trio.sleep(when) 30 | print(f"slept for {time.time() - sleep_start:.3f} seconds") 31 | busy_wait(0.1) 32 | 33 | print(what) 34 | if p: 35 | p.stop() 36 | p.print(show_all=True) 37 | 38 | 39 | async def task(): 40 | async with trio.open_nursery() as nursery: 41 | nursery.start_soon(say, "first hello", 2, True) 42 | nursery.start_soon(say, "second hello", 1, True) 43 | nursery.start_soon(say, "third hello", 3, True) 44 | 45 | 46 | trio.run(task) 47 | -------------------------------------------------------------------------------- /examples/busy_wait.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def function_1(): 5 | pass 6 | 7 | 8 | def function_2(): 9 | pass 10 | 11 | 12 | def main(): 13 | start_time = time.time() 14 | 15 | while time.time() < start_time + 0.25: 16 | function_1() 17 | function_2() 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /examples/c_sort.py: -------------------------------------------------------------------------------- 1 | """ 2 | list.sort is interesting in that it calls a C function, that calls back to a 3 | Python function. In an ideal world, we'd be able to record the time inside the 4 | Python function _inside_ list.sort, but it's not possible currently, due to 5 | the way that Python records frame objects. 6 | 7 | Perhaps one day we could add some functionality to pyinstrument_cext to keep 8 | a parallel stack containing both C and Python frames. But for now, this is 9 | fine. 10 | """ 11 | 12 | import sys 13 | import time 14 | 15 | import numpy as np 16 | 17 | arr = np.random.randint(0, 10, 10) 18 | 19 | # def print_profiler(frame, event, arg): 20 | # if event.startswith('c_'): 21 | # print(event, arg, getattr(arg, '__qualname__', arg.__name__), arg.__module__) 22 | # else: 23 | # print(event, frame.f_code.co_name) 24 | 25 | # sys.setprofile(print_profiler) 26 | 27 | 28 | def slow_key(el): 29 | time.sleep(0.01) 30 | return 0 31 | 32 | 33 | for i in range(10): 34 | list(arr).sort(key=slow_key) 35 | 36 | # sys.setprofile(None) 37 | -------------------------------------------------------------------------------- /examples/context_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pprint 3 | import sys 4 | import time 5 | 6 | pprint.pprint(sys.path) 7 | import pyinstrument 8 | 9 | 10 | @pyinstrument.profile() 11 | def main(): 12 | py_file_count = 0 13 | py_file_size = 0 14 | 15 | print("Start.") 16 | print("scanning home dir...") 17 | 18 | with pyinstrument.profile(): 19 | for dir_path, dirnames, filenames in os.walk(os.path.expanduser("~/Music")): 20 | for filename in filenames: 21 | file_path = os.path.join(dir_path, filename) 22 | _, ext = os.path.splitext(file_path) 23 | if ext == ".py": 24 | py_file_count += 1 25 | try: 26 | py_file_size += os.stat(file_path).st_size 27 | except: 28 | pass 29 | 30 | print("There are {} python files on your system.".format(py_file_count)) 31 | print("Total size: {} kB".format(py_file_size / 1024)) 32 | 33 | 34 | class A: 35 | @pyinstrument.profile() 36 | def foo(self): 37 | time.sleep(0.1) 38 | 39 | 40 | if __name__ == "__main__": 41 | a = A() 42 | a.foo() 43 | main() 44 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/README.md: -------------------------------------------------------------------------------- 1 | 2 | This is a simple simple test rig to develop pyinstrument's Django middleware 3 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/examples/demo_scripts/django_example/django_example/__init__.py -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 9 | } 10 | } 11 | 12 | DEBUG = True 13 | TEMPLATE_DEBUG = True 14 | 15 | SECRET_KEY = "qg7_r+b@)(--as*(4ls$j$$(9i(pl_@y$g0j0r+!=@&$he(+o%" 16 | 17 | ROOT_URLCONF = "django_example.urls" 18 | 19 | INSTALLED_APPS = ( 20 | "django_example", 21 | "django.contrib.admin", 22 | "django.contrib.contenttypes", 23 | "django.contrib.auth", 24 | "django.contrib.sessions", 25 | "django.contrib.messages", 26 | ) 27 | 28 | MIDDLEWARE = ( 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.middleware.common.CommonMiddleware", 31 | "django.middleware.csrf.CsrfViewMiddleware", 32 | "django.contrib.auth.middleware.AuthenticationMiddleware", 33 | "django.contrib.messages.middleware.MessageMiddleware", 34 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 35 | "pyinstrument.middleware.ProfilerMiddleware", 36 | ) 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "APP_DIRS": True, 42 | "OPTIONS": { 43 | "context_processors": [ 44 | "django.template.context_processors.debug", 45 | "django.template.context_processors.request", 46 | "django.contrib.auth.context_processors.auth", 47 | "django.contrib.messages.context_processors.messages", 48 | "django.template.context_processors.i18n", 49 | "django.template.context_processors.media", 50 | "django.template.context_processors.csrf", 51 | "django.template.context_processors.tz", 52 | "django.template.context_processors.static", 53 | ], 54 | }, 55 | }, 56 | ] 57 | 58 | 59 | def custom_show_pyinstrument(request): 60 | return request.user.is_superuser 61 | 62 | 63 | PYINSTRUMENT_SHOW_CALLBACK = "%s.custom_show_pyinstrument" % __name__ 64 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/templates/template.html: -------------------------------------------------------------------------------- 1 | {% extends "template_base.html" %} 2 | 3 | {% block content %} 4 | {% spaceless %} 5 | something 6 | {% endspaceless %} 7 | {% endblock content %} 8 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/templates/template_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block content %} 6 | {% endblock %} 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path(r"^$", views.hello_world), 9 | ] 10 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/django_example/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.http import HttpResponse 4 | 5 | 6 | def hello_world(request): 7 | # do some useless work to delay this call a bit 8 | y = 1 9 | for x in range(1, 10000): 10 | y *= x 11 | time.sleep(0.1) 12 | 13 | return HttpResponse("Hello, world!") # type: ignore 14 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /examples/demo_scripts/django_template_render.py: -------------------------------------------------------------------------------- 1 | import os 2 | from optparse import OptionParser 3 | 4 | try: 5 | import django 6 | except ImportError: 7 | print("This example requires Django.") 8 | print("Install using `pip install Django`.") 9 | exit(1) 10 | 11 | import django.conf 12 | import django.template.loader 13 | 14 | 15 | def main(): 16 | parser = OptionParser() 17 | parser.add_option( 18 | "-i", 19 | "--iterations", 20 | dest="iterations", 21 | action="store", 22 | type="int", 23 | help="number of template render calls to make", 24 | default=200, 25 | ) 26 | options, _ = parser.parse_args() 27 | 28 | os.chdir(os.path.dirname(__file__)) 29 | 30 | django.conf.settings.configure( 31 | INSTALLED_APPS=(), 32 | TEMPLATES=[ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": ["./django_example/django_example/templates"], 36 | } 37 | ], 38 | ) 39 | django.setup() 40 | 41 | render_templates(options.iterations) 42 | 43 | 44 | def render_templates(iterations: int): 45 | for _ in range(0, iterations): 46 | django.template.loader.render_to_string("template.html") 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /examples/demo_scripts/sympy_calculation.py: -------------------------------------------------------------------------------- 1 | # All right, here is a reproducer (sympy 1.12, pyinstrument 4.5.3, Python 3.11.5). 2 | # With python sympy_instrument.py, prints This took 0:00:00.636278 3 | # With pyinstrument sympy_instrument.py, prints This took 0:00:12.355938 4 | 5 | from datetime import datetime 6 | 7 | from sympy import FF, Poly, Rational, symbols, sympify # type: ignore 8 | 9 | 10 | def do_thing(): 11 | # Some elliptic curve crypto stuff that is not important 12 | field = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF 13 | params = { 14 | "a": 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC, 15 | "b": 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B, 16 | } 17 | k = FF(field) 18 | expr = sympify(f"3*b - b3", evaluate=False) 19 | for curve_param, value in params.items(): 20 | expr = expr.subs(curve_param, k(value)) 21 | param = str(expr.free_symbols.pop()) 22 | 23 | def resolve(expression, k): 24 | if not expression.args: 25 | return expression 26 | args = [] 27 | for arg in expression.args: 28 | if isinstance(arg, Rational): 29 | a = arg.p 30 | b = arg.q 31 | res = k(a) / k(b) 32 | else: 33 | res = resolve(arg, k) 34 | args.append(res) 35 | return expression.func(*args) 36 | 37 | expr = resolve(expr, k) 38 | poly = Poly(expr, symbols(param), domain=k) 39 | roots = poly.ground_roots() 40 | for root in roots: 41 | params[param] = int(root) 42 | break 43 | 44 | 45 | if __name__ == "__main__": 46 | start = datetime.now() 47 | for _ in range(1000): 48 | do_thing() 49 | end = datetime.now() 50 | print("This took", end - start) 51 | -------------------------------------------------------------------------------- /examples/demo_scripts/wikipedia_article_word_count.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | try: 4 | from urllib.request import urlopen 5 | except ImportError: 6 | from urllib2 import urlopen # type: ignore 7 | 8 | import collections 9 | import operator 10 | import sys 11 | 12 | WIKIPEDIA_ARTICLE_API_URL = "https://en.wikipedia.org/w/api.php?action=query&titles=Spoon&prop=revisions&rvprop=content&format=json" 13 | 14 | 15 | def download(): 16 | return urlopen(WIKIPEDIA_ARTICLE_API_URL).read() 17 | 18 | 19 | def parse(json_data): 20 | return json.loads(json_data) 21 | 22 | 23 | def most_common_words(page): 24 | word_occurences = collections.defaultdict(int) 25 | 26 | for revision in page["revisions"]: 27 | article = revision["*"] 28 | 29 | for word in article.split(): 30 | if len(word) < 2: 31 | continue 32 | word_occurences[word] += 1 33 | 34 | word_list = sorted(word_occurences.items(), key=operator.itemgetter(1), reverse=True) 35 | 36 | return word_list[0:5] 37 | 38 | 39 | def main(): 40 | data = parse(download()) 41 | page = list(data["query"]["pages"].values())[0] 42 | 43 | sys.stderr.write("This most common words were %s\n" % most_common_words(page)) 44 | 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /examples/falcon_hello.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from pyinstrument import Profiler 4 | 5 | try: 6 | import falcon 7 | 8 | PROFILING = True # Use environment variable for setting it 9 | except ImportError: 10 | print("This example requires falcon.") 11 | print("Install using `pip install falcon`.") 12 | exit(1) 13 | 14 | 15 | class ProfilerMiddleware: 16 | def __init__(self, interval=0.01): 17 | self.profiler = Profiler(interval=interval) 18 | 19 | def process_request(self, req, resp): 20 | self.profiler.start() 21 | 22 | def process_response(self, req, resp, resource, req_succeeded): 23 | self.profiler.stop() 24 | self.profiler.open_in_browser() # Autoloads the file in default browser 25 | 26 | 27 | class HelloResource: 28 | def on_get(self, req, resp): 29 | time.sleep(1) 30 | resp.media = "hello" 31 | 32 | 33 | app = falcon.App() 34 | if PROFILING: 35 | app.add_middleware(ProfilerMiddleware()) 36 | app.add_route("/", HelloResource()) 37 | -------------------------------------------------------------------------------- /examples/falcon_hello_file.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from pyinstrument import Profiler 5 | 6 | try: 7 | import falcon 8 | 9 | PROFILING = True # Use environment variable for setting it 10 | except ImportError: 11 | print("This example requires falcon.") 12 | print("Install using `pip install falcon`.") 13 | exit(1) 14 | 15 | 16 | class ProfilerMiddleware: 17 | filename = "pyinstrument-profile" 18 | 19 | def __init__(self, interval=0.01): 20 | self.profiler = Profiler(interval=interval) 21 | 22 | def process_request(self, req, resp): 23 | self.profiler.start() 24 | 25 | def process_response(self, req, resp, resource, req_succeeded): 26 | self.profiler.stop() 27 | filename = f"{self.filename}-{datetime.now().strftime('%m%d%Y-%H%M%S')}.html" 28 | with open(filename, "w") as file: 29 | file.write(self.profiler.output_html()) 30 | 31 | 32 | class HelloResource: 33 | def on_get(self, req, resp): 34 | time.sleep(1) 35 | resp.media = "hello" 36 | 37 | 38 | app = falcon.App() 39 | if PROFILING: 40 | app.add_middleware(ProfilerMiddleware()) 41 | app.add_route("/", HelloResource()) 42 | -------------------------------------------------------------------------------- /examples/flask_hello.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from pyinstrument import Profiler 4 | 5 | try: 6 | from flask import Flask, g, make_response, request 7 | except ImportError: 8 | print("This example requires Flask.") 9 | print("Install using `pip install flask`.") 10 | exit(1) 11 | 12 | app = Flask(__name__) 13 | 14 | 15 | @app.before_request 16 | def before_request(): 17 | if "profile" in request.args: 18 | g.profiler = Profiler() 19 | g.profiler.start() 20 | 21 | 22 | @app.after_request 23 | def after_request(response): 24 | if not hasattr(g, "profiler"): 25 | return response 26 | g.profiler.stop() 27 | output_html = g.profiler.output_html() 28 | return make_response(output_html) 29 | 30 | 31 | @app.route("/") 32 | def hello_world(): 33 | return "Hello, World!" 34 | 35 | 36 | @app.route("/sleep") 37 | def sleep(): 38 | time.sleep(0.1) 39 | return "Good morning!" 40 | 41 | 42 | @app.route("/dosomething") 43 | def do_something(): 44 | import requests 45 | 46 | requests.get("http://google.com") 47 | return "Google says hello!" 48 | -------------------------------------------------------------------------------- /examples/litestar_hello.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | 5 | from litestar import Litestar, get 6 | from litestar.middleware import MiddlewareProtocol 7 | from litestar.types import ASGIApp, Message, Receive, Scope, Send 8 | 9 | from pyinstrument import Profiler 10 | 11 | 12 | class ProfilingMiddleware(MiddlewareProtocol): 13 | def __init__(self, app: ASGIApp) -> None: 14 | super().__init__(app) # type: ignore 15 | self.app = app 16 | 17 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 18 | profiler = Profiler(interval=0.001, async_mode="enabled") 19 | profiler.start() 20 | profile_html: str | None = None 21 | 22 | async def send_wrapper(message: Message) -> None: 23 | if message["type"] == "http.response.start": 24 | profiler.stop() 25 | nonlocal profile_html 26 | profile_html = profiler.output_html() 27 | message["headers"] = [ 28 | (b"content-type", b"text/html; charset=utf-8"), 29 | (b"content-length", str(len(profile_html)).encode()), 30 | ] 31 | elif message["type"] == "http.response.body": 32 | assert profile_html is not None 33 | message["body"] = profile_html.encode() 34 | await send(message) 35 | 36 | await self.app(scope, receive, send_wrapper) 37 | 38 | 39 | @get("/") 40 | async def index() -> str: 41 | await sleep(1) 42 | return "Hello, world!" 43 | 44 | 45 | app = Litestar( 46 | route_handlers=[index], 47 | middleware=[ProfilingMiddleware], 48 | ) 49 | -------------------------------------------------------------------------------- /examples/np_c_function.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import numpy as np 4 | 5 | arr = np.random.randint(0, 10000, 10000) 6 | 7 | # def print_profiler(frame, event, arg): 8 | # print(event, arg, getattr(arg, '__qualname__', arg.__name__), arg.__module__, dir(arg)) 9 | 10 | # sys.setprofile(print_profiler) 11 | 12 | for i in range(10000): 13 | arr.cumsum() 14 | 15 | # sys.setprofile(None) 16 | -------------------------------------------------------------------------------- /examples/tbhide_demo.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def D(): 5 | time.sleep(0.7) 6 | 7 | 8 | def C(): 9 | __tracebackhide__ = True 10 | time.sleep(0.1) 11 | D() 12 | 13 | 14 | def B(): 15 | __tracebackhide__ = True 16 | time.sleep(0.1) 17 | C() 18 | 19 | 20 | def A(): 21 | time.sleep(0.1) 22 | B() 23 | 24 | 25 | A() 26 | -------------------------------------------------------------------------------- /html_renderer/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.svelte] 13 | indent_size = 2 14 | 15 | [*.html] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /html_renderer/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | /stats.html 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /html_renderer/demo-src/DemoApp.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
48 |
49 |
50 |
51 | Choose a demo profile: 52 | 57 |
58 |
59 |
60 | {#if loading} 61 |
Loading...
62 | {:else if error} 63 |
Error loading file: {error.message}
64 | {/if} 65 | 66 |
67 |
68 |
69 | 70 | 112 | -------------------------------------------------------------------------------- /html_renderer/demo-src/main.ts: -------------------------------------------------------------------------------- 1 | import DemoApp from "./DemoApp.svelte"; 2 | 3 | new DemoApp({ 4 | target: document.body, 5 | }); 6 | -------------------------------------------------------------------------------- /html_renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pyinstrument Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /html_renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json", 11 | "test": "vitest" 12 | }, 13 | "devDependencies": { 14 | "@sveltejs/vite-plugin-svelte": "^3.1.1", 15 | "@tsconfig/svelte": "^5.0.4", 16 | "rollup-plugin-visualizer": "^5.12.0", 17 | "sass": "^1.77.8", 18 | "svelte": "^4.2.18", 19 | "svelte-check": "^3.8.5", 20 | "svelte-preprocess": "^6.0.2", 21 | "tslib": "^2.6.3", 22 | "typescript": "^5.5.4", 23 | "vite": "^5.3.5", 24 | "vitest": "^2.0.5" 25 | }, 26 | "dependencies": { 27 | "svelte-persisted-store": "^0.11.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /html_renderer/src/App.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 |
52 |
53 |
54 |
55 |
56 | {#if !session.rootFrame} 57 |
58 |
59 |
60 | No samples recorded. 61 |
62 |
63 | {:else if $viewOptions.viewMode === 'call-stack'} 64 | 65 | {:else if $viewOptions.viewMode === 'timeline'} 66 | 67 | {:else} 68 |
69 | Unknown view mode: {$viewOptions.viewMode} 70 |
71 | {/if} 72 |
73 |
74 | 75 | 76 | 95 | -------------------------------------------------------------------------------- /html_renderer/src/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background-color: #303538; 3 | color: white; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | .margins { 9 | padding: 0 30px; 10 | } 11 | label { 12 | user-select: none; 13 | * { 14 | user-select: initial; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /html_renderer/src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/html_renderer/src/assets/favicon.png -------------------------------------------------------------------------------- /html_renderer/src/components/CogIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html_renderer/src/components/Header.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 |
23 |
24 | 27 |
28 |
29 | {@html htmlForStringWithWBRAtSlashes(session.target_description)} 30 |
31 |
32 |
33 | Recorded: 34 | {startTime} 35 |
36 |
37 |
38 | Samples: 39 | {session.sampleCount} 40 |
41 |
42 | CPU utilization: 43 | {(cpuUtilisation * 100).toFixed(0)}% 44 |
45 |
46 |
47 |
48 | View: 49 | 53 | 57 |
58 |
59 |
60 | 64 | {#if viewOptionsVisible} 65 | viewOptionsVisible = false}/> 66 | {/if} 67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 | 176 | -------------------------------------------------------------------------------- /html_renderer/src/components/Logo.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /html_renderer/src/components/TimelineCanvasViewTooltip.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 54 | 55 |
57 |
{f.name}
58 | {#if timeMode == 'both'} 59 |
time
60 |
61 |
{@html formatTime(f.time)}
62 | {#if (f.selfTime / f.time) > 1e-3 } 63 |
self
64 |
{@html formatTime (f.selfTime)}
65 | {/if} 66 |
67 | {:else} 68 |
{timeMode == 'self' ? 'self' : 'time'}
69 |
{@html formatTime(f.time)}
70 | {/if} 71 |
loc
72 |
73 |
74 | {@html locationHTML} 75 |
76 |
77 | 78 | 134 | -------------------------------------------------------------------------------- /html_renderer/src/components/TimelineView.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 | 42 |
43 | 44 | 55 | -------------------------------------------------------------------------------- /html_renderer/src/components/ViewOptions.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
45 |
46 |
{title}
47 |
48 | {#if $viewOptions.viewMode === "call-stack"} 49 | 50 | {:else if $viewOptions.viewMode === "timeline"} 51 | 52 | {/if} 53 |
54 |
55 |
56 | 57 | 91 | -------------------------------------------------------------------------------- /html_renderer/src/components/ViewOptionsTimeline.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 |
10 |
Remove frames
11 |
12 | 13 |
14 | 19 | 20 |
21 | 22 |
23 | 28 | 31 |
32 | 33 |
34 | 39 | 40 |
41 |
42 |
43 |
44 | 45 | 50 | -------------------------------------------------------------------------------- /html_renderer/src/lib/CanvasView.ts: -------------------------------------------------------------------------------- 1 | import DevicePixelRatioObserver from "./DevicePixelRatioObserver" 2 | 3 | export default abstract class CanvasView { 4 | canvas: HTMLCanvasElement 5 | _size_observer: ResizeObserver 6 | _devicePixelRatioObserver: DevicePixelRatioObserver 7 | 8 | constructor(readonly container: HTMLElement) { 9 | if (getComputedStyle(container).position != "absolute") { 10 | container.style.position = 'relative' 11 | } 12 | 13 | this.canvas = document.createElement('canvas') 14 | this.canvas.style.position = 'absolute' 15 | this.canvas.style.left = '0' 16 | this.canvas.style.top = '0' 17 | this.canvas.style.width = '100%' 18 | this.canvas.style.height = '100%' 19 | this.container.appendChild(this.canvas) 20 | 21 | this.setCanvasSize = this.setCanvasSize.bind(this); 22 | 23 | this._size_observer = new ResizeObserver(this.setCanvasSize) 24 | this._size_observer.observe(container); 25 | 26 | this._devicePixelRatioObserver = new DevicePixelRatioObserver(this.setCanvasSize) 27 | 28 | // set the canvas size on the next redraw - avoids problems with window 29 | // size changing during the first paint because of the scroll bar 30 | window.requestAnimationFrame(() => { 31 | this.setCanvasSize(); 32 | }); 33 | } 34 | 35 | destroy() { 36 | this._size_observer.disconnect() 37 | this._devicePixelRatioObserver.destroy() 38 | this.canvas.remove(); 39 | if (this.drawAnimationRequest !== null) { 40 | window.cancelAnimationFrame(this.drawAnimationRequest); 41 | this.drawAnimationRequest = null 42 | } 43 | } 44 | 45 | drawAnimationRequest: any = null 46 | 47 | setNeedsRedraw() { 48 | if (this.drawAnimationRequest !== null) { 49 | return 50 | } 51 | 52 | this.drawAnimationRequest = window.requestAnimationFrame(() => { 53 | this.drawAnimationRequest = null; 54 | this.canvasViewRedraw() 55 | }) 56 | } 57 | 58 | redrawIfNeeded() { 59 | if (this.drawAnimationRequest !== null) { 60 | window.cancelAnimationFrame(this.drawAnimationRequest); 61 | this.drawAnimationRequest = null; 62 | this.canvasViewRedraw() 63 | } 64 | } 65 | 66 | canvasViewRedraw() { 67 | const ctx = this.canvas.getContext('2d') 68 | if (!ctx) return 69 | 70 | ctx.resetTransform() 71 | ctx.scale(window.devicePixelRatio, window.devicePixelRatio) 72 | 73 | this.redraw(ctx, { 74 | width: this.canvas.width / window.devicePixelRatio, 75 | height: this.canvas.height / window.devicePixelRatio 76 | }); 77 | } 78 | abstract redraw(ctx: CanvasRenderingContext2D, extra: { width: number, height: number }): void 79 | 80 | get width() { return this.canvas.width / window.devicePixelRatio } 81 | get height() { return this.canvas.height / window.devicePixelRatio } 82 | 83 | setCanvasSize() { 84 | const ratio = window.devicePixelRatio 85 | this.canvas.height = this.container.clientHeight * ratio; 86 | this.canvas.width = this.container.clientWidth * ratio; 87 | this.canvasViewRedraw() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /html_renderer/src/lib/DevicePixelRatioObserver.ts: -------------------------------------------------------------------------------- 1 | export default class DevicePixelRatioObserver { 2 | mediaQueryList: MediaQueryList | null = null 3 | 4 | constructor(readonly onDevicePixelRatioChanged: () => void) { 5 | this._onChange = this._onChange.bind(this) 6 | this.createMediaQueryList() 7 | } 8 | 9 | createMediaQueryList() { 10 | this.removeMediaQueryList() 11 | let mqString = `(resolution: ${window.devicePixelRatio}dppx)`; 12 | 13 | this.mediaQueryList = matchMedia(mqString); 14 | this.mediaQueryList.addEventListener('change', this._onChange) 15 | } 16 | removeMediaQueryList() { 17 | this.mediaQueryList?.removeEventListener('change', this._onChange) 18 | this.mediaQueryList = null 19 | } 20 | _onChange(event: MediaQueryListEvent) { 21 | this.onDevicePixelRatioChanged() 22 | this.createMediaQueryList() 23 | } 24 | destroy() { 25 | this.removeMediaQueryList() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /html_renderer/src/lib/appState.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | 3 | export const visibleGroups = writable<{[id: string]: boolean}>({}) 4 | export const collapsedFrames = writable<{[id: string]: boolean}>({}) 5 | -------------------------------------------------------------------------------- /html_renderer/src/lib/color.ts: -------------------------------------------------------------------------------- 1 | export function colorForFrameProportionOfTotal(proportion: number): string { 2 | if (proportion > 0.6) { 3 | return '#FF4159' 4 | } else if (proportion > 0.3) { 5 | return '#F5A623' 6 | } else if (proportion > 0.15) { 7 | return '#D8CB2A' 8 | } else if (proportion > 0.05) { 9 | return '#7ED321' 10 | } else { 11 | return '#58984f' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /html_renderer/src/lib/dataTypes.ts: -------------------------------------------------------------------------------- 1 | export interface SessionData { 2 | session: { 3 | start_time: number; 4 | duration: number; 5 | min_interval: number; 6 | max_interval: number; 7 | sample_count: number; 8 | start_call_stack: string[], 9 | target_description: string; 10 | cpu_time: number; 11 | sys_path: string; 12 | sys_prefixes: string[]; 13 | }; 14 | frame_tree: FrameData|null; 15 | } 16 | 17 | export interface FrameData { 18 | identifier: string; 19 | time: number; 20 | attributes: {[name: string]: number}; 21 | children: FrameData[]; 22 | } 23 | -------------------------------------------------------------------------------- /html_renderer/src/lib/model/FrameGroup.ts: -------------------------------------------------------------------------------- 1 | import { randomId } from '../utils'; 2 | import type Frame from './Frame'; 3 | 4 | export default class FrameGroup { 5 | id: string; 6 | rootFrame: Frame; 7 | _frames: Frame[] = [] 8 | 9 | constructor(rootFrame: Frame) { 10 | this.id = randomId() 11 | this.rootFrame = rootFrame; 12 | } 13 | 14 | addFrame(frame: Frame) { 15 | if (frame.group) { 16 | frame.group.removeFrame(frame); 17 | } 18 | this._frames.push(frame); 19 | frame.group = this; 20 | } 21 | 22 | removeFrame(frame: Frame) { 23 | if (frame.group !== this) { 24 | throw new Error("Frame not in group."); 25 | } 26 | 27 | const index = this._frames.indexOf(frame); 28 | if (index === -1) { 29 | throw new Error("Frame not found in group."); 30 | } 31 | this._frames.splice(index, 1); 32 | frame.group = null; 33 | } 34 | 35 | get frames(): readonly Frame[] { 36 | return this._frames; 37 | } 38 | 39 | get exitFrames() { 40 | // exit frames are frames inside this group that have children outside the group. 41 | const exitFrames = [] 42 | 43 | for (const frame of this.frames) { 44 | let isExit = false; 45 | for (const child of frame.children) { 46 | if (child.group != this) { 47 | isExit = true; 48 | break; 49 | } 50 | } 51 | 52 | if (isExit) { 53 | exitFrames.push(frame); 54 | } 55 | } 56 | 57 | return exitFrames; 58 | } 59 | 60 | get libraries() { 61 | const libraries: string[] = []; 62 | 63 | for (const frame of this.frames) { 64 | const library = frame.library 65 | if (!library) { 66 | continue 67 | } 68 | 69 | if (!libraries.includes(library)) { 70 | libraries.push(library); 71 | } 72 | } 73 | 74 | return libraries; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /html_renderer/src/lib/model/Session.ts: -------------------------------------------------------------------------------- 1 | import type { SessionData } from "../dataTypes"; 2 | import Frame from "./Frame"; 3 | 4 | export default class Session { 5 | startTime: number; 6 | duration: number; 7 | minInterval: number; 8 | maxInterval: number; 9 | sampleCount: number; 10 | target_description: string; 11 | cpuTime: number; 12 | rootFrame: Frame|null; 13 | sysPath: string; 14 | sysPrefixes: string[]; 15 | 16 | constructor(data: SessionData) { 17 | this.startTime = data.session.start_time; 18 | this.duration = data.session.duration; 19 | this.minInterval = data.session.min_interval; 20 | this.maxInterval = data.session.max_interval; 21 | this.sampleCount = data.session.sample_count; 22 | this.target_description = data.session.target_description; 23 | this.cpuTime = data.session.cpu_time; 24 | this.sysPath = data.session.sys_path; 25 | this.sysPrefixes = data.session.sys_prefixes 26 | this.rootFrame = data.frame_tree ? new Frame(data.frame_tree, this) : null 27 | } 28 | 29 | _shortenPathCache: {[path: string]: string} = {} 30 | shortenPath(path: string): string { 31 | if (this._shortenPathCache[path]) { 32 | return this._shortenPathCache[path] 33 | } 34 | 35 | let result = path 36 | const pathParts = pathSplit(path) 37 | 38 | if (pathParts.length > 1) { 39 | for (const sysPathEntry of this.sysPath) { 40 | const candidate = getRelPath(path, sysPathEntry) 41 | if (pathSplit(candidate).length < pathSplit(result).length) { 42 | result = candidate 43 | } 44 | } 45 | } 46 | 47 | this._shortenPathCache[path] = result 48 | return result 49 | } 50 | } 51 | 52 | function pathSplit(path: string): string[] { 53 | return path.split(/[/\\]/) 54 | } 55 | 56 | function getPathDrive(path: string): string | null { 57 | const parts = pathSplit(path) 58 | if (parts.length > 0 && parts[0].endsWith(":")) { 59 | return parts[0] 60 | } else { 61 | return null 62 | } 63 | } 64 | 65 | function getRelPath(path: string, start: string): string { 66 | // returns the relative path from start to path 67 | // e.g. getRelPath("/a/b/c", "/a") -> "b/c" 68 | // e.g. getRelPath("/a/b/c", "/a/d/e") -> "../../b/c" 69 | 70 | if (getPathDrive(path) != getPathDrive(start)) { 71 | // different drives, can't make a relative path 72 | return path 73 | } 74 | 75 | const parts = pathSplit(path) 76 | const startParts = pathSplit(start) 77 | let i = 0 78 | while (i < parts.length && i < startParts.length && parts[i] == startParts[i]) { 79 | i++ 80 | } 81 | const relParts = startParts.slice(i).map(_ => "..") 82 | 83 | return relParts.concat(parts.slice(i)).join("/") 84 | } 85 | -------------------------------------------------------------------------------- /html_renderer/src/lib/model/frameOps.test.ts: -------------------------------------------------------------------------------- 1 | import { deleteFrameFromTree } from "./frameOps"; 2 | import { describe, it, expect } from "vitest"; 3 | import Frame, { SELF_TIME_FRAME_IDENTIFIER } from "./Frame"; 4 | 5 | const context = {shortenPath: (a:string) => a}; 6 | 7 | describe("deleteFrameFromTree", () => { 8 | it("should replace the frame with its children", () => { 9 | const parent = new Frame({ identifier: "parent" }, context); 10 | const frame = new Frame({ identifier: "frame" }, context); 11 | const child1 = new Frame({ identifier: "child1" }, context); 12 | const child2 = new Frame({ identifier: "child2" }, context); 13 | frame.addChild(child1); 14 | frame.addChild(child2); 15 | parent.addChild(frame); 16 | 17 | deleteFrameFromTree(frame, { replaceWith: "children" }); 18 | 19 | expect(parent.children).toContain(child1); 20 | expect(parent.children).toContain(child2); 21 | expect(parent.children).not.toContain(frame); 22 | }); 23 | 24 | it("should add a self-time frame as a replacement", () => { 25 | const parent = new Frame({ identifier: "parent" }, context); 26 | const frame = new Frame({ identifier: "frame" }, context); 27 | parent.addChild(frame); 28 | 29 | deleteFrameFromTree(frame, { replaceWith: "self_time" }); 30 | 31 | expect(parent.children).toHaveLength(1); 32 | expect(parent.children[0].identifier).toBe(SELF_TIME_FRAME_IDENTIFIER); 33 | }); 34 | 35 | it("should absorb the frame's time into the parent", () => { 36 | const parent = new Frame({ identifier: "parent" }, context); 37 | const frame = new Frame({ identifier: "frame", time: 10 }, context); 38 | parent.addChild(frame); 39 | 40 | deleteFrameFromTree(frame, { replaceWith: "nothing" }); 41 | 42 | expect(parent.absorbedTime).toBe(10); 43 | }); 44 | 45 | it("should throw an error if trying to delete the root frame", () => { 46 | const frame = new Frame({ identifier: "frame" }, context); 47 | 48 | expect(() => { 49 | deleteFrameFromTree(frame, { replaceWith: "children" }); 50 | }).toThrowError("Cannot delete the root frame"); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /html_renderer/src/lib/model/frameOps.ts: -------------------------------------------------------------------------------- 1 | import { UnreachableCaseError } from "../utils"; 2 | import Frame from "./Frame"; 3 | import { SELF_TIME_FRAME_IDENTIFIER } from "./Frame"; 4 | 5 | export function deleteFrameFromTree(frame: Frame, options: {replaceWith: 'children'|'self_time'|'nothing'}) { 6 | const {replaceWith} = options 7 | const parent = frame.parent 8 | if (!parent) { 9 | throw new Error('Cannot delete the root frame') 10 | } 11 | 12 | if (replaceWith == 'children') { 13 | parent.addChildren(frame.children, {after: frame}) 14 | } else if (replaceWith == 'self_time') { 15 | parent.addChild( 16 | new Frame({ 17 | identifier: SELF_TIME_FRAME_IDENTIFIER, 18 | time: frame.time, 19 | }, parent.context), 20 | {after: frame} 21 | ) 22 | } else if (replaceWith == 'nothing') { 23 | parent.absorbedTime += frame.time 24 | } else { 25 | throw new UnreachableCaseError(replaceWith) 26 | } 27 | 28 | frame.removeFromParent() 29 | removeFrameFromGroups(frame, true) 30 | } 31 | 32 | /** 33 | * Combines two frames into one. The frames must have the same parent. 34 | * 35 | * @param frame The frame to remove. 36 | * @param into The frame to combine into. 37 | */ 38 | export function combineFrames(frame: Frame, into: Frame): void { 39 | if (frame.parent !== into.parent) { 40 | throw new Error("Both frames must have the same parent."); 41 | } 42 | 43 | into.absorbedTime += frame.absorbedTime; 44 | into.time += frame.time; 45 | 46 | Object.entries(frame.attributes).forEach(([attribute, time]) => { 47 | if (into.attributes[attribute] !== undefined) { 48 | into.attributes[attribute] += time; 49 | } else { 50 | into.attributes[attribute] = time; 51 | } 52 | }); 53 | 54 | into.addChildren(frame.children); 55 | frame.removeFromParent(); 56 | removeFrameFromGroups(frame, false); 57 | } 58 | 59 | /** 60 | * Removes a frame from any groups that it is a member of. Should be used when 61 | * removing a frame from a tree, so groups don't keep references to removed frames. 62 | * 63 | * @param frame The frame to be removed from groups. 64 | * @param recursive Whether to also remove all child frames from their groups. 65 | */ 66 | export function removeFrameFromGroups(frame: Frame, recursive: boolean): void { 67 | if (recursive && frame.children) { 68 | frame.children.forEach(child => { 69 | removeFrameFromGroups(child, true); 70 | }); 71 | } 72 | 73 | if (frame.group) { 74 | const group = frame.group; 75 | group.removeFrame(frame); 76 | 77 | if (group.frames.length === 1) { 78 | // A group with only one frame is meaningless; remove it entirely. 79 | group.removeFrame(group.frames[0]); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /html_renderer/src/lib/model/modelUtil.ts: -------------------------------------------------------------------------------- 1 | import type Frame from "./Frame" 2 | import type { Processor, ProcessorFunction, ProcessorOptions } from "./processors" 3 | 4 | export function applyProcessors(rootFrame: Frame | null, processors: ProcessorFunction[], options: ProcessorOptions) { 5 | let frame: Frame | null = rootFrame 6 | for (const processor of processors) { 7 | frame = processor(frame, options) 8 | if (!frame) { 9 | return null 10 | } 11 | } 12 | return frame 13 | } 14 | -------------------------------------------------------------------------------- /html_renderer/src/lib/settings.ts: -------------------------------------------------------------------------------- 1 | import { persisted } from "svelte-persisted-store" 2 | 3 | export interface ViewOptionsCallStack { 4 | collapseMode: 'non-application'|'disabled'|'custom' 5 | collapseCustomHide: string 6 | collapseCustomShow: string 7 | removeImportlib: boolean 8 | removeTracebackHide: boolean 9 | removePyinstrument: boolean 10 | removeIrrelevant: boolean 11 | removeIrrelevantThreshold: number 12 | timeFormat: 'absolute'|'proportion' 13 | } 14 | 15 | export function CallStackViewOptionsDefaults(): ViewOptionsCallStack { 16 | return { 17 | collapseMode: 'non-application', 18 | collapseCustomHide: '', 19 | collapseCustomShow: '', 20 | removeImportlib: true, 21 | removeTracebackHide: true, 22 | removePyinstrument: true, 23 | removeIrrelevant: true, 24 | removeIrrelevantThreshold: 0.001, 25 | timeFormat: 'absolute', 26 | } 27 | } 28 | 29 | export const viewOptionsCallStack = persisted( 30 | 'pyinstrument:viewOptionsCallStack', 31 | CallStackViewOptionsDefaults(), 32 | { 33 | syncTabs: true, 34 | beforeRead(val) { 35 | // fill in any missing values with defaults 36 | return { 37 | ...CallStackViewOptionsDefaults(), 38 | ...val 39 | } 40 | } 41 | } 42 | ) 43 | export const viewOptions = persisted( 44 | 'pyinstrument:viewOptions', 45 | {viewMode: 'call-stack' as 'call-stack'|'timeline'}, 46 | {syncTabs: false} 47 | ) 48 | 49 | export interface ViewOptionsTimeline { 50 | removeImportlib: boolean, 51 | removeTracebackHide: boolean, 52 | removePyinstrument: boolean, 53 | removeIrrelevant: boolean, 54 | removeIrrelevantThreshold: number, 55 | } 56 | export const viewOptionsTimeline = persisted( 57 | 'pyinstrument:viewOptionsTimeline', 58 | { 59 | removeImportlib: true, 60 | removeTracebackHide: true, 61 | removePyinstrument: true, 62 | removeIrrelevant: true, 63 | removeIrrelevantThreshold: 0.0001, 64 | }, 65 | {syncTabs: true} 66 | ) 67 | -------------------------------------------------------------------------------- /html_renderer/src/main.ts: -------------------------------------------------------------------------------- 1 | import './app.css' 2 | import App from './App.svelte' 3 | import type { SessionData } from './lib/dataTypes' 4 | import Session from './lib/model/Session' 5 | 6 | export default { 7 | render(element: HTMLElement, data: SessionData) { 8 | const session = new Session(data) 9 | 10 | return new App({ 11 | target: element, 12 | props: { session }, 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /html_renderer/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'uuid' { 2 | export function v4(): string; 3 | } 4 | -------------------------------------------------------------------------------- /html_renderer/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /html_renderer/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /html_renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | /** 10 | * Typecheck JS in `.svelte` and `.js` files by default. 11 | * Disable checkJs if you'd like to use dynamic types in JS. 12 | * Note that setting allowJs false does not prevent the use 13 | * of JS in `.svelte` files. 14 | */ 15 | "allowJs": true, 16 | "checkJs": true, 17 | "isolatedModules": true, 18 | "moduleDetection": "force" 19 | }, 20 | "include": [ 21 | "src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte", 22 | "demo-src/**/*.d.ts", "demo-src/**/*.ts", "demo-src/**/*.js", "demo-src/**/*.svelte" 23 | ], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /html_renderer/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /html_renderer/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(env => { 6 | if (env.mode === 'preview') { 7 | return { 8 | plugins: [svelte()], 9 | base: './' 10 | } 11 | } else { 12 | return { 13 | plugins: [svelte()], 14 | build: { 15 | assetsInlineLimit: 1e100, 16 | cssCodeSplit: false, 17 | lib: { 18 | entry: 'src/main.ts', 19 | name: 'pyinstrumentHTMLRenderer', 20 | fileName: 'pyinstrument-html', 21 | formats: ['iife'], 22 | } 23 | } 24 | } 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /metrics/count_samples.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pyinstrument 4 | 5 | 6 | def do_nothing(): 7 | pass 8 | 9 | 10 | def busy_wait(duration: float): 11 | start = time.time() 12 | while time.time() - start < duration: 13 | do_nothing() 14 | 15 | 16 | def count_samples(duration: float, interval: float, use_timing_thread: bool): 17 | profiler = pyinstrument.Profiler(interval=interval, use_timing_thread=use_timing_thread) 18 | profiler.start() 19 | busy_wait(duration) 20 | session = profiler.stop() 21 | 22 | reference = duration / interval 23 | sample_count = len(session.frame_records) 24 | print(f"Interval: {interval}, use_timing_thread: {use_timing_thread}") 25 | print( 26 | f"Expected samples: {reference}, actual samples: {sample_count}, {sample_count / reference:.2f}x" 27 | ) 28 | 29 | 30 | count_samples(0.1, 0.001, False) 31 | count_samples(0.1, 0.001, True) 32 | count_samples(0.1, 0.0001, False) 33 | count_samples(0.1, 0.0001, True) 34 | -------------------------------------------------------------------------------- /metrics/frame_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from timeit import Timer 5 | from types import FrameType 6 | from typing import Final 7 | 8 | from pyinstrument.low_level.stat_profile import get_frame_info 9 | 10 | frame: Final[FrameType | None] = inspect.currentframe() 11 | assert frame 12 | 13 | 14 | def test_func(): 15 | get_frame_info(frame) 16 | 17 | 18 | t = Timer(stmt=test_func) 19 | test_func_timings = t.repeat(number=400000) 20 | 21 | print("min time", min(test_func_timings)) 22 | print("max time", max(test_func_timings)) 23 | print("average time", sum(test_func_timings) / len(test_func_timings)) 24 | -------------------------------------------------------------------------------- /metrics/interrupt.py: -------------------------------------------------------------------------------- 1 | from platform import platform 2 | 3 | from pyinstrument import Profiler 4 | 5 | p = Profiler() 6 | 7 | p.start() 8 | 9 | 10 | def func(): 11 | fd = open("/dev/urandom", "rb") 12 | _ = fd.read(1024 * 1024) 13 | 14 | 15 | func() 16 | 17 | # this failed on ubuntu 12.04 18 | platform() 19 | 20 | p.stop() 21 | 22 | print(p.output_text()) 23 | 24 | p.write_html("ioerror_out.html") 25 | -------------------------------------------------------------------------------- /metrics/multi_overhead.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import profile 3 | import re 4 | import sys 5 | import time 6 | from timeit import Timer 7 | 8 | import django.conf 9 | import django.template.loader 10 | 11 | import pyinstrument 12 | 13 | django.conf.settings.configure( 14 | INSTALLED_APPS=(), 15 | TEMPLATES=[ 16 | { 17 | "BACKEND": "django.template.backends.django.DjangoTemplates", 18 | "DIRS": [ 19 | "./examples/django_example/django_example/templates", 20 | ], 21 | } 22 | ], 23 | ) 24 | django.setup() 25 | 26 | 27 | def test_func_re(): 28 | re.compile( 29 | r"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" 30 | ) 31 | 32 | 33 | def test_func_template(): 34 | django.template.loader.render_to_string("template.html") 35 | 36 | 37 | # heat caches 38 | test_func_template() 39 | 40 | 41 | def time_base(function, repeats): 42 | timer = Timer(stmt=function) 43 | return timer.repeat(number=repeats) 44 | 45 | 46 | def time_profile(function, repeats): 47 | timer = Timer(stmt=function) 48 | p = profile.Profile() 49 | return p.runcall(lambda: timer.repeat(number=repeats)) 50 | 51 | 52 | def time_cProfile(function, repeats): 53 | timer = Timer(stmt=function) 54 | p = cProfile.Profile() 55 | return p.runcall(lambda: timer.repeat(number=repeats)) 56 | 57 | 58 | def time_pyinstrument(function, repeats): 59 | timer = Timer(stmt=function) 60 | p = pyinstrument.Profiler() 61 | p.start() 62 | result = timer.repeat(number=repeats) 63 | p.stop() 64 | return result 65 | 66 | 67 | profilers = ( 68 | ("Base", time_base), 69 | # ('profile', time_profile), 70 | ("cProfile", time_cProfile), 71 | ("pyinstrument", time_pyinstrument), 72 | ) 73 | 74 | tests = ( 75 | ("re.compile", test_func_re, 120000), 76 | ("django template render", test_func_template, 400), 77 | ) 78 | 79 | 80 | def timings_for_test(test_func, repeats): 81 | results = [] 82 | for profiler_tuple in profilers: 83 | time = profiler_tuple[1](test_func, repeats) 84 | results += (profiler_tuple[0], min(time)) 85 | 86 | return results 87 | 88 | 89 | # print header 90 | for column in [""] + [test[0] for test in tests]: 91 | sys.stdout.write(f"{column:>24}") 92 | 93 | sys.stdout.write("\n") 94 | 95 | for profiler_tuple in profilers: 96 | sys.stdout.write(f"{profiler_tuple[0]:>24}") 97 | sys.stdout.flush() 98 | for test_tuple in tests: 99 | time = min(profiler_tuple[1](test_tuple[1], test_tuple[2])) * 10 100 | sys.stdout.write(f"{time:>24.2f}") 101 | sys.stdout.flush() 102 | sys.stdout.write("\n") 103 | -------------------------------------------------------------------------------- /metrics/overflow.py: -------------------------------------------------------------------------------- 1 | from pyinstrument import Profiler 2 | 3 | p = Profiler() 4 | 5 | p.start() 6 | 7 | 8 | def func(num): 9 | if num == 0: 10 | return 11 | b = 0 12 | for x in range(1, 100000): 13 | b += x 14 | 15 | return func(num - 1) 16 | 17 | 18 | func(900) 19 | 20 | p.stop() 21 | 22 | print(p.output_text()) 23 | 24 | p.write_html("overflow_out.html") 25 | -------------------------------------------------------------------------------- /metrics/overhead.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import profile 3 | from timeit import Timer 4 | 5 | import django.conf 6 | import django.template.loader 7 | 8 | import pyinstrument 9 | 10 | django.conf.settings.configure( 11 | INSTALLED_APPS=(), 12 | TEMPLATES=[ 13 | { 14 | "BACKEND": "django.template.backends.django.DjangoTemplates", 15 | "DIRS": [ 16 | "./examples/demo_scripts/django_example/django_example/templates", 17 | ], 18 | } 19 | ], 20 | ) 21 | django.setup() 22 | 23 | 24 | def test_func_template(): 25 | django.template.loader.render_to_string("template.html") 26 | 27 | 28 | t = Timer(stmt=test_func_template) 29 | test_func = lambda: t.repeat(number=4000) 30 | 31 | # base 32 | base_timings = test_func() 33 | 34 | # # profile 35 | # p = profile.Profile() 36 | # profile_timings = p.runcall(lambda: test_func()) 37 | 38 | # cProfile 39 | cp = cProfile.Profile() 40 | cProfile_timings = cp.runcall(test_func) 41 | 42 | # pyinstrument 43 | profiler = pyinstrument.Profiler() 44 | profiler.start() 45 | pyinstrument_timings = test_func() 46 | profiler.stop() 47 | 48 | # pyinstrument timeline 49 | # profiler = pyinstrument.Profiler(timeline=True) 50 | # profiler.start() 51 | # pyinstrument_timeline_timings = test_func() 52 | # profiler.stop() 53 | 54 | profiler.write_html("out.html") 55 | 56 | print(profiler.output_text(unicode=True, color=True)) 57 | 58 | graph_data = ( 59 | ("Base timings", min(base_timings)), 60 | # ('profile', min(profile_timings)), 61 | ("cProfile", min(cProfile_timings)), 62 | ("pyinstrument", min(pyinstrument_timings)), 63 | # ('pyinstrument timeline', min(pyinstrument_timeline_timings)), 64 | ) 65 | 66 | GRAPH_WIDTH = 60 67 | print("Profiler overhead") 68 | print("–" * (GRAPH_WIDTH + 17)) 69 | max_time = max([t[1] for t in graph_data]) 70 | for name, time in graph_data: 71 | chars = int((time / max_time) * GRAPH_WIDTH) 72 | spaces = GRAPH_WIDTH - chars 73 | print(f'{name:15} {"█" * chars}{" " * spaces} {time:.3f}s') 74 | print() 75 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import nox 4 | 5 | nox.needs_version = ">=2024.4.15" 6 | nox.options.default_venv_backend = "uv|virtualenv" 7 | 8 | 9 | @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) 10 | def test(session): 11 | session.env["UV_PRERELEASE"] = "allow" 12 | session.install("-e", ".[test]", "setuptools") 13 | session.run("python", "setup.py", "build_ext", "--inplace") 14 | session.run("pytest") 15 | 16 | 17 | @nox.session() 18 | def docs(session): 19 | session.env["UV_PRERELEASE"] = "allow" 20 | session.install("-e", ".[docs]") 21 | session.run("make", "-C", "docs", "clean", "html") 22 | 23 | 24 | @nox.session(default=False) 25 | def livedocs(session): 26 | session.env["UV_PRERELEASE"] = "allow" 27 | session.install("-e", ".[docs]") 28 | session.run("make", "-C", "docs", "clean", "livehtml") 29 | 30 | 31 | @nox.session(default=False, python=False) 32 | def htmldev(session): 33 | with session.chdir("html_renderer"): 34 | session.run("npm", "install") 35 | session.run("npm", "run", "dev") 36 | 37 | 38 | @nox.session(default=False, python=False) 39 | def watchbuild(session): 40 | # this doesn't use nox's environment isolation, because we want to build 41 | # the python version of the activated venv 42 | # we pass --force because the build_ext command doesn't rebuild if the 43 | # headers change 44 | session.run("python", "setup.py", "build_ext", "--inplace", "--force") 45 | session.run( 46 | "pipx", 47 | "run", 48 | "--spec", 49 | "watchdog", 50 | "watchmedo", 51 | "shell-command", 52 | "--patterns=*.h;*.c;setup.py;setup.cfg", 53 | "--recursive", 54 | "--command=python setup.py build_ext --inplace --force", 55 | "pyinstrument", 56 | ) 57 | 58 | 59 | @nox.session(python=False, default=False) 60 | def watch(session): 61 | session.run( 62 | "npx", 63 | "concurrently", 64 | "--kill-others", 65 | "--names", 66 | "bext,html,docs", 67 | "--prefix-colors", 68 | "bgBlue,bgGreen,bgMagenta", 69 | "nox -s watchbuild", 70 | "nox -s htmldev", 71 | "nox -s livedocs", 72 | ) 73 | -------------------------------------------------------------------------------- /pyinstrument/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from pyinstrument.context_manager import profile 4 | from pyinstrument.profiler import Profiler 5 | 6 | __all__ = ["__version__", "Profiler", "load_ipython_extension", "profile"] 7 | __version__ = "5.0.2" 8 | 9 | # enable deprecation warnings 10 | warnings.filterwarnings("once", ".*", DeprecationWarning, r"pyinstrument\..*") 11 | 12 | 13 | def load_ipython_extension(ipython): 14 | """ 15 | This function is called by IPython to load the pyinstrument IPython 16 | extension, which is done with the magic command `%load_ext pyinstrument`. 17 | """ 18 | 19 | from pyinstrument.magic import PyinstrumentMagic 20 | 21 | ipython.register_magics(PyinstrumentMagic) 22 | -------------------------------------------------------------------------------- /pyinstrument/context_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import inspect 5 | import sys 6 | import typing 7 | 8 | from pyinstrument.profiler import AsyncMode, Profiler 9 | from pyinstrument.renderers.base import Renderer 10 | from pyinstrument.renderers.console import ConsoleRenderer 11 | from pyinstrument.typing import Unpack 12 | from pyinstrument.util import file_supports_color, file_supports_unicode 13 | 14 | CallableVar = typing.TypeVar("CallableVar", bound=typing.Callable) 15 | 16 | 17 | class ProfileContextOptions(typing.TypedDict, total=False): 18 | interval: float 19 | async_mode: AsyncMode 20 | use_timing_thread: bool | None 21 | renderer: Renderer | None 22 | target_description: str | None 23 | 24 | 25 | class ProfileContext: 26 | options: ProfileContextOptions 27 | 28 | def __init__( 29 | self, 30 | **kwargs: Unpack[ProfileContextOptions], 31 | ): 32 | profiler_options = { 33 | "interval": kwargs.get("interval", 0.001), 34 | # note- different async mode from the default, because it's easy 35 | # to run multiple profilers at once using the decorator/context 36 | # manager 37 | "async_mode": kwargs.get("async_mode", "disabled"), 38 | "use_timing_thread": kwargs.get("use_timing_thread", None), 39 | } 40 | self.profiler = Profiler(**profiler_options) 41 | self.options = kwargs 42 | 43 | @typing.overload 44 | def __call__(self, func: CallableVar, /) -> CallableVar: ... 45 | @typing.overload 46 | def __call__(self, /, **kwargs: Unpack[ProfileContextOptions]) -> "ProfileContext": ... 47 | def __call__( 48 | self, func: typing.Callable | None = None, /, **kwargs: Unpack[ProfileContextOptions] 49 | ): 50 | if func is not None: 51 | 52 | @functools.wraps(func) 53 | def wrapper(*args, **kwargs): 54 | target_description = self.options.get("target_description") 55 | if target_description is None: 56 | target_description = f"Function {func.__qualname__} at {func.__code__.co_filename}:{func.__code__.co_firstlineno}" 57 | 58 | with self(target_description=target_description): 59 | return func(*args, **kwargs) 60 | 61 | return typing.cast(typing.Callable, wrapper) 62 | else: 63 | return ProfileContext(**{**self.options, **kwargs}) 64 | 65 | def __enter__(self): 66 | if self.profiler.is_running: 67 | raise RuntimeError( 68 | "This profiler is already running - did you forget the brackets on pyinstrument.profile() ?" 69 | ) 70 | 71 | caller_frame = inspect.currentframe().f_back # type: ignore 72 | assert caller_frame is not None 73 | target_description = self.options.get("target_description") 74 | if target_description is None: 75 | target_description = "Block at {}:{}".format( 76 | caller_frame.f_code.co_filename, caller_frame.f_lineno 77 | ) 78 | 79 | self.profiler.start( 80 | caller_frame=caller_frame, 81 | target_description=target_description, 82 | ) 83 | 84 | def __exit__(self, exc_type, exc_value, traceback): 85 | session = self.profiler.stop() 86 | 87 | renderer = self.options.get("renderer") 88 | f = sys.stderr 89 | 90 | if renderer is None: 91 | renderer = ConsoleRenderer( 92 | color=file_supports_color(f), 93 | unicode=file_supports_unicode(f), 94 | short_mode=True, 95 | ) 96 | 97 | f.write(renderer.render(session)) 98 | 99 | 100 | class _Profile: 101 | @typing.overload 102 | def __call__(self, func: CallableVar, /) -> CallableVar: ... 103 | @typing.overload 104 | def __call__(self, /, **kwargs: Unpack[ProfileContextOptions]) -> "ProfileContext": ... 105 | def __call__( 106 | self, func: typing.Callable | None = None, /, **kwargs: Unpack[ProfileContextOptions] 107 | ): 108 | if func is not None: 109 | return ProfileContext(**kwargs)(func) 110 | else: 111 | return ProfileContext(**kwargs) 112 | 113 | 114 | profile = _Profile() 115 | -------------------------------------------------------------------------------- /pyinstrument/frame_info.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | # pyright: strict 4 | 5 | 6 | IDENTIFIER_SEP = "\x00" 7 | ATTRIBUTES_SEP = "\x01" 8 | 9 | ATTRIBUTE_MARKER_CLASS_NAME = "c" 10 | ATTRIBUTE_MARKER_LINE_NUMBER = "l" 11 | ATTRIBUTE_MARKER_TRACEBACKHIDE = "h" 12 | 13 | 14 | def parse_frame_info(frame_info: str) -> Tuple[str, List[str]]: 15 | """ 16 | Parses a frame_info string, returns a tuple of (identifier, attributes), 17 | where `identifier` is a unique identifier for this code (e.g. a function 18 | or method), and `attributes` is a list of invocation-specific attributes 19 | that were captured at profile-time. 20 | """ 21 | 22 | identifier, _, attributes_str = frame_info.partition(ATTRIBUTES_SEP) 23 | 24 | if not attributes_str: 25 | return identifier, [] 26 | 27 | return identifier, attributes_str.split(ATTRIBUTES_SEP) 28 | 29 | 30 | def frame_info_get_identifier(frame_info: str) -> str: 31 | """ 32 | Equivalent to `parse_frame_info(frame_info)[0]`, but faster. 33 | """ 34 | index = frame_info.find(ATTRIBUTES_SEP) 35 | 36 | if index == -1: 37 | # no attributes 38 | return frame_info 39 | 40 | return frame_info[0:index] 41 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_floatclock.c: -------------------------------------------------------------------------------- 1 | #include "pyi_floatclock.h" 2 | 3 | #include 4 | #include // gettimeofday, clock() 5 | #include // DBL_MAX 6 | 7 | 8 | /* 9 | The windows implementations mostly stolen from timemodule.c 10 | */ 11 | 12 | #if defined(MS_WINDOWS) && !defined(__BORLANDC__) 13 | #include 14 | 15 | double pyi_monotonic_coarse_resolution(void) 16 | { 17 | return DBL_MAX; 18 | } 19 | 20 | /* use QueryPerformanceCounter on Windows */ 21 | 22 | double pyi_floatclock(PYIFloatClockType timer) 23 | { 24 | if (timer == PYI_FLOATCLOCK_MONOTONIC_COARSE) { 25 | warn_once("CLOCK_MONOTONIC_COARSE not available on this system."); 26 | } 27 | static LARGE_INTEGER ctrStart; 28 | static double divisor = 0.0; 29 | LARGE_INTEGER now; 30 | double diff; 31 | 32 | if (divisor == 0.0) { 33 | LARGE_INTEGER freq; 34 | QueryPerformanceCounter(&ctrStart); 35 | if (!QueryPerformanceFrequency(&freq) || freq.QuadPart == 0) { 36 | /* Unlikely to happen - this works on all intel 37 | machines at least! Revert to clock() */ 38 | return ((double)clock()) / CLOCKS_PER_SEC; 39 | } 40 | divisor = (double)freq.QuadPart; 41 | } 42 | QueryPerformanceCounter(&now); 43 | diff = (double)(now.QuadPart - ctrStart.QuadPart); 44 | return diff / divisor; 45 | } 46 | 47 | #else /* !MS_WINDOWS */ 48 | 49 | #include 50 | #include // clock_gettime 51 | 52 | static double SEC_PER_NSEC = 1e-9; 53 | static double SEC_PER_USEC = 1e-6; 54 | 55 | double pyi_monotonic_coarse_resolution(void) 56 | { 57 | #ifdef CLOCK_MONOTONIC_COARSE 58 | static double resolution = -1; 59 | if (resolution == -1) { 60 | struct timespec res; 61 | int success = clock_getres(CLOCK_MONOTONIC_COARSE, &res); 62 | if (success == 0) { 63 | resolution = res.tv_sec + res.tv_nsec * SEC_PER_NSEC; 64 | } else { 65 | // clock_getres failed, so let's set the resolution to something 66 | // so this timer is never used. 67 | resolution = DBL_MAX; 68 | } 69 | } 70 | return resolution; 71 | #else 72 | return DBL_MAX; 73 | #endif 74 | } 75 | 76 | double pyi_floatclock(PYIFloatClockType timer) 77 | { 78 | // gets the current time in seconds, as quickly as possible. 79 | #ifdef _POSIX_TIMERS 80 | struct timespec t; 81 | int res; 82 | if (timer == PYI_FLOATCLOCK_MONOTONIC_COARSE) { 83 | # ifdef CLOCK_MONOTONIC_COARSE 84 | res = clock_gettime(CLOCK_MONOTONIC_COARSE, &t); 85 | if (res == 0) return t.tv_sec + t.tv_nsec * SEC_PER_NSEC; 86 | # else 87 | warn_once("CLOCK_MONOTONIC_COARSE not available on this system."); 88 | # endif 89 | } 90 | # ifdef CLOCK_MONOTONIC 91 | res = clock_gettime(CLOCK_MONOTONIC, &t); 92 | if (res == 0) return t.tv_sec + t.tv_nsec * SEC_PER_NSEC; 93 | # endif 94 | res = clock_gettime(CLOCK_REALTIME, &t); 95 | if (res == 0) return t.tv_sec + t.tv_nsec * SEC_PER_NSEC; 96 | #endif 97 | struct timeval tv; 98 | gettimeofday(&tv, (struct timezone *)NULL); 99 | return (double)tv.tv_sec + tv.tv_usec * SEC_PER_USEC; 100 | } 101 | 102 | #endif /* MS_WINDOWS */ 103 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_floatclock.h: -------------------------------------------------------------------------------- 1 | #ifndef PYI_FLOATCLOCK_H 2 | #define PYI_FLOATCLOCK_H 3 | 4 | #include 5 | #include "pyi_shared.h" 6 | 7 | typedef enum { 8 | PYI_FLOATCLOCK_DEFAULT = 0, 9 | PYI_FLOATCLOCK_MONOTONIC_COARSE = 1, 10 | } PYIFloatClockType; 11 | 12 | Py_EXPORTED_SYMBOL double pyi_monotonic_coarse_resolution(void); 13 | Py_EXPORTED_SYMBOL double pyi_floatclock(PYIFloatClockType timer); 14 | 15 | #endif 16 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_shared.h: -------------------------------------------------------------------------------- 1 | #ifndef PYI_SHARED_H 2 | #define PYI_SHARED_H 3 | 4 | #include 5 | #include 6 | 7 | #ifndef __has_attribute 8 | # define __has_attribute(x) 0 // Compatibility with non-clang compilers. 9 | #endif 10 | 11 | // Define Py_EXPORTED_SYMBOL to be the appropriate symbol for exporting, it's not set in Python 3.8. 12 | #ifndef Py_EXPORTED_SYMBOL 13 | # if defined(_WIN32) || defined(__CYGWIN__) 14 | # define Py_EXPORTED_SYMBOL __declspec(dllexport) 15 | # elif (defined(__GNUC__) && (__GNUC__ >= 4)) ||\ 16 | (defined(__clang__) && __has_attribute(visibility)) 17 | # define Py_EXPORTED_SYMBOL __attribute__ ((visibility ("default"))) 18 | # else 19 | # define Py_EXPORTED_SYMBOL 20 | # endif 21 | #endif 22 | 23 | #define warn_once(msg) \ 24 | do { \ 25 | static int warned = 0; \ 26 | if (!warned) { \ 27 | fprintf(stderr, "pyinstrument: %s\n", msg); \ 28 | warned = 1; \ 29 | } \ 30 | } while (0) 31 | 32 | #endif /* PYI_SHARED_H */ 33 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_timing_thread.c: -------------------------------------------------------------------------------- 1 | #include "pyi_timing_thread.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include "pyi_floatclock.h" 8 | 9 | static volatile double current_time = 0.0; 10 | 11 | static PyThread_type_lock subscriber_lock = NULL; 12 | static PyThread_type_lock update_lock = NULL; 13 | static int thread_should_exit = 0; 14 | static int thread_alive = 0; 15 | 16 | // Structure to hold subscriptions 17 | typedef struct Subscription { 18 | double interval; 19 | int id; 20 | } Subscription; 21 | 22 | #define MAX_SUBSCRIBERS 1000 23 | static Subscription subscribers[MAX_SUBSCRIBERS]; 24 | static int subscriber_count = 0; 25 | 26 | static double get_interval(double max_interval) { 27 | double min_interval = max_interval; 28 | 29 | for (int i = 0; i < subscriber_count; i++) { 30 | if (subscribers[i].interval < min_interval) { 31 | min_interval = subscribers[i].interval; 32 | } 33 | } 34 | 35 | return min_interval; 36 | } 37 | 38 | static void timing_thread(void* args) { 39 | while (!thread_should_exit) { 40 | double interval = get_interval(1.0); 41 | // sleep for the interval, or until we're woken up by a change 42 | PyLockStatus status = PyThread_acquire_lock_timed( 43 | update_lock, 44 | (PY_TIMEOUT_T)(interval * 1e6), 45 | 0 46 | ); 47 | if (status == PY_LOCK_ACQUIRED) { 48 | // rather than finishing the wait, another thread signaled a 49 | // change by releasing the lock. The lock was just for the sake of 50 | // the wakeup, so let's release it again. 51 | PyThread_release_lock(update_lock); 52 | } 53 | current_time = pyi_floatclock(PYI_FLOATCLOCK_DEFAULT); 54 | } 55 | } 56 | 57 | int pyi_timing_thread_subscribe(double desiredInterval) { 58 | if (subscriber_lock == NULL) { 59 | subscriber_lock = PyThread_allocate_lock(); 60 | } 61 | if (update_lock == NULL) { 62 | update_lock = PyThread_allocate_lock(); 63 | } 64 | 65 | PyThread_acquire_lock(subscriber_lock, WAIT_LOCK); 66 | 67 | if (!thread_alive) { 68 | PyThread_acquire_lock(update_lock, WAIT_LOCK); // Initially hold the lock 69 | thread_should_exit = 0; 70 | PyThread_start_new_thread(timing_thread, NULL); 71 | thread_alive = 1; 72 | 73 | // initialise the current_time in case it's read immediately 74 | current_time = pyi_floatclock(PYI_FLOATCLOCK_DEFAULT); 75 | } 76 | 77 | int new_id = 0; 78 | 79 | // find an unused ID 80 | for (; new_id < MAX_SUBSCRIBERS; new_id++) { 81 | int already_exists = 0; 82 | for (int i = 0; i < subscriber_count; i++) { 83 | if (subscribers[i].id == new_id) { 84 | already_exists = 1; 85 | break; 86 | } 87 | } 88 | if (!already_exists) { 89 | break; 90 | } 91 | } 92 | if (new_id == MAX_SUBSCRIBERS) { 93 | // Too many subscribers 94 | PyThread_release_lock(subscriber_lock); 95 | return PYI_TIMING_THREAD_TOO_MANY_SUBSCRIBERS; 96 | } 97 | 98 | int index = subscriber_count; 99 | subscribers[index].id = new_id; 100 | subscribers[index].interval = desiredInterval; 101 | subscriber_count++; 102 | 103 | // signal a possible change in the interval 104 | PyThread_release_lock(update_lock); 105 | PyThread_acquire_lock(update_lock, WAIT_LOCK); 106 | 107 | PyThread_release_lock(subscriber_lock); 108 | return new_id; 109 | } 110 | 111 | int pyi_timing_thread_unsubscribe(int id) { 112 | PyThread_acquire_lock(subscriber_lock, WAIT_LOCK); 113 | 114 | int removals = 0; 115 | 116 | for (int i = 0; i < subscriber_count; i++) { 117 | if (subscribers[i].id == id) { 118 | // Removal: overwrite this one with with the last element and decrement count. 119 | subscribers[i] = subscribers[subscriber_count-1]; 120 | subscriber_count--; 121 | removals++; 122 | break; 123 | } 124 | } 125 | 126 | // if the last subscriber was removed, stop the thread 127 | if (subscriber_count == 0) { 128 | thread_should_exit = 1; 129 | PyThread_release_lock(update_lock); 130 | thread_alive = 0; 131 | } 132 | 133 | PyThread_release_lock(subscriber_lock); 134 | 135 | if (removals == 0) { 136 | return PYI_TIMING_THREAD_NOT_SUBSCRIBED; 137 | } else { 138 | return 0; 139 | } 140 | } 141 | 142 | double pyi_timing_thread_get_time(void) { 143 | return current_time; 144 | } 145 | 146 | double pyi_timing_thread_get_interval(void) { 147 | if (thread_alive) { 148 | return get_interval(DBL_MAX); 149 | } else { 150 | return -1.0; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_timing_thread.h: -------------------------------------------------------------------------------- 1 | #ifndef PYI_TIMINGTHREAD_H 2 | #define PYI_TIMINGTHREAD_H 3 | 4 | #include 5 | #include "pyi_shared.h" 6 | 7 | /** 8 | * Adds a subscription to the timing thread, requesting that it updates the 9 | * time every `desired_interval` seconds. Returns an ID that can be used to 10 | * unsubscribe later, or a negative value indicating error. 11 | */ 12 | 13 | Py_EXPORTED_SYMBOL int pyi_timing_thread_subscribe(double desired_interval); 14 | 15 | /** 16 | * Returns the current time, as updated by the timing thread. 17 | */ 18 | Py_EXPORTED_SYMBOL double pyi_timing_thread_get_time(void); 19 | 20 | /** 21 | * Returns the current interval, or -1 if the thread is not running. 22 | */ 23 | Py_EXPORTED_SYMBOL double pyi_timing_thread_get_interval(void); 24 | 25 | /** 26 | * Unsubscribes from the timing thread. Returns 0 on success, or a negative 27 | * value indicating error. 28 | */ 29 | Py_EXPORTED_SYMBOL int pyi_timing_thread_unsubscribe(int id); 30 | 31 | #define PYI_TIMING_THREAD_UNKNOWN_ERROR -1 32 | #define PYI_TIMING_THREAD_TOO_MANY_SUBSCRIBERS -2 33 | #define PYI_TIMING_THREAD_NOT_SUBSCRIBED -3 34 | 35 | #endif /* PYI_TIMINGTHREAD_H */ 36 | -------------------------------------------------------------------------------- /pyinstrument/low_level/pyi_timing_thread_python.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | current_time = 0.0 5 | 6 | subscriber_lock = threading.Lock() 7 | update_lock = threading.Lock() 8 | thread_should_exit = False 9 | thread_alive = False 10 | 11 | 12 | class Subscription: 13 | def __init__(self, interval: float, id: int): 14 | self.interval = interval 15 | self.id = id 16 | 17 | 18 | subscribers = [] 19 | 20 | 21 | def get_interval(max_interval: float): 22 | if subscribers: 23 | return min(sub.interval for sub in subscribers) 24 | return max_interval 25 | 26 | 27 | def timing_thread(): 28 | global current_time, thread_should_exit 29 | 30 | while not thread_should_exit: 31 | interval = get_interval(1.0) 32 | acquired = update_lock.acquire(timeout=interval) 33 | if acquired: 34 | update_lock.release() 35 | current_time = time.perf_counter() 36 | 37 | 38 | def pyi_timing_thread_subscribe(desired_interval: float): 39 | global thread_alive, thread_should_exit, current_time 40 | 41 | with subscriber_lock: 42 | if not thread_alive: 43 | update_lock.acquire() 44 | thread_should_exit = False 45 | threading.Thread(target=timing_thread).start() 46 | thread_alive = True 47 | current_time = time.perf_counter() 48 | 49 | ids = [sub.id for sub in subscribers] 50 | new_id = 0 51 | while new_id in ids: 52 | new_id += 1 53 | 54 | subscribers.append(Subscription(desired_interval, new_id)) 55 | 56 | update_lock.release() 57 | update_lock.acquire() 58 | 59 | return new_id 60 | 61 | 62 | def pyi_timing_thread_unsubscribe(id: int): 63 | with subscriber_lock: 64 | subscriber_to_remove = next((sub for sub in subscribers if sub.id == id), None) 65 | 66 | if subscriber_to_remove: 67 | subscribers.remove(subscriber_to_remove) 68 | 69 | if not subscribers: 70 | global thread_should_exit, thread_alive 71 | thread_should_exit = True 72 | update_lock.release() 73 | thread_alive = False 74 | return 0 75 | else: 76 | raise Exception("PYI_TIMING_THREAD_NOT_SUBSCRIBED") 77 | 78 | 79 | def pyi_timing_thread_get_time() -> float: 80 | return current_time 81 | 82 | 83 | def pyi_timing_thread_get_interval() -> float: 84 | return get_interval(float("inf")) if thread_alive else -1.0 85 | -------------------------------------------------------------------------------- /pyinstrument/low_level/stat_profile.pyi: -------------------------------------------------------------------------------- 1 | import contextvars 2 | import types 3 | from typing import Any, Callable, Dict 4 | 5 | from pyinstrument.low_level.types import TimerType 6 | 7 | def setstatprofile( 8 | target: Callable[[types.FrameType, str, Any], Any] | None, 9 | interval: float = 0.001, 10 | context_var: contextvars.ContextVar[object | None] | None = None, 11 | timer_type: TimerType | None = None, 12 | timer_func: Callable[[], float] | None = None, 13 | ) -> None: ... 14 | def get_frame_info(frame: types.FrameType) -> str: ... 15 | def measure_timing_overhead() -> Dict[TimerType, float]: ... 16 | def walltime_coarse_resolution() -> float | None: ... 17 | -------------------------------------------------------------------------------- /pyinstrument/low_level/types.py: -------------------------------------------------------------------------------- 1 | from pyinstrument.typing import LiteralStr 2 | 3 | TimerType = LiteralStr["walltime", "walltime_thread", "timer_func", "walltime_coarse"] 4 | -------------------------------------------------------------------------------- /pyinstrument/magic/__init__.py: -------------------------------------------------------------------------------- 1 | from .magic import PyinstrumentMagic 2 | -------------------------------------------------------------------------------- /pyinstrument/magic/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # This file is largely based on https://gist.github.com/Carreau/0f051f57734222da925cd364e59cc17e which is in the public domain 4 | # This (or something similar) may eventually be moved into IPython 5 | import ast 6 | from ast import Assign, Expr, Load, Name, NodeTransformer, Store, parse 7 | from textwrap import dedent 8 | 9 | 10 | class PrePostAstTransformer(NodeTransformer): 11 | """ 12 | Allow to safely wrap user code with pre/post execution hooks that 13 | run just before and just after usercode, __inside__ the execution loop, 14 | But still returns the value of the last Expression. 15 | 16 | This might not behave as expected if the user change the InteractiveShell.ast_node_interactivity option. 17 | 18 | This is currently not hygienic and care must be taken to use uncommon names in the pre/post block. 19 | 20 | Assuming the user have 21 | 22 | ``` 23 | code_block: 24 | [with many expressions] 25 | last_expression 26 | ``` 27 | 28 | It will transform it into 29 | 30 | ``` 31 | try: 32 | pre_block 33 | code_block: 34 | [with many expressions] 35 | return_value = last_expression 36 | finally: 37 | post_block 38 | return_value 39 | ``` 40 | 41 | Thus making sure that post is always executed even if pre or user code fails. 42 | """ 43 | 44 | def __init__(self, pre: str | ast.Module, post: str | ast.Module): 45 | """ 46 | pre and post are either strings, or ast.Modules object that need to be run just before or after 47 | the user code. 48 | 49 | While strings are possible, we suggest using ast.Modules 50 | object and mangling the corresponding variable names 51 | to be invalid python identifiers to avoid name conflicts. 52 | """ 53 | if isinstance(pre, str): 54 | pre = parse(pre) 55 | if isinstance(post, str): 56 | post = parse(post) 57 | 58 | self.pre = pre.body 59 | self.post = post.body 60 | self.active = True 61 | 62 | def reset(self): 63 | self.core = parse( 64 | dedent( 65 | """ 66 | try: 67 | pass 68 | finally: 69 | pass 70 | """ 71 | ) 72 | ) 73 | self.try_ = self.core.body[0].body = [] # type: ignore 74 | self.fin = self.core.body[0].finalbody = [] # type: ignore 75 | 76 | def visit_Module(self, node: ast.Module): 77 | if not self.active: 78 | return node 79 | self.reset() 80 | last = node.body[-1] 81 | ret = None 82 | if isinstance(last, Expr): 83 | node.body.pop() 84 | node.body.append(Assign([Name("ast-tmp", ctx=Store())], value=last.value)) 85 | ret = Expr(value=Name("ast-tmp", ctx=Load())) 86 | # self.core.body.insert(0, Assign([Name('_p', ctx=Store())], value=ast.Constant(None) )) 87 | if ret: 88 | self.core.body.insert( 89 | 0, Assign([Name("ast-tmp", ctx=Store())], value=ast.Constant(None)) 90 | ) 91 | for p in self.pre + node.body: 92 | self.try_.append(p) 93 | for p in self.post: 94 | self.fin.append(p) 95 | if ret is not None: 96 | self.core.body.append(ret) 97 | 98 | ast.fix_missing_locations(self.core) 99 | return self.core 100 | -------------------------------------------------------------------------------- /pyinstrument/middleware.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | import time 5 | 6 | from django.conf import settings 7 | from django.http import HttpResponse 8 | from django.utils.module_loading import import_string 9 | 10 | from pyinstrument import Profiler 11 | from pyinstrument.renderers import Renderer 12 | from pyinstrument.renderers.html import HTMLRenderer 13 | 14 | try: 15 | from django.utils.deprecation import MiddlewareMixin 16 | except ImportError: 17 | MiddlewareMixin = object 18 | 19 | 20 | def get_renderer(path) -> Renderer: 21 | """Return the renderer instance.""" 22 | if path: 23 | try: 24 | renderer = import_string(path)() 25 | except ImportError as exc: 26 | print("Unable to import the class: %s" % path) 27 | raise exc 28 | 29 | if not isinstance(renderer, Renderer): 30 | raise ValueError(f"Renderer should subclass: {Renderer}") 31 | 32 | return renderer 33 | else: 34 | return HTMLRenderer() 35 | 36 | 37 | class ProfilerMiddleware(MiddlewareMixin): # type: ignore 38 | def process_request(self, request): 39 | profile_dir = getattr(settings, "PYINSTRUMENT_PROFILE_DIR", None) 40 | 41 | func_or_path = getattr(settings, "PYINSTRUMENT_SHOW_CALLBACK", None) 42 | if isinstance(func_or_path, str): 43 | show_pyinstrument = import_string(func_or_path) 44 | elif callable(func_or_path): 45 | show_pyinstrument = func_or_path 46 | else: 47 | show_pyinstrument = lambda request: True 48 | 49 | if ( 50 | show_pyinstrument(request) 51 | and getattr(settings, "PYINSTRUMENT_URL_ARGUMENT", "profile") in request.GET 52 | ) or profile_dir: 53 | profiler = Profiler() 54 | profiler.start() 55 | 56 | request.profiler = profiler 57 | 58 | def process_response(self, request, response): 59 | if hasattr(request, "profiler"): 60 | profile_session = request.profiler.stop() 61 | 62 | configured_renderer = getattr(settings, "PYINSTRUMENT_PROFILE_DIR_RENDERER", None) 63 | renderer = get_renderer(configured_renderer) 64 | 65 | output = renderer.render(profile_session) 66 | 67 | profile_dir = getattr(settings, "PYINSTRUMENT_PROFILE_DIR", None) 68 | 69 | # Limit the length of the file name (255 characters is the max limit on major current OS, but it is rather 70 | # high and the other parts (see line 36) are to be taken into account; so a hundred will be fine here). 71 | path = request.get_full_path().replace("/", "_")[:100] 72 | 73 | # Swap ? for _qs_ on Windows, as it does not support ? in filenames. 74 | if sys.platform in ["win32", "cygwin"]: 75 | path = path.replace("?", "_qs_") 76 | 77 | if profile_dir: 78 | default_filename = "{total_time:.3f}s {path} {timestamp:.0f}.{ext}" 79 | filename = getattr(settings, "PYINSTRUMENT_FILENAME", default_filename).format( 80 | total_time=profile_session.duration, 81 | path=path, 82 | timestamp=time.time(), 83 | ext=renderer.output_file_extension, 84 | ) 85 | 86 | file_path = os.path.join(profile_dir, filename) 87 | 88 | if not os.path.exists(profile_dir): 89 | os.mkdir(profile_dir) 90 | 91 | with open(file_path, "w", encoding="utf-8") as f: 92 | f.write(output) 93 | 94 | if getattr(settings, "PYINSTRUMENT_URL_ARGUMENT", "profile") in request.GET: 95 | if isinstance(renderer, HTMLRenderer): 96 | return HttpResponse(output) # type: ignore 97 | else: 98 | renderer = HTMLRenderer() 99 | output = renderer.render(profile_session) 100 | return HttpResponse(output) # type: ignore 101 | else: 102 | return response 103 | else: 104 | return response 105 | -------------------------------------------------------------------------------- /pyinstrument/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/pyinstrument/py.typed -------------------------------------------------------------------------------- /pyinstrument/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | from pyinstrument.renderers.base import FrameRenderer, Renderer 2 | from pyinstrument.renderers.console import ConsoleRenderer 3 | from pyinstrument.renderers.html import HTMLRenderer 4 | from pyinstrument.renderers.jsonrenderer import JSONRenderer 5 | from pyinstrument.renderers.pstatsrenderer import PstatsRenderer 6 | from pyinstrument.renderers.session import SessionRenderer 7 | from pyinstrument.renderers.speedscope import SpeedscopeRenderer 8 | 9 | __all__ = [ 10 | "ConsoleRenderer", 11 | "FrameRenderer", 12 | "HTMLRenderer", 13 | "JSONRenderer", 14 | "PstatsRenderer", 15 | "Renderer", 16 | "SessionRenderer", 17 | "SpeedscopeRenderer", 18 | ] 19 | -------------------------------------------------------------------------------- /pyinstrument/renderers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from typing import Any, List 5 | 6 | from pyinstrument import processors 7 | from pyinstrument.frame import Frame 8 | from pyinstrument.session import Session 9 | 10 | # pyright: strict 11 | 12 | 13 | ProcessorList = List[processors.ProcessorType] 14 | 15 | 16 | class Renderer: 17 | """ 18 | Abstract base class for renderers. 19 | """ 20 | 21 | output_file_extension: str = "txt" 22 | """ 23 | Renderer output file extension without dot prefix. The default value is `txt` 24 | """ 25 | 26 | output_is_binary: bool = False 27 | """ 28 | Whether the output of this renderer is binary data. The default value is `False`. 29 | """ 30 | 31 | def __init__(self): 32 | pass 33 | 34 | def render(self, session: Session) -> str: 35 | """ 36 | Return a string that contains the rendered form of `frame`. 37 | """ 38 | raise NotImplementedError() 39 | 40 | class MisconfigurationError(Exception): 41 | pass 42 | 43 | 44 | class FrameRenderer(Renderer): 45 | """ 46 | An abstract base class for renderers that process Frame objects using 47 | processor functions. Provides a common interface to manipulate the 48 | processors before rendering. 49 | """ 50 | 51 | processors: ProcessorList 52 | """ 53 | Processors installed on this renderer. This property is defined on the 54 | base class to provide a common way for users to add and 55 | manipulate them before calling :func:`render`. 56 | """ 57 | 58 | processor_options: dict[str, Any] 59 | """ 60 | Dictionary containing processor options, passed to each processor. 61 | """ 62 | 63 | show_all: bool 64 | timeline: bool 65 | 66 | def __init__( 67 | self, 68 | show_all: bool = False, 69 | timeline: bool = False, 70 | processor_options: dict[str, Any] | None = None, 71 | ): 72 | """ 73 | :param show_all: Don't hide or filter frames - show everything that pyinstrument captures. 74 | :param timeline: Instead of aggregating time, leave the samples in chronological order. 75 | :param processor_options: A dictionary of processor options. 76 | """ 77 | # processors is defined on the base class to provide a common way for users to 78 | # add to and manipulate them before calling render() 79 | self.processors = self.default_processors() 80 | self.processor_options = processor_options or {} 81 | 82 | self.show_all = show_all 83 | self.timeline = timeline 84 | 85 | if show_all: 86 | for p in ( 87 | processors.group_library_frames_processor, 88 | processors.remove_importlib, 89 | processors.remove_irrelevant_nodes, 90 | processors.remove_tracebackhide, 91 | # note: we're not removing these processors 92 | # processors.remove_unnecessary_self_time_nodes, 93 | # (still hide the inner pyinstrument synthetic frames) 94 | # processors.remove_first_pyinstrument_frames_processor, 95 | # (still hide the outer pyinstrument calling frames) 96 | ): 97 | with contextlib.suppress(ValueError): 98 | # don't care if the processor isn't in the list 99 | self.processors.remove(p) 100 | 101 | if timeline: 102 | with contextlib.suppress(ValueError): 103 | self.processors.remove(processors.aggregate_repeated_calls) 104 | 105 | def default_processors(self) -> ProcessorList: 106 | """ 107 | Return a list of processors that this renderer uses by default. 108 | """ 109 | raise NotImplementedError() 110 | 111 | def preprocess(self, root_frame: Frame | None) -> Frame | None: 112 | frame = root_frame 113 | for processor in self.processors: 114 | frame = processor(frame, options=self.processor_options) 115 | return frame 116 | 117 | def render(self, session: Session) -> str: 118 | """ 119 | Return a string that contains the rendered form of `frame`. 120 | """ 121 | raise NotImplementedError() 122 | -------------------------------------------------------------------------------- /pyinstrument/renderers/html.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import codecs 4 | import json 5 | import tempfile 6 | import urllib.parse 7 | import warnings 8 | import webbrowser 9 | from pathlib import Path 10 | 11 | from pyinstrument.renderers.base import FrameRenderer, ProcessorList, Renderer 12 | from pyinstrument.session import Session 13 | 14 | # pyright: strict 15 | 16 | 17 | class HTMLRenderer(Renderer): 18 | """ 19 | Renders a rich, interactive web page, as a string of HTML. 20 | """ 21 | 22 | output_file_extension = "html" 23 | 24 | def __init__( 25 | self, 26 | show_all: bool = False, 27 | timeline: bool = False, 28 | ): 29 | super().__init__() 30 | if show_all: 31 | warnings.warn( 32 | f"the show_all option is deprecated on the HTML renderer, and has no effect. Use the view options in the webpage instead.", 33 | DeprecationWarning, 34 | stacklevel=3, 35 | ) 36 | if timeline: 37 | warnings.warn( 38 | f"timeline is deprecated on the HTML renderer, and has no effect. Use the timeline view in the webpage instead.", 39 | DeprecationWarning, 40 | stacklevel=3, 41 | ) 42 | # this is an undocumented option for use by the ipython magic, might 43 | # be removed later 44 | self.preprocessors: ProcessorList = [] 45 | 46 | def render(self, session: Session): 47 | json_renderer = JSONForHTMLRenderer() 48 | json_renderer.processors = self.preprocessors 49 | session_json = json_renderer.render(session) 50 | 51 | resources_dir = Path(__file__).parent / "html_resources" 52 | 53 | js_file = resources_dir / "app.js" 54 | css_file = resources_dir / "app.css" 55 | 56 | if not js_file.exists() or not css_file.exists(): 57 | raise RuntimeError( 58 | "Could not find app.js / app.css. Perhaps you need to run bin/build_js_bundle.py?" 59 | ) 60 | 61 | js = js_file.read_text(encoding="utf-8") 62 | css = css_file.read_text(encoding="utf-8") 63 | 64 | page = f""" 65 | 66 | 67 | 68 | 69 | 70 |
71 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | """ 82 | 83 | return page 84 | 85 | def open_in_browser(self, session: Session, output_filename: str | None = None): 86 | """ 87 | Open the rendered HTML in a webbrowser. 88 | 89 | If output_filename=None (the default), a tempfile is used. 90 | 91 | The filename of the HTML file is returned. 92 | 93 | """ 94 | if output_filename is None: 95 | output_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False) 96 | output_filename = output_file.name 97 | with codecs.getwriter("utf-8")(output_file) as f: 98 | f.write(self.render(session)) 99 | else: 100 | with codecs.open(output_filename, "w", "utf-8") as f: 101 | f.write(self.render(session)) 102 | 103 | url = urllib.parse.urlunparse(("file", "", output_filename, "", "", "")) 104 | webbrowser.open(url) 105 | return output_filename 106 | 107 | 108 | class JSONForHTMLRenderer(FrameRenderer): 109 | """ 110 | The HTML takes a special form of JSON-encoded session, which includes 111 | an unprocessed frame tree rather than a list of frame records. This 112 | reduces the amount of parsing code that must be included in the 113 | Typescript renderer. 114 | """ 115 | 116 | output_file_extension = "json" 117 | 118 | def default_processors(self) -> ProcessorList: 119 | return [] 120 | 121 | def render(self, session: Session) -> str: 122 | session_json = session.to_json(include_frame_records=False) 123 | session_json_str = json.dumps(session_json) 124 | root_frame = session.root_frame() 125 | root_frame = self.preprocess(root_frame) 126 | frame_tree_json_str = root_frame.to_json_str() if root_frame else "null" 127 | return '{"session": %s, "frame_tree": %s}' % (session_json_str, frame_tree_json_str) 128 | -------------------------------------------------------------------------------- /pyinstrument/renderers/jsonrenderer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | from typing import Any, Callable 6 | 7 | from pyinstrument import processors 8 | from pyinstrument.frame import Frame 9 | from pyinstrument.renderers.base import FrameRenderer, ProcessorList 10 | from pyinstrument.session import Session 11 | 12 | # pyright: strict 13 | 14 | 15 | # note: this file is called jsonrenderer to avoid hiding built-in module 'json'. 16 | 17 | encode_str = typing.cast(Callable[[str], str], json.encoder.encode_basestring) # type: ignore 18 | 19 | 20 | def encode_bool(a_bool: bool): 21 | return "true" if a_bool else "false" 22 | 23 | 24 | class JSONRenderer(FrameRenderer): 25 | """ 26 | Outputs a tree of JSON, containing processed frames. 27 | """ 28 | 29 | output_file_extension = "json" 30 | 31 | def __init__(self, **kwargs: Any): 32 | super().__init__(**kwargs) 33 | 34 | def render_frame(self, frame: Frame | None): 35 | if frame is None: 36 | return "null" 37 | # we don't use the json module because it uses 2x stack frames, so 38 | # crashes on deep but valid call stacks 39 | 40 | property_decls: list[str] = [] 41 | property_decls.append('"function": %s' % encode_str(frame.function)) 42 | property_decls.append('"file_path_short": %s' % encode_str(frame.file_path_short or "")) 43 | property_decls.append('"file_path": %s' % encode_str(frame.file_path or "")) 44 | property_decls.append('"line_no": %d' % (frame.line_no or 0)) 45 | property_decls.append('"time": %f' % frame.time) 46 | property_decls.append('"await_time": %f' % frame.await_time()) 47 | property_decls.append( 48 | '"is_application_code": %s' % encode_bool(frame.is_application_code or False) 49 | ) 50 | 51 | # can't use list comprehension here because it uses two stack frames each time. 52 | children_jsons: list[str] = [] 53 | for child in frame.children: 54 | children_jsons.append(self.render_frame(child)) 55 | property_decls.append('"children": [%s]' % ",".join(children_jsons)) 56 | 57 | if frame.group: 58 | property_decls.append('"group_id": %s' % encode_str(frame.group.id)) 59 | 60 | if frame.class_name: 61 | property_decls.append('"class_name": %s' % encode_str(frame.class_name)) 62 | 63 | return "{%s}" % ",".join(property_decls) 64 | 65 | def render(self, session: Session): 66 | frame = self.preprocess(session.root_frame()) 67 | 68 | property_decls: list[str] = [] 69 | property_decls.append('"start_time": %f' % session.start_time) 70 | property_decls.append('"duration": %f' % session.duration) 71 | property_decls.append('"sample_count": %d' % session.sample_count) 72 | property_decls.append('"target_description": %s' % encode_str(session.target_description)) 73 | property_decls.append('"cpu_time": %f' % session.cpu_time) 74 | property_decls.append('"root_frame": %s' % self.render_frame(frame)) 75 | 76 | return "{%s}\n" % ",".join(property_decls) 77 | 78 | def default_processors(self) -> ProcessorList: 79 | return [ 80 | processors.remove_importlib, 81 | processors.remove_tracebackhide, 82 | processors.merge_consecutive_self_time, 83 | processors.aggregate_repeated_calls, 84 | processors.remove_irrelevant_nodes, 85 | processors.remove_unnecessary_self_time_nodes, 86 | processors.remove_first_pyinstrument_frames_processor, 87 | processors.group_library_frames_processor, 88 | ] 89 | -------------------------------------------------------------------------------- /pyinstrument/renderers/pstatsrenderer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import marshal 4 | from typing import Any, Dict, Tuple 5 | 6 | from pyinstrument import processors 7 | from pyinstrument.frame import Frame 8 | from pyinstrument.renderers.base import FrameRenderer, ProcessorList 9 | from pyinstrument.session import Session 10 | 11 | # pyright: strict 12 | 13 | FrameKey = Tuple[str, int, str] 14 | CallerValue = Tuple[float, int, float, float] 15 | FrameValue = Tuple[float, int, float, float, Dict[FrameKey, CallerValue]] 16 | StatsDict = Dict[FrameKey, FrameValue] 17 | 18 | 19 | class PstatsRenderer(FrameRenderer): 20 | """ 21 | Outputs a marshaled dict, containing processed frames in pstat format, 22 | suitable for processing by gprof2dot and snakeviz. 23 | """ 24 | 25 | output_file_extension = "pstats" 26 | output_is_binary = True 27 | 28 | def __init__(self, **kwargs: Any): 29 | super().__init__(**kwargs) 30 | 31 | def frame_key(self, frame: Frame) -> FrameKey: 32 | return (frame.file_path or "", frame.line_no or 0, frame.function) 33 | 34 | def render_frame(self, frame: Frame | None, stats: StatsDict) -> None: 35 | if frame is None: 36 | return 37 | 38 | key = self.frame_key(frame) 39 | 40 | if key not in stats: 41 | # create a new entry 42 | # being a statistical profiler, we don't know the exact call time or 43 | # number of calls, they're stubbed out 44 | call_time = -1 45 | number_calls = -1 46 | total_time = 0 47 | cumulative_time = 0 48 | callers: dict[FrameKey, CallerValue] = {} 49 | else: 50 | call_time, number_calls, total_time, cumulative_time, callers = stats[key] 51 | 52 | # update the total time and cumulative time 53 | total_time += frame.total_self_time 54 | cumulative_time += frame.time 55 | 56 | if frame.parent: 57 | parent_key = self.frame_key(frame.parent) 58 | if parent_key not in callers: 59 | p_call_time = -1 60 | p_number_calls = -1 61 | p_total_time = 0 62 | p_cumulative_time = 0 63 | else: 64 | p_call_time, p_number_calls, p_total_time, p_cumulative_time = callers[parent_key] 65 | 66 | p_total_time += frame.total_self_time 67 | p_cumulative_time += frame.time 68 | 69 | callers[parent_key] = p_call_time, p_number_calls, p_total_time, p_cumulative_time 70 | 71 | stats[key] = (call_time, number_calls, total_time, cumulative_time, callers) 72 | 73 | for child in frame.children: 74 | if not child.is_synthetic: 75 | self.render_frame(child, stats) 76 | 77 | def render(self, session: Session): 78 | frame = self.preprocess(session.root_frame()) 79 | 80 | stats: StatsDict = {} 81 | self.render_frame(frame, stats) 82 | 83 | # marshal.dumps returns bytes, so we need to decode it to a string 84 | # using surrogateescape 85 | return marshal.dumps(stats).decode(encoding="utf-8", errors="surrogateescape") 86 | 87 | def default_processors(self) -> ProcessorList: 88 | return [ 89 | processors.remove_importlib, 90 | processors.remove_tracebackhide, 91 | processors.merge_consecutive_self_time, 92 | processors.aggregate_repeated_calls, 93 | processors.remove_irrelevant_nodes, 94 | processors.remove_unnecessary_self_time_nodes, 95 | processors.remove_first_pyinstrument_frames_processor, 96 | ] 97 | -------------------------------------------------------------------------------- /pyinstrument/renderers/session.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from pyinstrument.renderers.base import Renderer 4 | from pyinstrument.session import Session 5 | 6 | 7 | class SessionRenderer(Renderer): 8 | output_file_extension: str = "pyisession" 9 | 10 | def __init__(self, tree_format: bool = False): 11 | super().__init__() 12 | self.tree_format = tree_format 13 | 14 | def render(self, session: Session) -> str: 15 | return json.dumps(session.to_json()) 16 | -------------------------------------------------------------------------------- /pyinstrument/typing.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TYPE_CHECKING, Any, Union 3 | 4 | if TYPE_CHECKING: 5 | from typing_extensions import Literal as LiteralStr 6 | from typing_extensions import TypeAlias, Unpack, assert_never 7 | else: 8 | # a type, that when subscripted, returns `str`. 9 | class _LiteralStr: 10 | def __getitem__(self, values): 11 | return str 12 | 13 | LiteralStr = _LiteralStr() 14 | 15 | def assert_never(value: Any): 16 | raise ValueError(value) 17 | 18 | Unpack = Any 19 | TypeAlias = Any 20 | 21 | 22 | PathOrStr = Union[str, "os.PathLike[str]"] 23 | 24 | __all__ = ["PathOrStr", "LiteralStr", "assert_never", "Unpack", "TypeAlias"] 25 | -------------------------------------------------------------------------------- /pyinstrument/util.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import importlib 3 | import math 4 | import os 5 | import re 6 | import sys 7 | import warnings 8 | from typing import IO, Any, AnyStr, Callable 9 | 10 | from pyinstrument.vendor.decorator import decorator 11 | 12 | 13 | def object_with_import_path(import_path: str) -> Any: 14 | if "." not in import_path: 15 | raise ValueError("Can't import '%s', it is not a valid import path" % import_path) 16 | module_path, object_name = import_path.rsplit(".", 1) 17 | 18 | module = importlib.import_module(module_path) 19 | return getattr(module, object_name) 20 | 21 | 22 | def truncate(string: str, max_length: int) -> str: 23 | if len(string) > max_length: 24 | return string[0 : max_length - 3] + "..." 25 | return string 26 | 27 | 28 | @decorator 29 | def deprecated(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: 30 | """Marks a function as deprecated.""" 31 | warnings.warn( 32 | f"{func} is deprecated and should no longer be used.", 33 | DeprecationWarning, 34 | stacklevel=3, 35 | ) 36 | return func(*args, **kwargs) 37 | 38 | 39 | def deprecated_option(option_name: str, message: str = "") -> Any: 40 | """Marks an option as deprecated.""" 41 | 42 | def caller(func, *args, **kwargs): 43 | if option_name in kwargs: 44 | warnings.warn( 45 | f"{option_name} is deprecated. {message}", 46 | DeprecationWarning, 47 | stacklevel=3, 48 | ) 49 | 50 | return func(*args, **kwargs) 51 | 52 | return decorator(caller) 53 | 54 | 55 | def file_supports_color(file_obj: IO[AnyStr]) -> bool: 56 | """ 57 | Returns True if the running system's terminal supports color. 58 | 59 | Borrowed from Django 60 | https://github.com/django/django/blob/master/django/core/management/color.py 61 | """ 62 | plat = sys.platform 63 | supported_platform = plat != "Pocket PC" and (plat != "win32" or "ANSICON" in os.environ) 64 | 65 | is_a_tty = file_is_a_tty(file_obj) 66 | 67 | return supported_platform and is_a_tty 68 | 69 | 70 | def file_supports_unicode(file_obj: IO[AnyStr]) -> bool: 71 | encoding = getattr(file_obj, "encoding", None) 72 | if not encoding: 73 | return False 74 | 75 | codec_info = codecs.lookup(encoding) 76 | 77 | return "utf" in codec_info.name 78 | 79 | 80 | def file_is_a_tty(file_obj: IO[AnyStr]) -> bool: 81 | return hasattr(file_obj, "isatty") and file_obj.isatty() 82 | 83 | 84 | def unwrap(string: str) -> str: 85 | string = string.replace("\n", " ") 86 | string = re.sub(r"\s+", " ", string) 87 | return string.strip() 88 | 89 | 90 | def format_float_with_sig_figs(value: float, sig_figs: int = 3, trim_zeroes=False) -> str: 91 | """ 92 | Format a float to a string with a specific number of significant figures. 93 | Doesn't use scientific notation. 94 | """ 95 | if value == 0: 96 | return "0" 97 | 98 | precision = math.ceil(-math.log10(abs(value))) + sig_figs - 1 99 | if precision < 0: 100 | precision = 0 101 | result = "{:.{precision}f}".format(value, precision=precision) 102 | 103 | if trim_zeroes and "." in result: 104 | result = result.rstrip("0").rstrip(".") 105 | 106 | return result 107 | 108 | 109 | def strtobool(val: str) -> bool: 110 | return val.lower() in {"y", "yes", "t", "true", "on", "1"} 111 | -------------------------------------------------------------------------------- /pyinstrument/vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/pyinstrument/vendor/__init__.py -------------------------------------------------------------------------------- /pyinstrument/vendor/keypath.py: -------------------------------------------------------------------------------- 1 | # keypath vendored from https://github.com/fictorial/keypath 2 | 3 | # keypath is released under the BSD license: 4 | 5 | # Copyright 2016, Fictorial LLC 6 | 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # * Redistributions of source code must retain the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer. 14 | 15 | # * Redistributions in binary form must reproduce the above 16 | # copyright notice, this list of conditions and the following 17 | # disclaimer in the documentation and/or other materials 18 | # provided with the distribution. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER “AS IS” AND ANY 21 | # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 23 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE 24 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 25 | # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 29 | # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 30 | # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 31 | # SUCH DAMAGE. 32 | # 33 | # Includes modifications by joerick, which fall under the same license. 34 | 35 | from typing import Any 36 | 37 | 38 | def value_at_keypath(obj: Any, keypath: str) -> Any: 39 | """ 40 | Returns value at given key path which follows dotted-path notation. 41 | 42 | >>> x = dict(a=1, b=2, c=dict(d=3, e=4, f=[2,dict(x='foo', y='bar'),5])) 43 | >>> assert value_at_keypath(x, 'a') == 1 44 | >>> assert value_at_keypath(x, 'b') == 2 45 | >>> assert value_at_keypath(x, 'c.d') == 3 46 | >>> assert value_at_keypath(x, 'c.e') == 4 47 | >>> assert value_at_keypath(x, 'c.f.0') == 2 48 | >>> assert value_at_keypath(x, 'c.f.-1') == 5 49 | >>> assert value_at_keypath(x, 'c.f.1.y') == 'bar' 50 | 51 | """ 52 | for part in keypath.split('.'): 53 | if isinstance(obj, dict): 54 | obj = obj.get(part, {}) 55 | elif type(obj) in [tuple, list]: 56 | obj = obj[int(part)] 57 | else: 58 | obj = getattr(obj, part, {}) 59 | return obj 60 | 61 | 62 | def set_value_at_keypath(obj: Any, keypath: str, val: Any): 63 | """ 64 | Sets value at given key path which follows dotted-path notation. 65 | 66 | Each part of the keypath must already exist in the target value 67 | along the path. 68 | 69 | >>> x = dict(a=1, b=2, c=dict(d=3, e=4, f=[2,dict(x='foo', y='bar'),5])) 70 | >>> assert set_value_at_keypath(x, 'a', 2) 71 | >>> assert value_at_keypath(x, 'a') == 2 72 | >>> assert set_value_at_keypath(x, 'c.f.-1', 6) 73 | >>> assert value_at_keypath(x, 'c.f.-1') == 6 74 | """ 75 | parts = keypath.split('.') 76 | for part in parts[:-1]: 77 | if isinstance(obj, dict): 78 | obj = obj[part] 79 | elif type(obj) in [tuple, list]: 80 | obj = obj[int(part)] 81 | else: 82 | obj = getattr(obj, part) 83 | last_part = parts[-1] 84 | if isinstance(obj, dict): 85 | obj[last_part] = val 86 | elif type(obj) in [tuple, list]: 87 | obj[int(last_part)] = val 88 | else: 89 | setattr(obj, last_part, val) 90 | return True 91 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 100 7 | 8 | [tool.pyright] 9 | include = ["pyinstrument", "test"] 10 | ignore = ["pyinstrument/vendor"] 11 | pythonVersion = "3.8" 12 | 13 | [tool.isort] 14 | profile = "black" 15 | multi_line_output = 3 16 | line_length = 100 17 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e .[test,bin,docs,examples,types] 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [tool:pytest] 5 | testpaths = test 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from setuptools import Extension, find_namespace_packages, setup 5 | 6 | PROJECT_ROOT = Path(__file__).parent 7 | long_description = (PROJECT_ROOT / "README.md").read_text(encoding="utf8") 8 | 9 | setup( 10 | name="pyinstrument", 11 | packages=find_namespace_packages(include=["pyinstrument*"]), 12 | version="5.0.2", 13 | ext_modules=[ 14 | Extension( 15 | "pyinstrument.low_level.stat_profile", 16 | sources=[ 17 | "pyinstrument/low_level/stat_profile.c", 18 | "pyinstrument/low_level/pyi_floatclock.c", 19 | "pyinstrument/low_level/pyi_timing_thread.c", 20 | ], 21 | ) 22 | ], 23 | description="Call stack profiler for Python. Shows you why your code is slow!", 24 | long_description=long_description, 25 | long_description_content_type="text/markdown", 26 | author="Joe Rickerby", 27 | author_email="joerick@mac.com", 28 | url="https://github.com/joerick/pyinstrument", 29 | keywords=["profiling", "profile", "profiler", "cpu", "time", "sampling"], 30 | install_requires=[], 31 | extras_require={ 32 | "test": [ 33 | "pytest", 34 | "flaky", 35 | "trio", 36 | "cffi >= 1.17.0", 37 | "greenlet>=3", 38 | # pinned to an older version due to an incompatibility with flaky 39 | "pytest-asyncio==0.23.8", 40 | "ipython", 41 | ], 42 | "bin": [ 43 | "click", 44 | "nox", 45 | ], 46 | "docs": [ 47 | "sphinx==7.4.7", 48 | "myst-parser==3.0.1", 49 | "furo==2024.7.18", 50 | "sphinxcontrib-programoutput==0.17", 51 | "sphinx-autobuild==2024.4.16", 52 | ], 53 | "examples": [ 54 | "numpy", 55 | "django", 56 | "litestar", 57 | ], 58 | "types": [ 59 | "typing_extensions", 60 | ], 61 | }, 62 | include_package_data=True, 63 | python_requires=">=3.8", 64 | entry_points={"console_scripts": ["pyinstrument = pyinstrument.__main__:main"]}, 65 | zip_safe=False, 66 | classifiers=[ 67 | "Environment :: Console", 68 | "Environment :: Web Environment", 69 | "Intended Audience :: Developers", 70 | "License :: OSI Approved :: BSD License", 71 | "Operating System :: MacOS", 72 | "Operating System :: Microsoft :: Windows", 73 | "Operating System :: POSIX", 74 | "Topic :: Software Development :: Debuggers", 75 | "Topic :: Software Development :: Testing", 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/test/__init__.py -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from pyinstrument import stack_sampler 6 | 7 | 8 | def pytest_addoption(parser) -> None: 9 | # IPython tests seem to pollute the test environment, so they're run in a 10 | # separate process. 11 | 12 | parser.addoption( 13 | "--only-ipython-magic", 14 | action="store_true", 15 | default=False, 16 | help="run only ipython magic tests", 17 | ) 18 | 19 | 20 | def pytest_configure(config): 21 | config.addinivalue_line( 22 | "markers", "ipythonmagic: test requires --only-ipython-magic flag to run" 23 | ) 24 | 25 | 26 | def pytest_collection_modifyitems(config, items) -> None: 27 | flag_was_passed = config.getoption("--only-ipython-magic") 28 | 29 | skip_not_ipython = pytest.mark.skip(reason="not an ipython test") 30 | skip_ipython = pytest.mark.skip(reason="requires --only-ipython-magic option to run") 31 | 32 | for item in items: 33 | if "ipythonmagic" in item.keywords: 34 | if not flag_was_passed: 35 | item.add_marker(skip_ipython) 36 | else: 37 | if flag_was_passed: 38 | item.add_marker(skip_not_ipython) 39 | 40 | 41 | @pytest.fixture(autouse=True) 42 | def check_sampler_state(): 43 | assert sys.getprofile() is None 44 | assert len(stack_sampler.get_stack_sampler().subscribers) == 0 45 | 46 | try: 47 | yield 48 | assert sys.getprofile() is None 49 | assert len(stack_sampler.get_stack_sampler().subscribers) == 0 50 | finally: 51 | sys.setprofile(None) 52 | stack_sampler.thread_locals.__dict__.clear() 53 | -------------------------------------------------------------------------------- /test/fake_time_util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import functools 4 | import random 5 | from typing import TYPE_CHECKING 6 | from unittest import mock 7 | 8 | from pyinstrument import stack_sampler 9 | 10 | if TYPE_CHECKING: 11 | from trio.testing import MockClock 12 | 13 | 14 | class FakeClock: 15 | def __init__(self) -> None: 16 | self.time = random.random() * 1e6 17 | 18 | def get_time(self): 19 | return self.time 20 | 21 | def sleep(self, duration): 22 | self.time += duration 23 | 24 | 25 | @contextlib.contextmanager 26 | def fake_time(fake_clock=None): 27 | fake_clock = fake_clock or FakeClock() 28 | stack_sampler.get_stack_sampler().timer_func = fake_clock.get_time 29 | 30 | try: 31 | with mock.patch("time.sleep", new=fake_clock.sleep): 32 | yield fake_clock 33 | finally: 34 | stack_sampler.get_stack_sampler().timer_func = None 35 | 36 | 37 | class FakeClockAsyncio: 38 | # this implementation mostly lifted from 39 | # https://aiotools.readthedocs.io/en/latest/_modules/aiotools/timer.html#VirtualClock 40 | # License: https://github.com/achimnol/aiotools/blob/800f7f1bce086b0c83658bad8377e6cb1908e22f/LICENSE 41 | # Copyright (c) 2017 Joongi Kim 42 | def __init__(self) -> None: 43 | self.time = random.random() * 1e6 44 | 45 | def get_time(self): 46 | return self.time 47 | 48 | def sleep(self, duration): 49 | self.time += duration 50 | 51 | def _virtual_select(self, orig_select, timeout): 52 | self.time += timeout 53 | return orig_select(0) # override the timeout to zero 54 | 55 | 56 | @contextlib.contextmanager 57 | def fake_time_asyncio(loop=None): 58 | loop = loop or asyncio.get_running_loop() 59 | fake_clock = FakeClockAsyncio() 60 | 61 | # fmt: off 62 | with mock.patch.object( 63 | loop._selector, # type: ignore 64 | "select", 65 | new=functools.partial(fake_clock._virtual_select, loop._selector.select), # type: ignore 66 | ), mock.patch.object( 67 | loop, 68 | "time", 69 | new=fake_clock.get_time 70 | ), fake_time(fake_clock): 71 | yield fake_clock 72 | # fmt: on 73 | 74 | 75 | class FakeClockTrio: 76 | def __init__(self, clock: "MockClock") -> None: 77 | self.trio_clock = clock 78 | 79 | def get_time(self): 80 | return self.trio_clock.current_time() 81 | 82 | def sleep(self, duration): 83 | self.trio_clock.jump(duration) 84 | 85 | 86 | @contextlib.contextmanager 87 | def fake_time_trio(): 88 | from trio.testing import MockClock 89 | 90 | trio_clock = MockClock(autojump_threshold=0) 91 | fake_clock = FakeClockTrio(trio_clock) 92 | 93 | with fake_time(fake_clock): 94 | yield fake_clock 95 | -------------------------------------------------------------------------------- /test/low_level/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joerick/pyinstrument/b04ab301cac954f6a026af55f6949416ceddbe7f/test/low_level/__init__.py -------------------------------------------------------------------------------- /test/low_level/test_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextvars 4 | import time 5 | from typing import Any 6 | 7 | import pytest 8 | 9 | from ..util import busy_wait 10 | from .util import parametrize_setstatprofile 11 | 12 | 13 | @parametrize_setstatprofile 14 | def test_context_type(setstatprofile): 15 | with pytest.raises(TypeError): 16 | setstatprofile(lambda f, e, a: 0, 1e6, "not a context var") 17 | setstatprofile(None) 18 | 19 | 20 | profiler_context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar( 21 | "profiler_context_var", default=None 22 | ) 23 | 24 | 25 | @parametrize_setstatprofile 26 | def test_context_tracking(setstatprofile): 27 | profile_calls = [] 28 | 29 | def profile_callback(frame, event, arg): 30 | nonlocal profile_calls 31 | profile_calls.append((frame, event, arg)) 32 | 33 | profiler_1 = object() 34 | profiler_2 = object() 35 | 36 | context_1 = contextvars.copy_context() 37 | context_2 = contextvars.copy_context() 38 | 39 | context_1.run(profiler_context_var.set, profiler_1) 40 | context_2.run(profiler_context_var.set, profiler_2) 41 | 42 | setstatprofile( 43 | profile_callback, 44 | 1e10, # set large interval so we only get context_change events 45 | profiler_context_var, 46 | ) 47 | 48 | context_1.run(busy_wait, 0.001) 49 | context_2.run(busy_wait, 0.001) 50 | 51 | setstatprofile(None) 52 | 53 | assert all(c[1] == "context_changed" for c in profile_calls) 54 | assert len(profile_calls) == 4 55 | 56 | new, old, _ = profile_calls[0][2] 57 | assert old is None 58 | assert new is profiler_1 59 | 60 | new, old, _ = profile_calls[1][2] 61 | assert old is profiler_1 62 | assert new is None 63 | 64 | new, old, _ = profile_calls[2][2] 65 | assert old is None 66 | assert new is profiler_2 67 | 68 | new, old, _ = profile_calls[3][2] 69 | assert old is profiler_2 70 | assert new is None 71 | -------------------------------------------------------------------------------- /test/low_level/test_custom_timer.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from .util import parametrize_setstatprofile 4 | 5 | 6 | class CallCounter: 7 | def __init__(self) -> None: 8 | self.count = 0 9 | 10 | def __call__(self, *args: Any, **kwds: Any) -> Any: 11 | self.count += 1 12 | 13 | 14 | @parametrize_setstatprofile 15 | def test_increment(setstatprofile): 16 | time = 0.0 17 | 18 | def fake_time(): 19 | return time 20 | 21 | def fake_sleep(duration): 22 | nonlocal time 23 | time += duration 24 | 25 | counter = CallCounter() 26 | 27 | setstatprofile(counter, timer_func=fake_time, timer_type="timer_func") 28 | 29 | for _ in range(100): 30 | fake_sleep(1.0) 31 | 32 | setstatprofile(None) 33 | 34 | assert counter.count == 100 35 | -------------------------------------------------------------------------------- /test/low_level/test_floatclock.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import time 3 | 4 | import pytest 5 | 6 | import pyinstrument.low_level.stat_profile as native_module 7 | 8 | lib = ctypes.CDLL(native_module.__file__) 9 | 10 | pyi_floatclock = lib.pyi_floatclock 11 | pyi_floatclock.argtypes = [ctypes.c_int] 12 | pyi_floatclock.restype = ctypes.c_double 13 | 14 | 15 | def test_floatclock(): 16 | time_a = pyi_floatclock(0) 17 | time.sleep(0.001) 18 | time_b = pyi_floatclock(0) 19 | assert time_b > time_a 20 | 21 | 22 | def test_is_in_seconds(): 23 | floatclock_time_a = pyi_floatclock(0) 24 | time_a = time.time() 25 | 26 | time.sleep(0.1) 27 | 28 | floatclock_time_b = pyi_floatclock(0) 29 | time_b = time.time() 30 | 31 | floatclock_duration = floatclock_time_b - floatclock_time_a 32 | duration = time_b - time_a 33 | 34 | assert floatclock_duration == pytest.approx(duration, rel=0.1) 35 | -------------------------------------------------------------------------------- /test/low_level/test_frame_info.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pytest 4 | 5 | from pyinstrument.low_level import stat_profile as stat_profile_c 6 | from pyinstrument.low_level import stat_profile_python 7 | 8 | 9 | class AClass: 10 | def get_frame_info_for_a_method(self, getter_function, del_local): 11 | if del_local: 12 | del self 13 | frame = inspect.currentframe() 14 | assert frame 15 | return getter_function(frame) 16 | 17 | def get_frame_info_with_cell_variable(self, getter_function, del_local): 18 | def an_inner_function(): 19 | # reference self to make it a cell variable 20 | if self: 21 | pass 22 | 23 | if del_local: 24 | del self 25 | frame = inspect.currentframe() 26 | assert frame 27 | 28 | return getter_function(frame) 29 | 30 | @classmethod 31 | def get_frame_info_for_a_class_method(cls, getter_function, del_local): 32 | if del_local: 33 | del cls 34 | frame = inspect.currentframe() 35 | assert frame 36 | return getter_function(frame) 37 | 38 | @classmethod 39 | def get_frame_info_for_a_class_method_where_cls_is_reassigned(cls, getter_function, del_local): 40 | cls = 1 41 | if del_local: 42 | del cls 43 | frame = inspect.currentframe() 44 | assert frame 45 | return getter_function(frame) 46 | 47 | 48 | def test_frame_info(): 49 | frame = inspect.currentframe() 50 | 51 | assert frame 52 | assert stat_profile_c.get_frame_info(frame) == stat_profile_python.get_frame_info(frame) 53 | 54 | 55 | def test_frame_info_hide_true(): 56 | __tracebackhide__ = True 57 | 58 | frame = inspect.currentframe() 59 | 60 | assert frame 61 | assert stat_profile_c.get_frame_info(frame) == stat_profile_python.get_frame_info(frame) 62 | 63 | 64 | def test_frame_info_hide_false(): 65 | """to avoid calling FastToLocals on the c side, 66 | __tracebackhide__ = True 67 | and 68 | __tracebackhide__ = False 69 | are treated the same. All that matters is that the var is defined 70 | """ 71 | __tracebackhide__ = False 72 | 73 | frame = inspect.currentframe() 74 | 75 | assert frame 76 | assert stat_profile_c.get_frame_info(frame) == stat_profile_python.get_frame_info(frame) 77 | 78 | 79 | instance = AClass() 80 | 81 | 82 | @pytest.mark.parametrize( 83 | "test_function", 84 | [ 85 | instance.get_frame_info_for_a_method, 86 | AClass.get_frame_info_for_a_class_method, 87 | instance.get_frame_info_with_cell_variable, 88 | AClass.get_frame_info_for_a_class_method_where_cls_is_reassigned, 89 | ], 90 | ) 91 | @pytest.mark.parametrize("del_local", [True, False]) 92 | def test_frame_info_with_classes(test_function, del_local): 93 | c_frame_info = test_function(stat_profile_c.get_frame_info, del_local=del_local) 94 | py_frame_info = test_function(stat_profile_python.get_frame_info, del_local=del_local) 95 | 96 | assert c_frame_info == py_frame_info 97 | -------------------------------------------------------------------------------- /test/low_level/test_setstatprofile.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from ..util import busy_wait, flaky_in_ci 8 | from .util import parametrize_setstatprofile 9 | 10 | 11 | class CallCounter: 12 | def __init__(self) -> None: 13 | self.count = 0 14 | 15 | def __call__(self, *args: Any, **kwds: Any) -> Any: 16 | self.count += 1 17 | 18 | 19 | @flaky_in_ci 20 | @parametrize_setstatprofile 21 | def test_100ms(setstatprofile): 22 | counter = CallCounter() 23 | setstatprofile(counter, 0.1) 24 | busy_wait(1.0) 25 | setstatprofile(None) 26 | assert 8 < counter.count < 12 27 | 28 | 29 | @flaky_in_ci 30 | @parametrize_setstatprofile 31 | def test_10ms(setstatprofile): 32 | counter = CallCounter() 33 | setstatprofile(counter, 0.01) 34 | busy_wait(1.0) 35 | setstatprofile(None) 36 | assert 70 <= counter.count <= 130 37 | 38 | 39 | @parametrize_setstatprofile 40 | def test_internal_object_compatibility(setstatprofile): 41 | setstatprofile(CallCounter(), 1e6) 42 | 43 | profile_state = sys.getprofile() 44 | 45 | print(repr(profile_state)) 46 | print(str(profile_state)) 47 | print(profile_state) 48 | print(type(profile_state)) 49 | print(type(profile_state).__name__) # type: ignore 50 | 51 | setstatprofile(None) 52 | -------------------------------------------------------------------------------- /test/low_level/test_threaded.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | import time 5 | from typing import Any, List 6 | from unittest import TestCase 7 | 8 | import pytest 9 | 10 | from pyinstrument.low_level.stat_profile import setstatprofile 11 | 12 | from ..util import busy_wait, do_nothing 13 | 14 | 15 | class CallCounter: 16 | def __init__(self, thread) -> None: 17 | self.thread = thread 18 | self.count = 0 19 | 20 | def __call__(self, *args: Any, **kwds: Any) -> Any: 21 | assert self.thread is threading.current_thread() 22 | self.count += 1 23 | 24 | 25 | def test_threaded(): 26 | # assert that each thread gets its own callbacks, and check that it 27 | # doesn't crash! 28 | 29 | counters: list[CallCounter | None] = [None for _ in range(10)] 30 | stop = False 31 | 32 | def profile_a_busy_wait(i): 33 | thread = threads[i] 34 | counter = CallCounter(thread) 35 | counters[i] = counter 36 | 37 | setstatprofile(counter, 0.001) 38 | while not stop: 39 | do_nothing() 40 | setstatprofile(None) 41 | 42 | threads = [threading.Thread(target=profile_a_busy_wait, args=(i,)) for i in range(10)] 43 | for thread in threads: 44 | thread.start() 45 | 46 | while not stop: 47 | stop = all(c is not None and c.count > 10 for c in counters) 48 | 49 | for thread in threads: 50 | thread.join() 51 | -------------------------------------------------------------------------------- /test/low_level/test_timing_thread.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | import sys 4 | import time 5 | 6 | import pyinstrument.low_level.stat_profile as native_module 7 | 8 | from ..util import busy_wait, flaky_in_ci 9 | 10 | lib = ctypes.CDLL(native_module.__file__) 11 | 12 | pyi_timing_thread_subscribe = lib.pyi_timing_thread_subscribe 13 | pyi_timing_thread_subscribe.argtypes = [ctypes.c_double] 14 | pyi_timing_thread_subscribe.restype = ctypes.c_int 15 | 16 | pyi_timing_thread_get_time = lib.pyi_timing_thread_get_time 17 | pyi_timing_thread_get_time.argtypes = [] 18 | pyi_timing_thread_get_time.restype = ctypes.c_double 19 | 20 | pyi_timing_thread_get_interval = lib.pyi_timing_thread_get_interval 21 | pyi_timing_thread_get_interval.argtypes = [] 22 | pyi_timing_thread_get_interval.restype = ctypes.c_double 23 | 24 | pyi_timing_thread_unsubscribe = lib.pyi_timing_thread_unsubscribe 25 | pyi_timing_thread_unsubscribe.argtypes = [ctypes.c_int] 26 | pyi_timing_thread_unsubscribe.restype = ctypes.c_int 27 | 28 | PYI_TIMING_THREAD_UNKNOWN_ERROR = -1 29 | PYI_TIMING_THREAD_TOO_MANY_SUBSCRIBERS = -2 30 | 31 | 32 | if sys.platform == "win32": 33 | # on windows, the thread scheduling 'quanta', the time that a thread can run 34 | # before potentially being pre-empted, is 20-30ms. This means that the 35 | # worst-case, we have to wait 30ms before the timing thread gets a chance to 36 | # run. This isn't really a huge problem in practice, because thread-based 37 | # timing isn't much use on windows, since the synchronous timing functions are 38 | # so fast. 39 | WAIT_TIME = 0.03 40 | elif os.environ.get("QEMU_EMULATED"): 41 | # the scheduler seems slower under emulation 42 | WAIT_TIME = 0.2 43 | else: 44 | WAIT_TIME = 0.015 45 | 46 | 47 | @flaky_in_ci 48 | def test(): 49 | # check the thread isn't running to begin with 50 | assert pyi_timing_thread_get_interval() == -1 51 | 52 | time_before = pyi_timing_thread_get_time() 53 | time.sleep(WAIT_TIME) 54 | assert pyi_timing_thread_get_time() == time_before 55 | 56 | # subscribe 57 | subscription_id = pyi_timing_thread_subscribe(0.001) 58 | try: 59 | assert subscription_id >= 0 60 | 61 | assert pyi_timing_thread_get_interval() == 0.001 62 | 63 | # check it's updating 64 | busy_wait(WAIT_TIME) 65 | time_a = pyi_timing_thread_get_time() 66 | assert time_a > time_before 67 | busy_wait(WAIT_TIME) 68 | time_b = pyi_timing_thread_get_time() 69 | assert time_b > time_a 70 | 71 | # unsubscribe 72 | assert pyi_timing_thread_unsubscribe(subscription_id) == 0 73 | 74 | assert pyi_timing_thread_get_interval() == -1 75 | 76 | # check it's stopped updating 77 | time.sleep(WAIT_TIME) 78 | time_c = pyi_timing_thread_get_time() 79 | time.sleep(WAIT_TIME) 80 | time_d = pyi_timing_thread_get_time() 81 | assert time_c == time_d 82 | finally: 83 | # ensure the subscriber is removed even if the test fails 84 | pyi_timing_thread_unsubscribe(subscription_id) 85 | 86 | 87 | def test_max_subscribers(): 88 | subscription_ids = [] 89 | 90 | try: 91 | for i in range(1000): 92 | subscription_id = pyi_timing_thread_subscribe(0.001) 93 | assert subscription_id >= 0 94 | subscription_ids.append(subscription_id) 95 | 96 | # the next one should fail 97 | assert pyi_timing_thread_subscribe(0.001) == PYI_TIMING_THREAD_TOO_MANY_SUBSCRIBERS 98 | 99 | # unsubscribe them in FIFO order 100 | for subscription_id in subscription_ids: 101 | assert pyi_timing_thread_get_interval() == 0.001 102 | assert pyi_timing_thread_unsubscribe(subscription_id) == 0 103 | 104 | # check there are no subscribers left 105 | assert pyi_timing_thread_get_interval() == -1 106 | finally: 107 | # ensure all subscription ids are removed even if the test fails 108 | while subscription_ids: 109 | subscription_id = subscription_ids.pop() 110 | pyi_timing_thread_unsubscribe(subscription_id) 111 | -------------------------------------------------------------------------------- /test/low_level/util.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import pytest 4 | 5 | from pyinstrument.low_level.stat_profile import setstatprofile as setstatprofile_c 6 | from pyinstrument.low_level.stat_profile_python import setstatprofile as setstatprofile_python 7 | 8 | """ 9 | Parametrizes the test with both the C and Python setstatprofile, just to check 10 | that the Python one is up-to-date with the C version. 11 | """ 12 | parametrize_setstatprofile = pytest.mark.parametrize( 13 | "setstatprofile", 14 | [setstatprofile_c, setstatprofile_python], 15 | ) 16 | -------------------------------------------------------------------------------- /test/test_cmdline_main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from pyinstrument.__main__ import main 6 | from pyinstrument.renderers.base import FrameRenderer 7 | 8 | from .util import BUSY_WAIT_SCRIPT 9 | 10 | fake_renderer_instance = None 11 | 12 | 13 | class FakeRenderer(FrameRenderer): 14 | def __init__(self, time=None, **kwargs): 15 | self.time = time 16 | super().__init__(**kwargs) 17 | global fake_renderer_instance 18 | fake_renderer_instance = self 19 | print("instance") 20 | 21 | def default_processors(self): 22 | """ 23 | Return a list of processors that this renderer uses by default. 24 | """ 25 | return [] 26 | 27 | def render(self, session) -> str: 28 | return "" 29 | 30 | 31 | def test_renderer_option(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): 32 | (tmp_path / "test_program.py").write_text(BUSY_WAIT_SCRIPT) 33 | monkeypatch.setattr( 34 | "sys.argv", 35 | [ 36 | "pyinstrument", 37 | "-r", 38 | "test.test_cmdline_main.FakeRenderer", 39 | "-p", 40 | "time=percent_of_total", 41 | "test_program.py", 42 | ], 43 | ) 44 | monkeypatch.chdir(tmp_path) 45 | 46 | global fake_renderer_instance 47 | fake_renderer_instance = None 48 | 49 | main() 50 | 51 | assert fake_renderer_instance is not None 52 | assert fake_renderer_instance.time == "percent_of_total" 53 | 54 | 55 | def test_json_renderer_option(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): 56 | (tmp_path / "test_program.py").write_text(BUSY_WAIT_SCRIPT) 57 | monkeypatch.setattr( 58 | "sys.argv", 59 | [ 60 | "pyinstrument", 61 | "-r", 62 | "test.test_cmdline_main.FakeRenderer", 63 | "-p", 64 | 'processor_options={"some_option": 44}', 65 | "test_program.py", 66 | ], 67 | ) 68 | monkeypatch.chdir(tmp_path) 69 | 70 | global fake_renderer_instance 71 | fake_renderer_instance = None 72 | 73 | main() 74 | 75 | assert fake_renderer_instance is not None 76 | assert fake_renderer_instance.processor_options["some_option"] == 44 77 | 78 | 79 | def test_dotted_renderer_option(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): 80 | (tmp_path / "test_program.py").write_text(BUSY_WAIT_SCRIPT) 81 | monkeypatch.setattr( 82 | "sys.argv", 83 | [ 84 | "pyinstrument", 85 | "-r", 86 | "test.test_cmdline_main.FakeRenderer", 87 | "-p", 88 | "processor_options.other_option=13", 89 | "test_program.py", 90 | ], 91 | ) 92 | monkeypatch.chdir(tmp_path) 93 | 94 | global fake_renderer_instance 95 | fake_renderer_instance = None 96 | 97 | main() 98 | 99 | assert fake_renderer_instance is not None 100 | assert fake_renderer_instance.processor_options["other_option"] == 13 101 | -------------------------------------------------------------------------------- /test/test_context_manager.py: -------------------------------------------------------------------------------- 1 | from test.fake_time_util import fake_time 2 | 3 | import pytest 4 | 5 | import pyinstrument 6 | from pyinstrument.context_manager import ProfileContext 7 | 8 | 9 | def test_profile_context_decorator(capfd): 10 | with fake_time() as clock: 11 | 12 | @pyinstrument.profile 13 | def my_function(): 14 | clock.sleep(1.0) 15 | 16 | my_function() 17 | 18 | out, err = capfd.readouterr() 19 | print(err) 20 | assert "Function test_profile_context_decorator" in err 21 | assert "1.000 my_function" in err 22 | 23 | 24 | def test_profile_context_manager(capfd): 25 | with fake_time() as clock: 26 | with pyinstrument.profile(): 27 | 28 | def my_function(): 29 | clock.sleep(1.0) 30 | 31 | my_function() 32 | 33 | out, err = capfd.readouterr() 34 | print(err) 35 | assert "Block at" in err 36 | assert "1.000 my_function" in err 37 | -------------------------------------------------------------------------------- /test/test_ipython_magic.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import textwrap 3 | from test.fake_time_util import fake_time 4 | from threading import Thread 5 | from time import sleep 6 | 7 | import pytest 8 | 9 | # note: IPython should be imported within each test. Importing it in our tests 10 | # seems to cause problems with subsequent tests. 11 | 12 | cell_code = """ 13 | import time 14 | 15 | def function_a(): 16 | function_b() 17 | function_c() 18 | 19 | def function_b(): 20 | function_d() 21 | 22 | def function_c(): 23 | function_d() 24 | 25 | def function_d(): 26 | function_e() 27 | 28 | def function_e(): 29 | time.sleep(0.1) 30 | 31 | function_a() 32 | """ 33 | 34 | # Tests # 35 | 36 | 37 | @pytest.mark.ipythonmagic 38 | def test_magics(ip): 39 | from IPython.utils.capture import capture_output as capture_ipython_output 40 | 41 | with fake_time(): 42 | with capture_ipython_output() as captured: 43 | ip.run_cell_magic("pyinstrument", line="", cell=cell_code) 44 | 45 | assert len(captured.outputs) == 1 46 | output = captured.outputs[0] 47 | assert "text/html" in output.data 48 | assert "text/plain" in output.data 49 | 50 | assert "function_a" in output.data["text/html"] 51 | assert " 0 55 | # The graph is 56 | # a() -> b() -> d() -> e() -> time.sleep() 57 | # \-> c() / 58 | # so make sure d has callers of b, c, and that the times make sense 59 | 60 | # in stats, 61 | # keys are tuples (file_path, line, func) 62 | # values are tuples (calltime, numcalls, selftime, cumtime, callers) 63 | # in callers, 64 | # keys are the same as in stats 65 | # values are the same as stats but without callers 66 | 67 | # check the time of d 68 | d_key = [k for k in stats.stats.keys() if k[2] == "d"][0] 69 | d_val = stats.stats[d_key] 70 | d_cumtime = d_val[3] 71 | assert d_cumtime == pytest.approx(2) 72 | 73 | # check d's callers times are split 74 | b_key = [k for k in stats.stats.keys() if k[2] == "b"][0] 75 | c_key = [k for k in stats.stats.keys() if k[2] == "c"][0] 76 | d_callers = d_val[4] 77 | b_cumtime = d_callers[b_key][3] 78 | c_cumtime = d_callers[c_key][3] 79 | assert b_cumtime == pytest.approx(1) 80 | assert c_cumtime == pytest.approx(1) 81 | 82 | # check the time of e 83 | e_key = [k for k in stats.stats.keys() if k[2] == "e"][0] 84 | e_val = stats.stats[e_key] 85 | e_cumtime = e_val[3] 86 | assert e_cumtime == pytest.approx(2) 87 | 88 | 89 | def test_round_trip_encoding_of_binary_data(tmp_path: Path): 90 | # as used by the pstats renderer 91 | data_blob = os.urandom(1024) 92 | file = tmp_path / "file.dat" 93 | 94 | data_blob_string = data_blob.decode(encoding="utf-8", errors="surrogateescape") 95 | 96 | # newline='' is required to prevent the default newline translation 97 | with open(file, mode="w", encoding="utf-8", errors="surrogateescape", newline="") as f: 98 | f.write(data_blob_string) 99 | 100 | assert data_blob == data_blob_string.encode(encoding="utf-8", errors="surrogateescape") 101 | assert data_blob == file.read_bytes() 102 | 103 | 104 | def sleep_and_busy_wait(clock: FakeClock): 105 | time.sleep(1.0) 106 | # this looks like a busy wait to the profiler 107 | clock.time += 1.0 108 | 109 | 110 | def test_sum_of_tottime(tmp_path): 111 | # Check that the sum of the tottime of all the functions is equal to the 112 | # total time of the profile 113 | 114 | with fake_time() as clock: 115 | profiler = Profiler() 116 | profiler.start() 117 | 118 | sleep_and_busy_wait(clock) 119 | 120 | profiler.stop() 121 | profiler_session = profiler.last_session 122 | 123 | assert profiler_session 124 | 125 | pstats_data = PstatsRenderer().render(profiler_session) 126 | fname = tmp_path / "test.pstats" 127 | with open(fname, "wb") as fid: 128 | fid.write(pstats_data.encode(encoding="utf-8", errors="surrogateescape")) 129 | stats: Any = Stats(str(fname)) 130 | assert stats.total_tt == pytest.approx(2) 131 | -------------------------------------------------------------------------------- /test/test_renderers.py: -------------------------------------------------------------------------------- 1 | # some tests for the renderer classes 2 | 3 | from __future__ import annotations 4 | 5 | import time 6 | 7 | import pytest 8 | 9 | from pyinstrument import renderers 10 | from pyinstrument.profiler import Profiler 11 | 12 | from .fake_time_util import fake_time 13 | 14 | # utils 15 | 16 | frame_renderer_classes: list[type[renderers.FrameRenderer]] = [ 17 | renderers.ConsoleRenderer, 18 | renderers.JSONRenderer, 19 | renderers.PstatsRenderer, 20 | renderers.SpeedscopeRenderer, 21 | ] 22 | 23 | parametrize_frame_renderer_class = pytest.mark.parametrize( 24 | "frame_renderer_class", frame_renderer_classes, ids=lambda c: c.__name__ 25 | ) 26 | 27 | # fixtures 28 | 29 | 30 | def a(): 31 | b() 32 | c() 33 | 34 | 35 | def b(): 36 | d() 37 | 38 | 39 | def c(): 40 | d() 41 | 42 | 43 | def d(): 44 | e() 45 | 46 | 47 | def e(): 48 | time.sleep(1) 49 | 50 | 51 | @pytest.fixture(scope="module") 52 | def profiler_session(): 53 | with fake_time(): 54 | profiler = Profiler() 55 | profiler.start() 56 | 57 | a() 58 | 59 | profiler.stop() 60 | return profiler.last_session 61 | 62 | 63 | # tests 64 | 65 | 66 | @parametrize_frame_renderer_class 67 | def test_empty_profile(frame_renderer_class: type[renderers.FrameRenderer]): 68 | with Profiler() as profiler: 69 | pass 70 | profiler.output(renderer=frame_renderer_class()) 71 | 72 | 73 | @parametrize_frame_renderer_class 74 | def test_timeline_doesnt_crash( 75 | profiler_session, frame_renderer_class: type[renderers.FrameRenderer] 76 | ): 77 | renderer = frame_renderer_class(timeline=True) 78 | renderer.render(profiler_session) 79 | 80 | 81 | @parametrize_frame_renderer_class 82 | def test_show_all_doesnt_crash( 83 | profiler_session, frame_renderer_class: type[renderers.FrameRenderer] 84 | ): 85 | renderer = frame_renderer_class(show_all=True) 86 | renderer.render(profiler_session) 87 | 88 | 89 | @pytest.mark.parametrize("flat_time", ["self", "total"]) 90 | def test_console_renderer_flat_doesnt_crash(profiler_session, flat_time): 91 | renderer = renderers.ConsoleRenderer(flat=True, flat_time=flat_time) 92 | renderer.render(profiler_session) 93 | -------------------------------------------------------------------------------- /test/test_stack_sampler.py: -------------------------------------------------------------------------------- 1 | import contextvars 2 | import sys 3 | import time 4 | 5 | import pytest 6 | 7 | from pyinstrument import stack_sampler 8 | 9 | from .util import do_nothing, flaky_in_ci, tidy_up_profiler_state_on_fail 10 | 11 | 12 | class SampleCounter: 13 | count = 0 14 | 15 | def sample(self, stack, time, async_state): 16 | self.count += 1 17 | 18 | 19 | def test_create(): 20 | sampler = stack_sampler.get_stack_sampler() 21 | assert sampler is not None 22 | 23 | assert sampler is stack_sampler.get_stack_sampler() 24 | 25 | 26 | @flaky_in_ci 27 | @tidy_up_profiler_state_on_fail 28 | def test_get_samples(): 29 | sampler = stack_sampler.get_stack_sampler() 30 | counter = SampleCounter() 31 | 32 | assert sys.getprofile() is None 33 | sampler.subscribe(counter.sample, desired_interval=0.001, use_async_context=True) 34 | assert sys.getprofile() is not None 35 | assert len(sampler.subscribers) == 1 36 | 37 | start = time.time() 38 | while time.time() < start + 1 and counter.count == 0: 39 | do_nothing() 40 | 41 | assert counter.count > 0 42 | 43 | assert sys.getprofile() is not None 44 | sampler.unsubscribe(counter.sample) 45 | assert sys.getprofile() is None 46 | 47 | assert len(sampler.subscribers) == 0 48 | 49 | 50 | @flaky_in_ci 51 | @tidy_up_profiler_state_on_fail 52 | def test_multiple_samplers(): 53 | sampler = stack_sampler.get_stack_sampler() 54 | counter_1 = SampleCounter() 55 | counter_2 = SampleCounter() 56 | 57 | sampler.subscribe(counter_1.sample, desired_interval=0.001, use_async_context=False) 58 | sampler.subscribe(counter_2.sample, desired_interval=0.001, use_async_context=False) 59 | 60 | assert len(sampler.subscribers) == 2 61 | 62 | start = time.time() 63 | while time.time() < start + 1 and counter_1.count == 0 and counter_2.count == 0: 64 | do_nothing() 65 | 66 | assert counter_1.count > 0 67 | assert counter_2.count > 0 68 | 69 | assert sys.getprofile() is not None 70 | 71 | sampler.unsubscribe(counter_1.sample) 72 | sampler.unsubscribe(counter_2.sample) 73 | 74 | assert sys.getprofile() is None 75 | 76 | assert len(sampler.subscribers) == 0 77 | 78 | 79 | def test_multiple_samplers_async_error(): 80 | sampler = stack_sampler.get_stack_sampler() 81 | 82 | counter_1 = SampleCounter() 83 | counter_2 = SampleCounter() 84 | 85 | sampler.subscribe(counter_1.sample, desired_interval=0.001, use_async_context=True) 86 | 87 | with pytest.raises(RuntimeError): 88 | sampler.subscribe(counter_2.sample, desired_interval=0.001, use_async_context=True) 89 | 90 | sampler.unsubscribe(counter_1.sample) 91 | 92 | 93 | @flaky_in_ci 94 | @tidy_up_profiler_state_on_fail 95 | def test_multiple_contexts(): 96 | sampler = stack_sampler.get_stack_sampler() 97 | 98 | counter_1 = SampleCounter() 99 | counter_2 = SampleCounter() 100 | 101 | context_1 = contextvars.copy_context() 102 | context_2 = contextvars.copy_context() 103 | 104 | assert sys.getprofile() is None 105 | assert len(sampler.subscribers) == 0 106 | context_1.run( 107 | sampler.subscribe, target=counter_1.sample, desired_interval=0.001, use_async_context=True 108 | ) 109 | context_2.run( 110 | sampler.subscribe, target=counter_2.sample, desired_interval=0.001, use_async_context=True 111 | ) 112 | 113 | assert sys.getprofile() is not None 114 | assert len(sampler.subscribers) == 2 115 | 116 | start = time.time() 117 | while time.time() < start + 1 and counter_1.count == 0 and counter_2.count == 0: 118 | do_nothing() 119 | 120 | assert counter_1.count > 0 121 | assert counter_2.count > 0 122 | 123 | assert sys.getprofile() is not None 124 | 125 | context_1.run(sampler.unsubscribe, counter_1.sample) 126 | context_2.run(sampler.unsubscribe, counter_2.sample) 127 | 128 | assert sys.getprofile() is None 129 | 130 | assert len(sampler.subscribers) == 0 131 | -------------------------------------------------------------------------------- /test/test_threading.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from test.fake_time_util import fake_time 4 | 5 | import pytest 6 | 7 | from pyinstrument import Profiler 8 | 9 | from .util import do_nothing 10 | 11 | 12 | def test_profiler_access_from_multiple_threads(): 13 | profiler = Profiler() 14 | 15 | profiler.start() 16 | 17 | thread_exception = None 18 | 19 | def helper(): 20 | while profiler._active_session and len(profiler._active_session.frame_records) < 10: 21 | time.sleep(0.0001) 22 | 23 | try: 24 | profiler.stop() 25 | except Exception as e: 26 | nonlocal thread_exception 27 | thread_exception = e 28 | 29 | t1 = threading.Thread(target=helper) 30 | t1.start() 31 | 32 | while t1.is_alive(): 33 | do_nothing() 34 | t1.join() 35 | 36 | with pytest.raises(Exception) as excinfo: 37 | profiler.output_html() 38 | 39 | assert "this profiler is still running" in excinfo.value.args[0] 40 | 41 | assert thread_exception is not None 42 | assert ( 43 | "Failed to stop profiling. Make sure that you start/stop profiling on the same thread." 44 | in thread_exception.args[0] 45 | ) 46 | 47 | # the above stop failed. actually stop the profiler 48 | profiler.stop() 49 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | import time 5 | from typing import Callable, Generator, Generic, Iterable, Iterator, NoReturn, Optional, TypeVar 6 | 7 | from flaky import flaky 8 | 9 | from pyinstrument import stack_sampler 10 | from pyinstrument.frame import SYNTHETIC_LEAF_IDENTIFIERS, Frame 11 | from pyinstrument.profiler import Profiler 12 | from pyinstrument.session import Session 13 | 14 | if "CI" in os.environ: 15 | # a decorator that allows some test flakiness in CI environments, presumably 16 | # due to contention. Useful for tests that rely on real time measurements. 17 | flaky_in_ci = flaky(max_runs=5, min_passes=1) 18 | else: 19 | flaky_in_ci = lambda a: a 20 | 21 | 22 | def assert_never(x: NoReturn) -> NoReturn: 23 | raise AssertionError(f"Invalid value: {x!r}") 24 | 25 | 26 | def do_nothing(): 27 | pass 28 | 29 | 30 | def busy_wait(duration): 31 | end_time = time.time() + duration 32 | 33 | while time.time() < end_time: 34 | do_nothing() 35 | 36 | 37 | def walk_frames(frame: Frame) -> Generator[Frame, None, None]: 38 | yield frame 39 | 40 | for f in frame.children: 41 | yield from walk_frames(f) 42 | 43 | 44 | T = TypeVar("T") 45 | 46 | 47 | def first(iterator: Iterator[T]) -> Optional[T]: 48 | try: 49 | return next(iterator) 50 | except StopIteration: 51 | return None 52 | 53 | 54 | def calculate_frame_tree_times(frame: Frame): 55 | # assuming that the leaf nodes of a frame tree have correct, time values, 56 | # calculate the times of all nodes in the frame tree 57 | 58 | child_time_sum = 0.0 59 | 60 | for child in frame.children: 61 | if child.identifier not in SYNTHETIC_LEAF_IDENTIFIERS: 62 | calculate_frame_tree_times(child) 63 | 64 | child_time_sum += child.time 65 | 66 | frame.time = child_time_sum + frame.absorbed_time 67 | 68 | 69 | BUSY_WAIT_SCRIPT = """ 70 | import time, sys 71 | 72 | def do_nothing(): 73 | pass 74 | 75 | def busy_wait(duration): 76 | end_time = time.time() + duration 77 | 78 | while time.time() < end_time: 79 | do_nothing() 80 | 81 | def main(): 82 | print('sys.argv: ', sys.argv) 83 | busy_wait(0.1) 84 | 85 | 86 | if __name__ == '__main__': 87 | main() 88 | """ 89 | 90 | 91 | def dummy_session() -> Session: 92 | return Session( 93 | frame_records=[], 94 | start_time=0, 95 | min_interval=0.1, 96 | max_interval=0.1, 97 | duration=0, 98 | sample_count=0, 99 | start_call_stack=[], 100 | target_description="dummy", 101 | cpu_time=0, 102 | sys_path=sys.path, 103 | sys_prefixes=Session.current_sys_prefixes(), 104 | ) 105 | 106 | 107 | def tidy_up_profiler_state_on_fail(func: Callable) -> Callable[[], None]: 108 | """ 109 | Useful inside a test that's flaky in CI, where the check_sampler_state 110 | fixture only gets to run at the end of all flaky attempts. 111 | """ 112 | # consider adding to the flasky_in_ci decorator if it's useful elsewhere 113 | 114 | def wrapped(): 115 | try: 116 | func() 117 | except BaseException: 118 | sys.setprofile(None) 119 | stack_sampler.thread_locals.__dict__.clear() 120 | raise 121 | 122 | return wrapped 123 | --------------------------------------------------------------------------------