エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

2018年版・この処理Pythonでどう書く?

f:id:doloopwhile:20100618223404j:plain

EF15形は高性能な電気機関車であったが、引き出し性能が蒸気機関車に劣ると誤解されていた。 誤った運転方法により本来の性能を引き出せていなかったのである。

(spaceaero2 [CC BY 3.0], ウィキメディア・コモンズより

こんにちは、エムスリー・エンジニアリングG・基盤開発チーム小本です。

WEBサイトの運用は RailsやSpringなどで実装するWEBアプリケーションの「本体」だけでは完結しません。 レポート作成・データ更新・バックアップなどの細かい処理も必要です。

過去にはこうした用途にはBashがよく使われました。しかし、Bashは歴史的な経緯による落とし穴が多かったり、クラスなどの抽象化機能がなかったりするなど、規模が大きくなると辛くなります。

そこで、Bashの置き換え候補に上がるのがスクリプト言語であるPythonです(最近は「機械学習の言語」というイメージが強いのですが、Pythonは最初はスクリプト向けとして普及しました)。実際、エムスリーでもかつてはBashを使っていましたが、現在は新規案件にはPythonを推奨し、過去のBashも順次Pythonで書き直しています。

しかし、実際にPythonで書き直そうとすると直面するのが、

この処理をPythonでどう書けばいいのか分からない Bashなら1行で書けるのに〜!!

という問題です。

Pythonは単純にBashと1対1対応するわけではありませんし、Python固有の落とし穴もあります。 特に、スクリプトで重要になるファイルまわり・外部コマンドまわりは、Pythonの進化により過去に学んだ方法が Obsolete になっており混乱しがちです。そこで、よくある処理を最新のPythonで実装する方法の資料を作成しましたのでご紹介します。

あの処理、Pythonでどう書く?

スクリプト系のよくある処理を最新のPythonで実装する方法を説明します。一部、対比のためにBashのコードも書いています。

なお、この資料で説明されていない処理については、まず公式ドキュメントを当たってください。Pythonは標準ライブラリが充実しているため、探せばモジュールが見つかるはずです。

標準出力・標準エラー出力

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.Pathstr で文字列に戻せます。また、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からはできません。osshutilの関数から行います。

なお、os や shutil の関数は引数に pathlib.Pathと文字列のどちらも使うことができます。 標準ライブラリのファイル関係の関数は全て pathlib.Path・文字列両対応のはずです。

import os
import shutil
import pathlib

path = pathlib.Path('.DS_Store')
os.remove(path) # 削除

path_from = pathlib.Path('.bash_history')
path_to = pathlib.Path('/tmp/.bash_history')
os.rename(path_from, path_to) # 移動(名前変更)

path_from = pathlib.Path('.bash_history')
path_to = pathlib.Path('/tmp/.bash_history')
shutil.copy2(path_from, path_to) # 移動(名前変更)

外部コマンド

subprocess.run を使います。 Pythonには外部コマンド実行機能が新旧いろいろあるのですが、3.7から登場したsubprocess.run決定版です。subprocess.run 1つで全てのシチュエーションに対応できます。

なお、不幸にして3.6以前のPythonを使う場合は subprocess.callsubprocess.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.runstdin, 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=TrueOSコマンドインジェクションの原因になり危険です。

また、たとえ実際には脆弱性にはならない(ユーザーの入力を.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への移行などを行いました。)

一緒に働く仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com


  1. そんなに Requests が優れているなら、どうして標準ライブラリに取り込んでしまわないんだ、と思うのですが・・・。