diff --git a/docs/api-reference.md b/docs/api-reference.md index 5df164e..576a67c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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]' = ) -> 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]' = , optional_dependencies: 'list[OptionalDependency]' = , safety: 'dict[str, Any]' = , quality_profile: 'dict[str, Any]' = , metadata: 'dict[str, Any]' = ) -> 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]' = , policy: 'dict[str, Any]' = , schema_version: 'str' = 'markitect.render.export.v1', metadata: 'dict[str, Any]' = ) -> None` - class. Service-free render/export request. +- `RenderExportResult(adapter: 'dict[str, Any]', operation: 'str', profile: 'str', artifacts: 'list[RenderArtifact]' = , exported_source: 'str | None' = None, diagnostics: 'list[Diagnostic]' = , provenance: 'list[RenderProvenance]' = , schema_version: 'str' = 'markitect.render.export.v1', metadata: 'dict[str, Any]' = ) -> 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]' = ) -> 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]' = , structured_inputs: 'dict[str, Any]' = , severity: 'str' = 'error', threshold: 'float | None' = None, provider: 'str | None' = None, model: 'str | None' = None, metadata: 'dict[str, Any]' = ) -> None` - class. A provider-neutral request for rubric assessment. diff --git a/docs/examples-index.md b/docs/examples-index.md index 45992f5..9334312 100644 --- a/docs/examples-index.md +++ b/docs/examples-index.md @@ -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 | diff --git a/docs/internal-extension-framework.md b/docs/internal-extension-framework.md index ca6a2ff..e66e6cd 100644 --- a/docs/internal-extension-framework.md +++ b/docs/internal-extension-framework.md @@ -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 diff --git a/docs/render-export-adapters.md b/docs/render-export-adapters.md new file mode 100644 index 0000000..475ed92 --- /dev/null +++ b/docs/render-export-adapters.md @@ -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. diff --git a/docs/workplan-planning-map.md b/docs/workplan-planning-map.md index 3c91d5c..870de6a 100644 --- a/docs/workplan-planning-map.md +++ b/docs/workplan-planning-map.md @@ -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 diff --git a/examples/render/fake-render-request.yaml b/examples/render/fake-render-request.yaml new file mode 100644 index 0000000..78d6d80 --- /dev/null +++ b/examples/render/fake-render-request.yaml @@ -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 diff --git a/src/markitect_tool/__init__.py b/src/markitect_tool/__init__.py index 0ea9882..136f893 100644 --- a/src/markitect_tool/__init__.py +++ b/src/markitect_tool/__init__.py @@ -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", diff --git a/src/markitect_tool/extension/builtins.py b/src/markitect_tool/extension/builtins.py index 62dd766..8deacfb 100644 --- a/src/markitect_tool/extension/builtins.py +++ b/src/markitect_tool/extension/builtins.py @@ -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 diff --git a/src/markitect_tool/render/__init__.py b/src/markitect_tool/render/__init__.py new file mode 100644 index 0000000..adf3d1a --- /dev/null +++ b/src/markitect_tool/render/__init__.py @@ -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", +] diff --git a/src/markitect_tool/render/engine.py b/src/markitect_tool/render/engine.py new file mode 100644 index 0000000..7581f1f --- /dev/null +++ b/src/markitect_tool/render/engine.py @@ -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"\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=""), + 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, [], {}, "") + } diff --git a/tests/test_builtin_extension_catalog.py b/tests/test_builtin_extension_catalog.py index 9fdf6ba..2ce282f 100644 --- a/tests/test_builtin_extension_catalog.py +++ b/tests/test_builtin_extension_catalog.py @@ -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 diff --git a/tests/test_render_export_contract.py b/tests/test_render_export_contract.py new file mode 100644 index 0000000..99b8773 --- /dev/null +++ b/tests/test_render_export_contract.py @@ -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("