Лекция 4. Отладка и интроспекция

Содержание

Лекция 4. Отладка и интроспекция#

План#

  1. Отладка — pdb вживую (брейкпоинты, шаги, фреймы, breakpoint(), PYTHONBREAKPOINT, подключение к работающему процессу по PID) и post-mortem, в том числе из pytest.

  2. inspect (сигнатуры, исходники, стек и фреймы), dis и code object — что реально исполняет интерпретатор.


Часть I. Отладка#

Начинаем с того, что видит разработчик, когда что-то падает — интерактивные и полуинтерактивные инструменты. Всё, что здесь, — про быстрый цикл «поставил breakpoint, посмотрел стек, поправил». Дальше в лекции спустимся глубже, но этими инструментами надо владеть свободно: они нужны ежедневно.

Фрейм и трейсбек — договоримся о словах#

Два слова, которые дальше будут в каждом абзаце.

Фрейм (frame) — это один вызов функции в момент исполнения. У него есть локальные переменные (f_locals), ссылка на исполняемый код (f_code), номер текущей строки (f_lineno) и ссылка на вызывающий фрейм (f_back). На каждый вызов интерпретатор создаёт фрейм, на выходе из функции — выбрасывает. Цепочка по f_back от текущего фрейма и есть стек вызовов.

Трейсбек (traceback) — объект, который исключение несёт в атрибуте __traceback__: путь, по которому оно уходило наружу. Это тоже цепочка, только звенья связаны полем tb_next: начинается она с внешнего вызова, а заканчивается там, где исключение возникло. Знакомый текст со строками «File …, line …» — просто её печатное представление.

Посмотрим на оба сразу.

%%writefile /tmp/frames_intro.py
def to_rub(amount, currency):
    rates = {"USD": 75.0}
    return amount * rates[currency]      # на "GBP" будет KeyError


def line_total(item):
    return to_rub(item["price"], item["currency"]) * item["qty"]


try:
    line_total({"price": 7, "currency": "GBP", "qty": 1})
except KeyError as err:
    print("traceback: цепочка tb_next, от внешнего вызова к месту падения")
    tb, deepest = err.__traceback__, err.__traceback__.tb_frame
    while tb is not None:
        frame, deepest = tb.tb_frame, tb.tb_frame
        names = [n for n in sorted(frame.f_locals) if not n.startswith("_")]
        print(f"  {frame.f_code.co_qualname:12} строка {tb.tb_lineno:2}  локальные: {names}")
        tb = tb.tb_next

    print("\nтот же путь снизу вверх, по f_back самого глубокого фрейма")
    frame = deepest
    while frame is not None:
        print(f"  {frame.f_code.co_qualname}")
        frame = frame.f_back
Writing /tmp/frames_intro.py
!uv run python /tmp/frames_intro.py
traceback: цепочка tb_next, от внешнего вызова к месту падения
  <module>     строка 11  локальные: ['deepest', 'err', 'frame', 'line_total', 'tb', 'to_rub']
  line_total   строка  7  локальные: ['item']
  to_rub       строка  3  локальные: ['amount', 'currency', 'rates']

тот же путь снизу вверх, по f_back самого глубокого фрейма
  to_rub
  line_total
  <module>

Внизу списка — место падения: to_rub со своими amount, currency, rates. Выше — те, кто его позвал. Отладчик всё время работает ровно с этой структурой: w печатает цепочку, u и d выбирают фрейм, p смотрит в f_locals выбранного. А отладчик, поднятый после падения, работает с фреймами из __traceback__ уже улетевшего исключения — к этому вернёмся отдельно.

1. pdb#

Стандартный отладчик Python. Вызывается через python -m pdb script.py или изнутри — breakpoint().

Сначала карта — весь набор команд на одной странице. Дальше разбираем его по группам на одном живом баге, а сюда удобно возвращаться потом.

Команда

Что делает

l / ll / source obj

код вокруг / текущая функция целиком / исходник объекта

p / pp / whatis / args

значение / pretty-print / тип / параметры фрейма

n / s / r

перешагнуть вызов / зайти внутрь / до выхода из функции

until [n] / c

до строки >= n (выйти из цикла) / дальше

b / b 15, cond / tbreak / cl

брейкпоинт / условный / одноразовый / удалить

disable / enable / ignore N k

выключить / включить / пропустить k срабатываний

display x / undisplay x

следить за выражением на брейкпоинтах и шагах

commands N … end

что делать при каждом срабатывании точки N

w / u / d

стек фреймов / выше / ниже

!<код> / jump n

выполнить код в фрейме / перескочить на строку

alias / unalias

свои команды, живут в ~/.pdbrc

debug expr / retval

вложенный отладчик / возвращённое значение

interact

консоль поверх копии namespace фрейма

exceptions [N]

цепочка исключений в post-mortem (3.13+)

restart / run [args] / q

перезапуск / перезапуск с аргументами / выход

h / h <команда>

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

Если знаешь gdb — pdb почти те же команды

pdb

gdb

что

b file:line

b file:line

breakpoint

n / s

n / s

next / step

c

c

continue

p x / pp x

p x / set print pretty on

печать значения / pretty-print

whatis x

ptype x / whatis x

тип выражения

w / bt / where

bt / where

stack trace (те же алиасы)

u / d

up / down

навигация по фреймам

interact

call / inline expr

выполнить код в контексте фрейма

Дальше — весь набор команд, но не списком, а на одном подопытном: корзина, которая считает сумму со скидкой.

%%writefile /tmp/cart_demo.py
RATES = {"USD": 75.0, "EUR": 85.0}


def to_rub(amount, currency):
    return amount * RATES[currency]


def line_total(item):
    return to_rub(item["price"], item["currency"]) * item["qty"]


def cart_total(items, discount=0.0):
    total = 0.0
    for item in items:
        total += line_total(item)
    return round(total * (1 - discount), 2)


def main():
    cart = [
        {"price": 10, "currency": "USD", "qty": 2},
        {"price": 5, "currency": "EUR", "qty": 3},
    ]
    print("итого:", cart_total(cart, discount=0.1))


if __name__ == "__main__":
    main()
Writing /tmp/cart_demo.py

Файл запускается и сам, так что всё, что дальше, можно повторить руками в терминале — той же командой, что и любой скрипт под отладчиком:

python -m pdb /tmp/cart_demo.py
(Pdb) b cart_total
(Pdb) c

В ноутбуке интерактивной сессии не устроить, поэтому воспользуемся тем, что pdb читает команды не только с клавиатуры: Pdb(stdin=..., stdout=...) возьмёт их откуда скажем. Дальше каждая ячейка — настоящая pdb-сессия, только ввод задан заранее; команды из неё можно набирать в терминале по одной и смотреть то же самое живьём.

import bdb
import io
import pdb
import sys

sys.path.insert(0, "/tmp")
from cart_demo import cart_total

CART = [{"price": 10, "currency": "USD", "qty": 2},
        {"price": 5, "currency": "EUR", "qty": 3}]


class ScriptedPdb(pdb.Pdb):
    """pdb, который печатает свой ввод — чтобы транскрипт читался как сессия."""

    def precmd(self, line):
        # Кроме наших команд, через precmd проходят служебные, которые pdb кладёт
        # себе в очередь сам (_pdbcmd_print_frame_status и другие). Их никто не
        # вводил — в транскрипте они лишние.
        if not line.startswith("_pdbcmd"):
            self.stdout.write(f"{line}\n")
        return super().precmd(line)


def session(commands: str) -> None:
    """Прогнать cart_total под pdb: одна строка commands = одна команда.

    Пустые строки и комментарии (#) выбрасываем — они для читателя, не для pdb.
    """
    bdb.Breakpoint.clearBreakpoints()        # нумерация брейкпоинтов сквозная на процесс
    cart = [dict(item) for item in CART]     # своя корзина: сессии её мутируют
    script = [line.split("#", 1)[0].strip() for line in commands.splitlines()]
    out = io.StringIO()
    dbg = ScriptedPdb(stdin=io.StringIO("\n".join(filter(None, script)) + "\nc\n"), stdout=out)
    dbg.runcall(cart_total, cart)
    print(out.getvalue())

Прежде чем смотреть транскрипты — посмотрим, от кого мы вообще унаследовались:

print(f"pdb.Pdb здесь:      {pdb.Pdb}")
print(f"предки:             {[c.__module__ + '.' + c.__name__ for c in pdb.Pdb.__mro__[:3]]}")
print(f"sys.breakpointhook: {sys.breakpointhook}")
pdb.Pdb здесь:      <class 'IPython.core.debugger.InterruptiblePdb'>
предки:             ['IPython.core.debugger.InterruptiblePdb', 'IPython.core.debugger.Pdb', 'pdb.Pdb']
sys.breakpointhook: <function debugpy_breakpointhook at 0x1073e00f0>

В ядре Jupyter pdb.Pdb — уже не stdlib-класс: IPython подменяет его своим подклассом. Поэтому дальше в транскриптах приглашение ipdb>, вокруг текущей строки печатается контекст, а строка с брейкпоинтом помечена 1--->. Команды те же, в терминале приглашение будет (Pdb). Заодно видно, что sys.breakpointhook в ядре тоже подменён — на хук debugpy, того самого отладчика, который стоит за кнопкой Debug в VS Code и JupyterLab.

Осмотреться, не двигаясь: ll, l, args, p, pp, whatis, source#

session("""
    ll                   # весь код функции; -> помечает строку, где стоим
    l 8,16               # произвольный диапазон строк файла
    args                 # только параметры этого вызова
    p items              # значение выражения
    pp items             # то же, но через pprint
    whatis discount      # тип выражения
    source line_total    # исходник любого объекта, доступного по имени
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> ll
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> l 8,16
      8 def line_total(item):
      9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)
     16     return round(total * (1 - discount), 2)

ipdb> args
items = [{'price': 10, 'currency': 'USD', 'qty': 2}, {'price': 5, 'currency': 'EUR', 'qty': 3}]
discount = 0.0
ipdb> p items
[{'price': 10, 'currency': 'USD', 'qty': 2}, {'price': 5, 'currency': 'EUR', 'qty': 3}]
ipdb> pp items
[{'currency': 'USD', 'price': 10, 'qty': 2},
 {'currency': 'EUR', 'price': 5, 'qty': 3}]
ipdb> whatis discount
<class 'float'>
ipdb> source line_total
  8  	def line_total(item):
  9  	    return to_rub(item["price"], item["currency"]) * item["qty"]
ipdb> c

ll (longlist) печатает текущую функцию целиком и помечает -> строку, на которой стоим. l без аргументов показывает десяток строк вокруг текущей, а если позвать её ещё раз — следующий кусок файла; l 8,16 печатает заданный диапазон строк. args — только параметры этого вызова, p — значение выражения, pp — оно же через pprint (для больших структур), whatis — тип, source — исходник другого объекта, до имени которого дотягиваемся из текущего фрейма.

Шаг за шагом: n, s, r, until, c#

session("""
    n           # перешагнуть строку, не заходя в вызовы
    n
    s           # зайти внутрь вызова
    s
    r           # доиграть функцию до return
    n           # вернуться во фрейм cart_total
    until 16    # дойти до строки 16, то есть выйти из цикла
    p total
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> n
> /tmp/cart_demo.py(14)cart_total()
     12 def cart_total(items, discount=0.0):
     13     total = 0.0
---> 14     for item in items:
     15         total += line_total(item)
     16     return round(total * (1 - discount), 2)

ipdb> n
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
---> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> s
--Call--
> /tmp/cart_demo.py(8)line_total()
      6 
      7 
----> 8 def line_total(item):
      9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 

ipdb> s
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> r
--Return--
1500.0
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> n
> /tmp/cart_demo.py(14)cart_total()
     12 def cart_total(items, discount=0.0):
     13     total = 0.0
---> 14     for item in items:
     15         total += line_total(item)
     16     return round(total * (1 - discount), 2)

ipdb> until 16
> /tmp/cart_demo.py(16)cart_total()
     14     for item in items:
     15         total += line_total(item)
---> 16     return round(total * (1 - discount), 2)
     17 
     18 

ipdb> p total
2775.0
ipdb> c

n перешагивает вызов целиком, s заходит внутрь (видно --Call--), r доводит текущую функцию до return и печатает возвращённое значение — --Return-- и 1500.0 отдельной строкой (терминальный pdb дописывает его к строке фрейма: line_total()->1500.0). until 16 — «беги, пока номер строки не станет >= 16», одним движением выходим из цикла. Тонкость: until останавливается ещё и когда текущий фрейм вернул управление, поэтому после r мы сначала делаем n — возвращаемся в cart_total — и только оттуда даём until. Дай его из фрейма line_total, и он сработает как выход из функции. c — до следующего брейкпоинта или до конца.

Брейкпоинты: b, условие, tbreak, ignore, disable/enable, cl#

session("""
    b line_total             # брейкпоинт по имени функции
    b                        # список брейкпоинтов: где, включён ли, условие, число срабатываний
    c
    args
    cl 1                     # удалить брейкпоинт 1
    b 15, item['qty'] > 2    # условный брейкпоинт: срабатывает, если условие истинно
    c
    p item
    disable 2                # выключить, не удаляя
    b
    enable 2
    tbreak 16                # одноразовый брейкпоинт
    c
    p total
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> b line_total
Breakpoint 1 at /tmp/cart_demo.py:9
ipdb> b
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /tmp/cart_demo.py:9
ipdb> c
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
1---> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> args
item = {'price': 10, 'currency': 'USD', 'qty': 2}
ipdb> cl 1
Deleted breakpoint 1 at /tmp/cart_demo.py:9
ipdb> b 15, item['qty'] > 2
Breakpoint 2 at /tmp/cart_demo.py:15
ipdb> c
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
2--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> p item
{'price': 5, 'currency': 'EUR', 'qty': 3}
ipdb> disable 2
Disabled breakpoint 2 at /tmp/cart_demo.py:15
ipdb> b
Num Type         Disp Enb   Where
2   breakpoint   keep no    at /tmp/cart_demo.py:15
	stop only if item['qty'] > 2
	breakpoint already hit 1 time
ipdb> enable 2
Enabled breakpoint 2 at /tmp/cart_demo.py:15
ipdb> tbreak 16
Breakpoint 3 at /tmp/cart_demo.py:16
ipdb> c
Deleted breakpoint 3 at /tmp/cart_demo.py:16
> /tmp/cart_demo.py(16)cart_total()
     14     for item in items:
2    15         total += line_total(item)
---> 16     return round(total * (1 - discount), 2)
     17 
     18 

ipdb> p total
2775.0
ipdb> c

b line_total ставит точку на функцию, b 15 — на строку текущего файла, b 15, item['qty'] > 2 — условную: в транскрипте видно, что мы сразу попали на второй товар, минуя первый. b без аргументов печатает таблицу — где стоит, включена ли, условие, сколько раз срабатывала. tbreak одноразовая, удаляется сама. cl удаляет, disable/enable выключают, не удаляя.

session("""
    b line_total
    ignore 1 1    # пропустить одно следующее срабатывание брейкпоинта 1
    c
    p item
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> b line_total
Breakpoint 1 at /tmp/cart_demo.py:9
ipdb> ignore 1 1
Will ignore next 1 crossing of breakpoint 1.
ipdb> c
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
1---> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> p item
{'price': 5, 'currency': 'EUR', 'qty': 3}
ipdb> c

ignore 1 1 — «пропусти следующее срабатывание точки 1». Способ добраться до нужной итерации, когда условие сформулировать лень.

Брейкпоинт не срабатывает — смотри на путь

pdb сравнивает путь из b file.py:15 с тем, который записан во фрейме, строка в строку. На macOS эти два пути легко разъезжаются: /tmp — симлинк на /private/tmp, поэтому один и тот же файл может называться по-разному, и брейкпоинт молча не сработает.

Надёжнее ставить его без пути: b 15 — по строке текущего файла, b line_total — по имени функции. А если путь всё-таки нужен, возьми его из вывода pdb: там файл назван ровно так, как видит его отладчик.

Следить за значением: display, undisplay#

session("""
    b 15
    c
    display total      # печатать total на брейкпоинтах и шагах, когда значение изменилось
    c
    undisplay total
    display            # список выражений под наблюдением
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> b 15
Breakpoint 1 at /tmp/cart_demo.py:15
ipdb> c
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
1--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> display total
display total: 0.0
ipdb> c
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
1--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

display total: 1500.0  [old: 0.0]
ipdb> undisplay total
ipdb> display
Currently displaying:
ipdb> c

display total печатает выражение и когда сработал брейкпоинт, и после каждого шага n или s — но только если значение изменилось, и показывает прошлое: display total: 1500.0  [old: 0.0]. Ровно то, зачем обычно ставят print внутрь цикла. display без аргументов показывает список, undisplay убирает.

Делать что-то при каждом срабатывании: commands#

session("""
    b 15
    commands 1             # команды, которые pdb выполнит при срабатывании точки 1
    p item['currency']
    end                    # конец блока
    c
    c
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> b 15
Breakpoint 1 at /tmp/cart_demo.py:15
ipdb> commands 1
(com) p item['currency']
(com) end
ipdb> c
p item['currency']
'USD'
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
1--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> c
p item['currency']
'EUR'
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
1--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> c

Блок commands 1 ... end привязывает команды к брейкпоинту — pdb выполнит их сам при каждом попадании (в транскрипте видно приглашение (com)). Если внутри блока последней командой поставить c, получится точка, которая печатает и бежит дальше: трассировка без правки кода.

По стеку вверх и вниз: w, u, d#

session("""
    tbreak to_rub    # одноразовый брейкпоинт в самой вложенной функции
    c
    w                # стек фреймов
    args
    u                # на фрейм выше
    p item
    u
    p total
    d                # и обратно вниз
    d
    p currency
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> tbreak to_rub
Breakpoint 1 at /tmp/cart_demo.py:5
ipdb> c
Deleted breakpoint 1 at /tmp/cart_demo.py:5
> /tmp/cart_demo.py(5)to_rub()
      3 
      4 def to_rub(amount, currency):
----> 5     return amount * RATES[currency]
      6 
      7 

ipdb> w
  /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
---> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 
  /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 
> /tmp/cart_demo.py(5)to_rub()
      3 
      4 def to_rub(amount, currency):
----> 5     return amount * RATES[currency]
      6 
      7 

ipdb> args
amount = 10
currency = 'USD'
ipdb> u
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> p item
{'price': 10, 'currency': 'USD', 'qty': 2}
ipdb> u
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
---> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> p total
0.0
ipdb> d
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

ipdb> d
> /tmp/cart_demo.py(5)to_rub()
      3 
      4 def to_rub(amount, currency):
----> 5     return amount * RATES[currency]
      6 
      7 

ipdb> p currency
'USD'
ipdb> c

w (where, bt) печатает цепочку фреймов, внизу — текущий. u поднимается к вызывающему, d спускается обратно, а p и args всегда работают в выбранном фрейме: так со дна чужой библиотеки поднимаешься к своему коду и смотришь, с чем его позвали. Служебных фреймов самого отладчика в транскрипте не видно — ядро IPython их прячет, а runcall начинает стек прямо с cart_total. В терминале под python -m pdb наверху w окажутся фреймы pdb.py и bdb.py, из которых отладчик исполняет твой скрипт: это видно в post-mortem-транскрипте ниже.

Менять состояние на ходу: !, alias, jump#

session("""
    b 15
    c
    p item
    !item['qty'] = 10                       # выполнить Python в текущем фрейме
    p item
    alias pq p item['price'], item['qty']   # своё сокращение для длинной команды
    pq
    unalias pq
    jump 16                                 # перескочить на строку 16
    p total
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> b 15
Breakpoint 1 at /tmp/cart_demo.py:15
ipdb> c
> /tmp/cart_demo.py(15)cart_total()
     13     total = 0.0
     14     for item in items:
1--> 15         total += line_total(item)
     16     return round(total * (1 - discount), 2)
     17 

ipdb> p item
{'price': 10, 'currency': 'USD', 'qty': 2}
ipdb> !item['qty'] = 10
ipdb> p item
{'price': 10, 'currency': 'USD', 'qty': 10}
ipdb> alias pq p item['price'], item['qty']
ipdb> pq
(10, 10)
ipdb> unalias pq
ipdb> jump 16
> /tmp/cart_demo.py(16)cart_total()
     14     for item in items:
1    15         total += line_total(item)
---> 16     return round(total * (1 - discount), 2)
     17 
     18 

ipdb> p total
0.0
ipdb> c

!<код> выполняет произвольный Python в текущем фрейме — item действительно поменялся. alias заводит свою команду (удобно держать их в ~/.pdbrc, pdb читает его на старте), unalias убирает. jump 16 переносит исполнение на другую строку того же фрейма: мы выскочили из цикла, и total остался 0.0 — прыжок меняет счётчик инструкций, но не откатывает и не досчитывает состояние.

Отладчик внутри отладчика: debug, retval#

session("""
    debug line_total(items[0])    # вычислить выражение под вложенным отладчиком
    s
    ll
    r
    retval                        # значение, которое вернул фрейм
    c                             # выйти из вложенного отладчика
""")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> debug line_total(items[0])
ENTERING RECURSIVE DEBUGGER
> <string>(1)<module>()

(ipdb>) s
--Call--
> /tmp/cart_demo.py(8)line_total()
      6 
      7 
----> 8 def line_total(item):
      9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 

(ipdb>) ll
----> 8 def line_total(item):
      9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 

(ipdb>) r
--Return--
1500.0
> /tmp/cart_demo.py(9)line_total()
      7 
      8 def line_total(item):
----> 9     return to_rub(item["price"], item["currency"]) * item["qty"]
     10 
     11 

(ipdb>) retval
1500.0
(ipdb>) c
LEAVING RECURSIVE DEBUGGER
ipdb> c

debug <выражение> вычисляет выражение под вложенным отладчиком — приглашение берётся в скобки: (ipdb>) в транскрипте, ((Pdb)) в терминале. Удобно, когда надо пройти шагами по одному вызову, не перезапуская программу. retval печатает значение, которое фрейм только что вернул.

interact — консоль поверх фрейма#

Здесь скриптованный ввод уже не годится: interact отдаёт stdin интерактивной консоли. Поэтому гоняем как обычный процесс, команды подаём файлом.

%%writefile /tmp/run_cart_bp.py
import sys

sys.path.insert(0, "/tmp")

from cart_demo import cart_total

CART = [{"price": 10, "currency": "USD", "qty": 2},
        {"price": 5, "currency": "EUR", "qty": 3}]
label = "корзина"

breakpoint()
print("итого:", cart_total(CART, discount=0.1))
Writing /tmp/run_cart_bp.py
%%writefile /tmp/cart_interact.pdb
p CART[0]
interact
CART[0]["qty"] = 99
label = "подменили имя"
exit()
p CART[0]
p label
c
Writing /tmp/cart_interact.pdb
!uv run python /tmp/run_cart_bp.py < /tmp/cart_interact.pdb
> /tmp/run_cart_bp.py(11)<module>()
-> breakpoint()
(Pdb) {'price': 10, 'currency': 'USD', 'qty': 2}
(Pdb) *pdb interact start*
>>> >>> >>> 
*exit from pdb interact command*
(Pdb) {'price': 10, 'currency': 'USD', 'qty': 99}
(Pdb) 'корзина'
(Pdb) итого: 67972.5

Внутри interact мы сделали две правки: мутировали словарь и присвоили новое значение имени label. После выхода (exit()) видно, что мутация объекта осталась — qty стал 99, и сумма выросла, — а присваивание имени пропало. interact работает в копии namespace фрейма: объекты те же, имена свои.

Терминальный режим: команды из файла и post-mortem#

Так pdb гоняют в CI и в домашках: скрипт запускается под отладчиком, команды приходят со stdin. Подопытный падает в глубине — три фрейма вниз, да ещё с цепочкой исключений.

%%writefile /tmp/checkout_demo.py
import sys

sys.path.insert(0, "/tmp")

from cart_demo import cart_total

CART = [{"price": 10, "currency": "USD", "qty": 2},
        {"price": 7, "currency": "GBP", "qty": 1}]


def checkout(items):
    try:
        return cart_total(items)
    except KeyError as exc:
        raise RuntimeError("не удалось посчитать корзину") from exc


print("итого:", checkout(CART))
Writing /tmp/checkout_demo.py
%%writefile /tmp/checkout_demo.pdb
w
exceptions
exceptions 0
p currency
p RATES
u
p item
q
Writing /tmp/checkout_demo.pdb
!uv run python -m pdb -c continue /tmp/checkout_demo.py < /tmp/checkout_demo.pdb 2>&1 | tail -18
-> self.run(target.code)
  /opt/homebrew/Cellar/python@3.14/3.14.8_2/Frameworks/Python.framework/Versions/3.14/lib/python3.14/bdb.py(913)run()
-> exec(cmd, globals, locals)
  <string>(1)<module>()
  /private/tmp/checkout_demo.py(18)<module>()
-> print("итого:", checkout(CART))
> /private/tmp/checkout_demo.py(15)checkout()
-> raise RuntimeError("не удалось посчитать корзину") from exc
(Pdb)     0 KeyError('GBP')
>   1 RuntimeError('не удалось посчитать корзину')
(Pdb) > /tmp/cart_demo.py(5)to_rub()
-> return amount * RATES[currency]
(Pdb) 'GBP'
(Pdb) {'USD': 75.0, 'EUR': 85.0}
(Pdb) > /tmp/cart_demo.py(9)line_total()
-> return to_rub(item["price"], item["currency"]) * item["qty"]
(Pdb) {'price': 7, 'currency': 'GBP', 'qty': 1}
(Pdb) 

-c continue — «не стой на первой строке, беги сразу»; на необработанном исключении pdb сам переходит в post-mortem и оказывается в фрейме падения. exceptions (с 3.13) печатает всю цепочку сцепленных исключений, а exceptions 0 переключает отладчик в фрейм исходной причины — тут это KeyError внутри to_rub, тремя фреймами глубже того raise ... from, который видно в трейсбеке. q выходит, restart перезапускает программу с теми же брейкпоинтами, run <args> — с другими аргументами.

Ровно так запускается задача debug/pdb_needle: python -m pdb -c continue log_processor.py < commands.pdb. У debug/pdb_quest тот же файл команд на stdin, но без -c continue — там сессия начинается там, где pdb встаёт перед первой строкой скрипта.

help — справка не выходя из отладчика#

Документация у каждой команды есть прямо в сессии, вместе с алиасами:

session("help until")
> /tmp/cart_demo.py(13)cart_total()
     11 
     12 def cart_total(items, discount=0.0):
---> 13     total = 0.0
     14     for item in items:
     15         total += line_total(item)

ipdb> help until
      Usage: unt(il) [lineno]
      
      Without argument, continue execution until the line with a
      number greater than the current one is reached.  With a line
      number, continue execution until a line with a number greater
      or equal to that is reached.  In both cases, also stop when
      the current frame returns.
ipdb> c

breakpoint()#

Ставим breakpoint() в коде — и попадаем в pdb, никаких import не нужно (PEP 553). Чем именно открывать отладчик, решает sys.breakpointhook.

Подопытный на всё, что дальше, — тот же cart_total, только вызванный из скрипта с breakpoint():

%%writefile /tmp/bp_demo.py
from cart_demo import cart_total

CART = [{"price": 10, "currency": "USD", "qty": 2}]

breakpoint()
print("итого:", cart_total(CART))
Writing /tmp/bp_demo.py

В терминале uv run python /tmp/bp_demo.py теперь останавливается на breakpoint() и ждёт команд.

PYTHONBREAKPOINT — переключить отладчик#

Переменная окружения задаёт, что звать вместо pdb.set_trace: ipdb — тот же pdb, но с автодополнением и подсветкой, web_pdb — сессия в браузере.

# Использовать ipdb вместо pdb (--with подтянет его только на время запуска)
PYTHONBREAKPOINT=ipdb.set_trace uv run --with ipdb python /tmp/bp_demo.py

# Отладчик в браузере, сессия открывается на http://localhost:5555
PYTHONBREAKPOINT=web_pdb.set_trace uv run --with web-pdb python /tmp/bp_demo.py

А 0 превращает breakpoint() в no-op — забытый в проде вызов не остановит процесс. Это уже можно посмотреть прямо здесь: скрипт с breakpoint() доезжает до конца и печатает результат, ни на чём не остановившись.

!PYTHONBREAKPOINT=0 uv run python /tmp/bp_demo.py
итого: 1500.0

Подключиться к работающему процессу — pdb -p PID#

PID (process id) — идентификатор процесса: операционная система выдаёт его каждому процессу при запуске, и дальше по этому номеру процесс и адресуют. Его принимают ps, kill и pdb -p, а сам процесс узнаёт свой через os.getpid().

С Python 3.14 pdb цепляется к уже запущенному процессу по PID, без правок его кода и без перезапуска — флаг -p; механизм описан в PEP 768. Практическое следствие одно: прицепиться можно только к процессу, который что-то делает. pdb -p не останавливает подопытного силой — он оставляет ему просьбу, а тот подхватит её, когда дойдёт до следующей инструкции. Если процесс висит в системном вызове (time.sleep, чтение из сокета, ожидание ввода), исполнять ему нечего, и отладчик просто ждёт, пока вызов не закончится.

Подопытный печатает свой PID и дальше просто считает в цикле. Режим sleep нужен, чтобы увидеть то самое требование живьём:

%%writefile /tmp/attach_victim.py
import os
import sys
import time

answer = 42

print("PID:", os.getpid(), flush=True)

if "sleep" in sys.argv:
    print("сплю 30 s — прицепиться получится только после", flush=True)
    time.sleep(30)

n = 0
while True:      # процесс всё время исполняет байткод
    n += 1
Writing /tmp/attach_victim.py

Дальше — из терминала: процессу надо жить дольше ячейки, а отладчику нужен свой tty. Ниже команды, чтобы скопировать.

# Терминал 1 — поднять подопытного, он напечатает свой PID:
#   uv run python /tmp/attach_victim.py          # сразу крутит цикл
#   uv run python /tmp/attach_victim.py sleep    # сначала 30 s в time.sleep
#
# Терминал 2 — прицепиться по этому PID (на macOS нужен sudo):
#   sudo uv run python -m pdb -p <PID>
#   (Pdb) where      # стек живого процесса
#   (Pdb) p answer   # 42 — читаем состояние работающей программы
#   (Pdb) p n        # счётчик; повторить команду - значение уже другое
#   (Pdb) q          # отцепиться, не убивая подопытного

В первом режиме приглашение появляется сразу. Во втором — только когда кончится time.sleep: пока процесс стоит в системном вызове, просьбу отладчика он не прочитает.

Подключение можно и запретить: переменная окружения PYTHON_DISABLE_REMOTE_DEBUG или флаг -X disable-remote-debug выключают его на стороне подопытного — в проде это разумно.


2. Post-mortem#

Post-mortem — отладка того, что уже упало. Исключение улетело наружу, но его трейсбек ещё жив, а значит живы и фреймы, и все объекты, на которые они ссылаются. Отладчик садится на этот трейсбек и позволяет ходить по фреймам падения так же, как на брейкпоинте: w, u, d, p работают как обычно. Разница одна — исполнение с этого места не продолжить: c или s запустят программу заново, и pdb об этом честно предупреждает строкой Running 'cont' or 'step' will restart the program.

Зачем это нужно: воспроизвести падение заново бывает дорого (полчаса на загрузку данных, пять минут на обучение модели) или невозможно вовсе — состояние гонки, данные, которые пришли один раз из прода.

Четыре способа попасть в pdb после падения#

Четыре сценария, четыре разных механизма.

1. python -m pdb -c continue script.py — тот самый терминальный режим, с которого начали: скрипт бежит сам, а на необработанном исключении отладчик встаёт в фрейме падения, без всяких import pdb. Годится для одноразового разбора упавшего CLI-скрипта.

2. pdb.pm() в REPL / Jupyter — когда работаешь интерактивно и что-то упало. Python держит последнее необработанное исключение в sys.last_exc (именно его читает pdb.pm();), поэтому отладчик можно открыть уже после падения:

>>> load_dataset()             # 30 секунд импорта
>>> model = train_forever()    # 5 минут обучения
>>> predict(x)                  # падает с ValueError
Traceback (most recent call last)...
>>> import pdb; pdb.pm()        # прыгаем в фрейм падения, dataset/model живые

Смысл — не терять состояние сессии. Перезапустить Jupyter/REPL ради отладки одной функции — дорого; pdb.pm() вскрывает уже произошедшее падение прямо на месте.

3. pdb.post_mortem() в except — программный путь: ты сам решаешь, когда открывать отладчик:

try:
    critical_operation()
except CriticalError:
    logger.exception("something bad")
    import pdb; pdb.post_mortem()

4. pytest --pdb — post-mortem прямо из прогона тестов; чаще всего в отладчик попадаешь именно так, механику разбираем ниже.

Отладчик из pytest: --pdb, --trace, -l#

В реальной работе падает обычно не скрипт, а тест, поэтому чаще всего в post-mortem попадаешь флагом pytest (см. Лекцию 3). Всё, что ниже, описано в pytest: How to handle test failures.

%%writefile /tmp/test_pdbflags_cart.py
PRICES = {"apple": 1.5, "pear": 2.0}


def cart_total(cart: dict[str, int]) -> float:
    return sum(PRICES[name] * qty for name, qty in cart.items())


def test_cart_total():
    cart = {"apple": 2, "peach": 1}
    assert cart_total(cart) == 5.0
Writing /tmp/test_pdbflags_cart.py

--pdb открывает post-mortem в фрейме падения. Команды отладчик читает со stdin, поэтому в лекции подаём их файлом — вживую те же p, u, c набираешь руками. Три флага рядом нужны только чтобы вывод влезал в ячейку:

  • -q (quiet) — понижает подробность: вместо строки на каждый тест печатаются точки (. прошёл, F упал), а шапка прогона и строка collected 2 items пропадают;

  • --no-header — убирает шапку с версиями: platform, rootdir, plugins. Вместе с -q он уже ничего не меняет (шапки и так нет), но пригодится, если вернуть обычную подробность;

  • -rN — выключает блок short test summary info в конце. У -r аргумент — набор букв, что в этот блок писать: f упавшие, E ошибки, s пропущенные, x/X xfail/xpass, p/P прошедшие, a всё кроме прошедших, A всё; по умолчанию fE. Буква N сбрасывает набор в пустой.

Сам разбор падения — блок FAILURES, строка с assert и итог 1 failed — ни одним из них не выключается.

%%writefile /tmp/pdbflags_cmds.txt
p name
u
p cart
c
Writing /tmp/pdbflags_cmds.txt
!uv run pytest /tmp/test_pdbflags_cart.py --pdb -q --no-header -rN < /tmp/pdbflags_cmds.txt
F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
    def test_cart_total():
        cart = {"apple": 2, "peach": 1}
>       assert cart_total(cart) == 5.0
               ^^^^^^^^^^^^^^^^

/tmp/test_pdbflags_cart.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/tmp/test_pdbflags_cart.py:5: in cart_total
    return sum(PRICES[name] * qty for name, qty in cart.items())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

.0 = <dict_itemiterator object at 0x10734de90>

>   return sum(PRICES[name] * qty for name, qty in cart.items())
               ^^^^^^^^^^^^
E   KeyError: 'peach'

/tmp/test_pdbflags_cart.py:5: KeyError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>
> /tmp/test_pdbflags_cart.py(5)<genexpr>()
-> return sum(PRICES[name] * qty for name, qty in cart.items())
(Pdb) 'peach'
(Pdb) > /tmp/test_pdbflags_cart.py(5)cart_total()
-> return sum(PRICES[name] * qty for name, qty in cart.items())
(Pdb) {'apple': 2, 'peach': 1}
(Pdb) 
>>>>>>>>>>>>>>>>>>>>> PDB continue (IO-capturing resumed) >>>>>>>>>>>>>>>>>>>>>>
                                                                         [100%]
1 failed in 0.10s

Приземлились не в тест и не в cart_total, а в <genexpr>: генераторное выражение внутри sum — отдельный фрейм со своими локальными переменными, и в нём name равен 'peach'. u поднимает на фрейм выше, где виден весь cart. c отдаёт управление pytest, и прогон спокойно доигрывает до отчёта; q вместо c оборвал бы всю сессию (Exit: Quitting debugger).

Строка PDB post_mortem (IO-capturing turned off) — pytest снял перехват stdout/stdin на время отладки. По той же причине breakpoint() прямо в тесте работает без -s:

%%writefile /tmp/test_pdbflags_bp.py
def test_with_breakpoint():
    cart = {"apple": 2, "peach": 1}
    breakpoint()
    assert sum(cart.values()) == 3
Writing /tmp/test_pdbflags_bp.py
%%writefile /tmp/pdbflags_bp.txt
p cart
c
Writing /tmp/pdbflags_bp.txt
!uv run pytest /tmp/test_pdbflags_bp.py -q --no-header -rN < /tmp/pdbflags_bp.txt
>>>>>>>>>>>>>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>
> /tmp/test_pdbflags_bp.py(3)test_with_breakpoint()
-> breakpoint()
(Pdb) {'apple': 2, 'peach': 1}
(Pdb) 
>>>>>>>>>>>>>>>>>>>>> PDB continue (IO-capturing resumed) >>>>>>>>>>>>>>>>>>>>>>
.                                                                        [100%]
1 passed in 0.07s

--trace — противоположный конец: pdb встаёт на первой строке каждого теста, до того как что-то выполнилось. Нужно, когда тест не падает, а ведёт себя странно.

%%writefile /tmp/pdbflags_trace.txt
n
p cart
c
Writing /tmp/pdbflags_trace.txt
!uv run pytest /tmp/test_pdbflags_cart.py --trace -q --no-header -rN < /tmp/pdbflags_trace.txt 2>&1 | head -8
>>>>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>
> /tmp/test_pdbflags_cart.py(9)test_cart_total()
-> cart = {"apple": 2, "peach": 1}
(Pdb) > /tmp/test_pdbflags_cart.py(10)test_cart_total()
-> assert cart_total(cart) == 5.0
(Pdb) {'apple': 2, 'peach': 1}
(Pdb) 

Часто отладчик и не нужен: -l (--showlocals) дописывает в трейсбек локальные переменные каждого фрейма. Сначала без него:

%%writefile /tmp/test_pdbflags_rows.py
def normalize(rows: list[dict[str, str]]) -> list[str]:
    out = []
    for i, row in enumerate(rows):
        name = row["name"].strip().lower()
        out.append(f"{i}:{name}")
    return out


def test_normalize():
    rows = [{"name": " Ann "}, {"name": "BOB"}, {"nome": "Eve"}]
    assert normalize(rows) == ["0:ann", "1:bob", "2:eve"]
Writing /tmp/test_pdbflags_rows.py
!uv run pytest /tmp/test_pdbflags_rows.py -q --no-header -rN 2>&1 | tail -12
rows = [{'name': ' Ann '}, {'name': 'BOB'}, {'nome': 'Eve'}]

    def normalize(rows: list[dict[str, str]]) -> list[str]:
        out = []
        for i, row in enumerate(rows):
>           name = row["name"].strip().lower()
                   ^^^^^^^^^^^
E           KeyError: 'name'

/tmp/test_pdbflags_rows.py:4: KeyError
1 failed in 0.09s

Видно KeyError: 'name', но не видно, на каком элементе входа. С -l:

!uv run pytest /tmp/test_pdbflags_rows.py -q --no-header -rN -l 2>&1 | tail -12
>           name = row["name"].strip().lower()
                   ^^^^^^^^^^^
E           KeyError: 'name'

i          = 2
name       = 'bob'
out        = ['0:ann', '1:bob']
row        = {'nome': 'Eve'}
rows       = [{'name': ' Ann '}, {'name': 'BOB'}, {'nome': 'Eve'}]

/tmp/test_pdbflags_rows.py:4: KeyError
1 failed in 0.09s

i равен 2, row равен {'nome': 'Eve'} — опечатка в третьем элементе, и и всё это без отладчика. Такой прогон воспроизводим, поэтому -l разумно держать в addopts, а не набирать руками.

Часть II. Программная интроспекция#

3. inspect — вытащить всё про объект#

Универсальный модуль для интроспекции. Зачем он нужен: почти вся «магия» знакомых библиотек — это чтение сигнатуры чужой функции. pytest смотрит на имена параметров теста и подставляет по ним фикстуры, dataclass собирает __init__ из аннотаций полей. Механизм один, и он в stdlib — вот он целиком, на примере фикстур:

import inspect

FIXTURES = {"db": "postgres://localhost", "user": "ivan", "clock": 1234}


def check_login(db, user):  # имена параметров = что этому тесту нужно
    return f"{user} logs into {db}"


params = inspect.signature(check_login).parameters
print(f"параметры: {list(params)}")
print(check_login(**{name: FIXTURES[name] for name in params}))
параметры: ['db', 'user']
ivan logs into postgres://localhost

Дальше — из чего сигнатура состоит.

Сигнатуры#

import inspect


def greet(name: str, age: int = 30, *, formal: bool = False) -> str:
    return f"Hi, {name}"


sig = inspect.signature(greet)
print(f"signature: {sig}")

for name, param in sig.parameters.items():
    print(f"  {name}: kind={param.kind.name}, default={param.default}, annotation={param.annotation}")

print(f"return: {sig.return_annotation}")
signature: (name: str, age: int = 30, *, formal: bool = False) -> str
  name: kind=POSITIONAL_OR_KEYWORD, default=<class 'inspect._empty'>, annotation=<class 'str'>
  age: kind=POSITIONAL_OR_KEYWORD, default=30, annotation=<class 'int'>
  formal: kind=KEYWORD_ONLY, default=False, annotation=<class 'bool'>
return: <class 'str'>

Исходники и docstring#

import inspect


def sample():
    """Sample doc."""
    x = 1
    return x + 1


print(inspect.getsource(sample))
print(f"docstring: {inspect.getdoc(sample)}")
def sample():
    """Sample doc."""
    x = 1
    return x + 1

docstring: Sample doc.

Обход членов: getmembers и предикаты#

import inspect
import os


funcs = [name for name, _ in inspect.getmembers(os.path, inspect.isfunction)
         if not name.startswith("_")]
print(f"os.path: {len(funcs)} публичных функций, первые пять: {funcs[:5]}")
os.path: 30 публичных функций, первые пять: ['abspath', 'basename', 'commonpath', 'commonprefix', 'dirname']

Полезные предикаты: isfunction, isclass, ismodule, iscoroutine, iscoroutinefunction, ismethod, isawaitable.

Стек и фреймы#

import inspect


def deep():
    stack = inspect.stack()
    for frame in stack[:3]:
        print(f"  {frame.function} at {frame.filename}:{frame.lineno}")


def mid():
    deep()


def top():
    mid()


print("call stack (top 3):")
top()
call stack (top 3):
  deep at /var/folders/db/8q3n6yxn6l32mz6r0frxg7v40000gn/T/ipykernel_32553/3132099831.py:5
  mid at /var/folders/db/8q3n6yxn6l32mz6r0frxg7v40000gn/T/ipykernel_32553/3132099831.py:11
  top at /var/folders/db/8q3n6yxn6l32mz6r0frxg7v40000gn/T/ipykernel_32553/3132099831.py:15

Каждый FrameInfo содержит: filename, lineno, function, code_context (строки вокруг), index (какая из них текущая), frame (сам frame object с f_locals и f_globals) и positions — точные границы выражения в строке, те самые из PEP 657.

Расплата за удобство: inspect.stack() проходит весь стек, на каждый фрейм собирает FrameInfo, а за code_context ещё и дёргает linecache. Выключить чтение исходника (context=0) — только половина экономии: сборка FrameInfo сама по себе на порядки дороже. В логгере или декораторе так делать нельзя — там берут sys._getframe().

import inspect
import sys
import timeit


def via_stack():
    return inspect.stack()[0].function


def via_stack_nocontext():
    return inspect.stack(context=0)[0].function


def via_getframe():
    return sys._getframe().f_code.co_name


for name, fn in (("inspect.stack()", via_stack),
                 ("inspect.stack(context=0)", via_stack_nocontext),
                 ("sys._getframe()", via_getframe)):
    us = timeit.timeit(fn, number=200) / 200 * 1e6
    print(f"{name:26s} {us:9.2f} us")
inspect.stack()              1717.26 us
inspect.stack(context=0)      670.95 us
sys._getframe()                 0.05 us

4. dis — что там под капотом#

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

import dis

for src in ("x in [1, 2, 3]", "x in (1, 2, 3)", "x in {1, 2, 3}"):
    code = compile(src, "<demo>", "eval")
    ops = [i.opname if i.opname != "LOAD_CONST" else f"LOAD_CONST {i.argval!r}"
           for i in dis.get_instructions(code)]
    print(f"{src:16} {' '.join(ops)}")
x in [1, 2, 3]   RESUME LOAD_NAME LOAD_CONST (1, 2, 3) CONTAINS_OP RETURN_VALUE
x in (1, 2, 3)   RESUME LOAD_NAME LOAD_CONST (1, 2, 3) CONTAINS_OP RETURN_VALUE
x in {1, 2, 3}   RESUME LOAD_NAME LOAD_CONST frozenset({1, 2, 3}) CONTAINS_OP RETURN_VALUE

Инструкции одинаковые: литерал целиком свернулся в константу ещё при компиляции, причём список стал кортежем, а множество — frozenset. Совет устарел, менять нечего — и узнать это можно только так.

CPython — стековая виртуальная машина. Твой def foo(x): return x + 1 компилируется в байткод — компактный набор инструкций (LOAD_FAST, LOAD_SMALL_INT, BINARY_OP, RETURN_VALUE). Полный список опкодов с описанием — в dis docs — Python bytecode instructions. Набор инструкций меняется от версии к версии вместе с компилятором. Внутренности этой машинерии разберём дальше в курсе, здесь только читаем байткод. Дизассемблер живёт в stdlib-модуле dis.

Откуда берётся байткод: токены, AST, компиляция#

Путь от текста до инструкций проходится тремя шагами, и каждый видно из stdlib. Подопытная программа на весь раздел:

program = "x = 1\ny = 2\nprint(3 * (x + y))\n"

exec(program)
9

Шаг первый — токенизация: текст режется на лексемы с их позициями в исходнике. Позиции потом всплывают в трейсбеках и в co_lines().

import io
import tokenize

for tok in list(tokenize.generate_tokens(io.StringIO(program).readline))[:8]:
    print(f"{tokenize.tok_name[tok.type]:10} {tok.string!r:8} {tok.start} -> {tok.end}")
NAME       'x'      (1, 0) -> (1, 1)
OP         '='      (1, 2) -> (1, 3)
NUMBER     '1'      (1, 4) -> (1, 5)
NEWLINE    '\n'     (1, 5) -> (1, 6)
NAME       'y'      (2, 0) -> (2, 1)
OP         '='      (2, 2) -> (2, 3)
NUMBER     '2'      (2, 4) -> (2, 5)
NEWLINE    '\n'     (2, 5) -> (2, 6)

Шаг второй — разбор в AST: дерево, где узлы это конструкции языка, а не символы. Приоритет операций уже зашит в форму дерева: 3 * (x + y) — это BinOp(Mult), у которого справа вложенный BinOp(Add).

import ast

print(ast.dump(ast.parse(program), indent=4))
Module(
    body=[
        Assign(
            targets=[
                Name(id='x', ctx=Store())],
            value=Constant(value=1)),
        Assign(
            targets=[
                Name(id='y', ctx=Store())],
            value=Constant(value=2)),
        Expr(
            value=Call(
                func=Name(id='print', ctx=Load()),
                args=[
                    BinOp(
                        left=Constant(value=3),
                        op=Mult(),
                        right=BinOp(
                            left=Name(id='x', ctx=Load()),
                            op=Add(),
                            right=Name(id='y', ctx=Load())))]))])

Шаг третий — компиляция: из AST получается code object, а в нём co_code — те самые байты. Инструкция занимает два байта: опкод и аргумент.

import dis

code = compile(program, "<demo>", "exec")
raw = list(code.co_code)
names = [dis.opname[b] for b in raw[::2]]

print(f"co_code:      {len(raw)} байт, {len(names)} пар (опкод, аргумент)")
print(f"инструкций:   {len(list(dis.get_instructions(code)))}")
print(f"из них CACHE: {names.count('CACHE')}")
print(names)
co_code:      58 байт, 29 пар (опкод, аргумент)
инструкций:   16
из них CACHE: 13
['RESUME', 'LOAD_SMALL_INT', 'STORE_NAME', 'LOAD_SMALL_INT', 'STORE_NAME', 'LOAD_NAME', 'PUSH_NULL', 'LOAD_SMALL_INT', 'LOAD_NAME', 'LOAD_NAME', 'BINARY_OP', 'CACHE', 'CACHE', 'CACHE', 'CACHE', 'CACHE', 'BINARY_OP', 'CACHE', 'CACHE', 'CACHE', 'CACHE', 'CACHE', 'CALL', 'CACHE', 'CACHE', 'CACHE', 'POP_TOP', 'LOAD_CONST', 'RETURN_VALUE']

Пар больше, чем инструкций: CACHE — не операция, а зарезервированное место под служебные данные, которые интерпретатор заполняет по ходу исполнения; таких слотов по нескольку после BINARY_OP и CALL. Зачем они — дальше в курсе, а здесь важно, что сырые байты читать руками бессмысленно: этим и занимается dis.

dis.dis — читаемый дамп#

import dis


def add_one(x: int) -> int:
    y = x + 1
    return y


dis.dis(add_one)
  4           RESUME                   0

  5           LOAD_FAST_BORROW         0 (x)
              LOAD_SMALL_INT           1
              BINARY_OP                0 (+)
              STORE_FAST               1 (y)

  6           LOAD_FAST_BORROW         1 (y)
              RETURN_VALUE

Читается как последовательность операций над стеком: LOAD_FAST_BORROW x -> LOAD_SMALL_INT 1 -> BINARY_OP + -> STORE_FAST y. Колонки: номер строки исходника, имя инструкции, её аргумент и расшифровка аргумента в скобках. Смещения в байткоде с 3.13 по умолчанию не печатаются — их включает dis.dis(add_one, show_offsets=True). Первая инструкция RESUME — точка входа в фрейм (и возобновления для генераторов), при чтении её можно пропускать.

Code object — что снаружи#

Байткод и мета-инфа самого кода живут в func.__code__ (объект code, неизменяемый) — но не всё: дефолты и аннотации лежат на функции, а не в code object. Ключевые атрибуты:

c = add_one.__code__

print(f"co_name:      {c.co_name}")
print(f"co_argcount:  {c.co_argcount}")
print(f"co_varnames:  {c.co_varnames}")
print(f"co_consts:    {c.co_consts}")
print(f"co_names:     {c.co_names}")       # имена глобалов и атрибутов; у add_one их нет
print(f"co_flags:     {c.co_flags:#b}")    # CO_OPTIMIZED, CO_NEWLOCALS, ...
print(f"co_code (первые 8 байт): {c.co_code[:8].hex()}")
co_name:      add_one
co_argcount:  1
co_varnames:  ('x', 'y')
co_consts:    (1,)
co_names:     ()
co_flags:     0b11
co_code (первые 8 байт): 800056005e012c00

co_code — сырые байты инструкций, их и парсит dis.dis. Ещё у code object есть co_lines() — итератор кортежей (start_offset, end_offset, line) для отображения инструкций на строки исходника; line бывает None — у инструкций, которых в исходнике нет. На этом отображении стоят все, кому надо показать строку по адресу инструкции: отладчики, coverage, профайлеры.

Сигнатура целиком — без inspect#

inspect.signature почти ничего не вычисляет: имена и счётчики параметров уже лежат в code object, дефолты — рядом, на самой функции. Раскладка co_varnames жёсткая, но порядок в ней не повторяет порядок записи в сигнатуре: сначала позиционные, потом именованные (keyword-only), и только после них — *args и **kwargs. В исходнике *args стоит раньше именованных (иначе их и не объявить), а в co_varnames он оказывается позже: имена звёздочных параметров задвинуты за все обычные. Дальше — остальные локальные переменные, кроме тех, что захватило замыкание: они уезжают в co_cellvars. Сколько чего — в трёх счётчиках, есть ли *args/**kwargs — в битах co_flags (список флагов). Ниже в выводе видно оба порядка сразу: def в коде ячейки и co_varnames первой строкой.

CO_VARARGS, CO_VARKEYWORDS = 0x04, 0x08


def demo(a, b, /, c, d=4, *args, e, f=6, **kwargs):
    local = a
    return local


co = demo.__code__
n_pos, n_kw = co.co_argcount, co.co_kwonlyargcount
pos, kwonly = co.co_varnames[:n_pos], co.co_varnames[n_pos : n_pos + n_kw]

tail = n_pos + n_kw
varargs = varkw = None
if co.co_flags & CO_VARARGS:
    varargs, tail = co.co_varnames[tail], tail + 1
if co.co_flags & CO_VARKEYWORDS:
    varkw, tail = co.co_varnames[tail], tail + 1

defaults = demo.__defaults__ or ()

print(f"co_varnames:                 {co.co_varnames}")
print(f"только позиционные:          {pos[:co.co_posonlyargcount]}")
print(f"позиционные или именованные: {pos[co.co_posonlyargcount:]}")
print(f"именованные:                 {kwonly}")
print(f"*args:                       {varargs}   **kwargs: {varkw}")
print(f"defaults:                    {dict(zip(pos[len(pos) - len(defaults):], defaults, strict=True))}")
print(f"kwdefaults:                  {demo.__kwdefaults__}")
print(f"остальные локальные:         {co.co_varnames[tail:]}")
co_varnames:                 ('a', 'b', 'c', 'd', 'e', 'f', 'args', 'kwargs', 'local')
только позиционные:          ('a', 'b')
позиционные или именованные: ('c', 'd')
именованные:                 ('e', 'f')
*args:                       args   **kwargs: kwargs
defaults:                    {'d': 4}
kwdefaults:                  {'f': 6}
остальные локальные:         ('local',)

Три места, где легко ошибиться

  • co_argcount — это все позиционные: и те, что только позиционные, и те, что можно передать ещё и именованными; а co_posonlyargcount — часть от него, не добавка. *args/**kwargs не входят ни в один из трёх счётчиков (в co_nlocals — входят): их имена стоят между параметрами и обычными локальными переменными, найти их можно только по флагам.
  • __defaults__ — кортеж, выровненный по хвосту позиционных: длина k покрывает последние k имён из co_argcount. Дефолты именованных живут отдельно в __kwdefaults__ (dict или None).
  • Дефолты и аннотации лежат не в code object, а на функции: code object переиспользуется, а __defaults__ вычисляются при каждом def.

Ту же раскладку готовым текстом печатает dis.show_code — быстро глянуть руками, не собирая слайсы.

import dis

dis.show_code(demo)
Name:              demo
Filename:          /var/folders/db/8q3n6yxn6l32mz6r0frxg7v40000gn/T/ipykernel_32553/1157754637.py
Argument count:    4
Positional-only arguments: 2
Kw-only arguments: 2
Number of locals:  9
Stack size:        1
Flags:             OPTIMIZED, NEWLOCALS, VARARGS, VARKEYWORDS
Constants:
   0: None
Variable names:
   0: a
   1: b
   2: c
   3: d
   4: e
   5: f
   6: args
   7: kwargs
   8: local

co_stacksize и co_lines()#

Ещё два места в code object, нужные не читателю, а машине. co_stacksize — максимальная глубина стека значений, которую посчитал компилятор; фрейм выделяется сразу под неё. число растёт от вложенности выражений.

def flat(a, b):
    return a + b


def nested(a, b, c, d, e):
    return max(a, min(b, c), sum([d, e, a + b]))


def gen(path):
    with open(path) as fh:
        yield from fh


for fn in (flat, nested, gen):
    print(f"{fn.__name__:6} co_stacksize={fn.__code__.co_stacksize}")
flat   co_stacksize=2
nested co_stacksize=10
gen    co_stacksize=7

У gen семёрку набирает служебная машинерия with (её выход через __exit__ — самое глубокое место) — в исходнике её не видно.

Обещанный выше co_lines() (PEP 626) — вживую, на том же генераторе. Сюрприз в порядке: строки идут не по возрастанию.

code = gen.__code__
for start, end, line in code.co_lines():
    print(f"  offsets {start:>3}..{end:<3} -> line {line}")

print("\nинструкции, у которых строки нет:")
for start, end, line in code.co_lines():
    if line is None:
        print("   ", [f"{i.opname} {i.argrepr}".strip()
                      for i in dis.get_instructions(code) if start <= i.offset < end])
  offsets   0..6   -> line 9
  offsets   6..46  -> line 10
  offsets  46..66  -> line 11
  offsets  66..86  -> line 10
  offsets  86..90  -> line 11
  offsets  90..124 -> line 10
  offsets 124..134 -> line None

инструкции, у которых строки нет:
    ['COPY', 'POP_EXCEPT', 'RERAISE', 'CALL_INTRINSIC_1 INTRINSIC_STOPITERATION_ERROR', 'RERAISE']

Диапазон без строки — байткод, которого в исходнике нет: reraise-хвост обработчика от with, а за ним CALL_INTRINSIC_1 INTRINSIC_STOPITERATION_ERROR — вставка, которую компилятор добавляет генераторам: она превращает улетевший из генератора StopIteration в RuntimeError. Свой tracer такие диапазоны обязан пропускать — иначе покажет отладчику строку из ниоткуда.

dis.Bytecode — итерируемый API#

Когда нужно программно анализировать инструкции (счётчики, оптимизатор, фильтр безопасности):

from collections import Counter
from dis import Bytecode


def spicy(xs: list[int]) -> int:
    total = 0
    for x in xs:
        total += x * 2
    return total


ops = Counter(instr.opname for instr in Bytecode(spicy))
print(ops.most_common(5))

instr = list(Bytecode(spicy))[3]
print(f"\nполя Instruction: {instr._fields}")
print(f"{instr.opname}: starts_line={instr.starts_line}, line_number={instr.line_number}")
[('STORE_FAST', 3), ('LOAD_SMALL_INT', 2), ('LOAD_FAST_BORROW', 2), ('BINARY_OP', 2), ('RESUME', 1)]

поля Instruction: ('opname', 'opcode', 'arg', 'argval', 'argrepr', 'offset', 'start_offset', 'starts_line', 'line_number', 'label', 'positions', 'cache_info')
LOAD_FAST_BORROW: starts_line=True, line_number=7

Одна ловушка: с 3.13 starts_line — это bool («инструкция начинает новую строку»), а сам номер строки лежит в line_number. Код, написанный по старым примерам из интернета, молча получит True вместо номера.

Вложенные code objects#

Один def — один code object, и только свой. Тело функции, метода, класса, генераторного выражения компилятор складывает константой в co_consts родителя, а наверху остаётся LOAD_CONST <code object ...> плюс MAKE_FUNCTION. Параметр depth=0 у dis.dis запрещает спускаться — видно ровно верхний уровень:

import dis

NEST_SRC = """
class Registry:
    prefix = "id-"

    def keys(self, items):
        return (self.prefix + k for k in items)
"""

module = compile(NEST_SRC, "<nest>", "exec")
dis.dis(module, depth=0)
  0           RESUME                   0

  2           LOAD_BUILD_CLASS
              PUSH_NULL
              LOAD_CONST               0 (<code object Registry at 0x107beb330, file "<nest>", line 2>)
              MAKE_FUNCTION
              LOAD_CONST               1 ('Registry')
              CALL                     2
              STORE_NAME               0 (Registry)
              LOAD_CONST               2 (None)
              RETURN_VALUE

Без depth тот же dis.dis идёт рекурсивно до самого низа и печатает по дампу на каждый вложенный code object. dis.Bytecode — нет: он про ровно один code object, и никакого флага «рекурсивно» у него нет.

import contextlib
import io

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    dis.dis(module)
for line in buf.getvalue().splitlines():
    if line.startswith("Disassembly"):
        print(line)

print(f"\ndis.Bytecode(module): {len(list(dis.Bytecode(module)))} инструкций, вложенные не тронуты")
Disassembly of <code object Registry at 0x107beb330, file "<nest>", line 2>:
Disassembly of <code object keys at 0x107c3ea60, file "<nest>", line 5>:
Disassembly of <code object <genexpr> at 0x107c12430, file "<nest>", line 6>:

dis.Bytecode(module): 10 инструкций, вложенные не тронуты

Значит, для программного анализа рекурсию пишешь сам — обход co_consts с проверкой на types.CodeType:

from collections.abc import Iterator
import types


def walk(code: types.CodeType) -> Iterator[types.CodeType]:
    """Сам code object и все вложенные, в глубину."""
    yield code
    for const in code.co_consts:
        if isinstance(const, types.CodeType):
            yield from walk(const)


for code in walk(module):
    print(f"{code.co_name:10} line {code.co_firstlineno:>2}  инструкций: {len(list(dis.Bytecode(code))):>2}")

print(f"\nв корне: {len(list(dis.Bytecode(module)))}, во всём дереве: {sum(len(list(dis.Bytecode(c))) for c in walk(module))}")
<module>   line  1  инструкций: 10
Registry   line  2  инструкций: 21
keys       line  5  инструкций: 11
<genexpr>  line  6  инструкций: 21

в корне: 10, во всём дереве: 63

Второй вход в то же дерево — через инструкции: у того самого LOAD_CONST в instr.argval лежит вложенный code object, так что обходить можно и не трогая co_consts напрямую.

simple = compile("def f():\n    return 42\n", "<argval>", "exec")
for instr in dis.Bytecode(simple):
    if isinstance(instr.argval, types.CodeType):
        print(f"{instr.opname:12} argval={instr.argval!r}")
LOAD_CONST   argval=<code object f at 0x107c011b0, file "<argval>", line 1>

Comprehension больше не отдельный code object

С 3.12 list/dict/set-comprehensions инлайнятся прямо в родителя (PEP 709) — своего code object у них нет, их байткод лежит в теле родителя. Генераторное выражение и lambda — по-прежнему отдельные. То есть форма дерева зависит от версии интерпретатора: один и тот же исходник на 3.11 и на 3.14 даст разное число вложенных code objects.

def nested_names(src: str) -> list[str]:
    code = compile(src, "<pep709>", "exec")
    return [c.co_name for c in code.co_consts if isinstance(c, types.CodeType)]


for src in [
    "y = [x * 2 for x in r]",
    "y = {x: x * 2 for x in r}",
    "y = (x * 2 for x in r)",
    "f = lambda x: x * 2",
    "def f(x): return x * 2",
]:
    print(f"{src:28} -> {nested_names(src)}")
y = [x * 2 for x in r]       -> []
y = {x: x * 2 for x in r}    -> []
y = (x * 2 for x in r)       -> ['<genexpr>']
f = lambda x: x * 2          -> ['<lambda>']
def f(x): return x * 2       -> ['f']

Зачем это в отладке#

  • Видеть, что происходит на самом деле. total += x * 2 внутри цикла — это LOAD_FAST_BORROW_LOAD_FAST_BORROW (total, x) (два подряд идущих чтения локальных переменных компилятор 3.14 сливает в одну инструкцию) + LOAD_SMALL_INT 2 + BINARY_OP 5 (*) + BINARY_OP 13 (+=) + STORE_FAST + JUMP_BACKWARD. Когда выражение ведёт себя не так, как читается, байткод отвечает на вопрос буквально.

  • Разбирать строку, которая делает несколько вещей. Трейсбек указывает на строку целиком, а упала одна операция из нескольких:

import dis

src = 'data = {"users": None}\nn = data["users"][0]["age"] + 10\n'
dis.dis(compile(src, "<demo>", "exec"))
  0           RESUME                   0

  1           LOAD_CONST               0 ('users')
              LOAD_CONST               1 (None)
              BUILD_MAP                1
              STORE_NAME               0 (data)

  2           LOAD_NAME                0 (data)
              LOAD_CONST               0 ('users')
              BINARY_OP               26 ([])
              LOAD_SMALL_INT           0
              BINARY_OP               26 ([])
              LOAD_CONST               2 ('age')
              BINARY_OP               26 ([])
              LOAD_SMALL_INT          10
              BINARY_OP                0 (+)
              STORE_NAME               1 (n)
              LOAD_CONST               1 (None)
              RETURN_VALUE

BINARY_OP 26 ([]) в дампе встречается трижды — по одной подписке на каждые скобки. Первая, data["users"], отработает штатно и вернёт None; падает вторая, которая этот None индексирует:

%%writefile /tmp/subscript_fail.py
data = {"users": None}
n = data["users"][0]["age"] + 10
Writing /tmp/subscript_fail.py
!uv run python /tmp/subscript_fail.py 2>&1 || true
Traceback (most recent call last):
  File "/tmp/subscript_fail.py", line 2, in <module>
    n = data["users"][0]["age"] + 10
        ~~~~~~~~~~~~~^^^
TypeError: 'NoneType' object is not subscriptable

Тильды в трейсбеке стоят под уже вычисленной частью, каретки — под упавшей операцией. С 3.11 трейсбек подчёркивает упавшее подвыражение сам (PEP 657), так что для такого кода dis уже не нужен. Что он по-прежнему даёт — состав строки: сколько операций в ней на самом деле, в каком порядке вычисляются аргументы, какие имена берутся как глобальные.


Материалы#