pyproject.toml入門 〜 Cython を添えて 〜
Pythonを本格的に使ってみようと思い立った初心者が、最初のプロジェクト構成でつまずいた記録です。最近ナウいといわれているpyproject.tomlを使ってみます。
プロジェクト概要
C++のコードとそれをサポートするためのPythonのコードを書くプロジェクトです。C++のコードはCythonでラップしてPythonから呼び出すことにします。
C++のコードはCMakeでビルドして、Pythonの環境はvenvで構築します。
参考サイト:
最終的なフォルダ構成
最初に全体像として今回実験した結果から、次のようなフォルダ構成でいこうと思っています。
📂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
を設定しておく必要があるので注意が必要です。
主要ファイルの内容を見る。
[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"]
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),
)
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()
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にたどり着きました。
-
setup.py
はスクリプトなので自由度が高すぎて初心者には難しい -
setup.cfg
でシンプルな設定のみ書けるようにしよう -
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/
にパスがとおりpython
やpip
で実行できます。
またpip install
でパッケージをインストールすると、.venv/lib/
にインストールされるので、ほかのプロジェクトと分離したPython環境が構築できます。
プロジェクトモジュールフォルダ
プロジェクトモジュールの名前はmylearn
とします。(通常1モジュールなら、プロジェクト名と同じにしますが、説明するときに識別できるように変えています。)
Pythonコードはフォルダを作成してその中に配置すると便利らしいです。
python -m mylearn
で実行できたり、import mylearn.hoge
でインポートできます。
# `__init__.py` は存在することに意味があるので空ファイルでOK
# `__main__.py` は`cli.py`に用意した`main`関数を実行するだけ
if __name__ == "__main__":
from mylearn.cli import main
main()
# `cli.py` はコマンド実行のメインファイル
import argparse
def main() -> None:
parser = argparse.ArgumentParser(description="MyLearn CLI")
parser.parse_args()
print("Hello, world!")
プロジェクト設定ファイル
いよいよ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]
でプロジェクト名やバージョンなどを書きます。今回大事なのはdependencies
とscripts
キーです。
dependencies
は、これまでpip freeze > requirements.txt
としていた依存関係をpyproject.toml
に書けます。バージョンの指定もできますし、この例のように省略もできます。
scripts
は、pip install
時に実行できるコマンドを書けます。my-learn
というコマンドを定義し、mylearn.cli
のmain
関数を実行するように設定しています。
このほか[tool]
テーブルでツールごとの設定を書けます。今回はsetuptools
のpy-modules
にモジュールのフォルダを指定してます。docs
やtests
フォルダがあったときにこの指定がないとモジュールがどれかわからずビルドエラーになります。
プロジェクトのビルド
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.pyx
とsetup.py
です。あとREADME.md
もありますが内容は省略します。
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()
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.py
はpyproject.toml
に書けないCython用のext_modules
だけを書いています。
Cythonプロジェクトでも(何やら複雑で読んでませんが)pyproject.toml
とsetup.py
を併用しています。
そしてCython用に変更があったものはmylearn/cli.py
とpyproject.toml
です。
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}")
# 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
フォルダに配置します。
#pragma once
#include <string>
float get_input_features();
#include "input_features.hpp"
float get_input_features() { return 3.14; }
#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では全体の設定を書き、サブディレクトリを登録します。
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)
# サブディレクトリの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.py
のlibrary_dirs
とlibraries
で指定してます。
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
に設定しています。
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
には静的解析ツールの設定なども書けるようです。今回は触れませんでしたが、興味があれば調べてみてください。
Discussion
Linux の場合に、.so のシェアードライブラリーを LD_LIBRARY_PATH の設定なしで読めるようにするために、
setup.py
で以下のように指定することで、なんとかなるかもしれません。setup.py
の中でExtension( ... , extension_param.update(extra_link_args=["-Wl,-rpath,$ORIGIN/path/to/lib"])
を指定。$ORIGIN
はこのまま書きます。これが python のライブラリーの行き先。.so
ファイルを同じ場所にインストールするなら、"-Wl,-rpath,$ORIGIN"
と書きます。パッケージ外の .so ファイルを、どうやって
.wheel
パッケージに入れるかということはこのコメントでは説明していませんが、ご参考まで。