enthought / comtypes

A pure Python, lightweight COM client and server framework, based on the ctypes Python FFI package.
Other
290 stars 97 forks source link

Question about comtypes.client typing for Autocad automation #516

Closed sindzicat closed 7 months ago

sindzicat commented 7 months ago

Hello!

There is a code sample in comtypes docs:

import array
import comtypes.client

#Get running instance of the AutoCAD application
app = comtypes.client.GetActiveObject("AutoCAD.Application")

#Get the ModelSpace object
ms = app.ActiveDocument.ModelSpace

#Add a POINT in ModelSpace
pt = array.array('d', [0,0,0])
point = ms.AddPoint(pt)

It's possible to use Python typing module for the code above (especially for app and ms variables)? If so, could you give me example of such code, please?

junkmd commented 7 months ago

Hello.

I'm excited that there's growing interest in static typing for this project.

Certainly, it is possible to use the features of Python typing (or features committed to the current main branch and expected to be released as comtypes==1.3.1 soon) to do static typing with the objects handled by comtypes.

However, I don't have AutoCAD. In order to provide appropriate advice, I'd need some information (assuming no license or NDA problems):

  1. The contents of the .py files that would be generated in your environment under .../comtypes/gen.
    • Please upload these files to your public repository so that I can point to the related sections with a permalink.
  2. Information about what the generated objects.
    • Please share the results of print(app), print(ms), print(pt), and print(point) from your script.

Once we have these information, I might be able to do static typing with your script.

sindzicat commented 7 months ago

To be honest, I use ZWCad, not Autocad, but ZWCad interface, commands and even API are identical to those from Autocad.

I created a public gist with generated module.

Types of app, ms and point:

from array import array
import comtypes.client as cc
from pathlib import Path

zwcad_tlib_path: Path = Path('C:/Program Files/Common Files/ZWSoft Shared/zwcad21.tlb')

assert zwcad_tlib_path.exists()

ZWCAD = cc.GetModule(str(zwcad_tlib_path))

app = cc.GetActiveObject('ZWCAD.Application.2024')
app.Visible = True
print(f'{type(app) = }')  # type(app) = <class 'comtypes.POINTER(IZcadApplication)'>

ms = app.ActiveDocument.ModelSpace
print(f'{type(ms) = }')  # type(ms) = <class 'comtypes.POINTER(IZcadModelSpace)'>

pt = array('d', [0, 0, 0])
point = ms.AddPoint(pt)
print(f'{type(point) = }')  # type(point) = <class 'comtypes.POINTER(IZcadPoint)'>

# app.ActiveDocument.Close()
# app.Quit()
sindzicat commented 7 months ago

Surprisingly for me, gist is very slow. I created a new repo. Now generated module is here:

https://github.com/sindzicat/zwcad-comtypes/blob/main/_2F671C10_669F_11E7_91B7_BC5FF42AC839_0_1_0.py

junkmd commented 7 months ago

Thank you for sharing the information. This is an intriguing use case.

In comtypes==1.3.0, static typing in your script can be achieved by following something like the example below.

from array import array
import comtypes.client as cc
from pathlib import Path

zwcad_tlib_path: Path = Path('C:/Program Files/Common Files/ZWSoft Shared/zwcad21.tlb')

assert zwcad_tlib_path.exists()

- ZWCAD = cc.GetModule(str(zwcad_tlib_path))
+ cc.GetModule(str(zwcad_tlib_path))  # Generate the module or ensure its existence.
+ from comtypes.gen import ZWCAD  # importing statically to analyze static typing

- app = cc.GetActiveObject('ZWCAD.Application.2024')
+ # Type checkers can perform static type inference by specifying the interface.
+ # In this case, type checkers infer `app` as an `IZcadApplication` instance.
+ app = cc.GetActiveObject('ZWCAD.Application.2024', interface=ZWCAD.IZcadApplication)
app.Visible = True
print(f'{type(app) = }')  # type(app) = <class 'comtypes.POINTER(IZcadApplication)'>

