【Python】inspectを使った仮引数(Parameter)とキーワード引数(kwargs)の取得の仕方


# この記事の背景
- あるクラス(Class)がどのような仮引数(Parameter)やキーワード引数(kwargs)を取るか知りたい
- そしてその方法は(思っていたより)ややこしかった
- よって､筆者の備忘も兼ねて記事にします

# 本記事の想定読者
- Pythonユーザー
- 外部ライブラリのクラスの仮引数､kwargsの一覧を利用したいケースがある
- できれば自動でその一覧を出力したい

# 結論
- 標準ライブラリのinspectを使用する
- 仮引数の取得は`list(inspect.signature(target_class).parameters.keys())`
- kwargsの取得は`dir(target_class)`や`inspect.getmembers()`を使う

```python: コード例import inspect
from typing import Callable

from matplotlib.figure import Figure
from matplotlib.axes._axes import Axes

def extract_params(target_class: Callable):
    """Extract parameters including **kwargs parameters"""
    
    params = list(inspect.signature(target_class).parameters.keys())
    
    all_members = [m for m in dir(target_class) if not m.startswith('_')]
    
    func_members = [m[0] for m in inspect.getmembers(target_class, inspect.isfunction)]
    func_members = [m for m in func_members if not m.startswith('set_')]
    
    param_members = [m.replace('set_', '') for m in all_members if m not in func_members]
    
    params = params + param_members
    
    return params

FIGURE_PARAMS = extract_params(Figure)
AXES_PARAMS = extract_params(Axes)
```

# 前提
Pythonで可変長のキーワード引数を使う際に､**kwargsという書き方があります｡[^1]
[^1]:実際には"kwargs"以外でも良いですが､慣習として"kwargs"がよく使われます｡

いろいろなキーワード引数がある場合に､**kwargsは便利です｡
しかし､**kwargsに入れたキーワード引数はひとまとめにされるため､
その中身を分けて利用しようとすると､扱いが難しい場合があります｡

たとえば､以下のような状況を仮定します｡
- 関数の内側にある複数のクラスで､それぞれkwargsを受け取りたい
- 各クラスは､それぞれ異なるkwargsを受け取る
- 各クラスにあわせたkwargsを代入したい

```python:うまくいかない例
main_func(**kwargs):
    """各クラスは､それぞれ異なるキーワード引数を受け取るものとする"""
    some_class = SomeClass(**kwargs)
    result = AnotherClass(some_class, **kwargs)
    return result
```

ここでは各クラスの中身を具体的に定義していませんが､
それぞれ異なる種類の多くのkwargsを持つものだと考えてください｡

このコードを実行した場合､SomeClassにもAnotherClassにも､
全く同じkwargsが代入されるため､不適切なキーワード引数まで渡してしまいます｡

もちろん､各クラスのkwargsをAPI Referenceなどで調べて､
main_func()の仮引数にすべてを設定しておくことも可能です｡

しかし､一つ一つを手打ちで仮引数に設定していくのは手間がかかりますし､
手作業ゆえのミスや見落としが発生する可能性もあります｡

この問題を標準ライブラリであるinspect[^2]を使って解決します｡
[^2]:https://docs.python.org/ja/3/library/inspect.html

# 解決方法
## 手順1: 仮引数の一覧を取得する
今回は例として､matplotlibのFigureクラス[^3]とAxesクラス[^4]を対象にします｡
[^3]:https://matplotlib.org/stable/api/figure_api.html
[^4]:https://matplotlib.org/stable/api/axes_api.html

例として､Axesの仮引数(Parameters)とkwargsを見てみます｡

![params_of_axes.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602439/1fb949b2-d13a-0885-e6a8-cfb7f86e37d8.png)


めっちゃありますよね｡

kwargsのリストはこの下にさらに続いています｡
筆者はFigure､Axesを絡めたラッパー関数を書こうとして､引数の扱いに悩みました｡

inspect.signature[^5]を使うと以下のような出力を得ることができます｡
[^5]:https://docs.python.org/ja/3/library/inspect.html#inspect.signature

