Add render export adapter contract

This commit is contained in:
2026-05-15 13:40:25 +02:00
parent 6cc44da628
commit 2887d57fa9
13 changed files with 1092 additions and 10 deletions

View File

@@ -13,6 +13,9 @@ Generated from `markitect_tool.__all__`.
- `LOCAL_INDEX_SCHEMA_VERSION` - object. str(object='') -> str
- `MAX_FUNCTION_PIPELINE_DEPTH` - object. int([x]) -> integer
- `NORMALIZED_SOURCE_SCHEMA_VERSION` - object. str(object='') -> str
- `RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP` - object. str(object='') -> str
- `RENDER_EXPORT_ADAPTER_KIND` - object. str(object='') -> str
- `RENDER_EXPORT_SCHEMA_VERSION` - object. str(object='') -> str
- `SOURCE_ADAPTER_ENTRY_POINT_GROUP` - object. str(object='') -> str
## `markitect_tool.backend.engine`
@@ -310,6 +313,23 @@ Generated from `markitect_tool.__all__`.
- `parse_reference(reference: 'str') -> 'ReferenceAddress'` - function. Parse a compact Markitect content reference.
- `resolve_reference(reference: 'str | ReferenceAddress', *, context: 'ReferenceContext') -> 'ReferenceResolution'` - function. Resolve a content reference to one or more content units.
## `markitect_tool.render.engine`
- `FakeRenderExportAdapter() -> 'None'` - class. Deterministic no-op renderer used for contract tests.
- `RenderArtifact(artifact_id: 'str', role: 'str', media_type: 'str', content: 'str | None' = None, path: 'str | None' = None, uri: 'str | None' = None, digest: 'str | None' = None, metadata: 'dict[str, Any]' = <factory>) -> None` - class. Metadata for a rendered or exported artifact.
- `RenderExportAdapter(*args, **kwargs)` - class. Render/export adapter protocol.
- `RenderExportAdapterDescriptor(id: 'str', version: 'str', name: 'str', operations: 'list[str]', input_contracts: 'list[str]', output_profiles: 'list[str]', artifact_media_types: 'list[str]', factory: 'RenderExportAdapterFactory', summary: 'str | None' = None, option_schema: 'dict[str, Any]' = <factory>, optional_dependencies: 'list[OptionalDependency]' = <factory>, safety: 'dict[str, Any]' = <factory>, quality_profile: 'dict[str, Any]' = <factory>, metadata: 'dict[str, Any]' = <factory>) -> None` - class. Inspectable descriptor for one render/export adapter.
- `RenderExportAdapterError` - class. Raised when render/export adapter contracts are invalid.
- `RenderExportAdapterRegistry(descriptors: 'Iterable[RenderExportAdapterDescriptor] | None' = None) -> 'None'` - class. Registry of render/export adapter descriptors.
- `RenderExportRequest(source: 'str', operation: 'str' = 'render-artifact', profile: 'str' = 'plain', source_path: 'str | None' = None, options: 'dict[str, Any]' = <factory>, policy: 'dict[str, Any]' = <factory>, schema_version: 'str' = 'markitect.render.export.v1', metadata: 'dict[str, Any]' = <factory>) -> None` - class. Service-free render/export request.
- `RenderExportResult(adapter: 'dict[str, Any]', operation: 'str', profile: 'str', artifacts: 'list[RenderArtifact]' = <factory>, exported_source: 'str | None' = None, diagnostics: 'list[Diagnostic]' = <factory>, provenance: 'list[RenderProvenance]' = <factory>, schema_version: 'str' = 'markitect.render.export.v1', metadata: 'dict[str, Any]' = <factory>) -> None` - class. Result of a render/export adapter operation.
- `RenderProvenance(operation: 'str', adapter_id: 'str', profile: 'str', source_path: 'str | None' = None, source_digest: 'str | None' = None, artifact_id: 'str | None' = None, metadata: 'dict[str, Any]' = <factory>) -> None` - class. Source-to-render provenance envelope.
- `default_render_export_adapter_registry() -> 'RenderExportAdapterRegistry'` - function. Return the built-in render/export adapter registry.
- `discover_render_export_adapters() -> 'list[RenderExportAdapterDescriptor]'` - function. Discover package-provided render/export adapter descriptors.
- `render_capability_diagnostics(descriptor: 'RenderExportAdapterDescriptor', request: 'RenderExportRequest') -> 'list[Diagnostic]'` - function. Return diagnostics for capabilities blocked by request policy.
- `render_export_registry_descriptor() -> 'ExtensionDescriptor'` - function. Descriptor for the render/export adapter registry itself.
- `render_with_adapter(request: 'RenderExportRequest', *, registry: 'RenderExportAdapterRegistry | None' = None, adapter_id: 'str' = 'render.fake') -> 'RenderExportResult'` - function. Render/export through a registered adapter.
## `markitect_tool.runtime.assessment`
- `AssessmentRequest(contract_id: 'str | None', rule_id: 'str', scope: 'str', text: 'str', criteria: 'Any', context: 'dict[str, Any]' = <factory>, structured_inputs: 'dict[str, Any]' = <factory>, severity: 'str' = 'error', threshold: 'float | None' = None, provider: 'str | None' = None, model: 'str | None' = None, metadata: 'dict[str, Any]' = <factory>) -> None` - class. A provider-neutral request for rubric assessment.

View File

