Правильный запуск python-пакетов

Jul 11, 2021 13:02 · 437 words · 3 minute read python

На днях меня попросили посмотреть один странный баг, связанный с запуском приложения. Вот так $ 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”. А говорят, что так программировать нельзя ;)