(spaceaero2 [CC BY 3.0], ウィキメディア・コモンズより)
こんにちは、エムスリー・エンジニアリングG・基盤開発チーム小本です。
WEBサイトは RailsやSpringなどの「本体部分」だけでは完結しません。レポート作成・データ更新などの細かい処理も必要です。
過去にはこうした用途にはBashがよく使われました。しかし、Bashは落とし穴が多かったり、クラスなどの抽象化機能がなかったりして、規模が大きくなると辛くなります。
そこで、Bashの代替候補に挙がるのがPythonです。エムスリーでもかつてはBashを使っていましたが、現在は新規案件にはPythonを推奨しています。
しかし、実際にPythonで書き直そうとすると直面するのが、
この処理をPythonでどう書けばいいのか分からない Bashなら1行で書けるのに〜!! もうBashでかいちゃえ
という問題です。
特にファイルまわり・外部コマンドまわりは、Bashでよく扱う上に、Pythonの進化によって過去に学んだ方法が Obsolete になっており混乱しがちです。そこで、よくある処理を最新のPythonで実装する方法の資料を作成しましたのでご紹介します。
- あの処理、Pythonでどう書く?
- エンジニアを募集しています!
あの処理、Pythonでどう書く?
スクリプト系のよくある処理を最新のPythonで実装する方法を説明します。一部、対比のためにBashのコードも書いています。
なお、この説明ではできるだけ標準ライブラリの範囲で書いています。 標準ライブラリでも機能的に十分なことが多いからです。サードパーティ・ライブラリを使おうとすると、ライブラリの選定、ライブラリをインストールする手順、アップデートするときの影響、など考えることが増えてしまいます。
また、やりたい処理がこの資料に書いていないときは、まず公式ドキュメントを当たってください。大抵のタスクは、それ用の標準ライブラリが見つかるはずです。
標準出力・標準エラー出力
echo "HELLO" # 標準出力にメッセージ echo 'ERROR!' >&2 # 標準エラーにメッセージ
どちらも組み込みのprint関数を使います:
print("HELLO") # 標準出力にメッセージ import sys print("ERROR!", file=sys.stderr) # 標準エラーにメッセージ
ただし、ここでメッセージの種類を考慮してください。進捗や処理結果などの人間が読むためのメッセージは print
ではなくloggingで出力しておいた方がいいかもしれません。後々、時刻やファイル名を追加したり、ログレベルの指定ができるからです。
import logging logger = logging.getLogger(__name__) logger.error("ERROR!") # => ERROR!
初期設定ではwarn
以上のログがそのまま(時刻・ファイル名を付加せず)標準エラー出力に出ます。つまり、print(x, file=sys.stderr)
と同等です。
また、logging.basicConfig
でログレベル・フォーマットを変更できます。
logging.basicConfig( level=logging.INFO, format="%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s", )
ファイル関係
パスの操作
Bashではパスは基本的には「"/" 区切りの文字列」です。
name="$(basename "/foo/bar/baz.txt")" # => baz.txt ファイル名 name="$(dirname "/foo/bar/baz.txt")" # => /foo/bar/ 親ディレクトリパス fullpath="/foo/bar/${name}" # => /foo/bar/baz.txt パスの連結
Pythonでもパスを文字列として扱うモジュールはある(os.path)のですが、pathlibを使う方がモダンです。
pathlib
では、一度文字列をPath
でラップして操作します。
import pathlib p = pathlib.Path("/foo/bar/baz.txt") # 文字列をPathでラップ。実際にはPathではなく、OS毎のサブクラスでラップされる p.name # => baz.txt ファイル名 p.parent # => PosixPath('/foo/bar') 親ディレクトリパス dirpath = pathlib.Path("/foo/bar/") fullpath = dirpath / "baz.txt" # => PosixPath('/foo/bar/baz.txt') パスの連結
pathlib.Path
は str
で文字列に戻せます。また、subprocess.run
などにそのまま渡せます。
import pathlib import subprocess fullpath = pathlib.Path("/foo/bar/baz.txt") str(fullpath) # '/foo/bar/baz.txt' 文字列に戻す subprocess.run(['cat', fullpath]) # 標準ライブラリにそのまま渡せる
チルダや環境変数が含まれるパスを扱う
Pythonのpathlibですが、ホームディレクトリを表す ~
や環境変数 $var
は明示的に展開しなければなりません(Unixでは "~" とか "$var" という名前のディレクトリを、その気になれば作れるので)。
import pathlib bad = pathlib.Path("~/Donwloads/report.txt") # ~ は自動展開されない! f = pathlib.Path("~/Donwloads/report.txt").expanduser() g = pathlib.Path("${WORK_DIR}/input.txt").expandvars()
ファイルの読み書き
Pythonの初歩なので、あえて解説は不要でしょう。
import pathlib path = pathlib.Path('foo.txt') with open(path, 'r', encoding="utf-8") as f: for line in f: # line を処理 with open(path, 'w', encoding="utf-8") as f: print("内容", file=f) with path.open('r') as f: # Pathのメソッドを使って開くこともできる。 # ...
ただし、encoding
で文字コードを指定することは忘れないでください。encoding
を省略することは出来ますが、環境変数などによっては別の文字コードになってしまうかもしれません。
行数を数える(wc -l
)
このように、sum
と内包表記を組み合わせるのが一番効率が良さそうです。
with path.open('rb') as f: count = sum(1 for line in f)
なお、open
には 'rb'
(バイナリモードで読み込み)を指定してください。
'r'
(テキストモードで読み込み)だと、ファイルを文字列型にデコードする(無駄な)処理が発生するのでとても遅くなります。
ファイルの列挙
pathlib.Path.glob
ではワイルドカードを使えます。
import pathlib dir = pathlib.Path('/tmp') for file in dir.glob('tmp.*'): # ファイルを処理する # fileは文字列ではなく、pathlib.Pathであることに注意
ファイルの情報(存在確認・作成日時)
その他、ファイル周りの色々な情報取得も、pathlib.Path
のメソッドでできます。
import pathlib f = pathlib.Path('/bin/bash') f.exists() # 存在確認 f.is_file() # ファイル? f.is_dir() # ディレクトリ? f.stat().st_ctime # 作成日時 f.stat().st_mtime # 更新日時 f.stat().st_atime # アクセス日時
移動・削除
ファイルの移動・削除もpathlib.Path
のメソッドでできます。
import pathlib path_from = pathlib.Path('.bash_history') path_to = pathlib.Path('/tmp/.bash_history') path_from.rename(path_to) # 移動 path_from.unlink() # 削除
コピー
ファイルのコピーは(なぜか)pathlibからはできません。shutilの関数から行います。
import shutil import pathlib path_from = pathlib.Path('.bash_history') path_to = pathlib.Path('/tmp/.bash_history') shutil.copy2(path_from, path_to) # コピー
なお、shutil や os にはファイル関係の関数が多数定義されていますが、どの関数も引数に pathlib.Path を渡すことができます。
外部コマンド
subprocess.run を使います。
Pythonには外部コマンド実行機能が新旧いろいろあるのですが、3.7から登場したsubprocess.run
は決定版です。subprocess.run
1つで全てのシチュエーションに対応できます。
なお、不幸にして3.6以前のPythonを使う場合は subprocess.call
や subprocess.check_call
で代用します。
単純に実行する
import subprocess subprocess.run(['sl', '-h'], check=True)
なお、基本的には常にcheck=True
を指定 します。終了ステータスが non-0 の場合に例外が発生し、スクリプト全体もエラー終了するからです(Bashで set -e
とした時のように)。
一方、check=False
を指定する(またはcheck
を指定しない)と終了コードを .returncode
で取得できます。
import subprocess r = subprocess.run(['false'], check=False) r.returncode # => 3
外部のコマンドを実行し、標準出力を受け取る:
stdout=subprocess.PIPE
を指定すると標準出力をキャプチャでき、同様に標準エラーもstderr
でキャプチャできます。
import subprocess r = subprocess.run(['echo', '世界'], check=True, stdout=subprocess.PIPE) r.stdout # => b'\xe4\xb8\x96\xe7\x95\x8c\n' '世界\n'をUTF-8でエンコードしたもの r.stdout.decode(sys.getfilesystemencoding()) # => '世界\n'
なお、.stdout
は 文字列ではなくバイト列であることに注意してください。
文字列とバイト列は混ぜることはできません。明示的にデコードしてください。
この際、たいていの場合はバイト列のエンコードはUTF-8になっています。しかし、古い環境などではUTF-8ではないこともあり、決め打ちはできません。文字コードは sys.getfilesystemencoding()
で取得してください。
環境変数やカレントディレクトリを変更する
追加の引数で環境変数やカレントディレクトリを変更することができます。
env = dict(os.environ) # Pythonスクリプトの環境変数をコピー env['DB_HOST'] = 'localhost' # 環境変数を変更 cwd = pathlib.Path('/') subprocess.run(['setup.sh'], cwd=cwd, env=env)
リダイレクトを使う
subprocess.run
のstdin
, stdout
, stderr
にはファイルオブジェクトを渡すことができます。
import subprocess import os.path fi = open(os.path.expanduser('~/.bash_history')) fo = open('p.txt', 'wb') subprocess.run(['grep', 'python[23]'], stdin=fi, stdout=fo) # p.txt に検索結果が出力される
パイプを使う
cat .bash_history | grep python
subprocess.Popen
を使います。.Popen
は .run
とほぼ同じ引数を受け取りますが、実行を待ちません。
p1 = subprocess.Popen(["cat", '.bash_history'], stdout=subprocess.PIPE) p2 = subprocess.Popen(["grep", "python"], stdin=p1.stdout, stdout=subprocess.PIPE) p1.stdout.close() output = p2.communicate()[0] # history から 'python' を含む行を検索した結果 p2.returncode # grep の終了コード
spawn → wait (外部コマンドを起動し、終了を待つ)
重い処理を並列実行するときなど、外部コマンドを複数起動(spawn)し、最後に全てのコマンドの終了を待つことがあります。
/path/to/heavy-sub-process1 & pid1=$! /path/to/heavy-sub-process2 & pid2=$! wait $pid1 wait $pid2
この場合も subprocess.Popen
を使います。
import subprocess p1 = subprocess.Popen(['/path/to/heavy-sub-process1']) p2 = subprocess.Popen(['/path/to/heavy-sub-process2']) p1.wait() p2.wait()
なお、spawnした外部コマンドの出力を受け取ったり、外部コマンドの実行中に別の処理を実行したくなるかもしれません。それは非同期処理の世界に足を踏み込む事になりますので、subprocess
ではなくasyncio
を使ってください。
シェルを実行する【危険!!】
subprocess.run
は外部コマンドをシェルを介さずに実行します。
単に引数にパイプやリダイレクトを書いても動作しません。
subprocess.run('echo Hello > /dev/null') # => FileNotFoundError: [Errno 2] No such file or directory: 'echo Hello > /dev/null'
ここで shell=True
を指定すると、引数をシェルを介して実行することができます。
subprocess.run('echo Hello > /dev/null', shell=True)
しかし、shell=True
はOSコマンドインジェクションの原因になり危険です。
また、たとえ実際には脆弱性にはならない(ユーザーの入力を.run
に渡していない)としても、あなたのスクリプトを引き継いだ人や静的チェックツールには分からないかもしれませんから、避けた方が無難です。
時刻関係
Unix時刻 <=> 文字列表記の時刻の変換と、経過秒数の算出はよく行うと思います。
date +%s # => 1540277405 現在のUnix時刻 date -d @1540277405 +%FT%T # => 2018-10-23T15:50:05 Unix時刻を文字列に変換 start="$(date +%s)" # 何か時間がかかる処理をする echo "$(( $(date +%s) - $start ))" # => 42 かかった秒数
Pythonではdatetimeを使います。 datetime - datetime は、秒でもミリ秒でもなく timedelta であることに注意してください。
from datetime import datetime, timedelta # datetimeは日時を、timedeltaは経過時間を表す epoch = datetime.now().timestamp() # => 1540277405.379158 現在のUnix時刻(小数) datetime.fromtimestamp(1540277405).strftime('%FT%T') # => '2018-10-23T15:50:05' start = datetime.now() # 何か時間がかかる処理をする duration = datetime.now() - start # datetime - datetime は timedelta print(duration / timedelta(seconds=1)) # 経過時間を数値型にするには、別の timedeltaで割る # => 42.680422 かかった秒数(小数) print(duration.total_seconds()) # これでもOK # => 42.680422 かかった秒数(小数)
他にも様々な機能があります。詳しくはドキュメントを参照してください。
文字列関係
文字列への式埋め込み
message="世界!" echo "Hello ${message}" # => Hello 世界!
今のPythonでは f-string を使って、ほとんど同じことができます。
message='世界!' print(f"Hello {message}") # => Hello 世界!
さらに f-string 内の {...}
には変数だけでなく、式であればどんなものも書くことができます。
print(f"1 + 2 = {1 + 2}") # => 1 + 2 = 3
ヒアドキュメント
cat <<EOS > report.txt レポート 日付: ${date} EOS
Python にはヒアドキュメントはありませんが、複数行文字列(3重の引用符で囲む)があります。
また、textwrap.dedent
は行頭の空白を削除してくれるので、文字列の中身をインデントして書くことができます。
import textwrap report = textwrap.dedent(f""" レポート 日付: {date} """)
コマンドライン引数
コマンドライン引数は、sys.argv
でリストとして受け取れます。
import sys sys.argv # => ['a.py', 'input.txt', '-o', 'output.txt']
コマンドラインオプションを処理したいときは argparse を使うのが標準です。gnu-getopt ライクな getopt もありますが、後々を考えてargparseにしておきましょう。
argparseの使用例(公式ドキュメントより):
import argparse parser = argparse.ArgumentParser(description='Process some integers.') parser.add_argument('integers', metavar='N', type=int, nargs='+', help='an integer for the accumulator') parser.add_argument('--sum', dest='accumulate', action='store_const', const=sum, default=max, help='sum the integers (default: find the max)') args = parser.parse_args() print(args.accumulate(args.integers))
argparse は高機能でやや複雑ですが、
- パーサーを
argparse.ArgumentParser
で生成 - 通常の引数を
.add_argument('hoge')
で定義 - オプション引数を
.add_argument('--hoge')
で定義 - 結果を
.parse_args()
で取得
ということだけ覚えれば、他は必要に応じてドキュメントを読めばなんとかなります。
終了時の処理&シグナルをtrapする
一時ファイルを削除したりするのに使います。
# ゴミ掃除用の trap 処理を指定する。 tf="temp.$$" trap 'echo "trapped."; rm -f $tf; exit 1' 1 2 3 15
単に終了時に何かの処理をしたいのなら、atexit を使うのが簡単です。
import atexit import os tf = '/path/to/tempfile' @atexit.register def cleanup(): os.remove(tf)
signal を使えば、OSのシグナルをハンドリングできるのですが、使う機会は少ないでしょう(大体、atexitのような高水準の代替ライブラリがあるはず)。
HTTPリクエスト(curl や wget の代替)
単にファイルをダウンロードしたり、APIにPOSTするだけなら、curlやwgetをsubprocess.run
で実行するだけでいいかもしれません。
また、サードパーティのHTTPクライアントとしてはRequestsが有名で、公式ドキュメントでも推奨しています。1
しかし、サードパーティのライブラリを使わずとも、標準ライブラリのurllib.request.urlopen でもかなり簡潔に書くことができます。
# URLを、GETでリクエストし、レスポンスボディを出力する import urllib.request import urllib.parse params = urllib.parse.urlencode({'spam': 1, 'eggs': 2, 'bacon': 0}) url = f"http://www.musi-cal.com/cgi-bin/query?{params}" with urllib.request.urlopen(url) as f: print(f.read().decode('utf-8'))
また、urllib.request
では、POSTでリクエストしたり、プロキシや認証越しにリクエストすることも出来ますが割愛します。
エンジニアを募集しています!
基盤開発チームでは、メールコンシェルジュ以外にも、 会員認証基盤などのエムスリー全体を支える様々なシステムを開発・運用しています。
また、事業から一歩引いた立ち位置にいるので、新技術導入、開発フローの改善などをリードしてきたいと思っています。 (最近はDockerを導入や、JenkinsからGitlab-CIへの移行などを行いました。)
一緒に働く仲間を募集中です。お気軽にお問い合わせください。
-
そんなに Requests が優れているなら、どうして標準ライブラリに取り込んでしまわないんだ、と思うのですが・・・。↩