Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace ctypes.DllGetClassObject and remove DllCanUnloadNow #127369

Open
encukou opened this issue Nov 28, 2024 · 23 comments
Open

Replace ctypes.DllGetClassObject and remove DllCanUnloadNow #127369

encukou opened this issue Nov 28, 2024 · 23 comments
Labels

Comments

@encukou
Copy link
Member

encukou commented Nov 28, 2024

As far as I can tell, these functions are hooks: third-party code is meant to replace them.

Their implementation in ctypes (i.e. their default behaviour) is to import and call the same-named functions from a third-party library, comtypes.server.inprocserver. This is not good. comtypes should instead register their hook on import.

Here's a possible plan to make the API boundary better without breaking users.

DllCanUnloadNow

While the Python interpreter is running, it is not safe to unload the shared library that contains _ctypes. Therefore:

  • The C function DllCanUnloadNow exported from _ctypes should be changed to always return S_FALSE. We should change that now, without a deprecation period. (Note that the comtypes hook already does this.)
  • We should stop importing and calling comtypes.server.inprocserver. I'm not sure about the necessary deprecation period, but I think that it should be a non-breaking change and can also be done immediately. Or is someone relying on it for side effects? O_o
  • Setting and getting the hook should be deprecated. In about Python 3.18 we should stop calling it, and remove it.

DllGetClassObject

This one, on the other hand, sounds like a useful hook. It also looks like an inprocess COM server need a special build so it's not useful to allow multiple hooks -- replacing a global one is enough. Is that so?
If yes:

  • ctypes.DllGetClassObject (the default implementation) should raise a DeprecationWarning. In about Python 3.18, it should be changed to do nothing, just, return CLASS_E_CLASSNOTAVAILABLE.
  • comtypes should be changed: on import, it should replace ctypes.DllGetClassObject with its own hook.

This should ensure that old versions of comtypes still work as before (until after the deprecation period).

Does that sound reasonable?
cc @junkmd

Linked PRs

@zooba
Copy link
Member

zooba commented Nov 28, 2024

I suspect these may be here because comtypes calls back through ctypes, and so to COM it looks like all the calls are coming from _ctypes.pyd? I've never noticed them in our code before (all my COM work with Python has been in my own extension modules).

No doubt it's a useful and important hack at some point, but without a more concrete use case they're probably not so necessary.

Changing default behaviour to not import comtypes is a good move, but it may not be possible for comtypes to hook them itself if it doesn't get imported before these are called. DllGetClassObject in particular is meant as an entry point, but I'm not sure how much gets executed before it may be called. It probably depends on how Python is registered as a local server, which is something that's definitely outside of our upstream support, and so I think it's reasonable for comtypes (or whoever) to figure out a preferred way to set that up.

If comtypes doesn't need these, then I'd prefer to deprecate and remove. I guess that's what @junkmd can tell us.

@junkmd
Copy link
Contributor

junkmd commented Nov 30, 2024

For those interested in this discussion, I would like to introduce some technical references related to DllCanUnloadNow and DllGetClassObject.

A notable reference book on OLE and related COM topics is "Inside OLE, 2nd Edition, Kraig Brockschmidt, Microsoft Press, 1995, ISBN: 1-55615-843-2".
The author, kraigb, has made the book publicly available under the MIT License.

Regarding DllGetClassObject, page 183 of the PDF states the following:

In-Process Server

Every DLL server must implement and export a function named DllGetClassObject with the following form:

STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void **ppv);

When a client asks COM to create an object and COM finds that an in-process server is available, COM will pull the DLL into memory with the COM API function CoLoadLibrary. COM then calls GetProcAddress looking for DllGetClassObject; if successful, it calls DllGetClassObject, passing the same CLSID and IID that the client passed to COM. This function creates a class factory for the CLSID and returns the appropriate interface pointer for the requested IID, usually IClassFactory or IClassFactory2, although the design of this function allows new interfaces to be used in the future. No straitjackets here.

DllGetClassObject is structurally similar to IClassFactory::CreateInstance, and as we'll see later in the sample code for this chapter, the two functions are almost identical: the difference is that CreateInstance creates the component's root object, whereas DllGetClassObject creates the class factory. Both query whatever object they create for the appropriate interface pointer to return, which conveniently calls AddRef as well.

Because DllGetClassObject is passed a CLSID, a single DLL server can provide different class factories for any number of different classes that is, a single module can be the server for any number of component types. The OLE DLL is itself an example of such a server; it provides most of the internally used object classes of OLE from one DLL.

Be Sure to Export DllGetClassObject

When creating an in-process server or handle, be sure to export DllGetClassObject as well as DllCanUnloadNow. (See "Unloading Mechanisms" later in this chapter.) Failure to do so will cause a myriad of really strange and utterly confusing bugs. I guarantee that you'll hate tracking these bugs down. Save yourself the trouble and write yourself a really big, hot-pink fluorescent Post-it note and stick it in the middle of your monitor so you'll remember.

Regarding DllCanUnloadNow, page 186 of the PDF states the following:

In-Process Server

Being rather passive, the DLL unloading mechanism is fairly trivial. Every now and then primarily when a client calls the function CoFreeUnusedLibraries COM attempts to call an exported function named DllCanUnloadNow in the same manner that it calls DllGetClassObject. This function takes no arguments and returns an HRESULT:

STDAPI DllCanUnloadNow(void)

When COM calls this function, it is essentially asking "Can I unload you now?" DllCanUnloadNow returns S_FALSE if any objects or any locks exist, in which case COM doesn't do anything more. If there are no locks or objects, the function returns NOERROR (or S_OK), and COM follows by calling CoFreeLibrary to reverse the CoLoadLibrary function call that COM used to load the DLL in the first place.

Note: Early 16-bit versions of OLE did not implement the CoFreeUnusedLibraries, so DllCanUnloadNow was never called. This led to the belief that it was not necessary to implement this function or IClassFactory::LockServer because the DLL would stay in memory for the duration of the client process. This is no longer true because the COM function is fully functional and will call all DLLs in response. To support proper unloading, you must always implement IClassFactory::LockServer as well as DllCanUnloadNow.

A DLL's response to a DllCanUnloadNow call does not take into consideration the existence or reference counts on any class factory object. Such references are not sufficient to keep a server in memory, which is why LockServer exists. To be perfectly honest, an in-process server could include a count of class factories in its global object count if it wanted to, but this doesn't work with a local server, as the following section illustrates.

@junkmd
Copy link
Contributor

junkmd commented Nov 30, 2024

Below is documentation for using comtypes (as of version 0.5) ¹⁾ as a COM server:
https://pythonhosted.org/comtypes/server.html


¹⁾ The comtypes documentation has not been updated for many years. This is due to a combination of factors: pythonhosted can no longer be updated; I am not yet well-versed in Sphinx; I am unfamiliar with how to migrate documentation to hosting platforms like ReadTheDocs; and I lack case studies on how external collaborators without ownership, like myself, can work with other maintainers to maintain documentation. One reason I started contributing to the cpython documentation was to familiarize myself with modern Sphinx practices.

@junkmd

This comment was marked as outdated.

@junkmd
Copy link
Contributor

junkmd commented Nov 30, 2024

Here are my current thoughts on this:

  1. Change the implementation of these ctypes APIs so they no longer depend on comtypes.
  2. Keep these APIs but modify them to make the hooks in ctypes easier for third-party libraries and applications to use.
  3. In any case, implement a COM server using ctypes to verify its behavior during the process.

I am not against changing ctypes to remove its dependency on comtypes. Considering the proper dependency relationship, the current implementation is suboptimal.

However, just as COM interfaces can be implemented by projects other than comtypes, changes to ctypes should leave room for implementing COM servers without relying on comtypes.

I think the hooks defined in ctypes and the callbacks defined in _ctypes (CanUnloadNow and DllGetClassObject) should remain.
I suspect these are essential when implementing a COM server using ctypes.

Regarding hook handling, I propose creating a relationship similar to sys.excepthook and sys.__excepthook__.
An implementation like below would allow application developers to specify the behavior invoked by hooks easily when combining ctypes and COM packages such as comtypes.
It would also enable calling the pre-overloaded function.

def DllCanUnloadNow():
    return __dll_can_unload_now__()


def __dll_can_unload_now__():
    ...


def DllGetClassObject(rclsid, riid, ppv):
    return __dll_get_class_object__(rclsid, riid, ppv)


def __dll_get_class_object__(rclsid, riid, ppv):
    ...

The main challenge is that we have yet to find modern use cases for implementing COM servers using ctypes or comtypes.

To make production changes, I believe comprehensive testing that covers actual use cases is essential.

The comtypes community likely includes developers who have implemented COM servers using comtypes or even developers familiar with implementing COM servers in languages other than Python.

If agreeable, I plan to reach out to them and invite them to participate in this discussion to provide their insights and cooperation.
I am also reaching out to developers interested in this topic in enthought/comtypes#671, but directly contacting developers who could be stakeholders in this change would be more effective.

cc: @encukou, @zooba

@zooba
Copy link
Member

zooba commented Dec 2, 2024

The main challenge is that we have yet to find modern use cases for implementing COM servers using ctypes or comtypes.

Agreed. There are no shortage of use cases for using COM servers, but very few that require implementing them (especially when they're then going to be activated through COM's global interfaces, as opposed to being directly passed in).

I assume this only applies to in-process activation as well? If you are registering a COM server for out-of-proc activation then I'm pretty sure ctypes isn't going to work anyway. So it's really only able to handle in-process activation, which is also not widely used.

If anyone has any such cases for implementing/registering a COM server in Python, modern or legacy, it would be helpful to list them here.

If we can't find sufficient modern cases to motivate this (and I emphasise modern here because we need to justify users who can update their CPython to a later version but somehow can't modify their own code or update other parts of their system - legacy cases where nobody has touched it in 20 years don't count, because you couldn't update CPython in that case), then I think deprecation and complete removal is on the table.

@encukou
Copy link
Member Author

encukou commented Dec 2, 2024

I suspect these may be here because comtypes calls back through ctypes, and so to COM it looks like all the calls are coming from _ctypes.pyd? I've never noticed them in our code before (all my COM work with Python has been in my own extension modules).

But, it's not safe to unload _ctypes.pyd (or any other extension's DLL) while a Python interpreter is running. So, we should make the DllCanUnloadNow hook a no-op.

Regarding hook handling, I propose creating a relationship similar to sys.excepthook and sys.__excepthook__.

IMO, that would make sense if __dll_get_class_object__ was complicated. But here the base implementation would be two lines:

def __dll_get_class_object__(rclsid, riid, ppv):
    return -2147221231  # CLASS_E_CLASSNOTAVAILABLE

(There's a magic number because ctypes doesn't have a good way to work with HRESULT error constant. But a library dealing with COM should make them easy to work with.)
It could even be no lines -- the C function can do this in case the Python hook does not exist at all.


If we need something more complex than a replaceable hook, i was thinking about a list, named for example ctypes.DllGetClassObject_hooks, to which users could add their hooks. The C function DllGetClassObject would call Python functions in this list in order, until one returns something else than CLASS_E_CLASSNOTAVAILABLE. It would return the result of the last call.

(We could instead make the list internal, and add a “register” function, but then we'd also need “unregister” and “clear” and so on -- a list sounds more convenient.)

Unlike with sys.excepthook, it makes sense to me that there could be multiple extensions that want to export COM objects. But, I have no idea if it's even something to keep in mind as a future possibility.

@bennyrowland
Copy link

I am one of the developers using comtypes to make servers rather than clients that @junkmd asked to weigh in on this discussion. I was going to say that I know absolutely nothing about this subject because I have only used local servers rather than inproc ones, but that felt a bit underwhelming, so I have done a bit of extra digging to try and understand at least a little bit that I might be able to contribute, apologies if none of this is new or interesting.

Local servers made intuitive sense to me because registering them just sets the Python executable and a script to run when the server is created, but it didn't make sense to me how an inproc server could work - how would the interpreter get set up, etc? So I looked at the comtypes registration code for inproc servers here which requires a frozendllhandle attribute to exist to register as inproc.

From somewhere in my distant memories that reminded me that comtypes had been associated with py2exe, so I checked that out and sure enough turned up this page on how to generate a COM DLL server. Some further digging through the code turned up the file source/run_ctypes_dll.c and particularly these lines. Basically what Py2exe is doing is compiling this C file into a DLL and embedding a Python interpreter into it to run the comtypes server code. As the DLL being used to provide the inproc server it has to implement DllGetClassObject() and DllCanUnloadNow, but it does so by loading the implementations out of ctypes (which of course then goes on to get them from comtypes).

I know that @theller was heavily involved in the early days of ctypes, comtypes, and py2exe and could probably add more insight into this situation, but I assume that the issue is that because comtypes is a pure Python package it doesn't have a DLL which could be imported by run_ctypes_dll.c, so it was convenient to make use of the _ctypes.dll to provide exported C functions which call the Python functions in turn.

From my perspective, there doesn't seem to be any reason to keep these functions in ctypes at all. Although they are nominally standard COM functions as explained above by @junkmd, there is no process to invoke them except via Py2exe and run_ctypes_dll.c. It would therefore seem much more logical to move the logic contained in the callbacks directly to Py2exe, but rather than invoking ctypes.DllGetClassObject it could directly invoke comtypes.DllGetClassObject (comtypes is guaranteed to be installed via Py2exe's freezing process anyway). Arguably, the inproc server code in comtypes should even be moved into Py2exe anyway given that it can only be used via Py2exe in the first place.

I will confess that I personally have little appetite for taking on the responsibility of updating Py2exe to no longer depend on these functions from types, I have never used Py2exe and it is sufficiently complex (and insufficiently documented) that I don't fancy setting up the necessary development environment to get it building. But it is actively being developed and I think that the actual fix is probably fairly simple for a maintainer to implement (mainly just copying code from ctypes/callbacks.c). I think that with a fairly long deprecation cycle before removing them from ctypes, combined with a bug report to Py2exe, anybody that is interested in using inproc servers in this way will have plenty of opportunity to implement the fix.

@encukou
Copy link
Member Author

encukou commented Dec 3, 2024

It seems that py2exe has special support for comtypes.server.inprocserver that it runs in a special ctypes_comdll target. It's not clear to me what an “inprocess COM server” is; is this it?

If it is, looks like it will call comtypes.server.register.register before Windows will make any calls to DllGetClassObject, so that's a function where comtypes can ensure that its hook is installed.

@bennyrowland
Copy link

@encukou an inproc COM server is one which is contained in a DLL and so can be instantiated in the calling process, and COM objects so produced can be used directly via function pointers. In the more common case (at least in my experience) the COM server is a separate process and methods are called via RPC with Windows messages.

