Pull to refresh

А как вам такой вариант управления зависимостями в Python?

Reading time 7 min
Views 8.9K

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


Проблема


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


В рамках моей работы чаще всего я использую Python в двух целях: это либо анализ данных и машинное обучение используя блокноты Jupyter, либо небольшие Python скрипты, которые каким-то образом подготавливают данные. Хочу отметить, что я не занимаюсь созданием пакетов и их публикацией в Pypi (поэтому не акцентирую внимание на этом процессе в этой статье).


Очень часто мне требуется запускать скрипты или блокноты, которые были созданы довольно давно. Поэтому у меня возникла необходимость каким-то образом фиксировать версии зависимостей и запускать скрипты и блокноты в виртуальном окружении. С другой стороны, иногда в новой версии какой-либо библиотеки может появиться функциональность, которая позволит улучшить результаты старого блокнота или скрипта. Например, в scikit-learn (библиотека для машинного обучения) могут добавить имплементацию нового алгоритма, который отлично подходит для моего случая.


Очень часто при разработке какого-то скрипта, я также вынужден устанавливать какие-то дополнительные зависимости, которые требуются только для разработки. Например, так как я использую VSCode для разработки, то он требует, чтобы в виртуальном окружении был установлен pylint. Другие люди, с которыми я сотрудничаю, могут использовать другие инструменты для разработки, которым эта зависимость совершенно не требуется.


Исходя из этих предпосылок, у меня сложились следующие требования для управления зависимостями:


  1. У меня должна быть возможность разделять центральные зависимости (необходимые для запуска скрипта) и зависимости необходимые только для разработки.
  2. Я хочу указывать центральные зависимости без привязки к конкретной версии библиотеки. Таким образом, я смогу легко обновлять зависимости. С другой стороны, у меня должна быть возможность зафиксировать версии библиотек, чтобы полностью повторить мое виртуальное окружение.
  3. Один и тот же подход должен одинаково хорошо работать и для скриптов и для блокнотов.

Канонический подход по управлению зависимостями, когда создается отдельное виртуальное окружение для проекта и когда версии всех зависимостей фиксируются (используя pip freeze > requirements.txt) в requirements.txt, не работает исходя из моих требований. Во-первых, он не позволяет разделить центральные и зависимости необходимые только для разработки. Во-вторых, в этом случае requirements.txt содержит все зависимости и их подзависимости, поэтому искать версию определенной библиотеки становится проблематичным.


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


Решение


Прежде всего, хочу отметить, что я в качестве операционной системы использую (k)Ubuntu 18.04. Если у вас другая операционная система, возможно потребуется адаптация моего подхода. Если вы адаптируете этот подход под другую операционку, то было бы здорово, если бы вы поделились им в комментариях.


Для решение этой проблемы, я применяю следующий подход. Во-первых, я разделил центральные и зависимости для разработки. При установке я записываю имена зависимостей в два разных файла: requirements.txt содержит центральные зависимости, в то время как requirements-dev.txt хранит зависимости необходимые для разработки. Чтобы автоматизировать этот процесс, я написал bash функцию pip-install.


pip-install
function pip-install() {
    packages=()
    dev_dependency=0
    requirements_file=
    while [ $# -gt 0 ]
    do
        case "$1" in
            -h|--help)
                echo "Usage: pip-install [-d|--dev] [-r|--req <file>] <package1> <package2> ..." 1>&2
                echo ""
                echo "This function installs provided Python packages using pip"
                echo "and adds this dependency to the file listing requirements."
                echo "The name of the package is added to the file without"
                echo "concreate version only if it is absent there." 
                echo ""
                echo "-h|--help        - prints this message and exits."
                echo "-d|--dev         - if the dependency is development."
                echo "-r|--req <file>  - in which file write the dependency."
                echo "    If the filename is not provided by default the function"
                echo "    writes this information to requirements.txt or to"
                echo "    requirements-dev.txt if -d parameter is provided."
                echo "<package1> <package2> ..."
                return 0
                ;;
            -d|--dev)
                shift
                dev_dependency=1
                ;;
            -r|--req)
                shift
                requirements_file="$1"
                echo "Requirements file specified: $requirements_file"
                shift
                ;;
            *)
                packages+=( "$1" )
                echo "$1"
                shift
                ;;
        esac
    done

    if ! [ -x "$(command -v pip)" ]; then
        echo "Cannot find pip tool. Aborting!"
        exit 1
    fi

    echo "Requirements file: $requirements_file"
    echo "Development dependencies: $dev_dependency"
    echo "Packages: ${packages[@]}"

    if [ -z "$requirements_file" ]; then
        if [ $dev_dependency -eq 0 ]; then
            requirements_file="requirements.txt"
        else
            requirements_file="requirements-dev.txt"
        fi
    fi

    for p in "${packages[@]}"
    do
        echo "Installing package: $p"
        pip install $p
        if [ $? -eq 0 ]; then
            echo "Package installed successfully"
            echo "$p" >> $requirements_file
            if [ $(grep -Ec "^$p([~=!<>]|$)" "$requirements_file") -eq 0 ]; then
                echo "$p" >> $requirements_file
            else
                echo "Package $p is already in $requirements_file"
            fi
        else
            echo "Cannot install package: $p"
        fi
    done
}

Чаще всего, я вызываю эту функцию следующим образом: pip-install scikit-learn или pip-install --dev pylint. В первом случае, эта функция устанавливает пакет scikit-learn используя pip и записывает имя пакета (которое вы написали) в файл requirements.txt. Во втором случае, имя пакета записывается в файл requirements-dev.txt. Стоит отметить, что так как эта функция используется при разработке, то я не указываю версию библиотеки, которую надо установить (в этом случае устанавливается последняя доступная версия). В последующем, в этом файле можно добавить ограничения на версию библиотеки вручную.


Когда мне требуется зафиксировать версии библиотек, я вызываю функцию pip-freeze. pip-freeze выбирает все зависимости из файла requirements.txt, фиксирует их версии и записывает результат в requirements.lock файл. После этого, мы можете легко восстановить свое виртуальное окружение на новой машине используя команду pip install -r requirements.lock. Команда pip-freeze --dev проделывает тот же фокус, только с зависимостями из requirements-dev.txt.


pip-freeze
function pip-freeze() {
    dump_all=0
    dev_dependency=0
    requirements_file=
    while [ $# -gt 0 ]
    do
        case "$1" in
            -h|--help)
                echo "Usage: pip-freeze [-a|--all] [-d|--dev] [-r|--req <file>]" 1>&2
                echo ""
                echo "This function freezes only the top-level dependencies listed"
                echo "in the <file> and writes the results to the <file>.lock file."
                echo "Later, the data from this file can be used to install all"
                echo "top-level dependencies." 
                echo ""
                echo "-h|--help        - prints this message and exits."
                echo "-d|--dev         - if the dependency is development."
                echo "-a|--all         - if we should freeze all dependencies"
                echo "  (not only top-level)."
                echo "-r|--req <file>  - what file to use to look for the list of"
                echo "    top-level dependencies. The results will be written to"
                echo "    the \"<file>.lock\" file." 
                echo "    If the <file> is not provided by default the function"
                echo "    uses \"requirements.txt\" or \"requirements-dev.txt\""
                echo "    if -d parameter is provided and writes the results to the"
                echo "    \"requirements.txt.lock\" or \"requirements-dev.txt.lock\""
                echo "    correspondingly."
                return 0
                ;;
            -d|--dev)
                shift
                echo "Development dependency"
                dev_dependency=1
                ;;
            -a|--all)
                shift
                dump_all=1 
                ;;
            -r|--req)
                shift
                requirements_file="$1"
                echo "Requirements file specified: $requirements_file"
                shift
                ;;
        esac
    done

    if ! [ -x "$(command -v pip)" ]; then
        echo "Cannot find pip tool. Aborting!"
        exit 1
    fi

    if [ -z "$requirements_file" ]; then
        if [ $dev_dependency -eq 0 ]; then
            requirements_file="requirements.txt"
        else
            requirements_file="requirements-dev.txt"
        fi
    fi

    lock_file="$requirements_file.lock"
    if [ $dump_all -eq 1 ] 
    then
        pip freeze > "$lock_file"
        if [ $? -eq 0 ]; then
            echo "Locked all dependencies to: $lock_file"
        else
            echo "Error happened while locking all dependencies"
        fi
    else
        cmd_output=$(pip freeze -r "$requirements_file")
        if [ $? -eq 0 ]; then
            > "$lock_file"
            while IFS= read -r line; do
                if [ "$line" = "## The following requirements were added by pip freeze:" ]; then
                    break
                fi
                echo "$line" >> "$lock_file"
            done <<< "$cmd_output"
        fi
    fi
}

Таким образом, в репозитории вместе с проектом я храню 4 файла: requirements.txt, requirements-dev.txt, requirements.lock и requirements-dev.lock.


Исходный код этих двух функций хранится у меня в файле (Всегда проверяйте исходники!!!). Вы можете скопировать его в директорию ~/.bash/, и чтобы сделать эти функции доступными у себя, добавить следующие строчки к себе в .bashrc:


if [ -f ~/.bash/pip_functions.sh ]; then
    source ~/.bash/pip_functions.sh
fi

Заключение


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


P.S.

Если вам интересно узнать о том, как я настраиваю свое Python окружение, то я написал очень длинную статью (на английском) по этой теме у себя в блоге.

Tags:
Hubs:
+4
Comments 8
Comments Comments 8

Articles