Bash(Shell Script)、もう嫌:BashをPython3.xに置換するために調査した内容

  • 42
    いいね
  • 3
    コメント

前書き

本記事には、Bash ScriptをPython3.xに置換するために調査した内容を記載しています。
置換を考えた動機は、「300Step超のBash Scriptは、自分の首を絞める」と察したからです。

Bashの強みは理解しています。
組み込み環境を除けば、主要なディストリビューションでの動作が保証されていますし、
POSIX互換を意識して書けば、移植時の修正箇所が減ります。
何よりも多くの開発者にとって馴染み深く、それはScriptを修正できる人(メンバ)の多さを意味します。
 
 
しかし、現実問題として、私は前述の利点を考えながらBashを書きません。
何も考えずに作成するが故に、後日痛い目を見ます(例:以下の流れ)。
 1. 楽がしたくてScriptを作成
 2. 個人用だが、念のため、Scriptを案件メンバに展開
 3. メンバから苦情を受ける(仮想環境で動かない、堅牢性がない、センスがない、etc...)
 4. 修正の結果、Scriptが肥大化
 5. 夜中に改良点(追加機能)を思いつき、翌朝Scriptを肥大化させる
 6. Scriptに不具合が起きた際、「(規模が大きいから)Aさんが直した方が早い」と言われる
 7. 久しぶりの修正作業で、「何故、この規模をBashで書いたんだ」という後悔に襲われる

このような現実とGoogle先生の助言(以下の引用)を踏まえて、
「2017年からはPython3.x」という結論に至りました。

原文:出典"Shell Style Guide"
If you are writing a script that is more than 100 lines long, you should probably
be writing it in Python instead. Bear in mind that scripts grow. Rewrite your
script in another language early to avoid a time-consuming rewrite at a later date.


100行以上の長さのスクリプトを書いているならば、おそらくその代わりにPythonで書くべきです。
スクリプトが成長することに注意してください。後の時間のかかる修正を避けるために、別の言語で
スクリプトを早く書き直してください。

検証環境

 ・Debian8.6(64bit)
 ・bash(GNU bash 4.3.30)
 ・python(Version3.5.1, Anaconda 4.0.0)

目次

 1. 「外部コマンド実行」および「外部コマンドとパイプを繋ぐ」
 2. ヒアドキュメントの使い方
 3. ユーザインプットの取得方法
 4. 標準出力の文字色の変更方法
 5. 権限の確認方法
 6. オプションの確認
 7. デバッグ方法

「外部コマンド実行」および「外部コマンドとパイプを繋ぐ」

bashの場合はScript内にコマンドとオプションをそのまま書けば実行できますが、
Pythonの場合はsubprocessモジュール経由で実行します。

外部コマンド(resizeコマンド)の使用例(Bash、Python)は、以下の通りです。

sample.sh
#!/bin/bash                                       
resize -s 30 50       # 一行の文字数が30文字、行数が50行
sample.py
#!/usr/bin/env python3
 -*- coding: utf-8 -*-                                             
import subprocess

# サブプロセスに渡したいコマンド + オプションを記載
# スペースを含む状態(run(["resize -s 30 50"]))で記載するとエラー
subprocess.run(["resize","-s","30","50"]) 

 
外部コマンドとパイプを繋ぐ場合の例は、以下の通りです。
Bashの"|"表記に比べると、記載する内容が多い。

sample.py
#!/usr/bin/env python3                            
import subprocess

# 標準出力、標準エラーに対するパイプを第二引数、第三引数の指定によって繋ぐ
result = subprocess.Popen(["resize","-s","30","50"], \
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = result.communicate() # 標準出力、標準エラーの取得

print("標準出力:%s" % out.decode('utf-8'))   # decode処理を行わないとバイトとして扱われる。
print("標準エラー:%s" % err.decode('utf-8'))

 
外部コマンドの出力結果をテキストファイルに書き込む例は、以下の通りです。
また、"python sample.py"の実行結果を併記します。

sample.py
#!/usr/bin/env python3
import subprocess

# オープンしたログファイル(command.log)に、標準出力を書き込むまでの流れ
# "communicate()"の[0]部分は、複数の返り値の中から標準出力を受け取るための記述
log_file = open("command.log", "w")
result = subprocess.Popen(["resize","-s","30","50"], stdout=subprocess.PIPE)
log_file.write(result.communicate()[0].decode('utf-8'))
log_file.close()

# 追記する場合は、"a"指定でファイルを開く。
log_file = open("command.log", "a")
result = subprocess.Popen(["ls", "-l"], stdout=subprocess.PIPE)
log_file.write(result.communicate()[0].decode('utf-8'))
log_file.close()
command.log
COLUMNS=50;                                                       
LINES=30;
export COLUMNS LINES;
合計 16
-rw-r--r-- 1 nao nao  44 12月 24 19:15 command.log
-rw-r--r-- 1 nao nao 543 12月 24 15:58 python_bash.txt
-rwxr-xr-x 1 nao nao 673 12月 24 19:15 sample.py
-rwxr-xr-x 1 nao nao  77 12月 24 15:58 sample.sh

ヒアドキュメントの使い方

サンプルファイルや雛形を作成する際に、ヒアドキュメントを使用する機会があります。
以下に、BashとPythonのヒアドキュメントの使用例を記載します。

sample.sh
#!/bin/bash
VAR="check"

# ヒアドキュメントの内容をoutput.txtに出力する。
cat << EOS > output.txt
①

ヒアドキュメントの範囲は、"<< EOS"をコマンドに渡した行の次の行(①)から、
次に単独で"EOS"が記載された行の一つ上の行(②)までです。
EOS部分は、自由な文字列で問題ありませんが、
ヒアドキュメントの開始と終了で、同じ文字列を用いなければなりません。

$VAR   # ヒアドキュメント内で変数を使用する場合。
\$VAR  # 変数として出力しない場合。     

②
EOS
sample.py
#!/usr/bin/env python3
VAR="check"

# ヒアドキュメント末尾の[1:-1]がない場合、文字列の前後に空白行が含まれる。
heredoc = """
Pythonのヒアドキュメントでは、
3つのダブルクォーテーションで文字列の前後を囲みます。
ヒアドキュメント内で変数を展開する場合は、
    {VAR}
というように記載します。
そして、文字列内変数は関数format()で展開されます。
"""[1:-1].format(**locals())  # **指定によって、変数をディクショナリ{'VAR':"check"}で受け取る。

output = open("output.txt", "w")
output.write(heredoc)
output.close

ユーザインプットの取得方法

ユーザインプットの取得は、下表の3種類の例を以下に示します。

入力文字数 エコーバック Enter(決定)の必要性
例1 制限なし あり 必要
例2 制限なし なし 必要
例3 一文字 なし 不要
sample.sh
#!/bin/bash
echo -n "文字を入力してください:"  # オプション"-n"は、改行の抑制
read USER_INPUT
echo "${USER_INPUT}"

echo -n "エコーバック無しの入力受付:"
stty -echo        # エコーバック OFF
read USER_INPUT
stty echo         # エコーバック ON
echo "${USER_INPUT}"

echo -n "一文字の入力受付。Enter入力は不要:"
read -s -n 1 USER_INPUT  # Bash限定
echo "${USER_INPUT}"
sample.py
#!/usr/bin/env python3
import os
import getch # "pip install getch"などでインストールする必要がある。

print("文字を入力してください:", end="")
user_input = input()
print(user_input)

# 以下の方法以外に、getpassモジュールのgetpass()を使用すれば、
# エコーバック無しの入力受付ができます。
# ただし、ターミナル上にはデフォルトで"Password:"と表示されます。
print("エコーバック無しの入力受付:", end="")
os.system("stty -echo")
user_input = input()
os.system("stty echo")
print(user_input)

print("一文字の入力受付。Enter入力は不要:", end="")
user_input = getch.getch()
print(user_input)

標準出力の文字色の変更方法

error message用(赤色)、warning message用(黄色)の文字色のみを以下に示します。
Bash側は、引数ではなくパイプで文字列を渡せる関数の方が使い勝手が良いと思われますが、
サンプルなので引数渡しの形式としています。

sample.sh
#!/bin/bash
function error_message (){
    echo -n -e "\033[31m\c"  # 文字を赤色にするエスケープシーケンス
    echo "$1"
    echo -n -e "\033[m\c"   # 文字色を元に戻す
}
function warning_message (){
    echo -n -e "\033[33m\c"  # 文字を黄色にするエスケープシーケンス
    echo "$1"
    echo -n -e "\033[m\c"   # 文字色を元に戻す
}

error_message "error"
warning_message "warning"
sample.py
#!/usr/bin/env python3
def error_message(message):
    print("\033[31m%s\033[0m" % message)

def warning_message(message):
    print("\033[33m%s\033[0m" % message)

error_message("error")
warning_message("warning")

権限の確認方法

Scriptの実行時に管理者権限を必要とする場合の例は、以下の通りです。

sample.sh
#!/bin/bash

# 実行UIDとUIDを確認し、"0"(root)であれば管理者権限を持つ。
# ":-"部分は${EUID}に値が入っていなければ、${UID}の値を代入するという意味
if [ ${EUID:-${UID}} = 0 ]; then
    echo "管理者権限を持っています。"
else
    echo "このスクリプトの実行には、管理者権限が必要です。"
fi
sample.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os

# UID、EUIDの取得によって、管理者権限の有無を確認する。
if os.getuid() == 0 and os.geteuid() == 0:
    print("管理者権限を持っています。")
else:
    print("このスクリプトの実行には、管理者権限が必要です。")

オプションの確認方法

Bashの場合は、getoptsを利用してオプションを解釈します。
この際、定義していないオプションを用いてScriptを実行した場合、
自動でエラー文が表示されます。以下にサンプルスクリプトと実行結果を示します。

sample.sh
#!/bin/bash

# getoptsは第一引数にオプションの文字を渡し、
# そのオプション(例:"d")が引数を取る場合はオプション文字直後に":"を書きます(例:"d:")。
# 以下の例では、"d"と"f"が引数を必要とするオプションです。
while getopts d:f:h OPT
do
    case $OPT in
        d)  DIR_NAME=$OPTARG   # オプション引数は変数OPTARGに格納されている。
            ;;
        f)  FILE_NAME=$OPTARG
            ;;
        h)  echo "スクリプトの使い方はxxxです。"
            exit 0
            ;;
        *) echo "スクリプトの使い方はxxxです。"  # 指定していないオプションが来た場合
            exit 1
            ;;
    esac
