カイワレの大冒険 Third

技術的なことや他愛もないことをたまに書いてます

Python製コマンドラインツールのディレクトリ構成について。その考察。

はじめに

去年ぐらいからPython製のコマンドラインのツールをいくつか作っていて、構成もだいぶ固まってきたので、まとめてみる。規模としては1ファイルでは終わらないぐらいで、関数の数も数十になってユーティリティを作ったり、クラスをいくつか作らないと、保守がしにくいような規模のものを想定しています。工数としては1日では終わらないけど、2週間はかからない程度の規模を想定。

構成

ということで、まず構成をさらしてみます。 こんな感じ。 SAMPLE_PROJECTレポジトリがあったとして、その具体的な構成が以下。

.
├── README.md
├── RELEASE.md
├── TODO
├── bin
│   ├── command1
│   ├── command2
│   └── command3
├── SAMPLE_PROJECT
│   ├── __init__.py
│   ├── constants.py
│   ├── log.py
│   ├── log_config.ini
│   ├── constants.py
│   └── util.py
├── flake8_config
├── prepare.sh
├── pytest.ini
├── requirements.txt
├── setup.py
├── test_cov.sh
└── tests
    ├── _settings.py
    ├── test_log.py
    └── test_util.py

ディレクトリのトップには、以下のファイル・ディレクトリがある。

  • README.md
  • RELEASE.md
  • TODO
  • bin/
  • SAMPLE_PROJECT
  • flake8_config
  • prepare.sh
  • pytest.ini
  • requirements.txt
  • setup.py
  • test_cov.sh
  • test/

ということで、一つ一つ説明していく。

説明

以下の説明では、「SAMPLE_PROJECT」という記述をたくさんしますが、自分で作る場合は実際のプロジェクト名に置き換えてください。

README.md

特に説明することはない。仕様だったり、コマンドの使い方の例だったりを書いておく。Jenkinsのビルドステータスなども載せておくとよりプロジェクトっぽい感が出る。

RELEASE.md

タグを切ったら、それぞれのバージョンについて詳細を書いておく。日付も入れておいて、少し情報量増やしておく。

TODO

備忘録用。箇条書きで書いておいたりする。TODO管理ソフト使ってもいいし、そのへんは気分だったりする。

bin/

実際のコマンドが置かれているディレクトリ。このディレクトリにあるファイルではあまり関数定義はしない。次に説明するSAMPLE_PROJECTディレクトリにあるファイルのほうで関数定義をして、こちらではそれを利用するだけ。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

from SAMPLE_PROJECT import log
from SAMPLE_PROJECT.lock import create_lock_file
from SAMPLE_PROJECT.lock import delete_lock_file
from SAMPLE_PROJECT.util import check_python_version
from SAMPLE_PROJECT.util import check_exec_user
import argparse

def argument_parse():
    __description='This script is...'
    parser = argparse.ArgumentParser(description=__description)
    parser.add_argument('-f',
        dest='config_file',
        default=None,
        help='設定ファイルを指定してください。',
        required=True
    )
    __args = parser.parse_args()
    return __args


# メイン処理開始
if __name__ == '__main__':

    __args = argument_parse()

    # Lock作成
    create_lock_file()

    # ここに実処理書く


    # Lock解除
    delete_lock_file()

最初の2行は実行スクリプトであるのでインタープリタの指定をしてるのと、文字コードを指定してる。 また、SAMPLE_PROJECTディレクトリ配下からのディレクトリ構成で、importを書いていく。

通常はプロジェクト配下のimportが大変(ex: bin/からSAMPLE_PROJECT/配下のファイルをimportするのが面倒)なのだけれど、 pip install -e . を実行することで、パスを通すようにしている。

また、コマンドライン引数を便利に使いたいので、argparseを使って、argument_parseという関数を作ってる。

SAMPLE_PROJECT

一番大事なディレクトリ。util.pyなどには便利な関数が沢山定義されている。具体的には今まで作ったPython, Ruby, Bashの関数を色々晒してみる - カイワレの大冒険 Thirdで書いたようなものが多い。 コマンドラインツールであれば、SSH経由でコマンド叩いたりするので、たとえば、以下のような「ssh.py」を用意してもいい。

