Как стать автором
Обновить
537.45
OTUS
Цифровые навыки от ведущих экспертов

Отладка с sys._getframe в Python

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров1.3K

Привет, Хабр!

Сегодня в коротком формате разберем с тем, что же творится внутри CPython, когда функции вызывают друг друга: sys._getframe, f_back, f_globals, f_locals, а так же создадим свои декораторы.

Внутреннее устройство call stack в CPython

Когда вы вызываете функцию в Python, интерпретатор создает объект frame. Этот объект можно сравнить с страницей в дневнике выполнения программы, на которой записана вся информация о текущем вызове. Рассмотрим, что хранится в каждом таком кадре:

  • Имя функции и её исходный код:

  • f_code — это объект, содержащий байт‑код, имя функции, имя файла и другую метаинформацию. Именно благодаря этому полю можно узнать, какая функция сейчас выполняется и получить доступ к её исходному коду, если потребуется.

  • Номер текущей строки f_lineno:
    Это значение показывает, какая строка исходного кода выполняется в данный момент. Если вы когда‑нибудь отлаживали код и пытались понять, где именно произошла ошибка, этот номер может стать ключом к разгадке.

  • Словари локальных f_locals и глобальных переменных f_global:
    Эти словари содержат все переменные, доступные в данный момент. Локальные переменные — это те, что определены внутри функции, а глобальные — общие для всего модуля.

  • Ссылка на предыдущий кадр f_back:
    Это ссылка на предыдущий вызов в стеке. Каждый кадр — это бумажная заметка, где написано: «Я был вызван из этой другой заметки». Связь через f_back позволяет, двигаясь «назад», реконструировать весь путь вызовов, от текущей функции до точки входа в программу.

Рассмотрим как можно пройтись по всем кадрам вызова:

import sys

def print_call_stack():
    frame = sys._getframe()  # Получаем текущий кадр (функция print_call_stack)
    stack = []
    while frame:
        # Формируем строку: имя функции и текущая строка в коде
        stack.append(f"{frame.f_code.co_name} (line {frame.f_lineno})")
        # Переходим к предыдущему кадру
        frame = frame.f_back
    print("Call Stack (от текущего к началу):")
    # Выводим стек в обратном порядке (от самой верхней точки входа до текущей функции)
    for entry in reversed(stack):
        print("  ->", entry)

def foo():
    bar()

def bar():
    print_call_stack()

foo()

Функция sys._getframe() возвращает текущий фрейм, то есть кадр, в котором выполняется, например, print_call_stack(), и служит отправной точкой для формирования цепочки вызовов; затем, в цикле while, пока кадр существует, мы извлекаем имя функции через frame.f_code.co_name и номер строки через frame.f_lineno, добавляем эту информацию в список stack и переходим к предыдущему кадру через frame.f_back; после завершения цикла мы выводим стек в обратном порядке, получая последовательность от корневого вызова (точка входа) к текущему, где самый верхний элемент — это начало исполнения, а нижний — самый последний вызов.

Допустим, при выполнении этого скрипта был получен следующий вывод:

Call Stack (от текущего к началу):
  -> _run_module_as_main (line 198)
  -> _run_code (line 88)
  -> <module> (line 37)
  -> launch_instance (line 992)
  -> start (line 712)
  -> start (line 205)
  -> run_forever (line 608)
  -> _run_once (line 1936)
  -> _run (line 84)
  -> dispatch_queue (line 510)
  -> process_one (line 499)
  -> dispatch_shell (line 406)
  -> execute_request (line 730)
  -> do_execute (line 383)
  -> run_cell (line 528)
  -> run_cell (line 2975)
  -> _run_cell (line 3030)
  -> _pseudo_sync_runner (line 78)
  -> run_cell_async (line 3257)
  -> run_ast_nodes (line 3473)
  -> run_code (line 3553)
  -> <cell line: 0> (line 19)
  -> foo (line 14)
  -> bar (line 17)
  -> print_call_stack (line 7)

Это полноценная цепочка вызовов, начиная с самых низкоуровневых функций интерпретатора (например, runmodule_as_main, runcode, <module>), через служебные вызовы среды (launch_instance, run_forever и прочие, характерные для интерактивных оболочек, таких как Jupyter/IPython), до вашего кода, где видно, что функция foo (line 14) вызвала bar (line 17), которая, в свою очередь, вызвала print_call_stack (line 7); такой вывод позволяет увидеть, как система организует выполнение кода, и помогает локализовать, на каком именно этапе (и в каком контексте) произошёл вызов.

Построение собственного трейсера: отладка без pdb

Теперь создадим легковесный отладчик, который отслеживает все вызовы функций. Для этого в Python есть функция sys.settrace, позволяющая установить глобальный обработчик событий.

Простой трейсер вызовов и возвратов:

import sys

def simple_tracer(frame, event, arg):
    if event == "call":
        code = frame.f_code
        func_name = code.co_name
        line_no = frame.f_lineno
        print(f"[CALL] {func_name} at line {line_no}")
    elif event == "return":
        code = frame.f_code
        func_name = code.co_name
        print(f"[RETURN] {func_name} returning {arg}")
    return simple_tracer

def traced_function(x):
    return x * 2

def another_traced_function(y):
    result = traced_function(y)
    return result + 1

def run_tracer():
    sys.settrace(simple_tracer)
    print("Result:", another_traced_function(5))
    sys.settrace(None)

run_tracer()

В итоге получаем этот код:

[CALL] another_traced_function at line 18
[CALL] traced_function at line 15
[RETURN] traced_function returning 10
[RETURN] another_traced_function returning 11
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
Result:[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 7
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
 [CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 1
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
11[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 2
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True

[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 1

Трейсер перехватывает вызовы и возвраты функций не только из пользовательского кода, но и из внутренних системных вызовов, инициированных, например, при печати результата. Видно, что сначала вызывается функция another_traced_function (строка 18), которая внутри вызывает traced_function (строка 15); та возвращает значение 10, после чего another_traced_function возвращает 11. Затем, когда результат выводится через print, запускаются дополнительные внутренние вызовы: функции write, ismaster_process, scheduleflush — они отвечают за обработку и синхронизацию вывода в консоль.

Декораторы с доступом к контексту вызова

Ччто, если нужно не просто обернуть функцию, а еще и узнать, кто её вызвал? Тут на помощь снова приходит sys._getframe.

import sys
from functools import wraps

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Получаем кадр вызывающей функции
        caller_frame = sys._getframe(1)
        caller_name = caller_frame.f_code.co_name
        caller_line = caller_frame.f_lineno
        print(f"[LOG] Функция '{func.__name__}' вызвана из '{caller_name}' на строке {caller_line}")
        result = func(*args, **kwargs)
        print(f"[LOG] Функция '{func.__name__}' завершилась с результатом {result}")
        return result
    return wrapper

@log_call
def compute_area(radius):
    from math import pi
    return pi * radius ** 2

def main():
    area = compute_area(5)
    print(f"Площадь круга: {area}")

main()

Вывод:

[LOG] Функция 'compute_area' вызвана из 'main' на строке 23
[LOG] Функция 'compute_area' завершилась с результатом 78.53981633974483
Площадь круга: 78.53981633974483

Иногда хочется, чтобы трассировка включалась не всегда, а только при определённых условиях. Например, если первый аргумент превышает заданное значение:

def conditional_trace(threshold):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Если первый аргумент — число и превышает порог, включаем трассировку
            if args and isinstance(args[0], (int, float)) and args[0] > threshold:
                print(f"[TRACE] {func.__name__} вызвана с args={args} kwargs={kwargs}")
                caller_frame = sys._getframe(1)
                print(f"[TRACE] Вызвана из {caller_frame.f_code.co_name} на строке {caller_frame.f_lineno}")
            result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@conditional_trace(10)
def multiply(a, b):
    return a * b

def test():
    print(multiply(5, 3))   # Трассировка не сработает, 5 < 10
    print(multiply(15, 2))  # Трассировка сработает, 15 > 10

test()

Вывод:

15
[TRACE] multiply вызвана с args=(15, 2) kwargs={}
[TRACE] Вызвана из test на строке 21
30

Легкий профайлер на базе sys._getframe

Если хочется не просто отлаживать код, а еще и профилировать его выполнение, то соберем легкий профайлер, который будет измерять время работы функций и показывать, откуда они были вызваны.

import sys
import time
from functools import wraps

def profile(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        # Извлекаем информацию о вызывающем контексте
        caller = sys._getframe(1)
        caller_info = f"{caller.f_code.co_name} (line {caller.f_lineno})"
        print(f"[PROFILE] Функция '{func.__name__}' вызвана из {caller_info} заняла {end_time - start_time:.6f} секунд")
        return result
    return wrapper

@profile
def heavy_computation(n):
    s = 0
    for i in range(n):
        s += i ** 2
    return s

def run_computation():
    result = heavy_computation(100000)
    print("Результат вычислений:", result)

run_computation()

Вывод:

[PROFILE] Функция 'heavy_computation' вызвана из run_computation (line 26) заняла 0.011234 секунд
Результат вычислений: 333328333350000

Конечно, это далеко не всё про call stack. Поделитесь своим опытом в комментариях.

Все актуальные лучшие практики программирования можно освоить на онлайн-курсах OTUS: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.

Теги:
Хабы:
Всего голосов 10: ↑5 и ↓5+2
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS