Лекция 4. Отладка и интроспекция#
План#
Отладка —
pdbвживую (брейкпоинты, шаги, фреймы,breakpoint(),PYTHONBREAKPOINT, подключение к работающему процессу по PID) и post-mortem, в том числе из pytest.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().
Сначала карта — весь набор команд на одной странице. Дальше разбираем его по группам на одном живом баге, а сюда удобно возвращаться потом.
Команда |
Что делает |
|---|---|
|
код вокруг / текущая функция целиком / исходник объекта |
|
значение / pretty-print / тип / параметры фрейма |
|
перешагнуть вызов / зайти внутрь / до выхода из функции |
|
до строки >= n (выйти из цикла) / дальше |
|
брейкпоинт / условный / одноразовый / удалить |
|
выключить / включить / пропустить k срабатываний |
|
следить за выражением на брейкпоинтах и шагах |
|
что делать при каждом срабатывании точки N |
|
стек фреймов / выше / ниже |
|
выполнить код в фрейме / перескочить на строку |
|
свои команды, живут в |
|
вложенный отладчик / возвращённое значение |
|
консоль поверх копии namespace фрейма |
|
цепочка исключений в post-mortem (3.13+) |
|
перезапуск / перезапуск с аргументами / выход |
|
справка по команде прямо в отладчике |
Если знаешь gdb — pdb почти те же команды
pdb |
gdb |
что |
|---|---|---|
|
|
breakpoint |
|
|
next / step |
|
|
continue |
|
|
печать значения / pretty-print |
|
|
тип выражения |
|
|
stack trace (те же алиасы) |
|
|
навигация по фреймам |
|
|
выполнить код в контексте фрейма |
Дальше — весь набор команд, но не списком, а на одном подопытном: корзина, которая считает сумму со скидкой.
%%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/Xxfail/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
уже не нужен. Что он по-прежнему даёт — состав строки: сколько операций в ней
на самом деле, в каком порядке вычисляются аргументы, какие имена берутся как
глобальные.
Материалы#
pdb— команды отладчика и API для вызова из кодаpdb -p PID— подключение к работающему процессу (3.14+)PYTHONBREAKPOINT— выбрать отладчик или выключитьbreakpoint()traceback— работа с трейсбеками из кодаsys.last_exc— последнее необработанное исключение, на нём стоитpdb.pm()inspect— сигнатуры, исходники, стек и фреймыdis— дизассемблер и полный список опкодовcode objects в модели данных — смысл каждого
co_*pytest:
--pdb— упасть в отладчик прямо из упавшего тестаОтладка C-расширений и внутренностей CPython через GDB —
python-gdb.py,py-bt,py-locals,py-printPEP 553 —
breakpoint()иsys.breakpointhookPEP 657 — точные позиции выражений в трейсбеке (3.11+)
PEP 709 — list/set/dict comprehension инлайнится в тело функции, генераторное выражение — нет (3.12+)
PEP 768 — безопасное подключение к работающему процессу,
sys.remote_exec