ぼくのかんがえたさいきょうのPythonフォーマッタ

computer_neochi

ぼくは生産性の低いプログラマです。あまりにも生産性が低くて困ってしまったので、一体何が生産性を下げているのか調査してみました。すると、コードを書いている時間の半分を、なんとコードの整形に費やしていることがわかったのです!

一行が80文字を超えたら、改行を入れなければいない。逆にすべてが一行に収まるようになったら、途中の改行とインデントを削除して一行に戻さなければいけない。Q&Aサイトからコピペしたコードは、プロジェクトのコーディング規約に合わせてスペースを入れたり削除したりしなければならない。import文は辞書順に並べなければならない・・・。こんな整形を繰り返してると、あっという間に時間が過ぎてしまいます。

EclipseのPyDevやPyCharmのような高機能なIDEを使ってる人にはない悩みかもしれませんが、テキストエディタで開発してるとほんとこういう作業が多い。そこで、OSSのツールを使って自動化することにしました。

各種整形ツール

Pythonにはソースコードの自動整形を行うツールがいろいろあります。

autopep8

autopep8は自動整形ツールでは古参のOSSで、PEP 8に準拠したスタイルに自動整形することを目的としています。autopep8を使うことで次のような整形を行えます(autopep8公式サイトより転載)。

整形前:

import math, sys;

def example1():
    ####This is a long comment. This should be wrapped to fit within 72 characters.
    some_tuple=(   1,2, 3,'a'  );
    some_variable={'long':'Long code lines should be wrapped within 79 characters.',
    'other':[math.pi, 100,200,300,9876543210,'This is a long string that goes on'],
    'more':{'inner':'This whole logical line should be wrapped.',some_tuple:[1,
    20,300,40000,500000000,60000000000000000]}}
    return (some_tuple, some_variable)
def example2(): return {'<foo></bar>has_key() is deprecated':True}.has_key({'f':2}.has_key(''));
class Example3(   object ):
    def __init__    ( self, bar ):
     #Comments should have a space after the hash.
     if bar : bar+=1;  bar=bar* bar   ; return bar
     else:
                    some_string = """
                       Indentation in multiline strings should not be touched.
Only actual code should be reindented.
"""
                    return (sys.path, some_string)

整形後:

import math
import sys


def example1():
    # This is a long comment. This should be wrapped to fit within 72
    # characters.
    some_tuple = (1, 2, 3, 'a')
    some_variable = {
        'long': 'Long code lines should be wrapped within 79 characters.',
        'other': [
            math.pi,
            100,
            200,
            300,
            9876543210,
            'This is a long string that goes on'],
        'more': {
            'inner': 'This whole logical line should be wrapped.',
            some_tuple: [
                1,
                20,
                300,
                40000,
                500000000,
                60000000000000000]}}
    return (some_tuple, some_variable)


def example2(): return ('' in {'f': 2}) in {'has_key() is deprecated': True}


class Example3(object):

    def __init__(self, bar):
        # Comments should have a space after the hash.
        if bar:
            bar += 1
            bar = bar * bar
            return bar
        else:
            some_string = """
                       Indentation in multiline strings should not be touched.
Only actual code should be reindented.
"""
            return (sys.path, some_string)

isort

isortは、import文の並び替えに特化したツールです。単に辞書順に並び替えるだけではなく、以下のようにimport文のマージも行ってくれます(isort公式サイトより転載)。

整形前:

from my_lib import Object

print("Hey")

import os

from my_lib import Object3

from my_lib import Object2

import sys

from third_party import lib15, lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8, lib9, lib10, lib11, lib12, lib13, lib14

import sys

from __future__ import absolute_import

from third_party import lib3

print("yo")

整形後:

from __future__ import absolute_import

import os
import sys

from third_party import (lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8,
                         lib9, lib10, lib11, lib12, lib13, lib14, lib15)

from my_lib import Object, Object2, Object3

print("Hey")
print("yo")

yapf

yapfはここ1年ほどで急速に人気を集めつつある自動整形ツールです。単にPEP 8に準拠させるだけではなく、より積極的にコーディングスタイルに介入することで、フォーマットの好みの違いによる開発者間のもめごとをなくすことを目標にしているそうです。公式サイトには以下のような例が載っています。

整形前:

x = {  'a':37,'b':42,

'c':927}

y = 'hello ''world'
z = 'hello '+'world'
a = 'hello {}'.format('world')
class foo  (     object  ):
  def f    (self   ):
    return       37*-+2
  def g(self, x,y=42):
      return y
def f  (   a ) :
  return      37+-+a[42-x :  y**3]

整形後:

x = {'a': 37, 'b': 42, 'c': 927}

y = 'hello ' 'world'
z = 'hello ' + 'world'
a = 'hello {}'.format('world')


class foo(object):
    def f(self):
        return 37 * -+2

    def g(self, x, y=42):
        return y


def f(a):
    return 37 + -+a[42 - x:y**3]

最強のフォーマッタを考えた

一見yapfですべてこと足りそうに思えるのですが、yapfはimport文をあまり整形してくれません。また、autopep8では行えるもののyapfでは行えない整形も一部存在します。そこで、これら3つのコマンドを連結させて全部の機能を利用できるようにしましょう!

