Как мы переходили с pip на Poetry

Всем привет! Я Станислав Бушуев, Software Engineer в Semrush. В этой статье я расскажу о том, как мы столкнулись с проблемой периодического обновления Python-зависимостей, тестировали решение с полной их фиксацией, ошибались, и в итоге перешли на Poetry.

202dc99e5e80d083f461151a7d458922.png

Предыстория

Чаще всего любой Python-проект начинается с git init и pip install my-favorite-framework. Далее происходит реализация базового функционала. После этого появляется необходимость поставить еще один python-пакет, а уже в самом конце вспоминается, что нужно добавить файл зависимостей проекта. 

В классическом варианте создается файл requirements.pip:

# requirements.pip
my-favorite-framework
first-package

MVP проекта реализован и выложен на пользователей, производится тестирование бизнес-идеи. Проект растет, появляется множество нового функционала и, конечно же, внушительный список зависимостей.

Один из проектов моей команды — разработка SEO Writing Assistant. В нем за четыре года разработки мы накопили следующие зависимости: фреймворк FastAPI, клиент для работы с кэшем Redis, клиент базы данных PostgreSQL, клиент для сбора метрик Prometheus, множество библиотек для работы с текстом, небольшая кучка пакетов для тестирования и прочих полезных штук. Справедливости ради стоит отметить, что у нас не были зафиксированы только версии пакетов для тестирования. 

Первые проблемы

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

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

E       AttributeError: 'async_generator' object has no attribute 'execute'

Начинаем разбираться и находим, что пакет pytest-asyncio обновился с версии 0.18.3 до 0.19.0. Спасибо разработчикам за отметку BREAKING в Changelog-файле. Это всегда помогает быстрее разбираться.

Такой кейс случается периодически, и после n-ого срабатывания мы решаем зафиксировать версии пакетов. Остается только вопрос: до какого уровня будем фиксировать?

В Python принято версионировать пакеты таким образом: major.minor.micro (смотри PEP-440). Фиксировать можно довольно гибко (подробнее в документации pip). Кто-то фиксирует только мажорную версию пакета, а кто-то все, включая минорную или даже микро. В идеальном мире минорные версии не должны ломать совместимость, но на практике бывает по-разному (как и случилось у нас). 

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

# requirements.pip
my-favorite-framework==1.1.2
first-package==0.1.42
...
the-last-one-package==5.3.2

Фиксация версий всех пакетов в окружении

Проект продолжает развиваться, зависимости периодически обновляются до свежих версий (вручную и с тестированием), но в один непрекрасный момент сборка образа с зависимостями завершается ошибкой. Может быть еще «лучше»: запуск приложения падает с исключением, без внесения изменений в кодовую базу проекта.

В нашем случае мы не вносили никаких изменений в сам проект, проблему нужно было искать в окружении. Но мы же зафиксировали все версии пакетов?

После пристального сравнения установленных пакетов в последней рабочей и текущей сборках видим, что версии некоторых пакетов отличаются. Проблема заключается в том, что в используемых пакетах тоже есть свои зависимости, их версии могут быть не зафиксированы. Так и случилось. Вышла версия 3.1.0 Jinja и пакет jinja2-cli (который мы используем) перестал работать:

Traceback (most recent call last):
 File "/tmp/.local/bin/jinja2", line 8, in 
   sys.exit(main())
 File "/tmp/.local/lib/python3.9/site-packages/jinja2cli/cli.py", line 335, in main
   sys.exit(cli(opts, args))
 File "/tmp/.local/lib/python3.9/site-packages/jinja2cli/cli.py", line 257, in cli
   output = render(template_path, data, extensions, opts.strict)
 File "/tmp/.local/lib/python3.9/site-packages/jinja2cli/cli.py", line 171, in render
   env = Environment(
 File "/tmp/.local/lib/python3.9/site-packages/jinja2/environment.py", line 363, in __init__
   self.extensions = load_extensions(self, extensions)
 File "/tmp/.local/lib/python3.9/site-packages/jinja2/environment.py", line 117, in load_extensions
   extension = t.cast(t.Type["Extension"], import_string(extension))
 File "/tmp/.local/lib/python3.9/site-packages/jinja2/utils.py", line 1[49](https://.../.../.../-/jobs/8292982#L49), in import_string
   return getattr(__import__(module, None, None, [obj]), obj)
AttributeError: module 'jinja2.ext' has no attribute 'with_'

В настройках пакета jinja2-cli видим установку самой свежей версии.

Немного поразмыслив над этим, решаем зафиксировать версии всех пакетов в окружении. Решение простое и надежное. Из последнего рабочего образа выполняем команду:

$ pip freeze > requirements_frozen.pip

А далее используем два файла с описанием зависимостей: requirements.pip — пакеты, которые используем непосредственно в коде проекта, а requirements_frozen.pip — все пакеты вместе с подзависимостями.

Прощай pip, здравствуй Poetry!

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

Начали искать способы решения проблемы. Варианты, которые мы отмели:

  • Pip constraints files. По факту это лишь улучшение файла requirements_frozen.pip, где все подзависимости выносятся в отдельный файл. Это никак не решает проблему изменения содержимого пакета без изменения версии.

  • Pip-tools. Есть параметр generate-hashes, который генерирует контрольные суммы пакетов. Здесь не понравилось то, что в итоге из файла requirements.in нужно собирать файл с зависимостями requirements.pip и хранить его в git. Получается та же обертка над изначальным файлом зависимостей.

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

Переход с pip на Poetry оказался довольно легким и приятным. В итоге мы получили удобное решение для управления зависимостями проекта, на 100% повторяемое окружение и удобный инструмент для обновления зависимостей.

Рекомендации, подводные камни

1. Импортирование текущих зависимостей

Установка и первоначальная настройка Poetry описана в документации и не вызывает никаких проблем или вопросов. А вот импортирование текущий зависимостей в Poetry не реализовано совсем. Странно, что в инструменте есть команда export, но нет import. На github«e есть подобный запрос, в котором обсуждается решение с хитрой командой на perl. Мы немного доработали команду, чтобы она учитывала комментарии:

$ cat requirements.pip | grep -v # | perl -pe 's/([<=>]+)/:$1/g' | xargs -n 1 echo poetry add

Linux-команда echo в сочетании с xargs просто выведет команды, а не запустит их. Убедившись, что все в порядке, удалите echo и подождите, пока Poetry соберет зависимости. 

2. Сравнение изначального списка зависимостей со списком из Poetry

Этот шаг очень полезен в момент перехода на Poetry. Выгружаем зависимости:

$ poetry export -f requirements.txt --output requirements.txt

И сравниваем со своим списком:

$ diff requirements.txt requirements.pip

3. Сборка Docker-образа

Мы используем Kubernetes-кластер и весь код упаковываем в Docker-образы, запуская все это в pods. В таком окружении нет смысла создавать еще и виртуальное окружение, так что необходимо добавить ENV переменную в Dockerfile:

ENV POETRY_VIRTUALENVS_CREATE=false 

4. Тестирование

Сюрприз, но при обновлении версий Python-пакетов отлично выручают тесты! Например, мы словили такое исключение:

$ coverage run -m pytest --color=yes --junitxml=junit.xml
============================= test session starts ==============================
platform linux -- Python 3.7.11, pytest-5.1.1, py-1.11.0, pluggy-0.13.1
rootdir: /builds/my-team/my-project inifile: pytest.ini
plugins: aiohttp-0.3.0, cov-3.0.0
collected 35 items / 1 errors / 34 selected
==================================== ERRORS ====================================
_______________ ERROR collecting my-project/tests/test_handlers.py ________________
tests/test_handlers.py:8: in 
   from my-project.app import make_app
app.py:3: in 
   import aiohttp_jinja2
/usr/local/lib/python3.7/site-packages/aiohttp_jinja2/__init__.py:8: in 
   from .helpers import GLOBAL_HELPERS
/usr/local/lib/python3.7/site-packages/aiohttp_jinja2/helpers.py:8: in 
   @jinja2.contextfunction
E   AttributeError: module 'jinja2' has no attribute 'contextfunction'

В пакете aiohttp-jinja2 поддержка jinja2>3.0 появилась в версии 1.5, а в зависимостях указано jinja2 >=2.10.1

$ poetry show aiohttp-jinja2 --tree
aiohttp-jinja2 1.1.2 jinja2 template renderer for aiohttp.web (http server for asyncio)
├── aiohttp >=3.2.0
│   ├── async-timeout >=3.0,<4.0
│   ├── attrs >=17.3.0
│   ├── chardet >=2.0,<4.0
│   ├── multidict >=4.5,<5.0
│   └── yarl >=1.0,<2.0
│       ├── idna >=2.0
│       ├── multidict >=4.0 (circular dependency aborted here)
│       └── typing-extensions >=3.7.4
└── jinja2 >=2.10.1
   └── markupsafe >=2.0

Естественно, при последней сборке с такими зависимостями прилетела последняя версия jinja2. Что тут скажешь… Обновляем:

$ poetry add "aiohttp-jinja2==1.5"

Updating dependencies
Resolving dependencies... (0.1s)

 SolverProblemError

 Because my-project depends on aiohttp-jinja2 (1.5) which depends on aiohttp (>=3.6.3), aiohttp is required.
 So, because my-project depends on aiohttp (==3.6.1), version solving failed.

 at ~/.poetry/lib/poetry/puzzle/solver.py:241 in _solve
     237│             packages = result.packages
     238│         except OverrideNeeded as e:
     239│             return self.solve_in_compatibility_mode(e.overrides, use_latest=use_latest)
     240│         except SolveFailure as e:
   → 241│             raise SolverProblemError(e)
     242│
     243│         results = dict(
     244│             depth_first_search(
     245│                 PackageNode(self._package, packages), aggregate_package_nodes

Мда. Все как обычно, одна зависимость тянет за собой следующую. Хорошо, обновили aiohttp тоже:

$ poetry add "aiohttp-jinja2==1.5" "aiohttp==3.6.3"

Updating dependencies
Resolving dependencies... (1.0s)

Writing lock file

Package operations: 0 installs, 3 updates, 0 removals

 • Updating yarl (1.7.2 -> 1.5.1)
 • Updating aiohttp (3.6.1 -> 3.6.3)
 • Updating aiohttp-jinja2 (1.1.2 -> 1.5)

После этого тесты прошли.

5. Разделение зависимостей на зависимости проекта и зависимости разработки

Перенос pytest в dev-dependencies:

$ poetry add --dev "pytest==5.1.1" "pytest-cov==3.0.0"

Updating dependencies
Resolving dependencies... (0.8s)

Writing lock file

No dependencies to install or update
$ poetry remove pytest pytest-cov
Updating dependencies
Resolving dependencies... (0.4s)

Writing lock file

No dependencies to install or update

На этом переход на Poetry завершен. Надеюсь, примеры нашей команды вдохновят вас на схожий шаг.

Спасибо за внимание. Вопросы в комментариях приветствуются.

© Habrahabr.ru