# 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 '' 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`.