Source code for schrodinger.ui.qt.appframework2.settings

import enum
import json
from types import ModuleType

import yaml

from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.ui.qt import config_dialog
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.appframework2 import baseapp
from schrodinger.ui.qt.appframework2 import validation
from schrodinger.ui.qt import utils
from schrodinger.utils import preferences


class SettingsMixin(object):
    """
    Mixin allows an object to save/restore its own state to/from a dictionary.
    A typical use case would be to collect the values of all the widgets in
    an options dialog as a dictionary. Example::

        dlg = MyOptionsDialog()
        saved_settings = dlg.getSettings()
        dlg.applySettings(new_settings)

    The settings are stored in a dictionary by each widget's variable name. For
    widgets that are referenced from within another object, a nested dictionary
    will be created, the most common example of this being the panel.ui object.
    A typical settings dictionary may look like this::

        {
            'ui': {
                    'option_combo': 'Option A',
                    'name_le': 'John',
                    'remove_duplicate_checkbox': False,
                    'num_copies_spinbox': 0
                   },
            'other_name_le': 'Job 3'
        }

    Notice that dictionary keys are the string variable names, not the widgets
    themselves, and that panel.ui has its own sub-dictionary.

    This mixin also supports the concept of aliases, which is a convenient way
    of accessing objects by a string or other identifier. Example::

        self.setAlias(self.ui.my_input_asl_le, 'ASL')
        # This is a shortcut for self.getAliasedSetting('ASL')
        old_asl = self['ASL']
        # This is a shortcut for self.setAliasedSetting('ASL')
        self['ASL'] = new_asl

    All the information about how to get values from the supported types is
    found in getObjValue() and setObjValue(). To extend this functionality
    for more types, either edit these two methods here or override these methods
    in the derived class, being sure to call the parent method in the else
    clause after testing for all new types. Always extend both the set and get
    functionality together.

    You can also add support for this mixin to any class by implementing
    af2SettingsGetValue() and af2SettingsSetValue(). In this way, more
    complicated widgets or other objects can be automatically discovered.

    """

    def __init__(self, *args, **kwargs):
        self.settings_aliases = {}
        self.persistent_aliases = {}
        super(SettingsMixin, self).__init__(*args, **kwargs)
        self.loadPersistentOptions()

    #===========================================================================
    # Aliased settings
    #===========================================================================

    def __getitem__(self, key):
        return self.getAliasedValue(key)

    def __setitem__(self, key, value):
        return self.setAliasedValue(key, value)

    def setAlias(self, alias, obj, persistent=False):
        """
        Sets an alias to conveniently access an object.

        :param alias: any hashable, but typically a string name
        :type alias: hashable
        :param obj: the actual object to be referenced
        :type obj: object
        :param persistent: whether to make the setting persistent
        :type persistent: bool
        """
        if not hasattr(self, 'settings_aliases'):
            self.settings_aliases = {}
        self.settings_aliases[alias] = obj

        if persistent:
            self.setPersistent(alias)

    def setAliases(self, alias_dict, persistent=False):
        """
        Sets multiple aliases at once. Already used aliases are overwritten;
        other existing aliases are not affected.

        :param alias_dict: map of aliases to objects
        :type alias_dict: dict
        :param persistent: whether to make the settings persistent
        :type persistent: bool
        """
        if not hasattr(self, 'settings_aliases'):
            self.settings_aliases = {}
        for alias, obj in alias_dict.items():
            self.setAlias(alias, obj)

        if persistent:
            for alias in alias_dict:
                self.setPersistent(alias)

    def getAliasedSettings(self):
        settings = {}
        for alias in self.settings_aliases:
            settings[alias] = self.getAliasedValue(alias)
        return settings

    def applyAliasedSettings(self, settings):
        """
        Applies any aliased settings with new values from the dictionary. Any
        aliases not present in the settings dictionary will be left unchanged.

        :param settings: a dictionary mapping aliases to new values to apply
        :type settings: dict
        """
        for alias, value in settings.items():
            self.setAliasedValue(alias, value)

    def getAliasedValue(self, alias):
        obj = self.settings_aliases[alias]
        return self.getObjValue(obj)

    def setAliasedValue(self, alias, value):
        obj = self.settings_aliases[alias]
        return self.setObjValue(obj, value)

    #===========================================================================
    # Persistent settings
    #===========================================================================

    def setPersistent(self, alias=None):
        """
        Set options to be persistent. Any options to be made persistent must be
        aliased, since the alias is used to form the preference key. If no
        alias is specified, all aliased settings will be made persistent.

        :param alias: the alias to save, or None
        :type alias: str or None
        """
        if alias is None:
            aliases = list(list(self.settings_aliases))
        else:
            aliases = [alias]

        for alias in aliases:
            self.persistent_aliases[alias] = self.getPersistenceKey(alias)

    def getPersistenceKey(self, alias):
        """
        Return a unique identifier for saving/restoring a setting in the
        preferences. Override this method to change the key scheme (this is
        necessary if creating a common resource which is shared by multiple
        panels).

        :param alias: the alias for which we are generating a key
        :type alias: str
        """
        return generate_preference_key(self, alias)

    def savePersistentOptions(self):
        """
        Store all persistent options to the preferences.
        """
        for alias, prefkey in self.persistent_aliases.items():
            value = self[alias]
            set_persistent_value(prefkey, value)

    def loadPersistentOptions(self):
        """
        Load all persistent options from the preferences.
        """
        for alias, prefkey in self.persistent_aliases.items():
            value = get_persistent_value(prefkey, None)
            if value is None:
                continue
            self[alias] = value

    #===========================================================================
    # Settings
    #===========================================================================

    def getSettings(self, target=None, ignore_list=None):
        if target is None:
            target = self
        return get_settings(target, ignore_list)

    def applySettings(self, settings, target=None):
        if target is None:
            target = self
        apply_settings(settings, target)

    def getObjValue(self, obj):
        return get_obj_value(obj)

    def setObjValue(self, obj, value):
        return set_obj_value(obj, value)


