Friday 9 August 2019

VBA - New Python COM classes !

So by default the Python Com Gateway class does not ship an intrinsic type library; this is a shame because Python has its own reflection capabilities and could do so easily IMHO. The official sample gives and compiles an Interface Definition Language (IDL) file but one has to maintain the IDL in sync with one's class. In this post I give code that gets Python to reflect on a class and automate the writing and compiling of the IDL into a type library.

Once you have a type library then you can create objects in VBA by adding a Tools->Reference and using New instead of using CreateObject(), you will get Intellisense . You will also get the object propertles in the VBA Locals and Watch windows.

DesignatedWrapPolicy

In Python COM behaviour is driven by policies, we want a type library so we'll need to use a DesignatedWrapPolicy policy which fortunately is the default. The doc string is worth quoting as it gives a round up of the attributes we need to give a class to make a type library appear,_typelib_guid_ and _typelib_version. This is taken from doc string of the DesignatedWrapPolicy class in win32com/server/policy.py . The opening remarks of that file also detail what a policy is.

class DesignatedWrapPolicy(MappedWrapPolicy):
  """A policy which uses a mapping to link functions and dispid
     
     A MappedWrappedPolicy which allows the wrapped object to specify, via certain
     special named attributes, exactly which methods and properties are exposed.

     All a wrapped object need do is provide the special attributes, and the policy
     will handle everything else.

     Attributes:

     _public_methods_ -- Required, unless a typelib GUID is given -- A list
                  of strings, which must be the names of methods the object
                  provides.  These methods will be exposed and callable
                  from other COM hosts.
     _public_attrs_ A list of strings, which must be the names of attributes on the object.
                  These attributes will be exposed and readable and possibly writeable from other COM hosts.
     _readonly_attrs_ -- A list of strings, which must also appear in _public_attrs.  These
                  attributes will be readable, but not writable, by other COM hosts.
     _value_ -- A method that will be called if the COM host requests the "default" method
                  (ie, calls Invoke with dispid==DISPID_VALUE)
     _NewEnum -- A method that will be called if the COM host requests an enumerator on the
                  object (ie, calls Invoke with dispid==DISPID_NEWENUM.)
                  It is the responsibility of the method to ensure the returned
                  object conforms to the required Enum interface.

    _typelib_guid_ -- The GUID of the typelibrary with interface definitions we use.
    _typelib_version_ -- A tuple of (major, minor) with a default of 1,1
    _typelib_lcid_ -- The LCID of the typelib, default = LOCALE_USER_DEFAULT

     _Evaluate -- Dunno what this means, except the host has called Invoke with dispid==DISPID_EVALUATE!
                  See the COM documentation for details.
  """

Up until now all the Python COM gateway classes on this blog have used late binding with the public methods listed in _public_methods_ . The doc string says that for early binding this will not be required but I will still use _public_methods_ to tell which methods to place in the type library. So I retain _public_methods_ (contrary to the documentation).

Official sample, pippo.py

The official code sample given to us by the great Mark Hammond (eternal thanks). The code sample consists of an IDL file, pippo.idl and a Python script implementing the COM server, pippo_server.py. Here is the pippo class

class CPippo:
    #
    # COM declarations    
    #
    _reg_clsid_ = "{05AC1CCE-3F9B-4d9a-B0B5-DFE8BE45AFA8}"
    _reg_desc_ = "Pippo Python test object"
    _reg_progid_ = "Python.Test.Pippo"
    #_reg_clsctx_ = pythoncom.CLSCTX_LOCAL_SERVER    
    ###
    ### Link to typelib
    _typelib_guid_ = '{41059C57-975F-4B36-8FF3-C5117426647A}'
    _typelib_version_ = 1, 0
    _com_interfaces_ = ['IPippo']

    def __init__(self):
        self.MyProp1 = 10

    def Method1(self):
        return wrap(CPippo())

    def Method2(self, in1, inout1):
        return in1, inout1 * 2

And the given idl is thus

 [
  object,
  uuid(F1A3CC2E-4B2A-4A81-992D-67862076949B),
  dual,
  helpstring("IPippo Interface"),
  pointer_default(unique)
 ]
 interface IPippo : IDispatch
 {  
  [id(1), helpstring("method Method1")] HRESULT Method1([out, retval] IPippo **val);
  [propget, id(2), helpstring("property MyProp1")] HRESULT MyProp1([out, retval] long *pVal);
  [id(3), helpstring("method Method2")] HRESULT Method2([in] long in1, [in, out] long *inout1,
                                                        [out, retval] long *val);
 };

