Help us understand the problem. What is going on with this article?

maya のメニュー登録を題材にメタプログラミング・デコレータの紹介

皆様ごきげんよう。毎年恒例 Maya Advent Calender 2019の季節がやってまいりました。これは19日目の記事。今回は Python の特定のオブジェクトにコード内容以外の情報を持たせ、その情報を別工程で使用し細工するという手法を紹介しよう。このようにプログラミングをひとつ階層うえから操作する、つまりプログラム自体をプログラムすることを メタプログラミングと呼ぶ。本稿では、メタプログラミングの一種であるdecoratorの解説と、普通に記述すると煩雑な記法を要求される maya のメニューの操作に対しこれを適用した記録を記述する。特定のDCCの機能についての話ではなく、むしろプログラミングの話題であるがすこしばかりお付き合いいただきたい。なお今回作成した動作サンプルは https://github.com/yamahigashi/QiitaMayaAdventCalendar2019 においておいた。必要に応じて参照してほしい。

はじめに

maya でのプログラミングに限らず、特定のAPI、領域に絞ったプログラミングというものには特徴がある。それは決まりきった手順やパターンが頻出するということだ。そんな場合、特徴を抽出し、便利に使えるようにフレームワーク・ライブラリとして整備していく。既存のものがあれば勿論それを利用する。

では、そのようなものを作成していて、しかし素直に書いていては対応できない、どうしても冗長になってしまう。ということが起きたとしよう。

そのようなときにつかえる(かもしれない)ものがメタプログラミングだ。

メタプログラミングとは

Wikipedia より引用すると

メタプログラミング とはプログラミング技法の一種で、ロジックを直接コーディングするのではなく、あるパターンをもったロジックを生成する高位ロジックによってプログラミングを行う方法、またその高位ロジックを定義する方法のこと。
メタプログラミング wikipedia

とある。また、あまり使われない言葉ではあるが、自動プログラミング(automatic programming)生成的プログラミング(generative programming) の一種といえる。はやりの言葉ならプロシージャルなプログラミングといえるかもしれない(いまかんがえた)。要はプログラミングを対象に、その 生成, 加工,解析 を楽にしようとするプログラムだ。このメタプログラミングを実施するための手法は各言語で様々あるのだが、代表的なところではマクロ1やテンプレート、リフレクションといったものがある。動的型付け言語では好き放題できたりする。

Python においては inspectモジュールastモジュール, 組み込み関数であるglobals()locals()2 などを使うことにより解析や介入が自由に出来る。のだが、この記事ではより手軽に利用できる decorator について紹介しよう。ほかの手法について興味を持たれたら、どのようなことができるか各自調べてみていただきたい3

Decorator とは

(もともとは)デザインパターンの一種 Decorator pattern で、オブジェクトを修飾(つまり機能を付与したり)する機構のことだ。デコ人(オブジェクト)とデコられる人(オブジェクト)がいることを覚えよう。デコられはいくら発生しても良い。

オブジェクト指向な言語を使用していると、ふるまいの細工を行う場合、まずクラスの継承を使ったスペシャライズが思い浮かぶかもしれない。継承による装飾は継承関係にある縦方向のみであるが、デコレータでは継承を無視した横方向の修飾を可能とする。

Pythonでは

趣旨にそえばどのような形態であっても decoratorと呼んでよさそうだが、pythonでは専用の構文が用意されている。単に decoratorという場合これを指す。いかにコードを示す。

