Skip to content

Commit 5d4473e

Browse files
authored
Implement declarative ext-modules in pyproject.toml ("experimental") (#4568)
2 parents 61f2906 + 592d089 commit 5d4473e

File tree

9 files changed

+420
-48
lines changed

9 files changed

+420
-48
lines changed

docs/userguide/ext_modules.rst

+41-12
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,41 @@ and all project metadata configuration in the ``pyproject.toml`` file:
2727
version = "0.42"
2828
2929
To instruct setuptools to compile the ``foo.c`` file into the extension module
30-
``mylib.foo``, we need to add a ``setup.py`` file similar to the following:
30+
``mylib.foo``, we need to define an appropriate configuration in either
31+
``pyproject.toml`` [#pyproject.toml]_ or ``setup.py`` file ,
32+
similar to the following:
3133

32-
.. code-block:: python
34+
.. tab:: pyproject.toml
3335

34-
from setuptools import Extension, setup
36+
.. code-block:: toml
3537
36-
setup(
37-
ext_modules=[
38-
Extension(
39-
name="mylib.foo", # as it would be imported
40-
# may include packages/namespaces separated by `.`
41-
42-
sources=["foo.c"], # all sources are compiled into a single binary file
43-
),
38+
[tool.setuptools]
39+
ext-modules = [
40+
{name = "mylib.foo", sources = ["foo.c"]}
4441
]
45-
)
42+
43+
.. tab:: setup.py
44+
45+
.. code-block:: python
46+
47+
from setuptools import Extension, setup
48+
49+
setup(
50+
ext_modules=[
51+
Extension(
52+
name="mylib.foo",
53+
sources=["foo.c"],
54+
),
55+
]
56+
)
57+
58+
The ``name`` value corresponds to how the extension module would be
59+
imported and may include packages/namespaces separated by ``.``.
60+
The ``sources`` value is a list of all source files that are compiled
61+
into a single binary file.
62+
Optionally any other parameter of :class:`setuptools.Extension` can be defined
63+
in the configuration file (but in the case of ``pyproject.toml`` they must be
64+
written using :wiki:`kebab-case` convention).
4665

4766
.. seealso::
4867
You can find more information on the `Python docs about C/C++ extensions`_.
@@ -168,6 +187,16 @@ Extension API Reference
168187
.. autoclass:: setuptools.Extension
169188

170189

190+
----
191+
192+
.. rubric:: Notes
193+
194+
.. [#pyproject.toml]
195+
Declarative configuration of extension modules via ``pyproject.toml`` was
196+
introduced recently and is still considered experimental.
197+
Therefore it might change in future versions of ``setuptools``.
198+
199+
171200
.. _Python docs about C/C++ extensions: https://docs.python.org/3/extending/extending.html
172201
.. _Cython: https://cython.readthedocs.io/en/stable/index.html
173202
.. _directory options: https://gcc.gnu.org/onlinedocs/gcc/Directory-Options.html

docs/userguide/pyproject_config.rst

+3
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ file, and can be set via the ``tool.setuptools`` table:
8888
Key Value Type (TOML) Notes
8989
========================= =========================== =========================
9090
``py-modules`` array See tip below.
91+
``ext-modules`` array of **Experimental** - Each item corresponds to a
92+
tables/inline-tables :class:`setuptools.Extension` object and may define
93+
the associated parameters in :wiki:`kebab-case`.
9194
``packages`` array or ``find`` directive See tip below.
9295
``package-dir`` table/inline-table Used when explicitly/manually listing ``packages``.
9396
------------------------- --------------------------- -------------------------

newsfragments/4568.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added support for defining ``ext-modules`` via ``pyproject.toml``
2+
(**EXPERIMENTAL**, may change in future releases).

setuptools/config/_apply_pyprojecttoml.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
from inspect import cleandoc
1818
from itertools import chain
1919
from types import MappingProxyType
20-
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Union
20+
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, TypeVar, Union
2121

2222
from .._path import StrPath
2323
from ..errors import RemovedConfigError
24+
from ..extension import Extension
2425
from ..warnings import SetuptoolsWarning
2526

2627
if TYPE_CHECKING:
@@ -35,6 +36,7 @@
3536
_ProjectReadmeValue: TypeAlias = Union[str, Dict[str, str]]
3637
_CorrespFn: TypeAlias = Callable[["Distribution", Any, StrPath], None]
3738
_Correspondence: TypeAlias = Union[str, _CorrespFn]
39+
_T = TypeVar("_T")
3840

3941
_logger = logging.getLogger(__name__)
4042

@@ -117,13 +119,14 @@ def json_compatible_key(key: str) -> str:
117119

118120

119121
def _set_config(dist: Distribution, field: str, value: Any):
122+
val = _PREPROCESS.get(field, _noop)(dist, value)
120123
setter = getattr(dist.metadata, f"set_{field}", None)
121124
if setter:
122-
setter(value)
125+
setter(val)
123126
elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:
124-
setattr(dist.metadata, field, value)
127+
setattr(dist.metadata, field, val)
125128
else:
126-
setattr(dist, field, value)
129+
setattr(dist, field, val)
127130

128131

129132
_CONTENT_TYPES = {
@@ -218,6 +221,17 @@ def _optional_dependencies(dist: Distribution, val: dict, _root_dir):
218221
dist.extras_require = {**existing, **val}
219222

220223

224+
def _ext_modules(dist: Distribution, val: list[dict]) -> list[Extension]:
225+
existing = dist.ext_modules or []
226+
args = ({k.replace("-", "_"): v for k, v in x.items()} for x in val)
227+
new = [Extension(**kw) for kw in args]
228+
return [*existing, *new]
229+
230+
231+
def _noop(_dist: Distribution, val: _T) -> _T:
232+
return val
233+
234+
221235
def _unify_entry_points(project_table: dict):
222236
project = project_table
223237
entry_points = project.pop("entry-points", project.pop("entry_points", {}))
@@ -376,6 +390,10 @@ def _acessor(obj):
376390
"license_files",
377391
}
378392

393+
_PREPROCESS = {
394+
"ext_modules": _ext_modules,
395+
}
396+
379397
_PREVIOUSLY_DEFINED = {
380398
"name": _attrgetter("metadata.name"),
381399
"version": _attrgetter("metadata.version"),

setuptools/config/_validate_pyproject/fastjsonschema_validations.py

+246-31
Large diffs are not rendered by default.

setuptools/config/pyprojecttoml.py

+3
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ def read_configuration(
130130
asdict["tool"] = tool_table
131131
tool_table["setuptools"] = setuptools_table
132132

133+
if "ext-modules" in setuptools_table:
134+
_ExperimentalConfiguration.emit(subject="[tool.setuptools.ext-modules]")
135+
133136
with _ignore_errors(ignore_option_errors):
134137
# Don't complain about unrelated errors (e.g. tools not using the "tool" table)
135138
subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}

setuptools/config/setuptools.schema.json

+81
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@
158158
"items": {"type": "string", "format": "python-module-name-relaxed"},
159159
"$comment": "TODO: clarify the relationship with ``packages``"
160160
},
161+
"ext-modules": {
162+
"description": "Extension modules to be compiled by setuptools",
163+
"type": "array",
164+
"items": {"$ref": "#/definitions/ext-module"}
165+
},
161166
"data-files": {
162167
"$$description": [
163168
"``dict``-like structure where each key represents a directory and",
@@ -254,6 +259,82 @@
254259
{"type": "string", "format": "pep561-stub-name"}
255260
]
256261
},
262+
"ext-module": {
263+
"$id": "#/definitions/ext-module",
264+
"title": "Extension module",
265+
"description": "Parameters to construct a :class:`setuptools.Extension` object",
266+
"type": "object",
267+
"required": ["name", "sources"],
268+
"additionalProperties": false,
269+
"properties": {
270+
"name": {
271+
"type": "string",
272+
"format": "python-module-name-relaxed"
273+
},
274+
"sources": {
275+
"type": "array",
276+
"items": {"type": "string"}
277+
},
278+
"include-dirs":{
279+
"type": "array",
280+
"items": {"type": "string"}
281+
},
282+
"define-macros": {
283+
"type": "array",
284+
"items": {
285+
"type": "array",
286+
"items": [
287+
{"description": "macro name", "type": "string"},
288+
{"description": "macro value", "oneOf": [{"type": "string"}, {"type": "null"}]}
289+
],
290+
"additionalItems": false
291+
}
292+
},
293+
"undef-macros": {
294+
"type": "array",
295+
"items": {"type": "string"}
296+
},
297+
"library-dirs": {
298+
"type": "array",
299+
"items": {"type": "string"}
300+
},
301+
"libraries": {
302+
"type": "array",
303+
"items": {"type": "string"}
304+
},
305+
"runtime-library-dirs": {
306+
"type": "array",
307+
"items": {"type": "string"}
308+
},
309+
"extra-objects": {
310+
"type": "array",
311+
"items": {"type": "string"}
312+
},
313+
"extra-compile-args": {
314+
"type": "array",
315+
"items": {"type": "string"}
316+
},
317+
"extra-link-args": {
318+
"type": "array",
319+
"items": {"type": "string"}
320+
},
321+
"export-symbols": {
322+
"type": "array",
323+
"items": {"type": "string"}
324+
},
325+
"swig-opts": {
326+
"type": "array",
327+
"items": {"type": "string"}
328+
},
329+
"depends": {
330+
"type": "array",
331+
"items": {"type": "string"}
332+
},
333+
"language": {"type": "string"},
334+
"optional": {"type": "boolean"},
335+
"py-limited-api": {"type": "boolean"}
336+
}
337+
},
257338
"file-directive": {
258339
"$id": "#/definitions/file-directive",
259340
"title": "'file:' directive",

setuptools/extension.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ class Extension(_Extension):
127127
:keyword bool py_limited_api:
128128
opt-in flag for the usage of :doc:`Python's limited API <python:c-api/stable>`.
129129
130-
:raises setuptools.errors.PlatformError: if 'runtime_library_dirs' is
130+
:raises setuptools.errors.PlatformError: if ``runtime_library_dirs`` is
131131
specified on Windows. (since v63)
132132
"""
133133

setuptools/tests/config/test_apply_pyprojecttoml.py

+21
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,27 @@ def test_invalid_module_name(self, tmp_path, monkeypatch, module):
324324
self.dist(module).py_modules
325325

326326

327+
class TestExtModules:
328+
def test_pyproject_sets_attribute(self, tmp_path, monkeypatch):
329+
monkeypatch.chdir(tmp_path)
330+
pyproject = Path("pyproject.toml")
331+
toml_config = """
332+
[project]
333+
name = "test"
334+
version = "42.0"
335+
[tool.setuptools]
336+
ext-modules = [
337+
{name = "my.ext", sources = ["hello.c", "world.c"]}
338+
]
339+
"""
340+
pyproject.write_text(cleandoc(toml_config), encoding="utf-8")
341+
with pytest.warns(pyprojecttoml._ExperimentalConfiguration):
342+
dist = pyprojecttoml.apply_configuration(Distribution({}), pyproject)
343+
assert len(dist.ext_modules) == 1
344+
assert dist.ext_modules[0].name == "my.ext"
345+
assert set(dist.ext_modules[0].sources) == {"hello.c", "world.c"}
346+
347+
327348
class TestDeprecatedFields:
328349
def test_namespace_packages(self, tmp_path):
329350
pyproject = tmp_path / "pyproject.toml"

0 commit comments

Comments
 (0)