Пакеты и переписывание

Почему колесо с расширением привязано к версии интерпретатора, платформе и libc — и как из этого следует, когда выносить горячий код в C или Rust, а когда это чистый убыток.

L1 · 9 минL2 · 10 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
import sysconfig
print(sysconfig.get_config_var("SOABI"))
print(sysconfig.get_platform())
cpython-311-x86_64-linux-gnu
linux-x86_64
Три координаты в одном имени. Все три обязаны совпасть.
Фрагмент B
import importlib.util
spec = importlib.util.find_spec("numpy._core._multiarray_umath")
print(spec.origin.split("/")[-1])
_multiarray_umath.cpython-311-x86_64-linux-gnu.so
Имя файла содержит версию Python. Не совпало — модуль не найден.
Фрагмент C
# pip install на машине без готового колеса
pip install some-c-package
Building wheel for some-c-package (pyproject.toml) ... error
error: command 'gcc' failed: No such file or directory
Пакет ставился годами. Сегодня требует компилятор.
Фрагмент D
import sysconfig
print(sysconfig.get_config_var("Py_GIL_DISABLED"))
False
А на free-threading-сборке будет 1 — и это другой ABI, с отдельными колёсами cp313t.

2Механизм

Из урока 15 известно: расширение видит объекты Python напрямую — их поля, счётчик ссылок, слоты. Здесь — что из этого следует для распространения кода.

Расширение — это скомпилированная библиотека, слинкованная против конкретной версии структур CPython.

ABI — двоичный контракт: раскладка структур, сигнатуры функций, размеры полей. Меняется от версии к версии.

Колесо — архив со скомпилированным содержимым и тегами в имени: версия Python, ABI, платформа. Не совпало хоть одно — колесо не подходит.

Следствие 1 · колесо привязано к трём координатам сразу

Фрагменты A и B. Имя cpython-311-x86_64-linux-gnu содержит версию интерпретатора, архитектуру и вариант libc. Файл расширения несёт этот тег прямо в имени, и импорт ищет его буквально по совпадению.

Отсюда комбинаторика, которую видно на любом крупном проекте: библиотека с C-кодом выпускает отдельное колесо на каждую пару «версия Python × платформа». Пять поддерживаемых версий, четыре платформы — двадцать сборок на релиз. Это не бюрократия, а прямое следствие того, что ABI не стабилен.

И отсюда же manylinux: на Linux дистрибутивы отличаются версией glibc, поэтому колесо собирают в старом окружении, чтобы оно работало и на новых. Тег вроде manylinux_2_17 означает «требует glibc не ниже 2.17».

Следствие 2 · нет колеса — нужен компилятор

Фрагмент C. Если под твою связку версии и платформы колеса не собрали, pip пытается собрать из исходников — и требует компилятор, заголовки Python, а иногда и системные библиотеки.

Это самая частая причина «у меня не ставится, а у коллеги ставится»: у коллеги подошло готовое колесо, у тебя — нет. Типовые триггеры: свежая версия Python, вышедшая раньше релизов библиотек; Alpine с musl вместо glibc; ARM, когда колёса собирают только под x86.

Диагностика прямая: посмотреть, что pip скачал — .whl или .tar.gz. Второе означает сборку из исходников.

Следствие 3 · стабильный ABI существует, но за него платят

Раз проблема в привязке к версии, напрашивается подмножество API с гарантией совместимости. Оно есть: Limited API и колёса с тегом abi3, работающие на всех версиях начиная с заявленной.

Цена — часть возможностей недоступна: нельзя обращаться к полям структур напрямую, только через функции. Для расширения, которое считает в своём буфере, это ничего не стоит; для тесно работающего с объектами Python — заметно. Поэтому abi3 выбирают библиотеки-обёртки над C-кодом, а numpy и подобные — нет.

Следствие 4 · free-threading — это отдельный ABI

Фрагмент D. Сборка без GIL меняет раскладку объектов: в заголовок добавляются поля для biased reference counting (урок 09). Значит расширения нужно пересобирать, и колёса помечаются отдельным тегом cp313t.

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

