2018年5月26日土曜日

シェルスクリプトの代わりにPythonを使う

勉強の為に転載しました。
https://kohkimakimoto.hatenablog.com/entry/2018/01/19/095920

Webプログラミングなどについてあれこれ。

シェルスクリプトの代わりにPythonを使う

これまで、開発や運用時に使う、ちょっとしたコマンドラインツール、自動化スクリプトは、主にBashシェルスクリプトで実装していたのですが、最近このような用途にはPythonを使うようにしています。

Bashスクリプトへの不満

Bashスクリプト実装において、以下のような不満がありました。
  • クラスや連想配列がないので、構造化したデータが扱いづらい。
  • JSONをパースできない。jqなどのシステムにデフォルトでインストールされていないコマンドが必要。
  • 基本、コマンドの組み合わせでロジックを書いていくのだが、MacLinuxで挙動が微妙に異なるコマンドがある。そのため思わぬ環境依存でハマることがある。
  • 関数の戻り値が数値しか返せないので、結果を文字列で欲しいときは、標準出力とパイプを使うなど、いろいろ細かいテクニックが必要。
追記: 連想配列bashにもあるとコメントがあったので修正しました。調べたらバージョン4あたりからサポートされたようです。
ちなみにBashスクリプトを書く上でのテクニックは、rbenvとherokuのbuildpackの実装が参考になります。
また、以下は昔まとめたBashチートシートです。
ともあれ、ある程度複雑な実装になってくると、Bashだとプログラミング言語としての機能が貧弱なので、つらいものがあります。

スクリプトの実装方針

私がスクリプトを書く際の、実装の基本方針は以下のようなものです。
  • MacでもLinux(主にCentOS)でも動作する。
  • なるべく、システムに標準でインストールされているコマンド以外に依存しない。
  • なるべく、1ファイルで実装する。
  • -hオプションでヘルプメッセージ表示に対応させて、必要なドキュメントも内包させる。
要は「Mac上で実装、デバックして、Linuxサーバ上で使う場合は、スクリプトファイル一個を置けば動作する」ように作りたいわけです。PythonMacにもCentOSにもデフォルトで入っているし、標準ライブラリも豊富なので、このようなポータビリティ重視のスクリプト記述に向いていると思いました。

ポータビリティ重視のための縛りPythonプログラミング

Pythonスクリプトを実装していく中で、前述のポータビリティを確保するため、いくつか留意すべき制限があります。
  • Pythonのバージョン2系と3系どちらでも、動作するように書く。
  • 外部パッケージを使わない。
現在のPythonの主流はバージョン3系なので、基本は3系のコードを書きます。しかし、MacCentOSにデフォルトインストールされているPythonは2系なので、そちらでも動くように、適宜ワークアラウンドを入れて、書きます。とはいえ、大がかりなアプリケーションを書くわけではないので、そこまで難しいものではありません。外部パッケージを使わないという縛りは、基本的に実装を1ファイルで済ませたいからです。あくまで、シェルスクリプトの代替として使いたい、というのが今回の目的です。

Tips

以上を背景に、実際にPythonスクリプトを書く際に使っているTipsを紹介します。

__future__モジュールでバージョン2、3両方に対応させる

__future__モジュールを以下のようにしてインポートします。個々の仕様の詳細は、もう忘れてしまいましたが、3系のコードを、2系のランタイムでも使用できるようにするものです。今は何も考えず、おまじないのように、スクリプトの最初に必ず記述する感じです。
from __future__ import division, print_function, absolute_import, unicode_literals

バージョン2、3で異なるモジュールを、同じ名前で読み込む

ConfigParserは、iniファイル形式の設定ファイルを扱うための標準モジュールです。 Bashでは難しい、構造化された外部設定ファイルを扱えるので、便利です。ただ2系と3系でモジュールの名前が変わっているので、以下のようにして、最初に3系のモジュールをimport、失敗したら2系をimportするようにしてます。これで両方のバージョンで同じようにモジュールが使用できます。
try:
    import configparser as ConfigParser
except ImportError:
    # fallback for python2
    import ConfigParser

config = ConfigParser.RawConfigParser()
config.read("/path/to/configfile.conf")

バージョン2、3を判別する関数を用意する

