428 lines
13 KiB
Markdown
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`.
|