Skip to content

fix: generate signed byte types for VT_I1 - #962

Draft
haoxuanjng-lang wants to merge 5 commits into
enthought:mainfrom
haoxuanjng-lang:fix/typelib-signed-int8
Draft

haoxuanjng-lang wants to merge 5 commits into
enthought:mainfrom
haoxuanjng-lang:fix/typelib-signed-int8

Conversation

@haoxuanjng-lang

@haoxuanjng-lang haoxuanjng-lang commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Generating VT_I1 as c_char changes native signed values such as -7 to 249 in tuples and produces byte-string arrays or reshape errors with NumPy. Propose signed char / c_byte for VT_I1, leaving explicit VT_LPSTR as a narrow string. Follow-up to #956 and the discussion in #935; this does not reproduce or resolve #935's intermittent UIA exception.

Draft: plain character-pointer compatibility regressions remain unresolved. Old bytes input and writable character-buffer output work with the old mapping and raise ArgumentError with the proposed mapping. The two assertions remain explicit expected failures.

The separate native CoByteCompatibilityTest fixture exercises scalar echoes, explicit LPSTR, and plain character pointers through generated interfaces and real out-of-process COM calls. Tests now separate:

  • ByteCompatibilityTest: signed/unsigned scalar echoes and explicit LPSTR.
  • LegacyMappingCompatibilityTest: the two old calls that regress.
  • ProposedMappingCompatibilityTest: separate pointer, byref and array methods for numeric inputs and raw output buffers, plus automatic signed output.

Expected values are literal input/result pairs; no datatype-dependent branches or calculated signed expectations remain. The output fixture writes one character into caller-owned storage and does not assume a native NUL terminator. The migration page explains affected generated versus saved/handwritten interfaces, arguments, signed outputs and explicit strings. Independent 1-D/2-D numeric SAFEARRAY value/shape/dtype/copy checks remain.

Merged current main 91408c25247bec90b09db0bbc7717f47438cb06f, resolving the Makefile conflict and preserving both upstream string-SAFEARRAY-in-VARIANT and VT_ERROR native components. The original published fixture interfaces are unchanged.

Observed local validation on Windows x64, Python 3.13.5, NumPy 2.5.3, pywin32 312, MSVC 19.42:

  • MIDL/C++ rebuild succeeds with nmake /f MAKEFILE; each stage regenerates fixture wrappers, uses temporary HKCU registrations and verifies cleanup.
  • Latest-main production mapping with the new fixture: both legacy calls pass, proposed numeric pointer/output group has 29 subcase errors across 7 tests, and explicit LPSTR/unsigned tables pass.
  • Proposed mapping focused suites: 76 run / 65 passed / 9 skipped / 2 expected compatibility failures.
  • Full python -m unittest discover -v -s comtypes/test -t comtypes/test: 482 run / 456 passed / 24 skipped / 2 expected compatibility failures, including upstream VT_ERROR tests.
  • Ruff 0.6.9 full CI lint/format scope passes in a clean worktree, 151 files. Final PR files match that copy and git diff --check origin/main passes.

Earlier revision only: related suites without NumPy/pywin32 passed 51 run / 41 passed / 8 skipped / 2 expected failures; strict Sphinx HTML passed, while full docs doctest stopped in optional Excel setup. Those checks were not rerun for this test restructuring; documentation and production mapping were unchanged.

CI for this new revision is pending. The prior head's 76 successful checks do not validate this head. The PR remains draft pending the compatibility strategy.

AI assistance: OpenAI Codex helped investigate, implement and validate this contribution.

Copilot AI balanced review requested due to automatic review settings October 9, 2026 06:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov-commenter

codecov-commenter commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.29630% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.38%. Comparing base (91408c2) to head (05003af).

Files with missing lines Patch % Lines
comtypes/test/test_cpp_byte_compatibility.py 96.25% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #962      +/-   ##
==========================================
+ Coverage   89.34%   89.38%   +0.03%     
==========================================
  Files         142      143       +1     
  Lines       13856    13931      +75     
==========================================
+ Hits        12380    12452      +72     
- Misses       1476     1479       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@junkmd junkmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your continued contributions!

This is clearly an inconsistency, and it is normally not the correct behavior for software. However, I could not find any earlier bug reports about this specific behavior. This makes me wonder whether users have come to expect the current behavior over time, or whether no one has ever reached this code path.

So, let's move forward step by step, checking both the impact of this change and backward compatibility.

As before, let's start with the test structure.

Comment thread comtypes/test/test_tlbparser.py Outdated
Comment thread comtypes/tools/tlbparser.py
@haoxuanjng-lang
haoxuanjng-lang marked this pull request as draft October 10, 2026 10:01

