first commit
This commit is contained in:
4
reporting/__init__.py
Normal file
4
reporting/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from reporting.bootstrap import build_renderer_registry, build_transport_registry
|
||||
from reporting.service import ReportService
|
||||
|
||||
__all__ = ["ReportService", "build_renderer_registry", "build_transport_registry"]
|
||||
81
reporting/bootstrap.py
Normal file
81
reporting/bootstrap.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.base import BaseRenderer, BaseTransport
|
||||
from core.plugin_types import PluginGroupConfig, PluginLoadError, discover_plugins
|
||||
from core.schemas.config import PluginPackagesConfig
|
||||
from reporting.registry import RendererRegistry
|
||||
from reporting.transport_registry import TransportRegistry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportingBootstrapResult:
|
||||
renderer_registry: RendererRegistry
|
||||
transport_registry: TransportRegistry
|
||||
errors: list[PluginLoadError]
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
transport_descriptors, transport_errors = discover_plugins(
|
||||
PluginGroupConfig(
|
||||
package_names=plugin_packages.transports,
|
||||
entry_point_group="hydrogen.transports",
|
||||
plugin_kind="transport",
|
||||
),
|
||||
BaseTransport,
|
||||
)
|
||||
|
||||
errors = [*renderer_errors, *transport_errors]
|
||||
|
||||
renderer_registry = RendererRegistry()
|
||||
for descriptor in renderer_descriptors:
|
||||
try:
|
||||
renderer_registry.register(descriptor.plugin)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
PluginLoadError(
|
||||
plugin_kind="renderer",
|
||||
source=descriptor.source,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
transport_registry = TransportRegistry()
|
||||
for descriptor in transport_descriptors:
|
||||
try:
|
||||
transport_registry.register(descriptor.plugin)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
PluginLoadError(
|
||||
plugin_kind="transport",
|
||||
source=descriptor.source,
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return ReportingBootstrapResult(
|
||||
renderer_registry=renderer_registry,
|
||||
transport_registry=transport_registry,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def build_renderer_registry(
|
||||
plugin_packages: PluginPackagesConfig | None = None,
|
||||
) -> RendererRegistry:
|
||||
return bootstrap_reporting(plugin_packages or PluginPackagesConfig()).renderer_registry
|
||||
|
||||
|
||||
def build_transport_registry(
|
||||
plugin_packages: PluginPackagesConfig | None = None,
|
||||
) -> TransportRegistry:
|
||||
return bootstrap_reporting(plugin_packages or PluginPackagesConfig()).transport_registry
|
||||
3
reporting/exporters/__init__.py
Normal file
3
reporting/exporters/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from reporting.exporters.json import JsonRenderer
|
||||
|
||||
__all__ = ["JsonRenderer"]
|
||||
21
reporting/exporters/json.py
Normal file
21
reporting/exporters/json.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
|
||||
from core.base import BaseRenderer
|
||||
from core.plugin_types import EmptyPluginConfig
|
||||
from reporting.models import AuditReport, RenderedReport
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
40
reporting/models.py
Normal file
40
reporting/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.schemas.manifest import ModuleManifest
|
||||
from core.schemas.results import AuditResults
|
||||
from core.schemas.status import AuditSeverity
|
||||
|
||||
|
||||
class PluginRuntimeError(BaseModel):
|
||||
plugin_kind: str
|
||||
plugin_name: str
|
||||
stage: str
|
||||
message: str
|
||||
|
||||
|
||||
class ModuleExecutionStats(BaseModel):
|
||||
duration_seconds: float = Field(ge=0)
|
||||
finding_counts: dict[AuditSeverity, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ModuleAuditResult(BaseModel):
|
||||
module: ModuleManifest
|
||||
result: AuditResults
|
||||
stats: ModuleExecutionStats | None = None
|
||||
error: PluginRuntimeError | None = None
|
||||
|
||||
|
||||
class AuditReport(BaseModel):
|
||||
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
results: list[ModuleAuditResult]
|
||||
exit_code: int
|
||||
plugin_errors: list[PluginRuntimeError] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RenderedReport(BaseModel):
|
||||
format_name: str
|
||||
media_type: str
|
||||
file_extension: str
|
||||
content: str
|
||||
32
reporting/registry.py
Normal file
32
reporting/registry.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from core.base import BaseRenderer
|
||||
|
||||
|
||||
class RendererRegistry:
|
||||
def __init__(self, renderers: Iterable[BaseRenderer] | None = None) -> None:
|
||||
self._renderers: dict[str, BaseRenderer] = {}
|
||||
|
||||
for renderer in renderers or ():
|
||||
self.register(renderer)
|
||||
|
||||
def register(self, renderer: BaseRenderer) -> None:
|
||||
self._register_key(renderer.content_type, renderer)
|
||||
for alias in renderer.aliases:
|
||||
self._register_key(alias, renderer)
|
||||
|
||||
def _register_key(self, key: str, renderer: BaseRenderer) -> None:
|
||||
if key in self._renderers:
|
||||
registered = self._renderers[key]
|
||||
raise ValueError(
|
||||
f"Renderer key '{key}' is already registered by {type(registered).__name__}"
|
||||
)
|
||||
self._renderers[key] = renderer
|
||||
|
||||
def get(self, content_type: str) -> BaseRenderer:
|
||||
try:
|
||||
return self._renderers[content_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Renderer for content type '{content_type}' is not registered"
|
||||
) from exc
|
||||
36
reporting/service.py
Normal file
36
reporting/service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from core.schemas.config import PluginPackagesConfig, ResolvedReportOutput
|
||||
from reporting.bootstrap import build_renderer_registry, build_transport_registry
|
||||
from reporting.models import AuditReport, ModuleAuditResult, PluginRuntimeError, RenderedReport
|
||||
from reporting.registry import RendererRegistry
|
||||
from reporting.transport_registry import TransportRegistry
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(
|
||||
self,
|
||||
renderer_registry: RendererRegistry | None = None,
|
||||
transport_registry: TransportRegistry | None = None,
|
||||
plugin_packages: PluginPackagesConfig | None = None,
|
||||
) -> None:
|
||||
self._renderer_registry = renderer_registry or build_renderer_registry(plugin_packages)
|
||||
self._transport_registry = transport_registry or build_transport_registry(plugin_packages)
|
||||
|
||||
def build_report(
|
||||
self,
|
||||
results: list[ModuleAuditResult],
|
||||
exit_code: int,
|
||||
plugin_errors: list[PluginRuntimeError] | None = None,
|
||||
) -> AuditReport:
|
||||
return AuditReport(results=results, exit_code=exit_code, plugin_errors=plugin_errors or [])
|
||||
|
||||
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)
|
||||
|
||||
def publish(self, report: AuditReport, report_config: ResolvedReportOutput) -> str:
|
||||
rendered_report = self.render(report, report_config)
|
||||
transport = self._transport_registry.get(report_config.transport.type)
|
||||
return transport.publish(rendered_report, report_config.transport)
|
||||
|
||||
def publish_many(self, report: AuditReport, outputs: list[ResolvedReportOutput]) -> list[str]:
|
||||
return [self.publish(report, output) for output in outputs]
|
||||
26
reporting/transport_registry.py
Normal file
26
reporting/transport_registry.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from core.base import BaseTransport
|
||||
|
||||
|
||||
class TransportRegistry:
|
||||
def __init__(self, transports: Iterable[BaseTransport] | None = None) -> None:
|
||||
self._transports: dict[str, BaseTransport] = {}
|
||||
|
||||
for transport in transports or ():
|
||||
self.register(transport)
|
||||
|
||||
def register(self, transport: BaseTransport) -> None:
|
||||
if transport.transport_type in self._transports:
|
||||
registered = self._transports[transport.transport_type]
|
||||
raise ValueError(
|
||||
"Transport "
|
||||
f"'{transport.transport_type}' is already registered by {type(registered).__name__}"
|
||||
)
|
||||
self._transports[transport.transport_type] = transport
|
||||
|
||||
def get(self, transport_type: str) -> BaseTransport:
|
||||
try:
|
||||
return self._transports[transport_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Transport '{transport_type}' is not registered") from exc
|
||||
4
reporting/transports/__init__.py
Normal file
4
reporting/transports/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from reporting.transports.file import FileTransport
|
||||
from reporting.transports.webhook import WebhookTransport
|
||||
|
||||
__all__ = ["FileTransport", "WebhookTransport"]
|
||||
25
reporting/transports/file.py
Normal file
25
reporting/transports/file.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.base import TypedTransport
|
||||
from reporting.models import RenderedReport
|
||||
|
||||
|
||||
class FileTransportConfig(BaseModel):
|
||||
path: str = Field()
|
||||
append_extension: bool = Field(True)
|
||||
|
||||
|
||||
class FileTransport(TypedTransport[FileTransportConfig]):
|
||||
transport_type = "file"
|
||||
config_model = FileTransportConfig
|
||||
|
||||
def publish_typed(self, rendered_report: RenderedReport, transport: FileTransportConfig) -> str:
|
||||
destination = Path(transport.path)
|
||||
if transport.append_extension and not destination.suffix:
|
||||
destination = destination.with_suffix(rendered_report.file_extension)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(rendered_report.content, encoding="utf-8")
|
||||
return str(destination)
|
||||
69
reporting/transports/webhook.py
Normal file
69
reporting/transports/webhook.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
from enum import StrEnum
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
|
||||
from core.base import TypedTransport
|
||||
from reporting.models import RenderedReport
|
||||
|
||||
|
||||
class WebhookMethod(StrEnum):
|
||||
POST = "POST"
|
||||
PUT = "PUT"
|
||||
PATCH = "PATCH"
|
||||
|
||||
|
||||
class WebhookPayloadMode(StrEnum):
|
||||
RENDERED = "rendered"
|
||||
ENVELOPE = "envelope"
|
||||
|
||||
|
||||
class WebhookTransportConfig(BaseModel):
|
||||
url: AnyHttpUrl = Field()
|
||||
method: WebhookMethod = Field(WebhookMethod.POST)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
timeout_seconds: float = Field(10.0, gt=0)
|
||||
payload_mode: WebhookPayloadMode = Field(WebhookPayloadMode.RENDERED)
|
||||
|
||||
|
||||
class WebhookTransport(TypedTransport[WebhookTransportConfig]):
|
||||
transport_type = "webhook"
|
||||
config_model = WebhookTransportConfig
|
||||
|
||||
def publish_typed(
|
||||
self, rendered_report: RenderedReport, transport: WebhookTransportConfig
|
||||
) -> str:
|
||||
body, headers = self._build_request(rendered_report, transport)
|
||||
request = Request(
|
||||
url=str(transport.url),
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=transport.method.value,
|
||||
)
|
||||
|
||||
with urlopen(request, timeout=transport.timeout_seconds) as response:
|
||||
return f"{response.status} {transport.url}"
|
||||
|
||||
def _build_request(
|
||||
self,
|
||||
rendered_report: RenderedReport,
|
||||
transport: WebhookTransportConfig,
|
||||
) -> tuple[bytes, dict[str, str]]:
|
||||
headers = dict(transport.headers)
|
||||
|
||||
if transport.payload_mode is WebhookPayloadMode.ENVELOPE:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"format": rendered_report.format_name,
|
||||
"content_type": rendered_report.media_type,
|
||||
"file_extension": rendered_report.file_extension,
|
||||
"content": rendered_report.content,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
return payload, headers
|
||||
|
||||
headers.setdefault("Content-Type", rendered_report.media_type)
|
||||
return rendered_report.content.encode("utf-8"), headers
|
||||
Reference in New Issue
Block a user