Публичный API Python-библиотеки: что скрыть, а что показать
Разбираем, как отделить публичный интерфейс библиотеки от внутренностей: __all__, ведущее подчёркивание, __init__.py и обещания, которые потом придётся держать в каждом релизе.

Вы опубликовали библиотеку на PyPI, а через полгода пользователи пишут в issues: «После обновления сломался импорт from mylib.utils.parser import _clean». Вы переименовали внутреннюю функцию — и получили жалобу, потому что кто-то нашёл её через автодополнение и встроил в прод. Проблема не в пользователе, а в том, что граница между публичным и приватным в вашей библиотеке нигде не проведена. Python не мешает импортировать что угодно откуда угодно, поэтому границу приходится проектировать вручную. Разберём, какими инструментами это делается в Python 3.10+ и чем каждый из них платит.
Почему граница вообще нужна
Всё, что доступно для импорта, пользователь рано или поздно заимпортирует. Как только он это сделал, вы связаны обещанием: изменить сигнатуру или удалить объект теперь значит сломать чужой код. Публичный API — это набор объектов, которые вы обязуетесь поддерживать по семантическому версионированию1: ломать только в мажорной версии, депрекейтить заранее.
Всё остальное — внутренности, которые вы вправе переписывать в любом патче. Но это работает, только если пользователь понимает, что публично, а что нет. Соглашение об этом в Python держится на трёх вещах: ведущее подчёркивание, переменная __all__ и структура __init__.py.
Ведущее подчёркивание: соглашение, а не защита
Имя, начинающееся с одного подчёркивания — _helper, _Cache, _MODULE_STATE — по соглашению приватное. Интерпретатор его не прячет: импорт по полному пути сработает. Единственное, на что влияет подчёркивание технически — from module import * не подхватит такие имена (если не задан __all__).
Двойное ведущее подчёркивание внутри класса — self.__state — включает name mangling: атрибут превращается в _ClassName__state. Это не про приватность API, а про защиту от случайного переопределения в наследниках. Для сокрытия деталей библиотеки двойное подчёркивание не нужно и чаще мешает.
Подчёркивание не запирает дверь. Оно вешает табличку «служебное помещение». Кто войдёт — сам виноват, но вы обязаны эту табличку повесить, иначе виноваты вы.
__all__: явный список того, что публично
Переменная __all__ в модуле — это список строк с именами, которые считаются публичными. У неё две роли. Первая: from module import * импортирует ровно то, что перечислено. Вторая, важнее — __all__ служит машиночитаемым контрактом: линтеры и инструменты документации ориентируются на него, а не на то, что «случайно оказалось в модуле».
# mylib/parser.py — публичным делаем только parse_document
__all__ = ["parse_document", "ParseError"]
class ParseError(Exception):
"""Ошибка разбора документа — часть публичного API."""
def _tokenize(raw: str) -> list[str]:
# Внутренняя функция. Имя с подчёркиванием, в __all__ не входит.
return raw.split()
def parse_document(raw: str) -> dict:
tokens = _tokenize(raw)
if not tokens:
raise ParseError("пустой документ")
return {"tokens": tokens}
Здесь _tokenize можно переименовать или выкинуть в любом патче: он приватный по двум признакам сразу. А parse_document и ParseError — публичны и явно перечислены.
Важная оговорка: __all__ управляет только звёздочным импортом. Прямой from mylib.parser import _tokenize всё равно сработает. __all__ — это декларация намерения, а не барьер.
__init__.py: фасад пакета
Пользователь не должен знать вашу внутреннюю раскладку модулей. Если публичная функция лежит в mylib/core/engine/processor.py, заставлять писать этот путь в импорте — значит зацементировать структуру навсегда: любая перестановка файлов сломает чужие импорты.
Решение — собрать публичные объекты в корневом __init__.py и переэкспортировать их:
# mylib/__init__.py
from mylib.parser import parse_document, ParseError
from mylib.client import Client
__all__ = ["parse_document", "ParseError", "Client"]
__version__ = "1.2.0"
Теперь пользователь пишет from mylib import parse_document, а вы вольны перетасовывать внутренние модули как угодно, пока корневой фасад экспортирует те же имена. Внутренняя структура становится вашей приватной территорией.
Нюанс: линтеры вроде flake8 и ruff ругаются на «импортировано, но не используется» в __init__.py. Наличие имени в __all__ для ruff снимает это предупреждение (правило F401 учитывает переэкспорт через __all__). Если пользуетесь другим линтером — проверьте его поведение на переэкспортах в документации инструмента, оно различается.
Приватные подпакеты
Целый модуль можно пометить приватным, назвав его с подчёркиванием: mylib/_internal.py или пакет mylib/_vendor/. Это сильный сигнал: «сюда не ходи». Библиотеки вроде такого подхода придерживаются, чтобы сгружать туда всё, что не входит в контракт.
Сравнение инструментов границы
| Инструмент | Что делает технически | Что сообщает пользователю | Цена |
|---|---|---|---|
Ведущее _ | Убирает из import * | «Служебное, не трогай» | Ничего не запрещает, только соглашение |
__all__ | Определяет import * и контракт для линтеров | «Вот полный публичный список» | Надо поддерживать вручную при добавлении API |
Фасад в __init__.py | Переэкспорт из глубины пакета | «Импортируй отсюда, путь внутри — не твоё дело» | Риск циклических импортов при сложных зависимостях |
Приватный подпакет _internal | Ничего, кроме имени | «Весь модуль — внутренности» | Соглашение, обходится прямым импортом |
Когда что выбирать
- Маленькая библиотека, один-два модуля — хватит
__all__в каждом модуле и подчёркивания у приватных функций. Фасад можно не строить. - Библиотека с вложенной структурой — обязателен фасад в корневом
__init__.py: без него вы навсегда завязаны на текущую раскладку файлов. - Много внутренней обвязки — выносите её в
_internal-подпакет, чтобы публичное дерево модулей оставалось чистым. - Нужна проверка контракта в CI — добавьте тест, который сверяет фактический
mylib.__all__с зафиксированным эталоном, чтобы случайный экспорт не просочился в релиз.
Что ломается чаще всего
Три ошибки, которые превращают приватное в публичное против вашей воли:
- Забыли подчёркивание у helper-функции. Она попала в автодополнение IDE, пользователь её нашёл и заимпортировал. Теперь переименование — ломающее изменение. Лечится дисциплиной: всё, что не в контракте, — с подчёркиванием, сразу.
__all__разошёлся с реальностью. Добавили публичную функцию, но забыли внести в__all__— иimport *её не видит, документация не подхватывает. Обратный случай: оставили в__all__имя удалённой функции —import *падает с ошибкой на этом имени.- Циклический импорт из-за фасада. Корневой
__init__.pyимпортируетclient, аclientв момент загрузки импортирует что-то изmylibнапрямую. ПолучаетеImportError: cannot import name ... (most likely due to a circular import). Решение — импортировать внутри пакета по полным путям к конкретным модулям, а не из корня.
Про типизацию: py.typed и приватность
Если библиотека типизирована, положите файл-маркер py.typed в пакет — иначе mypy у пользователя не увидит ваши аннотации. Для type checker'ов действует то же правило подчёркивания: имена с ведущим _ считаются приватными и не входят в публичный интерфейс при проверке. Явный переэкспорт для строгого режима стоит писать как from .parser import parse_document as parse_document — такая форма сообщает checker'у, что имя намеренно публичное.
Что сделать дальше
Пройдитесь по своему пакету и проверьте: у каждого модуля есть __all__; всё, чего нет в контракте, начинается с подчёркивания; публичные объекты доступны из корня. Затем добавьте в CI тест на состав __all__ — он поймает случайный экспорт раньше пользователя. Дальше стоит посмотреть, как границу оформляют зрелые проекты: их __init__.py и раскладка _internal-модулей — готовый образец. Отдельная тема — политика депрекейтов и предупреждения через warnings.warn(..., DeprecationWarning): здесь мы её не разворачивали.
Аудитор договоров: Word/PDF договоры: скрытые штрафы и пени, протокол разногласий.
Спросить за 20 ₽Источники: PEP 8 — Public and internal interfaces, Python docs — The import system (packages, __all__)
Частые вопросы
А если я не поставлю __all__ вообще — что будет?
Можно ли реально запретить импорт приватной функции?
У меня старая версия Python, 3.8 — всё это сработает?
Переэкспорт в __init__.py замедляет импорт библиотеки?
Что делать, если пользователи уже импортируют мою приватную функцию?
Ruff всё равно ругается на неиспользуемые импорты в __init__.py — как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.