Tuesday, May 12, 2015

Importing translators!

Ran up a little test app to make sure I know as much as I thought I knew about dynamic importing. Everything I know about this, I learned from working on PyInstaller. It has a library of "hooks", small modules that modify the loading process for a specific module. When it finds an import for modname, PyInstaller looks in its hooks folder for a file hook-modname.py. If there is such a hook, it loads that file of Python code into a namespace and looks at the namespace for certain things, such as a global "datas" that can be a list of data files to be loaded when any bundled app imports modname, or a function "hook" that it can call to edit the importation of modname.

It was knowing about this general pattern — load source into namespace, interrogate namespace attributes, call functions in namespace — that made me confident that I could support a variable number of "translator" modules, and even permit users to add new translators in the field.

In the actual app, the File menu will have a sub-menu "Translators". This sub-menu will be prepared at startup. The main window will call a function that populates a QMenu with names of translators. Here is the approximate code of that process.

The outer function will get the Extras path (as set in the Preferences) and look in it for a folder "Translators". It makes a list of all items in that folder and passes each to the following.

    def add_xlt_source( fpath ) :
        if not os.path.exists( fpath ) : return
        if not os.access( fpath, os.R_OK ) : return
        fname = os.path.basename( fpath )
        if not ( fname.endswith( '.py' ) or fname.endswith( '.pyc' ) ) : return
        # It exists, is readable, and ends in .py[c]. Try to load it into
        # a Python namespace.
        xlt_loader = importlib.machinery.SourceFileLoader( fname, fpath )
        print( 'getting namespace', fname)
        xlt_namespace = xlt_loader.load_module()
        # if it is a proper Translator, it has a global MENU_NAME
        if hasattr( xlt_namespace, 'MENU_NAME' ) :
            act = submenu.addAction( xlt_namespace.MENU_NAME )
            act.setData( xlt_namespace )
            act.triggered.connect( run_xlator )
            submenu.setEnabled( True )

The key is the one statement xlt_namespace = xlt_loader.load_module(). This performs an import. It executes all the statements in that source file. (Some of those might raise exceptions, so probably that statement should be in a try/except block.) The returned value is a Python namespace that represents everything declared in that module: its global variables, its classes, and its defined functions.

One can interrogate the namespace with hasattr(). In this case, a Translator has to define a global that is a string (this should be tested!) to use as the menu choice that invokes that translator.

If the module passes this test, the code makes a QAction with the name from the module and adds that action to the sub-menu. The menu action's "triggered" signal is pointed at a function to handle invocation of that translator, and the namespace itself is stored in the action as arbitrary data.

Here's the current stub of the run_xlator function.

        space = self.sender().data()
        print( space.MENU_NAME, getattr( space, 'DATA', '(no data)' ) )

This is a "slot" invoked from the "triggered" signal that is generated when the user selects that item on the menu. It must be part of a QObject-derived object. It can call QObject.sender() to get a reference to the object that created the signal, which in this case can only be the QAction from the sub-menu. The QAction has a data() method that returns the namespace that was stored in it with setData(). That's everything defined in the module that was loaded, so here we print two global values, one we know exists, and one that is optional.

For test purposes I've set up two files in the Translators folder. One is not a Translator,

'''
Test module that is NOT a translator.
'''
print('Non-translator module executing anyway!')

The other one is.

'''
test translator module
'''
MENU_NAME = 'Wahoo!'
DATA = 'Some Data'
print('Wahoo executing!')

Here's the output of a test.

getting namespace not_a_translator.py
Non-translator module executing anyway!
getting namespace xlt_wahoo.py
Wahoo executing!
Wahoo! Some Data  (from run_xlator)

No comments: