2025, Oct 31 03:17

Установилось, но не импортируется: ModuleNotFoundError из-за структуры пакета в src

Разбираем случай, когда wheel ставится без ошибок, но импорт падает с ModuleNotFoundError. Причина — структура пакета в src и настройки setuptools. Решение.

Когда свежее колесо Python (wheel) устанавливается без ошибок, но импорт заканчивается ModuleNotFoundError, дело зачастую не в самом рантайме. Проблема кроется в структуре пакета и в том, как setuptools решает, что включать в дистрибутив. Ниже — практический разбор реального случая: пакет установлен и виден pip, sys.path выглядит корректно, однако Python не может его импортировать.

Симптом: установлен, но не импортируется

Пакет собирался стандартными инструментами:

python3 -m build
python3 -m pip install dist/jbpy-0.0.2.9-py3-none-any.whl

pip подтвердил установку, а файлы оказались в ожидаемом каталоге user site-packages:

Location: /home/jbang/.local/lib/python3.12/site-packages

Пути Python тоже выглядели как надо; например, в sys.path был путь:

/home/jbang/.local/lib/python3.12/site-packages

Однако попытка импортировать пакет падала с ошибкой:

ModuleNotFoundError: No module named 'jbpy'

Тест импорта использовал тот же интерпретатор, что и системный python3 в оболочке. Это не проблема путей и не рассинхрон с виртуальным окружением.

Минимальный воспроизводимый пример

Вот упрощённый тест импорта, который воспроизводит ошибку. Поведение то же; просто привязка имени обозначена более явно:

#!/usr/bin/python3
# -*- coding: UTF-8 -*-
import jbpy.logging as log_pkg
if __name__ == "__main__":
    print("Modules imported")

Пакет был настроен через pyproject.toml так:

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "jbpy"
version = "0.0.2.9"
authors = [{name = "Jens Bang", email = "xxxx@xxxx.xx"}]
description = "A package with utility functions to make my Python life easier."
readme = "README.md"
requires-python = ">=3.6"
license = "BSD-3-Clause"
classifiers = [
    "Programming Language :: Python :: 3",
]
[tool.setuptools.packages.find]
where = ["src"]
include = ["argparse*", "logging*", "json*"]
exclude = ["__*"]
namespaces = false

Дерево исходников складывало файлы Python прямо в src, без каталога пакета jbpy:

src/
  argparse.py
  general.py
  __init__.py
  json.py
  logging.py
  jbpy.egg-info/

Что на самом деле пошло не так

setuptools не включал в дистрибутив никакого импортируемого кода. Если посмотреть внутрь исходного архива, там только метаданные и проектные файлы; модули Python отсутствуют полностью:

$ tar tf dist/jbpy-0.0.2.9.tar.gz
jbpy-0.0.2.9/
jbpy-0.0.2.9/PKG-INFO
jbpy-0.0.2.9/README.md
jbpy-0.0.2.9/pyproject.toml
jbpy-0.0.2.9/setup.cfg
jbpy-0.0.2.9/src/
jbpy-0.0.2.9/src/jbpy.egg-info/
jbpy-0.0.2.9/src/jbpy.egg-info/PKG-INFO
jbpy-0.0.2.9/src/jbpy.egg-info/SOURCES.txt
jbpy-0.0.2.9/src/jbpy.egg-info/dependency_links.txt
jbpy-0.0.2.9/src/jbpy.egg-info/top_level.txt

Причина — в настройках обнаружения пакетов. Директива where = ["src"] говорит setuptools искать пакеты внутри src. Но в src нет каталога пакета jbpy — там только файлы модулей. Шаблоны include нацелены на имена вроде argparse, logging, json, то есть на модули (и к тому же они пересекаются с названиями модулей стандартной библиотеки), а не на верхнеуровневый пакет вроде jbpy. В итоге под критерии «пакета» не попало ничего.

Как исправить

Создайте реальный каталог пакета внутри src и позвольте setuptools его обнаружить. Переместите связанные модули в src/jbpy и настройте обнаружение так, чтобы оно включало пакеты максимально широко. После реструктуризации структура должна выглядеть так:

src/
  jbpy/
    __init__.py
    argparse.py
    general.py
    json.py
    logging.py

Затем используйте конфигурацию обнаружения пакетов, которая действительно подхватывает этот пакет:

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "jbpy"
version = "0.0.2.9"
authors = [{name = "Jens Bang", email = "xxxx@xxxx.xx"}]
description = "A package with utility functions to make my Python life easier."
readme = "README.md"
requires-python = ">=3.6"
license = "BSD-3-Clause"
classifiers = [
    "Programming Language :: Python :: 3",
]
[tool.setuptools.packages.find]
where = ["src"]
include = ["*"]
exclude = []
namespaces = false

Соберите и установите заново:

python3 -m build
python3 -m pip install dist/jbpy-0.0.2.9-py3-none-any.whl

Теперь исходный дистрибутив содержит сам пакет и модули:

$ tar tf dist/jbpy-0.0.2.9.tar.gz
jbpy-0.0.2.9/
...
jbpy-0.0.2.9/src/jbpy/
jbpy-0.0.2.9/src/jbpy/__init__.py
jbpy-0.0.2.9/src/jbpy/argparse.py
jbpy-0.0.2.9/src/jbpy/general.py
jbpy-0.0.2.9/src/jbpy/json.py
jbpy-0.0.2.9/src/jbpy/logging.py
...

И импорт начинает работать как ожидается:

import jbpy.logging as log_pkg

Почему это важно

pip может сообщить, что пакет установлен, даже если wheel или sdist по факту содержит почти одни метаданные. Это создаёт путаницу: окружение выглядит корректным, sys.path указывает на верный site-packages, pip show подтверждает установку — а импорт всё равно падает. Проверка содержимого собранного дистрибутива быстро выявляет истинную причину. В данном случае обнаружение пакетов не нашло ни одного реального пакета, потому что код лежал модулями прямо в src, а шаблоны include/exclude отфильтровали нужное.

Итоги

Используйте корректную src-структуру с верхнеуровневым каталогом пакета внутри src и убедитесь, что механизм обнаружения пакетов setuptools указывает именно на него. Начинайте с широких шаблонов include и сужайте их только при необходимости. Если после успешной установки вы ловите ModuleNotFoundError, загляните в собранный sdist или wheel и убедитесь, что там действительно есть ваши файлы Python. Как только каталог пакета существует в src и обнаружение настроено на его включение, wheel будет содержать ваш код, и импорт начнёт соответствовать тому, что показывает pip.

Статья основана на вопросе на StackOverflow от Jens Bang и ответе J Earls.