Хаос зависимостей в Python

в 8:08, , рубрики: dephell, Go, Moscow Python Conf++, opensource, python, Блог компании Конференции Олега Бунина (Онтико), интервью, Программирование, Разработка веб-сайтов

Знакомы ли вы с историей Python packaging? Ориентируетесь ли в форматах пакетов? Знаете ли, что распутывать клубок зависимостей придется даже когда кажется, что вот оно чудо — zero dependency? Уверен, что знакомы со всем этим не так хорошо, как автор библиотеки DepHell.

Хаос зависимостей в Python - 1

Мне удалось поговорить с Никитой Вороновым, больше известным как Gram или orsinium, и расспросить его о теме будущего доклада и болях плохих решений резолвинга зависимостей. Мы поговорили о DepHell, pip, принципе first match wins, о Гвидо и сообществе, Pipflie, инкрементальном развитии Python, какое решение из Go можно было бы взять в Python, и о будущем экосистемы в плане работы с зависимостями.

— На Moscow Python Conf++ ты будешь рассказывать про зависимости и всё, что рядом с ними. Почему ты выбрал именно такую тему для доклада?

Потому что этот вопрос проходит через весь мой опыт работы с Python. Когда я сделал свой первый пакет, написал первый код, то подумал, как помочь другим людям, чтобы и они смогли его установить, и сделал setup.py. Потом работал в одной компании, в другой, в третьей, задача усложнялась и развивалась. Сначала был просто файлик requirements.txt, потом понял что нужно фиксировать зависимости, появился pip-tools, lock-файл. Позже у нас появился Pipenv, а потом Poetry.

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

— Боль — это всегда интересно. Как ты считаешь, какие проблемы сейчас есть в этой части Python?

В JS есть директория node_modules, и у каждой зависимости ее собственные зависимости сложены внутрь неё. В Python это не так. Например, в одном окружении установлен какой-то пакет, и все пакеты, которые его используют, используют одну и ту же версию этого пакета. Для этого нужно правильно разрешить зависимости — выбрать ту версию данного пакета, которая удовлетворит вообще всем пакетам в этом окружении. Задача довольно нетривиальная: пакеты зависят друг от друга, все переплетено, а резолвить зависимости сложно. Резолверов в Python практически нет. Умный резолвер есть только в Poetry и в DepHell.

Все это сильно осложняется тем, что pypi.org часто не предоставляет информацию о зависимостях пакета, потому что эту информацию должен указать клиент, сервер PyPI не может сам её выяснить. Поэтому, когда PyPI говорит, что у пакета нет зависимостей, верить ему нельзя. Приходится скачивать весь релиз, разархивировать и из setup.py парсить зависимости пакета. Это долгий процесс, поэтому резолвер в Python не может быть быстрым.

Мало того, что резолверов в Python мало, так они еще и медленные by design.

В своём докладе я хочу рассказать, как работает резолвинг в DepHell: как построить граф зависимостей, как этот граф выглядит, почему большинство научных статей лгут о том, как резолвить зависимости и работать с этим графом. Конечно, есть whitepapers, как это всё должно работать. Умные люди написали статьи с алгоритмами, но чаще всего они не работают для Python. Поэтому я расскажу, как работаю с резолвингом зависимостей на практике в DepHell.

— Я часто слышу от программистов, что они используют pip, и у них всё отлично работает. Что они делают не так?

Им везет, они не натыкаются на конфликт зависимостей. Хотя проблема может возникнуть когда ставишь всего два пакета на чистое окружение. Недавно был релиз пакета coverage 5.0, и если просто указать pip install pytest-cov coveralls, то pip пойдет по порядку и для первого пакета выберет последнюю версию coverage, то есть 5.0. В pip работает принцип first match wins, поэтому даже если версия будет не совместима со вторым пакетом, для первого пакета она уже будет зафиксирована. Часто такой подход срабатывает, но не всегда.

К тому же есть вопрос с воспроизводимыми окружениями. Так как pip всегда ставит последнюю версию, то версии в локальном окружении и на продакшн могут отличаться. Для решения этой проблемы принято фиксировать зависимости. А когда зависимости уже зафиксированы, указаны конкретные версии, которые должен ставить pip, тогда pip уже работает отлично. В pip нет резолвера, но он справляется когда кто-то другой зарезолвил для него зависимости, например, DepHell или Poetry.

— Почему эта тема сейчас набирает такую актуальность, как думаешь? Почему раньше вообще ничего не происходило, а сейчас пошло, да еще и в разные стороны?

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