И вывод про переписывание

Всё перечисленное — цена, которую платят за расширение. Значит решение «вынести горячий код в C или Rust» стоит не только написания кода: появляется матрица сборок, компилятор в CI, отдельные колёса под платформы, риск сегфолта вместо трейсбека и невозможность отладить обычным отладчиком. Оценивать надо это целиком, а не только выигрыш в скорости.

3Границы модели

Где сказанное перестаёт держать

Чистые Python-пакеты ничего этого не касается. Колесо с тегом py3-none-any работает везде. Вся сложность здесь — исключительно про бинарные расширения.

ABI и API — разные вещи. Исходник, собирающийся на 3.11 и 3.12, — совместимость API. Уже скомпилированный файл, работающий на обеих, — совместимость ABI, и она гораздо строже.

Теги — не гарантия работоспособности. manylinux обещает совместимость glibc, но не наличие системных библиотек, на которые расширение динамически слинковано. Отсюда ImportError: libXXX.so.1: cannot open shared object file при формально подошедшем колесе.

4Корень

Корень R6: расширения видят внутренности напрямую. Всё в этом уроке — счёт по тому решению. Прямой доступ дал скорость и позволил обернуть любую C-библиотеку за вечер; он же означает, что скомпилированный код знает раскладку структур, а значит ломается при её изменении.

Формат колёс (PEP 427, 2012) появился поздно — двадцать лет экосистема жила на исходных дистрибутивах, где установка каждого пакета была компиляцией. Именно поэтому «pip install научной библиотеки» когда-то занимал полчаса и часто падал, а conda возникла как способ поставлять уже собранное вместе с системными зависимостями.

Хронология важна для понимания текущего состояния: сначала был прямой доступ к структурам, потом — попытки ограничить его ради переносимости (Limited API, 2010), потом — формат для распространения собранного (колёса, 2012), потом — стандарт бинарной совместимости на Linux (manylinux, 2016). Каждый слой пристроен поверх решения, принятого в 1991-м.

5Аналогия

Точная аналогия — динамическая линковка в C. Файл расширения это .so, тег ABI — версия soname, а manylinux — то же, что символьное версионирование в glibc: способ пообещать, что собранное в старом окружении заработает в новом.

Совпадают и симптомы. Несовпадение версии в C даёт version GLIBC_2.34 not found; в Python — «нет подходящего колеса» или ImportError при импорте. И лечение то же самое по сути: либо собрать под целевое окружение, либо ограничиться подмножеством с гарантией совместимости (в C — версионированные символы, в Python — abi3).

Разница одна и она про аудиторию: в C с этим сталкивается тот, кто собирает программу, а в Python — тот, кто пишет pip install и обычно ничего не знает про ABI.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему пакет с C-расширением не ставится на новом Python?

Потому что колесо привязано к версии интерпретатора, платформе и libc — и подходящего просто нет.

Имя файла колеса — это и есть его условия совместимости:

numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.whl
        версия  ABI Python   платформа и минимальный glibc

requests-2.31.0-py3-none-any.whl
                 чистый Python — подходит везде

Вышел 3.13 — cp311 не подходит, и pip делает то, что должен: скачивает исходник и пытается собрать. Дальше либо нет компилятора (error: command 'gcc' failed), либо нет заголовков (Python.h: No such file or directory), либо код не собирается под новую версию C API.

Что посмотреть первым делом:

python -m pip debug --verbose     # какие теги вообще принимает этот интерпретатор
pip download пакет --no-deps -d /tmp/w && ls /tmp/w

Первая команда печатает список совместимых тегов от самого специфичного к общему; если нужного тега пакета там нет — колеса не будет.

Практический вывод для эксплуатации: обновление минорной версии Python — это пересборка всей матрицы зависимостей, а не смена одной строки в Dockerfile. Именно поэтому индустрия сидит на N−1 версии, и именно поэтому в 3.13 free-threading сделали отдельной сборкой: у неё свой ABI и своя матрица колёс.

