О чем статья: при разработке проектов, и, особенно, распределенных приложений, возникает необходимость использования некоторых частей приложения в качестве отдельных модулей. Например скомпилированные классы для gRPC, модули для работы с БД, и многое другое, могут применяться в неизменном виде в кодовой базе десятка микросервисов. Оставив за скобками копипасту, как «хорошую» плохую практику. Можно рассмотреть git submodules, однако, такое решение не очень удобно тем, что, во‑первых, нужно предоставлять разработчикам доступ к конкретным репозиториям с кодовой базой, во‑вторых, нужно понимать, какой коммит надо забрать себе, и в‑третьих установка зависимостей для кода, включенного в проект как субмодуль, остается на совести разработчика. Менеджеры пакетов (pip, или, лучше, poetry), умеют разрешать зависимости из коробки, без лишних действий, и, в целом, использование менеджера пакетов значительно проще, чем работа с субмодулем. В статье рассмотрим, как организовать реестр пакетов в GitLab, а также различные подводные камни, поджидающие на пути к удобной работе с ним.

Для кого: статья будет полезна разработчикам, столкнувшимся с необходимостью организации приватного реестра пакетов, в качестве руководства по организации такого реестра в GitLab.

О реестрах пакетов в целом, и почему именно GitLab

Шаги по организации частного реестра пакетов отражены в документации. По сути, в описанном варианте размещения, реестр представляет собой директорию, раздаваемую по HTTP, и содержащую .tar.gz и\или .whl пакеты, распределенные по папкам, соответствующим именам пакетов. Автоматическая загрузка пакетов в репозиторий в таком варианте является задачей "на подумать". В случае с GitLab становится возможным организовать работу таким образом, что кодовая база пакетов и реестр пакетов хранятся в одном пространстве, что дает следующие возможности: 

  • использовать каждый репозиторий как реестр пакетов;

  • создать общий реестр, содержащий все пакеты.

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

Подготовка реестра

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

Список загруженных пакетов и дополнительную информацию о них можно посмотреть, перейдя в пункт Packages & Registries > Package Registry.

К слову, GitLab может быть реестром не только PyPI-пакетов, но и npm, NuGet, и т.д.

Учетные данные

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

  • Personal access token - аутентифицирует владельца токена в соответствии с правами на репозиторий. Данный вид токенов лучше использовать для выдачи доступа конкретных пользователей к реестру. В этом случае пользователю необходимо назначить права на чтение из реестра, а access token генерируется ими самостоятельно.

  • Project access token - подойдет в случае, если необходимо дать доступ к реестру пакетов большому числу пользователей. Если использовать этот вид токена для передачи пользователям - возникает опасность “утечки” токена. Да и банально отобрать права у конкретного пользователя (в случае использования одного токена) не получится. Либо все, либо ничего.

  • Group access token - позволяет получать доступ ко всем пакетам в группе проектов.

В моем случае используется следующая схема:

  • Для загрузки пакетов в реестр используется Project access token с правами write_registry. Данный токен используется исключительно при автоматической сборке пакетов. Прописан в Variables для репозиториев, содержащих исходный код модулей;

  • Пользователи получают доступ с применением Personal access token.

Создание пакета

Создадим новый проект, в котором будет храниться код модуля. Для сборки пакета будем использовать poetry.

Для начала установим poetry.

pip install poetry

Далее, если репозиторий для модуля уже создан, и там есть код, можно воспользоваться командой:

poetry init

В противном случае можно создать новый проект (вместе со структурой каталогов) командой:

poetry new

И в том и в другом случае в проекте появится файл pyproject.toml, который используется poetry для хранения информации о проекте (наименование, описание, зависимости).

Если у вас уже есть файл requirements.txt и вы не хотите руками переносить все, что наросло за время разработки, можно перенести зависимости в poetry одной командой:

cat requirements.txt | xargs poetry add

Теперь давайте заглянем в pyproject.toml:

[tool.poetry]
name = "hello-world-package"
version = "0.1.3"
description = ""
authors = ["Dmitry <dmitry8912@gmail.com>"]
readme = "README.md"
packages = [{include = "hello_world_package"}]

[tool.poetry.dependencies]
python = "^3.10"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Здесь нужно обратить внимание на первую секцию файла - [tool.poetry], в ней описывается общая информация: имя пакета, версия, описание, и поддиректории, в которых непосредственно находится код модуля (за это отвечает строчка packages = [{include = "hello_world_package"}]). Если файлы с кодом находятся в корне проекта - перенесите их в отдельную поддиректорию, и укажите ее имя в строчке packages.

Также создайте README.md с детальным описанием пакета.

Этих настроек достаточно для построения файлов пакета. 

Команда poetry build создаст в корне проекта папку dist, в которой будут размещены .tar.gz и .whl файлы пакетов (в соответствии с версиями).

Версионирование

Пакет, как правило, не живет по принципу “выстрелил и забыл”. В процессе разработки в исходный код могут вноситься изменения, и возникает необходимость отличать одну версию от другой. Версия, хранимая в pyproject.toml, является текущей, и при пересборке пакета без обновления версии содержимое папки dist (для конкретной версии, разумеется) будет просто перезаписано. Поэтому, перед построением новой версии, нужно изменить номер в pyproject.toml. Руками оно, понятное дело, веселее, но предлагаю отойти от “обновления версий курильщика”. В poetry доступна команда poetry version с опциональными аргументами (patch, major, minor…) инкрементирующими версию в соответствии с правилом для каждого типа инкремента.

Например, для выпуска новой версии, содержащей незначительные багфиксы, и не ломающей совместимость, можно использовать poetry version patch.

При выходе новой версии, содержащей значительные изменения - poetry version major.

Забегая вперед замечу, что GitLab не примет пакет, если такая версия уже есть в его реестре, поэтому “поднимать” версию перед загрузкой пакета - необходимо.

Загрузка пакетов в реестр

Загрузка пакета в реестр возможна с помощью команды poetry publish, однако на своем опыте я неизменно получал 422-ю ошибку при попытке отправить изменения. Поэтому расскажу о том, как загрузить пакет в реестр, используя пакет twine. Сначала, конечно же, установим нужный пакет:

pip install twine

Нам понадобится создать файл .pypirc для хранения информации о реестре (адрес, токен для доступа):

[distutils]
index-servers =
    gitlab

[gitlab]
repository = https://<gitlab_address>/api/v4/projects/<project_id>/packages/pypi
username = <token_name>
password = <token>

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

[root@srv-dev-core0 hello_world_package]$ python -m twine upload --repository gitlab dist/* --config-file ./.pypirc

Uploading distributions to http://gitlab.local/api/v4/projects/29/packages/pypi
Uploading hello_world_package-0.0.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.0/4.0 kB • 00:00 • ?
Uploading hello_world_package-0.0.1.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.8/3.8 kB • 00:00 • ?

Информация о пакете должна отобразиться на странице реестра пакетов.

Установка из реестра

Собственно, устанавливать пакет я также рекомендую через poetry, поскольку pip не хранит информацию о том, откуда пакет был взят, и pip install -r requirements.txt будет искать пакет не там, где надо. Либо придется делить зависимости на части, хранить в отдельный requirements.txt, и указывать pip`у откуда что нужно брать. Poetry же хранит информацию об источнике пакета в pyproject.toml. При развертывании проекта остается только добавить данные для аутентификации.

В общем случае, порядок действий следующий:

  1. Добавляем новый адрес реестра в список (gitlab в данном случае - просто имя)

poetry source add gitlab https://<gitlab_address>/api/v4/projects/<project_id>/packages/pypi/simple
  1. Добавляем данные для аутентификации

poetry config http-basic.gitlab <token_name> <token>
  1. Указываем poetry откуда поставить пакет

poetry add --source gitlab <your_package_name>

В pyproject.toml, в информации о пакете, появятся данные об удаленном реестре, и связи пакета с конкретным реестром.

your_package = {version = "^0.1.2", source = "gitlab"}

...

[[tool.poetry.source]]
name = "gitlab"
url = "https://<gitlab_address>/api/v4/projects/<project_id>/packages/pypi/simple"
default = false
secondary = false

Теперь, для установки пакета другими разработчиками им нужно сообщить:

  • Адрес реестра;

  • Имя пакета;

  • Данные для доступа (либо указать на необходимость самостоятельной генерации).

CI\CD

Напоследок немного автоматизации. Гораздо удобнее, когда в каждом репозитории с кодом модулей настроена автоматическая сборка и загрузка в реестр прямо в CI\CD-пайплайне.

Для этого можно использовать следующий скрипт:

#!/bin/sh

# Скрипт оперирует следующими env-переменными
# TOKEN_VALUE - токен для доступа к реестру на запись (привилегия write_registry)
# BUMP_VERSION - patch|minor|major|..., или как именно инкриментировать версию

echo 'Preparing .pypirc'
# В .pypirc для token вписано значение {REGISTRY_TOKEN}, заменяемое через sed "на лету"
sed -i "s/{REGISTRY_TOKEN}/${TOKEN_VALUE}/g" .pypirc
echo "Package building stage"

# Скрипт version.py получает последнюю загруженную версию через GitLab API
VERSION=$(python version.py)
CURRENT_VERSION=$(cat pyproject.toml | grep -n version | cut -d : -f 1)
# Полученная версия прописывается в pyproject.toml
sed -i "${CURRENT_VERSION}s/.*/version = \"${VERSION}\"/" pyproject.toml