どうしても2系と3系で、処理を切り分ける必要がでてくる場合もあります。このため、バージョンを判別できる関数を定義しておくと便利です。以下のようにruntimeクラスのスタティックメソッドとして実装します。
class runtime:
    @staticmethod
    def v3():
        return sys.version_info >= (3,)

    @staticmethod
    def v2():
        return sys.version_info < (3,)

if runtime.v3(): 
    # バージョン3系のときの処理を書く...

よく使うスニペット

コピペして使っているスニペットをいくつか紹介します。全て、標準モジュールのみに依存するコードです。適宜import osimport subprocessなどのように、事前に必要なモジュールをロードして使います。

文字に色をつける

class colors:
    bold = '\033[1m'
    underlined = '\033[4m'

    black = '\033[30m'
    red = '\033[31m'
    green = '\033[32m'
    yellow = '\033[33m'
    blue = '\033[34m'
    magenta = '\033[35m'
    cyan = '\033[36m'
    lightgray = '\033[37m'
    darkgray = '\033[90m'
    lightred = '\033[91m'
    lightgreen = '\033[92m'
    lightyellow = '\033[93m'
    lightblue = '\033[94m'
    lightmagenta = '\033[95m'
    lightcyan = '\033[96m'
    
    background_black = '\033[40m'
    background_red = '\033[41m'
    background_green = '\033[42m'
    background_yellow = '\033[43m'
    background_blue = '\033[44m'
    background_magenta = '\033[45m'
    background_cyan = '\033[46m'

    reset = '\033[0m'

# 使い方
print(colors.red + "red text" + colors.reset)

赤い文字でエラーメッセージを出力して終了する

前述のcolorsクラスを使います。
def abort(s):
    print(colors.red + s + colors.reset, file=sys.stderr)
    sys.exit(1)

abort("error!")

外部コマンド実行する

シェルスクリプトの代替としてのPythonなので、外部コマンド実行は、ほぼ必ず使います。subprocess.check_outputを使うと、簡単にコマンド実行して標準出力を取得できます。 このメソッドの戻り値は2系と3系で異なるので、前述のrumtimeクラスによるバージョン判定を使って、以下のように利用します。
out = subprocess.check_output("ls -la",  shell=True).strip()
if runtime.v3(): out = out.decode('utf-8')

テキストをファイルに出力する

fd = open("/path/to/file", 'w')
fd.write("""#!/usr/bin/env bash
set -e

echo "generated by Python"

""")
fd.close()

ファイルに実行権限をつける

umask = os.umask(0)
os.chmod("/path/to/file", 0o755)
os.umask(umask)

スクリプトの同時、多重起動防止する

try:
    fd = open(__file__, 'r')
    fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
    print("Another process is using: " + os.path.basename(__file__), file=sys.stderr)
    sys.exit(1)

コマンドライン・オプションの解析

argparseモジュールでできます。以下は最小のサンプルです。
import argparse

parser = argparse.ArgumentParser(
    description="cli application description",
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog="""
additional description...

"""
    )

parser.add_argument("-V", "--version", dest="version", action="store_true", help="Print the version.")
args = parser.parse_args()
if args.version:
    print("v0.0.1")
    sys.exit(0)
これだけで-hオプションで、ヘルプメッセージを表示に対応したコマンドができます。epilogに使い方などを書けば、ちょっとしたドキュメント代わりになります。 ほかにも、gitのようなサブコマンドの作成にも対応していて、Bashに比べると、とても便利です。

HTTPリクエス

以下は、githubapiから、公開鍵を取得するサンプル。ついでにBashでは難しい、JSONのパースも。
#!/usr/bin/env python
from __future__ import division, print_function, absolute_import, unicode_literals

try:
    from urllib.request import urlopen, Request
    from urllib.error import HTTPError
except ImportError:
    # fallback for python2
    from urllib2 import urlopen, Request, HTTPError
import json
import sys

class runtime:
    @staticmethod
    def v3():
        return sys.version_info >= (3,)

    @staticmethod
    def v2():
        return sys.version_info < (3,)

def main():
    res = urlopen("https://api.github.com/users/kohkimakimoto/keys")
    body = res.read()
    if runtime.v3(): body = body.decode('utf-8')

    keys = json.loads(body)
    for key in keys:
        print(key['key'])

if __name__ == '__main__': main()

まとめ

Bashより格段に便利です。pipなどで外部パッケージを使わなくても、ちょっとした自動化スクリプトなら充分カバーできます。

0 コメント:

コメントを投稿