@@ -37,6 +37,12 @@ This index maps example files to practical usecases and useful commands.
| `examples/source-adapters/*.json`, `examples/source-adapters/normalized-output.md` | Expected envelopes for the v1 source adapter contract | Use as fixtures for `mkt source` commands after `MKTT-WP-0018` implementation |
| `examples/source-adapters/fake-adapter-pyproject.toml` | External adapter entry point shape | Use as the fake package discovery fixture for source adapter contract tests |
## Render Export Contract Fixtures
| Files | Usecase | Try |
| --- | --- | --- |
| `examples/render/fake-render-request.yaml` | Deterministic render/export contract fixture | Use with the `render.fake` API adapter; no external renderer required |
## Cache, Backend, Policy, And Context
| Files | Usecase | Try |

View File

@@ -41,7 +41,7 @@ framework organizes how Markitect itself exposes and composes capabilities.
| `generation-adapter` | provider-neutral assisted generation | request in, generated candidate out |
| `source-adapter` | EPUB3/PDF/DOCX adapters in external packages | source asset in, normalized Markdown out |
| `cli-group` | cache, backend, ref, class | command descriptors or registration hook |
| `render-export` | future Quarkdown/export adapters | Markdown source in, rendered/exported artifact out |
| `render-export` | Quarkdown/export adapters | Markdown source in, rendered/exported artifact descriptor out |
| `document-function` | future function layer | function call in, typed document value out |
## Canonical Lifecycle

View File

@@ -0,0 +1,87 @@
# Render Export Adapter Contract
Markitect owns the contract layer for optional render/export adapters. It does
not run real renderers in core, install renderer dependencies, store durable
artifacts, or publish outputs.
This is the output-side sibling of the read-only source adapter contract:
```text
source adapters: source formats -> normalized Markdown
render adapters: Markdown/context -> renderer source or artifact metadata
```
`markitect-filter` remains read-side only. Concrete Quarkdown execution belongs
in `markitect-quarkdown`.
## Contract Version
- `markitect.render.export.v1`
The optional package entry point group is:
```text
markitect_tool.render_export_adapters
```
## Descriptor Shape
Render/export adapters declare:
| Field | Meaning |
| --- | --- |
| `id` | Stable adapter id, for example `render.fake` or `render.quarkdown`. |
| `version` | Adapter contract implementation version. |
| `operations` | Supported operations: `inspect-profile`, `export-source`, `render-artifact`. |
| `input_contracts` | Accepted inputs, such as Markdown or function evaluation results. |
| `output_profiles` | Supported profiles: `plain`, `docs`, `slides`, `paged`, `static-site`, `pdf`. |
| `artifact_media_types` | Artifact media types the adapter may emit. |
| `safety` | Declared filesystem, process, network, native dependency, and side-effect behavior. |
The built-in `render.fake` adapter is deterministic and never invokes external
processes. It exists to test the contract and examples.
## Request And Result
`RenderExportRequest` contains Markdown source, operation, profile, source
identity, options, and local policy flags.
`RenderExportResult` contains:
- adapter identity
- operation and profile
- exported renderer source where applicable
- artifact metadata
- diagnostics
- source-to-render provenance
- metadata such as whether an external renderer was invoked
Artifacts are descriptors, not durable storage records. Real renderer packages
may write files, but core Markitect only models the result.
## Capability Gates
Adapters declare local safety flags:
- `filesystem_read`
- `filesystem_write`
- `external_process`
- `network`
- `native_renderer_dependency`
- `assisted_generation`
- `publication_side_effect`
`render_capability_diagnostics` maps those flags to denied-operation
diagnostics when a request policy blocks them. This is a contract boundary, not
a durable authorization service.
## Quarkdown Handoff
`MQD-WP-0001` should implement a concrete `render.quarkdown` adapter in
`markitect-quarkdown` against this contract. That adapter should own Quarkdown
CLI invocation, Java/Node/Puppeteer assumptions, Quarkdown permissions, output
directory conventions, artifact validation, and upstream compatibility
monitoring.
`markitect-tool` keeps only the render/export schema, descriptor registry,
fake renderer, diagnostics, provenance, and tests.

View File

@@ -44,8 +44,10 @@ and descriptions mirror the operational view.
| `MKTT-WP-0017` | complete | done | `MKTT-WP-0003`, `MKTT-WP-0013` | CLI/API polish and practical adoption track is complete: shell completion, extension discovery, generated CLI/API docs, usecase relevance matrix, E2E fixture matrix, large-corpus smoke coverage, first-use docs, examples index, and command cheat sheet. |
| `MKTT-WP-0019` | complete | done | `MKTT-WP-0013`, `MKTT-WP-0017` | Source adapter contract refinement is complete: v1 read-only scope, normalized model fields, package entry point discovery, CLI/API envelopes, fake adapter fixtures, and `markitect-filter` EPUB3 handoff are pinned in `docs/source-adapter-contract.md`. |
| `MKTT-WP-0018` | complete | done | `MKTT-WP-0013`, `MKTT-WP-0017`, `MKTT-WP-0019` | Source adapter framework implementation is complete: read-only models, protocol, registry, entry point discovery, extension descriptors, CLI/API, fake adapter fixtures, migration notes, and tests are in place. |
| `MKTT-WP-0015` | P2 | todo | `MKTT-WP-0010`, `MKTT-WP-0011`, `MKTT-WP-0012` | Future render and document-function extensions: typed values, richer syntax, document-local reusable functions, Quarkdown/export adapters, render-aware references, assets, and permission sandboxing. Defer unless publishing/export pressure becomes current. |
| `MKTT-WP-0016` | P2 | todo | `MKTT-WP-0008`, `MKTT-WP-0007`, `MKTT-WP-0009`, `MKTT-WP-0013` | Follow-on agentic memory architecture: reasoning decision graphs, conversational paths, long-term knowledge graphs, memory service blueprints/profiles, graph-to-context-package compilation, and adapter boundaries. |
| `MKTT-WP-0015` | complete | done | `MKTT-WP-0010`, `MKTT-WP-0011`, `MKTT-WP-0012` | Document function value contracts are complete: typed values, deterministic Markdown/JSON mapping, descriptor output validation, API exports, docs, examples, and tests. |
| `MKTT-WP-0016` | complete | done | `MKTT-WP-0008`, `MKTT-WP-0007`, `MKTT-WP-0009`, `MKTT-WP-0013` | Memory graph profile contracts are complete: graph/profile/event models, validation, context-package compilation, CLI, fixture breadth, invalid fixtures, and runtime adapter handoff descriptors. |
| `MKTT-WP-0020` | complete | done | `MKTT-WP-0013`, `MKTT-WP-0015` | Render/export adapter contract is complete: descriptors, registry/discovery, request/result/artifact/provenance envelopes, fake deterministic renderer, capability diagnostics, extension descriptors, docs, examples, and tests. |
| `MKTT-WP-0021` | P2 | todo | `MKTT-WP-0010`, `MKTT-WP-0015`, `MKTT-WP-0020` | Render reference and asset manifest contract remains open: passive references, numbered unit metadata, static asset manifests, and source-to-render provenance maps. |
## Dependency Notes

