Skip to content

Commit 833ac36

Browse files
authored
Generate entry_points.txt in built wheels (#46)
1 parent 9dbd3c2 commit 833ac36

8 files changed

Lines changed: 165 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,15 @@ jobs:
125125
python -c "import extra_utils; assert extra_utils.add(2, 3) == 5"
126126
python -c "import myadder_nanobind; assert myadder_nanobind.add(2, 3) == 5; assert myadder_nanobind.add_integers(10, 20) == 30; assert myadder_nanobind.greet('World').startswith('Hello'); print('myadder_nanobind OK')"
127127
python -c "import hello_bindings; hello_bindings.hello(); print('hello_bindings OK')"
128+
myadder add 2 3 | grep -q "= 5"
129+
myadder greet World | grep -q "Hello"
128130
shell: bash
129131
- name: Test editable package
130132
run: |
131133
pip uninstall -y myadder_pybind11
132134
pip install -e examples/basic-pybind11 --no-build-isolation
133135
python -c "import myadder_pybind11; assert myadder_pybind11.add(2, 3) == 5; assert myadder_pybind11.add_integers(10, 20) == 30; assert myadder_pybind11.greet('World').startswith('Hello'); print('myadder_pybind11 OK')"
136+
myadder add 2 3 | grep -q "= 5"
134137
shell: bash
135138
- name: Upload wheels
136139
uses: actions/upload-artifact@v4

docs/configuration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ or `CONAN_HOME` / `.conanrc`). Set
119119
`CONAN_PY_BUILD_PROFILE_AUTODETECT=1` to autodetect
120120
the profile instead of requiring `default`.
121121