done

echo "ディレクトリ名:${DIR_NAME}"
echo "ファイル名:${FILE_NAME}"
実行結果.
$ bash sample.sh -f file_name -d directory_name
ディレクトリ名:directory_name
ファイル名:file_name

$ bash sample.sh -f 
sample.sh: オプションには引数が必要です -- f
スクリプトの使い方はxxxです。

$ bash sample.sh -k
sample.sh: 不正なオプションです -- k
スクリプトの使い方はxxxです。

 
Python3の場合は、argparseモジュールが強力な仕組みを提供してくれます。
ヘルプを出力する関数を作成する必要はありませんし、Bashと異なりロングオプションが使用できます。
以下にサンプルスクリプトと実行結果を示します。

sample.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse

# 関数add_argument()でオプションを追加します。
# 一度に二種類の記法(例:"-d"、"--dir")を登録できます。
# typeにはオプション引数の型、destにはオプション引数(値)の格納先となる変数名を書きます。
# helpには、このオプションの意味合い(ヘルプ時に表示する文章)を記載します。
parser = argparse.ArgumentParser()
parser.add_argument("-d", "--dir", type=str, dest="dir", 
                    help="Scriptで使用するディレクトリ名を書きます")
parser.add_argument("-f","--file", type=str, dest="file",
                    help="Scriptで使用するファイル名を書きます")
args = parser.parse_args()

print("ディレクトリ名:%s" % args.dir)
print("ファイル名:%s" % args.file)
実行結果.
$ python sample.py -d directory_name -f file_name
ディレクトリ名:directory_name
ファイル名:file_name

$ python sample.py -h
usage: sample.py [-h] [-d DIR] [-f FILE]

optional arguments:
  -h, --help            show this help message and exit
  -d DIR, --dir DIR     Scriptで使用するディレクトリ名を書きます
  -f FILE, --file FILE  Scriptで使用するファイル名を書きます

$ python sample.py -s
usage: sample.py [-h] [-d DIR] [-f FILE]
sample.py: error: unrecognized arguments: -s

デバッグ方法

Bashの場合は、非常に原始的な方法でデバッグ作業を行います。
一般的には、以下のオプションを用いて、Scriptのデバッグを実践していると思います。

オプション 説明
-u 未定義の変数が存在すれば、処理を終了する
-v Scriptの内容を実行順で表示する。
変数名はそのまま表示される。
-x Scriptの内容を実行順で表示する。
変数は展開され、デバッグ情報が表示できる

使い方として、上記のオプションをShebangに付与(例:"#!/bin/bash -x")したり、
Scriptのデバッグを開始したい位置に"set -x"、終了したい位置に"set +x"を挿入します。
-xオプションはヌルコマンド(:)を表示するため、デバッグ時のみ表示されるコメントが挿入できます。

以下に、サンプルスクリプトと実行結果を示します。

sample.sh
#!/bin/bash -x                                                                  
START="スクリプトの開始"
END="スクリプトの終了"

echo ${START} # 変数が展開された状態で表示される
set +x        # オプションの解除
              : このヌルコマンドは表示されない
echo "このechoコマンドは、文字列部分のみ出力される"

set -x      # オプションの有効化
            : このヌルコマンドは表示される
echo "${END}" 
実行結果.
$ ./sample.sh 
+ START=スクリプトの開始
+ END=スクリプトの終了
+ echo スクリプトの開始
スクリプトの開始
+ set +x
このechoコマンドは、文字列部分のみ出力される
+ : このヌルコマンドは表示される
+ echo スクリプトの終了
スクリプトの終了

Python3の場合、デバッガpdbによるデバッグが可能です。
使い方は、"import pdb; pdb.set_trace()"をデバッグしたい箇所に挿入し、
Scriptが前述の挿入位置に到達した段階でデバッガが起動します。

私自身がpdbを使い込んでいないため、今回は参考にした記事を記載します(今後追記します)。
 ・「pdb — Python デバッガー
 ・「PythonデバッグTips(@TakesxiSximada)」
 ・「Pythonにおける効率的なデバック方法入門

最後に

非常に基本的な内容ですが、「予想より調査に時間がかかった項目」があったため、
備忘録として残しておきます。

BashをPythonに置換するという観点では、
 ・「Bashのtrap(シグナル検知)相当は?」
 ・「そもそもcd/pwd/mkdirなどの基本的なコマンドに対応するPython関数は?」
 ・「sedやawkでファイルを加工したほうが楽なのか、Pythonを使うべきなのか」
と、調べる事柄が尽きません。後日、この内容もQiitaで記事にします。

追記:Pythonで痛い目にあった話

BashではなくPython3.x。その考えに至ってから半年が経ちました。
この半年で、一度だけPython絡みで痛い目を見ました。作成したPython3が動かなかったのです。

開発環境(Debian8.8)から別の検証環境(CentOS7.x)にPythonスクリプトを移行し、
CentOS上で実行を試みたら、スクリプトが動作しませんでした。
原因は、CentOS7.xはデフォルトで2.7を用いているからです。
しかも、検証環境であるが故に、新しいパッケージ(Python3)を入れる事ができず、
泣く泣く3.xから2.7で動作するように、Pythonを書き換えました。

この経験より、「実行環境を確認してから、スクリプトを書く」という知見を得ました。
大事です、Runtime。