Python Модули, пакеты и распространение: импорты, структура проекта, публикация
0%

Модули, пакеты и распространение: импорты, структура проекта, публикация

Модули, пакеты и распространение: импорты, структура проекта, публикация

В большинстве компилируемых языков импорт — это указание компоновщику. В 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 и при желании заменить.

Механика импорта: от опкода до привязки имени

Ключевых деталей три.

  1. Имена импортируются по цепочке. Прежде чем добраться до engine, интерпретатор полностью импортирует myapp, затем myapp.db. Тяжёлый myapp/__init__.py замедляет вообще любой импорт из пакета.
  2. Поиск подпакета идёт не по sys.path, а по __path__ родителя. У пакета есть атрибут __path__ — список каталогов, в которых ищутся его подмодули. Именно поэтому «пакет» и «каталог» — не синонимы: __path__ можно подменить.
  3. Объект модуля попадает в 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 против src layout: что оказывается в sys.path

Разница между двумя раскладками сводится к одному вопросу: что импортируют ваши тесты — исходники в репозитории или установленный дистрибутив? При 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"

Сам конвейер публикации:

Ключевая современная практика — 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.modulessys.meta_pathsys.path/__path__ModuleSpec → регистрация в кеше → exec_module — объясняет всё остальное: и циклические импорты, и двойное выполнение __main__, и затенение стандартной библиотеки, и стоимость старта.

Структура проекта — не эстетика, а способ сделать так, чтобы тесты проверяли ровно тот артефакт, который получит пользователь: отсюда src layout и редактируемая установка. Распространение стоит на связке pyproject.toml → wheel → индекс, а современная публикация обходится без секретов благодаря Trusted Publishing. Точки входа дают штатную систему плагинов, importlib.resources — переносимый доступ к данным пакета, а importlib.metadata — единственный источник истины о версии.

Слабое место экосистемы — историческая фрагментация: несколько сборочных бэкендов, несколько менеджеров, разные лок-файлы. Она постепенно лечится стандартами (PEP 517/518/621/660/735/751), и стратегия «опираться на стандарт, а не на инструмент» окупается при каждой миграции.

Источники

Что дальше

Стандартная библиотека: что в ней есть и почему это стоит знать — пройдёмся по модулям, которые избавляют от половины сторонних зависимостей: pathlib, itertools, functools, dataclasses, datetime, subprocess, sqlite3 — и разберём, где стандартная библиотека честно проигрывает экосистеме.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов