diff --git a/.github/workflows/autotest.yml b/.github/workflows/autotest.yml index 52d6fad5..b7ef087c 100644 --- a/.github/workflows/autotest.yml +++ b/.github/workflows/autotest.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] architecture: ['x86', 'x64'] support: ['with 3rd parties', 'without 3rd parties'] steps: @@ -20,6 +20,7 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} + allow-prereleases: true - name: Set up MSVC uses: ilammy/msvc-dev-cmd@v1 - name: Build and register the OutProc COM server @@ -51,7 +52,7 @@ jobs: strategy: matrix: os: [windows-2025, windows-2022] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] architecture: ['x86', 'x64'] steps: - uses: actions/checkout@v6 @@ -60,6 +61,7 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} + allow-prereleases: true - name: install comtypes run: | pip install --upgrade build @@ -71,7 +73,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] type-checker: ['ty', 'mypy', 'pyright'] steps: - uses: actions/checkout@v6 @@ -79,6 +81,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Build sdist run: | pip install --upgrade build diff --git a/README.md b/README.md index 43bd7d96..1fb05196 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,7 @@ `comtypes` requires Windows and Python 3.9 or later. -- **Note about Python 3.15 and `enum` behavior** - Starting with Python 3.15, the internal handling of `IntFlag`(`Flag`) values is planned to change: - **Negative `IntFlag` members will be reinterpreted by masking them to the defined positive bit domain, instead of keeping their original negative literal values**. - This can affect enumeration types generated by `comtypes` from COM type libraries. Action is needed to maintain literal evaluation. - For details and ongoing discussion, see: [GH-894](https://github.com/enthought/comtypes/issues/894). +- Version <= [1.4.17](https://pypi.org/project/comtypes/1.4.17/) has issues with enum behavior on Python 3.15 as reported in [GH-894](https://github.com/enthought/comtypes/issues/894). Version 1.5.0 will support Python 3.15. - Version [1.4.12](https://pypi.org/project/comtypes/1.4.12/) is the last version to support Python 3.8. - Version <= [1.4.7](https://pypi.org/project/comtypes/1.4.7/) does not work with Python 3.13 as reported in [GH-618](https://github.com/enthought/comtypes/issues/618). Version [1.4.8](https://pypi.org/project/comtypes/1.4.8/) can work with Python 3.13. - Version [1.4.6](https://pypi.org/project/comtypes/1.4.6/) is the last version to support Python 3.7. diff --git a/comtypes/__init__.py b/comtypes/__init__.py index 5f3e34a1..a29d751c 100644 --- a/comtypes/__init__.py +++ b/comtypes/__init__.py @@ -18,22 +18,6 @@ import logging import sys -if sys.version_info >= (3, 15): - import warnings - - _PYVER = f"{sys.version_info.major}.{sys.version_info.minor}" - warnings.warn( - ( - f"You are running 'comtypes' on Python {_PYVER}, where the behavior of " - "enum types (such as IntFlag) may differ from Python <= 3.14.\n" - f"It is recommended to use a version compatible with Python {_PYVER}.\n" - "See: https://github.com/enthought/comtypes/issues/894" - ), - FutureWarning, - stacklevel=2, - ) - - # HACK: Workaround for projects that depend on this package # There should be several projects around the world that depend on this package # and indirectly reference the symbols of `ctypes` from `comtypes`. diff --git a/comtypes/automation.py b/comtypes/automation.py index acd4e86e..edc4851b 100644 --- a/comtypes/automation.py +++ b/comtypes/automation.py @@ -601,7 +601,7 @@ def ChangeType(self, typecode): VARIANT.empty = VARIANT() VARIANT.missing = v = VARIANT() v.vt = VT_ERROR -v._.VT_I4 = 0x80020004 +v._.VT_I4 = hresult.DISP_E_PARAMNOTFOUND del v diff --git a/comtypes/logutil.py b/comtypes/logutil.py index a3db8ef1..b3f2e7aa 100644 --- a/comtypes/logutil.py +++ b/comtypes/logutil.py @@ -1,7 +1,6 @@ # logutil.py -import functools import logging -import warnings +from collections.abc import Callable from ctypes import WinDLL from ctypes.wintypes import LPCSTR, LPCWSTR @@ -19,65 +18,11 @@ class NTDebugHandler(logging.Handler): def emit( self, - record, - writeW=_OutputDebugStringW, - ): + record: logging.LogRecord, + writeW: Callable[[str], None] = _OutputDebugStringW, + ) -> None: text = self.format(record) writeW(text + "\n") -logging.NTDebugHandler = NTDebugHandler - - -def deprecated(reason: str): - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - warnings.warn(reason, category=DeprecationWarning, stacklevel=2) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -@deprecated("Deprecated. See https://github.com/enthought/comtypes/issues/920.") -def setup_logging(*pathnames): - import configparser - - parser = configparser.ConfigParser() - parser.optionxform = str # use case sensitive option names! - - parser.read(pathnames) - - DEFAULTS = { - "handler": "StreamHandler()", - "format": "%(levelname)s:%(name)s:%(message)s", - "level": "WARNING", - } - - def get(section, option): - try: - return parser.get(section, option, True) - except (configparser.NoOptionError, configparser.NoSectionError): - return DEFAULTS[option] - - levelname = get("logging", "level") - format = get("logging", "format") - handlerclass = get("logging", "handler") - - # convert level name to level value - level = getattr(logging, levelname) - # create the handler instance - handler = eval(handlerclass, vars(logging)) - formatter = logging.Formatter(format) - handler.setFormatter(formatter) - logging.root.addHandler(handler) - logging.root.setLevel(level) - - try: - for name, value in parser.items("logging.levels", True): - value = getattr(logging, value) - logging.getLogger(name).setLevel(value) - except configparser.NoSectionError: - pass +logging.NTDebugHandler = NTDebugHandler # type: ignore diff --git a/comtypes/test/test_client.py b/comtypes/test/test_client.py index 9191d7f4..21c720ce 100644 --- a/comtypes/test/test_client.py +++ b/comtypes/test/test_client.py @@ -379,6 +379,26 @@ def test_munged_definitions(self): self.assertEqual(consts.MSVidCCService.None_, consts.None_) self.assertEqual(MSVidCtlLib.None_, consts.None_) + def test_enum_base_classes(self): + """Test that enums with negative values are generated as IntEnum, + and enums with only non-negative values are generated as IntFlag.""" + from enum import IntEnum, IntFlag + + # MsiInstallState in msi.dll contains negative values, so it should be + # an IntEnum to preserve the values in Python 3.15+. + # See https://github.com/enthought/comtypes/issues/894 + msi_module = comtypes.client.GetModule("msi.dll") + MsiInstallState = msi_module.MsiInstallState + self.assertTrue(issubclass(MsiInstallState, IntEnum)) + self.assertFalse(issubclass(MsiInstallState, IntFlag)) + + # OLE_TRISTATE in stdole2.tlb contains only 0, 1, 2, so it can be + # an IntFlag. + stdole_module = comtypes.client.GetModule("stdole2.tlb") + OLE_TRISTATE = stdole_module.OLE_TRISTATE + self.assertTrue(issubclass(OLE_TRISTATE, IntFlag)) + self.assertFalse(issubclass(OLE_TRISTATE, IntEnum)) + if __name__ == "__main__": ut.main() diff --git a/comtypes/test/test_logutil.py b/comtypes/test/test_logutil.py index 4d116daf..3414c377 100644 --- a/comtypes/test/test_logutil.py +++ b/comtypes/test/test_logutil.py @@ -12,7 +12,7 @@ from typing import Union as _UnionT from comtypes.client._events import SECURITY_ATTRIBUTES -from comtypes.logutil import NTDebugHandler, deprecated +from comtypes.logutil import NTDebugHandler from comtypes.logutil import ( _OutputDebugStringW as OutputDebugStringW, ) @@ -21,20 +21,6 @@ from ctypes import _CArgObject, _Pointer -class Test_deprecated(ut.TestCase): - def test_warning_is_raised(self): - reason_text = "This is deprecated." - - @deprecated(reason_text) - def test_func(): - return "success" - - with self.assertWarns(DeprecationWarning) as cm: - result = test_func() - self.assertEqual(result, "success") - self.assertEqual(reason_text, str(cm.warning)) - - _kernel32 = WinDLL("kernel32", use_last_error=True) # https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventw diff --git a/comtypes/test/test_util.py b/comtypes/test/test_util.py index 5e8f23aa..db7348b1 100644 --- a/comtypes/test/test_util.py +++ b/comtypes/test/test_util.py @@ -1,4 +1,3 @@ -import sys import unittest from ctypes import ( POINTER, @@ -14,25 +13,7 @@ sizeof, ) -PY_3_15_ALPHA_BETA = ( - sys.version_info.major == 3 - and sys.version_info.minor == 15 - and sys.version_info.releaselevel in ("alpha", "beta") -) - -try: - import comtypes.util -except RuntimeError as e: - SKIP_MSG = ( - "Starting from Python 3.15, PyCArgObject layout is changed. " - "See https://github.com/enthought/comtypes/issues/938." - ) - if PY_3_15_ALPHA_BETA: - - def setUpModule(): - raise unittest.SkipTest(SKIP_MSG) - else: - raise e +import comtypes.util from comtypes import GUID, CoCreateInstance, IUnknown, shelllink diff --git a/comtypes/tools/codegenerator/codegenerator.py b/comtypes/tools/codegenerator/codegenerator.py index 7ef7b07b..da389c7c 100644 --- a/comtypes/tools/codegenerator/codegenerator.py +++ b/comtypes/tools/codegenerator/codegenerator.py @@ -253,14 +253,16 @@ def generate_friendly_code(self, modname: str) -> str: Such as "comtypes.gen.stdole" and "comtypes.gen.Excel". """ output = io.StringIO() - print("from enum import IntFlag", file=output) + enumcode, enumbases = self.enums.to_enums() + if enumbases: + print(f"from enum import {', '.join(sorted(list(enumbases)))}", file=output) print(file=output) print(f"import {modname} as __wrapper_module__", file=output) print(self._make_friendly_module_import_part(modname), file=output) print(file=output) print(file=output) if self.enums: - print(self.enums.to_intflags(), file=output) + print(enumcode, file=output) print(file=output) print(file=output) if self.enum_aliases: diff --git a/comtypes/tools/codegenerator/namespaces.py b/comtypes/tools/codegenerator/namespaces.py index 590d3bd8..8c1845a5 100644 --- a/comtypes/tools/codegenerator/namespaces.py +++ b/comtypes/tools/codegenerator/namespaces.py @@ -182,9 +182,12 @@ def add(self, enum_name: str, member_name: str, value: int) -> None: The 'egg' member of the 'Bar' enumeration is already assigned 4, but it will be overwritten with 5, based on the type information. + >>> enums.add('Baz', 'mix', -1) + >>> enums.add('Baz', 'juice', 0) >>> assert 'Foo' in enums - >>> assert 'Baz' not in enums - >>> print(enums.to_intflags()) + >>> assert 'Qux' not in enums + >>> enumcode, enumbases = enums.to_enums() + >>> print(enumcode) class Foo(IntFlag): ham = 1 spam = 2 @@ -194,6 +197,13 @@ class Bar(IntFlag): bacon = 3 # egg = 4 # duplicated. Perhaps there is a bug in the type library? egg = 5 # duplicated. Perhaps there is a bug in the type library? + + + class Baz(IntEnum): + mix = -1 + juice = 0 + >>> sorted(list(enumbases)) + ['IntEnum', 'IntFlag'] >>> print(enums.to_constants()) # values for enumeration 'Foo' ham = 1 @@ -205,6 +215,11 @@ class Bar(IntFlag): egg = 4 # duplicated within the 'Bar'. Perhaps there is a bug? egg = 5 # duplicated within the 'Bar'. Perhaps there is a bug? Bar = c_int # enum + + # values for enumeration 'Baz' + mix = -1 + juice = 0 + Baz = c_int # enum """ members = self.data.setdefault(enum_name, []) if members: @@ -261,11 +276,12 @@ def to_constants(self) -> str: blocks.append("\n".join(lines)) return "\n\n".join(blocks) - def to_intflags(self) -> str: - blocks = [] + def to_enums(self) -> tuple[str, set[str]]: + enumbases: set[str] = set() + blocks: list[str] = [] for enum_name, members in self._iter_items(): + has_negative = False lines = [] - lines.append(f"class {enum_name}(IntFlag):") for member_name, member_value, is_dupl, rest_dupl_count in members: definition = f"{member_name} = {member_value}" if is_dupl: @@ -279,5 +295,11 @@ def to_intflags(self) -> str: lines.append(f" {base_line}") else: lines.append(f" {definition}") - blocks.append("\n".join(lines)) - return "\n\n\n".join(blocks) + if member_value < 0: + has_negative = True + # Preventing the range-masking in Python 3.15 + # See https://github.com/enthought/comtypes/issues/894 + base_class = "IntEnum" if has_negative else "IntFlag" + enumbases.add(base_class) + blocks.append("\n".join([f"class {enum_name}({base_class}):"] + lines)) + return "\n\n\n".join(blocks), enumbases diff --git a/comtypes/util.py b/comtypes/util.py index bbc27345..36eb93ca 100644 --- a/comtypes/util.py +++ b/comtypes/util.py @@ -12,6 +12,7 @@ byref, c_byte, c_char, + c_char_p, c_double, c_float, c_int, @@ -19,6 +20,7 @@ c_longdouble, c_longlong, c_short, + c_ssize_t, c_void_p, cast, sizeof, @@ -32,6 +34,13 @@ _T = TypeVar("_T") _CT = TypeVar("_CT", bound="_CData") +if sys.version_info >= (3, 15): + _TAG_TYPE = c_char_p + _SIZE_TYPE = c_ssize_t +else: + _TAG_TYPE = c_char + _SIZE_TYPE = c_int + def _calc_offset(): # Internal helper function that calculates where the object @@ -103,10 +112,10 @@ class value(Union): _fields_ = [ ("PyObject_HEAD", c_byte * object.__basicsize__), ("pffi_type", c_void_p), - ("tag", c_char), + ("tag", _TAG_TYPE), ("value", value), ("obj", c_void_p), - ("size", c_int), + ("size", _SIZE_TYPE), ] _anonymous_ = ["value"] diff --git a/docs/source/client.rst b/docs/source/client.rst index 51ffca1f..353a0cc1 100644 --- a/docs/source/client.rst +++ b/docs/source/client.rst @@ -551,7 +551,8 @@ from COM typelibraries. abstracted alias ``__wrapper_module__``, also imports interface classes, coclasses, constants, and structures from the wrapper module, and defines enumerations from typeinfo of the typelibrary - using `enum.IntFlag `_. + using `enum.IntFlag `_ + or `enum.IntEnum `_. The friendly module can be imported easier than the wrapper module because the module name is easier to type and read. @@ -600,6 +601,20 @@ from COM typelibraries. + from comtypes.gen.friendlymodule import __wrapper_module__ as mod c_int_alias = mod.TheName + *Changed in version 1.5.0*: The friendly module defines + enumerations containing negative member values using + `enum.IntEnum `_, + while enumerations with only non-negative member values continue to + use `enum.IntFlag `_. + Prior to this, all enumerations were defined using ``IntFlag``. + However, treating negative values as flags (bitmasks) is + mathematically inconsistent. Furthermore, starting with Python 3.15, + negative members in ``IntFlag`` are reinterpreted by masking them to + the defined positive bit domain instead of keeping their original + negative literal values. Defining enumerations with negative + members as ``IntEnum`` ensures that their literal values (such as + ``-1``) are preserved exactly as defined in the type library. + .. py:attribute:: gen_dir This variable determines the directory where the typelib wrappers @@ -656,6 +671,18 @@ friendly module generated by calling the ``GetModule`` function: +If an enumeration contains negative member values, it is defined as +an ``IntEnum`` rather than an ``IntFlag``: + +.. doctest:: + + >>> msi = GetModule('msi.dll') + >>> msi.MsiInstallState # an enumeration with negative members + + >>> msi.MsiInstallState.msiInstallStateUnknown + + + This code snippet could be used to generate the typelib wrapper module for Scripting Dictionary automatically when your script is run, and would include the module into the exe-file when the script is frozen