There are different hooks for different purposes in the DLL. DllRegisterServer() and DllUnregisterServer() are called by regsvr32 in order to create the relevant registry entries to allow the server to then be discoverable by potential clients (or to remove the entries, respectively). These functions would not normally be called when a registered server is being instantiated - that is when DllGetClassObject() is called.

I think the most important point to emphasise is that the DllGetClassObject hook which Windows calls is in the DLL which is registered for the inproc server. This is not the _ctypes.pyd but rather the custom DLL created by Py2exe. The DllGetClassObject in ctypes is therefore not actually a hook. AFAICT there is no reason for any of this code to be in ctypes at all - all of these functions in ctypes will only ever be called from Py2exe DLLs, there is no other mechanism to use them, so they really belong in Py2exe.

You can see here that the Py2exe DLL implementations of DllRegisterServer() and DllUnregisterServer() call into the Python interpreter that is provisioned in DllMain(), which has already imported boot_ctypes_com_server.py and so can directly call the Python versions of the functions here. For reasons that are not clear to me, the Py2exe DLL does not do the same thing for DllGetClassObject(), but instead imports the C functions from _ctypes.pyd that then do use the same paradigm of calling in to the Python code in ctypes. I think it should be very easy to move the Python DllGetClassObject() and DllCanUnloadNow() code from ctypes into boot_ctypes_com_server.py and then just change the way that the Py2exe DLL forwards the calls to match Dll(Un)RegisterServer()

In fact, I think there is definitely a case to be made for moving all of comtypes.server.inprocserver into Py2exe. The implementation in comtypes is custom designed for Py2exe to use (and cannot be used in any other way), and there is plenty of messy stuff like this where Py2exe is overwriting a private comtypes member because really all that logic belongs in Py2exe.

@junkmd
Copy link
Contributor

junkmd commented Dec 4, 2024

Based on previous discussions, I think the ideal approach is to implement the hooks directly in freezing tools like py2exe, with ctypes and comtypes providing supporting utility functions as needed.

Considering cases where implementing COM servers with win32com in pywin32, it is neither reasonable nor feasible for ctypes or comtypes to take responsibility for properly handling inproc COM server hooks for every possible COM implementation.

Moreover, as seen in py2exe/py2exe#24 (and mhammond/pywin32#868), it seems that registering COM servers with py2exe does not work well for either win32com or comtypes.

It might also be worthwhile to invite the maintainers of py2exe to participate in this discussion to help investigate the technical challenges and determine the required deprecation timeline (i.e., the duration of the deprecation period to be set).

@encukou
Copy link
Member Author

encukou commented Dec 4, 2024

I opened py2exe/py2exe#217.

@encukou
Copy link
Member Author

encukou commented Dec 9, 2024

So, no one knows :)
I think I'll remove these functions with a long deprecation period, to give time for users to notice and ctypes/comtypes/py2exe/win32com maintainers to discuss. Perhaps we can get a working, tested solution out of this. And if no one uses this, there's no harm in keeping the functions for a few years.

@junkmd
Copy link
Contributor

junkmd commented Dec 10, 2024

Thanks to @bennyrowland's insights and @forderud's improvements (enthought/comtypes#678), we were able to revive tests in comtypes that require COM server registration and unregistration, which had previously been skipped.

I ran these revived tests using a Python build from #127766 branch.
The results of executing the unit tests with GitHub Actions (workflow file contents are here) on my PoC repo are as follows:

results of executing the unit tests
test_creation (test_BSTR.Test.test_creation) ... ok
test_from_param (test_BSTR.Test.test_from_param) ... ok
test_inargs (test_BSTR.Test.test_inargs) ... ok
test_paramflags (test_BSTR.Test.test_paramflags) ... ok
test (test_DISPPARAMS.TestCase.test) ... ok
test_GUID_null (test_GUID.Test.test_GUID_null) ... ok
test_as_progid (test_GUID.Test.test_as_progid) ... ok
test_create_new (test_GUID.Test.test_create_new) ... ok
test_dunder_eq (test_GUID.Test.test_dunder_eq) ... ok
test_dunder_repr (test_GUID.Test.test_dunder_repr) ... ok
test_duner_str (test_GUID.Test.test_duner_str) ... ok
test_from_progid (test_GUID.Test.test_from_progid) ... ok
test_invalid_constructor_arg (test_GUID.Test.test_invalid_constructor_arg) ... ok
test (test_QueryService.TestCase.test) ... skipped 'This IE test is not working.  We need to move it to using some other win32 API.'
test (test_avmc.Test.test) ... skipped "This test does not work.  Apparently it's supposed to work with the 'avmc' stuff in comtypes/source, but it doesn't.  It's not clear to me why."
test_IUnknown (test_basic.BasicTest.test_IUnknown) ... ok
test_derived (test_basic.BasicTest.test_derived) ... ok
test_heirarchy (test_basic.BasicTest.test_heirarchy) ... ok
test_identity (test_basic.BasicTest.test_identity) ... ok
test_make_methods (test_basic.BasicTest.test_make_methods) ... ok
test_mro (test_basic.BasicTest.test_mro) ... ok
test_qi (test_basic.BasicTest.test_qi) ... ok
test_refcounts (test_basic.BasicTest.test_refcounts) ... ok
test_release (test_basic.BasicTest.test_release) ... ok
test (test_casesensitivity.TestCase.test) ... ok
test_clear_cache (test_clear_cache.ClearCacheTestCase.test_clear_cache) ... ok
test_alias (test_client.Test_Constants.test_alias) ... ok
test_enums_in_friendly_mod (test_client.Test_Constants.test_enums_in_friendly_mod) ... ok
test_munged_definitions (test_client.Test_Constants.test_munged_definitions) ... ok
test_progid (test_client.Test_Constants.test_progid) ... ok
test_punk (test_client.Test_Constants.test_punk) ... ok
test_returns_other_than_enum_members (test_client.Test_Constants.test_returns_other_than_enum_members) ... ok
test_clsid (test_client.Test_CreateObject.test_clsid) ... ok
test_clsid_string (test_client.Test_CreateObject.test_clsid_string) ... ok
test_progid (test_client.Test_CreateObject.test_progid) ... ok
test_remote (test_client.Test_CreateObject.test_remote) ... ok
test_server_info (test_client.Test_CreateObject.test_server_info) ... ok
test_abspath (test_client.Test_GetModule.test_abspath) ... ok
test_abstracted_wrapper_module_in_friendly_module (test_client.Test_GetModule.test_abstracted_wrapper_module_in_friendly_module) ... ok
test_clsid (test_client.Test_GetModule.test_clsid) ... ok
test_libid_and_version_numbers (test_client.Test_GetModule.test_libid_and_version_numbers) ... ok
test_mscorlib (test_client.Test_GetModule.test_mscorlib) ... ok
test_msvidctl (test_client.Test_GetModule.test_msvidctl) ... ok
test_no_replacing_Patch_namespace (test_client.Test_GetModule.test_no_replacing_Patch_namespace) ... ok
test_obj_has_reg_libid_and_reg_version (test_client.Test_GetModule.test_obj_has_reg_libid_and_reg_version) ... ok
test_one_length_sequence_containing_libid (test_client.Test_GetModule.test_one_length_sequence_containing_libid) ... ok
test_portabledeviceapi (test_client.Test_GetModule.test_portabledeviceapi) ... ok
test_ptr_itypelib (test_client.Test_GetModule.test_ptr_itypelib) ... ok
test_raises_typerror_if_takes_unsupported (test_client.Test_GetModule.test_raises_typerror_if_takes_unsupported) ... ok
test_relpath (test_client.Test_GetModule.test_relpath) ... skipped 'This depends on typelib and test module are in same drive'
test_the_name_of_the_enum_member_and_the_coclass_are_duplicated (test_client.Test_GetModule.test_the_name_of_the_enum_member_and_the_coclass_are_duplicated) ... ok
test_tlib_string (test_client.Test_GetModule.test_tlib_string) ... ok
test_symbols_in_comtypes (test_client.Test_KnownSymbols.test_symbols_in_comtypes) ... ok
test_symbols_in_comtypes_automation (test_client.Test_KnownSymbols.test_symbols_in_comtypes_automation) ... ok
test_symbols_in_comtypes_persist (test_client.Test_KnownSymbols.test_symbols_in_comtypes_persist) ... ok
test_symbols_in_comtypes_stream (test_client.Test_KnownSymbols.test_symbols_in_comtypes_stream) ... ok
test_symbols_in_comtypes_typeinfo (test_client.Test_KnownSymbols.test_symbols_in_comtypes_typeinfo) ... ok
test_dict (test_client_dynamic.Test_Dispatch_Class.test_dict) ... ok
test_returns_dynamic_Dispatch_if_takes_dynamic_Dispatch (test_client_dynamic.Test_Dispatch_Function.test_returns_dynamic_Dispatch_if_takes_dynamic_Dispatch) ... ok
test_returns_dynamic_Dispatch_if_takes_ptrIDispatch_and_raised_comerr (test_client_dynamic.Test_Dispatch_Function.test_returns_dynamic_Dispatch_if_takes_ptrIDispatch_and_raised_comerr) ... ok
test_returns_dynamic_Dispatch_if_takes_ptrIDispatch_and_raised_winerr (test_client_dynamic.Test_Dispatch_Function.test_returns_dynamic_Dispatch_if_takes_ptrIDispatch_and_raised_winerr) ... ok
test_returns_lazybind_Dispatch_if_takes_ptrIDispatch (test_client_dynamic.Test_Dispatch_Function.test_returns_lazybind_Dispatch_if_takes_ptrIDispatch) ... ok
test_returns_what_is_took_if_takes_other (test_client_dynamic.Test_Dispatch_Function.test_returns_what_is_took_if_takes_other) ... ok
test_all_modules_are_missing (test_client_regenerate_modules.Test.test_all_modules_are_missing) ... ok
test_dependency_modules_are_missing (test_client_regenerate_modules.Test.test_dependency_modules_are_missing) ... ok
test_friendly_module_is_missing (test_client_regenerate_modules.Test.test_friendly_module_is_missing) ... ok
test_wrapper_module_is_missing (test_client_regenerate_modules.Test.test_wrapper_module_is_missing) ... ok
test_IEnumVARIANT (test_collections.Test.test_IEnumVARIANT) ... ok
test_leaks_1 (test_collections.Test.test_leaks_1) ... skipped 'This test takes a long time.  Do we need it? Can it be rewritten?'
test_leaks_2 (test_collections.Test.test_leaks_2) ... skipped 'This test takes a long time.  Do we need it? Can it be rewritten?'
test_leaks_3 (test_collections.Test.test_leaks_3) ... skipped 'This test takes a long time.  Do we need it? Can it be rewritten?'
test_index_setter (test_collections.TestCollectionInterface.test_index_setter) ... ok
test_named_property_no_length (test_collections.TestCollectionInterface.test_named_property_no_length) ... ok
test_named_property_not_iterable (test_collections.TestCollectionInterface.test_named_property_not_iterable) ... ok
test_named_property_setter (test_collections.TestCollectionInterface.test_named_property_setter) ... ok
test_earlybind (test_comserver.PropPutRefTest.test_earlybind) ... D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\comtypes\comtypes\_post_coinit\misc.py:[13](https://github.com/junkmd/comtypes-python-dev-compatibility/actions/runs/12256226171/job/34191022894#step:6:14)7: DeprecationWarning: GetClassObject from _ctypes is deprecated and will be removed in Python 3.19
  _ole32.CoCreateInstance(byref(clsid), punkouter, clsctx, byref(iid), byref(p))
D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\comtypes\comtypes\_post_coinit\misc.py:137: DeprecationWarning: 'ctypes.DllGetClassObject' is deprecated and slated for removal in Python 3.[19](https://github.com/junkmd/comtypes-python-dev-compatibility/actions/runs/12256226171/job/34191022894#step:6:20)
  _ole32.CoCreateInstance(byref(clsid), punkouter, clsctx, byref(iid), byref(p))
ok
test_latebind (test_comserver.PropPutRefTest.test_latebind) ... ok
test_UDT (test_comserver.SafeArrayTest.test_UDT) ... ok
test (test_comserver.TestEvents.test) ... ok
test_SetName (test_comserver.TestInproc.test_SetName) ... ok
test_eval (test_comserver.TestInproc.test_eval) ... ok
test_get_id (test_comserver.TestInproc.test_get_id) ... ok
test_get_name (test_comserver.TestInproc.test_get_name) ... ok
test_get_typeinfo (test_comserver.TestInproc.test_get_typeinfo) ... ok
test_getname (test_comserver.TestInproc.test_getname) ... ok
test_mixedinout (test_comserver.TestInproc.test_mixedinout) ... ok
test_set_name (test_comserver.TestInproc.test_set_name) ... ok
test_SetName (test_comserver.TestInproc_win32com.test_SetName) ... skipped "This depends on 'pywin32'."
test_eval (test_comserver.TestInproc_win32com.test_eval) ... skipped "This depends on 'pywin32'."
test_get_id (test_comserver.TestInproc_win32com.test_get_id) ... skipped "This depends on 'pywin32'."
test_get_name (test_comserver.TestInproc_win32com.test_get_name) ... skipped "This depends on 'pywin32'."
test_get_typeinfo (test_comserver.TestInproc_win32com.test_get_typeinfo) ... skipped "This depends on 'pywin32'."
test_getname (test_comserver.TestInproc_win32com.test_getname) ... skipped "This depends on 'pywin32'."
test_mixedinout (test_comserver.TestInproc_win32com.test_mixedinout) ... skipped "This depends on 'pywin32'."
test_set_name (test_comserver.TestInproc_win32com.test_set_name) ... skipped "This depends on 'pywin32'."
test_SetName (test_comserver.TestLocalServer.test_SetName) ... ok
test_eval (test_comserver.TestLocalServer.test_eval) ... ok
test_get_id (test_comserver.TestLocalServer.test_get_id) ... ok
test_get_name (test_comserver.TestLocalServer.test_get_name) ... ok
test_get_typeinfo (test_comserver.TestLocalServer.test_get_typeinfo) ... skipped 'This fails. Why?'
test_getname (test_comserver.TestLocalServer.test_getname) ... ok
test_mixedinout (test_comserver.TestLocalServer.test_mixedinout) ... ok
test_set_name (test_comserver.TestLocalServer.test_set_name) ... ok
test_SetName (test_comserver.TestLocalServer_win32com.test_SetName) ... skipped "This depends on 'pywin32'."
test_eval (test_comserver.TestLocalServer_win32com.test_eval) ... skipped "This depends on 'pywin32'."
test_get_id (test_comserver.TestLocalServer_win32com.test_get_id) ... skipped "This depends on 'pywin32'."
test_get_name (test_comserver.TestLocalServer_win32com.test_get_name) ... skipped "This depends on 'pywin32'."
test_get_typeinfo (test_comserver.TestLocalServer_win32com.test_get_typeinfo) ... skipped "This depends on 'pywin32'."
test_getname (test_comserver.TestLocalServer_win32com.test_getname) ... skipped "This depends on 'pywin32'."
test_mixedinout (test_comserver.TestLocalServer_win32com.test_mixedinout) ... skipped "This depends on 'pywin32'."
test_set_name (test_comserver.TestLocalServer_win32com.test_set_name) ... skipped "This depends on 'pywin32'."
test_UDT (test_comserver.VariantTest.test_UDT) ... ok
setUpModule (test_createwrappers) ... skipped 'I have no idea what to do with this.  It programmatically creates *thousands* of tests and a few dozen of them fail.'
test_dict (test_dict.Test.test_dict) ... ok
test_in_record (test_dispifc_records.Test_DispMethods.test_in_record) ... skipped 'This depends on the out of process COM-server.'
test_inout_byref (test_dispifc_records.Test_DispMethods.test_inout_byref) ... skipped 'This depends on the out of process COM-server.'
test_inout_pointer (test_dispifc_records.Test_DispMethods.test_inout_pointer) ... skipped 'This depends on the out of process COM-server.'
test_in_safearray (test_dispifc_safearrays.Test_DispMethods.test_in_safearray) ... skipped 'This depends on the out of process COM-server.'
test_inout_byref (test_dispifc_safearrays.Test_DispMethods.test_inout_byref) ... skipped 'This depends on the out of process COM-server.'
test_inout_pointer (test_dispifc_safearrays.Test_DispMethods.test_inout_pointer) ... skipped 'This depends on the out of process COM-server.'
test_comtypes (test_dispinterface.Test_comtypes.test_comtypes) ... ok
test_withjscript (test_dispinterface.Test_jscript.test_withjscript) ... skipped "This raises 'ClassFactory cannot supply requested class'. Why?"
test_win32com_dynamic_dispatch (test_dispinterface.Test_win32com.test_win32com_dynamic_dispatch) ... skipped "This depends on 'pywin32'."
test_win32com_ensure_dispatch (test_dispinterface.Test_win32com.test_win32com_ensure_dispatch) ... skipped "This depends on 'pywin32'."
test_index_setter (test_dyndispatch.Test.test_index_setter) ... ok
test_named_property_no_length (test_dyndispatch.Test.test_named_property_no_length) ... ok
test_named_property_not_iterable (test_dyndispatch.Test.test_named_property_not_iterable) ... ok
test_named_property_setter (test_dyndispatch.Test.test_named_property_setter) ... ok
test_query_interface (test_dyndispatch.Test.test_query_interface) ... ok
test_reference_passing (test_dyndispatch.Test.test_reference_passing) ... ok
test_type (test_dyndispatch.Test.test_type) ... ok
test (test_excel.Test_EarlyBind.test) ... skipped 'This depends on Excel.'
test (test_excel.Test_LateBind.test) ... skipped 'This depends on Excel.'
test_frozen_console_exe (test_findgendir.Test.test_frozen_console_exe) ... ok
test_frozen_dll (test_findgendir.Test.test_frozen_dll) ... ok
test_frozen_windows_exe (test_findgendir.Test.test_frozen_windows_exe) ... ok
test_script (test_findgendir.Test.test_script) ... ok
test (test_getactiveobj.Test.test) ... skipped 'This depends on Word.'
setUpModule (test_ie) ... skipped 'External test dependencies like this seem bad.  Find a different built-in win32 API to use.'
test_ienum (test_ienum.Test_IEnum.test_ienum) ... ok
test_imfattributes (test_imfattributes.Test_IMFAttributes.test_imfattributes) ... ok
test_keywords_only (test_inout_args.Test_ArgsKwargsCombinations.test_keywords_only) ... ok
test_mixed_args_1 (test_inout_args.Test_ArgsKwargsCombinations.test_mixed_args_1) ... ok
test_mixed_args_2 (test_inout_args.Test_ArgsKwargsCombinations.test_mixed_args_2) ... ok
test_omitted_arguments_autogen (test_inout_args.Test_ArgsKwargsCombinations.test_omitted_arguments_autogen) ... ok
test_positionals_only (test_inout_args.Test_ArgsKwargsCombinations.test_positionals_only) ... ok
test_permutations (test_inout_args.Test_ArgspecPermutations.test_permutations) ... ok
test_missing_name_omitted (test_inout_args.Test_Error.test_missing_name_omitted) ... ok
test_IMFAttributes (test_inout_args.Test_RealWorldExamples.test_IMFAttributes) ... ok
test_IMoniker (test_inout_args.Test_RealWorldExamples.test_IMoniker) ... ok
test_IPin (test_inout_args.Test_RealWorldExamples.test_IPin) ... ok
test_IUrlHistoryStg (test_inout_args.Test_RealWorldExamples.test_IUrlHistoryStg) ... ok
test_HRESULT (test_midl_safearray_create.Test_midlSAFEARRAY_create.test_HRESULT) ... ok
test_ctype (test_midl_safearray_create.Test_midlSAFEARRAY_create.test_ctype) ... ok
test_idisp (test_midl_safearray_create.Test_midlSAFEARRAY_create.test_idisp) ... skipped 'This depends on the out of process COM-server.'
test_iunk (test_midl_safearray_create.Test_midlSAFEARRAY_create.test_iunk) ... ok
test_record (test_midl_safearray_create.Test_midlSAFEARRAY_create.test_record) ... skipped 'This depends on the out of process COM-server.'
test_EnumObjectParam (test_monikers.Test_IBindCtx.test_EnumObjectParam) ... ok
test_IsSystemMoniker (test_monikers.Test_IMoniker.test_IsSystemMoniker) ... ok
test_register_and_revoke_item_moniker (test_monikers.Test_IRunningObjectTable.test_register_and_revoke_item_moniker) ... ok
setUpModule (test_npsupport) ... skipped 'Skipping test_npsupport as numpy not installed.'
test_c_char (test_outparam.Test.test_c_char) ... skipped "This fails for reasons I don't understand yet"
test_GetClassID (test_persist.Test_IPersist.test_GetClassID) ... ok
test_load (test_persist.Test_IPersistFile.test_load) ... ok
test_save (test_persist.Test_IPersistFile.test_save) ... ok
test_pump_events_doesnt_leak_cycles (test_pump_events.PumpEventsTest.test_pump_events_doesnt_leak_cycles) ... ok
test_GetGuid (test_recordinfo.Test_IRecordInfo.test_GetGuid) ... skipped 'This depends on the out of process COM-server.'
test_GetName (test_recordinfo.Test_IRecordInfo.test_GetName) ... skipped 'This depends on the out of process COM-server.'
test_GetSize (test_recordinfo.Test_IRecordInfo.test_GetSize) ... skipped 'This depends on the out of process COM-server.'
test_GetTypeInfo (test_recordinfo.Test_IRecordInfo.test_GetTypeInfo) ... skipped 'This depends on the out of process COM-server.'
test_IsMatchingType (test_recordinfo.Test_IRecordInfo.test_IsMatchingType) ... skipped 'This depends on the out of process COM-server.'
test_RecordCopy (test_recordinfo.Test_IRecordInfo.test_RecordCopy) ... skipped 'This depends on the out of process COM-server.'
test_RecordCreateCopy (test_recordinfo.Test_IRecordInfo.test_RecordCreateCopy) ... skipped 'This depends on the out of process COM-server.'
test_VT_BOOL (test_safearray.SafeArrayTestCase.test_VT_BOOL) ... ok
test_VT_BSTR (test_safearray.SafeArrayTestCase.test_VT_BSTR) ... ok
test_VT_BSTR_leaks (test_safearray.SafeArrayTestCase.test_VT_BSTR_leaks) ... skipped 'This fails with a memory leak.  Figure out if false positive.'
test_VT_I4 (test_safearray.SafeArrayTestCase.test_VT_I4) ... ok
test_VT_I4_leaks (test_safearray.SafeArrayTestCase.test_VT_I4_leaks) ... skipped 'This fails with a memory leak.  Figure out if false positive.'
test_VT_UNKNOWN_1 (test_safearray.SafeArrayTestCase.test_VT_UNKNOWN_1) ... ok
test_VT_UNKNOWN_multi (test_safearray.SafeArrayTestCase.test_VT_UNKNOWN_multi) ... ok
test_VT_VARIANT (test_safearray.SafeArrayTestCase.test_VT_VARIANT) ... ok
test_equality (test_safearray.SafeArrayTestCase.test_equality) ... ok
test_2dim_array (test_safearray.VariantTestCase.test_2dim_array) ... ok
test_VARIANT_array (test_safearray.VariantTestCase.test_VARIANT_array) ... skipped 'This fails with a memory leak.  Figure out if false positive.'
test_double_array (test_safearray.VariantTestCase.test_double_array) ... skipped 'This fails with a memory leak.  Figure out if false positive.'
test_float_array (test_safearray.VariantTestCase.test_float_array) ... ok
test (test_sapi.Test.test) ... ok
test_dyndisp (test_sapi.Test.test_dyndisp) ... ok
test_server (unittest.loader.ModuleSkipped.test_server) ... skipped 'This test module cannot run as-is.  Investigate why'
test_set_and_get_arguments (test_shelllink.Test_IShellLinkA.test_set_and_get_arguments) ... ok
test_set_and_get_hotkey (test_shelllink.Test_IShellLinkA.test_set_and_get_hotkey) ... ok
test_set_and_get_icon_location (test_shelllink.Test_IShellLinkA.test_set_and_get_icon_location) ... ok
test_set_and_get_path (test_shelllink.Test_IShellLinkA.test_set_and_get_path) ... ok
test_set_and_get_showcmd (test_shelllink.Test_IShellLinkA.test_set_and_get_showcmd) ... ok
test_set_and_get_working_directory (test_shelllink.Test_IShellLinkA.test_set_and_get_working_directory) ... ok
test_set_and_get_arguments (test_shelllink.Test_IShellLinkW.test_set_and_get_arguments) ... ok
test_set_and_get_description (test_shelllink.Test_IShellLinkW.test_set_and_get_description) ... ok
test_set_and_get_hotkey (test_shelllink.Test_IShellLinkW.test_set_and_get_hotkey) ... ok
test_set_and_get_icon_location (test_shelllink.Test_IShellLinkW.test_set_and_get_icon_location) ... ok
test_set_and_get_path (test_shelllink.Test_IShellLinkW.test_set_and_get_path) ... ok
test_set_and_get_showcmd (test_shelllink.Test_IShellLinkW.test_set_and_get_showcmd) ... ok
test_set_and_get_working_directory (test_shelllink.Test_IShellLinkW.test_set_and_get_working_directory) ... ok
StdFont_ShowEvents (comtypes.test.test_showevents.ShowEventsExamples)
Doctest: comtypes.test.test_showevents.ShowEventsExamples.StdFont_ShowEvents ... ok
test_CreateStorage (test_storage.Test_IStorage.test_CreateStorage) ... ok
test_CreateStream (test_storage.Test_IStorage.test_CreateStream) ... ok
test_DestroyElement (test_storage.Test_IStorage.test_DestroyElement) ... ok
test_MoveElementTo (test_storage.Test_IStorage.test_MoveElementTo) ... ok
test_OpenStorage (test_storage.Test_IStorage.test_OpenStorage) ... ok
test_RemoteCopyTo (test_storage.Test_IStorage.test_RemoteCopyTo) ... ok
test_RenameElement (test_storage.Test_IStorage.test_RenameElement) ... ok
test_Revert (test_storage.Test_IStorage.test_Revert) ... ok
test_Clone (test_stream.Test_Clone.test_Clone) ... ok
test_RemoteCopyTo (test_stream.Test_RemoteCopyTo.test_RemoteCopyTo) ... ok
test_RemoteRead (test_stream.Test_RemoteRead.test_RemoteRead) ... ok
test_takes_STREAM_SEEK_CUR_as_origin (test_stream.Test_RemoteSeek.test_takes_STREAM_SEEK_CUR_as_origin) ... ok
test_takes_STREAM_SEEK_END_as_origin (test_stream.Test_RemoteSeek.test_takes_STREAM_SEEK_END_as_origin) ... ok
test_takes_STREAM_SEEK_SET_as_origin (test_stream.Test_RemoteSeek.test_takes_STREAM_SEEK_SET_as_origin) ... ok
test_RemoteWrite (test_stream.Test_RemoteWrite.test_RemoteWrite) ... ok
test_SetSize (test_stream.Test_SetSize.test_SetSize) ... ok
test_subclass (test_subinterface.Test.test_subclass) ... ok
test_subinterface (test_subinterface.Test.test_subinterface) ... ok
test_com_interface (test_typeannotator.Test_AvoidUsingKeywords.test_com_interface) ... ok
test_disp_interface (test_typeannotator.Test_AvoidUsingKeywords.test_disp_interface) ... ok
test_LoadRegTypeLib (test_typeinfo.Test.test_LoadRegTypeLib) ... ok
test_LoadTypeLibEx (test_typeinfo.Test.test_LoadTypeLibEx) ... ok
test_QueryPathOfRegTypeLib (test_typeinfo.Test.test_QueryPathOfRegTypeLib) ... ok
test_TypeInfo (test_typeinfo.Test.test_TypeInfo) ... ok
test_creation (test_urlhistory.Test.test_creation) ... skipped "This fails with: `TypeError: iter() returned non-iterator of type 'POINTER(IEnumSTATURL)'`"
test_double (test_variant.ArrayTest.test_double) ... ok
test_int (test_variant.ArrayTest.test_int) ... ok
test_BSTR (test_variant.VariantTestCase.test_BSTR) ... skipped 'This test causes python(3?) to crash.'
test_byref (test_variant.VariantTestCase.test_byref) ... ok
test_com_pointers (test_variant.VariantTestCase.test_com_pointers) ... ok
test_com_refcounts (test_variant.VariantTestCase.test_com_refcounts) ... ok
test_constants (test_variant.VariantTestCase.test_constants) ... ok
test_ctypes_in_variant (test_variant.VariantTestCase.test_ctypes_in_variant) ... ok
test_datetime (test_variant.VariantTestCase.test_datetime) ... ok
test_decimal_as_currency (test_variant.VariantTestCase.test_decimal_as_currency) ... ok
test_decimal_as_decimal (test_variant.VariantTestCase.test_decimal_as_decimal) ... ok
test_dispparams (test_variant.VariantTestCase.test_dispparams) ... ok
test_empty_BSTR (test_variant.VariantTestCase.test_empty_BSTR) ... ok
test_integers (test_variant.VariantTestCase.test_integers) ... ok
test_null_com_pointers (test_variant.VariantTestCase.test_null_com_pointers) ... ok
test_pythonobjects (test_variant.VariantTestCase.test_pythonobjects) ... ok
test_repr (test_variant.VariantTestCase.test_repr) ... ok
test_1 (test_w_getopt.TestCase.test_1) ... ok
test_2 (test_w_getopt.TestCase.test_2) ... ok
test_3 (test_w_getopt.TestCase.test_3) ... ok
test_4 (test_w_getopt.TestCase.test_4) ... ok
setUpModule (test_win32com_interop) ... skipped 'This test requires the pythoncom library installed.  If this is important tests then we need to add dev dependencies to the project that include pythoncom.'
test_wmi (test_wmi.Test.test_wmi) ... ok
test (test_word.Test.test) ... skipped 'This depends on Word.'
test_commandbar (test_word.Test.test_commandbar) ... skipped 'This depends on Word.'
----------------------------------------------------------------------
Ran [24](https://github.com/junkmd/comtypes-python-dev-compatibility/actions/runs/12256226171/job/34191022894#step:6:25)9 tests in 48.434s
OK (skipped=58)

Notably, a DeprecationWarning is raised when calling ole32.CoCreateInstance.
I would like to discuss with everyone what this means and its implications.

@bennyrowland
Copy link

@junkmd great job on getting those tests to run. I was very surprised to see the deprecation warning come up, so I have been digging through the code once again and it appears that I must have missed a possible way to register an InprocServer using _ctypes.

When registering a Python COM object as Inproc comtypes needs to get the DLL from which the server can be loaded (here), and it turns out that if Py2Exe is not being used then it falls back on using _ctypes.pyd instead (here). So _ctypes.pyd can in fact be used as the primary DLL for a COM InprocServer, and it turns out that calling the C DLL functions will initialise a Python interpreter when called, if necessary, and basically fulfil the same function as the Py2Exe frozen DLL.

Sorry for causing confusion with my previous claim that InprocServers would only work with Py2Exe. This unfortunately does reraise the question of what to do with these ctypes functions. I suspect the best solution would be for comtypes to provide a DLL of its own that implements these two functions from _ctypes.pyd, because the object returned from DllGetClassObject() is (I think) always going to need to be a comtypes.COMObject instance and so it makes no sense for ctypes to provide code that has to import functions from comtypes. Once implemented, this would probably be the best solution, but it would add complexity to the comtypes build, as the package is currently pure Python, so having to compile even a very simple DLL would be a big change. @junkmd what do you think about this idea?

@junkmd
Copy link
Contributor

junkmd commented Dec 11, 2024

@bennyrowland

Thank you for your investigation.

Sorry for causing confusion with my previous claim that InprocServers would only work with Py2Exe.

Don't worry.
We are discussing features that are among the most mature functionalities in Windows, some of the oldest APIs in ctypes, long-untested features in comtypes, and ones that we are no longer even sure work with py2exe.
Given this state, it's understandable that anyone could become confused or misinterpret things.
I appreciate your participation in this discussion.

Once implemented, this would probably be the best solution, but it would add complexity to the comtypes build, as the package is currently pure Python, so having to compile even a very simple DLL would be a big change.

As you mentioned, including a DLL in the build of comtypes would be a significant change.
One of the key features and differentiators of comtypes is that it is Pure Python, so I would like to maintain that concept.

As an alternative, based on my earlier suggestion and encukou's advice, I currently think it would be better to implement a system where each project can optionally set hooks.

if _os.name == "nt": # COM stuff
    __dll_get_class_object_hooks__ = []  # or set()?

    def DllGetClassObject(rclsid, riid, ppv):
        if not __dll_get_class_object_hooks__:
            return -2147221231  # CLASS_E_CLASSNOTAVAILABLE
        results = {f(rclsid, riid, ppv) in f for __dll_get_class_object_hooks__}
        if 0 in results:
            return 0  # S_OK
        if len(results) == 1:
            return tuple(results)[0]
        return -2147467259  # E_FAIL

For example, a package like comtypes would register its hooks in the following manner.

import ctypes

def dll_get_class_object_hook(rclsid: int, riid: int, ppv: int) -> int:
    """Returns S_OK, E_FAIL, or CLASS_E_CLASSNOTAVAILABLE.
    See also https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-dllgetclassobject
    """
    ...

if sys.version_info >= (3, x):
    ctypes.__dll_get_class_object_hooks__.append(dll_get_class_object_hook)

Of course, this implementation is merely an example, and I have no objections if a better implementation or operation is found that removes unnecessary dependencies from the standard library and preserving the concepts of third-party projects.

Also, I do NOT strongly desire to change the implementation of ctypes as described above right away.
I would like to wait for developers actively using this feature to notice the DeprecationWarning (Is FutureWarning more appropriate?), find this issue, and share their actual use cases by participating in this discussion.

I appreciate everyone participating in this discussion.

@encukou
Copy link
Member Author

encukou commented Dec 11, 2024

I'd be fine with ctypes providing a “trampoline” C functions that call user-defined callbacks, so that provided that:

  • it's possible to create a pure-Python COM server that works with current ctypes
  • there's a finite number of the necessary functions, so we don't get requests for new ones in every release

But what's not clear to me is if, with current ctypes, comtypes (or any other library) needs to build its own DLL. Are DllRegisterServer and DllUnregisterServer also needed (as in AvmcIfc.cpp), or are they optional?

If building a DLL is necessary, I think it's better to remove this functionality from ctypes.
I'd be fine with delaying the deprecation, though.

If building a DLL is not necessary for the third-party library to provide a useful COM server, let's

  • add optional functions like DllRegisterServer to ctypes as well, if there's a small well-defined set of them. (Are there others? Is this it?)
  • add a registration mechanism
  • provide a supported API to call the DLL functions
  • add tests to CPython (and I'd ask you to write these, like with the recent COM-related tests)

@junkmd
Copy link
Contributor

junkmd commented Dec 11, 2024

If APIs are needed to implement, the COM specification has remained unchanged for decades since its inception, so once the *Dll* APIs are defined, there should be no additions beyond that.

I plan to spend my free time this weekend writing tests to implement, register, and unregister a COM server using ctypes.

@bennyrowland
Copy link

@encukou DllRegisterServer and DllUnregisterServer are standard Windows entry points when using the regsvr32.exe application to register a DLL in the registry, they are not used during the creation and use of the COM objects. comtypes provides its own mechanism for doing registrations for Python COM servers which I think is much more practical and convenient than passing _ctypes.pyd to regsvr32.exe, and it would also be impossible in that case to specify which server(s) you wished to register, there would have to be some kind of blanket registration of all discoverable servers, which would be complicated to define and set-up.

The code in comtypes/source (Avmclfc.cpp etc.) is a C++ COM server that is (or was) being used as a test object for comtypes COM client code to be tested against. I believe that DllGetClassObject and DllCanUnloadNow are the only two functions that are required for a COM server to be created and used (I think they are the only ones implemented in current _ctypes.pyd and Py2Exe's DLLs, and things do work like that).

@junkmd the current comtypes tests in test_comserver.py are exactly already registering, using, and unregistering the COM server implemented in TestComServer.py. They test both InprocServer and LocalServer options, and your tests posted above show that they work just fine with current ctypes.

I do not see how the hook mechanism can work though, because the current mechanism for creating an InprocServer looks like:

  1. The COM client creating the object calls ole32.CoCreateInstance() or similar to invoke the COM machinery
  2. The COM machinery looks up the clsid in the registry and finds out that it is an InprocServer and gets the path to the DLL (which in this case is _ctypes.pyd)
  3. It loads the DLL and calls DllGetClassObject()
  4. _ctypes.pyd calls Py_Initialize()
  5. _ctypes.pyd imports the ctypes Python version of the function (func = _PyImport_GetModuleAttrString("ctypes", "DllGetClassObject");) and calls it.
  6. The Python ctypes DllGetClassObject() imports the comtypes version and runs that.

So there is no point at which comtypes or any other module gets imported where it can insert itself into that train of events. It only works because importing comtypes is hard-coded into ctypes. I suppose that we could do something with entry points, but that would involve a lot of design work to create something that would almost certainly never be used except by comtypes (it is very hard to see anyone creating a competing library, given the enormous complexity of working with COM, and how well, by and large, comtypes already works).

@junkmd if you want to keep comtypes as a pure Python package (which I do understand) then perhaps a solution would be to create a separate package (e.g. comtypes-inproc-support which would just contain the DLL (and install it somewhere in the comtypes namespace in site-packages) and which could be listed as an "extra" dependency of comtypes so people could do pip install comtypes[inproc] (or similar) to get the extra DLL. Then when registering an InprocServer comtypes could check for the availability of the DLL and if it is not available then give an error message explaining the extra package is needed to register InprocServers. This package could surely use the limited API and wouldn't have any Python code of its own so should require very little maintenance. I would be happy to help out with creating such a package.

@junkmd
Copy link
Contributor

junkmd commented Dec 15, 2024

As I mentioned earlier, I attempted to write tests this weekend to implement, register, and unregister an in-process COM server using only ctypes during my free time.

First, I investigated the winreg function calls used by comtypes to register an in-process server. For details about what gets registered in the registry, please refer to the test I added to comtypes.

In my PoC repository, I defined a simple TestCtypesComServer.idl file containing only a single custom interface. I then built it into a .tlb file using the midl command and wrote a test to register and instantiate it.

results of executing the unit tests
EF
======================================================================
ERROR: test_1 (__main__.TestInprocServer.test_1)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\src\test.py", line 192, in test_1
    create_instance(CLSCTX_INPROC_SERVER)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\src\test.py", line 134, in create_instance
    ole32.CoCreateInstance(
    ~~~~~~~~~~~~~~~~~~~~~~^
        byref(CLSID_TestCtypesComServer),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
        byref(psvr),
        ^^^^^^^^^^^^
    )
    ^
  File "_ctypes/callproc.c", line 1041, in GetResult
OSError: [WinError -2147221231] ClassFactory cannot supply requested class

======================================================================
FAIL: test_2 (__main__.TestInprocServer.test_2)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\src\test.py", line 211, in test_2
    create_instance(CLSCTX_INPROC_SERVER)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\src\test.py", line 134, in create_instance
    ole32.CoCreateInstance(
    ~~~~~~~~~~~~~~~~~~~~~~^
        byref(CLSID_TestCtypesComServer),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
        byref(psvr),
        ^^^^^^^^^^^^
    )
    ^
  File "_ctypes/callproc.c", line 1041, in GetResult
OSError: [WinError -2147418113] Catastrophic failure

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "D:\a\comtypes-python-dev-compatibility\comtypes-python-dev-compatibility\src\test.py", line 213, in test_2
    self.fail(f"FAIL:\n{e}\n{pprint.pformat(call_args_list)}")
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: FAIL:
[WinError -2147418113] Catastrophic failure
[('{C0A45AA7-4423-4263-9492-4BD6E446823F}',
  '{00000001-0000-0000-C000-000000000046}',
  926066[58](https://github.com/junkmd/comtypes-python-dev-compatibility/actions/runs/12339073919/job/34434835640#step:7:59)8144)]

----------------------------------------------------------------------
Ran 2 tests in 0.017s

When comtypes is not installed, DllGetClassObject always returns CLASS_E_CLASSNOTAVAILABLE (-2147221231, 0x80040111), causing an OSError with the same error code to be raised (test_1).

Here, if I patch DllGetClassObject to always return S_OK, even when rclsid matches CLSID_TestCtypesComServer, E_UNEXPECTED (-2147418113, 0x8000FFFF) is raised. At this point, the riid that is passed in matches IID_IClassFactory (test_2).

I believe it is necessary to perform actions similar to what comtypes does, such as obtaining the Vtbl structure mapped to the iid for a COMObject and then calling IUnknown_QueryInterface as shown here.

If you have any ideas for an easier way to implement an in-process COM server, please feel free to let me know.
I will continue investigating the minimal requirements for registering _ctypes.__file__ as a DLL, but please give me some more time.

@junkmd
Copy link
Contributor

junkmd commented Dec 15, 2024

@bennyrowland

Thank you for your detailed explanation.
I understand the current mechanism and how hard it is to introduce any hook system cleanly.
I actually tried writing a minimal test and realized how challenging it is.

However, I want the functionality in comtypes that currently relies on ctypes to continue working in the future.
While it is true that the proposed hook mechanism might primarily be used by comtypes for now, I believe it is valuable to keep the door open for other packages to leverage it in the future.
There are already multiple COM client packages out there, and increasing the flexibility could encourage more entrants or use cases.

That said, I also understand the challenges of continuing to maintain this functionality.
Therefore, I think it’s worthwhile to simultaneously consider launching a project to provide such DLLs or helpers to implement them, as well as modifying comtypes so that server.register.RegistryEntries can optionally specify a custom DLL.

@encukou
Copy link
Member Author

encukou commented Dec 16, 2024

As far as I can see, one way for CPython to avoid hardcoding comtypes would be to look in the registry for the requested rclsid, and require comtypes (and others) to register Python classes in two places -- Windows itself (for _ctypes.pyd) and a Python-specific location (for the Python module).

However, that's a lot of new functionality. If we put that functionality in CPython, it would be hard to iterate on it -- the release schedule is slow, and if we find an issue, users wouldn't be able to easily update only the COM support part.
@bennyrowland's idea of a helper binary extension sounds much better.

@junkmd
Copy link
Contributor

junkmd commented Dec 16, 2024

Breaking the dependency on comtypes and providing a way for ctypes to implement an inproc-server in pure Python certainly requires a significant and complex effort.
I am inclined toward @bennyrowland's idea of a helper binary extension.
However, I believe that deprecating an API without first providing a recommended alternative would confuse users.

I have no objections to launching the comtypes-outproc-server-support project, which will provide the necessary DLL for implementing inproc servers, with the help of @bennyrowland, @encukou, and others participating in this issue.
I am also willing to allocate resources to modify comtypes to make it easier to use this DLL.

Once comtypes-outproc-server-support has been tested and confirmed to work in conjunction with comtypes, I will not oppose deprecating the hooks currently present in ctypes.
By ensuring that users encountering the DeprecationWarning receive clear messaging on how to migrate, we can avoid confusion.

It is worth noting that even if these hooks are removed from ctypes, there remains the option to implement a local server in pure Python, as demonstrated by @forderud.

I also think that if users have the permissions to install Python, comtypes, and register _ctypes.pyd in the registry to implement and register an inproc COM server, replacing _ctypes.pyd with a third-party DLL might not pose any issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants