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/.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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f7dd7da..1990a44f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,26 +1,21 @@ 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 - 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 + rev: v2.4.3 hooks: - id: codespell exclude: "\\.(json)$|docs/_static/preview" @@ -28,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/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/noxfile.py b/noxfile.py index 945cc176..b0a897c3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,13 @@ nox.options.default_venv_backend = "uv|virtualenv" -@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) +@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" session.install("-e", ".[test]", "setuptools") 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/low_level/stat_profile.c b/pyinstrument/low_level/stat_profile.c index 273ab9bf..a2fe5aba 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; @@ -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; } @@ -286,12 +287,33 @@ 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) { +#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; + } + 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); @@ -299,6 +321,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) { @@ -317,12 +344,12 @@ _get_class_name_of_frame(PyFrameObject *frame, PyCodeObject *code) { return NULL; } - const char *result = NULL; + PyObject *result = NULL; PyObject *locals = PyFrame_GetLocals(frame); - if (!PyMapping_Check(locals)) { - Py_DECREF(locals); + if (locals == NULL || !PyMapping_Check(locals)) { + Py_XDECREF(locals); return NULL; } @@ -337,7 +364,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)) { @@ -350,8 +377,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); } @@ -393,7 +419,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. // @@ -441,14 +467,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"); } @@ -496,21 +520,28 @@ 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); + PyObject *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( - "%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; } - - PyObject *line_number_attribute; int line_number = PyFrame_GetLineNumber(frame); if (line_number < 1) { @@ -523,8 +554,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 +569,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 +591,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 +693,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 +816,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/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/pyinstrument/magic/magic.py b/pyinstrument/magic/magic.py index 2689cd16..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 @@ -298,17 +299,31 @@ 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) - coro = ip.run_cell_async(code) + thread.start() + 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: loop.call_soon_threadsafe(loop.stop) - asyncio.set_event_loop(old_loop) + thread.join() + loop.close() IPYTHON_INTERNAL_FILES = ( 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/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/pyinstrument/stack_sampler.py b/pyinstrument/stack_sampler.py index fa115dc1..454dc432 100644 --- a/pyinstrument/stack_sampler.py +++ b/pyinstrument/stack_sampler.py @@ -75,27 +75,41 @@ 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) + try: + self._update() + except BaseException: + # Preserve the original subscription error if rollback also fails. + pass + raise def unsubscribe(self, target: StackSamplerSubscriberTarget): try: @@ -250,8 +264,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/low_level/test_context.py b/test/low_level/test_context.py index 8c384fca..294c7ece 100644 --- a/test/low_level/test_context.py +++ b/test/low_level/test_context.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextvars +import sys import time from typing import Any @@ -17,6 +18,17 @@ def test_context_type(setstatprofile): setstatprofile(None) +@parametrize_setstatprofile +def test_context_without_default(setstatprofile): + context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar("context_var") + + setstatprofile(lambda frame, event, arg: None, context_var=context_var) + try: + assert sys.getprofile() is not None + finally: + setstatprofile(None) + + profiler_context_var: contextvars.ContextVar[object | None] = contextvars.ContextVar( "profiler_context_var", default=None ) 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..a2aeac93 100644 --- a/test/low_level/test_frame_info.py +++ b/test/low_level/test_frame_info.py @@ -45,6 +45,15 @@ 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() + + def test_frame_info(): frame = inspect.currentframe() @@ -52,6 +61,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 @@ -77,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, 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 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 20ab782a..9fdb14a9 100644 --- a/test/test_ipython_magic.py +++ b/test/test_ipython_magic.py @@ -1,11 +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 @@ -102,6 +104,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", @@ -121,6 +124,69 @@ 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: + def transform_cell(self, code): + return code + + async def run_cell_async(self, code, **kwargs): + 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: + def transform_cell(self, code): + return code + + async def run_cell_async(self, code, **kwargs): + 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 # 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 7468d854..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,8 +12,9 @@ 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, 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() @@ -382,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_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) 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