Skip to content
2 changes: 1 addition & 1 deletion distutils/compilers/C/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #374


# XXX things not handled by this compiler abstraction model:
# * client can't provide additional options for a compiler,
Expand Down
20 changes: 1 addition & 19 deletions distutils/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
DistutilsSetupError,
)
from .extension import Extension
from .extension import _safe as extension_keywords # noqa # backwards compatibility

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

Expand Down Expand Up @@ -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
Expand Down
249 changes: 137 additions & 112 deletions distutils/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,138 +22,161 @@
# 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
# 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.
*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.)
"""

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.
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.
"""

# 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_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.
"""


# Legal keyword arguments for the Extension constructor
Comment thread
abravalheri marked this conversation as resolved.
Outdated
_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)
Comment thread
abravalheri marked this conversation as resolved.
Outdated

# 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")

# 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}>'


def read_setup_file(filename): # noqa: C901
"""Reads a Setup file and returns Extension instances."""
Expand Down
49 changes: 44 additions & 5 deletions distutils/tests/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -106,12 +108,49 @@ 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

@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"
Loading