From 771a2ce6108f5e2da3293a9ed9f0fc64e05af4e0 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Fri, 11 Jul 2025 16:30:01 +0100 Subject: [PATCH 1/9] Remove unused type ignore mark --- distutils/compilers/C/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distutils/compilers/C/base.py b/distutils/compilers/C/base.py index 93385e13..6412dc34 100644 --- a/distutils/compilers/C/base.py +++ b/distutils/compilers/C/base.py @@ -70,7 +70,7 @@ class Compiler: # dictionary (see below -- used by the 'new_compiler()' factory # function) -- authors of new compiler interface classes are # responsible for updating 'compiler_class'! - compiler_type: ClassVar[str] = None # type: ignore[assignment] + compiler_type: ClassVar[str] = None # XXX things not handled by this compiler abstraction model: # * client can't provide additional options for a compiler, From 4bed279a1800033b34878744f76e7e84a0793814 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Fri, 11 Jul 2025 13:43:10 +0100 Subject: [PATCH 2/9] Use dataclass for typing Extension --- distutils/core.py | 20 +-- distutils/extension.py | 274 ++++++++++++++++-------------- distutils/tests/test_extension.py | 2 +- 3 files changed, 151 insertions(+), 145 deletions(-) diff --git a/distutils/core.py b/distutils/core.py index bd62546b..ef39600b 100644 --- a/distutils/core.py +++ b/distutils/core.py @@ -25,6 +25,7 @@ DistutilsSetupError, ) from .extension import Extension +from .extension import _safe as extension_keywords # noqa # backwards compatibility __all__ = ['Distribution', 'Command', 'Extension', 'setup'] @@ -74,25 +75,6 @@ def gen_usage(script_name): 'obsoletes', ) -# Legal keyword arguments for the Extension constructor -extension_keywords = ( - 'name', - 'sources', - 'include_dirs', - 'define_macros', - 'undef_macros', - 'library_dirs', - 'libraries', - 'runtime_library_dirs', - 'extra_objects', - 'extra_compile_args', - 'extra_link_args', - 'swig_opts', - 'export_symbols', - 'depends', - 'language', -) - def setup(**attrs): # noqa: C901 """The gateway to the Distutils: do everything your setup script needs diff --git a/distutils/extension.py b/distutils/extension.py index f5141126..b12a94a1 100644 --- a/distutils/extension.py +++ b/distutils/extension.py @@ -8,6 +8,8 @@ import os import warnings from collections.abc import Iterable +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING # This class is really only used by the "build_ext" command, so it might # make sense to put it in distutils.command.build_ext. However, that @@ -20,137 +22,159 @@ # order to do anything. -class Extension: +@dataclass +class _Extension: """Just a collection of attributes that describes an extension module and everything needed to build it (hopefully in a portable way, but there are hooks that let you be as unportable as you need). + """ + + # The use of a parent class as a "trick": + # - We need to modify __init__ so to achieve backwards compatibility + # - But we don't want to throw away the dataclass-generated __init__ + # - We also want to fool the typechecker to consider the same type + # signature as the dataclass-generated __init__ + + name: str + """ + the full name of the extension, including any packages -- ie. + *not* a filename or pathname, but Python dotted name + """ + + sources: Iterable[str | os.PathLike[str]] + """ + iterable of source filenames (except strings, which could be misinterpreted + as a single filename), relative to the distribution root (where the setup + script lives), in Unix form (slash-separated) for portability. Can be any + non-string iterable (list, tuple, set, etc.) containing strings or + PathLike objects. Source files may be C, C++, SWIG (.i), platform-specific + resource files, or whatever else is recognized by the "build_ext" command + as source for a Python extension. + """ + + include_dirs: list[str] = field(default_factory=list) + """ + list of directories to search for C/C++ header files (in Unix + form for portability) + """ + + define_macros: list[tuple[str, str | None]] = field(default_factory=list) + """ + list of macros to define; each macro is defined using a 2-tuple, + where 'value' is either the string to define it to or None to + define it without a particular value (equivalent of "#define + FOO" in source or -DFOO on Unix C compiler command line) + """ + + undef_macros: list[str] = field(default_factory=list) + """list of macros to undefine explicitly""" + + library_dirs: list[str] = field(default_factory=list) + """list of directories to search for C/C++ libraries at link time""" + + libraries: list[str] = field(default_factory=list) + """list of library names (not filenames or paths) to link against""" + + runtime_library_dirs: list[str] = field(default_factory=list) + """ + list of directories to search for C/C++ libraries at run time + (for shared extensions, this is when the extension is loaded) + """ + + extra_objects: list[str] = field(default_factory=list) + """ + list of extra files to link with (eg. object files not implied + by 'sources', static library that must be explicitly specified, + binary resource files, etc.) + """ + + extra_compile_args: list[str] = field(default_factory=list) + """ + any extra platform- and compiler-specific information to use + when compiling the source files in 'sources'. For platforms and + compilers where "command line" makes sense, this is typically a + list of command-line arguments, but for other platforms it could + be anything. + """ + + extra_link_args: list[str] = field(default_factory=list) + """ + any extra platform- and compiler-specific information to use + when linking object files together to create the extension (or + to create a new static Python interpreter). Similar + interpretation as for 'extra_compile_args'. + """ + + export_symbols: list[str] = field(default_factory=list) + """ + list of symbols to be exported from a shared extension. Not + used on all platforms, and not generally necessary for Python + extensions, which typically export exactly one symbol: "init" + + extension_name. + """ - Instance attributes: - name : string - the full name of the extension, including any packages -- ie. - *not* a filename or pathname, but Python dotted name - sources : Iterable[string | os.PathLike] - iterable of source filenames (except strings, which could be misinterpreted - as a single filename), relative to the distribution root (where the setup - script lives), in Unix form (slash-separated) for portability. Can be any - non-string iterable (list, tuple, set, etc.) containing strings or - PathLike objects. Source files may be C, C++, SWIG (.i), platform-specific - resource files, or whatever else is recognized by the "build_ext" command - as source for a Python extension. - include_dirs : [string] - list of directories to search for C/C++ header files (in Unix - form for portability) - define_macros : [(name : string, value : string|None)] - list of macros to define; each macro is defined using a 2-tuple, - where 'value' is either the string to define it to or None to - define it without a particular value (equivalent of "#define - FOO" in source or -DFOO on Unix C compiler command line) - undef_macros : [string] - list of macros to undefine explicitly - library_dirs : [string] - list of directories to search for C/C++ libraries at link time - libraries : [string] - list of library names (not filenames or paths) to link against - runtime_library_dirs : [string] - list of directories to search for C/C++ libraries at run time - (for shared extensions, this is when the extension is loaded) - extra_objects : [string] - list of extra files to link with (eg. object files not implied - by 'sources', static library that must be explicitly specified, - binary resource files, etc.) - extra_compile_args : [string] - any extra platform- and compiler-specific information to use - when compiling the source files in 'sources'. For platforms and - compilers where "command line" makes sense, this is typically a - list of command-line arguments, but for other platforms it could - be anything. - extra_link_args : [string] - any extra platform- and compiler-specific information to use - when linking object files together to create the extension (or - to create a new static Python interpreter). Similar - interpretation as for 'extra_compile_args'. - export_symbols : [string] - list of symbols to be exported from a shared extension. Not - used on all platforms, and not generally necessary for Python - extensions, which typically export exactly one symbol: "init" + - extension_name. - swig_opts : [string] - any extra options to pass to SWIG if a source file has the .i - extension. - depends : [string] - list of files that the extension depends on - language : string - extension language (i.e. "c", "c++", "objc"). Will be detected - from the source extensions if not provided. - optional : boolean - specifies that a build failure in the extension should not abort the - build process, but simply not install the failing extension. + swig_opts: list[str] = field(default_factory=list) """ + any extra options to pass to SWIG if a source file has the .i + extension. + """ + + depends: list[str] = field(default_factory=list) + """list of files that the extension depends on""" + + language: str | None = None + """ + extension language (i.e. "c", "c++", "objc"). Will be detected + from the source extensions if not provided. + """ + + optional: bool = False + """ + specifies that a build failure in the extension should not abort the + build process, but simply not install the failing extension. + """ + + +# Legal keyword arguments for the Extension constructor +_safe = tuple(f.name for f in fields(_Extension)) + + +if TYPE_CHECKING: + + @dataclass + class Extension(_Extension): + pass + +else: + + @dataclass(init=False) + class Extension(_Extension): + def __init__(self, name, sources, *args, **kwargs): + if not isinstance(name, str): + raise TypeError("'name' must be a string") + + # handle the string case first; since strings are iterable, disallow them + if isinstance(sources, str): + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects, not a string" + ) + + # now we check if it's iterable and contains valid types + try: + sources = list(map(os.fspath, sources)) + except TypeError: + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects" + ) + + extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in _safe} + if extra: + warnings.warn(f"Unknown Extension options: {','.join(extra)}") - # When adding arguments to this constructor, be sure to update - # setup_keywords in core.py. - def __init__( - self, - name: str, - sources: Iterable[str | os.PathLike[str]], - include_dirs: list[str] | None = None, - define_macros: list[tuple[str, str | None]] | None = None, - undef_macros: list[str] | None = None, - library_dirs: list[str] | None = None, - libraries: list[str] | None = None, - runtime_library_dirs: list[str] | None = None, - extra_objects: list[str] | None = None, - extra_compile_args: list[str] | None = None, - extra_link_args: list[str] | None = None, - export_symbols: list[str] | None = None, - swig_opts: list[str] | None = None, - depends: list[str] | None = None, - language: str | None = None, - optional: bool | None = None, - **kw, # To catch unknown keywords - ): - if not isinstance(name, str): - raise TypeError("'name' must be a string") - - # handle the string case first; since strings are iterable, disallow them - if isinstance(sources, str): - raise TypeError( - "'sources' must be an iterable of strings or PathLike objects, not a string" - ) - - # now we check if it's iterable and contains valid types - try: - self.sources = list(map(os.fspath, sources)) - except TypeError: - raise TypeError( - "'sources' must be an iterable of strings or PathLike objects" - ) - - self.name = name - self.include_dirs = include_dirs or [] - self.define_macros = define_macros or [] - self.undef_macros = undef_macros or [] - self.library_dirs = library_dirs or [] - self.libraries = libraries or [] - self.runtime_library_dirs = runtime_library_dirs or [] - self.extra_objects = extra_objects or [] - self.extra_compile_args = extra_compile_args or [] - self.extra_link_args = extra_link_args or [] - self.export_symbols = export_symbols or [] - self.swig_opts = swig_opts or [] - self.depends = depends or [] - self.language = language - self.optional = optional - - # If there are unknown keyword options, warn about them - if len(kw) > 0: - options = [repr(option) for option in kw] - options = ', '.join(sorted(options)) - msg = f"Unknown Extension options: {options}" - warnings.warn(msg) - - def __repr__(self): - return f'<{self.__class__.__module__}.{self.__class__.__qualname__}({self.name!r}) at {id(self):#x}>' + # Ensure default values (e.g. []) are used instead of None: + positional = {k: v for k, v in zip(_safe[2:], args) if v is not None} + keywords = {k: v for k, v in kwargs.items() if v is not None} + super().__init__(name, sources, **positional, **keywords) def read_setup_file(filename): # noqa: C901 diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index 5e8e7682..17d52579 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -106,7 +106,7 @@ def test_extension_init(self): assert getattr(ext, attr) == [] assert ext.language is None - assert ext.optional is None + assert ext.optional is False # if there are unknown keyword options, warn about them with check_warnings() as w: From 37447a402c09c9acc6332057e60dd57c1389fa93 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Fri, 11 Jul 2025 16:13:53 +0100 Subject: [PATCH 3/9] Separate __post_init__ --- distutils/extension.py | 65 ++++++++++++++++--------------- distutils/tests/test_extension.py | 47 ++++++++++++++++++++-- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/distutils/extension.py b/distutils/extension.py index b12a94a1..8a3b156b 100644 --- a/distutils/extension.py +++ b/distutils/extension.py @@ -31,9 +31,10 @@ class _Extension: # The use of a parent class as a "trick": # - We need to modify __init__ so to achieve backwards compatibility + # and keep allowing arbitrary keywords to be ignored # - But we don't want to throw away the dataclass-generated __init__ - # - We also want to fool the typechecker to consider the same type - # signature as the dataclass-generated __init__ + # specially because we don't want to have to redefine all the typing + # for the function arguments name: str """ @@ -139,42 +140,42 @@ class _Extension: _safe = tuple(f.name for f in fields(_Extension)) -if TYPE_CHECKING: - - @dataclass - class Extension(_Extension): - pass - -else: - - @dataclass(init=False) - class Extension(_Extension): - def __init__(self, name, sources, *args, **kwargs): - if not isinstance(name, str): - raise TypeError("'name' must be a string") - - # handle the string case first; since strings are iterable, disallow them - if isinstance(sources, str): - raise TypeError( - "'sources' must be an iterable of strings or PathLike objects, not a string" - ) - - # now we check if it's iterable and contains valid types - try: - sources = list(map(os.fspath, sources)) - except TypeError: - raise TypeError( - "'sources' must be an iterable of strings or PathLike objects" - ) +@dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required] +class Extension(_Extension): + if not TYPE_CHECKING: + def __init__(self, *args, **kwargs): extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in _safe} if extra: - warnings.warn(f"Unknown Extension options: {','.join(extra)}") + msg = f""" + Please remove unknown `Extension` options: {','.join(extra)} + this kind of usage is deprecated and may cause errors in the future. + """ + warnings.warn(msg) # Ensure default values (e.g. []) are used instead of None: - positional = {k: v for k, v in zip(_safe[2:], args) if v is not None} + positional = {k: v for k, v in zip(_safe, args) if v is not None} keywords = {k: v for k, v in kwargs.items() if v is not None} - super().__init__(name, sources, **positional, **keywords) + super().__init__(**positional, **keywords) + self.__post_init__() # does not seem to be called when customizing __init__ + + def __post_init__(self): + if not isinstance(self.name, str): + raise TypeError("'name' must be a string") + + # handle the string case first; since strings are iterable, disallow them + if isinstance(self.sources, str): + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects, not a string" + ) + + # now we check if it's iterable and contains valid types + try: + self.sources = list(map(os.fspath, self.sources)) + except TypeError: + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects" + ) def read_setup_file(filename): # noqa: C901 diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index 17d52579..4042d547 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -2,11 +2,13 @@ import os import pathlib +import re import warnings +from dataclasses import dataclass, field from distutils.extension import Extension, read_setup_file +from typing import TYPE_CHECKING import pytest -from test.support.warnings_helper import check_warnings class TestExtension: @@ -109,9 +111,46 @@ def test_extension_init(self): assert ext.optional is False # if there are unknown keyword options, warn about them - with check_warnings() as w: + msg = re.escape("unknown `Extension` options: 'chic'") + with pytest.warns(UserWarning, match=msg) as w: warnings.simplefilter('always') ext = Extension('name', ['file1', 'file2'], chic=True) - assert len(w.warnings) == 1 - assert str(w.warnings[0].message) == "Unknown Extension options: 'chic'" + assert len(w) == 1 + + +def test_can_be_extended_by_setuptools() -> None: + # Emulate how it could be extended in setuptools + + @dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required] + class setuptools_Extension(Extension): + py_limited_api: bool = False + _full_name: str = field(init=False, repr=False) + + if not TYPE_CHECKING: + # Custom __init__ is only needed for backwards compatibility + # (to ignore arbitrary keywords) + + def __init__(self, *args, py_limited_api=False, **kwargs): + self.py_limited_api = py_limited_api + super().__init__(*args, **kwargs) + + ext1 = setuptools_Extension("name", ["hello.c"], py_limited_api=True) + assert ext1.py_limited_api is True + assert ext1.define_macros == [] + + msg = re.escape("unknown `Extension` options: 'world'") + with pytest.warns(UserWarning, match=msg): + ext2 = setuptools_Extension("name", ["hello.c"], world=True) # type: ignore[call-arg] + + assert "world" not in ext2.__dict__ + assert ext2.py_limited_api is False + + # Without __init__ customization the following warning would be an error: + msg = re.escape("unknown `Extension` options: '_full_name'") + with pytest.warns(UserWarning, match=msg): + ext3 = setuptools_Extension("name", ["hello.c"], _full_name="hello") # type: ignore[call-arg] + + assert "_full_name" not in ext3.__dict__ + ext3._full_name = "hello world" # can still be set in build_ext + assert ext3._full_name == "hello world" From 0bb738e9e37ebd310808b6e91945043ab5e44ae8 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 28 Jan 2026 17:16:41 +0000 Subject: [PATCH 4/9] Add compat.py310.dataclass_transform fallback --- distutils/compat/py310.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 distutils/compat/py310.py diff --git a/distutils/compat/py310.py b/distutils/compat/py310.py new file mode 100644 index 00000000..f5865962 --- /dev/null +++ b/distutils/compat/py310.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +_T = TypeVar("_T") + +if sys.version_info >= (3, 11): + from typing import dataclass_transform +else: + if TYPE_CHECKING: + # typing_extensions usually "exist" when type-checking, + # without the need for extra runtime dependencies + from typing_extensions import dataclass_transform + else: + # Runtime no-op + def dataclass_transform( # type: ignore[misc] + *, + eq_default: bool | None = None, + order_default: bool | None = None, + kw_only_default: bool | None = None, + field_specifiers: tuple[type[Any], ...] = (), + **_: Any, + ) -> Callable[[_T], _T]: + def _decorator(obj: _T) -> _T: + return obj + + return _decorator From 15630428790f5ce2eb8e769d9ab28eabe2e6c9d7 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 28 Jan 2026 17:17:21 +0000 Subject: [PATCH 5/9] Add a dataclass-like decorator that is lenient towards extra arguments --- distutils/_dataclass.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 distutils/_dataclass.py diff --git a/distutils/_dataclass.py b/distutils/_dataclass.py new file mode 100644 index 00000000..0f1d00fa --- /dev/null +++ b/distutils/_dataclass.py @@ -0,0 +1,51 @@ +# This is a private module, but setuptools has the explicit permission to use it. +from __future__ import annotations + +import warnings +from dataclasses import dataclass, fields +from functools import wraps +from typing import TypeVar + +from .compat.py310 import dataclass_transform + +_T = TypeVar("_T", bound=type) + + +@dataclass_transform() +def lenient_dataclass(**dc_kwargs): + """ + Problem this class intends to solve: + - We need to modify __init__ so to achieve backwards compatibility + and keep allowing arbitrary keywords to be ignored + - But we don't want to throw away the dataclass-generated __init__ + specially because we don't want to have to redefine all the typing + for the function arguments + """ + + @wraps(dataclass) + def _wrap(cls: _T) -> _T: + cls = dataclass(**dc_kwargs)(cls) # type: ignore[misc] + # Allowed field names in order + safe = tuple(f.name for f in fields(cls)) + orig_init = cls.__init__ + + @wraps(orig_init) + def _wrapped_init(self, *args, **kwargs): + extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in safe} + if extra: + msg = f""" + Please remove unknown `{cls.__name__}` options: {','.join(extra)} + this kind of usage is deprecated and may cause errors in the future. + """ + warnings.warn(msg) + + # Ensure default values (e.g. []) are used instead of None: + positional = {k: v for k, v in zip(safe, args) if v is not None} + keywords = {k: v for k, v in kwargs.items() if v is not None} + orig_init(self, **positional, **keywords) + self.__post_init__() # does not seem to be called when customizing __init__ + + cls.__init__ = _wrapped_init + return cls + + return _wrap From dab5c11f054ca01227821f6a40ec1ff3a1f563ac Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 28 Jan 2026 17:18:17 +0000 Subject: [PATCH 6/9] Use lenient_dataclass to simplify Extension class --- distutils/extension.py | 45 +++++++------------------------ distutils/tests/test_extension.py | 29 ++++++-------------- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/distutils/extension.py b/distutils/extension.py index 8a3b156b..c9cf2b23 100644 --- a/distutils/extension.py +++ b/distutils/extension.py @@ -6,10 +6,10 @@ from __future__ import annotations import os -import warnings from collections.abc import Iterable -from dataclasses import dataclass, field, fields -from typing import TYPE_CHECKING +from dataclasses import field, fields + +from ._dataclass import lenient_dataclass # This class is really only used by the "build_ext" command, so it might # make sense to put it in distutils.command.build_ext. However, that @@ -22,20 +22,13 @@ # order to do anything. -@dataclass -class _Extension: +@lenient_dataclass() +class Extension: """Just a collection of attributes that describes an extension module and everything needed to build it (hopefully in a portable way, but there are hooks that let you be as unportable as you need). """ - # The use of a parent class as a "trick": - # - We need to modify __init__ so to achieve backwards compatibility - # and keep allowing arbitrary keywords to be ignored - # - But we don't want to throw away the dataclass-generated __init__ - # specially because we don't want to have to redefine all the typing - # for the function arguments - name: str """ the full name of the extension, including any packages -- ie. @@ -135,30 +128,6 @@ class _Extension: build process, but simply not install the failing extension. """ - -# Legal keyword arguments for the Extension constructor -_safe = tuple(f.name for f in fields(_Extension)) - - -@dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required] -class Extension(_Extension): - if not TYPE_CHECKING: - - def __init__(self, *args, **kwargs): - extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in _safe} - if extra: - msg = f""" - Please remove unknown `Extension` options: {','.join(extra)} - this kind of usage is deprecated and may cause errors in the future. - """ - warnings.warn(msg) - - # Ensure default values (e.g. []) are used instead of None: - positional = {k: v for k, v in zip(_safe, args) if v is not None} - keywords = {k: v for k, v in kwargs.items() if v is not None} - super().__init__(**positional, **keywords) - self.__post_init__() # does not seem to be called when customizing __init__ - def __post_init__(self): if not isinstance(self.name, str): raise TypeError("'name' must be a string") @@ -178,6 +147,10 @@ def __post_init__(self): ) +# Legal keyword arguments for the Extension constructor +_safe = tuple(f.name for f in fields(Extension)) + + def read_setup_file(filename): # noqa: C901 """Reads a Setup file and returns Extension instances.""" from distutils.sysconfig import _variable_rx, expand_makefile_vars, parse_makefile diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index 4042d547..804bb3a7 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -4,9 +4,9 @@ import pathlib import re import warnings -from dataclasses import dataclass, field +from dataclasses import field +from distutils._dataclass import lenient_dataclass from distutils.extension import Extension, read_setup_file -from typing import TYPE_CHECKING import pytest @@ -122,35 +122,22 @@ def test_extension_init(self): def test_can_be_extended_by_setuptools() -> None: # Emulate how it could be extended in setuptools - @dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required] + @lenient_dataclass() class setuptools_Extension(Extension): py_limited_api: bool = False _full_name: str = field(init=False, repr=False) - if not TYPE_CHECKING: - # Custom __init__ is only needed for backwards compatibility - # (to ignore arbitrary keywords) - - def __init__(self, *args, py_limited_api=False, **kwargs): - self.py_limited_api = py_limited_api - super().__init__(*args, **kwargs) - ext1 = setuptools_Extension("name", ["hello.c"], py_limited_api=True) assert ext1.py_limited_api is True assert ext1.define_macros == [] - msg = re.escape("unknown `Extension` options: 'world'") + # Without __init__ customization the following warning would be an error: + msg = re.escape("unknown `setuptools_Extension` options: 'world'") with pytest.warns(UserWarning, match=msg): ext2 = setuptools_Extension("name", ["hello.c"], world=True) # type: ignore[call-arg] assert "world" not in ext2.__dict__ assert ext2.py_limited_api is False - - # Without __init__ customization the following warning would be an error: - msg = re.escape("unknown `Extension` options: '_full_name'") - with pytest.warns(UserWarning, match=msg): - ext3 = setuptools_Extension("name", ["hello.c"], _full_name="hello") # type: ignore[call-arg] - - assert "_full_name" not in ext3.__dict__ - ext3._full_name = "hello world" # can still be set in build_ext - assert ext3._full_name == "hello world" + assert "_full_name" not in ext2.__dict__ # not initialized by default + ext2._full_name = "hello world" # can still be set in build_ext + assert ext2._full_name == "hello world" From e5c817be7ba4af4c2cadc08c636d95252e42fb43 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 28 Jan 2026 17:29:07 +0000 Subject: [PATCH 7/9] Remove the now unecessary explicit call to __post_init__ --- distutils/_dataclass.py | 5 ++--- distutils/tests/test_extension.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/distutils/_dataclass.py b/distutils/_dataclass.py index 0f1d00fa..b9aeb988 100644 --- a/distutils/_dataclass.py +++ b/distutils/_dataclass.py @@ -24,7 +24,7 @@ def lenient_dataclass(**dc_kwargs): @wraps(dataclass) def _wrap(cls: _T) -> _T: - cls = dataclass(**dc_kwargs)(cls) # type: ignore[misc] + cls = dataclass(**dc_kwargs)(cls) # Allowed field names in order safe = tuple(f.name for f in fields(cls)) orig_init = cls.__init__ @@ -42,8 +42,7 @@ def _wrapped_init(self, *args, **kwargs): # Ensure default values (e.g. []) are used instead of None: positional = {k: v for k, v in zip(safe, args) if v is not None} keywords = {k: v for k, v in kwargs.items() if v is not None} - orig_init(self, **positional, **keywords) - self.__post_init__() # does not seem to be called when customizing __init__ + return orig_init(self, **positional, **keywords) cls.__init__ = _wrapped_init return cls diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index 804bb3a7..b0868e14 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -64,14 +64,14 @@ def test_read_setup_file(self): def test_extension_init(self): # the first argument, which is the name, must be a string - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="'name' must be a string"): Extension(1, []) ext = Extension('name', []) assert ext.name == 'name' # the second argument, which is the list of files, must # be an iterable of strings or PathLike objects, and not a string - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="'sources' must be an iterable"): Extension('name', 'file') with pytest.raises(TypeError): Extension('name', ['file', 1]) From a24af9205a932a5cad618bf1ea5374ff2f7115b0 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 28 Jan 2026 17:37:13 +0000 Subject: [PATCH 8/9] Add comment about what to do when backward compatibility is not needed. --- distutils/_dataclass.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/distutils/_dataclass.py b/distutils/_dataclass.py index b9aeb988..6a870d60 100644 --- a/distutils/_dataclass.py +++ b/distutils/_dataclass.py @@ -20,6 +20,10 @@ def lenient_dataclass(**dc_kwargs): - But we don't want to throw away the dataclass-generated __init__ specially because we don't want to have to redefine all the typing for the function arguments + + If/when lenient behaviour and backward compatibility are no longer needed, + the whole customization can be removed. + A regular ``dataclass`` could be used instead. """ @wraps(dataclass) From 7a083daba92a7c5cbb35615036c9f21b0a8b1ffd Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 29 Jan 2026 00:50:23 +0000 Subject: [PATCH 9/9] Add sanity check for type inference on Extension --- distutils/tests/test_extension.py | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index b0868e14..44ce3118 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -1,5 +1,7 @@ """Tests for distutils.extension.""" +from __future__ import annotations + import os import pathlib import re @@ -7,6 +9,7 @@ from dataclasses import field from distutils._dataclass import lenient_dataclass from distutils.extension import Extension, read_setup_file +from inspect import cleandoc import pytest @@ -141,3 +144,59 @@ class setuptools_Extension(Extension): assert "_full_name" not in ext2.__dict__ # not initialized by default ext2._full_name = "hello world" # can still be set in build_ext assert ext2._full_name == "hello world" + + +TYPE_INFERENCE = { + # Simple example + """ + from distutils.extension import Extension + + reveal_type(Extension.__init__) + """: [ + "name: builtins.str", + "sources: typing.Iterable[builtins.str | os.PathLike[builtins.str]]", + "include_dirs: builtins.list[builtins.str]", + ], + # Inheritance example + """ + from dataclasses import field + from distutils._dataclass import lenient_dataclass + from distutils.extension import Extension + + @lenient_dataclass() + class setuptools_Extension(Extension): + py_limited_api: bool = False + _full_name: str = field(init=False, repr=False) + + reveal_type(setuptools_Extension.__init__) + """: [ + "libraries: builtins.list[builtins.str]", + "swig_opts: builtins.list[builtins.str]", + "py_limited_api: builtins.bool", + "_full_name: builtins.str", + ], +} + + +@pytest.mark.filterwarnings("ignore::EncodingWarning") # mypy.api.run +@pytest.mark.parametrize("example,expectations", TYPE_INFERENCE.items()) +def test_inference_sanity_check( + tmp_path: pathlib.Path, example: str, expectations: list[str] +) -> None: + """Ensure type inference is working well for Extension and subclasses""" + from mypy import api + + f = tmp_path / "typecheck_file.py" + f.write_text(cleandoc(example), encoding="utf-8") + + # Use an empty config file to avoid interference with test + empty = tmp_path / "empty" + empty.touch() + result = api.run([os.fspath(f), "--config-file", os.fspath(empty)]) + + separator = 'note: Revealed type is "def (self:' + assert separator in result[0] + _, _, note = result[0].partition(separator) + + for expectation in expectations: + assert expectation in note