540 lines
22 KiB
Python
540 lines
22 KiB
Python
"""
|
|
compose_docusign_template.py
|
|
----------------------------
|
|
Converts a downloaded Adobe Sign template folder into a DocuSign
|
|
envelopeTemplate JSON that can be posted directly to the DocuSign
|
|
Templates API via:
|
|
|
|
node packages/esign-direct/build/cli.js templates create --file <output.json>
|
|
|
|
Key rules applied:
|
|
- Tabs grouped by type: textTabs, signHereTabs, dateSignedTabs,
|
|
listTabs, checkboxTabs, radioGroupTabs, signerAttachmentTabs
|
|
- required / locked must be strings "true" / "false", not booleans
|
|
- listTabs need listItems: [{text, value}] not a plain string array
|
|
- radioGroupTabs use groupName + radios[] with per-option page/x/y
|
|
- Coordinates: Adobe top is from TOP of page; DocuSign yPosition is
|
|
from BOTTOM. For US Letter (792pt): y = PAGE_HEIGHT - top - height
|
|
- Signers in templates are role-placeholders (no email/name)
|
|
- No top-level "status" field (belongs on envelope sends, not templates)
|
|
|
|
Field type coverage:
|
|
Mapped: TEXT_FIELD, SIGNATURE, CHECKBOX, DATE, DROP_DOWN, RADIO, BLOCK, STAMP
|
|
Partial: FILE_CHOOSER → signerAttachmentTabs (with warning)
|
|
STAMP → stampTabs (requires stamp feature enabled on DocuSign account — warning logged)
|
|
Skipped: INLINE_IMAGE, PARTICIPATION_STAMP (no DocuSign equivalent — warning logged)
|
|
|
|
Conditional logic:
|
|
Mapped: Single-predicate SHOW conditions (EQUALS operator) →
|
|
conditionalParentLabel + conditionalParentValue on the dependent tab.
|
|
For radio groups the parentLabel matches the radio group name.
|
|
Warnings: Multi-predicate ANY/ALL conditions (only first EQUALS predicate mapped),
|
|
HIDE action (not supported in DocuSign — condition skipped),
|
|
non-EQUALS operators (skipped).
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from src.models.field_issue import (
|
|
FieldIssue,
|
|
CROSS_RECIPIENT_CONDITIONAL,
|
|
UNSUPPORTED_OPERATOR,
|
|
HIDE_ACTION,
|
|
MULTI_PREDICATE,
|
|
INVALID_PARENT_TAB,
|
|
FIELD_TYPE_SKIPPED,
|
|
PARTIAL_FIELD_TYPE,
|
|
)
|
|
|
|
DOCUMENT_ID = "1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coordinate translation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Minimum width for text-entry tabs so they render as a visible box rather
|
|
# than a vertical line. ~120pt ≈ 15 average characters at 8pt/char.
|
|
MIN_TEXT_WIDTH = 120
|
|
|
|
|
|
def loc_to_docusign(loc: dict) -> tuple[str, str, str, str, str]:
|
|
"""
|
|
Convert an Adobe Sign location dict to DocuSign (page, x, y, width, height) strings.
|
|
Both Adobe Sign and DocuSign measure from the top-left corner of the page,
|
|
with y increasing downward — no coordinate inversion needed.
|
|
Width is clamped to MIN_TEXT_WIDTH so text fields render as visible boxes.
|
|
"""
|
|
page = str(loc["pageNumber"])
|
|
x = str(int(loc["left"]))
|
|
y = str(int(loc["top"]))
|
|
width = str(max(int(loc.get("width", MIN_TEXT_WIDTH)), MIN_TEXT_WIDTH))
|
|
height = str(int(loc.get("height", 24)))
|
|
return page, x, y, width, height
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recipients: derive from form field assignees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def derive_recipients(fields: list) -> list[dict]:
|
|
"""
|
|
Build an ordered list of recipient role placeholders from the assignee
|
|
values on each field (e.g. "recipient0", "recipient1").
|
|
Returns [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}, ...]
|
|
"""
|
|
seen: dict[str, int] = {}
|
|
for f in fields:
|
|
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
|
|
if assignee not in seen:
|
|
try:
|
|
idx = int(assignee.replace("recipient", ""))
|
|
except ValueError:
|
|
idx = len(seen)
|
|
seen[assignee] = idx
|
|
|
|
recipients = sorted(
|
|
[{"assignee": k, "index": v, "roleName": f"Signer {v + 1}"} for k, v in seen.items()],
|
|
key=lambda r: r["index"],
|
|
)
|
|
return recipients if recipients else [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}]
|
|
|
|
|
|
def assignee_to_index(assignee: str | None, recipients: list[dict]) -> int:
|
|
if not assignee:
|
|
return 0
|
|
for r in recipients:
|
|
if r["assignee"] == assignee:
|
|
return r["index"]
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tab builder helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_sized_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
|
|
"""Build one sized DocuSign tab from a single Adobe Sign location."""
|
|
page, x, y, width, height = loc_to_docusign(loc)
|
|
tab = {
|
|
"tabLabel": label,
|
|
"documentId": DOCUMENT_ID,
|
|
"pageNumber": page,
|
|
"xPosition": x,
|
|
"yPosition": y,
|
|
"width": width,
|
|
"height": height,
|
|
}
|
|
if extra:
|
|
tab.update(extra)
|
|
return tab
|
|
|
|
|
|
def _make_base_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
|
|
"""Build one unsized DocuSign tab (for signature/checkbox fields)."""
|
|
page, x, y, _w, _h = loc_to_docusign(loc)
|
|
tab = {
|
|
"tabLabel": label,
|
|
"documentId": DOCUMENT_ID,
|
|
"pageNumber": page,
|
|
"xPosition": x,
|
|
"yPosition": y,
|
|
}
|
|
if extra:
|
|
tab.update(extra)
|
|
return tab
|
|
|
|
|
|
def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
|
|
"""
|
|
Emit one sized tab per location with the same tabLabel.
|
|
|
|
Adobe Sign allows a single field to have multiple locations (cloned/linked
|
|
instances). DocuSign replicates this via tab merging: tabs that share a
|
|
tabLabel auto-sync their value at signing time. Applies to all data-entry
|
|
tab types: textTabs, numberTabs, dateTabs, dateSignedTabs, fullNameTabs,
|
|
emailAddressTabs, companyTabs, titleTabs, listTabs, checkboxTabs.
|
|
"""
|
|
return [_make_sized_tab(loc, label, extra) for loc in locations]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conditional logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _apply_conditional_to_tabs(
|
|
tabs: dict,
|
|
field: dict,
|
|
warnings: list,
|
|
issues: list,
|
|
current_assignee: str = "",
|
|
field_assignee: dict | None = None,
|
|
) -> dict:
|
|
"""
|
|
Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based
|
|
on an Adobe Sign conditionalAction.
|
|
|
|
Adobe Sign model:
|
|
conditionalAction.predicates[].{fieldName, operator, value}, action=SHOW|HIDE
|
|
|
|
DocuSign model (on the dependent tab):
|
|
conditionalParentLabel — tabLabel or radioGroup groupName of the trigger tab
|
|
conditionalParentValue — value the trigger must have to reveal this tab
|
|
|
|
Mapping limitations:
|
|
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
|
|
- Only EQUALS operator is supported. Others are skipped.
|
|
- Cross-recipient conditions not supported — DocuSign conditionals only work within
|
|
a single recipient's tab set.
|
|
- Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported;
|
|
the first EQUALS predicate is used and a warning is logged.
|
|
"""
|
|
if not tabs:
|
|
return tabs
|
|
|
|
ca = field.get("conditionalAction", {})
|
|
predicates = ca.get("predicates", [])
|
|
if not predicates:
|
|
return tabs # No conditions — field is always visible
|
|
|
|
label = field.get("name", "unnamed")
|
|
action = ca.get("action", "SHOW")
|
|
|
|
if action != "SHOW":
|
|
msg = (
|
|
f"Field '{label}' has a HIDE condition which DocuSign does not support — "
|
|
f"condition dropped. The field will always be visible."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict())
|
|
return tabs
|
|
|
|
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
|
|
if not predicate:
|
|
ops = [p.get("operator") for p in predicates]
|
|
msg = (
|
|
f"Field '{label}' uses unsupported condition operator(s) {ops} — "
|
|
f"only EQUALS is supported in DocuSign. Condition dropped; field will always be visible."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(UNSUPPORTED_OPERATOR, label, msg).to_dict())
|
|
return tabs
|
|
|
|
parent_field_name = predicate["fieldName"]
|
|
|
|
# Cross-recipient check: DocuSign does not support conditionals across recipients
|
|
if field_assignee is not None and current_assignee:
|
|
parent_assignee = field_assignee.get(parent_field_name, "")
|
|
if parent_assignee and parent_assignee != current_assignee:
|
|
msg = (
|
|
f"Field '{label}' has a show/hide condition controlled by '{parent_field_name}', "
|
|
f"which belongs to a different recipient ({parent_assignee} vs {current_assignee}). "
|
|
f"DocuSign does not support cross-recipient conditional logic — condition dropped."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(CROSS_RECIPIENT_CONDITIONAL, label, msg).to_dict())
|
|
return tabs
|
|
|
|
if len(predicates) > 1:
|
|
msg = (
|
|
f"Field '{label}' has {len(predicates)} conditions combined with "
|
|
f"anyOrAll={ca.get('anyOrAll')} — only the first EQUALS predicate was mapped. "
|
|
f"Remaining conditions were dropped."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(MULTI_PREDICATE, label, msg).to_dict())
|
|
|
|
parent_value = predicate["value"]
|
|
|
|
for tab_list in tabs.values():
|
|
for tab in tab_list:
|
|
tab["conditionalParentLabel"] = parent_field_name
|
|
tab["conditionalParentValue"] = parent_value
|
|
|
|
return tabs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tab builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_tabs_for_field(field: dict, warnings: list, issues: list) -> dict:
|
|
"""
|
|
Convert one Adobe Sign field into the correct DocuSign tabs structure.
|
|
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
|
|
Unmappable fields are skipped; a warning string and a structured FieldIssue
|
|
are both appended so callers have both human-readable and machine-readable output.
|
|
"""
|
|
input_type = field.get("inputType", "")
|
|
label = field.get("name", "unnamed")
|
|
locations = field.get("locations", [])
|
|
required_str = "true" if field.get("required", False) else "false"
|
|
locked_str = "true" if field.get("readOnly", False) else "false"
|
|
|
|
if not locations:
|
|
return {}
|
|
|
|
content_type = field.get("contentType", "")
|
|
validation = field.get("validation", "")
|
|
|
|
if input_type == "TEXT_FIELD":
|
|
if content_type == "SIGNATURE_DATE":
|
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
|
elif content_type == "SIGNER_NAME":
|
|
return {"fullNameTabs": _sized_tabs(locations, label)}
|
|
elif content_type == "SIGNER_EMAIL":
|
|
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
|
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
|
return {"companyTabs": _sized_tabs(locations, label)}
|
|
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
|
return {"titleTabs": _sized_tabs(locations, label)}
|
|
elif content_type == "DATA" and validation == "DATE":
|
|
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
|
elif content_type == "DATA" and validation == "NUMBER":
|
|
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
|
else:
|
|
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
|
|
|
elif input_type == "SIGNATURE":
|
|
if content_type == "SIGNER_INITIALS":
|
|
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
|
else:
|
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
|
|
|
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
|
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
|
|
|
elif input_type == "DATE":
|
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
|
|
|
elif input_type == "CHECKBOX":
|
|
return {"checkboxTabs": _sized_tabs(locations, label, {"required": required_str})}
|
|
|
|
elif input_type == "DROP_DOWN":
|
|
options = field.get("hiddenOptions") or field.get("visibleOptions") or []
|
|
list_items = [{"text": str(v), "value": str(v)} for v in options if v]
|
|
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
|
|
|
|
elif input_type == "RADIO":
|
|
options = field.get("hiddenOptions") or []
|
|
radios = []
|
|
for i, loc in enumerate(locations):
|
|
pg, rx, ry, _rw, _rh = loc_to_docusign(loc)
|
|
value = options[i] if i < len(options) else str(i + 1)
|
|
radios.append({"pageNumber": pg, "xPosition": rx, "yPosition": ry, "value": value})
|
|
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
|
|
|
|
elif input_type == "FILE_CHOOSER":
|
|
msg = (
|
|
f"Field '{label}' is a FILE_CHOOSER — mapped to a signerAttachmentTabs tab. "
|
|
f"DocuSign attachment tabs behave differently from Adobe file upload fields; manual review recommended."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict())
|
|
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
|
|
return {"signerAttachmentTabs": [tab]}
|
|
|
|
elif input_type == "INLINE_IMAGE":
|
|
msg = f"Field '{label}' is an INLINE_IMAGE — skipped. There is no equivalent tab type in DocuSign."
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
|
|
return {}
|
|
|
|
elif input_type == "STAMP":
|
|
msg = (
|
|
f"Field '{label}' is a STAMP — mapped to stampTabs. "
|
|
f"This requires the stamp feature to be enabled on your DocuSign account."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict())
|
|
return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]}
|
|
|
|
elif input_type == "PARTICIPATION_STAMP":
|
|
msg = f"Field '{label}' is a PARTICIPATION_STAMP — skipped. There is no equivalent tab type in DocuSign."
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
|
|
return {}
|
|
|
|
else:
|
|
msg = f"Field '{label}' has unknown type '{input_type}' (contentType='{content_type}') — skipped."
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
|
|
return {}
|
|
|
|
|
|
def merge_tabs(acc: dict, new: dict) -> dict:
|
|
for key, tabs in new.items():
|
|
acc.setdefault(key, []).extend(tabs)
|
|
return acc
|
|
|
|
|
|
# Tab types DocuSign forbids as conditional parents (auto-filled or action tabs)
|
|
_INVALID_PARENT_TAB_TYPES = {
|
|
"signHereTabs", "initialHereTabs", "dateSignedTabs",
|
|
"fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs",
|
|
}
|
|
|
|
|
|
def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None:
|
|
"""
|
|
Remove conditionalParentLabel/Value from any tab whose parent label either
|
|
doesn't exist in the template or points to a tab type DocuSign forbids as a
|
|
parent (signature, initial, auto-filled). Mutates signers in place.
|
|
"""
|
|
for signer in signers:
|
|
tabs = signer.get("tabs", {})
|
|
|
|
# Collect valid parent labels: only tab types allowed as parents
|
|
valid_labels: set[str] = set()
|
|
for tab_type, tab_list in tabs.items():
|
|
if tab_type in _INVALID_PARENT_TAB_TYPES:
|
|
continue
|
|
for tab in tab_list:
|
|
lbl = tab.get("tabLabel") or tab.get("groupName")
|
|
if lbl:
|
|
valid_labels.add(lbl)
|
|
|
|
# Strip references to invalid/missing parents
|
|
for tab_list in tabs.values():
|
|
for tab in tab_list:
|
|
parent = tab.get("conditionalParentLabel")
|
|
if parent and parent not in valid_labels:
|
|
field_name = tab.get("tabLabel") or tab.get("groupName") or "?"
|
|
msg = (
|
|
f"Field '{field_name}' has a conditional that references parent "
|
|
f"'{parent}', which either does not exist as a tab or is a "
|
|
f"signature/auto-fill tab (forbidden as a DocuSign conditional parent). "
|
|
f"Condition stripped — field will always be visible."
|
|
)
|
|
warnings.append(msg)
|
|
issues.append(FieldIssue(INVALID_PARENT_TAB, field_name, msg).to_dict())
|
|
tab.pop("conditionalParentLabel", None)
|
|
tab.pop("conditionalParentValue", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main compose function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str], list[dict]]:
|
|
"""
|
|
Build a DocuSign template JSON from a downloaded Adobe Sign template folder.
|
|
|
|
Args:
|
|
template_dir: path to a downloads/<template-name>/ folder containing
|
|
metadata.json, form_fields.json, documents.json, and a PDF
|
|
output_path: where to write the resulting DocuSign template JSON
|
|
|
|
Returns:
|
|
(template_dict, warnings_list, field_issues_list)
|
|
field_issues_list contains structured FieldIssue dicts describing properties
|
|
that were dropped or approximated during migration (see src/models/field_issue.py).
|
|
"""
|
|
template_dir = Path(template_dir)
|
|
warnings: list[str] = []
|
|
issues: list[dict] = []
|
|
|
|
# Load source files
|
|
metadata = json.loads((template_dir / "metadata.json").read_text())
|
|
fields_data = json.loads((template_dir / "form_fields.json").read_text())
|
|
documents_data = json.loads((template_dir / "documents.json").read_text())
|
|
fields: list[dict] = fields_data.get("fields", [])
|
|
|
|
# Find the PDF file
|
|
pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name]
|
|
if not pdf_files:
|
|
raise FileNotFoundError(f"No PDF found in {template_dir}")
|
|
pdf_path = pdf_files[0]
|
|
pdf_b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
|
|
|
|
# Document name from documents.json
|
|
doc_info = documents_data.get("documents", [{}])[0]
|
|
doc_name = doc_info.get("name", pdf_path.name)
|
|
if not doc_name.lower().endswith(".pdf"):
|
|
doc_name = Path(doc_name).stem + ".pdf"
|
|
|
|
# Derive recipients from form field assignees
|
|
recipients = derive_recipients(fields)
|
|
|
|
# Build signers with empty tab groups
|
|
signers = []
|
|
for r in recipients:
|
|
signers.append({
|
|
"roleName": r["roleName"],
|
|
"recipientId": str(r["index"] + 1),
|
|
"routingOrder": str(r["index"] + 1),
|
|
"tabs": {},
|
|
})
|
|
|
|
# Build field→assignee lookup for cross-recipient conditional detection
|
|
field_assignee: dict[str, str] = {}
|
|
for f in fields:
|
|
name = f.get("name", "")
|
|
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
|
|
if name:
|
|
field_assignee[name] = assignee
|
|
|
|
# Assign tabs to the correct signer
|
|
for field in fields:
|
|
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
|
idx = assignee_to_index(assignee, recipients)
|
|
if idx >= len(signers):
|
|
idx = 0
|
|
tabs = build_tabs_for_field(field, warnings, issues)
|
|
tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee)
|
|
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
|
|
|
|
# Post-process: strip conditionalParentLabel references that point to
|
|
# non-existent or invalid parents (signature/initial tabs can't be parents).
|
|
_strip_invalid_conditionals(signers, warnings, issues)
|
|
|
|
template = {
|
|
"name": metadata.get("name", template_dir.name),
|
|
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
|
|
"documents": [
|
|
{
|
|
"documentBase64": pdf_b64,
|
|
"name": doc_name,
|
|
"fileExtension": "pdf",
|
|
"documentId": DOCUMENT_ID,
|
|
}
|
|
],
|
|
"recipients": {
|
|
"signers": signers,
|
|
},
|
|
}
|
|
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
with open(output_path, "w") as f:
|
|
json.dump(template, f, indent=2)
|
|
|
|
return template, warnings, issues
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
downloads_dir = Path(__file__).parent.parent / "downloads"
|
|
if not downloads_dir.exists():
|
|
print("No downloads/ folder found. Run download_templates.py first.")
|
|
sys.exit(1)
|
|
|
|
for template_dir in sorted(downloads_dir.iterdir()):
|
|
if not template_dir.is_dir():
|
|
continue
|
|
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
|
|
print(f"\n--- {template_dir.name} ---")
|
|
try:
|
|
_, warnings, issues = compose_template(str(template_dir), str(output_path))
|
|
print(f" Written: {output_path}")
|
|
for w in warnings:
|
|
print(f" WARNING: {w}")
|
|
except Exception as e:
|
|
print(f" ERROR: {e}")
|