Skip to content

Commit ff31f34

Browse files
committed
doc: add documentation for using extension modules
1 parent f3deb4c commit ff31f34

File tree

1 file changed

+367
-0
lines changed

1 file changed

+367
-0
lines changed

docs/building-extension-modules.md

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
---
2+
title: "Building extension modules"
3+
draft: false
4+
type: docs
5+
layout: single
6+
7+
menu:
8+
docs:
9+
weight: 125
10+
---
11+
12+
# Building Extension Modules
13+
14+
{{% warning %}}
15+
While this feature has been around since almost the beginning of the Poetry project and has needed minimal changes,
16+
it is still considered unstable. You can participate in the discussions about stabilizing this feature
17+
[here](https://github.com/python-poetry/poetry/issues/2740).
18+
19+
And as always, your contributions towards the goal of improving this feature are also welcome.
20+
{{% /warning %}}
21+
22+
Poetry allows a project developer to introduce support for, build and distribute native extensions within their project.
23+
In order to achieve this, at the highest level, the following steps are required.
24+
25+
{{< steps >}}
26+
{{< step >}}
27+
**Add Build Dependencies**
28+
29+
The build dependencies, in this context, refer to those Python packages that are required in order to successfully execute
30+
your build script. Common examples include `cython`, `meson`, `maturin`, `setuptools` etc., depending on how your
31+
extension is built.
32+
33+
{{% note %}}
34+
You must assume that only Python built-ins are available by default in a build environment. This means, if you need
35+
even packages like `setuptools`, it must be explicitly declared.
36+
{{% /note %}}
37+
38+
The necessary build dependencies must be added to the `build-system.requires` section of your `pyproject.toml` file.
39+
40+
```toml
41+
[build-system]
42+
requires = ["poetry-core", "setuptools", "cython"]
43+
build-backend = "poetry.core.masonry.api"
44+
```
45+
46+
{{% note %}}
47+
It is recommended that you consider specifying version constraints to all entries in `build-system.requires` in order to
48+
avoid surprises if one of the packages introduce a breaking change. For example, you can set `cython` to
49+
`cython>=3.0.11,<4.0.0` to ensure no major version upgrades are used.
50+
{{% /note %}}
51+
52+
{{% note %}}
53+
If you wish to develop the build script within your project's virtual environment, then you must also add the
54+
dependencies to your project explicitly to a dependency group - the name of which is not important.
55+
56+
```sh
57+
poetry add --group=build setuptools cython
58+
```
59+
{{% /note %}}
60+
61+
{{< /step >}}
62+
63+
{{< step >}}
64+
**Add Build Script**
65+
66+
The build script can be a free-form Python script that uses any dependency specified in the previous step. This can be
67+
named as needed, but **must** be located within the project root directory (or a subdirectory) and also **must**
68+
be included in your source distribution. You can see the [example snippets section]({{< relref "#example-snippets" >}})
69+
for inspiration.
70+
71+
{{% note %}}
72+
The build script is always executed from the project root. And it is expected to move files around to their destinations
73+
as expected by Poetry as per your `pyproject.toml` file.
74+
{{% /note %}}
75+
76+
```toml
77+
[tool.poetry.build]
78+
script = "relative/path/to/build-extension.py"
79+
```
80+
81+
{{% note %}}
82+
The name of the build script is arbitrary. Common practice has been to name it `build.py`, however this is not
83+
mandatory. You **should** consider [using a subdirectory]({{< relref "#can-i-store-the-build-script-in-a-subdirectory" >}})
84+
if feasible.
85+
{{% /note %}}
86+
87+
{{< /step >}}
88+
89+
{{< step >}}
90+
**Specify Distribution Files**
91+
92+
{{% warning %}}
93+
The following is an example, and should not be considered as complete.
94+
{{% /warning %}}
95+
96+
```toml
97+
[tool.poetry]
98+
...
99+
include = [
100+
{ path = "package/**/*.so", format = "wheel" },
101+
]
102+
```
103+
104+
The key takeaway here should be the following. You can refer to the [`pyproject.toml`]({{< relref "pyproject#exclude-and-include" >}})
105+
documentation for information on each of the relevant sections.
106+
107+
1. Include your build outputs in your wheel.
108+
2. Exclude your build inputs from your wheel.
109+
3. Include your build inputs to your source distribution.
110+
111+
{{< /step >}}
112+
113+
{{< /steps >}}
114+
115+
## Example Snippets
116+
117+
### Cython
118+
119+
{{< tabs tabTotal="3" tabID1="cython-pyproject" tabName1="pyproject.toml" tabID2="cython-build-script" tabName2="build-extension.py" tabID3="cython-src-tree" tabName3="Source Tree">}}
120+
121+
{{< tab tabID="cython-pyproject" >}}
122+
123+
```toml
124+
[build-system]
125+
requires = ["poetry-core", "cython", "setuptools"]
126+
build-backend = "poetry.core.masonry.api"
127+
128+
[tool.poetry]
129+
...
130+
packages = [
131+
{ include = "package", from = "src"},
132+
]
133+
include = [
134+
{ path = "src/package/**/*.so", format = "wheel" },
135+
]
136+
137+
[tool.poetry.build]
138+
script = "scripts/build-extension.py"
139+
```
140+
141+
{{< /tab >}}
142+
143+
{{< tab tabID="cython-build-script" >}}
144+
145+
```py
146+
from __future__ import annotations
147+
148+
import os
149+
import shutil
150+
151+
from pathlib import Path
152+
153+
from Cython.Build import cythonize
154+
from setuptools import Distribution
155+
from setuptools import Extension
156+
from setuptools.command.build_ext import build_ext
157+
158+
159+
COMPILE_ARGS = ["-march=native", "-O3", "-msse", "-msse2", "-mfma", "-mfpmath=sse"]
160+
LINK_ARGS = []
161+
INCLUDE_DIRS = []
162+
LIBRARIES = ["m"]
163+
164+
165+
def build() -> None:
166+
extensions = [
167+
Extension(
168+
"*",
169+
["src/package/*.pyx"],
170+
extra_compile_args=COMPILE_ARGS,
171+
extra_link_args=LINK_ARGS,
172+
include_dirs=INCLUDE_DIRS,
173+
libraries=LIBRARIES,
174+
)
175+
]
176+
ext_modules = cythonize(
177+
extensions,
178+
include_path=INCLUDE_DIRS,
179+
compiler_directives={"binding": True, "language_level": 3},
180+
)
181+
182+
distribution = Distribution({
183+
"name": "package",
184+
"ext_modules": ext_modules
185+
})
186+
187+
cmd = build_ext(distribution)
188+
cmd.ensure_finalized()
189+
cmd.run()
190+
191+
# Copy built extensions back to the project
192+
for output in cmd.get_outputs():
193+
output = Path(output)
194+
relative_extension = Path("src") / output.relative_to(cmd.build_lib)
195+
196+
shutil.copyfile(output, relative_extension)
197+
mode = os.stat(relative_extension).st_mode
198+
mode |= (mode & 0o444) >> 2
199+
os.chmod(relative_extension, mode)
200+
201+
202+
if __name__ == "__main__":
203+
build()
204+
```
205+
206+
{{< /tab >}}
207+
208+
{{< tab tabID="cython-src-tree" >}}
209+
210+
```
211+
scripts/
212+
└── build-extension.py
213+
src/
214+
└── package
215+
├── example.pyx
216+
└── __init__.py
217+
```
218+
219+
{{< /tab >}}
220+
221+
{{< /tabs >}}
222+
223+
### Meson
224+
225+
{{< tabs tabTotal="2" tabID1="meson-pyproject" tabName1="pyproject.toml" tabID2="meson-build-script" tabName2="build-extension.py">}}
226+
227+
{{< tab tabID="meson-pyproject" >}}
228+
229+
```toml
230+
[tool.poetry.build]
231+
script = "build-extension.py"
232+
233+
[build-system]
234+
requires = ["poetry-core", "meson"]
235+
build-backend = "poetry.core.masonry.api"
236+
```
237+
238+
{{< /tab >}}
239+
240+
{{< tab tabID="meson-build-script" >}}
241+
242+
```py
243+
from __future__ import annotations
244+
245+
import subprocess
246+
247+
from pathlib import Path
248+
249+
250+
def meson(*args):
251+
subprocess.call(["meson", *args])
252+
253+
254+
def build():
255+
build_dir = Path(__file__).parent.joinpath("build")
256+
build_dir.mkdir(parents=True, exist_ok=True)
257+
258+
meson("setup", build_dir.as_posix())
259+
meson("compile", "-C", build_dir.as_posix())
260+
meson("install", "-C", build_dir.as_posix())
261+
262+
263+
if __name__ == "__main__":
264+
build()
265+
```
266+
267+
{{< /tab >}}
268+
269+
{{< /tabs >}}
270+
271+
### Maturin
272+
273+
{{< tabs tabTotal="2" tabID1="maturin-pyproject" tabName1="pyproject.toml" tabID2="maturin-build-script" tabName2="build-extension.py">}}
274+
275+
{{< tab tabID="maturin-pyproject" >}}
276+
277+
```toml
278+
[tool.poetry.build]
279+
script = "build-extension.py"
280+
281+
[build-system]
282+
requires = ["poetry-core", "maturin"]
283+
build-backend = "poetry.core.masonry.api"
284+
```
285+
286+
{{< /tab >}}
287+
288+
{{< tab tabID="maturin-build-script" >}}
289+
290+
```py
291+
import os
292+
import shlex
293+
import shutil
294+
import subprocess
295+
import zipfile
296+
297+
from pathlib import Path
298+
299+
300+
def maturin(*args):
301+
subprocess.call(["maturin", *list(args)])
302+
303+
304+
def build():
305+
build_dir = Path(__file__).parent.joinpath("build")
306+
build_dir.mkdir(parents=True, exist_ok=True)
307+
308+
wheels_dir = Path(__file__).parent.joinpath("target/wheels")
309+
if wheels_dir.exists():
310+
shutil.rmtree(wheels_dir)
311+
312+
cargo_args = []
313+
if os.getenv("MATURIN_BUILD_ARGS"):
314+
cargo_args = shlex.split(os.getenv("MATURIN_BUILD_ARGS", ""))
315+
316+
maturin("build", "-r", *cargo_args)
317+
318+
# We won't use the wheel built by maturin directly since
319+
# we want Poetry to build it but, we need to retrieve the
320+
# compiled extensions from the maturin wheel.
321+
wheel = next(iter(wheels_dir.glob("*.whl")))
322+
with zipfile.ZipFile(wheel.as_posix()) as whl:
323+
whl.extractall(wheels_dir.as_posix())
324+
325+
for extension in wheels_dir.rglob("**/*.so"):
326+
shutil.copyfile(extension, Path(__file__).parent.joinpath(extension.name))
327+
328+
shutil.rmtree(wheels_dir)
329+
330+
331+
if __name__ == "__main__":
332+
build()
333+
```
334+
335+
{{< /tab >}}
336+
337+
{{< /tabs >}}
338+
339+
## FAQ
340+
### When is my build script executed?
341+
If your project uses a build script, it is run implicitly in the following scenarios.
342+
343+
1. When `poetry install` is run, it is executed prior to installing the project's root package.
344+
2. When `poetry build` is run, it is executed prior to building distributions.
345+
3. When a PEP 517 build is triggered from source or sdist by another build frontend.
346+
347+
### How does Poetry ensure my build script's dependencies are met?
348+
Prior to executing the build script, Poetry creates a temporary virtual environment with your project's active Python
349+
version and then installs all dependencies specified under `build-system.requires` into this environment. It should be
350+
noted that no packages will be present in this environment at the time of creation.
351+
352+
### Can I store the build script in a subdirectory?
353+
Yes you can. If storing the script in a subdirectory, your `pyproject.toml` might look something like this.
354+
355+
```toml
356+
[tool.poetry]
357+
...
358+
packages = [
359+
{ include = "package", from = "src"}
360+
]
361+
include = [
362+
{ path = "src/package/**/*.so", format = "wheel" },
363+
]
364+
365+
[tool.poetry.build]
366+
script = "scripts/build-extension.py"
367+
```

0 commit comments

Comments
 (0)