sys.settrace: как устроены дебаггеры, coverage и profilers в Python

sys.settrace: как устроены дебаггеры, coverage и profilers в Python, image #1

Когда запускаешь pytest --cov код выполняется как обычно, но в конце появляется отчёт о покрытии. Как pytest узнаёт, какие строки выполнились? Ответ в sys.settrace, это низкоуровневый хук, который позволяет перехватывать каждый шаг интерпретатора.

На этом механизме построены coverage.py, pdb, PyCharm debugger, hunters, и десятки других инструментов. Разберём, как это работает изнутри и почему трассировка устроена именно так.

Модель выполнения CPython

Прежде чем говорить о трассировке, нужно понять, как CPython выполняет код. Python-код компилируется в байткод — последовательность инструкций для виртуальной машины. Каждая функция имеет объект code, содержащий байткод и метаданные.

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

Интерпретатор — это цикл, который берёт следующую инструкцию из текущего frame и выполняет её. Трассировка встраивается в этот цикл: перед выполнением определённых инструкций интерпретатор проверяет, установлена ли trace-функция, и если да — вызывает её.

Базовый API: sys.settrace

sys.settrace(func) устанавливает глобальную trace-функцию. Она вызывается при определённых событиях:

import sys
def trace_calls(frame, event, arg):
"""
frame — текущий frame выполнения
event — тип события ('call', 'line', 'return', 'exception')
arg — дополнительные данные (зависит от event)
"""
print(f"{event}: {frame.f_code.co_name}, line {frame.f_lineno}")
return trace_calls # Возвращаем trace-функцию для локальных событий
sys.settrace(trace_calls)
def example():
x = 1
y = 2
return x + y
example()

sys.settrace(None)

Вывод:

call: example, line 1
line: example, line 2
line: example, line 3
line: example, line 4
return: example, line 4

Четыре типа событий:

  • call — вызов функции, arg = None
  • line — переход на новую строку, arg = None
  • return — возврат из функции, arg = возвращаемое значение
  • exception — возникло исключение, arg = (type, value, traceback)

trace-функция должна вернуть функцию для отслеживания локальных событий (line, return, exception). Если вернуть None,то локальные события этого frame игнорируются. Это механизм фильтрации: можно отслеживать вызовы всех функций, но line-события только для интересующих.

Анатомия frame-объекта

Frame — это окно в состояние выполнения. Доступные атрибуты:

def inspect_frame(frame, event, arg):
# Информация о коде
code = frame.f_code
print(f"Function: {code.co_name}")
print(f"Filename: {code.co_filename}")
print(f"First line: {code.co_firstlineno}")

# Текущее состояние
print(f"Current line: {frame.f_lineno}")
print(f"Locals: {frame.f_locals}")

# Стек вызовов
print(f"Caller frame: {frame.f_back}")

return inspect_frame

f_locals — словарь локальных переменных. Но есть нюанс, это снапшот на момент вызова trace-функции. Изменения в f_locals не отражаются на реальных переменных (кроме специальных случаев с ctypes). Для дебаггеров это проблема,нельзя просто так изменить переменную во время отладки.

f_back — ссылка на frame вызывающей функции. Позволяет ходить по стеку вызовов.

Как работает coverage.py

Coverage.py — стандартный инструмент измерения покрытия кода. Его архитектура: trace-функция отмечает выполненные строки, потом сравниваем с исходным кодом.

Упрощённая версия:

class SimpleCoverage:
def __init__(self):
self.executed_lines = {} # filename -> set of line numbers

def start(self):
sys.settrace(self._trace)

def stop(self):
sys.settrace(None)

def _trace(self, frame, event, arg):
if event == 'call':
# Для каждого вызова возвращаем локальный tracer
return self._trace_lines
return None

def _trace_lines(self, frame, event, arg):
if event == 'line':
filename = frame.f_code.co_filename
lineno = frame.f_lineno

if filename not in self.executed_lines:
self.executed_lines[filename] = set()
self.executed_lines[filename].add(lineno)

return self._trace_lines

def report(self):
for filename, lines in self.executed_lines.items():
print(f"{filename}: {sorted(lines)}")

Реальный coverage.py сложнее, он написан на C для производительности, отслеживает арки (transitions между строками) для branch coverage, интегрируется с multiprocessing.

Кстати, тут еще интересно, что coverage должен знать, какие строки вообще являются исполняемыми. Пустые строки, комментарии, строки с только else: не являются. Для этого coverage.py парсит AST исходного кода и определяет «исполняемые» строки.

Дебаггеры

pdb использует тот же механизм, но с другой логикой:

class SimpleDebugger:
def __init__(self):
self.breakpoints = set() # (filename, lineno)
self.step_mode = False

def set_break(self, filename, lineno):
self.breakpoints.add((filename, lineno))

def _trace(self, frame, event, arg):
if event == 'call':
return self._trace_lines
return None

def _trace_lines(self, frame, event, arg):
if event != 'line':
return self._trace_lines

