Skip to content

Commit ce80f31

Browse files
committed
doc: add documentation for using extension modules
1 parent c70cbf4 commit ce80f31

File tree

1 file changed

+338
-0
lines changed

1 file changed

+338
-0
lines changed

docs/building-extension-modules.md

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
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 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 use 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+
If you wish to develop the build script within your project's virtual environment, then you must also add the
48+
dependencies to your project explicitly to a dependency group - the name of which is not important.
49+
50+
```sh
51+
poetry add --group=build setuptools cython
52+
```
53+
{{% /note %}}
54+
55+
{{< /step >}}
56+
57+
{{< step >}}
58+
**Add Build Script**
59+
60+
The build script can be free-form Python script that uses any dependency specified in the previous step. This can be
61+
named as needed, but **must** be located within the project root directory (or a subdirectory) and also **must**
62+
be included in your source distribution. You can see the [example snippets section]({{< relref "#example-snippets" >}})
63+
for inspiration.
64+
65+
```toml
66+
[tool.poetry.build]
67+
script = "build-extension.py"
68+
```
69+
70+
{{% note %}}
71+
The name of the build script is arbitrary. Common practice has been to name it `build.py`, however this is not
72+
mandatory. You **should** consider [using a subdirectory]({{< relref "#can-i-store-the-build-script-in-a-subdirectory" >}})
73+
if feasible.
74+
{{% /note %}}
75+
76+
{{< /step >}}
77+
78+
{{< step >}}
79+
**Specify Distribution Files**
80+
81+
{{% warning %}}
82+
The following is an example, and should not be considered as complete.
83+
{{% /warning %}}
84+
85+
```toml
86+
[tool.poetry]
87+
...
88+
packages = [
89+
{ include = "package", from = "src" }
90+
]
91+
include = [
92+
{ path = "src/package/**/*.so", format = "wheel" },
93+
{ path = "build-extension.py", format = "sdist" }
94+
]
95+
exclude = [
96+
{ path = "build-extension.py", format = "wheel" },
97+
{ path = "src/package/**/*.c", format = "wheel" }
98+
]
99+
```
100+
101+
The key takeaway here should be the following. You can refer to the [`pyproject.toml`]({{< relref "pyproject#exclude-and-include" >}})
102+
documentation for information on each of the relevant sections.
103+
104+
1. Include your build outputs in your wheel.
105+
2. Exclude your build inputs from your wheel.
106+
3. Include your build inputs to your source distribution.
107+
108+
{{< /step >}}
109+
110+
{{< /steps >}}
111+
112+
## Example Snippets
113+
114+
### Cython
115+
116+
{{< tabs tabTotal="2" tabID1="cython-pyproject" tabName1="pyproject.toml" tabID2="cython-build-script" tabName2="build-extension.py">}}
117+
118+
{{< tab tabID="cython-pyproject" >}}
119+
120+
```toml
121+
[tool.poetry.build]
122+
script = "build-extension.py"
123+
124+
[build-system]
125+
requires = ["poetry-core", "cython"]
126+
build-backend = "poetry.core.masonry.api"
127+
```
128+
129+
{{< /tab >}}
130+
131+
{{< tab tabID="cython-build-script" >}}
132+
133+
```py
134+
from __future__ import annotations
135+
136+
import os
137+
import shutil
138+
139+
from Cython.Build import cythonize
140+
from setuptools import Distribution
141+
from setuptools import Extension
142+
from setuptools.command.build_ext import build_ext
143+
144+
COMPILE_ARGS = ["-march=native", "-O3", "-msse", "-msse2", "-mfma", "-mfpmath=sse"]
145+
LINK_ARGS = []
146+
INCLUDE_DIRS = []
147+
LIBRARIES = ["m"]
148+
149+
150+
def build():
151+
extensions = [
152+
Extension(
153+
"*",
154+
["src/package/*.pyx"],
155+
extra_compile_args=COMPILE_ARGS,
156+
extra_link_args=LINK_ARGS,
157+
include_dirs=INCLUDE_DIRS,
158+
libraries=LIBRARIES,
159+
)
160+
]
161+
ext_modules = cythonize(
162+
extensions,
163+
include_path=INCLUDE_DIRS,
164+
compiler_directives={"binding": True, "language_level": 3},
165+
)
166+
167+
distribution = Distribution({
168+
"name": "extended",
169+
"ext_modules": ext_modules
170+
})
171+
172+
cmd = build_ext(distribution)
173+
cmd.ensure_finalized()
174+
cmd.run()
175+
176+
# Copy built extensions back to the project
177+
for output in cmd.get_outputs():
178+
relative_extension = os.path.relpath(output, cmd.build_lib)
179+
shutil.copyfile(output, relative_extension)
180+
mode = os.stat(relative_extension).st_mode
181+
mode |= (mode & 0o444) >> 2
182+
os.chmod(relative_extension, mode)
183+
184+
185+
if __name__ == "__main__":
186+
build()
187+
```
188+
189+
{{< /tab >}}
190+
191+
{{< /tabs >}}
192+
193+
### Meson
194+
195+
{{< tabs tabTotal="2" tabID1="meson-pyproject" tabName1="pyproject.toml" tabID2="meson-build-script" tabName2="build-extension.py">}}
196+
197+
{{< tab tabID="meson-pyproject" >}}
198+
199+
```toml
200+
[tool.poetry.build]
201+
script = "build-extension.py"
202+
203+
[build-system]
204+
requires = ["poetry-core", "meson"]
205+
build-backend = "poetry.core.masonry.api"
206+
```
207+
208+
{{< /tab >}}
209+
210+
{{< tab tabID="meson-build-script" >}}
211+
212+
```py
213+
from __future__ import annotations
214+
215+
import subprocess
216+
217+
from pathlib import Path
218+
219+
220+
def meson(*args):
221+
subprocess.call(["meson", *args])
222+
223+
224+
def build():
225+
build_dir = Path(__file__).parent.joinpath("build")
226+
build_dir.mkdir(parents=True, exist_ok=True)
227+
228+
meson("setup", build_dir.as_posix())
229+
meson("compile", "-C", build_dir.as_posix())
230+
meson("install", "-C", build_dir.as_posix())
231+
232+
233+
if __name__ == "__main__":
234+
build()
235+
```
236+
237+
{{< /tab >}}
238+
239+
{{< /tabs >}}
240+
241+
### Maturin
242+
243+
{{< tabs tabTotal="2" tabID1="maturin-pyproject" tabName1="pyproject.toml" tabID2="maturin-build-script" tabName2="build-extension.py">}}
244+
245+
{{< tab tabID="maturin-pyproject" >}}
246+
247+
```toml
248+
[tool.poetry.build]
249+
script = "build-extension.py"
250+
251+
[build-system]
252+
requires = ["poetry-core", "maturin"]
253+
build-backend = "poetry.core.masonry.api"
254+
```
255+
256+
{{< /tab >}}
257+
258+
{{< tab tabID="maturin-build-script" >}}
259+
260+
```py
261+
import os
262+
import shlex
263+
import shutil
264+
import subprocess
265+
import zipfile
266+
267+
from pathlib import Path
268+
269+
270+
def maturin(*args):
271+
subprocess.call(["maturin", *list(args)])
272+
273+
274+
def build():
275+
build_dir = Path(__file__).parent.joinpath("build")
276+
build_dir.mkdir(parents=True, exist_ok=True)
277+
278+
wheels_dir = Path(__file__).parent.joinpath("target/wheels")
279+
if wheels_dir.exists():
280+
shutil.rmtree(wheels_dir)
281+
282+
cargo_args = []
283+
if os.getenv("MATURIN_BUILD_ARGS"):
284+
cargo_args = shlex.split(os.getenv("MATURIN_BUILD_ARGS", ""))
285+
286+
maturin("build", "-r", *cargo_args)
287+
288+
# We won't use the wheel built by maturin directly since
289+
# we want Poetry to build it but, we need to retrieve the
290+
# compiled extensions from the maturin wheel.
291+
wheel = next(iter(wheels_dir.glob("*.whl")))
292+
with zipfile.ZipFile(wheel.as_posix()) as whl:
293+
whl.extractall(wheels_dir.as_posix())
294+
295+
for extension in wheels_dir.rglob("**/*.so"):
296+
shutil.copyfile(extension, Path(__file__).parent.joinpath(extension.name))
297+
298+
shutil.rmtree(wheels_dir)
299+
300+
301+
if __name__ == "__main__":
302+
build()
303+
```
304+
305+
{{< /tab >}}
306+
307+
{{< /tabs >}}
308+
309+
## FAQ
310+
### When is my build script executed?
311+
If your project uses a build script, it is run implicitly in the following scenarios.
312+
313+
1. When `poetry install` is run, it is executed prior to installing the project's root package.
314+
2. When `poetry build` is run, it is executed prior to building distributions.
315+
3. When a PEP 517 build is triggered from source or sdist by another build frontend.
316+
317+
### How does Poetry ensure my build script's dependencies are met?
318+
Prior to executing the build script, Poetry creates a temporary virtual environment with your project's active Python
319+
version and then installs all dependencies specified under `build-system.requires` into this environment. It should be
320+
noted that no packages will be present in this environment at the time of creation.
321+
322+
### Can I store the build script in a subdirectory?
323+
Yes you can. If storing the script in a subdirectory, your `pyproject.toml` might look something like this.
324+
325+
```toml
326+
[tool.poetry]
327+
...
328+
include = [
329+
{ path = "src/package/**/*.so", format = "wheel" },
330+
{ path = "scripts/build-extension.py", format = "sdist" }
331+
]
332+
exclude = [
333+
{ path = "scripts/build-extension.py", format = "wheel" },
334+
]
335+
336+
[tool.poetry.build]
337+
script = "scripts/build-extension.py"
338+
```

0 commit comments

Comments
 (0)