Skip to content

Commit ebfb2eb

Browse files
authored
Use dataclass to add type annotations to Extension so that it can easily be extended in setuptools. (#373)
2 parents b8672ad + 2566ee3 commit ebfb2eb

6 files changed

Lines changed: 299 additions & 136 deletions

File tree

distutils/_dataclass.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# This is a private module, but setuptools has the explicit permission to use it.
2+
from __future__ import annotations
3+
4+
import warnings
5+
from dataclasses import dataclass, fields
6+
from functools import wraps
7+
from typing import TypeVar
8+
9+
from .compat.py310 import dataclass_transform
10+
11+
_T = TypeVar("_T", bound=type)
12+
13+
14+
@dataclass_transform()
15+
def lenient_dataclass(**dc_kwargs):
16+
"""
17+
Build a dataclass whose ``__init__`` ignores unknown keyword arguments.
18+
19+
Customize ``__init__`` to preserve backwards compatibility and keep
20+
tolerating arbitrary keywords, but keep the dataclass-generated
21+
``__init__`` to avoid redefining the typing for all the arguments.
22+
23+
Drop this customization once lenient behaviour and backward
24+
compatibility are no longer needed and use a regular ``dataclass``
25+
instead.
26+
"""
27+
28+
@wraps(dataclass)
29+
def _wrap(cls: _T) -> _T: # type: ignore[var-annotated]
30+
cls = dataclass(**dc_kwargs)(cls)
31+
# Allowed field names in order
32+
safe = tuple(f.name for f in fields(cls))
33+
orig_init = cls.__init__
34+
35+
@wraps(orig_init)
36+
def _wrapped_init(self, *args, **kwargs):
37+
extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in safe}
38+
if extra:
39+
msg = f"""
40+
Please remove unknown `{cls.__name__}` options: {','.join(extra)}
41+
this kind of usage is deprecated and may cause errors in the future.
42+
"""
43+
warnings.warn(msg)
44+
45+
# Ensure default values (e.g. []) are used instead of None:
46+
positional = {
47+
k: v for k, v in zip(safe, args, strict=False) if v is not None
48+
}
49+
keywords = {k: v for k, v in kwargs.items() if v is not None}
50+
return orig_init(self, **positional, **keywords)
51+
52+
cls.__init__ = _wrapped_init
53+
return cls
54+
55+
return _wrap

distutils/compat/py310.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from collections.abc import Callable
5+
from typing import TYPE_CHECKING, Any, TypeVar
6+
7+
_T = TypeVar("_T")
8+
9+
if sys.version_info >= (3, 11):
10+
from typing import dataclass_transform
11+
else:
12+
if TYPE_CHECKING:
13+
# typing_extensions usually "exist" when type-checking,
14+
# without the need for extra runtime dependencies
15+
from typing_extensions import dataclass_transform
16+
else:
17+
# Runtime no-op
18+
def dataclass_transform( # type: ignore[misc]
19+
*,
20+
eq_default: bool | None = None,
21+
order_default: bool | None = None,
22+
kw_only_default: bool | None = None,
23+
field_specifiers: tuple[type[Any], ...] = (),
24+
**_: Any,
25+
) -> Callable[[_T], _T]:
26+
def _decorator(obj: _T) -> _T:
27+
return obj
28+
29+
return _decorator

distutils/core.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
DistutilsSetupError,
2626
)
2727
from .extension import Extension
28+
from .extension import _safe as extension_keywords # noqa # backwards compatibility
2829

2930
__all__ = ['Distribution', 'Command', 'Extension', 'setup']
3031

@@ -74,25 +75,6 @@ def gen_usage(script_name):
7475
'obsoletes',
7576
)
7677

77-
# Legal keyword arguments for the Extension constructor
78-
extension_keywords = (
79-
'name',
80-
'sources',
81-
'include_dirs',
82-
'define_macros',
83-
'undef_macros',
84-
'library_dirs',
85-
'libraries',
86-
'runtime_library_dirs',
87-
'extra_objects',
88-
'extra_compile_args',
89-
'extra_link_args',
90-
'swig_opts',
91-
'export_symbols',
92-
'depends',
93-
'language',
94-
)
95-
9678

