Skip to content

Commit b400277

Browse files
authored
Improve type safety for dispmethods and enhance tests. (#885)
* test: Add test for pure dispatch WindowsInstaller object and dispmethod calls. Verifies the `WindowsInstaller.Installer` COM object's creation, its pure dispatch nature (non-dual), and its dispmethod calls. * refactor: Improve readability and conciseness. * test: Verify `RegistryValue` behavior without optional argument Adds a test for `Installer.RegistryValue`'s key existence check. Also renames a test method for clarity and updates comments. * test: Add safeguard test for named parameters in dispmethods. Adds a test to ensure a `ValueError` is raised when calling a dispmethod with named parameters, which is not yet supported. This acts as a safeguard against invalid calls. * test: Add test for `Installer.ProductState` property. Adds a test for the `Installer.ProductState` property to verify both correct value retrieval and error handling. * test: Correct typo and rename test method in `test_dict.py`. Corrected a typo in a comment from "assing" to "assign". Renamed the test method `test_dict` to `test_dynamic` to more accurately reflect that it tests the dynamic dispatch capabilities of `comtypes`. * test: Clarify comment for `HashVal` in `test_dict.py` Updated the comment for the `HashVal` property to clarify that it is a 'hidden' member used internally by `Scripting.Dictionary`, not intended for external use. * test: Refactor `test_dynamic` in `test_dict.py` to use `Scripting` constants. Replaced magic numbers with `comtypes.gen.Scripting` constants in `comtypes/test/test_dict.py`. This improves readability and maintainability by using named constants from the `scrrun.dll` type library, aligning with official documentation for `Scripting.Dictionary.CompareMode`. * test: Add dual interface tests for `Scripting.Dictionary`. * refactor: Improve code quality in `test_dict.py`. * test: Refactor `test_dynamic` and `test_static` in `test_dict.py`. * refactor: Enforce positional-only arguments for dispmethods. To improve type safety, this change marks all arguments in generated dispmethod stubs as positional-only. This allows static type checkers to catch invalid calls using named arguments, which are not supported. * test: Align `test_disp_interface` with actual annotator output. * fix: Avoid inappropriate signature for dispmethods with no arguments. Modified `DispMethodAnnotator` to prevent adding a positional-only parameter marker ('/') to method signatures that have no arguments. This fixes a bug where it would generate inappropriate type hints like `def method(self, /)`. An additional test case with a no-argument dispmethod (`egg`) was also added to `test_disp_interface` to verify the fix.
1 parent b823fc6 commit b400277

File tree

4 files changed

+180
-29
lines changed

4 files changed

+180
-29
lines changed

comtypes/test/test_dict.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
1-
"""Use Scripting.Dictionary to test the lazybind module."""
1+
"""Use Scripting.Dictionary to test the lazybind and the generated modules."""
22

33
import unittest
44

5+
from comtypes import typeinfo
56
from comtypes.automation import VARIANT
6-
from comtypes.client import CreateObject
7+
from comtypes.client import CreateObject, GetModule
78
from comtypes.client.lazybind import Dispatch
89

10+
GetModule("scrrun.dll")
11+
import comtypes.gen.Scripting as scrrun
12+
913

1014
class Test(unittest.TestCase):
11-
def test_dict(self):
15+
def test_dynamic(self):
1216
d = CreateObject("Scripting.Dictionary", dynamic=True)
1317
self.assertEqual(type(d), Dispatch)
1418

1519
# Count is a normal propget, no propput
1620
self.assertEqual(d.Count, 0)
1721
with self.assertRaises(AttributeError):
18-
setattr(d, "Count", -1)
22+
d.Count = -1
1923

2024
# HashVal is a 'named' propget, no propput
25+
# HashVal is a 'hidden' member and used internally.
2126
##d.HashVal
2227

2328
# Add(Key, Item) -> None
@@ -30,10 +35,11 @@ def test_dict(self):
3035

3136
# CompareMode: propget, propput
3237
# (Can only be set when dict is empty!)
33-
self.assertEqual(d.CompareMode, 0)
34-
d.CompareMode = 1
35-
self.assertEqual(d.CompareMode, 1)
36-
d.CompareMode = 0
38+
# Verify that the default is BinaryCompare.
39+
self.assertEqual(d.CompareMode, scrrun.BinaryCompare)
40+
d.CompareMode = scrrun.TextCompare
41+
self.assertEqual(d.CompareMode, scrrun.TextCompare)
42+
d.CompareMode = scrrun.BinaryCompare
3743

3844
# Exists(key) -> bool
3945
self.assertEqual(d.Exists(42), False)
@@ -66,32 +72,29 @@ def test_dict(self):
6672
# part 2, testing propput and propputref
6773

6874
s = CreateObject("Scripting.Dictionary", dynamic=True)
69-
s.CompareMode = 42
75+
s.CompareMode = scrrun.DatabaseCompare
7076

7177
# This calls propputref, since we assign an Object
7278
d.Item["object"] = s
73-
# This calls propput, since we assing a Value
79+
# This calls propput, since we assign a Value
7480
d.Item["value"] = s.CompareMode
7581

76-
a = d.Item["object"]
77-
7882
self.assertEqual(d.Item["object"], s)
79-
self.assertEqual(d.Item["object"].CompareMode, 42)
80-
self.assertEqual(d.Item["value"], 42)
83+
self.assertEqual(d.Item["object"].CompareMode, scrrun.DatabaseCompare)
84+
self.assertEqual(d.Item["value"], scrrun.DatabaseCompare)
8185

8286
# Changing a property of the object
83-
s.CompareMode = 5
87+
s.CompareMode = scrrun.BinaryCompare
8488
self.assertEqual(d.Item["object"], s)
85-
self.assertEqual(d.Item["object"].CompareMode, 5)
86-
self.assertEqual(d.Item["value"], 42)
89+
self.assertEqual(d.Item["object"].CompareMode, scrrun.BinaryCompare)
90+
self.assertEqual(d.Item["value"], scrrun.DatabaseCompare)
8791

8892
# This also calls propputref since we assign an Object
8993
d.Item["var"] = VARIANT(s)
9094
self.assertEqual(d.Item["var"], s)
9195

9296
# iter(d)
93-
keys = [x for x in d]
94-
self.assertEqual(d.Keys(), tuple([x for x in d]))
97+
self.assertEqual(d.Keys(), tuple(x for x in d))
9598

9699
# d[key] = value
97100
# d[key] -> value
@@ -100,6 +103,39 @@ def test_dict(self):
100103
# d(key) -> value
101104
self.assertEqual(d("blah"), "blarg")
102105

106+
def test_static(self):
107+
d = CreateObject(scrrun.Dictionary, interface=scrrun.IDictionary)
108+
# This confirms that the Dictionary is a dual interface.
109+
ti = d.GetTypeInfo(0)
110+
self.assertTrue(ti.GetTypeAttr().wTypeFlags & typeinfo.TYPEFLAG_FDUAL)
111+
# Count is a normal propget, no propput
112+
self.assertEqual(d.Count, 0)
113+
with self.assertRaises(AttributeError):
114+
d.Count = -1 # type: ignore
115+
# Dual interfaces call COM methods that support named arguments.
116+
d.Add("spam", "foo")
117+
d.Add("egg", Item="bar")
118+
self.assertEqual(d.Count, 2)
119+
d.Add(Key="ham", Item="baz")
120+
self.assertEqual(len(d), 3)
121+
d.Add(Item="qux", Key="toast")
122+
d.Item["beans"] = "quux"
123+
d["bacon"] = "corge"
124+
self.assertEqual(d("spam"), "foo")
125+
self.assertEqual(d.Item["egg"], "bar")
126+
self.assertEqual(d["ham"], "baz")
127+
self.assertEqual(d("toast"), "qux")
128+
self.assertEqual(d.Item("beans"), "quux")
129+
self.assertEqual(d("bacon"), "corge")
130+
# NOTE: Named parameters are not yet implemented for the named property.
131+
# See https://github.com/enthought/comtypes/issues/371
132+
# TODO: After named parameters are supported, this will become a test to
133+
# assert the return value.
134+
with self.assertRaises(TypeError):
135+
d.Item(Key="spam")
136+
with self.assertRaises(TypeError):
137+
d(Key="egg")
138+
103139

104140
if __name__ == "__main__":
105141
unittest.main()

comtypes/test/test_msi.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import unittest as ut
2+
import winreg
3+
4+
import comtypes.client
5+
from comtypes import GUID, typeinfo
6+
from comtypes.automation import IDispatch
7+
8+
MSI_TLIB = typeinfo.LoadTypeLibEx("msi.dll")
9+
comtypes.client.GetModule(MSI_TLIB)
10+
import comtypes.gen.WindowsInstaller as msi
11+
12+
HKCR = 0 # HKEY_CLASSES_ROOT
13+
HKCU = 1 # HKEY_CURRENT_USER
14+
15+
16+
class Test_Installer(ut.TestCase):
17+
def test_registry_value_with_root_key_value(self):
18+
# `WindowsInstaller.Installer` provides access to Windows configuration.
19+
inst = comtypes.client.CreateObject(
20+
"WindowsInstaller.Installer", interface=msi.Installer
21+
)
22+
# Both methods below get the "Programmatic Identifier" used to handle
23+
# ".txt" files.
24+
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ".txt") as key:
25+
progid, _ = winreg.QueryValueEx(key, "")
26+
# This confirms that the Installer can correctly read system information.
27+
self.assertEqual(progid, inst.RegistryValue(HKCR, ".txt", ""))
28+
29+
def test_registry_value_with_root_key(self):
30+
inst = comtypes.client.CreateObject(
31+
"WindowsInstaller.Installer", interface=msi.Installer
32+
)
33+
# If the third arg is missing, `Installer.RegistryValue` returns a Boolean
34+
# designating whether the key exists.
35+
# https://learn.microsoft.com/en-us/windows/win32/msi/installer-registryvalue
36+
# The `HKEY_CURRENT_USER\\Control Panel\\Desktop` registry key is a standard
37+
# registry key that exists across all versions of the Windows.
38+
self.assertTrue(inst.RegistryValue(HKCU, r"Control Panel\Desktop"))
39+
# Since a single backslash is reserved as a path separator and cannot be used
40+
# in a key name itself. Therefore, such a key exists in no version of Windows.
41+
self.assertFalse(inst.RegistryValue(HKCU, "\\"))
42+
43+
def test_registry_value_with_named_params(self):
44+
inst = comtypes.client.CreateObject(
45+
"WindowsInstaller.Installer", interface=msi.Installer
46+
)
47+
IID_Installer = msi.Installer._iid_
48+
# This confirms that the Installer is a pure dispatch interface.
49+
self.assertIsInstance(inst, IDispatch)
50+
ti = MSI_TLIB.GetTypeInfoOfGuid(IID_Installer)
51+
ta = ti.GetTypeAttr()
52+
self.assertEqual(IID_Installer, ta.guid)
53+
self.assertFalse(ta.wTypeFlags & typeinfo.TYPEFLAG_FDUAL)
54+
# NOTE: Named parameters are not yet implemented for the dispmethod called
55+
# via the `Invoke` method.
56+
# See https://github.com/enthought/comtypes/issues/371
57+
# As a safeguard until implementation is complete, an error will be raised
58+
# if named arguments are passed to prevent invalid calls.
59+
# TODO: After named parameters are supported, this will become a test to
60+
# assert the return value.
61+
ERRMSG = "named parameters not yet implemented"
62+
with self.assertRaises(ValueError, msg=ERRMSG):
63+
inst.RegistryValue(Root=HKCR, Key=".txt", Value="") # type: ignore
64+
with self.assertRaises(ValueError, msg=ERRMSG):
65+
inst.RegistryValue(Value="", Root=HKCR, Key=".txt") # type: ignore
66+
with self.assertRaises(ValueError, msg=ERRMSG):
67+
inst.RegistryValue(HKCR, Key=".txt", Value="") # type: ignore
68+
with self.assertRaises(ValueError, msg=ERRMSG):
69+
inst.RegistryValue(HKCR, ".txt", Value="") # type: ignore
70+
with self.assertRaises(ValueError, msg=ERRMSG):
71+
inst.RegistryValue(Root=HKCU, Key=r"Control Panel\Desktop") # type: ignore
72+
with self.assertRaises(ValueError, msg=ERRMSG):
73+
inst.RegistryValue(Key=r"Control Panel\Desktop", Root=HKCR) # type: ignore
74+
with self.assertRaises(ValueError, msg=ERRMSG):
75+
inst.RegistryValue(HKCR, Key=r"Control Panel\Desktop") # type: ignore
76+
77+
def test_product_state(self):
78+
inst = comtypes.client.CreateObject(
79+
"WindowsInstaller.Installer", interface=msi.Installer
80+
)
81+
# There is no product associated with the Null GUID.
82+
pdcode = str(GUID())
83+
expected = msi.MsiInstallState.msiInstallStateUnknown
84+
self.assertEqual(expected, inst.ProductState(pdcode))
85+
self.assertEqual(expected, inst.ProductState[pdcode])
86+
# The `ProductState` property is a read-only property.
87+
# https://learn.microsoft.com/en-us/windows/win32/msi/installer-productstate-property
88+
with self.assertRaises(TypeError):
89+
inst.ProductState[pdcode] = msi.MsiInstallState.msiInstallStateDefault # type: ignore
90+
# NOTE: Named parameters are not yet implemented for the named property.
91+
# See https://github.com/enthought/comtypes/issues/371
92+
# TODO: After named parameters are supported, this will become a test to
93+
# assert the return value.
94+
with self.assertRaises(TypeError):
95+
inst.ProductState(Product=pdcode) # type: ignore

