diff --git a/distutils/_dataclass.py b/distutils/_dataclass.py new file mode 100644 index 00000000..6a870d60 --- /dev/null +++ b/distutils/_dataclass.py @@ -0,0 +1,54 @@ +# 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 + + 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) + def _wrap(cls: _T) -> _T: + cls = dataclass(**dc_kwargs)(cls) + # 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} + return orig_init(self, **positional, **keywords) + + cls.__init__ = _wrapped_init + return cls + + return _wrap 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 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, 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..c9cf2b23 100644 --- a/distutils/extension.py +++ b/distutils/extension.py @@ -6,8 +6,10 @@ from __future__ import annotations import os -import warnings from collections.abc import Iterable +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 @@ -20,137 +22,133 @@ # order to do anything. +@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). + """ + + 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) + """ - 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. + 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.) """ - # 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): + 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. + """ + + 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. + """ + + 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(sources, str): + 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, sources)) + self.sources = list(map(os.fspath, self.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}>' + +# Legal keyword arguments for the Extension constructor +_safe = tuple(f.name for f in fields(Extension)) def read_setup_file(filename): # noqa: C901 diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py index 5e8e7682..44ce3118 100644 --- a/distutils/tests/test_extension.py +++ b/distutils/tests/test_extension.py @@ -1,12 +1,17 @@ """Tests for distutils.extension.""" +from __future__ import annotations + import os import pathlib +import re import warnings +from dataclasses import field +from distutils._dataclass import lenient_dataclass from distutils.extension import Extension, read_setup_file +from inspect import cleandoc import pytest -from test.support.warnings_helper import check_warnings class TestExtension: @@ -62,14 +67,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]) @@ -106,12 +111,92 @@ 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: + 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 + + @lenient_dataclass() + class setuptools_Extension(Extension): + py_limited_api: bool = False + _full_name: str = field(init=False, repr=False) + + ext1 = setuptools_Extension("name", ["hello.c"], py_limited_api=True) + assert ext1.py_limited_api is True + assert ext1.define_macros == [] + + # 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 + 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