Обратная совместимость Python-пакета: что ревьюить в API
Переименовали параметр — и у сотни проектов упал импорт. Разбираем, какие изменения в публичном API ломают чужой код и на что смотреть в ревью, прежде чем нажать merge.

Вы поддерживаете библиотеку, которую ставят через pip. В новой версии кто-то переименовал аргумент timeout в request_timeout ради ясности. Мердж прошёл, тесты зелёные — потому что внутри пакета никто старым именем не пользовался. Через день в issue-трекере три сообщения: TypeError: got an unexpected keyword argument 'timeout'. Каждый, кто вызывал вашу функцию по-старому, получил падение на ровном месте.
Это разбор того, что именно ломает обратную совместимость в публичном API Python-пакета, как ловить такие изменения на ревью и как рефакторить, не выкатывая мажорную версию на каждый чих. Речь про библиотеки под Python 3.9+; примеры на чистой стандартной библиотеке.
Что вообще считается публичным API
Ломающее изменение — это то, что заставит рабочий код пользователя перестать работать после обновления. Но пользователь взаимодействует не со всем вашим кодом, а только с публичной поверхностью. Прежде чем спорить о совместимости, договоритесь, где эта поверхность проходит.
- Всё, что импортируется без ведущего подчёркивания и не спрятано за
_private— по умолчанию публичное, даже если вы так не задумывали. - Имена в
__all__— явный контракт: это точно публичное. - Сигнатуры функций и методов: имена и порядок позиционных аргументов, имена ключевых, значения по умолчанию.
- Типы возвращаемых значений и типы исключений, которые вы бросаете.
- Атрибуты классов и структура публичных объектов, если пользователь их читает.
Модуль _internal.py и функция _parse_headers — не контракт. Если пользователь полез туда, это его риск. Но объявить это надо явно, иначе граница проходит там, где её проведёт первый же зависимый проект.
Публичным становится не то, что вы задумали публичным, а то, что кто-то успел заимпортировать. Единственный способ сузить границу задним числом — не переименование, а период депрекации.
Каталог ломающих изменений
На ревью полезно держать в голове закрытый список того, что рвёт совместимость. Не «изменение выглядит крупным», а конкретно какое.
| Изменение | Ломает? | Почему |
|---|---|---|
| Переименовать ключевой аргумент | Да | Вызов f(old=1) падает с TypeError |
| Убрать аргумент | Да | Тот же TypeError |
| Добавить обязательный аргумент без значения по умолчанию | Да | Старый вызов не передаёт его |
| Добавить аргумент со значением по умолчанию | Нет | Старые вызовы работают как раньше |
| Поменять порядок позиционных аргументов | Да | Позиционный вызов передаст значения не туда |
| Сделать позиционный аргумент только-ключевым | Да | Позиционный вызов упадёт |
| Сузить тип возвращаемого значения | Иногда | Зависит от того, как пользователь его использует |
| Бросать другой класс исключения | Да | Существующий except перестанет ловить |
| Переименовать/удалить публичное имя в модуле | Да | Импорт падает с ImportError |
Отдельно про изменение значения по умолчанию: timeout=30 стал timeout=5. Импорт не падает, тесты зелёные, но у пользователя внезапно начинают рваться долгие запросы. Это тихое ломающее изменение — самое опасное, потому что его не видно ни в трейсбеке, ни в дифе сигнатуры.
Как не ломать: депрекация вместо удаления
Правильный рефакторинг публичного имени — не переименование, а добавление нового при сохранении старого с предупреждением. Пользователь получает время мигрировать, а не сломанный прод.
Переименование ключевого аргумента
Принимаете оба имени, старое — с предупреждением. Пример для функции, где timeout переезжает в request_timeout:
import warnings
# Python 3.9+
def fetch(url, *, request_timeout=30, timeout=None):
# Старое имя ещё принимается, но помечено к удалению
if timeout is not None:
warnings.warn(
"Аргумент 'timeout' переименован в 'request_timeout' "
"и будет удалён в версии 3.0",
DeprecationWarning,
stacklevel=2, # чтобы предупреждение указало на код вызывающего
)
request_timeout = timeout
return _do_fetch(url, request_timeout)Ключевое здесь — stacklevel=2. Без него предупреждение укажет на строку внутри вашей библиотеки, и пользователь не поймёт, где в его коде проблема. С stacklevel=2 ссылка ведёт на строку вызова fetch(...).
Переименование публичного имени в модуле
Функцию parse_config переименовали в load_config. Оставьте псевдоним:
def load_config(path):
...
# Совместимость со старым именем, удалить в 3.0
def parse_config(path):
warnings.warn(
"parse_config переименована в load_config",
DeprecationWarning,
stacklevel=2,
)
return load_config(path)Для депрекации атрибута или имени на уровне всего модуля в Python 3.7+ есть module __getattr__ — функция __getattr__(name) на уровне модуля, которая срабатывает при обращении к отсутствующему имени. Механизм описан в PEP 562; синтаксис уточните в документации для вашей версии перед использованием.
Что ревьюить в пул-реквесте
Диф в файле с публичной функцией требует другого внимания, чем правка внутренней утилиты. Конкретные формулировки замечаний, которые стоит оставлять:
- Изменилась сигнатура публичной функции. «Убрали позиционный аргумент
encoding— это сломает всех, кто вызывает позиционно. Нужен период депрекации или мажорная версия. Что выбираем?» - Новый обязательный аргумент. «У
connect()появился обязательныйregionбез дефолта. Старые вызовы упадут. Дай значение по умолчанию или обоснуй мажор.» - Поменялось значение по умолчанию. «
retriesбыл 3, стал 0 — тихое изменение поведения. В тестах не видно, в CHANGELOG нужна отдельная строка под BREAKING.» - Сменился класс исключения. «Раньше бросали
ValueError, теперьConfigError. Чужиеexcept ValueErrorперестанут ловить. Либо наследуйConfigErrorотValueError, либо это мажор.» - Удалили или переименовали имя из
__all__. «УбралиClientиз__all__— импортfrom pkg import Clientсломается. Оставь псевдоним с DeprecationWarning.» - Отсутствует запись в CHANGELOG. «Изменение меняет поведение, но в CHANGELOG ничего. Пользователь должен узнать об этом до обновления, а не из трейсбека.»
Инструменты, которые ловят это до ревьюера
Человек пропускает переименованный аргумент в дифе на триста строк. Часть проверок стоит отдать в CI.
- Диф публичного API. Существуют инструменты, которые сравнивают сигнатуры между версиями пакета и падают в CI на ломающем изменении без бампа версии. Ищите по запросу «Python API diff» и проверьте, поддерживает ли инструмент вашу структуру пакета — точное имя и совместимость сверяйте в его документации.
- Тесты, гоняющие DeprecationWarning. В
pytestиспользуйтеpytest.warns(DeprecationWarning), чтобы проверить: старый путь всё ещё работает и всё ещё предупреждает. Это фиксирует контракт депрекации. - Проверка типов.
mypyв строгом режиме поймает изменившийся тип возврата на стороне ваших же примеров и внутренних вызовов, но не защитит код пользователя — там нужен диф API.
Когда ломать всё-таки можно
Обратная совместимость — не догма, а обещание в рамках мажорной версии. По семантическому версионированию ломающие изменения допустимы в мажорном релизе: 2.x → 3.0. Правила простые:
- Ломающее изменение — только с бампом мажорной версии.
- Между анонсом депрекации и удалением проходит хотя бы один минорный релиз, лучше несколько.
- Каждое удалённое имя к моменту удаления уже прожило хотя бы одну версию с
DeprecationWarning. - В CHANGELOG есть раздел с явной пометкой ломающих изменений и инструкцией по миграции.
Версии 0.x — исключение: по SemVer здесь совместимость не обеспечивается вовсе, и пользователи об этом знают. Пока пакет в нуле, ломать дешевле, чем таскать груз псевдонимов.
Что делать дальше
Заведите файл, фиксирующий вашу публичную поверхность: явный __all__ в каждом модуле и раздел «что публично» в документации. Настройте в CI диф API против последнего релиза на PyPI, чтобы ломающее изменение не проходило молча. Проверьте, что фильтр предупреждений в тестах не глушит DeprecationWarning — иначе ваши псевдонимы депрекации никто не увидит, пока имя не удалят. И заведите привычку: любой диф, который трогает сигнатуру публичной функции, требует ответа на один вопрос — «чей рабочий код это сломает».
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: PEP 562 – Module __getattr__ and __dir__, Python docs: warnings — Warning control
Частые вопросы
У меня версия 0.5, обязательно ли городить депрекации?
Добавление нового аргумента со значением по умолчанию — это ломающее изменение?
Я поменял только значение по умолчанию, тесты зелёные — это точно безопасно?
А если у меня старая версия Python, module __getattr__ сработает?
DeprecationWarning не показывается при запуске — я что-то сломал?
Обязательно ли выпускать мажорную версию при смене класса исключения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.