def get_settings(target, ignore_list=None):
    """
    Recursively collects all settings.

    :param target: the target object from which to collect from. Defaults to
        self. The target is normally only used in the recursive calls.
    :type target: object
    :param ignore_list: list of objects to ignore. Also used in recursive calls,
        to prevent circular reference traversal
    :type ignore_list: list of objects

    :return: the settings in a dict keyed by reference name. Nested references
        appear as dicts within the dict.
    :rtype: dict
    """

    if ignore_list is None:
        ignore_list = []

    settings = {}
    if isinstance(target, ModuleType):
        return settings
    try:
        for item in ignore_list:
            if target is item:
                return settings
    except TypeError:
        return settings
    ignore_list.append(target)
    if not hasattr(target, '__dict__'):
        return settings
    for name in target.__dict__:
        obj = target.__dict__[name]
        try:
            value = get_obj_value(obj)
            settings[name] = value
        except TypeError:
            try:
                value = obj.getSettings(ignore_list=ignore_list)
                if value:
                    settings[name] = value
            except AttributeError:
                subsettings = get_settings(obj, ignore_list)
                if subsettings:
                    settings[name] = subsettings
    return settings


def apply_settings(settings, target):
    """
    Recursively applies any settings supplied in the settings argument.
    """
    for name in settings:
        try:
            obj = target.__dict__[name]
        except KeyError:
            print("Error while applying the settings." \
                + "'%s' not found in target. Skipping the same." % (name))
            continue

        try:
            value = settings[name]
            set_obj_value(obj, value)
        except TypeError:
            try:
                obj.applySettings(value)
            except AttributeError:
                if isinstance(value, dict):
                    apply_settings(value, obj)
                else:
                    raise TypeError('No handler for type %s' % type(obj))