Если abi3 решает проблему версий, почему им не пользуются все?

Потому что стабильный ABI — это подмножество C API, и в нём нет как раз того, ради чего расширения пишут.

abi3 (PEP 384) обещает: собранное под 3.7 колесо работает на 3.8, 3.9, 3.11 и дальше. Одно колесо на платформу вместо одного на каждую версию — экономия огромная.

cryptography-42.0-cp37-abi3-manylinux_2_28_x86_64.whl
                        ↑ собрано под 3.7, работает на всех последующих

Чем платят. В стабильном ABI нельзя обращаться к полям структур напрямую — только через функции, а это медленнее. Нет доступа к внутренностям PyTypeObject, из-за чего долгое время нельзя было нормально определять типы со слотами. Нет части быстрых путей вроде vectorcall. Для numpy или lxml, где на счету каждое обращение к полю объекта, это неприемлемо.

Поэтому abi3 выбирают библиотеки, где расширение — тонкая обвязка вокруг чужого кода: cryptography (обёртка над OpenSSL), драйверы баз данных, биндинги. А numpy, pandas, torch собирают под каждую версию отдельно и держат матрицу колёс.

Полезно знать при выборе зависимости: посмотреть на имя колеса. abi3 в имени означает, что при обновлении Python эта библиотека почти наверняка не станет вашей проблемой. Отсутствие — что станет.

venv или контейнер — что и когда?

Они решают разные части одной задачи, и в проде нужны обе.

venv изолирует Python-зависимости: свой site-packages, свой pip. Он не изолирует ни системные библиотеки, ни версию самого Python, ни libc. Контейнер изолирует всё это и не изолирует ничего внутри себя.

Отсюда классическая авария, которую venv не ловит:

FROM python:3.11-slim
RUN pip install opencv-python-headless
CMD ["python", "-c", "import cv2"]
# → ImportError: libGL.so.1: cannot open shared object file

Колесо поставилось, зависимости зафиксированы, а расширение требует системной библиотеки, которой в slim-образе нет. venv про такое не знает в принципе.

# что расширение требует от системы
ldd $(python -c "import cv2, pathlib; print(pathlib.Path(cv2.__file__).parent / 'cv2.abi3.so')")
# строки с "not found" — это будущий ImportError

Практическое разделение. Разработка на машине — venv (или uv, poetry): дёшево, быстро, переключается мгновенно. Прод и CI — контейнер: фиксирует Python, libc и системные пакеты. Внутри контейнера venv тоже полезен, но по другой причине — чтобы не смешиваться с системным Python дистрибутива.

И ни то ни другое не заменяет lock-файл: без зафиксированных версий транзитивных зависимостей воспроизводимости нет ни в venv, ни в контейнере.

Итог. Расширение — скомпилированная библиотека, слинкованная против конкретной раскладки структур CPython, поэтому колесо привязано сразу к трём координатам: версия Python, ABI, платформа. Имя файла содержит их буквально, и импорт ищет по совпадению. Отсюда матрица сборок на каждый релиз, отдельный стандарт manylinux для Linux и самая частая причина «у меня не ставится»: под твою связку колеса не собрали, и pip ушёл компилировать из исходников. Стабильный ABI существует — Limited API и колёса abi3, — но за него платят отказом от прямого доступа к полям структур, и потому его выбирают обёртки, а не библиотеки, тесно работающие с объектами. Free-threading меняет заголовок объекта, то есть является отдельным ABI с тегом cp313t: оценка миграции начинается с инвентаризации бинарных зависимостей. И весь этот счёт — цена решения из урока 15: прямой доступ к внутренностям дал скорость и стоил переносимости.
Дальше — по желанию
контрфактуалC, C++ и RustПроблема ABI не питоновская — но у Python она в каждом pip install

C живёт с этим всегда: ABI зависит от компилятора, флагов, версии libc, и на Linux совместимость держится соглашением, а не гарантией. Разница в том, что там сборка — явный этап, где эти вопросы решает разработчик; у Python она спрятана внутрь pip install, и разбираться приходится пользователю библиотеки.