@junkmd junkmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding the native COM test.
It shows that signed 8-bit integers work correctly across the COM boundary in Echo....
It also confirms that explicit LPSTR still uses the existing string handling.

These tests are useful for checking number marshaling and making sure existing string handling still works. However, I noticed that these are distinct concerns.
We also need to check whether existing Python calls remain compatible with or break the new mapping.

Checking Backward Compatibility

To better understand the change, I wrote a parameterized test that passes different types of 1-byte data as arguments. I compared the results before and after the change in tlbparser.

For this test, I expected the return value to stay the same, even when the argument type changed.

def test_backward_compatibility(self):
    for i, value in enumerate(
        [
            b"h",
            pointer(c_byte(ord("h"))),
            byref(c_byte(ord("h"))),
            (c_byte * 1)(ord("h")),
        ]
    ):
        with self.subTest(index=i, value=value):
            self.assertEqual(self.server.ReadCharPointer(value), ord("h"))

The results differ before and after the change.

Before the change, b"h" works, but the other three arguments raise ctypes.ArgumentError.

test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) ... 
  test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=1, value=<ctypes.LP_c_byte object at 0x0000025878B3F250>) ... ERROR
  test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=2, value=<cparam 'P' (0x0000025878F938D8)>) ... ERROR
  test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=3, value=<comtypes.test.test_cpp_byte_echo.c_byte_Array_1 object at 0x0000025878F93A20>) ... ERROR

======================================================================
ERROR: test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=1, value=<ctypes.LP_c_byte object at 0x0000025878B3F250>)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path\to\comtypes\test\test_cpp_byte_echo.py", line 65, in test_backward_compatibility
    self.assertEqual(self.server.ReadCharPointer(value), ord("h"))
                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
ctypes.ArgumentError: argument 1: TypeError: 'LP_c_byte' object cannot be interpreted as ctypes.c_char_p

======================================================================
ERROR: test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=2, value=<cparam 'P' (0x0000025878F938D8)>)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path\to\comtypes\test\test_cpp_byte_echo.py", line 65, in test_backward_compatibility
    self.assertEqual(self.server.ReadCharPointer(value), ord("h"))
                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
ctypes.ArgumentError: argument 1: TypeError: '_ctypes.CArgObject' object cannot be interpreted as ctypes.c_char_p

======================================================================
ERROR: test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=3, value=<comtypes.test.test_cpp_byte_echo.c_byte_Array_1 object at 0x0000025878F93A20>)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path\to\comtypes\test\test_cpp_byte_echo.py", line 65, in test_backward_compatibility
    self.assertEqual(self.server.ReadCharPointer(value), ord("h"))
                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
ctypes.ArgumentError: argument 1: TypeError: 'c_byte_Array_1' object cannot be interpreted as ctypes.c_char_p

----------------------------------------------------------------------
Ran 1 test in 0.027s

FAILED (errors=3)
Generated Code (Only `IByteEchoTest`)
class IByteEchoTest(comtypes.gen._00020430_0000_0000_C000_000000000046_0_2_0.IUnknown):
    _case_insensitive_ = True
    _iid_ = GUID('{047E34C1-33A8-4DAA-A1E5-B941756A8319}')
    _idlflags_ = ['oleautomation']

    if TYPE_CHECKING:  # commembers
        def EchoInt8(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def EchoUint8(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def EchoLpStr(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def ReadCharPointer(self, value: hints.Incomplete) -> hints.Incomplete: ...


IByteEchoTest._methods_ = [
    COMMETHOD(
        [],
        HRESULT,
        'EchoInt8',
        (['in'], c_char, 'value'),
        (['out', 'retval'], STRING, 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'EchoUint8',
        (['in'], c_ubyte, 'value'),
        (['out', 'retval'], POINTER(c_ubyte), 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'EchoLpStr',
        (['in'], STRING, 'value'),
        (['out', 'retval'], POINTER(STRING), 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'ReadCharPointer',
        (['in'], STRING, 'value'),
        (['out', 'retval'], POINTER(c_ubyte), 'result')
    ),
]

↓

After the change, the other three arguments work, but b"h" raises ctypes.ArgumentError.

test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) ... 
  test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=0, value=b'h') ... ERROR

======================================================================
ERROR: test_backward_compatibility (comtypes.test.test_cpp_byte_echo.ByteEchoTest.test_backward_compatibility) (index=0, value=b'h')
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path\to\comtypes\test\test_cpp_byte_echo.py", line 65, in test_backward_compatibility
    self.assertEqual(self.server.ReadCharPointer(value), ord("h"))
                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
ctypes.ArgumentError: argument 1: TypeError: expected LP_c_byte instance instead of bytes

----------------------------------------------------------------------
Ran 1 test in 0.025s

FAILED (errors=1)
Generated Code (Only `IByteEchoTest`)
class IByteEchoTest(comtypes.gen._00020430_0000_0000_C000_000000000046_0_2_0.IUnknown):
    _case_insensitive_ = True
    _iid_ = GUID('{047E34C1-33A8-4DAA-A1E5-B941756A8319}')
    _idlflags_ = ['oleautomation']

    if TYPE_CHECKING:  # commembers
        def EchoInt8(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def EchoUint8(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def EchoLpStr(self, value: hints.Incomplete) -> hints.Incomplete: ...
        def ReadCharPointer(self, value: hints.Incomplete) -> hints.Incomplete: ...


IByteEchoTest._methods_ = [
    COMMETHOD(
        [],
        HRESULT,
        'EchoInt8',
        (['in'], c_byte, 'value'),
        (['out', 'retval'], POINTER(c_byte), 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'EchoUint8',
        (['in'], c_ubyte, 'value'),
        (['out', 'retval'], POINTER(c_ubyte), 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'EchoLpStr',
        (['in'], STRING, 'value'),
        (['out', 'retval'], POINTER(STRING), 'result')
    ),
    COMMETHOD(
        [],
        HRESULT,
        'ReadCharPointer',
        (['in'], POINTER(c_byte), 'value'),
        (['out', 'retval'], POINTER(c_ubyte), 'result')
    ),
]

The return value stays the same for the calls that succeed.

These results show that the new mapping allows calls that match the new type, but breaks some calls that worked with the old type. In other words, the accepted argument types change, even though the data still represents the same 1-byte value.

Checking the Other Direction

I think we should also test the opposite case of ReadCharPointer.

For example, we could add a method like GetCharacterBytes. It would take an integer such as ord("h") and return a Python bytes object such as b"h".

We should check what happens to existing calls after the mapping changes. Does the call raise ctypes.ArgumentError, or does it succeed but return a different value, causing the assertion to fail?

We should then test different combinations of argument and return types to find out which combinations pass. For this investigation, we can set aside whether each combination meets the original requirements.

By testing both directions, we can better understand how the mapping change affects arguments and return values, and what changes users may need to make to their Python code.

Docs for Compatibility

Some users define their interfaces in Python by inheriting from IUnknown or IDispatch. Others keep the generated module source code in their own repositories. These users may be able to fix problems by changing the type definitions themselves.

However, users who use modules generated from type libraries without changing them cannot easily fix the generated types. They need to know why the types have changed, which existing calls are affected, and how to update their code.

If we move forward with this change, I think we should explain its impact and how to migrate in a migration guide or the release notes.

In particular, users who use generated modules as they are need enough information to decide what changes to make. We should explain:

  • Which VT_I1 arguments and return values will have different generated types.
  • Which existing calls will stop working.
  • Which values or ctypes types users should pass after the change.
  • How explicitly declared string types, such as LPSTR, are handled.

Alternative Approach

Another option is to choose the type based on more detailed information instead of looking it up in the COMTYPES mapping by VT_* alone. If we consider this approach, we may need a different test double to test it.

I think these tests will help us understand the compatibility impact before deciding how to proceed.

Comment thread source/CppTestSrv/CoByteCompatibilityTest.h
@junkmd

junkmd commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

@haoxuanjng-lang

I am planning to add a component to the C++ test double to address another open issue in this project. This may cause conflicts with this PR, so please handle them if they occur.

@haoxuanjng-lang

Copy link
Copy Markdown
Contributor Author

Added the reverse-direction probe and expanded the real native-call matrix. The separate component/interface/test module are now named ByteCompatibilityTest, and I merged current main (939341c), resolving the three native fixture conflicts while retaining your new string-SAFEARRAY-in-VARIANT component.

The output probe writes one character through a plain char*, with caller-owned storage. I used this shape because a plain character pointer is not a NUL-terminated string contract; an allocated char** prototype could not safely assume a terminator through the universal marshaler. The final tests avoid that unbounded read.

Observed with freshly generated wrappers against latest-main production versus the proposed mapping:

Call Old mapping Proposed mapping
ReadCharPointer(b"h") 104 ArgumentError
Same call with pointer, byref, or array of c_byte(104) ArgumentError 104
Generated raw GetCharacterBytes(104, create_string_buffer(2)), then buffer .value b"h" ArgumentError
Same raw output call with numeric byte pointer/reference/array ArgumentError stored 104
High-level GetCharacterBytes(104) TypeError allocating implicit STRING output integer 104
High-level outputs for 128 / 255 same allocation TypeError integers -128 / -1

This establishes the opposite direction's accepted-buffer change independently of SAFEARRAYs. Explicit LPSTR and unsigned scalar tables still pass both versions. I retained the two legacy input/output assertions as expected failures and added documentation for affected generated types, saved/handwritten interfaces, replacement arguments, signed outputs and explicit string handling.

Full local unittest: 474 run / 448 passed / 24 skipped / 2 expected compatibility failures. Focused suites: 70 / 59 / 9 / 2; without NumPy/pywin32: 51 / 41 / 8 / 2. Native build, full Ruff CI scope and strict Sphinx HTML build pass. Existing full docs doctest cannot get past its optional Excel type-library setup locally (WinError -2147319779); this is recorded as unvalidated, not passing. New-head upstream CI is pending.

The mapping itself is unchanged in this follow-up and the PR remains draft. These results and the documentation describe the compatibility cost; they do not resolve it or decide whether a more detailed mapping should be used.

@junkmd junkmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding the new test double!
I am going to add another test double to source/CppTestSrv/ as well.
If any conflicts happen, it would be great if you could resolve them again.

Comment on lines +84 to +102
def test_character_pointer_output(self):
for value in (0, 1, ord("h"), 128, 255):
with self.subTest(value=value):
# Plain char* output uses the signed numeric mapping as well.
self.assertEqual(
self.server.GetCharacterBytes(value),
value if value < 128 else value - 256,
)

def test_character_ctypes_output_buffers(self):
for value in (0, ord("h"), 128, 255):
for result in (c_byte(), (c_byte * 1)()):
with self.subTest(value=value, result=result):
argument = byref(result) if isinstance(result, c_byte) else result
self.server._IByteCompatibilityTest__com_GetCharacterBytes(
value, argument
)
actual = result.value if isinstance(result, c_byte) else result[0]
self.assertEqual(actual, value if value < 128 else value - 256)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having if statements in the test and using values that are not fixed makes it a bit hard for me to read.

If the way we check values changes depending on the data type in parametrize, it might be better to split them into separate tests.
If the type is the same but the values are different, adding the expected value to the test parameters would make it easier to compare.

It could also be helpful to separate the test cases for old-mapping vs proposed-mapping.

I'm having trouble picturing the full behavior here. Could you walk me through it with test codebases?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split the test code into explicit groups and removed the datatype-dependent branches and computed expected values:

  • ByteCompatibilityTest retains the scalar/LPSTR smoke tests.
  • LegacyMappingCompatibilityTest contains only the old bytes-input and character-buffer-output assertions. Both pass under the old production mapping and remain expected failures under the proposal.
  • ProposedMappingCompatibilityTest has independent pointer, byref and array tests in each direction. Each assertion reads a fixed (input, expected) pair. Automatic outputs use (128, -128) and (255, -1); raw buffer assertions read the corresponding fixed storage shape without branching.

To make the behavior concrete, these are the actual calls represented by those classes:

Test group / call Old mapping Proposed mapping
Legacy: ReadCharPointer(b"h") 104 ArgumentError
Legacy: raw GetCharacterBytes(104, create_string_buffer(2)), then .value b"h" ArgumentError
Proposed: pointer/byref/array containing c_byte(-128) passed to ReadCharPointer ArgumentError 128
Proposed: raw output of 128 into pointer/byref/array of c_byte ArgumentError stored -128
Proposed: automatic GetCharacterBytes(255) TypeError during implicit STRING output allocation -1
Common: explicit LPSTR and unsigned echoes pass pass

I reran the old-mapping stage using freshly generated wrappers and the rebuilt fixture: the two legacy assertions pass; the proposed group reports 29 subcase errors across 7 tests; explicit LPSTR and unsigned cases pass. With the proposed mapping, focused suites run 76 tests (9 skipped, 2 expected failures), and the full suite runs 482 (24 skipped, 2 expected failures). The expected failures continue to disclose the compatibility cost.

Also merged main 91408c2 and resolved the Makefile conflict, retaining your new VT_ERROR component alongside the existing fixtures. MIDL/C++ rebuild and full Ruff CI scope pass. The source now provides separate runnable legacy/proposed classes to follow through each behavior.

The production mapping proposal is unchanged and this stays draft pending the compatibility strategy. CI for the new head is pending. OpenAI Codex assisted with this revision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation.VARIANT._set_value assumes c_char.value has length 1

4 participants