Skip to content

Commit ae2a2cc

Browse files
committed
Separate __post_init__
1 parent 9fab878 commit ae2a2cc

File tree

2 files changed

+76
-36
lines changed

2 files changed

+76
-36
lines changed

distutils/extension.py

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ class _Extension:
3131

3232
# The use of a parent class as a "trick":
3333
# - We need to modify __init__ so to achieve backwards compatibility
34+
# and keep allowing arbitrary keywords to be ignored
3435
# - But we don't want to throw away the dataclass-generated __init__
35-
# - We also want to fool the typechecker to consider the same type
36-
# signature as the dataclass-generated __init__
36+
# specially because we don't want to have to redefine all the typing
37+
# for the function arguments
3738

3839
name: str
3940
"""
@@ -139,42 +140,42 @@ class _Extension:
139140
_safe = tuple(f.name for f in fields(_Extension))
140141

141142

142-
if TYPE_CHECKING:
143-
144-
@dataclass
145-
class Extension(_Extension):
146-
pass
147-
148-
else:
149-
150-
@dataclass(init=False)
151-
class Extension(_Extension):
152-
def __init__(self, name, sources, *args, **kwargs):
153-
if not isinstance(name, str):
154-
raise TypeError("'name' must be a string")
155-
156-
# handle the string case first; since strings are iterable, disallow them
157-
if isinstance(sources, str):
158-
raise TypeError(
159-
"'sources' must be an iterable of strings or PathLike objects, not a string"
160-
)
161-
162-
# now we check if it's iterable and contains valid types
163-
try:
164-
sources = list(map(os.fspath, sources))
165-
except TypeError:
166-
raise TypeError(
167-
"'sources' must be an iterable of strings or PathLike objects"
168-
)
143+
@dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required]
144+
class Extension(_Extension):
145+
if not TYPE_CHECKING:
169146

147+
def __init__(self, *args, **kwargs):
170148
extra = {repr(k): kwargs.pop(k) for k in tuple(kwargs) if k not in _safe}
171149
if extra:
172-
warnings.warn(f"Unknown Extension options: {','.join(extra)}")
150+
msg = f"""
151+
Please remove unknown `Extension` options: {','.join(extra)}
152+
this kind of usage is deprecated and may cause errors in the future.
153+
"""
154+
warnings.warn(msg)
173155

174156
# Ensure default values (e.g. []) are used instead of None:
175-
positional = {k: v for k, v in zip(_safe[2:], args) if v is not None}
157+
positional = {k: v for k, v in zip(_safe, args) if v is not None}
176158
keywords = {k: v for k, v in kwargs.items() if v is not None}
177-
super().__init__(name, sources, **positional, **keywords)
159+
super().__init__(**positional, **keywords)
160+
self.__post_init__() # does not seem to be called when customizing __init__
161+
162+
def __post_init__(self):
163+
if not isinstance(self.name, str):
164+
raise TypeError("'name' must be a string")
165+
166+
# handle the string case first; since strings are iterable, disallow them
167+
if isinstance(self.sources, str):
168+
raise TypeError(
169+
"'sources' must be an iterable of strings or PathLike objects, not a string"
170+
)
171+
172+
# now we check if it's iterable and contains valid types
173+
try:
174+
self.sources = list(map(os.fspath, self.sources))
175+
except TypeError:
176+
raise TypeError(
177+
"'sources' must be an iterable of strings or PathLike objects"
178+
)
178179

179180

180181
def read_setup_file(filename): # noqa: C901

distutils/tests/test_extension.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import os
44
import pathlib
5+
import re
56
import warnings
7+
from dataclasses import dataclass, field
68
from distutils.extension import Extension, read_setup_file
9+
from typing import TYPE_CHECKING
710

811
import pytest
9-
from test.support.warnings_helper import check_warnings
1012

1113

1214
class TestExtension:
@@ -109,9 +111,46 @@ def test_extension_init(self):
109111
assert ext.optional is False
110112

111113
# if there are unknown keyword options, warn about them
112-
with check_warnings() as w:
114+
msg = re.escape("unknown `Extension` options: 'chic'")
115+
with pytest.warns(UserWarning, match=msg) as w:
113116
warnings.simplefilter('always')
114117
ext = Extension('name', ['file1', 'file2'], chic=True)
115118

116-
assert len(w.warnings) == 1
117-
assert str(w.warnings[0].message) == "Unknown Extension options: 'chic'"
119+
assert len(w) == 1
120+
121+
122+
def test_can_be_extended_by_setuptools() -> None:
123+
# Emulate how it could be extended in setuptools
124+
125+
@dataclass(init=True if TYPE_CHECKING else False) # type: ignore[literal-required]
126+
class setuptools_Extension(Extension):
127+
py_limited_api: bool = False
128+
_full_name: str = field(init=False, repr=False)
129+
130+
if not TYPE_CHECKING:
131+
# Custom __init__ is only needed for backwards compatibility
132+
# (to ignore arbitrary keywords)
133+
134+
def __init__(self, *args, py_limited_api=False, **kwargs):
135+
self.py_limited_api = py_limited_api
136+
super().__init__(*args, **kwargs)
137+
138+
ext1 = setuptools_Extension("name", ["hello.c"], py_limited_api=True)
139+
assert ext1.py_limited_api is True
140+
assert ext1.define_macros == []
141+
142+
msg = re.escape("unknown `Extension` options: 'world'")
143+
with pytest.warns(UserWarning, match=msg):
144+
ext2 = setuptools_Extension("name", ["hello.c"], world=True) # type: ignore[call-arg]
145+
146+
assert "world" not in ext2.__dict__
147+
assert ext2.py_limited_api is False
148+
149+
# Without __init__ customization the following warning would be an error:
150+
msg = re.escape("unknown `Extension` options: '_full_name'")
151+
with pytest.warns(UserWarning, match=msg):
152+
ext3 = setuptools_Extension("name", ["hello.c"], _full_name="hello") # type: ignore[call-arg]
153+
154+
assert "_full_name" not in ext3.__dict__
155+
ext3._full_name = "hello world" # can still be set in build_ext
156+
assert ext3._full_name == "hello world"

0 commit comments

Comments
 (0)