Jupyter Notebook の.ipynbファイルをimportするコードの試作

Jupyter Notebookで作成した.ipynbファイルを、別のコードからそのままインポートしたいことがありました。そこで、.ipynbファイルをimportするコードを試作してみます。

~~.ipynbファイルをインポートするためのライブラリは、すでにあるのでしょうか。既存なら、そちらを使っていきたい所、標準で.ipynbファイルも.pyと同様にインポートして使えるようになると、いいですね。~~
　↓
他の方法:

* JupyterNotebookでipynbを超かんたんにimport

https://blog.mojirca.com/2020/01/jupyter-ipynb-easy-import.html

* 公式の方法 Importing Jupyter Notebooks as Modules

https://jupyter-notebook.readthedocs.io/en/stable/examples/Notebook/Importing%20Notebooks.html

https://qiita.com/tdual/items/32d3918b4c8dd1f703e7

# 使い方

後段に記載の「.ipynbファイルをimportする.pyコード」章のコードを「ipynb_import_lib.py」の名前のファイルに保存しておいて、

同じフォルダにある「sample_lib.ipynb」ファイルをインポートするには、「ipynb_import_lib.import_ipynb」関数を呼び出して、

```python
import ipynb_import_lib
ipynb_import_lib.import_ipynb("./sample_lib.ipynb", "lib1")
```

または、

```python
import ipynb_import_lib
lib1 = ipynb_import_lib.import_ipynb("./sample_lib.ipynb")
```

で、「sample_lib.ipynb」を「lib1」名としてインポートできます。

あとは、「lib1」にある関数・クラスを任意に使用できます。

```python
a = lib1.any_func1(...) # 呼び出し等
```

# 関数・クラスだけをインポート

.ipynbファイルでは、各セルでそのまま直に式などのコードを実行することが多いので、.ipynbファイルの各セルのコードすべてをインポートすると、インポート時にすべてのセルを実行することになってしまい、不要なもの・試行錯誤したものまですべてを実行するのでは都合が悪いため、関数・クラスの定義だけを読み込むようになっています。

## 各セルのコードすべてをインポート

※「ipynb_import_lib.import_ipynb」関数のオプションで、「no_expr=False」の設定で呼び出すと、関数・クラスだけでなく各セルのコードすべてをインポートさせることが出来ます。

```python
import ipynb_import_lib
lib1 = ipynb_import_lib.import_ipynb("./sample_lib.ipynb", no_expr=False)
```

# コードの概要

前記の「.ipynbファイルをimportする.pyコード」の構成は、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/606924/d11bb320-af25-dc52-0129-85fbf0d46902.png)

import_ipynb関数がメイン、その中で、

```
.ipynbファイル内容の更新有無を確認
　↓
.ipynbファイルから.pyコードを生成 (関数呼出し→処理[1]へ)
　↓
生成した.pyコードをimportする処理
```

で、終了。

処理[1]は、get_code_from_ipynb関数となっていて、.ipynbファイルからpyコード(文字列)を取得します。当関数の中身は、

```
JSONローダーで.ipynbファイルを読み込み
　↓
.ipynb内のセルのうちcodeモードのセルを収集
　↓
Pythonの構文解析木(AST)
　↓
計算式となっているコード行(グローバル域の非関数コード)を全て抽出
　↓
計算式のコード行を全てコメントアウト
　↓
関数・クラスだけの.pyコード(文字列)を返す
```

となっています。

# .ipynbファイルをimportする.pyコード

