2025, Dec 23 03:02
Как исправить ModuleNotFoundError: стабильные импорты Python в многопроектной среде
Практическое решение ModuleNotFoundError при импортах между соседними пакетами: настройка sys.path от __file__, пример с PySpark и чтением конфигурации CSV.
Наладить импорты Python между соседними пакетами в многопроектной рабочей среде бывает неожиданно сложно. Типичный симптом — ModuleNotFoundError, хотя файл явно лежит в соседней папке. Чаще всего проблема не в самом коде, а в том, откуда запускают программу — в текущем рабочем каталоге. Ниже — практическая пошаговая схема, как сделать такие импорты надежными в конфигурации, где один скрипт находится в testing_framework/main_scripts и ему нужно импортировать модуль из testing_framework/user_functions.
Минимальный проблемный сценарий
Предположим, есть скрипт testing_framework/main_scripts/main_script.py, которому нужно обратиться к testing_framework/user_functions/config_reader.py. Прямой импорт выглядит естественно, но он может упасть в зависимости от того, какой рабочий каталог задаёт IDE или раннер:
from user_functions import config_reader
Если приложение запускается с рабочим каталогом, из-за которого testing_framework отсутствует в sys.path, Python не найдёт user_functions и выбросит ModuleNotFoundError.
Почему это ломается
Python ищет модули, проходясь по записям в sys.path. Многие IDE, включая VSCode, запускают скрипт с рабочим каталогом, отличным от каталога самого файла. Опираться на относительные пути вроде .. — ненадежно: всё зависит от точки запуска процесса, а не от местоположения файла. Быстрая проверка os.getcwd() покажет, стартовали ли вы не из того места. Исправление — вычислить абсолютный путь от выполняемого файла (через __file__), подняться к нужной родительской папке и добавить её в sys.path до импорта. При необходимости можно явно дописать полный путь к testing_framework в sys.path перед любым импортом, который ссылается на user_functions.
Рабочий подход
Идея в том, чтобы привязать пути к расположению самого скрипта и сделать родительскую папку видимой для системы импорта. Ниже — надёжный пример, гарантирующий, что импорт сработает из testing_framework/main_scripts/main_script.py при обращении к testing_framework/user_functions/config_reader.py.
Пример использует pyspark так же, как в рабочем варианте сценария, и читает конфигурацию из CSV, делегируя это config_reader.
Исправленный основной скрипт
import os, sys
from pyspark.sql import SparkSession
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
sys.path.append(project_root)
from user_functions import config_reader as cfg_reader
spark_ctx = SparkSession.builder.appName("validation").master("local").getOrCreate()
# Ожидается, что config_dir и config_name указывают на каталог с конфигурацией и имя файла
settings_data = cfg_reader.fetch_config(spark_ctx, config_dir, config_name)
print(settings_data)
Модуль, обеспечивающий загрузку конфигурации
import os
from pyspark.sql import SparkSession
def fetch_config(session: SparkSession, cfg_dir, cfg_file):
cfg_path = os.path.join(cfg_dir, cfg_file)
if cfg_file.endswith('.csv'):
cfg_df = session.read.format('csv') \
.option('header', True) \
.load(cfg_path)
return cfg_df.collect()
Что изменилось и почему теперь работает
Импорт стал стабильным потому, что мы вычисляем абсолютный путь к каталогу скрипта, поднимаемся к родителю, который содержит и main_scripts, и user_functions, и добавляем этого родителя в sys.path до выполнения импорта. Так исчезает зависимость от способа запуска и текущего значения os.getcwd(). При необходимости того же можно добиться, добавив в sys.path полный путь к testing_framework перед импортом из user_functions.
Почему это важно
Конвейеры данных и задания Spark часто запускаются из разных драйверов, ноутбуков или задач IDE, где рабочие каталоги различаются. Если импорты зависят от текущего каталога, один и тот же код может вести себя по-разному в разных средах. Привязывая импорты к реальному расположению скрипта, вы избавляетесь от недетерминированных сбоев и делаете код переносимым между инструментами и раннерами.
Выводы
Всегда проверяйте, что путь с нужным пакетом присутствует в sys.path до импорта. Получайте этот путь от местоположения скрипта через os.path.abspath(__file__) и поднимайтесь к нужному родительскому каталогу. При отладке выведите os.getcwd(), чтобы подтвердить, какой рабочий каталог использует ваш раннер. При необходимости добавьте полный путь к testing_framework в sys.path перед импортом модулей из user_functions. С такими предохранителями Python будет стабильно находить ваши пакеты, а загрузчик конфигурации для Spark будет работать как задумано.