PDFかどうかの判定と例外処理を考えてみる　自作の例外処理作成まで

[「モバイルSuicaの利用履歴を経費精算に簡単に出せるように編集するサービスの作成」](https://qiita.com/legacyworld/items/f3416c9dbca6503b135e)
[tabula-pyでモバイルSuicaの利用履歴PDFをpandas DataFrame形式にする](https://qiita.com/legacyworld/items/59b3c8844df42bf58c70)
完成品はこちら https://www.mobilesuica.work

# エラー処理
筆者のような素人に敷居が高いのがエラー処理である。
起こるであろう例外やユーザの誤操作等を予想して作るのだから難しい（勿論テスト結果で追加もするだろうが）。

今回考えるエラー処理は以下の2つ

- tabula-pyに渡すPDFが正しくない場合
    - アップロードされたファイルがPDFではない、モバイルSuicaのPDFではない、等
- tabula-pyの処理自体で何等か問題が起きた

下記リンク先や他のページを見た結果、try-exceptで例外をキャッチしてraiseで上げていくのが正しそうな気がした。
GitHubで拾ってくるものも大体そうなっている。
[※Pythonのプログラムでエラー通知をどのように実装するか](https://qiita.com/shiimaxx/items/cdbf57d7f194e2deed81)

# PDFかどうかの判定
これは[**PyPDF2**](https://pythonhosted.org/PyPDF2/)という便利なものを見つけた。

```python:test.py
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じゃないファイルを渡すとこうなる

```python:test.py
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**を拾えるようにしてあげれば良さそうだ。

```python:test.py
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**というモジュールを利用するだけである。

```python:test.py
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:**でも良さそうだ。
例えばこんな感じだろうか。

```python:test.py
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)を用意した。

```python:test.py
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として受け取り、それを処理するプログラムはこうなった。

```python:test.py
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**でそのまま再送出してしまうと元々のエラーメッセージしか送られない。
関数にするとこんな感じ

```python:test.py
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で例外を自作して使うコード例（３種類）](https://srbrnote.work/archives/3471)

というわけで自作の例外処理を入れたものはこちら

```python:test.py
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()の中 呼び出し元
```