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", "client_name": "Acme Ltd",
"provider_name": "Will Smith", "provider_name": "David Bowie",
"payment_type": "hourly", "payment_type": "fixed",
"hourly_rate_gbp": 40, "hourly_rate_gbp": 40,
"fixed_fee_gbp": 20,
"include_confidentiality": false, "include_confidentiality": false,
"include_ip_clause": false, "include_ip_clause": false,
"termination_notice_days": 14, "termination_notice_days": 14,

85
main.py
View file

@ -1,63 +1,114 @@
from __future__ import annotations
import argparse import argparse
import importlib
import json import json
from pathlib import Path 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: def parse_args() -> argparse.Namespace:
# keep cli parsing separate so main stays about workflow # 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( parser.add_argument(
"--input", "--input",
"-i", "-i",
type=Path, type=Path,
required=True, # required=True,
help="Path to input JSON file (e.g. examples/input.json)" help="Path to input JSON file (e.g. examples/input.json)"
) )
parser.add_argument( parser.add_argument(
"--template", "--template",
"-t", "-t",
default="doc.md.j2", default="template.md.j2",
help="Template file (e.g. doc.md.j2)" help="Template file (e.g. doc.md.j2)"
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
"-o", "-o",
type=Path, type=Path,
required=True, default=Path("out.md"),
# required=True,
help="Path to write rendered output (e.g. out.md)" help="Path to write rendered output (e.g. out.md)"
) )
return parser.parse_args() return parser.parse_args()
def render_template(template_name: str, data: dict) -> str: def load_schema_model(doc_name: str):
# Jinja env points at templates/ """
env = Environment(loader=FileSystemLoader("templates"), import documents.<doc_name>.schema and return DocumentInput model.
trim_blocks=True, """
lstrip_blocks=True) module = importlib.import_module(f"documents.{doc_name}.schema")
template = env.get_template(template_name) return getattr(module, "DocumentInput")
return template.render(data)
def main(): def main():
args = parse_args() 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 # load input data
# path() over hardcoding str # path() over hardcoding str
data = json.loads(args.input.read_text(encoding="utf-8")) data = json.loads(args.input.read_text(encoding="utf-8"))
# validate + normalise input # 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 # render using doc template
out = render_template(args.template, doc_input.model_dump()) 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 # 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}") 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: if self.payment_type == "fixed" and self.fixed_fee_gbp is None:
raise ValueError("fixed_fee_gbp is required when payment_type is 'fixed'") raise ValueError("fixed_fee_gbp is required when payment_type is 'fixed'")
return self return self