Режим мачете: теги для фреймов

от автора

Перевод подготовлен в рамках онлайн-курса «Python Developer. Professional«.

Также приглашаем всех желающих на открытый демо-урок «Что нового в Python 3.10». На этом вебинаре мы поговорим о том, какие самые важные PEPы включены в ближайший релиз Python 3.10. В частности о том, как изменится работа с типами.


Сегодня мне пришлось разгадывать загадку выполнения кода на Python, и воспользовался фокусом с отладкой в режиме мачете, чтобы понять, в чем дело. Если раньше вы никогда не слышали этот термин, то поясню, режим «мачете» — это когда вы используете грубый временный код для получения информации любым способом. 

Вот как это было. Я добавил новый параметризованный тест к тестовому набору coverage.py. Работало все очень медленно, поэтому я запустил его с отображением таймингов:

Что ж, странно: два теста вызываются, но вызываются еще и четыре вызова моей функции настройки теста и четыре для прерывания. Я только недавно преобразовал этот набор из unittest.TestCase, и у меня есть несколько странных «прокладок» для уменьшения оттока кода. Думая о повторной настройке, я подумал о том, что либо мои прокладки были какими-то не такими, либо я наткнулся на краевой сценарий того, как pytest запускает тесты.

Но как понять, почему настройка вызывается дважды при каждом запуске теста? Я решил воспользоваться инструментом, к которому часто обращался в прошлом и записать куда-нибудь информацию, полученную из стека.

def setup_test(self):     import inspect     project_home = "/Users/ned/coverage"     site_packages = ".tox/py39/lib/python3.9/site-packages/"     with open("/tmp/foo.txt", "a") as foo:         print("setup_test", file=foo)         for t in inspect.stack()[::-1]:             # t is (frame, filename, lineno, function, code_context, index)             frame, filename, lineno, function = t[:4]             filename = os.path.relpath(filename, project_home)             filename = filename.replace(site_packages, "")             show = "%30s : %s:%d" % (function, filename, lineno)             print(show, file=foo)

Вот моя функция настройки теста, которая слишком часто вызывается. Я использовал низкоуровневый способ логирования: добавление во временный файл. Для каждого кадра в стеке вызовов я пишу имя функции и место, где она определена. Пути к файлам длинные и повторяются, поэтому я делаю их относительными, и избавляюсь от путей к пакетам, которые я использую.

После работы кода, я получил четыре трассировки стека, по одной для каждого вызова настройки. Но все они получались одинаковыми:

setup_test                       <module> : igor.py:424                           main : igor.py:416            do_test_with_tracer : igor.py:216                      run_tests : igor.py:133                           main : _pytest/config/__init__.py:84                       __call__ : pluggy/hooks.py:286                      _hookexec : pluggy/manager.py:93                       <lambda> : pluggy/manager.py:84                     _multicall : pluggy/callers.py:187            pytest_cmdline_main : _pytest/main.py:243                   wrap_session : _pytest/main.py:206                          _main : _pytest/main.py:250                       __call__ : pluggy/hooks.py:286                      _hookexec : pluggy/manager.py:93                       <lambda> : pluggy/manager.py:84                     _multicall : pluggy/callers.py:187             pytest_runtestloop : _pytest/main.py:271                       __call__ : pluggy/hooks.py:286                      _hookexec : pluggy/manager.py:93                       <lambda> : pluggy/manager.py:84                     _multicall : pluggy/callers.py:187        pytest_runtest_protocol : flaky/flaky_pytest_plugin.py:94        pytest_runtest_protocol : _pytest/runner.py:78                runtestprotocol : _pytest/runner.py:87                call_and_report : flaky/flaky_pytest_plugin.py:138              call_runtest_hook : _pytest/runner.py:197                      from_call : _pytest/runner.py:226                       <lambda> : _pytest/runner.py:198                       __call__ : pluggy/hooks.py:286                      _hookexec : pluggy/manager.py:93                       <lambda> : pluggy/manager.py:84                     _multicall : pluggy/callers.py:187           pytest_runtest_setup : _pytest/runner.py:116                        prepare : _pytest/runner.py:362                          setup : _pytest/python.py:1468                   fillfixtures : _pytest/fixtures.py:296                  _fillfixtures : _pytest/fixtures.py:469                getfixturevalue : _pytest/fixtures.py:479         _get_active_fixturedef : _pytest/fixtures.py:502         _compute_fixture_value : _pytest/fixtures.py:587                        execute : _pytest/fixtures.py:894                       __call__ : pluggy/hooks.py:286                      _hookexec : pluggy/manager.py:93                       <lambda> : pluggy/manager.py:84                     _multicall : pluggy/callers.py:187           pytest_fixture_setup : _pytest/fixtures.py:936              call_fixture_func : _pytest/fixtures.py:795              connect_to_pytest : tests/mixins.py:33                     setup_test : tests/test_process.py:1651

Я надеялся, что для первого и второго вызовов будут немного разные трассировки, и эта разница даст мне подсказку. Поскольку в стеке все идентично, то дело должно быть в циклах. Но как достать их из стека?

Будь я знаком с кодом, о котором идет речь, одна трассировка могла бы указать на нужно место. Но pytest для меня не прозрачный, и я не хотел начинать копаться в нем. У меня несколько функций pytest, так что, похоже, дело сложное.

Трассировки стека были одинаковыми, поскольку они показывают только статические аспекты вызовов: кто что вызывает и откуда. Но для конкретных экземпляров вызовов функции стеки различаются. Самый верхний кадр один и тот же (одно выполнение основной программы), а самый нижний кадр варьируется (четыре выполнения функции настройки теста). Если мы найдем самый верхний кадр, который отличается от стека к стеку, то узнаем, какой цикл дважды вызывает функцию настройки.

Первым, что пришло мне в голову, было выводить id объекта фрейма, но id переиспользуются, поскольку сами объекты переиспользуются из списков свободной памяти. Вместо этого, почему бы не назначить им теги? У каждого фрейма есть свой набор локальных переменных, который хранится в словаре, привязанном к фрейму.  В каждый кадр я пишу целое число, то есть то количество раз, сколько мы видели этот кадр.

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

def setup_test(self):     import inspect     project_home = "/Users/ned/coverage"     site_packages = ".tox/py39/lib/python3.9/site-packages/"     with open("/tmp/foo.txt", "a") as foo:         print("setup_test", file=foo)         for t in inspect.stack()[::-1]:             # t is (frame, filename, lineno, function, code_context, index)             frame, filename, lineno, function = t[:4]             visits = frame.f_locals.get("$visits", 0)       ## new             frame.f_locals["$visits"] = visits + 1          ## new             filename = os.path.relpath(filename, project_home)             filename = filename.replace(site_packages, "")             show = "%30s :  %d  %s:%d" % (function, visits, filename, lineno)             print(show, file=foo)

Теперь стеки все еще одинаковые, но количество посещений различается. Вот стек второго вызова настройки теста:

setup_test                       <module> :  1  igor.py:424                           main :  1  igor.py:416            do_test_with_tracer :  1  igor.py:216                      run_tests :  1  igor.py:133                           main :  1  _pytest/config/__init__.py:84                       __call__ :  1  pluggy/hooks.py:286                      _hookexec :  1  pluggy/manager.py:93                       <lambda> :  1  pluggy/manager.py:84                     _multicall :  1  pluggy/callers.py:187            pytest_cmdline_main :  1  _pytest/main.py:243                   wrap_session :  1  _pytest/main.py:206                          _main :  1  _pytest/main.py:250                       __call__ :  1  pluggy/hooks.py:286                      _hookexec :  1  pluggy/manager.py:93                       <lambda> :  1  pluggy/manager.py:84                     _multicall :  1  pluggy/callers.py:187             pytest_runtestloop :  1  _pytest/main.py:271                       __call__ :  1  pluggy/hooks.py:286                      _hookexec :  1  pluggy/manager.py:93                       <lambda> :  1  pluggy/manager.py:84                     _multicall :  1  pluggy/callers.py:187        pytest_runtest_protocol :  1  flaky/flaky_pytest_plugin.py:94        pytest_runtest_protocol :  0  _pytest/runner.py:78                runtestprotocol :  0  _pytest/runner.py:87                call_and_report :  0  flaky/flaky_pytest_plugin.py:138              call_runtest_hook :  0  _pytest/runner.py:197                      from_call :  0  _pytest/runner.py:226                       <lambda> :  0  _pytest/runner.py:198                       __call__ :  0  pluggy/hooks.py:286                      _hookexec :  0  pluggy/manager.py:93                       <lambda> :  0  pluggy/manager.py:84                     _multicall :  0  pluggy/callers.py:187           pytest_runtest_setup :  0  _pytest/runner.py:116                        prepare :  0  _pytest/runner.py:362                          setup :  0  _pytest/python.py:1468                   fillfixtures :  0  _pytest/fixtures.py:296                  _fillfixtures :  0  _pytest/fixtures.py:469                getfixturevalue :  0  _pytest/fixtures.py:479         _get_active_fixturedef :  0  _pytest/fixtures.py:502         _compute_fixture_value :  0  _pytest/fixtures.py:587                        execute :  0  _pytest/fixtures.py:894                       __call__ :  0  pluggy/hooks.py:286                      _hookexec :  0  pluggy/manager.py:93                       <lambda> :  0  pluggy/manager.py:84                     _multicall :  0  pluggy/callers.py:187           pytest_fixture_setup :  0  _pytest/fixtures.py:936              call_fixture_func :  0  _pytest/fixtures.py:795              connect_to_pytest :  0  tests/mixins.py:33                     setup_test :  0  tests/test_process.py:1651

Единицы – это кадры, которые не менялись от первого ко второму вызову, а нули – новые кадры. И теперь мы видим, что в flaky_pytest_plugin.py есть цикл, который второй раз вызывает настройку. 

Стандартная ситуация: как только вы находите ответ, все становится очевидно. Я использую плагин pytest-flaky для автоматического повторения тестов, которые завершаются неудачно. Мой новый медленный тест не просто медленный, он еще и не проходит (на текущий момент), поэтому pytest-flaky запускает его еще раз.

Настоящая загадка была не в том, почему дважды вызывалась настройка, а в том, почему запуск теста проходил один раз. Я проверил: не просто настройка выполнялась дважды, тело теста также выполнялось дважды. 

Когда тест завершился успешно, выполнение по два раза исчезло, поскольку pytest-flaky не запускал его заново.

Вот вам и классическая история отладки в режиме мачете, то есть проблемы было легче отловить с помощью динамических инструментов, а не статических. Я сломал какой-то грубый код, чтобы получить нужную информацию, не зная, сработает ли это, но оно сработало.

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

setup_test                       <module> :  3  igor.py:424                           main :  3  igor.py:416            do_test_with_tracer :  3  igor.py:216                      run_tests :  3  igor.py:133                           main :  3  _pytest/config/__init__.py:84                       __call__ :  3  pluggy/hooks.py:286                      _hookexec :  3  pluggy/manager.py:93                       <lambda> :  3  pluggy/manager.py:84                     _multicall :  3  pluggy/callers.py:187            pytest_cmdline_main :  3  _pytest/main.py:243                   wrap_session :  3  _pytest/main.py:206                          _main :  3  _pytest/main.py:250                       __call__ :  3  pluggy/hooks.py:286                      _hookexec :  3  pluggy/manager.py:93                       <lambda> :  3  pluggy/manager.py:84                     _multicall :  3  pluggy/callers.py:187             pytest_runtestloop :  3  _pytest/main.py:271                       __call__ :  1  pluggy/hooks.py:286                      _hookexec :  1  pluggy/manager.py:93                       <lambda> :  1  pluggy/manager.py:84                     _multicall :  1  pluggy/callers.py:187        pytest_runtest_protocol :  1  flaky/flaky_pytest_plugin.py:94        pytest_runtest_protocol :  0  _pytest/runner.py:78                runtestprotocol :  0  _pytest/runner.py:87                call_and_report :  0  flaky/flaky_pytest_plugin.py:138              call_runtest_hook :  0  _pytest/runner.py:197                      from_call :  0  _pytest/runner.py:226                       <lambda> :  0  _pytest/runner.py:198                       __call__ :  0  pluggy/hooks.py:286                      _hookexec :  0  pluggy/manager.py:93                       <lambda> :  0  pluggy/manager.py:84                     _multicall :  0  pluggy/callers.py:187           pytest_runtest_setup :  0  _pytest/runner.py:116                        prepare :  0  _pytest/runner.py:362                          setup :  0  _pytest/python.py:1468                   fillfixtures :  0  _pytest/fixtures.py:296                  _fillfixtures :  0  _pytest/fixtures.py:469                getfixturevalue :  0  _pytest/fixtures.py:479         _get_active_fixturedef :  0  _pytest/fixtures.py:502         _compute_fixture_value :  0  _pytest/fixtures.py:587                        execute :  0  _pytest/fixtures.py:894                       __call__ :  0  pluggy/hooks.py:286                      _hookexec :  0  pluggy/manager.py:93                       <lambda> :  0  pluggy/manager.py:84                     _multicall :  0  pluggy/callers.py:187           pytest_fixture_setup :  0  _pytest/fixtures.py:936              call_fixture_func :  0  _pytest/fixtures.py:795              connect_to_pytest :  0  tests/mixins.py:33                     setup_test :  0  tests/test_process.py:1651

Тройки меняются на единицы в _pytest/main.py:271, то есть в цикле выполнения тестов, и это круто!


Узнать подробнее о курсе «Python Developer. Professional«

Смотреть вебинар «Что нового в Python 3.10»

ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/555278/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *