pyproject.toml入門 〜 Cython を添えて 〜

2024/03/10に公開
1

Pythonを本格的に使ってみようと思い立った初心者が、最初のプロジェクト構成でつまずいた記録です。最近ナウいといわれているpyproject.tomlを使ってみます。

プロジェクト概要

C++のコードとそれをサポートするためのPythonのコードを書くプロジェクトです。C++のコードはCythonでラップしてPythonから呼び出すことにします。

C++のコードはCMakeでビルドして、Pythonの環境はvenvで構築します。

参考サイト:

https://packaging.python.org/ja/latest/guides/writing-pyproject-toml/

https://packaging.python.org/ja/latest/guides/modernize-setup-py-project/

最終的なフォルダ構成

最初に全体像として今回実験した結果から、次のようなフォルダ構成でいこうと思っています。

最終形
📂proj-root/
├── 📄README.md            # プロジェクト全体の説明
├── 📄LICENSE.txt          # プロジェクトのライセンス
├── 📄CMakeLists.txt       # 全体のビルド設定
├── 📄.clangd              # clangdの設定
├── 📄.clang-format        # フォーマッター設定
├── 📄.envrc               # プロジェクト共有の環境変数設定
├── 📄.gitignore           # gitの無視設定
├── 📂pyproj/              # Pythonプロジェクト
│   ├── 📄README.md        # Pythonプロジェクトの説明
│   ├── 📄pyproject.toml   # Pythonプロジェクトの設定ファイル
│   ├── 📄setup.py         # Cythonの設定ファイル
│   ├── 📁.venv/           # Python venv環境
│   └── 📂pyproj/          # Pythonプロジェクトのモジュールフォルダ
│       ├── 📄__init__.py  # モジュール識別(ロガーを設定する予定)
│       ├── 📄__main__.py  # モジュール実行時のエントリーポイント
│       ├── 📄cli.py       # コマンド実行のメインファイル
│       └── 📄wrapper.pyx  # CythonでC++コードをラップ
├── 📂c++proj/             # C++プロジェクト
│   ├── 📄README.md        # C++プロジェクトの説明
│   ├── 📄CMakeLists.txt   # C++プロジェクトのビルド設定
│   └── 📁src/             # C++ソースコード
├── 📁build/               # CMakeビルドフォルダ
├── 📁bin/                 # プロジェクト実行ファイル置き場
└── 📁lib/                 # プロジェクトライブラリ置き場

proj-root/pyproj/フォルダで次のコマンドを実行すれば開発用のインストールができます。

ターミナル
pip install -e .
pyproj --help

Windows環境ならsetup.pyで書いたruntime_library_dirsが効くのでlibフォルダから動的にライブラリをロードしてくれます。
しかしLinux環境ではLD_LIBRARY_PATHをMac環境ではDYLD_LIBRARY_PATHを設定しておく必要があるので注意が必要です。

主要ファイルの内容を見る。
proj-root/pyproj/pyproject.toml
[build-system]
requires = [
    "Cython",
   "setuptools"
]
build-backend = "setuptools.build_meta"

[project]
name = "pyproj"
version = "0.0.1"
description = "pyproject.tomlのテスト"
requires-python = ">=3.11"
authors = [{ name = "ArkBig" }]
readme = "README.md"

dependencies = [
    "Cython",
   "scikit-learn",
]

[project.scripts]
pyproj = "pyproj.cli:main"

[tool.setuptools]
py-modules = ["pyproj"]
proj-root/pyproj/setup.py
from Cython.Build import cythonize
from setuptools import Extension, setup

ext_modules = [
    Extension(
        "pyproj.wrapper",
        ["pyproj/wrapper.pyx"],
        include_dirs=["../c++proj/src", "../include"],
        library_dirs=["../lib"],
        libraries=["c++proj-shared"],
        language="c++",
        extra_compile_args=["-std=c++20", "-O3", "-Wall", "-Wextra", "-Werror"],
        runtime_library_dirs=["../lib"], # これはWindowsしか効かないので注意
    ),
]

setup(
    ext_modules=cythonize(ext_modules),
)
proj-root/pyproj/pyproj/cli.py
import argparse
# Cythonでラップした関数を使う
from pyproj.wrapper import print_hello_world
def main() -> None:
    parser = argparse.ArgumentParser(description="PyProj CLI")
    parser.parse_args()
    print_hello_world()
proj-root/pyproj/wrapper.pyx
import cython

# C++コードで定義だけされているのを、Pythonから使えるようにする
cdef extern int hello_world()

def print_hello_world() -> int:
    return hello_world()

# C++ヘッダーで宣言されているのを、Pythonから使えるようにする
cdef extern from "input_features.hpp":
    float get_input_features()

def pred_get_input_features() -> float:
    return get_input_features()

以降は順を追ってこの構成になるようにそれぞれ説明していきます。

pyproject.tomlとの出会い

私はこれまでPythonはスクリプトとしてまれに使っていました。基本的にはshellやbatで済ませますが、ちょっと面倒なときはPythonを使っていました。そのため、プロジェクト構成についてはほとんど考えたことがありません。

最近になってC++コードと連携するためCythonを使ったのですが、そのときにsetup.pyを書く必要があったので使ってみました。

今回は本格的にAI学習するにあたりきちんとプロジェクト構成を考え調べてみました。
そしてpyproject.tomlにたどり着きました。

  1. setup.py はスクリプトなので自由度が高すぎて初心者には難しい
  2. setup.cfg でシンプルな設定のみ書けるようにしよう
  3. pyproject.toml そのほかの設定もまとめて書けるようにしよう

こんな流れで遷移してきたのかなと認識しています。

最初にpyproject.tomlを使う小さいプロジェクトを作ってみる

まずはpyproject.tomlでビルドできる環境を作ってみます。
Pythonプロジェクトの名前はlearnとします。

最小構成
📂learn/
├── 📁.venv            # 仮想環境
├── 📂mylearn          # プロジェクトモジュールフォルダ
│   ├── 📄__init__.py  # モジュール識別
│   ├── 📄__main__.py  # モジュール実行時のエントリーポイント
│   └── 📄cli.py       # コマンド実行のメインファイル
└── 📄pyproject.toml   # パッケージ設定ファイル

仮想環境の作成

最初はvenvを使ってPythonの仮想環境を作ります。

ターミナル
cd learn
python3 -m venv .venv
. .venv/bin/activate

venvを実行したPythonプログラムへのシンボリックリンクが.venv/bin/pythonに作成されます。.venv/bin/activateを実行すると、.venv/bin/にパスがとおりpythonpipで実行できます。
またpip installでパッケージをインストールすると、.venv/lib/にインストールされるので、ほかのプロジェクトと分離したPython環境が構築できます。

プロジェクトモジュールフォルダ

プロジェクトモジュールの名前はmylearnとします。(通常1モジュールなら、プロジェクト名と同じにしますが、説明するときに識別できるように変えています。)

Pythonコードはフォルダを作成してその中に配置すると便利らしいです。
python -m mylearnで実行できたり、import mylearn.hogeでインポートできます。

mylearn/__init__.py
# `__init__.py` は存在することに意味があるので空ファイルでOK
mylearn/__main__.py
# `__main__.py` は`cli.py`に用意した`main`関数を実行するだけ
if __name__ == "__main__":
    from mylearn.cli import main
    main()
mylearn/cli.py
# `cli.py` はコマンド実行のメインファイル
import argparse
def main() -> None:
    parser = argparse.ArgumentParser(description="MyLearn CLI")
    parser.parse_args()
    print("Hello, world!")

プロジェクト設定ファイル

いよいよpyproject.tomlを作成します。

pyproject.toml
# pyproject.tomlのビルドツールを指定します。
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

# プロジェクトの設定を書きます。
[project]
name = "learn-pyproject"
version = "0.0.1"
description = "pyproject.tomlのテスト"
requires-python = ">=3.11"
authors = [{ name = "ArkBig" }]
# 依存関係も書けます。
dependencies = [
    "scikit-learn",
]
# インストール時のコマンドを書きます。
[project.scripts]
my-learn = "mylearn.cli:main"