filename = frame.f_code.co_filename
lineno = frame.f_lineno

should_stop = (
self.step_mode or
(filename, lineno) in self.breakpoints
)

if should_stop:
self._interact(frame)

return self._trace_lines

def _interact(self, frame):
"""REPL для отладки"""
while True:
cmd = input(f"(Pdb) {frame.f_code.co_name}:{frame.f_lineno}> ")

if cmd == 'n': # next
self.step_mode = True
break
elif cmd == 'c': # continue
self.step_mode = False
break
elif cmd == 'p': # print locals
print(frame.f_locals)
elif cmd == 'q': # quit

sys.exit(0)

Команда step (войти в функцию) vs next (перешагнуть) реализуется через анализ событий: при step мы реагируем на следующее событие, при next игнорируем вложенные call/return и ждём line в текущем frame.

Условные breakpoints — просто проверка условия при остановке:

# breakpoint с условием x > 10
if eval(condition, frame.f_globals, frame.f_locals):
self._interact(frame)

Почему трассировка медленная

Trace-функция вызывается на каждую строку кода. Все это беда для производительности. Почему так:

  1. Вызов Python-функции — сам по себе дорогой (создание frame, setup arguments)
  2. Переход Python ↔ C — trace-функция на Python, интерпретатор на C
  3. Невозможность оптимизаций — трассировка отключает многие оптимизации CPython

Поэтому coverage.py имеет C-расширение, а профилировщики используют другие механизмы.

sys.setprofile: легковесная альтернатива

sys.setprofile похож на settrace, но события только на границах функций:

  • call
  • return
  • c_call (вызов C-функции)
  • c_return
  • c_exception

Нет событий line, огромная экономия. cProfile построен на этом:

import sys
call_counts = {}
def profiler(frame, event, arg):
if event == 'call':
name = frame.f_code.co_name
call_counts[name] = call_counts.get(name, 0) + 1
return profiler
sys.setprofile(profiler)
# ... код ...

sys.setprofile(None)

Для профилирования по времени можно использовать time.perf_counter() на call/return и считать разницу.

Per-thread трассировка

sys.settrace устанавливает trace-функцию для текущего потока. Для многопоточных приложений нужно устанавливать в каждом потоке:

import threading
import sys
def install_tracer():
sys.settrace(my_trace_func)
# Устанавливаем в главном потоке
sys.settrace(my_trace_func)
# Для новых потоков
threading.settrace(install_tracer) # deprecated в 3.12
# Или вручную
original_start = threading.Thread.start
def patched_start(self):
original_run = self.run
def traced_run():
sys.settrace(my_trace_func)
original_run()
self.run = traced_run
original_start(self)
threading.Thread.start = patched_start

coverage.py делает подобный monkey-patching для отслеживания всех потоков.

Python 3.12+: sys.monitoring

Python 3.12 добавил sys.monitoring — новый, более эффективный API для инструментации:

import sys
def line_handler(code, line_number):
print(f"Line {line_number} in {code.co_name}")
# Регистрируем callback
sys.monitoring.register_callback(
sys.monitoring.PROFILER_ID,
sys.monitoring.events.LINE,
line_handler
)
# Включаем события
sys.monitoring.set_events(
sys.monitoring.PROFILER_ID,
sys.monitoring.events.LINE

)

Меньше overhead — callbacks вызываются напрямую, без создания frame, плюсом имеем гранулярный контроль, можно включать события для конкретных функций.

API ещё стабилизируется.

Детектор утечек

Пример нетипичного применения — отслеживание незакрытых файлов:

import sys
import weakref
import traceback
open_files = {} # id -> (weakref, traceback)
original_open = open
def tracked_open(*args, **kwargs):
f = original_open(*args, **kwargs)

# Сохраняем traceback места открытия
tb = ''.join(traceback.format_stack())
open_files[id(f)] = (weakref.ref(f), tb)

return f
def trace_closes(frame, event, arg):
if event == 'return':
# Проверяем, не закрылся ли файл
for fid, (ref, tb) in list(open_files.items()):
if ref() is None:
del open_files[fid]
return trace_closes
# Установка
import builtins
builtins.open = tracked_open
sys.settrace(trace_closes)
# В конце программы
def report_leaks():
for fid, (ref, tb) in open_files.items():
f = ref()
if f and not f.closed:
print(f"Unclosed file: {f.name}")
print(f"Opened at:\n{tb}")

В проде использовали бы del или context managers.

sys.settrace — низкоуровневый механизм. Для большинства задач достаточно готовых инструментов.

Хотите разбираться в Python не только на уровне «пишу код», но и понимать, что делает интерпретатор, где теряется время и как устроены инструменты? Специализация разработчик на Python помогает пройти путь от базы до уверенного профессионала: практика, код-ревью, проекты и инженерные привычки.

Источник: https://habr.com/ru/companies/otus/articles/988880/

247 views·1 share