雑記: 自分のパッケージを TestPyPI(PyPI のテスト環境)に登録するまで

外部パッケージに依存しない単純なパッケージを TestPyPI(PyPI のテスト環境)に登録するまでの作業記録です。参考文献 [1] の公式チュートリアルにしたがいました。公式チュートリアルにしたがってつまづく点はあまりないですが、公式チュートリアルではテストがないのでテストもしました。ファイル一式はこちらです。

  1. Python のプロジェクトをパッケージングする — Python Packaging User Guide
    • 公式チュートリアルです。英語版をみて作業したのですが後から日本語版の存在に気付きました。
  2. PEP 621 – Storing project metadata in pyproject.toml | peps.python.org
    • pyproject.toml の project キーには PEP 621 で規定された内容を指定する必要があるとあります。
  3. python - When would the -e, --editable option be useful with pip install? - Stack Overflow
    • パッケージをローカルでテストしながら開発するプラクティスがわからなくて調べただけです。
  4. How to do I delete/edit a package and its release file list in test.pypi.org? - Stack Overflow
    • TestPyPI にアップロードしたパッケージを削除できるか調べたら削除はできました。しかしやってみてわかったのですが、パッケージの一部のバージョンもしくは全部のバージョンを削除して再度削除したバージョンをアップロードすると「This filename has already been used」といわれ拒絶されます。ファイルは削除できても歴史改変はできなかったです。
  5. pypi - How long does a Python package stay on testpypi? - Stack Overflow
    • TestPyPI インデックスは永続的ではないといわれますが、ではどれくらい保持されるのか気になったのですが、3 年前の時点で PyPI 管理者が「現在無期限に保持しているが、無期限に保持されることを期待すべきではない」といっています。

ファイル一式を用意する
ファイル一式はこちらにある通りですがツリーを描くと以下です。

cookies_utilities/
├── pyproject.toml
├── LICENSE
├── README.md
├── src/
│   └── cookies_utilities/
│       ├── __init__.py
│       └── timer.py
└── tests/
    └── test_timer.py

今回 timer.py に Timer というクラスを実装しました。このクラスは時間を計測したい処理の前後で press() していくと時刻を記録してくれ、最後に show() するとラップタイム一覧とトータルタイムを表示してくれます。こうかいていて「これは Timer ではなく Stopwatch なのでは」と気付きましたが後の祭りです。

さておきこの Timer(Timer ではない)をどこでも誰にでも pip install で手に入れてもらうようにするためには上のツリーのようなファイル一式を用意します。あまり得体の知れないファイルはないですが、パッケージングも Poetry も経験がないと pyproject.toml については得体が知れないのでこれについてメモします。

pyproject.toml について

  • build-system キーには、いくつかあるビルドツールから好きなものを指定します。今回はチュートリアルにしたがって hatchling を指定しました。
    • ただビルドを終えて pip list しても自分の環境に hatchling が導入された形跡がなかったのですが、チュートリアル「pip のようなビルドフロントエンドが、ビルド作業の一環として、一時的で隔離された仮想環境に自動的にインストールしてくれることでしょう」とありました。そもそもビルド時に画面に「Installing packages in isolated environment... (hatchling)」と表示されていました。
  • project キーには PEP 621 [2] で定義されたメタデータを指定します。
    • [2] には指定すべきメタデータとして name, version, description, readme, requires-python, license, authors, keywords, classifiers, urls などとあり、さらに続いています。これらのメタデータは (Test)PyPI 上に表示されるのはもちろん、ビルド時・アップロード時・インストール時に適宜利用されます。例えば、ビルド後にパッケージファイル名のバージョンをかき換えてアップロードしようとすると「メタデータがないですよ」と拒絶されます。捏造はできないようです。
    • ただ上記に太字でかいたうち keywords はチュートリアルでも指定していないし、私はさらに urls も指定しませんでしたが支障はありませんでした。他のキーにも省けるものがあるかもしれないですが私は試しません。
    • 個々のキーに得体の知れないものはあまりないですが、classifiers は Python3 系、MIT ライセンス、OS 非依存のパッケージであれば私と同じ(というかチュートリアルと同じ)ように記述すればよいようです。

ローカルでテストする
チュートリアルではいきなりパッケージングしていきますが、テストしないのはどうかと思うのでテストします。といっても editable モード(コード変更が即座に反映されるモード)でカレントディレクトリをパッケージとしてインストールして、tests 以下のテストコードを Python 標準の unittest で実行するだけです。

pip uninstall cookies_utilities  # uninstall the package if already installed
pip install -e .  #  install the package in editable mode
python -m unittest discover tests -v  # test

パッケージをビルドする
パッケージをビルド(ファイル一式を圧縮)します。つまづくことはないと思います。

pip install --upgrade build  # upgrade 'build'
python -m build

以下のようにパッケージが生成されます。

./dist/cookies_utilities-0.0.1.tar.gz
./dist/cookies_utilities-0.0.1-py3-none-any.whl

パッケージを TestPyPI にアップロードする

パッケージができたのでアップロードしたいですが、予め TestPyPI にアカウント登録する必要があります。
画面にしたがってアカウント登録できると思いますが、適当な TOTP アプリが必要です。github.com も今年に入って2段階認証を求めるようになったので、その関係で TOTP アプリを導入した方は同じアプリを利用できると思います。画面に 2 次元コードが出たらアプリでスキャンします。

また、おそらく TOTP アプリとの連携前に 8 つのリカバリーコードを生成して手元に保存すると思います。その後即座にそのうち1つの入力を求められるので好きな1つだけ入力します。画面をよく読まずに全部入力するとリカバリーコードを生成し直すことになるのでそういうことはやめます。

その辺の手順を終えると API トークン(pypi- から始まるながい文字列)が生成できるようになるので生成して手元に保存します。そして以下のようにパッケージをアップロードします。

pip install --upgrade twine
python -m twine upload --repository testpypi dist/*

このとき名前とパスワードを訊かれますが、TestPyPI のアカウント名とパスワードを入力するのではなく、名前には __token__ と入力しパスワードに先の API トークンを入力します。
それでパッケージがアップロードできますが、「同名同バージョンのパッケージを以前にアップロードしている場合(削除済みであっても)」「メタデータが不正な場合」はアップロードを拒絶されます。

無事アップロードできると画面に URL が表示されます。その URL に行くと自分のパッケージの情報とインストールコマンドが表示されているのでインストールして楽しみます。
https://test.pypi.org/project/cookies-utilities/0.0.3/

pip install -i https://test.pypi.org/simple/ cookies-utilities==0.0.3
python
>>> import cookies_utilities as cu
>>> timer = cu.Timer()
>>> timer.press('train start')
>>> timer.press('test start')
>>> timer.press('end')
>>> timer.show()
time1(train start-test start): 7.476s
time2(test start-end): 5.493s
total: 12.969s