```python:ipynb_import_lib.py

# -*- coding: utf-8 -*-

# .ipynbをimportする

# 使用例1:
# import ipynb_import_lib
# ipynb_import_lib.import_ipynb("./sample_lib.ipynb", "lib1")
# lib1.any_func1(...) # 呼び出し等

# 使用例2:
# import ipynb_import_lib
# lib1 = ipynb_import_lib.import_ipynb("./sample_lib.ipynb")
# lib1.any_func1(...) # 呼び出し等

import json, ast, re
import pathlib, sys

def main(): # 実行切替用
    pass

# sec: .ipynbをimport

# .ipynbファイルのpyコードをimport (import用の.py生成)
def import_ipynb(path_nb, # .ipynbのパス
    name=None, no_expr=True, # .pyファイル名, 計算式は除外
    if_import=True, must_import=False): # importしてglobalsに登録, 既存でも再import
    
    # sec: パス解決
    
    if not isinstance(path_nb, pathlib.Path):
        path_nb = pathlib.Path(path_nb)
    
    if name is None: # if: .ipynbと同名
        name = path_nb.stem
    
    path_py = path_nb.parent.joinpath("__pycache__", name + ".py")
    
    # sec: .ipynbファイル内容の更新有無
    
    if not path_py.exists() or \
        path_nb.stat().st_mtime > path_py.stat().st_mtime: # if: 更新あり
        
        # sec: .py生成
        
        text_code = get_code_from_ipynb(path_nb, no_expr)
        
        path_py.parent.mkdir(exist_ok=True) # __pycache__フォルダ作成
        
        with open(path_py, 'w', encoding='UTF8') as file:
            file.write(text_code)
    
    # sec: import
    
    if not if_import: # if: .py生成のみ
        return
    
    pygl = globals() # global域の変数
    if not must_import and name in pygl: # if: 既存 既にimport済み
        return pygl[name]
    
    else:
        sys.path.append(str(path_py.parent)) # __pycache__フォルダへのパス tag:CACH

        imported = __import__(name) # 注意: "abc.def"など下位を読み込めない
        # imported = importlib.import_module(name) # __import__で代用可 importlib要らず
        
        sys.path.remove(str(path_py.parent)) # 解除 tag:CACH

        pygl[name] = imported
        return imported

def test__import_ipynb():
    # 結果:
    # <module 'tut1' from '__pycache__\\tut1.py'>
    # <module 'tut1' from '__pycache__\\tut1.py'>
    # <class 'tut1.DQN'>
    # <module 'official-tut reinforcement_q_learning' from '__pycache__\\official-tut reinforcement_q_learning.py'>
    # <class 'official-tut reinforcement_q_learning.DQN'>

    PATH_NB = "./official-tut reinforcement_q_learning.ipynb"
    import_ipynb(PATH_NB, "tut1")
    print(tut1)
    import_ipynb(PATH_NB, "tut1") # 2回目
    print(tut1)
    print(tut1.DQN)

    tut2 = import_ipynb(PATH_NB)
    print(tut2)
    print(tut2.DQN)
# main = test__import_ipynb

# .ipynbファイルからpyコード(文字列)を取得
def get_code_from_ipynb(path_nb, # .ipynbのパス
    no_expr=True): # グローバル域に定義された計算式は除外
    # 非対応: """～多行～"""の文字列はコメントアウト不可
    
    # sec: load
    
    with open(path_nb, 'rb') as file:
        json_root = json.load(file)
    
    # sec: code cell
    
    re_ipy = re.compile(r"(^%|^!)", re.MULTILINE)
    text_code = ""
    for elem_i in json_root["cells"]:

        if elem_i["cell_type"] != "code":
            continue

        text = "".join(elem_i["source"])
        text = re_ipy.sub("# NOT-PY: \\1", text)
        
        text_code += "\n# ---------- cell ----------\n\n" + text + "\n"
        
    # print(text_code)
    
    if not no_expr: # if: コードそのまま
        return text_code
    
    # sec: AST
    
    text_code += "\npass" # HACK: 最終行の検出用に空要素を追加

    tree_root = ast.parse(text_code)

    # sec: 計算式
    
    i_curr = None
    node_curr = None
    expr_list = [] # グローバル域に定義された計算式範囲 [(i_from, i_to), ...]
    for node_i in ast.iter_child_nodes(tree_root):
        
        i_next = get_lineno(node_i) # 1つ前の要素の行番号範囲を決める
        if i_curr is not None: # if: 初回以外
            
            if node_curr.__class__.__name__ not in (
                "Import, ImportFrom, FunctionDef, ClassDef"):
            
                expr_list.append((i_curr, i_next - 1))
        
        i_curr, node_curr = i_next, node_i
    
    # HACK: 追加したpassは既に除去済み 1つ前の要素の為
    
    # print(expr_list)
    
    # sec: コードを行で分割
    
    re_line = re.compile(r"\r?\n")
    lines = re_line.split(text_code)
    lines = lines[:-1] # HACK: 追加したpassを除去
    
    # sec: 計算式をコメントアウト
    
    re_noco = re.compile(r"^(\s\t)*$|^(\s\t)*#")
    for i_from, i_to in expr_list:
        
        for i in range(i_from, i_to + 1):
            if not re_noco.search(lines[i]): # if: コードあり
                lines[i] = "# EXPR: " + lines[i]
    
    return "\n".join(lines)

def test__get_code_from_ipynb():
    # 結果:
    # 
    # # ---------- cell ----------
    # 
    # # NOT-PY: %matplotlib inline
    # 
    # # ---------- cell ----------
    # 
    # # NOT-PY: !pip show pip
    # 
    # # ---------- cell ----------
    # 
    # # import gym
    # import math
    # ...(以降略)
    
    PATH_NB = "./official-tut reinforcement_q_learning.ipynb"
    text_code = get_code_from_ipynb(PATH_NB)
    print(text_code)
# main = test__get_code_from_ipynb

# AST Nodeから行番号を取得
def get_lineno(node):
    
    try:
        return node.lineno - 1 # 開始番号は1からの為
    except:
        return None

# sec: entry

if __name__ == "__main__": main()

```