def get_obj_value(obj):
    """
    A generic function for getting the "value" of any supported object. This
    includes various types of QWidgets, any object that implements an
    af2SettingsGetValue method, or a tuple consisting of getter and setter
    functions.

    :param obj: the object whose value to get
    :type obj: object
    """
    if hasattr(obj, 'af2SettingsGetValue'):
        return obj.af2SettingsGetValue()
    elif (isinstance(obj, tuple) and len(obj) == 2 and callable(obj[0]) and
          callable(obj[1])):
        getter, setter = obj
        return getter()
    elif isinstance(obj, (QtWidgets.QLabel, QtWidgets.QLineEdit)):
        return obj.text()
    elif isinstance(obj, QtWidgets.QPlainTextEdit):
        return obj.toPlainText()
    elif isinstance(obj, QtWidgets.QComboBox):
        if obj.count() == 0:
            return None
        return obj.currentText()
    elif isinstance(
            obj,
        (QtWidgets.QCheckBox, QtWidgets.QGroupBox, QtWidgets.QRadioButton)):
        return bool(obj.isChecked())
    elif isinstance(obj, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)):
        return obj.value()
    elif isinstance(obj, QtWidgets.QButtonGroup):
        return obj.checkedId()
    elif isinstance(obj, QtWidgets.QTabWidget):
        settings = {}
        for i in range(obj.count()):
            tab_widget = obj.widget(i)
            tab_text = obj.tabText(i)
            # self ref may be causing recursion
            settings[tab_text] = get_settings(tab_widget)
        return settings
    elif isinstance(obj, QtWidgets.QStackedWidget):
        return obj.currentIndex()
    elif isinstance(obj, config_dialog.ConfigDialog):
        obj.getSettings()
        return obj.kw
    raise TypeError('No handler for type %s', type(obj))


def set_obj_value(obj, value):
    """
    A generic function for setting the "value" of any supported object. This
    includes various types of QWidgets, any object that implements an
    af2SettingsSetValue method, or a tuple consisting of getter and setter
    functions.

    :param obj: the object whose value to set
    :type obj: object

    :param value: the value to set the object to
    :type value: the type must match whatever the object is expecting
    """
    if hasattr(obj, 'af2SettingsSetValue'):
        return obj.af2SettingsSetValue(value)
    elif isinstance(obj, tuple):
        getter, setter = obj
        setter(value)
    elif isinstance(obj, (QtWidgets.QLineEdit, QtWidgets.QLabel)):
        obj.setText(u'%s' % str(value))
    elif isinstance(obj, QtWidgets.QPlainTextEdit):
        obj.setPlainText(u'%s' % str(value))
    elif isinstance(obj, QtWidgets.QComboBox):
        if value is None:
            if obj.count() == 0:
                return
            else:
                # Saved value is None, yet combo menu is not empty.
                # Eventually we should raise an exception here; but since this
                # breaks some existing code, we just return for now.
                return
        elif isinstance(value, str):
            index = obj.findText(value)
            if index == -1:
                # This exception must be raised - do not modify this code.
                # If an item is missing from the menu, add code to the panel
                # to re-add it before restoring from settings.
                raise ValueError('QComboBox %s has no item: "%s"' %
                                 (obj.objectName(), value))
        elif isinstance(value, enum.Enum):
            index = value.value
        else:
            index = int(value)
        obj.setCurrentIndex(index)
    elif isinstance(
            obj,
        (QtWidgets.QCheckBox, QtWidgets.QGroupBox, QtWidgets.QRadioButton)):
        obj.setChecked(bool(value))
    elif isinstance(obj, QtWidgets.QSpinBox):
        obj.setValue(int(value))
    elif isinstance(obj, QtWidgets.QDoubleSpinBox):
        obj.setValue(float(value))
    elif isinstance(obj, QtWidgets.QButtonGroup):
        obj.button(value).setChecked(True)
    elif isinstance(obj, QtWidgets.QTabWidget):
        for i in range(obj.count()):
            tab_widget = obj.widget(i)
            tab_text = obj.tabText(i)

            # self ref may be causing recursion
            apply_settings(value[tab_text], tab_widget)
    elif isinstance(obj, QtWidgets.QStackedWidget):
        obj.setCurrentIndex(int(value))
    elif isinstance(obj, config_dialog.ConfigDialog):
        settings = config_dialog.StartDialogParams()
        settings.__dict__.update(value)
        obj.applySettings(settings)
    else:
        raise TypeError('No handler for type %s', type(obj))


#===============================================================================
# Attribute Setting Wrapper
#===============================================================================


class AttributeSettingWrapper(object):
    """
    This allows any object attribute to be treated as a setting. This is
    useful for mapping an alias to an attribute.
    """

    def __init__(self, parent_obj, attribute_name):
        self.parent_obj = parent_obj
        self.attribute_name = attribute_name

    def af2SettingsGetValue(self):
        return getattr(self.parent_obj, self.attribute_name)

    def af2SettingsSetValue(self, value):
        setattr(self.parent_obj, self.attribute_name, value)