Setup.py вообще невозможно распарсить, его можно только исполнить. Если мы хотим, например, написать сервер на Go, чтобы быстро раздавать пакеты для Python, то не можем просто взять и прочитать setup.py, потому что это исполняемый файл. Соответственно, чтобы его исполнить, нужен Python и полное окружение, а часто еще и чтобы весь проект лежал рядом, и какие-то определенные зависимости были установлены. Кроме всех этих сложностей, исполнять setup.py может быть опасно, потому что какой-то чужой код будет исполняться на твоем компьютере. На самом деле, страшно даже исполнять код из-под текущего пользователя, потому что если он, например, получит мой приватный ключ SSH и куда-то отправит, это будет большая трагедия.

Второй вариант определения зависимостей, который давно существует и с ним все работают — это requirements.txt. Его точно так же практически невозможно распарсить. Pip умеет, но делает это очень-очень сложно: функции, которые вызывают функции, итераторы, все перемешано. Причем pip умеет читать из requirements.txt некоторые свои ключи, например, может быть указан индекс для скачивания. Но так работает не со всеми ключами.

Таким образом, чтобы распарсить requirements.txt, нужно либо использовать pip, либо какое-то стороннее решение. Все сторонние решения — по сути форки и используют какие-то предположения о файле. Не каждый хитрый requirements.txt файл, который может прочитать pip, смогут прочитать эти форки.

Сам pip не предназначен для использования в качестве библиотеки. Это исключительно CLI-инструмент с которым можно работать только из консоли. Весь исходный код pip спрятан за _internal, и разработчики прямо говорят: «Не используйте это!». И в каждом релизе ломают обратную совместимость. Они честно не гарантируют совместимость и могут поменять все что угодно в любой момент. И так и происходит — каждый раз, когда приходит новый релиз с pip, я узнаю об этому по сломанному CI в DepHell.

— Как же в других языках? Там так же плохо, или где-то все эти проблемы решены?

Недавно Гвидо ван Россуму вручали премию Дейкстры. Я был на его лекциях и спросил его про зависимости в Python. Гвидо сказал, что зависимости во всех языках это хаос, он старается туда не лезть и доверяет сообществу решить эту проблему.

Таким образом, в Python работу с зависимостями постепенно организует сообщество. Появляются новые решения. Когда-то в Python был встроен Distutils, потом люди поняли, что у него много проблем, надстроили Setuptools. Позже для установки пакетов был разработан easy_Install, но у него тоже были проблемы. Чтобы их решить создали pip. Теперь и у pip много проблем. Его исходники постоянно меняются, нет никакой архитектуры, нет вообще никакого интерфейса.

Сообщество пытается что-то придумать. Например, долго шло обсуждение issue под названием requirements 2.0 о том, как сделать requirements понятными как людям (здесь версия, здесь маркеры), так и программно с других языков.

Сделали Pipflie, но так как pip очень запутан, то в него не смогли добавить поддержку Pipflie.

Разработчики хотят это сделать, конечно. Скорее всего, когда-нибудь смогут, но пока pip не может поддерживать Pipflie. Поэтому сделали pipenv, чтобы работать с Pipfile и виртуальным окружением, еще какие-то обертки с окружением. Но в pipenv тоже все намешано и перепутано.

Что касается других языков, мне нравится, как работа с зависимостями реализована в Go. Раньше в нём не было версионирования, был go get, в котором указываешь, из какого репозитория какой пакет скачать. С точки зрения новичка это удобно: просто пишешь go get и пакет уже в системе. А когда начинаешь работать с Python, тут же обрушивается целый ворох всего: версии какие-то, PyPI, pip, requirements.txt, setup.py, сейчас еще Pipflie, Poetry, __pymodules__ и т.д.

Так как Python развивается инкрементально и с помощью сообщества, то в экосистеме накапливается legacy. В Go был просто go get, но опять-таки проявилась проблема, что зависимости нужно фиксировать, чтобы, в частности, окружение было воспроизводимым.

Воспроизводимое окружение можно создать с помощью docker-контейнера со всеми установленными зависимостями. Но иногда нужно обновить отдельные зависимости. Например, мы можем быть не готовы обновлять всё, потому что в проекте недостаточно тестов, чтобы доказать, что после обновления всё по-прежнему работает. Но определенную зависимость может быть нужно обновить, потому что, допустим, в ней нашли уязвимость. Для этого лучше иметь не docker-образ, а файлик, в котором написано: «Поставь определенную версию определённого пакета».

