OLE Automation support ON in IronPython 2.0 Beta 4

As I had mentioned in a previous post, we have added support in IronPython for accessing OLE Automation objects using the IDispatch interface, without having to rely on interop assemblies. This was enabled with the "ipy.exe -X:PreferComDispatch" command-line until IronPython 2.0 Beta 3. In Beta 4, this behaviour is on by default. This makes IronPython's support for OLE Automation more script-friendly akin to VBScript and JScript.

See the difference

In IronPython 1.X, trying to use Excel would cause IronPython to generate the inteorp assembly on the fly, which would take more than minute, before resulting in an exception. If you explictly added a reference to Microsoft.Office.Interop.Excel.dll, then things get back on track. However, note that not all machines with Office installed will necessarily have the primary interop assemblies (PIAs) installed in the GAC, as explained in this link.

 c:\IronPython-1.1.2>ipy.exe -v
IronPython 1.1.2 (1.1.2) on .NET 2.0.50727.1433
Copyright (c) Microsoft Corporation. All rights reserved.
>>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add() # This will take a long time
Generating Interop assembly for 000208d5-0000-0000-c000-000000000046
Traceback (most recent call last):
  File , line 0, in <stdin>##16
SystemError: Could not load type 'Excel._Application' from assembly 'Excel, Version=1.6.0.0, Culture=neutral, PublicKeyToken=null'.
>>>
>>> import clr
>>> clr.AddReference("Microsoft.Office.Interop.Excel")
>>> wb = excel.Workbooks.Add()
>>>

In IronPython 2.0 Beta 3, things work much better, but as can be seen from the type of "wb", it still relies on the PIA. If the PIA is not installed or registered on the machine, IronPython would try to generate the interop assembly on the fly, again taking a very long time.

 c:\IronPython-2.0B3>ipy.exe -v
IronPython 2.0 Beta (2.0.0.3000) on .NET 2.0.50727.1433
Type "help", "copyright", "credits" or "license" for more information.
>>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add()
>>> wb
<Microsoft.Office.Interop.Excel.WorkbookClass object at 0x000000000000002B>
>>> wb.GetType().Assembly.CodeBase
'file:///C:/Windows/assembly/GAC/Microsoft.Office.Interop.Excel/12.0.0.0__71e9bce111e9429c/Microsoft.Office.Interop.Excel.dll'
>>> excel.Quit()
>>>

In IronPython 2.0 Beta 4, things just work, thanks to the shiny new OleAut support. "wb" is just a simple COM object that supports IDispatch, and there is no need for a PIA anymore!

 c:\IronPython-2.0B4>ipy.exe -v
IronPython 2.0 Beta (2.0.0.4000) on .NET 2.0.50727.1433
Type "help", "copyright", "credits" or "license" for more information.
>>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add()
>>> wb
<System.__ComObject (_Workbook) object at 0x000000000000002B>
>>> excel.Quit()
>>>

 

Different ways to get started

IDispatch is about invoking a method on an existing OleAut object. But how do you get to an OleAut object in the first place? VBScript and VBA have a method called CreateObject to create an OleAut object. There are multiple ways of doing the same in IronPython.

The first is to use the Type.GetTypeFromProgID method as shown here. This is the simplest solution.

 >>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add()

The second way is to use the new "clr.AddReferenceToTypeLibrary"  function. This currently requires hard-coding the GUID of the typelib (we could consider easier ways in the future). However, the advantage is that you can access the typelib just like a Python module. This is particularly handy if you need to access enum values in your program. Using the TLB, you can use the symbolc enum names instead of hard-coding int constants.

 >>> import System
>>> excelTypeLibGuid = System.Guid("00020813-0000-0000-C000-000000000046")
>>> import clr
>>> clr.AddReferenceToTypeLibrary(excelTypeLibGuid)
>>> from Excel import Application
>>> excel = Application()

>>> wb = excel.Workbooks.Add()

