「モバイルSuicaの利用履歴を経費精算に簡単に出せるように編集するサービスの作成」
tabula-pyでモバイルSuicaの利用履歴PDFをpandas DataFrame形式にする
完成品はこちら https://www.mobilesuica.work
エラー処理
筆者のような素人に敷居が高いのがエラー処理である。
起こるであろう例外やユーザの誤操作等を予想して作るのだから難しい(勿論テスト結果で追加もするだろうが)。
今回考えるエラー処理は以下の2つ
- tabula-pyに渡すPDFが正しくない場合
- アップロードされたファイルがPDFではない、モバイルSuicaのPDFではない、等
- tabula-pyの処理自体で何等か問題が起きた
下記リンク先や他のページを見た結果、try-exceptで例外をキャッチしてraiseで上げていくのが正しそうな気がした。
GitHubで拾ってくるものも大体そうなっている。
※Pythonのプログラムでエラー通知をどのように実装するか
PDFかどうかの判定
これはPyPDF2という便利なものを見つけた。
import PyPDF2
with open('a.pdf','rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
print(f"ページ数は{pageNum}です")
実行結果
(app-root) bash-4.2# python3 test.py
ページ数は2です
PDFじゃないファイルを渡すとこうなる
fileList = ['a.pdf','test.py']
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
print(f"{fileName}のページ数は{pageNum}です")
実行結果
(app-root) bash-4.2# python3 test.py
a.pdfのページ数は2です
Traceback (most recent call last):
File "test.py", line 9, in <module>
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1084, in __init__
self.read(stream)
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1696, in read
raise utils.PdfReadError("EOF marker not found")
PyPDF2.utils.PdfReadError: EOF marker not found
PyPDF2.utils.PdfReadError: EOF marker not foundというのが、PDFファイルではなかったときにPyPDF2が投げる例外の様だ。
渡すファイルによってメッセージは変わるようでCould not read malformed PDF fileというのもあった。
いずれにせよこの例外をうまく処理しないとここでプログラムが止まってしまう。
例外処理
とりあえずはPyPDF2.utils.PdfReadErrorを拾えるようにしてあげれば良さそうだ。
try:
fileList = ['a.pdf','test.py']
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
print(f"{fileName}のページ数は{pageNum}です")
except PyPDF2.utils.PdfReadError as e:
print(f"ERROR: {e} PDFが正しくありません")
実行結果
(app-root) bash-4.2# python3 test.py
a.pdfのページ数は2です
ERROR: EOF marker not found PDFが正しくありません
これで例外が発生しても処理が続けられるようになった。
最終的にはPDFではないファイルをアップロードしたユーザにこれを通知してあげれば良いわけだ。
ところがこのままでは一つ不都合がある。tracebackが無いのでソースコードのどこでプログラムが止まったのか後でわからなくなるのである。
これは解決方法は簡単でtracebackというモジュールを利用するだけである。
import traceback
#...省略
except PyPDF2.utils.PdfReadError as e:
print(f"ERROR: {e} PDFが正しくありません")
traceback.print_exc()
実行結果
(app-root) bash-4.2# python3 test.py
a.pdfのページ数は2です
ERROR: EOF marker not found PDFが正しくありません
Traceback (most recent call last):
File "test.py", line 10, in <module>
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1084, in __init__
self.read(stream)
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1696, in read
raise utils.PdfReadError("EOF marker not found")
PyPDF2.utils.PdfReadError: EOF marker not found
先ほどとは違いtracebackが出ているのがわかる。
他の人のソースコードを見ているとexcept Exception as e:というので全ての例外を拾っている。これを基底クラスというらしい。
いろいろ読んでると、拾った例外によって処理を変えたい場合はPyPDF2.utils.PdfReadErrorのようにどの例外かを明示して、そうでなく処理が同じなら**except Exception as e:**でも良さそうだ。
例えばこんな感じだろうか。
try:
fileList = ['test.py']
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
print(f"{fileName}のページ数は{pageNum}です")
df = tabula.read_pdf(fileName,pages='all',pandas_options={'dtype':'object'})
except PyPDF2.utils.PdfReadError as e:
print(f"ERROR: {e} PDFが正しくありません")
except Exception as e:
print(f"ERROR: {e} なにかおかしい")
因みに基底クラスを先に書くとそちらの処理ルートに入ってしまうので、基底クラスは最後に書くようにしてみる。
tabula-pyでのエラー処理
表形式ではないPDFを与えても特に例外は発生しないので、モバイルSuicaのPDFではないことを確認するだけで良さそうだ。
まずはDataFrameのヘッダーを読んでみる。間違ったPDFとして空白のファイル(blank.pdf)を用意した。
fileList = ['a.pdf','blank.pdf']
for fileName in fileList:
df = tabula.read_pdf(fileName,pages='all',pandas_options={'dtype':'object'})
h = df[0].columns.values.tolist()
print(f"ヘッダは{h}です")
実行結果
(app-root) bash-4.2# python3 test.py
ヘッダは['月', '日', '種別', '利用駅', '種別.1', '利用駅.1', '残額', '差額']です
Traceback (most recent call last):
File "test.py", line 11, in <module>
h = df[0].columns.values.tolist()
IndexError: list index out of range
どうやらIndexErrorというのを出すようなのでこれは例外処理する。
更なるエラー処理として、DataFrameのヘッダーは取れているがモバイルSuicaのPDFとは異なる表形式のPDFをアップロードした可能性もある。
ヘッダーの中身までチェックすればそれもはじけるが、違うバグを作りそうなのでそこまでのエラー処理はやめておく。
最終的にユーザがアップロードしたファイルのリストをfileListとして受け取り、それを処理するプログラムはこうなった。
import tabula
import PyPDF2
import traceback
import pandas as pd
try:
fileList = ['a.pdf','blank.pdf']
dfList = []
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
df = tabula.read_pdf(fileName,pages='all',pandas_options={'dtype':'object'})
if df[0].columns.values.tolist():
for i in range(len(df)):
dfList.append(df[i])
print(f"{fileName}は正しく処理できました")
d = pd.concat(dfList,ignore_index=True)
except PyPDF2.utils.PdfReadError as e:
print(f"ERROR: {e} {fileName}はPDFではなさそうです")
traceback.print_exc()
except IndexError as e:
print(f"ERROR: {e} {fileName}はPDFですがモバイルSuicaのPDFではなさそうです")
traceback.print_exc()
except Exception as e:
print(f"ERROR: {e} {fileName}はなにかおかしい")
traceback.print_exc()
実行結果
(app-root) bash-4.2# python3 test.py
a.pdfは正しく処理できました
ERROR: list index out of range blank.pdfはPDFですがモバイルSuicaのPDFではなさそうです
Traceback (most recent call last):
File "test.py", line 13, in <module>
if df[0].columns.values.tolist():
IndexError: list index out of range
自作の例外処理
ここまでの処理は関数として呼ばれることになるので、このままではエラーメッセージはサーバのログに出るだけでユーザには見えない。ユーザに見えるようにするにはこのエラーメッセージを呼び出し元に返しておく必要がある。
raiseでそのまま再送出してしまうと元々のエラーメッセージしか送られない。
関数にするとこんな感じ
def test():
try:
fileList = ['copy.sh']
dfList = []
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
df = tabula.read_pdf(fileName,pages='all',pandas_options={'dtype':'object'})
if df[0].columns.values.tolist():
for i in range(len(df)):
dfList.append(df[i])
print(f"{fileName}は正しく処理できました")
d = pd.concat(dfList,ignore_index=True)
print(d)
except PyPDF2.utils.PdfReadError as e:
print(f"ERROR: {e} {fileName}はPDFではなさそうです test()の中")
raise
except IndexError as e:
print(f"ERROR: {e} {fileName}はPDFですがモバイルSuicaのPDFではなさそうです")
except Exception as e:
print(f"ERROR: {e} {fileName}はなにかおかしい")
try:
test()
except Exception as e:
print(f"{e} 呼び出し元")
実行結果
(app-root) bash-4.2# python3 test.py
ERROR: Could not read malformed PDF file copy.shはPDFではなさそうです test()の中
Could not read malformed PDF file 呼び出し元
どのファイルがダメだったのかわからなくなってしまう。
これを解決するには例外を自作するしかなさそうで少しへこんだが、かなり簡単であることが判明してほっとした。
こちらのサイトにわかりやすく説明がある。
何と2行追加するだけ!!
※Pythonで例外を自作して使うコード例(3種類)
というわけで自作の例外処理を入れたものはこちら
import tabula
import PyPDF2
import traceback
import pandas as pd
class ConvertError(Exception):
pass
def test():
try:
fileList = ['copy.sh']
dfList = []
for fileName in fileList:
with open(fileName,'rb') as f:
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
df = tabula.read_pdf(fileName,pages='all',pandas_options={'dtype':'object'})
if df[0].columns.values.tolist():
for i in range(len(df)):
dfList.append(df[i])
print(f"{fileName}は正しく処理できました")
d = pd.concat(dfList,ignore_index=True)
print(d)
except PyPDF2.utils.PdfReadError as e:
traceback.print_exc()
errorText = f"ERROR: {e} {fileName}はPDFではなさそうです => test()の中"
print(errorText)
raise ConvertError(errorText)
except IndexError as e:
traceback.print_exc()
errorText = f"ERROR: {e} {fileName}はPDFですがモバイルSuicaのPDFではなさそうです"
print(errorText)
raise ConvertError(errorText)
except Exception as e:
traceback.print_exc()
errorText = f"ERROR: {e} {fileName}はなにかおかしい"
print(errorText)
raise ConvertError(errorText)
try:
test()
except Exception as e:
print(f"{e} 呼び出し元")
実行結果
(app-root) bash-4.2# python3 test.py
Traceback (most recent call last):
File "test.py", line 15, in test
pageNum = PyPDF2.PdfFileReader(f).getNumPages()
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1084, in __init__
self.read(stream)
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1697, in read
line = self.readNextEndLine(stream)
File "/opt/app-root/lib/python3.6/site-packages/PyPDF2/pdf.py", line 1937, in readNextEndLine
raise utils.PdfReadError("Could not read malformed PDF file")
PyPDF2.utils.PdfReadError: Could not read malformed PDF file
ERROR: Could not read malformed PDF file copy.shはPDFではなさそうです => test()の中
ERROR: Could not read malformed PDF file copy.shはPDFではなさそうです => test()の中 呼び出し元