# Версия пакета поднимается
echo "Bumping version with rule ${BUMP_VERSION}"
poetry version $BUMP_VERSION

# Пакет собирается и загружается в реестр
echo "Building package"
poetry build

echo "Uploading package to registry"
python -m twine upload --repository gitlab dist/* --config-file ./.pypirc\

Данный скрипт работает внутри docker-контейнера, запускаемого раннером. В контейнер в качестве env-переменных передаются данные для аутентификации. В файл .pypirc заранее внесена информация об адресе реестра, и остается только заменить токен.

Но возникает проблема с обновлением версии. Два разработчика могут установить одну и ту же версию в pyproject.toml, и, в результате, какие-то изменения могут потеряться, так как GitLab не примет новый пакет с уже существующей в реестре версией. Выход из этой ситуации видится только один - определять последнюю актуальную версию в реестре, записывать ее в pyproject.toml, поднимать версию с помощью poetry version [patch|minor|major|...].

Для получения пакетов из реестра можно воспользоваться скриптом:

import json
import os
import requests

if __name__ == '__main__':
    token = os.getenv('TOKEN_VALUE')
    package_name = os.getenv('CI_PROJECT_NAME')
    result = None

    try:
        result = requests.get(f'https://<gitlab_address>/api/v4/projects/<proejct_id>/packages?sort=desc&package_name={package_name}',
                              headers={'Authorization': f"Bearer {token}"})
    except Exception as e:
        print(e)
        exit(1)

    if result.status_code == 200:
        data = json.loads(result.content)
        print(data[0]['version'])
        exit(0)

Скрипт, используя GitLab API, получит список пакетов в json, отсортированный по убыванию (самый последний загруженный пакет будет первым). Остается только подменить версию в pyproject.toml, поднять ее, и загрузить пакет в реестр.

Заключение

Управление PyPI-пакетами в GitLab достаточно простое. Буквально за пару часов можно заменить копипастнутые\загруженные через git submodules части приложения на более изящное решение, которое, к тому же, обновляет версии при каждом пуше в репозиторий. 

В конце статьи хочу порекомендовать бесплатный урок от OTUS по теме: "Design patterns". На уроке будут рассмотрены основные категории и наиболее известные паттерны. Подробности по ссылке.

Комментарии (4)


  1. box0547
    00.00.0000 00:00

    Спасибо, не знал, что на gitlab можно делать свой реестр для NuGet пакетов.


    1. Dmitry89 Автор
      00.00.0000 00:00

      Да, там комбайн, разве что пакеты из пятерочки не хранит)


  1. funca
    00.00.0000 00:00
    +1

    Выход из этой ситуации видится только один - определять последнюю актуальную версию в реестре, записывать ее в pyproject.toml, поднимать версию с помощью poetry version

    Можно задавать версии тегами git и вести отдельные истории для разных бранчей и MR. В первом приближении отдаётся на откуп https://pypi.org/project/poetry-dynamic-versioning/ или https://gitversion.net/docs/usage/ci.

    Некоторые неудобства доставляет тот факт, что формат версий в python PEP 440 и SemVer немного отличаются. Ну и не забывать, что из одной версии исходников можно собрать разные билды. Значит у них должны быть разные версии (подмешав например $CI_JOB_ID или datetime).


    1. Dmitry89 Автор
      00.00.0000 00:00

      Спасибо, с тэгами решение точно получше будет)