But as I said above, as given one would have to maintain the class and the IDL file in synchronization which is a little painful. So now I can add a little value here and give some code which will read a Python class and then write and compile an IDL file into a type library that is in sync which the original Python class.

My test class, FooBar (housed in AComGatewayClass.py)

So here is my test class call FooBar. I have added some type annotations to demonstrate these being defined in the type library. I have placed this into a script file called AComGatewayClass.py. To import one would write from AComGatewayClass import FooBar.

We still have a _public_methods_ attribute even though that is more for late-binding; I use it to determine which methods to write to the type library.

Also, I have invented a new attribute called _reg_itfid_ which is use to snap (fix) the guid of the interface, so don't expect official documentation for that!

class FooBar(object):
    
    _typelib_guid_ = "{92F288D0-4863-4030-A4EE-36DE63DB7664}"
    _typelib_version_ = 1,0
    _typelib_lcid_ = 0

    _reg_clsid_ = "{8B994B6B-0865-4D48-8A62-2EB97C291BDA}"
    _reg_itfid_ = "{B8FFDEFA-3EFB-4725-8CDD-1F6A9E35DD7C}"  ### I have invented this to snap the interface's guid the type library
    
    _reg_progid_ = 'MyPythonProject2.FooBar'
    _com_interfaces_ = ["_FooBar"]

    _reg_policy_spec_ = "DesignatedWrapPolicy" ### not strictly required as is already the default so key driver of functionality

    _public_attrs_ = ['MyProp1']
    _public_methods_ = ['Sum','NoArgs','Baz','Benjy']

    def __init__(self):
        self.MyProp1 = 10

    def Sum(self,a:float, b:float)->float:
        return a+b

    def NoArgs(self)  :
        pass

    def Baz(self,someString) -> str :
        someBoolean:bool=True
        if someBoolean:
            return "Hello " + someString 
        else:
            return "Goodbye " + someString 

    def Benjy(self,someInt:int, someDouble:float, untyped) -> int:
        pass

def RegisterThis():
    print("Registering COM servers...")
    import win32com.server.register
    win32com.server.register.UseCommandLine(FooBar)

if __name__ == '__main__':
    RegisterThis()

IdlWriter.py

So here is the code that will take a Python class and create a type library for it. It requires the MIDL launcher I gave in the previous post and I saved to a script file MidlLauncherHelper.py . The code has a little extra logic to examine an argument type and give the correct type in the IDL which carries through to the type library. There isn't a huge amount to see, it is all string concatenation to be honest.

import pythoncom
from MidlLauncherHelper import MidlLauncher


