From c38e33dd365d063d0f90b5f9cf6f2e8d08e13c91 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 18:39:40 +0100 Subject: [PATCH 01/14] Harden native profiler error handling Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/low_level/stat_profile.c | 45 ++++++++++++++++++++++----- test/low_level/test_custom_timer.py | 32 +++++++++++++++++++ test/low_level/test_frame_info.py | 12 +++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/pyinstrument/low_level/stat_profile.c b/pyinstrument/low_level/stat_profile.c index 273ab9bf..3268cafa 100644 --- a/pyinstrument/low_level/stat_profile.c +++ b/pyinstrument/low_level/stat_profile.c @@ -96,6 +96,7 @@ static double ProfilerState_GetTime(ProfilerState *self) { if (!PyFloat_Check(result)) { PyErr_SetString(PyExc_RuntimeError, "custom time function must return a float"); + Py_DECREF(result); return -1.0; } @@ -299,6 +300,11 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { return NULL; } + if (PyTuple_GET_SIZE(localsNames) == 0) { + Py_DECREF(localsNames); + return NULL; + } + PyObject *firstArgName = PyTuple_GET_ITEM(localsNames, 0); if (firstArgName == NULL) { @@ -321,8 +327,8 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { PyObject *locals = PyFrame_GetLocals(frame); - if (!PyMapping_Check(locals)) { - Py_DECREF(locals); + if (locals == NULL || !PyMapping_Check(locals)) { + Py_XDECREF(locals); return NULL; } @@ -496,10 +502,15 @@ static PyObject * _get_frame_info(PyFrameObject *frame) { PyCodeObject *code = code_from_frame(frame); - PyObject *class_name_attribute; + PyObject *class_name_attribute = NULL; + PyObject *line_number_attribute = NULL; + PyObject *frame_hidden_attribute = NULL; const char *class_name = _get_class_name_of_frame(frame, code); if (class_name == NULL) { + if (PyErr_Occurred()) { + goto error; + } class_name_attribute = PyUnicode_New(0, 127); // empty string } else { class_name_attribute = PyUnicode_FromFormat( @@ -509,8 +520,9 @@ _get_frame_info(PyFrameObject *frame) { class_name ); } - - PyObject *line_number_attribute; + if (class_name_attribute == NULL) { + goto error; + } int line_number = PyFrame_GetLineNumber(frame); if (line_number < 1) { @@ -523,8 +535,9 @@ _get_frame_info(PyFrameObject *frame) { line_number ); } - - PyObject *frame_hidden_attribute; + if (line_number_attribute == NULL) { + goto error; + } int tracebackhide = _get_tracebackhide(frame, code); if (tracebackhide <= 0) { @@ -537,6 +550,9 @@ _get_frame_info(PyFrameObject *frame) { '1' // '1' char denotes 'true' ); } + if (frame_hidden_attribute == NULL) { + goto error; + } PyObject *result = PyUnicode_FromFormat( "%U%c%U%c%i%U%U%U", @@ -556,6 +572,13 @@ _get_frame_info(PyFrameObject *frame) { Py_DECREF(frame_hidden_attribute); return result; + +error: + Py_DECREF(code); + Py_XDECREF(class_name_attribute); + Py_XDECREF(line_number_attribute); + Py_XDECREF(frame_hidden_attribute); + return NULL; } static int @@ -651,6 +674,11 @@ profile(PyObject *op, PyFrameObject *frame, int what, PyObject *arg) if ((what == WHAT_RETURN) && (code->co_flags & 0x80)) { PyObject *frame_identifier = _get_frame_info(frame); + if (frame_identifier == NULL) { + Py_DECREF(code); + PyEval_SetProfile(NULL, NULL); + return -1; + } int status = PyList_Append(pState->await_stack_list, frame_identifier); Py_DECREF(frame_identifier); @@ -769,6 +797,9 @@ setstatprofile(PyObject *m, PyObject *args, PyObject *kwds) // initialise the last invocation to avoid immediate callback pState->last_invocation = ProfilerState_GetTime(pState); + if (pState->last_invocation == -1.0 && PyErr_Occurred()) { + goto error; + } if (context_var) { Py_INCREF(context_var); diff --git a/test/low_level/test_custom_timer.py b/test/low_level/test_custom_timer.py index 493cc512..3d615e94 100644 --- a/test/low_level/test_custom_timer.py +++ b/test/low_level/test_custom_timer.py @@ -1,5 +1,12 @@ +import gc +import sys +import weakref from typing import Any +import pytest + +from pyinstrument.low_level.stat_profile import setstatprofile as setstatprofile_c + from .util import parametrize_setstatprofile @@ -32,3 +39,28 @@ def fake_sleep(duration): setstatprofile(None) assert counter.count == 100 + + +def test_invalid_timer_result_is_released(): + timer_result_ref = None + + class TimerResult: + pass + + def invalid_timer(): + nonlocal timer_result_ref + result = TimerResult() + timer_result_ref = weakref.ref(result) + return result + + with pytest.raises(RuntimeError, match="custom time function must return a float"): + setstatprofile_c( + CallCounter(), + timer_func=invalid_timer, # type: ignore[arg-type] + timer_type="timer_func", + ) + + assert sys.getprofile() is None + gc.collect() + assert timer_result_ref is not None + assert timer_result_ref() is None diff --git a/test/low_level/test_frame_info.py b/test/low_level/test_frame_info.py index 3bc911c5..70c94003 100644 --- a/test/low_level/test_frame_info.py +++ b/test/low_level/test_frame_info.py @@ -45,6 +45,10 @@ def get_frame_info_for_a_class_method_where_cls_is_reassigned(cls, getter_functi return getter_function(frame) +def frame_with_no_locals(): + return inspect.currentframe() + + def test_frame_info(): frame = inspect.currentframe() @@ -52,6 +56,14 @@ def test_frame_info(): assert stat_profile_c.get_frame_info(frame) == stat_profile_python.get_frame_info(frame) +def test_frame_info_with_no_locals(): + frame = frame_with_no_locals() + + assert frame + assert frame.f_code.co_varnames == () + assert stat_profile_c.get_frame_info(frame) == stat_profile_python.get_frame_info(frame) + + def test_frame_info_hide_true(): __tracebackhide__ = True From 8cf5369dbc3ad2851f0e45add4dd7315994d02ac Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 18:42:35 +0100 Subject: [PATCH 02/14] Roll back failed sampler subscriptions Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/stack_sampler.py | 40 ++++++++++++++++++++++------------- test/test_stack_sampler.py | 31 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/pyinstrument/stack_sampler.py b/pyinstrument/stack_sampler.py index fa115dc1..522dcca1 100644 --- a/pyinstrument/stack_sampler.py +++ b/pyinstrument/stack_sampler.py @@ -75,27 +75,37 @@ def subscribe( use_timing_thread: bool | None = None, use_async_context: bool, ): - if use_async_context: - if active_profiler_context_var.get() is not None: - raise RuntimeError( - "There is already a profiler running. You cannot run multiple profilers in the same thread or async context, unless you disable async support." - ) - active_profiler_context_var.set(target) + if use_async_context and active_profiler_context_var.get() is not None: + raise RuntimeError( + "There is already a profiler running. You cannot run multiple profilers in the same thread or async context, unless you disable async support." + ) existing_subscriber = next((s for s in self.subscribers if s.target == target), None) if existing_subscriber is not None: raise ValueError("This target is already subscribed to the stack sampler.") - self.subscribers.append( - StackSamplerSubscriber( - target=target, - desired_interval=desired_interval, - use_timing_thread=use_timing_thread, - bound_to_async_context=use_async_context, - async_state=AsyncState("in_context") if use_async_context else None, - ) + subscriber = StackSamplerSubscriber( + target=target, + desired_interval=desired_interval, + use_timing_thread=use_timing_thread, + bound_to_async_context=use_async_context, + async_state=AsyncState("in_context") if use_async_context else None, ) - self._update() + + context_token = None + if use_async_context: + context_token = active_profiler_context_var.set(target) + + try: + self.subscribers.append(subscriber) + self._update() + except BaseException: + if subscriber in self.subscribers: + self.subscribers.remove(subscriber) + if context_token is not None: + active_profiler_context_var.reset(context_token) + self._update() + raise def unsubscribe(self, target: StackSamplerSubscriberTarget): try: diff --git a/test/test_stack_sampler.py b/test/test_stack_sampler.py index f7b65b04..aeed2a04 100644 --- a/test/test_stack_sampler.py +++ b/test/test_stack_sampler.py @@ -141,3 +141,34 @@ def test_same_callback_twice_error(): sampler.subscribe(counter.sample, desired_interval=0.001, use_async_context=False) sampler.unsubscribe(counter.sample) + + +@tidy_up_profiler_state_on_fail +def test_failed_subscription_rolls_back_state(): + sampler = stack_sampler.get_stack_sampler() + counter_1 = SampleCounter() + counter_2 = SampleCounter() + + sampler.subscribe( + counter_1.sample, + desired_interval=0.001, + use_timing_thread=False, + use_async_context=False, + ) + active_profile = sys.getprofile() + + try: + with pytest.raises(ValueError, match="different timing thread preferences"): + sampler.subscribe( + counter_2.sample, + desired_interval=0.001, + use_timing_thread=True, + use_async_context=True, + ) + + assert [subscriber.target for subscriber in sampler.subscribers] == [counter_1.sample] + assert stack_sampler.active_profiler_context_var.get() is None + assert sys.getprofile() is active_profile + assert sampler.current_sampling_interval == 0.001 + finally: + sampler.unsubscribe(counter_1.sample) From 4b4c047369bffa3f5a2f0348da05207eeb3a3926 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 18:46:30 +0100 Subject: [PATCH 03/14] Handle sessions without a start call stack Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/session.py | 2 +- test/test_profiler.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pyinstrument/session.py b/pyinstrument/session.py index 3916cf4a..42543344 100644 --- a/pyinstrument/session.py +++ b/pyinstrument/session.py @@ -168,7 +168,7 @@ def _trim_stem(self, frame: Frame): start_stack = deque(frame_info_get_identifier(info) for info in self.start_call_stack) - if start_stack.popleft() != frame.identifier: + if not start_stack or start_stack.popleft() != frame.identifier: # the frame doesn't match where the profiler was started. Don't trim. return frame diff --git a/test/test_profiler.py b/test/test_profiler.py index 7468d854..2c0871fb 100644 --- a/test/test_profiler.py +++ b/test/test_profiler.py @@ -14,7 +14,7 @@ from pyinstrument.renderers.speedscope import SpeedscopeEvent, SpeedscopeEventType, SpeedscopeFrame from pyinstrument.session import Session -from .util import assert_never, busy_wait, flaky_in_ci +from .util import assert_never, busy_wait, dummy_session, flaky_in_ci # Utilities # @@ -43,6 +43,16 @@ def long_class_method(cls): # Tests # +def test_session_root_frame_with_no_start_call_stack(): + session = dummy_session() + session.frame_records = [(["root\x00file.py\x001"], 0.1)] + + root_frame = session.root_frame() + + assert root_frame is not None + assert root_frame.identifier == "root\x00file.py\x001" + + def test_collapses_multiple_calls_by_default(): profiler = Profiler() From 42cd892e7fbac0461271bd11f0353fb7e3e7286f Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 18:52:52 +0100 Subject: [PATCH 04/14] Clean up IPython async event loops Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/magic/magic.py | 8 ++--- test/test_ipython_magic.py | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/pyinstrument/magic/magic.py b/pyinstrument/magic/magic.py index 2689cd16..8ec83f07 100644 --- a/pyinstrument/magic/magic.py +++ b/pyinstrument/magic/magic.py @@ -298,17 +298,17 @@ def run_cell_async(self, ip, code): # # Please keep an eye on this issue to see if there's a better way: # https://github.com/ipython/ipython/issues/11314 - old_loop = asyncio.get_event_loop() loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever) try: - threading.Thread(target=loop.run_forever).start() - asyncio.set_event_loop(loop) + thread.start() coro = ip.run_cell_async(code) future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result() finally: loop.call_soon_threadsafe(loop.stop) - asyncio.set_event_loop(old_loop) + thread.join() + loop.close() IPYTHON_INTERNAL_FILES = ( diff --git a/test/test_ipython_magic.py b/test/test_ipython_magic.py index 20ab782a..ae3979f1 100644 --- a/test/test_ipython_magic.py +++ b/test/test_ipython_magic.py @@ -1,3 +1,4 @@ +import asyncio import signal import textwrap from test.fake_time_util import fake_time @@ -121,6 +122,63 @@ async def function_a(): assert "a: 42" in stdout +@pytest.mark.ipythonmagic +def test_run_cell_async_cleans_up_event_loop(monkeypatch): + from pyinstrument.magic.magic import PyinstrumentMagic + + class FakeIP: + async def run_cell_async(self, code): + await asyncio.sleep(0) + return code + + created_loops = [] + created_threads = [] + real_new_event_loop = asyncio.new_event_loop + + def new_event_loop(): + loop = real_new_event_loop() + created_loops.append(loop) + return loop + + def new_thread(*args, **kwargs): + thread = Thread(*args, **kwargs) + created_threads.append(thread) + return thread + + monkeypatch.setattr("pyinstrument.magic.magic.asyncio.new_event_loop", new_event_loop) + monkeypatch.setattr("pyinstrument.magic.magic.threading.Thread", new_thread) + + previous_loop = real_new_event_loop() + asyncio.set_event_loop(previous_loop) + try: + result = PyinstrumentMagic(shell=None).run_cell_async(FakeIP(), "result") + + assert result == "result" + assert asyncio.get_event_loop() is previous_loop + assert len(created_loops) == 1 + assert created_loops[0].is_closed() + assert len(created_threads) == 1 + assert not created_threads[0].is_alive() + finally: + asyncio.set_event_loop(None) + previous_loop.close() + + +@pytest.mark.ipythonmagic +def test_run_cell_async_without_current_event_loop(): + from pyinstrument.magic.magic import PyinstrumentMagic + + class FakeIP: + async def run_cell_async(self, code): + return code + + asyncio.set_event_loop(None) + + assert PyinstrumentMagic(shell=None).run_cell_async(FakeIP(), "result") == "result" + with pytest.raises(RuntimeError, match="There is no current event loop"): + asyncio.get_event_loop() + + # Utils # From 2c01a4fb748267838a0c268780867142cc69b921 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 18:57:50 +0100 Subject: [PATCH 05/14] Preprocess asynchronous IPython cells Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/magic/magic.py | 17 ++++++++++++++++- test/test_ipython_magic.py | 11 +++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/pyinstrument/magic/magic.py b/pyinstrument/magic/magic.py index 8ec83f07..14647156 100644 --- a/pyinstrument/magic/magic.py +++ b/pyinstrument/magic/magic.py @@ -2,6 +2,7 @@ import asyncio import html +import sys import threading import urllib.parse from ast import parse @@ -302,7 +303,21 @@ def run_cell_async(self, ip, code): thread = threading.Thread(target=loop.run_forever) try: thread.start() - coro = ip.run_cell_async(code) + if IPython.version_info >= (7, 17): # type: ignore + preprocessing_exc_tuple = None + try: + transformed_cell = ip.transform_cell(code) + except Exception: + transformed_cell = code + preprocessing_exc_tuple = sys.exc_info() + + coro = ip.run_cell_async( + code, + transformed_cell=transformed_cell, + preprocessing_exc_tuple=preprocessing_exc_tuple, + ) + else: + coro = ip.run_cell_async(code) future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result() finally: diff --git a/test/test_ipython_magic.py b/test/test_ipython_magic.py index ae3979f1..abc58e36 100644 --- a/test/test_ipython_magic.py +++ b/test/test_ipython_magic.py @@ -103,6 +103,7 @@ def test_pyinstrument_handles_interrupt_silently(ip, capsys): @pytest.mark.ipythonmagic +@pytest.mark.filterwarnings("error:.*run_cell_async.*transform_cell.*:DeprecationWarning") def test_async_cell_with_pyinstrument(ip, capsys): ip.run_cell_magic( "pyinstrument", @@ -127,7 +128,10 @@ def test_run_cell_async_cleans_up_event_loop(monkeypatch): from pyinstrument.magic.magic import PyinstrumentMagic class FakeIP: - async def run_cell_async(self, code): + def transform_cell(self, code): + return code + + async def run_cell_async(self, code, **kwargs): await asyncio.sleep(0) return code @@ -169,7 +173,10 @@ def test_run_cell_async_without_current_event_loop(): from pyinstrument.magic.magic import PyinstrumentMagic class FakeIP: - async def run_cell_async(self, code): + def transform_cell(self, code): + return code + + async def run_cell_async(self, code, **kwargs): return code asyncio.set_event_loop(None) From 8a8b4ca8673951fcb110665d5583e3df9d4c7be6 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 19:03:23 +0100 Subject: [PATCH 06/14] Add Python 3.14 CI coverage Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- .github/workflows/test.yml | 4 ++-- .github/workflows/wheels.yml | 10 +--------- noxfile.py | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 924ba045..f2a2da1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,11 +24,11 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] # also add an aarch64 test, just one python version include: - os: ubuntu-24.04-arm - python-version: "3.13" + python-version: "3.14" fail-fast: false steps: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7fc01051..10169e59 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -18,7 +18,7 @@ on: jobs: build_wheels: - name: Build wheels on ${{ matrix.archs }} for ${{ matrix.os }} + name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -27,10 +27,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up QEMU - if: ${{ matrix.archs == 'aarch64' }} - uses: docker/setup-qemu-action@v4 - - uses: actions/setup-python@v5 name: Install Python with: @@ -57,10 +53,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up QEMU - if: ${{ matrix.archs == 'aarch64' }} - uses: docker/setup-qemu-action@v4 - - uses: actions/setup-python@v5 name: Install Python with: diff --git a/noxfile.py b/noxfile.py index 945cc176..61694200 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,7 @@ nox.options.default_venv_backend = "uv|virtualenv" -@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) +@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]) def test(session): session.env["UV_PRERELEASE"] = "allow" session.install("-e", ".[test]", "setuptools") From 059ac11b10a54edff64f67f1722c9f70b279ddb3 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 19:11:44 +0100 Subject: [PATCH 07/14] Handle unset profiler context variables Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/low_level/stat_profile.c | 2 +- pyinstrument/low_level/stat_profile_python.py | 4 +-- test/low_level/test_context.py | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/pyinstrument/low_level/stat_profile.c b/pyinstrument/low_level/stat_profile.c index 3268cafa..61fda67f 100644 --- a/pyinstrument/low_level/stat_profile.c +++ b/pyinstrument/low_level/stat_profile.c @@ -60,7 +60,7 @@ static void ProfilerState_SetTarget(ProfilerState *self, PyObject *target) { static int ProfilerState_UpdateContextVar(ProfilerState *self) { PyObject *old = self->last_context_var_value; PyObject *new = NULL; - int status = PyContextVar_Get(self->context_var, NULL, &new); + int status = PyContextVar_Get(self->context_var, Py_None, &new); if (status == -1) { PyErr_SetString(PyExc_Exception, "failed to get value of the context var"); return 0; diff --git a/pyinstrument/low_level/stat_profile_python.py b/pyinstrument/low_level/stat_profile_python.py index c81a525a..c2698983 100644 --- a/pyinstrument/low_level/stat_profile_python.py +++ b/pyinstrument/low_level/stat_profile_python.py @@ -50,7 +50,7 @@ def __init__( self.last_invocation = self.get_time() - self.last_context_var_value = context_var.get() if context_var else None + self.last_context_var_value = context_var.get(None) if context_var else None self.await_stack = [] def __del__(self): @@ -61,7 +61,7 @@ def profile(self, frame: types.FrameType, event: str, arg: Any): now = self.get_time() if self.context_var: - context_var_value = self.context_var.get() + context_var_value = self.context_var.get(None) last_context_var_value = self.last_context_var_value if context_var_value is not last_context_var_value: diff --git a/test/low_level/test_context.py b/test/low_level/test_context.py index 8c384fca..9dcba09d 100644 --- a/test/low_level/test_context.py +++ b/test/low_level/test_context.py @@ -1,11 +1,15 @@ from __future__ import annotations import contextvars +import subprocess +import sys import time from typing import Any import pytest +from pyinstrument.low_level.stat_profile_python import setstatprofile as setstatprofile_python + from ..util import busy_wait from .util import parametrize_setstatprofile @@ -17,6 +21,37 @@ def test_context_type(setstatprofile): setstatprofile(None) +def test_context_without_default(): + result = subprocess.run( + [ + sys.executable, + "-c", + """ +import contextvars +from pyinstrument.low_level.stat_profile import setstatprofile + +context_var = contextvars.ContextVar("context_var") +setstatprofile(lambda frame, event, arg: None, context_var=context_var) +setstatprofile(None) +""", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_python_context_without_default(): + context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar("context_var") + + setstatprofile_python(lambda frame, event, arg: None, context_var=context_var) + try: + assert sys.getprofile() is not None + finally: + setstatprofile_python(None) + + profiler_context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar( "profiler_context_var", default=None ) From dde92f2a249215f5d75989d5f0b336f42f681f34 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 19:17:23 +0100 Subject: [PATCH 08/14] Run context regression tests in process Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- test/low_level/test_context.py | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/test/low_level/test_context.py b/test/low_level/test_context.py index 9dcba09d..294c7ece 100644 --- a/test/low_level/test_context.py +++ b/test/low_level/test_context.py @@ -1,15 +1,12 @@ from __future__ import annotations import contextvars -import subprocess import sys import time from typing import Any import pytest -from pyinstrument.low_level.stat_profile_python import setstatprofile as setstatprofile_python - from ..util import busy_wait from .util import parametrize_setstatprofile @@ -21,35 +18,15 @@ def test_context_type(setstatprofile): setstatprofile(None) -def test_context_without_default(): - result = subprocess.run( - [ - sys.executable, - "-c", - """ -import contextvars -from pyinstrument.low_level.stat_profile import setstatprofile - -context_var = contextvars.ContextVar("context_var") -setstatprofile(lambda frame, event, arg: None, context_var=context_var) -setstatprofile(None) -""", - ], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - - -def test_python_context_without_default(): +@parametrize_setstatprofile +def test_context_without_default(setstatprofile): context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar("context_var") - setstatprofile_python(lambda frame, event, arg: None, context_var=context_var) + setstatprofile(lambda frame, event, arg: None, context_var=context_var) try: assert sys.getprofile() is not None finally: - setstatprofile_python(None) + setstatprofile(None) profiler_context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar( From c7ada1e40796f598f45928e235e18863f665e04a Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 19:24:48 +0100 Subject: [PATCH 09/14] Preserve qualified class names in native frames Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/low_level/stat_profile.c | 43 ++++++++++++++++++--------- test/low_level/test_frame_info.py | 7 +++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/pyinstrument/low_level/stat_profile.c b/pyinstrument/low_level/stat_profile.c index 61fda67f..c10fe319 100644 --- a/pyinstrument/low_level/stat_profile.c +++ b/pyinstrument/low_level/stat_profile.c @@ -287,12 +287,29 @@ local_names_from_code(PyCodeObject *code) #endif } +/** + * Returns a new reference to the qualified name of a type. + */ +static PyObject * +_get_type_qualname(PyTypeObject *type) { + PyObject *qualname = PyObject_GetAttrString((PyObject *)type, "__qualname__"); + if (qualname == NULL) { + return NULL; + } + if (!PyUnicode_Check(qualname)) { + Py_DECREF(qualname); + PyErr_SetString(PyExc_TypeError, "type __qualname__ must be a string"); + return NULL; + } + return qualname; +} + #if PY_VERSION_HEX >= 0x030b0000 // Python 3.11.0 /** - * Returns a C-string containing the name of the class in the frame. The - * memory belongs to the type object, so it should not be freed. + * Returns a new reference to the qualified name of the class in the frame, + * or NULL when the frame does not represent a method. */ -static const char * +static PyObject * _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { PyObject *localsNames = PyCode_GetVarnames(code); @@ -323,7 +340,7 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { return NULL; } - const char *result = NULL; + PyObject *result = NULL; PyObject *locals = PyFrame_GetLocals(frame); @@ -343,7 +360,7 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { return NULL; } - result = _PyType_Name(self->ob_type); + result = _get_type_qualname(Py_TYPE(self)); Py_DECREF(self); } else if (has_cls && PyMapping_HasKey(locals, CLS_STRING)) { @@ -356,8 +373,7 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { } if (PyType_Check(cls)) { - PyTypeObject *type = (PyTypeObject *)cls; - result = _PyType_Name(type); + result = _get_type_qualname((PyTypeObject *)cls); } Py_DECREF(cls); } @@ -399,7 +415,7 @@ _get_first_arg_from_cell_variables(PyFrameObject *frame, PyCodeObject *code) { return NULL; } -static const char * +static PyObject * _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { // This code looks only at the first 'fast' frame local. // @@ -447,14 +463,12 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { } if (first_var_is_self) { - PyTypeObject *type = first_var->ob_type; - return _PyType_Name(type); + return _get_type_qualname(Py_TYPE(first_var)); } else if (first_var_is_cls) { if (!PyType_Check(first_var)) { return NULL; } - PyTypeObject *type = (PyTypeObject *)first_var; - return _PyType_Name(type); + return _get_type_qualname((PyTypeObject *)first_var); } else { Py_FatalError("unreachable code"); } @@ -506,7 +520,7 @@ _get_frame_info(PyFrameObject *frame) { PyObject *line_number_attribute = NULL; PyObject *frame_hidden_attribute = NULL; - const char *class_name = _get_class_name_of_frame(frame, code); + PyObject *class_name = _get_class_name_of_frame(frame, code); if (class_name == NULL) { if (PyErr_Occurred()) { goto error; @@ -514,11 +528,12 @@ _get_frame_info(PyFrameObject *frame) { class_name_attribute = PyUnicode_New(0, 127); // empty string } else { class_name_attribute = PyUnicode_FromFormat( - "%c%c%s", + "%c%c%U", 1, // 0x01 char denotes 'attribute' 'c', // 'c' char denotes 'class name' class_name ); + Py_DECREF(class_name); } if (class_name_attribute == NULL) { goto error; diff --git a/test/low_level/test_frame_info.py b/test/low_level/test_frame_info.py index 70c94003..a2aeac93 100644 --- a/test/low_level/test_frame_info.py +++ b/test/low_level/test_frame_info.py @@ -45,6 +45,11 @@ def get_frame_info_for_a_class_method_where_cls_is_reassigned(cls, getter_functi return getter_function(frame) +class OuterClass: + class InnerClass(AClass): + pass + + def frame_with_no_locals(): return inspect.currentframe() @@ -89,12 +94,14 @@ def test_frame_info_hide_false(): instance = AClass() +nested_instance = OuterClass.InnerClass() @pytest.mark.parametrize( "test_function", [ instance.get_frame_info_for_a_method, + nested_instance.get_frame_info_for_a_method, AClass.get_frame_info_for_a_class_method, instance.get_frame_info_with_cell_variable, AClass.get_frame_info_for_a_class_method_where_cls_is_reassigned, From 868b414995bb427a536b986b87b1c7e12b316dcc Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 19:33:30 +0100 Subject: [PATCH 10/14] Use public type qualname API on Python 3.11+ Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/low_level/stat_profile.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyinstrument/low_level/stat_profile.c b/pyinstrument/low_level/stat_profile.c index c10fe319..a2fe5aba 100644 --- a/pyinstrument/low_level/stat_profile.c +++ b/pyinstrument/low_level/stat_profile.c @@ -292,7 +292,11 @@ local_names_from_code(PyCodeObject *code) */ static PyObject * _get_type_qualname(PyTypeObject *type) { +#if PY_VERSION_HEX >= 0x030b0000 // Python 3.11.0 + PyObject *qualname = PyType_GetQualName(type); +#else PyObject *qualname = PyObject_GetAttrString((PyObject *)type, "__qualname__"); +#endif if (qualname == NULL) { return NULL; } From 1a82b172e5b3fee9d048e5842b43d9ff7eeb6ecd Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 20:19:47 +0100 Subject: [PATCH 11/14] Replace Black and isort with Ruff Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- .pre-commit-config.yaml | 15 +++++---------- metrics/overhead.py | 2 +- pyinstrument/frame.py | 6 +++--- pyinstrument/stack_sampler.py | 5 +++-- pyproject.toml | 7 ++++++- test/test_context_manager.py | 3 +-- test/test_ipython_magic.py | 3 ++- test/test_processors.py | 2 +- test/test_profiler.py | 20 ++++++++++---------- test/test_profiler_async.py | 2 +- test/test_pstats_renderer.py | 2 +- test/test_threading.py | 2 +- 12 files changed, 35 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f7dd7da..de84c6e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,17 +7,12 @@ repos: - id: check-yaml - id: check-added-large-files -- repo: https://github.com/pycqa/isort - rev: 5.13.2 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.0 hooks: - - id: isort - name: isort (python) - -- repo: https://github.com/psf/black - rev: 24.4.2 - hooks: - - id: black - language_version: python3 + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.3.0 diff --git a/metrics/overhead.py b/metrics/overhead.py index 5d2c51fe..2e1e1ee1 100644 --- a/metrics/overhead.py +++ b/metrics/overhead.py @@ -70,5 +70,5 @@ def test_func_template(): for name, time in graph_data: chars = int((time / max_time) * GRAPH_WIDTH) spaces = GRAPH_WIDTH - chars - print(f'{name:15} {"█" * chars}{" " * spaces} {time:.3f}s') + print(f"{name:15} {'█' * chars}{' ' * spaces} {time:.3f}s") print() diff --git a/pyinstrument/frame.py b/pyinstrument/frame.py index 53ecb966..84b1e680 100644 --- a/pyinstrument/frame.py +++ b/pyinstrument/frame.py @@ -323,9 +323,9 @@ def self_check(self, recursive: bool = True) -> None: return calculated_time = sum(child.time for child in self.children) + self.absorbed_time - assert math.isclose( - calculated_time, self.time - ), f"Frame time mismatch, should be {calculated_time}, was {self.time}, {self.children}" + assert math.isclose(calculated_time, self.time), ( + f"Frame time mismatch, should be {calculated_time}, was {self.time}, {self.children}" + ) if recursive: for child in self.children: diff --git a/pyinstrument/stack_sampler.py b/pyinstrument/stack_sampler.py index 522dcca1..41fe6aa5 100644 --- a/pyinstrument/stack_sampler.py +++ b/pyinstrument/stack_sampler.py @@ -260,8 +260,9 @@ def _check_timing_overhead(self, interval: float, timer_type: TimerType): overhead ({overheads["walltime_coarse"] * 1e9:.2g} nanoseconds). You can enable it by setting pyinstrument's interval to a value higher than - {format_float_with_sig_figs(coarse_resolution, - trim_zeroes=True)} seconds. If you're happy with the + { + format_float_with_sig_figs(coarse_resolution, trim_zeroes=True) + } seconds. If you're happy with the lower precision, this is the best option. """ ) diff --git a/pyproject.toml b/pyproject.toml index bbbc40b1..5c1b417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,13 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" -[tool.black] +[tool.ruff] line-length = 100 +target-version = "py38" +exclude = ["pyinstrument/renderers/html_resources/app.js", "pyinstrument/vendor"] + +[tool.ruff.lint] +select = ["I"] [tool.pyright] include = ["pyinstrument", "test"] diff --git a/test/test_context_manager.py b/test/test_context_manager.py index 34c416fd..af74c81e 100644 --- a/test/test_context_manager.py +++ b/test/test_context_manager.py @@ -1,9 +1,8 @@ -from test.fake_time_util import fake_time - import pytest import pyinstrument from pyinstrument.context_manager import ProfileContext +from test.fake_time_util import fake_time def test_profile_context_decorator(capfd): diff --git a/test/test_ipython_magic.py b/test/test_ipython_magic.py index abc58e36..9fdb14a9 100644 --- a/test/test_ipython_magic.py +++ b/test/test_ipython_magic.py @@ -1,12 +1,13 @@ import asyncio import signal import textwrap -from test.fake_time_util import fake_time from threading import Thread from time import sleep import pytest +from test.fake_time_util import fake_time + from .util import strip_ansi # note: IPython should be imported within each test. Importing it in our tests diff --git a/test/test_processors.py b/test/test_processors.py index 31b16c81..0d7ead79 100644 --- a/test/test_processors.py +++ b/test/test_processors.py @@ -1,11 +1,11 @@ import os import sys -from test.util import calculate_frame_tree_times, dummy_session from pytest import approx from pyinstrument import processors from pyinstrument.frame import SELF_TIME_FRAME_IDENTIFIER, Frame +from test.util import calculate_frame_tree_times, dummy_session ALL_PROCESSORS = [ processors.aggregate_repeated_calls, diff --git a/test/test_profiler.py b/test/test_profiler.py index 2c0871fb..b965eeb8 100644 --- a/test/test_profiler.py +++ b/test/test_profiler.py @@ -4,7 +4,6 @@ import json import time from functools import partial -from test.fake_time_util import fake_time from typing import Generator, Optional import pytest @@ -13,6 +12,7 @@ from pyinstrument.frame import Frame from pyinstrument.renderers.speedscope import SpeedscopeEvent, SpeedscopeEventType, SpeedscopeFrame from pyinstrument.session import Session +from test.fake_time_util import fake_time from .util import assert_never, busy_wait, dummy_session, flaky_in_ci @@ -392,20 +392,20 @@ def test_profiler_convenience_methods_have_all_options_available( # these options have been deprecated continue - assert ( - name in method_signature.parameters - ), f"Parameter {name} is missing from Profiler.{profiler_method_name}. {parameter}" + assert name in method_signature.parameters, ( + f"Parameter {name} is missing from Profiler.{profiler_method_name}. {parameter}" + ) method_parameter = method_signature.parameters[name] if profiler_method_name == "print" and name in {"color", "unicode"}: # print has a mechanism of autodetecting these continue - assert ( - method_parameter.default == parameter.default - ), f"Parameter {name} has a different default value in Profiler.{profiler_method_name}. {parameter}" - assert ( - method_parameter.annotation == parameter.annotation - ), f"Parameter {name} has a different annotation in Profiler.{profiler_method_name}. {parameter}" + assert method_parameter.default == parameter.default, ( + f"Parameter {name} has a different default value in Profiler.{profiler_method_name}. {parameter}" + ) + assert method_parameter.annotation == parameter.annotation, ( + f"Parameter {name} has a different annotation in Profiler.{profiler_method_name}. {parameter}" + ) def test_profiler_raises_on_double_subscribe(): diff --git a/test/test_profiler_async.py b/test/test_profiler_async.py index 2c4620e3..6fd9923b 100644 --- a/test/test_profiler_async.py +++ b/test/test_profiler_async.py @@ -2,7 +2,6 @@ import sys import time from functools import partial -from test.fake_time_util import fake_time, fake_time_asyncio, fake_time_trio from typing import Optional import pytest @@ -11,6 +10,7 @@ from pyinstrument.frame import AWAIT_FRAME_IDENTIFIER, OUT_OF_CONTEXT_FRAME_IDENTIFIER, Frame from pyinstrument.profiler import Profiler from pyinstrument.session import Session +from test.fake_time_util import fake_time, fake_time_asyncio, fake_time_trio from .util import assert_never, flaky_in_ci, walk_frames diff --git a/test/test_pstats_renderer.py b/test/test_pstats_renderer.py index 697b76de..61e55e91 100644 --- a/test/test_pstats_renderer.py +++ b/test/test_pstats_renderer.py @@ -2,13 +2,13 @@ import time from pathlib import Path from pstats import Stats -from test.fake_time_util import FakeClock, fake_time from typing import Any import pytest from pyinstrument import Profiler from pyinstrument.renderers import PstatsRenderer +from test.fake_time_util import FakeClock, fake_time def a(): diff --git a/test/test_threading.py b/test/test_threading.py index 8a936bf7..dbe1f94e 100644 --- a/test/test_threading.py +++ b/test/test_threading.py @@ -1,10 +1,10 @@ import threading import time -from test.fake_time_util import fake_time import pytest from pyinstrument import Profiler +from test.fake_time_util import fake_time from .util import do_nothing From 96b21c64ef7089e6255f6ada2260b0d04e3cf542 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 20:22:07 +0100 Subject: [PATCH 12/14] Add Nox lint session Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- noxfile.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/noxfile.py b/noxfile.py index 61694200..b0a897c3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,6 +6,12 @@ nox.options.default_venv_backend = "uv|virtualenv" +@nox.session() +def lint(session): + session.install("prek") + session.run("prek", "run", "--all-files") + + @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]) def test(session): session.env["UV_PRERELEASE"] = "allow" From e4a6a7251e13247c5558383c83f4196a0ec35a33 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 20:35:30 +0100 Subject: [PATCH 13/14] Group monthly dependency updates Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- .github/dependabot.yml | 10 +++++----- .pre-commit-config.yaml | 6 +++--- pyinstrument/renderers/html.py | 2 +- test/low_level/test_timing_thread.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7fddd081..2390d8c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,8 +3,8 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - ignore: - # Optional: Official actions have moving tags like v1; - # if you use those, you don't need updates. - - dependency-name: "actions/*" + interval: "monthly" + groups: + github-actions: + patterns: + - "*" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de84c6e3..1990a44f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -15,7 +15,7 @@ repos: - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.3 hooks: - id: codespell exclude: "\\.(json)$|docs/_static/preview" @@ -23,7 +23,7 @@ repos: - --ignore-words-list=vas - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.403 + rev: v1.1.411 hooks: - id: pyright additional_dependencies: diff --git a/pyinstrument/renderers/html.py b/pyinstrument/renderers/html.py index b37906dd..e8926771 100644 --- a/pyinstrument/renderers/html.py +++ b/pyinstrument/renderers/html.py @@ -143,7 +143,7 @@ def open_in_browser(self, session: Session, output_filename: str | None = None): with codecs.getwriter("utf-8")(output_file) as f: f.write(self.render(session)) else: - with codecs.open(output_filename, "w", "utf-8") as f: + with open(output_filename, "w", encoding="utf-8") as f: f.write(self.render(session)) url = urllib.parse.urlunparse(("file", "", output_filename, "", "", "")) diff --git a/test/low_level/test_timing_thread.py b/test/low_level/test_timing_thread.py index 5b185ab5..9521ffc9 100644 --- a/test/low_level/test_timing_thread.py +++ b/test/low_level/test_timing_thread.py @@ -31,7 +31,7 @@ if sys.platform == "win32": # on windows, the thread scheduling 'quanta', the time that a thread can run - # before potentially being pre-empted, is 20-30ms. This means that the + # before potentially being preempted, is 20-30ms. This means that the # worst-case, we have to wait 30ms before the timing thread gets a chance to # run. This isn't really a huge problem in practice, because thread-based # timing isn't much use on windows, since the synchronous timing functions are From 63f71f10c04225580bfbfc77411a04a299edbf78 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 29 Jul 2026 20:41:09 +0100 Subject: [PATCH 14/14] Preserve subscription errors during rollback Amp-Thread-ID: https://ampcode.com/threads/T-019faee0-cf92-708f-9ab1-f2f068ee5ef8 --- pyinstrument/stack_sampler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyinstrument/stack_sampler.py b/pyinstrument/stack_sampler.py index 41fe6aa5..454dc432 100644 --- a/pyinstrument/stack_sampler.py +++ b/pyinstrument/stack_sampler.py @@ -104,7 +104,11 @@ def subscribe( self.subscribers.remove(subscriber) if context_token is not None: active_profiler_context_var.reset(context_token) - self._update() + try: + self._update() + except BaseException: + # Preserve the original subscription error if rollback also fails. + pass raise def unsubscribe(self, target: StackSamplerSubscriberTarget):