Files
hydrogen/HYDROGEN_RENDERERS.md
2026-07-11 23:54:36 +07:00

428 lines
13 KiB
Markdown

# 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:
```python
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.type` selects the renderer by its `content_type` or alias.
- The renderer receives the full `AuditReport` and a validated config `BaseModel`.
- The renderer returns a `RenderedReport`.
- The chosen transport publishes that rendered output.
## Core Renderer Contract
The base class lives in `core/base.py`.
```python
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 in `config.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 to `EmptyPluginConfig`.
- `render(report, config)` — the rendering implementation.
## The `RenderedReport` Shape
Renderers must return `reporting.models.RenderedReport`:
```python
class RenderedReport(BaseModel):
format_name: str
media_type: str
file_extension: str
content: str
```
Field meanings:
- `format_name` — logical renderer name such as `json` or `markdown`.
- `media_type` — content type such as `application/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.
```yaml
reports:
outputs:
- renderer:
type: my_renderer
pretty: true
include_passed: false
```
Hydrogen resolves this in `core/runtime.py`:
```python
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`
```python
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 `AuditReport` with `report.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:
- `json`
- `application/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:
1. **Package scan** — Python packages listed in `plugin_packages.renderers` (default: `["reporting.exporters"]`).
2. **Entry points** — `hydrogen.renderers` entry points registered by installed packages.
```python
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`.
```yaml
reports:
outputs:
- renderer:
type: json
transport:
type: file
path: reports/latest
```
The lookup:
```python
renderer = self._renderer_registry.get(report_config.renderer.type)
```
If there is no renderer for that key, `RendererRegistry.get()` raises:
```python
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`
```python
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:
```yaml
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:
```toml
[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:
```yaml
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.
```python
aliases = ("md", "text/markdown")
```
This allows the following Hydrogen config values to point to the same renderer:
- `markdown`
- `md`
- `text/markdown`
Registry behavior:
```python
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`:
```python
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:
```python
class MyRendererConfig(BaseModel):
pretty: bool = Field(True)
max_findings: int = Field(50, ge=1)
```
Set it on the renderer class:
```python
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:
```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:
```python
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:
1. The file is inside one of the configured plugin packages.
2. The class subclasses `BaseRenderer`.
3. The class is not abstract.
4. The class is defined in that module, not only imported into it.
5. The class can be instantiated with no arguments.
6. `config_model` is a valid Pydantic `BaseModel` subclass (if defined).
7. `reports.outputs[].renderer.type` matches either `content_type` or one of the aliases.
8. The renderer returns a proper `RenderedReport`.
9. The `render()` method accepts two arguments: `report` and `config`.
## Design Guidance for Hydrogen Renderers
Good custom renderers usually follow these rules:
1. Keep rendering pure and deterministic.
2. Treat the renderer as a formatting layer, not a transport.
3. Use `media_type` and `file_extension` consistently so transports behave correctly.
4. Keep `content_type` short and stable because it becomes part of configuration.
5. Return text content only, because `RenderedReport.content` is currently a string.
6. If you need configurable behavior, define a `config_model` and use the `config` parameter.
7. 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 `AuditReport` into `RenderedReport`.
- 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`.