class IdlWriter(object):
    def PythonTypeToIDLType(self,annotations,argName:str) -> str:
        try:
            if argName in annotations:
                pythonargtype = annotations[argName]
                key2 = pythonargtype.__name__
                return {
                    'bool': "VARIANT_BOOL",
                    'str':"BSTR*",
                    'float':"double",
                    'int':"long",
                    '':"VARIANT*",
                    }[key2]
            else:
                return "VARIANT*"

        except Exception as e:
            print("Error: " + str(e) + "\n")


    def IdlFullFilename(self,idlFilename:str)->str:
        import os
        try:
            this_dir = os.path.dirname(__file__)
            return os.path.abspath(os.path.join(this_dir, idlFilename))
        except Exception as e:
            print("Error: " + str(e) + "\n")

    def Main(self,library:str,typelib_guid,typelib_version,coclasses,idlFilename:str):
        try:
            IdlFullFilename = self.IdlFullFilename(idlFilename)
            idlSrc = self.InspectMyClass(library,typelib_guid,typelib_version,coclasses)
            with open(IdlFullFilename, "w+") as f:
                f.write(idlSrc)
            MidlLauncher.CompileTypelib(IdlFullFilename)
        except Exception as e:
            print("Error: " + str(e) + "\n")

    def InspectMyClass(self,library:str,typelib_guid,typelib_version,coclasses):
        import inspect
        import uuid
        try:

            idl = "// Generated .IDL file (by Python code)\n//\n// typelib filename: FooBar.tlb\n"
            idl = idl + "import \"oaidl.idl\";\nimport \"ocidl.idl\";\n"
            idl = idl + "import \"unknwn.idl\";\n"
            idl = idl + "[\n  uuid(" + typelib_guid[1:-1] + "),\n  version(" + str(typelib_version[0]) + "." + str(typelib_version[1]) + ")\n]\n"
            idl = idl + "library " + library + "\n{\n"
            idl = idl + "    // TLib :     // TLib : OLE Automation : {00020430-0000-0000-C000-000000000046}\n"
            idl = idl + "    importlib(\"stdole32.tlb\");\n"
            idl = idl + "    importlib(\"stdole2.tlb\");\n\n"
            idl = idl + "    importlib(\"stdole2.tlb\");\n\n"
            
            idl = idl + "    // Forward declare all types defined in this typelib\n"

            for coclass in coclasses:
                idl = idl + "    interface " + "_" + coclass.__name__ + ";\n"

            idl = idl + "\n"

            for coclass in coclasses:
                ### rewritten to mimic pippo.idl in the win32com\test directory
                idl = idl + "    [\n"
                idl = idl + "      object,\n"
                idl = idl + "      uuid(" + coclass._reg_itfid_[1:-1] + "),\n" 
                idl = idl + "      dual,\n"
                idl = idl + "      helpstring(\"test\"),\n"
                idl = idl + "      pointer_default(unique)\n"
                idl = idl + "    ]\n"
                
                
                idl = idl + "    interface " + "_" + coclass.__name__ + " : IDispatch {\n"

                method_list2 = inspect.getmembers(coclass, inspect.isfunction)
                dispid = 1  #start from 1 as zero equates to default member
                for meth in method_list2 :
                    if meth[0] in coclass._public_methods_:
                        idl = idl + "        [id(" + str(dispid) + ")]\n"
                        idl = idl + "        HRESULT " + meth[0] + "(\n"
                        fullArgSpec = inspect.getfullargspec(meth[1])
                        argc = len(fullArgSpec.args)
                        for argIdx in range(1, argc):
                            arg = fullArgSpec.args[argIdx]
                            argType = self.PythonTypeToIDLType(fullArgSpec.annotations,arg)
                            idl = idl + "        \t\t[in] " + argType + " " + arg + ",\n"

                        argType = self.PythonTypeToIDLType(fullArgSpec.annotations,"return")

                        idl = idl + "        \t\t[out, retval] " + argType + "* retval );\n"

                    dispid = dispid + 1

                if hasattr(coclass,'_public_attrs_'):
                    for attr in coclass._public_attrs_:
                        idl = idl + "        [id(" + str(dispid) + "), propget]\n"
                        idl = idl + "        HRESULT " + attr + "([out, retval] VARIANT *pVal);\n"

                        idl = idl + "        [id(" + str(dispid) + "), propput]\n"
                        idl = idl + "        HRESULT " + attr + "([in] VARIANT rhs);\n"

                idl = idl + "\n    };\n\n"
                idl = idl + "    [\n      uuid(" + coclass._reg_clsid_[1:-1] + "),\n      version(1.0)\n    ]\n"
                idl = idl + "    coclass " + coclass.__name__ + "{\n"
                idl = idl + "        [default] interface " + "_" + coclass.__name__ + ";\n"
                idl = idl + "    };\n"
            idl = idl + "};\n"

            print(idl)
            return idl

        except Exception as e:
            print("Error: " + str(e) + "\n")

if __name__ == '__main__':
    from AComGatewayClass import FooBar
    idl = IdlWriter()
    idl.Main("MyPythonProject2",FooBar._typelib_guid_,FooBar._typelib_version_,[FooBar],"MyPythonProject2.idl")
    
    print("End of execution")

By the way, you can pass in more than one class, it is written to take a list of classes.

VBA Calling Code

So now we can New a Python class in VBA thus ...

Sub TestEarlyBound()
    On Error GoTo ErrHandler
    
    Dim obj As MyPythonProject2.FooBar
    Set obj = New MyPythonProject2.FooBar
    
    Debug.Print obj.Sum(1, -2)
    obj.MyProp1 = 256
    
    Stop  ' take a moment to admire the property MyProp1 in the Locals window, this would NOT appear without a type library
    Exit Sub
ErrHandler:
    Stop
    
End Sub

Enjoy!

Other Links

1 comment:

  1. Just wanted to point out that this discussion (https://stackoverflow.com/questions/35782404/registering-a-com-without-admin-rights) gives the registry keys required to register the server without admin rights. Very helpful in my work environment.

    ReplyDelete