1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/render_contract.py

274 lines
9.7 KiB
Python

"""Compatibility shim over the versioned manual projection boundary."""
from __future__ import annotations
import hashlib
import html
import json
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
from typing import Protocol, cast
from .errors import DocForgeError
from .manual_projection import build_manual_projection_package, build_manual_render_plan
from .models import ProjectSnapshot, RenderView
from .projection_contract import (
ManualRenderPlanV1,
ProjectionPackageV1,
ProjectionRenderResult,
)
from .projection_fragments import (
FragmentKey,
FragmentRecord,
ProjectionFragmentCache,
fragment_semantic_hash,
)
from .projection_worker import render_projection_in_worker
@dataclass(frozen=True)
class PreparedRender:
render_identity: str
output_hash: str
output: bytes
renderer: str
renderer_version: str
template_hash: str
projection_receipt: dict[str, object] | None = None
class Renderer(Protocol):
"""Fixed interface implemented by explicitly registered built-in renderers."""
renderer_id: str
renderer_version: str
def prepare(
self,
snapshot: ProjectSnapshot,
view: RenderView,
template_bytes: bytes,
*,
changeset_hash: str | None,
) -> PreparedRender: ...
class GenericHtmlRenderer:
"""Preserve the public v1 renderer API over the plan-only manual renderer."""
renderer_id = "generic_html"
contract_version = "1"
page_component_version = "manual.page@1"
def __init__(self, *, incremental: bool = True) -> None:
self.renderer_version = (
f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}"
)
self.incremental = incremental
def prepare(
self,
snapshot: ProjectSnapshot,
view: RenderView,
template_bytes: bytes,
*,
changeset_hash: str | None,
) -> PreparedRender:
selected = tuple(
node for node in snapshot.nodes if not view.families or node.family in view.families
)
selected_ids = {node.node_id for node in selected}
selected_edges = tuple(
edge
for edge in snapshot.edges
if edge.source_id in selected_ids and edge.target_id in selected_ids
)
template_hash = hashlib.sha256(template_bytes).hexdigest()
identity_payload = {
"schema_version": 1,
"project_id": snapshot.descriptor.project_id,
"adapter": snapshot.descriptor.adapter,
"source_hash": snapshot.source_hash,
"changeset_hash": changeset_hash,
"renderer": self.renderer_id,
"renderer_version": self.renderer_version,
"view": {
"id": view.view_id,
"title": view.title,
"families": list(view.families),
"output": view.output_path.relative_to(snapshot.descriptor.root).as_posix(),
},
"template_hash": template_hash,
"nodes": [
{
"id": node.node_id,
"content_hash": node.content_hash,
"source_path": node.source_path,
}
for node in selected
],
"edges": [edge.as_dict() for edge in selected_edges],
}
render_identity = hashlib.sha256(
json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash)
full_package = build_manual_projection_package(
plan,
template_bytes,
renderer_id=self.renderer_id,
renderer_version=self.renderer_version,
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
render_identity=render_identity,
)
if self.incremental:
result = self._incremental_result(
snapshot,
plan,
template_bytes,
full_package=full_package,
render_identity=render_identity,
)
else:
result = render_projection_in_worker(full_package)
if len(result.artifacts) != 1:
raise DocForgeError(
"invalid_projection",
"Manual renderer returned an unsupported artifact set",
)
output = result.artifacts[0].content
return PreparedRender(
render_identity=render_identity,
output_hash=hashlib.sha256(output).hexdigest(),
output=output,
renderer=self.renderer_id,
renderer_version=self.renderer_version,
template_hash=template_hash,
projection_receipt=result.receipt.as_dict(),
)
def _incremental_result(
self,
snapshot: ProjectSnapshot,
plan: ManualRenderPlanV1,
template_bytes: bytes,
*,
full_package: ProjectionPackageV1,
render_identity: str,
) -> ProjectionRenderResult:
cache = ProjectionFragmentCache(
snapshot.descriptor.root,
snapshot.descriptor.cache_root,
)
pages = cast(list[object], plan.document["pages"])
records: list[FragmentRecord | None] = []
missing: list[tuple[int, FragmentKey]] = []
for value in pages:
page = cast(dict[str, object], value)
key = FragmentKey.create(
projection_kind="manual",
renderer_id=self.renderer_id,
renderer_version=self.renderer_version,
component_version=self.page_component_version,
semantic_input_hash=fragment_semantic_hash(page),
)
record = cache.get(key)
if record is None:
missing.append((len(records), key))
records.append(record)
if not records:
return render_projection_in_worker(full_package)
full_result: ProjectionRenderResult | None = None
new_records: list[FragmentRecord] = []
if missing:
full_result = render_projection_in_worker(full_package)
fragments = self._extract_page_fragments(
pages,
full_result.artifacts[0].content,
)
try:
for position, key in missing:
record = FragmentRecord.create(key, fragments[position])
records[position] = record
new_records.append(record)
except DocForgeError:
return full_result
complete_records = [record for record in records if record is not None]
if len(complete_records) != len(records):
return full_result or render_projection_in_worker(full_package)
try:
incremental_package = build_manual_projection_package(
plan,
template_bytes,
fragment_records=[record.as_dict() for record in complete_records],
renderer_id=self.renderer_id,
renderer_version=self.renderer_version,
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
render_identity=render_identity,
)
incremental_result = render_projection_in_worker(incremental_package)
except DocForgeError:
cache.prune(())
return full_result or render_projection_in_worker(full_package)
if full_result is None:
cache.prune(tuple(record.key for record in complete_records))
return incremental_result
if tuple(
(artifact.artifact_id, artifact.media_type, artifact.content)
for artifact in incremental_result.artifacts
) != tuple(
(artifact.artifact_id, artifact.media_type, artifact.content)
for artifact in full_result.artifacts
):
cache.prune(())
return full_result
for record in new_records:
cache.put(record.key, record.content)
cache.prune(tuple(record.key for record in complete_records))
return incremental_result
@staticmethod
def _extract_page_fragments(
pages: list[object],
output: bytes,
) -> list[bytes]:
"""Extract exact deterministic page sections from one trusted full artifact."""
fragments: list[bytes] = []
cursor = 0
closing = b"</section>"
for value in pages:
page = cast(dict[str, object], value)
node_id = cast(str, page["node_id"])
marker = f'<section id="node-{html.escape(node_id, quote=True)}">'.encode()
start = output.find(marker, cursor)
end = output.find(closing, start + len(marker)) if start >= 0 else -1
if start < 0 or end < 0:
raise DocForgeError(
"invalid_projection",
"Full manual artifact does not contain its planned page fragments",
)
end += len(closing)
fragments.append(output[start:end])
cursor = end
return fragments
_RENDERERS: dict[str, type[GenericHtmlRenderer]] = {
GenericHtmlRenderer.renderer_id: GenericHtmlRenderer
}
def renderer_for(view: RenderView, *, incremental: bool = True) -> Renderer:
factory = _RENDERERS.get(view.renderer)
if factory is None:
raise DocForgeError(
"unsupported_renderer", "View does not name a supported built-in renderer"
)
return factory(incremental=incremental)
def relative_output(snapshot: ProjectSnapshot, path: Path) -> str:
return path.relative_to(snapshot.descriptor.root).as_posix()