C++ усугубляет манглингом имён: ABI зависит ещё и от компилятора и стандартной библиотеки, из-за чего смешивать объектные файлы от gcc и MSVC нельзя. Именно поэтому расширения к Python предпочитают экспортировать C-интерфейс даже когда внутри C++.

Rust ту же проблему решил радикально: своего стабильного ABI нет вовсе, всё компилируется из исходников целиком, а совместимость обеспечивается на уровне пакетного менеджера. Для расширений к Python (PyO3) это удобно — сборка всё равно даёт статически слинкованный артефакт, и остаётся только питоновская часть матрицы.

Отсюда практический вывод при выборе языка для расширения: Rust снимает часть боли за счёт того, что не пытается быть двоично совместимым ни с чем.

🐛 багОбраз собрался, контейнер не стартуетКолесо подошло по тегам, но динамическая зависимость в образе отсутствует

Сборка зелёная, тесты прошли, продовый образ падает на импорте.

FROM python:3.11-slim
RUN pip install opencv-python-headless
CMD ["python", "-c", "import cv2"]
ImportError: libGL.so.1: cannot open shared object file:
No such file or directory

Колесо установилось без единого предупреждения: теги совпали, ABI подошёл. Но расширение динамически слинковано с системными библиотеками, которых в slim-образе нет. manylinux гарантирует совместимость glibc и ничего не обещает про остальное.

Ревью пропускает, потому что на машине разработчика эти библиотеки стоят от других пакетов — там всё импортируется. Ломается только в минимальном образе, то есть в проде.