View File

@@ -0,0 +1,16 @@
schema_version: markitect.render.export.v1
operation: render-artifact
profile: docs
source_path: examples/render/source.md
source: |
# Fake Render Example
This fixture exercises the render/export adapter contract without invoking a
real renderer.
options:
deterministic: true
policy:
external_process: false
filesystem_write: false
metadata:
purpose: render-contract-fixture

View File

@@ -259,6 +259,25 @@ from markitect_tool.runtime import (
load_runtime_context_file_result,
run_contract_assessments,
)
from markitect_tool.render import (
RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP,
RENDER_EXPORT_ADAPTER_KIND,
RENDER_EXPORT_SCHEMA_VERSION,
FakeRenderExportAdapter,
RenderArtifact,
RenderExportAdapter,
RenderExportAdapterDescriptor,
RenderExportAdapterError,
RenderExportAdapterRegistry,
RenderExportRequest,
RenderExportResult,
RenderProvenance,
default_render_export_adapter_registry,
discover_render_export_adapters,
render_capability_diagnostics,
render_export_registry_descriptor,
render_with_adapter,
)
from markitect_tool.schema import (
MarkdownSchema,
SchemaValidationResult,
@@ -570,6 +589,23 @@ __all__ = [
"load_runtime_context_file",
"load_runtime_context_file_result",
"run_contract_assessments",
"RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP",
"RENDER_EXPORT_ADAPTER_KIND",
"RENDER_EXPORT_SCHEMA_VERSION",
"FakeRenderExportAdapter",
"RenderArtifact",
"RenderExportAdapter",
"RenderExportAdapterDescriptor",
"RenderExportAdapterError",
"RenderExportAdapterRegistry",
"RenderExportRequest",
"RenderExportResult",
"RenderProvenance",
"default_render_export_adapter_registry",
"discover_render_export_adapters",
"render_capability_diagnostics",
"render_export_registry_descriptor",
"render_with_adapter",
"MissingTemplateVariable",
"TemplateAnalysis",
"TemplateError",

View File

@@ -5,6 +5,10 @@ from __future__ import annotations
from markitect_tool.extension.registry import ExtensionDescriptor, ExtensionRegistry
from markitect_tool.extension.processing import ProcessingCapability
from markitect_tool.query import default_query_engine_registry
from markitect_tool.render import (
default_render_export_adapter_registry,
render_export_registry_descriptor,
)
from markitect_tool.source import (
default_source_adapter_registry,
source_adapter_registry_descriptor,
@@ -28,9 +32,12 @@ def builtin_extension_registry() -> ExtensionRegistry:
_memory_graph_contract_descriptor(),
_memory_runtime_adapter_descriptor(),
_agent_memory_descriptor(),
render_export_registry_descriptor(),
source_adapter_registry_descriptor(),
]:
registry.register(descriptor)
for descriptor in default_render_export_adapter_registry().extension_descriptors():
registry.register(descriptor)
for descriptor in default_source_adapter_registry().extension_descriptors():
registry.register(descriptor)
return registry

View File

@@ -0,0 +1,41 @@
"""Render/export adapter contracts."""
from markitect_tool.render.engine import (
RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP,
RENDER_EXPORT_ADAPTER_KIND,
RENDER_EXPORT_SCHEMA_VERSION,
FakeRenderExportAdapter,
RenderArtifact,
RenderExportAdapter,
RenderExportAdapterDescriptor,
RenderExportAdapterError,
RenderExportAdapterRegistry,
RenderExportRequest,
RenderExportResult,
RenderProvenance,
default_render_export_adapter_registry,
discover_render_export_adapters,
render_capability_diagnostics,
render_export_registry_descriptor,
render_with_adapter,
)
__all__ = [
"RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP",
"RENDER_EXPORT_ADAPTER_KIND",
"RENDER_EXPORT_SCHEMA_VERSION",
"FakeRenderExportAdapter",
"RenderArtifact",
"RenderExportAdapter",
"RenderExportAdapterDescriptor",
"RenderExportAdapterError",
"RenderExportAdapterRegistry",
"RenderExportRequest",
"RenderExportResult",
"RenderProvenance",
"default_render_export_adapter_registry",
"discover_render_export_adapters",
"render_capability_diagnostics",
"render_export_registry_descriptor",
"render_with_adapter",
]

View File

@@ -0,0 +1,725 @@
"""Contract-only render/export adapter framework."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import hashlib
import importlib.metadata
import json
from pathlib import Path
from typing import Any, Iterable, Protocol, runtime_checkable
from markitect_tool.diagnostics import Diagnostic, SourceLocation, has_error
from markitect_tool.extension import (
ExtensionDependencyCheck,
ExtensionDescriptor,
OptionalDependency,
ProcessingCapability,
)
RENDER_EXPORT_SCHEMA_VERSION = "markitect.render.export.v1"
RENDER_EXPORT_ADAPTER_KIND = "render-export"
RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP = "markitect_tool.render_export_adapters"
RENDER_OPERATIONS = {
"inspect-profile",
"export-source",
"render-artifact",
}
RENDER_PROFILES = {
"plain",
"docs",
"slides",
"paged",
"static-site",
"pdf",
}
_SAFETY_POLICY_FLAGS = {
"filesystem_read": "filesystem_read",
"filesystem_write": "filesystem_write",
"external_process": "external_process",
"network": "network",
"native_renderer_dependency": "native_renderer_dependency",
"assisted_generation": "assisted_generation",
"publication_side_effect": "publication_side_effect",
}
class RenderExportAdapterError(ValueError):
"""Raised when render/export adapter contracts are invalid."""
@dataclass(frozen=True)
class RenderArtifact:
"""Metadata for a rendered or exported artifact."""
artifact_id: str
role: str
media_type: str
content: str | None = None
path: str | None = None
uri: str | None = None
digest: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_content(
cls,
content: str,
*,
role: str,
media_type: str,
artifact_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "RenderArtifact":
digest = _digest_text(content)
return cls(
artifact_id=artifact_id or f"artifact:{digest.removeprefix('sha256:')[:16]}",
role=role,
media_type=media_type,
content=content,
digest=digest,
metadata=metadata or {},
)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(asdict(self))
@dataclass(frozen=True)
class RenderProvenance:
"""Source-to-render provenance envelope."""
operation: str
adapter_id: str
profile: str
source_path: str | None = None
source_digest: str | None = None
artifact_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(asdict(self))
@dataclass(frozen=True)
class RenderExportRequest:
"""Service-free render/export request."""
source: str
operation: str = "render-artifact"
profile: str = "plain"
source_path: str | None = None
options: dict[str, Any] = field(default_factory=dict)
policy: dict[str, Any] = field(default_factory=dict)
schema_version: str = RENDER_EXPORT_SCHEMA_VERSION
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(
{
"schema_version": self.schema_version,
"operation": self.operation,
"profile": self.profile,
"source": self.source,
"source_path": self.source_path,
"options": self.options,
"policy": self.policy,
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class RenderExportResult:
"""Result of a render/export adapter operation."""
adapter: dict[str, Any]
operation: str
profile: str
artifacts: list[RenderArtifact] = field(default_factory=list)
exported_source: str | None = None
diagnostics: list[Diagnostic] = field(default_factory=list)
provenance: list[RenderProvenance] = field(default_factory=list)
schema_version: str = RENDER_EXPORT_SCHEMA_VERSION
metadata: dict[str, Any] = field(default_factory=dict)
@property
def valid(self) -> bool:
return not has_error(self.diagnostics)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(
{
"schema_version": self.schema_version,
"valid": self.valid,
"adapter": self.adapter,
"operation": self.operation,
"profile": self.profile,
"artifacts": [artifact.to_dict() for artifact in self.artifacts],
"exported_source": self.exported_source,
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
"provenance": [event.to_dict() for event in self.provenance],
"metadata": self.metadata,
}
)
@runtime_checkable
class RenderExportAdapter(Protocol):
"""Render/export adapter protocol."""
descriptor: "RenderExportAdapterDescriptor"
def render(self, request: RenderExportRequest) -> RenderExportResult:
"""Run one render/export operation."""
RenderExportAdapterFactory = Any
@dataclass(frozen=True)
class RenderExportAdapterDescriptor:
"""Inspectable descriptor for one render/export adapter."""
id: str
version: str
name: str
operations: list[str]
input_contracts: list[str]
output_profiles: list[str]
artifact_media_types: list[str]
factory: RenderExportAdapterFactory = field(compare=False, repr=False)
summary: str | None = None
option_schema: dict[str, Any] = field(default_factory=dict)
optional_dependencies: list[OptionalDependency] = field(default_factory=list)
safety: dict[str, Any] = field(default_factory=dict)
quality_profile: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.id.strip():
raise RenderExportAdapterError("Render/export adapter id cannot be empty")
if not self.name.strip():
raise RenderExportAdapterError("Render/export adapter name cannot be empty")
unsupported_operations = sorted(set(self.operations) - RENDER_OPERATIONS)
if unsupported_operations:
raise RenderExportAdapterError(
"Unsupported render/export operation(s): " + ", ".join(unsupported_operations)
)
if not self.operations:
raise RenderExportAdapterError("Render/export adapter must declare at least one operation")
unsupported_profiles = sorted(set(self.output_profiles) - RENDER_PROFILES)
if unsupported_profiles:
raise RenderExportAdapterError(
"Unsupported render/export profile(s): " + ", ".join(unsupported_profiles)
)
object.__setattr__(self, "artifact_media_types", [value.lower() for value in self.artifact_media_types])
def instantiate(self) -> RenderExportAdapter:
adapter = self.factory()
if not isinstance(adapter, RenderExportAdapter):
missing = ["render"] if not callable(getattr(adapter, "render", None)) else []
if missing:
raise RenderExportAdapterError(
f"Render/export adapter `{self.id}` is missing method(s): {', '.join(missing)}"
)
return adapter
def to_dict(self) -> dict[str, Any]:
return _drop_empty(
{
"id": self.id,
"kind": RENDER_EXPORT_ADAPTER_KIND,
"version": self.version,
"name": self.name,
"summary": self.summary,
"operations": self.operations,
"input_contracts": self.input_contracts,
"output_profiles": self.output_profiles,
"artifact_media_types": self.artifact_media_types,
"option_schema": self.option_schema,
"optional_dependencies": [
dependency.to_dict() for dependency in self.optional_dependencies
],
"safety": self.safety,
"quality_profile": self.quality_profile,
"metadata": self.metadata,
"capabilities": [
capability.to_dict() for capability in _render_export_capabilities()
],
"docs": ["docs/render-export-adapters.md"],
}
)
def to_extension_descriptor(self) -> ExtensionDescriptor:
return ExtensionDescriptor(
id=self.id,
kind=RENDER_EXPORT_ADAPTER_KIND,
version=self.version,
summary=self.summary or self.name,
capabilities=_render_export_capabilities(),
optional_dependencies=self.optional_dependencies,
safety=self.safety,
input_contract="RenderExportRequest",
output_contract="RenderExportResult",
diagnostics_namespace="render",
provenance_prefix=self.id,
docs=["docs/render-export-adapters.md"],
examples=["examples/render/fake-render-request.yaml"],
metadata={
"render_export_adapter": {
"name": self.name,
"operations": self.operations,
"input_contracts": self.input_contracts,
"output_profiles": self.output_profiles,
"artifact_media_types": self.artifact_media_types,
"option_schema": self.option_schema,
"quality_profile": self.quality_profile,
"metadata": self.metadata,
}
},
)
class RenderExportAdapterRegistry:
"""Registry of render/export adapter descriptors."""
def __init__(self, descriptors: Iterable[RenderExportAdapterDescriptor] | None = None) -> None:
self._descriptors: dict[str, RenderExportAdapterDescriptor] = {}
for descriptor in descriptors or []:
self.register(descriptor)
def register(self, descriptor: RenderExportAdapterDescriptor) -> None:
if descriptor.id in self._descriptors:
raise RenderExportAdapterError(f"Duplicate render/export adapter id `{descriptor.id}`")
self._descriptors[descriptor.id] = descriptor
def get(self, adapter_id: str) -> RenderExportAdapterDescriptor:
try:
return self._descriptors[adapter_id]
except KeyError as exc:
raise RenderExportAdapterError(f"Unknown render/export adapter `{adapter_id}`") from exc
def list(self) -> list[RenderExportAdapterDescriptor]:
return [self._descriptors[key] for key in sorted(self._descriptors)]
def check_dependencies(
self,
adapter_id: str,
*,
available_modules: set[str] | None = None,
) -> ExtensionDependencyCheck:
descriptor = self.get(adapter_id)
available = (
available_modules
if available_modules is not None
else _available_modules(dependency.name for dependency in descriptor.optional_dependencies)
)
missing: list[str] = []
optional_missing: list[str] = []
for dependency in descriptor.optional_dependencies:
if dependency.name in available:
continue
if dependency.required:
missing.append(dependency.name)
else:
optional_missing.append(dependency.name)
return ExtensionDependencyCheck(
extension_id=adapter_id,
missing=missing,
optional_missing=optional_missing,
)
def to_dict(self) -> dict[str, Any]:
adapters = []
for descriptor in self.list():
data = descriptor.to_dict()
data["dependency_check"] = self.check_dependencies(descriptor.id).to_dict()
adapters.append(data)
return {"count": len(adapters), "adapters": adapters}
def extension_descriptors(self) -> list[ExtensionDescriptor]:
return [descriptor.to_extension_descriptor() for descriptor in self.list()]
class FakeRenderExportAdapter:
"""Deterministic no-op renderer used for contract tests."""
def __init__(self) -> None:
self.descriptor = fake_render_export_adapter_descriptor()
def render(self, request: RenderExportRequest) -> RenderExportResult:
diagnostics = render_capability_diagnostics(self.descriptor, request)
diagnostics.extend(_validate_request(self.descriptor, request))
adapter = {
"id": self.descriptor.id,
"version": self.descriptor.version,
"name": self.descriptor.name,
}
if diagnostics:
return RenderExportResult(
adapter=adapter,
operation=request.operation,
profile=request.profile,
diagnostics=diagnostics,
)
exported = _fake_exported_source(request)
provenance = [
RenderProvenance(
operation=request.operation,
adapter_id=self.descriptor.id,
profile=request.profile,
source_path=request.source_path,
source_digest=_digest_text(request.source),
metadata={"deterministic_fake": True},
)
]
if request.operation == "inspect-profile":
return RenderExportResult(
adapter=adapter,
operation=request.operation,
profile=request.profile,
provenance=provenance,
metadata={
"profile": request.profile,
"supported_operations": self.descriptor.operations,
"external_renderer_invoked": False,
},
)
if request.operation == "export-source":
artifact = RenderArtifact.from_content(
exported,
role="renderer-source",
media_type="text/markdown",
metadata={"profile": request.profile},
)
provenance = [
_event_with_artifact(event, artifact.artifact_id)
for event in provenance
]
return RenderExportResult(
adapter=adapter,
operation=request.operation,
profile=request.profile,
artifacts=[artifact],
exported_source=exported,
provenance=provenance,
metadata={"external_renderer_invoked": False},
)
rendered = f"FAKE RENDER ARTIFACT\nprofile: {request.profile}\n\n{exported}"
artifact = RenderArtifact.from_content(
rendered,
role="rendered-artifact",
media_type=_fake_media_type(request.profile),
metadata={"profile": request.profile, "fake_renderer": True},
)
provenance = [_event_with_artifact(event, artifact.artifact_id) for event in provenance]
return RenderExportResult(
adapter=adapter,
operation=request.operation,
profile=request.profile,
artifacts=[artifact],
exported_source=exported,
provenance=provenance,
metadata={"external_renderer_invoked": False},
)
def render_with_adapter(
request: RenderExportRequest,
*,
registry: RenderExportAdapterRegistry | None = None,
adapter_id: str = "render.fake",
) -> RenderExportResult:
"""Render/export through a registered adapter."""
registry = registry or default_render_export_adapter_registry()
descriptor = registry.get(adapter_id)
dependency_check = registry.check_dependencies(adapter_id)
if not dependency_check.compatible:
return RenderExportResult(
adapter={"id": descriptor.id, "version": descriptor.version, "name": descriptor.name},
operation=request.operation,
profile=request.profile,
diagnostics=[
_render_error(
"render.missing_dependency",
f"Render/export adapter `{descriptor.id}` is missing required dependencies.",
details=dependency_check.to_dict(),
)
],
)
return descriptor.instantiate().render(request)
def render_capability_diagnostics(
descriptor: RenderExportAdapterDescriptor,
request: RenderExportRequest,
) -> list[Diagnostic]:
"""Return diagnostics for capabilities blocked by request policy."""
policy = request.policy or {}
blocked = set(policy.get("blocked_capabilities") or [])
denied = []
for capability in _render_export_capabilities():
if capability.id in blocked:
denied.append(capability.id)
for safety_key, policy_key in _SAFETY_POLICY_FLAGS.items():
if descriptor.safety.get(safety_key) and policy.get(policy_key) is False:
denied.append(policy_key)
if not denied:
return []
return [
_render_error(
"render.capability_blocked",
f"Render/export adapter `{descriptor.id}` requires blocked capabilities.",
details={"adapter_id": descriptor.id, "capabilities": sorted(set(denied))},
)
]
def default_render_export_adapter_registry() -> RenderExportAdapterRegistry:
"""Return the built-in render/export adapter registry."""
return RenderExportAdapterRegistry([*discover_render_export_adapters(), fake_render_export_adapter_descriptor()])
def discover_render_export_adapters() -> list[RenderExportAdapterDescriptor]:
"""Discover package-provided render/export adapter descriptors."""
descriptors: list[RenderExportAdapterDescriptor] = []
for entry_point in _entry_points():
try:
descriptors.extend(_normalize_entry_point_result(entry_point.load()))
except Exception as exc: # pragma: no cover - defensive boundary
descriptors.append(_failed_discovery_descriptor(entry_point.name, exc))
return descriptors
def render_export_registry_descriptor() -> ExtensionDescriptor:
"""Descriptor for the render/export adapter registry itself."""
return ExtensionDescriptor(
id="render.export-registry",
kind="render-export-registry",
summary="Registry and descriptor contract for optional render/export adapters.",
capabilities=[
ProcessingCapability(id="render_export_adapters", kind="discover"),
ProcessingCapability(id="diagnostics", kind="emit"),
ProcessingCapability(id="provenance", kind="emit"),
],
safety={"network": False, "external_process": False, "filesystem_write": False},
input_contract="RenderExportAdapterDescriptor entry points",
output_contract="RenderExportAdapterRegistry",
diagnostics_namespace="render",
provenance_prefix="render.export_registry",
docs=["docs/render-export-adapters.md"],
examples=["examples/render/fake-render-request.yaml"],
metadata={
"entry_point_group": RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP,
"schema_version": RENDER_EXPORT_SCHEMA_VERSION,
"concrete_renderer_execution_required": False,
},
)
def fake_render_export_adapter_descriptor() -> RenderExportAdapterDescriptor:
"""Descriptor for the built-in fake render/export adapter."""
return RenderExportAdapterDescriptor(
id="render.fake",
version="1",
name="Deterministic Fake Renderer",
summary="Fake renderer for render/export contract tests; never invokes external processes.",
operations=["inspect-profile", "export-source", "render-artifact"],
input_contracts=["Markdown", "DocumentFunctionEvaluationResult"],
output_profiles=["plain", "docs", "slides", "paged", "static-site", "pdf"],
artifact_media_types=["text/plain", "text/markdown"],
factory=FakeRenderExportAdapter,
safety={
"filesystem_read": False,
"filesystem_write": False,
"external_process": False,
"network": False,
"native_renderer_dependency": False,
"assisted_generation": False,
"publication_side_effect": False,
},
quality_profile={"deterministic_fake": True, "layout_fidelity": "none"},
metadata={
"external_renderer_invoked": False,
"quarkdown_dependency": False,
"contract_fixture": True,
},
)
def _validate_request(
descriptor: RenderExportAdapterDescriptor,
request: RenderExportRequest,
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
if request.schema_version != RENDER_EXPORT_SCHEMA_VERSION:
diagnostics.append(
_render_error(
"render.schema_version",
f"Expected schema_version `{RENDER_EXPORT_SCHEMA_VERSION}`.",
severity="warning",
details={"actual": request.schema_version},
)
)
if request.operation not in descriptor.operations:
diagnostics.append(
_render_error(
"render.operation_unsupported",
f"Adapter `{descriptor.id}` does not support operation `{request.operation}`.",
details={"adapter_id": descriptor.id, "operation": request.operation},
)
)
if request.profile not in descriptor.output_profiles:
diagnostics.append(
_render_error(
"render.profile_unsupported",
f"Adapter `{descriptor.id}` does not support profile `{request.profile}`.",
details={"adapter_id": descriptor.id, "profile": request.profile},
)
)
if not request.source and request.operation != "inspect-profile":
diagnostics.append(_render_error("render.source_missing", "Render/export request source cannot be empty."))
return diagnostics
def _render_export_capabilities() -> list[ProcessingCapability]:
return [
ProcessingCapability(id="render_export", kind="execute"),
ProcessingCapability(id="renderer_source", kind="emit"),
ProcessingCapability(id="render_artifact", kind="emit"),
ProcessingCapability(id="diagnostics", kind="emit"),
ProcessingCapability(id="provenance", kind="emit"),
]
def _event_with_artifact(event: RenderProvenance, artifact_id: str) -> RenderProvenance:
return RenderProvenance(
operation=event.operation,
adapter_id=event.adapter_id,
profile=event.profile,
source_path=event.source_path,
source_digest=event.source_digest,
artifact_id=artifact_id,
metadata=event.metadata,
)
def _fake_exported_source(request: RenderExportRequest) -> str:
return (
f"<!-- render.fake profile={request.profile} operation={request.operation} -->\n\n"
+ request.source
)
def _fake_media_type(profile: str) -> str:
if profile == "pdf":
return "text/plain"
if profile in {"docs", "static-site"}:
return "text/html"
return "text/plain"
def _digest_text(value: str) -> str:
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
def _available_modules(module_names: Iterable[str]) -> set[str]:
import importlib.util
return {
module_name
for module_name in module_names
if importlib.util.find_spec(module_name) is not None
}
def _entry_points() -> list[Any]:
entry_points = importlib.metadata.entry_points()
if hasattr(entry_points, "select"):
return list(entry_points.select(group=RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP))
return list(entry_points.get(RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP, []))
def _normalize_entry_point_result(loaded: Any) -> list[RenderExportAdapterDescriptor]:
value = loaded() if callable(loaded) and not isinstance(loaded, RenderExportAdapterDescriptor) else loaded
if isinstance(value, RenderExportAdapterDescriptor):
return [value]
if isinstance(value, Iterable) and not isinstance(value, (str, bytes, dict)):
descriptors = list(value)
if all(isinstance(descriptor, RenderExportAdapterDescriptor) for descriptor in descriptors):
return descriptors
raise RenderExportAdapterError("Render/export adapter entry point must return descriptor objects")
def _failed_discovery_descriptor(name: str, exc: Exception) -> RenderExportAdapterDescriptor:
def factory() -> RenderExportAdapter:
return _FailedRenderExportAdapter(name, exc)
return RenderExportAdapterDescriptor(
id=f"render.discovery-failed.{name}",
version="0",
name=f"Failed render/export adapter: {name}",
operations=["inspect-profile"],
input_contracts=["RenderExportRequest"],
output_profiles=["plain"],
artifact_media_types=[],
factory=factory,
summary="Failed render/export adapter discovery placeholder.",
safety={"filesystem_read": False, "filesystem_write": False, "network": False},
metadata={"error": str(exc)},
)
class _FailedRenderExportAdapter:
def __init__(self, name: str, exc: Exception) -> None:
self.descriptor = _failed_discovery_descriptor(name, exc)
self._error = exc
def render(self, request: RenderExportRequest) -> RenderExportResult:
return RenderExportResult(
adapter={"id": self.descriptor.id, "version": self.descriptor.version, "name": self.descriptor.name},
operation=request.operation,
profile=request.profile,
diagnostics=[
_render_error(
"render.discovery_failed",
f"Render/export adapter `{self.descriptor.id}` failed during discovery.",
details={"error": str(self._error)},
)
],
)
def _render_error(
code: str,
message: str,
*,
severity: str = "error",
details: dict[str, Any] | None = None,
) -> Diagnostic:
return Diagnostic(
severity=severity,
code=code,
message=message,
source=SourceLocation(path="<render>"),
details=details or {},
)
def _drop_empty(data: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in data.items()
if value not in (None, [], {}, "")
}

View File

@@ -24,6 +24,8 @@ def test_builtin_extension_registry_lists_query_processors_and_backend():
assert "memory.graph-contract" in ids
assert "memory.runtime-adapter-boundary" in ids
assert "memory.context-package" in ids
assert "render.export-registry" in ids
assert "render.fake" in ids
assert "source.adapter-registry" in ids
@@ -182,3 +184,18 @@ def test_builtin_memory_graph_descriptor_exposes_runtime_handoff_boundaries():
assert adapters.safety["runtime_execution"] is False
assert adapters.metadata["services_launched_by_markitect_tool"] is False
assert "examples/memory/runtime-adapter-boundaries.yaml" in adapters.examples
def test_builtin_render_export_descriptors_expose_contract_boundary():
registry = builtin_extension_registry()
registry_descriptor = registry.get("render.export-registry")
fake = registry.get("render.fake")
assert registry_descriptor.kind == "render-export-registry"
assert registry_descriptor.safety["external_process"] is False
assert registry_descriptor.metadata["concrete_renderer_execution_required"] is False
assert fake.kind == "render-export"
assert fake.safety["external_process"] is False
assert fake.safety["filesystem_write"] is False
assert fake.metadata["render_export_adapter"]["metadata"]["quarkdown_dependency"] is False

View File

@@ -0,0 +1,107 @@
from markitect_tool.diagnostics import has_error
from markitect_tool.render import (
RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP,
RENDER_EXPORT_SCHEMA_VERSION,
RenderExportAdapterDescriptor,
RenderExportAdapterRegistry,
RenderExportRequest,
default_render_export_adapter_registry,
render_capability_diagnostics,
render_export_registry_descriptor,
render_with_adapter,
)
def test_render_export_registry_lists_fake_adapter_and_serializes_descriptor():
registry = default_render_export_adapter_registry()
descriptor = registry.get("render.fake")
data = descriptor.to_dict()
assert data["kind"] == "render-export"
assert data["operations"] == ["inspect-profile", "export-source", "render-artifact"]
assert "pdf" in data["output_profiles"]
assert registry.to_dict()["count"] >= 1
def test_fake_render_export_adapter_exports_source_and_provenance():
request = RenderExportRequest(
source="# Demo\n\nBody.",
operation="export-source",
profile="docs",
source_path="docs/demo.md",
)
result = render_with_adapter(request)
assert result.valid
assert result.schema_version == RENDER_EXPORT_SCHEMA_VERSION
assert result.exported_source.startswith("<!-- render.fake profile=docs")
assert result.artifacts[0].role == "renderer-source"
assert result.artifacts[0].media_type == "text/markdown"
assert result.provenance[0].source_path == "docs/demo.md"
assert result.provenance[0].artifact_id == result.artifacts[0].artifact_id
def test_fake_render_export_adapter_renders_artifact_without_external_renderer():
request = RenderExportRequest(source="# Demo", operation="render-artifact", profile="pdf")
result = render_with_adapter(request)
assert result.valid
assert result.artifacts[0].role == "rendered-artifact"
assert result.artifacts[0].digest.startswith("sha256:")
assert result.metadata["external_renderer_invoked"] is False
def test_render_export_request_validation_reports_unsupported_profile():
request = RenderExportRequest(source="# Demo", operation="render-artifact", profile="epub")
result = render_with_adapter(request)
assert not result.valid
assert result.diagnostics[0].code == "render.profile_unsupported"
def test_render_capability_policy_blocks_declared_safety_flags():
descriptor = RenderExportAdapterDescriptor(
id="render.external",
version="1",
name="External Renderer",
operations=["render-artifact"],
input_contracts=["Markdown"],
output_profiles=["pdf"],
artifact_media_types=["application/pdf"],
factory=lambda: object(),
safety={"external_process": True, "filesystem_write": True},
)
request = RenderExportRequest(
source="# Demo",
operation="render-artifact",
profile="pdf",
policy={"external_process": False, "filesystem_write": False},
)
diagnostics = render_capability_diagnostics(descriptor, request)
assert len(diagnostics) == 1
assert diagnostics[0].code == "render.capability_blocked"
assert diagnostics[0].details["capabilities"] == ["external_process", "filesystem_write"]
def test_render_export_registry_descriptor_points_to_entry_point_group():
descriptor = render_export_registry_descriptor()
assert descriptor.id == "render.export-registry"
assert descriptor.kind == "render-export-registry"
assert descriptor.metadata["entry_point_group"] == RENDER_EXPORT_ADAPTER_ENTRY_POINT_GROUP
assert descriptor.metadata["concrete_renderer_execution_required"] is False
def test_render_result_validity_tracks_error_diagnostics():
request = RenderExportRequest(source="", operation="render-artifact", profile="plain")
result = render_with_adapter(request)
assert has_error(result.diagnostics)
assert not result.valid

View File

@@ -3,10 +3,10 @@ id: MKTT-WP-0020
type: workplan
title: "Render Export Adapter Contract"
domain: markitect
status: todo
status: done
owner: markitect-tool
topic_slug: markitect
planning_priority: P2
planning_priority: complete
planning_order: 150
depends_on_workplans:
- MKTT-WP-0013
@@ -55,11 +55,29 @@ renderer-specific sources. The two directions must stay separate now that
Concrete Quarkdown integration belongs in `markitect-quarkdown`.
## Implementation Summary - 2026-05-15
Implemented the contract-only render/export adapter layer:
- `RenderExportAdapterDescriptor`, `RenderExportAdapterRegistry`, and optional
package discovery through `markitect_tool.render_export_adapters`.
- `RenderExportRequest`, `RenderExportResult`, `RenderArtifact`, and
`RenderProvenance` envelopes.
- Built-in deterministic `render.fake` adapter for contract tests.
- Capability and safety diagnostics for filesystem, network, external process,
native renderer dependency, assisted generation, and publication side-effect
boundaries.
- Extension catalog descriptors for `render.export-registry` and `render.fake`.
- Docs, fixture, public API exports, generated API reference, and tests.
No real renderer, Quarkdown invocation, external process, or filesystem-writing
publication behavior was added to `markitect-tool`.
## P20.1 - Define render adapter descriptors
```task
id: MKTT-WP-0020-T001
status: todo
status: done
priority: high
state_hub_task_id: "5b52b196-a7f5-4e4f-abc6-972febdc2638"
```
@@ -86,7 +104,7 @@ registry tests.
```task
id: MKTT-WP-0020-T002
status: todo
status: done
priority: high
state_hub_task_id: "3d43b168-7e55-4885-ae17-fcea262f2641"
```
@@ -108,7 +126,7 @@ Output: serializable models, round-trip tests, and docs.
```task
id: MKTT-WP-0020-T003
status: todo
status: done
priority: high
state_hub_task_id: "45748ad7-131c-4b75-9720-6958fde93208"
```
@@ -130,7 +148,7 @@ Output: fake adapter, tests, and examples.
```task
id: MKTT-WP-0020-T004
status: todo
status: done
priority: medium
state_hub_task_id: "50aa8f00-eeba-495d-9d45-089f855fc3bd"
```
@@ -154,7 +172,7 @@ Output: capability constants, blocked-operation diagnostics, and tests.
```task
id: MKTT-WP-0020-T005
status: todo
status: done
priority: medium
state_hub_task_id: "173a75d0-d82c-4dcb-9ced-eb73e9438db2"
```