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

от автора

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

Сегодня в коротком формате разберем с тем, что же творится внутри 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: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.


ссылка на оригинал статьи https://habr.com/ru/articles/893244/