ここでは、

  • まずautopep8で整形を行い
  • 続いてyapfでさらに大胆に整形し
  • 最後にisortでimport文のみを整える

といった順序で自動整形することにします。絶対この順序でなければいけないわけではありませんが、より強力なツールを後で使うようにしたほうが安定すると思います。

それでは、まずは必要なパッケージをインストールしましょう。

pip install autopep8 isort yapf

Pythonファイルの中で各パッケージのAPIを順番に呼び出して、3つのフォーマッタを連結させます。シェルスクリプトで何とかする方法もありますが、どのフォーマッタもPythonで書かれているのでPythonスクリプトから呼び出した方が何かと扱いが楽ですね。これを format.py として保存しましょう。

import sys

import autopep8
from isort import SortImports
from yapf.yapflib.yapf_api import FormatCode


def get_file_contents(path):
    with open(path, 'r', encoding='utf8') as f:
        return f.read()


def put_file_contents(path, contents):
    with open(path, 'w', encoding='utf8') as f:
        f.write(contents)


def beautify_with_autopep8_yapf_isort(path):
    contents = get_file_contents(path)

    autopep8ed_contents = autopep8.fix_code(contents, apply_config=True)
    try:
        yapfed_contents, _ = FormatCode(
            autopep8ed_contents, filename=path, style_config='setup.cfg')
    except SyntaxError as e:
        print(e)
        return False
    isorted_contents = SortImports(file_contents=yapfed_contents).output

    if contents == isorted_contents:
        return False
    put_file_contents(path, isorted_contents)
    return True


if __name__ == '__main__':
    beautify_with_autopep8_yapf_isort(sys.argv[1])

もうひとつ、 setup.cfg を作ってプロジェクトルートに設置しましょう。このファイルには各ツールの設定を書きます。今は設定項目はなにも記入せずデフォルトでいきましょう。

[isort]

[pep8]

[yapf]

では実行してみましょう。autopep8の公式サイトに載っていたコードを整形してみます。 test.py として保存しましょう。

import math, sys;

def example1():
    ####This is a long comment. This should be wrapped to fit within 72 characters.
    some_tuple=(   1,2, 3,'a'  );
    some_variable={'long':'Long code lines should be wrapped within 79 characters.',
    'other':[math.pi, 100,200,300,9876543210,'This is a long string that goes on'],
    'more':{'inner':'This whole logical line should be wrapped.',some_tuple:[1,
    20,300,40000,500000000,60000000000000000]}}
    return (some_tuple, some_variable)
def example2(): return {'has_key() is deprecated':True}.has_key({'f':2}.has_key(''));
class Example3(   object ):
    def __init__    ( self, bar ):
     #Comments should have a space after the hash.
     if bar : bar+=1;  bar=bar* bar   ; return bar
     else:
                    some_string = """
                       Indentation in multiline strings should not be touched.
Only actual code should be reindented.
"""
                    return (sys.path, some_string)

ここで、ターミナルから python format.py test.py を実行すると

import math
import sys


def example1():
    # This is a long comment. This should be wrapped to fit within 72
    # characters.
    some_tuple = (1, 2, 3, 'a')
    some_variable = {
        'long': 'Long code lines should be wrapped within 79 characters.',
        'other': [
            math.pi, 100, 200, 300, 9876543210,
            'This is a long string that goes on'
        ],
        'more': {
            'inner': 'This whole logical line should be wrapped.',
            some_tuple: [1, 20, 300, 40000, 500000000, 60000000000000000]
        }
    }
    return (some_tuple, some_variable)


def example2():
    return {'has_key() is deprecated': True}.has_key({'f': 2}.has_key(''))


class Example3(object):
    def __init__(self, bar):
        # Comments should have a space after the hash.
        if bar:
            bar += 1
            bar = bar * bar
            return bar
        else:
            some_string = """
                       Indentation in multiline strings should not be touched.
Only actual code should be reindented.
"""
            return (sys.path, some_string)

きれいに整形されました(^o^)/

あとは、これをファイルの変更を検知するツールと組み合わせましょう。Ubuntuだと inotifywait が一般的ですね。

お気に入りの setup.cfg

ちなみに、私個人のお気に入りの setup.cfg は以下のようなものです。

あえてPEP 8を無視してインデント幅を2にしているのがポイントです。インデント幅が4だとすぐ80文字になってしまうよ・・・( ・ั﹏・ั)

[isort]
order_by_type = 1
balanced_wrapping = 1
multi_line_output = 3

[pep8]
select = E3

[yapf]
based_on_style = google
split_before_first_argument = true
dedent_closing_brackets = false
indent_width = 2

注意点

とてもとても重要な注意点があります。yapfはPython 3.5で導入されたアンパッキング構文に対応しておらずprint({**{'a':1}, **{'a':2}}) のようなコードを読み込むとエラーになってしまいます。

また、リストやディクショナリのリテラルを何段階も入れ子にしたような構造があると、yapfの動作が目に見えて遅くなる問題も抱えています。

ですので、特に既存のプロジェクトに導入する際には、そのような問題が起こらないか必ず確認しましょう。