feat: load schemas and templates from doc folders

This commit is contained in:
rsxri 2026-01-08 20:02:33 +00:00
parent bb61418c79
commit e3c50bae05
10 changed files with 150 additions and 20 deletions

3
documents/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
document definitions (schemas + templates)
"""

View file

@ -0,0 +1,3 @@
"""
services_lite doc package
"""

View file

@ -0,0 +1,6 @@
QUESTIONS = {
"client_name": "client name",
"provider_name": "provider name",
"include_confidentiality": "include confidentiality (y/n)",
"governing_law": "governing law (e.g. England and Wales)"
}

View file

@ -0,0 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class DocumentInput(BaseModel):
client_name: str = Field(..., min_length=1)
provider_name: str = Field(..., min_length=1)
include_confidentiality: bool = False
governing_law: str = Field(..., min_length=1)

4
engine/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""
generic engine code
tidy this up later
"""

36
engine/registry.py Normal file
View file

@ -0,0 +1,36 @@
from __future__ import annotations
from pathlib import Path
def project_root() -> Path:
return Path(__file__).resolve().parent.parent
def documents_root() -> Path:
return project_root() / "documents"
def list_docs() -> list[str]:
"""
Return a list of available documents type names.
Document type is a dir in /documents (e.g. documents/services_lite/)
"""
root = documents_root()
# print(documents_root())
if not root.exists():
return []
return sorted(
p.name
for p in root.iterdir()
if p.is_dir() and (p / "schema.py").exists()
# use schema.py to check and not __init__.py (doc is valid if schema exists
)
def doc_dir(doc_name: str) -> Path:
"""
Return directory path for document type (e.g. documents/services_lite/).
"""
return documents_root() / doc_name

17
engine/render.py Normal file
View file

@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
def render_from_dir(doc_dir: Path, template_filename: str, data: dict) -> str:
"""
Render template located inside a document folder.
"""
env = Environment(
loader=FileSystemLoader(str(doc_dir)),
trim_blocks=True,
lstrip_blocks=True,
)
template = env.get_template(template_filename)
return template.render(**data)

View file

@ -1,8 +1,9 @@
{
"client_name": "Acme Ltd",
"provider_name": "Will Smith",
"payment_type": "hourly",
"provider_name": "David Bowie",
"payment_type": "fixed",
"hourly_rate_gbp": 40,
"fixed_fee_gbp": 20,
"include_confidentiality": false,
"include_ip_clause": false,
"termination_notice_days": 14,

85
main.py
View file

@ -1,63 +1,114 @@
from __future__ import annotations
import argparse
import importlib
import json
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from pydantic import ValidationError
from schema import DocumentInput
from engine.registry import doc_dir, list_docs
from engine.render import render_from_dir
def parse_args() -> argparse.Namespace:
# keep cli parsing separate so main stays about workflow
parser = argparse.ArgumentParser(description="Render document from JSON + Jinja2 template")
parser = argparse.ArgumentParser(description="Render document from schema + templates")
parser.add_argument(
"--list-documents",
action="store_true",
help="List all available documents",
)
parser.add_argument(
"--doc",
help="document type name (folder in documents/)"
)
parser.add_argument(
"--interactive",
action="store_true",
help="Prompt for answers interactively (CLI POC)",
)
parser.add_argument(
"--input",
"-i",
type=Path,
required=True,
# required=True,
help="Path to input JSON file (e.g. examples/input.json)"
)
parser.add_argument(
"--template",
"-t",
default="doc.md.j2",
default="template.md.j2",
help="Template file (e.g. doc.md.j2)"
)
parser.add_argument(
"--output",
"-o",
type=Path,
required=True,
default=Path("out.md"),
# required=True,
help="Path to write rendered output (e.g. out.md)"
)
return parser.parse_args()
def render_template(template_name: str, data: dict) -> str:
# Jinja env points at templates/
env = Environment(loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True)
template = env.get_template(template_name)
return template.render(data)
def load_schema_model(doc_name: str):
"""
import documents.<doc_name>.schema and return DocumentInput model.
"""
module = importlib.import_module(f"documents.{doc_name}.schema")
return getattr(module, "DocumentInput")
def main():
args = parse_args()
if args.list_documents:
docs = list_docs()
if not docs:
print("No documents found. (missing documents/ packages?)")
return
for d in docs:
print(d)
return
if not args.doc:
print("error: --doc is required (or use --list-docs)")
return
if args.interactive:
return # work on later
if not args.input:
print("error: --input is required")
# load input data
# path() over hardcoding str
data = json.loads(args.input.read_text(encoding="utf-8"))
# validate + normalise input
doc_input = DocumentInput(**data)
# validate w/ schema
model = load_schema_model(args.doc)
try:
validated = model(**data)
except ValidationError as e:
print(e)
return
# load template to render
out = render_template(args.template, doc_input.model_dump())
# render using doc template
ddir = doc_dir(args.doc)
rendered_text = render_from_dir(
doc_dir=ddir,
template_filename=args.template,
data=validated.model_dump(),
)
# write output - render md to file
args.output.write_text(out, encoding="utf-8")
args.output.write_text(rendered_text, encoding="utf-8")
print(f"wrote {args.output}")

View file

@ -24,4 +24,4 @@ class DocumentInput(BaseModel):
if self.payment_type == "fixed" and self.fixed_fee_gbp is None:
raise ValueError("fixed_fee_gbp is required when payment_type is 'fixed'")
return self
return self