Exporter Plugin Seam#
The exporter is a pipeline (descriptors -> bindings -> runtime -> stubs) and
that pipeline is intentionally finite. Codegen passes that aren’t part of the
core contract – interrupt-trio detection, schema emission, .pyi
enrichment, project-specific output formats – belong outside exporter.py
so the core stays small and the extra passes stay swappable.
The seam for that extra work is the peakrdl_pybind11.exporter_plugins
package. Drop a module into the package, expose register(exporter), and
the exporter picks it up at construction time. Optionally implement
post_export(ctx) and the same plugin gets a chance to write extra files
once the main pipeline has finished.
This page documents that contract for library extenders.
Overview#
peakrdl_pybind11.exporter_plugins is a regular Python package. At
Pybind11Exporter.__init__ time, the exporter walks the package, imports
every module that does not start with _, and calls each module’s
register(exporter) function. A module that returns a non-None plugin
instance from register is also added to the post-export list – the
exporter will fire post_export(ctx) on it after the four built-in
codegen stages complete.
Two phases, two responsibilities:
register(exporter)runs early, before any codegen. It can install Jinja filters, stash references on the exporter for templates to read, or swap in a different template loader. This is the right place to influence the rendered output of the built-in pipeline.post_export(ctx)runs late, after every built-in stage has finished. It can read the collected node tree, the resolved options, and the output directory, and write entirely new artifacts alongside the generated module.
Plugins never modify exporter.py. Adding a feature is adding a file to
exporter_plugins/.
Two-phase model#
A plugin module has at most two entry points. Both are optional only in the sense that a plugin that does nothing in either phase is degenerate – a plugin that contributes anything at all implements at least one.
register(exporter) -> plugin_or_NoneCalled once during
Pybind11Exporter.__init__, before the exporter has a top node, an output directory, or a soc name. Use this phase to:install Jinja filters or globals on
exporter.envstash references on the exporter (e.g. an interrupt-pattern matcher)
extend
exporter._KNOWN_UDPSor register additional UDPs
The return value matters. If
registerreturnsNone, the plugin is treated as a one-shot configuration hook and never seen again. If it returns anything else, that object is appended to the post-export plugin list, andrun_post_exportwill look forpost_exporton it later.# peakrdl_pybind11/exporter_plugins/my_plugin.py def register(exporter): exporter.env.filters["upper_snake"] = lambda s: s.upper() return MyPlugin() # opt in to post_export
post_export(ctx) -> NoneAn optional method on the plugin instance returned from
register. Called byPybind11Exporter.export()after the main pipeline (descriptors, bindings, runtime, stubs) has finished writing files. Use this phase for late codegen:emit a
schema.jsondescribing the register layoutwalk the collected nodes and write a detected-interrupt-group manifest
append synthesized declarations to the generated
.pyiproduce documentation, lint files, or vendor-specific output
By the time
post_exportruns, every artifact the core promised is on disk. A plugin that crashes here cannot corrupt the main module – the exporter has already declared success on its part of the contract.
PluginContext dataclass#
run_post_export builds a PluginContext and passes it to every
post-export plugin. Plugins should treat the context as read-only – it
is the same instance for every plugin in the registration list.
Field |
Type |
Meaning |
|---|---|---|
|
|
The exporter instance. Useful for reading
|
|
|
The resolved top-of-design node. Already
unwrapped from |
|
|
The directory the main pipeline wrote into.
New artifacts should land here or under |
|
|
The sanitized module name (also the name of the sub-package directory created for the runtime). Safe to use as a Python and C++ identifier. |
|
|
The node lists collected by |
|
|
CLI-derived options. Stable keys today include |
The dataclass is frozen by construction; plugins should not mutate it. To
share state between two plugins, park the state on ctx.exporter
(plugins are loaded in deterministic order, so a producer plugin that
registers earlier in alphabetical order can stash data the consumer reads).
Programmatic registration#
For tests and downstream tooling that build plugins outside the package
directory, exporter_plugins exposes two helpers.
register_plugin(plugin) -> NoneAppend
pluginto the registry as if it had been auto-discovered. Useful in pytest fixtures that want to run a one-off plugin against a real exporter without dropping a file underexporter_plugins/.registered_plugins() -> listReturn the current list of registered plugins, in registration order. Test code uses this to assert “did my plugin actually get picked up?” or to take a snapshot, run an export, and roll the registry back.
from peakrdl_pybind11.exporter_plugins import (
register_plugin,
registered_plugins,
)
class _CountingPlugin:
def __init__(self) -> None:
self.calls = 0
def post_export(self, ctx) -> None:
self.calls += 1
plugin = _CountingPlugin()
register_plugin(plugin)
assert plugin in registered_plugins()
run_post_export#
run_post_export(ctx) is the entry point Pybind11Exporter.export()
calls after the main pipeline. It iterates the registered plugins in
registration order and invokes post_export(ctx) on each one that
defines it. Plugins without post_export are skipped silently.
Note
run_post_export swallows plugin exceptions and logs them. The main
exporter has already written every artifact it promised by the time
post-export runs, so a misbehaving plugin must not be allowed to mark
the export as failed. Failures are logged with the plugin’s module name
so downstream tooling can detect and report them, but the exporter
itself returns normally.
Plugin authors who want a hard failure should raise inside their own
post_export and check the log – the exporter will not propagate the
exception.
Worked example: feature_detection.py#
The in-tree plugin under src/peakrdl_pybind11/exporter_plugins/feature_detection.py
detects the intr_state / intr_enable / intr_test trio that
appears across OpenTitan, ARM, and many vendor SoCs, and emits a
schema.json describing the discovered interrupt groups. It exists for
two reasons:
The detection logic is interesting (regex matching, partial-trio handling, alias resolution) and would clutter
exporter.py.The
schema.jsonartifact is consumed by external tooling (the CLI’speakrdl exploresubcommand and downstream lint passes), so it has to land in the output directory but is not part of the generated Python module itself.
It uses both phases:
# peakrdl_pybind11/exporter_plugins/feature_detection.py
class FeatureDetectionPlugin:
def __init__(self) -> None:
self.interrupt_groups: list[dict] = []
def post_export(self, ctx) -> None:
pattern = ctx.options.get("interrupt_pattern")
self.interrupt_groups = _detect_interrupt_trios(
ctx.nodes["regs"], pattern=pattern,
)
_emit_schema_json(
ctx.output_dir,
ctx.soc_name,
ctx.top_node,
self.interrupt_groups,
)
def register(exporter):
plugin = FeatureDetectionPlugin()
exporter._feature_detection = plugin # let templates see it
return plugin # opt in to post_export
The register body returns the plugin instance, opting it into the
post-export phase. post_export reads the collected node lists out of
ctx.nodes, runs the detector against ctx.options["interrupt_pattern"],
and writes schema.json to both locations:
ctx.output_dir / "schema.json"– next to the C++ sources, where build tooling expects it.ctx.output_dir / ctx.soc_name / "schema.json"– inside the runtime package, so the generated module can import-time load it without a path lookup outside its own__init__.py.
This double-write is a deliberate convention for any artifact that needs to be both a build-time input and a runtime resource.
Custom plugin tutorial#
A short, self-contained example: a plugin that emits a Markdown register summary alongside the generated module. The whole plugin fits in a single file.
Project layout:
src/peakrdl_pybind11/
exporter.py
exporter_plugins/
__init__.py
feature_detection.py
markdown_summary.py # <-- new file
The plugin module:
# peakrdl_pybind11/exporter_plugins/markdown_summary.py
"""Emit a register summary as Markdown next to the generated module."""
from pathlib import Path
class MarkdownSummaryPlugin:
def post_export(self, ctx) -> None:
lines: list[str] = [f"# {ctx.soc_name}", ""]
for reg in ctx.nodes["regs"]:
offset = reg.absolute_address
desc = reg.get_property("desc") or ""
lines.append(f"## `{reg.get_path()}` @ 0x{offset:08x}")
if desc:
lines.append("")
lines.append(desc)
lines.append("")
for field in reg.fields():
bits = f"[{field.high}:{field.low}]"
lines.append(f"- **{field.inst_name}** {bits}")
lines.append("")
out = Path(ctx.output_dir) / f"{ctx.soc_name}.md"
out.write_text("\n".join(lines), encoding="utf-8")
def register(exporter):
return MarkdownSummaryPlugin()
Drop the file in, run an export, and <output_dir>/<soc_name>.md
materializes alongside the C++ and Python artifacts. No edits to
exporter.py, no changes to the CLI – the register callback is the
contract.
Discovery semantics#
Discovery walks peakrdl_pybind11.exporter_plugins once per
Pybind11Exporter instance. The rules:
Modules whose name starts with an underscore (
_helpers.py,_test_fixtures.py) are reserved for internals and are skipped. Use this convention for shared helpers that should not be auto-loaded as plugins.Sub-packages are walked the same way; a directory containing an
__init__.pyis treated as a single module unless its__init__re-exports its members.Modules that fail to import are logged and skipped; one broken plugin does not block the others.
registeris called exactly once per module, in alphabetical filename order. This makes the registration list deterministic and lets a plugin authored fora_priorityrely on running beforez_priority.
discover_plugins(exporter) -> listReturns the list of plugin instances that opted into post-export (i.e. the non-
Nonereturn values fromregister). Tests and introspection tools can call this directly to see which plugins are active for a given exporter.
A typical test-time usage:
exporter = Pybind11Exporter()
plugins = discover_plugins(exporter)
assert any(isinstance(p, FeatureDetectionPlugin) for p in plugins)
The list is in registration order; plugins added later by
register_plugin appear at the end.