imageПочему важно уметь создавать пакеты Python?
• Пакеты легко устанавливаются (pip install demo).
• Пакеты упрощают разработку (Команда pip install -e устанавливает ваш пакет и следит за тем, чтобы он сам обновлялся в ходе всего процесса разработки).
• Пакеты легко запускать и тестировать (from demo.main import say_hello, а затем тестируем функцию).
• Пакеты легко версионировать, при этом вы не рискуете нарушить работу кода, зависящего от этого пакета (pip install demo==1.0.3).

В чем отличия между библиотекой, пакетом и модулем:
• Модуль: это .py-файл, в котором содержатся функции, образующие некоторое единство
• Пакет: это коллекция модулей, которую можно распространять
• Библиотека: это пакет, не учитывающий контекста
image
Заключать код Python в пакеты достаточно просто. Для этого вам понадобится всего один скрипт setup.py, позволяющий упаковать код сразу в нескольких форматах для распространения.

1. Подготовка к упаковке


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

➜ tree -a -L 2
.
├── .venv
│   └── ...
├── Pipfile
├── Pipfile.lock
├── src
│   └── demo
│       └── main.py
└── tests
    └── demo
        └── ...

9 directories, 3 files

Создаем файл setup.py в корневом каталоге. В этом файле мы будем описывать, каким именно образом хотим упаковать наш код. Для начала напишем следующее:

"""Скрипт Setup.py для проекта по упаковке."""

from setuptools import setup, find_packages

import json
import os

def read_pipenv_dependencies(fname):
    """Получаем из Pipfile.lock зависимости по умолчанию."""
    filepath = os.path.join(os.path.dirname(__file__), fname)
    with open(filepath) as lockfile:
        lockjson = json.load(lockfile)
        return [dependency for dependency in lockjson.get('default')]

if __name__ == '__main__':
    setup(
        name='demo',
        version=os.getenv('PACKAGE_VERSION', '0.0.dev0'),
        package_dir={'': 'src'},
        packages=find_packages('src', include=[
            'demo*'
        ]),
        description='A demo package.',
        install_requires=[
              *read_pipenv_dependencies('Pipfile.lock'),
        ]
    )

Теперь можно вызвать этот скрипт, который позволяет упаковать ваш код несколькими способами:

python setup.py develop # ничего не генерировать, просто установить локально 
python setup.py bdist_egg # сгенерировать дистрибутив «яйцо», не включать зависимости
python setup.py bdist_wheel # сгенерировать версионированное «колесо», включить зависимости
python setup.py sdist --formats=zip,gztar,bztar,ztar,tar # исходный код

Давайте запустим первый вариант из списка. Если все пройдет успешно, то вы сможете импортировать ваш код следующим образом:

from demo.main import say_hello

Примечание:
Если выдается сообщение “No module named demo…", то нужно добавить пустой файл __init__.py во все каталоги, из которых вы хотите импортировать. В нашем примере сюда включается только каталог demo. Подробнее об этих файлах __init__.py можно почитать здесь.

Теперь, когда мы в состоянии установить проект, давайте внимательнее рассмотрим аргументы, передаваемые функции setuptools.setup:
1. name: имя вашей функции
2. version: результатом каждого изменения, вносимого в код, должна быть новая версия пакета; в противном случае возможна ситуация, в которой разработчики устанавливают прежнюю версию пакета, которая вдруг станет функционировать не так как раньше и сломает код.
3. packages: список путей ко всем вашим файлам python
4. install_requires: список имен и версий пакетов (точно как в файле requirements.txt)
Как видите, я написал простую функцию read_pipenv_dependencies для считывания из Pipfile.lock зависимостей, не попадающих в разработку (non-dev). В данном случае я не хочу задавать зависимости вручную. Также я воспользуюсь os.getenv для считывания переменной окружения и определения версии пакета – пожалуй, это хорошие сюжеты для новых постов.

2. Документация


Точно как при считывании Pipfile.lock для указания зависимостей, я могу прочитать и файл README.md, чтобы отобразить полезную документацию как long_description. Подробнее о том, как это делается, рассказано в packaging.python.org.

Кроме того, можно создать полноценную веб-страницу с документацией при помощи readthedocs и sphinx. Создаем каталог для вашей документации:

mkdir docs

Устанавливаем sphinx:

pipenv install -d sphinx

Командой quickstart генерируем каталог с исходниками для вашей документации:

sphinx-quickstart

Теперь можно приступать к наполнению файла docs/index.rst самой документацией. Подробнее о том, как автоматизировать этот процесс, рассказано на сайте sphinx-doc.org.

3. Линтинг и тестирование


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

pipenv install -d mypy autopep8 \
  flake8 pytest bandit pydocstyle

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

4. Makefile


По мере того, как мы быстро вводим все новые команды, нужные для упаковки нашего конкретного проекта, распространенные команды полезно записывать. В большинстве инструментов для автоматизации сборки (например, в Gradle или npm) эта возможность предоставляется по умолчанию.

Make – это инструмент, организующий компиляцию кода. Традиционно используется в c-ориентированных проектах. Но с его помощью можно выполнять и любые другие команды.
По умолчанию при использовании make выполняется первая команда из списка. Таким образом, в следующем примере будет выполнена make help, а на экран будет выведено содержимое Makefile.

Если сделать make test, то сначала будет выполнена make dev, поскольку в файле Makefile она указана как зависимость:

help:
	@echo "Tasks in \033[1;32mdemo\033[0m:"
	@cat Makefile

lint:
	mypy src --ignore-missing-imports
	flake8 src --ignore=$(shell cat .flakeignore)

dev:
	pip install -e .

test: dev
	pytest --doctest-modules --junitxml=junit/test-results.xml
	bandit -r src -f xml -o junit/security.xml || true

build: clean
	pip install wheel
	python setup.py bdist_wheel

clean:
	@rm -rf .pytest_cache/ .mypy_cache/ junit/ build/ dist/
	@find . -not -path './.venv*' -path '*/__pycache__*' -delete
	@find . -not -path './.venv*' -path '*/*.egg-info*' -delete

Теперь, как видите, новым разработчикам достаточно легко внести свой вклад в проект. Распространенные команды у них как на ладони и, например, сразу видно, как собрать колесо: make build.

5. Установка колеса


Если запустить make build, программа использует файл setup.py, чтобы создать дистрибутив колеса. Файл .whl находится в каталоге dist/, в имени файла должно присутствовать 0.0.dev0. Теперь можно указать переменную окружения, чтобы изменить версию колеса:

export PACKAGE_VERSION='1.0.0'
make build
ls dist

Имея колесо, можно создать где-нибудь на ПК новый каталог, скопировать в него колесо, а затем установить его при помощи:

mkdir test-whl && cd test-whl
pipenv shell
pip install *.whl

Вывод списка установленных файлов:

pip list

6. Включить конфигурационные файлы


Добавить данные в пакет можно и другим способом, включив в скрипт setup.py следующие строки:

Примечание:
Dозможно, не будет работать на распределенных системах (например, в Databricks).

if __name__ == '__main__':
    setup(
        data_files=[
            ('data', ['data/my-config.json'])
        ]
    )

После этого можно будет прочитать файл при помощи следующей функции:

def get_cfg_file(filename: str, foldername: str) -> dict:
    """получить конфигурационный файл

    при помощи свойства 'data_files' из скрипта setup.py.
    """
    if not isinstance(foldername, str):
        raise ValueError('Foldername must be string.')
    if foldername[0] == '/':
        raise ValueError('Foldername must not start with \'/\'')
    if not isinstance(filename, str):
        raise ValueError('Filename must be string.')

    # Сначала попытается считать файл из того места, в котором он установлен 
    # Это касается только установок .whl 
    # В противном случае файл будет считываться напрямую
    try:
        filepath = os.path.join(sys.prefix, foldername, filename)
        with open(filepath) as f:
            return json.load(f)
    except FileNotFoundError:
        filepath = os.path.join(foldername, filename)
        with open(filepath) as f:
            return json.load(f)

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

