パッケージ開発と動作確認の手順 (最低限の構成編)

# 前置き

Python で `pip install` できるパッケージの開発をしようとしています。
開発中にちょこちょこ動作確認することになるので、その手順を自分なりに確立しておきたいと思って、記事にまとめることにしました。

今回は**「最低限の構成編」**ですので、PyPI に登録するようなパッケージを作るのには向きません。
ただしテストコードは含むようにしています。

何をもって「最低限」かというと、**他のパッケージに依存しない** というのがひとつのポイントになります。
そのためいろいろ端折っていますが、ちょっとしたパッケージを GitHub で配布するだけならこれで大丈夫だと思います。
他のパッケージに依存するパッケージの構成については、[またの機会](https://qiita.com/satamame/items/9b199c202edc11a06c7f)に書きたいと思います。

## 動作確認した環境

Python 3.8.1
pip 20.3.1
setuptools 51.0.0

## ステップ

1. ディレクトリを構成する
1. テストコードが動くようにする
1. インストールする側を作る (動作確認用)
1. 編集可能モードでインストールする
    1. pip コマンドによるインストール
    1. requirements ファイルを使ったインストール
1. GitHub に Push して、GitHub からインストール

# ディレクトリを構成する

パッケージ名を "min_pkg" として、以下のような構成にしました。
GitHub に置いてありますので、内容についてはそちらをご参照ください。
https://github.com/satamame/min_pkg

```
min_pkg/ (パッケージのリポジトリ)
 ├─ .git/
 ├─ min_pkg/
 │   ├─ __init__.py
 │   ├─ character.py
 │   └─ main_character.py
 ├─ tests/
 │   ├─ __init__.py
 │   └─ test_init_char.py
 ├─ .gitignore
 ├─ pyproject.toml
 ├─ README.md
 ├─ setup.cfg
 └─ setup.py
```

最低限という割にはファイルが多いですが、順番に見ていきましょう。

## パッケージになる部分

内側の "min_pkg" フォルダが、パッケージになる部分です。
モジュールが2つあるのは、**「同じパッケージ内の別のモジュールを import する」**という状況を作って動作確認したいからです。
ソースは [GitHub](https://github.com/satamame/min_pkg/tree/master/min_pkg) をご参照ください。

## テストコード

["tests" フォルダ](https://github.com/satamame/min_pkg/tree/master/tests) にテストコードを入れてあります。
「テストコードは要らない」という人は、フォルダごと無視して大丈夫です。

## setuptools で使うファイル

パッケージを `pip install` する時に setuptools が動きます (後述)。
以下のファイルは、setuptools で使うファイルになります。

### pyproject.toml

setuptools の公式ドキュメントの [Basic Use](https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#basic-use) を見ると、おまじないの様に決まった記述をすれば良いようです。

```plaintext:pyproject.toml
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
```

### setup.cfg

setuptools が参照する、パッケージ情報です。多分これで最低限だと思います。

```plaintext:setup.cfg
[metadata]
name = min_pkg
version = 0.1

[options]
packages = min_pkg
```

今回の構成ですと "tests" フォルダも (\_\_init\_\_.py があるので) パッケージになっていますが、`packages = min_pkg` で、"min_pkg" フォルダだけをインストールするよう指示しています。

### setup.py

先ほど 「`pip install` する時に setuptools が動く」と書きましたが、それは "setup.py" の中でそう書いているからです。

```python:setup.py
from setuptools import setup

setup()
```

#### setup.py を書くのは古いやり方？

上記の "setup.py" では、setuptools の `setup()` を呼んでいるだけですが、"setup.py" の中に全部の情報を書くこともできます。ただし、それは [distutils](https://docs.python.org/ja/3/library/distutils.html) を使っていた時代のやり方で、[互換性のために setuptools に引き継がれているよう](https://docs.python.org/ja/3/distutils/setupscript.html)です。

さらに、この最低限の "setup.py" を書くことすらも、[setuptools の Basic Use](https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#basic-use) ではしていません。
`setup()` だけの "setup.py" は、ある条件下では必要ない (省略できる) からです。それについても次回に説明したいと思います。

# テストコードが動くようにする

最初からテストコードをばっちり揃えておく必要はありませんが、「テストコードを書きたくなったらいつでも書ける状態にしておくこと」が大事だと思います。
動作確認するたびに使ったコードを蓄積していけるので、今後の無駄を省くことにもなります。

サンプルでは `unittest` で動くようにテストコードを書いたので、(リポジトリ・ルートから) 以下のコマンドで実行できます。
`-v` は実行中の情報を出力するオプションです。

```
> python -m unittest -v

test_init_character (tests.test_init_char.TestInitChar) ... ok
test_init_main_character (tests.test_init_char.TestInitChar) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK
```

## テストコードからの import

テストコードからは、以下のようにパッケージを `import` しています。

```python:test_init_char.py
import unittest
from min_pkg.character import Character
from min_pkg.main_character import MainCharacter

class TestInitChar(unittest.TestCase):
    '''各クラスのインスタンス初期化のテスト'''
    def test_init_character(self):
        # (以下、テストコード)
```

リポジトリ・ルートから実行することによって、リポジトリ・ルート直下にある "min_pkg" フォルダが (\_\_init\_\_.py があるので) パッケージとして認識され、このように `import` できます。

パッケージとして認識されているので、"main\_character.py" が自分と同じパッケージ内のモジュール ("character.py") を参照するところも上手く動いています。

```python:main_character.py
from .character import Character  # . (ドット) でパッケージ内を参照

class MainCharacter(Character):
    def __init__(self, name='ヒロ', age=14):
        self.name = name
        super().__init__(age)
```

#### 本当はインストールしてテストすべき？

テストコードの実行について、上のやり方でもあまり問題はない (と私は思う) のですが、より厳密にテストしたい場合は一回どこかへインストールして**「インストール済みパッケージとして」**テストする必要があります。
たとえば他のパッケージに依存するパッケージなら同じ仮想環境に入っている方をテストすべきですし、さらには、別のバージョンの Python や pip でインストールしたパッケージをテストしたい場合もあるかも知れません。

そのやり方はまた次回にでも書こうと思います。
今回は上のやり方でも大丈夫な構成になっています。

# インストールする側を作る (動作確認用)

"min_pkg" をインストールして動作確認するための、"install_check" というプロジェクトを作りました。
どこに作っても良いのですが、今回は以下のように同じディレクトリに作っています。

```
.
 ├─ min_pkg/ (パッケージのリポジトリ)
 │   └─ (略)
 └─ install_check/
     ├─ .venv/
     └─ make_chars.py
```

この段階で、".venv" ディレクトリは、素の仮想環境です。
念のため、これを作るコマンドも載せておきます。

```
> cd install_check
> python -m venv .venv
```

この状態で、仮想環境には Python, pip, setuptools がインストールされています。

# 編集可能モードでインストールする

ではこれから仮想環境に "min_pkg" をインストールします。
その前に「編集可能モード」についてご説明します。

「編集可能モード」でインストールすると、パッケージのソースを変更した時に、インストール先に即座に反映されます。
ビルドしたり再インストールしたりする手間が要らないので、開発中はこのモードを使います。

## pip コマンドによるインストール

先ほどの続きで、"install_check" ディレクトリにいる前提で進めます。
まずは仮想環境に入ります (以下は Windows で PowerShell を使う場合)。

```
> .venv/Scripts/activate
```

`pip` で編集可能モードでインストールするには、以下のようにします。

```
(.venv) > pip install -e <パッケージのパス>
```

今回は、今いるディレクトリのひとつ上のディレクトリにパッケージがあるので、以下のようにします (もちろんフルパスでもいいです)。

```
(.venv) > pip install -e ../min_pkg
```

インストールされていることを確認してみましょう。

```
(.venv) > pip list

Package    Version
---------- -------
min-pkg    0.1
pip        20.3.1
setuptools 51.0.0
```

## import して使ってみる

インストールする側のプロジェクト ("install_check") に、以下のスクリプトを書いて動かしてみます。
これは、`Character` と `MainCharacter` というクラスを `import` して、ユーザに入力してもらったパラメタでインスタンスを作る、というスクリプトです。

```python:make_chars.py
from min_pkg.character import Character
from min_pkg.main_character import MainCharacter

def main():
    print('Making a normal character')
    age = input('Age? ')
    if age.isdigit():
        character = Character(int(age))
    else:
        character = Character()

    print('Making a main character')
    name = input('Name? ')
    age = input('Age? ')

    kwargs = {}
    if name:
        kwargs['name'] = name
    if age.isdigit():
        kwargs['age'] = int(age)
    main_character = MainCharacter(**kwargs)

    print(f'You made a {character.age}-year-old character,', end=' ')
    print('and a {}-year-old character named "{}".'.format(
        main_character.age, main_character.name))

if __name__ == '__main__':
    main()
```

Enter キーを空打ちするとそのパラメタは使わないようになっているので、それぞれのクラスで定義しているデフォルト値が使われます。
実行してみます。

```
(.venv) > python make_chars.py

Making a normal character
Age? [Enter]
Making a main character
Name? [Enter]
Age? [Enter]
You made a 17-year-old character, and a 14-year-old character named "ヒロ".
```

## 元のパッケージを変更してみる

ここで「編集可能モード」になっていることを確認するために、元のパッケージを編集して、デフォルト値を変えてみます。

```python:main_character.py
from .character import Character

class MainCharacter(Character):
    def __init__(self, name='インディ', age=78):  # デフォルト値を変更
        self.name = name
        super().__init__(age)
```

そして先ほどの仮想環境で、そのまま同じコマンドを実行してみます。

```
(.venv) > python make_chars.py

  (中略)

You made a 17-year-old character, and a 78-year-old character named "インディ".
```

変更が即座に反映されたことが分かります。
※ちなみにこの状態でテストコードの方を実行すると失敗します (パラメタのデフォルト値が期待値になっていたので)。

## requirements ファイルを使ったインストール

さらに、インストールする側のプロジェクトを共有することも考えて、requirements ファイルでもインストールできるようにします。

```
.
 ├─ min_pkg/ (パッケージのリポジトリ)
 │   └─ (略)
 └─ install_check/
     ├─ .venv/
     ├─ make_chars.py
     └─ requirements.txt  # 追加
```

以下のように書きます。

```plaintext:requirements.txt
-e ../min_pkg
```

インストールされた状態から `pip freeze` で作ることもできますが、パスがフルパスになるようです。

では、今はいっているのをアンインストールしてから、このファイルを使ってインストールしてみます。

```
(.venv) > pip uninstall min_pkg

  (中略)

(.venv) > pip install -r requirements.txt

  (中略)

Successfully installed min-pkg
```

# GitHub からインストールする

GitHub で配布することを想定して、GitHub からのインストールも試してみます。
GitHub に Push するところは省略して、[今回のサンプルのリポジトリ](https://github.com/satamame/min_pkg)からインストールしてみます。

まずはアンインストールして、

```
(.venv) > pip uninstall min_pkg
```

以下のようなコマンドでインストールできます。

```
(.venv) > pip install git+https://github.com/satamame/min_pkg
```

"git+" の後ろがリポジトリの URL になっていれば、GitHub でなくても行けると思います。
requirements ファイルでインストールする場合も、同様の書き方になります。

```plaintext:requirements.txt
git+https://github.com/satamame/min_pkg
```

# Pipenv を使うパターン

`pip` ではなく `Pipenv` を使うパターンでもインストールしてみました。
使った `Pipenv` のバージョンは 2020.11.15 です。

まずは `Pipenv` で素の仮想環境を作ります。
"install_check" ディレクトリで "make_chars.py" 以外を削除して、以下のコマンドで作ります。

```
> pipenv install
```

ディレクトリはこんな感じになります。

```
.
 ├─ min_pkg/ (パッケージのリポジトリ)
 │   └─ (略)
 └─ install_check/
     ├─ .venv/
     ├─ make_chars.py
     ├─ Pipfile
     └─ Pipfile.lock
```

## 編集可能モードでインストール

`Pipenv` の場合も、パッケージの指定の仕方は `pip` の時と同じです。

```
> pipenv install -e ../min_pkg
```

ここで "UnicodeDecodeError" が出ることがあったのですが、原因が分からないまま出なくなりました。
パッケージのパスに日本語を入れたりはしない方が良いのかも知れません (他にも原因はあると思いますが…)。

そのまま "make_chars.py" を実行してみます。

```
> pipenv run python make_chars.py

  (中略)

You made a 17-year-old character, and a 14-year-old character named "ヒロ".
```
ちなみに "Pipfile" の中には、こんな行が足されていました。

```plaintext:Pipfile
[packages]
min-pkg = {editable = true, path = "./../min_pkg"}
```

## GitHub からインストール

GitHub からのインストールも試します。
まずは、先ほど入れたものをアンインストールします。

```
> pipenv uninstall min_pkg
```

アンインストールできたか確認するには、`Pipenv` の `run` コマンドで `pip list` します。

```
> pipenv run pip list
```

`Pipenv` で git リポジトリからインストールする時は、以下のようにします。
`#egg=<パッケージ名>` の部分を書かないと、警告が出てインストールに失敗します。

```
> pipenv install git+https://github.com/satamame/min_pkg#egg=min_pkg
```

このやり方でインストールすると、"Pipfile" に足される行はこのようになります。

```plaintext:Pipfile
[packages]
min-pkg = {git = "https://github.com/satamame/min_pkg"}
```

# あとがき

本文の途中にもちょいちょい書きましたが、今後、もっと整った感じの配布用パッケージについても、手順を (勉強して) まとめてみようと思っています ([こちら](https://qiita.com/satamame/items/9b199c202edc11a06c7f)に書きました)。

「Pipenv を使うパターン」は、後から書き足しました。
`Pipenv` でローカルや GitHub からのインストールを試した時に、何度か "UnicodeDecodeError" で失敗したので、最初は書くのを断念していたのですが、パッケージを今の形にしてからエラーが出なくなりました。