# どのフォルダをモジュールとして扱うかを書きます。
[tool.setuptools]
py-modules = ["mylearn"]

上記のように[build-system]でビルドツールを指定し、[project]でプロジェクトの設定を書きます。必要に応じて[tool...]でツールごとの設定を書きます。
ビルドツールはhatchingがイケているらしいですが、Cythonプロジェクトで使われているsetuptoolsを使ってみます。

[project]でプロジェクト名やバージョンなどを書きます。今回大事なのはdependenciesscriptsキーです。

dependenciesは、これまでpip freeze > requirements.txtとしていた依存関係をpyproject.tomlに書けます。バージョンの指定もできますし、この例のように省略もできます。

scriptsは、pip install時に実行できるコマンドを書けます。my-learnというコマンドを定義し、mylearn.climain関数を実行するように設定しています。

このほか[tool]テーブルでツールごとの設定を書けます。今回はsetuptoolspy-modulesにモジュールのフォルダを指定してます。docstestsフォルダがあったときにこの指定がないとモジュールがどれかわからずビルドエラーになります。

プロジェクトのビルド

pyproject.tomlを作成したら、開発用インストールを試してみます。

ターミナル
# インストールせずにただ実行する
python -m mylearn --help
# 開発用インストールして実行する
pip install -e .
my-learn --help

setup.pyを使ってたときは、python setup.py developとしていた開発用インストールがpip install -e .でできるようになりました。(-e--editableと同じです。)

次にCythonを使ってみる

次にCythonを使ってC++のコードをラップしてPythonから使えるようにしてみます。
C/C++の既存の資源を有効活用できたり、Pythonより高速に動作させるためCythonは有用です。

最終形
📂proj-root/
├── 📄README.md                     # プロジェクト全体の説明
├── 📄README.md                     # プロジェクト全体の説明
├── 📄LICENSE.txt                   # プロジェクトのライセンス
├── 📄CMakeLists.txt                # 全体のビルド設定
├── 📄.clangd                       # clangdの設定
├── 📄.clang-format                 # フォーマッター設定
├── 📄.envrc                        # プロジェクト共有の環境変数設定
├── 📄.gitignore                    # gitの無視設定
├── 📂learn/                        # Pythonプロジェクト
│   ├── 📄README.md                 # Pythonプロジェクトの説明
│   ├── 📄pyproject.toml            # Pythonプロジェクトの設定ファイル
│   ├── 📄setup.py                  # Cythonの設定ファイル
│   ├── 📁.venv/                    # Python venv環境
│   └── 📂mylearn/                  # Pythonプロジェクトのモジュールフォルダ
│       ├── 📄__init__.py           # モジュール識別
│       ├── 📄__main__.py           # モジュール実行時のエントリーポイント
│       ├── 📄cli.py                # コマンド実行のメインファイル
│       └── 📄wrapper.pyx           # CythonでC++コードをラップ
├── 📂predictor/                    # C++プロジェクト
│   ├── 📄README.md                 # C++プロジェクトの説明
│   ├── 📄CMakeLists.txt            # C++プロジェクトのビルド設定
│   └── 📂src/                      # C++ソースコード
│       ├── 📄input_features.hpp    # 関数宣言
│       ├── 📄input_features.cpp    # 関数定義
│       └── 📄main.cpp              # メインコード
├── 📁build/                        # CMakeビルドフォルダ
├── 📁bin/                          # プロジェクト実行ファイル置き場
└── 📁lib/                          # プロジェクトライブラリ置き場

先のPythonだけのプロジェクトと違って、C++のプロジェクトもあり2つを合わせたモノリポ構成になっています。
Python側の準備をしてからC++側の準備をし、合わせてビルドします。

learn PythonプロジェクトにCython用の追加をする

PythonプロジェクトにCython用のファイルを追加します。
先のlearnプロジェクトに追加したのは、mylearn/wrapper.pyxsetup.pyです。あとREADME.mdもありますが内容は省略します。

learn/mylearn/wrapper.pyx
import cython

# C++コードで定義だけされているのを、Pythonから使えるようにする
cdef extern int hello_world()

def print_hello_world() -> int:
    return hello_world()

# C++ヘッダーで宣言されているのを、Pythonから使えるようにする
cdef extern from "input_features.hpp":
    float get_input_features()

def pred_get_input_features() -> float:
    return get_input_features()
learn/setup.py
from Cython.Build import cythonize
from setuptools import Extension, setup

ext_modules = [
    Extension(
        "mylearn.pred_wrapper",
        ["mylearn/predictor_wrapper.pyx"],
        include_dirs=["../predictor/src"],
        library_dirs=["../lib"],
        libraries=["test-pyproject-predictor-shared"],
        language="c++",
        extra_compile_args=["-std=c++20", "-O3", "-Wall", "-Wextra", "-Werror"],
        runtime_library_dirs=["../lib"], # これはWindowsしか効かないので注意
    ),
]

# pyproject.tomlを使わない場合は、ほかにもいろいろ書いてたけど、今回はこれだけでいい。
setup(
    ext_modules=cythonize(ext_modules),
)

setup.pypyproject.tomlに書けないCython用のext_modulesだけを書いています。
Cythonプロジェクトでも(何やら複雑で読んでませんが)pyproject.tomlsetup.pyを併用しています。

https://github.com/cython/cython

そしてCython用に変更があったものはmylearn/cli.pypyproject.tomlです。

learn/mylearn/cli.py
import argparse
+ # Cythonでラップした関数を使う
+ from mylearn.pred_wrapper import pred_get_input_features, print_hello_world
def main() -> None:
    parser = argparse.ArgumentParser(description="MyLearn CLI")
    parser.parse_args()
-    print("Hello, world!")
+    print_hello_world()
+    features = pred_get_input_features()
+    print(f"Input features: {features}")
learn/pyproject.toml
# pyproject.tomlのビルドツールを指定します。
[build-system]
requires = [
+    "Cython",
    "setuptools"
]
build-backend = "setuptools.build_meta"

# プロジェクトの設定を書きます。
[project]
name = "learn-pyproject"
version = "0.0.1"
description = "pyproject.tomlのテスト"
requires-python = ">=3.11"
authors = [{ name = "ArkBig" }]
+readme = "README.md"

# 依存関係も書けます。
dependencies = [
+    "Cython",
    "scikit-learn",
]
# インストール時のコマンドを書きます。
[project.scripts]
my-learn = "mylearn.cli:main"

# どのフォルダをモジュールとして扱うかを書きます。
[tool.setuptools]
py-modules = ["mylearn"]

Cython用の追加があります。

C++側のプロジェクトを作る

C++側のソースファイルはpredictor/srcフォルダに配置します。

predictor/src/input_features.hpp
#pragma once
#include <string>
float get_input_features();
predictor/src/input_features.cpp
#include "input_features.hpp"
float get_input_features() { return 3.14; }
predictor/src/main.cpp
#include "input_features.hpp"
#include <iostream>
// ヘッダーで宣言していない関数
int hello_world() {
  std::cout << "Hello, World!" << std::endl;
  return 0;
}
int main() {
  hello_world();
  const auto features = get_input_features();
  std::cout << "Input features: " << features << std::endl;
  return 0;
}

input_features.{hpp,cpp}が一般的なヘッダーで関数宣言し、ソースファイルで関数定義している例です。main.cppでヘッダーファイルがない関数定義だけの例もあります。

C++プロジェクトをビルドする

上記のコードをPythonから使えるように動的ライブラリとしてビルドします。今回は簡単なCMakeを使ってビルドします。

CMakeLists.txt
# ルートフォルダのCMakeLists.txtでは全体の設定を書き、サブディレクトリを登録します。
cmake_minimum_required(VERSION 3.10)
project(test-pyproject)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_COMPILER "clang++")
add_compile_options(-Wall -Wextra -Werror)
add_subdirectory(predictor)
predictor/CMakeLists.txt
# サブディレクトリのCMakeLists.txtではライブラリのビルド設定を書きます。
# 出力先がリポジトリ内のパスになるように設定しています。
set (CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin)
set (CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
file(GLOB PREDICTOR_SRC "src/*.cpp")
add_executable(predictor ${PREDICTOR_SRC})
set_target_properties(predictor PROPERTIES OUTPUT_NAME test-pyproject-predictor)
# Debug build
add_executable(predictor-debug ${PREDICTOR_SRC})
set_target_properties(predictor-debug PROPERTIES OUTPUT_NAME test-pyproject-predictor-debug)
# Shared library
add_library(predictor-shared SHARED ${PREDICTOR_SRC})
set_target_properties(predictor-shared PROPERTIES OUTPUT_NAME test-pyproject-predictor-shared)

CMakeLists.txtが用意できたらビルドします。

ターミナル
# ビルド用に任意のディレクトリを作成(リポジトリ外でもいい)
mkdir build
cd build
# CMakeLists.txtがあるディレクトリを指定
cmake ..
# Makefileが作成されるのでビルド
make predictor-shared

これで動的ライブラリがlibフォルダに作成されます。
これはlearn/setup.pylibrary_dirslibrariesで指定してます。

Python + Cythonのプロジェクトをビルドする

Python側とC++側の準備ができたら、Pythonプロジェクトをビルドします。

ターミナル
cd learn
pip install -e .
# Windows環境(WSL含む)ならこれで動きます
py-modules --help

Pythonだけのプロジェクトと同じようにビルドできます。

Windows環境ならsetup.pyで書いたruntime_library_dirsが効くのでlibフォルダから動的にライブラリをロードしてくれます。
Linux環境ではLD_LIBRARY_PATHをMac環境ではDYLD_LIBRARY_PATHを設定しておく必要があります。私はdirenvを使っているので.envrcに設定しています。

.envrc
workspaceFolder=$(pwd)
export workspaceFolder
export LD_LIBRARY_PATH="$workspaceFolder/lib:$LD_LIBRARY_PATH"
export DYLD_LIBRARY_PATH="$workspaceFolder/lib:$DYLD_LIBRARY_PATH"

まとめ

この記事では、Pythonプロジェクトにおけるpyproject.tomlの導入と、C++コードをCythonでラップしてPythonから利用する方法について解説しました。

pyproject.tomlは従来のsetup.pyやsetup.cfgに代わる新しい設定ファイルで、プロジェクトのメタデータやビルド依存関係、スクリプトの登録などを一元的に管理できます。本記事では、pyproject.tomlを使ったPythonプロジェクトの基本的な構築方法を実践しました。

また、CythonによるC++コードのラッピングでは、C++で実装した関数やクラスをPythonから呼び出せるようにするための手順を追っていきました。Cythonを使うことで、C++の高速な計算リソースをPythonから活用できます。

今回はシンプルな例ですが、pyproject.tomlとCythonを組み合わせることで、C++の性能とPythonの生産性の両立を図ることができます。本記事がpyproject.tomlやCythonを学ぶ初学者の方の一助となれば幸いです。

ライブラリをPyPIに公開するなら、もう少しpyproject.tomlの設定を埋める必要があるでしょう。そのときは pyproject.tomlを書くなど参考にしてください。

また、pyproject.tomlには静的解析ツールの設定なども書けるようです。今回は触れませんでしたが、興味があれば調べてみてください。

https://data.gunosy.io/entry/linter_option_on_pyproject

Discussion

natsuwaternatsuwater

Linux の場合に、.so のシェアードライブラリーを LD_LIBRARY_PATH の設定なしで読めるようにするために、setup.py で以下のように指定することで、なんとかなるかもしれません。

  • .so をパッケージに同梱とした上で、setup.py の中で Extension( ... , extension_param.update(extra_link_args=["-Wl,-rpath,$ORIGIN/path/to/lib"]) を指定。$ORIGIN はこのまま書きます。これが python のライブラリーの行き先。.so ファイルを同じ場所にインストールするなら、 "-Wl,-rpath,$ORIGIN" と書きます。
  • extra_link_args の指定は、Windows では無視されます。

パッケージ外の .so ファイルを、どうやって .wheel パッケージに入れるかということはこのコメントでは説明していませんが、ご参考まで。