Скидки до 20% + 2-ая профессия бесплатно и подарки на 50 000₽

Главная | Все статьи | Код

Ликбез по пакетам и шпаргалка по модулям в Python

Время чтения статьи ~12 минут 66
Ликбез по пакетам и шпаргалка по модулям в Python главное изображение

Статья рассказывает об устройстве пакетов и модулей языка Python и раскрывает некоторые тонкости, о которых следует знать при работе с пакетами, моделями и их импортом.

О чём пойдёт речь

Как вы, возможно знаете, код на Python хранится в модулях (modules), которые могут быть объединены в пакеты (packages). Это руководство призвано подробно рассказать именно о пакетах, однако совсем не упомянуть модули нельзя, поэтому я немного расскажу и о них. Многое из того, что применимо к модулям, справедливо и для пакетов, особенно если принять во внимание тот факт, что каждый, как правило, ведёт себя как модуль.

Кратко о модулях

Модуль в Python — это файл с кодом. Во время же исполнения модуль представлен соответствующим объектом, атрибутами которого являются:

  1. Объявления, присутствующие в файле.
  2. Объекты, импортированные в этот модуль откуда-либо.

При этом определения и импортированные сущности ничем друг от друга не отличаются: и то, и другое — это всего лишь именованные ссылки на некоторые объекты первого класса (такие, которые могут быть переданы из одного участка кода в другой как обычные значения).

Такое единообразие удобно, например, при рефакторинге: мы можем разделить один разросшийся модуль на несколько, а потом импортировать вынесенные определения в оригинальный модуль. При этом с точки зрения внешнего наблюдателя переработанный модуль будет иметь те же атрибуты, которые имел до внесения изменений, а значит у пользователей модуля ничего в коде не сломается.

Модули и видимость содержимого

В Python нет настоящего сокрытия атрибутов объектов, поэтому и атрибуты объекта модуля так или иначе всегда доступны после импорта последнего. Однако существует ряд соглашений, которые влияют на процесс импортирования и поведение инструментов, работающих с кодом.

Так атрибуты, имя которых начинается с одиночного подчёркивания, считаются как бы помеченными "для внутреннего использования", и обычно не отображаются в IDE при обращению к объекту "через точку". И linter обычно предупреждает об использовании таких атрибутов, мол, "небезопасно!". "Опасность" состоит в том, что автор кода имеет полное право изменять состав таких атрибутов без уведомления пользователей кода. Поэтому программист, использовавший в своём коде приватные части чужого кода рискует в какой-то момент получить код, который перестанет работать при обновлении сторонней библиотеки.

Итак, мы можем определять публичные атрибуты модуля, приватные атрибуты (так называют упомянутые выше атрибуты "для внутреннего пользования"). И данное разделение касается не только определений, содержащихся в самом модуле, но и импортируемых сущностей. Ведь все импортированные объекты становятся атрибутами и того модуля, в который они импортированы.

Есть и третья группа атрибутов — атрибуты, добавляемые в область видимости при импортировании всего содержимого модуля ("со звёздочкой", from module import *). Если ничего явно не указывать, то при таком импортировании в текущую область видимости добавятся все публичные атрибуты модуля. Помимо данного умолчания существует и возможность явно указать, что конкретно будет экспортировано при импорте со звёздочкой. Для управления названным методом импорта существует атрибут __all__, в который можно положить список (а ещё лучше — кортеж) строк с именами, которые будут экспортироваться.

Живой пример видимости атрибутов модулей.

Рассмотрим пример, демонстрирующий всё вышеописанное. Пусть у нас будет два файла:

# Файл "module.py"
from other_module import CAT, DOG as _DOG, _GOAT

FISH = 'fish'
MEAT = 'meat'
_CARROT = 'carrot'

__all__ = ('FISH', '_CARROT')
# Файл "other_module.py"
CAT = 'cat'
DOG = 'dog'
_GOAT = 'goat'

Рассмотрим сначала обычный импорт import module. Если импортировать модуль таким образом, то IDE, REPL и остальные инструменты "увидят" у модуля следующие атрибуты:

  • FISH, MEAT т.к. имена констант — публичные,
  • CAT, т.к. константа импортирована под публичным именем.

