IPythonでimport文を省略したいときの最適解

## はじめに

ターミナル上でIPythonを実行したりJupyter QtConsole使ったりというときに、毎回`import matplotlib.pyplot as plt`と打ったり、`NameError: name 'plt' is not defined`でイラついたりを避けたいですよね。

## よく見る解決策

`IPython`は起動時にユーザーディレクトリからたどって /.ipython/profile_default/startupディレクトリ直下にあるpyファイルを全部（多分）実行します。この中にたとえば00-startup.pyファイルを

```python
# 00-startup.py

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
```

で保存しておくと、IPythonの起動後すぐに`np`とか使えます

## 欠点

起動時にimportしているので、当然起動が遅くなります。特に`pandas`や`matplotlib`はかなり重いので、起動がだいぶ遅れます。

できれば、`plt`を使わないときはimportせず、使うときは使うときでわざわざimportせずにどうにかしてほしいです。

## 最適解！

00-startup.pyに次の`LazyImporter`を定義します（少しずつ解説します）。

```python
## 00-startup.py

class LazyImporter:
    def __init__(self, name: str, alias: str = None):
        self._name = name
        self._alias = alias or name
    
    def __repr__(self):
        return repr(self._mod)
    
    def __str__(self):
        return str(self._mod)
    
    def __getattr__(self, name):
        return getattr(self._mod, name)

    @property
    def _mod(self):
        from IPython import get_ipython
        import importlib
        mod = importlib.import_module(self._name)
        shell = get_ipython()
        shell.push({self._alias: mod})  # update namespace
        return mod
```

何がやりたいかというと、`np = LazyImporter("numpy", "np")`で初期化し、`np.array`とか呼び出したときに`numpy`をimportしてグローバルの名前空間の`np`を上書きするという作戦です。ポイントは`_mod`の中身です。

```python
class LazyImporter:
    ...
    @property
    def _mod(self):
        from IPython import get_ipython
        import importlib
        mod = importlib.import_module(self._name)  # (1)
        shell = get_ipython()                      # (2)
        shell.push({self._alias: mod})             # (3)
        return mod
```

各行を説明すると:
1. `import_module`は引数にモジュールを文字列で渡してimportされたモジュールオブジェクトを返します。`self._name`が`"numpy"`なら`mod`に`numpy`が入り、`mod.array`とかが使える状態です。
2. `get_ipython()`は現在起動中のIPythonシェルを返します。ターミナル内でIPythonを起動している場合とJupyter QtConsoleを起動している場合では異なるオブジェクトが返されますが、当然共通のメソッドが実装されています。
3. `push`メソッドはシェルのグローバルな名前空間を`dict`で更新します。ここで`{"np": numpy}`を渡せば`np`が使えるようになるわけです。

あとは、モジュールが必要になる場面でこの`_mod`プロパティを呼び出すように細工すればよいです。考えられる場面はほぼ`np.array`とかのようにモジュール内の関数やクラスにアクセスするときなので、`__getattr__`で`_mod`を呼び出し、名前空間を更新しつつモジュールの`__getattr__`を呼べばよいです。

```python
class LazyImporter:
    ...
    def __getattr__(self, name):
        return getattr(self._mod, name)
```

一応、`>>> np`とか`>>> print(np)`にも対応させるため、`__repr__`も実装しておいたのが上の例になります。

こうすれば、使う可能性のあるモジュールを

```python
## 00-startup.py

np = LazyImporter('numpy', 'np')
plt = LazyImporter('matplotlib.pyplot', 'plt')
pd = LazyImporter('pandas', 'pd')
sns = LazyImporter('seaborn', 'sns')
da = LazyImporter('dask.array', 'da'))
```

みたく片っ端から名前空間にぶち込んでも起動は早いままですね！

## 残る問題点

さすがに変なことをしているので、問題は残ります。例えば、`numpy`がimportされていない状態で`np.a`とまで打ってTabで予測変換をしようとすると落ちます。これが回避できるかはもう少し試してみます。

#### 2022/09/23 追記

import lockのせいで`import_module`で例外が生じているんだろうなと思ってはいたんですが、try/exceptだけでどうやら落ちるのは回避できるっぽいです。

```python
class LazyImporter:
    ...
    @property
    def _mod(self):
        from IPython import get_ipython
        import importlib
        try:
            mod = importlib.import_module(self._name)
        except Exception:
            return
        shell = get_ipython()
        shell.push({self._alias: mod})
        return mod
```