Python プロジェクトの始め方

:::note info
この記事は正しい方法の案内**ではありません**。自己流の方法のメモです。
:::

できるだけ (事実上の) 標準に沿ったやり方を採用していますが、一部個人的な好みによって標準的でない方法があります。
Python **プロジェクト** の始め方なので、Python **パッケージ** を作るだけなら多くの箇所をスキップ可能です。

ライブラリとして開発する (=他のプロジェクトから `import` される) のかアプリとして開発する (=最終成果物で、 `import` されることはない) のかによって初期設定を変えるのは面倒なので、どちらでもいけるように最大公約数的な設定方法を採っています。

:::note warn
以下、 `myproject` をプロジェクト名のプレースホルダーとして使います。
:::

## 仮想環境の準備
[pyenv](https://github.com/pyenv/pyenv) とそのプラグインである [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) を使います[^1]。
pyenv それ自体のインストールは [pyenv-installer](https://github.com/pyenv/pyenv-installer) を使うと楽。

```console
$ pyenv install 3.10.12
$ pyenv virtualenv 3.10.12 myproject
$ pyenv activate myproject
```

## レポジトリ作成 & clone

Github を前提にしてます。
Github でレポジトリ作成し、python 向けの `.gitignore` を自動生成しておく。
LICENSE も自動生成でいいなら生成。特に気にしないなら MIT が無難だと思います。
README.md もどうせあとで作るのでここで自動生成。

Github 側のレポジトリが出来たら、ローカルに clone:

```bash
git clone git@github.com:<your_account>/myproject.git
```

## プロジェクトディレクトリ準備
以下の通りディレクトリ構成を作ります
```
myproject/
├── LICENSE                   # 適当に設定
├── Makefile                  # 後述
├── README.md                 # 適当に設定
├── myproject/                # 後述
│   ├── __init__.py
│   ├── _version.py
│   ├── cli.py
│   ├── module1.py
│   └── module2/
│       └── module2.py
├── myproject.code-workspace  # 後述
├── pyproject.toml            # 後述
├── requirements-dev.in       # 後述
├── requirements.in           # 後述
└── tests/                    # 後述
    ├── test_module1.py
    └── test_module2/
        └── test_module2.py
```

## pyproject.toml

- `[project]` 以下は [公式の仕様](https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata) に則って設定
- `[tool.pysen]` 以下は [pysen](https://github.com/pfnet/pysen/tree/main#configuration-model) を参照
- build backend は [setuptools](https://setuptools.pypa.io/en/latest/) を利用
    - why setuptools?
        - pypa が管理しており、公式と言って良い
        - 必要最小限の機能
            - lock を取るときに dependencies を外部ファイル (requirements.txt 的な) で参照できる
            - lock を取ること自体は後述する [pip-tools](https://pip-tools.readthedocs.io/en/latest/) を利用しています
    - why not [build](https://pypa-build.readthedocs.io/en/latest/)?
        - lock を取るときに dependencies を外部ファイル (requirements.txt 的な) で参照したくなるが、その機能がない？

```toml:pyproject.toml
[project]
name = "myproject"
version = "0.0.0"
description = "1行説明"
readme = "README.md"
requires-python = ">=3.10"
license = { file = "LICENSE" }
authors = [
  { name = "Your Name", email="dummy@example.com" },
]
scripts = { myproject = "myproject.cli:cli" }  # CLI がいらないなら不要
dynamic = [ "dependencies", "optional-dependencies" ]

[build-system]
requires = [ "setuptools>=60" ]
build-backend = "setuptools.build_meta"

[tool.setuptools.dynamic]
dependencies = { file = [ "requirements.in" ] }  # アプリの場合は "requirements.txt" のほうを指定
optional-dependencies.dev = { file = [ "requirements-dev.txt" ] }

[tool.pysen]
version = "0.10"

[tool.pysen.lint]
enable_black = true
enable_flake8 = true
enable_isort = true
enable_mypy = true
mypy_preset = "strict"
line_length = 120
py_version = "py310"
isort_known_first_party = ["myproject"]
[[tool.pysen.lint.mypy_targets]]
  paths = [
    "myproject/",
    "tests/",
  ]
```

## Makefile

Makefile はスペースとタブを区別するので注意

```Makefile:Makefile
SHELL=/usr/bin/env bash

.PHONY: install
install:
	pip install pip-tools
	pip-sync requirements.txt
	pip install .

.PHONY: install-dev
install-dev:
	pip install pip-tools
	pip-sync requirements.txt requirements-dev.txt
	pip install -e .

.PHONY: pin
pin:
    pip install pip-tools
	pip-compile requirements.in -o requirements.txt
	pip-compile requirements-dev.in -o requirements-dev.txt

# black との兼ね合いで E501 が disable されるが、black だけだと lint ができなくて不便なので enable しておく
.PHONY: pysen-configure
pysen-configure:
	pysen generate .
	sed -i setup.cfg -e 's/ignore = E203,E231,E501,W503/ignore = E203,E231,W503/g'

# pysen run lint がうまく階層を掘ってくれないので、それぞれ個別に実行
.PHONY: lint
lint:
	black --config pyproject.toml --diff --check tests/ myproject/
	flake8 --config setup.cfg tests/ myproject/
	isort --settings-path pyproject.toml --diff --check-only tests/ myproject/
	mypy --show-absolute-path --pretty --config-file setup.cfg tests/ myproject/

# pysen run format がうまく階層を掘ってくれないので、それぞれ個別に実行
.PHONY: format
format:
	black --config pyproject.toml tests/ myproject/
	isort --settings-path pyproject.toml tests/ myproject/

.PHONY: test
test:
	pytest tests/
```

`pip-compile` や `pip-sync` は、 [pip-tools](https://pip-tools.readthedocs.io/en/latest/) のサブコマンド。依存関係のロックを取ったり、そのロックから依存ライブラリを一括インストールする機能がある。

## python のボイラープレート

各ファイルを次の通り初期化。CLI が不要なら cli.py も不要です：

```python:myproject/__init__.py
from myproject._version import __version__  # NOQA
```

```python:myproject/_version.py
from importlib.metadata import PackageNotFoundError, version

try:
    __version__ = version("myproject")
except PackageNotFoundError:
    __version__ = "0.dev0"
```

```python:myproject/cli.py
import click

from myproject import module1
from myproject.module2 import module2


@click.command()
@click.option("--arg", type=str)
def cli(arg: str) -> None:
    print(module2.that_function(module1.some_function(arg)))
```

```python:myproject/module1.py
def some_function(arg: str) -> str:
    return "hello, " + arg
```

```python:myproject/module2/module2.py
def that_function(arg: str) -> str:
    return arg + "!!!"
```

```python:tests/test_module1.py
from myproject import module1


class TestModule1:
    def test_some_function(self) -> None:
        assert module1.some_function("foo") == "hello, foo"
```

```python:tests/test_module2/test_module2.py
from myproject.module2 import module2


class TestModule2:
    def test_that_function(self) -> None:
        assert module2.that_function("foo") == "foo!!!"
```

```requirements.in
click>=8.1
```

```requirements-dev.in
-c requirements.txt
black>=24.0
flake8>=7.0
isort>=5.0
mypy>=1.8
pip-tools>=7.3
pysen>=0.10
pytest>=8.0
```

## 開発版インストール

次の通りコマンドを実行していく：
```console
$ make pin  # requirements.in と requirements-dev.in のロックを取って requirements.txt と requirements-dev.txt を生成
$ make install-dev  # editable mode でインストール
$ make lint  # linter 実行
$ make format  # formatter 実行
$ make test  # テスト実行
$ myproject --arg world  # cli 動作チェック
hello, world!!!
```

## VSCode 設定
VSCode 上では extension 連携の都合から pysen を経由せずに flake8/mypy/isort/black を呼び出しているため、以下のコマンドで設定を生成しておく：

```bash
make pysen-configure  # pyproject.toml や setup.cfg に flake8/mypy/isort/black 用の設定が追加される
```

Visual Studio Code の設定ファイルを以下の通り作成。

- **`/Users/keisuke.nakata/.pyenv/versions/myproject` の部分は `pyenv prefix` の出力結果に置き換えてください。**
- `{workspaceFolder}` の部分は、VSCode workspace に登録されているのが単一のフォルダであることを前提にしているので、複数フォルダ登録しているときは絶対パスで記載

```json:myproject.code-workspace
{
	"folders": [
		{
			"path": "."
		}
	],
	"settings": {
		"python.defaultInterpreterPath": "/Users/keisuke.nakata/.pyenv/versions/myproject/bin/python",
		"flake8.args": [
			"--config=${workspaceFolder}/setup.cfg"
		],
		"flake8.path": [
			"/Users/keisuke.nakata/.pyenv/versions/myproject/bin/flake8"
		],
		"mypy-type-checker.args": [
			"--follow-imports=silent",
			"--ignore-missing-imports",
			"--show-column-numbers",
			"--no-pretty",
			"--config-file=${workspaceFolder}/setup.cfg"
		],
		"mypy-type-checker.path": [
			"/Users/keisuke.nakata/.pyenv/versions/myproject/bin/mypy"
		],
		"black-formatter.path": [
			"/Users/keisuke.nakata/.pyenv/versions/myproject/bin/black"
		],
		"isort.path": [
			"/Users/keisuke.nakata/.pyenv/versions/myproject/bin/isort"
		]
	}
}
```

## Github actions の設定

ここまででボイラープレートが完成したので commit & push して、github actions を設定する。
Python Package のテンプレートを選んで、微修正。今回は次のようにすればよい：

```yaml:.github/workflows/python-lint-test.yml
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python lint & test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11"]

    steps:
    - uses: actions/checkout@v3
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v3
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        pip install --upgrade pip
        make install-dev
    - name: Lint
      run: |
        make lint
    - name: Test
      run: |
        make test
```

`make install-dev` だとロックファイルからのインストールになってしまうため、外部パッケージの破壊的変更に CI で気づけない。
アプリとして作っている場合は運用環境が定まっているのでこれで問題ない。
ただし、ライブラリとして作っている場合でも、原理主義的にはあらゆる依存パッケージのバージョンの組み合わせを試さないといけないことになり、現実的ではないと思われる。
(全組み合わせとまではいかずとも、最新版での挙動を確認するくらいはやってもいいかも)

## PyPI への publish

ライブラリとして開発している場合のみ。

github actions の画面から "Publish Python Package" という新しい workflow をテンプレートから選択し、微修正。今回は次のようにすれば良い：

```yaml:.github/workflows/python-publish.yml
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Upload Python Package

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  deploy:

    runs-on: ubuntu-latest

    environment: release
    permissions:
      id-token: write  # IMPORTANT: this permission is mandatory for trusted publishing

    steps:
    - uses: actions/checkout@v3
    - name: Set up Python
      uses: actions/setup-python@v3
      with:
        python-version: '3.x'
    - name: Install dependencies
      run: |
        pip install --upgrade pip
        make install-dev
    - name: Build package
      run: python -m build
    - name: Publish package distributions
      uses: pypa/gh-action-pypi-publish@release/v1
```

最初は、本番の PyPI ではなく [TestPyPI](https://test.pypi.org) でテストすると良い。
一番最後の `pypa/gh-action-pypi-publish@release/v1` に `with` 項を足して、 `repository-url: https://test.pypi.org/legacy/` を足せば TestPyPI 向けになる。

事前に PyPI のアカウント登録や publisher 登録が必要。大雑把に言うと：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62450/c4854169-0300-9ed3-eddb-3db78ffd69e0.png)
- your account -> Publishing から、Add a new pending publisher に情報を登録し、Add.
  - どうやら、PyPI Project Name と Github の repository name は同じでないとうまく認証が通らない。[^2]

その後は、 pyproject.toml の project.version を書き換え、適当な git tag を打って [GitHub 側の Release 機能](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) で新しい Release を作ると、この python-publish.yml という Action が発火し、PyPI へ新しいバージョンがアップロードされる。


## 参考資料
- [The Basics of Python Packaging in Early 2023 - DrivenData Labs](https://drivendata.co/blog/python-packaging-2023)
- [Publishing with a Trusted Publisher - PyPI Docs](https://docs.pypi.org/trusted-publishers/using-a-publisher/)


[^1]: Python の仮想環境を作成するときの標準は [venv](https://docs.python.org/3/library/venv.html) ですが、個人的な好みで pyenv-virtualenv を採用しています。venv と機能は同じです。
[^2]: PyPI と github actions のドキュメントを辿ったが、それっぽい設定が見当たらない。environment に指定できる uri とかかなりそれっぽいのだが、設定しても github repository 名の pypi project に対して upload しようとして失敗してしまう。どうやればいいのか知っている人がいたら教えてください