В Go такого не было, и появилась вендоризация: просто берутся все зависимости и складываются в одну директорию. Это грязное решение, похожее на node_modules, которое в Go какое-то время реализовывалось с помощью сторонних решений. В Python такой подход тоже используется, например, в pip есть директория vendor. Когда ставишь pip, зависимости не ставятся, и можно подумать, что всё очень здорово и зависимостей нет вообще, а на самом деле они все внутри vendor.

Примерно год назад в Go появилcя go.mod (Go Modules). Это новый встроенный инструмент, но go get тоже поддерживается. В проекте содержится два файла:

  • в одном описываются зависимости, с которыми проект работает напрямую;
  • другой — это lock-файл, в котором описаны абсолютно все зависимости и их конкретные версии.

Это классное централизованное решение.

Что важно, они настаивают на том, что определенные вещи должны выглядеть определенным образом. Например, в Go версия должна быть semantic-version.

В Python тоже спецификация о том, как должна выглядеть версия. Для этого есть PEP 440. Но, во-первых, спецификация очень сложная: там есть не только три компонента версии (цифры), но еще пререлизы, пострелизы, эпохи (когда меняется способ версионирования). Во-вторых, PEP 440 был принят не сразу, к нему тоже пришли инкрементально, поэтому поддерживается legacy-версия, а это значит, в качестве версии может использоваться что угодно — любая строка типа «Привет, мир!».

— Ты сказал, что сообщество развивает язык инкрементально, поэтому появляется огромное количество решений. Но почему бы не избавиться от всего этого хлама? Почему бы не выкинуть Distutils, отказаться от старого и ненужного, что никто не использует, а вместо этого активно внедрять новые практики и инструменты?

Поддерживать это всё имеет смысл, чтобы все-таки можно было ставить и старые пакеты. Не получается настаивать что нужно делать именно так, а не иначе, потому что решение принимается силами сообщества. Никто из Core Python-разработчиков не приходит и не говорит: «Всё, все теперь делаем так, и никаких гвоздей».

В Go сразу есть все необходимое, чтобы работать с зависимостями. В Python нужно все доустановить снаружи, причем еще нужно понять, что именно. Чаще всего достаточно pip, но сейчас появляются другие варианты.

На сайте с официальными рекомендациями по работе с пакетами от Python Packaging Authority — группы, которая делает pip, pipenv, PyPI, написано использовать pipenv. С pipenv отдельная история. Во-первых, в нём плохой резолвинг. Во-вторых, релизов не было очень давно и сообщество уже ждет, когда создатели честно признают, что этот проект мертв. Третья проблема с pipenv в том, что он подходит только для проектов, но не для пакетов: в pipenv можно указать зависимости проекта, но нельзя указать его название, версию, и, соответственно, собрать в пакет для загрузки на PyPI. Получается, что следовать рекомендациям Python Packaging Authority и использовать pipenv, всё равно недостаточно, чтобы со всем разобраться.

Poetry старается быть революционным. Он принципиально не генерирует файл setup.py, который пригодился бы для обратной совместимости, потому что Poetry хочет быть новым и единственным форматом для всего. Он умеет собирать пакеты, и у него есть lock-файл, который нужен для проектов. И, тем не менее, в Poetry много странного, много привычных фич не поддерживается.

— Как считаешь, какое будущее у экосистемы в плане работы с зависимостями? Твой прогноз.

Всё более или менее налаживается. Например, я видел вакансию в pip, и разработчику, который приведет его в порядок, обещают много денег. Возможно, pip станет более универсальным решением. Но нужно, чтобы кто-то взялся за это всерьез: пришел и сказал, что все делаем именно так, теперь следуем какому-то более строгому PEP, и будет настаивать на его соблюдении (потому что PEP это просто рекомендации, которым вообще-то никто не обязан следовать).

Например, у нас была такая история: в lock-файле была залочена определенная версия PyYAML. В один день на CI тесты проходят, мы деплоим на продакшн, а там всё падает, потому что версия PyYAML не найдена. Дело было в том, что залоченную версию удалили с pypi.org. Все повозмущались, обновили lock-файл, как-то пережили, но осадочек остался.

Не так давно появился PEP 592, он уже принят и поддерживается в pip, в котором появились yanked-релизы. Yank означает, что релиз еще не совсем удален с pypi.org — он скрыт. То есть если указать, что нужен, например, PyYAML версии больше 3.0, то pip пропустит yanked-релизы, и установит последний доступный. Но если в lock-файле указана конкретная версия, и эта версия yank, то pip все равно её установит. Таким образом, lock-файлы и деплой не сломаются, но, по возможности, старая версия использоваться не будет.

Вторая интересная вещь — PEP для __pymodules__. Это легковесные виртуальные окружения: открываешь директорию с проектом, пишешь pip install PyYAML, и PyYAML устанавливается не глобально, а в директорию __pymodules__. Когда Python будет запускаться в этой директории, то импортирует PyYAML не глобально, а из этой директории.

Я называю это виртуальными окружениями на минималках, потому что здесь меньше уровень изоляции. Например, нет доступа к бинарным файлам. Когда активировано виртуальное окружение с установленным pytest, его можно использовать из консоли: просто написать pytest и выполнить что-нибудь. С __pymodules__ будут доступны для импорта пакеты, но не бинарники, так как они на самом деле не будут установлены.

Этот PEP разрабатывается, чтобы упростить работу новичкам. Чтобы им не нужно было разбираться со всеми тонкостями виртуальных окружений, а просто через pip install устанавливать все, что нужно в __pymodules__.

— Хорошо, будущее в твоем прогнозе видится светлее, чем сейчас.

Да, но как я сказал, если никто не придет и не скажет, что переделываем так и стараемся выкинуть legacy, то проблемы останутся. Сейчас мы накапливаем и накапливаем инструменты, и полностью избавиться от каких-то из них в ближайшем будущем будет невозможно.

— Как ты думаешь, почему никто из разработчиков не умеет обновлять зависимости Практически нигде — ни в компаниях, ни в опенсорс — не построен процесс работы с security-релизами, в принципе с новыми минорными или мажорными релизами. Где ты видишь здесь проблемы?

Как минимум, когда ты хочешь обновить зависимости, то все зависимости просто обновлять страшно, потому что не факт, что даже если прошли тесты, всё будет работать. Например, часто такая ситуация возникает с Celery, потому что полностью протестировать Celery в тестах просто не получается. Можно что-то замокать, что-то упростить, но то, что воркеры запускаются, проверить не получается.

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

Еще стоит упомянуть интересный аспект, должны ли лежать тесты в пакетах в Python? Когда скачиваешь c pypi.org пакет, должны ли там быть тесты? По идее, должны, и даже есть механизм для их запуска: в setup.py можно указать, как запускать тесты, какие у них зависимости.

Но, во-первых, многие люди не умеют их запускать и не запускают тесты, которые лежат в зависимостях. Поэтому часто они не нужны. Во-вторых, зачастую эти тесты имеют очень тяжёлые fixtures, и поэтому включать тесты в пакет — значит сделать пакет в 6-10 раз больше.

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

Кажется, этот аспект в основном упускают из виду. Но в некоторых других языках, в частности, в Go считается хорошей практикой, обновляя какой-то пакет в окружении, сразу же запускать для него тесты, чтобы убедиться, что в данном окружении этот пакет работает нормально.

— Почему в Python, на твой взгляд, не популярны инструменты для автоматического семантического версионирования?

Я думаю, одна из проблем в том, что версия может быть описана во многих местах. Чаще всего их три: формат описания метаданных проекта (pypi.org, poety, setup.py и т.д.), внутри самого проекта и в документации. Обновить версию в трех местах не то чтобы очень сложно, но легко забыть.

У DepHell есть команда для обновления версий. Причем DepHell настроен работать со всеми форматами зависимостей, соответственно, может работать и с версиями в любом формате. Это может быть semantic version, compatible version и т.д. В документации описано, какие бывают способы версионирования, и это довольно любопытно.

Интересно этот вопрос решен во Flit. Flit — это минималистичный инструмент с собственным форматом для описания метаданных пакета, для разборки пакета и его загрузки. Там всего четыре команды: init, build, publish и install. То есть создать каркас проекта, собрать его, загрузить на PyPI и установить зависимости пакета — все очень минималистично. В Flit версия хранится только в одном месте, то есть версию он читает изнутри самого проекта. И описание проекта он читает из docstring самого модуля. Не знаю почему так не делают другие, но это классное решение.

DepHell тоже умеет делать как Flit и даже больше. Он не только может прочитать версию и description, но и авторов, и название пакета, и вообще всё на свете самостоятельно найти.

Моя цель, чтобы управление зависимостями было максимально простым.

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

Спасибо Никите за интервью, с ним мы увидимся на Moscow Python Conf++ 27 марта. Кроме DepHell на конференции обсудим backend, web, сбор и обработку данных, AI/ML, тестирование, DevOps, базы данных, IoT, infosec и лучшие практики создания хорошего кода. Присоединяйтесь, бронируйте билеты, увидимся на Moscow Python Conf++.

Автор: Никита Соболев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js