JupyterLabで自動的に.pyファイルを保存したいけど，新規作成時は保存しないようにする

# 本記事のサマリ
- JupyterLabを使うときに.pyファイルを自動保存する設定していると，新規作成時に.txtファイルが生成されたり両方のファイル名を変更しなきゃいけなかったりでめんどくさい．
- .pyを保存するときは出力や実行順を消したいけど，.htmlで保存するときは残したい．
- 設定をいじることで新規作成時は自動保存しないようにする方法を述べる．
- (無理やりだけど)出力を削除するか否かを気軽に切り替える方法を述べる．
- (ついでに)hookに渡されるmodelの中身について述べる．

# 前提条件
本記事は以下の環境で検証しています．

- OS:Windows 10
- Python 3.7.0
- JupyterLab 0.35.3

# 背景
JupyterLab使いがgitでバージョン管理をしようとすると，.ipynbファイルは差分管理がしにくい（出力や実行順番，セルの区切りなど...）．そのため，`jupyter_notebook_config.py`を弄ることで保存時に出力結果を自動でクリアし，.pyファイルを同時に保存する，といった方法がよく見かけられる．

- 公式ドキュメント: https://jupyter-notebook.readthedocs.io/en/latest/extending/savehooks.html
- 参考記事：https://qiita.com/mmsstt/items/6f8382afcc94f57861d4

# 問題
上記の設定によりバージョン管理は楽に出来るようになるのだが，不便な点が2つある．

**問題1：新規ファイル作成時に.txtファイルが生成される**
上記の設定をした上で，JupyterLabからNotebookを新規作成すると，`Untitled.ipynb`というファイルが生成される．ここまでは良いのだが，同時に`Untitled.txt`というテキストファイルが生成され，邪魔である（私の環境だけ？）．何でテキストファイルなのかは不明だが，仮にちゃんと`Untitled.py`ファイルが生成されたとしても，Untitledという名前はどうせ変更するので，消すなり名前を変更するなりしなければならずめんどくさい．

**問題2：.html保存時には出力や実行番号を残したい**
上記の設定では，出力などをクリアする関数`scrub_output_pre_save`と，.pyファイルを自動保存する関数`script_post_save`を定義している．後者については.pyだけでなく.htmlへの変換も記述可能であるが，.htmlで保存する際には，印刷などのために出力が残っていた方が望ましい．`pre_save_hook`の処理をしないようにコメントアウトするなどすれば出力を残して保存できるが，毎回configファイル弄って，JupyterLab起動して...と繰り返すのはめんどくさい．

# 解決方針の検討
上記の問題を解決するために，configファイルで定義した関数を書き換えたい．（一部公式ドキュメントから弄っているが）現状の設定はこんな感じ．

``` python:jupyter_notebook_config.py
def script_pre_save(model, **kwargs):
    """scrub output before saving notebooks"""
    if model['type'] != 'notebook':
        return
    # only run on nbformat v4
    if model['content']['nbformat'] != 4:
        return

    for cell in model['content']['cells']:
        if cell['cell_type'] != 'code':
            continue
        cell['outputs'] = []
        cell['execution_count'] = None

c.FileContentsManager.pre_save_hook = script_pre_save

import os
from subprocess import check_call

def script_post_save(model, os_path, contents_manager):
    """post-save hook for converting notebooks to .py scripts"""
    if model['type'] != 'notebook':
        return # only do this for notebooks

    d, fname = os.path.split(os_path)
    base, ext = os.path.splitext(fname)
    check_call(['jupyter', 'nbconvert', '--to', 'script', fname], cwd=d)

c.FileContentsManager.post_save_hook = script_post_save
```

問題1については`script_post_save`で，新規作成のときはreturnする，みたいな文を入れれば良さそう．問題2については`script_pre_save`の`cell['outputs'] = []`とかやってる所で，条件分岐入れれば良さそう．
ただしここで疑問なのは，これらの関数の引数になっているmodelって何だ？ってこと．これがわからないと修正しようにも修正できない．なんとなく辞書っぽいのだけれど，どんなkeyがあるかも不明である．
# modelの中身
というわけで，上記の関数内にとりあえず`print(model)`と書いて，保存を実行してみた．その際に表示されたmodelの中身を適当に整形したのがこちら. 

