2012年12月6日

Python パッケージを作ってみた (clitool)

似たような処理を書く機会が増えてきましたので、Python のパッケージを作ってみました。

Unix 系のツールのお作法として、次のようなルールがあると思います。

  • 引数でファイルのパスを複数与えられた場合は、それらを連続して処理する
  • 引数が無い場合は標準入力から読み込む

grep, sed, awk, sort などはこれらの挙動をしますので、 パイプライン処理を簡単に実現できます。

上記の条件に沿ったコードを書くこと自体は難しくないと思いますが、 存在しないファイルが指定されたときにどうするか? 入力テキストの文字エンコーディングを指定したときにどうするか? ログ出力のレベルをどうするか? といったことを考え始めると、 それはそれで煩雑になっていくと思います。 1週間に2回以上もそんなことを考えているとうんざりするでしょうし、 そういったエラー処理を考慮しないスクリプトを運用するのも大変でしょう。

上記のパッケージを使うと、Python を素のまま使うよりは簡単に記述できると思います。 Python 2.7 と Python 3.3 で動かせるように書いているつもりです。

この記事ではパッケージの導入方法と簡単なスクリプトを紹介してみたいと思います。

インストール&導入

pip を利用できる環境ならば簡単にインストールできます。

$ pip install clitool

モジュールのスクリプトとして実行できるもの (clitool.cli) もあり、 Python インタープリタの -m オプションに clitool.cli を指定すると スクリプトのひな形が出力されます。

$ python -m clitool.cli
#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Description is here.
"""

import logging

from clitool.cli import climain


@climain
def main(basedir, files, output):
    logging.debug("Base directory  : " + basedir)
    print("Output          : " + output.name)
    if files:
        logging.debug("Input file count: %d", len(files))

if __name__ == '__main__':
    main()

# vim: set et ts=4 sw=4 cindent fileencoding=utf-8 :

ということで、次のようにすると新しいスクリプトを書き始められます。

$ python -m clitool.cli > your-script.py
$ chmod +x your-script.py

これで「 main() 関数を書くのがメンドイ」という言い訳を撲滅できます。 テキストエディタのテンプレート機構などを使えば良いだけの話ですが、 そうしたルールも守れない局面が (たまに) あったりします。。。

コマンドライン引数の処理

ひとまず、次のルールに決めました。

  • -c オプションで、設定ファイルを受け取る。
  • -o オプションで、出力ファイルを指定できる。
  • -v オプションで、ログ出力を冗長にする。
  • -q オプションで、ログ出力を消す。
  • -h オプションで、ヘルプメッセージを出力する。 (argparse の標準機能)
  • --basedir オプションで、基準となるディレクトリを指定する。 (デフォルトはカレントディレクトリ)
  • --input-encoding オプションで、入力の文字エンコーディングを指定する。 (デフォルトは UTF-8)
  • --output-encoding オプションで、出力の文字エンコーディングを指定する。 (デフォルトは UTF-8)
  • オプションではない引数はファイルパスとする。

スクリプトを記述するときは、何を使って何を無視するか決めてある場合が多いので、 main 関数の引数で受け取れるように実装しました。 自前でいちいち argparse から組み立てる手間がなくなります。

次のように記述すると、コマンドライン引数のファイル一覧と入力文字エンコーディングを受け取ります。 引数の順番は順不同です。 上記のスクリプトテンプレートに類似のコードが含まれています。

from clitool.cli import climain

@climain
def main(files, input_encoding):
        print(files)
        print(input_encoding)

argparse にはオプションパーサーの親を指定できますので、拡張したい場合もベースとなる機能を保持できます。 こんな感じです。

parser = argparse.ArgumentParser(parents=[base_parser(), ])

追加引数などもデコレータで指定できると記述が簡単だと思いますが、 デコレータの実装に慣れていないのでひとまず諦めました。 また、設定ファイルの受け取りは -c よりも -f の方が良かったかもしれません。 Command-Line Options として A から Z のアルファベットでどのような入力を受け付けるかは 悩ましいところと言えるでしょう。

なお、argparse の基本的な部分は以前にも少し使ってみました。

設定ファイルの読み込み

設定ファイルを INI 形式にしたり JSON にしたり YAML にしたり、、、結局はキー/バリューのペアを扱うだけのことが多いと思います。 ただ、ローカル開発環境とスーテジング環境、本番環境でデータベース接続や一時ディレクトリを切り替えたい、という要求もあります。

INI 形式だと次のイメージです。 (config.ini)

[development]
database.url=sqlite:///:memory:

[staging]
database.url=postgresql+pypostgresql:///user:pass@host/database

[production]
database.url=mysql:///user:pass@host/database

JSON だと、

{
    "development": {
        "database": {
            "url": "sqlite:///:memory:",
            "auto": true
        }
    },
    "staging": {
        "database": {
            "url": "postgresql+pypostgresql://user:pass@host/database"
        }
    },
    "production": {
        "database": {
            "url": "mysql+mysqlconnector://user:pass@host/database"
        }
    }
}

YAML だと . . .

development:
  database:
    url: "sqlite:///:memory:"

staging:
  database:
    url: "postgresql+pypostgresql:///user:pass@host/database"

production:
  database:
    url: "mysql:///user:pass@host/database"

こうしたファイルから、なんらかの条件に基づいて読み込むセクションを変更したい、という状況です。

実装する人によって「なんらかの条件」が異なってしまうと面倒なので、環境変数 PYTHON_CLITOOL_ENV で指定する、と決めました。

次のようなスクリプト (config-test.py) で確認できます。

from clitool.cli import climain, cliconfig

@climain
def main(config):
        cfg = cliconfig(config)
        print(cfg)

デフォルトは "development" を読むようにしてあります。

$ python config-test.py -c config.ini
{'database.url': 'sqlite:///:memory:'}

環境変数を設定すると、そのセクションを読み込みます。

$ PYTHON_CLITOOL_ENV=staging python config-test.py -c config.ini
{'database.url': 'postgresql+pypostgresql:///user:pass@host/database'}
$ PYTHON_CLITOOL_ENV=production python config-test.py -c config.ini
{'database.url': 'mysql:///user:pass@host/database'}

指定したセクションが存在しない場合には警告メッセージを出力します。

$ PYTHON_CLITOOL_ENV=roduction  python o.py -c ../data/config.ini
WARNING:root:Environment 'roduction' was not found.
WARNING:root:Configuration may be empty.
None

設定ファイルの形式が異なっても同じコードで読み込めますので、 ちょっとしたスクリプトを書き始める障壁が下がるのではないかと思います。 なお、YAML を読み込む場合は別途 PyYAML をインストールしておく必要があります。

実装する中で学習したこと

パッケージとしてまとめてみる中で、次のことを学習できました。

  • PyPI にパッケージを登録する。
  • PyPI にドキュメントをアップロードする。
  • Python 2.x と Python 3.x の文法の違いに対処する。
  • Travis-CI を使ってテストする。

どれも方法は読んだことあるけど実際に手を動かしてないことでしたので、 個人的にはとても勉強になりました。

Python 2.x と 3.x の両方に対応するためには six というライブラリがあります。 これを使うと、ライブラリのインポートをスマートに記述できたり、 Python 2.x と 3.x で挙動が変わるコードをラップできるようです。 いきなりあれもこれもと手を広げてしまうと大変なので、 まずは依存関係が少ない方法で実装しました。

終わりに

clitool という Python パッケージを実装してみました。 コマンドライン処理のサポートパッケージとしては paver もありますが、 setuptools に寄った感じもしましたので自前で実装しました。

他には clint (Command Line INterface Tools) パッケージもあり、これは実装した後で知りました。 上述のコマンドライン引数処理、設定ファイルの読み込み (書き込み) もできます。 インデントを揃えてコンソールに出力したり、色付けもサポートしています。 作者は requests パッケージを開発している人で、API が分かりやすいと思います。 説明文ではクリント・イーストウッドの顔写真を使っていて思わずクスッとなります。

clitool パッケージには上記の2点以外にもいくつかの機能がありますので、 記事を改めて書いていこうと思います。 ちょっとしたデータを読み込んで形式を変換する、という用途を意図しています。

コマンドライン引数の基本的な考え方は下記の記事 (英語) が分かりやすいと思います。

2 件のコメント:

Unknown さんのコメント...

kennethreitzさんは heroku の人でフォローしてると楽しい人です。

Shigeru さんのコメント...

そうなんですよ。レポジトリをつらつらと眺めていたら気付きました!
Python Guide なんかも書いていてスゴいですね〜