```python:
import inspect

from matplotlib.figure import Figure
from matplotlib.axes._axes import Axes

def params_of_class(target_class):
    params = list(inspect.signature(target_class).parameters.keys())
    print(params)

params_of_class(Figure)
# ['figsize', 'dpi', 'facecolor', 'edgecolor', 'linewidth', 'frameon', 'subplotpars', 'tight_layout', 'constrained_layout', 'layout', 'kwargs']

params_of_class(Axes)
#['fig', 'rect', 'facecolor', 'frameon', 'sharex', 'sharey', 'label', 'xscale', 'yscale', 'box_aspect', 'kwargs']
```
1行の処理がやや長いですが､分けて書くと以下のようになります｡
```python:
def params_of_class_detail(target_class):
    signature = inspect.signature(target_class)
    mappingproxy = signature.parameters
    odict_keys = mappingproxy.keys()
    params = list(odict_keys)
    print(params)
```
## 手順2: クラスのすべてのメンバーを取得する
やや粗いやり方ですが､dir()を使ってクラスのすべてのメンバーを取得します
この際､"_"で始まるメンバーは除外しています｡
いわゆるダンダーメソッド(__init__など)やプライベートメソッドを除く意図です｡

```python:
target_class = SomeClass
all_members = [m for m in dir(target_class) if not m.startswith('_')]
```
## 手順3: メソッドのメンバーを除外する
今回は引数に着目したいので､メソッドのメンバーを除外します｡

まず､除外する対象のリストを作成します｡

inspectのgetmembers()を使い､メソッドのメンバーだけを抽出します｡
結果は各メンバーごとに(メンバー名,詳細情報)のかたちで出力されるので､`m[0]`としています｡

このとき､`set_`で始まるメソッドは後のkwargsに関わってくるので､
除外リストであるfun_membersからは一旦除きます｡
(除外リストから除外､つまり対象のメンバーとして残ります)

```python:
func_members = [m[0] for m in inspect.getmembers(target_class, inspect.isfunction)]
func_members = [m for m in func_members if not m.startswith('set_')]
```

## 手順4: kwargsのリストを作成する
all_membersからfunc_menbersを除外します｡

このとき､先ほど温存した`set_`ではじまるメソッドは､
`set_`を除くかたちでリストに残します｡[^6]
[^6]:Axesに関していうと､Referenceのkwargsに含まれているはずなのに､set_以外のかたちでメンバーに含まれていない項目があったからです｡
あまりいいやり方ではないかもしれません｡


```python:
param_members = [m.replace('set_', '') for m in all_members if m not in func_members]
```

## 手順5: 仮引数リストとkwargsリストの結合
最後に作成した2つのリストを結合させます｡

```python:
params = params + param_members
```
重複が発生しうることを想定して､set()で集合にすることも考えましたが､
リストの形式のままの方が仮引数が先頭に表示されるので便利だと思い､リストのままにしました｡

# 実際の使用例
自作ライブラリの例で恐縮ですが､
**kwargsを途中でfigure_paramsとaxes_paramsに割り振っています｡[^7]
[^7]:該当箇所は[こちら](https://github.com/guneco/necoplot/blob/d97045441332cacb9c14e49ed60baf2b1be1c83b/necoplot/single_plot.py#L23)


念のため使われなかったkwargsについてはprintでCAUTIONを出すようにしました｡[^8]
[^8]:実はこのままだと重複する引数を両方に代入してしまいますが､対処は一旦保留しています｡

```python:single_plot.py
FIGURE_PARAMS = extract_params(Figure)
AXES_PARAMS = extract_params(Axes)

@common._apply_user_parameters([FIGURE_PARAMS, AXES_PARAMS])
def plot(
    ax_config: Optional[Callable] = None,
    figsize: tuple[float, float] = (6,4),
    dpi: int = 300,
    layout: str = 'tight',
    **kwargs) -> Axes:
    """Plot a figure with setting figure args and axes args"""
    
    figure_params = {key: kwargs[key] for key in kwargs.keys() if key in FIGURE_PARAMS}
    axes_params = {key: kwargs[key] for key in kwargs.keys() if key in AXES_PARAMS}
    
    for key in kwargs:
        if (key not in FIGURE_PARAMS) and (key not in AXES_PARAMS):
            print(f'CAUTION: {key} is ignored because it is not found in FIGURE_PARAMS or AXES_PARAMS')

    fig = plt.figure(figsize=figsize, dpi=dpi, layout=layout, **figure_params)
    
    ax_config = ax_config if ax_config else config_ax(**axes_params)
    ax = ax_config(fig)
            
    return ax
```
これで内側の複数の関数にそれぞれ必要なのkwargsを代入できるようになりました｡
(やり方はあまり厳密ではありませんが…)


# コード全文
冒頭に掲載したコードと同一のため省略します｡

# まとめ
- クラスの仮引数とkwargsの一覧が取得できた！
- **kwargsが必要な複数の関数を1つの関数に集約できた！