feat: load schemas and templates from doc folders
This commit is contained in:
parent
bb61418c79
commit
e3c50bae05
3
documents/__init__.py
Normal file
3
documents/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
document definitions (schemas + templates)
|
||||||
|
"""
|
||||||
3
documents/services_lite/__init__.py
Normal file
3
documents/services_lite/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
services_lite doc package
|
||||||
|
"""
|
||||||
6
documents/services_lite/questions.py
Normal file
6
documents/services_lite/questions.py
Normal 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)"
|
||||||
|
}
|
||||||
9
documents/services_lite/schema.py
Normal file
9
documents/services_lite/schema.py
Normal 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
4
engine/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""
|
||||||
|
generic engine code
|
||||||
|
tidy this up later
|
||||||
|
"""
|
||||||
36
engine/registry.py
Normal file
36
engine/registry.py
Normal 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
17
engine/render.py
Normal 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)
|
||||||
|
|
@ -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
85
main.py
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue