13 KiB
Hydrogen Report Rendering and Custom Renderers
This document explains how report rendering works in Hydrogen, how the built-in JSON renderer behaves, and how to add your own renderer.
Renderers transform an AuditReport into a transport-ready RenderedReport. They are the formatting layer between raw audit data and delivery.
Where Rendering Happens in Hydrogen
Hydrogen builds reporting output through ReportService in reporting/service.py.
The render path is:
def render(self, report: AuditReport, report_config: ResolvedReportOutput) -> RenderedReport:
renderer = self._renderer_registry.get(report_config.renderer.type)
return renderer.render(report, report_config.renderer.config)
This means:
reports.outputs[].renderer.typeselects the renderer by itscontent_typeor alias.- The renderer receives the full
AuditReportand a validated configBaseModel. - The renderer returns a
RenderedReport. - The chosen transport publishes that rendered output.
Core Renderer Contract
The base class lives in core/base.py.
class BaseRenderer(ABC):
content_type: str
media_type: str
file_extension: str
aliases: tuple[str, ...] = ()
config_model: type[BaseModel] = EmptyPluginConfig
@abstractmethod
def render(self, report: AuditReport, config: BaseModel) -> RenderedReport: ...
Every Hydrogen renderer must define:
content_type— primary configuration key used inconfig.yaml.media_type— MIME type used by transports (e.g.,application/json).file_extension— default extension for file-based delivery (e.g.,.json).aliases— optional alternative lookup names (e.g.,("application/json",)).config_model— optional Pydantic model for renderer-specific configuration. Defaults toEmptyPluginConfig.render(report, config)— the rendering implementation.
The RenderedReport Shape
Renderers must return reporting.models.RenderedReport:
class RenderedReport(BaseModel):
format_name: str
media_type: str
file_extension: str
content: str
Field meanings:
format_name— logical renderer name such asjsonormarkdown.media_type— content type such asapplication/json.file_extension— extension such as.json.content— the final text payload.
This object is the handoff contract between Hydrogen renderers and Hydrogen transports.
Renderer Configuration
Renderers can accept configuration via config_model. Options are passed as extra fields alongside type in the YAML configuration.
reports:
outputs:
- renderer:
type: my_renderer
pretty: true
include_passed: false
Hydrogen resolves this in core/runtime.py:
def _resolve_plugin_config(plugin_type, payload, plugin):
config_model = ensure_plugin_config_model(plugin)
return ResolvedPluginConfig(
type=plugin_type,
config=config_model.model_validate(payload)
)
If your renderer uses EmptyPluginConfig (the default), any extra fields in YAML will cause a validation error because EmptyPluginConfig forbids extras. To accept options, define a custom config model.
Built-In Renderer: JSON
Implementation: reporting/exporters/json.py
class JsonRenderer(BaseRenderer):
content_type = "json"
media_type = "application/json"
file_extension = ".json"
aliases = ("application/json",)
config_model = EmptyPluginConfig
def render(self, report: AuditReport, config: EmptyPluginConfig) -> RenderedReport:
return RenderedReport(
format_name=self.content_type,
media_type=self.media_type,
file_extension=self.file_extension,
content=json.dumps(report.model_dump(mode="json"), ensure_ascii=True, indent=2),
)
Behavior:
- Serializes
AuditReportwithreport.model_dump(mode="json"). - Output is formatted with
json.dumps(..., ensure_ascii=True, indent=2). - The result is UTF-8 safe ASCII JSON text.
Two renderer lookup keys work out of the box:
jsonapplication/json
This is because RendererRegistry.register() stores both the primary content_type and every alias.
How Hydrogen Discovers Renderers
Renderer discovery is implemented in reporting/bootstrap.py and core/plugin_types.py.
Hydrogen discovers renderers from two sources:
- Package scan — Python packages listed in
plugin_packages.renderers(default:["reporting.exporters"]). - Entry points —
hydrogen.renderersentry points registered by installed packages.
def bootstrap_reporting(plugin_packages: PluginPackagesConfig) -> ReportingBootstrapResult:
renderer_descriptors, renderer_errors = discover_plugins(
PluginGroupConfig(
package_names=plugin_packages.renderers,
entry_point_group="hydrogen.renderers",
plugin_kind="renderer",
),
BaseRenderer,
)
...
The discovery process:
- Imports the package and all its submodules via
pkgutil.walk_packages. - Inspects each module for non-abstract subclasses of
BaseRenderer. - Filters out classes that are only imported (not defined) in that module.
- Instantiates each discovered class with no constructor arguments.
- Registers them in
RendererRegistry.
Important implications for custom renderers:
- Your renderer class must be defined in a module under one of the configured packages.
- It must be a concrete subclass of
BaseRenderer. - It must have a zero-argument constructor.
- Discovery ignores classes that are only re-exported from another module.
- Names and aliases are registered in a single dictionary — collisions raise
ValueError.
How Renderer Selection Works
Hydrogen selects the renderer by reports.outputs[].renderer.type from config.yaml.
reports:
outputs:
- renderer:
type: json
transport:
type: file
path: reports/latest
The lookup:
renderer = self._renderer_registry.get(report_config.renderer.type)
If there is no renderer for that key, RendererRegistry.get() raises:
ValueError("Renderer for content type '<value>' is not registered")
This is a runtime failure, not a configuration-schema failure. renderer.type is a plain string in PluginConfigRef.
Adding a Custom Renderer
To add a renderer in Hydrogen, create a module under one of the configured plugin packages and implement a BaseRenderer subclass.
Via Package (In-Repository)
Create a file under reporting/exporters/:
File: reporting/exporters/markdown.py
from pydantic import BaseModel, Field
from core.base import BaseRenderer
from reporting.models import AuditReport, RenderedReport
class MarkdownRendererConfig(BaseModel):
include_passed: bool = Field(True, description="Include modules with no findings")
class MarkdownRenderer(BaseRenderer):
content_type = "markdown"
media_type = "text/markdown"
file_extension = ".md"
aliases = ("md", "text/markdown")
config_model = MarkdownRendererConfig
def render(self, report: AuditReport, config: MarkdownRendererConfig) -> RenderedReport:
lines = [
"# Hydrogen Security Report",
"",
f"Generated at: {report.generated_at.isoformat()}",
f"Exit code: {report.exit_code}",
"",
]
for module_result in report.results:
if not config.include_passed and not module_result.result.findings:
continue
lines.append(f"## {module_result.module.name}")
lines.append(f"Status: {module_result.result.status}")
lines.append(f"Risk level: {module_result.result.risk_level}")
lines.append("")
if not module_result.result.findings:
lines.append("No findings.")
lines.append("")
continue
for finding in module_result.result.findings:
lines.append(f"- [{finding.severity}] {finding.name}: {finding.description}")
lines.append("")
return RenderedReport(
format_name=self.content_type,
media_type=self.media_type,
file_extension=self.file_extension,
content="\n".join(lines),
)
Then use it in configuration:
reports:
outputs:
- renderer:
type: markdown
include_passed: false
transport:
type: file
path: reports/security
append_extension: true
With the built-in file transport, that produces reports/security.md.
Via Entry Points (Third-Party Package)
If you distribute your renderer as a pip-installable package, register it via entry points:
[project.entry-points."hydrogen.renderers"]
markdown = "my_package.renderers:MarkdownRenderer"
The entry point value can be:
- A class reference (Hydrogen instantiates it).
- A pre-built instance.
Hydrogen validates that the result is a non-abstract subclass or instance of BaseRenderer.
Via plugin_packages Configuration
You can add additional packages for renderer discovery:
plugin_packages:
renderers:
- reporting.exporters
- mycompany.reporting_extras
Hydrogen scans all listed packages and their submodules for BaseRenderer subclasses.
Renderer Aliases
Aliases are optional, but useful.
aliases = ("md", "text/markdown")
This allows the following Hydrogen config values to point to the same renderer:
markdownmdtext/markdown
Registry behavior:
self._renderers[renderer.content_type] = renderer
for alias in renderer.aliases:
self._renderers[alias] = renderer
Renderer Name Collisions
Hydrogen stores renderers in a dictionary. If two renderers register the same content_type or alias, register() raises a ValueError with a descriptive message.
In bootstrap.py, this error is caught and converted to a PluginLoadError:
try:
renderer_registry.register(descriptor.plugin)
except ValueError as exc:
errors.append(PluginLoadError(plugin_kind="renderer", source=descriptor.source, message=str(exc)))
Choose unique names and aliases for custom renderers.
Configurable Renderer Behavior
Unlike the old architecture, Hydrogen now supports renderer-specific configuration via config_model.
Define a Pydantic model for your options:
class MyRendererConfig(BaseModel):
pretty: bool = Field(True)
max_findings: int = Field(50, ge=1)
Set it on the renderer class:
class MyRenderer(BaseRenderer):
content_type = "my_format"
config_model = MyRendererConfig
def render(self, report: AuditReport, config: MyRendererConfig) -> RenderedReport:
if config.pretty:
...
Configure it in YAML:
outputs:
- renderer:
type: my_format
pretty: false
max_findings: 100
The extra fields (pretty, max_findings) are extracted from PluginConfigRef.payload() and validated against MyRendererConfig.
The __init__.py Export
Renderers do not need to be re-exported from the package's __init__.py for discovery to work. Hydrogen discovers submodules directly with pkgutil.walk_packages.
An explicit export like this is optional:
from reporting.exporters.markdown import MarkdownRenderer
It can still be useful for developer ergonomics and explicit imports.
Troubleshooting Custom Renderers
If Hydrogen does not pick up your renderer, check the following:
- The file is inside one of the configured plugin packages.
- The class subclasses
BaseRenderer. - The class is not abstract.
- The class is defined in that module, not only imported into it.
- The class can be instantiated with no arguments.
config_modelis a valid PydanticBaseModelsubclass (if defined).reports.outputs[].renderer.typematches eithercontent_typeor one of the aliases.- The renderer returns a proper
RenderedReport. - The
render()method accepts two arguments:reportandconfig.
Design Guidance for Hydrogen Renderers
Good custom renderers usually follow these rules:
- Keep rendering pure and deterministic.
- Treat the renderer as a formatting layer, not a transport.
- Use
media_typeandfile_extensionconsistently so transports behave correctly. - Keep
content_typeshort and stable because it becomes part of configuration. - Return text content only, because
RenderedReport.contentis currently a string. - If you need configurable behavior, define a
config_modeland use theconfigparameter. - Do not rely on global state — all required inputs come through
render(report, config).
Summary
Hydrogen renderers are straightforward extension points:
- They live under configurable plugin packages (default:
reporting/exporters/). - They convert
AuditReportintoRenderedReport. - They are selected by
reports.outputs[].renderer.type. - They receive a typed config object validated by their
config_model. - They are auto-discovered from packages and entry points.
The main difference from the old architecture is that renderers now receive a config parameter and support per-renderer options through config_model.