#===============================================================================
# Settings Panel Mixin
#===============================================================================


class PanelState(object):
    """
    A simple container to hold the panel state that is collected by the
    SettingsPanelMixin. Formerly, the state was held in a simple 2-tuple of
    (custom_state, auto_state).
    """

    def __init__(self, custom_state, auto_state):
        self.custom_state = custom_state
        self.auto_state = auto_state

    def __getitem__(self, key):
        """
        Allows state to be retrieved via key. A key is searched first in the
        custom_state, then the auto_state. There are two special keys, 0 and 1.
        This allows the PanelState to be treated like the old 2-tuple, for
        backwards-compatibility.
        """
        if key == 0:
            return self.custom_state
        if key == 1:
            return self.auto_state
        try:
            return self.custom_state[key]
        except KeyError:
            return self.auto_state[key]

    def __setitem__(self, key, value):
        if key == 0:
            self.custom_state = value
            return
        if key == 1:
            self.auto_state = value
            return
        if key in self.custom_state or key not in self.auto_state:
            self.custom_state[key] = value
        else:
            self.auto_state[key] = value


class SettingsPanelMixin(SettingsMixin):

    def __init__(self, *args, **kwargs):
        self.panel_settings = []
        super(SettingsPanelMixin, self).__init__(*args, **kwargs)

    def _configurePanelSettings(self):
        """
        The main responsibility of this method is to process the return value of
        self.definePanelSettings(). Doing this configures the panel for saving
        and restoring state.
        """
        self.panel_settings += self.definePanelSettings()
        for settingdef in self.panel_settings:
            numargs = len(settingdef)
            if numargs not in (2, 3):
                raise TypeError('Setting definition must have either 2 or 3 '
                                'values.')
            if numargs == 3:
                alias = '%s.%s' % (str(settingdef[2]), settingdef[0])
            else:
                alias = settingdef[0]

            obj = settingdef[1]
            if isinstance(obj, str):
                obj = AttributeSettingWrapper(self, obj)
            try:
                self.getObjValue(obj)
            except TypeError:
                print('Could not setup %s because there is no '
                      'handler for type %s.' % (alias, type(obj)))
                raise
            self.setAlias(alias, obj)

    def definePanelSettings(self):
        """
        Override this method to define the settings for the panel. The aliased
        settings provide an interface for saving/restoring panel state as well
        as for interacting with task/job runners that need to access the panel
        state in a way that is agnostic to the specifics of widget names and types.

        Each panel setting is defined by a tuple that specifies the mapping of
        alias to panel setting. An optional third element in the tuple can be
        used to group settings by category. This allows multiple settings to
        share the same alias.

        Each setting can either point to a specific object (usually a qt
        widget), or a pair of setter/getter functions.

        If the mapped object is a string, this will be interpreted by af2 as
        referring to an attribute on the panel, and a
        AttributeSettingWrapper instance will automatically be created.
        For example, specifying the string 'num_atoms' will create a mapping to
        self.num_atoms which will simply get and set the value of that instance
        member.

        Custom setter and getter functions should take the form getter(),
        returning a value that can be encoded/decoded by JSON, and
        setter(value), where the type of value is the same as the return
        type of the getter.

        Commonly used objects/widgets should be handled automatically in
        settings.py. It's worth considering whether it makes more sense to use a
        custom setter/getter here or add support for the widget in settings.py.

        :return: a list of tuples defining the custom settings.
        :rtype: list of tuples. Each tuple can be of type (str, object, str) or
            (str, (callable, callable), str) where the final str is optional.

        Custom settings tuples consists of up to three elements:

        1) alias - a string identier for the setting. Ex. "box_centroid"
        2) either:

            A) an object of a type that is supported by settings.py or
            B) the string name of an existing panel attribute (i.e. member
               variable), or
            C) a (getter, setter) tuple. The getter should take no arguments,
               and the setter should take a single value.

        3) optionally, a group identifier. This can be useful if the panel runs
           two different jobs that both have a parameter with the same name but
           that needs to map to different widgets. If a setting has a group
           name, it will be ignored by runners unless the runner name matches
           the group name.

        """
        return []

    def getPanelState(self):
        """
        Gets the current state of the panel in the form of a serializable dict.
        The state consists of the settings specified in definePanelSettings()
        as well as the automatically harvested settings.
        """
        ignore_list = list(self.settings_aliases.values())
        custom_state = self.getAliasedSettings()
        auto_state = self.getSettings(ignore_list=ignore_list)
        return PanelState(custom_state, auto_state)

    def setPanelState(self, state):
        """
        Resets the panel and then sets the panel to the specified state

        :param state: the panel state to set. This object should originate from
            a call to getPanelState()

        :type state: PanelState
        """
        self.setDefaults()
        custom_state = state.custom_state
        auto_state = state.auto_state
        for alias, value in custom_state.items():
            self.setAliasedValue(alias, value)
        self.applySettings(auto_state)

    def writePanelState(self, filename=None):
        """
        Write the panel state to a JSON file

        :param filename: the JSON filename. Defaults to "panelstate.json"
        :type filename: str
        """
        if filename is None:
            filename = 'panelstate.json'
        state = self.getPanelState()
        with open(filename, 'w') as fp:
            json.dump(
                (state.custom_state, state.auto_state),
                fp,
                indent=4,
                sort_keys=True)

    def loadPanelState(self, filename=None):
        """
        Load the panel state from a JSON file

        :param filename: the JSON filename. Defaults to "panelstate.json"
        :type filename: str
        """
        if filename is None:
            filename = 'panelstate.json'
        with open(filename, 'r') as fp:
            state = yaml.load(fp)
        self.setPanelState(PanelState(state[0], state[1]))


#===============================================================================
# Base Options Panel
#===============================================================================


class _Dialog(QtWidgets.QDialog):
    """
    Subclasses QDialog to add a single signal that fires when the dialog is
    dismissed, regardless of the method (OK, Cancel, [X] button, ESC key).
    """
    dialogDismissed = QtCore.pyqtSignal()

    def closeEvent(self, event):
        super(_Dialog, self).closeEvent(event)
        if event.isAccepted():
            self.dialogDismissed.emit()

    def hideEvent(self, event):
        super(_Dialog, self).hideEvent(event)
        if event.isAccepted():
            self.dialogDismissed.emit()


BODSuper = baseapp.ValidatedPanel


class BaseOptionsPanel(SettingsPanelMixin, BODSuper):
    """
    A base class for options dialogs that report all settings via a dictionary.
    This class descends from ValidatedPanel so it supports all the same
    validation system, including the @af2.validator decorators.

    It shares common code with af2 panels, so setting self.ui, self.title,
    and self.help_topic all work the same way. It uses the same startup system,
    so setPanelOptions, setup, setDefaults, and layOut should be used in like
    fashion.

    Appmethods (start, write, custom) are not supported.

    To use, instantiate once and keep a reference. Call run() on the instance
    to open the panel. When the user is done, the panel will either return the
    settings dictionary (if user clicks ok) or None (if the user clicks cancel).

    In place of run(), you may alternatively call open(), which will show the
    dialog as window modal and return immediately.  The settings dictionary may
    be retrieved using getSettings() or getAliasedSettings().
    """

    # FIXME make this class a sub-class of QDialog, instead of it being just
    # a wrapper object for the dialog.
    def __init__(self, parent=None, **kwargs):
        self.dialog = _Dialog(parent)
        self.initial_settings = None
        super(BaseOptionsPanel, self).__init__(**kwargs)
        self.initial_settings = self.getSettings()
        self.saved_settings = self.initial_settings.copy()

    def setPanelOptions(self):
        self.title = 'Options'
        self.ui = None  # This works the same as af2 panels
        self.help_topic = ''  # Giving this a value will auto-add help button
        self.include_reset = False
        self.buttons = (QtWidgets.QDialogButtonBox.Save,
                        QtWidgets.QDialogButtonBox.Cancel)

    def setup(self):
        """
        Along with the usual af2 setup actions (instantiating widgets and other
        objects, connecting signals, etc), this is the recommended place for
        setting aliases.
        """
        if self.ui:
            self.ui_widget = QtWidgets.QWidget(self)
            self.ui.setupUi(self.ui_widget)
        self.main_layout = swidgets.SVBoxLayout()

        dbb = QtWidgets.QDialogButtonBox
        button_flags = 0
        for button in self.buttons:
            button_flags = button_flags | button
        self.dialog_buttons = dbb(button_flags)
        self.dialog_buttons.accepted.connect(self.accept)
        self.dialog_buttons.rejected.connect(self.reject)
        self.dialog.dialogDismissed.connect(self.onDialogDismissed)

        if self.help_topic:
            self.dialog_buttons.addButton(dbb.Help)
            self.dialog_buttons.helpRequested.connect(self.help)
        if self.include_reset:
            reset_btn = self.dialog_buttons.addButton(dbb.Reset)
            reset_btn.clicked.connect(self.reset)

        if len(self.buttons) == 1:
            self.dialog_buttons.setCenterButtons(True)

    def setDefaults(self):
        self._configurePanelSettings()
        if self.initial_settings:
            self.applySettings(self.initial_settings)

    def reset(self):
        self.setDefaults()

    def layOut(self):
        self.panel_layout = swidgets.SVBoxLayout(self.dialog)
        self.panel_layout.addLayout(self.main_layout)
        self.panel_layout.setContentsMargins(2, 2, 2, 2)
        if self.ui:
            self.panel_layout.addWidget(self.ui_widget)
        self.panel_layout.addWidget(self.dialog_buttons)

    def accept(self):
        if not self.runValidation(stop_on_fail=True):
            return False
        settings = self.getSettings()
        self.saved_settings = settings
        self.savePersistentOptions()
        return self.dialog.accept()

    def run(self):
        """
        Show the dialog in modal mode. After dialog is closed, return None
        if user cancelled, or settings if user accepted.
        """
        self.saved_settings = self.getSettings()
        ret = self.dialog.exec_()
        if ret == QtWidgets.QDialog.Rejected:
            return None
        return self.getSettings()

    def open(self):
        """
        Open the dialog in window-modal mode, without blocking. This makes it
        possible for user to interact with the Workspace while dialog is open.
        """
        self.saved_settings = self.getSettings()
        self.dialog.open()

    def show(self):
        self.saved_settings = self.getSettings()
        self.dialog.show()

    def reject(self):
        self.applySettings(self.saved_settings)
        self.dialog.reject()

    def onDialogDismissed(self):
        """
        Override this method with any logic that needs to run when the dialog
        is dismissed.
        """

    def help(self):
        utils.help_dialog(self.help_topic, parent=self)

    @property
    def title(self):
        return self.dialog.windowTitle()

    @title.setter
    def title(self, title):
        return self.dialog.setWindowTitle(title)


#===============================================================================
# Persistent Settings
#===============================================================================

_preference_handler = None


def get_preference_handler():
    """
    Gets the af2 global prefence handler. This handler is used for storing
    persistent settings via this module.
    """
    global _preference_handler
    if _preference_handler is None:
        _preference_handler = preferences.Preferences(preferences.SCRIPTS)
        _preference_handler.beginGroup('af2_settings')
    return _preference_handler


def get_persistent_value(key, default=preferences.NODEFAULT):
    """
    Loads a saved value for the given key from the preferences.

    :param key: the preference key
    :type key: str

    :param default: the default value to return if the key is not found. If a
        default is not specified, a KeyError will be raised if the key is not found.
    """
    preference_handler = get_preference_handler()
    return preference_handler.get(key, default)


def set_persistent_value(key, value):
    """
    Save a value to the preferences.

    :param key: the preference key
    :type key: str

    :param value: the value to store
    :type value: any type accepted by the preferences module
    """
    preference_handler = get_preference_handler()
    preference_handler.set(key, value)


def remove_preference_key(key):
    """
    Delete a persistent keypair from the preferences.

    :param key: the preference key to remove
    :type key: str
    """
    preference_handler = get_preference_handler()
    preference_handler.remove(key)


def generate_preference_key(obj, tag):
    """
    Automatically generates a preference key based on the given object's class
    name, module name, and a string tag.

    Since persistant settings are intended to be used across sessions, keys are
    associated with class definitions, not instances.

    :param obj: the object (usually a panel) with which the value is associated
    :type obj: object
    :param tag: a string identifier for the piece of data being stored
    :type tag: str
    """
    module = obj.__module__
    classname = obj.__class__.__name__
    key = '%s-%s-%s' % (module, classname, tag)
    return key