7. DevOps


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

Здесь рассмотрим для примера Azure DevOps, где на git tags, а также в ветке master будет инициироваться процесс, представленный ниже.

Посмотрите код, и ниже мы обсудим его различные стадии и задачи:

resources:
  - repo: self

trigger:
  - master
  - refs/tags/v*

variables:
  python.version: "3.7"
  project: demo
  feed: demo
  major_minor: $[format('{0:yy}.{0:MM}', pipeline.startTime)]
  counter_unique_key: $[format('{0}.demo', variables.major_minor)]
  patch: $[counter(variables.counter_unique_key, 0)]
  fallback_tag: $(major_minor).dev$(patch)

stages:
  - stage: Test
    jobs:
      - job: Test
        displayName: Test
        steps:
          - task: UsePythonVersion@0
            displayName: "Use Python $(python.version)"
            inputs:
              versionSpec: "$(python.version)"

          - script: pip install pipenv && pipenv install -d --system --deploy --ignore-pipfile
            displayName: "Install dependencies"

          - script: pip install typed_ast && make lint
            displayName: Lint

          - script: pip install pathlib2 && make test
            displayName: Test

          - task: PublishTestResults@2
            displayName: "Publish Test Results junit/*"
            condition: always()
            inputs:
              testResultsFiles: "junit/*"
              testRunTitle: "Python $(python.version)"

  - stage: Build
    dependsOn: Test
    jobs:
      - job: Build
        displayName: Build
        steps:
          - task: UsePythonVersion@0
            displayName: "Use Python $(python.version)"
            inputs:
              versionSpec: "$(python.version)"

          - script: "pip install wheel twine"
            displayName: "Wheel and Twine"

          - script: |
              # Получить версию по тегу git (v1.0.0) -> (1.0.0)
              git_tag=`git describe --abbrev=0 --tags | cut -d'v' -f 2`
              echo "##vso[task.setvariable variable=git_tag]$git_tag"
            displayName: Set GIT_TAG variable if tag is pushed
            condition: contains(variables['Build.SourceBranch'], 'refs/tags/v')

          - script: |
              # Получить переменные, совместно используемые разными заданиями
              GIT_TAG=$(git_tag)
              FALLBACK_TAG=$(fallback_tag)
              echo GIT TAG: $GIT_TAG, FALLBACK_TAG: $FALLBACK_TAG

              # Экспортировать переменную, так, чтобы python мог ее принять
              export PACKAGE_VERSION=${GIT_TAG:-${FALLBACK_TAG:-default}}
              echo Version used in setup.py: $PACKAGE_VERSION

              # Использовать PACKAGE_VERSION в setup()
              python setup.py bdist_wheel
            displayName: Build

          - task: CopyFiles@2
            displayName: Copy dist files
            inputs:
              sourceFolder: dist/
              contents: demo*.whl
              targetFolder: $(Build.ArtifactStagingDirectory)
              flattenFolders: true

          - task: PublishBuildArtifacts@1
            displayName: PublishArtifact
            inputs:
              pathtoPublish: $(Build.ArtifactStagingDirectory)
              ArtifactName: demo.whl

          - task: TwineAuthenticate@1
            inputs:
              artifactFeed: $(project)/$(feed)

          - script: |
              twine upload -r $(feed) --config-file $(PYPIRC_PATH) dist/*
            displayName: PublishFeed

На этапе Test мы устанавливаем проект в контейнер конвейера, не создавая виртуального окружения. Затем выполняем команды make lint и make test, точно как вы сделали бы это на вашей машине.

На этапе Build попытаемся извлечь версию пакета, ориентируясь на тег git, а еще соберем резервную версию пакета. Выполним команду python setup.py bdist_wheel для сборки колеса, учитывая, что у нас уже установлена переменная окружения, соответствующая версии пакета. Наконец, мы публикуем артефакт в числе других артефактов Azure DevOps и (по желанию) можем выложить в ленту.

Чтобы опубликовать пакет в ленте, вам потребуется файл .pypirc, а затем вы можете скопировать содержимое ленты, созданной в Azure DevOps. Выглядеть файл будет примерно так:

[distutils]
Index-servers =
  stefanschenk

[stefanschenk]
Repository = https://pkgs.dev.azure.com/stefanschenk/_packaging/stefanschenk/pypi/upload

О том, как устанавливать пакеты из частной ленты, рассказано здесь.

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


  1. Andy_U
    18.11.2022 17:12
    +6

    Ну. вообще-то нынче рекомендуется (по возможности) использовать pyproject.toml. Cм. https://peps.python.org/pep-0518/ и прочие из этой серии. По возможности, это если не нужно, например, имплементировать команду setup.py clean, или собрать С/C++ extension.


    1. freeg0r
      18.11.2022 17:31
      +1

      ну конечно, еще в 2016 году. pyproject.toml и setup.cfg


    1. EvilsInterrupt
      20.11.2022 10:47

      А можете подсказать, чем именно отличается pyprojects.toml, который создает популярный инструмент poetry от того pyprojects.toml, который упоминается в PEP-0518 ? У меня в пет-проектах прижился poetry и мне он нравится. Поэтому хотелось бы лучше понимать возможные риски


      1. Andy_U
        20.11.2022 14:42
        +1

        Я, прошу прощения, poetry не использую. Хватает стандартных средств.


      1. masai
        20.11.2022 14:44
        +1

        Тут не совсем корректно говорить об отличии. Poetry просто следует этому стандарту.

        PEP 518 очень простой. Он лишь задаёт список зависимостей для системы сборки (обычно там одна зависимость — сама система) и указывает, что все свои настройки система должна хранить в tool.ИМЯ. В случае Poetry это tool.poetry.

        Технически никто не мешает использовать одновременно две системы сборки (например, Poetry и Flit) используя один и тот же project.py. Правда смысла в этом не очень много. Разве что на время переходного периода.

        Ну и инструменты вроде black, bandit, pytest могут хоанить свои настройки там же.

        А вот в чём Poetry стандартам не следует, так это в PEP 621. Этот стандарт говорит, что все метаданные должны храниться в таблице project включая зависимости. Poetry же для зависимостей использует собственную таблицу tool.poetry.dependencies. Более того, он использует нестандартный спецификатор версий (символ ^), не предусмотренный PEP 508.

        В Poetry 2.0 скорее всего появится поддержка PEP 621, но ценой отказа от нестандартных элементов.

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


  1. funca
    18.11.2022 18:53
    -2

    5. Установка колеса

    Если запустить make build, программа использует файл setup.py, чтобы создать дистрибутив колеса.

    Зачем телеге питону пятое колесо?)


  1. masai
    18.11.2022 19:34
    +2

    Вместо setup.py я бы посоветовал использовать Poetry или Hatch. (Я долгое время пользовался Poetry, но в последнее время больше склоняюсь к Hatch.) Это, кстати, заодно решило бы проблему установки зависимостей, нужных для разработки, и позволило бы избавиться от pipenv (а в случае Hatch, и от Tox).

    А вместо Makefile удобно пользоваться Taskfile.

    Для тех, кому интересен современный Python, есть неплохая серия статей Hypermodern Python.


    1. funca
      19.11.2022 23:38
      +2

      Hatch и taskfile развивают одиночки. У второго автор уже неоднократно жаловался на нехватку времени кодить и кажется полгода как отказался от идеи работать над v4. Я бы рекомендовал, если б их взяли под свое крыло какой-нибудь фонд или огранизация. А так есть большие риски через пару лет остаться с легаси один на один. Хотя для пет проекта, где можно переписать весь ci/cd за вечер на чем угодно, наверное, забавно.


      1. masai
        20.11.2022 00:27
        +1

        Насчёт Taskfile согласен, но даже в нынешнем виде он поприятнее Makefile. Я часто писал мейкфайлы, и после них Taskfile — глоток свежего воздуха.

        А Hatch с недавних пор под крылом PyPA. Развивается тоже очень активно несмотря на то, что появился меньше года назад. Poetry уже 4 года, и он тоже на первых порах разрабатывался парой этузиастов.

        У Poetry довольно много неочевидных проблем. Например, работа с приватными репозиториями — это боль, так как Poetry не использует pip. А у нас на работе настроена ротация ключей и поддержки чего-то кроме pip ждать не приходится. В итоге пришлось писать свой плагин. Альтернатива — дампить requirements.txt и ставить из него. Ситуация ещё усложнялась тем, что в CI/CD доступ к репозиторию был через прокси, то есть URL другой. И lock-файлы превращались в тыкву, так как нет простого способа подменить адреса.

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

        Справедливости ради, в Hatch вообще нет lock-файлов пока их поддержка не появится в pip. Но он внутри использует pip, то есть более практичен.

        И у Poetry, и у Hatch проблемы со сборкой бинарных пакетов. У первого есть build.py, но это нестабильная фича, у второго пока нет внятного решения. Но с другой стороны, свет на них клином не сошёлся. В таких случаях я просто пишу setup.py.

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

        Хотя для пет проекта, где можно переписать весь ci/cd за вечер на чем угодно, наверное, забавно.

        Вряд ли эта статья ориентирована на людей, которые делают сложный CI/CD в большом проекте. :) Так-то у нас на работе одна команда вообще Bazel для питона использует.


    1. degt1y
      20.11.2022 00:19
      -2

      import random print(random.randint(-1000000, 1000000)) print(random.randint(-1000000, 1000000)) print(random.randint(-1000000, 1000000)) print(random.randint(-1000000, 1000000)) print(random.randint(-1000000, 1000000))'


  1. onegreyonewhite
    20.11.2022 15:39
    +1

    Не надо использовать Makefile в экосистеме Python, если только вы не пишете чистые C-API модули без зависимостей и т.д. И даже тогда не надо этого делать...

    Есть прекрасный инструмент tox, который не только покрывает возможности Makefile, но и значительно обогащает его питонячьими плюшками (например, под каждую зависимость можно загнать свой набор зависимостей, собирать разом под несколько версий питона, сделать aio/all-in-one окружения, с учётом особенностей).

    Makefile на фоне tox'а выглядит бедно, но это не так. Просто это гибкий инструмент для совершенно иной экосистемы.


    1. masai
      20.11.2022 18:21
      +1

      tox и make — это всё же разные вещи, созданные с разными целями. tox (и аналогичный инструмент nox) — это автоматизация сборки и тестирования проектов на Python в разных окружениях, а make — это инструмент общего назначения для выполнения зависящих друг от друга задач.

      tox не перекрывает возможности make. (Тут, конечно, можно поспорить, что такое перекрывает, так как bash-скрипты перекрывают возможности их обоих.) Более того, в документации tox об этом даже написано и предлагается использовать pyinvoke для функционала, аналогичного make. Собственно, tox не то, чтоб очень универсален, иначе бы не появился nox.

      Это не значит, что tox — плохой инструмент. Отличный! (Хотя я в какой-то момент перешёл на nox, а потом и на возможности Hatch по созданию матрицы версий.) И действительно, он позволяет делать в пару строк то, что с помощью make сделать сложно.

      Просто глаз зацепился за сравнение инструментов из разных категорий.