Semver в Python-библиотеке: когда поднимать мажор
Разбираем на конкретных изменениях API, где заканчивается минорная версия и начинается мажорная. Что считается ломающим изменением в Python, а что нет, и во что это обходится вашим пользователям.

Вы выпустили 1.4.0, переименовали параметр функции с timeout на timeout_seconds и оставили старое имя работать «для совместимости». Через неделю приходит issue: у пользователя код на pip install --upgrade внезадача перестал импортироваться, потому что вы заодно убрали публичный класс, который казался внутренним. Формально вы подняли минор. Фактически сломали чужой прод. Semver существует ровно для того, чтобы такой апгрейд был предсказуем — но работает он только если вы честны в том, что считаете ломающим изменением.
Разберём на границе между минором и мажором в Python-библиотеке: какие изменения обязывают поднять первую цифру, какие нет, и почему в Python ответ иногда неочевиден.
Что обещает каждая цифра
Semantic Versioning 2.0.0 задаёт формат MAJOR.MINOR.PATCH с простым контрактом для потребителя вашего кода:
- PATCH (
1.4.0 → 1.4.1) — только исправления, поведение публичного API не меняется. Апгрейд безопасен вслепую. - MINOR (
1.4.0 → 1.5.0) — новая функциональность, обратно совместимая. Старый код продолжает работать без изменений. - MAJOR (
1.4.0 → 2.0.0) — есть изменение, которое может сломать чужой код. Пользователь обязан прочитать changelog перед апгрейдом.
Ключевое слово — публичный API. Semver защищает только то, что вы объявили публичным. Если вы нигде не зафиксировали границу публичного, потребитель считает публичным всё, до чего дотянулся импортом, и любое удаление становится ломающим.
Мажорная версия — это не «мы много всего сделали». Это «мы что-то сломали и предупреждаем». Релиз с пятьюдесятью новыми фичами и нулём ломающих изменений остаётся минором.
Где в Python граница публичного размывается
В языках со строгой видимостью (private/public) граница очевидна. В Python приватность — соглашение, а не барьер. Это создаёт три зоны неопределённости.
Соглашение о подчёркивании
Имя с ведущим подчёркиванием (_internal_helper) — приватное по договорённости. Менять и удалять такое можно в патче: вы не давали обещаний. Но если пользователи импортировали _internal_helper напрямую, это их риск, не ваш. Проблема в том, что многие библиотеки годами держат публичные по виду имена, которые задумывались как внутренние.
Атрибут __all__ как контракт
Явный способ зафиксировать границу — объявить __all__ в каждом публичном модуле. Это то, что попадёт в from mypackage import *, и то, что вы обязуетесь поддерживать.
# mypackage/__init__.py
# Публичный API пакета. Всё остальное — внутреннее, меняется без мажора.
from mypackage.client import Client
from mypackage.errors import ClientError, TimeoutError
__all__ = [
"Client",
"ClientError",
"TimeoutError",
]Всё, что не в __all__ и не задокументировано, вы вправе менять минором. Но это работает, только если вы это правило где-то записали для пользователей — в README или в разделе про стабильность API.
Аннотации типов как часть контракта
С распространением mypy и pyright сигнатуры стали частью публичного API даже если вы так не думали. Сужение типа параметра — ломающее изменение для тех, кто проверяет типы, хотя в рантайме ничего не падает.
Каталог изменений: минор или мажор
Ниже — типичные изменения в Python-библиотеке и куда они относятся. Исходим из того, что затронутый символ публичный.
| Изменение | Версия | Почему |
|---|---|---|
| Добавили новую функцию или класс | MINOR | Старый код не затронут |
| Добавили необязательный параметр со значением по умолчанию | MINOR | Существующие вызовы работают |
| Удалили публичную функцию или класс | MAJOR | Импорт упадёт с ImportError |
| Переименовали публичный параметр | MAJOR | Вызовы по ключу упадут с TypeError |
| Добавили обязательный параметр без умолчания | MAJOR | Старые вызовы упадут |
Сузили тип параметра (был str | None, стал str) | MAJOR | Ломает проверку типов у потребителя |
Расширили тип возврата (был str, стал str | None) | MAJOR | Потребитель не обрабатывал None |
| Функция стала бросать новый тип исключения вместо старого | MAJOR | Ломает except-блоки |
| Исправили баг, но кто-то полагался на баг | PATCH* | Спорно — см. ниже |
| Подняли минимальную версию Python | MAJOR | Часть окружений перестанет ставить пакет |
| Изменили значение параметра по умолчанию | MAJOR | Поведение меняется без изменения кода вызова |
Пункт про исправление бага — самый спорный. По букве Semver исправление бага — это патч, даже если кто-то на баг полагался. Но если поведение было задокументировано и его изменение массово сломает пользователей, честнее оформить это как мажор или хотя бы как минор с громким предупреждением в changelog.
Неочевидные ломающие изменения
Три случая, которые регулярно уезжают в минор по ошибке.
Изменение значения по умолчанию
Было def connect(retries=3), стало def connect(retries=0). Ни одна сигнатура формально не изменилась, но код connect() теперь ведёт себя иначе. Это мажор: поведение зависит от вашего умолчания, вы его изменили.
Сужение того, что функция возвращала
Раньше метод возвращал список, теперь — генератор. Итерация работает, но len(), индексация и повторный проход — ломаются. Внешне «оптимизация», по контракту — мажор.
Новое исключение вместо старого
Функция бросала ValueError, вы завели свой ClientError(Exception) и стали бросать его. Все except ValueError у пользователей перестают ловить. Смягчение — наследовать новый класс от старого (class ClientError(ValueError)), тогда старые обработчики продолжат работать и это уже минор.
Депрекация вместо резкого удаления
Мажор — дорогая для пользователя история: он обязан читать changelog и чинить код. Смягчите переход циклом депрекации, чтобы у людей была версия-другая на миграцию.
- В миноре добавьте новое API рядом со старым.
- Старое пометьте предупреждением через модуль
warnings. - Продержите оба API минимум один минорный релиз.
- Удалите старое только в следующем мажоре, перечислив всё удалённое в changelog.
import warnings
def fetch(timeout=None, timeout_seconds=None):
# timeout устарел с 1.5.0, будет удалён в 2.0.0
if timeout is not None:
warnings.warn(
"Параметр timeout устарел, используйте timeout_seconds. "
"Он будет удалён в версии 2.0.0.",
DeprecationWarning,
stacklevel=2,
)
timeout_seconds = timeout
...Аргумент stacklevel=2 важен: без него предупреждение укажет на строку внутри вашей библиотеки, а не на строку вызова у пользователя, и его будет невозможно найти в чужом коде.
Версии до 1.0.0 и период 0.x
Пока вы в 0.x, Semver формально снимает гарантии совместимости: по спецификации версии 0.y.z могут ломать что угодно. На практике многие библиотеки трактуют 0.y как «минор» — ломающие изменения поднимают y. Что бы вы ни выбрали, зафиксируйте это в README. Пользователь, ставящий 0.x, должен понимать, что стабильности вы пока не обещаете.
Как не полагаться на глаз
Ручное отслеживание ломающих изменений не масштабируется. Проверьте инструменты, которые сравнивают публичное API двух версий и сообщают о разнице. Точные названия и их поддержку текущих версий Python сверьте в их документации перед внедрением — экосистема таких утилит меняется, и вставлять сюда имя пакета без проверки было бы враньём.
Что сделать дальше по порядку: объявите __all__ в публичных модулях и запишите в README, что публично, а что нет; заведите CHANGELOG.md с разделами по типу изменений; включите в CI прогон тестов на минимальной и максимальной поддерживаемых версиях Python, чтобы поднятие минимума не проскочило незамеченным. Мажор перестанет быть сюрпризом, когда граница публичного зафиксирована в коде, а не в голове.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Semantic Versioning 2.0.0, Python Packaging User Guide — Versioning
Частые вопросы
Считается ли ломающим изменением, если я меняю только приватную функцию с подчёркиванием?
А если у пользователя другая версия Python — влияет ли поднятие минимальной версии на semver?
Что делать, если я случайно выпустил ломающее изменение как минор?
Является ли добавление нового обязательного поля в возвращаемый dict ломающим изменением?
Обязательно ли соблюдать semver, если моей библиотекой пользуюсь только я?
Меняет ли добавление аннотаций типов к ранее нетипизированной функции версию?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.