Source code for bundle.pybind.pybind
# Copyright 2026 HorusElohim
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
pybind.py
Primary CLI entrypoint for loading, resolving, and building pybind11 bindings via
setup.py or direct build calls. Delegates extension creation and custom build_ext
behavior to extension.py.
"""
from __future__ import annotations
import asyncio
import multiprocessing
import os
from pathlib import Path
import toml
from setuptools import setup as setuptools_setup
from bundle.core import logger, process, tracer
from .extension import ExtensionBuild, ExtensionSpec
from .plugins import PybindPluginResolved, PybindPluginSpec
from .resolved import ProjectResolved
from .resolvers import ProjectResolver
from .specs import ProjectSpec
log = logger.get_logger(__name__)
[docs]
class Pybind:
"""
Orchestrates reading pyproject.toml, applying plugins, resolving module specs,
and constructing Extension objects via extension.make_extension.
"""
def __init__(
self,
pyproject_path: Path | str,
plugins: list[object] | None = None,
) -> None:
self.pyproject = Path(pyproject_path)
self.base_dir = self.pyproject.parent
self.spec = self._load_project_spec()
self.resolver = ProjectResolver()
self.resolved: ProjectResolved | None = None
self.plugins: list[object] = plugins or []
@tracer.Sync.decorator.call_raise
def _load_project_spec(self) -> ProjectSpec:
if not self.pyproject.exists():
raise FileNotFoundError(f"{self.pyproject} does not exist")
data = toml.load(self.pyproject)
cfg = data.get("tool", {}).get("pybind11")
if cfg is None:
raise KeyError("Missing [tool.pybind11] in pyproject.toml")
log.debug("Loaded spec: %s", cfg)
return ProjectSpec(**cfg)
[docs]
@tracer.Sync.decorator.call_raise
def register_plugin(self, plugin: object) -> None:
self.plugins.append(plugin)
async def _apply_plugins(
self,
modules: list,
plugin_type: type,
) -> None:
tasks: list = []
for plugin in self.plugins:
if isinstance(plugin, plugin_type):
log.debug("Applying plugin %s", plugin)
tasks.extend(plugin.apply(m) for m in modules)
if tasks:
await asyncio.gather(*tasks)
[docs]
@tracer.Async.decorator.call_raise
async def apply_spec_plugins(self) -> None:
"""
Run all PybindPluginSpec instances against raw module specs.
"""
await self._apply_plugins(self.spec.modules, PybindPluginSpec)
[docs]
@tracer.Async.decorator.call_raise
async def apply_resolved_plugins(self) -> None:
"""
Run all PybindPluginResolved instances against resolved modules.
"""
if not self.resolved:
raise ValueError("Must resolve before applying resolved plugins")
await self._apply_plugins(self.resolved.modules, PybindPluginResolved)
[docs]
@tracer.Async.decorator.call_raise
async def resolve(self) -> ProjectResolved:
await self._apply_plugins(self.spec.modules, PybindPluginSpec)
self.resolved = await self.resolver.resolve(self.spec)
await self._apply_plugins(self.resolved.modules, PybindPluginResolved)
return self.resolved
[docs]
@tracer.Async.decorator.call_raise
async def get_spec_extensions(self) -> list[ExtensionBuild]:
"""
Resolve the project and build all Extension objects concurrently.
"""
resolved = await self.resolve()
tasks = [ExtensionSpec.from_module_resolved(m) for m in resolved.modules]
return list(await asyncio.gather(*tasks))
[docs]
@classmethod
@tracer.Sync.decorator.call_raise
def setup(
cls,
invoking_file: Path | str,
**kwargs,
) -> None:
"""
Entry point for setup.py: build Extension list and invoke setuptools.setup.
"""
root = Path(invoking_file).parent.resolve()
pyproject = root / "pyproject.toml"
pyb = cls(pyproject, plugins=kwargs.pop("plugins", []))
exts = asyncio.run(pyb.get_spec_extensions())
kwargs.setdefault("ext_modules", []).extend(exts)
kwargs.setdefault("cmdclass", {})["build_ext"] = ExtensionBuild
setuptools_setup(**kwargs)
[docs]
@classmethod
@tracer.Async.decorator.call_raise
async def build(
cls,
path: str,
parallel: int = multiprocessing.cpu_count(),
):
"""
Shell out to `python setup.py build_ext` with optional parallel.
"""
module_path = Path(path).resolve()
cmd = f"python {module_path / 'setup.py'} build_ext"
if parallel:
cmd += f" --parallel {parallel}"
env = os.environ.copy()
proc = process.Process(name="Pybind.build")
return await proc(cmd, cwd=str(module_path), env=env)
[docs]
@classmethod
@tracer.Async.decorator.call_raise
async def info(cls, path: str):
"""
Load and resolve project for inspection without building.
"""
pyproject = Path(path).resolve() / "pyproject.toml"
pyb = cls(pyproject)
resolved = await pyb.resolve()
log.info(await resolved.as_json())
return resolved