Finally, you can use the PIA as before. There is no particular advantage to doing this. It just means that your old code will keep on working as before (though method invocation will use the new IDispatch mechanism).

 >>> import clr
>>> clr.AddReference("Microsoft.Office.Interop.Excel")
>>> from Microsoft.Office.Interop.Excel import Application
>>> excel = Excel()
>>> wb = excel.Workbooks.Add()

All of the above techniques end up calling the Win32 function CoCreateInstance. So one more technique you could use is to make a pinvoke call to CoCreateInstance!

Once you have created a COM object, the rest of the code to access the object should work pretty much like before; it just wont require the presence of a PIA.

How to turn back the clock?

If you run into any bugs with the new OleAut support, there is a way to get to the old behavior. This is done by setting an environment variable called COREDLR_PreferComInteropAssembly to "TRUE" as show here. Do report the bug to users@lists.ironpython.com so that the issue can be fixed. COREDLR_PreferComInteropAssembly might go away in the future.

 c:\IronPython-2.0B4>ipy.exe
IronPython 2.0 Beta (2.0.0.4000) on .NET 2.0.50727.1433
Type "help", "copyright", "credits" or "license" for more information.
>>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add()
>>> wb
<System.__ComObject (_Workbook) object at 0x000000000000002B>
>>> ^Z 

 
c:\IronPython-2.0B4>set COREDLR_PreferComInteropAssembly=TRUE 

 
c:\IronPython-2.0B4>ipy.exe
IronPython 2.0 Beta (2.0.0.4000) on .NET 2.0.50727.1433
Type "help", "copyright", "credits" or "license" for more information.
>>> import System
>>> t = System.Type.GetTypeFromProgID("Excel.Application")
>>> excel = System.Activator.CreateInstance(t)
>>> wb = excel.Workbooks.Add()
>>> wb
<Microsoft.Office.Interop.Excel.WorkbookClass (Workbook) object at 0x000000000000002B>
>>> wb.GetType()
<System.RuntimeType object at 0x000000000000002C [Microsoft.Office.Interop.Excel.WorkbookClass]>
>>> 

Known issues

There are few expected differences to be aware of.

  • There is one important detail worth pointing out. IronPython tries to use the typelib of the OleAut object if it can be found, in order to do name resolution while accessing methods or properties. The reason for this is that the IDispatch interface does not make much of a distinction between properties and method calls. This is because of VB6 semantics where "excel.Quit" and "excel.Quit()" have the exact same semantics. However, IronPython has a strong distinction between properties and methods, and methods are first class objects. For IronPython to know whether "excel.Quit" should invoke the method Quit, or just return a callable object, it needs to inspect the typelib. If a typelib is not available, IronPython assumes that it is a method. So if a OleAut object has a property called "prop" but it has no typelib, you would need to write "p = obj.prop()" in IronPython to read the property value. This behavior is by design.
  • Calling a method with "out" (or in-out) parameters requires explicitly passing in an instance of "clr.Reference" as described in this blog, if you want to get the updated value from the method call. Note that COM methods with out parameters are not considered Automation-friendly. JScript does not support out parameters at all. If you do run into a COM component which has out parameters, having to use "clr.Reference" is a reasonable workaround. Another workaround is to leverage the inteorp assembly by using the full method name syntax of "outParamAsReturnValue = InteropAssemblyNamespace.IComInterface(comObject)".
    • Note that the Office APIs do have "VARIANT*" parameters, but there methods do not update the value of the VARIANT. The only reason they were defined with "VARIANT*" parameters was for performance since passing a pointer to a VARIANT is faster than pushing all the 4 DWORDs of the VARIANT onto the stack. So you can just treat such parameters as "in" parameters.
    • Real world examples of APIs with out parameters are ADODB.Command.Execute, most of the Microsoft Agent APIs. If you run into any others, let me know, so that I can compile a list here for reference. If this issue affects many scenarios, we could consider returning the out parameters as a return value tuple. This would require checking the ITypeInfo to check the type of parameters. We have avoided doing this so that we can have the same consistent code path irrespective of whether the typelib is available or not.

Currently, there are a few bugs in corner cases:

  • Passing a Python long (BigInteger) as an argument to a COM method expecting a LONGLONG causes a TypeError (InvalidCastException).
  • Passing some types of objects (eg "xrange(1)") to a COM method expecting an IUnknown results in a TypeError (InvalidCastException).
  • There are some corner cases where code that would have throw an exception before is now allowed. For example, it is possible to pass any object to a COM method expecting a BSTR. This is because the  default IDispatch marshalling rules allow this. Noone should be affected by this because it is a negative scenario anyway.

Under the hoods

Running with "ipy.exe -X:ShowRules", you can see that the generated code makes pinvoke calls on the IDispatch interfaces. This is similar to what VBScript (or VBA late bound code) would have done. UnsafeMethods.IDisaptchInvoke is a helper function in Microsoft.Scripting.Core.dll that will dereference the vtable of the OleAut object, get the function pointer for IDispatch::Invoke, and make a call to the function pointer.

 >>> wb = com.Workbooks
//
// AST: Rule
// .scope <rule> (
) {
    .if ((((.arg $arg0) != .null) && (Marshal.IsComObject)(
        (.arg $arg0),
    )) ) {.return .site (Object) GetMember Workbooks( // Python GetMember Workbooks IsNoThrow: False
        (ComObject.ObjectToComObject)(
            (.arg $arg0),
        )
        (.arg $arg1)
    );
    } .else {/*empty*/;
    }}

// AST: Rule
// 
.scope <rule> (
) {
    .if (((((.arg $arg0) != .null) && (((Object)(.arg $arg0)).(Object.GetType)() == ((Type)IDispatchComObject))
    ) && ((IDispatchComObject)(.arg $arg0).ComTypeDesc == (ComTypeDesc)System.Scripting.Com.ComTypeDesc)
    ) ) {.scope (
        System.Scripting.Com.IDispatchObject dispatchObject
        System.IntPtr dispatchPointer
        System.Int32 dispId
        System.Runtime.InteropServices.ComTypes.DISPPARAMS dispParams
        System.Scripting.Com.Variant invokeResult
        System.Object returnValue
    ) {
        .scope (
        ) {
            {
                (.var dispId) = (ComMethodDesc)Object&  Workbooks().DispId
                (.var dispParams).cArgs = 0
                (.var dispParams).cNamedArgs = 0
                (.var dispatchObject) = (IDispatchComObject)(.arg $arg0).DispatchObject
                (.var dispatchPointer) = ((.var dispatchObject)).(IDispatchObject.GetDispatchPointerInCurrentApartment)(
)
                .try {
                    .scope (
                        System.Scripting.Com.ExcepInfo excepInfo
                        System.UInt32 argErr
                        System.Int32 hresult
                    ) {
                        {
                            (.var hresult) = (UnsafeMethods.IDispatchInvoke)(
                                (.var dispatchPointer),
                                (.var dispId),
                                (INVOKEKIND)INVOKE_FUNC, INVOKE_PROPERTYGET,
                                (.var dispParams),
                                (.var invokeResult),
                                (.var excepInfo),
                                (.var argErr),
                            )
                            (ComRuntimeHelpers.CheckThrowException)(
                                (.var hresult),
                                (.var excepInfo),
                                (.var argErr),
                                "Workbooks",
                            )
                            (.var returnValue) = ((.var invokeResult)).(Variant.ToObject)()
                        }
                    }
                } .finally {
                    {
                        ((.var dispatchObject)).(IDispatchObject.ReleaseDispatchPointer)(
                            (.var dispatchPointer),
                        )
                        ((.var invokeResult)).(Variant.Clear)()
                    }
                }
                .return (.var returnValue);
            }
        }
    }
    } .else {/*empty*/;
    }}