comtypes/test/test_typeannotator.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,18 @@ def _create_typedesc_disp_interface(self) -> typedesc.DispInterface:
4848
put_def = typedesc.DispMethod(8, 4, "def", void_type, ["propput"], None)
4949
put_def.add_argument(VARIANT_type, "arg1", ["in", "optional"], None)
5050
put_def.add_argument(VARIANT_type, "arg2", ["in"], None)
51-
for m in [ham, bacon, get_spam, put_spam, except_, raise_, get_def, put_def]:
51+
egg = typedesc.DispMethod(643, 1, "egg", VARIANT_BOOL_type, [], None)
52+
for m in [
53+
ham,
54+
bacon,
55+
get_spam,
56+
put_spam,
57+
except_,
58+
raise_,
59+
get_def,
60+
put_def,
61+
egg,
62+
]:
5263
itf.add_member(m)
5364
return itf
5465

@@ -59,14 +70,15 @@ def test_disp_interface(self):
5970
" def ham(self) -> hints.Incomplete: ...\n"
6071
" pass # @property # dispprop\n"
6172
" pass # avoid using a keyword for def except(self) -> hints.Incomplete: ...\n" # noqa
62-
" def bacon(self, *args: hints.Any, **kwargs: hints.Any) -> hints.Incomplete: ...\n" # noqa
63-
" def _get_spam(self, arg1: hints.Incomplete = ...) -> hints.Incomplete: ...\n" # noqa
64-
" def _set_spam(self, arg1: hints.Incomplete = ..., **kwargs: hints.Any) -> hints.Incomplete: ...\n" # noqa
73+
" def bacon(self, *args: hints.Any, **kwargs: hints.Any, /) -> hints.Incomplete: ...\n" # noqa
74+
" def _get_spam(self, arg1: hints.Incomplete = ..., /) -> hints.Incomplete: ...\n" # noqa
75+
" def _set_spam(self, arg1: hints.Incomplete = ..., **kwargs: hints.Any, /) -> hints.Incomplete: ...\n" # noqa
6576
" spam = hints.named_property('spam', _get_spam, _set_spam)\n"
66-
" pass # avoid using a keyword for def raise(self, foo: hints.Incomplete, bar: hints.Incomplete = ...) -> hints.Incomplete: ...\n" # noqa
67-
" def _get_def(self, arg1: hints.Incomplete = ...) -> hints.Incomplete: ...\n" # noqa
68-
" def _set_def(self, arg1: hints.Incomplete = ..., **kwargs: hints.Any) -> hints.Incomplete: ...\n" # noqa
69-
" pass # avoid using a keyword for def = hints.named_property('def', _get_def, _set_def)" # noqa
77+
" pass # avoid using a keyword for def raise(self, foo: hints.Incomplete, bar: hints.Incomplete = ..., /) -> hints.Incomplete: ...\n" # noqa
78+
" def _get_def(self, arg1: hints.Incomplete = ..., /) -> hints.Incomplete: ...\n" # noqa
79+
" def _set_def(self, arg1: hints.Incomplete = ..., **kwargs: hints.Any, /) -> hints.Incomplete: ...\n" # noqa
80+
" pass # avoid using a keyword for def = hints.named_property('def', _get_def, _set_def)\n" # noqa
81+
" def egg(self) -> hints.Incomplete: ..." # noqa
7082
)
7183
self.assertEqual(
7284
expected, typeannotator.DispInterfaceMembersAnnotator(itf).generate()

comtypes/tools/codegenerator/typeannotator.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,16 @@ def getvalue(self, name: str) -> str:
293293
inargs.append(f"{argname}: hints.Incomplete = ...")
294294
has_optional = True
295295
out = _to_outtype(self.method.returns)
296-
in_ = ("self, " + ", ".join(inargs)) if inargs else "self"
297-
content = f"def {name}({in_}) -> {out}: ..."
296+
# NOTE: Since named parameters are not yet implemented, all arguments
297+
# for the dispmethod (called via `Invoke`) are marked as positional-only
298+
# parameters, introduced in PEP570. See also `automation.IDispatch.Invoke`.
299+
# See https://github.com/enthought/comtypes/issues/371
300+
# TODO: After named parameters are supported, the positional-only parameter
301+
# markers will be removed.
302+
if inargs:
303+
content = f"def {name}(self, {', '.join(inargs)}, /) -> {out}: ..."
304+
else:
305+
content = f"def {name}(self) -> {out}: ..."
298306
if keyword.iskeyword(name):
299307
content = f"pass # avoid using a keyword for {content}"
300308
return content

0 commit comments

Comments
 (0)