From e3c50bae05e9f4efab0dde79b314a24d64e1ff09 Mon Sep 17 00:00:00 2001 From: rsxri Date: Thu, 8 Jan 2026 20:02:33 +0000 Subject: [PATCH] feat: load schemas and templates from doc folders --- documents/__init__.py | 3 + documents/services_lite/__init__.py | 3 + documents/services_lite/questions.py | 6 ++ documents/services_lite/schema.py | 9 +++ engine/__init__.py | 4 ++ engine/registry.py | 36 ++++++++++++ engine/render.py | 17 ++++++ examples/input.json | 5 +- main.py | 85 ++++++++++++++++++++++------ schema.py | 2 +- 10 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 documents/__init__.py create mode 100644 documents/services_lite/__init__.py create mode 100644 documents/services_lite/questions.py create mode 100644 documents/services_lite/schema.py create mode 100644 engine/__init__.py create mode 100644 engine/registry.py create mode 100644 engine/render.py diff --git a/documents/__init__.py b/documents/__init__.py new file mode 100644 index 0000000..5365737 --- /dev/null +++ b/documents/__init__.py @@ -0,0 +1,3 @@ +""" +document definitions (schemas + templates) +""" \ No newline at end of file diff --git a/documents/services_lite/__init__.py b/documents/services_lite/__init__.py new file mode 100644 index 0000000..94170a6 --- /dev/null +++ b/documents/services_lite/__init__.py @@ -0,0 +1,3 @@ +""" +services_lite doc package +""" \ No newline at end of file diff --git a/documents/services_lite/questions.py b/documents/services_lite/questions.py new file mode 100644 index 0000000..b6d791f --- /dev/null +++ b/documents/services_lite/questions.py @@ -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)" +} diff --git a/documents/services_lite/schema.py b/documents/services_lite/schema.py new file mode 100644 index 0000000..acedaf2 --- /dev/null +++ b/documents/services_lite/schema.py @@ -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) diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..c3697c0 --- /dev/null +++ b/engine/__init__.py @@ -0,0 +1,4 @@ +""" +generic engine code +tidy this up later +""" \ No newline at end of file diff --git a/engine/registry.py b/engine/registry.py new file mode 100644 index 0000000..b45fd8a --- /dev/null +++ b/engine/registry.py @@ -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 diff --git a/engine/render.py b/engine/render.py new file mode 100644 index 0000000..0197bd7 --- /dev/null +++ b/engine/render.py @@ -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) diff --git a/examples/input.json b/examples/input.json index b6be293..5e8cbc1 100644 --- a/examples/input.json +++ b/examples/input.json @@ -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, diff --git a/main.py b/main.py index f7f2b5e..e33b554 100644 --- a/main.py +++ b/main.py @@ -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..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}") diff --git a/schema.py b/schema.py index 03c5d18..6518822 100644 --- a/schema.py +++ b/schema.py @@ -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 \ No newline at end of file + return self