2025, Dec 13 09:01

Фильтрация событий sys.settrace при exec: используем глубину стека

Разбираем, почему фильтрация по co_filename в Python не отделяет exec-обёртку от кода, и показываем решение для sys.settrace: порог по глубине стека.

Создание отладчика Python на базе sys.settrace — мощный способ наблюдать выполнение, но при загрузке пользовательского кода через exec есть тонкая ловушка. Трейс-хук начинает срабатывать и для скомпилированного пользовательского файла, и для обёртки, которая его подготавливает и запускает. Если вам важны только действия внутри логики входного файла, смешанный поток событий превращается в шум.

Как воспроизвести проблему

Рассмотрим минимальный раннер, который компилирует .py‑файл и выполняет его с установленным трейс‑хуком. События выполнения поступают из двух источников: из внешнего контекста, вызывающего exec, и из пользовательского кода, который вы пытаетесь изучить.

src_text = pack_source(SRC_PATH)
compiled_mod = compile(src_text, SRC_PATH, 'exec')

sys.settrace(trace_cb)
exec(compiled_mod)
sys.settrace(None)

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

if frm.f_code.co_filename != SRC_PATH:
    return None

Подвох в том, что frame.f_code.co_filename равен входному пути даже на начальном шаге exec, который оборачивает выполнение. То есть проверка имени файла проходит для обоих слоёв, и трассировщик по‑прежнему видит активность обёртки как пользовательскую логику.

Почему так происходит

При компиляции и exec входных данных выполняется скомпилированный <module>, который ссылается на тот же SRC_PATH. Обёртка, координирующая compile и exec, неглубока с точки зрения стека интерпретатора, но она всё равно создаёт фреймы, чей объект кода сообщает то же имя файла. Поскольку имя совпадает и в точке входа обёртки, и внутри кода файла, оно не подходит как надёжный признак для вашей задачи.

Практическое решение: использовать глубину стека

Вместо проверки имён полагайтесь на текущую глубину стека интерпретатора. Путь обёртки неглубокий, а вызовы, уходящие в пользовательские функции, глубже. Фильтрация по глубине аккуратно игнорирует внешний контекст exec и включает трассировку только при входе в реальную пользовательскую логику.

def trace_gate(frm, evt, payload=None):
    lvl = 0
    ptr = frm
    while ptr:
        lvl += 1
        ptr = ptr.f_back
    if lvl > 3:
        # ... анализируйте/логируйте по необходимости ...
        return trace_gate
    return None

Так трейс‑хук бездействует на неглубоких фреймах обёртки и активируется, когда стек вызовов пересекает выбранный порог.

Как выбрать порог

Порог 3 работает как практичная базовая точка, потому что типичная последовательность по слоям выглядит так: основной отладочный скрипт, точка входа exec, скомпилированный <module>, а далее функции пользователя. Как только вы переходите в эти функции, глубина становится больше этой базы. Если добавить ещё обёрток — например, перенести логику в класс или импортировать через дополнительный файл — глубина соответственно вырастет. Иными словами, точное число зависит от количества слоёв между вашим раннером и пользовательским кодом.

Почему это важно

Точность трассировки — это разница между полезными логами и шумом. Если фреймы обёртки смешиваются с вашим потоком событий, вы будете неверно приписывать вызовы, раздувать события по строкам не‑пользовательской активностью и усложнять дальнейший анализ. Глубина помогает разделить роли: вы получаете стабильную границу между оркестрацией и поведением.

Итоги

Если проверка имени файла не различает обёртку exec и логику входного файла, используйте глубину стека вызовов интерпретатора. Считайте фреймы через f_back, игнорируйте неглубокие события и запускайте трассировку только после того, как стек станет глубже наблюдаемой базы. Точный порог — эмпирический и отражает количество ваших обёрток, но сам подход остаётся устойчивым по мере эволюции загрузчика.