Правильный запуск python-пакетов
Jul 11, 2021 13:02 · 437 words · 3 minute read
На днях меня попросили посмотреть один странный баг, связанный с запуском приложения. Вот так $ python mymodule
интерпретатор валился с ошибкой ModuleNotFoundError: No module named 'http.server'; 'http' is not a package
(или ImportError: cannot import name 'server' from partially initialized module 'http'
- зависит от того как импортировать http.server
). Проблема уходит, если запускать пакет правильно $ python -m mymodule
. И вроде бы вопрос закрыт, можно было бы и забить, но что-то меня заставило копнуть глубже и разобраться чем эти 2 команды различаются.
Начальные условия
Структура пакета была такой:
__init__.py
__main__.py
http.py
Конечно же в файле http.py не было атрибута server. В этом месте должен был импортироваться http.server
из стандартной поставки. Ниже я привёл минимально необходимый код для воспроизведения проблемы.
Содержимое файла http.py
import http.server
def run():
...
Содержимое файла __main__.py
from python_m.http import run
if __name__ == "__main__":
run()
Расследование
Судя по стектрейсу, проблема именно в импорте. Берётся мой модуль http.py вместо стандартного, так что проблема в очерёдности путей. За запуск пакета или файла отвечает функция runpy._run_module_as_main
, так что я поставил протоколирование sys.path и аргументов (mod_name
и alter_argv
), с которыми эта функция запускается. Правильный вызов $ python -m mymodule
:
python_m 1
''
'/home/tyvik/Projects/~/Projects'
'/home/tyvik/Projects'
'/usr/lib/python36.zip'
'/usr/lib/python3.6'
'/usr/lib/python3.6/lib-dynload'
'/home/tyvik/.virtualenvs/kinopoisk/lib/python3.6/site-packages'
Вызов через $ python mymodule
:
__main__ 0
'python_m'
'/home/tyvik/Projects/~/Projects'
'/home/tyvik/Projects'
'/usr/lib/python36.zip'
'/usr/lib/python3.6'
'/usr/lib/python3.6/lib-dynload'
'/home/tyvik/.virtualenvs/kinopoisk/lib/python3.6/site-packages'
Видимо, проблема глубже - где-то в CPython. Функция runpy._run_module_as_main
вызывается фактически лишь из одного места - C-функции pymain_run_python
. В ней нас интересуют строчки:
else if (config->run_module) {
*exitcode = pymain_run_module(config->run_module, 1);
}
else if (main_importer_path != NULL) {
*exitcode = pymain_run_module(L"__main__", 0);
}
Это как раз те аргументы, которые мы получили первой строкой в выводах выше. Видимо, config->run_module
должен быть определён к этому моменту, а аргумент -m
при запуске как раз это и делает. Давайте посмотрим что за main_importer_path
, который задаётся чуть выше в этой же функции:
PyObject *main_importer_path = NULL;
if (config->run_filename != NULL) {
/* If filename is a package (ex: directory or ZIP file) which contains
__main__.py, main_importer_path is set to filename and will be
prepended to sys.path.
Otherwise, main_importer_path is left unchanged. */
if (pymain_get_importer(config->run_filename, &main_importer_path,
exitcode)) {
return;
}
}
Вот мы и нашли кто переопределяет sys.path
при запуске $ python mymodule
, а заодно узнали, что можно пакеты как zip-файлы запускать. То есть такой вариант перекрывает область видимости пакетов из стандартной поставки и site-packages.
Вывод
Я, конечно, обожаю подобные детективные истории, но иногда всё же стоит читать документацию. Там описано как правильно запускать пакеты ;) Однако, такое поведение появилось относительно недавно, в чисто техническом PR bpo-34170, так что не уверен, что это не баг.
Бонус
Не поленитесь, сходите в main.c и поищите “goto”. А говорят, что так программировать нельзя ;)