- ms = app.ActiveDocument.ModelSpace
+ ms: ZWCAD.IZcadModelSpace = app.ActiveDocument.ModelSpace
print(f'{type(ms) = }')  # type(ms) = <class 'comtypes.POINTER(IZcadModelSpace)'>

pt = array('d', [0, 0, 0])
- point = ms.AddPoint(pt)
+ point: ZWCAD.IZcadPoint = ms.AddPoint(pt)
print(f'{type(point) = }')  # type(point) = <class 'comtypes.POINTER(IZcadPoint)'>

# app.ActiveDocument.Close()
# app.Quit()

In 1.3.0, type checkers raise errors for statements like app.Visible = True due to a lack of static type information for methods and properties in COM interfaces. In the current main branch, these errors will be prevented by typeannotator (See also #490 ).

Currently, typeannotator is in its early stages, and there are many places that Any will be annotated. So, we need to write many variable annotations.

You might be concerned about the runtime type (comtypes.POINTER(IZcadApplication)) differing from static type (IZcadApplication). This discrepancy arises because the current Python static typing system cannot express the behavior of the _cominterface_meta and _compointer_meta. At runtime, comtypes.POINTER(IZcadApplication) behaves as a subclass of IZcadApplication due to those metaclasses. To represent this in static typing, I believe that Intersection would be necessary.

If you are interested in these matter, your contributions to enhance the functionality of typeannotator are welcome. If you have any ideas to improve this project, please share them with the community.

junkmd commented 7 months ago

We have released comtypes==1.3.1. Members of the COM interfaces in the ZWCAD module also should now have type hints. I hope this will be helpful for you.

If your questions have been resolved, please close this issue. Alternatively, if you have any proposals for this project, we welcome your submissions.

sindzicat commented 7 months ago

@junkmd, many thanks for your help!

If you have any ideas to improve this project, please share them with the community.

I have only basis knowledges about type hints in Python, but saw this answer on StackOverflow about hinting for COM objects. I hope the following may be useful:

If you know for certain the returned type will always have some specific methods or attributes available, you have the return type be a Protocol. Protocols basically let you do structural subtyping: any class that happens to implement the protocol methods and attributes is considered a subtype of that protocol, even if the class isn't inheriting from the protocol in any way. Protocols are basically like Go's interfaces, if you're familiar with Go.

junkmd commented 7 months ago

Thank you!

The approach using the protocol outlined in the answer on Stack Overflow that you shared requires developers to be familiar with the COM interface they intend to use. I aim for comtypes to automatically create static interfaces that are friendly to Python, making it unnecessary for developers to in-depth knowledge about the COM interface.

It seems necessary to enhance parsers and code generators. Since the integration of this project with static types is still in its early stages, feel free to share any opinions or suggestions you may have.

sindzicat commented 7 months ago

Sorry, it seems, I was unclear. I meant to use Protocol in comtypes library itself in generated modules, not for comtypes users.

junkmd commented 7 months ago

Never mind!

I have considered including Protocols in generated modules.

There was a time when I thought overriding methods of executable COM interfaces to define statically typed methods. However, this approach could potentially lead to mutable default arguments and other unknown troubles at runtime. It seemed counterproductive for runtime behavior to become problematic for the sake of static typing.

Instead, I was attempting to auto-generate typestubs that define Protocols for structural subtyping from COM interfaces at the same time. However, since comtypes COM interfaces are nominal subtypings from ctypes classes, that manner is insufficient to replicate that behavior.

After that, I realized that static typehints for methods without overriding could be applied using if TYPE_CHECKING. I adopted this approach.

This approach might be frustrating for those who wish to perform metaprogramming based on the runtime behavior of type annotations. However, I aimed to reconcile the assurance of backward-compatible behavior with a new coding experience.

Your question allowed me to articulate my philosophy.

Python's static typing is intended for asynchronous communication among community members, team colleagues, future maintainers, or even future versions of oneself.

Moreover, I believe that information about "why such implementations?" should be preserved for the community.

You provided me with an opportunity to do just that.

Thank you.