Всем привет! Я Станислав Бушуев, Software Engineer в Semrush. В этой статье я расскажу о том, как мы столкнулись с проблемой периодического обновления Python-зависимостей, тестировали решение с полной их фиксацией, ошибались, и в итоге перешли на Poetry.
Предыстория
Чаще всего любой 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 <module>
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 <module>
from my-project.app import make_app
app.py:3: in <module>
import aiohttp_jinja2
/usr/local/lib/python3.7/site-packages/aiohttp_jinja2/__init__.py:8: in <module>
from .helpers import GLOBAL_HELPERS
/usr/local/lib/python3.7/site-packages/aiohttp_jinja2/helpers.py:8: in <module>
@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 завершен. Надеюсь, примеры нашей команды вдохновят вас на схожий шаг.
Спасибо за внимание. Вопросы в комментариях приветствуются.
Комментарии (18)
barabashek
23.08.2022 12:43+2По поводу создание venv.
Не пробовали делать multistage сборку, как раз с созданием venv и переносом его в финальный образ?
Насколько помню, таким образом можно выгадать 100-200мб в размере образа итогового.sbushuev Автор
23.08.2022 12:58+1Хорошая мысль, спасибо! На самом деле у нас сейчас немного иначе сделано: мы ставим зависимости на первых шагах, а потом копируем исходники в образ.
Можно попробовать копирование, не уверен про 100-200 мб, но точно образ должен быть легче.
alexxxnf
23.08.2022 18:38По моему опыту просто переносить готовый venv опасно: могут потеряться бинарные зависимости или поехать пути.
Для меня лучше работает такая схема:
в базовом образе с помощью Poetry генерируем requirements.txt;
там же из requirements.txt собираем wheel-пакеты;
монтируем requirements.txt и wheel-пакеты в финальный образ (понадобится DOCKER_BUILDKIT);
устанавливаем зависимости из wheel-пакетов обычным pip;
PROFIT.
Таким образом в финальном образе получим правильно установленные пакеты, но самих wheel-файлов и даже Poetry там не будет.
sbushuev Автор
24.08.2022 12:21переносить готовый venv опасно: могут потеряться бинарные зависимости или поехать пути
согласен, но есть надежда на unit-тесты)
генерируем requirements.txt
изначально хотелось от этого отказаться
lebedec
23.08.2022 13:19+5Да, это распространенные аргументы в пользу poetry. Хотелось бы их парировать, не холивара ради, но, чтобы обнадёжить тех, кто не хочет или не может отказаться от pip.
В нашем случае мы не вносили никаких изменений в сам проект, проблему нужно было искать в окружении. Но мы же зафиксировали все версии пакетов?
Мало кто знает, но штатными средствами легко проверить совместимость Python зависимостей через pip check. Как минимум на уровне грамотного CI это не проблема.
Ну и в-третьих, версия пакета может не измениться, а содержимое поменяться кардинально.
Если это реальный вопрос безопасности, эффективнее использовать собственный PyPI индекс. Так вы сможете надёжно проконтролировать содержимое пакетов централизовано, один раз, на все случаи жизни. Следить за тем, чтобы все использовали poetry с проверкой хешей сложнее.
Инструмент предоставляет удобное управление зависимостями
Poetry подразумевает добавление зависимостей только через CLI. Это вызывает трудности если вы пользуетесь виртуализацией, работаете с Python через Docker, например. Со стандартным pip вы можете добавлять зависимости как угодно: руками или средствами вашей IDE.
К тому же автоматизация проще с pip. Например, poetry не умел раньше отдавать версию пакета, а парсить его внутренний формат было гораздо сложнее чем requirements.txt
Разделение зависимостей на зависимости проекта и зависимости разработки
Иногда скоупы зависимостей могут быть сильно сложнее чем зависимости “проекта” и “разработки”. Например, в наукоёмких проектах у вас могут быть тяжелые, но редко обновляемые ML зависимости, отдельно системные, отдельно проектные, и т.д.
Со стандартным pip это легко разбить на отдельные файлы и контролировать время установки. Например, можно использовать кэширование на уровне Docker чтобы каждый раз не переустанавливать вендорные зависимости. Так не получится сделать с poetry, потому что у него один файл конфигурации.
alexxxnf
23.08.2022 18:42+1Вроде бы Poetry уже поддерживает любое количество групп, а не только dev: https://python-poetry.org/docs/master/managing-dependencies/#dependency-groups
sbushuev Автор
24.08.2022 12:22Спасибо за коммент! В текущем проекте такой необходимости нет, но на будущее точно будет полезно
masai
24.08.2022 17:08+1Мало кто знает, но штатными средствами легко проверить совместимость Python зависимостей через pip check.
Это не решает все проблемы, из-за которых нужна фиксация зависимостей. Например, в минорной версии может поменяться поведение так как не все следуют SemVer.
В случае pip аналогом скорее была бы команда
pip freeze
.Если это реальный вопрос безопасности, эффективнее использовать собственный PyPI индекс. Так вы сможете надёжно проконтролировать содержимое пакетов централизовано, один раз, на все случаи жизни. Следить за тем, чтобы все использовали poetry с проверкой хешей сложнее.
Про свой индекс совет хороший. У нас на работе поднят jFrog Artifactory и это решает много проблем. Но и проблем с тем, чтобы все использовали poetry тоже нет. В CI/CD просто сборка упадёт, если хеши не сойдутся.
Poetry подразумевает добавление зависимостей только через CLI. Это вызывает трудности если вы пользуетесь виртуализацией, работаете с Python через Docker, например. Со стандартным pip вы можете добавлять зависимости как угодно: руками или средствами вашей IDE.
А какие проблемы с Docker? Вроде хорошо всё с контейнеризацией.
Строго говоря, добавлять зависимости можно и правкой pyproject.toml, просто надо будет отдельной командой зафиксировать зависимости. Но и с pip для этого тоже нужна будет команда, так что тут дополнительной сложности нет.
Например, poetry не умел раньше отдавать версию пакета, а парсить его внутренний формат было гораздо сложнее чем requirements.txt
Наоборот, проще же. Это просто TOML, его поддержка скоро в стандартной библиотеке Python будет. Можно загрузить с помощью одной из библиотек и работать как со словарём.
У requirements.txt не такой простой формат. Посмотрите пример из спецификации. Да, есть библиотека, но тогда чем это лучше TOML?
lebedec
24.08.2022 19:50А какие проблемы с Docker? Вроде хорошо всё с контейнеризацией.
Есть такой подход, вместо venv или локального Python, в процессе разработки использовать "удаленный" Python. В контексте нашего обсуждения это интерпретатор из Docker образа.
Все зависимости питонячие и системные, включая poetry CLI тогда находятся внутри образа. Значит, чтобы вам обновить pyproject.toml придется вольюмы создавать до хостовых файлов, как-то настраивать команды в своём IDE или через Docker Compose.
Естественно, такая же проблема будет с pip freeze. Я скорее хотел подчеркнуть, что poetry это "CLI-driven" решение, в отличии от pip. Ну или во всяком случае с pip проще работать как с "files-driven" решением.
Сюда же относится проблема с группами зависимостей. Как подсказал alexxxnf выше в poetry можно объявить группы зависимостей, но все они находятся в одном файле pyproject.toml. Это значит что даже если вы распишете установку групп разными шагами в Docker образе, кэш срабатывать не будет. Docker будет определять изменение pyproject.toml и проходить все шаги.
С pip можно разные группы зависимостей оформить в отдельные файлы и юзать кэш Docker по полной. Это вообще не проблема для обычного CRUD API проекта, но если вы работаете с чем-то системным или ML то проблема возникает (пересчёт poetry лок файла или полная установка зависимостей может занимать десятки минут).
Наоборот, проще же. Это просто TOML, его поддержка скоро в стандартной библиотеке Python будет. Можно загрузить с помощью одной из библиотек и работать как со словарём.
Представьте, у вас какой-то CI или дебаг на продакшене, bash и необходимость узнать версию зависимости. В случае pip это будет обычный греп. В случае poetry может быть описание пакета и его версии в несколько строк, это позволяет TOML формат, обработать это чистым bash будет несколько сложнее.
Опять же, поддержка poetry в PyCharm появилась год, может два назад. Наверняка в VSCode она тоже есть. Но в каких-то менее распространённых тулах её нет, зато есть pip.
Если вы решаете задачу обработки в комфортных для себя условиях, конечно, вопрос снимается.
Вообще я не отговариваю использовать Poetry, просто в статье приведены популярные аргументы, основанные на недостатках pip, которые на самом деле неактуальны или возникают из-за неполноты опыта автора.
Мне, например, нравится Poetry тем, что он пытается юзать и развивать предложенный стандарт проектного файла pyproject.toml, с этим в Python исторически всё не очень, в отличии от других языков.
masai
24.08.2022 23:35Есть такой подход, вместо venv или локального Python, в процессе разработки использовать "удаленный" Python. В контексте нашего обсуждения это интерпретатор из Docker образа.
Вы имеете в виду ситуацию, когда запущен контейнер и к нему примонтирована директория с кодом? Так я сам так делаю. Прямо в IDE (VS Code в моем случае) можно открыть терминал и выполнить произвольные команды или автоматизировать нужные действия. Вы, впрочем, это упомянули. Можно вообще настроить обновление lock-файла в pre-commit hooks. Если кто-то забудет обновить lock-файл, это сразу задетектит CI/CD и просто не даст вмерджить PR.
если вы работаете с чем-то системным или ML то проблема возникает (пересчёт poetry лок файла или полная установка зависимостей может занимать десятки минут).
Да, есть такая проблема. Правда, тут не poetry виноват, а тот факт, что для получения списка зависимостей нужно скачать пакет, а, скажем, PyTorch может весить пару гигабайт.
С группами зависимостей проблема тоже есть, тут я согласен. Решать можно по-разному с разной степенью костыльности:
Явно устанавливать самый тяжёлый пакет, который точно будет в зависимостях, а потом poetry install. Он не будет качать заново то, что уже установлено. Проблема — версия этого пакета будет в двух местах.
В скрипте, который собирает образ, экспортировать группы зависимостей в разные txt-файлы и этим свести задачу к предыдущей.
Может показаться, что pip тут выигрывает, но poetry делает много другого, что pip не делает.
обработать это чистым bash будет несколько слсложнее.
Если есть только bash и grep, то, конечно сложнее. Впрочем, с bash вообще много проблем из-за его очень ограниченных возможностей. Средствами утилит, которые гарантированно предоставлены POSIX, и менее экзотический JSON не разобрать.
Но с другой стороны, в инструментах для CI/CD обычно можно указывать свои образы, а в этом случае ничто не мешает установить любой из конвертеров TOML в JSON и с помощью jq делать запросы произвольной сложности прямо в bash.
Вообще я не отговариваю использовать Poetry, просто в статье приведены популярные аргументы, основанные на недостатках pip, которые на самом деле неактуальны или возникают из-за неполноты опыта автора.
Да, тут, конечно, всё от задач зависит. Я с очень многими вариантами работал. От pip и pip-tools до Bazel (у нас на работе он используется в некоторых проектах даже на чистом Python). И вот лично для себя остановился на Poetry. У него безусловно есть недостатки. Например, невозможность подменить индекс при установке пакетов (приходится экспортировать в requirements.txt) или долгое отсутствие поддержки однофайловых индексов пакетов. Но остальные возможности перекрывают недостатки.
densol92
26.08.2022 00:43Зато как poetry шикарно и быстро резолвит зависимости. У меня была жуткая связка из зависимостей aws библиотек awscli, boto3 и botocore между собой и другими библиотеками проекта. Версии всего были намертво прописаны в requirements чтобы сборка не занимала часы.
Час расплаты пришёл когда решили переезжать на новую мажорную версию библиотеки которая зависела от более свежих версий boto3.
Ручной подбор не дал результата. Pip env поскрипел пару часов и умер. Poetry справился за 20 минут и с тех пор мои зависимости гладкие и шелковистые. Конечно есть куча странных решений но конкретную задачу управления зависимости poetry решает хорошо
PashaWNN
Кажется, в пункте 2 опечатка в первой команде. Должно быть
$ poetry export -f requirements.txt --output requirements.pip
?sbushuev Автор
Это же имя файла для вывода, можно задать любое
PashaWNN
Да, но в статье дальше оно используется в следующей команде.
sbushuev Автор
В рекомендациях подразумевается, что твои изначальные зависимости лежат в файле requirements.pip (смотри пункт 1). Далее когда ты уже перенес зависимости в Poetry я предлагаю их перепроверить, т.е. выгрузить командой из пункта 2. После этого получится два файла:
requirements.pip - изначальные зависимости
requirements.txt - то, что находится в Poetry
Другое имя файла в пункте 2 нужно как раз для того, чтобы не перетереть изначальный файл.
masai
-f — это формат вывода, просто он выглядит как имя файла, а --output — это уже сам файл.