RUN apt-get update && apt-get install -y --no-install-recommends \
        libgl1 libglib2.0-0 \
    && rm -rf /var/lib/apt/lists/*

Как находить заранее, не дожидаясь прода:

# что расширение требует от системы
ldd $(python -c "import cv2, pathlib; print(pathlib.Path(cv2.__file__).parent / 'cv2.abi3.so')")
# строки с "not found" — это будущий ImportError

Правило: каждое бинарное расширение проверяется ldd в том образе, где поедет — а не там, где собиралось. И импорт всех зависимостей стоит сделать шагом сборки: RUN python -c "import cv2, numpy, lxml" ломает сборку, а не прод.

⚡ решениеПереписывать или нет — считать целикомПрофиль даёт долю времени; к ней прибавляется матрица сборок, CI с компилятором и потеря трейсбека

Вопрос «вынести горячий кусок в Rust?» решается не скоростью, а полной стоимостью.

Что кладётся на весы со стороны выигрыша. Только профиль на продовой нагрузке: какую долю общего времени занимает кандидат. Если двадцать процентов — идеальное ускорение даст двадцать процентов, и это потолок. Прежде чем считать дальше, стоит проверить два более дешёвых пути: алгоритм (урок 03: in по списку против множества — 154×) и векторизация (урок 15: та же работа в numpy плюс потоки).

Что кладётся со стороны цены — прямо из следствий этого урока:

статьячто это значит
матрица сборокколесо на каждую пару «версия Python × платформа»
CI с компиляторомкросс-сборка, manylinux-образы, подпись артефактов
отладкасегфолт вместо трейсбека, обычный отладчик не помогает
кто чинитнужен человек, читающий этот язык, — обычно один в команде
free-threadingещё одна ветка матрицы, когда до неё дойдёт

Порядок, который стоит пройти до переписывания: профиль → алгоритм → векторизация → потоки на том, что отпускает GIL → и только потом расширение. Каждый шаг дешевле следующего на порядок и часто закрывает вопрос.

Если всё же переписывать: Rust с PyO3 снимает часть боли — статическая линковка убирает историю с ldd, а abi3 сокращает матрицу до одной сборки на платформу. Формулировка для команды, которая проходит: «профиль показывает N% в этой функции, ускорение даст столько-то, стоит вот этих статей, план отката — оставить питоновскую реализацию за флагом».

💻 терминалПроверь в терминалеЧетыре фрагмента плюс теги колёс, ldd и сборка из исходников

Прогони фрагменты, сверяясь с предсказанием. Затем в терминале:

python -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))"
# Какое расширение имени получит скомпилированный модуль?

pip download numpy --no-deps -d /tmp/w && ls /tmp/w
# Что скачалось: .whl или .tar.gz? Какие теги в имени?

python -c "import numpy, pathlib; print(list(pathlib.Path(numpy.__file__).parent.rglob('*.so'))[:3])"
# Сколько бинарных модулей внутри одного пакета?

pip install --no-binary :all: --dry-run some-package
# Что произойдёт, если запретить готовые колёса?

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

исходникиsysconfig и importlib.machineryГде видно, как теги попадают в имя файла

Lib/sysconfig.py. Здесь собираются все переменные сборки, включая SOABI и EXT_SUFFIX. Прочитай, как формируется суффикс расширения: имя файла, по которому импорт находит модуль, склеивается из версии, ABI и платформы — следствие 1 в виде строки.

Lib/importlib/machinery.py. Найди EXTENSION_SUFFIXES: список суффиксов, по которым ищутся бинарные модули. Там же видно, что .abi3.so тоже в списке — механизм из следствия 3 поддержан прямо в импорте.

PEP 513 и его наследники. Не код, но стоит один раз прочитать список библиотек, которые manylinux разрешает считать присутствующими в системе. Всё, чего в этом списке нет, расширение обязано тащить с собой или требовать от образа — ровно то, обо что спотыкается баг из раздела «На практике».

L2Из чего состоит колесо и как pip выбираетТеги совместимости, порядок приоритета, что внутри архива

Из L1 ты знаешь про три координаты. Здесь — как они записываются и сравниваются.

Имя колеса устроено так: имя-версия-питон-abi-платформа.whl. Примеры:

requests-2.31.0-py3-none-any.whl
numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.whl
cryptography-42.0-cp37-abi3-manylinux_2_28_x86_64.whl

Первое — чистый Python, подходит везде. Второе — расширение под конкретный CPython 3.11. Третье — abi3: собрано против Limited API, начиная с 3.7, поэтому одно колесо на всю линейку версий.

pip строит список поддерживаемых тегов текущего окружения в порядке предпочтения и берёт первое совпавшее колесо. Посмотреть список можно так:

python -m pip debug --verbose
# Compatible tags: несколько сотен строк, от самого специфичного к самому общему

Внутри архива — обычный zip: пакет как есть плюс каталог *.dist-info с метаданными, списком файлов и контрольными суммами. Установка сводится к распаковке; никакого кода при установке колеса не исполняется — в отличие от исходного дистрибутива с setup.py, где исполняется всё.

Это, кстати, отдельный аргумент за колёса помимо скорости: установка колеса не запускает произвольный код, установка из исходников — запускает.

L3Где это в исходниках и стандартахsysconfig, machinery, PEP 427 и 513

Тег v3.11.15.

  • Lib/sysconfig.py — формирование SOABI и EXT_SUFFIX.
  • Lib/importlib/_bootstrap_external.py — ExtensionFileLoader и список суффиксов, по которым ищутся .so.
  • PEP 427 — формат колеса; короткий и полностью читаемый.
  • PEP 384 — Limited API и abi3; в мотивации перечислено, от чего именно приходится отказаться.
связиКуда это ведётL15, L17, L19 · контраст с динамической линковкой C и моделью Rust
корень R6 · C API как способ расширения
← основа L15 · Граница с C и zero-copy — прямой доступ к структурам, за который здесь платят
← основа L17 · Импорт насквозь — как импорт находит бинарный модуль по имени файла
↔ контраст Динамическая линковка в C: soname и версионирование символов вместо тегов колеса
↔ контраст Rust: своего стабильного ABI нет вовсе, и это упрощает жизнь
→ дальше L19 · GIL — почему free-threading оказался отдельным ABI
чего здесь нет Инструменты окружений и локфайлы — это про воспроизводимость, а не про механизм языка
источникиЧто почитатьPEP 427 🟧 · PEP 384 🟦 · packaging.python.org 🟥