💈

シェルスクリプトをPythonで書き直した

2021/02/21に公開

概要

作業を楽にするために個人的にシェルスクリプトを書きました。
これを他のメンバーにも共有し、チーム全体の作業効率をアップや改善しようと考えています。
しかし、シェルスクリプトに触れたがあるメンバーが少なかったため、全員が触れたことのあるPythonで書き換えることにしました。(何かの試験問題の導入文みたい)

どんなシェルスクリプトだったのか

シェルスクリプトは、こちらの記事のテンプレートを参考に書きました。

処理内容は大まかに5つです。

  • テンプレファイルをコピーし、ファイル内容を一部置換
  • ファイル名を間違えたとき用に一括置換
  • コンパイルで作られた一時ファイルを削除
  • TeXファイルをエディタで開く
  • 出力PDFファイルをPDFビュワーで開く

ただし、具体的な処理は本筋ではないので、折りたたみの中に書きました。↓

シェルスクリプトはだいたいこんな感じ
#!/usr/bin/env bash
set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

usage() {
  cat <<EOF
Usage:
bash $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-ot] [-op] [-r aftername] dirname

# これは何?
hoge用のシェルスクリプトです。
dirnameに作業ディレクトリ名を入力してください。
ディレクトリ構成は以下です。
-------------------------------
./
├── cli.sh
└── template
    ├── A.tex
    ├── QandA.tex
    └── Q.tex
-------------------------------

# 使い方
"bash cli.sh hoge"とコマンドラインに入力することで、
カレントディレクトリにhogeという作業ディレクトリが作成されます。
構成は以下です。
-------------------------------
hoge
├── hoge_Q.tex ... 問題texファイル
├── hoge_A.tex ... 解答texファイル
└── hoge.tex ... 問題と解答を1つにするtexファイル
-------------------------------

また、"bash cli.sh fuga/hoge"とコマンドラインに入力することで、
カレントディレクトリにfugaというディレクトリが作成され、
fugaの中にhogeという作業ディレクトリが作成されます。
構成は以下です。
-------------------------------
hoge
├── hoge_Q.tex ... texファイル1
├── hoge_A.tex ... texファイル2
└── hoge.tex ... 上記2つを1つにするtexファイル
-------------------------------


# オプション一覧
-h, --help      ヘルプを表示
-v, --verbose   デバッグ用なので気にしないで
-ot, --opentex  エディタでTeXファイルを開く
-op, --openpdf  出力PDFファイルを開く
-c, --clear     コンパイルで作られた一時ファイルを削除
-r, --rename    間違えたとき用に一括置換
EOF
  exit
}

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
}

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

die() {
  local msg=$1
  local code=${2-1}
  msg "$msg"
  exit "$code"
}

parse_params() {
  opentex=0
  openpdf=0
  rename=''
  clearfiles=0

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -ot | --opentex) opentex=1 ;;
    -op | --openpdf) openpdf=1 ;;
    -c | --clear) clearfiles=1 ;;
    -r | --rename)
      rename="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  [[ ${#args[@]} -eq 0 ]] && die "作業ディレクトリを指定してください。"

  return 0
}

parse_params "$@"
setup_colors

