Python 3.7とVisual Studio Codeで型チェックが捗る内作Pythonアプリケーション開発環境の構築

PySpa統合思念体です。

Pythonの開発環境の最新の環境の構築に挑戦してみたら、いろいろ型チェックとかできるようになって面白かったのですが、まとまった情報がなかったのでまとめてみます。基本的にはmacOSでやっているけど、Python本体のインストール以外には使用しているツール類はOS依存のものは使っていないため、パスや環境変数の設定を読み替えれば他の環境でも使えると思います。もし他の環境で注意すべきポイントがあったらお気軽に編集リクエストください。

本エントリーは @aodag と @moriyoshi 、 @knqyf263 の情報提供により完成しました。ありがとうございました。

# タイトルの意味

タイトルは本文の想定状況を過不足なく状況をお伝えするために色々制約を加えています。「内作アプリケーション」と書いているのは、配布しているツールとかでなければ、特定のバージョン1つを選択してサポートすれば多くの場合は問題ないからです。

ライブラリでも、OSSのアプリケーションでも、特定のバージョンだけに限定してサポートを表明すればもちろん問題ないのですが、Python界隈は後方互換性ラブなエコシステムの雰囲気があります。最低限現在サポートされているPythonのバージョンをサポートしようとするだけでもかなりの手間暇がかかるし、そのぶん、最大公約数の機能しか使えなくなります。今回は、そういう足枷をとっぱらって、最新機能を思う存分使うには、という目的で書いています。