import paramiko
import sys
from SAMPLE_PROJECT.constants import Constants

class Ssh:
    def __init__(self, user_name, remote_host, port, private_key, timeout):
        self.user = user_name
        self.host = remote_host
        self.port = port
        self.private_key = private_key
        self.timeout = timeout

    def _command_execute(self, command):
        from hogehoge import log
        log.output('Command Execute: ' + command, 'debug')

        try:
            self.__create_connection()
            stdin, stdout, stderr = self.client.exec_command(command)

            stdout_msg = stdout.read()
            stderr_msg = stderr.read()

            # エラー出力があれば、例外スロー
            if stderr_msg:
                stderr_decoded = stderr_msg.decode(sys.stdout.encoding)
                log.output(stderr_decoded, 'warning')
                raise Exception

            stdout_decoded = stdout_msg.decode(sys.stdout.encoding)
            return stdout_decoded

        except paramiko.SSHException as e:
            log.output("Password is invalid:" + str(e))
        except paramiko.AuthenticationException as e:
            log.output("We had an authentication exception!" + str(e))
        except Exception as e:
            log.output("### SSH Client Exception Catched! ###" + str(e))

    def __create_connection(self):
        u""" コネクションを作成します。
        """
        self.conn = self.client.connect(
            hostname=self.host,
            port=self.port,
            username=self.user,
            key_filename=self.private_key,
            timeout=self.timeout
        )
        return self.conn

    def __enter__(self):
        u""" オブジェクト生成時にSSHクライアントを生成します。
        """
        self.client = paramiko.SSHClient()
        self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        return self

    def __exit__(self, exec_type, exec_value, traceback):
        self.client.close()

あとは、Ssh()としてコンストラクタを呼べば、オブジェクト化できるので、自由にbin/配下で使えるようになる。

他には上記の記事で紹介したロック処理を賄う「lock.py」などもある。また、定数を定めたconstants.pyがある。それは以下のようなものになる。

# ---------------
# COMMON
# ---------------

# lock
lock_file_dir = ''
lock_file_name = 'sample.lock'

# log
log_config_file_name = 'log_config.ini'

# ssh
ssh_port = 22

# apps
bin_dir = 'bin'
src_dir = 'SAMPLE_PROJECT'
exec_user = 'masudak'


CONSTANTS = {
    'DEFAULT': {
        'lock': {
            'FILE_DIR': lock_file_dir,
            'FILE_NAME': lock_file_name
        },
        'log': {
            'config_file_name': log_config_file_name
        },
        'ssh': {
            'USER_NAME': 'sample_user',
            'PASSWORD': 'sample_pass',
            'PORT': ssh_port
        },
        'apps': {
            'BIN_DIR': bin_dir,
            'SRC_DIR': src_dir,
            'EXEC_USER': exec_user
        },
    },
    'DEV': {

    },
    'PRODUCTION': {

    }
}

色々調べたのだけれど、Pythonのプロジェクトで環境変数を駆使できるような定数の管理方法がどうも見つからなかった。そのため、上記のような管理をし、その上で環境変数を取得して、適切な値を取れるようにしている。

たとえば、util.pyに以下のような関数を作っておく。

def retrieve_env():
    u""" 環境変数を参照し、スクリプト実行環境を判別します。

         実行時デフォルトはdefaultを返すようになっています
         「SCRIPT_ENV=production hogehoge.py」のように実行することで、
         環境に応じたコンフィグを得ることができます。
    """
    __env = 'DEFAULT'
    if 'SCRIPT_ENV' in os.environ:
        __env = os.environ['SCRIPT_ENV']
    return __env

こうして次に定数を使いたいファイルで以下のようにimportする。

from SAMPLE_PROJECT.constants import Constants

何度もいうが、このSAMPLE_PROJECTからのimportは「pip install -e .」しないといけない。

そして、関数内とかで以下のように使う。

from SAMPLE_PROJECT.util import retrieve_env
__env = retrieve_env()
return CONSTANTS[__env]['apps']['EXEC_USER']

他にはログの設定をいれたlog_config.iniなどもここには置かれている。

いずれにしても、このディレクトリ内の処理を充実させることにかなり時間を使う。そのため、実装の前段階で、どういったクラスが必要か、どのような関数を用意しておけばいいかをざっくりイメージして作っていく。その後、必要に応じて別ファイルに切り出したりする(大半は面倒になってくるので、早い段階で設計はFIXしといたほうがいい)。

flake8_config

flake8の設定を書いておく。

[flake8]
ignore = E265
max-line-length = 120
max-complexity = 10

その上で、以下のようにして実行する。

$ flake8 bin/ SAMPLE_PROJECT/ tests/ --config=flake8_config

tests/は対象にしてもしなくてもいいかもしれない。とりあえず、必要と思ったものはflake8通しておく。 実際はpytestとセットにしたtest_cov.sh(後述する)を使うことが多い。

prepare.sh

cloneしたあとに最初にやる処理をまとめてかいておく。

#!/bin/bash

BASE_DIR=`dirname ${0}`
pip install -r "${BASE_DIR}"/requirements.txt
pip install -e .

pytest.ini

pytest用の設定を書いておく。こんな感じ。

[pytest]
addopts = --junitxml=pytest.xml --cov tests -v --cov-report=xml

Jenkinsでテストだけでなくカバレッジも計測したいので、pytest-covの設定をいれてある(でも、カバレッジがそこまで正確ではない気がする)。

requirements.txt

必要なパッケージを記入しておく。たとえば、以下のような感じ。

PyYAML==3.11
cov-core==1.13.0
coverage==3.7.1
mock==1.0.1
pep8==1.5.7
pytest==2.6.0
pytest-cov==1.7.0
pytest-pep8==1.0.6
requests==2.3.0
flake8==2.2.3

これらは前述したprepare.shでインストールされる。

setup.py

pip install -e . したいので、簡単に作っておく。

import os
from distutils.core import setup

setup(name='SAMPLE_PROJECT',
      version='1.0.0'
      )

test_cov.sh

ローカルでコード書いてるときに実行する用。プレコミットで入れてもいいし、エディタに設定してもよい。

#!/bin/bash

py.test && flake8 . --config=flake8_config

これでテストとスタイルチェックができる。

test/

ここはテストを置くディレクトリ。テストの書き方はググってもらうとして、基本的にはSAMPLE_PROJECT/配下にあるファイル単位でテスト用ファイルを作成し書いてる。

また、このディレクトリには、_settings.pyというファイルを作り、以下の記述をしている。

import os
import sys


def get_home_dir(file):
    u""" アプリケーションのホームディレクトリを返します
    """
    return os.path.split(os.path.dirname(os.path.abspath(file)))[0]


def append_home_to_path(file):
    u""" アプリケーションのホームディレクトリをsys.pathに追加します
    """
    sys.path.insert(0, get_home_dir(file))

そして、この_setting.pyを各テストファイルの冒頭で以下のようにimportする。

import _settings
_settings.append_home_to_path(__file__)

そうすることで、テストファイルのなかでもSAMPLE_PROJECTトップからのimportが可能になり、「pip install -e .」と同じような機能を果たしてくれる。

おわりに

色々試行錯誤してこの形になったものの pip install -e . してパスを通すのは妥当かどうかの自信がないし、定数の管理方法もまだ納得いかなかったりする。気に入らないところがまだまだあるので、数年後この記事を見たら、なんでこんなことに悩んでいたのかと思うかもしれない。 ただ、現状自分が書ける範囲で構成は整理してみたし、不便はあるものの、自分としてはわりと見やすい・追いやすいので、今持てる知識で最大限まとめてはいる。 なので、もしおかしいなと思う点があったら、是非暖かくツッコミを頂けるとすごく助かります。よりよい設計で、読みやすいコードを書いていきたいので、識者の方々ご助言是非宜しくお願い致します。

続きを書きました。 Pythonでコマンドラインツールを作りたいときには、setup.pyには何を書くべきか - カイワレの大冒険 Third