PoetryのDependency groupとExtras周りの話

こんにちは。

本記事は株式会社インティメート・マージャーの[Advent Calendar 2023](https://qiita.com/advent-calendar/2023/intimatemerger)の7日目の記事になります。

:::note info
6日目の記事
[Kustomize(v5.2)からpatchesにマルチリソースファイルを指定できる](https://qiita.com/im-dev/items/54abc4ae277121aab3c6)
:::


突然ですがみなさん、Poetry使ってますか？とても便利ですよね！

[アドベントカレンダーの2日目の記事](https://qiita.com/im-dev/items/0cda4059f1a7a537899b)でも出てきた話題ですが、今回はそのPoetryを使っているとよく違いが分からなくなる[`Dependency group`](https://python-poetry.org/docs/master/managing-dependencies#dependency-groups)と[`Extras`](https://python-poetry.org/docs/master/pyproject/#extras)という概念について、環境構築の仕方から備忘録的な意味も込めて軽く記事にまとめたので、Poetryを使ってアプリケーション開発をする際の参考にしていただければと思います！

# 忙しい人のため

- Dependency group
    - パッケージ開発時に使うもの
    - poetryでしかインストール出来ない
    - 例）`pytest`や`ruff (flake8)` などのツール
- Extras
    - エンドユーザーがpipインストール時にも指定出来るようにするためのもの
    - 例）`pip install fastapi[standard]`などの`[]`内の設定

# 前提
- Ubuntu: 22.04.2 LTS
- Docker: 24.0.7, build afdd53b
- VSCode: 1.84.2
- DevContainer: v0.321.0
- Poetry: 1.7.1

# Poetryとは

公式ドキュメントによると、PoetryとはPythonの「**依存管理**」と「**パッケージング**」のためのツールとあります。
> Poetry is a tool for **dependency management** and **packaging** in Python.

https://python-poetry.org/docs/

今回の記事では主に「依存管理」の用途で使うので、パッケージングについてあまり詳しくは触れません。（パッケージングに関しては、少し古い記事になりますが以下の記事が詳しくまとまっていると思います。）

https://blog.recruit.co.jp/rls/2019-12-25-python-packaging-specs/


# 環境構築

今回は[アドベントカレンダーの2日目の記事](https://qiita.com/im-dev/items/0cda4059f1a7a537899b)に近い形で`VSCode`+`DevContainer`+`Poetry`を使って環境を構築していきたいと思います。

実は最低限の`pyproject.toml`ファイルさえあれば`Dockerfile`が無くても開発が出来ます（もちろんDevContainerを使うのでdockerコマンド自体は必要です）。

```toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

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

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

```json:.vscode/settings.json
{
    "python.analysis.extraPaths": [
        "/usr/local/py-utils/venvs/poetry/lib/python3.11/site-packages",
    ],
    "files.eol": "\n",
    "files.insertFinalNewline": true,
    "files.trimFinalNewlines": true,
    "files.trimTrailingWhitespace": true
}
```

```json:.devcontainer/devcontainer.json
{
    "name": "Python 3",
    "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
    "postCreateCommand": "pipx install poetry && poetry install --no-root",
    "waitFor": "postCreateCommand",
    "containerUser": "vscode",
    "containerEnv": {
        "PYTHONUNBUFFERED": "1",
        "PYTHONDONTWRITEBYTECODE": "1",
        "POETRY_VERSION": "1.7.1",
        "POETRY_HOME": "/etc/poetry",
        "POETRY_NO_INTERACTION": "1",
        "POETRY_VIRTUALENVS_CREATE": "false"
    },
    "customizations": {
        "vscode": {
            "extensions": ["ms-python.python"]
        }
    }
}
```


※最新のDevContainerであればmount等の設定を書かなくてもデフォルトでプロジェクトフォルダが`/workspaces/$project`にマウントされます。


# 動かしてみる

上記の設定でDevContainerを立ち上げてアタッチすると以下のような構成でlockファイルが新たに作成された状態で開発環境が構築されます。

```
.
├── .devcontainer
│   └── devcontainer.json
├── .vscode
│   └── setting.json
├── poetry.lock
└── pyproject.toml
```

あとは`app/main.py`などを作成して、`poetry run`コマンドで実行してあげることでpythonのコードを実行することができます。

```shell-session
$ mkdir app
$ echo 'print("Hello, World!")' > app/main.py
$ poetry run python app/main.py
Hello, World!
```


# パッケージインストール

まだこのままだと標準ライブラリしか入っていないため、外部ライブラリを使うことが出来ません。

Poetryではライブラリを追加するために、以下の方法があります。

1. `pyproject.toml`ファイルを直接編集する方法
1. `poetry add`コマンドで追加する方法

1つ目の`pyproject.toml`ファイルを編集する方法では、`poetry lock`コマンド等を実行しないと`poetry.lock`ファイルに反映されないため`poetry add`コマンドをお勧めします。

ここでは実際に`poetry add`コマンドで`pendulum`を追加してみます。

```shell-session
$ poetry add pendulum
Using version ^2.1.2 for pendulum

Updating dependencies
Resolving dependencies... (0.6s)

Package operations: 4 installs, 0 updates, 0 removals

  • Installing six (1.16.0)
  • Installing python-dateutil (2.8.2)
  • Installing pytzdata (2020.1)
  • Installing pendulum (2.1.2)

Writing lock file
$ cat << EOS > app/main.py
> import pendulum
> 
> print(pendulum.Date.today())
> EOS
$ poetry run python app/main.py
2023-12-07
```

今日の日付が出力されていることから正しく追加されていることが分かります。

このとき`pyproject.toml`ファイルには以下のように依存関係が追記されます。


```diff_toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
+ pendulum = "^2.1.2"

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



# Dependency groupsとExtras

ここで先ほどの`poetry add`コマンドのオプションを見てみると、`--group`,`--extras`,`--optional`というオプションが見つかります。

> --group (-G): The group to add the dependency to.
> --extras (-E): Extras to activate for the dependency. (multiple values allowed)
> --optional: Add as an optional dependency.

https://python-poetry.org/docs/cli/#options-4

`optional`は一旦置いておき、`group`と`extras`の違いは一体何なのか見てみましょう。これは公式ドキュメントに書いてあり、

> Dependency groups, other than the implicit main group, must only contain dependencies you need in your development process. Installing them is only possible by using Poetry.
>
> To declare a set of dependencies, which add additional functionality to the project during runtime, use extras instead. Extras can be installed by the end user using pip.

https://python-poetry.org/docs/master/managing-dependencies/#dependency-groups

とのことで、つまり以下ような違いがあります。

- group
    - **poetryからのみ**インストール出来る
    - 開発時に使うもの
- extras
    - poetry、pipインストール時にも指定出来る
    - 主にエンドユーザーが使うもの

言い換えると、groupには開発時にテスト環境のみにインストールしたい`pytest`や`flake8`などを指定し、extrasにはプラグインのようなインストール時に取捨選択させたいものを指定するのが正しい使い方になります。


# poetry addコマンド実行結果

## group

実際にgroupに追加するコマンドを実行してみると以下のようになります。

```shell-session
$ poetry add --group dev pytest
Using version ^7.4.3 for pytest

Updating dependencies
Resolving dependencies... (0.7s)

Package operations: 3 installs, 0 updates, 0 removals

  • Installing iniconfig (2.0.0)
  • Installing pluggy (1.3.0)
  • Installing pytest (7.4.3)

Writing lock file
```

:::note info
最新のPoetryでは`poetry add`コマンドの`--dev (-D)`オプションは非推奨になっています。
:::


pyoroject.tomlファイルには`[tool.poetry.group.<group名>.dependencies]`というテーブルが追加されます。

```diff_toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
pendulum = "^2.1.2"

+ [tool.poetry.group.dev.dependencies]
+ pytest = "^7.4.3"

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

## extras

extrasについてはgroupへの追加とは少し異なり、cryptoというextrasにcryptographyを追加しようとしてgroupのときと同様に以下のコマンドを実行すると、

```shell-session
$ poetry add --extras crypto cryptography
Using version ^41.0.7 for cryptography

Updating dependencies
Resolving dependencies... (0.6s)

No dependencies to install or update

Writing lock file
```

以下のように、`pyproject.toml`ファイルのメインの依存関係`[tool.poetry.dependencies]`に特殊な形で`cryptography[crypto]`をインストールする設定として追加されます。

```diff_toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
pendulum = "^2.1.2"
+ cryptography = {version = "^41.0.7", extras = ["crypto"]}

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"

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

実際に`poetry add cryptography[crypto]`でも同じように追記されます。

ではどうすればextrasに追加出来るかというと、ドキュメントにある通り`optional`というものを使います。

>Poetry supports extras to allow expression of:
>
>- optional dependencies, which enhance a package, but are not required; and
>- clusters of optional dependencies.

https://python-poetry.org/docs/pyproject/#extras

一旦、`poetry remove`コマンドで余計に入れたものを取り除いておきます。

```shell-session
$ poetry remove cryptography
Updating dependencies
Resolving dependencies... (0.2s)

Package operations: 0 installs, 0 updates, 3 removals

  • Removing cffi (1.16.0)
  • Removing cryptography (41.0.7)
  • Removing pycparser (2.21)

Writing lock file
```

正しくextrasに設定するには、まず`poetry add`コマンドでメインの依存関係に`optional`な設定を追加します。

```shell-session
$ poetry add --optional cryptography
Using version ^41.0.7 for cryptography

Updating dependencies
Resolving dependencies... (0.3s)

No dependencies to install or update

Writing lock file
```

```diff_toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
pendulum = "^2.1.2"
+ cryptography = {version = "^41.0.7", optional = true}

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"

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

そしてpyproject.tomlを直接編集して`[tool.poetry.extras]`の設定を追加します。

```diff_toml:pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
pendulum = "^2.1.2"
cryptography = {version = "^41.0.7", optional = true}

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"
+ 
+ [tool.poetry.extras]
+ crypto = ["cryptography"]

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

追加したら`poetry lock`コマンドで`poetry.lock`ファイルも更新しておきます。

```shell-session
$ poetry lock
Updating dependencies
Resolving dependencies... (0.4s)

Writing lock file
```

# 実際にインストールしてみる

最後に`pip install`コマンドでextrasが正しく設定出来ていることを確認しておきます。

まず`poetry build`コマンドでここまでのコードをパッケージングします。

```shell-session
$ poetry build
Building app (0.1.0)
  - Building sdist
fatal: not a git repository (or any parent up to mount point /workspaces)
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).
  - Built app-0.1.0.tar.gz
  - Building wheel
fatal: not a git repository (or any parent up to mount point /workspaces)
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).
  - Built app-0.1.0-py3-none-any.whl
```

成果物は`dist/`ディレクトリに作成されるので、`pip install`コマンドでインストールします。

```shell-session
$ pip install dist/app-0.1.0.tar.gz 
Defaulting to user installation because normal site-packages is not writeable
Processing ./dist/app-0.1.0.tar.gz
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Collecting pendulum<3.0.0,>=2.1.2 (from app==0.1.0)
  Downloading pendulum-2.1.2.tar.gz (81 kB)
...

$ pip list
Package         Version
--------------- -------
app             0.1.0
pendulum        2.1.2
pip             23.2.1
python-dateutil 2.8.2
pytzdata        2020.1
setuptools      68.2.2
six             1.16.0
wheel           0.41.3
```


`pip install dist/app-0.1.0.tar.gz[crypto]`も実行してみると違いが分かります。（`pip uninstall`コマンドではarchiveファイルパスが指定出来ないので、DevContainerにアタッチし直すなどしてお試しください。）

```shell-session
$ pip install dist/app-0.1.0.tar.gz[crypto]
Defaulting to user installation because normal site-packages is not writeable
Processing ./dist/app-0.1.0.tar.gz
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Collecting cryptography<42.0.0,>=41.0.7 (from app==0.1.0)
  Obtaining dependency information for cryptography<42.0.0,>=41.0.7 from https://files.pythonhosted.org/packages/62/bd/69628ab50368b1beb900eb1de5c46f8137169b75b2458affe95f2f470501/cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl.metadata
  Downloading cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl.metadata (5.2 kB)
...

$ pip list
Package         Version
--------------- -------
app             0.1.0
cffi            1.16.0
cryptography    41.0.7
pendulum        2.1.2
pip             23.2.1
pycparser       2.21
python-dateutil 2.8.2
pytzdata        2020.1
setuptools      68.2.2
six             1.16.0
wheel           0.41.3
```

これでextrasが正しく設定されていることが分かりました。


# まとめ

本記事ではPoetryの`Dependency group`と`Extras`周りの違いや扱い方などを実際に触れながら見てきました。

`Dependency group`に関してはテスト環境などを分離するために使えて、`Extras`は機能を分けるために使えるというものでした。

この`Extras`を上手く使うことでただのパッケージングだけでなく、Monorepoでの開発を行ったり、一部機能を共有したサービスの開発などを一つの`pyproject.toml`ファイルで管理したりなどすることも出来たりします。（`Dependency group`にも[optional groups](https://python-poetry.org/docs/managing-dependencies#optional-groups)というものがありこちらを使うのが正しそうですが、サービスが増えていくとどうしても記述が汚くなっていってしまうので悩ましいです。）


# あとがき
ここまで読んでいただいてありがとうございました！

明日はstrapiというCMSについてのお話だそうです！
ぜひお楽しみに！

https://qiita.com/advent-calendar/2023/intimatemerger