generated from coulomb/repo-seed
Deterministic templating and generation support
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user