デコル.py
def decorratter(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        print("デコッタ")
    return wrapper

# これが専用構文
@decorratter
def hoge():
    print("hoge")

# だがこう書いても動作は一緒
decorated_hoge = decorratter(hoge)
実行すると.py
>>> hoge()
hoge
デコッタ

>>> decorated_hoge()
hoge
デコッタ
デコッタ  # 2回デコったので2回表示される

実態は関数を引数にとり、(別の)関数を返す関数だ。上記ではわかりやすいように decorated_hoge と別名で受け取っている。ではどのような(別の)関数を返すかというと、元の関数の前後に別の処理をするようなものだ。どのような別の処理かというと、何をやってもよい。4より詳しい解説は以下のような参考リンクを参照してほしい。
https://qiita.com/mtb_beta/items/d257519b018b8cd0cc2e
https://note.crohaco.net/2014/python-decorator/

できること

では(mayaやDCCツールの場合)実際にどのようなことに使うかというと、たとえば

  • 実行前の状態を保存(選択やワークスペースなど)し実行後復元
  • 実行結果のキャッシング
  • 処理や処理結果を監視

などに使える。webフレームワークを何かしら触れたことのある方は @ がそこらじゅうにちりばめられていることにお気づきだろう。微妙に異なる定型処理を大量に自動生成することによく使える。

状態の保存と復元は、説明不要だと思うが、たとえば本処理中で cmds.selectcmds.ls(sl=True) などを使用している場合、コマンド実行前後でユーザの選択状況が意図せず変わってしまう。そのようなことを避けるためのものだ。

キャッシング用途では、例えば shotgun api や google spread sheet へのクエリに適用すると効果が高い。(maya関係ないけど)この記事の読者であればおそらくご興味あることだと思う。実クエリの発動前に、更新あるなしを先行して問い合わせ、なければキャッシュを返してやるようなものをdecoratorで実装してやればよい5

注意

あまりに技巧的なコードでは、コードの見た目と実際の動作が乖離していく。書かれたコードの習熟やデバッグに支障をきたすことがある。やりすぎには注意し、動作や目的がわかるようなるべく平易な記述につとめ、他人が読んでも想像つくもの、期待を裏切らないコードとなるよう心がけよう。

実践編

前置きが長くなったが、ようやく本題だ。
maya においてメニューにコマンドを登録するにはどのようにするだろう?

メニューいっこつくる.py
cmds.menuItem(
    menu_name,
    label=safe_string(label),
    parent=parent,
    echoCommand=True,
    annotation=annotation,
    command=function,
)

以上のようなコード片によりメニュー要素を必要な分用意するのが一般的だろう。複数要素に対応するにはおおまかに2つ

  • コードに一個一個べた書き
  • コマンド名、ペアレント名などのコマンド内容をデータとして記述し、ルールにより登録

いづれかに分類される。適切に編成されているのであれば、べた書きも悪い手段ではない。(えっ何もなく各所野放図?ソレモマタ マ ヤ

では、ためしに以下のようなメニューを作ることを考えてみよう。

Screenshot_223.png

これをべた書きすると

ずらずら書き下す.py
    前略

    cmds.menuItem(
        'sugokunai_animation',
        label='Animation',
        subMenu=True,
        tearOff=True,
        parent=MENU_NAME
    )

    cmds.menuItem(
        'anim_synoptic',
        label='Synoptic',
        parent='sugokunai_animation',
        echoCommand=True,
        command=animation.open_synoptic
    )

    cmds.menuItem(
        'anim_studiolibrary',
        label='Studio Library',
        parent='sugokunai_animation',
        echoCommand=True,
        command="""import studiolibrary\r\nstudiolibrary.main()"""
    )

    以下これをたくさん書く

このようになるはずだ。見てわかる通り冗長になる。これだけ使って3つしかメニューを登録できていない。呼び出しを一行にも書けるが今度は視認性が極端に悪化する。では必要な要素のみを抜き出し、データとしてまとめループで回してみよう、

データにまとめてループでメニューを作る.py
animation_menu_items = [
    ["anim_synoptic", "Synoptic", animation.open_synoptic],
    ["anim_studiolibrary", "StudioLibrary", """import studiolibrary\r\nstudiolibrary.main()"""],
    # メニュー要素分ずらずら用意する
]

def add_menu_item(name, label, parent, command):
    cmds.menuItem(
        name,
        label=label,
        parent=parent,
        echoCommand=True,
        command=command
    )

for name, label, command in animation_menu_items:
    add_menu_item(name, label, parent, command)

データをコード外に持たせるかどうかでさらに分かれるが、おおむねこのように記述することになる。フォルダへの内包などを規定する場合もう少し複雑なデータ構造が必要になるが本題からそれるのでここでは割愛する6。では上記2通りのような書き方で何か問題あるだろうか7
はじめに気づくであろうことは冗長であることだ。実際の動作には無関係なメニューへの登録のためだけに大掛かりな記述が必要になる。二点目はコマンド内容とメニュー記述の距離だ(とはいえメニューとはユーザのために存在するもの、コードは開発者のためのものなので、距離があること自体が即、悪いわけではないのだが)距離があると把握しづらい、メンテしづらいなどの欠点がある。

ではここでメタプログラミングを適用してみよう。
読者の方も読み進める前にどのように工夫が可能かを立ちどまって考えてみていただきたい。

 
 
 

 

どうだろう、筆者はコマンドになるべき関数にメタ情報をもたせ、あとで回収する。という方法を思いついた8。Pythonにおけるメタ情報の付与にはいくつかの方法があるが9、ここでは decorator を用いた方法を紹介する。では要件を確認していこう。

要件

あらためてまとめると、

  • メニューを登録したい
  • 実行される関数と登録に必要なデータを近い距離に書きたい

メニューを構成するには必須となる情報、任意の情報があり、後から補完可能な情報と外から与えなければならない情報が存在する。機械的に算出可能は情報は不要であるので 関数そのものに付与する情報は以下のようになるだろう

  • ラベル(必須)
  • 格納先メニュー(※)
  • サブフォルダの有無
  • divider(区切り線)の有無
  • アイコン画像のパス

補完関係な情報とは

  • (内部的な)メニュー名

これはモジュール名や関数名から機械的に採番してよいので省略可能だ。

実装

先に、デコレータ本体ではなく修飾される側をお見せする。メニューにより呼ばれる関数を以下のように記述したい。

decorated.py
@menu.command_item("Synoptic")
def open_synoptic():
    from mgear import synoptic
    synoptic.open()


@menu.command_item("Studio Library")
def open_studiolibrary():
    import studiolibrary
    studiolibrary.main()


@menu.command_item("Mirror selected animation", divider="")
def mirror_selected_animation():
    import gml.animation as an
    an.mirror().mirror_all_animation()


@menu.command_item("IK/FK Space Transfer")
def show_space_transfer():
    """現在開いているシーンのキャラクタのスペーストランスファを表示する。"""
    import rigging as r
    import scene as s
    characters = s.get_characters()
    for c in characters:
        r.show_space_transfer_ui(character)


@menu.command_item("Edit export setting", divider="Export")
def edit_export_setting():
    """現在開いているシーンのエクスポート設定を編集する。"""
    pass


@menu.command_item("debug", folder="debug")
def debug():
    """現在開いているシーンのエクスポート設定を編集する。"""
    pass

関数宣言の↑にある @menu.command_item() がデコレータ。ここにメニューラベル名や、ペアレント名、dividerの有無を必要に応じ宣言する。では次にこの関数を定義してあるモジュールをメタ的に解析しイイ感じにメニューに落とし込む機構を用意してやる仕組みを見ていこう。モジュールに宣言されている関数群を inspect.getmembers(module) により取得し、それらのカスタムプロパティにアクセスしメニューに必要な情報を収集、 cmds.menuItem() を発行してやればよい。すこしく長いが要所抜粋、

gather_commands.py
def register_module_menu(module, menu_label, parent_menu_name):
    # type: (Callable, Text, Text) -> None  # noqa
    """`parent_menu_name` 以下に `module` に記述されたコマンドをメニューとして登録する。
    """

    前略

    # 組み込みモジュール `inspect` を使用し `module` に定義されたメンバを取得する
    # if _is_menu_item によりメニューアイテムのみを取得する(後で解説する)
    members = [o for o in inspect.getmembers(module) if _is_menu_item(o[1])]
    members.sort(key=_linenumber)

    for func_info in members:
        parent = folder  # subfolder が指定されていればペアレントはそれ、そうでないなら folder素通し

        func_name = func_info[0]
        func = func_info[1]
        label = func.label
        sub_folder_label = func.folder
        divider = func.divider
        annotation = safe_to_display(_get_annotation(func))  # docstring をアノテーションに利用する

        中略

        cmds.menuItem(
            "{}_{}".format(package_path, func_name),
            label=safe_to_display(label),
            parent=parent,
            echoCommand=True,
            annotation=annotation,
            command=func,
        )

上記の関数に先のメニューに登録したい関数群が定義されたモジュールを投げてやればよい。モジュールに定義された is_maya_menu_item な関数を収集し、その属性に応じた menuItemの作成を行う。関数の収集には inspectモジュールを使用し、属性の付与にdecoratorを使用している。ではデコレータはどんなものかというと、

decorator.py
def command_item(label, folder=None, divider=None):
    # type: (Text, Optional[Text], Optional[Text]) -> Callable
    """Decorator that appends meta data to a function for Maya command menu."""

    def decorator(func):

        @functools.wraps(func)
        def wrap(*args, **kwargs):
            return func()

        setattr(wrap, "linenumber", inspect.getsourcelines(func)[1])  # アイテムのソートに利用する
        setattr(wrap, "folder", folder)
        setattr(wrap, "label", label)
        setattr(wrap, "divider", divider)
        setattr(wrap, "is_maya_menu_item", True)
        return wrap

    return decorator

本体はこれだけだ。組み込み関数setattr() を使用して関数オブジェクトに強制的にデータを追加している10。若干説明が必要な箇所は、setattr(wrap, "linenumber", inspect.getsourcelines(func)[1]) だろうか。これはinspect.getmember() による収集ではアルファベット順でソートされた結果が返ってくるため、メニューへの登録の際に不便である。そのためソースコード中の行数を保存し、記述順でメニューに登録されるようにしている。

アトリビュートの取り出し部分.py
    members = [o for o in inspect.getmembers(module) if _is_menu_item(o[1])]

    for func_info in members:
        parent = folder  # subfolder が指定されていればペアレントはそれ、そうでないなら folder素通し

        func_name = func_info[0]
        func = func_info[1]

        label = func.label
        sub_folder_label = func.folder
        divider = func.divider
        annotation = safe_to_display(_get_annotation(func))  # docstring をアノテーションに利用する

先のコードで付与した属性の取得はこのように行っている。.label, .divider, .folder といった要素に単純にアクセスするだけであり、その後の処理も通常と同じように可能だ。

取り出した情報をもとにメニューに追加.py
        cmds.menuItem(
            "{}_{}".format(package_path, func_name),
            label=safe_to_display(label),
            parent=parent,
            echoCommand=True,
            annotation=annotation,
            command=func,
        )

これで動作が理解いただけたと思う。人力で分類・ラベリングしていた箇所を機械的になおかつ柔軟に解決できた。メニューへの登録は若干複雑であるが、デコレータ本体は単純であるのがわかると思う。では省略したコード(フォルダの用意やdividerの設定)を含めた全体像をお見せしよう

register_menu_items.py
def register_module_menu(module, menu_label, parent_menu_name):
    # type: (Module, Text, Text) -> None  # noqa

    package_path = module.__name__
    safe_package_name = get_folder_name_for_module(module)

    folder = cmds.menuItem(
        safe_package_name,
        label=safe_to_display(menu_label),
        subMenu=True,
        tearOff=True,
        parent=parent_menu_name
    )

    members = [o for o in inspect.getmembers(module, inspect.isfunction) if _is_menu_item(o[1])]
    for klass_info in inspect.getmembers(module, inspect.isclass):
        logger.debug(klass_info)
        if not klass_info[1].__module__ == module.__name__:
            continue

        for method in inspect.getmembers(klass_info[1], inspect.ismethod):
            logger.debug(method)
            if _is_menu_item(method[1]):
                members.append(method)

    members.sort(key=_linenumber)

    for func in members:
        parent = folder

        func_name = func[0]
        label = func[1].label
        sub_folder_label = func[1].folder
        divider = func[1].divider
        annotation = safe_to_display(_get_annotation(func[1]))

        if sub_folder_label:
            sub_folder_name = sub_folder_label.replace(" ", "_").replace("-", "_")
            sub_folder_name = "{}_{}".format(safe_package_name, sub_folder_name)
            exists = cmds.menuItem(sub_folder_name, exists=True)

            if exists:
                parent = sub_folder_name

            else:
                parent = cmds.menuItem(
                    sub_folder_name,
                    label=safe_to_display(sub_folder_label),
                    subMenu=True,
                    tearOff=True,
                    parent=folder
                )

        if isinstance(divider, str) or isinstance(divider, unicode):
            cmds.menuItem(
                dividerLabel=safe_to_display(divider),
                parent=parent,
                divider=True
            )

        cmds.menuItem(
            "{}_{}".format(package_path, func_name),
            label=safe_to_display(label),
            parent=parent,
            echoCommand=True,
            annotation=annotation,
            command=func[1],
        )

あらためて注意点

デコレータの発動タイミングは、関数が定義される時にデコレーションされる。関数呼び出し時に修飾されるわけではないのでよくよく注意しよう。 reload(Module) を多用するナイーブなかたもよくこのことを覚えておこう。

おわりに

いかがだったろうか。やりたいことは大したことないのに無駄に長くしかもmayaな意味ほとんどない記事になってしまった。実際のところ、『最良の方法』というものはない。今回紹介した方法があなたに適しているとも言い難いだろう。状況に応じてより適切な方法があり、チームで開発しているのであればどのような手法をとるにせよ運用を定めることがより大事といえる。それでもこのような手法があると頭の片隅おいておいていただけると、なにがしかのお役に立てれば幸いだ。

明日20日は @tm8rさんの「最近のツール配布構成」です。

おまけ

筆者がよく使うデコレータを紹介しておく。また github等コードサーチで wraps, maya.cmds で検索するとほかの方の書かれたものも多数ヒットする。とても参考になるものもあるので興味ある方はぜひご覧いただきたい。また、本稿ではふれなかったがこのようなデコレータはほぼそのまま Context Manager つまり with句として流用可能だ。デコレータは関数を修飾するものだが、contextlib.contextmanager はコードの特定箇所、一部分に作用すると覚えておけば良い。このようなプログラミングにおけるイディオム、パターンというものは覚えておくだけで役に立つ場面が存在する。

ビューポートの停止.py
def viewport_off(func):
    """https://qiita.com/pontya/items/d8b180f746517287ea48
    """
    @functools.wraps(func)
    def wrap(*args, **kwargs):
        # type: (List[Any], Dict[Any]) -> Any

        # Turn $gMainPane Off:
        import maya.mel as mel
        import maya.cmds as cmds

        # paneLayout -manage
        gMainPane = mel.eval('global string $gMainPane; $temp = $gMainPane;')
        cmds.paneLayout(gMainPane, edit=True, manage=False)

        # ogs
        ogs_paused = cmds.ogs(q=True, pause=True)
        if not ogs_paused:
            cmds.ogs(pause=True)

        # refresh
        cmds.refresh(suspend=True)

        try:
            return func(*args, **kwargs)

        except Exception:
            import traceback
            traceback.print_stack()
            traceback.print_exc()
            raise

        finally:
            cmds.paneLayout(gMainPane, edit=True, manage=True)
            if not ogs_paused:
                cmds.ogs(pause=True)
            cmds.refresh(suspend=False)

    return wrap
関数呼び出し前の`set_project`状況を保存・復元.py
def keep_workspace(func):
    @functools.wraps(func)
    def wrap(*args, **kwargs):
        # type: (List[Any], Dict[Any]) -> None
        """Decorator - Keep current workspace while func is running.

        if func will fail, the error will be raised after.
        """

        import maya.cmds as cmds
        try:
            current = cmds.workspace(query=True, fullName=True)
            restore = True

        except Exception:
            restore = False

        try:
            return func(*args, **kwargs)

        except Exception:
            import traceback
            traceback.print_stack()
            traceback.print_exc()
            raise

        finally:
            if restore:
                cmds.workspace(current, openWorkspace=True)

    return wrap
関数呼び出し前の選択状態を保存・復元.py
def keep_selection(func):
    @functools.wraps(func)
    def hoge_wrapper(*args, **kwargs):
        """Store current selection and restore that after executing func."""

        import maya.cmds as cmds
        try:
            current_selection = cmds.ls(sl=True)
        except Exception:
            pass

        res = func(*args, **kwargs)

        try:
            cmds.select(current_selection)
        except Exception:
            pass

        return res

    return hoge_wrapper

  1. Let Over Lambdaオススメ / boo言語のマクロとかもなかなか具合良い。ただし実用性はナイ 

  2. string interpolation のキーワード引数に **locals()とか使うと便利だよ。python3だと f-strings入ったから不要だけど。 

  3. https://www.ibm.com/developerworks/jp/analytics/library/ba-metaprogramming-python/index.html このあたりとかかな?globals() と type() つかって実行時にクラスを錬成とか、astつかって print を pprint に強制上書きみたいな 

  4. 元の関数がとる引数を細工するとか 

  5. ただしそういうのは python以外で書いたほうがよい。一定規模のデータの操作はたとえばgo langとか使うほうが圧倒的に快適だし実行速度も桁違い。必要に応じて、別の選択肢を検討しよう。うっかりキャッシュシステム自体を自前で実装するとデバグ等のコストがすさまじく嵩むことを覚悟 

  6. さらにそれるが、menuItem()のオプション引数 command に文字列、特に複数行文字列を記入したい場合は textwrap.dedent() を併用するとよい。 

  7. この時点で2つを比較した場合どちらも甲乙つけがたい。下手にデータとアルゴリズムを抽象化するくらいならべた書きのほうが読みやすい場合も多い。たとえばネストを表現しようとすると途端に難易度が上がる(データの用意あるいはアルゴリズム、もしくはどちらも) 

  8. 他には astや inspectを用いて特定モジュールの関数を列挙するようなものを思いついたがあまりきれいなやり方にはならないので却下 

  9. 命名やドキュメント文字列を用いても場合によって有用 

  10. 実は当初、関数オブジェクトではなくプロトタイプベースなかんじのコマンドアイテムクラスを動的生成するデコレータを、なんらかの情報をメンバとして持つならクラスのほうが妥当だろう程度のかんがえで書いていたのだが、そうするとdoctest面倒だったり副作用が大きかったので不採用となった。関数にsetattr するの微妙に気持ち悪さあるので嫌だったのだが結局これに落ち着いた。github のサンプルにはクラス動的生成版もあげておくので興味ある方はご覧ください。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした