first commit
This commit is contained in:
427
HYDROGEN_RENDERERS.md
Normal file
427
HYDROGEN_RENDERERS.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user