PythonでBashスクリプトを生成する

Pythonのf-stringなどを使ってBashスクリプトの生成を行う。
Python
Shell
f-string
Published

August 30, 2024

以下のポストではRでglueパッケージを用いたがそれと同じように、今度はPythonを使ってBashスクリプトの生成を行う。

glueパッケージでスクリプトを生成する – めも

Rのglueパッケージを使って、他の言語の一部分だけ変更したスクリプトを生成する。

https://t-arae.blog/posts/2024/2024-08-18-glue-shell-script/

Code
from IPython import InteractiveShell
InteractiveShell.ast_node_interactivity = 'all'

import session_info

Pythonでのやり方

Rではglueパッケージを使って行うことができる文字列補完は、 Pythonではビルトインの機能であるf-stringやstr.format()を用いる。

フォーマット済み文字列リテラル(f-string)

フォーマット済み文字列リテラル(f-string)を作成するには、 文字列リテラルの前にfをつける(例: f"{apple}")。 実行するとプレースホルダ({})内の式が評価されて、置換された文字列が得られる。

# プレースホルダ内は式も書ける
one = 1
f"{one} + {one} = {one+one}"
#> '1 + 1 = 2'

# f-stringを返す関数を定義すれば、同じテンプレート文字列を何度も使い回すことができる
temp = lambda : f"{i} + {i} = {i*2}"
for i in range(1, 4):
    print(temp())
#> 1 + 1 = 2
#> 2 + 2 = 4
#> 3 + 3 = 6

formatメソッド(str.format()

テンプレートとなる文字列型(str)オブジェクトのformat()メソッドを使って、 補完を行うことができる。 位置引数(*args)と名前付き引数(**kwagrs)を使って、プレースホルダを置換できる。

one = 1
"one is {}".format(one)                # 位置引数で指定
#> 'one is 1'
"one is {one}".format(one=one)         # 名前付き引数で指定
#> 'one is 1'
"one is {one}".format(**{"one": one})  # 辞書を**で名前付き引数に展開
#> 'one is 1'
"one is {one}".format(**vars())        # vars()で得られるスコープ内の変数の辞書を使用
#> 'one is 1'

f-stringとは異なりプレースホルダ内は式として評価される訳ではない。 式を書くとエラーになる。

one = 1
"{one} + {one} = {one+one}".format(one=one)
#> KeyError: 'one+one'

変数の辞書を返すビルトイン関数(vars(), locals(), globals()

スコープ内の変数を辞書で返す関数にはvars()の他にlocals()globals()がある。 各関数について関数内と関数外それぞれで実行した場合に、どの変数にアクセスできるかを確認してみる。

# "one", "two"というkeyが辞書になければ、そのkeyに"undefined"という値をセット
def set_ud(d):
    for key in ["one", "two"]:
        if key not in d:
            d[key] = "undefined"
    return d

t = "{0:<10}: one is {one}, two is {two}"

# global scopeの変数
one = 1
def check():
    # local scopeの変数
    two = 2
    print(t.format("vars()", **set_ud(vars())))
    print(t.format("locals()", **set_ud(locals())))
    print(t.format("globals()", **set_ud(globals())))

print("@outside check()")
print(t.format("vars()", **set_ud(vars())))
print(t.format("locals()", **set_ud(locals())))
print(t.format("globals()", **set_ud(globals())))
print("@insite check()")
check()
@outside check()
vars()    : one is 1, two is undefined
locals()  : one is 1, two is undefined
globals() : one is 1, two is undefined
@insite check()
vars()    : one is undefined, two is 2
locals()  : one is undefined, two is 2
globals() : one is 1, two is undefined

関数内で呼び出すと、locals()ではグローバル変数に、globals()ではローカル変数にアクセスできない。

シェルスクリプトの作成

先のポストの例をPythonを使って実践してみよう。 str.format()を使う場合は以下の様に書ける。

from pathlib import Path

# Bashスクリプトのヘッダ
header = r"""!/bin/bash
set -euC

echo "Process started!"
echo ""

"""

# Bashスクリプトのフッタ
footer = r"""echo "All finished!"

"""

# スクリプトのテンプレート
cmd_template = r"""echo "Processing: '{label}'"
cat {label}.csv | \
  awk -F, 'NR > 1 {{
  sum1 += $1
  sum2 += $2
  }} END {{
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }}'
echo ""

"""

# ディレクトリ内のCSVファイル名からファイル拡張子を除く文字列を抽出
labels = sorted([f.stem for f in Path(".").glob("*.csv")])

with open("temp.sh", mode="w") as f:
    _ = f.write(header)
    for label in labels:
        _ = f.write(cmd_template.format(**locals()))
    _ = f.write(footer)
temp.sh
!/bin/bash
set -euC

echo "Process started!"
echo ""

echo "Processing: 'setosa'"
cat setosa.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "Processing: 'versicolor'"
cat versicolor.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "Processing: 'virginica'"
cat virginica.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "All finished!"

f-stringは即時評価されるので、f-stringを返す関数を定義してループ内で評価する。 また、f-string単体で使用すると\をエスケープしなければいけないので、 raw-stringと組み合わせてfr""として使う。

cmd_template = lambda : fr"""echo "Processing: '{label}'"
cat {label}.csv | \
  awk -F, 'NR > 1 {{
  sum1 += $1
  sum2 += $2
  }} END {{
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }}'
echo ""

"""

with open("temp.sh", mode="w") as f:
    _ = f.write(header)
    for label in labels:
        _ = f.write(cmd_template())
    _ = f.write(footer)
temp.sh
!/bin/bash
set -euC

echo "Process started!"
echo ""

echo "Processing: 'setosa'"
cat setosa.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "Processing: 'versicolor'"
cat versicolor.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "Processing: 'virginica'"
cat virginica.csv | \
  awk -F, 'NR > 1 {
  sum1 += $1
  sum2 += $2
  } END {
   print "Avg. Sepal.Length:", sum1/(NR-1)
   print "Avg. Sepal.Width :", sum2/(NR-1)
  }'
echo ""

echo "All finished!"

感想

str.format()よりは、f-stringの方がRのglue::glue()に近い感じで使える気がする。

Sessioninfo

session_info.show()
-----
IPython             8.26.0
session_info        1.0.0
-----
Python 3.12.2 (main, Feb 25 2024, 03:55:42) [Clang 17.0.6 ]
macOS-13.1-arm64-arm-64bit
-----
Session information updated at 2024-09-14 16:35