PySideの小ネタ Mixin編+α

INDEX

はじめに

こんにちは。モノリスソフト テクニカルアーティストの菅原です。

今回はPythonにおけるMixinについての簡単な説明と、Mayaのツール開発を行うにあたって役に立つかもしれないMixinクラスのまとめになります。
記事内のコードはMayaのスクリプトエディタで動作しますので、実際に挙動を確認したい方はコピーして実行して頂ければと思います。

Mixinとは

色々と調べた結果を自分なりにまとめると...

Mixinとはプログラミング技法の1つで、Pythonでは「特定のメソッドを持つクラス」と「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」を多重継承することで、実質的に特定のメソッドを持つクラスに機能追加を行う

というもののようです。
また「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」は、このクラス単体で動作させる想定をしないことも特徴の1つかと思われます。

メリット

  • 状況に合わせて必要な機能のみを容易に追加できる
  • 機能のみのコードで済むので短く見やすい
  • 再利用性が非常に高い

デメリット

  • 多重継承した機能同士が干渉しあうとコードが複雑になりやすい

Mixinの例

Mayaにはツール開発が容易になるMixinが標準でいくつか用意されているので、今回はその中からMayaQWidgetBaseMixinを例として説明します。

MayaQWidgetBaseMixinは上記Mixinとはで述べた「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」になります。
この時「特定のメソッドを持つクラス」に相当するものはQWidgetです。

MayaQWidgetBaseMixinがQWidgetに追加する機能は次の3つです。

  • 自動命名
  • 親が明示されていないときにMayaMainWindowを親Windowにする
  • 変数がスコープ外になった時に消えないようにする

以下のコードをMayaのスクリプトエディタで実行して実際に3つの機能が追加されているかを見てみます。

MayaQWidgetBaseMixinの挙動確認

from maya.app.general import mayaMixin
from PySide2 import QtWidgets, QtCore
 
from maya import OpenMayaUI as omui
 
 
# QWidgetのみ
class WidgetOnly(QtWidgets.QWidget):
    pass
 
# 直接表示
WidgetOnly().show()
# 変数に保持して表示
a = WidgetOnly()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
 
 
# # MayaQWidgetBaseMixinのみ(動作しない)
# class MixinOnly(mayaMixin.MayaQWidgetBaseMixin):
#     pass
#
# # 直接表示
# MixinOnly().show()
# # 変数に保持して表示
# a = MixinOnly()
# a.show()
# # 自動命名
# print('NAME: ' + a.objectName())
 
 
# MayaQWidgetBaseMixinとQWidgetの多重継承
class MixinWidget(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
    pass
 
# 直接表示
MixinWidget().show()
# 変数に保持して表示
a = MixinWidget()
a.show()
# 自動命名
print('NAME: ' + a.objectName())

tech_15_02.gif

確認したところ3つの機能が正しく追加されているようでした。

※以降「特定のメソッドを持つクラス」をMixin対象クラス・「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」をMixinクラスとします。

メソッド解決順序の把握

Mixinクラスを使用・作成する際にはある程度メソッド解決順序を把握しておくことが重要になります。
以下のコードをMayaのスクリプトエディタで実行してみてください。

継承順番による挙動の違いの確認

from maya.app.general import mayaMixin
from PySide2 import QtWidgets, QtCore
 
from maya import OpenMayaUI as omui
 
 
# MayaQWidgetBaseMixinとQWidgetの多重継承:OK(Mixinが正しく動作する)
class MixinWidgetOK(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
    pass
 
# 直接表示
MixinWidgetOK().show()
# 変数に保持して表示
a = MixinWidgetOK()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
print('MRO: ' + str(a.__class__.mro()))
 
 
# MayaQWidgetBaseMixinとQWidgetの多重継承:NG((Mixinが正しく動作しない)
class MixinWidgetNG(QtWidgets.QWidget, mayaMixin.MayaQWidgetBaseMixin):
    pass
 
# 直接表示
MixinWidgetNG().show()
# 変数に保持して表示
a = MixinWidgetNG()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
print('MRO: ' + str(a.__class__.mro()))

このコード下部のMixinWidgetNGはMixinWidgetOKの継承順番を変えただけのものですが、QWidgetにMayaQWidgetBaseMixinの機能が追加されていないことがわかります。
これは継承の解決順が変わったことが原因です。

それぞれの「MRO」とプリントとされている部分を比較して見てください。

MROプリント出力

# OK
MRO: [<class '__main__.MixinWidgetOK'>, <class 'maya.app.general.mayaMixin.MayaQWidgetBaseMixin'>, <type 'PySide2.QtWidgets.QWidget'>, <type 'PySide2.QtCore.QObject'>, <type 'PySide2.QtGui.QPaintDevice'>, <type 'Shiboken.Object'>, <type 'object'>]
# NG
MRO: [<class '__main__.MixinWidgetNG'>, <type 'PySide2.QtWidgets.QWidget'>, <type 'PySide2.QtCore.QObject'>, <type 'PySide2.QtGui.QPaintDevice'>, <type 'Shiboken.Object'>, <class 'maya.app.general.mayaMixin.MayaQWidgetBaseMixin'>, <type 'object'>]

MROプリント出力の表

MixinWidgetOK
MixinWidgetNG
  1. MixinWidgetOK
  2. MayaQWidgetBaseMixin
  3. QWidget
  4. QObject
  5. QPaintDevice
  6. Object(shiboken)
  7. object(python)
  1. MixinWidgetNG
  2. QWidget
  3. QObject
  4. QPaintDevice
  5. Object(shiboken)
  6. MayaQWidgetBaseMixin
  7. object(python)

MROとは

MROとはMethodResolutionOrder(メソッド解決順序)の略で継承されたクラスのメソッドがどの順序で解決されるかを示しており、リスト左側のクラスから解決されていきます。

OKとNGではMayaQWidgetBaseMixinの解決順序が異なっていることがわかります。OKでは2番目ですが、NGでは6番目です。

NGのような解決順序になってしまうと実行時にメソッドのオーバーライドで問題が発生します。
例えばMayaQWidgetBaseMixinでQWidgetのメソッド(__init__等の特殊メソッド含む)のオーバーライドを想定して機能が追加されていたとしても、QWidget関連のクラスよりMayaQWidgetBaseMixinの方が優先順位が低いためオーバーライドされず、MayaQWidgetBaseMixinの処理に到達することはありません。

MixinクラスとMixin対象クラスを多重継承したクラスのメソッド実行イメージ

・色付き:定義されているメソッド

・〇:MixinWidgetOK・NGインスタンスからアクセスしたときに実際に実行される処理

MixinWidgetOK:MayaQWidgetBaseMixinを経由してQWidgetが実行されている。
インスタンスのメソッド 1,MixinWidgetOK 2,MayaQWidgetBaseMixin 3,QWidget(関連クラス含む)
__init__ 〇(superで→を実行) 〇(superで→を実行)
objectName
show 〇(superで→を実行)
MixinWidgetNG:QWidgetまでで処理が完結しているためMayaQWidgetBaseMixinの処理は実行されない。
インスタンスのメソッド 1,MixinWidgetNG 2,QWidget(関連クラス含む) 6,MayaQWidgetBaseMixin
__init__ 〇(superで→を実行)
objectName
show

そのためMixinWidgetNGはMixinの機能が正しく動作しませんでした。
このことからMixinクラスはMixin対象クラスよりも上位となるように継承する必要があることがわかります。

他にも気を付ける部分はあるのですが、最終的には「メソッド解決順序を把握しましょう」というところに落ち着くのでここでは詳細は省きます。
さらに詳しいことを知りたい方は公式のドキュメントをご覧になるか、「Python MRO」「C3線形化アルゴリズム」等で調べてみてください。

The Python 2.3 Method Resolution Order | Python.org
https://www.python.org/download/releases/2.3/mro/

Mixinクラスの使用・作成時の注意点

メソッド解決順序を全て正しく把握できていればこの限りではないのですが、Mixinクラスの使用・作成時の注意点をテンプレート的にざっくりとまとめると以下のようになります。

使用上の注意点
多重継承の際にはMixinクラスをMixin対象クラスよりも左側に記述する
作成時の注意点
Mixinクラスの親クラスはobjectにする
Mixinクラスは__init__の引数でMixin対象クラスの__init__の引数を受け取れるようにする
Mixinクラスの__init__の先頭にはsuperで親クラスの__init__を呼ぶような処理を記述する

Mixinクラスのサンプル

以下のサンプルコードはMayaのスクリプトエディタから実行できます。

各Mixinクラスは全てQWidgetをMixin対象クラスとしています。
サンプルではQWidgetを継承しているLogWidgetを使用しているので、ベースとなるWidgetのコードを実行してから各Mixinクラスのコードを実行してください。

ベースとなるWidget

tech_15_03.png

サンプルコード ソースを展開 ソースを折りたたむ


from PySide2 import QtWidgets, QtCore, QtGui
from maya.app.general import mayaMixin
 
 
class LogWidget(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
    def __init__(self, dlg_name='', parent=None):
        super(LogWidget, self).__init__(parent)
 
        self.setWindowTitle(dlg_name or 'Log')
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
 
        self.__log = QtWidgets.QTextEdit(self)
        self.__log.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
        # self.__log.setReadOnly(True)
 
        font = QtGui.QFont(u'MS ゴシック')
        font.setPointSize(10)
        self.__log.setFont(font)
 
        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(self.__log)
 
        self.setLayout(layout)
        self.resize(300, 200)
 
    @property
    def log(self):
        return self.__log
 
 
dlg = LogWidget()
dlg.log.setText('output log.')
dlg.show()

このWidgetをベースとして機能を追加していきます。

ShareSettingMixin

tech_15_04.gif

サンプルコード ソースを展開 ソースを折りたたむ


import os
import tempfile
 
 
class ShareSettingMixin(object):
    """QSettingsを使用したパラメータ保存
    """
    _SETTING_FILE_NAME = 'hoge'
 
    def __init__(self, *args, **kwargs):
        super(ShareSettingMixin, self).__init__(*args, **kwargs)
 
    @property
    def setting(self):
        setting_file = os.path.join(tempfile.gettempdir(), self._SETTING_FILE_NAME + '.ini')
        return QtCore.QSettings(setting_file, QtCore.QSettings.IniFormat)
 
 
class ShareSettingWidget(ShareSettingMixin, LogWidget):
    _SETTING_FILE_NAME = '__test_setting'
 
    def __init__(self, parent=None):
        super(ShareSettingWidget, self).__init__(parent)
        self.log.setText(self.setting.value('text'))
 
    def closeEvent(self, evt):
        super(ShareSettingWidget, self).closeEvent(evt)
        self.setting.setValue('text', self.log.toPlainText())
 
 
dlg = ShareSettingWidget()
dlg.show()

QSettingを使用してファイルにパラメータを保存する処理です。
外部ファイルに保存が必要な設定ファイルの作成や他のMixinで使用します。

RestoreGeometryMixin

tech_15_05.gif

サンプルコード ソースを展開 ソースを折りたたむ


class RestoreGeometryMixin(ShareSettingMixin):
    """ShareSettingMixinを使用したツールの位置・サイズの保存・復元
    """
    _SETTING_FILE_NAME = 'file_name'
 
    def __init__(self, parent=None):
        super(RestoreGeometryMixin, self).__init__(parent)
 
    def save_geo(self):
        self.setting.setValue('win_pos', self.pos())
        self.setting.setValue('win_size', self.size())
 
    def load_geo(self):
        p = self.setting.value('win_pos')
        s = self.setting.value('win_size')
        if p:
            self.move(p)
        if s:
            self.resize(s)
 
 
class RestoreGeometryWidget(RestoreGeometryMixin, LogWidget):
    _SETTING_FILE_NAME = '__test_setting'
 
    def __init__(self, parent=None):
        super(RestoreGeometryWidget, self).__init__(parent)
        self.load_geo()
 
    def closeEvent(self, evt):
        super(RestoreGeometryWidget, self).closeEvent(evt)
        self.save_geo()
 
 
dlg = RestoreGeometryWidget()
dlg.show()

ツールの位置・画面サイズの保持・復元を行う処理です。
ShareSettingMixinを使用しています。

RunOnlyMixin

tech_15_06.gif

サンプルコード ソースを展開 ソースを折りたたむ


class RunOnlyMixin(object):
    """ツールが2つ以上起動しないように既に起動しているツールを閉じる
    """
    #: :type: dict
    __RUNNING = {}
 
    def __new__(cls, *args, **kwargs):
        print('run_only: ', cls)
        if cls in RunOnlyMixin.__RUNNING.keys():
            w = RunOnlyMixin.__RUNNING.pop(cls)
            try:
                w.close()
            except RuntimeError as e:
                pass
 
        ins = super(RunOnlyMixin, cls).__new__(cls, args, kwargs)
        RunOnlyMixin.__RUNNING[cls] = ins
 
        return ins
 
 
class RunOnlyWidget(RunOnlyMixin, LogWidget):
    pass
 
 
dlg = RunOnlyWidget()
dlg.show()

同じツールを2つ以上起動しないようにするための処理です。

CustomShelfMixin

tech_15_07.gif

サンプルコード ソースを展開 ソースを折りたたむ


import uuid
import shiboken2
 
from maya import cmds, mel
import maya.OpenMaya as om
import maya.OpenMayaUI as omui
import pymel.core as pm
 
 
class QMayaWidget(QtWidgets.QWidget):
    """MayaUIをQWidgetに追加
    """
    def __init__(self, maya_ui_path, parent=None):
        super(QMayaWidget, self).__init__(parent)
 
        ptr = omui.MQtUtil.findControl(maya_ui_path)
        self.__core = shiboken2.wrapInstance(long(ptr), QtWidgets.QWidget)
 
        layout = QtWidgets.QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
 
        self.setLayout(layout)
        self.layout().addWidget(self.__core)
 
 
class CustomShelfMixin(object):
    """ツール毎のShelf作成
    """
    def __init__(self, parent=None):
        super(CustomShelfMixin, self).__init__(parent)
 
        # PySideで作成されたツールのfullNameを取得
        win_path = omui.MQtUtil.fullName(long(shiboken2.getCppPointer(self)[0]))
        # Shelfに登録されるIconサイズの調整用にMayaのUIScalingを取得
        scaling = cmds.mayaDpiSetting(rsv=True, q=True)
 
        # shelfLayoutの名前はUniqueでなければならない
        layout_name = str(uuid.uuid4())
        self.__tab = pm.shelfTabLayout('ShortcutShelf', parent=win_path, height=65)
        self.__layout = pm.shelfLayout(layout_name, cellWidthHeight=[34 / scaling, 34 / scaling])
        pm.shelfTabLayout(self.__tab, e=True, tabLabel=[self.__layout, 'Shortcut'])
 
        # 終了時のイベント設定
        self.__evt = om.MEventMessage.addEventCallback('quitApplication', self.__finalize)
 
        # QtにMayaUIの追加・設定
        self.__widget = QMayaWidget(self.__tab, self)
        self.__widget.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
        self.__widget.mouseDoubleClickEvent = self.__change_proc
        self.__enable_add = False
 
        self.__top_shelf = ''
        self.layout().insertWidget(0, self.__widget)
 
    @property
    def __shelf_path(self):
        """Shelfの情報が保存されているファイルパス(拡張子.mel無し)
        """
        return cmds.internalVar(userTmpDir=True) + self.__shelf_command
 
    @property
    def __shelf_command(self):
        """sourceされたShelf情報MELの実行コマンド
        """
        return 'shelf_' + self.objectName().replace(' ', '')
 
    def init_shelf(self):
        """Shelfの初期設定
        """
        pm.setParent(self.__layout)
 
        if os.path.exists(self.__shelf_path + '.mel'):
            # Shelf構築MELがあったら再構築
            print('load shelf file from: {0}'.format(self.__shelf_path))
            mel.eval('source "{0}";'.format(self.__shelf_path))
            mel.eval('{0}();'.format(self.__shelf_command))
        else:
            # Shelf構築MELが無かったら初期のボタン設定を適用
            print('shelf file not found.')
            self._default_shelf()
 
        pm.setParent('..')
 
    def reset_shelf(self):
        if os.path.exists(self.__shelf_path + '.mel'):
            os.remove(self.__shelf_path + '.mel')
 
        btns = pm.shelfLayout(self.__layout, q=True, ca=True) or []
        if any(btns):
            pm.deleteUI(btns)
        self.init_shelf()
 
    def _default_shelf(self):
        """初期のボタン設定を実装
        """
        pass
 
    def __change_proc(self, event):
        """登録対象のShelfを変更
        """
        self.__enable_add = not self.__enable_add
 
        if self.__enable_add:
            self.__widget.setStyleSheet('background-color:rgb(82, 133, 166, 255);')
            self.__top_shelf = mel.eval('$temp_var = $gShelfTopLevel;')
            mel.eval('$gShelfTopLevel = "{0}";'.format(self.__tab))
        else:
            self.__widget.setStyleSheet('')
            if self.__top_shelf:
                mel.eval('$gShelfTopLevel = "{0}";'.format(self.__top_shelf))
        pass
 
    def __finalize(self, *args, **kwargs):
        """終了時処理
        """
        if self.__evt is not None:
            om.MMessage.removeCallback(self.__evt)
            self.__evt = None
 
        pm.saveShelf(self.__layout, self.__shelf_path)
        if self.__top_shelf:
            mel.eval('$gShelfTopLevel = "{0}";'.format(self.__top_shelf))
 
        self.deleteLater()
 
    def closeEvent(self, *args, **kwargs):
        super(CustomShelfMixin, self).closeEvent(*args, **kwargs)
        self.__finalize()
 
    def dockCloseEventTriggered(self, *args, **kwargs):
        super(CustomShelfMixin, self).dockCloseEventTriggered(*args, **kwargs)
        self.__finalize()
 
 
class CustomShelfWidget(CustomShelfMixin, LogWidget):
    def __init__(self, parent=None):
        super(CustomShelfWidget, self).__init__(parent)
 
        # ObjectNameが必要
        self.setObjectName('CustomShelfDialog')
        # Shelfの初期化
        self.init_shelf()
        # QTextEditダブルクリックでShelfリセット
        self.log.mouseDoubleClickEvent = lambda x: self.reset_shelf()
 
    def _default_shelf(self):
        """初期のボタン設定を実装
 
        %TEMP%\shelf_CustomShelfWidget.melから初期設定にしたい段階でコピー
        """
        mel.eval("""
        shelfButton
            -enableCommandRepeat 1
            -flexibleWidthType 3
            -flexibleWidthValue 32
            -enable 1
            -width 35
            -height 34
            -manage 1
            -visible 1
            -preventOverride 0
            -annotation "Create a polygonal sphere on the grid"
            -enableBackground 0
            -backgroundColor 0 0 0
            -highlightColor 0.321569 0.521569 0.65098
            -align "center"
            -label "Sphere"
            -labelOffset 0
            -rotation 0
            -flipX 0
            -flipY 0
            -useAlpha 1
            -font "plainLabelFont"
            -overlayLabelColor 0.8 0.8 0.8
            -overlayLabelBackColor 0 0 0 0.5
            -image "polySphere.png"
            -image1 "polySphere.png"
            -style "iconOnly"
            -marginWidth 1
            -marginHeight 1
            -command "polySphere -r 1 -sx 20 -sy 20 -ax 0 1 0 -cuv 2 -ch 1; objectMoveCommand;"
            -sourceType "mel"
            -doubleClickCommand "CreatePolygonSphereOptions"
            -commandRepeatable 1
            -flat 1
        ;
        """)
 
 
dlg = CustomShelfWidget()
dlg.show()

ツールごとにShelfを追加する処理です。

おまけ

この記事を作成するにあたってエラーをダイアログ表示するMixinを作成しようと思ったのですが、どう考えてもデコレータで書いた方が簡単だったのでこの場で供養させてください。

tech_15_08.gif

サンプルコード ソースを展開 ソースを折りたたむ


import sys
import traceback
 
 
def exception_dlg(func):
    """エラー発生時に内容をダイアログに表示
    """
    def wrapper(*args, **kwargs):
        try:
            func(*args, **kwargs)
        except Exception as e:
            err_items = []
            for s in traceback.format_exception(*sys.exc_info()):
                if not any(s):
                    continue
 
                try:
                    err_items.append(s.decode('unicode-escape').rstrip())
                except Exception as e:
                    err_items.append(s.rstrip())
 
            dlg = LogWidget('Error')
            dlg.resize(900, 500)
            dlg.log.setText('\n'.join(err_items))
            dlg.show()
    return wrapper
 
 
class ExceptionWidget(LogWidget):
    def __init__(self, parent=None):
        super(ExceptionWidget, self).__init__(parent)
 
        # QTextEditダブルクリックで例外発生
        self.log.mouseDoubleClickEvent = self.__on_double_click
 
    @exception_dlg
    def __on_double_click(self, *args, **kwargs):
        raise RuntimeError(u'エラー発生')
 
 
dlg = ExceptionWidget()
dlg.show()

まとめ

いかがでしたでしょうか。

今回の記事はMixinについてまとめてみました。今後PySideでツールを作る必要があった時の手助けになれましたら幸いです。

参考

The Python 2.3 Method Resolution Order | Python.org
https://www.python.org/download/releases/2.3/mro/

執筆者:菅原

インタラクティブコンテンツ制作会社を経てモノリスソフトへ入社。 以来、テクニカルアーティストとして主にMayaツール開発の業務を担当。 好きな食べものはごはん。

ABOUT

モノリスソフト開発スタッフが日々取り組んでいる技術研究やノウハウをご紹介

RECRUIT採用情報