Deterministic templating and generation support

This commit is contained in:
2026-05-04 01:12:54 +02:00
parent 4f010315bb
commit 1a1b5ab39c
13 changed files with 1122 additions and 7 deletions

View File

@@ -16,9 +16,22 @@ from markitect_tool.contract import (
load_contract_file,
validate_contract,
)
from markitect_tool.generation import (
GenerationPlanError,
generate_stub_from_contract,
load_data_file,
load_generation_plan_file,
run_generation_plan,
)
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
from markitect_tool.query import InvalidQueryError, extract_document, query_document
from markitect_tool.schema import load_schema_file, validate_markdown_file, validate_schema
from markitect_tool.template import (
MissingTemplateVariable,
TemplateError,
analyze_template,
render_template,
)
@click.group()
@@ -275,6 +288,179 @@ def include(
_emit_markdown_result(result.to_dict(), output_format, output)
@main.group()
def template() -> None:
"""Render and inspect deterministic Markdown templates."""
@template.command("inspect")
@click.argument("template_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def template_inspect(template_file: Path, output_format: str) -> None:
"""Inspect variables required by a template."""
data = analyze_template(template_file.read_text(encoding="utf-8")).to_dict() | {
"template_path": str(template_file)
}
_emit_template_analysis(data, output_format)
raise click.exceptions.Exit(0 if data["valid"] else 1)
@template.command("render")
@click.argument("template_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--data",
"data_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="JSON, YAML, or CSV data file. CSV must contain one record for render.",
)
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set a template data value. Dot paths create nested mappings.",
)
@click.option("--lenient", is_flag=True, help="Keep unresolved placeholders instead of failing.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write rendered Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def template_render(
template_file: Path,
data_file: Path | None,
set_values: tuple[str, ...],
lenient: bool,
output: Path | None,
output_format: str,
) -> None:
"""Render a Markdown template with structured data."""
try:
data = _load_template_data(data_file)
data = _deep_merge_cli(data, _parse_key_value_options(set_values))
result = render_template(
template_file.read_text(encoding="utf-8"),
data,
strict=not lenient,
)
except (MissingTemplateVariable, TemplateError, ValueError, TypeError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.group()
def generate() -> None:
"""Generate Markdown from contracts, rules, or external hooks."""
@generate.command("stub")
@click.option(
"--contract",
"contract_file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Markdown document contract to generate from.",
)
@click.option(
"--data",
"data_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Optional JSON/YAML data for frontmatter values.",
)
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set generation data. Dot paths create nested mappings.",
)
@click.option("--include-optional", is_flag=True, help="Include optional contract sections.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write generated Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def generate_stub(
contract_file: Path,
data_file: Path | None,
set_values: tuple[str, ...],
include_optional: bool,
output: Path | None,
output_format: str,
) -> None:
"""Generate a Markdown stub from a document contract."""
try:
data = _load_template_data(data_file)
data = _deep_merge_cli(data, _parse_key_value_options(set_values))
result = generate_stub_from_contract(
load_contract_file(contract_file),
data=data,
include_optional=include_optional,
)
except (ContractLoaderError, ValueError, TypeError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@generate.command("rules")
@click.argument("rules_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--output-dir",
type=click.Path(file_okay=False, path_type=Path),
help="Directory used for relative output paths in the plan.",
)
@click.option("--dry-run", is_flag=True, help="Render without writing output files.")
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml"], case_sensitive=False),
default="json",
show_default=True,
)
def generate_rules(
rules_file: Path,
output_dir: Path | None,
dry_run: bool,
output_format: str,
) -> None:
"""Run a Markdown/YAML generation plan."""
try:
plan = load_generation_plan_file(rules_file)
result = run_generation_plan(
plan,
base_dir=rules_file.parent,
output_dir=output_dir,
dry_run=dry_run,
)
except (GenerationPlanError, TemplateError, MissingTemplateVariable) as exc:
raise click.ClickException(str(exc)) from exc
_emit_jsonish(result.to_dict(), output_format)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
@@ -461,6 +647,27 @@ def _emit_markdown_result(data: dict, output_format: str, output: Path | None) -
click.echo(markdown, nl=False)
def _emit_jsonish(data: dict, output_format: str) -> None:
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
def _emit_template_analysis(data: dict, output_format: str) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
click.echo("valid" if data["valid"] else "invalid")
click.echo(f"variables: {data['unique_variables']}")
for variable in data["variables"]:
click.echo(f"- {variable}")
for error in data["syntax_errors"]:
click.echo(f"! {error}")
def _parse_key_value_options(items: tuple[str, ...]) -> dict[str, object]:
values: dict[str, object] = {}
for item in items:
@@ -484,5 +691,28 @@ def _set_path(mapping: dict[str, object], path: list[str], value: object) -> Non
current[path[-1]] = value
def _load_template_data(data_file: Path | None) -> dict[str, object]:
if data_file is None:
return {}
data = load_data_file(data_file)
if isinstance(data, list):
if len(data) != 1:
raise ValueError("Template render expects exactly one CSV record")
data = data[0]
if not isinstance(data, dict):
raise ValueError("Template data must be a mapping")
return data
def _deep_merge_cli(left: dict[str, object], right: dict[str, object]) -> dict[str, object]:
merged = dict(left)
for key, value in right.items():
if isinstance(merged.get(key), dict) and isinstance(value, dict):
merged[key] = _deep_merge_cli(merged[key], value)
else:
merged[key] = value
return merged
if __name__ == "__main__":
main()