nikkie-ftnextの日記

イベントレポートや読書メモを発信

pycodestyleは type(value) == int というコードにどうやって E721 do not compare types を送出する? ソースコードリーディングメモ

はじめに

新世代は この未来だ♪1 nikkieです(ユーフォ アンコン編観た)

タイトルの件が「わたし、気になります!」と、ソースコードを読んで知ったことをまとめます。

目次

pycodestyleのソースコードを読みたくなった経緯

flake8がお怒りなので2typeの返り値を型と比較するコードの代わりに、isinstanceを使いましょうという記事を書きました

今週はLintについてインプットしていて、その中でこの検出の実装を興味深いと感じ始めました。

  • E721はpycodestyleが送出
    • flake8はpycodestyle(と他の2つのLinter)をまとめているにすぎない
  • pycodestyleのソースコードを見ると正規表現を使った実装3

トークン列でもASTでもなく、ソースコード(つまり単なる文字列)に対して正規表現を使って検出しているのです!

正規表現COMPARE_TYPE_REGEXを読み解く

https://github.com/PyCQA/pycodestyle/blob/2.11.0/pycodestyle.py#L128-L131

COMPARE_TYPE_REGEX = re.compile(
    r'[=!]=\s+type(?:\s*\(\s*([^)]*[^ )])\s*\))'
    r'|\btype(?:\s*\(\s*([^)]*[^ )])\s*\))\s+[=!]='
)

reモジュールのドキュメントにあたりながら読み解きましょう

|で2つの正規表現から1つの正規表現に組み上げています

  1. r'[=!]=\s+type(?:\s*\(\s*([^)]*[^ )])\s*\))'
  2. r'\btype(?:\s*\(\s*([^)]*[^ )])\s*\))\s+[=!]='

いくつか共通部分がありますよね。
これから読み解いていきますが、大まかな全体像は以下です。

  • 1は、「== type(...)」や「!= type(...)」にマッチする正規表現です
  • 2は、「type(...) ==」や「type(...) !=」にマッチします

(?:\s*\(\s*([^)]*[^ )])\s*\))ってなんだ?

(?:...)は「普通の丸括弧の、キャプチャしない版」です。
つまり内側の「\s*\(\s*([^)]*[^ )])\s*\)」にマッチする文字列をキャプチャしません。

内側を読み解いていくと

  • \s*:0個以上の空白文字
    • Pythonソースコードがマッチするかを見るので、空白文字とは[ \t\n\r\f\v]と単純に考えています
  • \(エスケープした(
  • 再度の\s*
  • ([^)]*[^ )])(後述)
  • またまた\s*
  • \)エスケープした)

([^)]*[^ )])ですが、キャプチャの()が付いています。
外側の(?:...)はキャプチャしませんが、内側の丸カッコでは再度(?:...)としない限りキャプチャします

キャプチャする文字列[^)]*[^ )]は、文字の集合で表されています

  • [^)]:閉じカッコでない文字
    • これが0個以上
  • [^ )]:半角スペースでも閉じカッコでもない文字(1文字)

つまり、typeに引数が与えられたような場合(type(hoge)type(1))は、この正規表現にマッチし、引数部分がキャプチャされます。

確認例(この記事ではPython 3.11.4で確認しています)

>>> re.match(r'[^)]*[^ )]', "hoge")
<re.Match object; span=(0, 4), match='hoge'>
>>> re.match(r'[^)]*[^ )]', ")hoge")  # )で始まるのでマッチしない
>>> re.match(r'[^)]*[^ )]', "hoge ) ")  # 末尾の半角スペースや)を除いてマッチ
<re.Match object; span=(0, 4), match='hoge'>

落ち穂拾い

  • [=!]===または!=にマッチしますね
    • 組み込みのtypeの返り値の同値性を比較する実装で使われます
  • この前後に入る\s+は1つ以上の空白文字
  • 2にある\b
    • 空文字列にマッチしますが、単語の先頭か末尾でのみです。

    • \btypeなので、先頭に何も来ないtypeでマッチします
    • compute_typeのような文字列にはマッチしません
>>> re.match(r'\btype', "type(")
<re.Match object; span=(0, 4), match='type'>
>>> re.match(r'\btype', "compute_type(")  # typeが単語の途中のため、マッチしない

以上、組み込みのtypeの返り値を==!=で比較するコードを表す正規表現を見てきました。

正規表現を使ってE721をチェックする実装

comparison_type関数にあります。
(そうそう、pycodestyleの実装は1ファイルなんですよ!)

https://github.com/PyCQA/pycodestyle/blob/2.11.0/pycodestyle.py#L1451-L1470

# 一部抜粋
def comparison_type(logical_line, noqa):
    match = COMPARE_TYPE_REGEX.search(logical_line)
    if match and not noqa:
        yield (
            match.start(),
            "E721 do not compare types, ...",
        )
type(
    value
) == int

type(value) \
== int

検出できるか確認です。

>>> from pycodestyle import COMPARE_TYPE_REGEX
>>> COMPARE_TYPE_REGEX.search("if isinstance(obj, int):")  # Okay
>>> COMPARE_TYPE_REGEX.search("if type(obj) is int:")  # Okay
>>> COMPARE_TYPE_REGEX.search("if type(obj) == type(1):")  # E721の例
<re.Match object; span=(3, 15), match='type(obj) =='>
>>> COMPARE_TYPE_REGEX.search("if type(obj) == int:")
<re.Match object; span=(3, 15), match='type(obj) =='>

論理行(文字列)の中に、正規表現に該当する部分文字列があるかsearchして、ある場合はE721を送出。
正規表現で実装できていますね〜

終わりに

pycodestyleがE721を送出するのに正規表現を使った実装でどうやっているのか見てきました。
typeの返り値を==!=で比較する実装って正規表現で表せるんですね!
ソースコード中に正規表現に該当する部分文字列があるか探して検出しています。

今回、pycodestyleは2.11.0を使って動作確認しました。

P.S. 最近pycodestyleのE721がアップデートされました

私がE721に気づいた裏には、どうやらpycodestyle v2.11.0のリリースがあるようです。
https://github.com/PyCQA/pycodestyle/blob/2.11.0/CHANGES.txt#L4

E721: adjust handling of type comparison. Allowed forms are now isinstance(x, t) or type(x) is t. PR #1086, #1167.

NG🙅‍♂️

  • type(x) == t

OK

  • type(x) is t
  • isinstance(x, t)

typeの返り値にisを使って同一性を比較する場合、サブクラスは考慮されません

class User:
    ...


class RoyalUser(User):
    ...


user = User()
if type(user) is User:  # True
    print("userはUserです")
royal_user = RoyalUser()
if type(royal_user) is RoyalUser:  # True
    print("royal_userはRoyalUserです")
if type(royal_user) is User:  # False
    print("royal_userはUserです")
% pycodestyle okorare.py  # 上記のコードは一切怒られません
% python okorare.py
userはUserです
royal_userはRoyalUserです

なので私のオススメはisinstanceです。


  1. flake8に怒られないように# noqaするのではなく、コードに手を入れて怒りを鎮めましょう。この変更の妥当性については、PEP8に「isinstanceを使おう」と記載があることを確認しました(「経緯」に埋め込んだうちの最初の記事にて)
  2. 外部に依存を持たず、可能な限り速く動かすためにこの方針のようです。ref: https://pycodestyle.pycqa.org/en/latest/developer.html#direction
  3. tokenizeモジュールを使った実装を見つけたのですが、今回はアウトプットの範囲外とします。またの機会に