---
jupytext:
  formats: md:myst
  text_representation:
    extension: .md
    format_name: myst
kernelspec:
  display_name: Python 3.14
  language: python
  name: python3
---

```{code-cell} python
:tags: [remove-cell]

import os, sys
os.environ["PATH"] = f"{os.path.dirname(sys.executable)}:{os.environ['PATH']}"
os.environ.pop("VIRTUAL_ENV", None)
```

# Лекция 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 ..." — просто её печатное
представление.

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

```{code-cell} python
%%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
```

```{code-cell} python
!uv run python /tmp/frames_intro.py
```

Внизу списка — место падения: `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 <команда>` | справка по команде прямо в отладчике |

<div class="adm-note" style="background: #44944A; border: 1px solid #fbfbfb; border-radius: 10px; color: #fff; padding: 15px; margin: 10px 0;">

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

| pdb | gdb | что |
|---|---|---|
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">b file:line</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">b file:line</code> | breakpoint |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">n</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">s</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">n</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">s</code> | next / step |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">c</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">c</code> | continue |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">p x</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">pp x</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">p x</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">set print pretty on</code> | печать значения / pretty-print |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">whatis x</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">ptype x</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">whatis x</code> | тип выражения |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">w</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">bt</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">where</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">bt</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">where</code> | stack trace (те же алиасы) |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">u</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">d</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">up</code> / <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">down</code> | навигация по фреймам |
| <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">interact</code> | <code style="background:rgba(0,0,0,.35);color:#fff;padding:0 3px;border-radius:3px">call</code> / inline expr | выполнить код в контексте фрейма |

</div>

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

```{code-cell} python
%%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()
```

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

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

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

```{code-cell} python
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())
```

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

```{code-cell} python
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}")
```

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

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

```{code-cell} python
session("""
    ll                   # весь код функции; -> помечает строку, где стоим
    l 8,16               # произвольный диапазон строк файла
    args                 # только параметры этого вызова
    p items              # значение выражения
    pp items             # то же, но через pprint
    whatis discount      # тип выражения
    source line_total    # исходник любого объекта, доступного по имени
""")
```

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

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

```{code-cell} python
session("""
    n           # перешагнуть строку, не заходя в вызовы
    n
    s           # зайти внутрь вызова
    s
    r           # доиграть функцию до return
    n           # вернуться во фрейм cart_total
    until 16    # дойти до строки 16, то есть выйти из цикла
    p total
""")
```

`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`

```{code-cell} python
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
""")
```

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

```{code-cell} python
session("""
    b line_total
    ignore 1 1    # пропустить одно следующее срабатывание брейкпоинта 1
    c
    p item
""")
```

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

<div class="adm-warn" style="background: #FFBA00; border-left: 5px solid #ffcc00; color: #222; padding: 15px; margin: 10px 0;">

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

pdb сравнивает путь из <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">b file.py:15</code> с тем, который записан во
фрейме, строка в строку. На macOS эти два пути легко разъезжаются: <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">/tmp</code>
— симлинк на <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">/private/tmp</code>, поэтому один и тот же файл может
называться по-разному, и брейкпоинт молча не сработает.

Надёжнее ставить его без пути: <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">b 15</code> — по строке текущего файла,
<code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">b line&#95;total</code> — по имени функции. А если путь всё-таки нужен,
возьми его из вывода pdb: там файл назван ровно так, как видит его отладчик.

</div>

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

```{code-cell} python
session("""
    b 15
    c
    display total      # печатать total на брейкпоинтах и шагах, когда значение изменилось
    c
    undisplay total
    display            # список выражений под наблюдением
""")
```

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

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

```{code-cell} python
session("""
    b 15
    commands 1             # команды, которые pdb выполнит при срабатывании точки 1
    p item['currency']
    end                    # конец блока
    c
    c
""")
```

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

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

```{code-cell} python
session("""
    tbreak to_rub    # одноразовый брейкпоинт в самой вложенной функции
    c
    w                # стек фреймов
    args
    u                # на фрейм выше
    p item
    u
    p total
    d                # и обратно вниз
    d
    p currency
""")
```

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

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

```{code-cell} python
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
""")
```

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

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

```{code-cell} python
session("""
    debug line_total(items[0])    # вычислить выражение под вложенным отладчиком
    s
    ll
    r
    retval                        # значение, которое вернул фрейм
    c                             # выйти из вложенного отладчика
""")
```

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

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

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

```{code-cell} python
%%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))
```

```{code-cell} python
%%writefile /tmp/cart_interact.pdb
p CART[0]
interact
CART[0]["qty"] = 99
label = "подменили имя"
exit()
p CART[0]
p label
c
```

```{code-cell} python
!uv run python /tmp/run_cart_bp.py < /tmp/cart_interact.pdb
```

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

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

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

```{code-cell} python
%%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))
```

```{code-cell} python
%%writefile /tmp/checkout_demo.pdb
w
exceptions
exceptions 0
p currency
p RATES
u
p item
q
```

```{code-cell} python
!uv run python -m pdb -c continue /tmp/checkout_demo.py < /tmp/checkout_demo.pdb 2>&1 | tail -18
```

`-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` — справка не выходя из отладчика

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

```{code-cell} python
session("help until")
```

#### `breakpoint()`

Ставим `breakpoint()` в коде — и попадаем в `pdb`, никаких import не нужно
([PEP 553](https://peps.python.org/pep-0553/)). Чем именно открывать отладчик,
решает
[`sys.breakpointhook`](https://docs.python.org/3/library/sys.html#sys.breakpointhook).

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

```{code-cell} python
%%writefile /tmp/bp_demo.py
from cart_demo import cart_total

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

breakpoint()
print("итого:", cart_total(CART))
```

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

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

Переменная окружения задаёт, что звать вместо `pdb.set_trace`:
[`ipdb`](https://github.com/gotcha/ipdb) — тот же pdb, но с автодополнением и
подсветкой, [`web_pdb`](https://github.com/romanvm/python-web-pdb) — сессия в
браузере.

```bash
# Использовать 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()`
доезжает до конца и печатает результат, ни на чём не остановившись.

```{code-cell} python
!PYTHONBREAKPOINT=0 uv run python /tmp/bp_demo.py
```

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

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

С Python 3.14 `pdb` цепляется к уже запущенному процессу по PID, без правок его
кода и без перезапуска — флаг
[`-p`](https://docs.python.org/3/library/pdb.html#cmdoption-pdb-p); механизм
описан в [PEP 768](https://peps.python.org/pep-0768/). Практическое следствие
одно: **прицепиться можно только к процессу, который что-то делает.** `pdb -p`
не останавливает подопытного силой — он оставляет ему просьбу, а тот подхватит
её, когда дойдёт до следующей инструкции. Если процесс висит в системном вызове
(`time.sleep`, чтение из сокета, ожидание ввода), исполнять ему нечего, и
отладчик просто ждёт, пока вызов не закончится.

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

```{code-cell} python
%%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
```

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

```{code-cell} python
# Терминал 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`](https://docs.python.org/3/using/cmdline.html#envvar-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`](https://docs.python.org/3/library/sys.html#sys.last_exc)
(именно его читает `pdb.pm()`;), поэтому отладчик можно открыть уже после падения:

```python
>>> 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`** — программный путь: ты сам решаешь,
когда открывать отладчик:

```python
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](https://docs.pytest.org/en/stable/how-to/failures.html).

```{code-cell} python
%%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
```

`--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` — ни
одним из них не выключается.

```{code-cell} python
%%writefile /tmp/pdbflags_cmds.txt
p name
u
p cart
c
```

```{code-cell} python
!uv run pytest /tmp/test_pdbflags_cart.py --pdb -q --no-header -rN < /tmp/pdbflags_cmds.txt
```

Приземлились не в тест и не в `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`:

```{code-cell} python
%%writefile /tmp/test_pdbflags_bp.py
def test_with_breakpoint():
    cart = {"apple": 2, "peach": 1}
    breakpoint()
    assert sum(cart.values()) == 3
```

```{code-cell} python
%%writefile /tmp/pdbflags_bp.txt
p cart
c
```

```{code-cell} python
!uv run pytest /tmp/test_pdbflags_bp.py -q --no-header -rN < /tmp/pdbflags_bp.txt
```

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

```{code-cell} python
%%writefile /tmp/pdbflags_trace.txt
n
p cart
c
```

```{code-cell} python
!uv run pytest /tmp/test_pdbflags_cart.py --trace -q --no-header -rN < /tmp/pdbflags_trace.txt 2>&1 | head -8
```

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

```{code-cell} python
%%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"]
```

```{code-cell} python
!uv run pytest /tmp/test_pdbflags_rows.py -q --no-header -rN 2>&1 | tail -12
```

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

```{code-cell} python
!uv run pytest /tmp/test_pdbflags_rows.py -q --no-header -rN -l 2>&1 | tail -12
```

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

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

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

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

```{code-cell} python
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}))
```

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

#### Сигнатуры

```{code-cell} python
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}")
```

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

```{code-cell} python
import inspect


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


print(inspect.getsource(sample))
print(f"docstring: {inspect.getdoc(sample)}")
```

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

```{code-cell} python
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]}")
```

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

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

```{code-cell} python
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()
```

Каждый `FrameInfo` содержит: `filename`, `lineno`, `function`, `code_context`
(строки вокруг), `index` (какая из них текущая), `frame` (сам frame object с
`f_locals` и `f_globals`) и `positions` — точные границы выражения в строке,
те самые из [PEP 657](https://peps.python.org/pep-0657/).

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

```{code-cell} python
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")
```

---

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

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

```{code-cell} python
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)}")
```

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

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

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

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

```{code-cell} python
program = "x = 1\ny = 2\nprint(3 * (x + y))\n"

exec(program)
```

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

```{code-cell} python
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}")
```

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

```{code-cell} python
import ast

print(ast.dump(ast.parse(program), indent=4))
```

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

```{code-cell} python
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)
```

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

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

```{code-cell} python
import dis


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


dis.dis(add_one)
```

Читается как последовательность операций над стеком: `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. Ключевые атрибуты:

```{code-cell} python
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_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`
([список флагов](https://docs.python.org/3/library/inspect.html#inspect.CO_OPTIMIZED)).
Ниже в выводе видно оба порядка сразу: `def` в коде ячейки и `co_varnames`
первой строкой.

```{code-cell} python
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:]}")
```

<div class="adm-warn" style="background: #FFBA00; border-left: 5px solid #ffcc00; color: #222; padding: 15px; margin: 10px 0;">

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

<ul>
<li><code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">co&#95;argcount</code> — это все позиционные: и те, что <b>только</b> позиционные, и те, что можно передать ещё и именованными; а <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">co&#95;posonlyargcount</code> — часть от него, не добавка. <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">&#42;args</code>/<code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">&#42;&#42;kwargs</code> не входят ни в один из трёх счётчиков (в <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">co&#95;nlocals</code> — входят): их имена стоят <b>между</b> параметрами и обычными локальными переменными, найти их можно только по флагам.</li>
<li><code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">&#95;&#95;defaults&#95;&#95;</code> — кортеж, выровненный по <b>хвосту</b> позиционных: длина k покрывает последние k имён из <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">co&#95;argcount</code>. Дефолты именованных живут отдельно в <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">&#95;&#95;kwdefaults&#95;&#95;</code> (dict или <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">None</code>).</li>
<li>Дефолты и аннотации лежат не в code object, а на функции: code object переиспользуется, а <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">&#95;&#95;defaults&#95;&#95;</code> вычисляются при каждом <code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">def</code>.</li>
</ul>

</div>

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

```{code-cell} python
import dis

dis.show_code(demo)
```

#### `co_stacksize` и `co_lines()`

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

```{code-cell} python
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}")
```

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

Обещанный выше `co_lines()` ([PEP 626](https://peps.python.org/pep-0626/)) —
вживую, на том же генераторе. Сюрприз в порядке: строки идут не по
возрастанию.

```{code-cell} python
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])
```

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

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

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

```{code-cell} python
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}")
```

Одна ловушка: с 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`](https://docs.python.org/3/library/dis.html#dis.dis) запрещает
спускаться — видно ровно верхний уровень:

```{code-cell} python
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)
```

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

```{code-cell} python
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)))} инструкций, вложенные не тронуты")
```

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

```{code-cell} python
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))}")
```

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

```{code-cell} python
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}")
```

<div class="adm-warn" style="background: #FFBA00; border-left: 5px solid #ffcc00; color: #222; padding: 15px; margin: 10px 0;">

<b>Comprehension больше не отдельный code object</b>

С 3.12 list/dict/set-comprehensions инлайнятся прямо в родителя (<a
href="https://peps.python.org/pep-0709/" style="color: #003366; text-decoration: underline;">PEP 709</a>) — своего code object у
них нет, их байткод лежит в теле родителя. Генераторное выражение и
<code style="background:rgba(0,0,0,.15);color:#111;padding:0 3px;border-radius:3px">lambda</code> — по-прежнему отдельные. То есть форма дерева зависит от
версии интерпретатора: один и тот же исходник на 3.11 и на 3.14 даст разное
число вложенных code objects.

</div>

```{code-cell} python
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)}")
```

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

- **Видеть, что происходит на самом деле.** `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`.
  Когда выражение ведёт себя не так, как читается, байткод отвечает на вопрос
  буквально.
- **Разбирать строку, которая делает несколько вещей.** Трейсбек указывает на
  строку целиком, а упала одна операция из нескольких:

```{code-cell} python
import dis

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

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

```{code-cell} python
%%writefile /tmp/subscript_fail.py
data = {"users": None}
n = data["users"][0]["age"] + 10
```

```{code-cell} python
!uv run python /tmp/subscript_fail.py 2>&1 || true
```

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

---

## Материалы

- [`pdb`](https://docs.python.org/3/library/pdb.html) — команды отладчика и
  API для вызова из кода
- [`pdb -p PID`](https://docs.python.org/3/library/pdb.html#cmdoption-pdb-p) —
  подключение к работающему процессу (3.14+)
- [`PYTHONBREAKPOINT`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONBREAKPOINT)
  — выбрать отладчик или выключить `breakpoint()`
- [`traceback`](https://docs.python.org/3/library/traceback.html) — работа с
  трейсбеками из кода
- [`sys.last_exc`](https://docs.python.org/3/library/sys.html#sys.last_exc) —
  последнее необработанное исключение, на нём стоит `pdb.pm()`
- [`inspect`](https://docs.python.org/3/library/inspect.html) — сигнатуры,
  исходники, стек и фреймы
- [`dis`](https://docs.python.org/3/library/dis.html) — дизассемблер и
  [полный список опкодов](https://docs.python.org/3/library/dis.html#python-bytecode-instructions)
- [code objects в модели данных](https://docs.python.org/3/reference/datamodel.html#code-objects)
  — смысл каждого `co_*`
- [pytest: `--pdb`](https://docs.pytest.org/en/stable/how-to/failures.html#dropping-to-pdb-on-failures)
  — упасть в отладчик прямо из упавшего теста
- [Отладка C-расширений и внутренностей CPython через GDB](https://docs.python.org/3/howto/gdb_helpers.html)
  — `python-gdb.py`, `py-bt`, `py-locals`, `py-print`
- [PEP 553](https://peps.python.org/pep-0553/) — `breakpoint()` и `sys.breakpointhook`
- [PEP 657](https://peps.python.org/pep-0657/) — точные позиции выражений в
  трейсбеке (3.11+)
- [PEP 709](https://peps.python.org/pep-0709/) — list/set/dict comprehension
  инлайнится в тело функции, генераторное выражение — нет (3.12+)
- [PEP 768](https://peps.python.org/pep-0768/) — безопасное подключение к
  работающему процессу, `sys.remote_exec`
