Bundler plugins
Plugins extend bunpy build with custom logic at two points in the bundle pipeline: the resolve hook intercepts module specifiers before bunpy decides what file to load, and the transform hook processes the raw file content before it is added to the bundle. Both hooks are written in Python and registered in pyproject.toml.
Plugin structure
A plugin is a Python module that exports a Plugin dataclass and a setup function:
# myplugin.py
from bunpy.build import Plugin, BuildContext, ResolveArgs, TransformArgs
plugin = Plugin(name="myplugin", version="0.1.0")
def setup(ctx: BuildContext) -> None:
ctx.on_resolve(on_resolve)
ctx.on_transform(on_transform)
def on_resolve(args: ResolveArgs) -> str | None:
# Return a file path to handle this import, or None to pass through
return None
def on_transform(args: TransformArgs) -> str | None:
# Return transformed source code, or None to pass through
return NoneRegister the plugin in pyproject.toml:
[tool.bunpy.build]
plugins = ["myplugin:plugin"]The string "myplugin:plugin" follows the module:attribute pattern - bunpy imports myplugin and reads its plugin attribute.
Resolve hook
The resolve hook fires for every import statement bunpy encounters during the static trace. Use it to redirect imports for non-standard modules:
def on_resolve(args: ResolveArgs) -> str | None:
# args.specifier: the import string, e.g. "myapp.config"
# args.importer: absolute path of the file containing the import
# args.kind: "import" | "from_import"
if args.specifier.endswith(".toml"):
# Map "config.toml" to its absolute path
import os
base = os.path.dirname(args.importer)
return os.path.join(base, args.specifier)
return None # let bunpy resolve normallyTransform hook
The transform hook fires after a file is read, before it is stored in the bundle. Return modified source code or None to leave the file unchanged:
def on_transform(args: TransformArgs) -> str | None:
# args.path: absolute path of the file being processed
# args.contents: raw file contents as a string
# args.loader: file extension, e.g. ".py" or ".toml"
if args.loader != ".toml":
return None
import tomllib, json
data = tomllib.loads(args.contents)
# Emit a .py module that exposes the TOML data as a dict
return f"data = {json.dumps(data, indent=2)}\n"Example: .toml imports
This full plugin lets you write from myapp import settings where settings maps to settings.toml:
# bunpy_toml_plugin.py
import os
import tomllib
import json
from bunpy.build import Plugin, BuildContext, ResolveArgs, TransformArgs
plugin = Plugin(name="toml-importer", version="0.1.0")
def setup(ctx: BuildContext) -> None:
ctx.on_resolve(on_resolve)
ctx.on_transform(on_transform)
def on_resolve(args: ResolveArgs) -> str | None:
if not args.specifier.endswith(".toml"):
return None
base = os.path.dirname(args.importer)
candidate = os.path.normpath(os.path.join(base, args.specifier))
if os.path.isfile(candidate):
return candidate
return None
def on_transform(args: TransformArgs) -> str | None:
if args.loader != ".toml":
return None
data = tomllib.loads(args.contents)
return f"# generated by toml-importer\ndata = {json.dumps(data, indent=2)}\n"# pyproject.toml
[tool.bunpy.build]
plugins = ["bunpy_toml_plugin:plugin"]# src/myapp/main.py
from . import settings # resolves to settings.toml
print(settings.data["database"]["host"])Example: Jinja2 template embedding
Embed .jinja2 templates as precompiled Python strings so they are included in the bundle:
# bunpy_jinja_plugin.py
from bunpy.build import Plugin, BuildContext, TransformArgs
plugin = Plugin(name="jinja2-loader", version="0.1.0")
def setup(ctx: BuildContext) -> None:
ctx.on_transform(on_transform)
def on_transform(args: TransformArgs) -> str | None:
if args.loader != ".jinja2":
return None
# Expose the template source as a module-level string
escaped = args.contents.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
return f'template_source = """{escaped}"""\n'In application code:
from jinja2 import Environment
from . import email_template # email_template.jinja2
env = Environment()
tmpl = env.from_string(email_template.template_source)
print(tmpl.render(name="Alice"))Example: Version injection
Inject the package version from pyproject.toml into the bundle at build time, so the shipped binary always knows its own version:
# bunpy_version_plugin.py
import tomllib
import os
from bunpy.build import Plugin, BuildContext, TransformArgs
plugin = Plugin(name="version-inject", version="0.1.0")
_project_version: str = ""
def setup(ctx: BuildContext) -> None:
global _project_version
pyproject_path = os.path.join(ctx.root, "pyproject.toml")
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
_project_version = data["project"]["version"]
ctx.on_transform(on_transform)
def on_transform(args: TransformArgs) -> str | None:
if args.loader != ".py":
return None
if "__VERSION__" not in args.contents:
return None
return args.contents.replace("__VERSION__", repr(_project_version))# src/myapp/__init__.py
__version__ = __VERSION__ # replaced with "1.2.3" at build timePlugin registration reference
[tool.bunpy.build]
plugins = [
"bunpy_toml_plugin:plugin",
"bunpy_jinja_plugin:plugin",
"bunpy_version_plugin:plugin",
]Plugins run in declaration order. The resolve hooks run in order until one returns a non-None value. The transform hooks run in order and chain - the output of hook N is passed as args.contents to hook N+1.
TransformArgs and ResolveArgs reference
ResolveArgs
| Field | Type | Description |
|---|---|---|
specifier | str | The import string from the source file |
importer | str | Absolute path of the file containing the import |
kind | str | "import" or "from_import" |
TransformArgs
| Field | Type | Description |
|---|---|---|
path | str | Absolute path of the file |
contents | str | Raw file contents |
loader | str | File extension including the dot, e.g. ".py" |
BuildContext
| Method | Description |
|---|---|
ctx.on_resolve(fn) | Register a resolve hook |
ctx.on_transform(fn) | Register a transform hook |
ctx.root | Absolute path to the project root |