API библиотеки на Python: что ломает обратную совместимость
Один переименованный аргумент — и после pip install --upgrade у пользователей падает прод. Разбираем, какие изменения в публичном API Python-библиотеки считаются ломающими и как их избегать.

Вы выпустили версию 2.0.1 с виду безобидным патчем: переименовали параметр timeout в request_timeout для единообразия. Через час в issues прилетает TypeError: got an unexpected keyword argument 'timeout' от десятка проектов, которые обновились автоматически. Формально вы починили название. Фактически — сломали обратную совместимость в патч-релизе, чего по SemVer делать нельзя.
Разберём, какие изменения в публичном API Python-библиотеки ломают чужой код, почему часть из них незаметна на этапе разработки и как выпускать изменения так, чтобы обновление не превращалось в инцидент. Речь о библиотеке, которую ставят через pip и импортируют другие — не о внутреннем коде приложения, где вы контролируете всех потребителей.
Что такое «публичный API» в Python
В Python нет модификаторов доступа. Приватность — договорённость, а не защита компилятора. Всё, что импортируется и не начинается с подчёркивания, пользователи считают частью контракта, даже если вы так не планировали.
Границу публичного API определяют три вещи:
- Имена без ведущего подчёркивания — модули, классы, функции, атрибуты.
Client.send()публичен,Client._retry()— нет. - Список
__all__в модуле — что попадает вfrom mylib import *и что документация считает экспортируемым. - То, что описано в документации и примерах. Если в README вы показали вызов — люди на него завязались, и это теперь контракт.
Всё, что пользователь может импортировать без подчёркивания и вызвать без ошибки, он рано или поздно вызовет. «Мы не думали, что это публичное» — не аргумент в баг-репорте.
Практический вывод: явно помечайте приватное подчёркиванием с первого дня. Переклассифицировать внутреннюю функцию в приватную задним числом — уже ломающее изменение, если она была доступна.
Изменения, которые ломают вызывающий код
Ниже — то, что нарушает работу пользователей, даже когда код внутри библиотеки стал «лучше».
Сигнатуры функций
- Переименование параметра. Пользователи вызывают его по ключевому слову:
send(timeout=5). Переименование ломает такой вызов. - Смена порядка позиционных аргументов.
connect(host, port)→connect(port, host)— вызов не упадёт, но начнёт работать неверно, что хуже падения. - Превращение опционального аргумента в обязательный — убрали значение по умолчанию.
- Удаление аргумента, даже если он был со значением по умолчанию.
- Сужение принимаемых типов. Функция принимала
str | Path, стала принимать толькоPath.
Возвращаемые значения
- Смена типа возврата: был
dict, стал объектdataclass. Код сresult["id"]сломается. - Удаление ключа из возвращаемого словаря или атрибута из объекта.
- Смена того, что бросается: было
ValueError, стало кастомное исключение, не наследующее прежний класс. Всеexcept ValueErrorперестанут ловить.
Структура пакета
- Перемещение класса между модулями без реэкспорта:
from mylib.client import Clientперестаёт работать. - Переименование модуля или пакета.
- Удаление из
__all__.
Поведение при неизменной сигнатуре
Самое коварное. Сигнатура та же, но поведение другое: функция стала бросать исключение там, где раньше возвращала None; изменилось значение по умолчанию (retries был 0, стал 3); функция стала асинхронной. Тесты пользователей это ловят не всегда, а прод — сразу.
Незаметная разница: позиционные и ключевые аргументы
Разберём частый случай. У вас функция:
# mylib 1.x
def fetch(url, timeout=30, retries=0):
...Вы решили добавить параметр headers и вставили его вторым «по логике»:
# mylib 2.0 — ломающее изменение
def fetch(url, headers=None, timeout=30, retries=0):
...Тот, кто вызывал fetch("https://api.example.com", 5) позиционно, теперь передаёт 5 в headers вместо timeout. Ошибки нет — есть тихо неверное поведение.
Защита от этого встроена в язык: маркер * в сигнатуре превращает всё, что после него, в keyword-only аргументы. Их нельзя передать позиционно, значит их порядок и добавление новых не ломают вызовы.
# устойчивый к расширению вариант
def fetch(url, *, timeout=30, retries=0, headers=None):
# url — позиционный, остальное только по имени
...Теперь добавление headers в любое место после * ничего не ломает: пользователи всё равно обязаны передавать эти аргументы по имени. Это соглашение, которое стоит закладывать в новые публичные функции с самого начала. Для существующих функций перевод в keyword-only — сам по себе ломающее изменение, поэтому его планируют на мажорный релиз.
Как выпускать изменения по SemVer
Семантическое версионирование (MAJOR.MINOR.PATCH) даёт пользователям контракт: по номеру версии видно, безопасно ли обновляться автоматически.
| Часть версии | Что допустимо менять | Пример |
|---|---|---|
| PATCH (2.1.3) | Только исправления без изменения API и поведения | Починили утечку соединения |
| MINOR (2.2.0) | Добавление нового, обратно совместимо | Новый метод, новый keyword-аргумент со значением по умолчанию |
| MAJOR (3.0.0) | Любые ломающие изменения | Удалили метод, переименовали параметр, сменили тип возврата |
Ключевое правило: ломающее изменение только в мажорном релизе. Переименование параметра из вступления — это MAJOR, а не PATCH. Если вы выпустили его как 2.0.1, вы нарушили ожидание пользователей, у которых в зависимостях указано mylib>=2,<3.
Депрекация вместо резкого удаления
Правильный путь удаления публичного API — не «убрали в 3.0», а «предупредили в 2.x, убрали в 3.0». Пользователь успевает мигрировать, увидев предупреждение в своих логах и тестах.
Стандартный механизм — DeprecationWarning из модуля warnings стандартной библиотеки:
import warnings
def fetch(url, *, timeout=30, request_timeout=None):
# старое имя ещё работает, но предупреждает
if request_timeout is not None:
warnings.warn(
"Параметр 'request_timeout' переименован в 'timeout' "
"и будет удалён в версии 3.0",
DeprecationWarning,
stacklevel=2, # чтобы предупреждение указывало на строку вызова, а не сюда
)
timeout = request_timeout
...Параметр stacklevel=2 важен: без него предупреждение укажет на строку внутри вашей библиотеки, а не на код пользователя, и он не поймёт, что чинить. По умолчанию DeprecationWarning не показывается в обычном запуске Python, но виден в тестах (pytest включает их) и при запуске с флагом -W, поэтому активные разработчики его заметят.
Порядок депрекации
- В минорном релизе добавляете предупреждение и новый способ, старый продолжает работать. Как понять, что сделано верно: тесты с включёнными warnings проходят, но пишут
DeprecationWarning. - В CHANGELOG отдельным разделом «Deprecated» перечисляете, что и когда исчезнет.
- Держите старое поведение минимум один минорный цикл, лучше дольше.
- Удаляете только в следующем мажорном релизе. В его CHANGELOG — раздел «Removed» и ссылка на гайд по миграции.
Как поймать поломку до релиза
Полагаться на «я помню, что публичное» нельзя. Инструменты, которые ловят ломающие изменения автоматически:
- Тесты как контракт пользователя. Пишите тесты, которые вызывают API так, как это делают внешние потребители — по документации, а не заглядывая внутрь. Такой тест упадёт при переименовании параметра.
- Проверка типов.
mypyилиpyrightв CI ловят смену сигнатур и типов возврата, если у вас есть аннотации. Публичный API без аннотаций сегодня — упущенная страховка. - Диффы публичного API. Существуют инструменты, сравнивающие сигнатуры между версиями и сообщающие о несовместимых изменениях. Их точные названия и поддержку версий сверяйте в актуальной документации — экосистема тут меняется, и я не буду называть конкретный пакет без проверки.
- Явный список экспорта. Держите
__all__в каждом публичном модуле. Это и документация контракта, и точка, где ревьюер видит: удаление имени отсюда — ломающее изменение.
Что делать дальше
Начните с малого на текущей кодовой базе: пройдитесь по публичным функциям и переведите расширяемые параметры в keyword-only через * — это единственная защита от тихой поломки при добавлении аргументов, и внедрять её в новом мажорном релизе дешевле, чем разгребать баг-репорты потом.
Дальше — заведите в CI прогон mypy на публичных модулях и правило в CHANGELOG: любое изменение попадает в один из разделов Added / Changed / Deprecated / Removed до мержа. Если в PR появился раздел Removed или Changed с ломающим пунктом — это сигнал бампнуть мажорную версию. На проде смотрите на то, какие DeprecationWarning реально фиксируют пользователи: если через два минорных цикла жалоб на старый API нет, удаление в мажоре пройдёт спокойно.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Semantic Versioning 2.0.0, Python docs — warnings module
Частые вопросы
У меня библиотека ещё в версии 0.x — обязательно соблюдать SemVer?
Добавление нового необязательного аргумента точно безопасно?
А если я сломал совместимость и уже выпустил патч-релиз?
DeprecationWarning не показывается при обычном запуске — как пользователи его увидят?
Смена типа возврата с dict на dataclass — это ломающее изменение, если поля те же?
Считается ли ломающим изменением повышение минимальной версии Python?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.