А эти атрибуты не будут видны:

  • _DOG, т.к. при импортировании константа переименована в приватной манере,
  • _GOAT, т.к. импортирована по своему приватному имени (тут линтер может и поругать за обращение к приватному атрибуту модуля!),
  • _CARROT, ибо приватная константа.

Импорт import other_module я не рассматриваю как тривиальный случай.

Теперь рассмотрим импорт всего содержимого module:

from module import *

После импортирования в текущей области видимости мы получим одно новое имя: _CARROT — именно оно перечислено в атрибуте __all__. Заметьте, что в данном случае при массовом импорте добавится даже приватный атрибут, потому что он явно указан!

Последствия импорта from other_module import * тоже очевидны и я их не рассматриваю.

Наконец-то, пакеты!

Пакет в Python — директория с обязательным модулем __init__.py. Остальное содержимое опционально и может включать в себя и модули, и другие пакеты.

Импортирование пакетов

Пакет с единственным модулем __init__.py при импорте ведёт себя как обычный модуль. Содержимое инициализирующего модуля определяет атрибуты объекта пакета.

Прочие модули пакета и вложенные пакеты не импортируются автоматически вместе с пакетом-родителем, но могут быть импортированы отдельно с указанием полного имени. Важный момент: при импортировании вложенного модуля всегда сначала импортируются модули инициализации всех родительских пакетов (если они ещё ни разу не импортировались, но об этом я расскажу ниже).

Рассмотрим, к примеру, следующую структуру директорий и файлов:

.
└── package/
    ├── __init__.py
    ├── module.py
    └── subpackage/
        ├── __init__.py
        └── submodule.py

Когда мы импортируем модуль submodule.py, то фактически происходит следующее (именно в таком порядке):

  1. загружается и выполняется модуль package/__init__.py,
  2. загружается и выполняется package/subpackage/__init__.py,
  3. наконец, импортируется package/subpackage/submodule.py.

При импорте package.module предварительно загружается только package/__init__.py.

Так что же, если мы загрузим парочку вложенных модулей, то для каждого будет выполняться загрузка всех __init__.py по дороге? Не будет! Подсистема интерпретатора, отвечающая за загрузку модулей, кэширует уже загруженные пакеты и модули. Каждый конкретный модуль загружается ровно один раз, в том числе и инициализирующие модули __init__.py (короткие имена модулей хоть и одинаковы, но полные имена всегда разные). Все последующие импортирования модуля не приводят к его загрузке, только лишь нужные атрибуты копируются в соответствующие области видимости.

Пакеты и __all__

В целом атрибут __all__ в модуле инициализации пакета ведёт себя так же, как и в случае с обычным модулем. Но если при импорте пакета "со звёздочкой" среди перечисленных имён встретится имя вложенного модуля, а сам модуль не окажется импортирован ранее в этом же __init__.py, то этот модуль импортируется неявно! Очередной пример это продемонстрирует.

Вот структура пакета:

.
└── package/
    ├── __init__.py
    ├── a.py
    └── b.py

Файл же package/__init__.py содержит следующее (и только это!):

__all__ = ('a', 'b')

А импортируем мы from package import *. В области видимости у нас окажутся объекты модулей a и b под своими именами (без полного пути, то есть без package.). При этом сами модули в коде нигде явно не импортируются! Такая вот "автомагия".

Указанный автоматизм достаточно ограничен: не работает "вглубь", например — не импортирует "через звёздочку" указанные модули и подпакеты. Если же вам вдруг такого захочется, вы всегда сможете на соответствующих уровнях в __init__.py сделать from x import * и получить в корневом пакете плоскую область видимости со всем нужным содержимым. Но такое нужно довольно редко, потому что "не помогает" ни IDE, ни ручному поиску по коду. Впрочем, знать о фиче и иметь её в виду — не вредно, как мне кажется.

Изучайте Python на Хекслете Первые курсы в профессии Python-программист доступны бесплатно сразу после регистрации. Начните сегодня, учитесь в комфортном для вас темпе.

Пакеты, модули и точки входа

