Почему колесо с расширением привязано к версии интерпретатора, платформе и libc — и как из этого следует, когда выносить горячий код в C или Rust, а когда это чистый убыток.
Зафиксируй вывод и причину до того, как откроешь ответ.
import sysconfig
print(sysconfig.get_config_var("SOABI"))
print(sysconfig.get_platform())cpython-311-x86_64-linux-gnu
linux-x86_64Три координаты в одном имени. Все три обязаны совпасть.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. Не совпало — модуль не найден.# pip install на машине без готового колеса
pip install some-c-packageBuilding wheel for some-c-package (pyproject.toml) ... error
error: command 'gcc' failed: No such file or directoryПакет ставился годами. Сегодня требует компилятор.import sysconfig
print(sysconfig.get_config_var("Py_GIL_DISABLED"))FalseА на free-threading-сборке будет 1 — и это другой ABI, с отдельными колёсами cp313t.Из урока 15 известно: расширение видит объекты Python напрямую — их поля, счётчик ссылок, слоты. Здесь — что из этого следует для распространения кода.
Расширение — это скомпилированная библиотека, слинкованная против конкретной версии структур CPython.
ABI — двоичный контракт: раскладка структур, сигнатуры функций, размеры полей. Меняется от версии к версии.
Колесо — архив со скомпилированным содержимым и тегами в имени: версия Python, ABI, платформа. Не совпало хоть одно — колесо не подходит.
Фрагменты A и B. Имя cpython-311-x86_64-linux-gnu содержит версию интерпретатора, архитектуру и вариант libc. Файл расширения несёт этот тег прямо в имени, и импорт ищет его буквально по совпадению.
Отсюда комбинаторика, которую видно на любом крупном проекте: библиотека с C-кодом выпускает отдельное колесо на каждую пару «версия Python × платформа». Пять поддерживаемых версий, четыре платформы — двадцать сборок на релиз. Это не бюрократия, а прямое следствие того, что ABI не стабилен.
И отсюда же manylinux: на Linux дистрибутивы отличаются версией glibc, поэтому колесо собирают в старом окружении, чтобы оно работало и на новых. Тег вроде manylinux_2_17 означает «требует glibc не ниже 2.17».
Фрагмент C. Если под твою связку версии и платформы колеса не собрали, pip пытается собрать из исходников — и требует компилятор, заголовки Python, а иногда и системные библиотеки.
Это самая частая причина «у меня не ставится, а у коллеги ставится»: у коллеги подошло готовое колесо, у тебя — нет. Типовые триггеры: свежая версия Python, вышедшая раньше релизов библиотек; Alpine с musl вместо glibc; ARM, когда колёса собирают только под x86.
Диагностика прямая: посмотреть, что pip скачал — .whl или .tar.gz. Второе означает сборку из исходников.
Раз проблема в привязке к версии, напрашивается подмножество API с гарантией совместимости. Оно есть: Limited API и колёса с тегом abi3, работающие на всех версиях начиная с заявленной.
Цена — часть возможностей недоступна: нельзя обращаться к полям структур напрямую, только через функции. Для расширения, которое считает в своём буфере, это ничего не стоит; для тесно работающего с объектами Python — заметно. Поэтому abi3 выбирают библиотеки-обёртки над C-кодом, а numpy и подобные — нет.
Фрагмент D. Сборка без GIL меняет раскладку объектов: в заголовок добавляются поля для biased reference counting (урок 09). Значит расширения нужно пересобирать, и колёса помечаются отдельным тегом cp313t.
Отсюда честная оценка перехода: дело не в том, чтобы поставить другой интерпретатор, а в том, что вся бинарная часть зависимостей должна иметь сборку под него. Первый шаг любой оценки миграции — инвентаризация расширений и проверка, есть ли у них такие колёса.
Всё перечисленное — цена, которую платят за расширение. Значит решение «вынести горячий код в C или Rust» стоит не только написания кода: появляется матрица сборок, компилятор в CI, отдельные колёса под платформы, риск сегфолта вместо трейсбека и невозможность отладить обычным отладчиком. Оценивать надо это целиком, а не только выигрыш в скорости.
Чистые Python-пакеты ничего этого не касается. Колесо с тегом py3-none-any работает везде. Вся сложность здесь — исключительно про бинарные расширения.
ABI и API — разные вещи. Исходник, собирающийся на 3.11 и 3.12, — совместимость API. Уже скомпилированный файл, работающий на обеих, — совместимость ABI, и она гораздо строже.
Теги — не гарантия работоспособности. manylinux обещает совместимость glibc, но не наличие системных библиотек, на которые расширение динамически слинковано. Отсюда ImportError: libXXX.so.1: cannot open shared object file при формально подошедшем колесе.
Корень R6: расширения видят внутренности напрямую. Всё в этом уроке — счёт по тому решению. Прямой доступ дал скорость и позволил обернуть любую C-библиотеку за вечер; он же означает, что скомпилированный код знает раскладку структур, а значит ломается при её изменении.
Формат колёс (PEP 427, 2012) появился поздно — двадцать лет экосистема жила на исходных дистрибутивах, где установка каждого пакета была компиляцией. Именно поэтому «pip install научной библиотеки» когда-то занимал полчаса и часто падал, а conda возникла как способ поставлять уже собранное вместе с системными зависимостями.
Хронология важна для понимания текущего состояния: сначала был прямой доступ к структурам, потом — попытки ограничить его ради переносимости (Limited API, 2010), потом — формат для распространения собранного (колёса, 2012), потом — стандарт бинарной совместимости на Linux (manylinux, 2016). Каждый слой пристроен поверх решения, принятого в 1991-м.
Точная аналогия — динамическая линковка в C. Файл расширения это .so, тег ABI — версия soname, а manylinux — то же, что символьное версионирование в glibc: способ пообещать, что собранное в старом окружении заработает в новом.
Совпадают и симптомы. Несовпадение версии в C даёт version GLIBC_2.34 not found; в Python — «нет подходящего колеса» или ImportError при импорте. И лечение то же самое по сути: либо собрать под целевое окружение, либо ограничиться подмножеством с гарантией совместимости (в C — версионированные символы, в Python — abi3).
Разница одна и она про аудиторию: в C с этим сталкивается тот, кто собирает программу, а в Python — тот, кто пишет pip install и обычно ничего не знает про ABI.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что колесо привязано к версии интерпретатора, платформе и 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 изолирует 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, ни в контейнере.
manylinux для Linux и самая частая причина «у меня не ставится»: под твою связку колеса не собрали, и pip ушёл компилировать из исходников. Стабильный ABI существует — Limited API и колёса abi3, — но за него платят отказом от прямого доступа к полям структур, и потому его выбирают обёртки, а не библиотеки, тесно работающие с объектами. Free-threading меняет заголовок объекта, то есть является отдельным ABI с тегом cp313t: оценка миграции начинается с инвентаризации бинарных зависимостей. И весь этот счёт — цена решения из урока 15: прямой доступ к внутренностям дал скорость и стоил переносимости.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" ломает сборку, а не прод.
Вопрос «вынести горячий кусок в Rust?» решается не скоростью, а полной стоимостью.
Что кладётся на весы со стороны выигрыша. Только профиль на продовой нагрузке: какую долю общего времени занимает кандидат. Если двадцать процентов — идеальное ускорение даст двадцать процентов, и это потолок. Прежде чем считать дальше, стоит проверить два более дешёвых пути: алгоритм (урок 03: in по списку против множества — 154×) и векторизация (урок 15: та же работа в numpy плюс потоки).
Что кладётся со стороны цены — прямо из следствий этого урока:
| статья | что это значит |
|---|---|
| матрица сборок | колесо на каждую пару «версия Python × платформа» |
| CI с компилятором | кросс-сборка, manylinux-образы, подпись артефактов |
| отладка | сегфолт вместо трейсбека, обычный отладчик не помогает |
| кто чинит | нужен человек, читающий этот язык, — обычно один в команде |
| free-threading | ещё одна ветка матрицы, когда до неё дойдёт |
Порядок, который стоит пройти до переписывания: профиль → алгоритм → векторизация → потоки на том, что отпускает GIL → и только потом расширение. Каждый шаг дешевле следующего на порядок и часто закрывает вопрос.
Если всё же переписывать: Rust с PyO3 снимает часть боли — статическая линковка убирает историю с ldd, а abi3 сокращает матрицу до одной сборки на платформу. Формулировка для команды, которая проходит: «профиль показывает N% в этой функции, ускорение даст столько-то, стоит вот этих статей, план отката — оставить питоновскую реализацию за флагом».
Прогони фрагменты, сверяясь с предсказанием. Затем в терминале:
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
# Что произойдёт, если запретить готовые колёса?
Второй вопрос — самый практичный: научиться отличать «скачалось готовое» от «сейчас начнётся компиляция» экономит часы при разборе чужих проблем с установкой.
Lib/sysconfig.py. Здесь собираются все переменные сборки, включая SOABI и EXT_SUFFIX. Прочитай, как формируется суффикс расширения: имя файла, по которому импорт находит модуль, склеивается из версии, ABI и платформы — следствие 1 в виде строки.
Lib/importlib/machinery.py. Найди EXTENSION_SUFFIXES: список суффиксов, по которым ищутся бинарные модули. Там же видно, что .abi3.so тоже в списке — механизм из следствия 3 поддержан прямо в импорте.
PEP 513 и его наследники. Не код, но стоит один раз прочитать список библиотек, которые manylinux разрешает считать присутствующими в системе. Всё, чего в этом списке нет, расширение обязано тащить с собой или требовать от образа — ровно то, обо что спотыкается баг из раздела «На практике».
Из 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, где исполняется всё.
Это, кстати, отдельный аргумент за колёса помимо скорости: установка колеса не запускает произвольный код, установка из исходников — запускает.
Тег v3.11.15.
Lib/sysconfig.py — формирование SOABI и EXT_SUFFIX.Lib/importlib/_bootstrap_external.py — ExtensionFileLoader и список суффиксов, по которым ищутся .so.abi3; в мотивации перечислено, от чего именно приходится отказаться.