274 lines
9.7 KiB
Python
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()
|