Source code for schrodinger.infra.fix_sip6_enum_access

"""
Modify enums in SIP 6 to behave like SIP 4 (e.g. allow
`QComboBox.InsertAlphabetically` in addition to
`QComboBox.InsertPolicy.InsertAlphabetically`).  Note that the functions in this
module are no-ops under Qt 5.

The typical use for this module would be to compile a SIP module to
`schrodinger.whatever._my_sip_module` and then create a my_sip_module.py that
contains::

from ._my_sip_module import *  # noqa F403
from schrodinger.infra import fix_sip6_enum_access
fix_sip6_enum_access.modify_enum_access(globals(), __name__)
del fix_sip6_enum_access
"""

import enum

try:
    from PyQt6 import sip
except ImportError:
    is_qt6 = False
else:
    is_qt6 = True

#  The StyleOptionType and StyleOptionVersion enums are intentionally redefined
#  in each QStyleHintReturn or QStyleOption subclass, so we expect naming
#  clashes.  In the code below, we allow each subclass's enum to override the
#  inherited enum, which mimics the C++ behavior.
_KNOWN_CLASHES = (
    ("StyleOptionType", "Type"),
    ("StyleOptionVersion", "Version"),
)
_UNKNOWN_CLASH_ERR = "Naming clash for enum {}.{} in {}"

# Some enum are declared using "enum class" instead of "enum".  Access to these
# enum classes is scoped in C++ (and in PyQt 5, PySide, etc.) and allowing for
# unscoped access can lead to name clashes.  For example, QColorSpace.Primaries
# and QColorSpace.TransferFunction both have an enum member named "SRgb", so
# QColorSpace.SRgb would be ambiguous.  Similarly,
# QtNetwork.QNetworkInformation.Feature has an enum member named "Reachability"
# even though there is a separate enum class named
# QtNetwork.QNetworkInformation.Reachability.  There's no way to distinguish
# enum classes from regular enums through introspection in PyQt, so we hard code
# a list of all enum classes.  This list was built by searching through the Qt
# header files and our *.sip files, e.g.:
# grep -E "\benum\s+class\b" /software/lib/Linux-x86_64/qt-6.2.1/include/QtCore/*
_ENUM_CLASSES = (
    # QtCharts
    ("QtCharts.QXYSeries", "PointConfiguration"),
    # QtCore
    ("QtCore.QAbstractItemModel", "CheckIndexOption"),
    ("QtCore.QByteArray", "Base64DecodingStatus"),
    ("QtCore.QCalendar", "System"),
    ("QtCore.QDateTime", "YearRange"),
    ("QtCore.QStringConverter", "Flag"),
    # QtGui
    ("QtGui.QActionGroup", "ExclusionPolicy"),
    ("QtGui.QColorSpace", "Primaries"),
    ("QtGui.QColorSpace", "TransferFunction"),
    ("QtGui.QInputDevice", "DeviceType"),
    ("QtGui.QInputDevice", "Capability"),
    ("QtGui.QTextFormat", "MarkerType"),
    # QtNetwork
    ("QtNetwork.QNetworkCookie", "SameSite"),
    ("QtNetwork.QOcspResponse", "QOcspCertificateStatus"),
    ("QtNetwork.QOcspResponse", "QOcspRevocationReason"),
    ("QtNetwork.QSslCertificate", "PatternSyntax"),
    ("QtNetwork.QNetworkInformation", "Feature"),
    ("QtNetwork.QNetworkInformation", "Reachability"),
    ("QtNetwork.QSsl", "AlertLevel"),
    ("QtNetwork.QSsl", "AlertType"),
    ("QtNetwork.QSsl", "ImplementedClass"),
    ("QtNetwork.QSsl", "SupportedFeature"),
    # schrodinger
    ("schrodinger.infra.jobhub", "JobOption"),
    ("schrodinger.infra.jobhub", "JobStatusNotif"),
    ("schrodinger.ui.maestro_ui", "AppProfileMode"),
    ("schrodinger.ui.maestro_ui", "InteractionMode"),
    ("schrodinger.ui.maestro_ui", "LigandReceptorInteractions"),
    ("schrodinger.ui.maestro_ui.maestro", "MoveType"),
    ("schrodinger.ui.sketcher", "ColorHeteroatomsMode"),
)


def modify_enum_access(namespace_dict: dict, namespace_name: str):
    """
    Iterate through the given namespace dictionary and modify the namespace
    itself as well as all classes and nested namespaces it contains so that
    enums can be accessed directly through a class/namespace without going
    through the enum class (e.g. allow for `QComboBox.InsertAlphabetically` or
    `Qt.AlignLeft` in addition to `QComboBox.InsertPolicy.InsertAlphabetically`
    or `Qt.Alignment.AlignLeft`).

    :param namespace_dict: The module namespace dictionary to modify.  To modify
        a module, pass in e.g. `QtCore.__dict__`.  To modify a module from
        within that module, pass in `globals()`.

    :param namespace_name: The name of the namespace being modified.  If this is
        a Qt namespace, omit the leading "PyQt6." or "schrodinger.Qt.", e.g. use
        "QtCore" instead of "PyQt6.QtCore".
    """
    for attr_name, attr in namespace_dict.copy().items():
        if (namespace_name, attr_name) in _ENUM_CLASSES:
            continue
        elif isinstance(attr, enum.EnumMeta):
            for enum_val in attr:
                if (enum_val.name in namespace_dict and
                        not (attr_name, enum_val.name) in _KNOWN_CLASHES):
                    raise RuntimeError(
                        _UNKNOWN_CLASH_ERR.format(attr_name, enum_val,
                                                  namespace_name))
                namespace_dict[enum_val.name] = enum_val
        if isinstance(attr, sip.wrappertype):
            # We can't recurse on modify_enum_access here because Class.__dict__
            # is a read-only mappingproxy (unlike module.__dict__, which is an
            # actual dict instance).  Because of this, passing a class
            # dictionary into modify_enum_access would lead to "TypeError:
            # 'mappingproxy' object does not support item assignment"
            _modify_enum_access_nested(attr, f"{namespace_name}.{attr_name}")


def _modify_enum_access_nested(namespace: type, namespace_name: str):
    """
    Equivalent to `modify_enum_access`, but takes a namespace instead of a
    namespace dictionary.

    :param namespace: The class or namespace to modify.
    :param namespace_name: The name of the namespace being modified.
    """
    for attr_name, attr in namespace.__dict__.copy().items():
        if (namespace_name, attr_name) in _ENUM_CLASSES:
            continue
        elif isinstance(attr, enum.EnumMeta):
            for enum_name, enum_val in attr.__members__.items():
                # Iterating through attr.__members__ instead of attr itself
                # allows us to handle aliases (i.e. two enums with the same
                # value)
                if (hasattr(namespace, enum_name) and
                        not (attr_name, enum_name) in _KNOWN_CLASHES):
                    raise RuntimeError(
                        _UNKNOWN_CLASH_ERR.format(attr_name, enum_val,
                                                  namespace_name))
                setattr(namespace, enum_name, enum_val)
        elif isinstance(attr, sip.wrappertype):
            _modify_enum_access_nested(attr, f"{namespace_name}.{attr_name}")


# turn the above methods into no-ops if we're in a SIP 4 build
if not is_qt6:

[docs] def modify_enum_access(*args, **kwargs): # noqa: F811 pass
def _modify_enum_access_nested(*args, **kwargs): # noqa: F811 pass