```
# script_pre_saveの方のmodel
{'type': 'notebook', 
'format': 'json', 
'content': 
  {'metadata': 
		{'language_info': 
			{'name': 'python', 
			 'version': '3.7.0', 
			 'mimetype': 'text/x-python',
			 'codemirror_mode': {'name': 'ipython', 'version': 3}, 
			 'pygments_lexer': 'ipython3', 
			 'nbconvert_exporter': 'python', 
			 'file_extension': '.py'},
		'kernelspec': {'name': 'python3', 'display_name': 'Python 3', 'language': 'python'}
		}, 
	'nbformat_minor': 2,
	'nbformat': 4,
	'cells': [{'cell_type': 'code', 'source': '', 'metadata': {}, 'execution_count': None, 'outputs': []}]
	}, 
'path': '（実行フォルダ）/Untitled.ipynb'}

# script_post_saveの方
{'name': 'Untitled.ipynb', 
'path': '（実行フォルダ）/Untitled.ipynb', 
'last_modified': datetime.datetime(2019, 3, 5, 5, 23, 8, 689896, tzinfo=<notebook._tz.tzUTC object at 0x000002772B490438>), 
'created': datetime.datetime(2019, 3, 5, 5, 21, 56, 532588, tzinfo=<notebook._tz.tzUTC object at 0x000002772B490438>), 
'content': None, 
'format': None, 
'mimetype': None, 
'size': 555, 
'writable': True, 
'type': 'notebook'}

```
まずわかることとして，script_pre_saveとscript_post_saveではmodelの中身が異なる．なので，残念ながら.html保存→出力クリア→.py保存みたいなことは出来なさそう．
この中で使えそうな要素は限られていて，今回新たに使うのは，pre_saveのcontent>cells, post_saveのlast_modified，createdだけである．

# 解決策
**解決策1：createdとlast_modifiedから新規作成か否かを判定**
問題1については，post_saveのlast_modified，createdから新規作成か否かを判定し，新規作成なら.pyを保存せずに終了すれば良い．つまり，以下のように修正する．

```python:jupyter_notebook_config.py
import datetime
def script_post_save(model, os_path, contents_manager):
    if model['type'] != 'notebook':
        return # only do this for notebooks
    if model['last_modified'] - model['created'] < datetime.timedelta(seconds=1):
        return # 【追加】createとlast_modifiedの差が小さければ, 新規作成と判断してなにもしない
    ### ～～略～～

```

最初は`model['last_modified'] == model['created']`で良いかと思っていたが，マイクロ秒単位でずれたりしたので，1秒以内なら誤差と考えてこのような実装にした．

**解決策2：cellの中身で出力をクリアするか判定(暫定策)**
問題2については良い方法が浮かばなかった．なのでかなり雑ではあるが，とりあえずJupyterLabを再起動しなくても出力をクリアするか選べれば良いと考え，暫定的に以下のように修正した．

```python:jupyter_notebook_config.py
def script_pre_save(model, **kwargs):
    ### ～～略～～
    for cell in model['content']['cells']:
        if cell['source'] == '#not_clear':
            return # 【追加】#not_clearとあるセルがあれば，それ以降は除去しない
        
        ### ～～略～～
```

こうしておけば，htmlファイルで保存したいときには先頭のセルなどを`#not_clear`としておき，通常通りhtmlで保存すれば出力がクリアされないまま保存できる．htmlとして保存した後は，上記のセルを消しておくなりすることを忘れずに．
保存時にコードを書き換えなければならずあまりいい方法とは思えないので，他に解決方法があれば教えてください．

# 最終版
上記の修正を反映したバージョンがこちら．

``` python:jupyter_notebook_config.py
def script_pre_save(model, **kwargs):
    """scrub output before saving notebooks"""
    if model['type'] != 'notebook':
        return
    # only run on nbformat v4
    if model['content']['nbformat'] != 4:
        return

    for cell in model['content']['cells']:
        if cell['source'] == '#not_clear':
            return  #【追加】#not_clearとあるセルがあれば，それ以降は除去しない
        if cell['cell_type'] != 'code':
            continue
        cell['outputs'] = []
        cell['execution_count'] = None

c.FileContentsManager.pre_save_hook = script_pre_save

import os
from subprocess import check_call
import datetime

def script_post_save(model, os_path, contents_manager):
    """post-save hook for converting notebooks to .py scripts"""
    if model['type'] != 'notebook':
        return # only do this for notebooks
    if model['last_modified'] - model['created'] < datetime.timedelta(seconds=1):
        return  #【追加】createとlast_modifiedの差が小さければ, 新規作成と判断してなにもしない

    d, fname = os.path.split(os_path)
    base, ext = os.path.splitext(fname)
    check_call(['jupyter', 'nbconvert', '--to', 'script', fname], cwd=d)

c.FileContentsManager.post_save_hook = script_post_save
```