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

Симптом: минорный релиз ломает половину пользователей
Вы выпустили версию 2.4.1 — по SemVer это патч, без изменений поведения. Через сутки в трекере пять issue: у людей падает импорт, кто-то ловит AttributeError на объекте, который раньше был. Вы ничего не удаляли из документации. Но кто-то импортировал mylib.core._Session, кто-то полез в obj._cache, а кто-то распаковывал ваш namedtuple по индексам — и вы поменяли порядок полей.
Проблема не в пользователях. Проблема в том, что граница между «этим можно пользоваться» и «это внутреннее» в Python не выражена языком. Нет private, нет internal, нет ничего, что компилятор проверит. Есть договорённости, и первым ломается то, о чём вы не договорились явно.
Что в Python реально ограничивает доступ
Ничего не ограничивает жёстко. Есть три механизма разной силы, и путать их — источник половины проблем.
- Подчёркивание в начале имени (
_session,_Cache) — соглашение. Означает «не трогай», но интерпретатору всё равно. Атрибут доступен, импортируется, вызывается. - Двойное подчёркивание (
__cache) — включает name mangling: внутри классаFooимя__cacheпревращается в_Foo__cache. Это не защита, а способ избежать коллизий при наследовании. Достучаться всё равно можно через искажённое имя. - Список
__all__в модуле — управляет тем, что попадёт приfrom module import *. На точечный импортmodule.thingне влияет вообще.
Вывод неприятный: язык вам не помощник. Публичность API — это то, что вы задокументировали и пообещали держать, а не то, что видно снаружи. Всё, что не помечено приватным, пользователи будут считать частью контракта — потому что оно работает.
Если атрибут доступен и решает чью-то задачу, кто-то его использует. Наличие подчёркивания не остановит человека, у которого горит дедлайн. Остановит только отсутствие доступа или явно сломанное поведение при доступе.
Что ломается первым — по частоте
Порядок примерно такой, от самого частого к редкому.
1. Утечка приватных имён в примеры и подсказки IDE
Вы написали в README быстрый пример и использовали в нём MyLib()._client.request(...), потому что так короче. Всё, теперь _client — публичный де-факто. Пользователи копируют примеры дословно.
Сюда же — автодополнение. IDE показывает и приватные атрибуты. Если ваш публичный объект несёт на себе десяток _-полей, часть из них будет использована, что бы ни было в документации.
2. Реэкспорт, который вы не собирались обещать
Классическая ситуация: в __init__.py пакета вы пишете from .core import * для удобства. В core нет __all__, и наверх утекает всё, включая вспомогательные функции и импортированные зависимости. Теперь from mylib import json работает, кто-то на это завязался, а вы через релиз перешли с json на orjson — и сломали чужой импорт, даже не тронув свой код.
3. Изменение структуры «прозрачных» объектов
namedtuple, dataclass и dict-подобные объекты, которые вы возвращаете, — часть API целиком: имена полей, их порядок, типы. Перестановка полей в namedtuple ломает распаковку по индексу. Переименование поля dataclass ломает доступ по имени. Это не внутренности — это то, что уходит наружу с каждым вызовом.
4. Сигнатуры под давлением обратной совместимости
Вы добавили новый обязательный параметр в публичную функцию — сломали всех, кто вызывал её позиционно. Даже добавление параметра со значением по умолчанию в середину позиционных ломает тех, кто передавал аргументы по позиции.
Как проводить границу так, чтобы она держалась
Приёмы ниже работают в CPython 3.x и не требуют сторонних библиотек, кроме отдельно отмеченных.
Явный __all__ в каждом модуле, который импортируют
# mylib/client.py
__all__ = ["Client", "ClientError"]
class Client:
...
class ClientError(Exception):
...
# Вспомогательное — не в __all__, с подчёркиванием
def _build_headers(token):
...
А в __init__.py пакета — только именованные реэкспорты, без звёздочки:
# mylib/__init__.py
from .client import Client, ClientError
__all__ = ["Client", "ClientError"]
Проверка: import mylib; print(mylib.__all__) должен показать ровно ваш публичный набор, а dir(mylib) не должен содержать имён импортированных библиотек.
Скрывать приватные атрибуты из автодополнения
Магический метод __dir__ позволяет вернуть из объекта только публичное — IDE и dir() покажут чистый список. Атрибуты при этом остаются доступны, но перестают маячить перед пользователем.
class Client:
def __init__(self):
self._session = None # внутреннее состояние
self.base_url = "..." # публичное
def request(self, path):
...
def __dir__(self):
# возвращаем только то, что считаем API
return ["base_url", "request"]
Форсировать keyword-only аргументы
Всё, что стоит после * в сигнатуре, можно передать только по имени. Это развязывает вам руки: порядок таких параметров можно менять, не ломая вызовы.
def connect(host, *, timeout=30, retries=3):
...
# connect("db.local", timeout=10) — ок
# connect("db.local", 10) — TypeError, и это правильно
Не отдавать наружу изменяемое внутреннее состояние
Если метод возвращает внутренний список или словарь напрямую, пользователь сможет его мутировать — и вы получите повреждённое состояние объекта из чужого кода. Отдавайте копию или неизменяемое представление (tuple, types.MappingProxyType для dict).
Проверить границу под нагрузкой пользователей
«Нагрузка» для библиотеки — это не RPS, а количество способов, которыми её используют. Чем больше проектов зависит от вас, тем выше шанс, что кто-то залез внутрь. Как это увидеть до релиза.
| Что проверяем | Чем | Что ловим |
|---|---|---|
| Что реально экспортируется | dir(mylib), сравнение с __all__ | протёкшие имена, чужие импорты |
| Изменения публичного API между версиями | инструмент диффа API (например, griffe) | удалённые/переименованные символы, сломанные сигнатуры |
| Использование приватного в своём же коде | линтер, флаги про доступ к protected-члену | примеры и тесты, лезущие в _поля |
| Совместимость сигнатур | тесты, вызывающие функции позиционно и по имени | поломку от нового параметра |
Точное имя правила линтера и набор флагов у griffe уточняйте в их актуальной документации — интерфейсы этих инструментов меняются между версиями, и я не буду называть флаги, в которых не уверен.
Где подход ломается
- Наследование от ваших классов. Как только пользователь наследует ваш класс, его сабкласс видит
_-атрибуты и завязывается на них. Если публичная точка расширения — наследование, приватного у базового класса почти не остаётся: всё, что видит наследник, вы обещали. Либо запрещайте наследование (композиция вместо него), либо документируйте protected-интерфейс отдельно. - Мокинг в чужих тестах. Пользователи патчат ваши внутренние функции в своих тестах через путь вроде
mylib.client._build_headers. Переименовали — сломали чужие тесты. Формально это их проблема, практически — ваши issue. - Monkey-patching в рантайме. Кто-то заменяет ваш метод на свой. Тут граница бесполезна: доступно всё. Защита — только версионирование и чёткое «поведение приватного не гарантируется».
Что делать дальше
Соберите список того, что вы обещаете, и зафиксируйте его: __all__ в модулях, документация только по публичным именам, политика версионирования (SemVer) в README. Заведите в CI шаг, который диффит публичный API между тегами и падает на несогласованном удалении символа. Перед мажорным релизом пройдитесь по dir() корневого пакета — глазами, один раз. То, что там лежит и не должно, — ваш следующий breaking change, который лучше сделать осознанно, чем случайно в патче.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: Python Packaging User Guide, PEP 8 — Naming Conventions (Public and internal interfaces)
Частые вопросы
Одно подчёркивание или два для приватных атрибутов?
Влияет ли __all__ на обычный импорт по точке?
Пользователи всё равно лезут в приватное — что делать?
У меня другая версия Python — приёмы сработают?
Как отличить breaking change от безопасного при обновлении?
Стоит ли запрещать наследование от классов библиотеки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.