feat: add deterministic preview rendering
This commit is contained in:
parent
8c75f4f44d
commit
411f417670
23 changed files with 1413 additions and 142 deletions
299
src/docforge/rendering.py
Normal file
299
src/docforge/rendering.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""Confined preview and derived-output orchestration for declared render views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .changesets import ChangesetStore
|
||||
from .errors import DocForgeError
|
||||
from .models import ProjectSnapshot, RenderConfig, RenderView
|
||||
from .project import Project, project_root_fingerprint
|
||||
from .render_contract import PreparedRender, relative_output, renderer_for
|
||||
|
||||
|
||||
class RenderService:
|
||||
"""Render only declared views through fixed built-in renderer implementations."""
|
||||
|
||||
def __init__(self, project: Project, changesets: ChangesetStore | None = None) -> None:
|
||||
self.project = project
|
||||
self.changesets = changesets or ChangesetStore(project)
|
||||
|
||||
def status(self, view_id: str | None = None) -> dict[str, object]:
|
||||
snapshot = self.project.load()
|
||||
config = snapshot.descriptor.render
|
||||
if config is None:
|
||||
return self._result(
|
||||
snapshot,
|
||||
configured=False,
|
||||
state="not_configured",
|
||||
outputs=[],
|
||||
)
|
||||
views = self._views(config, view_id)
|
||||
outputs: list[dict[str, object]] = []
|
||||
for view in views:
|
||||
prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
|
||||
state = "missing"
|
||||
actual_hash: str | None = None
|
||||
output = view.output_path
|
||||
if output.is_symlink() or output.resolve(strict=False) != output:
|
||||
state = "unsafe"
|
||||
elif output.is_file():
|
||||
if output.stat().st_size > snapshot.descriptor.limits.max_render_bytes:
|
||||
state = "oversized"
|
||||
else:
|
||||
raw = output.read_bytes()
|
||||
actual_hash = hashlib.sha256(raw).hexdigest()
|
||||
state = "current" if actual_hash == prepared.output_hash else "stale"
|
||||
outputs.append(
|
||||
self._view_result(snapshot, view, prepared, state=state, actual_hash=actual_hash)
|
||||
)
|
||||
return self._result(
|
||||
snapshot,
|
||||
configured=True,
|
||||
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
|
||||
outputs=outputs,
|
||||
)
|
||||
|
||||
def render(self, view_id: str) -> dict[str, object]:
|
||||
with self._lock():
|
||||
snapshot = self.project.load()
|
||||
config = self._config(snapshot)
|
||||
view = self._views(config, view_id)[0]
|
||||
prepared, template_bytes = self._prepare(snapshot, view, changeset_hash=None)
|
||||
self._atomic_write(
|
||||
view.output_path,
|
||||
prepared.output,
|
||||
verify=lambda: self._verify_canonical(snapshot, view, template_bytes),
|
||||
)
|
||||
return self._result(
|
||||
snapshot,
|
||||
configured=True,
|
||||
state="current",
|
||||
output=self._view_result(
|
||||
snapshot,
|
||||
view,
|
||||
prepared,
|
||||
state="current",
|
||||
actual_hash=prepared.output_hash,
|
||||
),
|
||||
)
|
||||
|
||||
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
|
||||
with self._lock():
|
||||
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
|
||||
config = self._config(snapshot)
|
||||
view = self._views(config, view_id)[0]
|
||||
prepared, template_bytes = self._prepare(snapshot, view, changeset_hash=changeset_hash)
|
||||
preview_path = config.preview_root / changeset_id / f"{view.view_id}.html"
|
||||
if not preview_path.is_relative_to(config.preview_root):
|
||||
raise DocForgeError("path_escape", "Preview path escaped its configured root")
|
||||
|
||||
def verify() -> None:
|
||||
current, current_hash = self.changesets.projected_snapshot(changeset_id)
|
||||
if current.source_hash != snapshot.source_hash or current_hash != changeset_hash:
|
||||
raise DocForgeError(
|
||||
"render_input_changed",
|
||||
"Changeset or canonical input changed during preview",
|
||||
)
|
||||
self._verify_template(view, template_bytes)
|
||||
|
||||
self._atomic_write(preview_path, prepared.output, verify=verify, preview=True)
|
||||
return self._result(
|
||||
snapshot,
|
||||
configured=True,
|
||||
state="current",
|
||||
changeset_id=changeset_id,
|
||||
changeset_hash=changeset_hash,
|
||||
preview_identity=prepared.render_identity,
|
||||
preview={
|
||||
**self._view_result(
|
||||
snapshot,
|
||||
view,
|
||||
prepared,
|
||||
state="current",
|
||||
actual_hash=prepared.output_hash,
|
||||
),
|
||||
"path": relative_output(snapshot, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
def _prepare(
|
||||
self,
|
||||
snapshot: ProjectSnapshot,
|
||||
view: RenderView,
|
||||
*,
|
||||
changeset_hash: str | None,
|
||||
) -> tuple[PreparedRender, bytes]:
|
||||
template = self._template_bytes(snapshot, view)
|
||||
prepared = renderer_for(view).prepare(
|
||||
snapshot,
|
||||
view,
|
||||
template,
|
||||
changeset_hash=changeset_hash,
|
||||
)
|
||||
if len(prepared.output) > snapshot.descriptor.limits.max_render_bytes:
|
||||
raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
|
||||
return prepared, template
|
||||
|
||||
def _template_bytes(self, snapshot: ProjectSnapshot, view: RenderView) -> bytes:
|
||||
path = view.template_path
|
||||
config = self._config(snapshot)
|
||||
if (
|
||||
path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
or config.template_root.resolve(strict=False) != config.template_root
|
||||
or not path.is_file()
|
||||
or not path.is_relative_to(config.template_root)
|
||||
):
|
||||
raise DocForgeError("unsafe_template", "Render template is missing or unsafe")
|
||||
if path.stat().st_size > snapshot.descriptor.limits.max_template_bytes:
|
||||
raise DocForgeError(
|
||||
"template_too_large", "Render template exceeds the configured limit"
|
||||
)
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > snapshot.descriptor.limits.max_template_bytes:
|
||||
raise DocForgeError(
|
||||
"template_too_large", "Render template exceeds the configured limit"
|
||||
)
|
||||
return raw
|
||||
|
||||
def _verify_canonical(
|
||||
self, snapshot: ProjectSnapshot, view: RenderView, template_bytes: bytes
|
||||
) -> None:
|
||||
current = self.project.load()
|
||||
if current.source_hash != snapshot.source_hash:
|
||||
raise DocForgeError("render_input_changed", "Canonical input changed during rendering")
|
||||
self._verify_template(view, template_bytes)
|
||||
|
||||
@staticmethod
|
||||
def _verify_template(view: RenderView, template_bytes: bytes) -> None:
|
||||
if (
|
||||
view.template_path.is_symlink()
|
||||
or view.template_path.resolve(strict=False) != view.template_path
|
||||
or not view.template_path.is_file()
|
||||
):
|
||||
raise DocForgeError("render_input_changed", "Render template changed during rendering")
|
||||
if view.template_path.read_bytes() != template_bytes:
|
||||
raise DocForgeError("render_input_changed", "Render template changed during rendering")
|
||||
|
||||
def _atomic_write(
|
||||
self,
|
||||
output: Path,
|
||||
content: bytes,
|
||||
*,
|
||||
verify: Callable[[], None],
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
root = self.project.descriptor.root
|
||||
if output.is_symlink() or not output.is_relative_to(root):
|
||||
raise DocForgeError("path_escape", "Render output path is unsafe")
|
||||
parent = output.parent
|
||||
if parent.resolve(strict=False) != parent:
|
||||
raise DocForgeError("path_escape", "Render output directory is unsafe")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
if parent.resolve() != parent or not parent.is_relative_to(root):
|
||||
raise DocForgeError("path_escape", "Render output directory is unsafe")
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".docforge-render-", dir=parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
verify()
|
||||
if output.is_symlink():
|
||||
raise DocForgeError("path_escape", "Render output became unsafe")
|
||||
os.replace(temporary, output)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if preview:
|
||||
self._remove_empty_preview_parents(parent)
|
||||
raise
|
||||
|
||||
def _remove_empty_preview_parents(self, parent: Path) -> None:
|
||||
config = self.project.descriptor.render
|
||||
if config is None:
|
||||
return
|
||||
current = parent
|
||||
while current != config.preview_root:
|
||||
try:
|
||||
current.rmdir()
|
||||
except OSError:
|
||||
return
|
||||
current = current.parent
|
||||
|
||||
def _view_result(
|
||||
self,
|
||||
snapshot: ProjectSnapshot,
|
||||
view: RenderView,
|
||||
prepared: PreparedRender,
|
||||
*,
|
||||
state: str,
|
||||
actual_hash: str | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"view_id": view.view_id,
|
||||
"renderer": prepared.renderer,
|
||||
"renderer_version": prepared.renderer_version,
|
||||
"render_identity": prepared.render_identity,
|
||||
"expected_output_hash": prepared.output_hash,
|
||||
"actual_output_hash": actual_hash,
|
||||
"template_hash": prepared.template_hash,
|
||||
"path": relative_output(snapshot, view.output_path),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _config(snapshot: ProjectSnapshot) -> RenderConfig:
|
||||
if snapshot.descriptor.render is None:
|
||||
raise DocForgeError("render_not_configured", "Project has no configured render views")
|
||||
return snapshot.descriptor.render
|
||||
|
||||
@staticmethod
|
||||
def _views(config: RenderConfig, view_id: str | None) -> tuple[RenderView, ...]:
|
||||
if view_id is None:
|
||||
return config.views
|
||||
views = tuple(view for view in config.views if view.view_id == view_id)
|
||||
if not views:
|
||||
raise DocForgeError(
|
||||
"unknown_render_view", "Render view is not declared by this project", view=view_id
|
||||
)
|
||||
return views
|
||||
|
||||
@staticmethod
|
||||
def _result(snapshot: ProjectSnapshot, **payload: object) -> dict[str, object]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_id": snapshot.descriptor.project_id,
|
||||
"project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
|
||||
"adapter": snapshot.descriptor.adapter,
|
||||
"revision": snapshot.revision,
|
||||
"source_hash": snapshot.source_hash,
|
||||
**payload,
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _lock(self) -> Iterator[None]:
|
||||
root = self.project.descriptor.cache_root
|
||||
if root.resolve(strict=False) != root:
|
||||
raise DocForgeError("path_escape", "Render lock directory is not safe")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if not root.is_dir() or root.resolve(strict=False) != root:
|
||||
raise DocForgeError("path_escape", "Render lock directory is not safe")
|
||||
lock_path = root / "render.lock"
|
||||
try:
|
||||
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as error:
|
||||
raise DocForgeError("path_escape", "Render lock path is not safe") from error
|
||||
with os.fdopen(descriptor, "a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
Loading…
Add table
Add a link
Reference in a new issue