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

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.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.

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:

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.

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 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.
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:

  • markdown
  • md
  • text/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:

  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.