С модулем __init__.py разобрались. Настала очередь модуля __main__.py. Этот модуль позволяет сделать пакет исполняемым посредством вызова python -m …. Те из вас, кому знакомо оформление точки входа в модулях, могут догадаться, откуда ноги растут у магического выражения __name__ == '__main__' — да, отсюда! Для остальных напоминаю: чтобы модуль сделать "исполняемым, но не при импорте", в конец модуля дописывается конструкция

if __name__ == '__main__':
    main()  # тут что-то выполняем

У модуля, который скармливается интерпретатору напрямую (python file.py) или в роли претендента на запуск (python -m module), атрибут __name__ будет содержать то самое магическое '__main__'. А в остальное время атрибут содержит полное имя модуля. С помощью условия, показанного выше, модуль может решить, что делать при запуске.

У пакетов роль атрибута выполняет специальный файл __main__.py. Когда мы запустим пакет через python path/to/package или python -m package, интерпретатор будет искать и выполнять именно этот файл.

Более того, модули __main__ нельзя импортировать обычным способом, поэтому можно не бояться случайного импорта и писать команды прямо на верхнем уровне: всё равно странно в модуле с именем __main__ проверять, что его имя равно __main__ (хе-хе!).

А ещё модуль __main__.py удобен тем, что его можно класть в корень вашего проекта, после чего запускать проект можно будет с помощью команды python .! Лаконично, не правда ли?

PEP 420, или неявные пространства имён

Раз уж развёл ликбез, расскажу и про эту штуку.

Долгое время в Python пакеты были обязаны иметь файл __init__.py — наличие этого файла позволяло отличить пакет от обычной директории с модулями (с которыми Python работать не мог). Но с версии Python3.3 вступил в силу PEP 420, позволяющий создавать пространства имён "на вырост".

Теперь вы можете создавать пакет без __init__.py, и такой пакет сможет существовать полноценно, разве что при импорте содержимого не будет производиться инициализация. Но, конечно же, данное изменение делалось не с целью сэкономить на файлах. Подобные пакеты могут встречаться в путях поиска пакетов (о поиске пакетов я ниже расскажу) более одного раза: все встреченные структуры с общим корневым именем при загрузке схлопнутся в одно пространство имён.

Тут стоит отметить, что с полноценными пакетами подобное не срабатывало ранее и не будет работать в будущем. Если среди путей пакет с модулем инициализации находится в первый раз, все последующие пакеты с тем же именем будут проигнорированы. Это защищает вас от смешивания сторонних пакетов с системными. И даже просто от ошибок именования: назвав пакет так же, как называется встроенный пакет или модуль, вы получите ошибку — ваши определения не будут импортироваться.

Пакеты — пространства имён (Namespace Packages, NP) — а именно так называются пакеты без инициализации — не могут объединяться с полноценными пакетами, поэтому добавить что-то в системный пакет вам также не удастся. И тут всё защищено!

Какая же польза от неявных пространств имён? А вы представьте себя авторами, скажем, игрового движка. Вы хотите весь код держать в общем пространстве имён engine, но при этом не желаете, чтобы весь код поставлялся одним дистрибутивом (не каждому же пользователю нужны все-все компоненты движка). С NP вы можете в нескольких дистрибутивах использовать общее корневое имя engine, но разные подпакеты и подмодули. А на выходе вы получите возможность делать импорты вида

from engine import graphics, sound

Важно: помните, если встретятся обычный пакет и NP с одинаковым именем, то победит обычный пакет! А NP, сколько бы их не было, не будут загружены!

Циклические импорты

Если вдруг вы захотите в один модуль импортировать другой, а другой захочет, пусть даже и не напрямую, импортировать первый, то вы получите ImportError. Потому что у вас случится циклический импорт. Про него нужно просто знать и стараться архитектурить код так, чтобы циклов не случалось.

Если же приспичивает, и импортировать что-то "ну очень нужно", то можно попробовать обойтись локальным импортом:

### foo.py
import bar

A = bar(42)
### bar.py
# import foo — тут не импортируем

def bar(x):
    return x + 1

def baz():
    import foo  # а тут уже можно
    return foo.A

