Модули, пакеты и распространение: импорты, структура проекта, публикация
В большинстве компилируемых языков импорт — это указание компоновщику. В Go import разрешается на этапе компиляции, в Java import вообще не делает ничего во время выполнения — это синтаксический сахар над полными именами классов. В Python всё иначе: import — это вызов функции, который выполняется тогда, когда до него доходит поток управления, и который выполняет чужой код целиком.
Из этого одного факта следуют почти все практические сюжеты главы: почему появляется ImportError: cannot import name ... (most likely due to a circular import), почему python file.py и python -m pkg.file ведут себя по-разному, почему CLI стартует полторы секунды, почему тесты зелёные, а у пользователя пакет падает на отсутствующем .sql-файле, и почему структура каталогов проекта — не вопрос вкуса.
Инструментальную часть — venv, pip, uv, poetry, wheel против sdist, слои упаковки — мы разобрали в главе Установка и инструментарий; здесь она предполагается известной. Если вы только начинаете, вам нужен курс Программирование с нуля. Карта трека — в обзоре.
Модуль — это объект, а не файл
Первое, что нужно перестать делать, — думать о модуле как о «файле с кодом». Модуль в Python — обычный объект типа module, чей __dict__ служит глобальным пространством имён для выполняемого в нём кода.
import json
import sys
print(type(json)) # <class 'module'>
print(json.__name__) # json
print(json.__file__) # /usr/lib/python3.13/json/__init__.py
print(json.__package__) # json
print(json.__spec__.origin) # тот же путь — spec это «паспорт» модуля
print(sys.modules["json"] is json) # True — объект живёт в кеше процесса
# Глобальные переменные модуля — это буквально его атрибуты
json.MY_FLAG = 42
from json import MY_FLAG # работает: атрибут уже есть
Отсюда — свойство, на котором держится половина реальных проблем: тело модуля выполняется ровно один раз за жизнь процесса, при первом импорте. Дальше все import возвращают уже готовый объект из sys.modules.
# counter.py
print("counter.py выполняется")
REGISTRY: list[str] = []
# main.py
import counter
import counter # ничего не печатает
from counter import REGISTRY
counter.REGISTRY.append("a")
print(REGISTRY) # ['a'] — тот же самый список, не копия
Практический вывод: модуль — это синглтон уровня процесса, а глобальные переменные модуля — разделяемое изменяемое состояние. Кеш подключений, инициализированный логгер, пул — всё это «работает» ровно потому, что модуль один. Ровно поэтому же изменяемое состояние на уровне модуля почти всегда ошибка: два теста в одном процессе увидят мусор друг друга, а multiprocessing со spawn создаст независимые копии, и вы получите «загадочное» расхождение.
Что на самом деле делает import
Разберём инструкцию import myapp.db.engine по шагам. Каждый шаг — это код на Python, который можно прочитать в importlib и при желании заменить.
Ключевых деталей три.
- Имена импортируются по цепочке. Прежде чем добраться до
engine, интерпретатор полностью импортируетmyapp, затемmyapp.db. Тяжёлыйmyapp/__init__.pyзамедляет вообще любой импорт из пакета. - Поиск подпакета идёт не по
sys.path, а по__path__родителя. У пакета есть атрибут__path__— список каталогов, в которых ищутся его подмодули. Именно поэтому «пакет» и «каталог» — не синонимы:__path__можно подменить. - Объект модуля попадает в
sys.modulesдо выполнения его тела. Это осознанное решение: без него любой цикл в графе импортов приводил бы к бесконечной рекурсии. Ценой становятся «частично инициализированные» модули.
Обратите внимание на последний шаг: import myapp.db.engine связывает в текущем пространстве имён только имя myapp. Форма from myapp.db import engine связывает engine — и именно поэтому вторая форма чувствительна к циклам, а первая нет.
Протокол импорта — это два интерфейса, которые вы можете реализовать сами:
Это не абстрактная красота: на этом протоколе построены загрузка модулей из zip-архивов (zipimport), ленивые импорты в scikit-learn и TensorFlow, подмена модулей в тестах и инструментация вроде OpenTelemetry. Минимальный ленивый загрузчик — семь строк:
import importlib.util
import sys
def lazy_import(name: str):
"""Модуль будет реально выполнен при первом обращении к его атрибуту."""
spec = importlib.util.find_spec(name)
loader = importlib.util.LazyLoader(spec.loader)
spec.loader = loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module # регистрируем ДО exec, как это делает импортёр
loader.exec_module(module)
return module
pandas = lazy_import("pandas") # ~1 мс вместо ~400 мс
print(pandas.DataFrame) # вот здесь pandas действительно загрузится
Байткод-кеш
Рядом с mod.py появляется __pycache__/mod.cpython-313.pyc — результат компиляции в байткод. По умолчанию актуальность проверяется по времени модификации и размеру исходника; PEP 552 добавил режим на основе хеша (--invalidation-mode checked-hash), который нужен для воспроизводимых сборок.
В контейнерах это заметная деталь: если образ запускается от пользователя без прав на запись, .pyc не сохраняются и каждый старт заново компилирует весь код. Лечится одной строкой в Dockerfile:
RUN python -m compileall -q /app /usr/local/lib/python3.13/site-packages
ENV PYTHONDONTWRITEBYTECODE=1
Пакеты, __init__.py и namespace-пакеты
Каталог с __init__.py — «обычный» пакет: файл выполняется при импорте пакета, а его пространство имён и есть пространство имён пакета. Каталог без __init__.py тоже импортируется — с PEP 420 он становится namespace-пакетом: у него нет __file__, а __path__ — специальный объект, который может склеивать несколько каталогов с разных мест sys.path.
Namespace-пакеты решают ровно одну задачу: разбить один логический пакет (google.cloud.*, azure.*, zope.*) на независимо публикуемые дистрибутивы. Во всех остальных случаях они появляются случайно и приносят только вред:
tests/
├── unit/test_orders.py # нет __init__.py
└── integration/test_orders.py
# pytest: import file mismatch — два разных файла претендуют на модуль test_orders
Правило простое: если каталог не задуман как namespace-пакет, __init__.py в нём должен быть. Пустой файл стоит ноль и снимает целый класс ошибок.
Что писать в __init__.py
__init__.py — это публичный фасад пакета. Две крайности одинаково плохи: пустой файл заставляет пользователя знать внутреннюю раскладку модулей, а «импортируем всё на всякий случай» превращает пакет в неподъёмный.
# src/orderflow/__init__.py
"""Публичный API пакета. Всё, чего здесь нет, — внутренности."""
from orderflow.errors import OrderflowError as OrderflowError # явный ре-экспорт
from orderflow.models import Order as Order
__all__ = ["Order", "OrderflowError", "Engine"]
Два нюанса, о которые спотыкаются регулярно:
__all__влияет только наfrom pkg import *— и на инструменты, которые его читают (mypy, документация, линтеры). Он не делает имена приватными.- В строгом режиме mypy (
implicit_reexport = false, входит в--strict) импортfrom .models import Orderне считается ре-экспортом: снаружиorderflow.Orderбудет ошибкой типизации. Нужна формаimport X as Xили наличие имени в__all__. Подробнее — в главе Аннотации типов.
Если фасад тянет тяжёлые зависимости, откладывайте их до первого обращения через PEP 562 — __getattr__ на уровне модуля:
# src/orderflow/__init__.py
from typing import TYPE_CHECKING
__all__ = ["Order", "Engine"]
if TYPE_CHECKING: # только для mypy и автодополнения IDE
from orderflow.engine import Engine
from orderflow.models import Order
def __getattr__(name: str):
"""Вызывается ТОЛЬКО для имён, которых нет в модуле."""
if name == "Engine":
from orderflow.engine import Engine # тянет sqlalchemy — дорого
return Engine
if name == "Order":
from orderflow.models import Order
return Order
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__() -> list[str]:
return sorted(__all__)
Так устроены scipy, dask и sqlalchemy: import orderflow остаётся дешёвым, а orderflow.Engine работает как обычный атрибут.
Абсолютные, относительные импорты и запуск
Относительный импорт (from . import models, from ..errors import Fail) разрешается не по файловой системе, а по атрибуту __package__ текущего модуля. Никакой магии с каталогами здесь нет, и это причина самой частой ошибки новичка в пакетах:
$ python src/orderflow/cli.py
ImportError: attempted relative import with no known parent package
Файл, запущенный как скрипт, получает __name__ == "__main__" и __package__ == "" — родительского пакета у него нет, значит и относительный импорт разрешать не от чего. Способ запуска меняет три вещи сразу:
| Команда | sys.path[0] |
__name__ модуля |
Относительные импорты |
|---|---|---|---|
python src/orderflow/cli.py |
src/orderflow |
__main__ |
не работают |
python -m orderflow.cli |
текущий каталог | __main__ |
работают |
orderflow (console script) |
.venv/bin |
orderflow.cli |
работают |
python -c "import orderflow" |
текущий каталог | orderflow |
работают |
Рабочее правило: код внутри пакета запускается только через -m или через точку входа, прямой запуск файла оставьте одноразовым скриптам. Для -m пакету нужен __main__.py:
# src/orderflow/__main__.py
from orderflow.cli import main
if __name__ == "__main__": # защита от повторного выполнения при импорте
raise SystemExit(main())
Ловушка двойного импорта
Модуль, запущенный через -m pkg.mod, регистрируется в sys.modules под именем __main__. Если этот же модуль кто-то импортирует как pkg.mod, интерпретатор выполнит его второй раз — в sys.modules окажутся два разных объекта с одинаковым исходником.
# orderflow/worker.py
class Task:
...
if __name__ == "__main__":
from orderflow.worker import Task as ImportedTask # тот же файл, другой модуль
print(Task is ImportedTask) # False!
Последствия — isinstance ложно возвращает False, декораторы-регистраторы срабатывают дважды, pickle не может восстановить объект в другом процессе. Отсюда правило: в __main__-модуле не должно быть определений классов, функций и глобального состояния — только вызов кода, живущего в нормальном модуле.
Абсолютные или относительные
Спор старый, ответ прагматичный: внутри пакета относительные импорты (from .models import Order) удобны тем, что пакет можно переименовать или вложить без правки каждого файла, — это реально помогает при выделении модулей в отдельные дистрибутивы. Абсолютные (from orderflow.models import Order) читаются лучше и однозначно указывают источник. Выберите одно правило на репозиторий и включите проверку в ruff:
[tool.ruff.lint]
select = ["TID", "I", "F401"] # TID252 — контроль относительных импортов
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "parents" # свои — можно, из родителей (..) — нет
Циклические импорты: почему падает и как чинить
Цикл в графе импортов сам по себе не ошибка. Ошибка возникает, когда модуль обращается к имени из партнёра, до которого выполнение ещё не дошло.
Классика:
# orderflow/models.py
from orderflow.repo import OrderRepo # (2) уходим в repo
class Order:
...
# orderflow/repo.py
from orderflow.models import Order # (3) models уже в sys.modules, но пустой
class OrderRepo:
def get(self) -> Order: ...
$ python -c "import orderflow.models"
ImportError: cannot import name 'Order' from partially initialized module
'orderflow.models' (most likely due to a circular import)
Четыре способа починки, по возрастанию правильности:
1. Импортировать модуль, а не имя. import orderflow.models не требует, чтобы атрибут уже существовал — обращение orderflow.models.Order произойдёт позже, во время вызова.
# orderflow/repo.py
import orderflow.models # цикл не рвётся, но и не падает
class OrderRepo:
def get(self) -> "orderflow.models.Order":
return orderflow.models.Order() # атрибут ищется в момент вызова
2. Импорт только для типов. Если имя нужно исключительно в аннотациях — уберите его из рантайма:
from __future__ import annotations # аннотации становятся строками
from typing import TYPE_CHECKING
if TYPE_CHECKING: # False во время выполнения
from orderflow.models import Order
class OrderRepo:
def get(self) -> Order: ... # mypy видит, интерпретатор — нет
3. Локальный импорт внутри функции. Легально и иногда единственный вариант (например, разрыв цикла между слоями фреймворка). Цена — микросекунды на поиск в sys.modules при каждом вызове и спрятанная зависимость, которую не видно сверху файла.
4. Убрать цикл. Единственное настоящее решение. Цикл в импортах почти всегда означает цикл в зависимостях модулей: либо общий код надо вынести в третий модуль (orderflow/types.py), либо направление зависимости выбрано неверно и нужен Protocol в нижнем слое вместо конкретного класса из верхнего. Это ровно та дисциплина, о которой говорят архитектурные паттерны и глава про ООП.
Полезная деталь: с Python 3.7 форма from pkg import submodule умеет доставать подмодуль из sys.modules, если атрибут ещё не проставлен. Поэтому циклы между подмодулями часто «работают», а между модулем и его именем — нет. Опираться на это не стоит: поведение зависит от порядка импортов, то есть от того, кто первым дёрнул пакет.
Найти циклы можно без запуска кода:
uvx pydeps orderflow --show-cycles # граф зависимостей и циклы
uvx import-linter --config setup.cfg # правила слоёв как тест в CI
import-linter особенно полезен: контракт «слой domain не должен импортировать infrastructure» становится проверкой в CI, а не устной договорённостью.
Структура проекта: src layout и почему это не вкусовщина
Разница между двумя раскладками сводится к одному вопросу: что импортируют ваши тесты — исходники в репозитории или установленный дистрибутив? При flat layout корень репозитория попадает в sys.path[0], и import orderflow находит каталог с исходниками. Пакет при этом может быть даже не установлен. Все ошибки упаковки — незаявленный подпакет, забытый .sql-файл, отсутствующий py.typed — обнаруживает пользователь, а не CI.
При src layout каталога orderflow в корне нет, поэтому импорт возможен только из site-packages. Единственная плата — обязательная редактируемая установка (PEP 660):
uv pip install -e . # или: uv sync — она делает это сама
Редактируемая установка кладёт в site-packages не копию файлов, а .pth-файл или специальный finder, указывающий на src/. Правки видны сразу, но путь импорта идёт через дистрибутив со всеми его метаданными.
Рабочий скелет сервиса:
orderflow/
├── pyproject.toml
├── uv.lock
├── README.md
├── src/
│ └── orderflow/
│ ├── __init__.py
│ ├── py.typed # маркер PEP 561: пакет типизирован
│ ├── __main__.py # python -m orderflow
│ ├── cli.py
│ ├── domain/
│ ├── adapters/
│ └── data/
│ └── schema.sql
└── tests/
├── conftest.py
├── unit/
│ └── __init__.py
└── integration/
└── __init__.py
Несколько практических деталей, которые экономят часы:
tests/внеsrc/и не входит в дистрибутив. В sdist его класть можно и полезно (мейнтейнеры дистрибутивов Linux прогоняют тесты при сборке), в wheel — нет.conftest.pyв корнеtests/задаётrootdirи общие фикстуры. Про фикстуры — в главе Тестирование.importmode = "importlib"в настройках pytest снимает исторические странности со вставкой каталогов вsys.path:
[tool.pytest.ini_options]
addopts = "-q --import-mode=importlib"
testpaths = ["tests"]
pythonpath = [] # никаких ручных манипуляций с sys.path
- Никогда не правьте
sys.pathруками. Строкаsys.path.append(os.path.dirname(os.path.dirname(__file__)))в начале файла — верный признак того, что проект не установлен как пакет. Она ломается при любой смене структуры, не работает в контейнере и делает код неимпортируемым извне.
Имя дистрибутива ≠ имя импорта
Это отдельный источник путаницы: pip install принимает имя дистрибутива, а import — имя пакета, и они не обязаны совпадать.
| Дистрибутив | Импорт |
|---|---|
scikit-learn |
sklearn |
Pillow |
PIL |
python-dateutil |
dateutil |
beautifulsoup4 |
bs4 |
PyYAML |
yaml |
opencv-python |
cv2 |
protobuf |
google.protobuf |
Один дистрибутив может ставить несколько пакетов, а один пакет собираться из нескольких дистрибутивов. Разрешить связь программно:
from importlib.metadata import packages_distributions
print(packages_distributions()["sklearn"]) # ['scikit-learn']
Отсюда же практическое следствие для безопасности: имя в import ничего не гарантирует об источнике. Атака «тайпсквоттинг» (python-dateutil против python-datetutil) живёт именно на этом зазоре — см. Безопасность цепочки поставок.
Монорепозиторий и workspaces
Когда пакетов несколько, вместо sys.path-фокусов используйте workspace: один лок-файл, одно окружение, локальные пакеты видны друг другу по именам.
# корневой pyproject.toml
[tool.uv.workspace]
members = ["packages/*", "services/*"]
# packages/orderflow-core/pyproject.toml — обычный пакет
# services/api/pyproject.toml:
[project]
dependencies = ["orderflow-core"]
[tool.uv.sources]
orderflow-core = { workspace = true } # брать из монорепо, а не с PyPI
Общие соображения о монорепозиториях — в главе Монорепозиторий; про границы модулей — в модульном монолите.
Точки входа: команды и плагины
[project.scripts] генерирует при установке исполняемый файл в bin/, который импортирует указанный модуль и вызывает функцию. Никакой «компиляции» — это восемь строк на Python:
[project.scripts]
orderflow = "orderflow.cli:main" # создаст .venv/bin/orderflow
[project.gui-scripts]
orderflow-ui = "orderflow.ui:main" # на Windows — без консольного окна
Гораздо интереснее второй механизм: произвольные группы точек входа, то есть штатная система плагинов, где плагин не нужно ни импортировать, ни регистрировать — достаточно установить.
# в pyproject.toml пакета-плагина
[project.entry-points."orderflow.exporters"]
csv = "orderflow_csv.exporter:CsvExporter"
parquet = "orderflow_parquet:ParquetExporter"
# в основном пакете — обнаружение плагинов
from importlib.metadata import entry_points
from typing import Protocol
class Exporter(Protocol):
def export(self, rows: list[dict], path: str) -> None: ...
def discover_exporters() -> dict[str, type[Exporter]]:
"""Читает метаданные всех установленных дистрибутивов, не импортируя их."""
found = entry_points(group="orderflow.exporters")
# ep.load() выполняет импорт ТОЛЬКО выбранного плагина — остальные не трогаем
return {ep.name: ep.load() for ep in found}
exporters = discover_exporters()
print(sorted(exporters)) # ['csv', 'parquet'] — если оба установлены
Так устроены плагины pytest, flake8, sphinx, бэкенды matplotlib и провайдеры airflow. Важный нюанс производительности: entry_points() читает метаданные с диска, поэтому вызывайте её один раз и кешируйте (functools.cache), а ep.load() — только для реально выбранного плагина.
Ресурсы внутри пакета
Классическая ошибка — открывать файлы данных относительно __file__:
# ПЛОХО: сломается в zip-архиве, в PyInstaller, при namespace-пакете
from pathlib import Path
SCHEMA = (Path(__file__).parent / "data" / "schema.sql").read_text()
Правильный способ — importlib.resources, который абстрагирует «где физически лежит пакет»:
from importlib.resources import files
def load_schema() -> str:
"""Работает одинаково для каталога, zip-архива и editable-установки."""
return files("orderflow.data").joinpath("schema.sql").read_text(encoding="utf-8")
# Если библиотеке нужен НАСТОЯЩИЙ путь на диске (передать в C-библиотеку):
from importlib.resources import as_file
with as_file(files("orderflow.data") / "model.onnx") as real_path:
session = load_onnx(real_path) # временно распакует, если пакет в архиве
И второе: файл, который не является .py, не попадёт в wheel сам по себе. Нужно явно указать это сборочному бэкенду:
# hatchling
[tool.hatch.build.targets.wheel]
packages = ["src/orderflow"]
artifacts = ["src/orderflow/data/*.sql"]
# setuptools
[tool.setuptools.package-data]
orderflow = ["data/*.sql", "py.typed"]
Проверять — не глазами, а командой: python -m zipfile -l dist/orderflow-1.0.0-py3-none-any.whl или uvx check-wheel-contents dist/. Это ровно та проверка, которую flat layout незаметно отменяет.
Отдельно — py.typed из PEP 561: пустой файл рядом с __init__.py, без которого mypy у пользователей проигнорирует все ваши аннотации. Забыть его — самая частая ошибка авторов типизированных библиотек.
Версии и публикация
Схема версий Python описана PEP 440: помимо привычного 1.4.2 она допускает пре-релизы (1.5.0rc1), пост-релизы (1.4.2.post1), dev-версии (1.5.0.dev3) и epoch. Для библиотек берите семантическое версионирование, для приложений разумен CalVer (2026.7.1) — пользователю приложения важнее «насколько свежее», чем «что сломалось в API».
Держать версию в двух местах (pyproject.toml и __init__.py) — гарантированный рассинхрон. Единственный источник истины — либо метаданные, либо git-тег:
# src/orderflow/__init__.py
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("orderflow") # читает METADATA установленного пакета
except PackageNotFoundError: # запуск из исходников без установки
__version__ = "0.0.0+unknown"
# версия берётся из git-тега — руками её не правят вообще
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "orderflow"
dynamic = ["version"]
[tool.hatch.version]
source = "vcs"
Сам конвейер публикации:
получаем sdist + wheel"] C --> D["twine check dist/*
метаданные и README валидны?"] D -->|ошибка| X["Правим pyproject, тег переносим"] D -->|ок| E["check-wheel-contents dist/
всё нужное внутри?"] E --> F{"Первая публикация
или рискованный релиз?"} F -->|да| G["TestPyPI: publish + установка
в чистый venv"] F -->|нет| H G --> H["PyPI: Trusted Publishing по OIDC"] H --> I["PEP 740: аттестация происхождения
прикрепляется автоматически"] I --> J["GitHub Release с артефактами"] J --> K{"Нашли критичный баг?"} K -->|да| L["yank версии, PEP 592
+ выпуск 1.4.1"] K -->|нет| M["Готово"]
Ключевая современная практика — Trusted Publishing: PyPI доверяет OIDC-токену вашего CI, и долгоживущий API-токен в секретах репозитория не нужен вовсе.
# .github/workflows/release.yml
name: release
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # hatch-vcs нужны теги
- uses: astral-sh/setup-uv@v5
- run: uv build
- run: uvx twine check dist/*
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi # ручное подтверждение релиза
permissions:
id-token: write # ← вся суть: OIDC вместо секрета
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- uses: pypa/gh-action-pypi-publish@release/v1
Что нужно знать до первой публикации:
- Версию нельзя перезаписать. Загрузили
1.4.0с багом — выпускайте1.4.1. Удаление файла не освобождает номер. - Имя проекта нормализуется по PEP 503:
Order_Flowиorder-flow— это одно и то же имя, занять «похожее» не выйдет. yank(PEP 592) вместо удаления. Отозванная версия остаётся доступной для тех, кто закрепил её точно (лок-файлы не ломаются), но резолвер её больше не выберет.- Сначала TestPyPI.
uv publish --publish-url https://test.pypi.org/legacy/, затем установка в чистый venv — так проверяется, что пакет вообще работает установленным.
Про подписи, SBOM и аудит зависимостей — в главе Безопасность цепочки поставок и в CI/CD.
Приватные пакеты
Внутренние библиотеки не публикуют на PyPI. Варианты: собственный индекс (devpi, Artifactory, Nexus, GitLab/GitHub Package Registry) или прямая ссылка на git.
[tool.uv.sources]
orderflow-core = { git = "ssh://git@github.com/acme/core.git", tag = "v2.1.0" }
Главная опасность здесь — dependency confusion: если приватный индекс подключён как --extra-index-url, а на публичном PyPI кто-то зарегистрировал пакет с тем же именем и версией повыше, резолвер по умолчанию возьмёт публичный. Именно так в 2021 году Алекс Бирсан получил доступ в инфраструктуру Apple, Microsoft и десятков других компаний. Защита: явно привязывать внутренние пакеты к своему индексу.
[[tool.uv.index]]
name = "internal"
url = "https://pypi.acme.internal/simple"
explicit = true # используется только для явно указанных пакетов
[tool.uv.sources]
orderflow-core = { index = "internal" }
Не только PyPI: чем ещё доставляют Python-код
Три варианта заслуживают отдельного упоминания.
Однофайловый скрипт с зависимостями — PEP 723. Метаданные живут в комментарии, запускающий инструмент сам создаёт временное окружение. Это лучшее, что случилось с «скриптами на пять минут»:
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.27", "rich>=13"]
# ///
import httpx
from rich import print
resp = httpx.get("https://pypi.org/pypi/httpx/json", timeout=10)
print(f"[bold]httpx[/bold] последняя версия: {resp.json()['info']['version']}")
uv run report.py # окружение создаётся и кешируется автоматически
CLI-утилиты — через uv tool / pipx, а не pip install в системный Python. Каждая утилита получает изолированное окружение, а её команды линкуются в ~/.local/bin. Это же снимает конфликт с системным пакетным менеджером, из-за которого современные дистрибутивы возвращают externally-managed-environment (PEP 668).
Контейнер — для сервисов дефолт. Wheel при этом никуда не девается: правильный Dockerfile собирает окружение из лок-файла отдельным слоем, а код кладёт последним, чтобы кеш слоёв работал. Детали — в главе Контейнеры и реестры, а специфика деплоя Python — в заключительной главе трека.
Замораживание в один бинарник (PyInstaller, Nuitka, cx_Freeze) честно работает, но стоит дорого: сотни мегабайт, ломающиеся динамические импорты (__import__ по строке резолвер не видит), отдельная сборка под каждую ОС и мучительная отладка. Это вариант для десктопных приложений, а не для «упростить деплой».
Время импорта — это время старта
Для сервиса, живущего сутками, лишние 800 мс на импортах незаметны. Для CLI-утилиты, лямбды или задачи, которая запускается тысячу раз в день, это главная статья расходов. Измеряется одной командой:
python -X importtime -c "import orderflow.cli" 2>&1 | tail -15
import time: self [us] | cumulative | imported package
import time: 1204 | 398417 | pandas
import time: 318 | 95210 | sqlalchemy
import time: 890 | 12043 | orderflow.cli
cumulative — суммарное время с учётом вложенных импортов; смотреть надо именно на него. Типичная находка: pandas подтянулся из-за одной функции конвертации в отчёте, который вызывается раз в месяц.
Что делать:
- Тяжёлые зависимости — за
__getattr__модуля (см. выше) или локальным импортом в функции. - В
__init__.pyне тянуть весь пакет ради удобства. - В CI поставить бюджет:
python -X importtime -c "import orderflow"и падение теста, если суммарное время превысило порог. - Помнить, что
from x import yстоит столько же, сколькоimport x: модуль всё равно выполняется целиком.
Подробнее о профилировании — в главе Производительность.
Грабли, на которые наступают все
Файл, затеняющий стандартную библиотеку. random.py, types.py, queue.py, email.py рядом с main.py ломают не ваш код, а чужие библиотеки, которые импортируют оригинал. Диагностика: python -c "import random; print(random.__file__)". Профилактика — PYTHONSAFEPATH=1 (флаг -P, Python 3.11+) и src layout.
from module import *. Тянет неизвестный набор имён, ломает статический анализ и незаметно переопределяет ваши переменные при обновлении библиотеки. Единственное приемлемое место — интерактивная сессия.
Побочные эффекты в теле модуля. Подключение к БД, чтение конфига, logging.basicConfig() на верхнем уровне превращают импорт в запуск приложения: --help лезет в базу, тесты не собираются без сети, документация не строится. Всё, что делает работу, должно быть в функции.
Мутабельное состояние на уровне модуля. Глобальный словарь-кеш переживает тесты и течёт между ними. Если состояние правда нужно — оформляйте его объектом с явным жизненным циклом.
importlib.reload() как «горячая перезагрузка». Он создаёт новые объекты классов, но старые ссылки (в других модулях, в списках, в замыканиях) продолжают указывать на прежние. Итог — isinstance врёт. reload уместен в REPL и внутри dev-серверов, которые перезапускают процесс целиком, и нигде больше.
__init__.py, импортирующий всё подряд. Кроме медленного старта даёт циклы: подмодуль импортирует пакет, пакет импортирует подмодуль.
Разные версии одного пакета в окружении. Устанавливали через pip поверх uv sync, получили рассинхрон с лок-файлом. Лечится uv sync --frozen (сходимость к локу), а не pip install --force-reinstall.
Тесты без __init__.py и с одинаковыми именами файлов. import file mismatch при --import-mode=prepend. Лечится либо __init__.py в каталогах тестов, либо --import-mode=importlib.
pip install -e . без pyproject.toml. Легаси-режим setup.py develop вставляет исходники в sys.path целиком — и снова превращает src layout в flat.
Чек-лист перед публикацией пакета
src-layout, пакет ставится редактируемо, тесты гоняются против установленного дистрибутива.- В
pyproject.tomlзаполненыname,version(илиdynamic),description,readme,requires-python,license,urls. - Версия имеет один источник истины: git-тег или метаданные, не два
__version__. py.typedна месте, если пакет типизирован; аннотации проверены mypy.- Не-
.pyресурсы явно включены в wheel и читаются черезimportlib.resources. uv build+twine check dist/*+check-wheel-contents dist/проходят в CI.- Установка собранного wheel в чистое окружение проверена отдельным шагом (
uv run --isolated --with dist/*.whl python -c "import orderflow"). - Публикация идёт через Trusted Publishing, а не через долгоживущий токен.
__init__.pyне тянет тяжёлые зависимости;python -X importtimeуложился в бюджет.- Циклов в графе импортов нет; правила слоёв проверяются
import-linter.
Мини-итог
Импорт в Python — это выполнение чужого кода в момент, когда до строки дошло управление. Понимание одной цепочки — sys.modules → sys.meta_path → sys.path/__path__ → ModuleSpec → регистрация в кеше → exec_module — объясняет всё остальное: и циклические импорты, и двойное выполнение __main__, и затенение стандартной библиотеки, и стоимость старта.
Структура проекта — не эстетика, а способ сделать так, чтобы тесты проверяли ровно тот артефакт, который получит пользователь: отсюда src layout и редактируемая установка. Распространение стоит на связке pyproject.toml → wheel → индекс, а современная публикация обходится без секретов благодаря Trusted Publishing. Точки входа дают штатную систему плагинов, importlib.resources — переносимый доступ к данным пакета, а importlib.metadata — единственный источник истины о версии.
Слабое место экосистемы — историческая фрагментация: несколько сборочных бэкендов, несколько менеджеров, разные лок-файлы. Она постепенно лечится стандартами (PEP 517/518/621/660/735/751), и стратегия «опираться на стандарт, а не на инструмент» окупается при каждой миграции.
Источники
- The import system — нормативное описание всей механики импорта.
- importlib, importlib.resources, importlib.metadata — API finders, loaders, ресурсов и метаданных.
- Python Packaging User Guide — канонический источник PyPA: структура проекта, сборка, публикация.
- src layout vs flat layout — официальное объяснение разницы.
- Entry points specification — формат и семантика точек входа.
- PEP 420 — namespace-пакеты; PEP 562 —
__getattr__модуля; PEP 660 — редактируемые установки; PEP 723 — метаданные в скрипте. - PEP 440 — схема версий; PEP 592 — yanking; PEP 561 — распространение типов.
- Trusted Publishers на PyPI — публикация без API-токенов.
- Brett Cannon, How the Python import system works — заметки автора
importlibо внутренностях и подводных камнях. - Luciano Ramalho, Fluent Python, 2nd ed. — главы о пакетах, ресурсах и метапрограммировании.
- Brett Slatkin, Effective Python, 3rd ed. — практические рекомендации по модулям,
__all__и организации пакетов. - uv: projects and workspaces — монорепозитории и локальные источники пакетов.
Что дальше
Стандартная библиотека: что в ней есть и почему это стоит знать — пройдёмся по модулям, которые избавляют от половины сторонних зависимостей: pathlib, itertools, functools, dataclasses, datetime, subprocess, sqlite3 — и разберём, где стандартная библиотека честно проигрывает экосистеме.