9779
def setup(**attrs): # noqa: C901
9880
"""The gateway to the Distutils: do everything your setup script needs

distutils/extension.py

Lines changed: 109 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from __future__ import annotations
77

88
import os
9-
import warnings
109
from collections.abc import Iterable
10+
from dataclasses import field, fields
11+
12+
from ._dataclass import lenient_dataclass
1113

1214
# This class is really only used by the "build_ext" command, so it might
1315
# make sense to put it in distutils.command.build_ext. However, that
@@ -20,136 +22,133 @@
2022
# order to do anything.
2123

2224

25+
@lenient_dataclass()
2326
class Extension:
2427
"""Just a collection of attributes that describes an extension
2528
module and everything needed to build it (hopefully in a portable
2629
way, but there are hooks that let you be as unportable as you need).
30+
"""
31+
32+
name: str
33+
"""
34+
the full name of the extension, including any packages -- ie.
35+
*not* a filename or pathname, but Python dotted name
36+
"""
37+
38+
sources: Iterable[str | os.PathLike[str]]
39+
"""
40+
iterable of source filenames (except strings, which could be misinterpreted
41+
as a single filename), relative to the distribution root (where the setup
42+
script lives), in Unix form (slash-separated) for portability. Can be any
43+
non-string iterable (list, tuple, set, etc.) containing strings or
44+
PathLike objects. Source files may be C, C++, SWIG (.i), platform-specific
45+
resource files, or whatever else is recognized by the "build_ext" command
46+
as source for a Python extension.
47+
"""
48+
49+
include_dirs: list[str] = field(default_factory=list)
50+
"""
51+
list of directories to search for C/C++ header files (in Unix
52+
form for portability)
53+
"""
2754

28-
Instance attributes:
29-
name : string
30-
the full name of the extension, including any packages -- ie.
31-
*not* a filename or pathname, but Python dotted name
32-
sources : Iterable[string | os.PathLike]
33-
iterable of source filenames (except strings, which could be misinterpreted
34-
as a single filename), relative to the distribution root (where the setup
35-
script lives), in Unix form (slash-separated) for portability. Can be any
36-
non-string iterable (list, tuple, set, etc.) containing strings or
37-
PathLike objects. Source files may be C, C++, SWIG (.i), platform-specific
38-
resource files, or whatever else is recognized by the "build_ext" command
39-
as source for a Python extension.
40-
include_dirs : [string]
41-
list of directories to search for C/C++ header files (in Unix
42-
form for portability)
43-
define_macros : [(name : string, value : string|None)]
44-
list of macros to define; each macro is defined using a 2-tuple,
45-
where 'value' is either the string to define it to or None to
46-
define it without a particular value (equivalent of "#define
47-
FOO" in source or -DFOO on Unix C compiler command line)
48-
undef_macros : [string]
49-
list of macros to undefine explicitly
50-
library_dirs : [string]
51-
list of directories to search for C/C++ libraries at link time
52-
libraries : [string]
53-
list of library names (not filenames or paths) to link against
54-
runtime_library_dirs : [string]
55-
list of directories to search for C/C++ libraries at run time
56-
(for shared extensions, this is when the extension is loaded)
57-
extra_objects : [string]
58-
list of extra files to link with (eg. object files not implied
59-
by 'sources', static library that must be explicitly specified,
60-
binary resource files, etc.)
61-
extra_compile_args : [string]
62-
any extra platform- and compiler-specific information to use
63-
when compiling the source files in 'sources'. For platforms and
64-
compilers where "command line" makes sense, this is typically a
65-
list of command-line arguments, but for other platforms it could
66-
be anything.
67-
extra_link_args : [string]
68-
any extra platform- and compiler-specific information to use
69-
when linking object files together to create the extension (or
70-
to create a new static Python interpreter). Similar
71-
interpretation as for 'extra_compile_args'.
72-
export_symbols : [string]
73-
list of symbols to be exported from a shared extension. Not
74-
used on all platforms, and not generally necessary for Python
75-
extensions, which typically export exactly one symbol: "init" +
76-
extension_name.
77-
swig_opts : [string]
78-
any extra options to pass to SWIG if a source file has the .i
79-
extension.
80-
depends : [string]
81-
list of files that the extension depends on
82-
language : string
83-
extension language (i.e. "c", "c++", "objc"). Will be detected
84-
from the source extensions if not provided.
85-
optional : boolean
86-
specifies that a build failure in the extension should not abort the
87-
build process, but simply not install the failing extension.
55+
define_macros: list[tuple[str, str | None]] = field(default_factory=list)
56+
"""
57+
list of macros to define; each macro is defined using a 2-tuple,
58+
where 'value' is either the string to define it to or None to
59+
define it without a particular value (equivalent of "#define
60+
FOO" in source or -DFOO on Unix C compiler command line)
61+
"""
62+
63+
undef_macros: list[str] = field(default_factory=list)
64+
"""list of macros to undefine explicitly"""
65+
66+
library_dirs: list[str] = field(default_factory=list)
67+
"""list of directories to search for C/C++ libraries at link time"""
68+
69+
libraries: list[str] = field(default_factory=list)
70+
"""list of library names (not filenames or paths) to link against"""
71+
72+
runtime_library_dirs: list[str] = field(default_factory=list)
73+
"""
74+
list of directories to search for C/C++ libraries at run time
75+
(for shared extensions, this is when the extension is loaded)
76+
"""
77+
78+
extra_objects: list[str] = field(default_factory=list)
79+
"""
80+
list of extra files to link with (eg. object files not implied
81+
by 'sources', static library that must be explicitly specified,
82+
binary resource files, etc.)
8883
"""
8984

90-
# When adding arguments to this constructor, be sure to update
91-
# setup_keywords in core.py.
92-
def __init__(
93-
self,
94-
name: str,
95-
sources: Iterable[str | os.PathLike[str]],
96-
include_dirs: list[str] | None = None,
97-
define_macros: list[tuple[str, str | None]] | None = None,
98-
undef_macros: list[str] | None = None,
99-
library_dirs: list[str] | None = None,
100-
libraries: list[str] | None = None,
101-
runtime_library_dirs: list[str] | None = None,
102-
extra_objects: list[str] | None = None,
103-
extra_compile_args: list[str] | None = None,
104-
extra_link_args: list[str] | None = None,
105-
export_symbols: list[str] | None = None,
106-
swig_opts: list[str] | None = None,
107-
depends: list[str] | None = None,
108-
language: str | None = None,
109-
optional: bool | None = None,
110-
**kw, # To catch unknown keywords
111-
):
112-
if not isinstance(name, str):
85+
extra_compile_args: list[str] = field(default_factory=list)
86+
"""
87+
any extra platform- and compiler-specific information to use
88+
when compiling the source files in 'sources'. For platforms and
89+
compilers where "command line" makes sense, this is typically a
90+
list of command-line arguments, but for other platforms it could
91+
be anything.
92+
"""
93+
94+
extra_link_args: list[str] = field(default_factory=list)
95+
"""
96+
any extra platform- and compiler-specific information to use
97+
when linking object files together to create the extension (or
98+
to create a new static Python interpreter). Similar
99+
interpretation as for 'extra_compile_args'.
100+
"""
101+
102+
export_symbols: list[str] = field(default_factory=list)
103+
"""
104+
list of symbols to be exported from a shared extension. Not
105+
used on all platforms, and not generally necessary for Python
106+
extensions, which typically export exactly one symbol: "init" +
107+
extension_name.
108+
"""
109+
110+
swig_opts: list[str] = field(default_factory=list)
111+
"""
112+
any extra options to pass to SWIG if a source file has the .i
113+
extension.
114+
"""
115+
116+
depends: list[str] = field(default_factory=list)
117+
"""list of files that the extension depends on"""
118+
119+
language: str | None = None
120+
"""
121+
extension language (i.e. "c", "c++", "objc"). Will be detected
122+
from the source extensions if not provided.
123+
"""
124+
125+
optional: bool = False
126+
"""
127+
specifies that a build failure in the extension should not abort the
128+
build process, but simply not install the failing extension.
129+
"""
130+
131+
def __post_init__(self):
132+
if not isinstance(self.name, str):
113133
raise TypeError("'name' must be a string")
114134

115135
# handle the string case first; since strings are iterable, disallow them
116-
if isinstance(sources, str):
136+
if isinstance(self.sources, str):
117137
raise TypeError(
118138
"'sources' must be an iterable of strings or PathLike objects, not a string"
119139
)
120140

121141
# now we check if it's iterable and contains valid types
122142
try:
123-
self.sources = list(map(os.fspath, sources))
143+
self.sources = list(map(os.fspath, self.sources))
124144
except TypeError:
125145
raise TypeError(
126146
"'sources' must be an iterable of strings or PathLike objects"
127147
)
128148

129-
self.name = name
130-
self.include_dirs = include_dirs or []
131-
self.define_macros = define_macros or []
132-
self.undef_macros = undef_macros or []
133-
self.library_dirs = library_dirs or []
134-
self.libraries = libraries or []
135-
self.runtime_library_dirs = runtime_library_dirs or []
136-
self.extra_objects = extra_objects or []
137-
self.extra_compile_args = extra_compile_args or []
138-
self.extra_link_args = extra_link_args or []
139-
self.export_symbols = export_symbols or []
140-
self.swig_opts = swig_opts or []
141-
self.depends = depends or []
142-
self.language = language
143-
self.optional = optional
144-
145-
# If there are unknown keyword options, warn about them
146-
if len(kw) > 0:
147-
options = ', '.join(sorted([repr(option) for option in kw]))
148-
msg = f"Unknown Extension options: {options}"
149-
warnings.warn(msg)
150-
151-
def __repr__(self):
152-
return f'<{self.__class__.__module__}.{self.__class__.__qualname__}({self.name!r}) at {id(self):#x}>'
149+
150+
_safe = tuple(f.name for f in fields(Extension))
151+
"""Legal keyword arguments for the Extension constructor."""
153152

154153

155154
def read_setup_file(filename): # noqa: C901

0 commit comments

Comments
 (0)