From b949e9ed458e3ab0e50f2cc5525f6c3efb6f26dc Mon Sep 17 00:00:00 2001 From: John Haddon Date: Tue, 9 Jan 2024 12:37:08 +0000 Subject: [PATCH] Layouts : Improve loading of layouts with unavailable editors This uses proxies for missing modules and editors, so we can load the rest of the layout successfully. The proxy editor appears as a tab in the UI as normal but with an "editor not available" label rather than the usual contents. The proxy can also be resaved into a new layout and then be loaded as the _real_ editor later when that becomes available. This will allow us to load layouts from Gaffer 1.4 in 1.3, even if they include an ImageInspector or LocalJobs panel. Saving the layout in 1.3 and reloading it in 1.4 will even preserve the new editors. --- Changes.md | 1 + python/GafferUI/Layouts.py | 93 ++++++++++++++++++++++++++++-- python/GafferUITest/LayoutsTest.py | 42 ++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/Changes.md b/Changes.md index 3098013d82a..6b4ff41901d 100644 --- a/Changes.md +++ b/Changes.md @@ -14,6 +14,7 @@ Improvements - Added bookmarks. - USDLight : Added file browser for `shaping:ies:file` parameter. - OpenColorIOContext : Added file browser for `config` plug. +- Layouts : Added the ability to load layouts containing editors that aren't currently available. This allows layouts containing new editors introduced in Gaffer 1.4 to be loaded in Gaffer 1.3. Fixes ----- diff --git a/python/GafferUI/Layouts.py b/python/GafferUI/Layouts.py index 9bb3a048a47..3182eec8524 100644 --- a/python/GafferUI/Layouts.py +++ b/python/GafferUI/Layouts.py @@ -36,6 +36,7 @@ ########################################################################## import collections +import importlib import re import traceback import weakref @@ -125,7 +126,8 @@ def create( self, name, scriptNode ) : layout = self.__namedLayouts[name] - # first try to import the modules the layout needs + # First try to import the modules the layout needs. We wrap these with + # `_ModuleProxy` so that we can handle missing editor types gracefully. contextDict = { "scriptNode" : scriptNode, "imath" : imath } imported = set() classNameRegex = re.compile( r"[a-zA-Z]*Gaffer[^(,]*\(" ) @@ -133,10 +135,10 @@ def create( self, name, scriptNode ) : moduleName = className.partition( "." )[0] if moduleName not in imported : try : - exec( "import %s" % moduleName, contextDict, contextDict ) - except( ImportError ) : - IECore.msg( IECore.MessageHandler.Level.Error, "GafferUI.Layouts", "Failed to load \"{layout}\" layout. {module} is not available.".format( layout=name, module=moduleName ) ) - return GafferUI.CompoundEditor( scriptNode ) + module = importlib.import_module( moduleName ) + except ImportError : + module = None + contextDict[moduleName] = _EditorModuleProxy( module, moduleName ) imported.add( moduleName ) try : @@ -205,3 +207,84 @@ def __save( self ) : if self.__defaultPersistent and self.__default in self.__namedLayouts : f.write( "layouts.setDefault( {0}, persistent = True )\n".format( repr( self.__default ) ) ) + +# Proxy used for modules needed by `Layouts.create()`, so we can gracefully +# handle missing modules and editors. +class _EditorModuleProxy : + + def __init__( self, module, path ) : + + self.__module = module + self.__path = path + + def __getattr__( self, name ) : + + if self.__module is not None : + a = getattr( self.__module, name, None ) + if a is not None : + return a + + return _EditorModuleProxy( None, f"{self.__path}.{name}" ) + + def __call__( self, *args, **kw ) : + + return _MissingEditor( self.__path, *args, **kw ) + +# Proxy for Editor types that are currently unavailable. +class _MissingEditor( GafferUI.Editor ) : + + def __init__( self, path, scriptNode, *args, **kw ) : + + column = GafferUI.ListContainer( borderWidth = 8 ) + GafferUI.Editor.__init__( self, column, scriptNode ) + + with column : + + GafferUI.Spacer( + imath.V2i( 1 ), + parenting = { "expand" : True } + ) + + with GafferUI.ListContainer( + GafferUI.ListContainer.Orientation.Horizontal, spacing = 4, + parenting = { "horizontalAlignment" : GafferUI.Label.HorizontalAlignment.Center }, + ) : + + GafferUI.Image( "warningNotification.png" ) + GafferUI.Label( + f"{path} not available", horizontalAlignment = GafferUI.HorizontalAlignment.Center, + ) + + GafferUI.Spacer( + imath.V2i( 1 ), + parenting = { "expand" : True } + ) + + self.__path = path + self.__args = args + self.__kw = kw + + # We don't actually derive from NodeSetEditor, because we don't know if + # we should or not. But we provide the same methods so that CompoundEditor + # doesn't get upset if it tries to restore a node set. + def setNodeSet( self, nodeSet ) : + + self.__nodeSet = nodeSet + + def getNodeSet( self ) : + + return self.__nodeSet + + def getTitle( self ) : + + return IECore.CamelCase.toSpaced( self.__path.rpartition( "." )[2] ) + + def __repr__( self ) : + + return "{path}( scriptNode{argsSeparator}{args}{kwSeparator}{kw} )".format( + path = self.__path, + argsSeparator = ", " if self.__args else "", + args = ", ".join( repr( a ) for a in self.__args ), + kwSeparator = ", " if self.__kw else "", + kw = ", ".join( f"{k} = {v}" for k, v in self.__kw.items() ) + ) diff --git a/python/GafferUITest/LayoutsTest.py b/python/GafferUITest/LayoutsTest.py index f1c7ec7a676..536b518fab9 100644 --- a/python/GafferUITest/LayoutsTest.py +++ b/python/GafferUITest/LayoutsTest.py @@ -191,5 +191,47 @@ def testNodeSetRestore( self ) : ns = editors[3].getNodeSet() self.assertTrue( isinstance( ns, Gaffer.StandardSet ) ) + def testMissingEditorType( self ) : + + applicationRoot = Gaffer.ApplicationRoot( "testApp" ) + layouts = GafferUI.Layouts.acquire( applicationRoot ) + + # Register a layout containing an editor that doesn't exist. + layouts.add( "MissingEditor", "GafferUI.NonexistentEditor( scriptNode )" ) + + # Check that we can still create such a layout, and that + # it has a proxy with the expected properties. + script = Gaffer.ScriptNode() + layout = layouts.create( "MissingEditor", script ) + self.assertIsInstance( layout, GafferUI.Editor ) + self.assertEqual( layout.getTitle(), "Nonexistent Editor" ) + + # Save the layout again and check that the same applies. + layouts.add( "MissingEditor2", layout ) + layout2 = layouts.create( "MissingEditor2", script ) + self.assertIsInstance( layout2, GafferUI.Editor ) + self.assertEqual( layout2.getTitle(), "Nonexistent Editor" ) + + def testMissingEditorModule( self ) : + + applicationRoot = Gaffer.ApplicationRoot( "testApp" ) + layouts = GafferUI.Layouts.acquire( applicationRoot ) + + # Register a layout containing an editor from a module that doesn't exist. + layouts.add( "MissingEditor", "NonexistentGafferModule.NonexistentEditor( scriptNode )" ) + + # Check that we can still create such a layout, and that + # it has a proxy with the expected properties. + script = Gaffer.ScriptNode() + layout = layouts.create( "MissingEditor", script ) + self.assertIsInstance( layout, GafferUI.Editor ) + self.assertEqual( layout.getTitle(), "Nonexistent Editor" ) + + # Save the layout again and check that the same applies. + layouts.add( "MissingEditor2", layout ) + layout2 = layouts.create( "MissingEditor2", script ) + self.assertIsInstance( layout2, GafferUI.Editor ) + self.assertEqual( layout2.getTitle(), "Nonexistent Editor" ) + if __name__ == "__main__": unittest.main()