参考までに、サポートするバージョンを執筆時点の[Pythonの各バージョンの状況](https://devguide.python.org/#status-of-python-branches)はこんな感じです。

|バージョン|PEP|更新状況|リリース日|サポート終了日|最新バージョン|
|--:|:--|:--|:--|:--|:--|
| 3.7 | [PEP 537](https://www.python.org/dev/peps/pep-0537/) | bugfix | 2018-06-27 | 2023-06-27 | 3.7.0 |
| 3.6 | [PEP 494](https://www.python.org/dev/peps/pep-494/) | bugfix | 2016-12-23 | 2021-12-23 | 3.6.6 |
| 2.7 | [PEP 373](https://www.python.org/dev/peps/pep-373/) | bugfix | 2010-07-03 | 2020-01-01 |  2.7.15 |
| 3.5 | [PEP 478](https://www.python.org/dev/peps/pep-478/) | security | 2015-09-13 | 2020-09-13 | 3.5.5 |
| 3.4 | [PEP 429](https://www.python.org/dev/peps/pep-429/)| security | 2014-03-16 | 2019-03-16 | 3.4.8 |

ちなみに、3.7としているけど、ほぼ3.6でも問題なくいくかと思います。

# 本エントリーの方針

このエントリーではPython 3.7で動くアプリケーションの開発環境を作りますが、その過程で必要なツール（Python本体、pip、pipenv）をそれぞれの場所にインストールします。とりあえず次の方針でやります。Python本体以外はPythonのエコシステムでやっていきます。イージーよりはシンプルという方針です。

* 管理者権限が必要なシステムへの変更は最低限にする。ユーザー権限で入るものはユーザー権限で入れる。
* PATHや環境変数への変更も最低限にする。
* Python本体以外はOS固有のツールには依存しないようにする。
* curlで取ってきたスクリプトをパイプでsudoで実行するみたいな頭に虫が湧いているようなマジキチなことはしない。

# Python処理系環境構築 (システムごとに1度だけ実行）

まずはPython 3.7を入れます。バイナリインストーラでもいいですし、HomebrewでもMacPortsでもいいです。僕はMacPortsを使っています。ビルド済みのバイナリがあったので10秒かからずに入りました。

Pythonでは標準のパッケージ管理ツールのpipを有効にするには、ensurepipを実行する必要があります。MacPortsだとパスの通っていない/opt/local/Library/Frameworks以下にコマンドが作られます。ここにパスを通してもいいのですが、1度しか使わなくてもいいので、絶対パスで実行します。これでインストールされるpipは少し古いので、その後、最新版のpipをユーザー権限で動作するフォルダ以下に入れます。

```bash
$ sudo port install python3.7
$ sudo python3.7 -m ensurepip
$ /opt/local/Library/Frameworks/Python.framework/Versions/3.7/bin/pip3.7 install --user --upgrade pip
```

[--userをつけた時のインストール先](https://qiita.com/ronin_gw/items/cdf8112b61649ca455f5)はOSによって異なります。macだと~/Library以下ですし、Linuxだと~/.local、Windowsだと%APPDATA%とのこと。これらもデフォルトではパスは通りません。今入れたユーザー権限のpipは1度しか実行しないのでいいのですが、これから入れるpipenvコマンドは、Visual Studio Codeからpipenvコマンドが見えていないといけないという制約があります。このコマンドが生成された場所にパスを通すのが良いでしょう。

```bash:.bash_profileとか.zprofileとか
export PATH=$HOME/Library/Python3.7/bin/:$PATH
```

次にpipenvを入れます。これもユーザー権限で入れます。

```bash
$ pip3 install --user pipenv
```

pipenv専用設定になりますが、次の設定をしておくと、プロジェクト内部にpipenvでインストールしたファイル群が入ります。Visual Studio Codeで仮想環境を設定するにはこちらがある方が便利です（ないとできない？)。

```bash:.bash_profileとか.zprofileとか
export PIPENV_VENV_IN_PROJECT=true
```


なお、[macOS環境ではpipenvとの相性の問題がある](https://github.com/pypa/pipenv/issues/187)ため、次の設定が必要です。

```bash:.bash_profileとか、.zprofileとか
export LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8
```

Visual Studio Codeも入れます。Python拡張はまだ入れなくてもいいです。入れてもいいので、すでに入れちゃった人はわざわざ消さなくても良いです。.profileとか.zprofileの反映がGUIのアプリケーションに見えるようにするには、一度ログオフしてあげる必要があるかと思います。

# プロジェクトの環境の作成 (プロジェクト作成時に1度だけ実行）

pipenvを使ってプロジェクトの環境の構築をします。pipenvを使うことでプロジェクト固有のライブラリやツールを、そのプロジェクトディレクトリ内部に押し込めることができます。

まずは適当なフォルダを作ります。ここでは、myprojectとします。フォルダを作ったら、venvの環境を作ります。.venvフォルダとPipfile、Pipfile.lockファイルができます。.venvフォルダができない（~/.localに生成したとメッセージが出た)ときは、PIPENV_VENV_IN_PROJECT環境設定が抜けている場合なので、環境変数を設定してから再実行してください。

```bash
$ mkdir myproject
$ cd myproject
$ pipenv install
$ ls -1
.venv
Pipfile
Pipfile.lock
```

次に、Visual Studio Code用の設定を追加します。recommendationsの拡張にPython拡張を設定しておきます。そうすると、このフォルダを開くと勝手にインストールしますか？と出るので、チーム開発で設定が楽になります。「git cloneしてVSCodeでトップフォルダを開いてね。他のエディタを使う人は自己責任で」と言えるようになります。

```json:.vscode/extensions.json
{
    "recommendations": [
        "ms-python.python"
    ]
}
```

次にプロジェクトの環境や、デフォルトのツール類を有効にするように設定を書いておきます。これを書くと、同じリポジトリで作業をしているメンバーが共通の設定を利用するようになるため、コードレビューとかはかなり負担が軽減されます。まず、Python本体はpipenvで作った環境を見るようにするため、venvの設定および、ワークフォルダのbin/pythonを設定しておきます。これでpipenvの仮想環境下で開発ができますし、開発に必要なツール類もこちらにインストールされます。

```json:.vscode/settings.json
{
    "python.venvPath": ".venv",
    "python.pythonPath": "${workspaceFolder}/.venv/bin/python"
}
```

VSCodeのPython拡張は、いくつかの開発補助ツールをサポートしており、設定で有効化できます。

とりあえず、formatterとしてはautopep8, yapf, blackに対応しています。最近は設定ファイルなしでずばっとフォーマットしてくれるblackが人気らしいので、これを使います。

```json:.vscode/settings.json
{
    "python.formatting.provider": "black"
}
```

linterとしては、flake8, pylint, pycodestyle(pep8), mypyに対応しています。ここではflake8とmypyをチョイスします。なお、aodag先生によると、formatterとlinterには相性があって、とpycodestyleはPEP8にはないチェックもするが、これがblackの生成するコードをひっかけてしまうということで、この2つの組み合わせは要注意とのことです。

型チェックと言えばmypyなので、これは忘れずにtrueにしておきます。

```json:.vscode/settings.json
{
    "python.linting.flake8Enabled": true,
    "python.linting.mypyEnabled": true
}
```

**8/8追記 (ここから)**

blackとflake8はどちらもほぼデフォルト設定で問題なく使えますが、組み合わせた時には、それぞれの想定している行の長さが違うため、フォーマットした結果がエラーになってしまうというのがあります。blackは1行88を想定していますが、flake8は79文字です。なお、pep8の説明ではデフォルトで79だが、チームで合意のうえで80から100ぐらいの間なら許容する、というルールになっています。今回はblackに合わせて、チェック要件をゆるめましょう。型情報を付与すると横方向に長くなってしまうため、79だとすぐに改行が必要となってしまい、見にくくなりがちです。

```json:.vscode/settings.json
{
    "python.linting.flake8Args": ["--max-line-length", "88"]
}
```

**8/8追記 (ここまで)**

テスティングフレームワークもお好みで選べます。標準のunittest、nose、pytestがサポートされています。昔はテストをまとめて実行するのを簡単にしたいときはnose、強力なテスティングフレームワークが欲しい人はpytestで、unittestは必要最低限という雰囲気でした。しかし、Python3系になって、unittestにも数々の強力な機能が入りましたし、どれを使っても不満が出ることはないでしょう。今回はunittestにします。testフォルダ以下にあるテストを自動収集して実行するように[テストディスカバリ](https://docs.python.jp/3/library/unittest.html#test-discovery)を使うようにしています。

```json:.vscode/settings.json
{
    "python.testing.unittestEnabled": true,
    "python.testing.unittestArgs": [
        "discover", "test"
    ]
}
```

それ以外の各種設定はお好みで選んでください。最終的には以下のようになりました。

* [Python settings reference](https://code.visualstudio.com/docs/python/settings-reference)

```json:.vscode/settings.json
{
    "python.venvPath": ".venv",
    "python.pythonPath": "${workspaceFolder}/.venv/bin/python",
    "python.formatting.provider": "black",
    "python.linting.flake8Enabled": true,
    "python.linting.mypyEnabled": true,
    "python.testing.unittestEnabled": true,
    "python.testing.unittestArgs": [
        "discover", "test"
    ]
}
```

実際にコードをリポジトリに格納するときは、この.vscodeフォルダと、Pipfile、Pipfile.lockファイルを入れておきます。

さて、このフォルダをVisual Studio Codeで開いてください。まずはPython拡張をインストールするか聞かれます。その後は有効にしたformatterやらlinterをインストールするか聞いてくるはずです。

最後に.gitignoreも追加しておきましょう。今回扱った環境では、mypyのキャッシュ、 ``.venv`` 、最近のPythonが生成する ``__pycache__`` などが管理対象外のファイルになります。これらは以下のgithubのデフォルトテンプレートに含まれているので、これを置けば大丈夫です。

* [Pythonの.gitignoreの雛形](https://github.com/github/gitignore/blob/master/Python.gitignore)

# プロジェクトの環境を他の開発者がセットアップ

チーム内で設定を正しく共有するのはチーム開発では大切です。ここまで準備できていれば、ここは簡単です。

1. まずは、[Python処理系環境構築](#Python処理系環境構築)を参考にして、pipenvを使えるようにしてください。
2. 次にこのリポジトリをクローンしてきます。クローンしたらこのフォルダ内部でpipenv installを最初に実行してください。必要なライブラリやツールがインストールされます。
3. 最後にVisual Studio Codeでこのフォルダを開きます。Python拡張をインストールするか聞かれますのでインストールします。また、最初にPythonのファイルを開いたタイミングでlinterやformatterなどをインストールするか聞いてきます。OKを押して行けば、すべての設定が完了した環境ができあがっているはずです。

# 開発の様子

mypyも有効になっているため、どんどん型ヒントをつけてコードを書くことができます。またコードを書く時にもヒントがどんどん出てくるので、少し前のPython環境しか知らないと、かなり時代が変わった感じがあります。保存時にlinterやformatterも有効になっています。とても楽しいです。

オライリーのGo言語でつくるインタプリタをPythonで実装してみようかと思っていたので、その最初のテストのところまでのソースを貼っておきます。まず、monkeyとtestのフォルダを作って、それぞれに空の ``__init__.py`` ファイルを配置しておきます。ファイルは2つ作成しました。Python 3.7から導入のdataclassも使ってみましょう。

```py:monkey/token.py
import typing
import dataclasses


@dataclasses.dataclass(frozen=True)
class Token:
    type: str
    literal: typing.Union[str, None]


ILLEGAL = "ILLEGAL"
EOF = "EOF"

# identifier
IDENT = "IDENT"
INT = "INT"

# operator
ASSIGN = "="
PLUS = "+"

# delimiter
COMMA = ","
SEMICOLON = ";"

LPAREN = "("
RPAREN = ")"
LBRACE = "{"
RBRACE = "}"

# keywords
FUNCTION = "FUNCTION"
LET = "LET"
```

Lexerの方はmypyの型チェックがガンガン行われるので、入力しているそばからミスがわかって効率よくコード書けますね。

```py:monkey/lexer.py
import re
import typing
from . import token


class Lexer:
    def __init__(self, input: str) -> None:
        self._input = input
        self._position = 0
        self._read_position = 0
        self._ch: typing.Union[str, None] = None
        self._read_char()

    def next_token(self) -> token.Token:
        self._skip_whitespace()

        if self._ch == "=":
            tok = token.Token(token.ASSIGN, self._ch)
        elif self._ch == ";":
            tok = token.Token(token.SEMICOLON, self._ch)
        elif self._ch == "(":
            tok = token.Token(token.LPAREN, self._ch)
        elif self._ch == ")":
            tok = token.Token(token.RPAREN, self._ch)
        elif self._ch == "{":
            tok = token.Token(token.LBRACE, self._ch)
        elif self._ch == "}":
            tok = token.Token(token.RBRACE, self._ch)
        elif self._ch == ",":
            tok = token.Token(token.COMMA, self._ch)
        elif self._ch == "+":
            tok = token.Token(token.PLUS, self._ch)
        elif self._ch is None:
            tok = token.Token(token.EOF, None)
        self._read_char()
        return tok
    
    def _read_char(self) -> None:
        if self._read_position >= len(self._input):
            self._ch = None
        else:
            self._ch = self._input[self._read_position]
        self._position = self._read_position
        self._read_position += 1
    
    def _peek_char(self) -> typing.Union[str, None]:
        if self._read_position >= len(self._input):
            return None
        else:
            return self._input[self._read_position]
    
    def _read_identifier(self) -> str:
        pos = self._position
        while is_letter(self._ch):
            self._read_char()
        return self._input[pos:self._position]

    def _skip_whitespace(self) -> None:
        while self._ch is not None and self._ch in " \r\n\t":
            self._read_char()


letter_pattern = re.compile(r"[a-zA-Z_]")


def is_letter(ch) -> bool:
    return bool(letter_pattern.match(ch))
```

テストは、こんな感じです。テストディスカバリの設定で、testフォルダ以下にあるコードをテストとみなす、と設定したので、test以下におきます。それ以外では次の条件でテストコードを探すので、それに合わせたルールでテストを記述します。

* test*.pyにマッチするファイル名
* Test*にマッチし、unittest.TestCaseを継承したテストケースクラス
* test_*にマッチするテストメソッド

Go言語で作るインタプリタ本では、Goで推奨されているテーブルテストが多様されています。原著の方でも、Go 1.7から導入されたサブテストが書かれていないので、ループの中を ``t.Run(t, func(t *testing.T) {`` でくくる方が今時でイケてる（死語）コードになりますが、Python 3にもサブテストがあるので(``with self.subTest(expected_literal):`` のところ)それを使います。

```py:test/test_lexer.py
import unittest
from monkey import token
from monkey import lexer


class TestTokenizer(unittest.TestCase):
    def test_next_token(self):
        input = """
        let five = 5;
        let ten = 10;
        let add = fn(x, y) {
            x + y;
        };
        let result = add(five, ten);
        """

        tests = [
            (token.LET, "let"),
            (token.IDENT, "five"),
            (token.ASSIGN, "="),
            (token.INT, "5"),
            (token.SEMICOLON, ";"),

            (token.LET, "let"),
            (token.IDENT, "ten"),
            (token.ASSIGN, "="),
            (token.INT, "10"),
            (token.SEMICOLON, ";"),

            (token.LET, "let"),
            (token.IDENT, "add"),
            (token.ASSIGN, "="),
            (token.FUNCTION, "fn"),
            (token.LPAREN, "("),
            (token.IDENT, "x"),
            (token.COMMA, ","),
            (token.IDENT, "y"),
            (token.RPAREN, ")"),
            (token.LBRACE, "{"),
            (token.IDENT, "x"),
            (token.PLUS, "+"),
            (token.IDENT, "y"),
            (token.SEMICOLON, ";"),
            (token.RBRACE, "}"),
            (token.SEMICOLON, ";"),

            (token.LET, "let"),
            (token.IDENT, "result"),
            (token.ASSIGN, "="),
            (token.IDENT, "add"),
            (token.LPAREN, "("),
            (token.IDENT, "five"),
            (token.COMMA, ","),
            (token.IDENT, "ten"),
            (token.RPAREN, ")"),
            (token.SEMICOLON, ";"),

            (token.EOF, None),
        ]

        lex = lexer.Lexer(input)

        for expected_type, expected_literal in tests:
            with self.subTest(expected_literal):
                tok = lex.next_token()
                self.assertEqual(tok.type, expected_type)
                self.assertEqual(tok.literal, expected_literal)

```

ここまでできたら、あとは手を動かすのみですね。

# この説明が将来変更になりそうなポイント

最初のシステムの設定でPIPENV_VENV_IN_PROJECTという環境変数を設定しました。これの設定の有無による違いは仮想環境の構成ファイルを格納するフォルダが作成される位置です。

* これを設定すると、カレントフォルダ内に.venvフォルダが作成される
* これを設定しないと、.local/share/virtualenvs/以下にフォルダが作成される

これを設定すると、Pythonの実行ファイル置き場が、.venv/bin/pythonと確定します。そのため、python.pythonPathという設定にパスが書けるようになります。設定しないとユーザーごとに異なるパスになるため、設定ファイルを共有することができなくなります。python.pythonPathがないと、パスを探して、OSデフォルトのPythonを設定しようとしたりします。

このPythonパスが確定すると、formatterとかlinterの追加インストール先がこのPythonの環境の中になってくれます。プロジェクトごとに固有の設定になりますし、ユーザー権限でインストールできます。設定ファイルの共有に意味がでてきます。

本来はPipfileをみて環境を探して、Pythonのパスもそこから自動取得してほしいところです。[ちょうど1週間前にリリースされたPyCharm 2018.2](https://blog.jetbrains.com/pycharm/2018/07/pycharm-2018-2-out-now/)も、このあたりを大幅に強化しているので、VSCodeも今後はここを強化して、PIPENV_VENV_IN_PROJECTと、python.pythonPath、python.venvPathを設定しなくても済むようになったりするんじゃないですかね。そもそも、node.jsの方式で困っていないので、PIPENV_VENV_IN_PROJECTがデフォルトでもいいのに、という気がしないでもなく。

# 最終的に導入したツール類と置き場

|インストール場所|アプリケーション・ツール|メモ|場所(MacPorts利用時)|
|:-----|:----|:-----|:-----|
|システム|Python|何で入れてもたぶん結果は同様|/opt/local/Library/Frameworks/Python.framework/Versions/3.7/bin|
|システム|pip (10)|1度しか使わない|/opt/local/Library/Frameworks/Python.framework/Versions/3.7/bin|
|ユーザー|pip (18)|1度しか使わない|~/Library/Python/Versions/3.7/bin|
|ユーザー|pipenv|日々使うしVSCodeから見えるようにパス通す必要あり|~/Library/Python/Versions/3.7/bin|
|システム?|Visual Studio Code|開発環境|/Applications|
|プロジェクト|mypy|VSCodeが裏で使う|${workdirectory}/.venv/bin|
|プロジェクト|flake8|VSCodeが裏で使う|${workdirectory}/.venv/bin|

ユーザーのpipはそのうちいらなくなりますかね。Python 3.7とpipの開発のタイミングの問題の気もするので。