Да, это костыль. Но иногда полезный. В идеале — до ближайшего большого рефакторинга. Поэтому настраивайте linter на ловлю локальных импортов и стремитесь убирать такие костыли хоть когда-нибудь!

Также полезно Как я писал telegram-бот с админкой на Django: личный опыт.

Поиск пакетов и модулей

Пайтон ищет модули и пакеты в директориях, во время исполнения перечисленных в списке sys.path — по порядку от первого пути к последнему.

В этом списке пути до стандартных библиотек обычно расположены раньше, чем директории со сторонними пакетами, чтобы нельзя было случайно заменить стандартный пакет сторонним (помним: кто первый, того и тапки — среди нескольких с одинаковыми именами загружается первый попавшийся пакет).

В списке путей (обычно в начале) присутствует и путь '', означающий текущую директорию. Это, в свою очередь, означает, что модули и пакет в текущем проекте имеют больший приоритет.

Обычно пути трогать не нужно, всё вполне нормально "работает само". Но если очень хочется, то путей у вас несколько:

  1. Использовать переменную окружения PYTHONPATH (значение — строка с путями, разделёнными символом :),
  2. Во время исполнения изменить sys.path.

Первый способ — простой и понятный. Не сложнее добавления пути до исполняемых файлов в PATH (даже синтаксис тот же).

Второй способ — сложный и требующий внимательности. Дело в том, что sys.path нужно изменять максимально рано — где-нибудь в точке входа. Если не торопиться менять sys.path, то что-то уже может успеть загрузиться до того, как вы перестроите пути для поиска пакетов. А ведь эта загрузка может произойти в другом потоке исполнения! Отлаживать проблемы с очерёдностью загрузки модулей сложно. Лучше просто их не создавать.

Кстати, когда вы используете виртуальные окружения, sys.path будет содержать пути до локальных копий стандартных библиотек. Именно это позволяет виртуальному окружению быть самодостаточным (работать на любой машине с подходящей ОС — даже без установленного в систему Python!).

Что не было раскрыто?

Я специально не стал рассказывать про

  • создание модулей и пакетов на лету (без использования файлов исходников);
  • загрузку модулей не с диска, а из других источников;
  • расширение подсистемы импортирования с целью загрузки в виде объектов-модулей чего-то, не являющегося кодом вовсе (XML, CSV, JSON).

Темы эти насколько интересны, настолько и велики. На наше счастье, самим разбираться в такой тонкой и сложной машинерии приходится редко. Мы просто пользуемся готовыми магическими артефактами, а зачаровывают их другие :) Если же вы захотите научиться магии, документация вам в руки.

Аватар пользователя Aleksei Pirogov
Aleksei Pirogov 20 сентября 2019
66
Похожие статьи
Рекомендуемые программы
профессия
Верстка на HTML5 и CSS3, Программирование на JavaScript в браузере, разработка клиентских приложений используя React
10 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на Python, Разработка веб-приложений и сервисов используя Django, проектирование и реализация REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
Тестирование веб-приложений, чек-листы и тест-кейсы, этапы тестирования, DevTools, Postman, SQL, Git, HTTP/HTTPS, API
4 месяца
с нуля
Старт 26 декабря
профессия
Программирование на Java, Разработка веб-приложений и микросервисов используя Spring Boot, проектирование REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
новый
Google таблицы, SQL, Python, Superset, Tableau, Pandas, визуализация данных, Anaconda, Jupyter Notebook, A/B-тесты, ROI
9 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на PHP, Разработка веб-приложений и сервисов используя Laravel, проектирование и реализация REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на Ruby, Разработка веб-приложений и сервисов используя Rails, проектирование и реализация REST API
5 месяцев
c опытом
Старт 26 декабря
профессия
Программирование на JavaScript в браузере и на сервере (Node.js), разработка бекендов на Fastify и фронтенда на React
16 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на JavaScript, разработка веб-приложений, bff и сервисов используя Fastify, проектирование REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
новый
Git, JavaScript, Playwright, бэкенд-тесты, юнит-тесты, API-тесты, UI-тесты, Github Actions, HTTP/HTTPS, API, Docker, SQL
8 месяцев
c опытом
Старт 26 декабря