# ----------------------------------------------------------------
dirname=${args[0]}
filename=${dirname##*/}
if [[ -n $rename ]]; then
  if [[ ! -d $dirname ]]; then
    die "「$dirname」が存在しません。"
  fi
  echo -n "$dirnameから$renameへ一括置換をしますか? (y/n) >"
  read do_rename
  if [[ $do_rename = 'y' ]]; then
    sed -i s/$dirname/$rename/g $dirname/$filename.tex
    mkdir $rename
    mv $dirname/$filename.tex $rename/$rename.tex
    mv $dirname/${filename}_Q.tex $rename/${rename}_Q.tex
    mv $dirname/${filename}_A.tex $rename/${rename}_A.tex
    rmdir $dirname
    echo "$dirnameから$renameへ一括置換しました"
  else
    echo "一括置換は行われませんでした"
  fi
  exit 0
fi

if [[ ! -d $dirname ]]; then
  mkdir $dirname
  cp template/QandA.tex $dirname/$filename.tex
  cp template/Q.tex $dirname/${filename}_Q.tex
  cp template/A.tex $dirname/${filename}_A.tex
  sed -i s/DIRNAME/$filename/g $dirname/$filename.tex
fi

pdf=$dirname/$filename.pdf
if [[ $openpdf = 1 ]]; then
  if [[ -f $pdf ]]; then
    /mnt/c/Program\ Files/SumatraPDF/SumatraPDF.exe $pdf &
  else
    /mnt/c/Program\ Files/SumatraPDF/SumatraPDF.exe &
  fi
fi

if [[ $clearfiles = 1 ]]; then
  rm $dirname/*.aux $dirname/*.dvi $dirname/*.fdb_latexmk $dirname/*.fls $dirname/*.log $dirname/*.synctex.gz
fi

if [[ $opentex = 1 ]]; then
  cd $dirname
  vim *.tex
fi

僕の環境がUbuntu(WSL2)であるため、テキストエディタやPDFのビュワーの部分は各自で書き換えてもらうことにします。それ以外の部分=ファイル操作については、WindowsでもUbuntuでも動くように改善していきます。また、Pythonでは標準ライブラリのみ使います。

いざ書き換え

引数とヘルプ

シェルスクリプトでは、以下のように while で引数を処理していました。

シェルスクリプト
opentex=0
openpdf=0
rename=''
clearfiles=0

while :; do
  case "${1-}" in
  -h | --help) usage ;;
  -v | --verbose) set -x ;;
  --no-color) NO_COLOR=1 ;;
  -ot | --opentex) opentex=1 ;;
  -op | --openpdf) openpdf=1 ;;
  -c | --clear) clearfiles=1 ;;
  -r | --rename)
    rename="${2-}"
    shift
    ;;
  -?*) die "Unknown option: $1" ;;
  *) break ;;
  esac
  shift
done

args=("$@")

[[ ${#args[@]} -eq 0 ]] && die "作業ディレクトリを指定してください。"

また、ヘルプの内容は、usageという関数内に書いています。

シェルスクリプト
usage() {
  cat <<EOF
Usage:
bash $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-ot] [-op] [-r aftername] dirname

# これは何?
省略

# 詳しい使い方
省略

# オプション一覧
-h, --help      ヘルプを表示
省略
EOF
  exit
}

これをPythonで書き換えるには、argparseモジュールを使います。

Python
import argparse
p = argparse.ArgumentParser(
    formatter_class=argparse.RawDescriptionHelpFormatter,
    description="""

# これは何?
省略

# 詳しい使い方
省略
""",
)
# 位置引数
p.add_argument("dirname", help="作業ディレクトリ名")
# オプション引数
group = p.add_mutually_exclusive_group()
group.add_argument("-ot", "--opentex", help="エディタでTeXファイルを開く", action="store_true")
group.add_argument("-op", "--openpdf", help="出力PDFファイルを開く", action="store_true")
group.add_argument("-c", "--clear", help="コンパイルで作られた一時ファイルを削除", action="store_true")
group.add_argument("-r", "--rename", help="間違えたとき用に一括置換")
args = p.parse_args()

ArgumentParserオブジェクトを作成し、add_argumentメソッドで引数を追加していきます。引数の値は、parse_argsの返り値から、args.dirname, args.opentexなどのように得ることができます。

自分で --help オプションを追加しなくても、add_argumentメソッドでhelpに指定することで、usageやオプションがイイ感じに表示されます。

usage: cli.py [-h] [-ot | -op | -c | -r RENAME] dirname

positional arguments:
  dirname               作業ディレクトリ名

optional arguments:
  -h, --help            show this help message and exit
  -ot, --opentex        エディタでTeXファイルを開く
  -op, --openpdf        出力PDFファイルを開く
  -c, --clear           コンパイルで作られた一時ファイルを削除
  -r RENAME, --rename RENAME
                        間違えたとき用に一括置換

今回はさらに、descriptionを指定しました。descriptionで指定したものは、ヘルプのusageとpositional argumentsの間に追加されます。
descriptionでの改行はそのまま維持したいため、formatter_classにはRawDescriptionHelpFormatterを指定しました。

add_argumentメソッドでは、頭に-を付けたらオプション引数、付けなければ位置引数になります。ここでaction="store_true"にすることで、その引数が指定されればTrue、指定されなければFalseを得ることができます。

ArgumentParserのオブジェクトでadd_mutually_exclusive_groupメソッドを使うことで、グループ内の引数は2つ以上同時に使うことができなくなります。usageにもそれが反映されていますね。

あとは、

if args.opentex:
    pass

のように条件分岐で処理を書いていくだけです。

ファイル操作

UnixでもWindowsでも処理適切にパスを作成するためにosモジュールを使います。
忙しい人のために(自分が後で見返しやすいように)、よく使いそうなものを羅列します。
os.sep:パスの区切り文字の取得
os.path.join:パスの結合
os.path.split:パスの末尾とそれ以前を分割
os.path.exists:パスが実在しているかどうか
os.rename:リネーム
os.mkdir:ディレクトリ作成
os.remove:削除
覚えやすいですね~。

ファイルのコピーは、shutil.copyfile(src, dist) のようにshutilモジュールを使いました(これだけは頭の中にぱっと出てこなかったです)。

エディタ等の起動

テキストエディタやPDFビュワーを起動するために、subprocessモジュールを使いました。

Python
import subprocess as sb
if args.opentex:
    sb.run("cd {} && vim *.tex".format(dirname), shell=True)

本来は第一引数にはコマンドと引数のリストを指定しますが、そのまま文字列で書きたかったためshell=Trueを指定しました。出力を変えるときは、stdoutstderrで指定できます。

完成品

そんなわけで完成品はこちら
import argparse
import os
import re
import sys
import shutil
import subprocess as sb

p = argparse.ArgumentParser(
    formatter_class=argparse.RawDescriptionHelpFormatter,
    description="""
# これは何?
dirnameに作業ディレクトリ名を入力してください。
ディレクトリ構成は以下です。
-------------------------------
./
├── cli.py
└── template
    ├── A.tex
    ├── QandA.tex
    └── Q.tex
-------------------------------


# 使い方
## 主な使い方
"python cli.py hoge"とコマンドラインに入力することで、
カレントディレクトリにhogeという作業ディレクトリが作成されます。
構成は以下です。
-------------------------------
hoge
├── hoge_Q.tex ... 問題texファイル
├── hoge_A.tex ... 解答texファイル
└── hoge.tex ... 問題と解答を1つにするtexファイル
-------------------------------

また、"python cli.py fuga/hoge"とコマンドラインに入力することで、
カレントディレクトリにfugaというディレクトリが作成され、
fugaの中にhogeという作業ディレクトリが作成されます。
構成は以下です。
-------------------------------
hoge
├── hoge_Q.tex ... 問題texファイル
├── hoge_A.tex ... 解答texファイル
└── hoge.tex ... 問題と解答を1つにするtexファイル
-------------------------------

以下省略
""",
)
# 位置引数
p.add_argument("dirname", help="作業ディレクトリ名")
# オプション引数
group = p.add_mutually_exclusive_group()
group.add_argument("-ot", "--opentex", help="エディタでTeXファイルを開く", action="store_true")
group.add_argument("-op", "--openpdf", help="出力PDFファイルを開く", action="store_true")
group.add_argument("-c", "--clear", help="コンパイルで作られた一時ファイルを削除", action="store_true")
group.add_argument("-r", "--rename", help="間違えたとき用に一括置換")
args = p.parse_args()

# hoge でも hoge/fuga でも hoge/ でも hoge/fuga/ でも対応 windowsでも対応
dirname = re.sub(repr(os.sep + "$")[1:-1], "", args.dirname)
filename = os.path.split(dirname)[-1]
dir_file_name = os.path.join(dirname, filename)

# 一括置換
if args.rename:
    re_dirname = re.sub(repr(os.sep + "$")[1:-1], "", args.rename)
    re_filename = os.path.split(re_dirname)[-1]
    # ファイルチェック
    if not os.path.exists(dirname):
        print("「{}」が存在しません".format(dirname), file=sys.stderr)
        sys.exit(1)
    if os.path.exists(re_dirname):
        print("「{}」は既に存在しています".format(re_dirname), file=sys.stderr)
        sys.exit(1)

    do_rename = input("{}から{}へ一括置換をしますか? (y/n) >".format(dirname, re_dirname))
    if do_rename == "y":
        # ファイル内の文字列を置換
        with open(dir_file_name + ".tex") as f:
            lines = f.read()
        lines = lines.replace(filename, re_filename)
        with open(dir_file_name + ".tex", mode="w") as f:
            f.write(lines)

        # ファイル名のリネーム
        os.rename(dirname, re_dirname)
        for qa in ["", "_Q", "_A"]:
            os.rename(
                os.path.join(re_dirname, filename + qa + ".tex"),
                os.path.join(re_dirname, re_filename + qa + ".tex"),
            )

        print("{}から{}へ一括置換しました".format(dirname, re_dirname))
        sys.exit(0)
    else:
        print("一括置換は行われませんでした")
        sys.exit(0)

# テンプレートのコピー
if not os.path.exists(dirname):
    os.mkdir(dirname)

    # テンプレ文字列を置換
    with open(os.path.join("template", "QandA.tex")) as f:
        lines = f.read()
    lines = lines.replace("DIRNAME", filename)

    # テンプレをコピー
    with open(dir_file_name + ".tex", mode="w") as f:
        f.write(lines)
    shutil.copyfile(os.path.join("template", "Q.tex"), dir_file_name + "_Q.tex")
    shutil.copyfile(os.path.join("template", "A.tex"), dir_file_name + "_A.tex")

if args.openpdf:
    pdf = os.path.join(dirname, filename + ".pdf")
    if os.path.exists(pdf):
        sb.run(
            "/mnt/c/Program\ Files/SumatraPDF/SumatraPDF.exe {} &".format(pdf),
            stdout=sb.DEVNULL,
            stderr=sb.DEVNULL,
            shell=True,
        )
    else:
        sb.run(
            "/mnt/c/Program\ Files/SumatraPDF/SumatraPDF.exe & ",
            shell=True,
        )

if args.clear:
    for rf in [".aux", ".dvi", ".fdb_latexmk", ".fls", ".log", ".synctex.gz"]:
        os.remove(dir_file_name + rf)

if args.opentex:
    sb.run("cd {} && vim *.tex".format(dirname), shell=True)

僕がPythonのほうが慣れているのもあって、書きやすいな~と思いました。引数の解析とヘルプが一緒に書け、usageやオプションの表示も勝手にしてくれるので楽ですね。
argparseにはチュートリアルもあります。

Discussion