122+
## Entry points (PEP 621)
123+
124+
`[project.scripts]`, `[project.gui-scripts]` and
125+
`[project.entry-points.*]` from `pyproject.toml` are
126+
written to `<pkg>.dist-info/entry_points.txt` in the
127+
wheel, per the PyPA
128+
[entry points specification](https://packaging.python.org/en/latest/specifications/entry-points/).
129+
Installers create the corresponding console/GUI
130+
wrappers at install time; runtime tools like
131+
`importlib.metadata.entry_points()` read them
132+
from this file.
133+
134+
```toml
135+
[project.scripts]
136+
mycli = "mypackage.cli:main"
137+
138+
[project.gui-scripts]
139+
mygui = "mypackage.gui:run"
140+
141+
[project.entry-points."myplugin.hooks"]
142+
on_event = "mypackage.hooks:on_event"
143+
```
144+
145+
The file is only written when at least one entry
146+
point is declared.
147+
122148
## License files (PEP 639)
123149

124150
Set `license-files` in `[project]`

examples/basic-pybind11/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
Example of a Python package with C++ code using **pybind11**.
44

5+
Showcases PEP 621 features wired through the backend: dynamic
6+
`version`, PEP 639 license files, and a [`project.scripts`][ep]
7+
entry that installs the `myadder` CLI as a real executable.
8+
Custom plugin-discovery groups (`[project.entry-points."…"]`)
9+
are also supported — see the
10+
[backend docs](https://conan-py-build.conan.io/configuration/).
11+
12+
[ep]: https://packaging.python.org/en/latest/specifications/entry-points/
13+
514
## Build and Install
615

716
```bash
@@ -25,6 +34,11 @@ pip install dist/myadder_pybind11-*.whl
2534
# Test it
2635
python -c "import myadder_pybind11; print(myadder_pybind11.add(2, 3)); print(myadder_pybind11.add_integers(10, 20)); print(myadder_pybind11.greet('World'))"
2736

37+
# Console script installed automatically via [project.scripts]
38+
myadder add 2 3
39+
myadder add-int 10 20
40+
myadder greet World
41+
2842
# Uninstall when done
2943
pip uninstall myadder-pybind11 -y
3044
```

examples/basic-pybind11/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ license = "MIT"
1010
license-files = ["LICENSE"]
1111
requires-python = ">=3.8"
1212

13+
[project.scripts]
14+
myadder = "myadder_pybind11.cli:main"
15+
1316
[tool.conan-py-build.version]
1417
file = "python/myadder_pybind11/__init__.py"
1518

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""CLI for myadder_pybind11; wired via [project.scripts] in pyproject.toml."""
2+
import argparse
3+
import sys
4+
5+
from myadder_pybind11 import add, add_integers, greet
6+
7+
8+
def main(argv=None) -> int:
9+
parser = argparse.ArgumentParser(prog="myadder")
10+
sub = parser.add_subparsers(dest="cmd", required=True)
11+
12+
p_add = sub.add_parser("add", help="Add two floats")
13+
p_add.add_argument("a", type=float)
14+
p_add.add_argument("b", type=float)
15+
16+
p_addi = sub.add_parser("add-int", help="Add two integers")
17+
p_addi.add_argument("a", type=int)
18+
p_addi.add_argument("b", type=int)
19+
20+
p_greet = sub.add_parser("greet", help="Greet someone")
21+
p_greet.add_argument("name")
22+
23+
args = parser.parse_args(argv)
24+
25+
if args.cmd == "add":
26+
add(args.a, args.b)
27+
elif args.cmd == "add-int":
28+
add_integers(args.a, args.b)
29+
elif args.cmd == "greet":
30+
greet(args.name)
31+
return 0
32+
33+
34+
if __name__ == "__main__":
35+
sys.exit(main())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "conan-py-build"
7-
version = "0.4.1"
7+
version = "0.4.2"
88
description = "A minimal PEP 517 compliant build backend that uses Conan to build Python C/C++ extensions"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/conan_py_build/build.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,37 @@ def _write_metadata_file(dist_info_dir: Path, metadata: dict, project_dir: Path)
304304
f.write(content)
305305

306306

307+
def _write_entry_points(dist_info_dir: Path, metadata: dict, project_dir: Path) -> None:
308+
"""Write entry_points.txt per PEP 621 / PyPA entry-points spec; skip if empty."""
309+
std = _get_standard_metadata(metadata, project_dir)
310+
groups: dict = {}
311+
if std.scripts:
312+
groups["console_scripts"] = std.scripts
313+
if std.gui_scripts:
314+
groups["gui_scripts"] = std.gui_scripts
315+
for group, entries in (std.entrypoints or {}).items():
316+
if not entries:
317+
continue
318+
if group in groups:
319+
raise RuntimeError(
320+
f"Entry point group {group!r} declared in both "
321+
"[project.entry-points] and [project.scripts]/[project.gui-scripts]."
322+
)
323+
groups[group] = entries
324+
if not groups:
325+
return
326+
327+
lines = []
328+
for group, entries in groups.items():
329+
lines.append(f"[{group}]")
330+
for name, target in entries.items():
331+
lines.append(f"{name} = {target}")
332+
lines.append("")
333+
334+
with (dist_info_dir / "entry_points.txt").open("w", encoding="utf-8", newline="\n") as f:
335+
f.write("\n".join(lines))
336+
337+
307338
def _create_dist_info(staging_dir: Path, metadata: dict, project_dir: Path) -> Path:
308339
"""Create .dist-info directory with metadata files."""
309340
name = _normalize_name(metadata.get("name", "unknown"))
@@ -313,6 +344,7 @@ def _create_dist_info(staging_dir: Path, metadata: dict, project_dir: Path) -> P
313344
dist_info_dir.mkdir(parents=True, exist_ok=True)
314345

315346
_write_metadata_file(dist_info_dir, metadata, project_dir)
347+
_write_entry_points(dist_info_dir, metadata, project_dir)
316348

317349
return dist_info_dir
318350

tests/test_unit.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_check_wheel_package_path,
2121
_get_wheel_packages,
2222
_create_dist_info,
23+
_write_entry_points,
2324
_build_wheel_with_tags,
2425
_copy_license_files_from_paths,
2526
_validate_version_config,
@@ -358,6 +359,56 @@ def test_prepare_metadata_dynamic_version_from_file(tmp_path, monkeypatch):
358359
assert "Version: 0.5.0" in (tmp_path / "meta" / name / "METADATA").read_text(encoding="utf-8")
359360

360361

362+
def test_write_entry_points_omits_file_when_no_entries(tmp_path):
363+
dist_info = tmp_path / "pkg-1.0.0.dist-info"
364+
dist_info.mkdir()
365+
_write_entry_points(dist_info, {"name": "pkg", "version": "1.0.0"}, tmp_path)
366+
assert not (dist_info / "entry_points.txt").exists()
367+
368+
369+
def test_write_entry_points_emits_all_section_kinds(tmp_path):
370+
"""scripts -> [console_scripts], gui-scripts -> [gui_scripts],
371+
entry-points."group" -> [group], all coexisting in the same file."""
372+
dist_info = tmp_path / "pkg-1.0.0.dist-info"
373+
dist_info.mkdir()
374+
metadata = {
375+
"name": "pkg",
376+
"version": "1.0.0",
377+
"scripts": {"mytool": "pkg.cli:main"},
378+
"gui-scripts": {"mygui": "pkg.gui:run"},
379+
"entry-points": {"rasterio.rio_commands": {"info": "pkg.info:info"}},
380+
}
381+
_write_entry_points(dist_info, metadata, tmp_path)
382+
content = (dist_info / "entry_points.txt").read_text(encoding="utf-8")
383+
assert "[console_scripts]\nmytool = pkg.cli:main" in content
384+
assert "[gui_scripts]\nmygui = pkg.gui:run" in content
385+
assert "[rasterio.rio_commands]\ninfo = pkg.info:info" in content
386+
387+
388+
def test_write_entry_points_reserved_group_in_entry_points_raises(tmp_path):
389+
"""Reserved group ('console_scripts') under [project.entry-points] is forbidden by PEP 621."""
390+
dist_info = tmp_path / "pkg-1.0.0.dist-info"
391+
dist_info.mkdir()
392+
metadata = {
393+
"name": "pkg",
394+
"version": "1.0.0",
395+
"scripts": {"cli": "pkg.cli:main"},
396+
"entry-points": {"console_scripts": {"other": "pkg.cli:other"}},
397+
}
398+
with pytest.raises(Exception):
399+
_write_entry_points(dist_info, metadata, tmp_path)
400+
401+
402+
def test_create_dist_info_writes_entry_points_when_scripts_declared(tmp_path):
403+
"""End-to-end: _create_dist_info emits entry_points.txt alongside METADATA."""
404+
staging = tmp_path / "staging"
405+
staging.mkdir()
406+
metadata = {"name": "pkg", "version": "1.0.0", "scripts": {"mytool": "pkg.cli:main"}}
407+
dist_info = _create_dist_info(staging, metadata, tmp_path)
408+
assert (dist_info / "METADATA").is_file()
409+
assert (dist_info / "entry_points.txt").is_file()
410+
411+
361412
def test_prepare_metadata_with_license_file(tmp_path, monkeypatch):
362413
"""License file declared in pyproject.toml is copied into .dist-info/licenses/."""
363414
(tmp_path / "pyproject.toml").write_text("""\

0 commit comments

Comments
 (0)